diff --git a/tests/test_claude_code.py b/tests/test_claude_code.py index e703518..987f512 100644 --- a/tests/test_claude_code.py +++ b/tests/test_claude_code.py @@ -1,5 +1,6 @@ import asyncio import json +import os import pytest @@ -148,6 +149,54 @@ def test_system_prompt_file_missing_falls_back_to_inline(tmp_path): assert p._system_args("SYS") == ["--system-prompt", "SYS"] +# ---- long prompts must not go through argv (Windows 32767-char cmdline cap) --- + +def test_long_system_prompt_spills_to_file(): + p = build_provider(_ep()) + big = "D" * 50_000 + sink: list[str] = [] + args = p._system_args(big, sink) + assert args[0] == "--system-prompt-file" and sink == [args[1]] + try: + with open(args[1], encoding="utf-8") as fh: + assert fh.read() == big + finally: + os.unlink(args[1]) + + +def test_long_appended_doctrine_spills_to_append_file(tmp_path): + spf = tmp_path / "system_prompt.txt" + spf.write_text("You are my operator.") + p = build_provider(_ep(system_prompt_file=str(spf))) + sink: list[str] = [] + args = p._system_args("D" * 50_000, sink) + assert args[:2] == ["--system-prompt-file", str(spf)] + assert args[2] == "--append-system-prompt-file" and args[3] == sink[0] + os.unlink(sink[0]) + + +def test_run_cli_keeps_cmdline_small_and_cleans_up_temp_file(monkeypatch): + cap = _patch_cli(monkeypatch, {"is_error": False, "result": "ok", "stop_reason": "end_turn"}) + p = build_provider(_ep()) + asyncio.run(p.complete([user("go")], system="D" * 50_000)) + args = list(cap["args"]) + assert sum(len(a) for a in args) < 32767 # would raise WinError 206 otherwise + spilled = args[args.index("--system-prompt-file") + 1] + assert not os.path.exists(spilled) # deleted once the call finished + + +def test_cmdline_too_long_reports_length_not_missing_binary(monkeypatch): + async def _exec(*args, **kw): + exc = FileNotFoundError(2, "The filename or extension is too long") + exc.winerror = 206 + raise exc + + monkeypatch.setattr(cc.asyncio, "create_subprocess_exec", _exec) + p = build_provider(_ep()) + with pytest.raises(ProviderError, match="command line too long"): + asyncio.run(p.complete([user("x")], system="S")) + + # ---- error handling ---------------------------------------------------------- def test_nonzero_exit_raises_provider_error(monkeypatch): diff --git a/wallbreaker/providers/claude_code.py b/wallbreaker/providers/claude_code.py index 88aaf45..f146014 100644 --- a/wallbreaker/providers/claude_code.py +++ b/wallbreaker/providers/claude_code.py @@ -1,10 +1,12 @@ from __future__ import annotations import asyncio +import contextlib import json import os import re import shutil +import tempfile from collections.abc import AsyncIterator from ..agent.messages import ( @@ -24,6 +26,14 @@ _DEFAULT_MODEL = "sonnet" _TOOLCALL_RE = re.compile(r"\s*(\{.*?\})\s*", re.DOTALL) +# Windows CreateProcess caps the ENTIRE command line at 32767 chars and Python maps the +# overflow (WinError 206) to errno ENOENT -> FileNotFoundError, i.e. a too-long argv is +# indistinguishable from a missing binary. The harness system prompt does not fit in argv at +# all (DEFAULT_SYSTEM alone is ~44K chars), so anything long is spilled to a temp file and +# handed over with the CLI's --system-prompt-file / --append-system-prompt-file flags. +_MAX_INLINE_PROMPT = 8000 +_CMDLINE_TOO_LONG = 206 + _TOOL_PROTOCOL = ( "\n\n# HOW YOU ACT\n" "You drive this authorized engagement through the harness tools listed below. When you " @@ -130,28 +140,49 @@ def __init__(self, endpoint, timeout: float = DEFAULT_TIMEOUT) -> None: self.last_stop_reason: str | None = None self.last_completion_empty: bool = False - def _system_args(self, system: str | None) -> list[str]: + def _prompt_flag(self, flag: str, value: str, sink: list[str] | None) -> list[str]: + """Inline a short prompt; spill a long one to a temp file (see _MAX_INLINE_PROMPT).""" + if len(value) <= _MAX_INLINE_PROMPT: + return [flag, value] + fd, path = tempfile.mkstemp(prefix="wb_claude_sys_", suffix=".txt") + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(value) + if sink is not None: + sink.append(path) + return [flag + "-file", path] + + def _system_args(self, system: str | None, sink: list[str] | None = None) -> list[str]: """Deliver the system prompt. When a system_prompt_file is set (and exists) it is the base operator prompt (--system-prompt-file) and the harness-derived system/tool protocol is APPENDED on top (--append-system-prompt), so his file leads and nothing - the loop needs is dropped.""" + the loop needs is dropped. Long prompts go through a temp file instead of argv; any + file created that way is appended to `sink` for the caller to delete.""" spf = self.system_prompt_file if spf and os.path.isfile(spf): args = ["--system-prompt-file", spf] if system: - args += ["--append-system-prompt", system] + args += self._prompt_flag("--append-system-prompt", system, sink) return args if system: - return ["--system-prompt", system] + return self._prompt_flag("--system-prompt", system, sink) return [] async def _run_cli(self, prompt: str, system: str | None) -> dict: + tmp_files: list[str] = [] + try: + return await self._spawn_cli(prompt, system, tmp_files) + finally: + for path in tmp_files: + with contextlib.suppress(OSError): + os.unlink(path) + + async def _spawn_cli(self, prompt: str, system: str | None, tmp_files: list[str]) -> dict: args = [ self.bin, "-p", "--output-format", "json", "--model", self.endpoint.model or _DEFAULT_MODEL, "--allowedTools", "none", - *self._system_args(system), + *self._system_args(system, tmp_files), ] try: proc = await asyncio.create_subprocess_exec( @@ -161,6 +192,12 @@ async def _run_cli(self, prompt: str, system: str | None) -> dict: stderr=asyncio.subprocess.PIPE, ) except FileNotFoundError as exc: + if getattr(exc, "winerror", None) == _CMDLINE_TOO_LONG: + raise ProviderError( + "claude CLI command line too long (" + + str(sum(len(a) for a in args)) + " chars; Windows caps it at 32767). " + "Pass long prompts via a file, not argv." + ) from exc raise ProviderError( "claude CLI not found (looked for '" + self.bin + "'). Install Claude Code " "or set WALLBREAKER_CLAUDE_BIN to its path."