From 4f581306d7366f2f5163f07ec45a7d4a914accb8 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 03:16:27 +0000 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E6=8B=92=E7=BB=9D=E6=8A=8A?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=8F=91=E7=BB=99=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=B8=8D=E4=B8=80=E8=87=B4=E7=9A=84=20PI=20r?= =?UTF-8?q?unner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test 启动时根 .env 的 dev runner URL 盖过了 18866,sidecar 把 test 工作区重写到 dev 空目录,模型因读不到材料报 MISSING_TARGET。端口约定优先,并在调用前核对 runner data_dir。 --- .doc/ops/CURRENT.md | 1 + configs/.env.test.example | 3 ++ internal/agent/backend_test.go | 83 ++++++++++++++++++++++++++++++++ internal/agent/piagent.go | 52 ++++++++++++++++++++ internal/errcode/codes_domain.go | 1 + internal/stream/classify.go | 6 +++ internal/stream/classify_test.go | 25 ++++++++++ scripts/launch-single.sh | 10 +++- 8 files changed, 180 insertions(+), 1 deletion(-) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 90f922a9..80caf57d 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Cursor | pi-runner-env-mismatch-2026-09-28 | fix/pi-runner-env-mismatch-2026-09-28 | .worktrees/pi-runner-env-mismatch-2026-09-28 | pi-runner-env | in_progress | | Cursor | error-events-pg-roundtrip-2026-09-28 | feature/error-events-pg-roundtrip-2026-09-28 | .worktrees/error-events-pg-roundtrip-2026-09-28 | error-events-pg-evidence | done | | Cursor | error-code-observability-2026-09-28 | feature/error-code-observability-2026-09-28 | .worktrees/error-code-observability-2026-09-28 | error-code-observability | done | | Cursor | attachment-only-turn-2026-09-28 | fix/attachment-only-turn-2026-09-28 | .worktrees/attachment-only-turn-2026-09-28 | attachment-only-turn | done | diff --git a/configs/.env.test.example b/configs/.env.test.example index 73f96e38..4b4750a7 100644 --- a/configs/.env.test.example +++ b/configs/.env.test.example @@ -28,6 +28,9 @@ KB_DB_PORT=5432 # 5. Cursor / LLM Agent Runner 服务 PI_AGENT_RUNNER_PORT=18866 +# 与端口一致。本文件在根 .env 之后加载,可盖住根 .env 里的 dev runner URL。 +# launch-single.sh 在进程启动前未导出 URL 时仍按端口派生,避免 dev sidecar 泄漏。 +PI_AGENT_RUNNER_URL=http://127.0.0.1:18866 AGENT_BACKEND=pi-agent AGENT_TIMEOUT=60m # app 与 pi-agent 容器共享(test 环境建议设置非空值) diff --git a/internal/agent/backend_test.go b/internal/agent/backend_test.go index 1df81878..aec10c73 100644 --- a/internal/agent/backend_test.go +++ b/internal/agent/backend_test.go @@ -368,3 +368,86 @@ func TestPiAgentRemoteRunner_BodyTooLargeDoesNotWrite(t *testing.T) { t.Fatal("must not materialize after body-too-large reject") } } + +func TestPiAgentRemoteRunner_DataRootMismatchSkipsRun(t *testing.T) { + dir := t.TempDir() + var runHits int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + _ = json.NewEncoder(w).Encode(map[string]any{"data_dir": filepath.Join(dir, "other-root")}) + case "/v1/run": + runHits++ + http.Error(w, "should not be called", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + res, err := agent.NewBackend("pi-agent", &config.Config{ + AgentBackend: "pi-agent", + PiAgentRunnerURL: ts.URL, + PiAgentModel: "MiniMax-M3", + PiAgentProvider: "minimax", + AgentTimeout: 5 * time.Second, + }).Execute(context.Background(), agent.Request{ + WorkDir: dir, Prompt: "draft", Targets: []string{"output.md"}, + }) + if err != nil { + t.Fatal(err) + } + if res.Success || res.ErrorReason != "pi runner data root mismatch" { + t.Fatalf("got success=%v reason=%q", res.Success, res.ErrorReason) + } + if strings.Contains(res.ErrorReason, dir) { + t.Fatalf("reason leaked a path: %s", res.ErrorReason) + } + if runHits != 0 { + t.Fatalf("/v1/run hits=%d", runHits) + } +} + +func TestPiAgentRemoteRunner_DataRootMatchStillRuns(t *testing.T) { + dir := t.TempDir() + var runHits int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + _ = json.NewEncoder(w).Encode(map[string]any{"data_dir": dir}) + case "/v1/run": + runHits++ + body := "ok" + runID := filepath.Base(dir) + rv := agent.BuildResultV1(runID, "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte(body)}}) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "run_id": runID, "runner_protocol": "typed-v1", + "patches": []map[string]string{{"rel_path": "output.md", "content": body}}, + "result_v1": rv, + }) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + res, err := agent.NewBackend("pi-agent", &config.Config{ + AgentBackend: "pi-agent", + PiAgentRunnerURL: ts.URL, + PiAgentModel: "MiniMax-M3", + PiAgentProvider: "minimax", + AgentTimeout: 5 * time.Second, + }).Execute(context.Background(), agent.Request{ + WorkDir: dir, Prompt: "draft", Targets: []string{"output.md"}, + }) + if err != nil { + t.Fatal(err) + } + if !res.Success { + t.Fatalf("matched data_dir must run: %s", res.ErrorReason) + } + if runHits != 1 { + t.Fatalf("/v1/run hits=%d", runHits) + } +} diff --git a/internal/agent/piagent.go b/internal/agent/piagent.go index 615e6c06..3cd7e765 100644 --- a/internal/agent/piagent.go +++ b/internal/agent/piagent.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" "chat-agent-system/internal/config" @@ -155,7 +156,58 @@ func (p *PiAgentBackend) runnerPayload(req Request, workDir string, remote bool, return body } +// runnerDataRoots caches a successful /health data_dir per runner URL. +// A failed probe is not cached: the next call may try again, and this call skips the check. +var runnerDataRoots sync.Map + +func probeRunnerDataRoot(ctx context.Context, runnerURL string) (string, bool) { + if v, ok := runnerDataRoots.Load(runnerURL); ok { + root, _ := v.(string) + return root, root != "" + } + probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + httpReq, err := http.NewRequestWithContext(probeCtx, http.MethodGet, runnerURL+"/health", nil) + if err != nil { + return "", false + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return "", false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", false + } + var body struct { + DataDir string `json:"data_dir"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { + return "", false + } + root := filepath.Clean(strings.TrimSpace(body.DataDir)) + if root == "" || root == "." { + return "", false + } + runnerDataRoots.Store(runnerURL, root) + return root, true +} + +func workDirWithinRunnerRoot(workDir, root string) bool { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(workDir)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + return true +} + func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, req Request, workDir string, timeout time.Duration) (*Result, error) { + if root, ok := probeRunnerDataRoot(ctx, runnerURL); ok && !workDirWithinRunnerRoot(workDir, root) { + // Do not include either path: the caller classifies this phrase, and paths leak host layout. + return &Result{Success: false, ErrorReason: "pi runner data root mismatch"}, nil + } + expectedRunID := filepath.Base(workDir) if expectedRunID == "" || expectedRunID == "." || expectedRunID == string(filepath.Separator) { expectedRunID = "unknown" diff --git a/internal/errcode/codes_domain.go b/internal/errcode/codes_domain.go index a867d755..514ab30a 100644 --- a/internal/errcode/codes_domain.go +++ b/internal/errcode/codes_domain.go @@ -48,6 +48,7 @@ func init() { {ID: "CHAT_TIMEOUT", Status: 504, Message: "生成回复超时,请稍后重试", Module: "stream", Class: ClassTransient, Actions: []string{"retry"}}, {ID: "NETWORK_ERROR", Status: 502, Message: "网络连接异常,请稍后重试", Module: "stream", Class: ClassTransient, Actions: []string{"retry"}}, {ID: "RUNNER_RESULT_REJECTED", Status: 502, Message: "助手执行结果校验失败,请重新开始任务", Module: "stream", Actions: []string{"restart"}}, + {ID: "RUNNER_MISMATCH", Status: 500, Message: "模型执行环境与数据目录不一致,请联系管理员检查部署配置", Module: "stream"}, {ID: "TASK_INTERRUPTED", Status: 409, Message: "任务执行中断,可继续或重新开始", Module: "taskrun", Class: ClassResumable, Actions: []string{"continue", "restart"}}, {ID: "TASK_SCOPE_UNRESOLVED", Status: 400, Message: "无法确定任务所属阶段", Module: "projectinstance"}, diff --git a/internal/stream/classify.go b/internal/stream/classify.go index 2d5324f7..2fddc100 100644 --- a/internal/stream/classify.go +++ b/internal/stream/classify.go @@ -71,6 +71,12 @@ func ClassifyError(err error, phaseID, requestID, sessionID, workspaceID string) info.UserMessage = "网络连接异常,请稍后重试" info.Actions = []string{"retry"} + case strings.Contains(msg, "runner data root mismatch"): + info.Code = "RUNNER_MISMATCH" + info.Class = ErrorClassFatal + info.UserMessage = "模型执行环境与数据目录不一致,请联系管理员检查部署配置" + info.Actions = []string{} + case isRunnerContractError(msg): // Permanent runner wire/contract failures must not look "resumable". info.Code = "RUNNER_RESULT_REJECTED" diff --git a/internal/stream/classify_test.go b/internal/stream/classify_test.go index b2331d2f..073f1d27 100644 --- a/internal/stream/classify_test.go +++ b/internal/stream/classify_test.go @@ -145,6 +145,31 @@ func TestClassifyError_TypedResultRejectedIsFatalNotResumable(t *testing.T) { } } +func TestClassifyError_RunnerDataRootMismatch(t *testing.T) { + info := ClassifyError( + errors.New("task bd_research failed: pi runner data root mismatch"), + "dialogue_generating", + "req_mismatch", + "sess-p5-u32", + "ws_should_not_make_this_resumable", + ) + if info.Code != "RUNNER_MISMATCH" { + t.Fatalf("code=%s", info.Code) + } + if info.Class != ErrorClassFatal { + t.Fatalf("class=%s", info.Class) + } + if len(info.Actions) != 0 { + t.Fatalf("actions=%v", info.Actions) + } + if strings.Contains(info.UserMessage, "/") || strings.Contains(info.Upstream, "/home/") { + t.Fatalf("leaked path: %+v", info) + } + if info.UserMessage == "" || info.Message != info.UserMessage { + t.Fatalf("message=%q user=%q", info.Message, info.UserMessage) + } +} + func TestClassifyError_BodyTooLargeIsFatalNotResumable(t *testing.T) { info := ClassifyError( errors.New("step step_01_organize failed: runner result body too large"), diff --git a/scripts/launch-single.sh b/scripts/launch-single.sh index 3d320e56..f4ad7b92 100755 --- a/scripts/launch-single.sh +++ b/scripts/launch-single.sh @@ -51,6 +51,8 @@ act() { printf '\033[1;33m 🔄 %s\033[0m\n' "$*"; } fail() { printf '\033[1;31m ❌ %s\033[0m\n' "$*"; } # ---------- 分层加载配置:根 .env(公共密钥)→ configs/.env.(环境差异) ---------- +# 先记下调用方在 shell 里显式导出的 runner URL。根 .env 里的 dev URL 不能算“显式”。 +PROCESS_RUNNER_URL="${PI_AGENT_RUNNER_URL:-}" # shellcheck disable=SC1091 set -a [[ -f "$ROOT/.env" ]] && . "$ROOT/.env" @@ -84,7 +86,13 @@ export CONTEXT_DB_PASSWORD="${SYSTEM_DB_PASSWORD:?SYSTEM_DB_PASSWORD required}" export CONTEXT_DB_NAME="${SYSTEM_DB_NAME:-agent_system_$ENV_NAME}" PI_PORT="${PI_AGENT_RUNNER_PORT:-18866}" -export PI_AGENT_RUNNER_URL="${PI_AGENT_RUNNER_URL:-http://127.0.0.1:$PI_PORT}" +# 非 dev 环境以端口为 runner 权威。只有进程在加载 .env 之前就导出了 URL,才尊重它 +# (运维直连远端 runner)。否则根 .env 的 dev URL(18865)会把 test 工作区打到 dev sidecar。 +if [[ -n "${PROCESS_RUNNER_URL}" ]]; then + export PI_AGENT_RUNNER_URL="${PROCESS_RUNNER_URL}" +else + export PI_AGENT_RUNNER_URL="http://127.0.0.1:${PI_PORT}" +fi # 与 compose 对齐的 vault 状态根(FAV 关闭时 inert;无 master key 时 health 同样报 disconnected) export VAULT_STATE_ROOT="${VAULT_STATE_ROOT:-$ROOT/data/vault/$ENV_NAME}"