From 98cfceed7e10001ca747c99d3d28605f386f6bbe Mon Sep 17 00:00:00 2001 From: piyush0049 Date: Wed, 22 Jul 2026 23:52:38 +0530 Subject: [PATCH 1/6] fix(filesystem): support path-scoped glob patterns in post-edit hooks --- pkg/tools/builtin/filesystem/postedit.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/tools/builtin/filesystem/postedit.go b/pkg/tools/builtin/filesystem/postedit.go index 52935ec9fe..23323684ef 100644 --- a/pkg/tools/builtin/filesystem/postedit.go +++ b/pkg/tools/builtin/filesystem/postedit.go @@ -8,6 +8,7 @@ import ( "log/slog" "os/exec" "path/filepath" + "strings" "github.com/docker/docker-agent/pkg/shellpath" ) @@ -15,7 +16,12 @@ import ( // runPostEditCommands executes configured shell commands after a file edit. func runPostEditCommands(ctx context.Context, postEditCommands []PostEditConfig, filePath string) error { for _, postEdit := range postEditCommands { - matched, err := filepath.Match(postEdit.Path, filepath.Base(filePath)) + pattern := filepath.ToSlash(postEdit.Path) + target := filepath.Base(filePath) + if strings.Contains(pattern, "/") { + target = filepath.ToSlash(filePath) + } + matched, err := filepath.Match(pattern, target) if err != nil { slog.WarnContext(ctx, "Invalid post-edit pattern", "pattern", postEdit.Path, "error", err) continue From 3346ddb2c7bc0869eb20dcf4045543f480617781 Mon Sep 17 00:00:00 2001 From: piyush0049 Date: Wed, 22 Jul 2026 23:52:44 +0530 Subject: [PATCH 2/6] fix(tools): fix POSIX path resolution and test assertions on Windows --- pkg/session/session.go | 2 +- pkg/session/sqlitestore/sqlitestore_test.go | 5 +++++ pkg/tools/builtin/sessionplan/sessionplan_test.go | 2 +- pkg/tools/workingdir/workingdir.go | 7 ++----- pkg/tools/workingdir/workingdir_test.go | 2 +- pkg/tui/internal/editorname/editorname_test.go | 9 ++++++++- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/pkg/session/session.go b/pkg/session/session.go index a758dbe5b8..7a1a37f658 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -1202,7 +1202,7 @@ func (s *Session) AddAttachedFile(absPath string) { if absPath == "" { return } - if !filepath.IsAbs(absPath) { + if !filepath.IsAbs(absPath) && !strings.HasPrefix(absPath, "/") { slog.Debug("ignoring non-absolute attached file path", "session_id", s.ID, "path", absPath) return } diff --git a/pkg/session/sqlitestore/sqlitestore_test.go b/pkg/session/sqlitestore/sqlitestore_test.go index d5938d1d26..6f7146adba 100644 --- a/pkg/session/sqlitestore/sqlitestore_test.go +++ b/pkg/session/sqlitestore/sqlitestore_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "os" "path/filepath" + "runtime" "testing" "time" @@ -18,6 +19,10 @@ import ( func TestNew_DirectoryNotWritable(t *testing.T) { t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX permission mode bits (0o555) do not prevent directory creation/writes on Windows NTFS") + } + readOnlyDir := filepath.Join(t.TempDir(), "readonly") err := os.Mkdir(readOnlyDir, 0o555) require.NoError(t, err) diff --git a/pkg/tools/builtin/sessionplan/sessionplan_test.go b/pkg/tools/builtin/sessionplan/sessionplan_test.go index dd1f701611..8de61898e9 100644 --- a/pkg/tools/builtin/sessionplan/sessionplan_test.go +++ b/pkg/tools/builtin/sessionplan/sessionplan_test.go @@ -16,7 +16,7 @@ func TestPath(t *testing.T) { t.Run("accepts UUID-shaped IDs", func(t *testing.T) { path, err := Path("/plans", "7c2d8f0a-1234-4abc-9def-1234567890ab") require.NoError(t, err) - assert.Equal(t, "/plans/7c2d8f0a-1234-4abc-9def-1234567890ab.md", path) + assert.Equal(t, filepath.Join("/plans", "7c2d8f0a-1234-4abc-9def-1234567890ab.md"), path) }) // Path-traversal defence: the regex is the only thing standing between an diff --git a/pkg/tools/workingdir/workingdir.go b/pkg/tools/workingdir/workingdir.go index c39e8a0779..d00d33f39f 100644 --- a/pkg/tools/workingdir/workingdir.go +++ b/pkg/tools/workingdir/workingdir.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/docker/docker-agent/pkg/path" ) @@ -14,14 +15,10 @@ func Resolve(toolsetWorkingDir, agentWorkingDir string) string { return agentWorkingDir } toolsetWorkingDir = path.ExpandPath(toolsetWorkingDir) - if filepath.IsAbs(toolsetWorkingDir) { + if filepath.IsAbs(toolsetWorkingDir) || strings.HasPrefix(toolsetWorkingDir, "/") { return toolsetWorkingDir } if agentWorkingDir != "" { - abs, err := filepath.Abs(filepath.Join(agentWorkingDir, toolsetWorkingDir)) - if err == nil { - return abs - } return filepath.Join(agentWorkingDir, toolsetWorkingDir) } return toolsetWorkingDir diff --git a/pkg/tools/workingdir/workingdir_test.go b/pkg/tools/workingdir/workingdir_test.go index 52f7514ac8..184a89ae08 100644 --- a/pkg/tools/workingdir/workingdir_test.go +++ b/pkg/tools/workingdir/workingdir_test.go @@ -19,7 +19,7 @@ func TestResolve(t *testing.T) { }{ {name: "empty uses agent working dir", agentWorkingDir: "/workspace", want: "/workspace"}, {name: "absolute wins", toolsetWorkingDir: "/tmp/app", agentWorkingDir: "/workspace", want: "/tmp/app"}, - {name: "relative joins agent dir", toolsetWorkingDir: "tools/mcp", agentWorkingDir: "/workspace", want: "/workspace/tools/mcp"}, + {name: "relative joins agent dir", toolsetWorkingDir: "tools/mcp", agentWorkingDir: "/workspace", want: filepath.Join("/workspace", "tools/mcp")}, {name: "relative without agent dir remains relative", toolsetWorkingDir: "tools/mcp", want: "tools/mcp"}, {name: "tilde expands", toolsetWorkingDir: "~/projects/app", agentWorkingDir: "/workspace", want: filepath.Join(home, "projects", "app")}, } diff --git a/pkg/tui/internal/editorname/editorname_test.go b/pkg/tui/internal/editorname/editorname_test.go index cb09ff5198..8fc9815b98 100644 --- a/pkg/tui/internal/editorname/editorname_test.go +++ b/pkg/tui/internal/editorname/editorname_test.go @@ -7,6 +7,13 @@ import ( "github.com/stretchr/testify/assert" ) +func defaultEditor() string { + if goruntime.GOOS == "windows" { + return "Notepad" + } + return "Vi" +} + func TestFromEnv(t *testing.T) { t.Parallel() @@ -68,7 +75,7 @@ func TestFromEnv(t *testing.T) { name: "Empty (uses platform default)", visual: "", editorEnv: "", - want: "Vi", // On non-Windows platforms, falls back to vi + want: defaultEditor(), }, { name: "VSCode Insiders", From 8b7ed61317d610bca11a209dd843a9d101eee2d9 Mon Sep 17 00:00:00 2001 From: piyush0049 Date: Thu, 23 Jul 2026 11:53:54 +0530 Subject: [PATCH 3/6] fix(windows): resolve cross-platform path handling and linting errors --- pkg/selfupdate/exec_windows.go | 6 +++--- pkg/tools/builtin/filesystem/filesystem.go | 2 +- pkg/tools/builtin/filesystem/filesystem_paths_test.go | 6 ++++-- pkg/tools/builtin/filesystem/filesystem_test.go | 5 +++-- pkg/tools/builtin/filesystem/postedit.go | 10 ++++++++-- pkg/tools/builtin/filesystem/postedit_js.go | 2 +- pkg/tools/builtin/sessionplan/sessionplan_test.go | 2 +- pkg/tools/workingdir/workingdir.go | 4 ++++ pkg/tools/workingdir/workingdir_test.go | 10 +++++++++- 9 files changed, 34 insertions(+), 13 deletions(-) diff --git a/pkg/selfupdate/exec_windows.go b/pkg/selfupdate/exec_windows.go index 6517efcc91..27c599be10 100644 --- a/pkg/selfupdate/exec_windows.go +++ b/pkg/selfupdate/exec_windows.go @@ -27,9 +27,9 @@ func swapBinary(dst, src string) error { if cpErr := atomicWriteFromFile(dst, src); cpErr != nil { // Roll back so we never leave the install without a binary. if rbErr := os.Rename(old, dst); rbErr != nil { - return fmt.Errorf("installing new binary: %w (copy fallback failed: %v; rollback also failed: %v)", err, cpErr, rbErr) + return fmt.Errorf("installing new binary: %w (copy fallback failed: %v; rollback also failed: %v)", err, cpErr, rbErr) //nolint:errorlint // can only wrap one error } - return fmt.Errorf("installing new binary: %w (copy fallback failed: %v)", err, cpErr) + return fmt.Errorf("installing new binary: %w (copy fallback failed: %v)", err, cpErr) //nolint:errorlint // can only wrap one error } _ = os.Remove(src) } @@ -48,7 +48,7 @@ func reExecProcess(path string, args, env []string) error { childArgs = args[1:] } - cmd := exec.Command(path, childArgs...) //nolint:gosec // path is our own freshly installed binary + cmd := exec.Command(path, childArgs...) //nolint:noctx // context not applicable here cmd.Env = env cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout diff --git a/pkg/tools/builtin/filesystem/filesystem.go b/pkg/tools/builtin/filesystem/filesystem.go index e61a47553a..603c1345b0 100644 --- a/pkg/tools/builtin/filesystem/filesystem.go +++ b/pkg/tools/builtin/filesystem/filesystem.go @@ -584,7 +584,7 @@ func (t *ToolSet) executePostEditCommands(ctx context.Context, filePath string) if len(t.postEditCommands) == 0 { return nil } - return runPostEditCommands(ctx, t.postEditCommands, filePath) + return runPostEditCommands(ctx, t.workingDir, t.postEditCommands, filePath) } // resolvePath resolves a path relative to the working directory. diff --git a/pkg/tools/builtin/filesystem/filesystem_paths_test.go b/pkg/tools/builtin/filesystem/filesystem_paths_test.go index c7d5dbe431..a88a2488f4 100644 --- a/pkg/tools/builtin/filesystem/filesystem_paths_test.go +++ b/pkg/tools/builtin/filesystem/filesystem_paths_test.go @@ -48,7 +48,8 @@ func TestFilesystemTool_AllowList_DotMeansWorkingDir(t *testing.T) { require.NoError(t, err) // Outside working dir is rejected. - _, err = tool.resolveAndCheckPath("/etc/hosts") + outsideDir := t.TempDir() + _, err = tool.resolveAndCheckPath(filepath.Join(outsideDir, "hosts")) require.Error(t, err) assert.Contains(t, err.Error(), "outside the allowed directories") @@ -109,7 +110,8 @@ func TestFilesystemTool_AllowList_MultipleRoots(t *testing.T) { _, err = tool.resolveAndCheckPath(filepath.Join(otherDir, "file.txt")) require.NoError(t, err) - _, err = tool.resolveAndCheckPath("/etc/hosts") + outsideDir := t.TempDir() + _, err = tool.resolveAndCheckPath(filepath.Join(outsideDir, "hosts")) require.Error(t, err) } diff --git a/pkg/tools/builtin/filesystem/filesystem_test.go b/pkg/tools/builtin/filesystem/filesystem_test.go index 9add4a556b..d2a3908f6b 100644 --- a/pkg/tools/builtin/filesystem/filesystem_test.go +++ b/pkg/tools/builtin/filesystem/filesystem_test.go @@ -64,8 +64,9 @@ func TestFilesystemTool_ResolvePath(t *testing.T) { assert.Equal(t, tmpDir, resolvedPath) // Test absolute paths are allowed - resolvedPath = tool.resolvePath("/etc/hosts") - assert.Equal(t, "/etc/hosts", resolvedPath) + absPath := filepath.Join(t.TempDir(), "hosts") + resolvedPath = tool.resolvePath(absPath) + assert.Equal(t, absPath, resolvedPath) } // TestFilesystemTool_ResolvePath_ExpandsTilde is a regression test for diff --git a/pkg/tools/builtin/filesystem/postedit.go b/pkg/tools/builtin/filesystem/postedit.go index 23323684ef..392f951f10 100644 --- a/pkg/tools/builtin/filesystem/postedit.go +++ b/pkg/tools/builtin/filesystem/postedit.go @@ -14,12 +14,18 @@ import ( ) // runPostEditCommands executes configured shell commands after a file edit. -func runPostEditCommands(ctx context.Context, postEditCommands []PostEditConfig, filePath string) error { +func runPostEditCommands(ctx context.Context, workingDir string, postEditCommands []PostEditConfig, filePath string) error { for _, postEdit := range postEditCommands { pattern := filepath.ToSlash(postEdit.Path) target := filepath.Base(filePath) if strings.Contains(pattern, "/") { - target = filepath.ToSlash(filePath) + // Make filePath relative to workingDir so "pkg/*.go" can match + rel, err := filepath.Rel(workingDir, filePath) + if err == nil { + target = filepath.ToSlash(rel) + } else { + target = filepath.ToSlash(filePath) + } } matched, err := filepath.Match(pattern, target) if err != nil { diff --git a/pkg/tools/builtin/filesystem/postedit_js.go b/pkg/tools/builtin/filesystem/postedit_js.go index 057aa52522..ece867fc6b 100644 --- a/pkg/tools/builtin/filesystem/postedit_js.go +++ b/pkg/tools/builtin/filesystem/postedit_js.go @@ -5,6 +5,6 @@ package filesystem import "context" // runPostEditCommands is a no-op under js/wasm (no os/exec available). -func runPostEditCommands(_ context.Context, _ []PostEditConfig, _ string) error { +func runPostEditCommands(_ context.Context, _ string, _ []PostEditConfig, _ string) error { return nil } diff --git a/pkg/tools/builtin/sessionplan/sessionplan_test.go b/pkg/tools/builtin/sessionplan/sessionplan_test.go index 8de61898e9..7ec28e70b2 100644 --- a/pkg/tools/builtin/sessionplan/sessionplan_test.go +++ b/pkg/tools/builtin/sessionplan/sessionplan_test.go @@ -16,7 +16,7 @@ func TestPath(t *testing.T) { t.Run("accepts UUID-shaped IDs", func(t *testing.T) { path, err := Path("/plans", "7c2d8f0a-1234-4abc-9def-1234567890ab") require.NoError(t, err) - assert.Equal(t, filepath.Join("/plans", "7c2d8f0a-1234-4abc-9def-1234567890ab.md"), path) + assert.Equal(t, filepath.Join(string(filepath.Separator), "plans", "7c2d8f0a-1234-4abc-9def-1234567890ab.md"), path) }) // Path-traversal defence: the regex is the only thing standing between an diff --git a/pkg/tools/workingdir/workingdir.go b/pkg/tools/workingdir/workingdir.go index d00d33f39f..e7cec2a5e1 100644 --- a/pkg/tools/workingdir/workingdir.go +++ b/pkg/tools/workingdir/workingdir.go @@ -19,6 +19,10 @@ func Resolve(toolsetWorkingDir, agentWorkingDir string) string { return toolsetWorkingDir } if agentWorkingDir != "" { + abs, err := filepath.Abs(filepath.Join(agentWorkingDir, toolsetWorkingDir)) + if err == nil { + return abs + } return filepath.Join(agentWorkingDir, toolsetWorkingDir) } return toolsetWorkingDir diff --git a/pkg/tools/workingdir/workingdir_test.go b/pkg/tools/workingdir/workingdir_test.go index 184a89ae08..b65613bc6b 100644 --- a/pkg/tools/workingdir/workingdir_test.go +++ b/pkg/tools/workingdir/workingdir_test.go @@ -7,6 +7,14 @@ import ( "github.com/stretchr/testify/assert" ) +func mustAbs(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + panic(err) + } + return abs +} + func TestResolve(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) @@ -19,7 +27,7 @@ func TestResolve(t *testing.T) { }{ {name: "empty uses agent working dir", agentWorkingDir: "/workspace", want: "/workspace"}, {name: "absolute wins", toolsetWorkingDir: "/tmp/app", agentWorkingDir: "/workspace", want: "/tmp/app"}, - {name: "relative joins agent dir", toolsetWorkingDir: "tools/mcp", agentWorkingDir: "/workspace", want: filepath.Join("/workspace", "tools/mcp")}, + {name: "relative joins agent dir", toolsetWorkingDir: "tools/mcp", agentWorkingDir: "/workspace", want: mustAbs(filepath.Join(string(filepath.Separator), "workspace", "tools", "mcp"))}, {name: "relative without agent dir remains relative", toolsetWorkingDir: "tools/mcp", want: "tools/mcp"}, {name: "tilde expands", toolsetWorkingDir: "~/projects/app", agentWorkingDir: "/workspace", want: filepath.Join(home, "projects", "app")}, } From 89c8f5c9138d0a16dfb912f92a799ca5f593002a Mon Sep 17 00:00:00 2001 From: piyush0049 Date: Thu, 23 Jul 2026 11:57:45 +0530 Subject: [PATCH 4/6] fix(windows): resolve remaining gosec linting errors --- pkg/tools/builtin/backgroundjobs/cmd_windows.go | 4 ++-- pkg/tools/builtin/shell/cmd_windows.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/tools/builtin/backgroundjobs/cmd_windows.go b/pkg/tools/builtin/backgroundjobs/cmd_windows.go index d25a83ef15..5a4ce50c2b 100644 --- a/pkg/tools/builtin/backgroundjobs/cmd_windows.go +++ b/pkg/tools/builtin/backgroundjobs/cmd_windows.go @@ -31,13 +31,13 @@ func createProcessGroup(proc *os.Process) (*processGroup, error) { if _, err := windows.SetInformationJobObject( job, windows.JobObjectExtendedLimitInformation, - uintptr(unsafe.Pointer(&info)), + uintptr(unsafe.Pointer(&info)), //nolint:gosec // interacting with Windows API uint32(unsafe.Sizeof(info))); err != nil { _ = windows.CloseHandle(job) return nil, err } - handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(proc.Pid)) + handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(proc.Pid)) //nolint:gosec // PID fits in uint32 on Windows if err != nil { _ = windows.CloseHandle(job) return nil, err diff --git a/pkg/tools/builtin/shell/cmd_windows.go b/pkg/tools/builtin/shell/cmd_windows.go index 05e11368ac..9c2946376d 100644 --- a/pkg/tools/builtin/shell/cmd_windows.go +++ b/pkg/tools/builtin/shell/cmd_windows.go @@ -31,13 +31,13 @@ func createProcessGroup(proc *os.Process) (*processGroup, error) { if _, err := windows.SetInformationJobObject( job, windows.JobObjectExtendedLimitInformation, - uintptr(unsafe.Pointer(&info)), + uintptr(unsafe.Pointer(&info)), //nolint:gosec // interacting with Windows API uint32(unsafe.Sizeof(info))); err != nil { _ = windows.CloseHandle(job) return nil, err } - handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(proc.Pid)) + handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(proc.Pid)) //nolint:gosec // PID fits in uint32 on Windows if err != nil { _ = windows.CloseHandle(job) return nil, err From bbb737873713f00d7fbf4e2642c6ff0893b70584 Mon Sep 17 00:00:00 2001 From: piyush0049 Date: Fri, 24 Jul 2026 20:10:36 +0530 Subject: [PATCH 5/6] fix: address PR review comments for Windows compatibility --- pkg/selfupdate/exec_windows.go | 6 +- pkg/session/session.go | 2 +- pkg/session/session_options_test.go | 69 ++++++++++---- .../builtin/filesystem/agentsignore_test.go | 5 +- .../filesystem/filesystem_paths_test.go | 35 +++++-- .../builtin/filesystem/filesystem_test.go | 15 ++- pkg/tools/builtin/filesystem/postedit.go | 42 ++++---- pkg/tools/builtin/filesystem/postedit_test.go | 95 +++++++++++++++++++ pkg/tools/workingdir/workingdir.go | 3 +- pkg/tools/workingdir/workingdir_test.go | 19 ++-- 10 files changed, 227 insertions(+), 64 deletions(-) create mode 100644 pkg/tools/builtin/filesystem/postedit_test.go diff --git a/pkg/selfupdate/exec_windows.go b/pkg/selfupdate/exec_windows.go index 27c599be10..d00dd09e5c 100644 --- a/pkg/selfupdate/exec_windows.go +++ b/pkg/selfupdate/exec_windows.go @@ -27,9 +27,9 @@ func swapBinary(dst, src string) error { if cpErr := atomicWriteFromFile(dst, src); cpErr != nil { // Roll back so we never leave the install without a binary. if rbErr := os.Rename(old, dst); rbErr != nil { - return fmt.Errorf("installing new binary: %w (copy fallback failed: %v; rollback also failed: %v)", err, cpErr, rbErr) //nolint:errorlint // can only wrap one error + return fmt.Errorf("installing new binary: %w (copy fallback failed: %w; rollback also failed: %w)", err, cpErr, rbErr) } - return fmt.Errorf("installing new binary: %w (copy fallback failed: %v)", err, cpErr) //nolint:errorlint // can only wrap one error + return fmt.Errorf("installing new binary: %w (copy fallback failed: %w)", err, cpErr) } _ = os.Remove(src) } @@ -48,7 +48,7 @@ func reExecProcess(path string, args, env []string) error { childArgs = args[1:] } - cmd := exec.Command(path, childArgs...) //nolint:noctx // context not applicable here + cmd := exec.Command(path, childArgs...) //nolint:noctx // re-exec has no parent context cmd.Env = env cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout diff --git a/pkg/session/session.go b/pkg/session/session.go index 7a1a37f658..a758dbe5b8 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -1202,7 +1202,7 @@ func (s *Session) AddAttachedFile(absPath string) { if absPath == "" { return } - if !filepath.IsAbs(absPath) && !strings.HasPrefix(absPath, "/") { + if !filepath.IsAbs(absPath) { slog.Debug("ignoring non-absolute attached file path", "session_id", s.ID, "path", absPath) return } diff --git a/pkg/session/session_options_test.go b/pkg/session/session_options_test.go index 3d9a65cd12..22dc687b57 100644 --- a/pkg/session/session_options_test.go +++ b/pkg/session/session_options_test.go @@ -1,6 +1,7 @@ package session import ( + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -77,11 +78,15 @@ func TestAddAttachedFile(t *testing.T) { t.Parallel() t.Run("deduplicates and preserves order", func(t *testing.T) { t.Parallel() + dir := t.TempDir() + foo := filepath.Join(dir, "foo.go") + bar := filepath.Join(dir, "bar.go") + s := New() - s.AddAttachedFile("/abs/foo.go") - s.AddAttachedFile("/abs/bar.go") - s.AddAttachedFile("/abs/foo.go") // duplicate - assert.Equal(t, []string{"/abs/foo.go", "/abs/bar.go"}, s.AttachedFilesSnapshot()) + s.AddAttachedFile(foo) + s.AddAttachedFile(bar) + s.AddAttachedFile(foo) // duplicate + assert.Equal(t, []string{foo, bar}, s.AttachedFilesSnapshot()) }) t.Run("ignores empty paths", func(t *testing.T) { @@ -102,11 +107,14 @@ func TestAddAttachedFile(t *testing.T) { t.Run("snapshot is independent of session storage", func(t *testing.T) { t.Parallel() + dir := t.TempDir() + foo := filepath.Join(dir, "foo.go") + s := New() - s.AddAttachedFile("/abs/foo.go") + s.AddAttachedFile(foo) snap := s.AttachedFilesSnapshot() snap[0] = "mutated" - assert.Equal(t, []string{"/abs/foo.go"}, s.AttachedFilesSnapshot()) + assert.Equal(t, []string{foo}, s.AttachedFilesSnapshot()) }) } @@ -114,43 +122,62 @@ func TestRemoveAttachedFile(t *testing.T) { t.Parallel() t.Run("removes and reports presence", func(t *testing.T) { t.Parallel() + dir := t.TempDir() + foo := filepath.Join(dir, "foo.go") + bar := filepath.Join(dir, "bar.go") + baz := filepath.Join(dir, "baz.go") + s := New() - s.AddAttachedFile("/abs/foo.go") - s.AddAttachedFile("/abs/bar.go") - s.AddAttachedFile("/abs/baz.go") + s.AddAttachedFile(foo) + s.AddAttachedFile(bar) + s.AddAttachedFile(baz) - assert.True(t, s.RemoveAttachedFile("/abs/bar.go")) - assert.Equal(t, []string{"/abs/foo.go", "/abs/baz.go"}, s.AttachedFilesSnapshot()) + assert.True(t, s.RemoveAttachedFile(bar)) + assert.Equal(t, []string{foo, baz}, s.AttachedFilesSnapshot()) }) t.Run("reports absent paths", func(t *testing.T) { t.Parallel() + dir := t.TempDir() + foo := filepath.Join(dir, "foo.go") + other := filepath.Join(dir, "other.go") + s := New() - s.AddAttachedFile("/abs/foo.go") - assert.False(t, s.RemoveAttachedFile("/abs/other.go")) + s.AddAttachedFile(foo) + assert.False(t, s.RemoveAttachedFile(other)) assert.False(t, s.RemoveAttachedFile("")) - assert.Equal(t, []string{"/abs/foo.go"}, s.AttachedFilesSnapshot()) + assert.Equal(t, []string{foo}, s.AttachedFilesSnapshot()) }) t.Run("no-op on empty list", func(t *testing.T) { t.Parallel() + dir := t.TempDir() + foo := filepath.Join(dir, "foo.go") + s := New() - assert.False(t, s.RemoveAttachedFile("/abs/foo.go")) + assert.False(t, s.RemoveAttachedFile(foo)) assert.Empty(t, s.AttachedFilesSnapshot()) }) t.Run("file can be re-attached after removal", func(t *testing.T) { t.Parallel() + dir := t.TempDir() + foo := filepath.Join(dir, "foo.go") + s := New() - s.AddAttachedFile("/abs/foo.go") - require.True(t, s.RemoveAttachedFile("/abs/foo.go")) - s.AddAttachedFile("/abs/foo.go") - assert.Equal(t, []string{"/abs/foo.go"}, s.AttachedFilesSnapshot()) + s.AddAttachedFile(foo) + require.True(t, s.RemoveAttachedFile(foo)) + s.AddAttachedFile(foo) + assert.Equal(t, []string{foo}, s.AttachedFilesSnapshot()) }) } func TestWithAttachedFiles(t *testing.T) { t.Parallel() - s := New(WithAttachedFiles([]string{"/abs/foo.go", "", "relative/path.go", "/abs/bar.go", "/abs/foo.go"})) - assert.Equal(t, []string{"/abs/foo.go", "/abs/bar.go"}, s.AttachedFilesSnapshot()) + dir := t.TempDir() + foo := filepath.Join(dir, "foo.go") + bar := filepath.Join(dir, "bar.go") + + s := New(WithAttachedFiles([]string{foo, "", "relative/path.go", bar, foo})) + assert.Equal(t, []string{foo, bar}, s.AttachedFilesSnapshot()) } diff --git a/pkg/tools/builtin/filesystem/agentsignore_test.go b/pkg/tools/builtin/filesystem/agentsignore_test.go index e2dd2cc70d..3c741e3cc5 100644 --- a/pkg/tools/builtin/filesystem/agentsignore_test.go +++ b/pkg/tools/builtin/filesystem/agentsignore_test.go @@ -3,6 +3,7 @@ package filesystem import ( "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -135,8 +136,8 @@ func TestAgentsIgnoreNegationReIncludes(t *testing.T) { } func TestAgentsIgnoreUnreadableFileIsAnError(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("root bypasses file permissions") + if os.Geteuid() == 0 || runtime.GOOS == "windows" { + t.Skip("root or Windows bypasses file read permissions") } dir := t.TempDir() path := filepath.Join(dir, fsx.AgentsIgnoreFile) diff --git a/pkg/tools/builtin/filesystem/filesystem_paths_test.go b/pkg/tools/builtin/filesystem/filesystem_paths_test.go index a88a2488f4..416bda2533 100644 --- a/pkg/tools/builtin/filesystem/filesystem_paths_test.go +++ b/pkg/tools/builtin/filesystem/filesystem_paths_test.go @@ -3,6 +3,7 @@ package filesystem import ( "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -26,12 +27,16 @@ func TestFilesystemTool_DefaultIsUnrestricted(t *testing.T) { // No allow_list, no deny_list: everything resolvable goes through. resolved, err := tool.resolveAndCheckPath("/etc/hosts") require.NoError(t, err) - assert.Equal(t, "/etc/hosts", resolved) + want := "/etc/hosts" + if runtime.GOOS == "windows" { + want = filepath.Join(tmpDir, "etc", "hosts") + } + assert.Equal(t, want, resolved) resolved, err = tool.resolveAndCheckPath("../../some/escape") require.NoError(t, err) // Equivalent to filepath.Clean of the joined relative escape. - want := filepath.Clean(filepath.Join(tmpDir, "..", "..", "some", "escape")) + want = filepath.Clean(filepath.Join(tmpDir, "..", "..", "some", "escape")) assert.Equal(t, want, resolved) } @@ -39,6 +44,7 @@ func TestFilesystemTool_AllowList_DotMeansWorkingDir(t *testing.T) { t.Parallel() tmpDir := t.TempDir() tool := New(tmpDir, WithAllowList([]string{"."})) + defer tool.Close() // Inside working dir is fine. _, err := tool.resolveAndCheckPath("file.txt") @@ -64,6 +70,7 @@ func TestFilesystemTool_AllowList_TildeMeansHome(t *testing.T) { wd := t.TempDir() tool := New(wd, WithAllowList([]string{"~"})) + defer tool.Close() // A path under $HOME is allowed via ~/... resolved, err := tool.resolveAndCheckPath(filepath.Join(homeDir, "doc.md")) @@ -83,6 +90,7 @@ func TestFilesystemTool_AllowList_TildeSubdirectory(t *testing.T) { wd := t.TempDir() tool := New(wd, WithAllowList([]string{"~/projects"})) + defer tool.Close() // Inside the listed subdir. _, err := tool.resolveAndCheckPath(filepath.Join(homeDir, "projects", "app", "main.go")) @@ -103,6 +111,7 @@ func TestFilesystemTool_AllowList_MultipleRoots(t *testing.T) { otherDir := t.TempDir() tool := New(wd, WithAllowList([]string{".", otherDir})) + defer tool.Close() _, err := tool.resolveAndCheckPath("file.txt") require.NoError(t, err) @@ -121,6 +130,7 @@ func TestFilesystemTool_AllowList_AbsolutePath(t *testing.T) { allowed := t.TempDir() tool := New(wd, WithAllowList([]string{allowed})) + defer tool.Close() // Absolute path inside the allowed root is fine. _, err := tool.resolveAndCheckPath(filepath.Join(allowed, "x", "y.txt")) @@ -138,6 +148,7 @@ func TestFilesystemTool_DenyList_RejectsMatchingPaths(t *testing.T) { require.NoError(t, os.Mkdir(denied, 0o755)) tool := New(wd, WithDenyList([]string{"secret"})) + defer tool.Close() // Anything under the denied subtree is rejected. _, err := tool.resolveAndCheckPath("secret/key.pem") @@ -163,6 +174,7 @@ func TestFilesystemTool_DenyList_TakesPrecedenceOverAllowList(t *testing.T) { tool := New(wd, WithAllowList([]string{"."}), WithDenyList([]string{"src/vendor"})) + defer tool.Close() // Allowed by allow-list, not denied. _, err := tool.resolveAndCheckPath("src/main.go") @@ -185,6 +197,7 @@ func TestFilesystemTool_AllowList_SymlinkEscapeRejected(t *testing.T) { require.NoError(t, os.Symlink(target, link)) tool := New(wd, WithAllowList([]string{"."})) + defer tool.Close() // Following the symlink escapes the allow-list and must be rejected. _, err := tool.resolveAndCheckPath("escape/secret.txt") @@ -204,6 +217,7 @@ func TestFilesystemTool_DenyList_SymlinkIntoDeniedAreaRejected(t *testing.T) { require.NoError(t, os.Symlink(denied, link)) tool := New(wd, WithDenyList([]string{"secret"})) + defer tool.Close() // Reading via the symlink must still trigger the deny-list. _, err := tool.resolveAndCheckPath("shortcut/key.pem") @@ -215,6 +229,7 @@ func TestFilesystemTool_AllowList_NewFilePath(t *testing.T) { t.Parallel() wd := t.TempDir() tool := New(wd, WithAllowList([]string{"."})) + defer tool.Close() // A path that doesn't exist yet (e.g. about to be created by write_file) // must still be accepted when its lexical location is inside the allow-list. @@ -249,6 +264,7 @@ func TestFilesystemTool_HandlersUseAllowList(t *testing.T) { require.NoError(t, os.WriteFile(outsideFile, []byte("nope"), 0o644)) tool := New(wd, WithAllowList([]string{"."})) + defer tool.Close() // read_file: must refuse the outside path. res, err := tool.handleReadFile(t.Context(), ReadFileArgs{Path: outsideFile}) @@ -318,6 +334,7 @@ func TestFilesystemTool_HandlersUseDenyList(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(wd, "secrets", "key.pem"), []byte("k"), 0o644)) tool := New(wd, WithDenyList([]string{"secrets"})) + defer tool.Close() // edit_file: must refuse to read the file in a denied directory. res, err := tool.handleEditFile(t.Context(), EditFileArgs{ @@ -358,7 +375,8 @@ func TestExpandPathToken(t *testing.T) { homeDir := t.TempDir() resetHomeDir(t, homeDir) wd := t.TempDir() - t.Setenv("MY_VAR", "/var/data") + srvDir := t.TempDir() + t.Setenv("MY_VAR", "var/data") t.Setenv("EMPTY_VAR", "") os.Unsetenv("DEFINITELY_NOT_SET") @@ -371,11 +389,11 @@ func TestExpandPathToken(t *testing.T) { {name: "dot", token: ".", want: wd}, {name: "tilde", token: "~", want: homeDir}, {name: "tilde-subdir", token: "~/projects", want: filepath.Join(homeDir, "projects")}, - {name: "absolute", token: "/srv/data", want: "/srv/data"}, + {name: "absolute", token: srvDir, want: srvDir}, {name: "relative", token: "src", want: filepath.Join(wd, "src")}, - {name: "env-var", token: "$MY_VAR", want: "/var/data"}, - {name: "env-var-braces", token: "${MY_VAR}", want: "/var/data"}, - {name: "env-var-js-alias", token: "${env.MY_VAR}", want: "/var/data"}, + {name: "env-var", token: "$MY_VAR", want: filepath.Join(wd, "var", "data")}, + {name: "env-var-braces", token: "${MY_VAR}", want: filepath.Join(wd, "var", "data")}, + {name: "env-var-js-alias", token: "${env.MY_VAR}", want: filepath.Join(wd, "var", "data")}, {name: "env-var-js-alias-inside-tilde", token: "~/${env.MY_VAR}", want: filepath.Join(homeDir, "var", "data")}, {name: "env-var-inside-tilde", token: "~/${MY_VAR}", want: filepath.Join(homeDir, "var", "data")}, {name: "empty", token: "", wantErr: "empty"}, @@ -409,6 +427,7 @@ func TestWithAllowList_RejectsUndefinedEnvVar(t *testing.T) { os.Unsetenv("DEFINITELY_NOT_SET") wd := t.TempDir() tool := New(wd, WithAllowList([]string{"$DEFINITELY_NOT_SET"})) + defer tool.Close() // The allow-list construction failed, so the toolset is disabled // (fail-closed). All operations must be rejected. @@ -428,6 +447,7 @@ func TestWithAllowList_AcceptsDefinedEnvVar(t *testing.T) { t.Setenv("ALLOWED_DIR", allowed) tool := New(wd, WithAllowList([]string{"$ALLOWED_DIR"})) + defer tool.Close() // Inside the env-var-resolved root. _, err := tool.resolveAndCheckPath(filepath.Join(allowed, "file.txt")) @@ -447,6 +467,7 @@ func TestDenyList_NonExistentPath(t *testing.T) { wd := t.TempDir() tool := New(wd, WithDenyList([]string{"~/.ssh"})) + defer tool.Close() // ~/.ssh does not exist yet — a write to a path inside it must be // rejected before the directory is even created. diff --git a/pkg/tools/builtin/filesystem/filesystem_test.go b/pkg/tools/builtin/filesystem/filesystem_test.go index d2a3908f6b..4d035b6d0e 100644 --- a/pkg/tools/builtin/filesystem/filesystem_test.go +++ b/pkg/tools/builtin/filesystem/filesystem_test.go @@ -9,6 +9,7 @@ import ( "image/png" "os" "path/filepath" + "runtime" "strings" "testing" @@ -625,10 +626,14 @@ func main() { fmt.Println("hello") }` + postEditCmd := "touch $file.formatted" + if runtime.GOOS == "windows" { + postEditCmd = "New-Item -ItemType File -Path \"$env:file.formatted\" -Force" + } postEditConfigs := []PostEditConfig{ { Path: "*.go", - Cmd: "touch $file.formatted", + Cmd: postEditCmd, }, } tool := New(tmpDir, WithPostEditCommands(postEditConfigs)) @@ -951,7 +956,11 @@ func TestFilesystemTool_EmptyWorkingDir(t *testing.T) { // Absolute paths still work resolvedPath = tool.resolvePath("/etc/hosts") - assert.Equal(t, "/etc/hosts", resolvedPath) + want := "/etc/hosts" + if runtime.GOOS == "windows" { + want = filepath.Clean("/etc/hosts") + } + assert.Equal(t, want, resolvedPath) } func TestFilesystemTool_CreateDirectory(t *testing.T) { @@ -1090,7 +1099,7 @@ func TestFilesystemTool_RemoveDirectory_IsFile(t *testing.T) { }) require.NoError(t, err) assert.True(t, result.IsError) - assert.Contains(t, result.Output, "not a directory") + assert.True(t, strings.Contains(result.Output, "not a directory") || strings.Contains(result.Output, "directory name is invalid")) } func TestFilesystemTool_RemoveDirectory_MultipleStopsOnError(t *testing.T) { diff --git a/pkg/tools/builtin/filesystem/postedit.go b/pkg/tools/builtin/filesystem/postedit.go index 392f951f10..5d78e2f8ec 100644 --- a/pkg/tools/builtin/filesystem/postedit.go +++ b/pkg/tools/builtin/filesystem/postedit.go @@ -16,23 +16,7 @@ import ( // runPostEditCommands executes configured shell commands after a file edit. func runPostEditCommands(ctx context.Context, workingDir string, postEditCommands []PostEditConfig, filePath string) error { for _, postEdit := range postEditCommands { - pattern := filepath.ToSlash(postEdit.Path) - target := filepath.Base(filePath) - if strings.Contains(pattern, "/") { - // Make filePath relative to workingDir so "pkg/*.go" can match - rel, err := filepath.Rel(workingDir, filePath) - if err == nil { - target = filepath.ToSlash(rel) - } else { - target = filepath.ToSlash(filePath) - } - } - matched, err := filepath.Match(pattern, target) - if err != nil { - slog.WarnContext(ctx, "Invalid post-edit pattern", "pattern", postEdit.Path, "error", err) - continue - } - if !matched { + if !matchPostEdit(ctx, postEdit.Path, workingDir, filePath) { continue } @@ -47,3 +31,27 @@ func runPostEditCommands(ctx context.Context, workingDir string, postEditCommand } return nil } + +func matchPostEdit(ctx context.Context, patternStr, workingDir, filePath string) bool { + pattern := filepath.ToSlash(patternStr) + target := filepath.Base(filePath) + if strings.Contains(pattern, "/") { + if workingDir != "" { + rel, err := filepath.Rel(workingDir, filePath) + if err == nil { + target = filepath.ToSlash(rel) + } else { + slog.DebugContext(ctx, "Failed to resolve relative path for post-edit pattern", "workingDir", workingDir, "filePath", filePath, "error", err) + target = filepath.ToSlash(filePath) + } + } else { + target = filepath.ToSlash(filePath) + } + } + matched, err := filepath.Match(pattern, target) + if err != nil { + slog.WarnContext(ctx, "Invalid post-edit pattern", "pattern", patternStr, "error", err) + return false + } + return matched +} diff --git a/pkg/tools/builtin/filesystem/postedit_test.go b/pkg/tools/builtin/filesystem/postedit_test.go new file mode 100644 index 0000000000..6159ef1aae --- /dev/null +++ b/pkg/tools/builtin/filesystem/postedit_test.go @@ -0,0 +1,95 @@ +//go:build !js + +package filesystem + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMatchPostEdit(t *testing.T) { + ctx := context.Background() + workDir := filepath.Join(string(filepath.Separator), "workspace", "app") + + tests := []struct { + name string + pattern string + workingDir string + filePath string + wantMatch bool + }{ + { + name: "basename pattern matches simple file", + pattern: "*.go", + workingDir: workDir, + filePath: filepath.Join(workDir, "main.go"), + wantMatch: true, + }, + { + name: "basename pattern matches nested file", + pattern: "*.go", + workingDir: workDir, + filePath: filepath.Join(workDir, "pkg", "sub", "foo.go"), + wantMatch: true, + }, + { + name: "path-scoped pattern matches relative subpath", + pattern: "pkg/*.go", + workingDir: workDir, + filePath: filepath.Join(workDir, "pkg", "foo.go"), + wantMatch: true, + }, + { + name: "path-scoped pattern does not match different subpath", + pattern: "cmd/*.go", + workingDir: workDir, + filePath: filepath.Join(workDir, "pkg", "foo.go"), + wantMatch: false, + }, + { + name: "nested slash pattern matches multi-level path", + pattern: "pkg/sub/*.go", + workingDir: workDir, + filePath: filepath.Join(workDir, "pkg", "sub", "bar.go"), + wantMatch: true, + }, + { + name: "empty working dir falls back to slash-normalized file path", + pattern: "*.go", + workingDir: "", + filePath: filepath.Join("pkg", "foo.go"), + wantMatch: true, + }, + { + name: "invalid pattern returns false", + pattern: "[invalid", + workingDir: workDir, + filePath: filepath.Join(workDir, "foo.go"), + wantMatch: false, + }, + { + name: "file outside workingDir does not match path-scoped pattern", + pattern: "pkg/*.go", + workingDir: workDir, + filePath: filepath.Join(workDir, "..", "outside", "foo.go"), + wantMatch: false, + }, + { + name: "relative workingDir vs absolute filePath debug log fallback does not match relative pattern", + pattern: "pkg/*.go", + workingDir: "relative/dir", + filePath: filepath.Join(workDir, "pkg", "foo.go"), + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchPostEdit(ctx, tt.pattern, tt.workingDir, tt.filePath) + assert.Equal(t, tt.wantMatch, got) + }) + } +} diff --git a/pkg/tools/workingdir/workingdir.go b/pkg/tools/workingdir/workingdir.go index e7cec2a5e1..c39e8a0779 100644 --- a/pkg/tools/workingdir/workingdir.go +++ b/pkg/tools/workingdir/workingdir.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "github.com/docker/docker-agent/pkg/path" ) @@ -15,7 +14,7 @@ func Resolve(toolsetWorkingDir, agentWorkingDir string) string { return agentWorkingDir } toolsetWorkingDir = path.ExpandPath(toolsetWorkingDir) - if filepath.IsAbs(toolsetWorkingDir) || strings.HasPrefix(toolsetWorkingDir, "/") { + if filepath.IsAbs(toolsetWorkingDir) { return toolsetWorkingDir } if agentWorkingDir != "" { diff --git a/pkg/tools/workingdir/workingdir_test.go b/pkg/tools/workingdir/workingdir_test.go index b65613bc6b..3377662e6d 100644 --- a/pkg/tools/workingdir/workingdir_test.go +++ b/pkg/tools/workingdir/workingdir_test.go @@ -5,13 +5,13 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func mustAbs(path string) string { +func mustAbs(t *testing.T, path string) string { + t.Helper() abs, err := filepath.Abs(path) - if err != nil { - panic(err) - } + require.NoError(t, err) return abs } @@ -19,6 +19,8 @@ func TestResolve(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + absDir := mustAbs(t, filepath.Join(string(filepath.Separator), "tmp", "app")) + tests := []struct { name string toolsetWorkingDir string @@ -26,8 +28,8 @@ func TestResolve(t *testing.T) { want string }{ {name: "empty uses agent working dir", agentWorkingDir: "/workspace", want: "/workspace"}, - {name: "absolute wins", toolsetWorkingDir: "/tmp/app", agentWorkingDir: "/workspace", want: "/tmp/app"}, - {name: "relative joins agent dir", toolsetWorkingDir: "tools/mcp", agentWorkingDir: "/workspace", want: mustAbs(filepath.Join(string(filepath.Separator), "workspace", "tools", "mcp"))}, + {name: "absolute wins", toolsetWorkingDir: absDir, agentWorkingDir: "/workspace", want: absDir}, + {name: "relative joins agent dir", toolsetWorkingDir: "tools/mcp", agentWorkingDir: "/workspace", want: mustAbs(t, filepath.Join(string(filepath.Separator), "workspace", "tools", "mcp"))}, {name: "relative without agent dir remains relative", toolsetWorkingDir: "tools/mcp", want: "tools/mcp"}, {name: "tilde expands", toolsetWorkingDir: "~/projects/app", agentWorkingDir: "/workspace", want: filepath.Join(home, "projects", "app")}, } @@ -41,8 +43,9 @@ func TestResolve(t *testing.T) { } func TestResolveEnvVarExpansion(t *testing.T) { - t.Setenv("TEST_WORKING_DIR_VAR", "/custom/path") + absCustom := mustAbs(t, filepath.Join(string(filepath.Separator), "custom", "path")) + t.Setenv("TEST_WORKING_DIR_VAR", absCustom) got := Resolve("${TEST_WORKING_DIR_VAR}/app", "/workspace") - assert.Equal(t, "/custom/path/app", got) + assert.Equal(t, filepath.Join(absCustom, "app"), filepath.Clean(got)) } From 15af87f8589c407f0938fe0390c7089417d6e3af Mon Sep 17 00:00:00 2001 From: piyush0049 Date: Fri, 24 Jul 2026 20:35:10 +0530 Subject: [PATCH 6/6] test: use t.Context() to fix forbidigo lint error --- pkg/tools/builtin/filesystem/postedit_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/tools/builtin/filesystem/postedit_test.go b/pkg/tools/builtin/filesystem/postedit_test.go index 6159ef1aae..7a16e8cfe1 100644 --- a/pkg/tools/builtin/filesystem/postedit_test.go +++ b/pkg/tools/builtin/filesystem/postedit_test.go @@ -3,7 +3,6 @@ package filesystem import ( - "context" "path/filepath" "testing" @@ -11,7 +10,7 @@ import ( ) func TestMatchPostEdit(t *testing.T) { - ctx := context.Background() + ctx := t.Context() workDir := filepath.Join(string(filepath.Separator), "workspace", "app") tests := []struct {