mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* fix: warn about untracked scratch files left behind after a run Fixes #551. Headless exec runs could leave behind debug/scratch files (e.g. _fix_test.py, _debug.py) created by write_file during iteration, which would then be silently swept up by a later git add -A. - FileTracker now records brand-new files (RecordCreated/CreatedFiles), distinct from the existing version-baseline tracking used for conflict detection. - write_file records a path as created only on first write, not on overwrite of an existing file. - After a headless run completes, exec.go checks git status for any created file still present and untracked, and surfaces a warning via the existing writer.warning() (works across text/-o json/stream-json). No files are deleted automatically — a wrong guess at 'scratch' could destroy real work, so this only surfaces what git already knows. Co-authored-by: Copilot App <[email protected]> * fix: address scratch warning review comments * test: normalize created file tracker paths * fix: track apply_patch-created scratch files * fix: address scratch warning review feedback * fix: tighten scratch warning lifecycle --------- Co-authored-by: Copilot App <[email protected]>
747 lines
23 KiB
Go
747 lines
23 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
|
|
toolset := CoreTools(t.TempDir())
|
|
byName := make(map[string]Tool, len(toolset))
|
|
for _, tool := range toolset {
|
|
byName[tool.Name()] = tool
|
|
}
|
|
|
|
for _, name := range []string{"write_file", "edit_file", "apply_patch"} {
|
|
tool, ok := byName[name]
|
|
if !ok {
|
|
t.Fatalf("expected core tools to include %s", name)
|
|
}
|
|
if tool.Safety().SideEffect != SideEffectWrite {
|
|
t.Fatalf("%s side effect = %s, want write", name, tool.Safety().SideEffect)
|
|
}
|
|
if tool.Safety().Permission != PermissionPrompt {
|
|
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
|
|
}
|
|
}
|
|
|
|
planTool, ok := byName["update_plan"]
|
|
if !ok {
|
|
t.Fatalf("expected core tools to include update_plan")
|
|
}
|
|
if planTool.Safety().Permission != PermissionAllow {
|
|
t.Fatalf("update_plan permission = %s, want allow", planTool.Safety().Permission)
|
|
}
|
|
}
|
|
|
|
func TestRegistryBlocksPromptToolsWithoutGrant(t *testing.T) {
|
|
root := t.TempDir()
|
|
target := filepath.Join(root, "blocked.txt")
|
|
registry := NewRegistry()
|
|
registry.Register(NewWriteFileTool(root))
|
|
|
|
result := registry.Run(context.Background(), "write_file", map[string]any{
|
|
"path": "blocked.txt",
|
|
"content": "nope",
|
|
})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s", result.Status)
|
|
}
|
|
if !strings.Contains(result.Output, "Permission required for write_file") {
|
|
t.Fatalf("expected permission error, got %q", result.Output)
|
|
}
|
|
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
|
t.Fatalf("expected file to remain absent, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestRegistryRunsPromptToolsWithGrant(t *testing.T) {
|
|
root := t.TempDir()
|
|
registry := NewRegistry()
|
|
registry.Register(NewWriteFileTool(root))
|
|
|
|
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
|
"path": "allowed.txt",
|
|
"content": "hello",
|
|
}, RunOptions{PermissionGranted: true})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(root, "allowed.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(content) != "hello" {
|
|
t.Fatalf("expected written content, got %q", string(content))
|
|
}
|
|
}
|
|
|
|
func TestWriteFileToolCreatesAndProtectsExistingFiles(t *testing.T) {
|
|
root := t.TempDir()
|
|
tool := NewWriteFileTool(root)
|
|
|
|
created := tool.Run(context.Background(), map[string]any{
|
|
"path": "nested/file.txt",
|
|
"content": "first",
|
|
})
|
|
if created.Status != StatusOK {
|
|
t.Fatalf("expected create ok, got %s: %s", created.Status, created.Output)
|
|
}
|
|
if !strings.Contains(created.Output, "Created nested/file.txt") {
|
|
t.Fatalf("unexpected create output: %q", created.Output)
|
|
}
|
|
|
|
refused := tool.Run(context.Background(), map[string]any{
|
|
"path": "nested/file.txt",
|
|
"content": "second",
|
|
})
|
|
if refused.Status != StatusError {
|
|
t.Fatalf("expected overwrite refusal, got %s", refused.Status)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(root, "nested", "file.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(content) != "first" {
|
|
t.Fatalf("expected original content, got %q", string(content))
|
|
}
|
|
|
|
overwrote := tool.Run(context.Background(), map[string]any{
|
|
"path": "nested/file.txt",
|
|
"content": "second",
|
|
"overwrite": true,
|
|
})
|
|
if overwrote.Status != StatusOK {
|
|
t.Fatalf("expected overwrite ok, got %s: %s", overwrote.Status, overwrote.Output)
|
|
}
|
|
content, err = os.ReadFile(filepath.Join(root, "nested", "file.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(content) != "second" {
|
|
t.Fatalf("expected overwritten content, got %q", string(content))
|
|
}
|
|
}
|
|
|
|
func TestWriteFileToolRecordsCreatedFileButNotOverwrite(t *testing.T) {
|
|
root := t.TempDir()
|
|
registry := NewRegistry()
|
|
registry.Register(NewWriteFileTool(root))
|
|
tracker := NewFileTracker()
|
|
|
|
created := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
|
"path": "scratch.txt",
|
|
"content": "first",
|
|
}, RunOptions{PermissionGranted: true, FileTracker: tracker})
|
|
if created.Status != StatusOK {
|
|
t.Fatalf("expected create ok, got %s: %s", created.Status, created.Output)
|
|
}
|
|
absPath := filepath.Join(root, "scratch.txt")
|
|
absPath, err := filepath.EvalSymlinks(absPath)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks(%q): %v", absPath, err)
|
|
}
|
|
if got := tracker.CreatedFiles(); len(got) != 1 || got[0] != absPath {
|
|
t.Fatalf("CreatedFiles() = %v, want [%s]", got, absPath)
|
|
}
|
|
|
|
overwrote := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
|
"path": "scratch.txt",
|
|
"content": "second",
|
|
"overwrite": true,
|
|
}, RunOptions{PermissionGranted: true, FileTracker: tracker})
|
|
if overwrote.Status != StatusOK {
|
|
t.Fatalf("expected overwrite ok, got %s: %s", overwrote.Status, overwrote.Output)
|
|
}
|
|
// Overwriting an existing file is not a "new" creation, so CreatedFiles
|
|
// must not gain a second (duplicate) entry for it.
|
|
if got := tracker.CreatedFiles(); len(got) != 1 || got[0] != absPath {
|
|
t.Fatalf("CreatedFiles() after overwrite = %v, want unchanged [%s]", got, absPath)
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchRecordsCreatedFileButNotExistingEdits(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "existing.txt"), []byte("old\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
registry := NewRegistry()
|
|
registry.Register(NewApplyPatchTool(root))
|
|
tracker := NewFileTracker()
|
|
patch := strings.Join([]string{
|
|
"diff --git a/scratch.txt b/scratch.txt",
|
|
"new file mode 100644",
|
|
"index 0000000..e965047",
|
|
"--- /dev/null",
|
|
"+++ b/scratch.txt",
|
|
"@@ -0,0 +1 @@",
|
|
"+hello",
|
|
"diff --git a/existing.txt b/existing.txt",
|
|
"--- a/existing.txt",
|
|
"+++ b/existing.txt",
|
|
"@@ -1 +1 @@",
|
|
"-old",
|
|
"+new",
|
|
"",
|
|
}, "\n")
|
|
|
|
result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{
|
|
"patch": patch,
|
|
}, RunOptions{PermissionGranted: true, FileTracker: tracker})
|
|
if result.Status != StatusOK {
|
|
if gitApplyUnavailable(result.Output) {
|
|
t.Skipf("git binary unavailable: %s", result.Output)
|
|
}
|
|
t.Fatalf("expected apply_patch ok, got %s: %s", result.Status, result.Output)
|
|
}
|
|
absPath := filepath.Join(root, "scratch.txt")
|
|
absPath, err := filepath.EvalSymlinks(absPath)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks(%q): %v", absPath, err)
|
|
}
|
|
if got := tracker.CreatedFiles(); len(got) != 1 || got[0] != absPath {
|
|
t.Fatalf("CreatedFiles() = %v, want [%s]", got, absPath)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileSummaryReportsLineCount(t *testing.T) {
|
|
root := t.TempDir()
|
|
tool := NewWriteFileTool(root)
|
|
// Three lines, no trailing newline -> "3 lines" (not a byte count).
|
|
result := tool.Run(context.Background(), map[string]any{
|
|
"path": "multi.txt",
|
|
"content": "one\ntwo\nthree",
|
|
})
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "(3 lines)") {
|
|
t.Fatalf("summary should report a line count, got %q", result.Output)
|
|
}
|
|
if strings.Contains(result.Output, "bytes") {
|
|
t.Errorf("summary should no longer report bytes: %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileToolAllowsEmptyContent(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
|
|
"path": "empty.txt",
|
|
"content": "",
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(root, "empty.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(content) != "" {
|
|
t.Fatalf("expected empty file, got %q", string(content))
|
|
}
|
|
}
|
|
|
|
func TestWriteFileToolReportsTypeErrorsForEmptyAllowedStrings(t *testing.T) {
|
|
result := NewWriteFileTool(t.TempDir()).Run(context.Background(), map[string]any{
|
|
"path": "bad.txt",
|
|
"content": 42,
|
|
})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s", result.Status)
|
|
}
|
|
if !strings.Contains(result.Output, "content must be a string") {
|
|
t.Fatalf("expected string type error, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileToolRejectsOutsideWorkspace(t *testing.T) {
|
|
root := t.TempDir()
|
|
outside := filepath.Join(t.TempDir(), "outside.txt")
|
|
|
|
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
|
|
"path": outside,
|
|
"content": "secret",
|
|
})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s", result.Status)
|
|
}
|
|
if !strings.Contains(result.Output, "must stay inside the workspace") {
|
|
t.Fatalf("expected workspace error, got %q", result.Output)
|
|
}
|
|
if _, err := os.Stat(outside); !os.IsNotExist(err) {
|
|
t.Fatalf("expected outside file to remain absent, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileToolRejectsSymlinkParent(t *testing.T) {
|
|
root := t.TempDir()
|
|
realDirectory := filepath.Join(root, "real")
|
|
if err := os.MkdirAll(realDirectory, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(realDirectory, filepath.Join(root, "link")); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
|
|
result := NewWriteFileTool(root).Run(context.Background(), map[string]any{
|
|
"path": "link/escape.txt",
|
|
"content": "secret",
|
|
})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s", result.Status)
|
|
}
|
|
if !strings.Contains(result.Output, "must not traverse symlink") {
|
|
t.Fatalf("expected symlink error, got %q", result.Output)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(realDirectory, "escape.txt")); !os.IsNotExist(err) {
|
|
t.Fatalf("expected symlink target file to remain absent, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestEditFileToolReplacesExactStrings(t *testing.T) {
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "code.go")
|
|
writeTestFile(t, path, "const a = 1\nconst b = 2\n")
|
|
|
|
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
|
|
"path": "code.go",
|
|
"old_string": "const a = 1",
|
|
"new_string": "const a = 42",
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
|
|
}
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(content) != "const a = 42\nconst b = 2\n" {
|
|
t.Fatalf("unexpected edited content: %q", string(content))
|
|
}
|
|
}
|
|
|
|
func TestEditFileToolReplacesCRLF(t *testing.T) {
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "code.go")
|
|
writeTestFile(t, path, "const a = 1\r\nconst b = 2\r\n")
|
|
|
|
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
|
|
"path": "code.go",
|
|
"old_string": "const a = 1\nconst b = 2",
|
|
"new_string": "const a = 42\nconst b = 24",
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
|
|
}
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(content) != "const a = 42\r\nconst b = 24\r\n" {
|
|
t.Fatalf("unexpected edited content: %q", string(content))
|
|
}
|
|
}
|
|
|
|
func TestEditFileToolEmitsUnifiedDiff(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeTestFile(t, filepath.Join(root, "code.go"), "const a = 1\nconst b = 2\n")
|
|
res := NewEditFileTool(root).Run(context.Background(), map[string]any{
|
|
"path": "code.go", "old_string": "const a = 1", "new_string": "const a = 42",
|
|
})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("edit failed: %s", res.Output)
|
|
}
|
|
// The model-facing Output stays the one-line summary; the red/green diff lives
|
|
// on the card-only Display.Preview, so it costs the model zero tokens.
|
|
if !strings.HasPrefix(res.Output, "Successfully edited") {
|
|
t.Fatalf("summary must be the Output: %q", res.Output)
|
|
}
|
|
if strings.Contains(res.Output, "@@") {
|
|
t.Fatalf("Output must NOT carry the diff (card-only preview): %q", res.Output)
|
|
}
|
|
for _, want := range []string{"@@", "-const a = 1", "+const a = 42"} {
|
|
if !strings.Contains(res.Display.Preview, want) {
|
|
t.Fatalf("edit preview missing diff marker %q: %q", want, res.Display.Preview)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWriteFileToolEmitsAdditionsDiff(t *testing.T) {
|
|
root := t.TempDir()
|
|
res := NewWriteFileTool(root).Run(context.Background(), map[string]any{
|
|
"path": "new.txt", "content": "line one\nline two\n",
|
|
})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("write failed: %s", res.Output)
|
|
}
|
|
if strings.Contains(res.Output, "@@") {
|
|
t.Fatalf("Output must stay summary-only (the diff is card-only): %q", res.Output)
|
|
}
|
|
for _, want := range []string{"@@", "+line one", "+line two"} {
|
|
if !strings.Contains(res.Display.Preview, want) {
|
|
t.Fatalf("new-file preview missing additions diff %q: %q", want, res.Display.Preview)
|
|
}
|
|
}
|
|
if strings.Contains(res.Display.Preview, "\n-line") {
|
|
t.Fatalf("a fresh-create diff must have no removed lines: %q", res.Display.Preview)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileToolOverwriteEmitsRedGreenDiff(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeTestFile(t, filepath.Join(root, "f.txt"), "old line\nkeep\n")
|
|
res := NewWriteFileTool(root).Run(context.Background(), map[string]any{
|
|
"path": "f.txt", "content": "new line\nkeep\n", "overwrite": true,
|
|
})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("overwrite failed: %s", res.Output)
|
|
}
|
|
if strings.Contains(res.Output, "@@") {
|
|
t.Fatalf("Output must stay summary-only (the diff is card-only): %q", res.Output)
|
|
}
|
|
for _, want := range []string{"-old line", "+new line"} {
|
|
if !strings.Contains(res.Display.Preview, want) {
|
|
t.Fatalf("overwrite preview missing %q: %q", want, res.Display.Preview)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEditFileToolAllowsDeletingRegions(t *testing.T) {
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "notes.txt")
|
|
writeTestFile(t, path, "keep\nremove\nkeep\n")
|
|
|
|
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
|
|
"path": "notes.txt",
|
|
"old_string": "remove\n",
|
|
"new_string": "",
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
|
|
}
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(content) != "keep\nkeep\n" {
|
|
t.Fatalf("unexpected edited content: %q", string(content))
|
|
}
|
|
}
|
|
|
|
func TestEditFileToolRejectsMissingAndAmbiguousMatches(t *testing.T) {
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "dup.txt")
|
|
writeTestFile(t, path, "x\nx\n")
|
|
tool := NewEditFileTool(root)
|
|
|
|
missing := tool.Run(context.Background(), map[string]any{
|
|
"path": "dup.txt",
|
|
"old_string": "missing",
|
|
"new_string": "y",
|
|
})
|
|
if missing.Status != StatusError || !strings.Contains(missing.Output, "Could not find") {
|
|
t.Fatalf("expected missing error, got %s: %s", missing.Status, missing.Output)
|
|
}
|
|
|
|
ambiguous := tool.Run(context.Background(), map[string]any{
|
|
"path": "dup.txt",
|
|
"old_string": "x",
|
|
"new_string": "y",
|
|
})
|
|
if ambiguous.Status != StatusError || !strings.Contains(ambiguous.Output, "matches 2 locations") {
|
|
t.Fatalf("expected ambiguity error, got %s: %s", ambiguous.Status, ambiguous.Output)
|
|
}
|
|
|
|
all := tool.Run(context.Background(), map[string]any{
|
|
"path": "dup.txt",
|
|
"old_string": "x",
|
|
"new_string": "y",
|
|
"replace_all": true,
|
|
})
|
|
if all.Status != StatusOK {
|
|
t.Fatalf("expected replace_all ok, got %s: %s", all.Status, all.Output)
|
|
}
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(content) != "y\ny\n" {
|
|
t.Fatalf("expected all replacements, got %q", string(content))
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
|
|
patch := strings.Join([]string{
|
|
"diff --git a/hello.txt b/hello.txt",
|
|
"--- a/hello.txt",
|
|
"+++ b/hello.txt",
|
|
"@@ -1,2 +1,2 @@",
|
|
" hello",
|
|
"-old",
|
|
"+new",
|
|
"",
|
|
}, "\n")
|
|
|
|
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
|
|
"patch": patch,
|
|
})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected patch ok, got %s: %s", result.Status, result.Output)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.ReplaceAll(string(content), "\r\n", "\n") != "hello\nnew\n" {
|
|
t.Fatalf("unexpected patched content: %q", string(content))
|
|
}
|
|
}
|
|
|
|
// A hunk-body line that removes content beginning with "-- " appears in the diff
|
|
// as "--- ..."; it must NOT be mistaken for a file header (which previously made
|
|
// apply_patch reject a valid patch as targeting an outside path).
|
|
func TestApplyPatchToolHandlesHunkBodyLookingLikeHeader(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeTestFile(t, filepath.Join(root, "notes.md"), "keep\n-- /etc/old\n")
|
|
patch := strings.Join([]string{
|
|
"diff --git a/notes.md b/notes.md",
|
|
"--- a/notes.md",
|
|
"+++ b/notes.md",
|
|
"@@ -1,2 +1,1 @@",
|
|
" keep",
|
|
"--- /etc/old",
|
|
"",
|
|
}, "\n")
|
|
|
|
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{"patch": patch})
|
|
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected patch ok (hunk body must not be parsed as a header), got %s: %s", result.Status, result.Output)
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(root, "notes.md"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.ReplaceAll(string(content), "\r\n", "\n") != "keep\n" {
|
|
t.Fatalf("unexpected patched content: %q", string(content))
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolRejectsHunkCountInflationHidingEscapePath(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeTestFile(t, filepath.Join(root, "safe.txt"), "old\n")
|
|
// A crafted section heading after the closing "@@" injects a "+9,999999"
|
|
// token. If parseHunkCounts scanned the whole line it would treat 999999 as
|
|
// the new-line count, stay stuck in hunk mode, and swallow the second file
|
|
// header below — hiding the ../escape.txt write from path validation.
|
|
patch := strings.Join([]string{
|
|
"diff --git a/safe.txt b/safe.txt",
|
|
"--- a/safe.txt",
|
|
"+++ b/safe.txt",
|
|
"@@ -1,1 +1,1 @@ +9,999999",
|
|
"-old",
|
|
"+new",
|
|
"--- a/../escape.txt",
|
|
"+++ b/../escape.txt",
|
|
"@@ -1,1 +1,1 @@",
|
|
"-secret",
|
|
"+pwned",
|
|
"",
|
|
}, "\n")
|
|
|
|
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{"patch": patch})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("crafted hunk header must not hide the out-of-workspace path, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "must stay inside the workspace") {
|
|
t.Fatalf("expected workspace-confinement rejection, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolRejectsSymlinkPath(t *testing.T) {
|
|
root := t.TempDir()
|
|
realDirectory := filepath.Join(root, "real")
|
|
if err := os.MkdirAll(realDirectory, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(realDirectory, filepath.Join(root, "link")); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
patch := strings.Join([]string{
|
|
"diff --git a/link/new.txt b/link/new.txt",
|
|
"new file mode 100644",
|
|
"index 0000000..e965047",
|
|
"--- /dev/null",
|
|
"+++ b/link/new.txt",
|
|
"@@ -0,0 +1 @@",
|
|
"+hello",
|
|
"",
|
|
}, "\n")
|
|
|
|
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
|
|
"patch": patch,
|
|
})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s", result.Status)
|
|
}
|
|
if !strings.Contains(result.Output, "must not traverse symlink") {
|
|
t.Fatalf("expected symlink error, got %q", result.Output)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(realDirectory, "new.txt")); !os.IsNotExist(err) {
|
|
t.Fatalf("expected symlink target file to remain absent, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchToolRejectsOutsideWorkspace(t *testing.T) {
|
|
root := t.TempDir()
|
|
outside := t.TempDir()
|
|
|
|
result := NewApplyPatchTool(root).Run(context.Background(), map[string]any{
|
|
"cwd": outside,
|
|
"patch": strings.Join([]string{
|
|
"diff --git a/nope.txt b/nope.txt",
|
|
"--- a/nope.txt",
|
|
"+++ b/nope.txt",
|
|
"@@ -0,0 +1 @@",
|
|
"+nope",
|
|
"",
|
|
}, "\n"),
|
|
})
|
|
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status, got %s", result.Status)
|
|
}
|
|
if !strings.Contains(result.Output, "must stay inside the workspace") {
|
|
t.Fatalf("expected workspace error, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
// Finding 3: apply_patch with cwd != "." must report WORKSPACE-relative
|
|
// ChangedFiles (cwd-prefixed), not cwd-relative paths. Otherwise the session's
|
|
// rewind/diff layer keys off the wrong path.
|
|
func TestApplyPatchReportsWorkspaceRelativeChangedFilesUnderCwd(t *testing.T) {
|
|
root := t.TempDir()
|
|
subdir := filepath.Join(root, "sub", "dir")
|
|
if err := os.MkdirAll(subdir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(subdir, "a.txt"), []byte("one\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
patch := "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-one\n+two\n"
|
|
|
|
res := NewApplyPatchTool(root).Run(context.Background(), map[string]any{"patch": patch, "cwd": "sub/dir"})
|
|
if res.Status != StatusOK {
|
|
if gitApplyUnavailable(res.Output) {
|
|
t.Skipf("git binary unavailable: %s", res.Output)
|
|
}
|
|
t.Fatalf("apply_patch with cwd failed (possible regression): %s", res.Output)
|
|
}
|
|
if len(res.ChangedFiles) != 1 || res.ChangedFiles[0] != "sub/dir/a.txt" {
|
|
t.Fatalf("ChangedFiles = %v, want [sub/dir/a.txt]", res.ChangedFiles)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileReportsChangedFileAndDisplay(t *testing.T) {
|
|
root := t.TempDir()
|
|
res := NewWriteFileTool(root).Run(context.Background(), map[string]any{"path": "notes.txt", "content": "hello"})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status=%s output=%s", res.Status, res.Output)
|
|
}
|
|
if len(res.ChangedFiles) != 1 || res.ChangedFiles[0] != "notes.txt" {
|
|
t.Fatalf("ChangedFiles = %v, want [notes.txt]", res.ChangedFiles)
|
|
}
|
|
if res.Display.Kind != "file" {
|
|
t.Errorf("Display.Kind = %q, want file", res.Display.Kind)
|
|
}
|
|
if res.Display.Summary == "" {
|
|
t.Error("expected a non-empty Display.Summary")
|
|
}
|
|
}
|
|
|
|
func TestEditFileReportsChangedFileAndDisplay(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "f.txt"), []byte("alpha beta"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res := NewEditFileTool(root).Run(context.Background(), map[string]any{"path": "f.txt", "old_string": "alpha", "new_string": "gamma"})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status=%s output=%s", res.Status, res.Output)
|
|
}
|
|
if len(res.ChangedFiles) != 1 || res.ChangedFiles[0] != "f.txt" {
|
|
t.Fatalf("ChangedFiles = %v, want [f.txt]", res.ChangedFiles)
|
|
}
|
|
if res.Display.Kind != "diff" {
|
|
t.Errorf("Display.Kind = %q, want diff", res.Display.Kind)
|
|
}
|
|
}
|
|
|
|
func TestApplyPatchReportsChangedFiles(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("one\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
patch := "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-one\n+two\n"
|
|
res := NewApplyPatchTool(root).Run(context.Background(), map[string]any{"patch": patch})
|
|
if res.Status != StatusOK {
|
|
if gitApplyUnavailable(res.Output) {
|
|
t.Skipf("git binary unavailable: %s", res.Output)
|
|
}
|
|
t.Fatalf("apply_patch failed (possible regression): %s", res.Output)
|
|
}
|
|
found := false
|
|
for _, f := range res.ChangedFiles {
|
|
if f == "a.txt" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected a.txt in ChangedFiles, got %v", res.ChangedFiles)
|
|
}
|
|
if res.Display.Kind != "diff" {
|
|
t.Errorf("Display.Kind = %q, want diff", res.Display.Kind)
|
|
}
|
|
}
|
|
|
|
func TestWriteFileAcceptsContentAlias(t *testing.T) {
|
|
root := t.TempDir()
|
|
// minimax-style: content under an alias key instead of "content".
|
|
res := NewWriteFileTool(root).Run(context.Background(), map[string]any{
|
|
"path": "shop.html",
|
|
"contents": "<html>hi</html>",
|
|
})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("alias content should write, got %s: %s", res.Status, res.Output)
|
|
}
|
|
got, _ := os.ReadFile(filepath.Join(root, "shop.html"))
|
|
if string(got) != "<html>hi</html>" {
|
|
t.Fatalf("file content = %q", got)
|
|
}
|
|
}
|
|
|
|
// gitApplyUnavailable reports whether an apply_patch failure is due to the git
|
|
// binary being absent (an environment condition worth skipping) rather than a
|
|
// real regression (which must fail the test). apply_patch shells out to
|
|
// `git apply`; a missing binary surfaces as exec's "executable file not found".
|
|
func gitApplyUnavailable(output string) bool {
|
|
return strings.Contains(output, "executable file not found")
|
|
}
|