From 3fa4d3dcbb6f7d336dff523ca3fb7697afd0e719 Mon Sep 17 00:00:00 2001 From: Alek Beynenson Date: Wed, 12 Aug 2026 13:09:58 -0400 Subject: [PATCH] fix: strip all Claude Code session env vars from child processes Stripping CLAUDECODE alone stopped being enough once Claude Code 2.1.x began marking spawned processes with a family of per-session vars. Any one of them left in the child env makes the spawned claude behave as a nested session: it attaches to the parent's session plumbing and never writes its own transcript, so a PTY wrapper like fya waits for output that never arrives and each turn burns the full --turn-timeout (30m by default) having done no work and left no diagnostic beyond the timeout itself. Observed running ralphex from inside a Claude Code session on Claude Code 2.1.228: /proc//environ on the fya child showed CLAUDECODE correctly removed but CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_MESSAGING_SOCKET, CLAUDE_CODE_BRIDGE_SESSION_ID and friends still set. Iterations timed out at 30m each with no transcript written; relaunching with the full set scrubbed produced a transcript in ~85s and normal progress. Collect the markers in sessionEnvVars and strip them in both executors - claudeChildEnv and the codex runner's childEnv have the same defect, and the codex loop is reached in the same run. The list is explicit rather than a CLAUDE_CODE_* prefix strip because user-set config vars share that prefix (CLAUDE_CODE_USE_BEDROCK, _USE_VERTEX, _MAX_OUTPUT_TOKENS, _SUBAGENT_MODEL) and must still reach the child; a test covers that. The set is "every session-scoped marker" rather than a bisected minimal one - which var trips the nested-session detection is Claude Code's business, and a wrapper is better off dropping all of them, including any added later. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UhHLFUDbCTxtsg4zis4aP4 --- pkg/executor/codex.go | 17 ++++++++------- pkg/executor/codex_test.go | 24 ++++++++++++++++++++ pkg/executor/executor.go | 38 ++++++++++++++++++++++++++------ pkg/executor/executor_test.go | 41 +++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 15 deletions(-) diff --git a/pkg/executor/codex.go b/pkg/executor/codex.go index 10a8c9f3..02c68f54 100644 --- a/pkg/executor/codex.go +++ b/pkg/executor/codex.go @@ -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) { diff --git a/pkg/executor/codex_test.go b/pkg/executor/codex_test.go index 3959ae4f..19194ed0 100644 --- a/pkg/executor/codex_test.go +++ b/pkg/executor/codex_test.go @@ -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 { diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index 8eb90c3e..020e0bb1 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -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. @@ -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. diff --git a/pkg/executor/executor_test.go b/pkg/executor/executor_test.go index c1eb7730..68f8fc45 100644 --- a/pkg/executor/executor_test.go +++ b/pkg/executor/executor_test.go @@ -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 {