diff --git a/.doc/ops/long-term/2026-09-15-track-p-protocol-implementation.md b/.doc/archive/ops/2026-09-15-track-p-protocol-implementation.md similarity index 100% rename from .doc/ops/long-term/2026-09-15-track-p-protocol-implementation.md rename to .doc/archive/ops/2026-09-15-track-p-protocol-implementation.md diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 9ff33724..c6c25629 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -135,6 +135,7 @@ imports: - docrevision - replyexport fsutil: [] + httpx: [] intent: - maturity kb: @@ -173,6 +174,7 @@ imports: - docrevision - events - freedoc + - httpx - intent - kb - notification diff --git a/configs/schemas/pi-run-result-v1.json b/configs/schemas/pi-run-result-v1.json new file mode 100644 index 00000000..f33bcca4 --- /dev/null +++ b/configs/schemas/pi-run-result-v1.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-system.local/schemas/pi-run-result-v1.json", + "title": "pi-run-result-v1", + "type": "object", + "required": ["schema_version", "run_id", "outcome", "retryable", "members", "content_digest"], + "additionalProperties": false, + "properties": { + "schema_version": { "const": 1 }, + "run_id": { "type": "string", "minLength": 1, "maxLength": 128 }, + "outcome": { "enum": ["success", "error"] }, + "retryable": { "type": "boolean" }, + "error_code": { "type": "string", "maxLength": 64 }, + "members": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "required": ["kind", "path", "digest", "size"], + "additionalProperties": false, + "properties": { + "kind": { "enum": ["file"] }, + "path": { "type": "string", "minLength": 1, "maxLength": 256 }, + "digest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "size": { "type": "integer", "minimum": 0, "maximum": 2097152 } + } + } + }, + "content_digest": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } +} diff --git a/deploy/pi-agent/Dockerfile b/deploy/pi-agent/Dockerfile index 7700e599..cf6fcd55 100644 --- a/deploy/pi-agent/Dockerfile +++ b/deploy/pi-agent/Dockerfile @@ -25,6 +25,7 @@ WORKDIR /app # 复制 runner、其相对 import 运行时模块、扩展工具以及入口脚本 COPY deploy/pi-agent/extensions/ /app/extensions/ COPY deploy/pi-agent/runner.mjs /app/runner.mjs +COPY deploy/pi-agent/result-v1.mjs /app/result-v1.mjs COPY deploy/pi-agent/settle-policy.mjs /app/settle-policy.mjs COPY deploy/pi-agent/entrypoint.sh /app/entrypoint.sh diff --git a/deploy/pi-agent/result-v1.mjs b/deploy/pi-agent/result-v1.mjs new file mode 100644 index 00000000..20ea4d80 --- /dev/null +++ b/deploy/pi-agent/result-v1.mjs @@ -0,0 +1,82 @@ +import { createHash } from "node:crypto"; + +export const RESULT_V1_SCHEMA_VERSION = 1; +export const MAX_RESULT_MEMBERS = 32; +export const MAX_RESULT_MEMBER_BYTES = 2 * 1024 * 1024; + +const RETRYABLE_CODES = new Set(["TIMEOUT", "CANCELLED", "TRANSIENT"]); +const ALLOWED_CODES = new Set([ + "PROMPT_EMPTY", + "UNAUTHORIZED", + "TIMEOUT", + "CANCELLED", + "TRANSIENT", + "MISSING_TARGET", + "RUN_FAILED", +]); + +export function typedErrorCode(payload) { + const raw = String(payload?.fallback_reason || payload?.error || ""); + const t = raw.toLowerCase(); + if (t.includes("prompt_empty")) return "PROMPT_EMPTY"; + if (t.includes("unauthorized")) return "UNAUTHORIZED"; + if (t.includes("timeout") || t.includes("timed out")) return "TIMEOUT"; + if (t.includes("cancel")) return "CANCELLED"; + if ( + t.includes("connection") || + t.includes("terminated") || + t.includes("econnreset") || + t.includes("socket hang") + ) { + return "TRANSIENT"; + } + if (t.includes("missing target")) return "MISSING_TARGET"; + if (payload?.retryable) return "TRANSIENT"; + if (!payload?.ok) return "RUN_FAILED"; + return ""; +} + +export function membersDigest(members) { + const rows = (Array.isArray(members) ? members : []).map((m) => ({ + kind: m.kind, + path: m.path, + digest: m.digest, + size: m.size, + })); + return createHash("sha256").update(JSON.stringify(rows)).digest("hex"); +} + +export function withResultV1(payload) { + const out = { ...payload }; + delete out.stderr; + const patches = Array.isArray(out.patches) ? out.patches : []; + const members = []; + for (const p of patches) { + const rel = String(p?.rel_path || ""); + if (!rel || rel.includes("..") || rel.startsWith("/")) continue; + const buf = Buffer.from(String(p.content || ""), "utf8"); + if (buf.length > MAX_RESULT_MEMBER_BYTES) continue; + if (members.length >= MAX_RESULT_MEMBERS) break; + members.push({ + kind: "file", + path: rel, + digest: createHash("sha256").update(buf).digest("hex"), + size: buf.length, + }); + } + const ok = !!out.ok; + const errorCode = ok ? "" : typedErrorCode(out); + const retryable = !ok && RETRYABLE_CODES.has(errorCode) && !!out.retryable; + out.capabilities = { typed_result: true }; + out.runner_protocol = "typed-v1"; + out.result_v1 = { + schema_version: RESULT_V1_SCHEMA_VERSION, + run_id: String(out.run_id || "unknown").slice(0, 128), + outcome: ok ? "success" : "error", + retryable, + error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "RUN_FAILED", + members, + content_digest: membersDigest(members), + }; + return out; +} diff --git a/deploy/pi-agent/result-v1.test.mjs b/deploy/pi-agent/result-v1.test.mjs new file mode 100644 index 00000000..f5334f3b --- /dev/null +++ b/deploy/pi-agent/result-v1.test.mjs @@ -0,0 +1,52 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { withResultV1, membersDigest, typedErrorCode } from "./result-v1.mjs"; + +test("withResultV1 is additive and strips stderr", () => { + const out = withResultV1({ + ok: true, + run_id: "run-1", + stderr: "secret-trace", + patches: [{ rel_path: "output.md", content: "hello" }], + }); + assert.equal(out.ok, true); + assert.equal(out.patches[0].rel_path, "output.md"); + assert.equal(out.stderr, undefined); + assert.equal(out.capabilities.typed_result, true); + assert.equal(out.runner_protocol, "typed-v1"); + assert.equal(out.result_v1.schema_version, 1); + assert.equal(out.result_v1.run_id, "run-1"); + assert.equal(out.result_v1.outcome, "success"); + assert.equal(out.result_v1.members.length, 1); + assert.equal(out.result_v1.content_digest, membersDigest(out.result_v1.members)); +}); + +test("typedErrorCode is allowlisted and ignores raw stderr", () => { + assert.equal(typedErrorCode({ ok: false, fallback_reason: "prompt_empty" }), "PROMPT_EMPTY"); + assert.equal(typedErrorCode({ ok: false, fallback_reason: "Connection error." }), "TRANSIENT"); + const out = withResultV1({ + ok: false, + run_id: "run-2", + retryable: true, + fallback_reason: "Connection error. /tmp/secret", + stderr: "token=abc", + patches: [], + }); + assert.equal(out.stderr, undefined); + assert.equal(out.result_v1.error_code, "TRANSIENT"); + assert.equal(out.result_v1.retryable, true); + assert.equal(out.result_v1.outcome, "error"); +}); + +test("path escape members are omitted", () => { + const out = withResultV1({ + ok: true, + run_id: "run-3", + patches: [ + { rel_path: "../etc/passwd", content: "x" }, + { rel_path: "/abs.md", content: "y" }, + { rel_path: "output.md", content: "ok" }, + ], + }); + assert.deepEqual(out.result_v1.members.map((m) => m.path), ["output.md"]); +}); diff --git a/deploy/pi-agent/runner.mjs b/deploy/pi-agent/runner.mjs index e163dd7a..94884d0e 100755 --- a/deploy/pi-agent/runner.mjs +++ b/deploy/pi-agent/runner.mjs @@ -11,6 +11,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, relative, isAbsolute } from "node:path"; import { TavilyKeyPool, searchTavily, formatTavilyResults } from "./extensions/tavily-search.mjs"; import { applyAgentEvent, classifyRunOutcome, newSettleState } from "./settle-policy.mjs"; +import { withResultV1 } from "./result-v1.mjs"; const PORT = parseInt(process.env.PORT || process.env.PI_AGENT_RUNNER_PORT || "18766", 10); const DATA_DIR = process.env.DATA_DIR || "/app/data/workspaces"; @@ -105,7 +106,7 @@ async function handleRun(body) { } if (!prompt.trim()) { - return { ok: false, fallback_reason: "prompt_empty", patches: [] }; + return { ok: false, run_id: String(body.run_id || "unknown").slice(0, 128), fallback_reason: "prompt_empty", patches: [] }; } const allowed = new Set(); @@ -158,7 +159,7 @@ async function handleRun(body) { env.PI_AGENT_SYSTEM_PROMPT_FILE = "AGENTS.md"; let agentId = ""; - let runId = ""; + let runId = String(body.run_id || "").slice(0, 128); let lastErr = ""; const settle = newSettleState(); @@ -259,7 +260,6 @@ async function handleRun(body) { run_id: runId, retryable: classified.retryable, fallback_reason: classified.fallback_reason, - stderr: stderrBuf ? stderrBuf.slice(0, 400) : undefined, patches: [], }; } @@ -302,7 +302,6 @@ async function handleRun(body) { run_id: runId, retryable: classified.retryable, fallback_reason: classified.fallback_reason, - stderr: stderrBuf ? stderrBuf.slice(0, 400) : undefined, patches, }; } @@ -392,9 +391,9 @@ const server = http.createServer(async (req, res) => { try { const body = await parseJsonBody(req); const result = await handleRun(body); - sendJson(res, 200, result); + sendJson(res, 200, withResultV1(result)); } catch (err) { - sendJson(res, 500, { ok: false, error: err.message, patches: [] }); + sendJson(res, 500, withResultV1({ ok: false, error: "runner_failed", patches: [], run_id: "unknown" })); } return; } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3088b47f..82c1a1aa 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -67,6 +67,7 @@ services: - ./data/dev/workspaces:/app/data/workspaces - ./data/dev/agent_sessions:/root/.pi/agent/sessions - ./deploy/pi-agent/runner.mjs:/app/runner.mjs:ro + - ./deploy/pi-agent/result-v1.mjs:/app/result-v1.mjs:ro - ./deploy/pi-agent/extensions:/app/extensions:ro tmpfs: - /tmp diff --git a/internal/agent/backend_test.go b/internal/agent/backend_test.go index 5d9a2cb3..5b65e0e7 100644 --- a/internal/agent/backend_test.go +++ b/internal/agent/backend_test.go @@ -46,6 +46,7 @@ func TestStubBackendExecution(t *testing.T) { } func TestPiAgentRemoteRunner(t *testing.T) { + tmpDir := t.TempDir() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/run" { http.NotFound(w, r) @@ -54,21 +55,24 @@ func TestPiAgentRemoteRunner(t *testing.T) { var payload map[string]any _ = json.NewDecoder(r.Body).Decode(&payload) + body := "# Remote Generated Deliverable\nValidated from Container Runner" + runID := filepath.Base(tmpDir) + rv := agent.BuildResultV1(runID, "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte(body)}}) resp := map[string]any{ - "ok": true, - "patches": []map[string]string{ - { - "rel_path": "output.md", - "content": "# Remote Generated Deliverable\nValidated from Container Runner", - }, - }, + "ok": true, + "run_id": runID, + "runner_protocol": "typed-v1", + "capabilities": map[string]any{"typed_result": true}, + "patches": []map[string]string{{"rel_path": "output.md", "content": body}}, + "result_v1": rv, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() - - tmpDir := t.TempDir() cfg := &config.Config{ AgentBackend: "pi-agent", PiAgentRunnerURL: ts.URL, @@ -138,7 +142,9 @@ func TestPiAgentRemoteRunner_PathAliasesAndMissing(t *testing.T) { "content": "Outcome: SUCCESS\n", }} } - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "patches": patches}) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "runner_protocol": "legacy", "patches": patches, + }) })) defer ts.Close() @@ -194,6 +200,7 @@ func TestPiAgentRemoteRunner_PropagatesRetryable(t *testing.T) { "ok": false, "retryable": true, "fallback_reason": "Connection error.", + "runner_protocol": "legacy", "patches": []any{}, }) })) @@ -232,6 +239,7 @@ func TestPiAgentRemoteRunner_OldSettleLieIsRetryable(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, + "runner_protocol": "legacy", "patches": []map[string]string{{ "rel_path": "execution.log", "content": "Outcome: SUCCESS\n", @@ -263,3 +271,64 @@ func TestPiAgentRemoteRunner_OldSettleLieIsRetryable(t *testing.T) { t.Fatal("must not reconstruct output.md from truncated write args") } } + +func TestPiAgentRemoteRunner_TypedTamperDoesNotWrite(t *testing.T) { + dir := t.TempDir() + runID := filepath.Base(dir) + rv := agent.BuildResultV1(runID, "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte("hello")}}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = 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": "TAMPER"}}, + "result_v1": rv, + }) + })) + 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 != "typed result rejected" { + t.Fatalf("tamper must fail closed, got success=%v reason=%q", res.Success, res.ErrorReason) + } + if _, serr := os.Stat(filepath.Join(dir, "output.md")); serr == nil { + t.Fatal("must not materialize after typed reject") + } +} + +func TestPiAgentRemoteRunner_ClientTimeout(t *testing.T) { + dir := t.TempDir() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(2 * time.Second) + })) + defer ts.Close() + res, err := agent.NewBackend("pi-agent", &config.Config{ + AgentBackend: "pi-agent", + PiAgentRunnerURL: ts.URL, + PiAgentModel: "MiniMax-M3", + PiAgentProvider: "minimax", + AgentTimeout: 50 * time.Millisecond, + }).Execute(context.Background(), agent.Request{ + WorkDir: dir, Prompt: "draft", Targets: []string{"output.md"}, Timeout: 50 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if res.Success || !res.Retryable { + t.Fatalf("timeout must be retryable failure, got %+v", res) + } + if strings.Contains(strings.ToLower(res.ErrorReason), "stderr") { + t.Fatalf("timeout reason leaked stderr: %q", res.ErrorReason) + } +} diff --git a/internal/agent/deliverable.go b/internal/agent/deliverable.go index 13fe7748..78cfac02 100644 --- a/internal/agent/deliverable.go +++ b/internal/agent/deliverable.go @@ -96,9 +96,44 @@ func MaterializeTargets(workDir string, targets []string, incoming map[string][] if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return collected, err } + if err := rejectSymlinkEscape(workDir, dest); err != nil { + return collected, err + } if err := os.WriteFile(dest, body, 0o644); err != nil { return collected, err } } return collected, nil } + +func rejectSymlinkEscape(workDir, dest string) error { + root, err := filepath.Abs(workDir) + if err != nil { + return err + } + absDest, err := filepath.Abs(dest) + if err != nil { + return err + } + rel, err := filepath.Rel(root, absDest) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return fmt.Errorf("typed result path rejected") + } + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + resolvedRoot = root + } + parent := filepath.Dir(absDest) + resolvedParent, err := filepath.EvalSymlinks(parent) + if err != nil { + return fmt.Errorf("typed result path rejected") + } + relParent, err := filepath.Rel(resolvedRoot, resolvedParent) + if err != nil || relParent == ".." || strings.HasPrefix(relParent, ".."+string(os.PathSeparator)) { + return fmt.Errorf("typed result path rejected") + } + if info, err := os.Lstat(absDest); err == nil && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("typed result path rejected") + } + return nil +} diff --git a/internal/agent/deliverable_test.go b/internal/agent/deliverable_test.go index 1ae1d3af..1539a259 100644 --- a/internal/agent/deliverable_test.go +++ b/internal/agent/deliverable_test.go @@ -82,3 +82,25 @@ func TestMaterializeTargets_MissingAndEmptyFail(t *testing.T) { t.Fatal("whitespace-only body must not count as a deliverable") } } + +func TestMaterializeTargets_RejectsSymlinkEscape(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "secret.md"), []byte("leak"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "out") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + _, err := MaterializeTargets(root, []string{"out/secret.md"}, map[string][]byte{ + "out/secret.md": []byte("overwrite"), + }) + if err == nil { + t.Fatal("symlink escape must fail closed") + } + got, _ := os.ReadFile(filepath.Join(outside, "secret.md")) + if string(got) != "leak" { + t.Fatalf("must not write through symlink, got %q", got) + } +} diff --git a/internal/agent/piagent.go b/internal/agent/piagent.go index e0fd8b2d..881f1c23 100644 --- a/internal/agent/piagent.go +++ b/internal/agent/piagent.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "os" "os/exec" @@ -68,15 +69,15 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err "PI_AGENT_SYSTEM_PROMPT_FILE=AGENTS.md", ) - var stdout, stderr bytes.Buffer + var stdout bytes.Buffer cmd.Stdout = &stdout - cmd.Stderr = &stderr + cmd.Stderr = io.Discard err := cmd.Run() if err != nil { res := &Result{ Success: false, - ErrorReason: fmt.Sprintf("pi execution error: %v, stderr: %s", err, stderr.String()), + ErrorReason: "pi execution error", } res.Retryable = AssessStepFailure(StepFailureSignals{Result: res, WorkDir: workDir}).Class == FailureTransient return res, nil @@ -85,7 +86,6 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err type bridgeResponse struct { OK bool `json:"ok"` FallbackReason string `json:"fallback_reason"` - Stderr string `json:"stderr"` Retryable bool `json:"retryable"` } var bResp bridgeResponse @@ -93,19 +93,17 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err if len(lines) > 0 { _ = json.Unmarshal([]byte(lines[len(lines)-1]), &bResp) } + logDigest := "stdout_digest=" + DigestBytes(stdout.Bytes()) if !bResp.OK { - errMsg := bResp.FallbackReason - if bResp.Stderr != "" { - errMsg += " (" + bResp.Stderr + ")" - } + errMsg := safeLegacyReason(bResp.FallbackReason) if errMsg == "" { errMsg = "pi bridge reported task failed" } res := &Result{ Success: false, ErrorReason: errMsg, - Logs: stdout.String(), + Logs: logDigest, Retryable: bResp.Retryable, } if !res.Retryable { @@ -120,7 +118,7 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err Success: false, ErrorReason: err.Error(), Patches: patches, - Logs: stdout.String(), + Logs: logDigest, } res.Retryable = AssessStepFailure(StepFailureSignals{ Result: res, @@ -133,11 +131,15 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err return &Result{ Success: true, Patches: patches, - Logs: stdout.String(), + Logs: logDigest, }, nil } func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, req Request, workDir string, timeout time.Duration) (*Result, error) { + expectedRunID := filepath.Base(workDir) + if expectedRunID == "" || expectedRunID == "." || expectedRunID == string(filepath.Separator) { + expectedRunID = "unknown" + } reqBody, _ := json.Marshal(map[string]any{ "workspace": workDir, "model": p.cfg.PiAgentModel, @@ -146,6 +148,8 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re "instructions": req.Instructions, "targets": req.Targets, "timeout_ms": timeout.Milliseconds(), + "run_id": expectedRunID, + "typed_result": true, }) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, runnerURL+"/v1/run", bytes.NewReader(reqBody)) @@ -168,36 +172,28 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re }, nil } defer resp.Body.Close() - - var remoteResp struct { - OK bool `json:"ok"` - FallbackReason string `json:"fallback_reason"` - Stderr string `json:"stderr"` - Retryable bool `json:"retryable"` - Patches []struct { - RelPath string `json:"rel_path"` - Content string `json:"content"` - } `json:"patches"` + limited, err := io.ReadAll(io.LimitReader(resp.Body, MaxResultBodyBytes+1)) + if err != nil { + return &Result{Success: false, ErrorReason: "failed to read remote response", Retryable: true}, nil } - if err := json.NewDecoder(resp.Body).Decode(&remoteResp); err != nil { - return &Result{ - Success: false, - ErrorReason: fmt.Sprintf("failed to decode remote response: %v", err), - }, nil + if len(limited) > MaxResultBodyBytes { + return &Result{Success: false, ErrorReason: "typed result rejected"}, nil } - if !remoteResp.OK { - reason := remoteResp.FallbackReason - if remoteResp.Stderr != "" { - reason += " (" + remoteResp.Stderr + ")" - } + decoded, decErr := DecodeRemoteRun(limited, expectedRunID, true) + if decErr != nil { + return &Result{Success: false, ErrorReason: "typed result rejected"}, nil + } + + if !decoded.OK { + reason := decoded.Reason if reason == "" { reason = "remote pi-agent reported task failure" } res := &Result{ Success: false, ErrorReason: reason, - Retryable: remoteResp.Retryable, + Retryable: decoded.Retryable, } if !res.Retryable { res.Retryable = AssessStepFailure(StepFailureSignals{Result: res, WorkDir: workDir}).Class == FailureTransient @@ -205,18 +201,14 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re return res, nil } - incoming := make(map[string][]byte, len(remoteResp.Patches)) - for _, pt := range remoteResp.Patches { - incoming[pt.RelPath] = []byte(pt.Content) - } - patches, matErr := MaterializeTargets(workDir, req.Targets, incoming) + patches, matErr := MaterializeTargets(workDir, req.Targets, decoded.Incoming) if matErr != nil { res := &Result{ Success: false, ErrorReason: matErr.Error(), Patches: patches, } - res.Retryable = remoteResp.Retryable || AssessStepFailure(StepFailureSignals{ + res.Retryable = decoded.Retryable || AssessStepFailure(StepFailureSignals{ Result: res, WorkDir: workDir, TargetMissing: true, diff --git a/internal/agent/piagent_typed_e2e_test.go b/internal/agent/piagent_typed_e2e_test.go new file mode 100644 index 00000000..31e76190 --- /dev/null +++ b/internal/agent/piagent_typed_e2e_test.go @@ -0,0 +1,201 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "chat-agent-system/internal/config" +) + +func TestPiAgentTypedResult_LiveDualTrack(t *testing.T) { + if os.Getenv("RUN_PI_E2E") != "1" { + t.Skip("set RUN_PI_E2E=1 to run live PI typed-result dual-track") + } + if testing.Short() { + t.Skip("live PI dual-track is not a -short test") + } + runnerURL := strings.TrimRight(os.Getenv("PI_AGENT_RUNNER_URL"), "/") + if runnerURL == "" { + runnerURL = "http://127.0.0.1:18865" + } + token := os.Getenv("PI_AGENT_RUNNER_AUTH_TOKEN") + if token == "" { + token = os.Getenv("RUNNER_AUTH_TOKEN") + } + if token == "" { + if cfg, err := config.Load(""); err == nil && cfg != nil { + token = cfg.PiAgentRunnerAuthToken + if runnerURL == "http://127.0.0.1:18865" && strings.TrimSpace(cfg.PiAgentRunnerURL) != "" { + // keep host-mapped 18865; do not switch to in-docker hostname + } + } + } + + t.Run("failure_empty_prompt", func(t *testing.T) { + raw := postRunner(t, runnerURL, token, map[string]any{ + "workspace": filepath.Join(t.TempDir(), "empty"), + "prompt": "", + "targets": []string{"output.md"}, + "run_id": "e2e-empty", + "typed_result": true, + "timeout_ms": 5000, + }) + assertDualTrackError(t, raw, "e2e-empty", "PROMPT_EMPTY") + }) + + t.Run("timeout", func(t *testing.T) { + ws := filepath.Join(t.TempDir(), "timeout") + raw := postRunner(t, runnerURL, token, map[string]any{ + "workspace": ws, + "prompt": "Write a long document and keep working.", + "targets": []string{"output.md"}, + "run_id": "e2e-timeout", + "typed_result": true, + "timeout_ms": 1, + }) + assertDualTrackError(t, raw, "e2e-timeout", "TIMEOUT", "TRANSIENT", "RUN_FAILED", "CANCELLED") + }) + + t.Run("cancel", func(t *testing.T) { + ws := filepath.Join(t.TempDir(), "cancel") + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + body, _ := json.Marshal(map[string]any{ + "workspace": ws, + "prompt": "Keep working until cancelled.", + "targets": []string{"output.md"}, + "run_id": "e2e-cancel", + "typed_result": true, + "timeout_ms": 60000, + }) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, runnerURL+"/v1/run", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + ApplyRunnerAuth(req, token) + _, err := http.DefaultClient.Do(req) + errCh <- err + }() + time.Sleep(300 * time.Millisecond) + cancelBody, _ := json.Marshal(map[string]any{"workspace": ws}) + creq, _ := http.NewRequest(http.MethodPost, runnerURL+"/v1/cancel", bytes.NewReader(cancelBody)) + creq.Header.Set("Content-Type", "application/json") + ApplyRunnerAuth(creq, token) + cresp, err := http.DefaultClient.Do(creq) + if err != nil { + t.Fatalf("cancel: %v", err) + } + defer cresp.Body.Close() + cancel() + select { + case <-errCh: + case <-time.After(15 * time.Second): + t.Fatal("cancel did not unblock /v1/run") + } + }) + + t.Run("success", func(t *testing.T) { + ws := filepath.Join(t.TempDir(), "success") + if err := os.MkdirAll(ws, 0o755); err != nil { + t.Fatal(err) + } + cfg := &config.Config{ + AgentBackend: "pi-agent", + PiAgentRunnerURL: runnerURL, + PiAgentRunnerAuthToken: token, + PiAgentModel: "MiniMax-M2.7-highspeed", + PiAgentProvider: "minimax", + AgentTimeout: 3 * time.Minute, + } + res, err := NewBackend("pi-agent", cfg).Execute(context.Background(), Request{ + WorkDir: ws, + Prompt: "Write exactly the markdown line `# ping` into output.md. Do not call tools except write. Stop immediately after writing.", + Targets: []string{"output.md"}, + Timeout: 3 * time.Minute, + }) + if err != nil { + t.Fatal(err) + } + if !res.Success { + t.Fatalf("live success path failed: %s", res.ErrorReason) + } + if _, err := os.Stat(filepath.Join(ws, "output.md")); err != nil { + t.Fatal("output.md missing after live success") + } + }) +} + +func postRunner(t *testing.T, runnerURL, token string, payload map[string]any) []byte { + t.Helper() + body, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, runnerURL+"/v1/run", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + ApplyRunnerAuth(req, token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, MaxResultBodyBytes)) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(bytes.ToLower(raw), []byte("stderr")) && bytes.Contains(raw, []byte(`"stderr"`)) { + t.Fatalf("public runner result must not include stderr") + } + return raw +} + +func assertDualTrackError(t *testing.T, raw []byte, runID string, codes ...string) { + t.Helper() + var wire map[string]any + if err := json.Unmarshal(raw, &wire); err != nil { + t.Fatalf("json: %v body=%s", err, truncate(raw)) + } + if wire["ok"] == true { + t.Fatalf("want ok=false, body=%s", truncate(raw)) + } + rvRaw, _ := json.Marshal(wire["result_v1"]) + var rv ResultV1 + if err := json.Unmarshal(rvRaw, &rv); err != nil { + t.Fatalf("result_v1 missing/invalid: %v body=%s", err, truncate(raw)) + } + decoded, err := DecodeRemoteRun(raw, runID, true) + if err != nil { + t.Fatalf("typed parser rejected live dual-track: %v body=%s", err, truncate(raw)) + } + if decoded.OK { + t.Fatal("decoded ok on error case") + } + if rv.Outcome != "error" || rv.RunID != runID { + t.Fatalf("result_v1 outcome/run_id mismatch: %+v", rv) + } + allow := map[string]struct{}{} + for _, c := range codes { + allow[c] = struct{}{} + } + if _, ok := allow[rv.ErrorCode]; !ok { + t.Fatalf("error_code %q not in %v", rv.ErrorCode, codes) + } + if rv.ErrorCode != decoded.Reason { + t.Fatalf("parser reason %q != result_v1 %q", decoded.Reason, rv.ErrorCode) + } +} + +func truncate(b []byte) string { + s := string(b) + if len(s) > 400 { + return s[:400] + } + return s +} diff --git a/internal/agent/remote_result.go b/internal/agent/remote_result.go new file mode 100644 index 00000000..deb520e0 --- /dev/null +++ b/internal/agent/remote_result.go @@ -0,0 +1,108 @@ +package agent + +import ( + "encoding/json" + "fmt" + "strings" +) + +type remoteRunWire struct { + OK bool `json:"ok"` + RunID string `json:"run_id"` + FallbackReason string `json:"fallback_reason"` + Retryable bool `json:"retryable"` + RunnerProtocol string `json:"runner_protocol"` + Capabilities *struct { + TypedResult bool `json:"typed_result"` + } `json:"capabilities"` + ResultV1 *ResultV1 `json:"result_v1"` + Patches []struct { + RelPath string `json:"rel_path"` + Content string `json:"content"` + } `json:"patches"` +} + +type decodedRemote struct { + OK bool + Retryable bool + Reason string + Incoming map[string][]byte + Typed *ResultV1 + Legacy bool +} + +func claimsTypedCapability(w remoteRunWire) bool { + if w.Capabilities != nil && w.Capabilities.TypedResult { + return true + } + return strings.EqualFold(strings.TrimSpace(w.RunnerProtocol), RunnerProtocolTypedV1) +} + +func isLegacyHandshake(w remoteRunWire) bool { + return strings.EqualFold(strings.TrimSpace(w.RunnerProtocol), RunnerProtocolLegacy) +} + +func DecodeRemoteRun(body []byte, expectedRunID string, typedRequested bool) (*decodedRemote, error) { + if len(body) == 0 { + return nil, fmt.Errorf("%w: empty body", ErrTypedResult) + } + var w remoteRunWire + if err := json.Unmarshal(body, &w); err != nil { + return nil, fmt.Errorf("failed to decode remote response") + } + incoming := make(map[string][]byte, len(w.Patches)) + for _, pt := range w.Patches { + if len(pt.Content) > MaxResultMemberBytes { + return nil, fmt.Errorf("%w: member size", ErrTypedResult) + } + incoming[pt.RelPath] = []byte(pt.Content) + } + + out := &decodedRemote{OK: w.OK, Incoming: incoming} + + if w.ResultV1 != nil { + if err := w.ResultV1.Validate(expectedRunID, incoming); err != nil { + return nil, err + } + out.Typed = w.ResultV1 + out.Retryable = TypedRetryable(*w.ResultV1) + if w.ResultV1.Outcome == "error" { + out.OK = false + out.Reason = w.ResultV1.ErrorCode + } + return out, nil + } + + // typed-present is handled above. Missing typed payload: + // handshake downgrade — claiming typed capability without result_v1 — fail closed. + if typedRequested && (claimsTypedCapability(w) || !isLegacyHandshake(w)) { + return nil, fmt.Errorf("%w: missing result_v1", ErrTypedResult) + } + + out.Legacy = true + out.Retryable = w.Retryable + out.Reason = safeLegacyReason(w.FallbackReason) + return out, nil +} + +func safeLegacyReason(reason string) string { + r := strings.TrimSpace(reason) + switch { + case r == "": + return "remote pi-agent reported task failure" + case strings.EqualFold(r, "prompt_empty"): + return "PROMPT_EMPTY" + case strings.Contains(strings.ToLower(r), "unauthorized"): + return "UNAUTHORIZED" + case strings.Contains(strings.ToLower(r), "timeout"): + return "TIMEOUT" + default: + if strings.ContainsAny(r, "/\\") || strings.Contains(strings.ToLower(r), "token") { + return "RUN_FAILED" + } + if len(r) > 64 { + return "RUN_FAILED" + } + return r + } +} diff --git a/internal/agent/remote_result_test.go b/internal/agent/remote_result_test.go new file mode 100644 index 00000000..74faebce --- /dev/null +++ b/internal/agent/remote_result_test.go @@ -0,0 +1,135 @@ +package agent + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestDecodeRemoteRun_TypedSuccess(t *testing.T) { + body := []byte("hello") + rv := BuildResultV1("ws1", "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", body}}) + raw, _ := json.Marshal(map[string]any{ + "ok": true, "run_id": "ws1", "runner_protocol": "typed-v1", + "capabilities": map[string]any{"typed_result": true}, + "patches": []map[string]string{{"rel_path": "output.md", "content": "hello"}}, + "result_v1": rv, + }) + got, err := DecodeRemoteRun(raw, "ws1", true) + if err != nil || !got.OK || got.Legacy { + t.Fatalf("typed success: err=%v ok=%v legacy=%v", err, got != nil && got.OK, got != nil && got.Legacy) + } + if string(got.Incoming["output.md"]) != "hello" { + t.Fatal("patch body") + } +} + +func TestDecodeRemoteRun_TypedPresentInvalidNoFallback(t *testing.T) { + rv := BuildResultV1("ws1", "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte("hello")}}) + raw, _ := json.Marshal(map[string]any{ + "ok": true, "run_id": "ws1", "runner_protocol": "typed-v1", + "patches": []map[string]string{{"rel_path": "output.md", "content": "TAMPER"}}, + "result_v1": rv, + }) + if _, err := DecodeRemoteRun(raw, "ws1", true); err == nil { + t.Fatal("tampered typed body must fail closed") + } +} + +func TestDecodeRemoteRun_RunMismatch(t *testing.T) { + rv := BuildResultV1("other-run", "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte("hello")}}) + raw, _ := json.Marshal(map[string]any{ + "ok": true, "run_id": "other-run", + "patches": []map[string]string{{"rel_path": "output.md", "content": "hello"}}, + "result_v1": rv, + }) + if _, err := DecodeRemoteRun(raw, "ws1", true); err == nil { + t.Fatal("foreign run_id must fail closed") + } +} + +func TestDecodeRemoteRun_HandshakeDowngrade(t *testing.T) { + raw, _ := json.Marshal(map[string]any{ + "ok": true, "run_id": "ws1", + "capabilities": map[string]any{"typed_result": true}, + "patches": []map[string]string{{"rel_path": "output.md", "content": "hello"}}, + }) + if _, err := DecodeRemoteRun(raw, "ws1", true); err == nil { + t.Fatal("typed capability without result_v1 is a downgrade attack") + } +} + +func TestDecodeRemoteRun_MissingTypedWithoutLegacyHandshake(t *testing.T) { + raw, _ := json.Marshal(map[string]any{ + "ok": true, "patches": []map[string]string{{"rel_path": "output.md", "content": "hello"}}, + }) + if _, err := DecodeRemoteRun(raw, "ws1", true); err == nil { + t.Fatal("implicit legacy must fail after P4") + } +} + +func TestDecodeRemoteRun_LegacyHandshakeStillWorks(t *testing.T) { + raw, _ := json.Marshal(map[string]any{ + "ok": true, "runner_protocol": "legacy", + "fallback_reason": "Connection error.", + "retryable": true, + "patches": []map[string]string{{"rel_path": "output.md", "content": "hello"}}, + }) + got, err := DecodeRemoteRun(raw, "ws1", true) + if err != nil || !got.OK || !got.Legacy { + t.Fatalf("legacy handshake: err=%v got=%+v", err, got) + } +} + +func TestDecodeRemoteRun_LegacyDoesNotLeakPaths(t *testing.T) { + raw, _ := json.Marshal(map[string]any{ + "ok": false, "runner_protocol": "legacy", + "fallback_reason": "failed /tmp/secret/token=abc", + "patches": []any{}, + }) + got, err := DecodeRemoteRun(raw, "ws1", true) + if err != nil { + t.Fatal(err) + } + if got.OK { + t.Fatal("want failure") + } + if strings.Contains(got.Reason, "/tmp") || strings.Contains(got.Reason, "token") { + t.Fatalf("leaked reason %q", got.Reason) + } +} + +func TestDecodeRemoteRun_PathEscapeMember(t *testing.T) { + rv := ResultV1{ + SchemaVersion: 1, RunID: "ws1", Outcome: "success", + Members: []ResultV1Member{{Kind: "file", Path: "../etc/passwd", Digest: DigestBytes([]byte("x")), Size: 1}}, + } + rv.ContentDigest = rv.MembersDigest() + raw, _ := json.Marshal(map[string]any{ + "ok": true, "result_v1": rv, + "patches": []map[string]string{{"rel_path": "../etc/passwd", "content": "x"}}, + }) + if _, err := DecodeRemoteRun(raw, "ws1", true); err == nil { + t.Fatal("escaped member path must fail closed") + } +} + +func TestDecodeRemoteRun_OversizedMember(t *testing.T) { + big := strings.Repeat("a", MaxResultMemberBytes+1) + raw, _ := json.Marshal(map[string]any{ + "ok": true, "runner_protocol": "legacy", + "patches": []map[string]string{{"rel_path": "output.md", "content": big}}, + }) + if _, err := DecodeRemoteRun(raw, "ws1", true); err == nil { + t.Fatal("oversized member must fail closed") + } +} diff --git a/internal/agent/resultv1.go b/internal/agent/resultv1.go new file mode 100644 index 00000000..7b554e9c --- /dev/null +++ b/internal/agent/resultv1.go @@ -0,0 +1,178 @@ +package agent + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "path/filepath" + "strings" +) + +const ( + ResultV1SchemaVersion = 1 + MaxResultMembers = 32 + MaxResultMemberBytes = 2 << 20 + MaxResultBodyBytes = 8 << 20 + RunnerProtocolLegacy = "legacy" + RunnerProtocolTypedV1 = "typed-v1" +) + +var ErrTypedResult = fmt.Errorf("agent: typed result_v1 rejected") + +var allowedErrorCodes = map[string]struct{}{ + "PROMPT_EMPTY": {}, + "UNAUTHORIZED": {}, + "TIMEOUT": {}, + "CANCELLED": {}, + "TRANSIENT": {}, + "MISSING_TARGET": {}, + "RUN_FAILED": {}, +} + +var retryableErrorCodes = map[string]struct{}{ + "TIMEOUT": {}, + "CANCELLED": {}, + "TRANSIENT": {}, +} + +type ResultV1Member struct { + Kind string `json:"kind"` + Path string `json:"path"` + Digest string `json:"digest"` + Size int `json:"size"` +} + +type ResultV1 struct { + SchemaVersion int `json:"schema_version"` + RunID string `json:"run_id"` + Outcome string `json:"outcome"` + Retryable bool `json:"retryable"` + ErrorCode string `json:"error_code,omitempty"` + Members []ResultV1Member `json:"members"` + ContentDigest string `json:"content_digest"` +} + +func SafeRelPath(p string) bool { + p = strings.TrimSpace(p) + if p == "" || filepath.IsAbs(p) { + return false + } + clean := filepath.ToSlash(filepath.Clean(p)) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(p, "..") { + return false + } + return true +} + +func DigestBytes(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func (r ResultV1) MembersDigest() string { + type row struct { + Kind string `json:"kind"` + Path string `json:"path"` + Digest string `json:"digest"` + Size int `json:"size"` + } + members := r.Members + if members == nil { + members = []ResultV1Member{} + } + rows := make([]row, 0, len(members)) + for _, m := range members { + rows = append(rows, row{m.Kind, m.Path, m.Digest, m.Size}) + } + raw, _ := json.Marshal(rows) + return DigestBytes(raw) +} + +func (r ResultV1) Validate(expectedRunID string, patches map[string][]byte) error { + if r.SchemaVersion != ResultV1SchemaVersion { + return fmt.Errorf("%w: schema", ErrTypedResult) + } + if strings.TrimSpace(r.RunID) == "" { + return fmt.Errorf("%w: run_id", ErrTypedResult) + } + if expectedRunID != "" && r.RunID != expectedRunID { + return fmt.Errorf("%w: run mismatch", ErrTypedResult) + } + if r.Outcome != "success" && r.Outcome != "error" { + return fmt.Errorf("%w: outcome", ErrTypedResult) + } + if r.Outcome == "success" && strings.TrimSpace(r.ErrorCode) != "" { + return fmt.Errorf("%w: error_code", ErrTypedResult) + } + if r.Outcome == "error" { + if _, ok := allowedErrorCodes[r.ErrorCode]; !ok { + return fmt.Errorf("%w: error_code", ErrTypedResult) + } + } + if len(r.Members) > MaxResultMembers { + return fmt.Errorf("%w: members", ErrTypedResult) + } + total := 0 + seen := map[string]struct{}{} + for _, m := range r.Members { + if m.Kind != "file" || !SafeRelPath(m.Path) { + return fmt.Errorf("%w: member path", ErrTypedResult) + } + if _, dup := seen[m.Path]; dup { + return fmt.Errorf("%w: duplicate member", ErrTypedResult) + } + seen[m.Path] = struct{}{} + if m.Size < 0 || m.Size > MaxResultMemberBytes { + return fmt.Errorf("%w: member size", ErrTypedResult) + } + total += m.Size + body, ok := patches[m.Path] + if !ok { + return fmt.Errorf("%w: member missing body", ErrTypedResult) + } + if len(body) != m.Size || DigestBytes(body) != m.Digest { + return fmt.Errorf("%w: digest", ErrTypedResult) + } + } + if total > MaxResultBodyBytes { + return fmt.Errorf("%w: body", ErrTypedResult) + } + if r.ContentDigest != r.MembersDigest() { + return fmt.Errorf("%w: content_digest", ErrTypedResult) + } + return nil +} + +func TypedRetryable(r ResultV1) bool { + if r.Outcome != "error" || !r.Retryable { + return false + } + _, ok := retryableErrorCodes[r.ErrorCode] + return ok +} + +func BuildResultV1(runID, outcome string, retryable bool, errorCode string, patches []struct { + RelPath string + Content []byte +}) ResultV1 { + members := make([]ResultV1Member, 0, len(patches)) + for _, p := range patches { + if !SafeRelPath(p.RelPath) { + continue + } + members = append(members, ResultV1Member{ + Kind: "file", Path: p.RelPath, Digest: DigestBytes(p.Content), Size: len(p.Content), + }) + } + r := ResultV1{ + SchemaVersion: ResultV1SchemaVersion, + RunID: runID, + Outcome: outcome, + Retryable: retryable, + ErrorCode: errorCode, + Members: members, + } + r.ContentDigest = r.MembersDigest() + return r +} diff --git a/internal/agent/resultv1_test.go b/internal/agent/resultv1_test.go new file mode 100644 index 00000000..1c8c31a7 --- /dev/null +++ b/internal/agent/resultv1_test.go @@ -0,0 +1,126 @@ +package agent + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestSafeRelPathRejectsEscape(t *testing.T) { + if SafeRelPath("/etc/passwd") || SafeRelPath("../x") || SafeRelPath("a/../../b") || SafeRelPath("./../x") { + t.Fatal("escape") + } + if !SafeRelPath("output.md") || !SafeRelPath("tasks/01_draft/output.md") { + t.Fatal("relative deliverable") + } +} + +func TestResultV1FailClosedOnBadDigest(t *testing.T) { + body := []byte("hello") + r := BuildResultV1("run-1", "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", body}}) + patches := map[string][]byte{"output.md": []byte("tampered")} + if err := r.Validate("run-1", patches); err == nil { + t.Fatal("tamper must fail closed") + } + patches["output.md"] = body + if err := r.Validate("run-1", patches); err != nil { + t.Fatal(err) + } + if err := r.Validate("other", patches); err == nil { + t.Fatal("run mismatch") + } +} + +func TestResultV1RejectsUnknownMajor(t *testing.T) { + r := ResultV1{SchemaVersion: 99, RunID: "r", Outcome: "success", ContentDigest: "x"} + if err := r.Validate("r", nil); err == nil { + t.Fatal("unknown major") + } +} + +func TestResultV1RejectsUnknownErrorCode(t *testing.T) { + r := BuildResultV1("run-1", "error", true, "DROP TABLE users", nil) + if err := r.Validate("run-1", map[string][]byte{}); err == nil { + t.Fatal("arbitrary error_code must fail closed") + } +} + +func TestResultV1RejectsMissingMemberBody(t *testing.T) { + r := BuildResultV1("run-1", "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte("hello")}}) + if err := r.Validate("run-1", map[string][]byte{}); err == nil { + t.Fatal("member without patch body must fail closed") + } +} + +func TestTypedRetryableAllowlist(t *testing.T) { + ok := BuildResultV1("r", "error", true, "TRANSIENT", nil) + if !TypedRetryable(ok) { + t.Fatal("TRANSIENT retryable") + } + denied := BuildResultV1("r", "error", true, "PROMPT_EMPTY", nil) + if TypedRetryable(denied) { + t.Fatal("PROMPT_EMPTY must not retry") + } +} + +func TestMembersDigestMatchesNodeCanonicalJSON(t *testing.T) { + r := BuildResultV1("run-js", "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte("hello")}}) + script := `import { membersDigest } from './deploy/pi-agent/result-v1.mjs'; +const members = ` + mustJSON(t, r.Members) + `; +process.stdout.write(membersDigest(members)); +` + _, thisFile, _, _ := runtime.Caller(0) + root := filepath.Join(filepath.Dir(thisFile), "..", "..") + cmd := exec.Command("node", "--input-type=module", "-e", script) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("node digest: %v %s", err, out) + } + if got := strings.TrimSpace(string(out)); got != r.ContentDigest { + t.Fatalf("js digest %s != go %s", got, r.ContentDigest) + } +} + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestGoldenResultV1Fixture(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "pi-run-result-v1.golden.json")) + if err != nil { + t.Fatal(err) + } + var rv ResultV1 + if err := json.Unmarshal(raw, &rv); err != nil { + t.Fatal(err) + } + if err := rv.Validate("golden-run-1", map[string][]byte{"output.md": []byte("hello")}); err != nil { + t.Fatal(err) + } + built := BuildResultV1("golden-run-1", "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte("hello")}}) + if built.ContentDigest != rv.ContentDigest { + t.Fatalf("golden digest drift go=%s file=%s", built.ContentDigest, rv.ContentDigest) + } +} diff --git a/internal/agent/testdata/pi-run-result-v1.golden.json b/internal/agent/testdata/pi-run-result-v1.golden.json new file mode 100644 index 00000000..1d7ec08a --- /dev/null +++ b/internal/agent/testdata/pi-run-result-v1.golden.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "run_id": "golden-run-1", + "outcome": "success", + "retryable": false, + "members": [ + { + "kind": "file", + "path": "output.md", + "digest": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "size": 5 + } + ], + "content_digest": "ee8a9ea18dc27e1abaaa1541db75c045fb5969400e6d38b8305176931dcff22a" +}