From d212a9c8fc422683cf66aa026fe042d72666b44d Mon Sep 17 00:00:00 2001 From: lancekrogers Date: Fri, 17 Apr 2026 00:22:54 -0600 Subject: [PATCH 1/2] feat(claude): add WorkingDirectory to RunOptions Sets cmd.Dir on the Claude CLI child process for RunPrompt, RunFromStdin, and StreamPrompt when RunOptions.WorkingDirectory is non-empty. Matches the existing WorkingDirectory field on SubagentConfig and removes the "would need to be added to RunOptions if needed" TODO. - options.go: new WorkingDirectory field on RunOptions - claude.go: wire cmd.Dir in RunPromptCtx and RunFromStdinCtx - streaming.go: wire cmd.Dir in StreamPrompt - claude_test.go: helper-process hooks that echo cwd, plus TestRunPrompt_WorkingDirectory and TestStreamPrompt_WorkingDirectory --- pkg/claude/claude.go | 6 +++ pkg/claude/claude_test.go | 107 ++++++++++++++++++++++++++++++++++++++ pkg/claude/options.go | 2 + pkg/claude/streaming.go | 3 ++ 4 files changed, 118 insertions(+) diff --git a/pkg/claude/claude.go b/pkg/claude/claude.go index 4517117..11f1c86 100644 --- a/pkg/claude/claude.go +++ b/pkg/claude/claude.go @@ -71,6 +71,9 @@ func (c *ClaudeClient) RunPromptCtx(ctx context.Context, prompt string, opts *Ru args := BuildArgs(prompt, opts) cmd := execCommand(ctx, c.BinPath, args...) + if opts.WorkingDirectory != "" { + cmd.Dir = opts.WorkingDirectory + } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -165,6 +168,9 @@ func (c *ClaudeClient) RunFromStdinCtx(ctx context.Context, stdin io.Reader, pro args := BuildArgs(prompt, opts) cmd := execCommand(ctx, c.BinPath, args...) + if opts.WorkingDirectory != "" { + cmd.Dir = opts.WorkingDirectory + } cmd.Stdin = stdin var stdout, stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/pkg/claude/claude_test.go b/pkg/claude/claude_test.go index dd2a646..b718c57 100644 --- a/pkg/claude/claude_test.go +++ b/pkg/claude/claude_test.go @@ -46,6 +46,26 @@ func TestHelperProcess(t *testing.T) { } defer os.Exit(0) + if os.Getenv("GO_HELPER_PRINT_PWD") == "1" { + wd, err := os.Getwd() + if err != nil { + os.Stderr.WriteString(err.Error()) + os.Exit(1) + } + os.Stdout.Write([]byte(wd)) + return + } + + if os.Getenv("GO_HELPER_STREAM_PWD") == "1" { + wd, err := os.Getwd() + if err != nil { + os.Stderr.WriteString(err.Error()) + os.Exit(1) + } + os.Stdout.Write([]byte(`{"type":"result","subtype":"success","total_cost_usd":0.0,"duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"result":"` + wd + `","session_id":"test-session"}` + "\n")) + return + } + output := os.Getenv("GO_HELPER_OUTPUT") exitCode := int(os.Getenv("GO_HELPER_EXIT_CODE")[0] - '0') @@ -107,6 +127,45 @@ func TestRunPrompt(t *testing.T) { } } +func TestRunPrompt_WorkingDirectory(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + wantDir := t.TempDir() + execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcess", "--", name} + cs = append(cs, arg...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = []string{ + "GO_WANT_HELPER_PROCESS=1", + "GO_HELPER_PRINT_PWD=1", + } + return cmd + } + + client := &ClaudeClient{BinPath: "claude"} + result, err := client.RunPrompt("Hello, Claude", &RunOptions{ + Format: TextOutput, + WorkingDirectory: wantDir, + }) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + gotDir, err := filepath.EvalSymlinks(strings.TrimSpace(result.Result)) + if err != nil { + t.Fatalf("EvalSymlinks(result) error = %v", err) + } + resolvedWantDir, err := filepath.EvalSymlinks(wantDir) + if err != nil { + t.Fatalf("EvalSymlinks(wantDir) error = %v", err) + } + if gotDir != resolvedWantDir { + t.Fatalf("expected cwd %q, got %q", resolvedWantDir, gotDir) + } +} + func TestStreamPrompt(t *testing.T) { // For streaming test, we'll create a simple mock that sends predefined messages originalExecCommand := execCommand @@ -1144,6 +1203,54 @@ func TestRunPromptCtx_ContextDeadlineExceeded(t *testing.T) { }) } +func TestStreamPrompt_WorkingDirectory(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + wantDir := t.TempDir() + execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcess", "--", name} + cs = append(cs, arg...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = []string{ + "GO_WANT_HELPER_PROCESS=1", + "GO_HELPER_STREAM_PWD=1", + } + return cmd + } + + client := &ClaudeClient{BinPath: "claude"} + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + messageCh, errCh := client.StreamPrompt(ctx, "Test streaming", &RunOptions{ + WorkingDirectory: wantDir, + }) + + var got Message + for msg := range messageCh { + got = msg + } + for err := range errCh { + if err != nil { + t.Fatalf("Streaming error: %v", err) + } + } + gotDir, err := filepath.EvalSymlinks(got.Result) + if err != nil { + t.Fatalf("EvalSymlinks(result) error = %v", err) + } + resolvedWantDir, err := filepath.EvalSymlinks(wantDir) + if err != nil { + t.Fatalf("EvalSymlinks(wantDir) error = %v", err) + } + if gotDir != resolvedWantDir { + t.Fatalf("expected cwd %q, got %q", resolvedWantDir, gotDir) + } +} + func TestStreamPrompt_ContextCancellation(t *testing.T) { originalExecCommand := execCommand defer func() { diff --git a/pkg/claude/options.go b/pkg/claude/options.go index 67dc5d8..65dcc5c 100644 --- a/pkg/claude/options.go +++ b/pkg/claude/options.go @@ -119,6 +119,8 @@ type RunOptions struct { // Additional CLI flags // AddDirectories specifies additional directories to include in context AddDirectories []string + // WorkingDirectory sets the process working directory for Claude CLI execution + WorkingDirectory string // PrintMode enables print mode output (required for some flags) PrintMode bool diff --git a/pkg/claude/streaming.go b/pkg/claude/streaming.go index 1cc9363..4d4f6f4 100644 --- a/pkg/claude/streaming.go +++ b/pkg/claude/streaming.go @@ -55,6 +55,9 @@ func (c *ClaudeClient) StreamPrompt(ctx context.Context, prompt string, opts *Ru // Create a custom command that supports context cmd := execCommand(ctx, c.BinPath, args...) + if streamOpts.WorkingDirectory != "" { + cmd.Dir = streamOpts.WorkingDirectory + } stdout, err := cmd.StdoutPipe() if err != nil { From 6c252cabc0bbe3a93e73936ccacbb7bff637d6f6 Mon Sep 17 00:00:00 2001 From: lancekrogers Date: Fri, 17 Apr 2026 01:47:55 -0600 Subject: [PATCH 2/2] feat(claude): wire WorkingDirectory through subagent and dangerous paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #17 review findings. - subagent.go: ToRunOptions now sets opts.WorkingDirectory from sc.WorkingDirectory, inheriting from parentOpts when empty. Removes the stale "would need to be added to RunOptions if needed" TODO. Closes the gap where SubagentConfig.WorkingDirectory was a public, JSON-tagged field that silently did nothing. - dangerous/client.go: runWithDangerousFlags now sets cmd.Dir from opts.WorkingDirectory. Introduces a package-level execCommand test seam mirroring pkg/claude so the behavior is mock-testable. - options.go: expanded WorkingDirectory godoc — empty inherits parent cwd (backward compatible), must be an absolute, existing path at call time, per-call (no shared state). Tests: - TestSubagentConfig_ToRunOptions gains three subtests (subagent wins, inherit from parent, both empty). - TestRunFromStdin_WorkingDirectory covers the stdin entry point. - TestWorkingDirectory_EmptyInheritsParentCwd asserts the unset case across RunPrompt / RunFromStdin / StreamPrompt. - TestBYPASS_ALL_PERMISSIONS_CTX_WorkingDirectory mirrors the happy-path test in the dangerous subpackage. All new tests pass under -race. --- pkg/claude/claude_test.go | 139 ++++++++++++++++++++++++++++ pkg/claude/dangerous/client.go | 8 +- pkg/claude/dangerous/client_test.go | 67 ++++++++++++++ pkg/claude/options.go | 7 +- pkg/claude/subagent.go | 6 +- pkg/claude/subagent_test.go | 42 +++++++++ 6 files changed, 266 insertions(+), 3 deletions(-) diff --git a/pkg/claude/claude_test.go b/pkg/claude/claude_test.go index b718c57..f14efdb 100644 --- a/pkg/claude/claude_test.go +++ b/pkg/claude/claude_test.go @@ -166,6 +166,145 @@ func TestRunPrompt_WorkingDirectory(t *testing.T) { } } +func TestRunFromStdin_WorkingDirectory(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + wantDir := t.TempDir() + execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcess", "--", name} + cs = append(cs, arg...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = []string{ + "GO_WANT_HELPER_PROCESS=1", + "GO_HELPER_PRINT_PWD=1", + } + return cmd + } + + client := &ClaudeClient{BinPath: "claude"} + result, err := client.RunFromStdin(strings.NewReader("stdin input"), "Hello, Claude", &RunOptions{ + Format: TextOutput, + WorkingDirectory: wantDir, + }) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + gotDir, err := filepath.EvalSymlinks(strings.TrimSpace(result.Result)) + if err != nil { + t.Fatalf("EvalSymlinks(result) error = %v", err) + } + resolvedWantDir, err := filepath.EvalSymlinks(wantDir) + if err != nil { + t.Fatalf("EvalSymlinks(wantDir) error = %v", err) + } + if gotDir != resolvedWantDir { + t.Fatalf("expected cwd %q, got %q", resolvedWantDir, gotDir) + } +} + +// TestWorkingDirectory_EmptyInheritsParentCwd verifies that when WorkingDirectory +// is empty, the Claude subprocess inherits the parent process's cwd across all +// three entry points. Guards against accidentally always setting cmd.Dir. +func TestWorkingDirectory_EmptyInheritsParentCwd(t *testing.T) { + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + parentCwd, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd() error = %v", err) + } + resolvedParent, err := filepath.EvalSymlinks(parentCwd) + if err != nil { + t.Fatalf("EvalSymlinks(parent) error = %v", err) + } + + assertInheritedCwd := func(t *testing.T, got string) { + t.Helper() + resolved, err := filepath.EvalSymlinks(strings.TrimSpace(got)) + if err != nil { + t.Fatalf("EvalSymlinks(result) error = %v", err) + } + if resolved != resolvedParent { + t.Fatalf("expected child to inherit parent cwd %q, got %q", resolvedParent, resolved) + } + } + + t.Run("RunPrompt", func(t *testing.T) { + execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcess", "--", name} + cs = append(cs, arg...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = []string{ + "GO_WANT_HELPER_PROCESS=1", + "GO_HELPER_PRINT_PWD=1", + } + return cmd + } + + client := &ClaudeClient{BinPath: "claude"} + result, err := client.RunPrompt("Hello, Claude", &RunOptions{Format: TextOutput}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + assertInheritedCwd(t, result.Result) + }) + + t.Run("RunFromStdin", func(t *testing.T) { + execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcess", "--", name} + cs = append(cs, arg...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = []string{ + "GO_WANT_HELPER_PROCESS=1", + "GO_HELPER_PRINT_PWD=1", + } + return cmd + } + + client := &ClaudeClient{BinPath: "claude"} + result, err := client.RunFromStdin(strings.NewReader("in"), "Hello, Claude", &RunOptions{Format: TextOutput}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + assertInheritedCwd(t, result.Result) + }) + + t.Run("StreamPrompt", func(t *testing.T) { + execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcess", "--", name} + cs = append(cs, arg...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = []string{ + "GO_WANT_HELPER_PROCESS=1", + "GO_HELPER_STREAM_PWD=1", + } + return cmd + } + + client := &ClaudeClient{BinPath: "claude"} + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + messageCh, errCh := client.StreamPrompt(ctx, "Test", &RunOptions{}) + + var got Message + for msg := range messageCh { + got = msg + } + for err := range errCh { + if err != nil { + t.Fatalf("Streaming error: %v", err) + } + } + assertInheritedCwd(t, got.Result) + }) +} + func TestStreamPrompt(t *testing.T) { // For streaming test, we'll create a simple mock that sends predefined messages originalExecCommand := execCommand diff --git a/pkg/claude/dangerous/client.go b/pkg/claude/dangerous/client.go index f9a2115..1e3b973 100644 --- a/pkg/claude/dangerous/client.go +++ b/pkg/claude/dangerous/client.go @@ -11,6 +11,9 @@ import ( "github.com/lancekrogers/claude-code-go/pkg/claude" ) +// execCommand is overridable in tests to mock subprocess execution. +var execCommand = exec.CommandContext + // DangerousClient provides access to unsafe Claude Code operations // WARNING: This client can bypass critical security controls // REQUIREMENT: Must set CLAUDE_ENABLE_DANGEROUS="i-accept-all-risks" @@ -193,7 +196,10 @@ func (c *DangerousClient) runWithDangerousFlags(ctx context.Context, prompt stri } // Create command with context support - cmd := exec.CommandContext(ctx, c.ClaudeClient.BinPath, args...) + cmd := execCommand(ctx, c.ClaudeClient.BinPath, args...) + if opts != nil && opts.WorkingDirectory != "" { + cmd.Dir = opts.WorkingDirectory + } // Set custom environment if requested if useCustomEnv && len(c.envVars) > 0 { diff --git a/pkg/claude/dangerous/client_test.go b/pkg/claude/dangerous/client_test.go index 0f28169..48f55a0 100644 --- a/pkg/claude/dangerous/client_test.go +++ b/pkg/claude/dangerous/client_test.go @@ -3,12 +3,32 @@ package dangerous import ( "context" "os" + "os/exec" + "path/filepath" "strings" "testing" "github.com/lancekrogers/claude-code-go/pkg/claude" ) +// TestHelperProcess isn't a real test - it's used to mock exec.Command in this package. +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + defer os.Exit(0) + + if os.Getenv("GO_HELPER_PRINT_PWD") == "1" { + wd, err := os.Getwd() + if err != nil { + os.Stderr.WriteString(err.Error()) + os.Exit(1) + } + os.Stdout.Write([]byte(`{"type":"result","subtype":"success","total_cost_usd":0.0,"duration_ms":1,"duration_api_ms":1,"is_error":false,"num_turns":1,"result":"` + wd + `","session_id":"test-session"}` + "\n")) + return + } +} + func TestNewDangerousClient_RequiresEnvironmentVariable(t *testing.T) { // Clear environment originalEnv := os.Getenv("CLAUDE_ENABLE_DANGEROUS") @@ -521,3 +541,50 @@ func TestDangerousClient_ResetClearsAll(t *testing.T) { t.Errorf("Expected 0 warnings after reset, got %d", len(client.GetSecurityWarnings())) } } + +func TestBYPASS_ALL_PERMISSIONS_CTX_WorkingDirectory(t *testing.T) { + originalDangerous := os.Getenv("CLAUDE_ENABLE_DANGEROUS") + defer os.Setenv("CLAUDE_ENABLE_DANGEROUS", originalDangerous) + os.Setenv("CLAUDE_ENABLE_DANGEROUS", "i-accept-all-risks") + + originalExecCommand := execCommand + defer func() { + execCommand = originalExecCommand + }() + + wantDir := t.TempDir() + execCommand = func(_ context.Context, name string, arg ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcess", "--", name} + cs = append(cs, arg...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = []string{ + "GO_WANT_HELPER_PROCESS=1", + "GO_HELPER_PRINT_PWD=1", + } + return cmd + } + + client, err := NewDangerousClient("claude") + if err != nil { + t.Fatalf("NewDangerousClient() error = %v", err) + } + + result, err := client.BYPASS_ALL_PERMISSIONS_CTX(context.Background(), "Hello", &claude.RunOptions{ + Format: claude.JSONOutput, + WorkingDirectory: wantDir, + }) + if err != nil { + t.Fatalf("BYPASS_ALL_PERMISSIONS_CTX() error = %v", err) + } + gotDir, err := filepath.EvalSymlinks(strings.TrimSpace(result.Result)) + if err != nil { + t.Fatalf("EvalSymlinks(result) error = %v", err) + } + resolvedWantDir, err := filepath.EvalSymlinks(wantDir) + if err != nil { + t.Fatalf("EvalSymlinks(wantDir) error = %v", err) + } + if gotDir != resolvedWantDir { + t.Fatalf("expected cwd %q, got %q", resolvedWantDir, gotDir) + } +} diff --git a/pkg/claude/options.go b/pkg/claude/options.go index 65dcc5c..d659304 100644 --- a/pkg/claude/options.go +++ b/pkg/claude/options.go @@ -119,7 +119,12 @@ type RunOptions struct { // Additional CLI flags // AddDirectories specifies additional directories to include in context AddDirectories []string - // WorkingDirectory sets the process working directory for Claude CLI execution + // WorkingDirectory sets the process working directory (cmd.Dir) for the + // Claude CLI subprocess. If empty, the subprocess inherits the parent + // process's current directory (backward compatible). Must be an + // absolute path that exists at the time Run/Stream is called; otherwise + // exec will fail to start the subprocess. Set per call — no shared + // state across invocations. WorkingDirectory string // PrintMode enables print mode output (required for some flags) PrintMode bool diff --git a/pkg/claude/subagent.go b/pkg/claude/subagent.go index 5463c90..94a3c56 100644 --- a/pkg/claude/subagent.go +++ b/pkg/claude/subagent.go @@ -77,7 +77,11 @@ func (sc *SubagentConfig) ToRunOptions(parentOpts *RunOptions) *RunOptions { } // Use subagent's working directory or inherit from parent - // Note: WorkingDirectory would need to be added to RunOptions if needed + if sc.WorkingDirectory != "" { + opts.WorkingDirectory = sc.WorkingDirectory + } else if parentOpts != nil { + opts.WorkingDirectory = parentOpts.WorkingDirectory + } // Inherit MCP config from parent if parentOpts != nil { diff --git a/pkg/claude/subagent_test.go b/pkg/claude/subagent_test.go index 4851f4a..41ed60a 100644 --- a/pkg/claude/subagent_test.go +++ b/pkg/claude/subagent_test.go @@ -169,6 +169,48 @@ func TestSubagentConfig_ToRunOptions(t *testing.T) { t.Errorf("MaxTurns = %d, want subagent's %d", opts.MaxTurns, 3) } }) + + t.Run("subagent WorkingDirectory overrides parent", func(t *testing.T) { + config := &SubagentConfig{ + Description: "Test agent", + Prompt: "You are a test agent", + WorkingDirectory: "/tmp/subagent-wd", + } + parentOpts := &RunOptions{WorkingDirectory: "/tmp/parent-wd"} + + opts := config.ToRunOptions(parentOpts) + + if opts.WorkingDirectory != "/tmp/subagent-wd" { + t.Errorf("WorkingDirectory = %q, want subagent's %q", opts.WorkingDirectory, "/tmp/subagent-wd") + } + }) + + t.Run("WorkingDirectory inherits from parent when subagent empty", func(t *testing.T) { + config := &SubagentConfig{ + Description: "Test agent", + Prompt: "You are a test agent", + } + parentOpts := &RunOptions{WorkingDirectory: "/tmp/parent-wd"} + + opts := config.ToRunOptions(parentOpts) + + if opts.WorkingDirectory != "/tmp/parent-wd" { + t.Errorf("WorkingDirectory = %q, want inherited %q", opts.WorkingDirectory, "/tmp/parent-wd") + } + }) + + t.Run("WorkingDirectory empty when neither set", func(t *testing.T) { + config := &SubagentConfig{ + Description: "Test agent", + Prompt: "You are a test agent", + } + + opts := config.ToRunOptions(nil) + + if opts.WorkingDirectory != "" { + t.Errorf("WorkingDirectory = %q, want empty", opts.WorkingDirectory) + } + }) } func TestNewSubagentManager(t *testing.T) {