feat(deploy): 画布 Agent - 独立 yingce-agent 容器并修复运行中断

生产会话不再因 stdout 管道提前关闭被误判失败。设置 YINGCE_AGENT_URL 后只走独立容器,失败不回退到内嵌进程。
This commit is contained in:
ddcat666 committed 2026-09-29 21:41:36 +08:00
1 parent 3fb363aad2
commit cb146d27a1
25 files changed
+821 -101

No files matched your search

+121 -49
View File
@@ -15,7 +15,9 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
@@ -54,7 +56,21 @@ type ProcessRequest struct {
Model map[string]any `json:"model"`
}
// BridgeServer is the per-run callback endpoint. The Node runtime calls it for
// model, tool, and event steps. Authorization is the bearer token; binding can
// be loopback for the embedded process or the container network for yingce-agent.
type BridgeServer struct {
URL string
Token string
Close func()
}
func Run(ctx context.Context, request ProcessRequest, bridge Bridge) error {
release, err := acquireAgentProcess(ctx)
if err != nil {
return err
}
defer release()
if bridge.Model == nil || bridge.Tool == nil || bridge.Event == nil {
return errors.New("Agent bridge handlers are incomplete")
}
@@ -62,34 +78,27 @@ func Run(ctx context.Context, request ProcessRequest, bridge Bridge) error {
if err != nil {
return err
}
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return fmt.Errorf("create Agent bridge credential: %w", err)
}
token := hex.EncodeToString(tokenBytes)
listener, err := net.Listen("tcp", "127.0.0.1:0")
server, err := StartBridge("127.0.0.1:0", "127.0.0.1", bridge)
if err != nil {
return fmt.Errorf("listen for Agent bridge: %w", err)
return err
}
defer listener.Close()
mux := http.NewServeMux()
mux.HandleFunc("POST /model", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Model) })
mux.HandleFunc("POST /tool", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Tool) })
mux.HandleFunc("POST /event", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Event) })
server := &http.Server{Handler: mux, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 2 * time.Minute}
serverDone := make(chan error, 1)
go func() { serverDone <- server.Serve(listener) }()
defer func() { _ = server.Shutdown(context.Background()) }()
request.BridgeURL = "http://" + listener.Addr().String()
request.BridgeToken = token
defer server.Close()
request.BridgeURL = server.URL
request.BridgeToken = server.Token
payload, err := json.Marshal(request)
if err != nil {
return fmt.Errorf("encode Agent runtime request: %w", err)
}
cmd := exec.CommandContext(ctx, "node", filepath.Join(runtimeDir, "agent-runtime.mjs"))
cmd := exec.CommandContext(ctx, "node", "--max-old-space-size="+nodeMemoryMB(), filepath.Join(runtimeDir, "agent-runtime.mjs"))
cmd.Dir = runtimeDir
cmd.Stdin = strings.NewReader(string(payload))
// 环境变量白名单:只传 Node 运行所需的最少变量。
cmd.Env = []string{
"PATH=" + os.Getenv("PATH"),
"HOME=" + request.AgentDir,
"NODE_ENV=production",
"PI_OFFLINE=1",
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("open Agent runtime output: %w", err)
@@ -98,32 +107,10 @@ func Run(ctx context.Context, request ProcessRequest, bridge Bridge) error {
if err := cmd.Start(); err != nil {
return fmt.Errorf("start Agent runtime: %w", err)
}
stdoutErr := make(chan error, 1)
go func() {
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), lineLimit)
for scanner.Scan() {
var event struct {
Event string `json:"event"`
Message string `json:"message"`
}
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
stdoutErr <- fmt.Errorf("decode Agent runtime event: %w", err)
return
}
if event.Event == "runtime_error" || event.Event == "bridge_error" {
stdoutErr <- errors.New(firstNonEmpty(event.Message, "Agent runtime failed"))
return
}
}
if err := scanner.Err(); err != nil {
stdoutErr <- fmt.Errorf("read Agent runtime output: %w", err)
return
}
stdoutErr <- nil
}()
// StdoutPipe 的读取必须在 Wait 之前结束。Wait 会关闭管道,提前调用会把
// 正常退出误报为 "file already closed"。
lineErr := ReadOutput(stdout)
waitErr := cmd.Wait()
lineErr := <-stdoutErr
if lineErr != nil {
return lineErr
}
@@ -136,11 +123,65 @@ func Run(ctx context.Context, request ProcessRequest, bridge Bridge) error {
return nil
}
func BridgeCall(w http.ResponseWriter, r *http.Request, expectedToken string, handler func(context.Context, map[string]json.RawMessage) (any, error)) {
if r.RemoteAddr == "" || !strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") {
http.Error(w, `{"error":"loopback bridge only"}`, http.StatusForbidden)
return
func StartBridge(listenAddr, advertiseHost string, bridge Bridge) (*BridgeServer, error) {
if bridge.Model == nil || bridge.Tool == nil || bridge.Event == nil {
return nil, errors.New("Agent bridge handlers are incomplete")
}
advertiseHost = strings.TrimSpace(advertiseHost)
if advertiseHost == "" || strings.ContainsAny(advertiseHost, `:/\\ `) {
return nil, errors.New("Agent bridge host must be a hostname or IP without a scheme or port")
}
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, fmt.Errorf("create Agent bridge credential: %w", err)
}
token := hex.EncodeToString(tokenBytes)
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
return nil, fmt.Errorf("listen for Agent bridge: %w", err)
}
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok || tcpAddr.Port == 0 {
_ = listener.Close()
return nil, errors.New("Agent bridge did not receive a TCP port")
}
mux := http.NewServeMux()
mux.HandleFunc("POST /model", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Model) })
mux.HandleFunc("POST /tool", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Tool) })
mux.HandleFunc("POST /event", func(w http.ResponseWriter, r *http.Request) { BridgeCall(w, r, token, bridge.Event) })
server := &http.Server{Handler: mux, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 2 * time.Minute}
go func() { _ = server.Serve(listener) }()
return &BridgeServer{
URL: fmt.Sprintf("http://%s:%d", advertiseHost, tcpAddr.Port),
Token: token,
Close: func() { _ = server.Shutdown(context.Background()) },
}, nil
}
func ReadOutput(stdout io.Reader) error {
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), lineLimit)
var lastError error
for scanner.Scan() {
var event struct {
Event string `json:"event"`
Message string `json:"message"`
}
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
lastError = fmt.Errorf("decode Agent runtime event: %w", err)
continue
}
if event.Event == "runtime_error" || event.Event == "bridge_error" {
lastError = errors.New(firstNonEmpty(event.Message, "Agent runtime failed"))
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read Agent runtime output: %w", err)
}
return lastError
}
func BridgeCall(w http.ResponseWriter, r *http.Request, expectedToken string, handler func(context.Context, map[string]json.RawMessage) (any, error)) {
if r.Header.Get("Authorization") != "Bearer "+expectedToken {
http.Error(w, `{"error":"unauthorized bridge"}`, http.StatusUnauthorized)
return
@@ -181,6 +222,37 @@ func RuntimeDir() (string, error) {
return "", errors.New("Agent runtime files are missing; set CANVAS_PI_RUNTIME_DIR")
}
var (
agentSlotsOnce sync.Once
agentSlots chan struct{}
)
func acquireAgentProcess(ctx context.Context) (func(), error) {
agentSlotsOnce.Do(func() {
limit := 8
if raw := strings.TrimSpace(os.Getenv("CANVAS_AGENT_MAX_PROCESSES")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 1000 {
limit = parsed
}
}
agentSlots = make(chan struct{}, limit)
})
select {
case agentSlots <- struct{}{}:
return func() { <-agentSlots }, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func nodeMemoryMB() string {
raw := strings.TrimSpace(os.Getenv("YINGCE_AGENT_NODE_MEMORY_MB"))
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 128 && parsed <= 8192 {
return strconv.Itoa(parsed)
}
return "512"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
@@ -0,0 +1,83 @@
package runtime
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// fakeRuntime 用一个假的 agent-runtime.mjs 替换真实运行时,只验证 Go 侧的进程与输出处理。
func fakeRuntime(t *testing.T, script string) {
t.Helper()
if _, err := exec.LookPath("node"); err != nil {
t.Skip("node is not installed")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "agent-runtime.mjs"), []byte(script), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("CANVAS_PI_RUNTIME_DIR", dir)
}
func noopBridge() Bridge {
handler := func(context.Context, map[string]json.RawMessage) (any, error) { return map[string]any{"ok": true}, nil }
return Bridge{Model: handler, Tool: handler, Event: handler}
}
// 回归:Wait 早于读完 stdout 时,大量输出后正常退出会被判成
// "read |0: file already closed"。
func TestRunDrainsStdoutBeforeWait(t *testing.T) {
fakeRuntime(t, `
const line = JSON.stringify({ event: "progress", pad: "x".repeat(2048) }) + "\n";
for (let i = 0; i < 2000; i++) process.stdout.write(line);
process.stdout.write(JSON.stringify({ event: "settled" }) + "\n");
`)
for i := 0; i < 5; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := Run(ctx, ProcessRequest{Model: map[string]any{"id": "m"}}, noopBridge())
cancel()
if err != nil {
t.Fatalf("attempt %d: normal exit reported as failure: %v", i, err)
}
}
}
// runtime_error 之后仍要读到 EOF,并把该错误作为运行结果返回。
func TestRunReportsRuntimeErrorAfterDrainingOutput(t *testing.T) {
fakeRuntime(t, `
process.stdout.write(JSON.stringify({ event: "runtime_error", message: "boom" }) + "\n");
const line = JSON.stringify({ event: "progress", pad: "x".repeat(2048) }) + "\n";
for (let i = 0; i < 500; i++) process.stdout.write(line);
process.exitCode = 1;
`)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := Run(ctx, ProcessRequest{Model: map[string]any{"id": "m"}}, noopBridge())
if err == nil || err.Error() != "boom" {
t.Fatalf("want runtime_error message, got %v", err)
}
}
// 子进程只能拿到白名单环境变量,拿不到数据库连接等密钥。
func TestRunDoesNotLeakBackendEnvironment(t *testing.T) {
t.Setenv("DATABASE_URL", "postgres://secret")
fakeRuntime(t, `
if (process.env.DATABASE_URL) {
process.stdout.write(JSON.stringify({ event: "runtime_error", message: "leaked" }) + "\n");
process.exitCode = 1;
}
`)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := Run(ctx, ProcessRequest{Model: map[string]any{"id": "m"}}, noopBridge()); err != nil {
if strings.Contains(err.Error(), "leaked") {
t.Fatal("backend environment leaked into the Agent runtime")
}
t.Fatal(err)
}
}
@@ -0,0 +1,69 @@
package yingceagent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
agentruntime "infinite-canvas/backend/internal/agent/runtime"
)
// Run executes one Agent session on the independent yingce-agent service.
// The Go process keeps the bridge, billing, and tool execution. A configured
// remote endpoint never falls back to an embedded Node process: doing so can
// run the same step twice.
func Run(ctx context.Context, endpoint, serviceToken, bridgeHost string, request agentruntime.ProcessRequest, bridge agentruntime.Bridge) error {
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
serviceToken = strings.TrimSpace(serviceToken)
if endpoint == "" {
return fmt.Errorf("YINGCE_AGENT_URL is empty")
}
if len(serviceToken) < 32 {
return fmt.Errorf("YINGCE_AGENT_TOKEN must contain at least 32 characters")
}
server, err := agentruntime.StartBridge("0.0.0.0:0", bridgeHost, bridge)
if err != nil {
return err
}
defer server.Close()
request.BridgeURL = server.URL
request.BridgeToken = server.Token
payload, err := json.Marshal(request)
if err != nil {
return fmt.Errorf("encode remote Agent request: %w", err)
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/v1/runs", bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("create remote Agent request: %w", err)
}
httpRequest.Header.Set("Authorization", "Bearer "+serviceToken)
httpRequest.Header.Set("Content-Type", "application/json")
response, err := remoteClient.Do(httpRequest)
if err != nil {
return fmt.Errorf("call yingce-agent: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf("yingce-agent returned HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(detail)))
}
return agentruntime.ReadOutput(response.Body)
}
var remoteClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
IdleConnTimeout: 90 * time.Second,
},
}
@@ -0,0 +1,119 @@
package yingceagent
import (
"bufio"
"context"
"encoding/json"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
agentruntime "infinite-canvas/backend/internal/agent/runtime"
)
func TestRemoteRunCallsBridgeAndRejectsBadToken(t *testing.T) {
if _, err := exec.LookPath("node"); err != nil {
t.Skip("node is not installed")
}
_, source, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate test file")
}
serverPath := filepath.Clean(filepath.Join(filepath.Dir(source), "../../../../yingce-agent/server.mjs"))
runtimePath := filepath.Join(t.TempDir(), "agent-runtime.mjs")
script := `
let raw = "";
for await (const chunk of process.stdin) raw += chunk;
const request = JSON.parse(raw);
const response = await fetch(new URL("/tool", request.bridgeURL), {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer " + request.bridgeToken },
body: JSON.stringify({ name: "lookup" }),
});
if (!response.ok) {
process.stdout.write(JSON.stringify({ event: "runtime_error", message: "bridge " + response.status }) + "\n");
process.exitCode = 1;
} else {
process.stdout.write(JSON.stringify({ event: "settled" }) + "\n");
}
`
if err := os.WriteFile(runtimePath, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
token := strings.Repeat("ab", 16)
cmd := exec.Command("node", serverPath)
cmd.Env = append(os.Environ(),
"YINGCE_AGENT_TOKEN="+token,
"YINGCE_AGENT_RUNTIME="+runtimePath,
"PORT=0",
"MAX_CONCURRENT_SESSIONS=2",
)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
})
reader := bufio.NewReader(stdout)
line, err := reader.ReadString('\n')
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(line, "YINGCE_AGENT_READY ") {
t.Fatalf("server did not report its port: %q", line)
}
endpoint := "http://127.0.0.1:" + strings.TrimSpace(strings.TrimPrefix(line, "YINGCE_AGENT_READY "))
health, err := http.Get(endpoint + "/healthz")
if err != nil {
t.Fatal(err)
}
defer health.Body.Close()
if health.StatusCode != http.StatusOK {
t.Fatalf("health status %d", health.StatusCode)
}
unauthorized, err := http.Post(endpoint+"/v1/runs", "application/json", strings.NewReader(`{}`))
if err != nil {
t.Fatal(err)
}
defer unauthorized.Body.Close()
if unauthorized.StatusCode != http.StatusUnauthorized {
t.Fatalf("missing token status %d", unauthorized.StatusCode)
}
called := false
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
err = Run(ctx, endpoint, token, "127.0.0.1", agentruntime.ProcessRequest{
Model: map[string]any{"id": "m"},
}, agentruntime.Bridge{
Model: func(context.Context, map[string]json.RawMessage) (any, error) {
return map[string]any{"ok": true}, nil
},
Tool: func(context.Context, map[string]json.RawMessage) (any, error) {
called = true
return map[string]any{"content": "ok"}, nil
},
Event: func(context.Context, map[string]json.RawMessage) (any, error) {
return map[string]any{"ok": true}, nil
},
})
if err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("remote runtime did not call the Go tool bridge")
}
}