diff --git a/src/claude_agent_sdk/__init__.py b/src/claude_agent_sdk/__init__.py index 4898bc0b..07a3ff25 100644 --- a/src/claude_agent_sdk/__init__.py +++ b/src/claude_agent_sdk/__init__.py @@ -10,6 +10,7 @@ CLIJSONDecodeError, CLINotFoundError, ProcessError, + SandboxFileWatcherError, ) from ._internal.transport import Transport from ._version import __version__ @@ -362,4 +363,5 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any: "CLINotFoundError", "ProcessError", "CLIJSONDecodeError", + "SandboxFileWatcherError", ] diff --git a/src/claude_agent_sdk/_errors.py b/src/claude_agent_sdk/_errors.py index c86bf235..e0fbc36a 100644 --- a/src/claude_agent_sdk/_errors.py +++ b/src/claude_agent_sdk/_errors.py @@ -54,3 +54,26 @@ class MessageParseError(ClaudeSDKError): def __init__(self, message: str, data: dict[str, Any] | None = None): self.data = data super().__init__(message) + + +class SandboxFileWatcherError(ClaudeSDKError): + """Raised when the CLI's sandbox file watcher fails. + + This typically happens on macOS when the sandbox tries to watch + system temp directories containing socket files or other unwatchable + file types. The CLI's file watcher throws EOPNOTSUPP or EINTR errors + on these files. + """ + + def __init__(self, path: str, error_code: str): + self.path = path + self.error_code = error_code + message = ( + f"Sandbox file watcher failed on '{path}' ({error_code}). " + "This is a known issue with the Claude CLI's sandbox on macOS. " + "The sandbox tries to watch system temp directories that contain " + "socket files (from VSCode, Docker, etc.) which cannot be watched. " + "Workarounds: 1) Disable sandbox (sandbox=None), " + "2) Run in a container with a clean /tmp directory." + ) + super().__init__(message) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index a4882db1..875663c2 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -20,7 +20,12 @@ from anyio.abc import Process from anyio.streams.text import TextReceiveStream, TextSendStream -from ..._errors import CLIConnectionError, CLINotFoundError, ProcessError +from ..._errors import ( + CLIConnectionError, + CLINotFoundError, + ProcessError, + SandboxFileWatcherError, +) from ..._errors import CLIJSONDecodeError as SDKJSONDecodeError from ..._version import __version__ from ...types import ClaudeAgentOptions @@ -66,6 +71,9 @@ def __init__( ) self._temp_files: list[str] = [] # Track temporary files for cleanup self._write_lock: anyio.Lock = anyio.Lock() + # Track sandbox file watcher errors from stderr + self._sandbox_watcher_error: SandboxFileWatcherError | None = None + self._sandbox_watcher_error_event: anyio.Event | None = None def _find_cli(self) -> str: """Find Claude Code CLI binary.""" @@ -391,12 +399,24 @@ async def connect(self) -> None: if self._cwd: process_env["PWD"] = self._cwd - # Pipe stderr if we have a callback OR debug mode is enabled + # Check if sandbox is enabled - we need stderr to detect file watcher errors + sandbox_enabled = ( + self._options.sandbox is not None + and self._options.sandbox.get("enabled", False) + ) + + # Pipe stderr if we have a callback, debug mode, or sandbox is enabled + # (sandbox needs stderr to detect file watcher errors) should_pipe_stderr = ( self._options.stderr is not None or "debug-to-stderr" in self._options.extra_args + or sandbox_enabled ) + # Initialize event for sandbox error detection + if sandbox_enabled: + self._sandbox_watcher_error_event = anyio.Event() + # For backward compat: use debug_stderr file object if no callback and debug is on stderr_dest = PIPE if should_pipe_stderr else None @@ -446,6 +466,13 @@ async def connect(self) -> None: self._exit_error = error raise error from e + # Regex pattern to detect sandbox file watcher errors from CLI stderr + # Matches: "EOPNOTSUPP: unknown error, watch '/path/...'" + # "EINTR: interrupted system call, watch '/path/...'" + _SANDBOX_WATCHER_ERROR_PATTERN = re.compile( + r"(EOPNOTSUPP|EINTR):[^,]+,\s*watch\s+'([^']+)'" + ) + async def _handle_stderr(self) -> None: """Handle stderr stream - read and invoke callbacks.""" if not self._stderr_stream: @@ -457,6 +484,25 @@ async def _handle_stderr(self) -> None: if not line_str: continue + # Check for sandbox file watcher errors + match = self._SANDBOX_WATCHER_ERROR_PATTERN.search(line_str) + if match and self._sandbox_watcher_error is None: + error_code = match.group(1) + path = match.group(2) + self._sandbox_watcher_error = SandboxFileWatcherError( + path=path, error_code=error_code + ) + logger.error( + f"Sandbox file watcher error detected: {error_code} on {path}" + ) + # Signal any waiters that an error occurred + if self._sandbox_watcher_error_event: + self._sandbox_watcher_error_event.set() + # Terminate the process since it will hang + if self._process and self._process.returncode is None: + with suppress(ProcessLookupError): + self._process.terminate() + # Call the stderr callback if provided if self._options.stderr: self._options.stderr(line_str) @@ -569,6 +615,10 @@ async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]: # Process stdout messages try: async for line in self._stdout_stream: + # Check for sandbox file watcher error before processing + if self._sandbox_watcher_error is not None: + raise self._sandbox_watcher_error + line_str = line.strip() if not line_str: continue @@ -612,6 +662,10 @@ async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]: # Client disconnected pass + # Check for sandbox file watcher error after reading + if self._sandbox_watcher_error is not None: + raise self._sandbox_watcher_error + # Check process completion and handle errors try: returncode = await self._process.wait() @@ -620,6 +674,9 @@ async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]: # Use exit code for error detection if returncode is not None and returncode != 0: + # Check if it was due to a sandbox file watcher error + if self._sandbox_watcher_error is not None: + raise self._sandbox_watcher_error self._exit_error = ProcessError( f"Command failed with exit code {returncode}", exit_code=returncode, diff --git a/tests/test_errors.py b/tests/test_errors.py index 9490d075..f7cfcb4d 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -6,6 +6,7 @@ CLIJSONDecodeError, CLINotFoundError, ProcessError, + SandboxFileWatcherError, ) @@ -50,3 +51,17 @@ def test_json_decode_error(self): assert error.line == "{invalid json}" assert error.original_error == e assert "Failed to decode JSON" in str(error) + + def test_sandbox_file_watcher_error(self): + """Test SandboxFileWatcherError.""" + error = SandboxFileWatcherError( + path="/var/folders/abc/T/vscode-git-123.sock", + error_code="EOPNOTSUPP" + ) + assert isinstance(error, ClaudeSDKError) + assert error.path == "/var/folders/abc/T/vscode-git-123.sock" + assert error.error_code == "EOPNOTSUPP" + assert "Sandbox file watcher failed" in str(error) + assert "vscode-git-123.sock" in str(error) + assert "EOPNOTSUPP" in str(error) + assert "socket files" in str(error) # Helpful message diff --git a/tests/test_transport.py b/tests/test_transport.py index fe9b6b22..5dc1159b 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -826,3 +826,33 @@ async def do_write(i: int): await process.wait() anyio.run(_test, backend="trio") + + def test_sandbox_watcher_error_pattern_detection(self): + """Test that sandbox file watcher error patterns are correctly detected.""" + transport = SubprocessCLITransport( + prompt="test", + options=make_options(sandbox={"enabled": True}), + ) + + # Test EOPNOTSUPP error pattern + pattern = transport._SANDBOX_WATCHER_ERROR_PATTERN + + # Test cases that should match + eopnotsupp_line = "EOPNOTSUPP: unknown error, watch '/var/folders/abc/T/vscode-git-123.sock'" + match = pattern.search(eopnotsupp_line) + assert match is not None + assert match.group(1) == "EOPNOTSUPP" + assert match.group(2) == "/var/folders/abc/T/vscode-git-123.sock" + + eintr_line = "EINTR: interrupted system call, watch '/var/folders/pq/9xtx/T/python-test-discovery-5c4f'" + match = pattern.search(eintr_line) + assert match is not None + assert match.group(1) == "EINTR" + assert match.group(2) == "/var/folders/pq/9xtx/T/python-test-discovery-5c4f" + + # Test cases that should NOT match + normal_error = "Error: Something went wrong" + assert pattern.search(normal_error) is None + + other_syscall = "ENOENT: no such file or directory, open '/path/to/file'" + assert pattern.search(other_syscall) is None