From eb4dc0366065c0f2399eb8c387449cf038bbe539 Mon Sep 17 00:00:00 2001 From: Michael Jerge <112141470+mmjerge@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:58:12 -0400 Subject: [PATCH] fix(transport): state what options.user does and fail with a clear error The user option is passed to subprocess.Popen(user=...). Popen resolves it with getpwnam() and runs the CLI as that OS account. The docstring called it a session user identifier. Callers who believed it got: CLIConnectionError: Failed to start Claude Code: "getpwnam(): name not found: 'customer-42'" Correct the docstring. Translate KeyError, PermissionError and ValueError from the spawn into an error that names options.user and explains the semantics, only when the option is set. Add regression tests for both paths; the first fails without the fix. --- .../_internal/transport/subprocess_cli.py | 20 +++++ src/claude_agent_sdk/types.py | 10 ++- tests/test_transport.py | 81 +++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 58abc438d..cb9bb4775 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -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 diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 308b76cb7..a961ca4f5 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -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. diff --git a/tests/test_transport.py b/tests/test_transport.py index 2a0ed35bf..0ea46223c 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -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