feat(e2e): make Claude Code send the same bytes every build

The compat cells drove the CLI with a fresh HOME per invocation and the
pytest process's own working directory, and both reach the request body.
The system prompt names a memory directory built from
$CLAUDE_CONFIG_DIR/projects/<cwd slug>, so a per-invocation config
directory rewrote every body, and the CLI adds a git block for its
working directory, so inheriting the checkout rewrote every body once
per candidate. The device id churned for the same reason: the CLI mints
it once and persists it in .claude.json, which we threw away each call.

Nothing here was load-bearing. All three ride in metadata.user_id, whose
job is abuse detection, not quota, caching or continuity. So pin the
config directory and the working directory at fixed paths, seed the
device id, and pin the session id.

HOME stays fresh and empty per invocation, so the isolation is no weaker
than before, and the CLI's own state no longer outlives the pod either.
The working directory is deliberately not the checkout, so a
model-directed Read now sees an empty directory rather than the
repository.

A pinned session id needs --no-session-persistence beside it: the CLI
refuses a session id another live process holds, and the matrix runs its
cells across xdist workers. Without the flag, six of eight concurrent
invocations die on "Session ID is already in use".
This commit is contained in:
Yuneng Jiang 2026-09-16 12:42:15 -07:00
parent 7d42bc751d
commit 39acea0754
No known key found for this signature in database
2 changed files with 200 additions and 0 deletions

View file

