From 5fdbbb2a6b60f76890b5ee83afb7076c7d196599 Mon Sep 17 00:00:00 2001 From: shihyayou Date: Fri, 10 Jul 2026 11:05:02 +0800 Subject: [PATCH 1/2] fix(transport): pass oversized system prompts via --system-prompt-file A str `system_prompt` of >=131072 UTF-8 bytes was passed as a single `--system-prompt` argv element, which exceeds Linux MAX_ARG_STRLEN and makes execve() fail with E2BIG ("Argument list too long") before the CLI starts. Above the per-argument limit, write the prompt to a temp file and use the already-supported `--system-prompt-file` flag instead; smaller prompts are passed inline unchanged. The temp file is removed in close(), including the failed-connect() path so it cannot leak. Fixes #1096. Used AI assistance; reviewed and tested by me (full unit suite green on both asyncio and trio). Co-Authored-By: Claude Opus 4.8 --- .../_internal/transport/subprocess_cli.py | 49 +++++++++++++++++- tests/test_transport.py | 51 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index e9a522d13..054e4ecca 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,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 @@ -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.""" @@ -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: @@ -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": @@ -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 diff --git a/tests/test_transport.py b/tests/test_transport.py index d55aa18b0..170f09c6e 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -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 @@ -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( From 9cad48671ebb2105513e4aec7d6053321318bca2 Mon Sep 17 00:00:00 2001 From: shihyayou Date: Fri, 10 Jul 2026 15:56:45 +0800 Subject: [PATCH 2/2] fix(transport): address review feedback on oversized-prompt spill - Use NamedTemporaryFile so the fd cannot leak if opening or writing the spill file fails, and remove the previous temp file when _build_command() runs more than once. - Measure the argv threshold with os.fsencode() to match how subprocess encodes argv for execve(), rather than assuming UTF-8. - Qualify the MAX_ARG_STRLEN comment: 32 * PAGE_SIZE is 131072 bytes only on the common 4 KiB page size, and the fixed threshold is conservative. - Derive test sizes from _MAX_SYSTEM_PROMPT_ARG_BYTES instead of hardcoding 131072, make temp-file cleanup in tests try/finally-safe, and add a test that rebuilding the command replaces the spill file without leaking the previous one. Used AI assistance; reviewed and tested by me (ruff, mypy, and the full unit suite green). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../_internal/transport/subprocess_cli.py | 54 ++++++++++----- tests/test_transport.py | 67 ++++++++++++++----- 2 files changed, 86 insertions(+), 35 deletions(-) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 054e4ecca..0d0f7768c 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -31,11 +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). +# Linux caps a single argv element (MAX_ARG_STRLEN) at 32 * PAGE_SIZE, which is +# 131072 bytes (including the trailing NUL) on the common 4 KiB page size. 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). The threshold is a fixed conservative value: systems with +# larger pages allow more, and spilling to a file early is harmless. _MAX_SYSTEM_PROMPT_ARG_BYTES = 131072 # Track live CLI subprocesses so we can terminate them when the parent Python @@ -289,6 +291,13 @@ def _apply_skills_defaults( return allowed_tools, setting_sources + def _remove_system_prompt_tmp(self) -> None: + """Remove the temp file backing ``--system-prompt-file``, if any.""" + if self._system_prompt_tmp is not None: + with suppress(OSError): + self._system_prompt_tmp.unlink() + self._system_prompt_tmp = None + def _write_system_prompt_file(self, system_prompt: str) -> str: """Write an oversized system prompt to a temp file and return its path. @@ -296,16 +305,28 @@ def _write_system_prompt_file(self, system_prompt: str) -> str: 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") + # _build_command() may run more than once; drop the previous temp file + # so it cannot leak. + self._remove_system_prompt_tmp() + # NamedTemporaryFile owns the fd, so it cannot leak even if opening or + # writing fails; delete=False because the CLI reads it after we return. + tmp = None try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write(system_prompt) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + prefix="claude-system-prompt-", + suffix=".txt", + delete=False, + ) as tmp: + tmp.write(system_prompt) except BaseException: - with suppress(OSError): - Path(path).unlink() + if tmp is not None: + with suppress(OSError): + Path(tmp.name).unlink() raise - self._system_prompt_tmp = Path(path) - return path + self._system_prompt_tmp = Path(tmp.name) + return tmp.name def _build_command(self) -> list[str]: """Build CLI command with arguments.""" @@ -317,7 +338,9 @@ def _build_command(self) -> list[str]: cmd.extend(["--system-prompt", ""]) elif isinstance(self._options.system_prompt, str): system_prompt = self._options.system_prompt - if len(system_prompt.encode("utf-8")) >= _MAX_SYSTEM_PROMPT_ARG_BYTES: + # Measure the same bytes subprocess hands to execve(): argv elements + # are encoded with the filesystem encoding, not necessarily UTF-8. + if len(os.fsencode(system_prompt)) >= _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( @@ -691,10 +714,7 @@ async def close(self) -> None: # 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 + self._remove_system_prompt_tmp() if not self._process: self._ready = False diff --git a/tests/test_transport.py b/tests/test_transport.py index 170f09c6e..75b0bd646 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -10,7 +10,10 @@ import anyio import pytest -from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport +from claude_agent_sdk._internal.transport.subprocess_cli import ( + _MAX_SYSTEM_PROMPT_ARG_BYTES, + SubprocessCLITransport, +) from claude_agent_sdk.types import ClaudeAgentOptions DEFAULT_CLI_PATH = "/usr/bin/claude" @@ -185,28 +188,32 @@ def test_build_command_with_system_prompt_file(self): 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) + """A system prompt at the argv limit goes via file, not inline (#1096).""" + big = "x" * _MAX_SYSTEM_PROMPT_ARG_BYTES # smallest size that must spill 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 + try: + # 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 + finally: + # close() would normally remove this; don't leave it behind if an + # assertion above fails. + if transport._system_prompt_tmp is not None: + transport._system_prompt_tmp.unlink(missing_ok=True) + + def test_build_command_system_prompt_under_limit_stays_inline(self): + """The largest prompt that fits in one argv element stays inline (#1096).""" + prompt = "x" * (_MAX_SYSTEM_PROMPT_ARG_BYTES - 1) transport = SubprocessCLITransport( prompt="test", options=make_options(system_prompt=prompt), @@ -217,12 +224,36 @@ def test_build_command_system_prompt_at_limit_stays_inline(self): assert "--system-prompt-file" not in cmd assert transport._system_prompt_tmp is None + def test_build_command_twice_replaces_oversized_prompt_file(self): + """Rebuilding the command must not leak the previous temp file (#1096).""" + big = "x" * _MAX_SYSTEM_PROMPT_ARG_BYTES + transport = SubprocessCLITransport( + prompt="test", + options=make_options(system_prompt=big), + ) + + first = second = None + try: + transport._build_command() + first = transport._system_prompt_tmp + transport._build_command() + second = transport._system_prompt_tmp + + assert first is not None + assert second is not None + assert not first.exists() # replaced file was removed + assert second.exists() + finally: + for path in (first, second): + if path is not None: + path.unlink(missing_ok=True) + @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), + options=make_options(system_prompt="x" * _MAX_SYSTEM_PROMPT_ARG_BYTES), ) transport._build_command()