Skip to content
Merged
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
5 changes: 4 additions & 1 deletion src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,11 @@ async def connect(self) -> None:
# Merge environment variables. CLAUDE_CODE_ENTRYPOINT defaults to
# sdk-py regardless of inherited process env; options.env can override
# it. CLAUDE_AGENT_SDK_VERSION is always set by the SDK.
# Filter out CLAUDECODE so SDK-spawned subprocesses don't think
# they're running inside a Claude Code parent (see #573).
inherited_env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}
process_env = {
**os.environ,
**inherited_env,
"CLAUDE_CODE_ENTRYPOINT": "sdk-py",
**self._options.env,
"CLAUDE_AGENT_SDK_VERSION": __version__,
Expand Down
94 changes: 94 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,100 @@ async def _test():

anyio.run(_test)

def test_claudecode_env_var_not_inherited(self):
"""Test that CLAUDECODE env var is filtered from the subprocess environment."""

async def _test():
options = make_options()

with (
patch.dict(os.environ, {"CLAUDECODE": "1"}),
patch(
"anyio.open_process", new_callable=AsyncMock
) as mock_open_process,
):
mock_version_process = MagicMock()
mock_version_process.stdout = MagicMock()
mock_version_process.stdout.receive = AsyncMock(
return_value=b"2.0.0 (Claude Code)"
)
mock_version_process.terminate = MagicMock()
mock_version_process.wait = AsyncMock()

mock_process = MagicMock()
mock_process.stdout = MagicMock()
mock_stdin = MagicMock()
mock_stdin.aclose = AsyncMock()
mock_process.stdin = mock_stdin
mock_process.returncode = None

mock_open_process.side_effect = [
mock_version_process,
mock_process,
]

transport = SubprocessCLITransport(
prompt="test",
options=options,
)
await transport.connect()

env_passed = mock_open_process.call_args_list[1].kwargs["env"]

# CLAUDECODE must NOT be inherited from the parent process
assert "CLAUDECODE" not in env_passed

# Other env vars should still be present
assert "CLAUDE_CODE_ENTRYPOINT" in env_passed
assert "CLAUDE_AGENT_SDK_VERSION" in env_passed

anyio.run(_test)

def test_claudecode_can_be_set_via_options_env(self):
"""Test that users can explicitly set CLAUDECODE via options.env."""

async def _test():
options = make_options(env={"CLAUDECODE": "1"})

with (
patch.dict(os.environ, {}, clear=False),
patch(
"anyio.open_process", new_callable=AsyncMock
) as mock_open_process,
):
mock_version_process = MagicMock()
mock_version_process.stdout = MagicMock()
mock_version_process.stdout.receive = AsyncMock(
return_value=b"2.0.0 (Claude Code)"
)
mock_version_process.terminate = MagicMock()
mock_version_process.wait = AsyncMock()

mock_process = MagicMock()
mock_process.stdout = MagicMock()
mock_stdin = MagicMock()
mock_stdin.aclose = AsyncMock()
mock_process.stdin = mock_stdin
mock_process.returncode = None

mock_open_process.side_effect = [
mock_version_process,
mock_process,
]

transport = SubprocessCLITransport(
prompt="test",
options=options,
)
await transport.connect()

env_passed = mock_open_process.call_args_list[1].kwargs["env"]

# Explicit options.env should be respected
assert env_passed.get("CLAUDECODE") == "1"

anyio.run(_test)

def test_connect_as_different_user(self):
"""Test connect as different user."""

Expand Down
Loading