From c23300f28f8b87f211a20a719c3404e33b8747dc Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 4 Jul 2026 17:17:42 +0000 Subject: [PATCH 1/3] feat(mcp): optional working dir for the bash tool (cmd.Dir) --- mcp/bgshell.go | 16 +++++++++++++++- mcp/bgshell_test.go | 22 ++++++++++++++++++++++ types/config.go | 3 +++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/mcp/bgshell.go b/mcp/bgshell.go index dbdfe08..4ae441e 100644 --- a/mcp/bgshell.go +++ b/mcp/bgshell.go @@ -115,9 +115,16 @@ type bgJobManager struct { // job's wait goroutine. Used to push a completion notice into the live run's // message-injection channel (cogito has no concept of shell jobs). onDone func(*bgJob) + + // dir, when non-empty, is the working directory launched commands run in + // (cmd.Dir). Empty means the process cwd (legacy behavior). + dir string } -func newBgJobManager() *bgJobManager { return &bgJobManager{jobs: map[string]*bgJob{}} } +func newBgJobManager() *bgJobManager { return newBgJobManagerInDir("") } +func newBgJobManagerInDir(dir string) *bgJobManager { + return &bgJobManager{jobs: map[string]*bgJob{}, dir: dir} +} // launch starts script under a context derived from parent (so the job survives // a single turn but is cancelled when the session/app shuts down). When @@ -137,6 +144,9 @@ func (m *bgJobManager) launch(parent context.Context, script string, foreground shellExec, shellArgs := shellInvocation(script) cmd := exec.CommandContext(ctx, shellExec, shellArgs...) + if m.dir != "" { + cmd.Dir = m.dir + } cmd.Stdout = &j.stdout cmd.Stderr = &j.stderr @@ -297,6 +307,10 @@ type ShellJobs struct { // NewShellJobs creates an empty shared shell-job registry. func NewShellJobs() *ShellJobs { return &ShellJobs{mgr: newBgJobManager()} } +// NewShellJobsInDir creates a shell-job registry whose commands run in dir +// (cmd.Dir). An empty dir preserves the legacy process-cwd behavior. +func NewShellJobsInDir(dir string) *ShellJobs { return &ShellJobs{mgr: newBgJobManagerInDir(dir)} } + // ShellJobInfo is a UI-facing snapshot of a shell job. type ShellJobInfo struct { ID string diff --git a/mcp/bgshell_test.go b/mcp/bgshell_test.go index 24602d6..4341fa0 100644 --- a/mcp/bgshell_test.go +++ b/mcp/bgshell_test.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "path/filepath" "strings" "testing" "time" @@ -229,3 +230,24 @@ func TestLockedBufferTruncates(t *testing.T) { t.Fatal("oversized output should be marked truncated") } } + +func TestLaunchRunsInConfiguredDir(t *testing.T) { + dir := t.TempDir() + sj := NewShellJobsInDir(dir) + j := sj.mgr.launch(context.Background(), "pwd", false) + <-j.doneCh + out := strings.TrimSpace(j.stdout.String()) + // macOS /var symlinks to /private/var; compare resolved paths. + want, _ := filepath.EvalSymlinks(dir) + got, _ := filepath.EvalSymlinks(out) + if got != want { + t.Fatalf("pwd = %q, want %q", got, want) + } +} + +func TestLaunchEmptyDirUsesProcessCwd(t *testing.T) { + sj := NewShellJobs() // no dir → current behavior + if sj.mgr.dir != "" { + t.Fatalf("default manager dir = %q, want empty", sj.mgr.dir) + } +} diff --git a/types/config.go b/types/config.go index 8702665..b6e927e 100644 --- a/types/config.go +++ b/types/config.go @@ -149,6 +149,9 @@ type Config struct { // and re-applied on every turn (see Session.SendMessage), so a seeded system // message would duplicate it. Set at runtime, never from the YAML config. InitialHistory []openai.ChatCompletionMessage `yaml:"-"` + // WorkingDir, when non-empty, is the directory host tools (bash, filesystem) + // operate in. Runtime-only; empty means the process cwd (legacy behavior). + WorkingDir string `yaml:"-"` } func (c *Config) GetPrompt() string { From 875eb7c8c09531ce0ec0f181862334feeb0172ed Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 4 Jul 2026 17:20:10 +0000 Subject: [PATCH 2/3] feat(mcp): root the filesystem tool at an optional working dir --- mcp/filesystem.go | 57 ++++++++++++++++++++++++++++++++++++------ mcp/filesystem_test.go | 26 ++++++++++++++++--- mcp/transport.go | 2 +- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/mcp/filesystem.go b/mcp/filesystem.go index 8d4d8ae..a978992 100644 --- a/mcp/filesystem.go +++ b/mcp/filesystem.go @@ -20,13 +20,27 @@ import ( // it or by writing it. This prevents blind edits against files the agent has // never inspected. type fileSystem struct { + // root, when non-empty, is the working directory that relative paths are + // rooted at. Empty means paths are used verbatim (legacy process-cwd + // behavior). + root string mu sync.Mutex seen map[string]bool } -// newFileSystem creates a filesystem handler with an empty seen-file set. -func newFileSystem() *fileSystem { - return &fileSystem{seen: make(map[string]bool)} +// newFileSystem creates a filesystem handler with an empty seen-file set, +// rooted at root. An empty root preserves the legacy process-cwd behavior. +func newFileSystem(root string) *fileSystem { + return &fileSystem{root: root, seen: make(map[string]bool)} +} + +// resolve roots a relative path at the configured working dir. Absolute paths +// and the empty-root case are returned unchanged. +func (f *fileSystem) resolve(path string) string { + if f.root == "" || filepath.IsAbs(path) { + return path + } + return filepath.Join(f.root, path) } // pathKey normalizes a path so the same file is recognized regardless of how @@ -58,6 +72,7 @@ func (f *fileSystem) read(ctx context.Context, req *mcp.CallToolRequest, input r readFileOutput, error, ) { + input.Path = f.resolve(input.Path) res, out, err := readFile(ctx, req, input) if out.Success { f.markSeen(input.Path) @@ -72,6 +87,7 @@ func (f *fileSystem) write(ctx context.Context, req *mcp.CallToolRequest, input writeFileOutput, error, ) { + input.Path = f.resolve(input.Path) res, out, err := writeFile(ctx, req, input) if out.Success { f.markSeen(input.Path) @@ -86,6 +102,7 @@ func (f *fileSystem) edit(ctx context.Context, req *mcp.CallToolRequest, input e editFileOutput, error, ) { + input.Path = f.resolve(input.Path) if !f.hasSeen(input.Path) { return nil, editFileOutput{ Success: false, @@ -137,6 +154,28 @@ type editFileOutput struct { Error string `json:"error,omitempty" jsonschema:"error message if failed"` } +// glob roots the base path at the working dir before delegating to globFiles. +// resolve("") returns "" when root is empty (globFiles then defaults to "."), +// and returns the root itself when a root is configured. +func (f *fileSystem) glob(ctx context.Context, req *mcp.CallToolRequest, input globFilesInput) ( + *mcp.CallToolResult, + globFilesOutput, + error, +) { + input.Path = f.resolve(input.Path) + return globFiles(ctx, req, input) +} + +// grep roots the base path at the working dir before delegating to grepFiles. +func (f *fileSystem) grep(ctx context.Context, req *mcp.CallToolRequest, input grepFilesInput) ( + *mcp.CallToolResult, + grepFilesOutput, + error, +) { + input.Path = f.resolve(input.Path) + return grepFiles(ctx, req, input) +} + // Input type for glob operation type globFilesInput struct { Pat string `json:"pat" jsonschema:"the glob pattern to match files"` @@ -540,8 +579,10 @@ func grepFiles(ctx context.Context, req *mcp.CallToolRequest, input grepFilesInp }, nil } -// StartFileSystemMCPServer starts the filesystem MCP server -func StartFileSystemMCPServer(ctx context.Context, transport mcp.Transport) error { +// StartFileSystemMCPServer starts the filesystem MCP server. When root is +// non-empty, relative paths are rooted at it; an empty root preserves the +// legacy process-cwd behavior. +func StartFileSystemMCPServer(ctx context.Context, transport mcp.Transport, root string) error { // Create MCP server for filesystem operations server := mcp.NewServer(&mcp.Implementation{ Name: "filesystem", @@ -549,7 +590,7 @@ func StartFileSystemMCPServer(ctx context.Context, transport mcp.Transport) erro }, nil) // Per-server state gating edits behind a prior read or write of the file. - fs := newFileSystem() + fs := newFileSystem(root) // Add tool for reading files mcp.AddTool(server, &mcp.Tool{ @@ -573,13 +614,13 @@ func StartFileSystemMCPServer(ctx context.Context, transport mcp.Transport) erro mcp.AddTool(server, &mcp.Tool{ Name: "glob", Description: "Find files by glob pattern, sorted by modification time (newest first)", - }, globFiles) + }, fs.glob) // Add tool for grep file search mcp.AddTool(server, &mcp.Tool{ Name: "grep", Description: "Search files for regex pattern, returns up to 50 matches", - }, grepFiles) + }, fs.grep) // Run the server if err := server.Run(ctx, transport); err != nil { diff --git a/mcp/filesystem_test.go b/mcp/filesystem_test.go index f6d9e5f..f506b1c 100644 --- a/mcp/filesystem_test.go +++ b/mcp/filesystem_test.go @@ -353,7 +353,7 @@ func TestEditRequiresRead(t *testing.T) { t.Fatal(err) } - fs := newFileSystem() + fs := newFileSystem("") ctx := context.Background() // Editing before reading must be rejected and must not modify the file. @@ -403,7 +403,7 @@ func TestEditAfterWrite(t *testing.T) { tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "new.txt") - fs := newFileSystem() + fs := newFileSystem("") ctx := context.Background() if _, wout, err := fs.write(ctx, &mcp.CallToolRequest{}, writeFileInput{Path: tmpFile, Content: "foo bar"}); err != nil || !wout.Success { @@ -427,7 +427,7 @@ func TestEditReadPathNormalization(t *testing.T) { t.Fatal(err) } - fs := newFileSystem() + fs := newFileSystem("") ctx := context.Background() // Read via a path containing a redundant "." segment. @@ -698,7 +698,7 @@ func TestStartFileSystemMCPServer(t *testing.T) { serverErrChan := make(chan error, 1) go func() { - err := StartFileSystemMCPServer(ctx, serverTransport) + err := StartFileSystemMCPServer(ctx, serverTransport, "") serverErrChan <- err }() @@ -749,3 +749,21 @@ func TestStartFileSystemMCPServer(t *testing.T) { t.Error("server did not stop after context cancellation") } } + +func TestFileSystemResolvesRelativeAgainstRoot(t *testing.T) { + root := t.TempDir() + fs := newFileSystem(root) + got := fs.resolve("notes.txt") + want := filepath.Join(root, "notes.txt") + if got != want { + t.Fatalf("resolve(rel) = %q, want %q", got, want) + } + abs := filepath.Join(t.TempDir(), "other.txt") + if fs.resolve(abs) != abs { + t.Fatalf("resolve(abs) must be unchanged, got %q", fs.resolve(abs)) + } + // Empty root preserves the raw path (legacy cwd behavior). + if newFileSystem("").resolve("x.txt") != "x.txt" { + t.Fatalf("empty root must not rewrite paths") + } +} diff --git a/mcp/transport.go b/mcp/transport.go index fac9bcd..1bdc8e9 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -27,7 +27,7 @@ func StartTransports(ctx context.Context, cfg types.Config, shellJobs *ShellJobs filesystemMCPServerTransport, filesystemMCPServerClient := mcp.NewInMemoryTransports() go func() { - if err := StartFileSystemMCPServer(ctx, filesystemMCPServerTransport); err != nil { + if err := StartFileSystemMCPServer(ctx, filesystemMCPServerTransport, ""); err != nil { fmt.Fprintf(os.Stderr, "Filesystem MCP server error: %v\n", err) } }() From aa2189b6a7ad9f804b3dc53edb84948bb2e06938 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 4 Jul 2026 17:21:26 +0000 Subject: [PATCH 3/3] feat(mcp): StartTransports threads cfg.WorkingDir to bash + filesystem --- mcp/transport.go | 4 ++-- mcp/transport_test.go | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/mcp/transport.go b/mcp/transport.go index 1bdc8e9..9d9e64f 100644 --- a/mcp/transport.go +++ b/mcp/transport.go @@ -12,7 +12,7 @@ import ( func StartTransports(ctx context.Context, cfg types.Config, shellJobs *ShellJobs) ([]mcp.Transport, error) { if shellJobs == nil { - shellJobs = NewShellJobs() + shellJobs = NewShellJobsInDir(cfg.WorkingDir) } // Set MCP servers bashMCPServerTransport, bashMCPServerClient := mcp.NewInMemoryTransports() @@ -27,7 +27,7 @@ func StartTransports(ctx context.Context, cfg types.Config, shellJobs *ShellJobs filesystemMCPServerTransport, filesystemMCPServerClient := mcp.NewInMemoryTransports() go func() { - if err := StartFileSystemMCPServer(ctx, filesystemMCPServerTransport, ""); err != nil { + if err := StartFileSystemMCPServer(ctx, filesystemMCPServerTransport, cfg.WorkingDir); err != nil { fmt.Fprintf(os.Stderr, "Filesystem MCP server error: %v\n", err) } }() diff --git a/mcp/transport_test.go b/mcp/transport_test.go index d52900a..6767126 100644 --- a/mcp/transport_test.go +++ b/mcp/transport_test.go @@ -29,3 +29,12 @@ func TestStartTransportsReturnsOnlyBuiltins(t *testing.T) { t.Fatalf("StartTransports should ignore skills/mcp_servers now: %d vs %d", len(base), len(withExtras)) } } + +func TestStartTransportsUsesWorkingDir(t *testing.T) { + dir := t.TempDir() + // When the caller passes its own ShellJobs, StartTransports must NOT override it. + own := NewShellJobsInDir(dir) + if own.mgr.dir != dir { + t.Fatalf("precondition: own manager dir = %q", own.mgr.dir) + } +}