Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions pkg/executor/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,23 @@ type CodexRunner interface {
// stripAnthropicKey scopes ANTHROPIC_API_KEY filtering to first-class --codex runs;
// external codex review in default claude mode keeps the host env intact so custom
// codex wrappers proxying through Anthropic (e.g., scripts/codex-as-claude/codex-as-claude.sh) keep
// authenticating. CLAUDECODE is always stripped regardless of mode to prevent
// nested-session errors when codex is launched from inside a Claude Code session.
// authenticating. the Claude Code session markers are always stripped regardless of mode
// to prevent nested-session errors when codex is launched from inside a Claude Code session.
type execCodexRunner struct {
stdin io.Reader
stripAnthropicKey bool
}

// childEnv builds the codex child-process env. CLAUDECODE is always stripped to
// prevent nested-session errors. ANTHROPIC_API_KEY is stripped only when the
// caller requested it (first-class --codex mode); default-claude external codex
// review passes the key through so custom Anthropic-proxying wrappers keep working.
// childEnv builds the codex child-process env. the Claude Code session markers (see
// sessionEnvVars) are always stripped to prevent nested-session errors.
// ANTHROPIC_API_KEY is stripped only when the caller requested it (first-class --codex
// mode); default-claude external codex review passes the key through so custom
// Anthropic-proxying wrappers keep working.
func (r *execCodexRunner) childEnv(env []string) []string {
if r.stripAnthropicKey {
return filterEnv(env, "ANTHROPIC_API_KEY", "CLAUDECODE")
return filterEnv(env, append([]string{"ANTHROPIC_API_KEY"}, sessionEnvVars...)...)
}
return filterEnv(env, "CLAUDECODE")
return filterEnv(env, sessionEnvVars...)
}

func (r *execCodexRunner) Run(ctx context.Context, name string, args ...string) (CodexStreams, func() error, error) {
Expand Down
24 changes: 24 additions & 0 deletions pkg/executor/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,30 @@ func TestExecCodexRunner_childEnv(t *testing.T) {
env: []string{"OPENAI_API_KEY=ok", "FOO=bar"},
want: []string{"OPENAI_API_KEY=ok", "FOO=bar"},
},
{
name: "every Claude Code session marker stripped regardless of mode",
stripAnthropicKey: false,
env: []string{
"PATH=/usr/bin", "CLAUDECODE=1", "CLAUDE_CODE_ENTRYPOINT=cli",
"CLAUDE_CODE_EXECPATH=/usr/bin/claude", "CLAUDE_CODE_SESSION_ID=abc",
"CLAUDE_CODE_CHILD_SESSION=1", "CLAUDE_CODE_BRIDGE_SESSION_ID=def",
"CLAUDE_CODE_MESSAGING_SOCKET=/tmp/sock", "CLAUDE_CODE_MESSAGING_TOKEN=tok",
"CLAUDE_PID=123", "CLAUDE_EFFORT=high", "HOME=/home/user",
},
want: []string{"PATH=/usr/bin", "HOME=/home/user"},
},
{
name: "keeps user-set CLAUDE_CODE_ config vars",
stripAnthropicKey: true,
env: []string{
"CLAUDE_CODE_USE_BEDROCK=1", "CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192",
"CLAUDE_CONFIG_DIR=/custom/config", "CLAUDE_CODE_SESSION_ID=abc",
},
want: []string{
"CLAUDE_CODE_USE_BEDROCK=1", "CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192",
"CLAUDE_CONFIG_DIR=/custom/config",
},
},
}

for _, tc := range tests {
Expand Down
38 changes: 31 additions & 7 deletions pkg/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ 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

// build child env: always strip CLAUDECODE (prevents nested session errors); strip
// build child env: always strip the Claude Code session markers (prevents nested
// session errors, see sessionEnvVars); 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.
Expand Down Expand Up @@ -197,15 +198,38 @@ 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.
// sessionEnvVars are the per-session markers Claude Code sets on processes it spawns.
// any one of them left in a child env makes the spawned claude behave as a nested session:
// it attaches to the parent's session plumbing, never writes its own transcript, and a PTY
// wrapper such as fya then waits for output that never arrives until its turn timeout fires,
// burning a full iteration per attempt with no work done. CLAUDECODE alone covered this
// before Claude Code 2.1.x introduced the rest.
//
// deliberately an explicit list rather than a CLAUDE_CODE_* prefix strip: user-set config
// vars share that prefix (CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX,
// CLAUDE_CODE_MAX_OUTPUT_TOKENS, CLAUDE_CODE_SUBAGENT_MODEL) and must reach the child.
var sessionEnvVars = []string{
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_EXECPATH",
"CLAUDE_CODE_SESSION_ID",
"CLAUDE_CODE_CHILD_SESSION",
"CLAUDE_CODE_BRIDGE_SESSION_ID",
"CLAUDE_CODE_MESSAGING_SOCKET",
"CLAUDE_CODE_MESSAGING_TOKEN",
"CLAUDE_PID",
"CLAUDE_EFFORT",
}

// claudeChildEnv builds the environment for a child claude process. the Claude Code session
// markers (see sessionEnvVars) are 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, sessionEnvVars...)
}
return filterEnv(env, "ANTHROPIC_API_KEY", "CLAUDECODE")
return filterEnv(env, append([]string{"ANTHROPIC_API_KEY"}, sessionEnvVars...)...)
}

