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
49 changes: 49 additions & 0 deletions tests/test_claude_code.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import json
import os

import pytest

Expand Down Expand Up @@ -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):
Expand Down
47 changes: 42 additions & 5 deletions wallbreaker/providers/claude_code.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -24,6 +26,14 @@
_DEFAULT_MODEL = "sonnet"
_TOOLCALL_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", 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 "
Expand Down Expand Up @@ -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(
Expand All @@ -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."
Expand Down