Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
49 changes: 48 additions & 1 deletion src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import re
import shutil
import signal
import tempfile
from collections.abc import AsyncIterable, AsyncIterator
from contextlib import suppress
from pathlib import Path
Expand All @@ -30,6 +31,13 @@
_DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
MINIMUM_CLAUDE_CODE_VERSION = "2.0.0"

# Linux caps a single argv element (MAX_ARG_STRLEN) at 32 * PAGE_SIZE = 131072
# bytes, including the trailing NUL. A ``--system-prompt`` value at or above this
# makes execve() fail with E2BIG ("Argument list too long") before the CLI ever
# starts, so oversized prompts are written to a temp file and passed via
# ``--system-prompt-file`` instead (see issue #1096).
_MAX_SYSTEM_PROMPT_ARG_BYTES = 131072

# Track live CLI subprocesses so we can terminate them when the parent Python
# process exits. This mirrors the TypeScript SDK's parent-exit cleanup and
# prevents orphaned `claude` processes from leaking when callers crash or exit
Expand Down Expand Up @@ -138,6 +146,8 @@ def __init__(
else _DEFAULT_MAX_BUFFER_SIZE
)
self._write_lock: anyio.Lock = anyio.Lock()
# Temp file backing an oversized --system-prompt-file, removed in close().
self._system_prompt_tmp: Path | None = None

def _find_cli(self) -> str:
"""Find Claude Code CLI binary."""
Expand Down Expand Up @@ -279,6 +289,24 @@ def _apply_skills_defaults(

return allowed_tools, setting_sources

def _write_system_prompt_file(self, system_prompt: str) -> str:
"""Write an oversized system prompt to a temp file and return its path.

Passing a very large ``--system-prompt`` value directly overflows the
per-argument OS limit and aborts the CLI before startup (issue #1096).
The file is removed in :meth:`close`.
"""
fd, path = tempfile.mkstemp(prefix="claude-system-prompt-", suffix=".txt")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(system_prompt)
except BaseException:
with suppress(OSError):
Path(path).unlink()
raise
self._system_prompt_tmp = Path(path)
return path

def _build_command(self) -> list[str]:
"""Build CLI command with arguments."""
if self._cli_path is None:
Expand All @@ -288,7 +316,18 @@ def _build_command(self) -> list[str]:
if self._options.system_prompt is None:
cmd.extend(["--system-prompt", ""])
elif isinstance(self._options.system_prompt, str):
cmd.extend(["--system-prompt", self._options.system_prompt])
system_prompt = self._options.system_prompt
if len(system_prompt.encode("utf-8")) >= _MAX_SYSTEM_PROMPT_ARG_BYTES:
# Too large to pass as a single argv element (see #1096); hand it
# to the CLI as a file via the already-supported flag instead.
cmd.extend(
[
"--system-prompt-file",
self._write_system_prompt_file(system_prompt),
]
)
else:
cmd.extend(["--system-prompt", system_prompt])
else:
sp = self._options.system_prompt
if sp.get("type") == "file":
Expand Down Expand Up @@ -649,6 +688,14 @@ async def close(self) -> None:
atexit reaper instead of dropping it. Making the escalation robust to a
foreign `CancelledError` is a follow-up.
"""
# Remove the temp file backing an oversized --system-prompt-file, if we
# created one. Runs regardless of process state so a failed connect()
# does not leak it (see #1096).
if self._system_prompt_tmp is not None:
with suppress(OSError):
self._system_prompt_tmp.unlink()
self._system_prompt_tmp = None

if not self._process:
self._ready = False
return
Expand Down
51 changes: 51 additions & 0 deletions tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import uuid
from collections.abc import AsyncIterator
from contextlib import nullcontext
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import anyio
Expand Down Expand Up @@ -183,6 +184,56 @@ def test_build_command_with_system_prompt_file(self):
assert "--system-prompt-file" in cmd
assert "/path/to/prompt.md" in cmd

def test_build_command_with_oversized_system_prompt_uses_file(self):
"""A system prompt over the argv limit goes via file, not inline (#1096)."""
big = "x" * 200_000 # exceeds MAX_ARG_STRLEN (131072 bytes)
transport = SubprocessCLITransport(
prompt="test",
options=make_options(system_prompt=big),
)

cmd = transport._build_command()
# Must not be inlined as a single oversized argv element.
assert big not in cmd
assert "--system-prompt" not in cmd
assert "--system-prompt-file" in cmd

path = cmd[cmd.index("--system-prompt-file") + 1]
assert transport._system_prompt_tmp is not None
assert Path(path).read_text(encoding="utf-8") == big

Path(path).unlink() # cleanup (close() would normally remove this)

def test_build_command_system_prompt_at_limit_stays_inline(self):
"""A prompt just under the limit is still passed inline (#1096)."""
prompt = "x" * (131072 - 1) # 131071 bytes: largest that fits in one arg
transport = SubprocessCLITransport(
prompt="test",
options=make_options(system_prompt=prompt),
)

cmd = transport._build_command()
assert "--system-prompt" in cmd
assert "--system-prompt-file" not in cmd
assert transport._system_prompt_tmp is None

@pytest.mark.anyio
async def test_close_removes_oversized_system_prompt_file(self):
"""close() deletes the temp file created for an oversized prompt (#1096)."""
transport = SubprocessCLITransport(
prompt="test",
options=make_options(system_prompt="x" * 200_000),
)
transport._build_command()

tmp = transport._system_prompt_tmp
assert tmp is not None and tmp.exists()

await transport.close()

assert not tmp.exists()
assert transport._system_prompt_tmp is None

def test_build_command_with_options(self):
"""Test building CLI command with options."""
transport = SubprocessCLITransport(
Expand Down