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
2 changes: 2 additions & 0 deletions src/claude_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
CLIJSONDecodeError,
CLINotFoundError,
ProcessError,
SandboxFileWatcherError,
)
from ._internal.transport import Transport
from ._version import __version__
Expand Down Expand Up @@ -362,4 +363,5 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
"CLINotFoundError",
"ProcessError",
"CLIJSONDecodeError",
"SandboxFileWatcherError",
]
23 changes: 23 additions & 0 deletions src/claude_agent_sdk/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
61 changes: 59 additions & 2 deletions src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
CLIJSONDecodeError,
CLINotFoundError,
ProcessError,
SandboxFileWatcherError,
)


Expand Down Expand Up @@ -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
30 changes: 30 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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