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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user