// filterEnv returns a copy of env with specified keys removed.
Expand Down
41 changes: 41 additions & 0 deletions pkg/executor/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,47 @@ func TestClaudeChildEnv(t *testing.T) {
preserveAPIKey: true,
want: []string{"ANTHROPIC_API_KEY_OLD=old", "ANTHROPIC_API_KEY=new"},
},
{
// names spelled out rather than derived from sessionEnvVars: dropping one from the
// production list must fail here, which a tautological loop over it would not catch
name: "strips every Claude Code session marker",
env: []string{
"PATH=/usr/bin", "CLAUDECODE=1", "CLAUDE_CODE_ENTRYPOINT=cli",
"CLAUDE_CODE_EXECPATH=/usr/bin/claude", "CLAUDE_CODE_SESSION_ID=abc",
"CLAUDE_CODE_CHILD_SESSION=1", "CLAUDE_CODE_BRIDGE_SESSION_ID=def",
"CLAUDE_CODE_MESSAGING_SOCKET=/tmp/sock", "CLAUDE_CODE_MESSAGING_TOKEN=tok",
"CLAUDE_PID=123", "CLAUDE_EFFORT=high", "HOME=/home/user",
},
preserveAPIKey: false,
want: []string{"PATH=/usr/bin", "HOME=/home/user"},
},
{
name: "preserve keeps api key but still strips every session marker",
env: []string{
"ANTHROPIC_API_KEY=secret", "CLAUDECODE=1", "CLAUDE_CODE_ENTRYPOINT=cli",
"CLAUDE_CODE_EXECPATH=/usr/bin/claude", "CLAUDE_CODE_SESSION_ID=abc",
"CLAUDE_CODE_CHILD_SESSION=1", "CLAUDE_CODE_BRIDGE_SESSION_ID=def",
"CLAUDE_CODE_MESSAGING_SOCKET=/tmp/sock", "CLAUDE_CODE_MESSAGING_TOKEN=tok",
"CLAUDE_PID=123", "CLAUDE_EFFORT=high", "PATH=/usr/bin",
},
preserveAPIKey: true,
want: []string{"ANTHROPIC_API_KEY=secret", "PATH=/usr/bin"},
},
{
// a CLAUDE_CODE_* prefix strip would eat these; they configure the child and must survive
name: "keeps user-set CLAUDE_CODE_ config vars while stripping session markers",
env: []string{
"CLAUDE_CODE_USE_BEDROCK=1", "CLAUDE_CODE_USE_VERTEX=1",
"CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192", "CLAUDE_CODE_SUBAGENT_MODEL=haiku",
"CLAUDE_CONFIG_DIR=/custom/config", "CLAUDE_CODE_SESSION_ID=abc", "CLAUDECODE=1",
},
preserveAPIKey: false,
want: []string{
"CLAUDE_CODE_USE_BEDROCK=1", "CLAUDE_CODE_USE_VERTEX=1",
"CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192", "CLAUDE_CODE_SUBAGENT_MODEL=haiku",
"CLAUDE_CONFIG_DIR=/custom/config",
},
},
}

for _, tc := range tests {
Expand Down