From ac6772473d9794e4f4f85114ea949b84851b178a Mon Sep 17 00:00:00 2001 From: Unterwegs <75739931+UnterwegsDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:31:00 +0200 Subject: [PATCH] fix: route oversized system prompt through a temp file A string system_prompt of 128 KiB or more made connect() die with "[Errno 7] Argument list too long" before any request went out: the prompt was passed as a single --system-prompt argv entry and Linux caps one entry at MAX_ARG_STRLEN (PAGE_SIZE * 32 = 128 KiB). Prompts that size now get written to a temp file and passed via --system-prompt-file, reusing the path that already exists for {"type": "file"} prompts. The temp file is removed in close(). Fixes #1096 --- .../_internal/transport/subprocess_cli.py | 52 +++++++++++++++- tests/test_transport.py | 62 ++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index e9a522d13..706abda82 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -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 @@ -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 @@ -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 @@ -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: @@ -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": @@ -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 diff --git a/tests/test_transport.py b/tests/test_transport.py index d55aa18b0..138a0a5f6 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -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" @@ -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(