package builtin // workdir_override_test.go — SubAgent 通过 pkg/tools.WithWorkdir(ctx, path) // 注入的 cwd 覆盖必须真穿透到 BashTool / BashToolBackground / FileEditTool / // GitignoreTool 的执行面, 否则 worktree 隔离模式挂牌不开工 (sa.Cwd 设了但 // 工具仍在父 engine cwd 执行). // // 每个子测试锁一条 sub-claim, 用不同构造 cwd + ctx 覆盖 cwd, 断言工具实际 // 消费的是覆盖值. 构造 cwd 故意给真实路径 (t.TempDir()) 但和 ctx 覆盖不同, // 工具若忽略 ctx 就会落回构造 cwd, test 立刻失败. // // workdir_override_test.go — SubAgent-injected cwd override via // pkg/tools.WithWorkdir(ctx, path) must really reach BashTool / // BashToolBackground / FileEditTool / GitignoreTool execution paths, // otherwise worktree isolation is a label-only fiction (sa.Cwd is set but // tools keep running in the parent engine's cwd). // // Each sub-test locks one sub-claim by using distinct construction cwd vs // ctx-override cwd; if a tool ignores ctx it falls back to the construction // cwd and the test fails immediately. import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // TestBashTool_WorkdirOverride_UsesCtx — sub-claim: BashTool 前台执行在 // ctx 覆盖 cwd 下跑, 不回退构造 cwd. 用 pwd 输出路径验证. func TestBashTool_WorkdirOverride_UsesCtx(t *testing.T) { parentCwd := t.TempDir() overrideCwd := t.TempDir() bash := NewBashTool(parentCwd, execenv.DefaultExecutor{}) ctx := tools.WithWorkdir(context.Background(), overrideCwd) input := json.RawMessage(`{"command":"pwd"}`) result, err := bash.Execute(ctx, input, nil) if err != nil { t.Fatalf("Execute: %v", err) } if result.IsError { t.Fatalf("bash error: %s", result.Output) } // macOS /tmp 是 /private/tmp 的 symlink; t.TempDir() 在 CI 上可能返回 // 不同形态. 比对时走 EvalSymlinks 归一化, 任一端解析失败退回原字符串 // 走 HasSuffix 兜底, 语义不变. // // macOS /tmp symlinks to /private/tmp; t.TempDir() may return either // form depending on CI. Normalize via EvalSymlinks before comparison; // fall back to raw-suffix match if resolution fails either side. got := strings.TrimSpace(result.Output) if !pathsEquivalent(got, overrideCwd) { t.Errorf("pwd 输出 %q, 期望 %q (ctx 覆盖未生效, 落回父 cwd %q)", got, overrideCwd, parentCwd) } if pathsEquivalent(got, parentCwd) && !pathsEquivalent(parentCwd, overrideCwd) { t.Errorf("pwd 落回父 cwd %q — ctx 覆盖被忽略", parentCwd) } } // TestBashTool_WorkdirOverride_EmptyCtxFallsBack — 反向 sub-claim: ctx 没 // 注入时 BashTool 回退构造 cwd (现有行为不回归). func TestBashTool_WorkdirOverride_EmptyCtxFallsBack(t *testing.T) { parentCwd := t.TempDir() bash := NewBashTool(parentCwd, execenv.DefaultExecutor{}) input := json.RawMessage(`{"command":"pwd"}`) result, err := bash.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("Execute: %v", err) } got := strings.TrimSpace(result.Output) if !pathsEquivalent(got, parentCwd) { t.Errorf("pwd 输出 %q, 期望 %q (空 ctx 回退失败)", got, parentCwd) } } // TestFileEditTool_WorkdirOverride_SymlinkGuard — sub-claim: FileEditTool // symlink guard 消费 ctx 覆盖的 cwd, 不回退构造 cwd. 构造一个符号链接目标 // 在 override cwd 之外, guard 应拒绝; 目标在 override 之内, 应放行. func TestFileEditTool_WorkdirOverride_SymlinkGuard(t *testing.T) { parentCwd := t.TempDir() // 构造 cwd 范围广, 包含下面所有路径 overrideCwd := filepath.Join(parentCwd, "worktree") if err := os.MkdirAll(overrideCwd, 0o755); err != nil { t.Fatal(err) } // 真实目标放在 parent 下但 worktree 外 — 落回构造 cwd 时会误放行 realTarget := filepath.Join(parentCwd, "outside-worktree.txt") if err := os.WriteFile(realTarget, []byte("original"), 0o644); err != nil { t.Fatal(err) } // symlink 本身放在 override cwd 内, 指向 override 外的 realTarget symlinkPath := filepath.Join(overrideCwd, "link.txt") if err := os.Symlink(realTarget, symlinkPath); err != nil { t.Fatal(err) } tool := NewFileEditToolWithCwd(parentCwd) // 构造 cwd = parent, 落回会放行 ctx := tools.WithWorkdir(context.Background(), overrideCwd) input, _ := json.Marshal(map[string]any{ "file_path": symlinkPath, "old_string": "original", "new_string": "changed", }) result, err := tool.Execute(ctx, input, nil) if err != nil { t.Fatalf("Execute: %v", err) } if !result.IsError { t.Errorf("symlink guard 应拒绝 (target 在 override cwd %q 之外), 但放行了; output=%s", overrideCwd, result.Output) } if !strings.Contains(result.Output, "symlink target") { t.Errorf("错误消息未提及 symlink guard: %s", result.Output) } // 文件未被修改, 佐证 guard 真生效 content, _ := os.ReadFile(realTarget) if string(content) != "original" { t.Errorf("文件被修改了, guard 失效: %q", content) } } // TestGitignoreTool_WorkdirOverride_Dir — sub-claim: GitignoreTool 在 p.Dir // 缺省时用 ctx 覆盖, ctx 缺省时用构造 cwd. 三级 fallback 顺序正确. func TestGitignoreTool_WorkdirOverride_Dir(t *testing.T) { parentCwd := t.TempDir() overrideCwd := t.TempDir() tool := NewGitignoreTool(parentCwd) // action=add 不传 dir, 有 ctx 覆盖 → 应写到 overrideCwd/.gitignore ctx := tools.WithWorkdir(context.Background(), overrideCwd) input := json.RawMessage(`{"action":"add","patterns":["*.log"]}`) result, err := tool.Execute(ctx, input, nil) if err != nil { t.Fatalf("Execute: %v", err) } if result.IsError { t.Fatalf("add error: %s", result.Output) } if _, err := os.Stat(filepath.Join(overrideCwd, ".gitignore")); err != nil { t.Errorf("overrideCwd/.gitignore 未创建: %v", err) } if _, err := os.Stat(filepath.Join(parentCwd, ".gitignore")); err == nil { t.Errorf("parentCwd/.gitignore 被误创建 — ctx 覆盖未生效") } } // pathsEquivalent 归一化比较两个路径, 宽容 symlink 解析差异 (macOS // /tmp vs /private/tmp) 和结尾斜杠. pathsEquivalent normalizes two paths // for comparison, tolerating symlink-resolution differences (macOS /tmp // vs /private/tmp) and trailing slashes. func pathsEquivalent(a, b string) bool { ra, err1 := filepath.EvalSymlinks(a) rb, err2 := filepath.EvalSymlinks(b) if err1 == nil && err2 == nil { return ra == rb } return strings.TrimRight(a, "/") == strings.TrimRight(b, "/") }