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
20 changes: 20 additions & 0 deletions src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,26 @@ async def connect(self) -> None:
error = CLINotFoundError(f"Claude Code not found at: {self._cli_path}")
self._exit_error = error
raise error from e
except (KeyError, PermissionError, ValueError) as e:
# These three are how subprocess.Popen(user=...) fails when
# options.user is set: KeyError from getpwnam() for an unknown
# account, PermissionError when the process may not switch to it,
# and ValueError on platforms without setreuid() (e.g. Windows).
# Callers who mistake `user` for a session/user identifier hit the
# KeyError case, so explain what the option actually does.
if self._options.user is not None:
error = CLIConnectionError(
f"Failed to start Claude Code as OS user"
f" {self._options.user!r}: {e}. The `user` option runs the"
" CLI subprocess as that operating-system account (it is"
" not a session or user identifier). The account must"
" exist and the current process needs permission to"
" switch to it; the option is not supported on Windows."
)
else:
error = CLIConnectionError(f"Failed to start Claude Code: {e}")
self._exit_error = error
raise error from e
except Exception as e:
error = CLIConnectionError(f"Failed to start Claude Code: {e}")
self._exit_error = error
Expand Down
10 changes: 9 additions & 1 deletion src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2139,7 +2139,15 @@ class ClaudeAgentOptions:
"""

user: str | None = None
"""Optional user identifier associated with the session."""
"""Run the Claude Code subprocess as this operating-system user.

The value is passed to ``subprocess.Popen(user=...)``, which resolves it
via ``getpwnam()`` and switches the child process to that account before
the CLI starts. It is **not** a session or analytics identifier.

Requires the account to exist and the current process to have permission
to switch users (typically root). Not supported on Windows.
"""

include_partial_messages: bool = False
"""Include partial/streaming message events in the output.
Expand Down
81 changes: 81 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -1731,6 +1731,87 @@ async def _test():

anyio.run(_test)

def test_connect_with_unknown_os_user_explains_user_option(self):
"""A getpwnam failure for options.user must name the option and
explain that it is an OS account, not a session/user identifier."""
from claude_agent_sdk._errors import CLIConnectionError

async def _test():
options = make_options(user="customer-42")

with patch(
"anyio.open_process", new_callable=AsyncMock
) as mock_open_process:
# Mock version check 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()

# Version check succeeds; the main spawn fails the way
# subprocess.Popen(user=...) fails for an unknown account.
mock_open_process.side_effect = [
mock_version_process,
KeyError("getpwnam(): name not found: 'customer-42'"),
]

transport = SubprocessCLITransport(
prompt="test",
options=options,
)

with pytest.raises(CLIConnectionError) as exc_info:
await transport.connect()

message = str(exc_info.value)
assert "'customer-42'" in message
assert "operating-system account" in message
assert "not a session or user identifier" in message

anyio.run(_test)

def test_connect_spawn_failure_without_user_keeps_generic_error(self):
"""The OS-user explanation must not leak into failures unrelated to
options.user."""
from claude_agent_sdk._errors import CLIConnectionError

async def _test():
options = make_options() # user not set

with patch(
"anyio.open_process", new_callable=AsyncMock
) as mock_open_process:
# Mock version check 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_open_process.side_effect = [
mock_version_process,
ValueError("embedded null byte"),
]

transport = SubprocessCLITransport(
prompt="test",
options=options,
)

with pytest.raises(CLIConnectionError) as exc_info:
await transport.connect()

message = str(exc_info.value)
assert "Failed to start Claude Code: embedded null byte" in message
assert "operating-system account" not in message

anyio.run(_test)

def test_build_command_with_sandbox_only(self):
"""Test building CLI command with sandbox settings (no existing settings)."""
import json
Expand Down