@ -0,0 +1,143 @@
"""The CLI must send the same request bytes from one build to the next.
Markerless harness test: it drives the real `claude` binary against a local
stub instead of a proxy, so it carries no `e2e` marker. The binary is a
prerequisite of this whole suite, so a missing one is a failure rather than a
skip.
Two builds differ in ways the driver does not control: a fresh pod, so no CLI
state survives, and a different candidate checked out at a different commit.
Both used to reach the request body, through the memory path the system prompt
names and through the git block the CLI adds for its working directory, so the
shared provider cache missed on every Claude Code cell. This replays those two
differences across a pair of invocations and holds the bytes equal.
A pinned session id is what makes the second test necessary. The matrix runs
its cells across xdist workers, and the CLI refuses to start a session id that
another live process already holds, so pinning one without also opting out of
session persistence turns most of a parallel run red.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import threading
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import List, Tuple
import pytest
from claude_code.cli_driver import _stable_cli_state, run_claude
from claude_code.rate_limiter import RateLimiter
_STUB_REPLY = {
"id": "msg_stub",
"type": "message",
"role": "assistant",
"model": "claude-haiku-4-5",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 2},
}
def _make_repo(root: Path, subject: str) -> Path:
root.mkdir(parents=True, exist_ok=True)
identity = {"NAME": "t", "EMAIL": "t@e2e"}
env = dict(
os.environ,
**{f"GIT_{role}_{key}": value for role in ("AUTHOR", "COMMITTER") for key, value in identity.items()},
)
(root / "file.txt").write_text(subject, encoding="utf-8")
for args in (["init", "-q"], ["add", "."], ["commit", "-q", "-m", subject]):
subprocess.run(["git", *args], cwd=root, env=env, check=True, capture_output=True)
return root
@pytest.fixture(name="captured")
def _captured() -> Tuple[str, List[bytes]]:
bodies: List[bytes] = []
lock = threading.Lock()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_POST(self) -> None:
raw = self.rfile.read(int(self.headers.get("content-length") or 0))
if "count_tokens" not in self.path:
with lock:
bodies.append(raw)
payload = json.dumps({"input_tokens": 10} if "count_tokens" in self.path else _STUB_REPLY).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *_args: object) -> None:
return
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}", bodies
finally:
server.shutdown()
def test_two_builds_send_the_same_request_bytes(captured: Tuple[str, List[bytes]], tmp_path: Path) -> None:
base_url, bodies = captured
limiter = RateLimiter(state_dir=tmp_path / "limiter")
checkouts = (_make_repo(tmp_path / "build-1", "first"), _make_repo(tmp_path / "build-2", "second"))
origin = Path.cwd()
sent = []
for checkout in checkouts:
shutil.rmtree(Path(_stable_cli_state()[0]).parent, ignore_errors=True)
os.chdir(checkout)
try:
before = len(bodies)
run_claude(
prompt="say ok",
model="claude-haiku-4-5",
base_url=base_url,
api_key="stub",
extra_env={"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"},
rate_limiter=limiter,
)
sent.append(bodies[before:])
finally:
os.chdir(origin)
assert sent[0], "the CLI sent no request to the stub, so there is nothing to compare"
assert sent[0] == sent[1]
def test_concurrent_cells_do_not_collide_on_the_pinned_session(
captured: Tuple[str, List[bytes]], tmp_path: Path
) -> None:
base_url, bodies = captured
limiter = RateLimiter(state_dir=tmp_path / "limiter")
def one(_index: int) -> int:
return run_claude(
prompt="say ok",
model="claude-haiku-4-5",
base_url=base_url,
api_key="stub",
extra_env={"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"},
rate_limiter=limiter,
).exit_code
with ThreadPoolExecutor(max_workers=4) as pool:
codes = list(pool.map(one, range(4)))
assert codes == [0, 0, 0, 0]
assert bodies, "the CLI sent no request to the stub, so there is nothing to compare"
assert set(Counter(bodies).values()) == {4}

View file

@ -132,6 +132,57 @@ def _make_isolated_home() -> str:
return tempfile.mkdtemp(prefix="claude-cli-home-")
_FIXED_CLI_USER_ID = "0" * 64
_FIXED_CLI_SESSION_ID = "00000000-0000-4000-8000-000000000000"
def _seed_cli_identity(config_dir: str) -> None:
"""Pin the device id the CLI would otherwise mint per config directory.
It mints 32 random bytes on first run, writes them to `.claude.json` as
`userID`, and sends them in `metadata.user_id` forever after, so the value
is stable for exactly as long as that file lives. Pinning it, and the
session id passed beside it, costs nothing: both feed abuse detection
rather than quota, caching or continuity."""
path = os.path.join(config_dir, ".claude.json")
try:
with open(path, encoding="utf-8") as handle:
if json.load(handle).get("userID") == _FIXED_CLI_USER_ID:
return
except (OSError, ValueError):
pass
staged = f"{path}.{os.getpid()}"
with open(staged, "w", encoding="utf-8") as handle:
json.dump({"userID": _FIXED_CLI_USER_ID}, handle)
os.replace(staged, path)
def _stable_cli_state() -> Tuple[str, str]:
"""Config directory and working directory for the CLI, at fixed paths.
Both reach the request body. The memory directory the system prompt
names is `$CLAUDE_CONFIG_DIR/projects/<cwd slug>/memory`, and a working
directory inside a git repository also contributes its branch and recent
commits. So a per-invocation config directory rewrites every body, and
inheriting the checkout rewrites every body once per candidate, which is
why the shared provider cache could never serve a Claude Code cell.
Pinning both makes the bodies repeatable across builds.
This narrows what survives rather than widening it: HOME stays fresh and
empty per invocation, so the isolation `_make_isolated_home` describes is
unchanged, and the CLI's own state no longer outlives the pod either. The
working directory is deliberately not the checkout, so a model-directed
`Read` sees an empty directory instead of the repository.
"""
root = os.path.join(tempfile.gettempdir(), f"litellm-e2e-claude-{os.getuid()}")
config_dir = os.path.join(root, "config")
workspace = os.path.join(root, "workspace")
for path in (root, config_dir, workspace):
os.makedirs(path, mode=0o700, exist_ok=True)
_seed_cli_identity(config_dir)
return config_dir, workspace
class ClaudeCLIError(RuntimeError):
"""Raised when the `claude` CLI cannot be invoked or returns a fatal error."""
@ -222,6 +273,9 @@ def run_claude(
"--verbose",
"--model",
model,
"--session-id",
_FIXED_CLI_SESSION_ID,
"--no-session-persistence",
]
if extra_args:
cmd.extend(extra_args)
@ -244,6 +298,8 @@ def run_claude(
# regardless of how the subprocess exits.
isolated_home = _make_isolated_home()
env["HOME"] = isolated_home
config_dir, workspace = _stable_cli_state()
env["CLAUDE_CONFIG_DIR"] = config_dir
if extra_env:
env.update(extra_env)
@ -262,6 +318,7 @@ def run_claude(
completed = run_fn(
cmd,
env=env,
cwd=workspace,
input=stdin_input,
capture_output=True,
text=True,