diff --git a/CLAUDE.md b/CLAUDE.md index ed6a0571..967375d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -291,6 +291,7 @@ GOOS=windows GOARCH=amd64 go build ./... - `session_timeout` config option: per-session timeout for claude (e.g., "30m", "1h"). Kills hanging sessions and continues to next iteration. CLI flag `--session-timeout` takes precedence. Disabled by default - `idle_timeout` config option: kills claude sessions when no output for specified duration (e.g., "5m"). Resets on each output line, only fires when session goes silent. CLI flag `--idle-timeout` takes precedence. Disabled by default - `move_plan_on_completion` config option: controls whether completed plans move to `docs/plans/completed/` on success. Default `true`. Disable for workflows that manage plan lifecycle externally (spec-driven tooling with separate archive steps) +- `preserve_anthropic_api_key` config option / `--preserve-anthropic-api-key` CLI flag: when true, `ANTHROPIC_API_KEY` is passed through to the child claude process. Required for users who authenticate Claude Code via API key rather than OAuth/keychain. Default `false` strips the key so a host-set value cannot silently override OAuth credentials and bill a different account. The merge sentinel `PreserveAnthropicAPIKeySet` lives only on `Values` (load-bearing for local-overrides-global merge); `Config` carries the resolved bool only. Plumbed: `Config.PreserveAnthropicAPIKey` → `pkg/processor/runner.go` → `ClaudeExecutor.PreserveAPIKey` → `execClaudeRunner.preserveAPIKey` → `claudeChildEnv()` in `pkg/executor/executor.go`. When enabled, the startup banner emits `auth: ANTHROPIC_API_KEY passthrough enabled` (in both task-execution and plan-creation modes) so users can spot wrong-context runs before claude bills the wrong account. CLAUDECODE is always stripped regardless of this flag (prevents nested-session errors) ### Local Project Config (.ralphex/) diff --git a/README.md b/README.md index b0e44fda..04aebcd5 100644 --- a/README.md +++ b/README.md @@ -566,6 +566,9 @@ ralphex --session-timeout=30m docs/plans/feature.md # kill claude session when no output for 5 minutes (idle detection) ralphex --idle-timeout=5m docs/plans/feature.md +# preserve ANTHROPIC_API_KEY in the claude child env (for API-key auth users) +ralphex --preserve-anthropic-api-key docs/plans/feature.md + # with web dashboard ralphex --serve docs/plans/feature.md @@ -596,6 +599,7 @@ ralphex --serve --port=3000 docs/plans/feature.md | `--session-timeout` | Per-session timeout for claude (e.g., `30m`, `1h`). Kills hanging sessions | disabled | | `--idle-timeout` | Kill claude session when no output for specified duration (e.g., `5m`). Resets on each output line | disabled | | `--worktree` | Run in isolated git worktree (full and tasks-only modes only) | false | +| `--preserve-anthropic-api-key` | Pass `ANTHROPIC_API_KEY` through to claude (for users authenticating Claude Code via API key rather than OAuth/keychain) | false | | `--plan` | Create plan interactively (provide description) | - | | `-s, --serve` | Start web dashboard for real-time streaming | false | | `-p, --port` | Web dashboard port (used with `--serve`) | 8080 | @@ -841,6 +845,7 @@ Provider-related CLI flags (`--claude-command`, `--claude-args`, `--external-rev | `finalize_enabled` | Enable finalize step after reviews | `false` | | `move_plan_on_completion` | Move completed plan file into `docs/plans/completed/` on success (disable for external plan-lifecycle workflows) | `true` | | `use_worktree` | Run each plan in an isolated git worktree (full and tasks-only modes only) | `false` | +| `preserve_anthropic_api_key` | Pass `ANTHROPIC_API_KEY` through to the claude child process (for users authenticating Claude Code via API key rather than OAuth/keychain). Default `false` strips the key so a host-set value cannot silently override OAuth credentials | `false` | | `plans_dir` | Plans directory | `docs/plans` | | `default_branch` | Override auto-detected default branch for review diffs | auto-detect | | `vcs_command` | VCS command for the git backend (set to a translation script for hg repos) | `git` | diff --git a/cmd/ralphex/main.go b/cmd/ralphex/main.go index bc580c43..297b460a 100644 --- a/cmd/ralphex/main.go +++ b/cmd/ralphex/main.go @@ -31,38 +31,39 @@ import ( // opts holds all command-line options. type opts struct { - MaxIterations int `short:"m" long:"max-iterations" description:"maximum task iterations (default: 50)"` - MaxExternalIterations int `long:"max-external-iterations" default:"0" description:"override external review iteration limit (0 = auto)"` - ReviewPatience int `long:"review-patience" default:"0" description:"terminate external review after N unchanged rounds (0 = disabled)"` - TaskModel string `long:"task-model" description:"model for task execution as model[:effort] (e.g., opus, opus:high, :medium)"` - ReviewModel string `long:"review-model" description:"model for review phases as model[:effort] (falls back to --task-model)"` - ClaudeCommand string `long:"claude-command" description:"override claude-compatible command for this run"` - ClaudeArgs string `long:"claude-args" description:"override claude-compatible command args for this run"` - ExternalReviewTool string `long:"external-review-tool" choice:"codex" choice:"custom" choice:"none" description:"override external review tool for this run"` - CustomReviewScript string `long:"custom-review-script" description:"override custom external review script for this run"` - Review bool `short:"r" long:"review" description:"skip task execution, run full review pipeline"` - ExternalOnly bool `short:"e" long:"external-only" description:"skip tasks and first review, run only external review loop"` - CodexOnly bool `short:"c" long:"codex-only" description:"alias for --external-only (deprecated)"` - TasksOnly bool `short:"t" long:"tasks-only" description:"run only task phase, skip all reviews"` - BaseRef string `short:"b" long:"base-ref" description:"override default branch for review diffs (branch name or commit hash)"` - Wait time.Duration `long:"wait" description:"wait duration on rate limit before retry (e.g. 1h, 30m)"` - SessionTimeout time.Duration `long:"session-timeout" description:"per-session timeout for claude (e.g. 30m, 1h)"` - IdleTimeout time.Duration `long:"idle-timeout" description:"kill claude session after no output for this duration (e.g. 5m, 10m)"` - SkipFinalize bool `long:"skip-finalize" description:"skip finalize step even if enabled in config"` - Worktree bool `long:"worktree" description:"run in isolated git worktree"` - Branch string `long:"branch" description:"override branch name for worktree/branch creation (default: derived from plan filename)"` - PlanDescription string `long:"plan" description:"create plan interactively (enter plan description)"` - Debug bool `short:"d" long:"debug" description:"enable debug logging"` - NoColor bool `long:"no-color" description:"disable color output"` - Version bool `short:"v" long:"version" description:"print version and exit"` - Serve bool `short:"s" long:"serve" description:"start web dashboard for real-time streaming"` - Port int `short:"p" long:"port" default:"8080" description:"web dashboard port"` - Host string `long:"host" default:"127.0.0.1" env:"RALPHEX_WEB_HOST" description:"web dashboard listen address"` - Watch []string `short:"w" long:"watch" description:"directories to watch for progress files (repeatable)"` - Init bool `long:"init" description:"initialize local .ralphex/ config directory in current project"` - Reset bool `long:"reset" description:"interactively reset global config to embedded defaults"` - DumpDefaults string `long:"dump-defaults" description:"extract raw embedded defaults to specified directory"` - ConfigDir string `long:"config-dir" env:"RALPHEX_CONFIG_DIR" description:"custom config directory"` + MaxIterations int `short:"m" long:"max-iterations" description:"maximum task iterations (default: 50)"` + MaxExternalIterations int `long:"max-external-iterations" default:"0" description:"override external review iteration limit (0 = auto)"` + ReviewPatience int `long:"review-patience" default:"0" description:"terminate external review after N unchanged rounds (0 = disabled)"` + TaskModel string `long:"task-model" description:"model for task execution as model[:effort] (e.g., opus, opus:high, :medium)"` + ReviewModel string `long:"review-model" description:"model for review phases as model[:effort] (falls back to --task-model)"` + ClaudeCommand string `long:"claude-command" description:"override claude-compatible command for this run"` + ClaudeArgs string `long:"claude-args" description:"override claude-compatible command args for this run"` + ExternalReviewTool string `long:"external-review-tool" choice:"codex" choice:"custom" choice:"none" description:"override external review tool for this run"` + CustomReviewScript string `long:"custom-review-script" description:"override custom external review script for this run"` + Review bool `short:"r" long:"review" description:"skip task execution, run full review pipeline"` + ExternalOnly bool `short:"e" long:"external-only" description:"skip tasks and first review, run only external review loop"` + CodexOnly bool `short:"c" long:"codex-only" description:"alias for --external-only (deprecated)"` + TasksOnly bool `short:"t" long:"tasks-only" description:"run only task phase, skip all reviews"` + BaseRef string `short:"b" long:"base-ref" description:"override default branch for review diffs (branch name or commit hash)"` + Wait time.Duration `long:"wait" description:"wait duration on rate limit before retry (e.g. 1h, 30m)"` + SessionTimeout time.Duration `long:"session-timeout" description:"per-session timeout for claude (e.g. 30m, 1h)"` + IdleTimeout time.Duration `long:"idle-timeout" description:"kill claude session after no output for this duration (e.g. 5m, 10m)"` + SkipFinalize bool `long:"skip-finalize" description:"skip finalize step even if enabled in config"` + PreserveAnthropicAPIKey bool `long:"preserve-anthropic-api-key" description:"pass ANTHROPIC_API_KEY through to claude (for users authenticating Claude Code via API key rather than OAuth/keychain)"` + Worktree bool `long:"worktree" description:"run in isolated git worktree"` + Branch string `long:"branch" description:"override branch name for worktree/branch creation (default: derived from plan filename)"` + PlanDescription string `long:"plan" description:"create plan interactively (enter plan description)"` + Debug bool `short:"d" long:"debug" description:"enable debug logging"` + NoColor bool `long:"no-color" description:"disable color output"` + Version bool `short:"v" long:"version" description:"print version and exit"` + Serve bool `short:"s" long:"serve" description:"start web dashboard for real-time streaming"` + Port int `short:"p" long:"port" default:"8080" description:"web dashboard port"` + Host string `long:"host" default:"127.0.0.1" env:"RALPHEX_WEB_HOST" description:"web dashboard listen address"` + Watch []string `short:"w" long:"watch" description:"directories to watch for progress files (repeatable)"` + Init bool `long:"init" description:"initialize local .ralphex/ config directory in current project"` + Reset bool `long:"reset" description:"interactively reset global config to embedded defaults"` + DumpDefaults string `long:"dump-defaults" description:"extract raw embedded defaults to specified directory"` + ConfigDir string `long:"config-dir" env:"RALPHEX_CONFIG_DIR" description:"custom config directory"` PlanFile string `positional-arg-name:"plan-file" description:"path to plan file (optional, uses fzf if omitted)"` @@ -127,12 +128,13 @@ func (stderrLog) Print(format string, args ...any) { // startupInfo holds parameters for printing startup information. type startupInfo struct { - PlanFile string - PlanDescription string // used for plan mode instead of PlanFile - Branch string - Mode processor.Mode - MaxIterations int - ProgressPath string + PlanFile string + PlanDescription string // used for plan mode instead of PlanFile + Branch string + Mode processor.Mode + MaxIterations int + ProgressPath string + PreserveAnthropicAPIKey bool // when true, surfaced in the banner so users can spot wrong-context runs before claude bills the wrong account } // executePlanRequest holds parameters for plan execution. @@ -552,11 +554,12 @@ func executePlan(ctx context.Context, o opts, req executePlanRequest) error { // print startup info printStartupInfo(startupInfo{ - PlanFile: req.PlanFile, - Branch: branch, - Mode: req.Mode, - MaxIterations: resolveMaxIterations(o.MaxIterations, req.Config), - ProgressPath: plr.baseLog.Path(), + PlanFile: req.PlanFile, + Branch: branch, + Mode: req.Mode, + MaxIterations: resolveMaxIterations(o.MaxIterations, req.Config), + ProgressPath: plr.baseLog.Path(), + PreserveAnthropicAPIKey: req.Config.PreserveAnthropicAPIKey, }, req.Colors) // create and run the runner @@ -935,7 +938,11 @@ func printStartupInfo(info startupInfo, colors *progress.Colors) { colors.Info().Printf("starting interactive plan creation\n") colors.Info().Printf("request: %s\n", info.PlanDescription) colors.Info().Printf("branch: %s (max %d iterations)\n", info.Branch, info.MaxIterations) - colors.Info().Printf("progress log: %s\n\n", toRelPath(info.ProgressPath)) + colors.Info().Printf("progress log: %s\n", toRelPath(info.ProgressPath)) + if info.PreserveAnthropicAPIKey { + colors.Warn().Printf("auth: ANTHROPIC_API_KEY passthrough enabled\n") + } + colors.Info().Printf("\n") return } @@ -945,6 +952,9 @@ func printStartupInfo(info startupInfo, colors *progress.Colors) { } colors.Info().Printf("starting ralphex loop (max %d iterations)%s\n", info.MaxIterations, modeStr) displayMeta(colors, 0, info.PlanFile, info.Branch, info.ProgressPath) + if info.PreserveAnthropicAPIKey { + colors.Warn().Printf("auth: ANTHROPIC_API_KEY passthrough enabled\n") + } colors.Info().Printf("\n") } @@ -989,11 +999,12 @@ func runPlanMode(ctx context.Context, o opts, req executePlanRequest, selector * // print startup info for plan mode printStartupInfo(startupInfo{ - PlanDescription: o.PlanDescription, - Branch: branch, - Mode: processor.ModePlan, - MaxIterations: maxIter, - ProgressPath: baseLog.Path(), + PlanDescription: o.PlanDescription, + Branch: branch, + Mode: processor.ModePlan, + MaxIterations: maxIter, + ProgressPath: baseLog.Path(), + PreserveAnthropicAPIKey: req.Config.PreserveAnthropicAPIKey, }, req.Colors) // create input collector @@ -1268,6 +1279,9 @@ func applyCLIOverrides(o opts, cfg *config.Config) { if o.SkipFinalize { cfg.FinalizeEnabled = false } + if o.PreserveAnthropicAPIKey { + cfg.PreserveAnthropicAPIKey = true + } if o.Worktree { cfg.WorktreeEnabled = true } diff --git a/cmd/ralphex/main_test.go b/cmd/ralphex/main_test.go index f9f8e712..6f7e160c 100644 --- a/cmd/ralphex/main_test.go +++ b/cmd/ralphex/main_test.go @@ -622,6 +622,35 @@ func TestSkipFinalizeFlag(t *testing.T) { }) } +func TestPreserveAnthropicAPIKeyFlag(t *testing.T) { + t.Run("flag enables when config disabled", func(t *testing.T) { + cfg := &config.Config{PreserveAnthropicAPIKey: false} + o := parseTestOpts(t, "--preserve-anthropic-api-key") + + applyCLIOverrides(o, cfg) + + assert.True(t, cfg.PreserveAnthropicAPIKey, "CLI flag should enable preserve in config") + }) + + t.Run("absent flag preserves config true", func(t *testing.T) { + cfg := &config.Config{PreserveAnthropicAPIKey: true} + o := parseTestOpts(t) + + applyCLIOverrides(o, cfg) + + assert.True(t, cfg.PreserveAnthropicAPIKey, "config-set true should be preserved when flag absent") + }) + + t.Run("absent flag preserves config false", func(t *testing.T) { + cfg := &config.Config{PreserveAnthropicAPIKey: false} + o := parseTestOpts(t) + + applyCLIOverrides(o, cfg) + + assert.False(t, cfg.PreserveAnthropicAPIKey) + }) +} + func TestProviderOverrideFlags(t *testing.T) { t.Run("claude_command_overrides_config", func(t *testing.T) { cfg := &config.Config{ClaudeCommand: "configured-claude"} @@ -927,6 +956,56 @@ func TestPrintStartupInfo(t *testing.T) { // verify it doesn't panic with empty plan printStartupInfo(info, colors) }) + + t.Run("shows auth passthrough line when preserve enabled", func(t *testing.T) { + info := startupInfo{ + PlanFile: "/path/to/plan.md", + Branch: "feature-branch", + Mode: processor.ModeFull, + MaxIterations: 50, + ProgressPath: "progress.txt", + PreserveAnthropicAPIKey: true, + } + out := captureStdout(t, func() { + printStartupInfo(info, colors) + }) + assert.Contains(t, out, "ANTHROPIC_API_KEY passthrough enabled", + "banner must surface API key passthrough so users notice wrong-context runs") + }) + + t.Run("hides auth line when preserve disabled", func(t *testing.T) { + info := startupInfo{ + PlanFile: "/path/to/plan.md", + Branch: "feature-branch", + Mode: processor.ModeFull, + MaxIterations: 50, + ProgressPath: "progress.txt", + PreserveAnthropicAPIKey: false, + } + out := captureStdout(t, func() { + printStartupInfo(info, colors) + }) + assert.NotContains(t, out, "passthrough", "no auth line when default-strip behavior") + }) + + t.Run("shows auth passthrough line in plan mode when preserve enabled", func(t *testing.T) { + // plan mode has its own early-return branch in printStartupInfo; the auth + // line must surface there too because passthrough is the only safety + // signal once the run is on the wrong account. + info := startupInfo{ + PlanDescription: "add health endpoint", + Branch: "plan-branch", + Mode: processor.ModePlan, + MaxIterations: 50, + ProgressPath: "progress.txt", + PreserveAnthropicAPIKey: true, + } + out := captureStdout(t, func() { + printStartupInfo(info, colors) + }) + assert.Contains(t, out, "ANTHROPIC_API_KEY passthrough enabled", + "plan mode banner must surface API key passthrough") + }) } func TestToRelPath(t *testing.T) { diff --git a/llms.txt b/llms.txt index ade802c0..2c2d4750 100644 --- a/llms.txt +++ b/llms.txt @@ -91,6 +91,9 @@ RALPHEX_CONFIG_DIR=~/my-config ralphex docs/plans/feature.md # use AWS Bedrock for Claude (Docker wrapper only) ralphex --claude-provider=bedrock docs/plans/feature.md RALPHEX_CLAUDE_PROVIDER=bedrock ralphex docs/plans/feature.md + +# preserve ANTHROPIC_API_KEY in the claude child env (for API-key auth users) +ralphex --preserve-anthropic-api-key docs/plans/feature.md ``` ## Requirements @@ -147,6 +150,8 @@ review_model = sonnet:medium **Plan move behavior:** `move_plan_on_completion` config option controls whether completed plans move to `docs/plans/completed/` on success. Default `true` (existing behavior). Set to `false` for workflows that manage plan file lifecycle externally, such as spec-driven tooling with separate archive steps. +**Preserving ANTHROPIC_API_KEY:** by default, ralphex strips `ANTHROPIC_API_KEY` from the child claude process so a host-set key cannot silently override OAuth/keychain credentials. If you authenticate Claude Code via API key (not OAuth), set `preserve_anthropic_api_key = true` in config or pass `--preserve-anthropic-api-key` on the CLI to keep the key in the child env. When passthrough is active, ralphex prints `auth: ANTHROPIC_API_KEY passthrough enabled` in the startup banner (in both task-execution and plan-creation modes) so wrong-context runs are visible before claude bills the wrong account. `CLAUDECODE` is always stripped regardless (prevents nested-session errors). + **Notifications** (`notify_*` fields in config): Optional alerts on completion/failure via `telegram`, `email`, `slack`, `webhook`, or `custom` script. Disabled by default. See `docs/notifications.md` for setup. Run `ralphex --init` to create local `.ralphex/` project config with commented-out defaults. diff --git a/pkg/config/config.go b/pkg/config/config.go index 1001ae08..a173b4a4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -74,6 +74,8 @@ type Config struct { FinalizeEnabled bool `json:"finalize_enabled"` FinalizeEnabledSet bool `json:"-"` // tracks if finalize_enabled was explicitly set in config + PreserveAnthropicAPIKey bool `json:"preserve_anthropic_api_key"` // when true, ANTHROPIC_API_KEY is passed through to the claude child process + MovePlanOnCompletion bool `json:"move_plan_on_completion"` WorktreeEnabled bool `json:"worktree_enabled"` @@ -280,48 +282,49 @@ func loadConfigFromDirs(globalDir, localDir string) (*Config, error) { // assemble config c := &Config{ - ClaudeCommand: values.ClaudeCommand, - ClaudeArgs: values.ClaudeArgs, - TaskModel: values.TaskModel, - ReviewModel: values.ReviewModel, - CodexEnabled: values.CodexEnabled, - CodexEnabledSet: values.CodexEnabledSet, - CodexCommand: values.CodexCommand, - CodexModel: values.CodexModel, - CodexReasoningEffort: values.CodexReasoningEffort, - CodexTimeoutMs: values.CodexTimeoutMs, - CodexTimeoutMsSet: values.CodexTimeoutMsSet, - CodexSandbox: values.CodexSandbox, - ExternalReviewTool: values.ExternalReviewTool, - CustomReviewScript: values.CustomReviewScript, - IterationDelayMs: values.IterationDelayMs, - IterationDelayMsSet: values.IterationDelayMsSet, - TaskRetryCount: values.TaskRetryCount, - TaskRetryCountSet: values.TaskRetryCountSet, - MaxIterations: values.MaxIterations, - MaxIterationsSet: values.MaxIterationsSet, - MaxExternalIterations: values.MaxExternalIterations, - ReviewPatience: values.ReviewPatience, - FinalizeEnabled: values.FinalizeEnabled, - FinalizeEnabledSet: values.FinalizeEnabledSet, - MovePlanOnCompletion: values.MovePlanOnCompletion, - WorktreeEnabled: values.WorktreeEnabled, - WorktreeEnabledSet: values.WorktreeEnabledSet, - PlansDir: values.PlansDir, - DefaultBranch: values.DefaultBranch, - VcsCommand: values.VcsCommand, - CommitTrailer: values.CommitTrailer, - WatchDirs: values.WatchDirs, - ClaudeErrorPatterns: values.ClaudeErrorPatterns, - CodexErrorPatterns: values.CodexErrorPatterns, - ClaudeLimitPatterns: values.ClaudeLimitPatterns, - CodexLimitPatterns: values.CodexLimitPatterns, - WaitOnLimit: values.WaitOnLimit, - WaitOnLimitSet: values.WaitOnLimitSet, - SessionTimeout: values.SessionTimeout, - SessionTimeoutSet: values.SessionTimeoutSet, - IdleTimeout: values.IdleTimeout, - IdleTimeoutSet: values.IdleTimeoutSet, + ClaudeCommand: values.ClaudeCommand, + ClaudeArgs: values.ClaudeArgs, + TaskModel: values.TaskModel, + ReviewModel: values.ReviewModel, + CodexEnabled: values.CodexEnabled, + CodexEnabledSet: values.CodexEnabledSet, + CodexCommand: values.CodexCommand, + CodexModel: values.CodexModel, + CodexReasoningEffort: values.CodexReasoningEffort, + CodexTimeoutMs: values.CodexTimeoutMs, + CodexTimeoutMsSet: values.CodexTimeoutMsSet, + CodexSandbox: values.CodexSandbox, + ExternalReviewTool: values.ExternalReviewTool, + CustomReviewScript: values.CustomReviewScript, + IterationDelayMs: values.IterationDelayMs, + IterationDelayMsSet: values.IterationDelayMsSet, + TaskRetryCount: values.TaskRetryCount, + TaskRetryCountSet: values.TaskRetryCountSet, + MaxIterations: values.MaxIterations, + MaxIterationsSet: values.MaxIterationsSet, + MaxExternalIterations: values.MaxExternalIterations, + ReviewPatience: values.ReviewPatience, + FinalizeEnabled: values.FinalizeEnabled, + FinalizeEnabledSet: values.FinalizeEnabledSet, + PreserveAnthropicAPIKey: values.PreserveAnthropicAPIKey, + MovePlanOnCompletion: values.MovePlanOnCompletion, + WorktreeEnabled: values.WorktreeEnabled, + WorktreeEnabledSet: values.WorktreeEnabledSet, + PlansDir: values.PlansDir, + DefaultBranch: values.DefaultBranch, + VcsCommand: values.VcsCommand, + CommitTrailer: values.CommitTrailer, + WatchDirs: values.WatchDirs, + ClaudeErrorPatterns: values.ClaudeErrorPatterns, + CodexErrorPatterns: values.CodexErrorPatterns, + ClaudeLimitPatterns: values.ClaudeLimitPatterns, + CodexLimitPatterns: values.CodexLimitPatterns, + WaitOnLimit: values.WaitOnLimit, + WaitOnLimitSet: values.WaitOnLimitSet, + SessionTimeout: values.SessionTimeout, + SessionTimeoutSet: values.SessionTimeoutSet, + IdleTimeout: values.IdleTimeout, + IdleTimeoutSet: values.IdleTimeoutSet, NotifyParams: notify.Params{ Channels: values.NotifyChannels, OnError: values.NotifyOnError, diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index ed77d039..22110a75 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -384,6 +384,61 @@ func TestLoad_MovePlanOnCompletion(t *testing.T) { } } +func TestLoad_PreserveAnthropicAPIKey(t *testing.T) { + testCases := []struct { + name string + configBody string + want bool + }{ + { + name: "default not set yields false", + configBody: "", + want: false, + }, + { + name: "explicit true yields true", + configBody: "preserve_anthropic_api_key = true", + want: true, + }, + { + name: "explicit false yields false", + configBody: "preserve_anthropic_api_key = false", + want: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "ralphex") + require.NoError(t, os.MkdirAll(configDir, 0o700)) + require.NoError(t, os.MkdirAll(filepath.Join(configDir, "prompts"), 0o700)) + require.NoError(t, os.MkdirAll(filepath.Join(configDir, "agents"), 0o700)) + + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config"), []byte(tc.configBody), 0o600)) + + cfg, err := Load(configDir) + require.NoError(t, err) + + assert.Equal(t, tc.want, cfg.PreserveAnthropicAPIKey) + }) + } +} + +func TestLoad_PreserveAnthropicAPIKey_InvalidValue(t *testing.T) { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "ralphex") + require.NoError(t, os.MkdirAll(configDir, 0o700)) + require.NoError(t, os.MkdirAll(filepath.Join(configDir, "prompts"), 0o700)) + require.NoError(t, os.MkdirAll(filepath.Join(configDir, "agents"), 0o700)) + + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config"), []byte("preserve_anthropic_api_key = notabool"), 0o600)) + + _, err := Load(configDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "preserve_anthropic_api_key") +} + func TestLoad_AllUserValues(t *testing.T) { tmpDir := t.TempDir() configDir := filepath.Join(tmpDir, "ralphex") diff --git a/pkg/config/defaults/config b/pkg/config/defaults/config index 7dcc8150..e6e179a0 100644 --- a/pkg/config/defaults/config +++ b/pkg/config/defaults/config @@ -98,6 +98,18 @@ external_review_tool = codex # default: false # finalize_enabled = false +# ------------------------------------------------------------------------------ +# claude authentication +# ------------------------------------------------------------------------------ + +# preserve_anthropic_api_key: when true, ANTHROPIC_API_KEY is passed through to +# the child claude process. enable this if you authenticate Claude Code via API +# key (ANTHROPIC_API_KEY) rather than OAuth/keychain login. by default the key +# is stripped so a host-set key cannot silently override OAuth credentials and +# bill a different account. +# default: false +# preserve_anthropic_api_key = false + # ------------------------------------------------------------------------------ # plan move behavior # ------------------------------------------------------------------------------ diff --git a/pkg/config/values.go b/pkg/config/values.go index c01c282a..8118a8e4 100644 --- a/pkg/config/values.go +++ b/pkg/config/values.go @@ -15,49 +15,51 @@ import ( // set in config. This allows distinguishing explicit false/0 from "not set", enabling // proper merge behavior where local config can override global config with zero values. type Values struct { - ClaudeCommand string - ClaudeArgs string - TaskModel string // model for task execution (e.g., "opus", "sonnet", "haiku") - ReviewModel string // model for review phases (falls back to TaskModel if empty) - ClaudeErrorPatterns []string // patterns to detect in claude output (e.g., rate limit messages) - CodexEnabled bool - CodexEnabledSet bool // tracks if codex_enabled was explicitly set - CodexCommand string - CodexModel string - CodexReasoningEffort string - CodexTimeoutMs int - CodexTimeoutMsSet bool // tracks if codex_timeout_ms was explicitly set - CodexSandbox string - CodexErrorPatterns []string // patterns to detect in codex output (e.g., rate limit messages) - ClaudeLimitPatterns []string // patterns to detect rate limits in claude output (for wait+retry) - CodexLimitPatterns []string // patterns to detect rate limits in codex output (for wait+retry) - WaitOnLimit time.Duration - WaitOnLimitSet bool // tracks if wait_on_limit was explicitly set - SessionTimeout time.Duration - SessionTimeoutSet bool // tracks if session_timeout was explicitly set - IdleTimeout time.Duration // kill session after no output for this duration - IdleTimeoutSet bool // tracks if idle_timeout was explicitly set - ExternalReviewTool string // "codex", "custom", or "none" - CustomReviewScript string // path to custom review script (when ExternalReviewTool = "custom") - IterationDelayMs int - IterationDelayMsSet bool // tracks if iteration_delay_ms was explicitly set - TaskRetryCount int - TaskRetryCountSet bool // tracks if task_retry_count was explicitly set - MaxIterations int - MaxIterationsSet bool // tracks if max_iterations was explicitly set - MaxExternalIterations int // override external review iteration limit (0 = auto) - ReviewPatience int // terminate external review after N unchanged rounds (0 = disabled) - FinalizeEnabled bool - FinalizeEnabledSet bool // tracks if finalize_enabled was explicitly set - MovePlanOnCompletion bool - MovePlanOnCompletionSet bool // tracks if move_plan_on_completion was explicitly set - WorktreeEnabled bool - WorktreeEnabledSet bool // tracks if use_worktree was explicitly set - VcsCommand string // custom VCS command (default: "git") - CommitTrailer string // trailer line to append to all commits (e.g., "Co-authored-by: ...") - PlansDir string - DefaultBranch string // override auto-detected default branch - WatchDirs []string // directories to watch for progress files + ClaudeCommand string + ClaudeArgs string + TaskModel string // model for task execution (e.g., "opus", "sonnet", "haiku") + ReviewModel string // model for review phases (falls back to TaskModel if empty) + ClaudeErrorPatterns []string // patterns to detect in claude output (e.g., rate limit messages) + CodexEnabled bool + CodexEnabledSet bool // tracks if codex_enabled was explicitly set + CodexCommand string + CodexModel string + CodexReasoningEffort string + CodexTimeoutMs int + CodexTimeoutMsSet bool // tracks if codex_timeout_ms was explicitly set + CodexSandbox string + CodexErrorPatterns []string // patterns to detect in codex output (e.g., rate limit messages) + ClaudeLimitPatterns []string // patterns to detect rate limits in claude output (for wait+retry) + CodexLimitPatterns []string // patterns to detect rate limits in codex output (for wait+retry) + WaitOnLimit time.Duration + WaitOnLimitSet bool // tracks if wait_on_limit was explicitly set + SessionTimeout time.Duration + SessionTimeoutSet bool // tracks if session_timeout was explicitly set + IdleTimeout time.Duration // kill session after no output for this duration + IdleTimeoutSet bool // tracks if idle_timeout was explicitly set + ExternalReviewTool string // "codex", "custom", or "none" + CustomReviewScript string // path to custom review script (when ExternalReviewTool = "custom") + IterationDelayMs int + IterationDelayMsSet bool // tracks if iteration_delay_ms was explicitly set + TaskRetryCount int + TaskRetryCountSet bool // tracks if task_retry_count was explicitly set + MaxIterations int + MaxIterationsSet bool // tracks if max_iterations was explicitly set + MaxExternalIterations int // override external review iteration limit (0 = auto) + ReviewPatience int // terminate external review after N unchanged rounds (0 = disabled) + FinalizeEnabled bool + FinalizeEnabledSet bool // tracks if finalize_enabled was explicitly set + PreserveAnthropicAPIKey bool + PreserveAnthropicAPIKeySet bool // tracks if preserve_anthropic_api_key was explicitly set + MovePlanOnCompletion bool + MovePlanOnCompletionSet bool // tracks if move_plan_on_completion was explicitly set + WorktreeEnabled bool + WorktreeEnabledSet bool // tracks if use_worktree was explicitly set + VcsCommand string // custom VCS command (default: "git") + CommitTrailer string // trailer line to append to all commits (e.g., "Co-authored-by: ...") + PlansDir string + DefaultBranch string // override auto-detected default branch + WatchDirs []string // directories to watch for progress files // notification settings NotifyChannels []string // channels to use: telegram, email, webhook, slack, custom @@ -296,6 +298,16 @@ func (vl *valuesLoader) parseValuesFromBytes(data []byte) (Values, error) { values.FinalizeEnabledSet = true } + // preserve ANTHROPIC_API_KEY in claude child env (for users authenticating Claude Code via API key) + if key, err := section.GetKey("preserve_anthropic_api_key"); err == nil { + val, boolErr := key.Bool() + if boolErr != nil { + return Values{}, fmt.Errorf("invalid preserve_anthropic_api_key: %w", boolErr) + } + values.PreserveAnthropicAPIKey = val + values.PreserveAnthropicAPIKeySet = true + } + // move plan on completion if key, err := section.GetKey("move_plan_on_completion"); err == nil { val, boolErr := key.Bool() @@ -502,6 +514,10 @@ func (dst *Values) mergeExtraFrom(src *Values) { dst.FinalizeEnabled = src.FinalizeEnabled dst.FinalizeEnabledSet = true } + if src.PreserveAnthropicAPIKeySet { + dst.PreserveAnthropicAPIKey = src.PreserveAnthropicAPIKey + dst.PreserveAnthropicAPIKeySet = true + } if src.MovePlanOnCompletionSet { dst.MovePlanOnCompletion = src.MovePlanOnCompletion dst.MovePlanOnCompletionSet = true diff --git a/pkg/config/values_test.go b/pkg/config/values_test.go index 2bed7f6f..04798dfd 100644 --- a/pkg/config/values_test.go +++ b/pkg/config/values_test.go @@ -460,6 +460,67 @@ func TestValuesLoader_Load_LocalOverridesMovePlanOnCompletion(t *testing.T) { assert.True(t, values.MovePlanOnCompletionSet) } +func TestValues_mergeFrom_PreserveAnthropicAPIKey(t *testing.T) { + t.Run("set flag merges", func(t *testing.T) { + dst := Values{PreserveAnthropicAPIKey: false, PreserveAnthropicAPIKeySet: false} + src := Values{PreserveAnthropicAPIKey: true, PreserveAnthropicAPIKeySet: true} + dst.mergeFrom(&src) + assert.True(t, dst.PreserveAnthropicAPIKey) + assert.True(t, dst.PreserveAnthropicAPIKeySet) + }) + + t.Run("unset flag preserves dst", func(t *testing.T) { + dst := Values{PreserveAnthropicAPIKey: true, PreserveAnthropicAPIKeySet: true} + src := Values{PreserveAnthropicAPIKey: false, PreserveAnthropicAPIKeySet: false} + dst.mergeFrom(&src) + assert.True(t, dst.PreserveAnthropicAPIKey) + assert.True(t, dst.PreserveAnthropicAPIKeySet) + }) + + t.Run("local explicit false overrides global true", func(t *testing.T) { + // safety case: this is the whole reason the *Set sentinel exists. + // without it, a local config that omits the key would zero-value-overwrite + // a global true; with explicit false we must propagate the disable. + dst := Values{PreserveAnthropicAPIKey: true, PreserveAnthropicAPIKeySet: true} + src := Values{PreserveAnthropicAPIKey: false, PreserveAnthropicAPIKeySet: true} + dst.mergeFrom(&src) + assert.False(t, dst.PreserveAnthropicAPIKey) + assert.True(t, dst.PreserveAnthropicAPIKeySet) + }) + + t.Run("local file overrides global through Load", func(t *testing.T) { + // end-to-end check: global sets true, local sets false → local wins. + // guards against a refactor that drops the sentinel handling. + tmpDir := t.TempDir() + globalCfg := filepath.Join(tmpDir, "global") + localCfg := filepath.Join(tmpDir, "local") + require.NoError(t, os.WriteFile(globalCfg, []byte(`preserve_anthropic_api_key = true`), 0o600)) + require.NoError(t, os.WriteFile(localCfg, []byte(`preserve_anthropic_api_key = false`), 0o600)) + + loader := newValuesLoader(defaultsFS) + values, err := loader.Load(localCfg, globalCfg) + require.NoError(t, err) + assert.False(t, values.PreserveAnthropicAPIKey) + assert.True(t, values.PreserveAnthropicAPIKeySet) + }) + + t.Run("local omitted preserves global true", func(t *testing.T) { + // the converse case: a local file that doesn't mention the key must not + // silently strip a globally-enabled passthrough. + tmpDir := t.TempDir() + globalCfg := filepath.Join(tmpDir, "global") + localCfg := filepath.Join(tmpDir, "local") + require.NoError(t, os.WriteFile(globalCfg, []byte(`preserve_anthropic_api_key = true`), 0o600)) + require.NoError(t, os.WriteFile(localCfg, []byte(`# unrelated comment`), 0o600)) + + loader := newValuesLoader(defaultsFS) + values, err := loader.Load(localCfg, globalCfg) + require.NoError(t, err) + assert.True(t, values.PreserveAnthropicAPIKey) + assert.True(t, values.PreserveAnthropicAPIKeySet) + }) +} + func TestValues_mergeFrom_MovePlanOnCompletion(t *testing.T) { t.Run("set flag merges", func(t *testing.T) { dst := Values{MovePlanOnCompletion: false, MovePlanOnCompletionSet: false} diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index f171bc41..93fb5d5b 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -58,8 +58,11 @@ type CommandRunner interface { // execClaudeRunner is the default command runner using os/exec. // when stdin is non-nil, it is connected to the child process's stdin (used to pass // the prompt via pipe instead of a -p CLI argument to avoid Windows 8191-char cmd limit). +// preserveAPIKey, when true, leaves ANTHROPIC_API_KEY intact in the child env (for users +// who authenticate Claude Code via API key rather than OAuth/keychain). type execClaudeRunner struct { - stdin io.Reader + stdin io.Reader + preserveAPIKey bool } func (r *execClaudeRunner) Run(ctx context.Context, name string, args ...string) (io.Reader, func() error, error) { @@ -72,8 +75,11 @@ func (r *execClaudeRunner) Run(ctx context.Context, name string, args ...string) // to ensure the entire process group is killed, not just the direct child cmd := exec.Command(name, args...) //nolint:noctx // intentional: we handle context cancellation via process group kill - // filter out ANTHROPIC_API_KEY (claude uses different auth) and CLAUDECODE (prevents nested session errors) - cmd.Env = filterEnv(os.Environ(), "ANTHROPIC_API_KEY", "CLAUDECODE") + // build child env: always strip CLAUDECODE (prevents nested session errors); strip + // ANTHROPIC_API_KEY by default so a host-set key cannot silently override OAuth/keychain + // auth and bill a different account. preserveAPIKey opts into keeping the key for users + // who authenticate Claude Code via API key. + cmd.Env = claudeChildEnv(os.Environ(), r.preserveAPIKey) // pass prompt via stdin when set (avoids Windows 8191-char command-line limit) if r.stdin != nil { @@ -174,6 +180,17 @@ func stripFlag(args []string, flag string) []string { return result } +// claudeChildEnv builds the environment for a child claude process. CLAUDECODE is always +// stripped to prevent nested-session errors. ANTHROPIC_API_KEY is stripped unless +// preserveAPIKey is true; preserving it is required for users who authenticate Claude Code +// via API key rather than OAuth/keychain. +func claudeChildEnv(env []string, preserveAPIKey bool) []string { + if preserveAPIKey { + return filterEnv(env, "CLAUDECODE") + } + return filterEnv(env, "ANTHROPIC_API_KEY", "CLAUDECODE") +} + // filterEnv returns a copy of env with specified keys removed. func filterEnv(env []string, keysToRemove ...string) []string { result := make([]string, 0, len(env)) @@ -214,17 +231,18 @@ type streamEvent struct { // ClaudeExecutor runs claude CLI commands with streaming JSON parsing. type ClaudeExecutor struct { - Command string // command to execute, defaults to "claude" - Args string // additional arguments (space-separated), defaults to standard args - ArgsSet bool // true when Args was explicitly set, including an empty value - Model string // model override (e.g., "opus", "sonnet", "haiku"); empty = CLI default - Effort string // reasoning effort override (e.g., "low", "medium", "high", "xhigh", "max"); empty = CLI default - OutputHandler func(text string) // called for each text chunk, can be nil - Debug bool // enable debug output - ErrorPatterns []string // patterns to detect in output (e.g., rate limit messages) - LimitPatterns []string // patterns to detect rate limits (checked before error patterns) - IdleTimeout time.Duration // kill session after this duration of no output, zero = disabled - cmdRunner CommandRunner // for testing, nil uses default + Command string // command to execute, defaults to "claude" + Args string // additional arguments (space-separated), defaults to standard args + ArgsSet bool // true when Args was explicitly set, including an empty value + Model string // model override (e.g., "opus", "sonnet", "haiku"); empty = CLI default + Effort string // reasoning effort override (e.g., "low", "medium", "high", "xhigh", "max"); empty = CLI default + OutputHandler func(text string) // called for each text chunk, can be nil + Debug bool // enable debug output + ErrorPatterns []string // patterns to detect in output (e.g., rate limit messages) + LimitPatterns []string // patterns to detect rate limits (checked before error patterns) + IdleTimeout time.Duration // kill session after this duration of no output, zero = disabled + PreserveAPIKey bool // when true, ANTHROPIC_API_KEY is passed through to the child; default false strips it + cmdRunner CommandRunner // for testing, nil uses default } // Run executes claude CLI with the given prompt and parses streaming JSON output. @@ -270,7 +288,7 @@ func (e *ClaudeExecutor) Run(ctx context.Context, prompt string) Result { if e.cmdRunner != nil { runner = e.cmdRunner } else { - runner = &execClaudeRunner{stdin: stdinReader} + runner = &execClaudeRunner{stdin: stdinReader, preserveAPIKey: e.PreserveAPIKey} } // set up idle timeout: derive a cancellable context that fires when no output diff --git a/pkg/executor/executor_test.go b/pkg/executor/executor_test.go index b92954c0..9045a15b 100644 --- a/pkg/executor/executor_test.go +++ b/pkg/executor/executor_test.go @@ -556,6 +556,53 @@ func TestFilterEnv(t *testing.T) { } } +func TestClaudeChildEnv(t *testing.T) { + tests := []struct { + name string + env []string + preserveAPIKey bool + want []string + }{ + { + name: "default strips both ANTHROPIC_API_KEY and CLAUDECODE", + env: []string{"PATH=/usr/bin", "CLAUDECODE=1", "ANTHROPIC_API_KEY=secret", "HOME=/home/user"}, + preserveAPIKey: false, + want: []string{"PATH=/usr/bin", "HOME=/home/user"}, + }, + { + name: "preserve keeps ANTHROPIC_API_KEY but still strips CLAUDECODE", + env: []string{"PATH=/usr/bin", "CLAUDECODE=1", "ANTHROPIC_API_KEY=secret", "HOME=/home/user"}, + preserveAPIKey: true, + want: []string{"PATH=/usr/bin", "ANTHROPIC_API_KEY=secret", "HOME=/home/user"}, + }, + { + name: "preserve with no api key in env keeps everything except CLAUDECODE", + env: []string{"PATH=/usr/bin", "CLAUDECODE=1", "HOME=/home/user"}, + preserveAPIKey: true, + want: []string{"PATH=/usr/bin", "HOME=/home/user"}, + }, + { + name: "default with no api key in env still strips CLAUDECODE", + env: []string{"PATH=/usr/bin", "CLAUDECODE=1", "HOME=/home/user"}, + preserveAPIKey: false, + want: []string{"PATH=/usr/bin", "HOME=/home/user"}, + }, + { + name: "preserve does not affect partial-match keys like ANTHROPIC_API_KEY_OLD", + env: []string{"ANTHROPIC_API_KEY_OLD=old", "ANTHROPIC_API_KEY=new", "CLAUDECODE=1"}, + preserveAPIKey: true, + want: []string{"ANTHROPIC_API_KEY_OLD=old", "ANTHROPIC_API_KEY=new"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := claudeChildEnv(tc.env, tc.preserveAPIKey) + assert.Equal(t, tc.want, got) + }) + } +} + func TestClaudeExecutor_parseStream_largeLines(t *testing.T) { // test that lines of arbitrary length are handled without limit diff --git a/pkg/processor/runner.go b/pkg/processor/runner.go index a6c371ab..d26a8559 100644 --- a/pkg/processor/runner.go +++ b/pkg/processor/runner.go @@ -140,6 +140,7 @@ func New(cfg Config, log Logger, holder *status.PhaseHolder) *Runner { claudeExec.ErrorPatterns = cfg.AppConfig.ClaudeErrorPatterns claudeExec.LimitPatterns = cfg.AppConfig.ClaudeLimitPatterns claudeExec.IdleTimeout = cfg.AppConfig.IdleTimeout + claudeExec.PreserveAPIKey = cfg.AppConfig.PreserveAnthropicAPIKey } taskModel, taskEffort := ParseModelEffort(cfg.TaskModel) claudeExec.Model, claudeExec.Effort = taskModel, taskEffort @@ -167,6 +168,7 @@ func New(cfg Config, log Logger, holder *status.PhaseHolder) *Runner { re.ErrorPatterns = cfg.AppConfig.ClaudeErrorPatterns re.LimitPatterns = cfg.AppConfig.ClaudeLimitPatterns re.IdleTimeout = cfg.AppConfig.IdleTimeout + re.PreserveAPIKey = cfg.AppConfig.PreserveAnthropicAPIKey } reviewExec = re }