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