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
52 changes: 51 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,14 @@
_DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
MINIMUM_CLAUDE_CODE_VERSION = "2.0.0"

# Linux caps a single argv entry at MAX_ARG_STRLEN (PAGE_SIZE * 32 = 128 KiB,
# including the trailing NUL). A --system-prompt at or above that makes exec()
# fail with "[Errno 7] Argument list too long" before the CLI even starts
# (issue #1096). Prompts this large are written to a temp file and passed via
# --system-prompt-file instead. The threshold keeps comfortable headroom below
# the hard limit.
_MAX_INLINE_SYSTEM_PROMPT_BYTES = 64 * 1024

# 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 @@ -132,6 +141,7 @@ def __init__(
self._stderr_task: TaskHandle | None = None
self._ready = False
self._exit_error: Exception | None = None # Track process exit errors
self._temp_files: list[Path] = [] # Oversized args materialized to disk
self._max_buffer_size = (
options.max_buffer_size
if options.max_buffer_size is not None
Expand Down Expand Up @@ -279,6 +289,31 @@ def _apply_skills_defaults(

return allowed_tools, setting_sources

def _write_system_prompt_file(self, content: str) -> str:
"""Materialize an oversized system prompt to a temp file.

A single argv entry above the kernel's per-arg limit makes exec() fail
before the CLI runs (issue #1096), so a very large ``--system-prompt``
is written to disk and consumed via ``--system-prompt-file`` instead.
The file is removed by :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(content)
except BaseException:
Path(path).unlink(missing_ok=True)
raise
self._temp_files.append(Path(path))
return path

def _cleanup_temp_files(self) -> None:
"""Remove any temp files created for oversized CLI arguments."""
for path in self._temp_files:
with suppress(OSError):
path.unlink(missing_ok=True)
self._temp_files = []

def _build_command(self) -> list[str]:
"""Build CLI command with arguments."""
if self._cli_path is None:
Expand All @@ -288,7 +323,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_INLINE_SYSTEM_PROMPT_BYTES:
# Too large to pass as an argv entry without tripping the kernel's
# per-arg limit (issue #1096) — hand it over through a file.
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 +695,10 @@ async def close(self) -> None:
atexit reaper instead of dropping it. Making the escalation robust to a
foreign `CancelledError` is a follow-up.
"""
# Reached on every teardown path, including a connect() that failed
# after _build_command() already materialized a prompt file.
self._cleanup_temp_files()

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

import anyio
import pytest

from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
from claude_agent_sdk._internal.transport.subprocess_cli import (
_MAX_INLINE_SYSTEM_PROMPT_BYTES,
SubprocessCLITransport,
)
from claude_agent_sdk.types import ClaudeAgentOptions

DEFAULT_CLI_PATH = "/usr/bin/claude"
Expand Down Expand Up @@ -183,6 +187,62 @@ 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_large_system_prompt_string_uses_file(self):
"""A very large string system prompt is passed via a temp file.

A single argv entry at/above the kernel's per-arg limit makes exec()
fail with "Argument list too long" before the CLI runs (issue #1096),
so an oversized --system-prompt must be routed through
--system-prompt-file instead.
"""
large_prompt = "x" * (_MAX_INLINE_SYSTEM_PROMPT_BYTES + 1)
transport = SubprocessCLITransport(
prompt="test",
options=make_options(system_prompt=large_prompt),
)
try:
cmd = transport._build_command()

# The prompt itself is never placed on the command line.
assert "--system-prompt" not in cmd
assert large_prompt not in cmd
assert "--system-prompt-file" in cmd

file_path = Path(cmd[cmd.index("--system-prompt-file") + 1])
assert file_path.read_text(encoding="utf-8") == large_prompt
finally:
transport._cleanup_temp_files()

def test_build_command_system_prompt_just_under_limit_stays_inline(self):
"""A string prompt below the limit is still passed inline."""
prompt = "x" * (_MAX_INLINE_SYSTEM_PROMPT_BYTES - 1)
transport = SubprocessCLITransport(
prompt="test",
options=make_options(system_prompt=prompt),
)
cmd = transport._build_command()

assert "--system-prompt-file" not in cmd
assert "--system-prompt" in cmd
assert cmd[cmd.index("--system-prompt") + 1] == prompt
assert transport._temp_files == []

def test_cleanup_temp_files_removes_prompt_file(self):
"""close()/cleanup removes any prompt file materialized on disk."""
large_prompt = "x" * (_MAX_INLINE_SYSTEM_PROMPT_BYTES + 1)
transport = SubprocessCLITransport(
prompt="test",
options=make_options(system_prompt=large_prompt),
)
transport._build_command()
created = list(transport._temp_files)
assert created and all(p.exists() for p in created)

transport._cleanup_temp_files()

assert transport._temp_files == []
assert all(not p.exists() for p in created)

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