fix(agent): map runner patch paths and require real targets

Normalize absolute, ./, and duplicated step prefixes onto output.md, and fail closed when the runner reports ok without a non-empty deliverable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 19:09:48 +00:00
co-authored by Cursor
parent e04829ee8e
commit 4a97ff5dd1
5 changed files with 297 additions and 28 deletions
+85
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -101,3 +102,87 @@ func TestPiAgentRemoteRunner(t *testing.T) {
t.Errorf("unexpected local file content: %s", string(localContent))
}
}
func TestPiAgentRemoteRunner_PathAliasesAndMissing(t *testing.T) {
run := func(mode, want string, expectOK bool) {
t.Helper()
dir := filepath.Join(t.TempDir(), "tasks", "01_draft")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/run" {
http.NotFound(w, r)
return
}
var patches []map[string]string
switch mode {
case "abs":
patches = []map[string]string{{
"rel_path": filepath.Join(dir, "output.md"),
"content": "# from absolute rel_path\n",
}}
case "nested":
patches = []map[string]string{{
"rel_path": "tasks/01_draft/output.md",
"content": "# from nested rel_path\n",
}}
case "dot":
patches = []map[string]string{{
"rel_path": "./output.md",
"content": "# from dot rel_path\n",
}}
default:
patches = []map[string]string{{
"rel_path": "execution.log",
"content": "Outcome: SUCCESS\n",
}}
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "patches": patches})
}))
defer ts.Close()
cfg := &config.Config{
AgentBackend: "pi-agent",
PiAgentRunnerURL: ts.URL,
PiAgentModel: "MiniMax-M3",
PiAgentProvider: "minimax",
AgentTimeout: 5 * time.Second,
}
res, err := agent.NewBackend("pi-agent", cfg).Execute(context.Background(), agent.Request{
WorkDir: dir,
Prompt: "draft",
Targets: []string{"output.md"},
})
if err != nil {
t.Fatalf("mode %s unexpected err: %v", mode, err)
}
if expectOK {
if !res.Success {
t.Fatalf("mode %s want success, got %s", mode, res.ErrorReason)
}
got, rerr := os.ReadFile(filepath.Join(dir, "output.md"))
if rerr != nil {
t.Fatalf("mode %s missing file: %v", mode, rerr)
}
if string(got) != want {
t.Fatalf("mode %s content %q", mode, got)
}
return
}
if res.Success {
t.Fatalf("mode %s must fail closed", mode)
}
if !strings.Contains(res.ErrorReason, "missing target deliverable output.md") {
t.Fatalf("mode %s reason %q", mode, res.ErrorReason)
}
if _, serr := os.Stat(filepath.Join(dir, "output.md")); serr == nil {
t.Fatalf("mode %s must not invent output.md", mode)
}
}
run("abs", "# from absolute rel_path\n", true)
run("nested", "# from nested rel_path\n", true)
run("dot", "# from dot rel_path\n", true)
run("missing", "", false)
}
+104
View File
@@ -0,0 +1,104 @@
package agent
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
)
func slashClean(p string) string {
return filepath.ToSlash(filepath.Clean(p))
}
// MatchDeliverableRel reports whether a runner rel_path refers to target
// inside workDir. Accepts output.md, ./output.md, absolute paths under workDir,
// and a duplicated step prefix (tasks/01_draft/output.md when workDir already
// ends with tasks/01_draft). Paths that escape workDir never match.
func MatchDeliverableRel(workDir, relPath, target string) bool {
target = slashClean(strings.TrimSpace(target))
if target == "" || target == "." || strings.HasPrefix(target, "../") || strings.Contains(target, "..") {
return false
}
raw := strings.TrimSpace(relPath)
if raw == "" {
return false
}
abs := raw
if !filepath.IsAbs(raw) {
abs = filepath.Join(workDir, raw)
}
abs = filepath.Clean(abs)
rel, err := filepath.Rel(filepath.Clean(workDir), abs)
if err != nil {
return false
}
rel = slashClean(rel)
if rel == ".." || strings.HasPrefix(rel, "../") {
return false
}
if rel == target {
return true
}
if filepath.Base(rel) != filepath.Base(target) {
return false
}
prefix := slashClean(filepath.Dir(rel))
wd := slashClean(workDir)
return prefix != "." && (strings.HasSuffix(wd, "/"+prefix) || strings.HasSuffix(wd, prefix))
}
// CollectTargetPatches maps incoming runner keys onto canonical target names
// and also accepts a non-empty file already at workDir/target. Empty bodies
// do not count — callers must not invent a deliverable.
func CollectTargetPatches(workDir string, targets []string, incoming map[string][]byte) map[string][]byte {
out := make(map[string][]byte, len(targets))
for _, t := range targets {
t = strings.TrimSpace(t)
if t == "" {
continue
}
for k, v := range incoming {
if len(bytes.TrimSpace(v)) == 0 {
continue
}
if MatchDeliverableRel(workDir, k, t) {
out[t] = v
break
}
}
if _, ok := out[t]; ok {
continue
}
p := filepath.Join(workDir, t)
if b, err := os.ReadFile(p); err == nil && len(bytes.TrimSpace(b)) > 0 {
out[t] = b
}
}
return out
}
// MaterializeTargets writes collected non-empty target bodies to workDir/<target>.
// Returns a missing-target error without writing a stub if any required file is absent.
func MaterializeTargets(workDir string, targets []string, incoming map[string][]byte) (map[string][]byte, error) {
collected := CollectTargetPatches(workDir, targets, incoming)
for _, t := range targets {
t = strings.TrimSpace(t)
if t == "" {
continue
}
body, ok := collected[t]
if !ok || len(bytes.TrimSpace(body)) == 0 {
return collected, fmt.Errorf("missing target deliverable %s", t)
}
dest := filepath.Join(workDir, filepath.FromSlash(t))
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return collected, err
}
if err := os.WriteFile(dest, body, 0o644); err != nil {
return collected, err
}
}
return collected, nil
}
+83
View File
@@ -0,0 +1,83 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestMatchDeliverableRel_Aliases(t *testing.T) {
workDir := filepath.Join(string(filepath.Separator), "app", "data", "workspaces", "bizdev", "prelim_x", "tasks", "01_draft")
abs := filepath.Join(workDir, "output.md")
cases := []struct {
rel string
want bool
}{
{"output.md", true},
{"./output.md", true},
{abs, true},
{"tasks/01_draft/output.md", true},
{"execution.log", false},
{"../output.md", false},
{"/etc/passwd", false},
{"tasks/02_review/output.md", false},
}
for _, tc := range cases {
if got := MatchDeliverableRel(workDir, tc.rel, "output.md"); got != tc.want {
t.Errorf("MatchDeliverableRel(%q) = %v, want %v", tc.rel, got, tc.want)
}
}
}
func TestMaterializeTargets_MapsAbsoluteAndNested(t *testing.T) {
workDir := filepath.Join(t.TempDir(), "tasks", "01_draft")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatal(err)
}
body := []byte("# 系统初步设计方案\n\n真实 runner 产物\n")
incoming := map[string][]byte{
filepath.Join(workDir, "output.md"): body,
}
got, err := MaterializeTargets(workDir, []string{"output.md"}, incoming)
if err != nil {
t.Fatalf("absolute rel_path must materialize: %v", err)
}
if string(got["output.md"]) != string(body) {
t.Fatalf("canonical patch missing")
}
onDisk, err := os.ReadFile(filepath.Join(workDir, "output.md"))
if err != nil {
t.Fatal(err)
}
if string(onDisk) != string(body) {
t.Fatalf("disk output.md mismatch")
}
nestedDir := filepath.Join(t.TempDir(), "ws", "tasks", "01_draft")
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
t.Fatal(err)
}
if _, err := MaterializeTargets(nestedDir, []string{"output.md"}, map[string][]byte{
"tasks/01_draft/output.md": []byte("# nested alias\n"),
}); err != nil {
t.Fatalf("duplicated step prefix must map: %v", err)
}
}
func TestMaterializeTargets_MissingAndEmptyFail(t *testing.T) {
workDir := t.TempDir()
if _, err := MaterializeTargets(workDir, []string{"output.md"}, map[string][]byte{
"execution.log": []byte("Outcome: SUCCESS\n"),
}); err == nil || !strings.Contains(err.Error(), "missing target deliverable output.md") {
t.Fatalf("must fail closed without inventing output.md, got %v", err)
}
if _, err := os.Stat(filepath.Join(workDir, "output.md")); err == nil {
t.Fatal("must not write a stub output.md")
}
if _, err := MaterializeTargets(workDir, []string{"output.md"}, map[string][]byte{
"output.md": []byte(" \n"),
}); err == nil {
t.Fatal("whitespace-only body must not count as a deliverable")
}
}
+17 -19
View File
@@ -106,12 +106,14 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err
}, nil
}
patches := make(map[string][]byte)
for _, t := range req.Targets {
p := filepath.Join(workDir, t)
if b, err := os.ReadFile(p); err == nil {
patches[t] = b
}
patches, err := MaterializeTargets(workDir, req.Targets, nil)
if err != nil {
return &Result{
Success: false,
ErrorReason: err.Error(),
Patches: patches,
Logs: stdout.String(),
}, nil
}
return &Result{
@@ -182,21 +184,17 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re
}, nil
}
patches := make(map[string][]byte)
incoming := make(map[string][]byte, len(remoteResp.Patches))
for _, pt := range remoteResp.Patches {
patches[pt.RelPath] = []byte(pt.Content)
localPath := filepath.Join(workDir, pt.RelPath)
_ = os.MkdirAll(filepath.Dir(localPath), 0755)
_ = os.WriteFile(localPath, []byte(pt.Content), 0644)
incoming[pt.RelPath] = []byte(pt.Content)
}
for _, t := range req.Targets {
if _, ok := patches[t]; !ok {
targetPath := filepath.Join(workDir, t)
if b, err := os.ReadFile(targetPath); err == nil {
patches[t] = b
}
}
patches, matErr := MaterializeTargets(workDir, req.Targets, incoming)
if matErr != nil {
return &Result{
Success: false,
ErrorReason: matErr.Error(),
Patches: patches,
}, nil
}
return &Result{
+8 -9
View File
@@ -583,16 +583,15 @@ func (d *WorkflowDriver) runOneStep(
return fmt.Errorf("step %s failed: %s", step.ID, errMsg)
}
// 2. 确认 deliverable 文件落盘
targetPath := filepath.Join(subDir, deliverableName)
if _, err := os.Stat(targetPath); err != nil {
// 若后端补丁在内存中返回,写入磁盘
if res != nil && len(res.Patches[deliverableName]) > 0 {
_ = os.WriteFile(targetPath, res.Patches[deliverableName], 0o644)
} else {
return fmt.Errorf("step %s missing target deliverable %s", step.ID, deliverableName)
}
// 2. 确认 deliverable 文件落盘(路径别名归一化;缺产物不得编造)
incoming := map[string][]byte{}
if res != nil {
incoming = res.Patches
}
if _, matErr := agent.MaterializeTargets(subDir, []string{deliverableName}, incoming); matErr != nil {
return fmt.Errorf("step %s %w", step.ID, matErr)
}
targetPath := filepath.Join(subDir, deliverableName)
// 3. 检查是否有 fields.json 并合并入 blackboard.json
fieldsPath := filepath.Join(subDir, "fields.json")