From 471d33d5bb42410df5b5ba188ffde6282a4d236c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 08:49:29 -0700 Subject: [PATCH 01/24] feat(agents): ship Claude Code in the wheel, as the agents bundle's claude-code agent An agent folder built from its own Dockerfile: Anthropic's native Claude Code binary at a pinned version, checked against its release manifest, behind an A2A server on agentenv-framework-protocol. The configured endpoint reaches the CLI as Anthropic's variables, with [model.headers] on every call and the model's aliases pinned to the run's model. A release pins every shipped example's protocol requirement. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/publish-pypi.yml | 10 +- pyproject.toml | 1 + src/agent_env/examples/agents/README.md | 14 + .../agents/agents/claude-code/Dockerfile | 31 + .../agents/agents/claude-code/a2a_server.py | 524 +++++++++++++++ .../agents/claude-code/claude_bridge.py | 600 ++++++++++++++++++ .../agents/agents/claude-code/entrypoint.sh | 7 + .../examples/agents/agents/claude-code/kit.py | 210 ++++++ .../agents/claude-code/requirements.txt | 2 + .../agents/tasks/claude-code-smoke.json | 7 + tst/unit/bundle/installed_test.py | 2 +- tst/unit/test_release_protocol_pins.py | 14 +- 12 files changed, 1412 insertions(+), 10 deletions(-) create mode 100644 src/agent_env/examples/agents/README.md create mode 100644 src/agent_env/examples/agents/agents/claude-code/Dockerfile create mode 100644 src/agent_env/examples/agents/agents/claude-code/a2a_server.py create mode 100644 src/agent_env/examples/agents/agents/claude-code/claude_bridge.py create mode 100755 src/agent_env/examples/agents/agents/claude-code/entrypoint.sh create mode 100644 src/agent_env/examples/agents/agents/claude-code/kit.py create mode 100644 src/agent_env/examples/agents/agents/claude-code/requirements.txt create mode 100644 src/agent_env/examples/agents/tasks/claude-code-smoke.json diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 48282736..b5747e04 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -93,10 +93,12 @@ jobs: # workspace members, and the bare name keys [tool.uv.sources]; none of those may be rewritten. sed -i "s/\"agentenv-framework-protocol[^\"]*\"/\"agentenv-framework-protocol==${protocol}\"/" pyproject.toml grep -q "\"agentenv-framework-protocol==${protocol}\"" pyproject.toml - # The echo-mcp example's server image installs the protocol this release ships with. - echo_requirements=src/agent_env/examples/echo-mcp/envs/echo/requirements.txt - sed -i "s/^agentenv-framework-protocol.*/agentenv-framework-protocol==${protocol}/" "$echo_requirements" - grep -qx "agentenv-framework-protocol==${protocol}" "$echo_requirements" + # Each shipped example's image installs the protocol this release ships with, keeping its extras. + for requirements in src/agent_env/examples/*/*/*/requirements.txt; do + grep -q "^agentenv-framework-protocol" "$requirements" || continue + sed -i -E "s/^agentenv-framework-protocol(\[[^]]*\])?.*/agentenv-framework-protocol\1==${protocol}/" "$requirements" + grep -qE "^agentenv-framework-protocol(\[[^]]*\])?==${protocol}$" "$requirements" + done uv build --no-sources --out-dir dist - name: Check its metadata renders run: uvx --index-url https://pypi.org/simple twine check --strict dist/* diff --git a/pyproject.toml b/pyproject.toml index 29498171..55e1cddc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,7 @@ smol = [ agent-env = "agent_env.cli:cli" [project.entry-points."agent_env.bundles"] +agents = "agent_env.examples" hello = "agent_env.examples" echo-mcp = "agent_env.examples" diff --git a/src/agent_env/examples/agents/README.md b/src/agent_env/examples/agents/README.md new file mode 100644 index 00000000..e43fc4a9 --- /dev/null +++ b/src/agent_env/examples/agents/README.md @@ -0,0 +1,14 @@ +Agents that come with agent-env, each an agent folder a bundle builds and deploys like its own. + +`agents/claude-code/` runs Anthropic's [Claude Code](https://code.claude.com/docs/en/headless) CLI behind an A2A +server: the prompt goes to `claude -p`, the task env's MCP servers to `--mcp-config`, and its stream-json output is the +turn's trajectory. It's the default agent, so a `deploy_agent` or an agent judge that names no agent deploys it. Name it +from another bundle as `@local/agentenv-framework/agents/claude-code`. + +It needs Docker, and a model endpoint serving Anthropic's Messages API, `/v1/messages`, which a LiteLLM gateway +does: the `[model]` base URL and key reach it as `ANTHROPIC_BASE_URL` and a bearer token, and `[model.headers]` are sent +on every model call. Building the image downloads Claude Code, which is Anthropic's software under its +[Commercial Terms](https://www.anthropic.com/legal/commercial-terms); the image is built where it runs and never +published. The CLI's version is the Dockerfile's `CLAUDE_CODE_VERSION`. + +Run its smoke task with `agent-env run agents`. diff --git a/src/agent_env/examples/agents/agents/claude-code/Dockerfile b/src/agent_env/examples/agents/agents/claude-code/Dockerfile new file mode 100644 index 00000000..491421bc --- /dev/null +++ b/src/agent_env/examples/agents/agents/claude-code/Dockerfile @@ -0,0 +1,31 @@ +FROM public.ecr.aws/docker/library/python:3.11-slim@sha256:c20888b6acdd1e63e1c433a185bf3ad162c0288fe484616ce062e0d28add2900 +# poppler-utils: the CLI's Read tool runs pdftoppm for a PDF's pages. +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl poppler-utils \ + && rm -rf /var/lib/apt/lists/* +# Claude Code's native build for this machine's platform, checked against its release manifest. +ARG CLAUDE_CODE_VERSION=2.1.280 +RUN set -eu; \ + case "$(uname -m)" in x86_64) platform=linux-x64 ;; aarch64) platform=linux-arm64 ;; \ + *) echo "Claude Code has no build for $(uname -m)" >&2; exit 1 ;; esac; \ + release="https://downloads.claude.ai/claude-code-releases/${CLAUDE_CODE_VERSION}"; \ + curl -fsSL --retry 3 "$release/manifest.json" -o /tmp/manifest.json; \ + checksum=$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["platforms"][sys.argv[2]]["checksum"])' \ + /tmp/manifest.json "$platform"); \ + curl -fsSL --retry 3 "$release/$platform/claude" -o /usr/local/bin/claude; \ + echo "$checksum /usr/local/bin/claude" | sha256sum -c -; \ + chmod 0755 /usr/local/bin/claude; \ + rm /tmp/manifest.json +WORKDIR /opt/agent +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY a2a_server.py claude_bridge.py kit.py entrypoint.sh ./ +# The CLI's cwd, /app, and its state, fixed so neither follows the uid: Modal runs the image as root. +RUN useradd --create-home --uid 10001 agent && mkdir -p /app /home/agent/.claude \ + && chown agent:agent /app /home/agent/.claude && chmod 0755 entrypoint.sh +ENV A2A_PORT=8000 PYTHONPATH=/opt/agent CLAUDE_CONFIG_DIR=/home/agent/.claude \ + DISABLE_AUTOUPDATER=1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +USER agent +WORKDIR /app +EXPOSE 8000 +# CMD rather than ENTRYPOINT: Modal passes a sandbox's env to the image's CMD only. +CMD ["/opt/agent/entrypoint.sh"] diff --git a/src/agent_env/examples/agents/agents/claude-code/a2a_server.py b/src/agent_env/examples/agents/agents/claude-code/a2a_server.py new file mode 100644 index 00000000..d7aaaa59 --- /dev/null +++ b/src/agent_env/examples/agents/agents/claude-code/a2a_server.py @@ -0,0 +1,524 @@ +"""An A2A agent running the Claude Code CLI. + +``AgentEnvAgent`` serves the Agent Card, the A2A task lifecycle and the SDK-owned extensions (agent-config, +mcp-config, skill listing, completed-task trajectories, triggers). Each context maps to a CLI session, so a +multi-turn conversation threads through ``claude --resume``. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import shutil +import tarfile +import tempfile +import uuid +from pathlib import Path +from typing import Any + +from a2a.types import AgentSkill +from agentenv_protocol.a2a_agent import ( + MCP_CONFIG_V1, + SKILL_CONFIG_V1, + SNAPSHOT_V1, + TRAJECTORY_V1, + TRIGGERS_V1, + AgentConfig, + AgentEnvAgent, + AgentIdentity, + BundleSkillRequest, + ContextObjectTrajectoryRequest, + ContextTrajectoryRequest, + FilePart, + InlineSkillRequest, + ObjectSnapshotLoadRequest, + ObjectSnapshotLoadResponse, + ObjectSnapshotSaveRequest, + ObjectSnapshotSaveResponse, + SkillAddResponse, + SnapshotReadObjects, + TaskRequest, + TaskResult, + TextPart, + Usage, + a2a_agent, + enable, + extension, +) +from agentenv_protocol.transfers import Uploaded, download, upload +from starlette.applications import Starlette +from starlette.exceptions import HTTPException + +from claude_bridge import ( + CLAUDE_STATE_DIR, + SESSION_JSONL_DIR, + read_partial_session_trajectory, + run_claude, + session_jsonl_path, +) +from kit import ( + DurableSessionRefs, + FilePartMaterializeError, + install_skill, + json_error_bodies, + materialize_file_part, + read_object_bytes, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +HARNESS = "claude-code" +# The versioned format of the stream-json events a turn's trajectory holds. +TRAJECTORY_FORMAT = "claude-code-stream-json/1" +# The CLI's cwd: what the model writes lands here, and collect_artifacts sees only that. +WORKSPACE_DIR = Path("/app") +# The CLI loads skills from /skills//SKILL.md. +SKILLS_DIR = CLAUDE_STATE_DIR / "skills" +# FileParts are written here, one folder per task, and their paths appended to the prompt: the CLI reads a path in +# the prompt and attaches an image or PDF as multimodal input. +INPUTS_DIR = WORKSPACE_DIR / "inputs" +_SKIPPABLE_WORKSPACE_ERRORS = (FileNotFoundError, PermissionError, NotADirectoryError, IsADirectoryError) + +# What agent-env keeps of a failed task's error_message. +_ERROR_MESSAGE_LIMIT = 1000 + + +class ClaudeCodeConfig(AgentConfig): + timeout_seconds: int = 1800 + model: str | None = None + allowed_tools: list[str] | None = None + system_prompt: str | None = None + output_format: dict[str, Any] | None = None + # LiteLLM spend attribution, sent as headers on each model call. + project_id: str | None = None + task_id: str | None = None + # The CLI's --effort: low, medium, high, xhigh, max, ultracode or auto. + effort: str | None = None + max_turns: int | None = None + max_thinking_tokens: int | None = None + + +def _rewrite_session_ids(jsonl_text: str, session_id: str) -> tuple[list[str], int]: + """The transcript's lines with every record's `sessionId` set to `session_id`, and how many records carried + one. Blank lines are dropped and unparseable ones kept verbatim.""" + rewritten: list[str] = [] + rewrite_count = 0 + for line in jsonl_text.splitlines(): + if not line.strip(): + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + rewritten.append(line) + continue + if isinstance(entry, dict) and "sessionId" in entry: + entry["sessionId"] = session_id + rewrite_count += 1 + rewritten.append(json.dumps(entry)) + return rewritten, rewrite_count + + +def _add_workspace_entry(tar: tarfile.TarFile, path: Path, arcname: str, *, stage_source: bool) -> bool: + if not stage_source: + try: + tar.add(path, arcname=arcname, recursive=False) + except _SKIPPABLE_WORKSPACE_ERRORS: + return False + return True + try: + tarinfo = tar.gettarinfo(path, arcname=arcname) + except OSError: + return False + if not tarinfo.isreg(): + tar.addfile(tarinfo) + return True + try: + source = path.open("rb") + except OSError: + return False + with source, tempfile.TemporaryFile() as staged: + remaining = tarinfo.size + while remaining: + try: + chunk = source.read(min(1024 * 1024, remaining)) + except OSError: + return False + if not chunk: + break + staged.write(chunk) + remaining -= len(chunk) + tarinfo.size = staged.tell() + staged.seek(0) + tar.addfile(tarinfo, staged) + return True + + +def _write_workspace_archive(archive_path: Path, *, stage_sources: bool = False) -> tuple[int, int]: + def add_tree(tar: tarfile.TarFile, source_root: Path, *, archive_prefix: str = "", + excluded_top_level: frozenset[str] = frozenset()) -> int: + count = 0 + for dirpath, dirnames, filenames in os.walk(source_root, followlinks=False): + dirnames.sort() + filenames.sort() + root = Path(dirpath) + if root == source_root: + dirnames[:] = [d for d in dirnames if d not in excluded_top_level] + filenames = [f for f in filenames if f not in excluded_top_level] + relative_root = root.relative_to(source_root) + if relative_root.parts: + arcname = "/".join(p for p in (archive_prefix, relative_root.as_posix()) if p) + if not _add_workspace_entry(tar, root, arcname, stage_source=stage_sources): + continue + for filename in filenames: + path = root / filename + arcname = "/".join(p for p in (archive_prefix, path.relative_to(source_root).as_posix()) if p) + if _add_workspace_entry(tar, path, arcname, stage_source=stage_sources): + count += 1 + return count + + with tarfile.open(archive_path, mode="w:gz", dereference=False) as tar: + # The CLI's state goes in as `.claude` from wherever it lives, so a `/app/.claude` isn't added twice. + file_count = add_tree(tar, WORKSPACE_DIR, excluded_top_level=frozenset({".claude"})) + if CLAUDE_STATE_DIR.is_dir(): + file_count += add_tree(tar, CLAUDE_STATE_DIR, archive_prefix=".claude") + return file_count, archive_path.stat().st_size + + +def _build_workspace_archive(archive_path: Path) -> tuple[int, int]: + """``/app`` and the CLI's state as a gzip tar on disk, out of the agent's heap; its file count and size. A file + that changes while it's read rebuilds the archive with each file staged first, so it can be skipped. Symlinked + directories aren't followed.""" + if not WORKSPACE_DIR.is_dir(): + raise FileNotFoundError(f"workspace directory does not exist: {WORKSPACE_DIR}") + try: + return _write_workspace_archive(archive_path) + except OSError: + logger.warning("Workspace changed while archiving; rebuilding with staged files") + return _write_workspace_archive(archive_path, stage_sources=True) + + +def _read_complete_session_jsonl(path: Path) -> bytes: + """The transcript's newline-complete records, while the CLI may still be appending.""" + data = path.read_bytes() + if not data: + raise ValueError("session transcript is empty") + if data.endswith(b"\n"): + return data + complete, separator, tail = data.rpartition(b"\n") + if separator: + return complete + b"\n" + try: + json.loads(tail) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError("session transcript has no complete JSONL record") from exc + return tail + b"\n" + + +def _clear_directory(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + for child in path.iterdir(): + if child.is_symlink() or child.is_file(): + child.unlink() + else: + shutil.rmtree(child) + + +def _copy_directory_contents(source: Path, destination: Path) -> None: + destination.mkdir(parents=True, exist_ok=True) + for child in source.iterdir(): + target = destination / child.name + if child.is_symlink(): + target.symlink_to(os.readlink(child), target_is_directory=child.is_dir()) + elif child.is_dir(): + shutil.copytree(child, target, symlinks=True) + else: + shutil.copy2(child, target) + + +def _restore_workspace_archive(archive_path: Path) -> None: + """Restore the workspace's files, and the archive's `.claude` to the CLI's state directory.""" + with tempfile.TemporaryDirectory(prefix="claude-snapshot-load-") as raw_stage: + stage = Path(raw_stage) + with tarfile.open(archive_path, mode="r:gz") as tar: + # The data filter refuses absolute paths, traversal, devices and unsafe links before anything is written. + tar.extractall(stage, filter="data") + staged_state = stage / ".claude" + if not staged_state.is_dir() or staged_state.is_symlink(): + raise ValueError("workspace archive is missing Claude internal state") + _clear_directory(WORKSPACE_DIR) + for child in stage.iterdir(): + if child.name == ".claude": + continue + target = WORKSPACE_DIR / child.name + if child.is_symlink(): + target.symlink_to(os.readlink(child), target_is_directory=child.is_dir()) + elif child.is_dir(): + shutil.copytree(child, target, symlinks=True) + else: + shutil.copy2(child, target) + _clear_directory(CLAUDE_STATE_DIR) + _copy_directory_contents(staged_state, CLAUDE_STATE_DIR) + + +def _answer(uploaded: Uploaded) -> dict[str, Any]: + """An upload, as agent-env reads it back.""" + return uploaded.model_dump(mode="json") + + +async def _fetch_granted_snapshot(objects: SnapshotReadObjects, archive_path: Path) -> tuple[str, bool]: + """The conversation JSONL behind the "trajectory" read grant, and whether a "workspace" archive was sent and + written to ``archive_path``; a conversation-only snapshot has none.""" + if objects.workspace is not None: + await download(objects.workspace, archive_path) + jsonl = await read_object_bytes(objects.trajectory) + return jsonl.decode(), objects.workspace is not None + + +@a2a_agent( + identity=AgentIdentity( + name="Claude Code", + description="An A2A agent running the Claude Code CLI.", + version="1.0.0", + input_modes=("text", "image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"), + output_modes=("text",), + skills=( + AgentSkill( + id="claude-code", + name="claude-code", + description="Runs the Claude Code CLI with full tool access (bash, read, edit, glob, grep).", + tags=["coding", "agent"], + ), + ), + ), + config=ClaudeCodeConfig, + config_description="Set or read agent configuration such as model, role, and allowed tools.", + extensions=( + enable(TRAJECTORY_V1, live=True, description=( + "Retrieve a task's Claude Code stream-json trajectory, also while it runs, " + "or a context's cumulative trajectory including a live run.")), + enable(MCP_CONFIG_V1, description="Register MCP servers the Claude Code CLI should connect to."), + enable(SKILL_CONFIG_V1, description="Register a skill the Claude Code CLI loads from its skills directory."), + enable(SNAPSHOT_V1, description=( + "Capture and restore the Claude Code conversation, the /app workspace and the CLI's state, " + "through agent-env's object-transfer grants.")), + enable(TRIGGERS_V1, description=( + "Register a typed trigger script and decide the stakeholder reply per turn " + "(step / env_trigger / conversational / all / any conditions -> say / end actions).")), + ), +) +class ClaudeCodeAgent(DurableSessionRefs, AgentEnvAgent): + """Bridges A2A tasks onto the `claude` CLI.""" + + def __init__(self) -> None: + # Contexts whose CLI is running. Trajectory reads and snapshot saves take no lock, so this reports liveness + # without waiting on the turn; a snapshot load holds the lock across its restore. + self._active_contexts: set[str] = set() + self._contexts_lock = asyncio.Lock() + + async def run(self, request: TaskRequest[ClaudeCodeConfig]) -> TaskResult: + # The CLI's stdout events, appended as it prints them, are the turn's trajectory. + request.trajectory.set_format(TRAJECTORY_FORMAT) + text_chunks: list[str] = [] + attachment_paths: list[str] = [] + try: + for part in request.parts: + if isinstance(part, TextPart): + text_chunks.append(part.text or "") + elif isinstance(part, FilePart): + attachment_paths.append(await materialize_file_part(part, INPUTS_DIR / request.task_id)) + except FilePartMaterializeError as e: + logger.error(f"FilePart materialization failed: {e}", exc_info=True) + return (TaskResult.builder().failed("filepart_error", str(e)[:_ERROR_MESSAGE_LIMIT]) + .add_text(str(e)).build()) + + user_text = "\n".join(c for c in text_chunks if c) + if attachment_paths: + paths_block = "\n".join(attachment_paths) + user_text = f"{user_text}\n\n{paths_block}" if user_text else paths_block + + cfg = request.config + context_id = request.context_id + async with self._contexts_lock: + self._active_contexts.add(context_id) + # Bound before the CLI starts, so a live trajectory read or a snapshot save finds this turn's session. + session_id = self.session_ref_for_context(context_id) + resume = session_id is not None + if not resume: + session_id = str(uuid.uuid4()) + self.set_session_ref_for_context(context_id, session_id) + try: + result = await run_claude( + prompt=user_text, + session_id=session_id, + resume=resume, + model=cfg.model, + allowed_tools=cfg.allowed_tools, + mcp_servers=dict(request.mcp_servers), + system_prompt=cfg.system_prompt, + output_format=cfg.output_format, + timeout_s=cfg.timeout_seconds, + project_id=cfg.project_id, + task_id=cfg.task_id, + role=cfg.role, + effort=cfg.effort, + max_turns=cfg.max_turns, + max_thinking_tokens=cfg.max_thinking_tokens, + event_callback=request.trajectory.append, + ) + except Exception as e: + logger.error(f"claude CLI invocation raised: {e}", exc_info=True) + return (TaskResult.builder().failed("infra_error", str(e)[:_ERROR_MESSAGE_LIMIT], error_type="infra_error") + .add_text(str(e)).build()) + finally: + self._active_contexts.discard(context_id) + # The CLI keeps the session id it's given; if it ever reports another, the next turn resumes that one. + if result.session_id and result.session_id != session_id: + session_id = result.session_id + self.set_session_ref_for_context(context_id, session_id) + + data: dict[str, Any] = {"tool_call_count": result.tool_call_count} + if result.attempts > 1: + data["cli_attempts"] = result.attempts + builder = TaskResult.builder() + if result.error: + builder.failed("cli_error", result.error[:_ERROR_MESSAGE_LIMIT]) + else: + builder.succeeded() + return ( + builder + # A CLI that failed before its `result` event has no response; the error is the diagnostic. + .add_text(result.response or (result.error or "")) + .add_data(data) + .session_ref(session_id) + .usage(Usage(tool_call_count=result.tool_call_count)) + .build() + ) + + async def _context_trajectory(self, context_id: str) -> dict[str, Any]: + session_id = self.session_ref_for_context(context_id) + if session_id is None: + raise HTTPException(status_code=404, detail=f"No session available for context '{context_id}'") + try: + trajectory, session_lines = await asyncio.to_thread(read_partial_session_trajectory, session_id) + except FileNotFoundError: + # The session is bound just before the CLI starts, so a first read can beat its transcript. + raise HTTPException(status_code=404, detail=f"No transcript yet for context_id '{context_id}'") + return { + "trajectory": trajectory, + "session_lines": session_lines, + "is_live": context_id in self._active_contexts, + "session_id": session_id, + "harness": HARNESS, + } + + @extension(TRAJECTORY_V1.get.context) + async def get_context_trajectory(self, request: ContextTrajectoryRequest) -> dict[str, Any]: + """A context's cumulative trajectory, including a live run.""" + return await self._context_trajectory(request.context_id) + + @extension(TRAJECTORY_V1.get.context_objects) + async def upload_context_trajectory(self, request: ContextObjectTrajectoryRequest) -> dict[str, Any]: + trajectory = (await self._context_trajectory(request.context_id))["trajectory"] + body = json.dumps(trajectory, separators=(",", ":"), default=str).encode() + return {"objects": {"trajectory": _answer(await upload(request.objects.trajectory, body))}} + + @extension(SKILL_CONFIG_V1.add.inline) + async def add_inline_skill(self, request: InlineSkillRequest) -> SkillAddResponse: + return await install_skill(request, SKILLS_DIR) + + @extension(SKILL_CONFIG_V1.add.bundle) + async def add_bundle_skill(self, request: BundleSkillRequest) -> SkillAddResponse: + return await install_skill(request, SKILLS_DIR) + + @extension(SNAPSHOT_V1.save) + async def save_snapshot(self, request: ObjectSnapshotSaveRequest) -> ObjectSnapshotSaveResponse: + """Capture a loadable bundle: "trajectory" is the conversation JSONL, "workspace" the /app and CLI state + archive.""" + context_id = request.context_id + if not context_id: + raise HTTPException(status_code=400, detail="context_id is required") + session_id = self.session_ref_for_context(context_id) + if session_id is None: + raise HTTPException(status_code=404, detail=f"No session available for context_id '{context_id}'") + try: + jsonl_bytes = await asyncio.to_thread(_read_complete_session_jsonl, session_jsonl_path(session_id)) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=f"No transcript yet for context_id '{context_id}'") from exc + except ValueError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + archive_fd, raw_archive_path = tempfile.mkstemp(prefix="claude-workspace-", suffix=".tar.gz") + jsonl_fd, raw_jsonl_path = tempfile.mkstemp(prefix="claude-conversation-", suffix=".jsonl") + os.close(archive_fd) + os.close(jsonl_fd) + archive_path, snapshot_jsonl_path = Path(raw_archive_path), Path(raw_jsonl_path) + try: + file_count, bytes_written = await asyncio.to_thread(_build_workspace_archive, archive_path) + await asyncio.to_thread(snapshot_jsonl_path.write_bytes, jsonl_bytes) + uploaded = {"trajectory": _answer(await upload(request.objects.trajectory, snapshot_jsonl_path))} + if request.objects.workspace is not None: + uploaded["workspace"] = _answer(await upload(request.objects.workspace, archive_path)) + except FileNotFoundError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + finally: + archive_path.unlink(missing_ok=True) + snapshot_jsonl_path.unlink(missing_ok=True) + logger.info(f"Snapshot saved: context_id={context_id} session_id={session_id} files={file_count} " + f"bytes={bytes_written}") + return ObjectSnapshotSaveResponse.model_validate({"context_id": context_id, "objects": uploaded}) + + @extension(SNAPSHOT_V1.load) + async def load_snapshot(self, request: ObjectSnapshotLoadRequest) -> ObjectSnapshotLoadResponse: + """Restore a full snapshot, or a conversation-only one.""" + target_context_id = request.target_context_id + if target_context_id is not None and not target_context_id: + raise HTTPException(status_code=400, detail="target_context_id must be a non-empty string") + new_context_id = target_context_id or uuid.uuid4().hex + new_session_id = str(uuid.uuid4()) + + archive_fd, raw_archive_path = tempfile.mkstemp(prefix="claude-workspace-load-", suffix=".tar.gz") + os.close(archive_fd) + archive_path = Path(raw_archive_path) + try: + jsonl_text, has_workspace_archive = await _fetch_granted_snapshot(request.objects, archive_path) + except Exception: + archive_path.unlink(missing_ok=True) + raise + + try: + rewritten, rewrite_count = _rewrite_session_ids(jsonl_text, new_session_id) + if rewrite_count == 0: + raise HTTPException(status_code=400, detail="no sessionId fields found in JSONL — unexpected format") + async with self._contexts_lock: + if self.session_ref_for_context(new_context_id) is not None: + raise HTTPException(status_code=409, detail=( + f"context_id '{new_context_id}' is already bound — retry with a different target_context_id")) + if self._active_contexts: + raise HTTPException(status_code=409, detail="cannot load a workspace while an agent turn is running") + if has_workspace_archive: + try: + await asyncio.to_thread(_restore_workspace_archive, archive_path) + except (tarfile.TarError, OSError, ValueError) as exc: + raise HTTPException(status_code=400, detail=f"invalid workspace archive: {exc}") from exc + SESSION_JSONL_DIR.mkdir(parents=True, exist_ok=True) + session_jsonl_path(new_session_id).write_text("\n".join(rewritten) + "\n") + self.set_session_ref_for_context(new_context_id, new_session_id) + finally: + archive_path.unlink(missing_ok=True) + logger.info(f"Snapshot loaded: context_id={new_context_id} session_id={new_session_id} " + f"(workspace={'restored' if has_workspace_archive else 'not present'}; " + f"rewrote {rewrite_count} sessionId fields)") + return ObjectSnapshotLoadResponse(context_id=new_context_id) + + +def build_app(agent: ClaudeCodeAgent) -> Starlette: + """The agent's Starlette app, answering errors with ``{"detail": ...}`` bodies.""" + return json_error_bodies(agent.create_app()) + + +app = build_app(ClaudeCodeAgent()) +# install_agent reads the agent's card by importing this name from the image. +AGENT_CARD = app.state.agentenv_a2a.card diff --git a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py new file mode 100644 index 00000000..cd80857c --- /dev/null +++ b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py @@ -0,0 +1,600 @@ +"""One turn of the Claude Code CLI: a ``claude -p`` run, its stream-json output parsed into a result, resumed when a +transient API failure cuts it short. Session threading (new vs resume) is the caller's.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import os +import re +import signal +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field, replace +from functools import partial +from pathlib import Path + +logger = logging.getLogger(__name__) + +# The CLI's own state: settings, skills and session transcripts. The image fixes it, so it doesn't follow the uid. +CLAUDE_STATE_DIR = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude") +# The CLI keeps a session's transcript under a directory named for its cwd, which is always /app. +SESSION_JSONL_DIR = CLAUDE_STATE_DIR / "projects" / "-app" + +# The model aliases the CLI resolves for itself, pinned to the run's model on a gateway that may serve no other. +_MODEL_ALIASES = ( + "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL", +) +# What agent-env gives an agent for [model.headers]: a JSON object of header names to values. +MODEL_HEADERS_ENV = "LITELLM_EXTRA_HEADERS" + + +def session_jsonl_path(session_id: str) -> Path: + return SESSION_JSONL_DIR / f"{session_id}.jsonl" + + +def _session_record_to_stream_event(record: dict) -> dict | None: + """One persisted session record in stream-json shape: assistant messages and tool results, which a trajectory + needs, and nothing of the session's bookkeeping or compaction summaries, which would repeat its history.""" + if record.get("isCompactSummary") or record.get("isVisibleInTranscriptOnly"): + return None + record_type = record.get("type") + message = record.get("message") + if record_type == "assistant": + if not isinstance(message, dict): + return None + elif record_type == "user": + # stream-json replays no user prompt, only the user-shaped tool results. + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list) or not any( + isinstance(block, dict) and block.get("type") == "tool_result" for block in content): + return None + else: + return None + event: dict = {"type": record_type, "message": message} + field_map = {"sessionId": "session_id", "requestId": "request_id", "toolUseResult": "tool_use_result", + "uuid": "uuid", "timestamp": "timestamp"} + for source, target in field_map.items(): + if source in record: + event[target] = record[source] + return event + + +def read_partial_session_trajectory(session_id: str) -> tuple[list[dict], int]: + """The session's trajectory so far, and how many records it has decoded, which only grows. A torn last line, + as the CLI appends, is skipped until the next read. Raises FileNotFoundError until the CLI writes the file.""" + data = session_jsonl_path(session_id).read_bytes() + trajectory: list[dict] = [] + session_lines = 0 + for raw_line in data.splitlines(): + if not raw_line.strip(): + continue + try: + record = json.loads(raw_line) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + if not isinstance(record, dict): + continue + session_lines += 1 + event = _session_record_to_stream_event(record) + if event is not None: + trajectory.append(event) + return trajectory, session_lines + + +@dataclass +class ClaudeResult: + response: str + tool_call_count: int + session_id: str | None + trajectory: list[dict] = field(default_factory=list) + error: str | None = None + # The CLI's own account of a failure, from its terminal `result` event or else stderr. + error_detail: str | None = None + # The terminal `result` event's output, validated against a requested JSON Schema. + structured_output: dict | None = None + # How many `claude -p` runs the turn took; more than one means it was resumed. + attempts: int = 1 + + +def _attribution_headers(project_id: str | None, task_id: str | None) -> dict[str, str]: + """LiteLLM's spend attribution headers for a run's project and task.""" + if project_id: + tags = [f"projectId:{project_id}", *([f"taskId:{task_id}"] if task_id else [])] + return {"x-litellm-customer-id": project_id, "x-litellm-tags": ",".join(tags)} + return {"x-litellm-tags": f"taskId:{task_id}"} if task_id else {} + + +def _model_headers(raw: str | None) -> dict[str, str]: + """[model.headers], from agent-env's JSON object; none when it isn't one.""" + if not raw: + return {} + try: + headers = json.loads(raw) + except json.JSONDecodeError: + logger.warning(f"ignoring {MODEL_HEADERS_ENV}, which isn't JSON") + return {} + if not isinstance(headers, dict): + logger.warning(f"ignoring {MODEL_HEADERS_ENV}, which isn't a JSON object") + return {} + return {str(name): str(value) for name, value in headers.items()} + + +def cli_env(environ: Mapping[str, str], *, model: str | None = None, project_id: str | None = None, + task_id: str | None = None, max_thinking_tokens: int | None = None) -> dict[str, str]: + """The CLI's environment: this process's, pointed at the endpoint agent-env configured (its key as a bearer + token, so an API key too would conflict), with the endpoint's headers and the run's attribution, and the model's + aliases pinned to ``model`` on that endpoint. A variable already set is kept.""" + env = dict(environ) + if not env.get("ANTHROPIC_AUTH_TOKEN") and env.get("LITELLM_API_KEY"): + env["ANTHROPIC_AUTH_TOKEN"] = env["LITELLM_API_KEY"] + if not env.get("ANTHROPIC_BASE_URL") and env.get("LITELLM_BASE_URL"): + env["ANTHROPIC_BASE_URL"] = env["LITELLM_BASE_URL"] + if env.get("ANTHROPIC_AUTH_TOKEN"): + env["ANTHROPIC_API_KEY"] = "" + if model and env.get("ANTHROPIC_BASE_URL"): + for alias in _MODEL_ALIASES: + env.setdefault(alias, model) + headers = {**_model_headers(env.get(MODEL_HEADERS_ENV)), **_attribution_headers(project_id, task_id)} + if headers: + env["ANTHROPIC_CUSTOM_HEADERS"] = "\n".join(f"{name}: {value}" for name, value in headers.items()) + if max_thinking_tokens: + env["MAX_THINKING_TOKENS"] = str(max_thinking_tokens) + # Older CLIs prepend a changing attribution block to the system prompt on custom base URLs, which defeats the + # provider's prefix cache. + env["CLAUDE_CODE_ATTRIBUTION_HEADER"] = "0" + return env + + +_DEFAULT_API_ERROR_RETRIES = 2 +_MIN_RETRY_BUDGET_S = 120 +# agent-env polls with the timeout it posts, so a resume that spent the budget to zero would leave it no answer. +_DEADLINE_HEADROOM_S = 30 +_RETRY_BACKOFF_S = (5, 15) + +# For a CLI that reports no `terminal_reason`, or one this doesn't know: transient API failures, by their text. +_RESUMABLE_API_ERROR_MARKERS = ( + "operation timed out", + "socket connection was closed", + "overloaded", + "internal server error", + "bad gateway", +) + +# A real user turn, kept in the session transcript; the stream-json trajectory never shows it. +_RESUME_PROMPT = ( + "Your previous turn was interrupted by a transient API error before it finished. " + "The conversation above is intact. Continue the original request from where you " + "stopped, without redoing work that is already complete, and finish it." +) + + +def _configured_api_error_retries() -> int: + raw = os.environ.get("CLAUDE_API_ERROR_RETRIES", "").strip() + if not raw: + return _DEFAULT_API_ERROR_RETRIES + try: + return max(0, int(raw)) + except ValueError: + logger.warning(f"ignoring non-integer CLAUDE_API_ERROR_RETRIES={raw!r}") + return _DEFAULT_API_ERROR_RETRIES + + +# The CLI has filed a stalled stream under both; an unknown reason falls through to the markers. +_RESUMABLE_TERMINAL_REASONS = ("completed", "api_error") +# The 4xx a resume may clear: LiteLLM answers its own timeout with 408. +_RETRYABLE_CLIENT_ERRORS = (408, 425) + + +def _status(value: object) -> int | None: + """An HTTP status the CLI reports, as a number or a string.""" + if isinstance(value, str): + value = value.strip()[:3] + try: + return int(value) + except (TypeError, ValueError, OverflowError): + return None + + +def _is_client_error(status: object) -> bool: + """Whether the CLI saw a 4xx that a resume would hit again. 429 counts: the CLI has already backed off.""" + code = _status(status) + return code is not None and 400 <= code < 500 and code not in _RETRYABLE_CLIENT_ERRORS + + +def _terminal_result(trajectory: list[dict]) -> dict | None: + return next((event for event in reversed(trajectory) if event.get("type") == "result"), None) + + +def _resumable_api_failure(result: ClaudeResult) -> str | None: + """The CLI's error text when the turn died on an upstream failure a resume may clear, else None.""" + event = _terminal_result(result.trajectory) + if result.error is None or event is None or not event.get("is_error"): + return None + text = event.get("result") + if not isinstance(text, str) or _is_client_error(event.get("api_error_status")): + return None + if event.get("terminal_reason") in _RESUMABLE_TERMINAL_REASONS: + return text + return text if any(marker in text.lower() for marker in _RESUMABLE_API_ERROR_MARKERS) else None + + +def _endpoint_hint(trajectory: list[dict]) -> str | None: + """What a 404 from the model endpoint likely means.""" + event = _terminal_result(trajectory) + if event is None or _status(event.get("api_error_status")) != 404: + return None + base = os.environ.get("ANTHROPIC_BASE_URL") or os.environ.get("LITELLM_BASE_URL") or "the model endpoint" + return (f"{base} answered 404: Claude Code needs an endpoint serving Anthropic's Messages API " + "(/v1/messages), such as a LiteLLM gateway, that knows the model") + + +def _ends_in_result(trajectory: list[dict]) -> bool: + return bool(trajectory) and trajectory[-1].get("type") == "result" + + +def _without_terminal_result(trajectory: list[dict]) -> list[dict]: + """The attempt's events less its last `result`: a resumed turn is one turn, and readers take the first `result` + as its answer.""" + for index in range(len(trajectory) - 1, -1, -1): + if trajectory[index].get("type") == "result": + return trajectory[:index] + trajectory[index + 1:] + return list(trajectory) + + +# Bounded so the whole message fits the agent's 1000-character error message. +_ERROR_DETAIL_LIMIT = 600 +_FIRST_FAILURE_LIMIT = 200 +# A key in the CLI's error text: a LiteLLM or Google key, or a bearer token. LiteLLM's masked `sk-...abcd` is kept. +_SECRET_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}|\bAIza[0-9A-Za-z_-]{35}|Bearer\s+[A-Za-z0-9._~+/=-]+") +# Node echoes a rejected header's whole value, quotes and all. +_HEADER_VALUE_PATTERN = re.compile(r"(Header '[^']*' has invalid value: ')(?:.*'|.*)", re.DOTALL) +_LINE_BREAK_PATTERN = re.compile(r"\s*\n\s*") +_STDERR_NOISE = re.compile(r"\s+at ") + + +def _redact(text: str) -> str: + return _SECRET_PATTERN.sub("[redacted]", _HEADER_VALUE_PATTERN.sub(r"\1[redacted]'", text)) + + +def _one_line(text: str) -> str: + return _LINE_BREAK_PATTERN.sub(" ", text).strip() + + +def _cli_error_text(trajectory: list[dict]) -> str | None: + """The CLI's own account of a failed turn: its terminal `result` event's text, or its `errors`.""" + event = _terminal_result(trajectory) + if event is None or not event.get("is_error"): + return None + text = event.get("result") + if isinstance(text, str) and text.strip(): + return _one_line(text) + errors = event.get("errors") + if isinstance(errors, list): + return _one_line("; ".join(e for e in errors if isinstance(e, str) and e)) or None + return None + + +def _stderr_tail(stderr: str) -> str | None: + """stderr less stack frames, for a CLI that emitted no `result` event.""" + lines = [line.strip() for line in _redact(stderr).splitlines() if line.strip() and not _STDERR_NOISE.match(line)] + return " | ".join(lines)[-_ERROR_DETAIL_LIMIT:] or None + + +def _failure_message(head: str, detail: str | None, attempts: int = 1, first_failure: str | None = None) -> str: + """What ended the turn, the CLI's own words, then how a resumed turn began: what matters most survives a + caller's truncation.""" + message = head if attempts == 1 else f"{head} on attempt {attempts}" + if detail: + message += f": {_redact(detail)[:_ERROR_DETAIL_LIMIT]}" + if first_failure and first_failure != detail: + message += f" (attempt 1: {_redact(first_failure)[:_FIRST_FAILURE_LIMIT]})" + return message + + +async def run_claude( + prompt: str, + session_id: str, + resume: bool, + model: str | None = None, + allowed_tools: list[str] | None = None, + mcp_servers: dict[str, dict] | None = None, + system_prompt: str | None = None, + output_format: dict | None = None, + timeout_s: int = 600, + project_id: str | None = None, + task_id: str | None = None, + role: str | None = None, + effort: str | None = None, + max_turns: int | None = None, + max_thinking_tokens: int | None = None, + event_callback: Callable[[dict], None] | None = None, +) -> ClaudeResult: + """Run one turn, resuming the CLI session when a transient upstream failure cuts it short; a resume spends what + is left of ``timeout_s``, so the turn never outlives its caller's deadline. ``CLAUDE_API_ERROR_RETRIES=0`` turns + resumes off. ``event_callback`` gets each stdout event as the CLI prints it; an attempt's trailing `result` is + held until it's known not to be resumed.""" + deadline = time.monotonic() + timeout_s + retries_left = _configured_api_error_retries() + spawn = partial( + _spawn_claude, model=model, allowed_tools=allowed_tools, mcp_servers=mcp_servers, + system_prompt=system_prompt, output_format=output_format, project_id=project_id, task_id=task_id, + role=role, effort=effort, max_turns=max_turns, max_thinking_tokens=max_thinking_tokens, + event_callback=event_callback, + ) + attempt_prompt, attempt_resume, attempt_session = prompt, resume, session_id + trajectory: list[dict] = [] + tool_calls = 0 + attempts = 0 + first_failure: str | None = None + + while True: + attempts += 1 + attempt_timeout_s = (timeout_s if attempts == 1 + else max(int(deadline - time.monotonic()) - _DEADLINE_HEADROOM_S, 1)) + result = await spawn(prompt=attempt_prompt, session_id=attempt_session, resume=attempt_resume, + timeout_s=attempt_timeout_s) + tool_calls += result.tool_call_count + + failure = _resumable_api_failure(result) + budget_left = deadline - time.monotonic() + can_retry = (failure is not None and retries_left > 0 and budget_left >= _MIN_RETRY_BUDGET_S + and session_jsonl_path(result.session_id).exists()) + if not can_retry: + trajectory.extend(result.trajectory) + if event_callback is not None and _ends_in_result(result.trajectory): + event_callback(result.trajectory[-1]) + if failure is not None: + logger.warning(f"claude CLI turn cut short ({_redact(failure)!r}) and not resumed " + f"(retries_left={retries_left}, budget_left={int(budget_left)}s)") + # The first attempt's `result` was dropped from the trajectory, so the error says how the turn began. + error = result.error and _failure_message(result.error, result.error_detail, attempts, first_failure) + return replace(result, response=_redact(result.response) if error else result.response, + tool_call_count=tool_calls, trajectory=trajectory, attempts=attempts, error=error) + + trajectory.extend(_without_terminal_result(result.trajectory)) + first_failure = first_failure or result.error_detail + retries_left -= 1 + attempt_prompt, attempt_resume, attempt_session = _RESUME_PROMPT, True, result.session_id + backoff = _RETRY_BACKOFF_S[min(attempts - 1, len(_RETRY_BACKOFF_S) - 1)] + logger.warning(f"claude CLI turn cut short ({_redact(failure)!r}); resuming session {attempt_session} " + f"in {backoff}s with {int(budget_left)}s of the original {timeout_s}s budget left") + await asyncio.sleep(backoff) + + +_PROCESS_CLEANUP_TIMEOUT_S = 5 + + +def _kill_cli(proc) -> None: + """Kill the CLI and everything under it: its process group, and, by parent, the tools it starts in sessions of + their own (its shells), which no group kill reaches.""" + pid = getattr(proc, "pid", None) + if pid is None: + with contextlib.suppress(ProcessLookupError): + proc.kill() + return + if proc.returncode is not None: + return # gone, and its pid may be another process's by now + tree = [pid, *_descendants(pid)] + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(pid, signal.SIGKILL) + for member in tree: + with contextlib.suppress(ProcessLookupError, PermissionError): + os.kill(member, signal.SIGKILL) + + +async def _reap(proc) -> None: + """Wait for the killed CLI, but not for a process that left its tree and still holds its pipes open.""" + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(proc.wait(), _PROCESS_CLEANUP_TIMEOUT_S) + + +def _descendants(pid: int) -> list[int]: + """The processes under ``pid``, by parent, from /proc; none where there is no /proc.""" + children: dict[int, list[int]] = {} + for entry in os.listdir("/proc") if os.path.isdir("/proc") else (): + if not entry.isdigit(): + continue + try: + with open(f"/proc/{entry}/stat") as stat: + parent = int(stat.read().rsplit(")", 1)[1].split()[1]) + except (OSError, ValueError, IndexError): + continue + children.setdefault(parent, []).append(int(entry)) + found, pending = [], [pid] + while pending: + for child in children.get(pending.pop(), ()): + found.append(child) + pending.append(child) + return found + + +_STDOUT_READ_BYTES = 64 * 1024 + + +async def _stream_cli(proc, stdin_data: bytes, on_line: Callable[[str], None]) -> bytes: + """``proc.communicate(stdin_data)``, handing ``on_line`` each stdout line as it arrives, split as + ``splitlines`` splits the whole output; stderr once the CLI has exited.""" + + async def feed_stdin() -> None: + try: + proc.stdin.write(stdin_data) + await proc.stdin.drain() + except (BrokenPipeError, ConnectionResetError): + pass + finally: + proc.stdin.close() + + stdin_task = asyncio.create_task(feed_stdin()) + stderr_task = asyncio.create_task(proc.stderr.read()) + try: + # A line's pieces, joined once its newline arrives, so a long line costs its length, not its square. + pieces: list[bytes] = [] + while chunk := await proc.stdout.read(_STDOUT_READ_BYTES): + *ends, rest = chunk.split(b"\n") + for end in ends: + pieces.append(end) + for line in b"".join(pieces).decode(errors="replace").splitlines(): + on_line(line) + pieces = [] + pieces.append(rest) + for line in b"".join(pieces).decode(errors="replace").splitlines(): + on_line(line) + await stdin_task + stderr = await stderr_task + await proc.wait() + return stderr + finally: + stdin_task.cancel() + stderr_task.cancel() + + +def _command(session_id: str, resume: bool, *, model: str | None, allowed_tools: list[str] | None, + mcp_servers: dict[str, dict] | None, system_prompt: str | None, output_format: dict | None, + role: str | None, effort: str | None, max_turns: int | None) -> list[str]: + """The CLI's argv. The prompt goes on stdin, since one argument over 128 KiB fails exec.""" + cmd = ["claude", "-p", "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions", + "--setting-sources", "user"] + cmd += ["--resume", session_id] if resume else ["--session-id", session_id] + if model: + cmd += ["--model", model] + if system_prompt: + cmd += ["--system-prompt", system_prompt] + if effort: + cmd += ["--effort", effort] + if max_turns: + cmd += ["--max-turns", str(max_turns)] + if output_format: + # agent-env's {"type": "json_schema", "schema": {...}}; the flag takes the schema alone. + if output_format.get("type") != "json_schema" or "schema" not in output_format: + raise ValueError(f'output_format must be {{"type": "json_schema", "schema": {{...}}}}; ' + f'got keys={list(output_format.keys())}') + cmd += ["--json-schema", json.dumps(output_format["schema"])] + if allowed_tools: + cmd += ["--allowedTools", ",".join(allowed_tools)] + if mcp_servers: + def entry(info: dict) -> dict: + server: dict = {"type": "http", "url": info["url"]} + # The gateway filters an env's tools by the role header, which wins over a stored one. + headers = {**(info.get("headers") or {}), **({"AgentEnv-Role": role} if role else {})} + if headers: + server["headers"] = headers + return server + + cmd += ["--mcp-config", json.dumps({"mcpServers": {name: entry(info) for name, info in mcp_servers.items()}})] + return cmd + + +async def _spawn_claude( + prompt: str, + session_id: str, + resume: bool, + model: str | None = None, + allowed_tools: list[str] | None = None, + mcp_servers: dict[str, dict] | None = None, + system_prompt: str | None = None, + output_format: dict | None = None, + timeout_s: int = 600, + project_id: str | None = None, + task_id: str | None = None, + role: str | None = None, + effort: str | None = None, + max_turns: int | None = None, + max_thinking_tokens: int | None = None, + event_callback: Callable[[dict], None] | None = None, +) -> ClaudeResult: + cmd = _command(session_id, resume, model=model, allowed_tools=allowed_tools, mcp_servers=mcp_servers, + system_prompt=system_prompt, output_format=output_format, role=role, effort=effort, + max_turns=max_turns) + logger.info(f"Running claude CLI (resume={resume}, session_id={session_id}, model={model})") + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=cli_env(os.environ, model=model, project_id=project_id, task_id=task_id, + max_thinking_tokens=max_thinking_tokens), + start_new_session=True, + ) + trajectory: list[dict] = [] + tool_count = 0 + cli_session_id: str | None = None + final_response: str | None = None + structured_output: dict | None = None + # A trailing `result` is run_claude's to report, since it knows whether the attempt is resumed. + held_result: dict | None = None + + def on_line(line: str) -> None: + nonlocal tool_count, cli_session_id, final_response, structured_output, held_result + if not line.strip(): + return + try: + event = json.loads(line) + except json.JSONDecodeError: + logger.warning(f"claude CLI emitted non-JSON line: {line[:200]}") + return + if not isinstance(event, dict): + logger.warning(f"claude CLI emitted a non-object JSON line: {line[:200]}") + return + trajectory.append(event) + etype = event.get("type") + if etype == "system" and event.get("subtype") == "init": + cli_session_id = event.get("session_id", cli_session_id) + elif etype == "assistant": + # Only tool calls count: the answer is the terminal `result`, not the narration between calls. + for block in (event.get("message") or {}).get("content", []): + if block.get("type") == "tool_use": + tool_count += 1 + elif etype == "result": + cli_session_id = event.get("session_id", cli_session_id) + if event.get("subtype") == "success" and isinstance(event.get("result"), str): + final_response = event["result"] + if isinstance(event.get("structured_output"), dict): + structured_output = event["structured_output"] + if event_callback is None: + return + if held_result is not None: + event_callback(held_result) + held_result = None + if etype == "result": + held_result = event + else: + event_callback(event) + + try: + stderr = await asyncio.wait_for(_stream_cli(proc, prompt.encode(), on_line), timeout=timeout_s) + except asyncio.CancelledError: + _kill_cli(proc) + await _reap(proc) + raise + except asyncio.TimeoutError: + _kill_cli(proc) + await _reap(proc) + return ClaudeResult(response="", tool_call_count=0, session_id=session_id, + error=f"claude CLI timed out after {timeout_s}s") + except Exception: + # Reading failed, as when an event_callback raises: the CLI mustn't keep running. + _kill_cli(proc) + await _reap(proc) + raise + + err: str | None = None + detail: str | None = None + if proc.returncode != 0: + err = (f"claude CLI was killed by signal {-proc.returncode}" if proc.returncode < 0 + else f"claude CLI exited with code {proc.returncode}") + detail = _cli_error_text(trajectory) or _stderr_tail(stderr.decode(errors="replace")) + if hint := _endpoint_hint(trajectory): + detail = f"{detail}; {hint}" if detail else hint + logger.error(f"claude CLI failed: {_failure_message(err, detail)}") + + # A requested JSON Schema's output is the answer, as JSON text the caller loads. + if structured_output is not None: + final_response = json.dumps(structured_output) + + return ClaudeResult(response=final_response or "", tool_call_count=tool_count, + session_id=cli_session_id or session_id, trajectory=trajectory, error=err, + error_detail=detail, structured_output=structured_output) diff --git a/src/agent_env/examples/agents/agents/claude-code/entrypoint.sh b/src/agent_env/examples/agents/agents/claude-code/entrypoint.sh new file mode 100755 index 00000000..292b5398 --- /dev/null +++ b/src/agent_env/examples/agents/agents/claude-code/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e +# The CLI refuses --dangerously-skip-permissions as root unless it's told it's sandboxed. +if [ "$(id -u)" = "0" ]; then + export IS_SANDBOX=1 +fi +exec uvicorn a2a_server:app --host 0.0.0.0 --port "${A2A_PORT:-8000}" diff --git a/src/agent_env/examples/agents/agents/claude-code/kit.py b/src/agent_env/examples/agents/agents/claude-code/kit.py new file mode 100644 index 00000000..b1809ca4 --- /dev/null +++ b/src/agent_env/examples/agents/agents/claude-code/kit.py @@ -0,0 +1,210 @@ +"""The pieces of agent plumbing this agent shares in shape with other CLI agents: durable session references, +skill installs, FastAPI-style error bodies, grant reads and FileParts written to disk.""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +import logging +import re +import shutil +import tempfile +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +import httpx +from agentenv_protocol.a2a_agent import BundleSkillRequest, FilePart, InlineSkillRequest, SkillAddResponse +from agentenv_protocol.transfers import ReadObject, download +from starlette.applications import Starlette +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +logger = logging.getLogger(__name__) + +_REFS_ATTR = "_durable_session_refs" + + +class DurableSessionRefs: + """Mixin, listed before ``AgentEnvAgent``, that keeps every context's session reference for the life of the + process: the framework's own record is a bounded LRU, so an idle context would otherwise start a fresh CLI + session.""" + + def session_ref_for_context(self, context_id: str) -> str | None: + return self.__dict__.get(_REFS_ATTR, {}).get(context_id) or super().session_ref_for_context(context_id) + + def set_session_ref_for_context(self, context_id: str, session_ref: str) -> None: + super().set_session_ref_for_context(context_id, session_ref) + self.__dict__.setdefault(_REFS_ATTR, {})[context_id] = session_ref + + +_NO_BODY_STATUSES = frozenset({204, 205, 304}) + + +async def _detail_body(_request: Request, exc: HTTPException) -> Response: + if exc.status_code < 200 or exc.status_code in _NO_BODY_STATUSES: + return Response(status_code=exc.status_code, headers=exc.headers) + return JSONResponse({"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers) + + +def json_error_bodies(app: Starlette) -> Starlette: + """Answer every ``HTTPException`` with a ``{"detail": ...}`` body, as agent-env reads errors.""" + app.add_exception_handler(HTTPException, _detail_body) + return app + + +async def read_object_bytes(source: ReadObject) -> bytes: + """The bytes behind a read grant.""" + with tempfile.TemporaryDirectory(prefix="agent-read-") as work: + target = Path(work) / "object" + await download(source, target) + return target.read_bytes() + + +def _validate_skill_name(name: str) -> None: + if name != Path(name).name or ".." in name or "/" in name or "\\" in name: + raise HTTPException(status_code=400, detail=f"Invalid skill name '{name}': must not contain path separators or '..'") + + +async def install_skill(request: InlineSkillRequest | BundleSkillRequest, skills_dir: Path) -> SkillAddResponse: + """Write ``skills_dir//``: the inline ``SKILL.md``, or every bundle file through its read grant. A failed + install leaves no directory behind.""" + _validate_skill_name(request.name) + skill_dir = skills_dir / request.name + if skill_dir.exists(): + raise HTTPException(status_code=409, detail=f"Skill '{request.name}' is already registered") + skill_dir.mkdir(parents=True) + try: + if isinstance(request, BundleSkillRequest): + for item in request.skill_bundle.files: + await download(item.object, skill_dir / item.path) + else: + if not request.skill_md or not request.skill_md.strip(): + raise HTTPException(status_code=400, detail="Skill content must not be empty") + (skill_dir / "SKILL.md").write_text(request.skill_md) + except BaseException: + shutil.rmtree(skill_dir, ignore_errors=True) + raise + logger.info(f"Registered skill '{request.name}' at {skill_dir}") + return SkillAddResponse(name=request.name) + + +# A FilePart's name, reduced to a safe charset so it can't leave the directory it's written to. +_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]") +# A FilePart URI may be presigned, so its userinfo and query are credentials: errors and logs name it without them. +_URL_USERINFO_RE = re.compile(r"(?i)\b(https?://)[^\s/?#@]+@") +_URL_QUERY_RE = re.compile(r"(?i)\b(https?://[^\s?#]+)\?(?!)\S*") +_LOGGED_URL_RE = re.compile(r"(?i)\b(?Phttps?)://(?:[^/?#@\s]*@)?(?P[^/?#\s]+)\S*") +_REDACT_LOGGED_URLS: ContextVar[bool] = ContextVar("claude_code_filepart_download", default=False) + + +class _RedactDownloadUrls(logging.Filter): + """httpx logs each request's full URL; while a FilePart downloads, only its origin.""" + + def filter(self, record: logging.LogRecord) -> bool: + if _REDACT_LOGGED_URLS.get(): + record.msg = _LOGGED_URL_RE.sub(r"\g://\g/", record.getMessage()) + record.args = None + return True + + +logging.getLogger("httpx").addFilter(_RedactDownloadUrls()) + + +@contextmanager +def _redacting_logged_urls() -> Iterator[None]: + token = _REDACT_LOGGED_URLS.set(True) + try: + yield + finally: + _REDACT_LOGGED_URLS.reset(token) + + +class FilePartMaterializeError(Exception): + """A FilePart that can't be written to disk, its message free of the URI's credentials.""" + + def __init__(self, message: str) -> None: + message = _URL_USERINFO_RE.sub(r"\1", message) + super().__init__(_URL_QUERY_RE.sub(r"\1?", message)) + + +def _shown(uri: str) -> str: + """``uri`` without userinfo, query or fragment.""" + try: + parts = urlsplit(uri) + host = parts.hostname or "" + if ":" in host: + host = f"[{host}]" + if parts.port: + host = f"{host}:{parts.port}" + except ValueError: + return "" + return urlunsplit((parts.scheme, host, parts.path, "" if parts.query else "", "")) + + +def _without_credentials(text: str, uri: str) -> str: + """``text`` with every copy of ``uri``'s userinfo, fragment and query, raw or as httpx encodes them, removed.""" + try: + parts, url = urlsplit(uri), httpx.URL(uri) + except (httpx.InvalidURL, ValueError): + return text + userinfos = {parts.netloc.rpartition("@")[0], url.userinfo.decode("ascii", "replace")} + for userinfo in filter(None, userinfos): + text = text.replace(f"{userinfo}@", "") + for fragment in sorted(filter(None, {parts.fragment, url.fragment}), key=len, reverse=True): + text = text.replace(f"#{fragment}", "") + for query in sorted(filter(None, {parts.query, url.query.decode("ascii", "replace")}), key=len, reverse=True): + text = text.replace(query, "") + return text + + +def _safe_name(candidate: str | None) -> str: + candidate = _SAFE_NAME_RE.sub("_", Path(candidate).name) if candidate else "" + return candidate or uuid.uuid4().hex + + +def _download_uri_to(uri: str, target: Path) -> None: + scheme = urlsplit(uri).scheme.lower() + if scheme not in ("http", "https"): + raise FilePartMaterializeError(f"unsupported URI scheme {scheme!r} in {_shown(uri)}") + try: + with _redacting_logged_urls(), httpx.Client(timeout=60, follow_redirects=True) as client: + response = client.get(uri) + response.raise_for_status() + target.write_bytes(response.content) + except httpx.HTTPStatusError as e: # not chained: httpx's messages can repeat the full URL + raise FilePartMaterializeError( + f"http(s) download failed for {_shown(uri)}: HTTP {e.response.status_code}") from None + except httpx.HTTPError as e: + raise FilePartMaterializeError( + f"http(s) download failed for {_shown(uri)}: {_without_credentials(str(e), uri)}") from None + + +def _materialize(part: FilePart, target_dir: Path) -> str: + target_dir.mkdir(parents=True, exist_ok=True) + name = _safe_name(part.name) + target = target_dir / name + if target.exists(): + target = target_dir / f"{uuid.uuid4().hex[:8]}-{name}" + if part.bytes is not None: + try: + target.write_bytes(base64.b64decode(part.bytes, validate=True)) + except (binascii.Error, ValueError) as e: + raise FilePartMaterializeError(f"malformed base64 in FilePart '{name}': {e}") from e + except OSError as e: + raise FilePartMaterializeError(f"failed to write FilePart '{name}': {e}") from e + else: + _download_uri_to(part.uri, target) + logger.info(f"Materialized FilePart name='{name}' mime={part.mime_type} size={target.stat().st_size}B to {target}") + return str(target) + + +async def materialize_file_part(part: FilePart, target_dir: Path) -> str: + """Write a FilePart's base64 bytes or http(s) contents to ``target_dir``, off the event loop; the path. Two + parts of one message sharing a name are kept apart. Raises FilePartMaterializeError.""" + return await asyncio.to_thread(_materialize, part, target_dir) diff --git a/src/agent_env/examples/agents/agents/claude-code/requirements.txt b/src/agent_env/examples/agents/agents/claude-code/requirements.txt new file mode 100644 index 00000000..45a09831 --- /dev/null +++ b/src/agent_env/examples/agents/agents/claude-code/requirements.txt @@ -0,0 +1,2 @@ +# A release pins agentenv-framework-protocol to the version it was released with. +agentenv-framework-protocol[agent] diff --git a/src/agent_env/examples/agents/tasks/claude-code-smoke.json b/src/agent_env/examples/agents/tasks/claude-code-smoke.json new file mode 100644 index 00000000..c28c5ece --- /dev/null +++ b/src/agent_env/examples/agents/tasks/claude-code-smoke.json @@ -0,0 +1,7 @@ +[ + {"id": "deploy", "type": "deploy_agent", "a2a_agent_id": "claude-code", "env_ids": [], "sandbox_type": "local"}, + {"id": "ask", "type": "prompt_agent", "prompt_id": "ask", "depends_on": ["deploy"], + "prompt": "Run the shell command `echo $((6 * 7))` and reply with only the number it prints."}, + {"id": "check", "type": "agent_prompt_response_verifier", "prompt_id": "ask", "verifier_id": "answer", + "criteria": [{"type": "response_contains", "needles": ["42"]}], "depends_on": ["ask"]} +] diff --git a/tst/unit/bundle/installed_test.py b/tst/unit/bundle/installed_test.py index 4dc26877..e5d23658 100644 --- a/tst/unit/bundle/installed_test.py +++ b/tst/unit/bundle/installed_test.py @@ -126,7 +126,7 @@ def test_the_plugin_report_counts_the_bundles_agent_env_ships(monkeypatch, quiet assert result.exit_code == 0, result.output (core,) = json.loads(result.stdout)["plugins"] assert [(c["group"], c["name"], c["status"]) for c in core["contributions"]] == [ - (BUNDLES, "echo-mcp", "active"), (BUNDLES, "hello", "active")] + (BUNDLES, "agents", "active"), (BUNDLES, "echo-mcp", "active"), (BUNDLES, "hello", "active")] assert "\n agent-env itself\n" in shown and "requires:" not in shown diff --git a/tst/unit/test_release_protocol_pins.py b/tst/unit/test_release_protocol_pins.py index e0b65e5e..f666ec1e 100644 --- a/tst/unit/test_release_protocol_pins.py +++ b/tst/unit/test_release_protocol_pins.py @@ -14,8 +14,12 @@ def test_the_pyproject_holds_the_one_quoted_protocol_requirement_the_release_pin assert len(re.findall(r'"agentenv-framework-protocol[^"]*"', (REPO / "pyproject.toml").read_text())) == 1 -def test_the_echo_mcp_requirements_the_release_pins_hold_one_protocol_line(): - (path,) = re.findall(r"^\s*echo_requirements=(\S+)$", WORKFLOW, re.MULTILINE) - lines = (REPO / path).read_text().splitlines() - - assert len([line for line in lines if line.startswith("agentenv-framework-protocol")]) == 1 +def test_each_shipped_example_requirements_file_the_release_pins_holds_one_protocol_line(): + (pattern,) = re.findall(r"^\s*for requirements in (\S+); do$", WORKFLOW, re.MULTILINE) + pinned = {path.relative_to(REPO).as_posix(): [line for line in path.read_text().splitlines() + if line.startswith("agentenv-framework-protocol")] + for path in REPO.glob(pattern)} + + assert {"src/agent_env/examples/echo-mcp/envs/echo/requirements.txt", + "src/agent_env/examples/agents/agents/claude-code/requirements.txt"} <= set(pinned) + assert all(len(lines) == 1 for lines in pinned.values()), pinned From dd78e3025963f17e434dd72657e59d116d49882c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 08:49:34 -0700 Subject: [PATCH 02/24] test(agents): the Claude Code agent's card, turns, retries, trajectories, snapshots and environment Co-Authored-By: Claude Opus 5.5 (1M context) --- tst/unit/examples/__init__.py | 0 tst/unit/examples/claude_code/__init__.py | 0 tst/unit/examples/claude_code/agent.py | 14 + tst/unit/examples/claude_code/conftest.py | 52 +++ tst/unit/examples/claude_code/support.py | 120 +++++ .../claude_code/test_card_contract.py | 134 ++++++ .../claude_code/test_claude_bridge_cancel.py | 92 ++++ .../test_claude_bridge_failure_message.py | 225 ++++++++++ .../claude_code/test_claude_bridge_live.py | 154 +++++++ .../claude_code/test_claude_bridge_retry.py | 418 ++++++++++++++++++ .../claude_code/test_claude_bridge_stdin.py | 116 +++++ tst/unit/examples/claude_code/test_cli_env.py | 121 +++++ .../examples/claude_code/test_ext_mcp_name.py | 20 + .../examples/claude_code/test_extensions.py | 105 +++++ .../claude_code/test_image_contract.py | 36 ++ .../claude_code/test_message_contract.py | 179 ++++++++ .../claude_code/test_object_transfer.py | 254 +++++++++++ .../claude_code/test_partial_trajectory.py | 295 ++++++++++++ .../claude_code/test_workspace_snapshot.py | 299 +++++++++++++ 19 files changed, 2634 insertions(+) create mode 100644 tst/unit/examples/__init__.py create mode 100644 tst/unit/examples/claude_code/__init__.py create mode 100644 tst/unit/examples/claude_code/agent.py create mode 100644 tst/unit/examples/claude_code/conftest.py create mode 100644 tst/unit/examples/claude_code/support.py create mode 100644 tst/unit/examples/claude_code/test_card_contract.py create mode 100644 tst/unit/examples/claude_code/test_claude_bridge_cancel.py create mode 100644 tst/unit/examples/claude_code/test_claude_bridge_failure_message.py create mode 100644 tst/unit/examples/claude_code/test_claude_bridge_live.py create mode 100644 tst/unit/examples/claude_code/test_claude_bridge_retry.py create mode 100644 tst/unit/examples/claude_code/test_claude_bridge_stdin.py create mode 100644 tst/unit/examples/claude_code/test_cli_env.py create mode 100644 tst/unit/examples/claude_code/test_ext_mcp_name.py create mode 100644 tst/unit/examples/claude_code/test_extensions.py create mode 100644 tst/unit/examples/claude_code/test_image_contract.py create mode 100644 tst/unit/examples/claude_code/test_message_contract.py create mode 100644 tst/unit/examples/claude_code/test_object_transfer.py create mode 100644 tst/unit/examples/claude_code/test_partial_trajectory.py create mode 100644 tst/unit/examples/claude_code/test_workspace_snapshot.py diff --git a/tst/unit/examples/__init__.py b/tst/unit/examples/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tst/unit/examples/claude_code/__init__.py b/tst/unit/examples/claude_code/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tst/unit/examples/claude_code/agent.py b/tst/unit/examples/claude_code/agent.py new file mode 100644 index 00000000..ee6be756 --- /dev/null +++ b/tst/unit/examples/claude_code/agent.py @@ -0,0 +1,14 @@ +"""The Claude Code agent's modules, imported from its folder as its image runs them: by their own names, from the +folder on the path.""" + +import importlib +import sys +from pathlib import Path + +import agent_env + +AGENT_DIR = Path(agent_env.__file__).parent / "examples" / "agents" / "agents" / "claude-code" +sys.path.insert(0, str(AGENT_DIR)) +a2a_server = importlib.import_module("a2a_server") +claude_bridge = importlib.import_module("claude_bridge") +kit = importlib.import_module("kit") diff --git a/tst/unit/examples/claude_code/conftest.py b/tst/unit/examples/claude_code/conftest.py new file mode 100644 index 00000000..4e20dffe --- /dev/null +++ b/tst/unit/examples/claude_code/conftest.py @@ -0,0 +1,52 @@ +"""A fresh agent per test, its directories under the test's own, and a fake object store behind agent-env's +grants.""" + +from __future__ import annotations + +import httpx +import pytest +from agentenv_protocol import transfers as protocol_transfers +from starlette.testclient import TestClient + +from tst.unit.examples.claude_code.agent import a2a_server, claude_bridge +from tst.unit.examples.claude_code.support import FakeStore + + +@pytest.fixture(autouse=True) +def agent_dirs(monkeypatch, tmp_path): + """The workspace and the CLI's state under the test's folder, never this machine's ~/.claude.""" + state = tmp_path / "claude-state" + sessions = state / "projects" / "-app" + for module in (a2a_server, claude_bridge): + monkeypatch.setattr(module, "CLAUDE_STATE_DIR", state) + monkeypatch.setattr(module, "SESSION_JSONL_DIR", sessions) + monkeypatch.setattr(a2a_server, "SKILLS_DIR", state / "skills") + monkeypatch.setattr(a2a_server, "WORKSPACE_DIR", tmp_path / "app") + monkeypatch.setattr(a2a_server, "INPUTS_DIR", tmp_path / "app" / "inputs") + return state + + +@pytest.fixture +def app(): + return a2a_server.build_app(a2a_server.ClaudeCodeAgent()) + + +@pytest.fixture +def agent(app): + return app.state.agentenv_a2a.agent + + +@pytest.fixture +def client(app) -> TestClient: + return TestClient(app) + + +@pytest.fixture +def store(monkeypatch) -> FakeStore: + """Every grant transfer against one fake store.""" + fake = FakeStore() + real_async_client = httpx.AsyncClient + monkeypatch.setattr(protocol_transfers.httpx, "AsyncClient", + lambda **kwargs: real_async_client(transport=httpx.MockTransport(fake), **kwargs)) + monkeypatch.setattr(protocol_transfers, "_RETRY_BACKOFF_SECONDS", 0) + return fake diff --git a/tst/unit/examples/claude_code/support.py b/tst/unit/examples/claude_code/support.py new file mode 100644 index 00000000..df39ea61 --- /dev/null +++ b/tst/unit/examples/claude_code/support.py @@ -0,0 +1,120 @@ +"""Driving the agent over HTTP the way agent-env does, grants against a fake object store, and a scripted CLI.""" + +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime, timedelta, timezone + +import httpx +from starlette.testclient import TestClient + +ORIGIN = "https://bucket.example.com" + + +def card(app) -> dict: + return app.state.agentenv_a2a.card.model_dump(mode="json", exclude_none=True, by_alias=True) + + +def extension_methods(app, uri: str) -> dict: + ext = next(e for e in card(app)["capabilities"]["extensions"] if e["uri"] == uri) + return ext["params"]["methods"] + + +def send(client: TestClient, parts: list[dict] | str, context_id: str = "ctx-1") -> dict: + """message/send, which blocks until the task is terminal; the task.""" + if isinstance(parts, str): + parts = [{"kind": "text", "text": parts}] + message = {"kind": "message", "role": "user", "messageId": uuid.uuid4().hex, "contextId": context_id, "parts": parts} + response = client.post("/a2a", json={"jsonrpc": "2.0", "id": 1, "method": "message/send", "params": {"message": message}}) + response.raise_for_status() + return response.json()["result"] + + +def terminal(task: dict) -> tuple[str, str, list[dict]]: + """A terminal task's state, its text, and its data parts in order.""" + parts = task["status"]["message"]["parts"] + text = next(part["text"] for part in parts if part["kind"] == "text") + return task["status"]["state"], text, [part["data"] for part in parts if part["kind"] == "data"] + + +def expiry() -> str: + return (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat().replace("+00:00", "Z") + + +def write_grant(key: str, media_type: str = "application/octet-stream", max_bytes: int = 1 << 20) -> dict: + return { + "media_type": media_type, + "max_bytes": max_bytes, + "write": {"kind": "http-put", "url": f"{ORIGIN}/{key}?sig=w", "expires_at": expiry(), "headers": {"Content-Type": media_type}}, + } + + +def read_grant(key: str, size: int) -> dict: + return { + "media_type": "application/octet-stream", + "max_bytes": max(size, 1), + "size_bytes": size, + "read": {"kind": "http-get", "url": f"{ORIGIN}/{key}?sig=r", "expires_at": expiry()}, + } + + +class FakeStore: + """An object store behind grants: PUT stores bytes, GET serves them.""" + + def __init__(self) -> None: + self.objects: dict[str, bytes] = {} + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + body = request.read() + key = request.url.path.lstrip("/") + if request.method == "PUT": + self.objects[key] = body + return httpx.Response(200) + if key not in self.objects: + return httpx.Response(404, stream=httpx.ByteStream(b"NoSuchKey")) + return httpx.Response(200, stream=httpx.ByteStream(self.objects[key])) + + +class FakeStdin: + def __init__(self) -> None: + self.data = b"" + self.closed = False + + def write(self, data: bytes) -> None: + self.data += data + + async def drain(self) -> None: + return None + + def close(self) -> None: + self.closed = True + + +def _reader(data: bytes, *, eof: bool = True) -> asyncio.StreamReader: + reader = asyncio.StreamReader() + reader.feed_data(data) + if eof: + reader.feed_eof() + return reader + + +class FakeCLI: + """A scripted `claude -p` process: fixed stdout, stderr and exit code, and a record of its stdin. ``hang`` + keeps its output open, as a CLI that never finishes.""" + + def __init__(self, stdout: bytes = b"", returncode: int = 0, stderr: bytes = b"", *, hang: bool = False): + self.stdin = FakeStdin() + self.stdout = _reader(stdout, eof=not hang) + self.stderr = _reader(stderr, eof=not hang) + self.returncode = returncode + self.killed = False + + def kill(self) -> None: + self.killed = True + self.returncode = -9 + + async def wait(self) -> int: + return self.returncode diff --git a/tst/unit/examples/claude_code/test_card_contract.py b/tst/unit/examples/claude_code/test_card_contract.py new file mode 100644 index 00000000..20b7454d --- /dev/null +++ b/tst/unit/examples/claude_code/test_card_contract.py @@ -0,0 +1,134 @@ +"""The Claude Code agent's Agent Card: what agent-env negotiates against, where it expects it.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +from tst.unit.examples.claude_code.agent import AGENT_DIR +from tst.unit.examples.claude_code.support import card + +_CLAUDE_SKILL = { + "id": "claude-code", + "name": "claude-code", + "description": "Runs the Claude Code CLI with full tool access (bash, read, edit, glob, grep).", + "tags": ["coding", "agent"], +} + + +def _extensions(app) -> dict: + return {ext["uri"]: ext for ext in card(app)["capabilities"]["extensions"]} + + +def test_identity(app): + served = card(app) + assert {key: served[key] for key in ( + "name", "description", "version", "url", "defaultInputModes", "defaultOutputModes", "skills", + )} == { + "name": "Claude Code", + "description": "An A2A agent running the Claude Code CLI.", + "version": "1.0.0", + "url": "/a2a", + "defaultInputModes": ["text", "image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"], + "defaultOutputModes": ["text"], + "skills": [_CLAUDE_SKILL], + } + capabilities = served["capabilities"] + assert (capabilities["streaming"], capabilities["pushNotifications"], capabilities["stateTransitionHistory"]) == ( + False, False, False) + + +def test_extensions_and_their_routes(app): + routes = { + uri: (ext["params"].get("endpoint"), + {name: (method["method"], method.get("endpoint")) for name, method in ext["params"].get("methods", {}).items()}) + for uri, ext in _extensions(app).items() + } + assert routes == { + "urn:agentenv:agent-config/v1": ("/ext/agent-config", {"set": ("POST", None), "get": ("GET", None)}), + "urn:agentenv:trajectory/v1": ("/ext/trajectory", {"get": ("POST", None)}), + "urn:agentenv:mcp-config/v1": ("/ext/mcp-config", {"add": ("POST", None), "list": ("GET", None)}), + "urn:agentenv:skill-config/v1": ("/ext/skill-config", {"add": ("POST", None), "list": ("GET", None)}), + "urn:agentenv:snapshot/v1": ("/ext/snapshot", {"save": ("POST", None), "load": ("PUT", None)}), + "urn:agentenv:triggers/v1": ("/ext/triggers", { + "register": ("POST", None), "decide": ("POST", "/ext/triggers/decide"), "state": ("GET", None), + }), + "urn:agentenv:staging/v1": ("/ext/staging", {}), + } + + +def test_the_config_takes_every_field_prompt_agent_sends_that_the_cli_has(app): + config = _extensions(app)["urn:agentenv:agent-config/v1"]["params"]["methods"] + assert config["set"]["request"]["supported"] == [ + "allowed_tools", "description", "effort", "max_thinking_tokens", "max_turns", "model", "name", + "output_format", "project_id", "role", "system_prompt", "task_id", "timeout_seconds", + ] + assert set(config["set"]["request"]["schema"]["properties"]) == set(config["set"]["request"]["supported"]) + assert config["get"] == {"method": "GET", "response": {"required": ["config"]}} + + +def test_request_and_response_contracts(app): + methods = {uri: ext["params"].get("methods", {}) for uri, ext in _extensions(app).items()} + assert methods["urn:agentenv:trajectory/v1"]["get"]["request"] == { + "oneOf": [ + {"required": ["task_id"]}, + {"required": ["task_id", "objects"]}, + {"required": ["context_id"]}, + {"required": ["context_id", "objects"]}, + {"required": ["task_id", "after"], "optional": ["limit"]}, + {"required": ["context_id", "after"], "optional": ["limit"]}, + ], + } + assert methods["urn:agentenv:mcp-config/v1"] == { + "add": {"method": "POST", "request": {"required": ["url"], "optional": ["headers", "name"]}}, + "list": {"method": "GET", "response": {"required": ["mcp_servers"]}}, + } + assert methods["urn:agentenv:skill-config/v1"] == { + "add": { + "method": "POST", + "request": {"required": ["name", "description"], + "oneOf": [{"required": ["skill_md"]}, {"required": ["skill_bundle"]}]}, + "response": {"required": ["name"]}, + }, + "list": {"method": "GET", "response": {"required": ["skills"]}}, + } + assert methods["urn:agentenv:snapshot/v1"] == { + "save": {"method": "POST", "request": {"required": ["context_id", "objects"]}, + "response": {"required": ["context_id", "objects"]}}, + "load": {"method": "PUT", "request": {"required": ["objects"], "optional": ["target_context_id"]}, + "response": {"required": ["context_id"]}}, + } + triggers = methods["urn:agentenv:triggers/v1"] + assert triggers["register"] == {"method": "POST", "request": {"required": ["triggers"]}} + assert triggers["decide"] == { + "method": "POST", + "endpoint": "/ext/triggers/decide", + "request": {"required": ["turn"], "optional": ["solver_message", "context_id", "env_triggers"]}, + "response": {"required": ["parts", "done", "fired"]}, + } + assert triggers["state"] == {"method": "GET", "response": {"required": ["firing_log"]}} + + +def test_the_card_and_health_are_served(client): + legacy, current, health = (client.get(path) for path in ( + "/.well-known/agent.json", "/.well-known/agent-card.json", "/health", + )) + assert legacy.json() == current.json() + assert legacy.json()["name"] == "Claude Code" + assert health.json() == {"status": "ok"} + + +def test_install_agent_reads_the_card_and_finds_no_install_extension(): + """install_agent runs this script in the image, with no agent environment, and parses its stdout as the card: + the agent declares no install/v1, so it says so rather than failing to import.""" + script = ("import sys, json; sys.path.insert(0, '/app'); " + "from a2a_server import AGENT_CARD; " + "print(json.dumps(AGENT_CARD.model_dump()))") + env = {**{k: v for k, v in os.environ.items() if not k.startswith(("LITELLM_", "ANTHROPIC_"))}, + "PYTHONPATH": str(AGENT_DIR)} + out = subprocess.run([sys.executable, "-c", script], cwd=AGENT_DIR, env=env, capture_output=True, text=True, + check=True) + uris = [ext["uri"] for ext in json.loads(out.stdout)["capabilities"]["extensions"]] + assert "urn:agentenv:agent-config/v1" in uris and "urn:agentenv:install/v1" not in uris diff --git a/tst/unit/examples/claude_code/test_claude_bridge_cancel.py b/tst/unit/examples/claude_code/test_claude_bridge_cancel.py new file mode 100644 index 00000000..6c37d52e --- /dev/null +++ b/tst/unit/examples/claude_code/test_claude_bridge_cancel.py @@ -0,0 +1,92 @@ +"""A turn the A2A client cancels kills the claude CLI and every tool it started, not only the CLI. + +""" +import asyncio +import contextlib +import os +import shutil +import signal +import subprocess +import time + +import pytest +from tst.unit.examples.claude_code.agent import claude_bridge + + + +def _state(pid: int) -> str: + """The process's state letter, from /proc where there is one, else ps; empty once it is gone.""" + if os.path.isdir("/proc"): + try: + with open(f"/proc/{pid}/stat") as stat: + return stat.read().rsplit(")", 1)[1].split()[0] + except FileNotFoundError: + return "" + return subprocess.run(["ps", "-o", "stat=", "-p", str(pid)], capture_output=True, text=True).stdout.strip()[:1] + + +def _gone(pid: int, within: float = 5.0) -> bool: + deadline = time.monotonic() + within + while _state(pid) not in ("", "Z"): + if time.monotonic() > deadline: + return False + time.sleep(0.05) + return True + + +@pytest.mark.parametrize("tool", [ + "sleep 600", + pytest.param("setsid sleep 600", marks=pytest.mark.skipif( + not (shutil.which("setsid") and os.path.isdir("/proc")), reason="needs setsid and /proc (Linux)")), +], ids=["in-its-group", "in-a-session-of-its-own"]) +def test_a_cancelled_turn_kills_the_cli_and_the_tools_it_started(monkeypatch, tmp_path, tool): + pids = tmp_path / "pids" + spawn = asyncio.create_subprocess_exec + + async def cli_that_starts_a_tool(*_cmd, **kwargs): + return await spawn("sh", "-c", f'{tool} & echo "$$ $!" > {pids}; wait', **kwargs) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", cli_that_starts_a_tool) + + async def cancel_mid_turn(): + turn = asyncio.create_task(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + deadline = time.monotonic() + 10 + while not (pids.exists() and len(pids.read_text().split()) == 2): + if turn.done() or time.monotonic() > deadline: + pytest.fail(f"the CLI never started: {turn.result() if turn.done() else 'still waiting after 10 s'}") + await asyncio.sleep(0.01) + turn.cancel() + with pytest.raises(asyncio.CancelledError): # a TimeoutError means a tool held the CLI's pipes open + await asyncio.wait_for(turn, 10) + return [int(pid) for pid in pids.read_text().split()] + + cli, tool = asyncio.run(cancel_mid_turn()) + + assert _gone(cli) and _gone(tool) + + +@pytest.mark.skipif(not (shutil.which("setsid") and os.path.isdir("/proc")), reason="needs setsid and /proc (Linux)") +def test_a_process_that_left_the_clis_tree_does_not_hold_up_the_cancel(monkeypatch, tmp_path): + pids, escaped = tmp_path / "pids", tmp_path / "escaped" + spawn = asyncio.create_subprocess_exec + + async def cli_whose_tool_left_its_tree(*_cmd, **kwargs): + script = f"sh -c 'setsid sleep 600 & echo $! > {escaped}'; echo $$ > {pids}; exec sleep 600" + return await spawn("sh", "-c", script, **kwargs) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", cli_whose_tool_left_its_tree) + + async def cancel_while_it_holds_the_pipes(): + turn = asyncio.create_task(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + while not (pids.exists() and pids.read_text().strip() and escaped.exists() and escaped.read_text().strip()): + await asyncio.sleep(0.01) + tool = int(escaped.read_text()) + try: + turn.cancel() + with pytest.raises(asyncio.CancelledError): # still holding the tool's pipes would be a TimeoutError + await asyncio.wait_for(turn, 10) + finally: + with contextlib.suppress(ProcessLookupError): + os.kill(tool, signal.SIGKILL) + + asyncio.run(cancel_while_it_holds_the_pipes()) diff --git a/tst/unit/examples/claude_code/test_claude_bridge_failure_message.py b/tst/unit/examples/claude_code/test_claude_bridge_failure_message.py new file mode 100644 index 00000000..22e21365 --- /dev/null +++ b/tst/unit/examples/claude_code/test_claude_bridge_failure_message.py @@ -0,0 +1,225 @@ +"""A failed turn's error names the CLI's own cause, ahead of anything a caller may cut. + +The CLI reports an API failure as a terminal `result` event with `is_error` set +(subtype "success" on 2.1.177) and exits 1 with nothing on stderr, so the cause is +in the stream or nowhere. The event shapes below are copied from captures of the +pinned CLI, with keys replaced by fakes. + +""" +import asyncio +import json +import logging + +import pytest + +from tst.unit.examples.claude_code.support import FakeCLI +from tst.unit.examples.claude_code.agent import claude_bridge + +# What agent-env keeps of an agent's error_message, and a2a_server's own cap. +_AGENT_ENV_KEEPS = 500 +_A2A_SERVER_CAP = 1000 + +_REFUSAL = ( + "API Error: Claude Code is unable to respond to this request, which appears to violate " + "our Usage Policy (https://www.anthropic.com/legal/aup). API integrators: you can reduce " + "refusals for your users by configuring a fallback model — see " + "https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback. Try " + "rephrasing the request in a new session or change your model." +) +_FAKE_KEY = "sk-FAKEfake0000fake0000fak" + + +def _stdout(*events): + lines = [{"type": "system", "subtype": "init", "session_id": "sess-1"}, *events] + return "".join(json.dumps(e) + "\n" for e in lines).encode() + + +def _api_error(text, **fields): + return { + "type": "result", "subtype": "success", "is_error": True, "session_id": "sess-1", + "terminal_reason": "completed", "result": text, **fields, + } + + +@pytest.fixture +def run(monkeypatch, tmp_path): + """Run one turn against scripted `claude -p` invocations of (stdout, returncode[, stderr]).""" + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", tmp_path) + monkeypatch.setattr(claude_bridge, "_RETRY_BACKOFF_S", (0, 0)) + monkeypatch.delenv("CLAUDE_API_ERROR_RETRIES", raising=False) + (tmp_path / "sess-1.jsonl").write_text('{"type":"assistant"}\n') + + def _run(*invocations): + queue = list(invocations) + + async def _exec(*cmd, **kwargs): + return FakeCLI(*queue.pop(0)) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _exec) + return asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + return _run + + +@pytest.mark.parametrize( + "event", + [ + _api_error("API Error: 400 Budget has been exceeded! Key=pub-xxxx Spend=1 Budget=1", + api_error_status=400), + _api_error("Failed to authenticate. API Error: 401 Authentication Error, Invalid proxy " + "server token passed. Received API Key = sk-...0000, Key Hash (Token) =deadbeef.", + api_error_status=401), + _api_error("API Error: 400 {'error': '/v1/messages: Invalid model name passed in " + "model=claude-nope-9. Call `/v1/models` to view available models for your key.'}", + api_error_status=400), + _api_error("API Error: Request rejected (429) · 429: Rate limit exceeded for api_key: " + "pub-xxxx. Limit type: tokens.", api_error_status=429), + _api_error("Prompt is too long", api_error_status=400, terminal_reason="prompt_too_long"), + ], + ids=["budget", "auth", "invalid-model", "rate-limit", "prompt-too-long"], +) +def test_the_cli_cause_follows_the_exit_code(run, event): + result = run((_stdout(event), 1)) + + assert result.attempts == 1 + assert result.error == f"claude CLI exited with code 1: {event['result']}" + + +def test_an_errors_only_result_names_its_errors(run): + max_turns = { + "type": "result", "subtype": "error_max_turns", "is_error": True, + "terminal_reason": "max_turns", "errors": ["Reached maximum number of turns (1)"], + } + + result = run((_stdout(max_turns), 1)) + + assert result.error == "claude CLI exited with code 1: Reached maximum number of turns (1)" + + +def test_a_structured_answer_keeps_its_response_and_still_reports_the_failure(run): + event = _api_error("API Error: Request rejected (429) · 429: Rate limit exceeded", + api_error_status=429, structured_output={"verdict": "pass"}) + + result = run((_stdout(event), 1)) + + assert result.response == json.dumps({"verdict": "pass"}) + assert result.error == "claude CLI exited with code 1: API Error: Request rejected (429) · 429: Rate limit exceeded" + + +def test_a_killed_cli_names_the_signal(run): + tool_use = {"type": "assistant", "message": {"content": [ + {"type": "tool_use", "name": "Bash", "input": {"command": "pkill -9 -f gimp"}}, + ]}} + + result = run((_stdout(tool_use), -9)) + + assert result.error == "claude CLI was killed by signal 9" + + +def test_nothing_to_report_keeps_the_exit_code_alone(run): + assert run((b"", 1)).error == "claude CLI exited with code 1" + + +def test_a_finished_turn_that_exits_nonzero_keeps_its_answer_out_of_the_error(run): + answer = {"type": "result", "subtype": "success", "is_error": False, "result": "the answer"} + + result = run((_stdout(answer), 1)) + + assert result.response == "the answer" + assert result.error == "claude CLI exited with code 1" + + +def test_an_error_event_on_a_zero_exit_stays_a_success(run): + result = run((_stdout(_api_error("API Error: 400 bad", api_error_status=400)), 0)) + + assert result.error is None + + +@pytest.mark.parametrize("errors", [[{"message": "boom"}], "boom"]) +def test_an_unexpected_errors_shape_falls_back_to_the_exit_code(run, errors): + event = {"type": "result", "subtype": "error_during_execution", "is_error": True, "errors": errors} + + assert run((_stdout(event), 1)).error == "claude CLI exited with code 1" + + +def test_stderr_explains_a_cli_that_emitted_no_result(run): + result = run((b"", 1, b"No conversation found with session ID: abc\n")) + + assert result.error == "claude CLI exited with code 1: No conversation found with session ID: abc" + + +def test_the_cli_result_wins_over_stderr(run): + event = _api_error("API Error: 400 Budget has been exceeded!", api_error_status=400) + + result = run((_stdout(event), 1, b"(node:12) Warning: something noisy\n")) + + assert result.error == "claude CLI exited with code 1: API Error: 400 Budget has been exceeded!" + + +def test_a_long_cause_on_a_resumed_turn_keeps_the_attempt_in_view(run): + refusal = (_stdout(_api_error(_REFUSAL + " " + "x" * 200)), 1) + + result = run(refusal, refusal, refusal) + + assert result.attempts == 3 + assert "claude CLI exited with code 1 on attempt 3: API Error: Claude Code is unable" in result.error[:_AGENT_ENV_KEEPS] + assert len(result.error) <= _A2A_SERVER_CAP + + +def test_verbose_stderr_after_a_resume_stays_under_the_cap(run): + timed_out = (_stdout(_api_error("API Error: The operation timed out.")), 1) + died = (b"", 1, b"x" * 1500 + b"\nfinal stderr line\n") + + result = run(timed_out, died) + + assert result.attempts == 2 + assert result.error.startswith("claude CLI exited with code 1 on attempt 2: ") + assert result.error.endswith(" | final stderr line (attempt 1: API Error: The operation timed out.)") + assert len(result.error) <= _A2A_SERVER_CAP + + +def test_a_crash_keeps_its_error_line_ahead_of_its_stack(run): + crash = b"TypeError: Cannot read properties of undefined (reading 'map')\n" + ( + b" at fn (file:///usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js:1003:2003)\n" * 10 + ) + b"\nNode.js v20.18.1\n" + + result = run((b"", 1, crash)) + + assert result.error == ( + "claude CLI exited with code 1: TypeError: Cannot read properties of undefined " + "(reading 'map') | Node.js v20.18.1" + ) + + +def test_a_key_cut_by_the_stderr_tail_is_still_redacted(run): + stderr = "x" * 100 + " " + _FAKE_KEY + " " + "y" * 573 + + assert _FAKE_KEY[3:] not in run((b"", 1, stderr.encode())).error + + +def test_a_key_in_the_cli_error_is_redacted_from_error_response_and_logs(run, caplog): + # The shape of a real incident: a multi-line key value echoed in an HTTP header error. + leak = (_stdout(_api_error( + f"API Error: Header '14' has invalid value: 'Bearer Name: PUB_FAKE\nValue: {_FAKE_KEY}'" + )), 1) + + with caplog.at_level(logging.INFO, logger=claude_bridge.__name__): + result = run(leak, leak, leak) + + assert result.attempts == 3 + assert "[redacted]" in result.error + for text in (result.error, result.response, caplog.text): + assert _FAKE_KEY not in text + + +def test_redaction_leaves_masked_keys_and_ids_alone(): + text = ("Received API Key = sk-...0000 for task-abcdefghijkl; raw sk-abcd1234efgh; " + "google AIzaFAKEfake0000fake0000fake0000fake000; " + 'Bearer tok.en; {"auth":"Bearer abc123","code":"401"}; ' + "Header 'x-goog-api-key' has invalid value: 'owner's note\nopaque-secret'") + + assert claude_bridge._redact(text) == ( + "Received API Key = sk-...0000 for task-abcdefghijkl; raw [redacted]; google [redacted]; " + '[redacted]; {"auth":"[redacted]","code":"401"}; ' + "Header 'x-goog-api-key' has invalid value: '[redacted]'" + ) diff --git a/tst/unit/examples/claude_code/test_claude_bridge_live.py b/tst/unit/examples/claude_code/test_claude_bridge_live.py new file mode 100644 index 00000000..726af099 --- /dev/null +++ b/tst/unit/examples/claude_code/test_claude_bridge_live.py @@ -0,0 +1,154 @@ +"""The CLI's stdout events reach `event_callback` as it prints them, and are exactly the events of the +turn's trajectory: an attempt that is resumed never reports its `result`. + +""" +import asyncio +import json + +import pytest + +from tst.unit.examples.claude_code.support import FakeCLI +from tst.unit.examples.claude_code.agent import claude_bridge + +_INIT = {"type": "system", "subtype": "init", "session_id": "sess-1"} +_TOOL = {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Bash", "input": {}}]}} +_DONE = {"type": "result", "subtype": "success", "result": "done", "session_id": "sess-1"} +_STALLED = { + "type": "result", "subtype": "success", "is_error": True, "session_id": "sess-1", + "terminal_reason": "api_error", "result": "API Error: The operation timed out.", +} + + +def _lines(*events, ensure_ascii=True): + return "".join(json.dumps(e, ensure_ascii=ensure_ascii) + "\n" for e in events).encode() + + +@pytest.fixture +def cli(monkeypatch, tmp_path): + """Queue the `claude -p` processes run_claude starts, in order, each made when it starts.""" + queue: list = [] + + async def _exec(*cmd, **kwargs): + return queue.pop(0)() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _exec) + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", tmp_path) + monkeypatch.setattr(claude_bridge, "_RETRY_BACKOFF_S", (0, 0)) + monkeypatch.delenv("CLAUDE_API_ERROR_RETRIES", raising=False) + (tmp_path / "sess-1.jsonl").write_text('{"type":"assistant"}\n') + return queue + + +def _run(**kwargs): + reported: list[dict] = [] + result = asyncio.run( + claude_bridge.run_claude( + prompt="p", session_id="sess-1", resume=False, event_callback=reported.append, **kwargs + ) + ) + return result, reported + + +def test_a_turn_reports_its_events_in_order_and_they_are_its_trajectory(cli): + cli.append(lambda: FakeCLI(_lines(_INIT, _TOOL, _DONE))) + + result, reported = _run() + + assert reported == result.trajectory == [_INIT, _TOOL, _DONE] + assert result.response == "done" + + +def test_a_resumed_attempt_never_reports_its_result(cli): + cli.append(lambda: FakeCLI(_lines(_INIT, _TOOL, _STALLED), returncode=1)) + cli.append(lambda: FakeCLI(_lines(_INIT, _DONE))) + + result, reported = _run() + + assert result.attempts == 2 + assert reported == result.trajectory == [_INIT, _TOOL, _INIT, _DONE] + + +def test_an_attempt_that_is_not_resumed_reports_its_result(cli, monkeypatch): + monkeypatch.setenv("CLAUDE_API_ERROR_RETRIES", "0") + cli.append(lambda: FakeCLI(_lines(_INIT, _STALLED), returncode=1)) + + result, reported = _run() + + assert result.error is not None + assert reported == result.trajectory == [_INIT, _STALLED] + + +def test_events_are_reported_while_the_cli_runs(cli): + started: list[FakeCLI] = [] + cli.append(lambda: started.append(FakeCLI(_lines(_INIT), hang=True)) or started[0]) + + async def turn(): + reported: list[dict] = [] + running = asyncio.create_task( + claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False, event_callback=reported.append) + ) + async with asyncio.timeout(5): + while not reported: + await asyncio.sleep(0.01) + seen_while_running = list(reported) + proc = started[0] + proc.stdout.feed_data(_lines(_TOOL, _DONE)) + proc.stdout.feed_eof() + proc.stderr.feed_eof() + await running + return seen_while_running, reported + + seen_while_running, reported = asyncio.run(turn()) + + assert seen_while_running == [_INIT] + assert reported == [_INIT, _TOOL, _DONE] + + +def test_lines_split_exactly_as_the_whole_output_did(cli): + """`splitlines` also breaks on U+2028, which the CLI leaves raw in JSON strings: such an event never + parsed, and must not start to, or the trajectory would change.""" + separated = {"type": "assistant", "text": "a
b"} + stdout = _lines(_INIT, separated, _DONE, ensure_ascii=False) + b"\r\n" + expected = [json.loads(line) for line in stdout.decode(errors="replace").splitlines() if _parses(line)] + cli.append(lambda: FakeCLI(stdout)) + + result, reported = _run() + + assert reported == result.trajectory == expected == [_INIT, _DONE] + + +def test_a_line_longer_than_a_stream_buffer_is_one_event(cli): + big = {"type": "user", "message": {"content": [{"type": "tool_result", "content": "x" * 300_000}]}} + cli.append(lambda: FakeCLI(_lines(_INIT, big, _DONE))) + + result, reported = _run() + + assert reported == result.trajectory == [_INIT, big, _DONE] + + +def test_a_json_line_that_is_not_an_object_is_skipped(cli): + cli.append(lambda: FakeCLI(_lines(_INIT) + b"[1, 2]\n" + _lines(_DONE))) + + result, reported = _run() + + assert reported == result.trajectory == [_INIT, _DONE] + + +def _parses(line: str) -> bool: + try: + return isinstance(json.loads(line), dict) + except json.JSONDecodeError: + return False + + +def test_a_callback_that_raises_kills_the_cli(cli): + started: list[FakeCLI] = [] + cli.append(lambda: started.append(FakeCLI(_lines(_INIT), hang=True)) or started[0]) + + def refuse(event): + raise ValueError("rejected") + + with pytest.raises(ValueError, match="rejected"): + asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False, event_callback=refuse)) + + assert started[0].killed diff --git a/tst/unit/examples/claude_code/test_claude_bridge_retry.py b/tst/unit/examples/claude_code/test_claude_bridge_retry.py new file mode 100644 index 00000000..12fe46dc --- /dev/null +++ b/tst/unit/examples/claude_code/test_claude_bridge_retry.py @@ -0,0 +1,418 @@ +"""A transient upstream failure resumes the CLI session instead of losing the turn. + +When the model call stalls, the CLI exits 1 with a terminal `result` event carrying is_error and its own message +("API Error: The operation timed out."). The session transcript survives on disk, so the turn is resumable. These pin +which failures resume, which don't, and that resumes never outlive the caller's timeout budget. + +""" +import asyncio +import json + +import pytest + +from tst.unit.examples.claude_code.support import FakeCLI +from tst.unit.examples.claude_code.agent import claude_bridge + + +def _event(**kw): + return json.dumps(kw) + "\n" + + +def _ok_stdout(text="done", session_id="sess-1"): + return (_event(type="result", subtype="success", result=text, session_id=session_id)).encode() + + +def _api_error_stdout(message, session_id="sess-1", tool_uses=0, **verdict): + lines = _event(type="system", subtype="init", session_id=session_id) + for _ in range(tool_uses): + lines += _event( + type="assistant", + message={"content": [{"type": "tool_use", "name": "Bash", "input": {}}]}, + ) + lines += _event( + type="result", subtype="success", is_error=True, result=message, + session_id=session_id, **verdict + ) + return lines.encode() + + +@pytest.fixture +def scripted(monkeypatch, tmp_path): + """Drive run_claude with a queue of (stdout, returncode) invocations.""" + calls: list[list[str]] = [] + procs: list[FakeCLI] = [] + queue: list[tuple[bytes, int]] = [] + + async def _fake_exec(*cmd, **kwargs): + calls.append(list(cmd)) + stdout, rc = queue.pop(0) + procs.append(FakeCLI(stdout, rc)) + return procs[-1] + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + # The resume gate requires a real session transcript on disk. + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", tmp_path) + monkeypatch.setattr(claude_bridge, "_RETRY_BACKOFF_S", (0, 0)) + monkeypatch.delenv("CLAUDE_API_ERROR_RETRIES", raising=False) + return {"calls": calls, "procs": procs, "queue": queue, "session_dir": tmp_path} + + +def _write_session(scripted, session_id="sess-1"): + (scripted["session_dir"] / f"{session_id}.jsonl").write_text('{"type":"assistant"}\n') + + +def test_success_makes_exactly_one_invocation(scripted): + scripted["queue"].append((_ok_stdout(), 0)) + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + assert result.attempts == 1 + assert result.error is None + assert result.response == "done" + assert len(scripted["calls"]) == 1 + + +def test_stalled_turn_resumes_the_session_and_recovers(scripted): + _write_session(scripted) + scripted["queue"].append((_api_error_stdout("API Error: The operation timed out.", tool_uses=3), 1)) + scripted["queue"].append((_ok_stdout("finished after resume"), 0)) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + assert result.attempts == 2 + assert result.error is None + assert result.response == "finished after resume" + # First invocation opens the session, second resumes it. + assert "--session-id" in scripted["calls"][0] + assert scripted["calls"][1][scripted["calls"][1].index("--resume") + 1] == "sess-1" + assert "--session-id" not in scripted["calls"][1] + # The resume carries a continuation nudge, never the original prompt again. + assert scripted["procs"][0].stdin.data == b"p" + assert scripted["procs"][1].stdin.data == claude_bridge._RESUME_PROMPT.encode() + # Tool calls accumulate across attempts. + assert result.tool_call_count == 3 + + +def test_abandoned_attempt_leaves_its_work_but_not_its_error(scripted): + """Downstream readers take the FIRST result event as the turn's answer.""" + _write_session(scripted) + scripted["queue"].append((_api_error_stdout("API Error: The operation timed out.", tool_uses=3), 1)) + scripted["queue"].append((_ok_stdout("finished after resume"), 0)) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + results = [e for e in result.trajectory if e.get("type") == "result"] + assert len(results) == 1 + assert results[0]["result"] == "finished after resume" + assert not any(e.get("is_error") for e in result.trajectory) + # The abandoned attempt's real work is still there: init + 3 tool_use turns. + assert len([e for e in result.trajectory if e.get("type") == "assistant"]) == 3 + assert len(result.trajectory) == 5 + + +def test_non_resumable_api_error_is_not_retried(scripted): + _write_session(scripted) + scripted["queue"].append((_api_error_stdout("API Error: 401 authentication_error"), 1)) + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + assert result.attempts == 1 + assert result.error is not None + assert len(scripted["calls"]) == 1 + + +@pytest.mark.parametrize( + "message", + [ + # What the CLI says once a sandbox's egress is cut. Each emits a terminal result event, so only the + # message allowlist keeps them from being retried. + "API Error: Unable to connect to API (FailedToOpenSocket)", + "API Error: Unable to connect to API (ConnectionRefused)", + ], +) +def test_unreachable_endpoint_is_never_retried(scripted, message): + _write_session(scripted) + scripted["queue"].append((_api_error_stdout(message), 1)) + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + assert result.attempts == 1 + assert len(scripted["calls"]) == 1 + + +def test_cli_death_without_a_result_event_is_not_retried(scripted): + _write_session(scripted) + scripted["queue"].append((b"", 1)) + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + assert result.attempts == 1 + assert len(scripted["calls"]) == 1 + + +def test_missing_session_transcript_blocks_the_resume(scripted): + scripted["queue"].append((_api_error_stdout("API Error: The operation timed out."), 1)) + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + assert result.attempts == 1 + assert result.error is not None + + +def test_retries_are_bounded(scripted, monkeypatch): + _write_session(scripted) + monkeypatch.setenv("CLAUDE_API_ERROR_RETRIES", "1") + for _ in range(3): + scripted["queue"].append((_api_error_stdout("API Error: The operation timed out."), 1)) + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + assert result.attempts == 2 + assert result.error is not None + + +def test_retries_can_be_disabled(scripted, monkeypatch): + _write_session(scripted) + monkeypatch.setenv("CLAUDE_API_ERROR_RETRIES", "0") + scripted["queue"].append((_api_error_stdout("API Error: The operation timed out."), 1)) + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + assert result.attempts == 1 + + +def test_exhausted_time_budget_blocks_the_resume(scripted): + """A retry must never outlive the deadline the caller sized.""" + _write_session(scripted) + scripted["queue"].append((_api_error_stdout("API Error: The operation timed out."), 1)) + result = asyncio.run( + claude_bridge.run_claude( + prompt="p", session_id="sess-1", resume=False, + timeout_s=claude_bridge._MIN_RETRY_BUDGET_S - 1, + ) + ) + assert result.attempts == 1 + assert result.error is not None + + +def test_structured_output_survives_a_resume(scripted): + _write_session(scripted) + schema = {"type": "json_schema", "schema": {"type": "object"}} + scripted["queue"].append((_api_error_stdout("API Error: The operation timed out."), 1)) + scripted["queue"].append(( + (_event(type="result", subtype="success", result="{}", session_id="sess-1", + structured_output={"verdict": "pass"})).encode(), 0)) + + result = asyncio.run( + claude_bridge.run_claude( + prompt="p", session_id="sess-1", resume=False, output_format=schema + ) + ) + + assert result.structured_output == {"verdict": "pass"} + assert result.response == json.dumps({"verdict": "pass"}) + # The schema is re-applied on the resumed invocation, not dropped. + assert "--json-schema" in scripted["calls"][1] + + +@pytest.mark.parametrize( + "message", + [ + # Real CLI messages; the first is the stall this exists for. + "API Error: The operation timed out.", + "API Error: The socket connection was closed unexpectedly. For more " + "information, pass `verbose: true` in the second argument to fetch()", + 'API Error: 529 {"type":"error","error":{"type":"overloaded_error"}}', + 'API Error: 500 {"type":"error","error":{"type":"api_error","message":' + '"Internal server error"}}', + ], +) +def test_resumable_markers(scripted, message): + _write_session(scripted) + scripted["queue"].append((_api_error_stdout(message), 1)) + scripted["queue"].append((_ok_stdout("recovered"), 0)) + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + assert result.attempts == 2, f"expected {message!r} to be resumable" + assert result.error is None + + +# Captured from claude-code 2.1.177 driven against a scripted upstream: every +# field below is what the CLI actually emitted, not a guess at its schema. +_CAPTURED = { + "stalled_stream": { + "message": "API Error: API returned an empty or malformed response (HTTP 200) " + "\u2014 check for a proxy or gateway intercepting the request", + "verdict": {"terminal_reason": "completed", "api_error_status": None}, + "resumable": True, + }, + "overloaded": { + "message": "API Error: Repeated 529 Overloaded errors. The API is at capacity", + "verdict": {"terminal_reason": "completed", "api_error_status": None}, + "resumable": True, + }, + # 2.1.232 moved upstream failures out of "completed" into a new "api_error"; + # the Dockerfile pins the CLI and bumping it is routine, so both must resume. + "stalled_stream_2_1_232": { + "message": "API Error: API returned an empty or malformed response (HTTP 200)", + "verdict": {"terminal_reason": "api_error", "api_error_status": None}, + "resumable": True, + }, + "overloaded_2_1_232": { + "message": "API Error: Repeated 529 Overloaded errors. The API is at capacity", + "verdict": {"terminal_reason": "api_error", "api_error_status": 529}, + "resumable": True, + }, + "prompt_too_long": { + "message": "Prompt is too long \u00b7 the request is ~300000 tokens (limit 200000)", + "verdict": {"terminal_reason": "prompt_too_long", "api_error_status": 400}, + "resumable": False, + }, +} + + +@pytest.mark.parametrize("name", sorted(_CAPTURED)) +def test_captured_cli_verdicts(scripted, name): + """The CLI's own `terminal_reason` decides, so a reworded message cannot + silently turn a resumable failure into an unrecoverable one.""" + case = _CAPTURED[name] + _write_session(scripted) + scripted["queue"].append( + (_api_error_stdout(case["message"], **case["verdict"]), 1) + ) + scripted["queue"].append((_ok_stdout("recovered"), 0)) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + assert result.attempts == (2 if case["resumable"] else 1) + + +@pytest.mark.parametrize( + "api_error_status, expected_attempts", + [ + # `terminal_reason: completed` is not sufficient on its own: a 4xx is the + # request's own fault and reproduces byte-for-byte on a resume. + (400, 1), + (503, 2), + ], +) +def test_api_error_status_gates_a_completed_turn(scripted, api_error_status, expected_attempts): + _write_session(scripted) + scripted["queue"].append( + (_api_error_stdout(f"API Error: {api_error_status}", + terminal_reason="completed", + api_error_status=api_error_status), 1) + ) + scripted["queue"].append((_ok_stdout("recovered"), 0)) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + assert result.attempts == expected_attempts + + +def test_message_markers_still_apply_without_a_terminal_reason(scripted): + """Builds that do not report `terminal_reason` keep the pre-existing + behaviour exactly: the marker list is a fallback, not a replacement.""" + _write_session(scripted) + scripted["queue"].append((_api_error_stdout("API Error: The operation timed out."), 1)) + scripted["queue"].append((_ok_stdout("recovered"), 0)) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + assert result.attempts == 2 + + +def test_unknown_message_without_a_terminal_reason_is_not_resumed(scripted): + _write_session(scripted) + scripted["queue"].append((_api_error_stdout("API Error: something brand new"), 1)) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + assert result.attempts == 1 + + +@pytest.mark.parametrize( + "api_error_status", + # The CLI types this as a bare JS number today; the veto must not depend on + # that staying true, and must beat a marker the prose happens to contain. + [400, "400", "429", "400 Bad Request", 400.0], +) +def test_a_client_error_vetoes_even_when_the_prose_matches_a_marker(scripted, api_error_status): + _write_session(scripted) + scripted["queue"].append( + (_api_error_stdout('API Error: 400 {"error":{"message":"Internal server error"}}', + terminal_reason="completed", + api_error_status=api_error_status), 1) + ) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + assert result.attempts == 1 + + +def test_a_permanent_reason_does_not_veto_a_marker_it_cannot_explain(scripted): + """An unrecognised `terminal_reason` must fall through to the markers rather + than refuse: the enum is open and grew 12 -> 19 values across two builds, so + reading anything unknown as permanent would disable this on a CLI bump.""" + _write_session(scripted) + scripted["queue"].append( + (_api_error_stdout("API Error: The operation timed out.", + terminal_reason="some_reason_added_next_release"), 1) + ) + scripted["queue"].append((_ok_stdout("recovered"), 0)) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + assert result.attempts == 2 + + +def test_the_original_failure_survives_into_the_final_error(scripted): + """`_without_terminal_result` drops the event carrying the original text, and + `cli_attempts` does not cross the A2A boundary — so the error string is the + only field that can still name what actually went wrong.""" + _write_session(scripted) + scripted["queue"].append( + (_api_error_stdout("API Error: The operation timed out."), 1) + ) + scripted["queue"].append((_api_error_stdout("API Error: something permanent"), 1)) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + assert result.attempts == 2 + assert result.error == ( + "claude CLI exited with code 1 on attempt 2: API Error: something permanent " + "(attempt 1: API Error: The operation timed out.)" + ) + + +def test_a_resume_that_fails_the_same_way_names_it_once(scripted): + _write_session(scripted) + for _ in range(3): + scripted["queue"].append((_api_error_stdout("API Error: The operation timed out."), 1)) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + assert result.attempts == 3 + assert result.error == "claude CLI exited with code 1 on attempt 3: API Error: The operation timed out." + + +def test_an_unresumed_turn_does_not_gain_an_attribution_suffix(scripted): + _write_session(scripted) + scripted["queue"].append((_api_error_stdout("API Error: something permanent"), 1)) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + assert result.attempts == 1 + assert result.error == "claude CLI exited with code 1: API Error: something permanent" + + +@pytest.mark.parametrize( + "api_error_status, expected_attempts", + [ + # litellm mints 408 for its own Timeout, so a proxy timeout — the failure + # this retry exists for — arrives as a 4xx and must not be vetoed. + (408, 2), + (425, 2), + # 429 stays vetoed: the CLI has already backed off on it. + (429, 1), + (400, 1), + (401, 1), + ], +) +def test_the_retryable_4xx_survive_the_veto(scripted, api_error_status, expected_attempts): + _write_session(scripted) + scripted["queue"].append( + (_api_error_stdout(f"API Error: {api_error_status} Request timed out.", + terminal_reason="api_error", + api_error_status=api_error_status), 1) + ) + scripted["queue"].append((_ok_stdout("recovered"), 0)) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + + assert result.attempts == expected_attempts diff --git a/tst/unit/examples/claude_code/test_claude_bridge_stdin.py b/tst/unit/examples/claude_code/test_claude_bridge_stdin.py new file mode 100644 index 00000000..b727d7d8 --- /dev/null +++ b/tst/unit/examples/claude_code/test_claude_bridge_stdin.py @@ -0,0 +1,116 @@ +"""The prompt goes to the CLI on stdin, never argv: a judge prompt (the agent's prompt, its whole response and the +criteria) can pass Linux's per-argument limit (MAX_ARG_STRLEN, 128 KiB), and exec fails with E2BIG before the CLI +starts. The CLI's environment is checked here too. + +""" +import asyncio +import json + +import pytest + +from tst.unit.examples.claude_code.support import FakeCLI +from tst.unit.examples.claude_code.agent import claude_bridge + +MAX_ARG_STRLEN = 131072 # Linux single-argument limit (PAGE_SIZE * 32) + + +_RESULT = {"type": "result", "subtype": "success", "result": "judge verdict", "session_id": "sess-1"} + + +@pytest.fixture +def spawn_capture(monkeypatch): + capture: dict = {} + + async def _fake_exec(*cmd, **kwargs): + capture["cmd"] = list(cmd) + capture["kwargs"] = kwargs + capture["proc"] = FakeCLI((json.dumps(_RESULT) + "\n").encode()) + return capture["proc"] + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + return capture + + +@pytest.fixture +def spawn_capture_hang(monkeypatch): + capture: dict = {} + + async def _fake_exec(*cmd, **kwargs): + capture["cmd"] = list(cmd) + capture["kwargs"] = kwargs + # Never finishes within the test's timeout — forces the + # asyncio.TimeoutError path in run_claude (proc.kill/proc.wait). + capture["proc"] = FakeCLI(hang=True) + return capture["proc"] + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + return capture + + +def test_prompt_goes_to_stdin_not_argv(spawn_capture): + prompt = "evaluate this trajectory against the criteria" + result = asyncio.run(claude_bridge.run_claude(prompt=prompt, session_id="sess-1", resume=False)) + assert not any(prompt in arg for arg in spawn_capture["cmd"]) + assert spawn_capture["kwargs"].get("stdin") is asyncio.subprocess.PIPE + assert spawn_capture["proc"].stdin.data == prompt.encode() + assert result.response == "judge verdict" + assert result.error is None + + +@pytest.mark.parametrize( + "model", + ["claude-sonnet-5", "gpt-5.6-sol", "gemini/gemini-3.1-pro-preview"], +) +def test_disables_dynamic_attribution_block_for_every_model(spawn_capture, monkeypatch, model): + monkeypatch.setenv("CLAUDE_CODE_ATTRIBUTION_HEADER", "1") + + asyncio.run( + claude_bridge.run_claude( + prompt="p", + session_id="sess-1", + resume=False, + model=model, + ) + ) + + assert spawn_capture["kwargs"]["env"]["CLAUDE_CODE_ATTRIBUTION_HEADER"] == "0" + assert spawn_capture["cmd"][spawn_capture["cmd"].index("--model") + 1] == model + + +def test_cache_fix_preserves_litellm_cost_attribution(spawn_capture): + asyncio.run( + claude_bridge.run_claude( + prompt="p", + session_id="sess-1", + resume=False, + project_id="project-1", + task_id="task-1", + ) + ) + + env = spawn_capture["kwargs"]["env"] + assert env["CLAUDE_CODE_ATTRIBUTION_HEADER"] == "0" + assert env["ANTHROPIC_CUSTOM_HEADERS"] == ( + "x-litellm-customer-id: project-1\n" + "x-litellm-tags: projectId:project-1,taskId:task-1" + ) + + +def test_oversized_judge_prompt_never_lands_on_argv(spawn_capture): + # ~360 KB, well past MAX_ARG_STRLEN. + prompt = "criteria " * 45_000 + asyncio.run(claude_bridge.run_claude(prompt=prompt, session_id="sess-1", resume=False)) + assert all(len(arg) < MAX_ARG_STRLEN for arg in spawn_capture["cmd"]) + assert spawn_capture["proc"].stdin.data == prompt.encode() + + +def test_timeout_kills_process_and_returns_error(spawn_capture_hang): + # A hung CLI must hit the asyncio.TimeoutError path, which kills the + # process and awaits proc.wait() — exercises those _FakeProc stubs. + result = asyncio.run( + claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False, timeout_s=0) + ) + assert result.error is not None + assert "timed out" in result.error + assert result.response == "" + assert spawn_capture_hang["proc"].killed is True diff --git a/tst/unit/examples/claude_code/test_cli_env.py b/tst/unit/examples/claude_code/test_cli_env.py new file mode 100644 index 00000000..2bcfbbd4 --- /dev/null +++ b/tst/unit/examples/claude_code/test_cli_env.py @@ -0,0 +1,121 @@ +"""What the CLI is run with: the configured endpoint as Anthropic's variables, the model's aliases pinned to the run's +model, [model.headers] and the run's attribution on every call, the turn and thinking limits, and what a 404 from +the endpoint means.""" + +from __future__ import annotations + +import asyncio +import json + +import pytest + +from tst.unit.examples.claude_code.agent import claude_bridge +from tst.unit.examples.claude_code.support import FakeCLI + +GATEWAY = {"LITELLM_API_KEY": "gateway-key", "LITELLM_BASE_URL": "https://gateway.example.com"} +ALIASES = ("ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL") + + +def test_the_configured_endpoint_is_the_clis_with_its_key_as_a_bearer_token(): + env = claude_bridge.cli_env({**GATEWAY, "ANTHROPIC_API_KEY": "stale"}) + + assert (env["ANTHROPIC_AUTH_TOKEN"], env["ANTHROPIC_BASE_URL"], env["ANTHROPIC_API_KEY"]) == ( + "gateway-key", "https://gateway.example.com", "") + assert env["CLAUDE_CODE_ATTRIBUTION_HEADER"] == "0" + + +def test_anthropic_variables_already_set_are_kept(): + env = claude_bridge.cli_env({**GATEWAY, "ANTHROPIC_AUTH_TOKEN": "own", "ANTHROPIC_BASE_URL": "https://own.example"}) + + assert (env["ANTHROPIC_AUTH_TOKEN"], env["ANTHROPIC_BASE_URL"]) == ("own", "https://own.example") + + +def test_no_endpoint_leaves_the_cli_to_its_own_and_an_api_key_alone(): + env = claude_bridge.cli_env({"ANTHROPIC_API_KEY": "direct"}, model="claude-x") + + assert env["ANTHROPIC_API_KEY"] == "direct" and "ANTHROPIC_AUTH_TOKEN" not in env + assert not any(alias in env for alias in ALIASES) + + +def test_the_models_aliases_are_pinned_on_the_endpoint_but_an_explicit_one_wins(): + env = claude_bridge.cli_env({**GATEWAY, "ANTHROPIC_DEFAULT_HAIKU_MODEL": "small"}, model="claude-x") + + assert {alias: env[alias] for alias in ALIASES} == { + "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-x", "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-x", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "small", "ANTHROPIC_DEFAULT_FABLE_MODEL": "claude-x", + "CLAUDE_CODE_SUBAGENT_MODEL": "claude-x", + } + + +def test_model_headers_and_the_runs_attribution_reach_every_call(): + env = claude_bridge.cli_env( + {**GATEWAY, "LITELLM_EXTRA_HEADERS": json.dumps({"X-Bill-To": "team-a", "x-litellm-tags": "from-config"})}, + project_id="p-1", task_id="t-1") + + assert env["ANTHROPIC_CUSTOM_HEADERS"].splitlines() == [ + "X-Bill-To: team-a", + "x-litellm-tags: projectId:p-1,taskId:t-1", + "x-litellm-customer-id: p-1", + ] + + +@pytest.mark.parametrize("raw", ["not json", '["a list"]']) +def test_model_headers_that_arent_a_json_object_are_ignored(raw): + assert "ANTHROPIC_CUSTOM_HEADERS" not in claude_bridge.cli_env({**GATEWAY, "LITELLM_EXTRA_HEADERS": raw}) + + +def test_attribution_alone_is_sent_without_model_headers(): + env = claude_bridge.cli_env(GATEWAY, task_id="t-1") + + assert env["ANTHROPIC_CUSTOM_HEADERS"] == "x-litellm-tags: taskId:t-1" + + +@pytest.fixture +def spawned(monkeypatch): + seen: dict = {} + + async def _exec(*cmd, **kwargs): + seen["cmd"], seen["env"] = list(cmd), kwargs["env"] + return FakeCLI(b'{"type": "result", "subtype": "success", "result": "ok", "session_id": "s"}\n') + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _exec) + return seen + + +def test_the_turn_and_thinking_limits_reach_the_cli(spawned, monkeypatch): + for name, value in GATEWAY.items(): + monkeypatch.setenv(name, value) + + asyncio.run(claude_bridge.run_claude(prompt="p", session_id="s", resume=False, model="claude-x", max_turns=7, + max_thinking_tokens=4096)) + + assert spawned["cmd"][spawned["cmd"].index("--max-turns") + 1] == "7" + assert spawned["env"]["MAX_THINKING_TOKENS"] == "4096" + assert spawned["env"]["ANTHROPIC_AUTH_TOKEN"] == "gateway-key" + + +def test_no_limits_add_no_flags(spawned): + asyncio.run(claude_bridge.run_claude(prompt="p", session_id="s", resume=False)) + + assert "--max-turns" not in spawned["cmd"] and "MAX_THINKING_TOKENS" not in spawned["env"] + + +def test_a_404_from_the_endpoint_says_it_may_not_serve_anthropics_messages_api(monkeypatch): + monkeypatch.setenv("LITELLM_BASE_URL", "https://router.example.com/v1") + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + not_found = {"type": "result", "subtype": "success", "is_error": True, "session_id": "s", + "terminal_reason": "api_error", "api_error_status": 404, "result": "API Error: 404 Not Found"} + + async def _exec(*cmd, **kwargs): + return FakeCLI((json.dumps(not_found) + "\n").encode(), returncode=1) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _exec) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="s", resume=False)) + + assert result.attempts == 1 + assert result.error == ( + "claude CLI exited with code 1: API Error: 404 Not Found; https://router.example.com/v1 answered 404: Claude " + "Code needs an endpoint serving Anthropic's Messages API (/v1/messages), such as a LiteLLM gateway, " + "that knows the model") diff --git a/tst/unit/examples/claude_code/test_ext_mcp_name.py b/tst/unit/examples/claude_code/test_ext_mcp_name.py new file mode 100644 index 00000000..ffe06782 --- /dev/null +++ b/tst/unit/examples/claude_code/test_ext_mcp_name.py @@ -0,0 +1,20 @@ +"""mcp-config add keeps the name the caller sends, and mints mcp_ when it sends none.""" + +from tst.unit.examples.claude_code.agent import a2a_server + + +MCP_CONFIG_PATH = a2a_server.MCP_CONFIG_V1.endpoint + + +def test_add_mcp_uses_the_callers_name_and_mints_one_otherwise(client): + named = client.post(MCP_CONFIG_PATH, json={"url": "https://gw/mcp", "name": "env"}).json() + assert named["name"] == "env" + minted = client.post(MCP_CONFIG_PATH, json={"url": "https://gw2/mcp"}).json() + assert minted["name"].startswith("mcp_") and len(minted["name"]) == 12 + assert set(client.get(MCP_CONFIG_PATH).json()["mcp_servers"]) == {"env", minted["name"]} + + +def test_a_second_server_under_the_same_name_is_refused(client): + assert client.post(MCP_CONFIG_PATH, json={"url": "https://gw/mcp", "name": "env"}).status_code == 200 + assert client.post(MCP_CONFIG_PATH, json={"url": "https://other/mcp", "name": "env"}).status_code == 409 + assert client.get(MCP_CONFIG_PATH).json()["mcp_servers"]["env"]["url"] == "https://gw/mcp" diff --git a/tst/unit/examples/claude_code/test_extensions.py b/tst/unit/examples/claude_code/test_extensions.py new file mode 100644 index 00000000..9d15c05f --- /dev/null +++ b/tst/unit/examples/claude_code/test_extensions.py @@ -0,0 +1,105 @@ +"""agent-config, skill-config and triggers over HTTP, as agent-env calls them.""" + +from __future__ import annotations + +import pytest +from agentenv_protocol.a2a_agent import AGENT_CONFIG_V1 + +from tst.unit.examples.claude_code.support import card +from tst.unit.examples.claude_code.agent import a2a_server + +AGENT_CONFIG_PATH = AGENT_CONFIG_V1.endpoint +SKILL_CONFIG_PATH = a2a_server.SKILL_CONFIG_V1.endpoint + + +# ── agent-config ───────────────────────────────────────────────────────────────── + + +def test_config_set_is_read_back_and_renames_the_card(app, client): + assert client.post(AGENT_CONFIG_PATH, json={"role": "solver", "name": "Renamed", "model": "m"}).json() == { + "status": "updated" + } + assert client.post(AGENT_CONFIG_PATH, json={"description": "New description"}).status_code == 200 + + assert client.get(AGENT_CONFIG_PATH).json() == { + "config": {"role": "solver", "name": "Renamed", "model": "m", "description": "New description"} + } + assert (card(app)["name"], card(app)["description"]) == ("Renamed", "New description") + + +@pytest.mark.parametrize( + "body", + [ + {"temperature": 0.5}, + {"timeout_seconds": "soon"}, + {"allowed_tools": "Bash"}, + {"name": ""}, + ], +) +def test_config_set_refuses_unknown_fields_wrong_types_and_empty_identity(client, body): + response = client.post(AGENT_CONFIG_PATH, json=body) + + assert response.status_code == 400 + assert client.get(AGENT_CONFIG_PATH).json() == {"config": {}} + + +# ── skill-config ───────────────────────────────────────────────────────────────── + + +def test_an_inline_skill_is_installed_listed_and_projected(monkeypatch, tmp_path, app, client): + monkeypatch.setattr(a2a_server, "SKILLS_DIR", tmp_path / "skills") + + response = client.post(SKILL_CONFIG_PATH, json={"name": "demo", "description": "d", "skill_md": "# Demo\n"}) + + assert response.json() == {"name": "demo"} + assert (tmp_path / "skills" / "demo" / "SKILL.md").read_text() == "# Demo\n" + assert client.get(SKILL_CONFIG_PATH).json() == { + "skills": { + "claude-code": {"description": "Runs the Claude Code CLI with full tool access (bash, read, edit, glob, grep)."}, + "demo": {"description": "d"}, + } + } + assert card(app)["skills"][-1] == {"id": "skill-demo", "name": "demo", "description": "d", "tags": ["skill"]} + + +def test_a_duplicate_or_identity_skill_name_is_a_409(monkeypatch, tmp_path, client): + monkeypatch.setattr(a2a_server, "SKILLS_DIR", tmp_path / "skills") + body = {"name": "demo", "description": "d", "skill_md": "# Demo\n"} + assert client.post(SKILL_CONFIG_PATH, json=body).status_code == 200 + + duplicate = client.post(SKILL_CONFIG_PATH, json=body) + identity = client.post(SKILL_CONFIG_PATH, json={**body, "name": "claude-code"}) + + assert (duplicate.status_code, duplicate.json()) == (409, {"detail": "Skill 'demo' is already registered"}) + assert (identity.status_code, identity.json()) == (409, {"detail": "Skill 'claude-code' is already registered"}) + assert not (tmp_path / "skills" / "claude-code").exists() + + +def test_a_bad_skill_name_or_empty_content_is_a_400_and_leaves_nothing(monkeypatch, tmp_path, client): + monkeypatch.setattr(a2a_server, "SKILLS_DIR", tmp_path / "skills") + + dotted = client.post(SKILL_CONFIG_PATH, json={"name": "a..b", "description": "d", "skill_md": "x"}) + traversal = client.post(SKILL_CONFIG_PATH, json={"name": "../x", "description": "d", "skill_md": "x"}) + empty = client.post(SKILL_CONFIG_PATH, json={"name": "blank", "description": "d", "skill_md": " \n"}) + + assert (dotted.status_code, dotted.json()) == ( + 400, {"detail": "Invalid skill name 'a..b': must not contain path separators or '..'"}) + assert traversal.status_code == 400 + assert (empty.status_code, empty.json()) == (400, {"detail": "Skill content must not be empty"}) + assert not (tmp_path / "skills" / "blank").exists() + assert set(client.get(SKILL_CONFIG_PATH).json()["skills"]) == {"claude-code"} + + +# ── triggers ───────────────────────────────────────────────────────────────────── + + +def test_triggers_register_decide_and_report_their_state(client): + script = {"triggers": [{"id": "hello", "when": {"type": "step", "turn": 1}, "actions": [{"type": "say", "text": "hi"}]}]} + + registered = client.post("/ext/triggers", json=script).json() + decided = client.post("/ext/triggers/decide", json={"turn": 1, "solver_message": "x"}).json() + state = client.get("/ext/triggers").json() + + assert registered == {"ok": True, "added": ["hello"], "all": ["hello"]} + assert decided == {"parts": [{"kind": "text", "text": "hi"}], "done": False, "fired": ["hello"]} + assert [entry.get("trigger_id") for entry in state["firing_log"] if entry["kind"] == "fired"] == ["hello"] diff --git a/tst/unit/examples/claude_code/test_image_contract.py b/tst/unit/examples/claude_code/test_image_contract.py new file mode 100644 index 00000000..c5412782 --- /dev/null +++ b/tst/unit/examples/claude_code/test_image_contract.py @@ -0,0 +1,36 @@ +"""The agent's folder is its build context: the Dockerfile copies only its own files, keeps TLS checks on, and +installs the protocol the release pins.""" + +from __future__ import annotations + +import re + +from tst.unit.examples.claude_code.agent import AGENT_DIR + +DOCKERFILE = (AGENT_DIR / "Dockerfile").read_text() + + +def test_every_file_the_image_copies_is_in_the_folder(): + copied = [name for line in re.findall(r"^COPY (.+) \S+$", DOCKERFILE, re.MULTILINE) for name in line.split()] + + assert copied and all((AGENT_DIR / name).is_file() for name in copied), copied + assert {"a2a_server.py", "claude_bridge.py", "kit.py", "entrypoint.sh"} <= set(copied) + + +def test_the_image_installs_the_protocol_alone_from_its_requirements(): + requirements = [line for line in (AGENT_DIR / "requirements.txt").read_text().splitlines() + if line and not line.startswith("#")] + + assert requirements == ["agentenv-framework-protocol[agent]"] + + +def test_the_image_pins_its_cli_checks_its_checksum_and_keeps_tls_on(): + assert re.search(r"^ARG CLAUDE_CODE_VERSION=\d+\.\d+\.\d+$", DOCKERFILE, re.MULTILINE) + assert "sha256sum -c" in DOCKERFILE + for path in (AGENT_DIR / "Dockerfile", AGENT_DIR / "entrypoint.sh"): + assert "NODE_TLS_REJECT_UNAUTHORIZED" not in path.read_text() + + +def test_the_image_starts_through_cmd_which_modal_passes_its_env_to(): + assert 'CMD ["/opt/agent/entrypoint.sh"]' in DOCKERFILE + assert not re.search(r"^ENTRYPOINT", DOCKERFILE, re.MULTILINE) diff --git a/tst/unit/examples/claude_code/test_message_contract.py b/tst/unit/examples/claude_code/test_message_contract.py new file mode 100644 index 00000000..41e119f6 --- /dev/null +++ b/tst/unit/examples/claude_code/test_message_contract.py @@ -0,0 +1,179 @@ +"""What a turn sends the CLI and what agent-env reads back: the terminal message's parts for a +success and for each failure, and the session threading across turns.""" + +from __future__ import annotations + +import base64 + +import pytest +from agentenv_protocol.a2a_agent import AGENT_CONFIG_V1 + +from tst.unit.examples.claude_code.support import send, terminal +from tst.unit.examples.claude_code.agent import a2a_server, claude_bridge, kit + +AGENT_CONFIG_PATH = AGENT_CONFIG_V1.endpoint +MCP_CONFIG_PATH = a2a_server.MCP_CONFIG_V1.endpoint + + +class Calls(list): + """Every run_claude call's arguments; each is answered with ``result_for(kwargs)``.""" + + def __init__(self) -> None: + super().__init__() + self.result_for = lambda kwargs: claude_bridge.ClaudeResult( + response="done", tool_call_count=2, session_id=kwargs["session_id"], trajectory=[{"type": "result"}], + ) + + +@pytest.fixture +def calls(monkeypatch) -> Calls: + recorded = Calls() + + async def _run_claude(**kwargs): + recorded.append(kwargs) + return recorded.result_for(kwargs) + + monkeypatch.setattr(a2a_server, "run_claude", _run_claude) + return recorded + + +def test_a_successful_turn(calls, client): + state, text, data = terminal(send(client, "hello")) + + assert (state, text) == ("completed", "done") + assert data == [{"tool_call_count": 2}, {"usage": {"tool_call_count": 2}}] + + +def test_a_resumed_turn_reports_its_attempts(calls, client): + calls.result_for = lambda kwargs: claude_bridge.ClaudeResult( + response="done", tool_call_count=1, session_id=kwargs["session_id"], attempts=3, + ) + + _, _, data = terminal(send(client, "hello")) + + assert data == [{"tool_call_count": 1, "cli_attempts": 3}, {"usage": {"tool_call_count": 1}}] + + +def test_a_cli_error_is_a_failed_task_with_the_capped_message(calls, client): + error = "claude CLI exited 1: " + "x" * 2000 + calls.result_for = lambda kwargs: claude_bridge.ClaudeResult( + response="", tool_call_count=4, session_id=kwargs["session_id"], error=error, + ) + + state, text, data = terminal(send(client, "hello")) + + assert (state, text) == ("failed", error) + assert data == [ + {"tool_call_count": 4}, + { + "usage": {"tool_call_count": 4}, + "error_type": "agent_error", + "error_code": "cli_error", + "error_message": error[:1000], + }, + ] + + +def test_a_cli_error_keeps_the_response_text_when_there_is_one(calls, client): + calls.result_for = lambda kwargs: claude_bridge.ClaudeResult( + response="partial answer", tool_call_count=0, session_id=kwargs["session_id"], error="boom", + ) + + state, text, _ = terminal(send(client, "hello")) + + assert (state, text) == ("failed", "partial answer") + + +def test_a_bridge_exception_is_an_infra_error(monkeypatch, client): + async def _raise(**_kwargs): + raise RuntimeError("spawn failed") + + monkeypatch.setattr(a2a_server, "run_claude", _raise) + + state, text, data = terminal(send(client, "hello")) + + assert (state, text) == ("failed", "spawn failed") + assert data == [{"error_type": "infra_error", "error_code": "infra_error", "error_message": "spawn failed"}] + + +def test_an_unreadable_file_part_is_a_filepart_error(calls, client): + parts = [{"kind": "file", "file": {"name": "a.png", "mimeType": "image/png", "bytes": "not base64!"}}] + + state, text, data = terminal(send(client, parts)) + + assert state == "failed" and text.startswith("malformed base64 in FilePart 'a.png'") + assert data == [{"error_type": "agent_error", "error_code": "filepart_error", "error_message": text}] + assert calls == [] + + +def test_file_parts_are_materialized_and_their_paths_appended(monkeypatch, tmp_path, calls, client): + monkeypatch.setattr(a2a_server, "INPUTS_DIR", tmp_path / "inputs") + image = base64.b64encode(b"png-bytes").decode() + parts = [ + {"kind": "text", "text": "describe"}, + {"kind": "file", "file": {"name": "../../etc/a b.png", "mimeType": "image/png", "bytes": image}}, + {"kind": "data", "data": {"ignored": True}}, + ] + + task = send(client, parts) + + (call,) = calls + path = tmp_path / "inputs" / task["id"] / "a_b.png" + assert call["prompt"] == f"describe\n\n{path}" + assert path.read_bytes() == b"png-bytes" + + +def test_a_context_resumes_its_session_and_a_new_context_starts_one(calls, client): + send(client, "one", context_id="ctx-a") + send(client, "two", context_id="ctx-a") + send(client, "three", context_id="ctx-b") + + first, second, third = calls + assert (first["resume"], second["resume"], third["resume"]) == (False, True, False) + assert second["session_id"] == first["session_id"] != third["session_id"] + + +def test_a_session_id_the_cli_reports_is_trusted_for_the_next_turn(calls, client): + calls.result_for = lambda kwargs: claude_bridge.ClaudeResult(response="ok", tool_call_count=0, session_id="wire-session") + + send(client, "one") + send(client, "two") + + assert calls[1]["session_id"] == "wire-session" and calls[1]["resume"] is True + + +def test_agent_config_and_mcp_servers_reach_the_cli(calls, client): + config = { + "model": "claude-opus-4-6", + "allowed_tools": ["Bash", "Read"], + "system_prompt": "be brief", + "output_format": {"type": "json_schema", "schema": {"type": "object"}}, + "timeout_seconds": 42, + "project_id": "p-1", + "task_id": "t-1", + "role": "solver", + "effort": "high", + "max_turns": 7, + "max_thinking_tokens": 4096, + } + assert client.post(AGENT_CONFIG_PATH, json=config).json() == {"status": "updated"} + client.post(MCP_CONFIG_PATH, json={"url": "https://gw/mcp", "name": "env", "headers": {"CF-Access-Client-Id": "id"}}) + + send(client, "go") + + (call,) = calls + assert {key: call[key] for key in config if key != "timeout_seconds"} == { + key: value for key, value in config.items() if key != "timeout_seconds" + } + assert call["timeout_s"] == 42 + assert call["mcp_servers"] == {"env": {"url": "https://gw/mcp", "headers": {"CF-Access-Client-Id": "id"}}} + + +def test_the_default_timeout_is_thirty_minutes(calls, client): + send(client, "go") + + assert calls[0]["timeout_s"] == 1800 and calls[0]["model"] is None and calls[0]["mcp_servers"] == {} + + +def test_session_references_outlive_the_frameworks_bounded_record(): + assert issubclass(a2a_server.ClaudeCodeAgent, kit.DurableSessionRefs) diff --git a/tst/unit/examples/claude_code/test_object_transfer.py b/tst/unit/examples/claude_code/test_object_transfer.py new file mode 100644 index 00000000..f0a60721 --- /dev/null +++ b/tst/unit/examples/claude_code/test_object_transfer.py @@ -0,0 +1,254 @@ +"""The object forms of skill-config, trajectory and snapshot: agent-env's grants drive the transfer against a fake +object store.""" + +from __future__ import annotations + +import asyncio +import hashlib +import io +import json +import tarfile +from pathlib import Path + + +from tst.unit.examples.claude_code.support import card, extension_methods, read_grant, send, write_grant +from tst.unit.examples.claude_code.agent import a2a_server, claude_bridge + +SKILL_CONFIG_PATH = a2a_server.SKILL_CONFIG_V1.endpoint +TRAJECTORY_PATH = a2a_server.TRAJECTORY_V1.endpoint +SNAPSHOT_PATH = a2a_server.SNAPSHOT_V1.endpoint + + +def test_card_offers_every_object_form_and_no_s3_form(app): + skill = extension_methods(app, a2a_server.SKILL_CONFIG_V1.uri)["add"]["request"] + assert skill["oneOf"] == [{"required": ["skill_md"]}, {"required": ["skill_bundle"]}] + trajectory = extension_methods(app, a2a_server.TRAJECTORY_V1.uri)["get"]["request"] + assert {"required": ["task_id", "objects"]} in trajectory["oneOf"] + assert {"required": ["context_id", "objects"]} in trajectory["oneOf"] + snapshot = extension_methods(app, a2a_server.SNAPSHOT_V1.uri) + assert snapshot["load"]["request"] == {"required": ["objects"], "optional": ["target_context_id"]} + assert set(snapshot) == {"save", "load"} + requests = json.dumps([ + method.get("request") + for ext in card(app)["capabilities"]["extensions"] + for method in ext["params"].get("methods", {}).values() + ]) + for s3_form in ("skill_s3_url", "trajectory_s3_prefix", "s3_prefix", "presigned_post", "up_to_tool_call_exclusive"): + assert s3_form not in requests + + +# ── skills ─────────────────────────────────────────────────────────────────────── + + +def test_a_skill_bundle_is_installed_through_its_read_grants(monkeypatch, tmp_path, app, client, store): + monkeypatch.setattr(a2a_server, "SKILLS_DIR", tmp_path / "skills") + store.objects.update({"skills/demo/SKILL.md": b"# Demo\n", "skills/demo/scripts/run.sh": b"echo hi\n"}) + bundle = { + "max_total_bytes": 16, + "files": [ + {"path": "SKILL.md", "object": read_grant("skills/demo/SKILL.md", 7)}, + {"path": "scripts/run.sh", "object": read_grant("skills/demo/scripts/run.sh", 8)}, + ], + } + + response = client.post(SKILL_CONFIG_PATH, json={"name": "demo", "description": "d", "skill_bundle": bundle}) + + assert response.json() == {"name": "demo"} + assert (tmp_path / "skills" / "demo" / "scripts" / "run.sh").read_bytes() == b"echo hi\n" + assert client.get(SKILL_CONFIG_PATH).json()["skills"]["demo"] == {"description": "d"} + assert {"id": "skill-demo", "name": "demo", "description": "d", "tags": ["skill"]} in card(app)["skills"] + + +def test_a_skill_bundle_that_fails_leaves_no_skill_behind(monkeypatch, tmp_path, client, store): + monkeypatch.setattr(a2a_server, "SKILLS_DIR", tmp_path / "skills") + bundle = {"max_total_bytes": 7, "files": [{"path": "SKILL.md", "object": read_grant("missing/SKILL.md", 7)}]} + + response = client.post(SKILL_CONFIG_PATH, json={"name": "gone", "description": "d", "skill_bundle": bundle}) + + assert response.status_code == 502 + assert response.json()["error"]["code"] == "transfer_rejected" + assert not (tmp_path / "skills" / "gone").exists() + assert "gone" not in client.get(SKILL_CONFIG_PATH).json()["skills"] + + +# ── trajectory ─────────────────────────────────────────────────────────────────── + + +def test_a_task_trajectory_is_uploaded_through_its_grant_and_kept(monkeypatch, client, store): + trajectory = [{"type": "assistant", "message": "hi"}] + + async def _run_claude(**kwargs): + for event in trajectory: + kwargs["event_callback"](event) + return claude_bridge.ClaudeResult(response="hi", tool_call_count=0, session_id=kwargs["session_id"], trajectory=trajectory) + + monkeypatch.setattr(a2a_server, "run_claude", _run_claude) + task_id = send(client, "go")["id"] + + result = client.post( + TRAJECTORY_PATH, + json={"task_id": task_id, "objects": {"trajectory": write_grant("traj/trajectory-t-1.json", "application/json")}}, + ).json() + + body = store.objects["traj/trajectory-t-1.json"] + assert body == json.dumps(trajectory, separators=(",", ":")).encode() + assert result == {"objects": {"trajectory": {"size_bytes": len(body), "sha256": hashlib.sha256(body).hexdigest()}}} + assert client.post(TRAJECTORY_PATH, json={"task_id": task_id}).json() == {"trajectory": trajectory} + + +def test_a_context_trajectory_is_uploaded_through_its_grant(monkeypatch, tmp_path, agent, client, store): + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", tmp_path) + message = {"role": "assistant", "content": [{"type": "text", "text": "working"}]} + (tmp_path / "s-1.jsonl").write_text(json.dumps({"type": "assistant", "message": message, "sessionId": "s-1"}) + "\n") + agent.set_session_ref_for_context("ctx-1", "s-1") + + result = client.post( + TRAJECTORY_PATH, + json={"context_id": "ctx-1", "objects": {"trajectory": write_grant("traj/ctx-1.json", "application/json")}}, + ).json() + + body = store.objects["traj/ctx-1.json"] + assert body == json.dumps([{"type": "assistant", "message": message, "session_id": "s-1"}], separators=(",", ":")).encode() + assert result == {"objects": {"trajectory": {"size_bytes": len(body), "sha256": hashlib.sha256(body).hexdigest()}}} + + +# ── snapshot ───────────────────────────────────────────────────────────────────── + + +def _roots(monkeypatch, tmp_path: Path, session_id: str, conversation: bytes) -> Path: + workspace = tmp_path / "app" + state = tmp_path / "home" / ".claude" + session_dir = state / "projects" / "-app" + session_dir.mkdir(parents=True) + (session_dir / f"{session_id}.jsonl").write_bytes(conversation) + (workspace / "report").mkdir(parents=True) + (workspace / "report" / "answer.txt").write_text("done") + monkeypatch.setattr(a2a_server, "WORKSPACE_DIR", workspace) + monkeypatch.setattr(a2a_server, "CLAUDE_STATE_DIR", state) + monkeypatch.setattr(a2a_server, "SESSION_JSONL_DIR", session_dir) + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", session_dir) + return workspace + + +def test_a_snapshot_saves_and_loads_through_grants(monkeypatch, tmp_path, agent, client, store): + conversation = (json.dumps({"type": "user", "sessionId": "s-1", "message": "hello"}) + "\n").encode() + workspace = _roots(monkeypatch, tmp_path, "s-1", conversation) + agent.set_session_ref_for_context("ctx-1", "s-1") + + saved = client.post( + SNAPSHOT_PATH, + json={"context_id": "ctx-1", "objects": {"trajectory": write_grant("snap/trajectory"), "workspace": write_grant("snap/workspace")}}, + ).json() + + assert set(saved) == {"context_id", "objects"} and saved["context_id"] == "ctx-1" + assert store.objects["snap/trajectory"] == conversation + assert saved["objects"]["workspace"]["size_bytes"] == len(store.objects["snap/workspace"]) + with tarfile.open(fileobj=io.BytesIO(store.objects["snap/workspace"]), mode="r:gz") as archive: + assert "report/answer.txt" in archive.getnames() + + (workspace / "report" / "answer.txt").unlink() + loaded = client.put( + SNAPSHOT_PATH, + json={ + "objects": { + "trajectory": read_grant("snap/trajectory", len(conversation)), + "workspace": read_grant("snap/workspace", len(store.objects["snap/workspace"])), + }, + "target_context_id": "restored", + }, + ).json() + + assert loaded == {"context_id": "restored"} + assert (workspace / "report" / "answer.txt").read_text() == "done" + new_session = agent.session_ref_for_context("restored") + restored = (a2a_server.SESSION_JSONL_DIR / f"{new_session}.jsonl").read_text() + assert json.loads(restored)["sessionId"] == new_session + + +def test_a_snapshot_load_without_a_workspace_restores_the_conversation_only(monkeypatch, tmp_path, agent, client, store): + conversation = (json.dumps({"type": "user", "sessionId": "old"}) + "\n").encode() + _roots(monkeypatch, tmp_path, "s-0", b"") + store.objects["snap/trajectory"] = conversation + + loaded = client.put(SNAPSHOT_PATH, json={"objects": {"trajectory": read_grant("snap/trajectory", len(conversation))}}).json() + + assert set(loaded) == {"context_id"} and agent.session_ref_for_context(loaded["context_id"]) is not None + + +def test_a_snapshot_load_refuses_a_bound_context_and_a_running_turn(monkeypatch, tmp_path, agent, client, store): + conversation = (json.dumps({"type": "user", "sessionId": "old"}) + "\n").encode() + _roots(monkeypatch, tmp_path, "s-0", b"") + store.objects["snap/trajectory"] = conversation + body = {"objects": {"trajectory": read_grant("snap/trajectory", len(conversation))}, "target_context_id": "bound"} + agent.set_session_ref_for_context("bound", "s-0") + + bound = client.put(SNAPSHOT_PATH, json=body) + agent._active_contexts.add("other") + running = client.put(SNAPSHOT_PATH, json={**body, "target_context_id": "fresh"}) + + assert (bound.status_code, bound.json()) == ( + 409, {"detail": "context_id 'bound' is already bound — retry with a different target_context_id"}) + assert (running.status_code, running.json()) == ( + 409, {"detail": "cannot load a workspace while an agent turn is running"}) + assert agent.session_ref_for_context("fresh") is None + + +def test_a_snapshot_save_and_load_keep_their_400s(client): + save = client.post(SNAPSHOT_PATH, json={"context_id": "", "objects": {"trajectory": write_grant("snap/trajectory")}}) + load = client.put( + SNAPSHOT_PATH, json={"objects": {"trajectory": read_grant("snap/trajectory", 1)}, "target_context_id": ""} + ) + + assert (save.status_code, save.json()) == (400, {"detail": "context_id is required"}) + assert (load.status_code, load.json()) == (400, {"detail": "target_context_id must be a non-empty string"}) + + +def test_a_turn_that_arrives_during_a_load_waits_for_it(monkeypatch, agent): + """The load holds the agent's context lock across its restore, so a turn cannot start + against a half-restored workspace, and it resumes the session the load bound.""" + started: list[tuple[str, bool]] = [] + + async def _run_claude(**kwargs): + started.append((kwargs["session_id"], kwargs["resume"])) + return claude_bridge.ClaudeResult(response="ok", tool_call_count=0, session_id=kwargs["session_id"]) + + monkeypatch.setattr(a2a_server, "run_claude", _run_claude) + request = a2a_server.TaskRequest( + task_id="t-1", context_id="restored", parts=(a2a_server.TextPart(text="go"),), + config=a2a_server.ClaudeCodeConfig(), + ) + + async def scenario(): + async with agent._contexts_lock: + turn = asyncio.create_task(agent.run(request)) + await asyncio.sleep(0.1) + assert started == [] + agent.set_session_ref_for_context("restored", "restored-session") + return await turn + + result = asyncio.run(scenario()) + + assert started == [("restored-session", True)] and result.session_ref == "restored-session" + + +# ── errors ─────────────────────────────────────────────────────────────────────── + + +def test_a_malformed_grant_is_a_validation_400(client): + response = client.put(SNAPSHOT_PATH, json={"objects": {"trajectory": {"media_type": "x"}}}) + + assert response.status_code == 400 + errors = json.loads(response.json()["detail"]) + assert ("objects", "trajectory", "max_bytes") in {tuple(error["loc"]) for error in errors} + + +def test_a_transfer_error_answers_with_the_typed_body(client, store): + """A malformed grant is refused by validation; an I/O failure behind a well-formed one is agent-env's typed + error body, never the grant.""" + response = client.put(SNAPSHOT_PATH, json={"objects": {"trajectory": read_grant("snap/missing", 1)}}) + + assert response.status_code == 502 + assert response.json() == { + "error": {"code": "transfer_rejected", "message": "The object store rejected the transfer.", "retryable": False} + } + assert "sig=r" not in response.text diff --git a/tst/unit/examples/claude_code/test_partial_trajectory.py b/tst/unit/examples/claude_code/test_partial_trajectory.py new file mode 100644 index 00000000..2d6b46c7 --- /dev/null +++ b/tst/unit/examples/claude_code/test_partial_trajectory.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import asyncio +import json +import uuid +from pathlib import Path + +import httpx +import pytest + +from tst.unit.examples.claude_code.support import extension_methods, send +from tst.unit.examples.claude_code.support import FakeCLI +from tst.unit.examples.claude_code.agent import a2a_server, claude_bridge + +TRAJECTORY_PATH = a2a_server.TRAJECTORY_V1.endpoint + + +def _write_jsonl(path: Path, records: list[dict], trailing: bytes = b"") -> None: + body = b"".join(json.dumps(record).encode() + b"\n" for record in records) + path.write_bytes(body + trailing) + + +def test_agent_card_advertises_one_get_trajectory_method(app): + assert extension_methods(app, a2a_server.TRAJECTORY_V1.uri) == { + "get": { + "method": "POST", + "request": { + "oneOf": [ + {"required": ["task_id"]}, + {"required": ["task_id", "objects"]}, + {"required": ["context_id"]}, + {"required": ["context_id", "objects"]}, + {"required": ["task_id", "after"], "optional": ["limit"]}, + {"required": ["context_id", "after"], "optional": ["limit"]}, + ], + }, + } + } + + +def test_partial_trajectory_matches_stream_message_shape(monkeypatch, tmp_path): + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", tmp_path) + session_id = "session-1" + path = tmp_path / f"{session_id}.jsonl" + assistant_message = { + "role": "assistant", + "content": [{"type": "tool_use", "id": "tool-1", "name": "Bash", "input": {}}], + } + tool_result_message = { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "tool-1", "content": "ok"} + ], + } + _write_jsonl( + path, + [ + {"type": "queue-operation", "operation": "enqueue"}, + {"type": "user", "message": {"role": "user", "content": "prompt"}}, + { + "type": "assistant", + "message": assistant_message, + "sessionId": session_id, + "requestId": "request-1", + "uuid": "assistant-1", + }, + { + "type": "user", + "message": tool_result_message, + "sessionId": session_id, + "toolUseResult": {"stdout": "ok", "stderr": ""}, + "uuid": "result-1", + }, + ], + ) + + trajectory, session_lines = claude_bridge.read_partial_session_trajectory(session_id) + + assert session_lines == 4 + assert trajectory == [ + { + "type": "assistant", + "message": assistant_message, + "session_id": session_id, + "request_id": "request-1", + "uuid": "assistant-1", + }, + { + "type": "user", + "message": tool_result_message, + "session_id": session_id, + "tool_use_result": {"stdout": "ok", "stderr": ""}, + "uuid": "result-1", + }, + ] + + +def test_compaction_and_transcript_only_records_and_incomplete_tail_are_excluded( + monkeypatch, tmp_path +): + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", tmp_path) + session_id = "session-2" + path = tmp_path / f"{session_id}.jsonl" + before = {"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "before"}]}} + after = {"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "after"}]}} + _write_jsonl( + path, + [ + before, + {"type": "system", "subtype": "compact_boundary", "compactMetadata": {}}, + { + "type": "assistant", + "isCompactSummary": True, + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "compaction summary"}], + }, + }, + { + "type": "assistant", + "isVisibleInTranscriptOnly": True, + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "UI-only message"}], + }, + }, + after, + ], + trailing=b'{"type":"assistant","message":', + ) + + trajectory, session_lines = claude_bridge.read_partial_session_trajectory(session_id) + + assert session_lines == 5 + assert [event["message"] for event in trajectory] == [before["message"], after["message"]] + + +def test_context_trajectory_endpoint_reports_complete_live_session(monkeypatch, tmp_path, agent, client): + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", tmp_path) + agent.set_session_ref_for_context("ctx-1", "session-1") + agent._active_contexts.add("ctx-1") + previous_message = { + "role": "assistant", + "content": [{"type": "text", "text": "previous turn"}], + } + message = { + "role": "assistant", + "content": [{"type": "text", "text": "working"}], + } + _write_jsonl( + tmp_path / "session-1.jsonl", + [ + { + "type": "assistant", + "message": previous_message, + "sessionId": "session-1", + }, + {"type": "assistant", "message": message, "sessionId": "session-1"}, + ], + ) + + result = client.post(TRAJECTORY_PATH, json={"context_id": "ctx-1"}).json() + + assert result == { + "trajectory": [ + { + "type": "assistant", + "message": previous_message, + "session_id": "session-1", + }, + { + "type": "assistant", + "message": message, + "session_id": "session-1", + } + ], + "session_lines": 2, + "is_live": True, + "session_id": "session-1", + "harness": "claude-code", + } + + +def test_completed_task_returns_finalized_stream_trajectory(monkeypatch, client): + trajectory = [{"type": "result", "result": "done"}] + + async def _run_claude(**kwargs): + for event in trajectory: + kwargs["event_callback"](event) + return claude_bridge.ClaudeResult(response="done", tool_call_count=0, session_id=kwargs["session_id"], trajectory=trajectory) + + monkeypatch.setattr(a2a_server, "run_claude", _run_claude) + task = send(client, "go") + + result = client.post(TRAJECTORY_PATH, json={"task_id": task["id"]}).json() + live = client.post(TRAJECTORY_PATH, json={"task_id": task["id"], "after": 0}).json() + + assert result == {"trajectory": trajectory} + assert (live["format"], live["state"], live["events"]) == ( + a2a_server.TRAJECTORY_FORMAT, "completed", trajectory, + ) + + +@pytest.mark.parametrize( + "payload", + [{}, {"task_id": "task-1", "context_id": "ctx-1"}], +) +def test_trajectory_endpoint_requires_exactly_one_identifier(client, payload): + response = client.post(TRAJECTORY_PATH, json=payload) + + assert response.status_code == 400 + assert response.json()["detail"].startswith("request must match exactly one of variants") + + +def test_task_trajectory_is_unavailable_until_turn_completes(client): + response = client.post(TRAJECTORY_PATH, json={"task_id": "task-1"}) + + assert response.status_code == 404 + assert response.json() == {"detail": "No completed trajectory available for task 'task-1'"} + + +def test_partial_trajectory_endpoint_maps_missing_jsonl_to_first_tick_404( + monkeypatch, tmp_path, agent, client +): + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", tmp_path) + agent.set_session_ref_for_context("ctx-1", "session-1") + + response = client.post(TRAJECTORY_PATH, json={"context_id": "ctx-1"}) + + assert response.status_code == 404 + assert response.json() == {"detail": "No transcript yet for context_id 'ctx-1'"} + + +def test_a_context_without_a_session_is_a_404(client): + response = client.post(TRAJECTORY_PATH, json={"context_id": "ctx-unknown"}) + + assert response.status_code == 404 + assert response.json() == {"detail": "No session available for context 'ctx-unknown'"} + + +_INIT = {"type": "system", "subtype": "init", "session_id": "sess-1"} +_TOOL = {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Bash", "input": {}}]}} +_DONE = {"type": "result", "subtype": "success", "result": "done", "session_id": "sess-1"} + + +def _lines(*events: dict) -> bytes: + return b"".join(json.dumps(event).encode() + b"\n" for event in events) + + +def test_a_turn_is_readable_while_the_cli_runs_and_its_log_is_its_trajectory(app, monkeypatch, tmp_path): + """Driven over HTTP, through the real bridge: the CLI prints one event, then blocks until released.""" + started: list[FakeCLI] = [] + + async def spawn(*cmd, **kwargs): + started.append(FakeCLI(_lines(_INIT), hang=True)) + return started[-1] + + monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn) + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", tmp_path) + + async def exercise(): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://claude") as client: + message = { + "kind": "message", "role": "user", "messageId": uuid.uuid4().hex, + "parts": [{"kind": "text", "text": "go"}], + } + sent = await client.post("/a2a", json={ + "jsonrpc": "2.0", "id": 1, "method": "message/send", + "params": {"message": message, "configuration": {"blocking": False}}, + }) + task_id = sent.json()["result"]["id"] + + async def read(after: int) -> dict: + return (await client.post(TRAJECTORY_PATH, json={"task_id": task_id, "after": after})).json() + + async with asyncio.timeout(5): + while (running := await read(0))["next"] < 1: + await asyncio.sleep(0.01) + cli = started[0] + cli.stdout.feed_data(_lines(_TOOL, _DONE)) + cli.stdout.feed_eof() + cli.stderr.feed_eof() + async with asyncio.timeout(5): + while (ended := await read(running["next"]))["state"] != "completed": + await asyncio.sleep(0.01) + final = (await client.post(TRAJECTORY_PATH, json={"task_id": task_id})).json() + return running, ended, final + + running, ended, final = asyncio.run(exercise()) + + assert (running["state"], running["format"], running["events"]) == ( + "running", a2a_server.TRAJECTORY_FORMAT, [_INIT], + ) + assert (ended["events"], ended["next"], ended["has_more"]) == ([_TOOL, _DONE], 3, False) + assert final == {"trajectory": [_INIT, _TOOL, _DONE]} diff --git a/tst/unit/examples/claude_code/test_workspace_snapshot.py b/tst/unit/examples/claude_code/test_workspace_snapshot.py new file mode 100644 index 00000000..7bb778fe --- /dev/null +++ b/tst/unit/examples/claude_code/test_workspace_snapshot.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import asyncio +import hashlib +import io +import json +import tarfile +from pathlib import Path + +from agentenv_protocol.transfers import Uploaded + +from tst.unit.examples.claude_code.support import extension_methods, read_grant, write_grant +from tst.unit.examples.claude_code.agent import a2a_server, claude_bridge + +SNAPSHOT_PATH = a2a_server.SNAPSHOT_V1.endpoint + + +def _snapshot_roots(monkeypatch, tmp_path: Path) -> tuple[Path, Path]: + workspace = tmp_path / "app" + state = tmp_path / "home" / ".claude" + (state / "projects" / "-app" / "memory").mkdir(parents=True) + (state / "projects" / "-app" / "memory" / "MEMORY.md").write_text( + "remember this" + ) + (workspace / "report").mkdir(parents=True) + (workspace / "report" / "answer.txt").write_text("done") + monkeypatch.setattr(a2a_server, "WORKSPACE_DIR", workspace) + monkeypatch.setattr(a2a_server, "CLAUDE_STATE_DIR", state) + return workspace, state + + +def test_agent_card_advertises_unified_save_method(app): + methods = extension_methods(app, a2a_server.SNAPSHOT_V1.uri) + assert "save-workspace" not in methods + assert methods["save"] == { + "method": "POST", + "request": {"required": ["context_id", "objects"]}, + "response": {"required": ["context_id", "objects"]}, + } + + +def test_workspace_archive_includes_hidden_and_nested_files(monkeypatch, tmp_path): + _snapshot_roots(monkeypatch, tmp_path) + archive = tmp_path / "workspace.tar.gz" + + file_count, bytes_written = a2a_server._build_workspace_archive(archive) + + assert file_count == 2 + assert bytes_written == archive.stat().st_size + with tarfile.open(archive, mode="r:gz") as tar: + names = {member.name for member in tar.getmembers() if member.isfile()} + assert names == { + ".claude/projects/-app/memory/MEMORY.md", + "report/answer.txt", + } + + +def test_workspace_archive_tolerates_live_file_races(monkeypatch, tmp_path): + workspace, _ = _snapshot_roots(monkeypatch, tmp_path) + archive = tmp_path / "workspace.tar.gz" + unreadable = workspace / "unreadable.txt" + unreadable.write_text("secret") + unstable = workspace / "report" / "answer.txt" + real_add = tarfile.TarFile.add + real_copy = tarfile.copyfileobj + real_open = Path.open + poisoned = False + + class _FailingReader: + def __init__(self, source): + self.source = source + self.read_once = False + + def __enter__(self): + return self + + def __exit__(self, *_args): + self.source.close() + + def read(self, size): + if self.read_once: + raise OSError("file changed during read") + self.read_once = True + return self.source.read(min(size, 1)) + + def _add(tar, path, *args, **kwargs): + if Path(path) == unreadable: + raise PermissionError("permissions changed") + return real_add(tar, path, *args, **kwargs) + + def _copy(source, target, length=None, exception=OSError, bufsize=None): + nonlocal poisoned + if source.name == str(unstable) and not poisoned: + poisoned = True + target.write(source.read(1)) + raise OSError("unexpected end of data") + return real_copy( + source, + target, + length, + exception=exception, + bufsize=bufsize, + ) + + def _open(path, mode="r", *args, **kwargs): + if path == unreadable and mode == "rb": + raise PermissionError("permissions changed") + source = real_open(path, mode, *args, **kwargs) + if path == unstable and mode == "rb": + return _FailingReader(source) + return source + + monkeypatch.setattr(tarfile.TarFile, "add", _add) + monkeypatch.setattr(tarfile, "copyfileobj", _copy) + monkeypatch.setattr(Path, "open", _open) + + file_count, _ = a2a_server._build_workspace_archive(archive) + + assert poisoned is True + assert file_count == 1 + with tarfile.open(archive, mode="r:gz") as tar: + names = {member.name for member in tar.getmembers() if member.isfile()} + assert names == {".claude/projects/-app/memory/MEMORY.md"} + + +def test_save_trims_an_incomplete_tail_takes_no_lock_and_uploads_the_bundle(monkeypatch, tmp_path, agent): + _, state = _snapshot_roots(monkeypatch, tmp_path) + session_dir = state / "projects" / "-app" + session_id = "session-1" + complete_record = ( + json.dumps( + { + "type": "assistant", + "sessionId": session_id, + "message": {"role": "assistant", "content": "working"}, + } + ) + + "\n" + ) + (session_dir / f"{session_id}.jsonl").write_text( + complete_record + '{"type":"assistant"' + ) + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", session_dir) + agent.set_session_ref_for_context("ctx-1", session_id) + uploads: list[dict] = [] + + async def _upload(target, source): + body = source.read_bytes() + uploads.append({"target": target, "path": source, "body": body}) + return Uploaded(size_bytes=len(body), sha256=hashlib.sha256(body).hexdigest()) + + monkeypatch.setattr(a2a_server, "upload", _upload) + request = a2a_server.ObjectSnapshotSaveRequest.model_validate( + {"context_id": "ctx-1", "objects": {"trajectory": write_grant("snap/trajectory"), "workspace": write_grant("snap/workspace")}} + ) + + async def _save_while_a_turn_holds_the_lock(): + # A live save must not wait for the turn it snapshots. + async with agent._contexts_lock: + return await asyncio.wait_for(agent.save_snapshot(request), 30) + + result = asyncio.run(_save_while_a_turn_holds_the_lock()) + + assert [upload["target"] for upload in uploads] == [request.objects.trajectory, request.objects.workspace] + assert uploads[0]["body"] == complete_record.encode() + with tarfile.open(fileobj=io.BytesIO(uploads[1]["body"]), mode="r:gz") as tar: + assert {member.name for member in tar.getmembers() if member.isfile()} == { + ".claude/projects/-app/memory/MEMORY.md", + ".claude/projects/-app/session-1.jsonl", + "report/answer.txt", + } + assert result.model_dump(mode="json", exclude_none=True) == { + "context_id": "ctx-1", + "objects": { + "trajectory": {"size_bytes": len(uploads[0]["body"]), "sha256": hashlib.sha256(uploads[0]["body"]).hexdigest()}, + "workspace": {"size_bytes": len(uploads[1]["body"]), "sha256": hashlib.sha256(uploads[1]["body"]).hexdigest()}, + }, + } + assert all(not upload["path"].exists() for upload in uploads) + + +def test_load_restores_workspace_claude_state_and_conversation(monkeypatch, tmp_path, agent, client, store): + _, source_state = _snapshot_roots(monkeypatch, tmp_path / "source") + source_session_id = "source-session" + source_session_dir = source_state / "projects" / "-app" + conversation = ( + json.dumps( + { + "type": "assistant", + "sessionId": source_session_id, + "message": {"role": "assistant", "content": "remember"}, + } + ) + + "\n" + ).encode() + (source_session_dir / f"{source_session_id}.jsonl").write_bytes(conversation) + archive = tmp_path / "workspace.tar.gz" + a2a_server._build_workspace_archive(archive) + + destination_workspace = tmp_path / "destination" / "app" + destination_state = tmp_path / "destination" / "home" / ".claude" + destination_session_dir = destination_state / "projects" / "-app" + destination_workspace.mkdir(parents=True) + destination_state.mkdir(parents=True) + (destination_workspace / "stale.txt").write_text("remove me") + (destination_state / "stale.txt").write_text("remove me") + monkeypatch.setattr(a2a_server, "WORKSPACE_DIR", destination_workspace) + monkeypatch.setattr(a2a_server, "CLAUDE_STATE_DIR", destination_state) + monkeypatch.setattr(a2a_server, "SESSION_JSONL_DIR", destination_session_dir) + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", destination_session_dir) + store.objects.update({"captures/one/workspace": archive.read_bytes(), "captures/one/trajectory": conversation}) + + response = client.put( + SNAPSHOT_PATH, + json={ + "objects": { + "trajectory": read_grant("captures/one/trajectory", len(conversation)), + "workspace": read_grant("captures/one/workspace", archive.stat().st_size), + }, + "target_context_id": "restored-context", + }, + ) + + restored_session_id = agent.session_ref_for_context("restored-context") + restored_jsonl = ( + destination_session_dir / f"{restored_session_id}.jsonl" + ).read_text() + assert response.status_code == 200 + assert response.json() == {"context_id": "restored-context"} + assert [request.url.path for request in store.requests] == [ + "/captures/one/workspace", + "/captures/one/trajectory", + ] + assert (destination_workspace / "report" / "answer.txt").read_text() == "done" + assert not (destination_workspace / "stale.txt").exists() + assert not (destination_workspace / ".claude").exists() + assert ( + destination_state / "projects" / "-app" / "memory" / "MEMORY.md" + ).read_text() == "remember this" + assert not (destination_state / "stale.txt").exists() + assert json.loads(restored_jsonl)["sessionId"] == restored_session_id + + +def test_load_conversation_only_snapshot_leaves_workspace_untouched( + monkeypatch, tmp_path, agent, client, store +): + destination_workspace = tmp_path / "app" + destination_state = tmp_path / "home" / ".claude" + destination_session_dir = destination_state / "projects" / "-app" + destination_workspace.mkdir(parents=True) + destination_state.mkdir(parents=True) + (destination_workspace / "keep.txt").write_text("workspace stays") + (destination_state / "keep.txt").write_text("state stays") + monkeypatch.setattr(a2a_server, "WORKSPACE_DIR", destination_workspace) + monkeypatch.setattr(a2a_server, "CLAUDE_STATE_DIR", destination_state) + monkeypatch.setattr(a2a_server, "SESSION_JSONL_DIR", destination_session_dir) + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", destination_session_dir) + + conversation = ( + json.dumps( + { + "type": "assistant", + "sessionId": "legacy-session", + "message": {"role": "assistant", "content": "remember"}, + } + ) + + "\n" + ).encode() + store.objects["captures/legacy/trajectory"] = conversation + + response = client.put( + SNAPSHOT_PATH, + json={ + "objects": {"trajectory": read_grant("captures/legacy/trajectory", len(conversation))}, + "target_context_id": "restored-context", + }, + ) + + restored_session_id = agent.session_ref_for_context("restored-context") + restored_jsonl = ( + destination_session_dir / f"{restored_session_id}.jsonl" + ).read_text() + assert response.json() == {"context_id": "restored-context"} + assert [request.url.path for request in store.requests] == ["/captures/legacy/trajectory"] + assert (destination_workspace / "keep.txt").read_text() == "workspace stays" + assert (destination_state / "keep.txt").read_text() == "state stays" + assert json.loads(restored_jsonl)["sessionId"] == restored_session_id + + +def test_load_fetch_failure_is_a_typed_502(agent, client, store): + response = client.put( + SNAPSHOT_PATH, + json={"objects": {"trajectory": read_grant("captures/missing/trajectory", 10)}}, + ) + + assert response.status_code == 502 + assert response.json() == { + "error": {"code": "transfer_rejected", "message": "The object store rejected the transfer.", "retryable": False} + } From e4136744ec06eda1eac41da8aa4e7932f40ed47c Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 08:49:36 -0700 Subject: [PATCH 03/24] test(agents): run the shipped Claude Code against a fake Messages endpoint on local docker Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/run_bundle_claude_code_test.py | 118 ++++++++++ tst/util/fake_anthropic.py | 212 ++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 tst/integration/cli/run_bundle_claude_code_test.py create mode 100644 tst/util/fake_anthropic.py diff --git a/tst/integration/cli/run_bundle_claude_code_test.py b/tst/integration/cli/run_bundle_claude_code_test.py new file mode 100644 index 00000000..4695280f --- /dev/null +++ b/tst/integration/cli/run_bundle_claude_code_test.py @@ -0,0 +1,118 @@ +"""``agent-env run`` on bundles whose tasks name no agent, so they deploy the agents bundle's Claude Code: its image +built from the shipped folder with real docker, the real CLI, and a fake Anthropic Messages endpoint as the configured +model endpoint, so no model key is needed. Needs a Docker daemon and network access for the image's downloads.""" + +import json +import logging + +import pytest +from click.testing import CliRunner + +from agent_env.artifact.store import reset_artifact_store +from agent_env.cli import cli +from agent_env.config import configure, reset_config +from agent_env.providers.sandbox_providers.local_sandbox import _host_ips +from agent_env.store.object_store.local.grant_server import grant_server +from tst.util.fake_anthropic import FakeAnthropic, answer, tool_then_answer + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +AGENT = "agentenv-framework/agents:agents/claude-code" +IMAGE = f"{AGENT} (Dockerfile image)" +MODEL = "claude-fake-1" +ECHO_ENV = "@local/agentenv-framework/echo-mcp/echo" + + +@pytest.fixture +def endpoint(): + """The fake endpoint, where this machine's containers reach it: on Linux, the Docker bridge address.""" + with FakeAnthropic(host=_host_ips()[-1]) as fake: + yield fake + + +@pytest.fixture +def state(monkeypatch, tmp_path, endpoint): + """Local stores under this test's folder, and [model] pointed at the fake endpoint with a header of its own. + Whatever ran, no sandbox work folder may be left behind.""" + sandboxes = tmp_path / "sandboxes" + config = tmp_path / "config.toml" + config.write_text(f'[model]\nbase_url = "{endpoint.url}"\napi_key = "fake-key"\ndefault = "{MODEL}"\n\n' + '[model.headers]\nX-Agentenv-Test = "header-check"\n') + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("AGENT_ENV_CONFIG", str(config)) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + monkeypatch.setenv("AGENT_ENV_DOCUMENT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_LOCAL_SANDBOX_DIR", str(sandboxes)) + for name in ("LITELLM_API_KEY", "LITELLM_BASE_URL"): + monkeypatch.delenv(name, raising=False) + configure() + reset_artifact_store() + logging.disable(logging.CRITICAL) # pytest's live logging would take CliRunner's stdout + try: + yield tmp_path + assert not sandboxes.exists() or not any(sandboxes.iterdir()), "a run left a sandbox work folder" + finally: + logging.disable(logging.NOTSET) + grant_server(None, None).close() + reset_artifact_store() + reset_config() + + +def _bundle(root, name, steps): + (root / "tasks").mkdir(parents=True) + (root / f"tasks/{name}.json").write_text(json.dumps(steps)) + return root + + +def _ask(prompt, needle, env_ids=()): + return [ + {"id": "agent", "type": "deploy_agent", "env_ids": list(env_ids), "sandbox_type": "local"}, + {"id": "ask", "type": "prompt_agent", "prompt_id": "ask", "prompt": prompt, "depends_on": ["agent"]}, + {"id": "check", "type": "agent_prompt_response_verifier", "prompt_id": "ask", "verifier_id": "answer", + "criteria": [{"type": "response_contains", "needles": [needle]}], "depends_on": ["ask"]}, + ] + + +def _run(root): + return CliRunner().invoke(cli, ["run", str(root)]) + + +def test_a_task_naming_no_agent_runs_the_shipped_claude_code_against_the_configured_endpoint(state, endpoint): + endpoint.respond = answer("pong") + first = _run(_bundle(state / "one", "ask", _ask("Say pong.", "pong"))) + + assert first.exit_code == 0, first.output + assert f"{IMAGE}: building with docker" in first.output, first.output + assert f"{AGENT}: v1 (new)" in first.output, first.output + assert "tasks/ask.json v1: passed" in first.output, first.output + calls = endpoint.messages() + assert calls, "the CLI never called the endpoint" + assert {call.headers["authorization"] for call in calls} == {"Bearer fake-key"} + assert {call.headers.get("x-agentenv-test") for call in calls} == {"header-check"} + assert MODEL in {call.body.get("model") for call in calls} + + second = _run(_bundle(state / "two", "ask", _ask("Say pong.", "pong"))) + + assert second.exit_code == 0, second.output + assert "building with docker" not in second.output, second.output + assert f"{IMAGE}: v1, unchanged" in second.output and f"{AGENT}: v1, unchanged" in second.output, second.output + assert "tasks/ask.json v1: passed" in second.output, second.output + + +def test_the_shipped_claude_code_calls_the_tools_of_the_env_it_is_deployed_with(state, endpoint): + endpoint.respond = tool_then_answer("__echo", {"text": "ping-from-tool"}, prefix="the tool said: ") + root = _bundle(state / "tools", "echo", [ + {"id": "env", "type": "deploy_env", "env_id": ECHO_ENV, "sandbox_type": "local"}, + *_ask("Call the echo tool with the text ping-from-tool.", "the tool said: ping-from-tool", env_ids=[ECHO_ENV]), + ]) + root.joinpath("tasks/echo.json").write_text(json.dumps( + [{**step, "depends_on": ["env"]} if step["id"] == "agent" else step + for step in json.loads(root.joinpath("tasks/echo.json").read_text())])) + + result = _run(root) + + assert result.exit_code == 0, result.output + assert "tasks/echo.json v1: passed" in result.output, result.output + offered = {tool["name"] for call in endpoint.messages() for tool in call.body.get("tools") or []} + assert any(name.endswith("__echo") for name in offered), sorted(offered) diff --git a/tst/util/fake_anthropic.py b/tst/util/fake_anthropic.py new file mode 100644 index 00000000..06f5b853 --- /dev/null +++ b/tst/util/fake_anthropic.py @@ -0,0 +1,212 @@ +"""A fake Anthropic Messages endpoint, so the real Claude Code CLI runs with no model key: ``POST /v1/messages`` +is answered by a responder, streamed as the Messages API's server-sent events, and every request is recorded. +A fault queued with ``fail_next`` replaces the next answer: an HTTP error, a reset or truncated stream, or one that +stalls.""" + +from __future__ import annotations + +import json +import socket +import struct +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +@dataclass(frozen=True) +class Text: + text: str + + +@dataclass(frozen=True) +class ToolUse: + name: str + input: dict + + +@dataclass(frozen=True) +class Fault: + """``status``: answer with that HTTP error; ``reset``/``truncate``: start the stream, then drop the connection + hard or end it without its last events; ``stall``: start it, then send nothing for ``seconds``.""" + + kind: str + status: int = 500 + seconds: float = 0.0 + + +@dataclass +class Seen: + path: str + headers: dict[str, str] + body: dict + + +Responder = Callable[[dict], Text | ToolUse] + + +def answer(text: str) -> Responder: + """Every request answered with ``text``.""" + return lambda body: Text(text) + + +def tool_then_answer(tool_suffix: str, arguments: dict, *, prefix: str = "") -> Responder: + """Call the request's tool whose name ends in ``tool_suffix``, then answer with ``prefix`` and the tool result's + text; answer ``prefix`` alone to a request that offers no such tool.""" + + def respond(body: dict) -> Text | ToolUse: + result = _tool_result_text(body) + if result is not None: + return Text(f"{prefix}{result}") + tool = next((t["name"] for t in body.get("tools") or [] if t.get("name", "").endswith(tool_suffix)), None) + return ToolUse(tool, arguments) if tool else Text(prefix or "no tool") + + return respond + + +def _tool_result_text(body: dict) -> str | None: + for message in reversed(body.get("messages") or []): + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_result": + inner = block.get("content") + if isinstance(inner, list): + return " ".join(part.get("text", "") for part in inner if isinstance(part, dict)) + return str(inner) + return None + return None + + +@dataclass +class FakeAnthropic: + host: str + respond: Responder = field(default_factory=lambda: answer("pong")) + seen: list[Seen] = field(default_factory=list) + faults: list[Fault] = field(default_factory=list) + _server: ThreadingHTTPServer | None = None + _lock: threading.Lock = field(default_factory=threading.Lock) + _released: threading.Event = field(default_factory=threading.Event) + + def __enter__(self) -> FakeAnthropic: + self._server = ThreadingHTTPServer((self.host, 0), _handler(self)) + self._server.daemon_threads = True + threading.Thread(target=self._server.serve_forever, daemon=True).start() + return self + + def __exit__(self, *exc) -> None: + self._released.set() + self._server.shutdown() + self._server.server_close() + + @property + def url(self) -> str: + return f"http://{self.host}:{self._server.server_port}" + + def fail_next(self, *faults: Fault) -> None: + with self._lock: + self.faults.extend(faults) + + def messages(self) -> list[Seen]: + """The model calls, not the token counts.""" + return [seen for seen in self.seen if seen.path == "/v1/messages"] + + def _next_fault(self) -> Fault | None: + with self._lock: + return self.faults.pop(0) if self.faults else None + + +def _handler(fake: FakeAnthropic) -> type[BaseHTTPRequestHandler]: + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args) -> None: + pass + + def do_POST(self) -> None: + path = self.path.split("?", 1)[0] + body = json.loads(self.rfile.read(int(self.headers.get("content-length") or 0)) or b"{}") + fake.seen.append(Seen(path, {k.lower(): v for k, v in self.headers.items()}, body)) + if path == "/v1/messages/count_tokens": + return self._json(200, {"input_tokens": 10}) + if path != "/v1/messages": + return self._json(404, {"type": "error", "error": {"type": "not_found_error", "message": path}}) + fault = fake._next_fault() + if fault is not None and fault.kind == "status": + return self._json(fault.status, {"type": "error", "error": {"type": _error_type(fault.status), + "message": f"injected {fault.status}"}}) + reply = fake.respond(body) + if not body.get("stream"): + return self._json(200, _message(body, reply)) + self._stream(body, reply, fault) + + def _json(self, status: int, payload: dict) -> None: + data = json.dumps(payload).encode() + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _stream(self, body: dict, reply: Text | ToolUse, fault: Fault | None) -> None: + self.send_response(200) + self.send_header("content-type", "text/event-stream") + self.send_header("cache-control", "no-cache") + self.send_header("connection", "close") + self.end_headers() + events = _events(body, reply) + if fault is not None: + self._send(events[:2]) + if fault.kind == "reset": + # SO_LINGER 0 makes close send a reset rather than a clean end. + self.connection.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + elif fault.kind == "stall": + fake._released.wait(fault.seconds) + self.close_connection = True + return + self._send(events) + self.close_connection = True + + def _send(self, events: list[tuple[str, dict]]) -> None: + for name, data in events: + self.wfile.write(f"event: {name}\ndata: {json.dumps(data)}\n\n".encode()) + self.wfile.flush() + + return Handler + + +def _error_type(status: int) -> str: + return {400: "invalid_request_error", 401: "authentication_error", 404: "not_found_error", + 429: "rate_limit_error", 529: "overloaded_error"}.get(status, "api_error") + + +def _message(body: dict, reply: Text | ToolUse) -> dict: + content = ([{"type": "text", "text": reply.text}] if isinstance(reply, Text) + else [{"type": "tool_use", "id": f"toolu_{time.monotonic_ns()}", "name": reply.name, "input": reply.input}]) + return {"id": f"msg_{time.monotonic_ns()}", "type": "message", "role": "assistant", "model": body.get("model"), + "content": content, "stop_reason": "end_turn" if isinstance(reply, Text) else "tool_use", + "stop_sequence": None, "usage": {"input_tokens": 10, "output_tokens": 5}} + + +def _events(body: dict, reply: Text | ToolUse) -> list[tuple[str, dict]]: + message = {**_message(body, reply), "content": [], "stop_reason": None, + "usage": {"input_tokens": 10, "output_tokens": 1}} + if isinstance(reply, Text): + block = {"type": "text", "text": ""} + delta = {"type": "text_delta", "text": reply.text} + stop = "end_turn" + else: + block = {"type": "tool_use", "id": f"toolu_{time.monotonic_ns()}", "name": reply.name, "input": {}} + delta = {"type": "input_json_delta", "partial_json": json.dumps(reply.input)} + stop = "tool_use" + return [ + ("message_start", {"type": "message_start", "message": message}), + ("content_block_start", {"type": "content_block_start", "index": 0, "content_block": block}), + ("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": delta}), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ("message_delta", {"type": "message_delta", "delta": {"stop_reason": stop, "stop_sequence": None}, + "usage": {"output_tokens": 5}}), + ("message_stop", {"type": "message_stop"}), + ] From ed200cd91bf970caaeb8018b51421c88020a1709 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 08:49:52 -0700 Subject: [PATCH 04/24] feat(config)!: the default agent is the agents bundle's Claude Code A step that names no agent deploys @local/agentenv-framework/agents/claude-code, which a bundle run pulls from the installed agents bundle. A store agent named a2a-default is no longer the default: set [agents] default_a2a_agent_id to keep it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .agentenv/config.example.toml | 5 +++-- AGENTS.md | 7 +++++-- src/agent_env/config/runtime.py | 4 ++-- .../ui/src/components/agents-hub-page.tsx | 2 +- tst/integration/cli/bundle_init_scopes_test.py | 13 ++++++++----- tst/unit/bundle/importer_test.py | 16 ++++++++-------- tst/unit/config/test_default_a2a_agent.py | 10 +++++++--- 7 files changed, 34 insertions(+), 23 deletions(-) diff --git a/.agentenv/config.example.toml b/.agentenv/config.example.toml index 19a056f5..8abc4a7d 100644 --- a/.agentenv/config.example.toml +++ b/.agentenv/config.example.toml @@ -24,8 +24,9 @@ agent_default = "local" # agents too # team_id = "your-team" # project_id = "your-project" -[agents] -default_a2a_agent_id = "a2a-default" # what a deploy_agent step that names no agent deploys +# [agents] +# default_a2a_agent_id = "my-agent" # what a deploy_agent step that names no agent deploys; by default the +# # agents bundle's Claude Code, @local/agentenv-framework/agents/claude-code # [model] # base_url = "http://localhost:4000" # a model proxy on this machine; local containers reach it as host.docker.internal diff --git a/AGENTS.md b/AGENTS.md index e11b9828..ccdebcd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,8 +81,11 @@ or one of them only where it is used. what steps staged in `/tmp` under that id. Those processes are found through `/proc`, so on macOS they keep running; files the install wrote elsewhere (`/opt`, packages) stay. - `[agents] default_a2a_agent_id`: the agent a `deploy_agent` step without an id deploys; built-in - `a2a-default`, overridable by `configure(default_a2a_agent_id=...)`. Any step's agent field left unset - falls back to it (`entity.refs.default_agent_refs`), so a bundle run plans the default for it. + `@local/agentenv-framework/agents/claude-code`, the `agents` bundle's Claude Code + (`src/agent_env/examples/agents/`), overridable by `configure(default_a2a_agent_id=...)`. Any step's + agent field left unset falls back to it (`entity.refs.default_agent_refs`), so a bundle run plans the + default for it, pulling the built-in one from the installed `agents` bundle. Claude Code needs a model + endpoint serving Anthropic's Messages API, such as a LiteLLM gateway. - Secrets never live in the file: use `secret:KEY` or `env:NAME` references, resolved through `[stores.secret]`. - agent-env has no stage concept: "dev" versus "prod" is which file `AGENT_ENV_CONFIG` names. A diff --git a/src/agent_env/config/runtime.py b/src/agent_env/config/runtime.py index 6c7c5c52..a4f19ea2 100644 --- a/src/agent_env/config/runtime.py +++ b/src/agent_env/config/runtime.py @@ -75,7 +75,7 @@ def _default_sandbox_mode() -> str: _HUMAN_A2A_CONFIG_KEY = "default_human_a2a_url" _AGENTS_CONFIG_SECTION = "agents" _AGENTS_CONFIG_KEY = "default_a2a_agent_id" -_DEFAULT_A2A_AGENT_ID = "a2a-default" +_DEFAULT_A2A_AGENT_ID = "@local/agentenv-framework/agents/claude-code" # the agents bundle's Claude Code def _validated_human_a2a_url(value: object, source: str) -> str: @@ -383,7 +383,7 @@ def get_default_human_a2a_url(self) -> str: def get_default_a2a_agent_id(self) -> str: """The agent a ``deploy_agent`` step without an id deploys: - ``configure(default_a2a_agent_id=...)`` > ``[agents] default_a2a_agent_id`` > ``a2a-default``.""" + ``configure(default_a2a_agent_id=...)`` > ``[agents] default_a2a_agent_id`` > the agents bundle's Claude Code.""" key = _KEY_BY_NAME[f"{_AGENTS_CONFIG_SECTION}.{_AGENTS_CONFIG_KEY}"] for layer in self.key_candidates(key): if layer.kind == KIND_INSTALLED: diff --git a/src/agent_env/explorer/ui/src/components/agents-hub-page.tsx b/src/agent_env/explorer/ui/src/components/agents-hub-page.tsx index 007cba41..8cdb8e80 100644 --- a/src/agent_env/explorer/ui/src/components/agents-hub-page.tsx +++ b/src/agent_env/explorer/ui/src/components/agents-hub-page.tsx @@ -12,7 +12,7 @@ const AGENTS_PAGE_SIZE = 20; // GET /api/v1/agents/{id}; one that doesn't resolve renders a dimmed // "Unavailable" tile rather than vanishing. Add entries as agent artifacts register. export const FEATURED_AGENTS: Array<{ id: string; logo?: string }> = [ - { id: 'claude-code-cli', logo: '/logos/claude.svg' }, + { id: '@local/agentenv-framework/agents/claude-code', logo: '/logos/claude.svg' }, ]; const FEATURED_AGENT_IDS = FEATURED_AGENTS.map(f => f.id); export const FEATURED_LOGO_BY_ID: Record = diff --git a/tst/integration/cli/bundle_init_scopes_test.py b/tst/integration/cli/bundle_init_scopes_test.py index 183745fb..772a5642 100644 --- a/tst/integration/cli/bundle_init_scopes_test.py +++ b/tst/integration/cli/bundle_init_scopes_test.py @@ -16,7 +16,7 @@ from agent_env.artifact.store import get_artifact_store, reset_artifact_store from agent_env.bundle.resolve import build_step from agent_env.cli import cli -from agent_env.config import configure, get_config, reset_config +from agent_env.config import configure, reset_config from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider from agent_env.providers.sandbox_providers.sandbox_provider import ( reset_agent_sandbox_provider, @@ -167,12 +167,15 @@ def test_an_agent_task_imported_without_its_users_agents_takes_the_receivers_def assert "Would run:" in _ok("run", machines.root / "agents-user", "--dry-run") machines.on("receiver") - default = get_config().get_default_a2a_agent_id() - bare = _refused("run", machines.root / "agents", "--dry-run") - assert f"names no agent, so it deploys the default, {default!r}, and there is no agent {default!r}" in bare + shipped = _ok("run", machines.root / "agents", "--dry-run") + assert "agentenv-framework/agents:agents/claude-code: v1 (new)" in shipped and "Would run:" in shipped assert "there is no agent 'acme-solver'" in _refused("run", machines.root / "agents-user", "--dry-run") - _agent(default, "docker.io/library/python:3.12-slim") + + configure(default_a2a_agent_id="receiver-default") + bare = _refused("run", machines.root / "agents", "--dry-run") + assert "names no agent, so it deploys the default, 'receiver-default', and there is no agent 'receiver-default'" in bare + _agent("receiver-default", "docker.io/library/python:3.12-slim") assert "Would run:" in _ok("run", machines.root / "agents", "--dry-run") assert "there is no agent 'acme-solver'" in _refused("run", machines.root / "agents-user", "--dry-run") diff --git a/tst/unit/bundle/importer_test.py b/tst/unit/bundle/importer_test.py index 33f1ead7..fac90228 100644 --- a/tst/unit/bundle/importer_test.py +++ b/tst/unit/bundle/importer_test.py @@ -24,7 +24,7 @@ from agent_env.bundle.resolve import resolve_bundle from agent_env.cli import cli from agent_env.config import get_config -from agent_env.config.runtime import Config +from agent_env.config.runtime import _DEFAULT_A2A_AGENT_ID as DEFAULT_AGENT, Config from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.eval import Eval from agent_env.eval.eval import EvalTask @@ -110,13 +110,13 @@ def test_a_reference_that_takes_no_version_and_a_step_that_names_no_agent_are_re imported = import_task("triage", store / "triage") assert sorted((ref.where, ref.kind.value, ref.id) for ref in imported.floating) == [ - ("tasks/triage.json: step 'agent': a2a_agent_id", "agent", "a2a-default"), - ("tasks/triage.json: step 'helper': a2a_agent_id", "agent", "a2a-default"), - ("tasks/triage.json: step 'judge': judge_a2a_agent_id", "agent", "a2a-default"), + ("tasks/triage.json: step 'agent': a2a_agent_id", "agent", DEFAULT_AGENT), + ("tasks/triage.json: step 'helper': a2a_agent_id", "agent", DEFAULT_AGENT), + ("tasks/triage.json: step 'judge': judge_a2a_agent_id", "agent", DEFAULT_AGENT), ] kept = import_task("triage", store / "kept", include_scopes=[FieldScope.USER]) assert sorted((ref.where, ref.kind.value, ref.id) for ref in kept.floating) == [ - ("tasks/triage.json: step 'helper': a2a_agent_id", "agent", "a2a-default"), + ("tasks/triage.json: step 'helper': a2a_agent_id", "agent", DEFAULT_AGENT), ("tasks/triage.json: step 'judge': judge_a2a_agent_id", "agent", "solver"), ] @@ -239,7 +239,7 @@ def test_an_env_no_step_of_the_task_deploys_floats(store): assert [(ref.kind.value, ref.id, ref.where) for ref in imported.floating] == [ ("env", "crm-env", "tasks/live.json: step 'agent': env_ids[0]"), - ("agent", "a2a-default", "tasks/live.json: step 'agent': a2a_agent_id")] + ("agent", DEFAULT_AGENT, "tasks/live.json: step 'agent': a2a_agent_id")] def test_a_pin_the_task_already_has_is_kept_when_the_store_holds_it_and_refused_when_it_doesnt(store): @@ -574,8 +574,8 @@ def test_a_steps_user_scoped_fields_are_left_out_and_reported_and_the_rest_of_it assert steps["agent"]["agent_changelog_object_url"] == "s3://their-bucket/changelog/" # the state it starts from assert steps["agent"]["agent_changelog_toolcall_position_exclusive"] == 0 assert {(floating.where, floating.id) for floating in imported.floating} >= { - ("tasks/scoped.json: step 'agent': a2a_agent_id", "a2a-default"), - ("tasks/scoped.json: step 'install': a2a_agent_id", "a2a-default")} + ("tasks/scoped.json: step 'agent': a2a_agent_id", DEFAULT_AGENT), + ("tasks/scoped.json: step 'install': a2a_agent_id", DEFAULT_AGENT)} def test_including_the_user_scope_keeps_each_scoped_field_as_stored(store): diff --git a/tst/unit/config/test_default_a2a_agent.py b/tst/unit/config/test_default_a2a_agent.py index 1da5a009..2e6eca57 100644 --- a/tst/unit/config/test_default_a2a_agent.py +++ b/tst/unit/config/test_default_a2a_agent.py @@ -1,4 +1,5 @@ -"""Default A2A agent resolution: configure() > [agents] default_a2a_agent_id > built-in a2a-default.""" +"""Default A2A agent resolution: configure() > [agents] default_a2a_agent_id > the built-in, the agents bundle's +Claude Code.""" import pytest @@ -12,12 +13,15 @@ def _config(config_file=None, **kwargs): return config_with_document({} if config_file is None else config_file, **kwargs) +BUILT_IN = "@local/agentenv-framework/agents/claude-code" + + def test_built_in_default_when_the_section_is_absent(): - assert _config().get_default_a2a_agent_id() == "a2a-default" + assert _config().get_default_a2a_agent_id() == BUILT_IN def test_an_empty_agents_section_keeps_the_built_in_default(): - assert _config(config_file={"agents": {}}).get_default_a2a_agent_id() == "a2a-default" + assert _config(config_file={"agents": {}}).get_default_a2a_agent_id() == BUILT_IN def test_config_key_resolves(): From 2e2415325e4223b10da18333c24e7b7a03aa5783 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 09:23:13 -0700 Subject: [PATCH 05/24] test(agents): read the CLI's latest user turn for its tool result, and assert on what a run surely prints The CLI follows a tool result with system messages, and serves a tool's output as JSON; a rerun on one machine reuses the agent image without building it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/run_bundle_claude_code_test.py | 15 +++++----- tst/util/fake_anthropic.py | 28 +++++++++++-------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/tst/integration/cli/run_bundle_claude_code_test.py b/tst/integration/cli/run_bundle_claude_code_test.py index 4695280f..4df70345 100644 --- a/tst/integration/cli/run_bundle_claude_code_test.py +++ b/tst/integration/cli/run_bundle_claude_code_test.py @@ -83,8 +83,7 @@ def test_a_task_naming_no_agent_runs_the_shipped_claude_code_against_the_configu first = _run(_bundle(state / "one", "ask", _ask("Say pong.", "pong"))) assert first.exit_code == 0, first.output - assert f"{IMAGE}: building with docker" in first.output, first.output - assert f"{AGENT}: v1 (new)" in first.output, first.output + assert f"{IMAGE}: v1 (new)" in first.output and f"{AGENT}: v1 (new)" in first.output, first.output assert "tasks/ask.json v1: passed" in first.output, first.output calls = endpoint.messages() assert calls, "the CLI never called the endpoint" @@ -104,7 +103,7 @@ def test_the_shipped_claude_code_calls_the_tools_of_the_env_it_is_deployed_with( endpoint.respond = tool_then_answer("__echo", {"text": "ping-from-tool"}, prefix="the tool said: ") root = _bundle(state / "tools", "echo", [ {"id": "env", "type": "deploy_env", "env_id": ECHO_ENV, "sandbox_type": "local"}, - *_ask("Call the echo tool with the text ping-from-tool.", "the tool said: ping-from-tool", env_ids=[ECHO_ENV]), + *_ask("Call the echo tool with the text ping-from-tool.", "ping-from-tool", env_ids=[ECHO_ENV]), ]) root.joinpath("tasks/echo.json").write_text(json.dumps( [{**step, "depends_on": ["env"]} if step["id"] == "agent" else step @@ -112,7 +111,9 @@ def test_the_shipped_claude_code_calls_the_tools_of_the_env_it_is_deployed_with( result = _run(root) - assert result.exit_code == 0, result.output - assert "tasks/echo.json v1: passed" in result.output, result.output - offered = {tool["name"] for call in endpoint.messages() for tool in call.body.get("tools") or []} - assert any(name.endswith("__echo") for name in offered), sorted(offered) + offered = sorted({tool["name"] for call in endpoint.messages() for tool in call.body.get("tools") or []}) + seen = (result.output, offered, endpoint.tool_results()) + assert result.exit_code == 0 and "tasks/echo.json v1: passed" in result.output, seen + # The fake never repeats the prompt, so the text reached the answer through the tool. + assert any(name.endswith("__echo") for name in offered), seen + assert ["ping-from-tool" in result for result in endpoint.tool_results()] == [True], seen diff --git a/tst/util/fake_anthropic.py b/tst/util/fake_anthropic.py index 06f5b853..2d3e8a12 100644 --- a/tst/util/fake_anthropic.py +++ b/tst/util/fake_anthropic.py @@ -10,6 +10,7 @@ import struct import threading import time +from collections import deque from collections.abc import Callable from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -66,17 +67,15 @@ def respond(body: dict) -> Text | ToolUse: def _tool_result_text(body: dict) -> str | None: - for message in reversed(body.get("messages") or []): - content = message.get("content") - if not isinstance(content, list): - continue - for block in content: - if isinstance(block, dict) and block.get("type") == "tool_result": - inner = block.get("content") - if isinstance(inner, list): - return " ".join(part.get("text", "") for part in inner if isinstance(part, dict)) - return str(inner) - return None + """The text of a tool result in the latest user turn, or None. The CLI may follow it with system messages.""" + user = next((m for m in reversed(body.get("messages") or []) if m.get("role") == "user"), None) + content = user.get("content") if user else None + for block in content if isinstance(content, list) else (): + if isinstance(block, dict) and block.get("type") == "tool_result": + inner = block.get("content") + if isinstance(inner, list): + return " ".join(part.get("text", "") for part in inner if isinstance(part, dict)) + return str(inner) return None @@ -84,7 +83,8 @@ def _tool_result_text(body: dict) -> str | None: class FakeAnthropic: host: str respond: Responder = field(default_factory=lambda: answer("pong")) - seen: list[Seen] = field(default_factory=list) + # The latest requests only: a CLI that loops resends its whole history each call. + seen: deque[Seen] = field(default_factory=lambda: deque(maxlen=200)) faults: list[Fault] = field(default_factory=list) _server: ThreadingHTTPServer | None = None _lock: threading.Lock = field(default_factory=threading.Lock) @@ -113,6 +113,10 @@ def messages(self) -> list[Seen]: """The model calls, not the token counts.""" return [seen for seen in self.seen if seen.path == "/v1/messages"] + def tool_results(self) -> list[str]: + """The text of each tool result the CLI sent back.""" + return [text for seen in self.messages() if (text := _tool_result_text(seen.body)) is not None] + def _next_fault(self) -> Fault | None: with self._lock: return self.faults.pop(0) if self.faults else None From 8aa8308714f3e2c5a70a10df8458bd493270e644 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 09:30:07 -0700 Subject: [PATCH 06/24] fix(agents): the CLI gets the model key once, as its bearer token Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/agents/agents/claude-code/claude_bridge.py | 8 +++++--- tst/unit/examples/claude_code/test_cli_env.py | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py index cd80857c..3f92883d 100644 --- a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py +++ b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py @@ -126,8 +126,8 @@ def _model_headers(raw: str | None) -> dict[str, str]: def cli_env(environ: Mapping[str, str], *, model: str | None = None, project_id: str | None = None, task_id: str | None = None, max_thinking_tokens: int | None = None) -> dict[str, str]: """The CLI's environment: this process's, pointed at the endpoint agent-env configured (its key as a bearer - token, so an API key too would conflict), with the endpoint's headers and the run's attribution, and the model's - aliases pinned to ``model`` on that endpoint. A variable already set is kept.""" + token, so an API key too would conflict, and no longer as LITELLM_API_KEY), with the endpoint's headers and the + run's attribution, and the model's aliases pinned to ``model`` on that endpoint. A variable already set is kept.""" env = dict(environ) if not env.get("ANTHROPIC_AUTH_TOKEN") and env.get("LITELLM_API_KEY"): env["ANTHROPIC_AUTH_TOKEN"] = env["LITELLM_API_KEY"] @@ -138,7 +138,9 @@ def cli_env(environ: Mapping[str, str], *, model: str | None = None, project_id: if model and env.get("ANTHROPIC_BASE_URL"): for alias in _MODEL_ALIASES: env.setdefault(alias, model) - headers = {**_model_headers(env.get(MODEL_HEADERS_ENV)), **_attribution_headers(project_id, task_id)} + headers = {**_model_headers(env.pop(MODEL_HEADERS_ENV, None)), **_attribution_headers(project_id, task_id)} + # Mapped to Anthropic's variables above, so the CLI's tools don't get the key twice. + env.pop("LITELLM_API_KEY", None) if headers: env["ANTHROPIC_CUSTOM_HEADERS"] = "\n".join(f"{name}: {value}" for name, value in headers.items()) if max_thinking_tokens: diff --git a/tst/unit/examples/claude_code/test_cli_env.py b/tst/unit/examples/claude_code/test_cli_env.py index 2bcfbbd4..6b6eca2e 100644 --- a/tst/unit/examples/claude_code/test_cli_env.py +++ b/tst/unit/examples/claude_code/test_cli_env.py @@ -22,6 +22,7 @@ def test_the_configured_endpoint_is_the_clis_with_its_key_as_a_bearer_token(): assert (env["ANTHROPIC_AUTH_TOKEN"], env["ANTHROPIC_BASE_URL"], env["ANTHROPIC_API_KEY"]) == ( "gateway-key", "https://gateway.example.com", "") + assert "LITELLM_API_KEY" not in env and "LITELLM_EXTRA_HEADERS" not in env assert env["CLAUDE_CODE_ATTRIBUTION_HEADER"] == "0" From f8ca06f9edd9e9e5afd4c72b41854ce76580fba0 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 10:18:34 -0700 Subject: [PATCH 07/24] test(installer): agent-env's own plugin row lists the agents bundle too Co-Authored-By: Claude Opus 5.5 (1M context) --- tst/installer/test_container_journey.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tst/installer/test_container_journey.py b/tst/installer/test_container_journey.py index fe3e9782..2a61d796 100644 --- a/tst/installer/test_container_journey.py +++ b/tst/installer/test_container_journey.py @@ -133,7 +133,7 @@ def test_a_new_user_installs_agent_env_adds_plugins_runs_a_task_and_removes_them container.ok("python", "-m", "pip", "install", "-q", "--no-index", "--find-links", "/wheels", "uv") container.ok("uv", "tool", "install", "agentenv-framework") rows = container.ok("agent-env", "plugin", "list").stdout.split("STATUS\n", 1)[1] - assert re.fullmatch(r"agentenv-framework +\S+ +bundle echo-mcp, bundle hello +ok\n", rows), rows + assert re.fullmatch(r"agentenv-framework +\S+ +bundle agents, bundle echo-mcp, bundle hello +ok\n", rows), rows added = container.ok("agent-env", "plugin", "add", browser, grader, "--yes") assert "toy_navigate active" in added.stdout and "toy_grade active" in added.stdout From ea1580a4d1f988c064da0b3611124abd5635869d Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 10:53:59 -0700 Subject: [PATCH 08/24] fix(agents): a turn cancelled while the CLI starts kills the CLI and its tools asyncio kills a process cancelled mid-start, but not what it already started, and the bridge had no process to kill yet. The spawn is shielded, and a cancel waits for it, then kills the whole tree. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/claude-code/claude_bridge.py | 18 +++++++++-- .../claude_code/test_claude_bridge_cancel.py | 31 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py index 3f92883d..c63a8ac6 100644 --- a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py +++ b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py @@ -414,6 +414,20 @@ def _descendants(pid: int) -> list[int]: return found +async def _started(spawn): + """The CLI ``spawn`` starts. A turn cancelled while it starts lets it finish starting, then kills it and what it + started: asyncio would kill the CLI alone, and leave its tools running.""" + start = asyncio.ensure_future(spawn) + try: + return await asyncio.shield(start) + except asyncio.CancelledError: + with contextlib.suppress(Exception): + proc = await start + _kill_cli(proc) + await _reap(proc) + raise + + _STDOUT_READ_BYTES = 64 * 1024 @@ -512,7 +526,7 @@ async def _spawn_claude( system_prompt=system_prompt, output_format=output_format, role=role, effort=effort, max_turns=max_turns) logger.info(f"Running claude CLI (resume={resume}, session_id={session_id}, model={model})") - proc = await asyncio.create_subprocess_exec( + proc = await _started(asyncio.create_subprocess_exec( *cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, @@ -520,7 +534,7 @@ async def _spawn_claude( env=cli_env(os.environ, model=model, project_id=project_id, task_id=task_id, max_thinking_tokens=max_thinking_tokens), start_new_session=True, - ) + )) trajectory: list[dict] = [] tool_count = 0 cli_session_id: str | None = None diff --git a/tst/unit/examples/claude_code/test_claude_bridge_cancel.py b/tst/unit/examples/claude_code/test_claude_bridge_cancel.py index 6c37d52e..36959363 100644 --- a/tst/unit/examples/claude_code/test_claude_bridge_cancel.py +++ b/tst/unit/examples/claude_code/test_claude_bridge_cancel.py @@ -90,3 +90,34 @@ async def cancel_while_it_holds_the_pipes(): os.kill(tool, signal.SIGKILL) asyncio.run(cancel_while_it_holds_the_pipes()) + + +def test_a_turn_cancelled_while_the_cli_starts_kills_it_once_it_has(monkeypatch, tmp_path): + """A cancel can arrive before the CLI's pipes are set up, when the bridge has no process to kill yet.""" + pids = tmp_path / "pids" + spawn = asyncio.create_subprocess_exec + starting = asyncio.Event() + + async def slow_to_start(*_cmd, **kwargs): + proc = await spawn("sh", "-c", f'sleep 600 & echo "$$ $!" > {pids}; wait', **kwargs) + starting.set() + await asyncio.sleep(0.5) # still starting when the cancel arrives + return proc + + monkeypatch.setattr(asyncio, "create_subprocess_exec", slow_to_start) + + async def cancel_while_starting(): + turn = asyncio.create_task(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + await asyncio.wait_for(starting.wait(), 10) + turn.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(turn, 10) + deadline = time.monotonic() + 10 + while not (pids.exists() and len(pids.read_text().split()) == 2): + assert time.monotonic() < deadline, "the CLI never wrote its pids" + await asyncio.sleep(0.01) + return [int(pid) for pid in pids.read_text().split()] + + cli, tool = asyncio.run(cancel_while_starting()) + + assert _gone(cli) and _gone(tool) From 3bd198149c35e73b90ba2b40a8e76c5e3bf88a03 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 11:00:46 -0700 Subject: [PATCH 09/24] fix(agents): a snapshot load keeps the other conversations, a failed start resumes nothing, and links survive A load clears the CLI's state, and kept the transcripts of no other bound context: it now keeps them. A turn marks its context active before writing its attachments, so a load can't clear them, and resumes its session only once the CLI has written its transcript, so a turn that failed before then doesn't break the next. A timed-out turn keeps its tool calls. A link to a directory is archived as a link, and a link the load's data filter would refuse is left out. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/agents/claude-code/a2a_server.py | 72 ++++++++++++++----- .../agents/claude-code/claude_bridge.py | 8 ++- .../examples/agents/agents/claude-code/kit.py | 11 ++- .../claude_code/test_claude_bridge_stdin.py | 13 ++++ .../claude_code/test_message_contract.py | 38 +++++++++- .../claude_code/test_object_transfer.py | 23 ++++++ .../claude_code/test_workspace_snapshot.py | 19 +++++ 7 files changed, 161 insertions(+), 23 deletions(-) diff --git a/src/agent_env/examples/agents/agents/claude-code/a2a_server.py b/src/agent_env/examples/agents/agents/claude-code/a2a_server.py index d7aaaa59..920f3e37 100644 --- a/src/agent_env/examples/agents/agents/claude-code/a2a_server.py +++ b/src/agent_env/examples/agents/agents/claude-code/a2a_server.py @@ -11,10 +11,12 @@ import json import logging import os +import posixpath import shutil import tarfile import tempfile import uuid +from collections.abc import Iterable from pathlib import Path from typing import Any @@ -84,6 +86,8 @@ # What agent-env keeps of a failed task's error_message. _ERROR_MESSAGE_LIMIT = 1000 +# How much of a changing workspace file one read stages. +_STAGE_CHUNK_BYTES = 1024 * 1024 class ClaudeCodeConfig(AgentConfig): @@ -121,7 +125,22 @@ def _rewrite_session_ids(jsonl_text: str, session_id: str) -> tuple[list[str], i return rewritten, rewrite_count +def _restorable_link(path: Path, arcname: str) -> bool: + """Whether the link at ``path`` restores where ``arcname`` puts it: relative, and inside the archive, as the load's + data filter requires.""" + target = os.readlink(path) + if posixpath.isabs(target): + return False + resolved = posixpath.normpath(posixpath.join(posixpath.dirname(arcname), target)) + return resolved != ".." and not resolved.startswith("../") + + def _add_workspace_entry(tar: tarfile.TarFile, path: Path, arcname: str, *, stage_source: bool) -> bool: + try: + if path.is_symlink() and not _restorable_link(path, arcname): + return False + except OSError: + return False if not stage_source: try: tar.add(path, arcname=arcname, recursive=False) @@ -143,7 +162,7 @@ def _add_workspace_entry(tar: tarfile.TarFile, path: Path, arcname: str, *, stag remaining = tarinfo.size while remaining: try: - chunk = source.read(min(1024 * 1024, remaining)) + chunk = source.read(min(_STAGE_CHUNK_BYTES, remaining)) except OSError: return False if not chunk: @@ -172,6 +191,8 @@ def add_tree(tar: tarfile.TarFile, source_root: Path, *, archive_prefix: str = " arcname = "/".join(p for p in (archive_prefix, relative_root.as_posix()) if p) if not _add_workspace_entry(tar, root, arcname, stage_source=stage_sources): continue + # A link to a directory is listed here and never walked: it goes in as a link, like a file's. + filenames = sorted([*filenames, *(d for d in dirnames if (root / d).is_symlink())]) for filename in filenames: path = root / filename arcname = "/".join(p for p in (archive_prefix, path.relative_to(source_root).as_posix()) if p) @@ -238,10 +259,15 @@ def _copy_directory_contents(source: Path, destination: Path) -> None: shutil.copy2(child, target) -def _restore_workspace_archive(archive_path: Path) -> None: - """Restore the workspace's files, and the archive's `.claude` to the CLI's state directory.""" +def _restore_workspace_archive(archive_path: Path, keep: Iterable[Path] = ()) -> None: + """Restore the workspace's files, and the archive's `.claude` to the CLI's state directory, keeping the files in + ``keep`` the archive doesn't hold: the transcripts of the other conversations this agent resumes.""" with tempfile.TemporaryDirectory(prefix="claude-snapshot-load-") as raw_stage: - stage = Path(raw_stage) + stage, kept = Path(raw_stage) / "archive", Path(raw_stage) / "kept" + kept.mkdir() + held = [(path, kept / str(index)) for index, path in enumerate(keep) if path.is_file()] + for path, copy in held: + shutil.copy2(path, copy) with tarfile.open(archive_path, mode="r:gz") as tar: # The data filter refuses absolute paths, traversal, devices and unsafe links before anything is written. tar.extractall(stage, filter="data") @@ -261,6 +287,10 @@ def _restore_workspace_archive(archive_path: Path) -> None: shutil.copy2(child, target) _clear_directory(CLAUDE_STATE_DIR) _copy_directory_contents(staged_state, CLAUDE_STATE_DIR) + for path, copy in held: + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(copy, path) def _answer(uploaded: Uploaded) -> dict[str, Any]: @@ -321,6 +351,23 @@ def __init__(self) -> None: async def run(self, request: TaskRequest[ClaudeCodeConfig]) -> TaskResult: # The CLI's stdout events, appended as it prints them, are the turn's trajectory. request.trajectory.set_format(TRAJECTORY_FORMAT) + cfg = request.config + context_id = request.context_id + async with self._contexts_lock: + # Active from here, so a snapshot load can't clear the attachments written below. + self._active_contexts.add(context_id) + # Bound before the CLI starts, so a live trajectory read or a snapshot save finds this turn's session. + session_id = self.session_ref_for_context(context_id) + if session_id is None: + session_id = str(uuid.uuid4()) + self.set_session_ref_for_context(context_id, session_id) + try: + return await self._turn(request, cfg, context_id, session_id) + finally: + self._active_contexts.discard(context_id) + + async def _turn(self, request: TaskRequest[ClaudeCodeConfig], cfg: ClaudeCodeConfig, context_id: str, + session_id: str) -> TaskResult: text_chunks: list[str] = [] attachment_paths: list[str] = [] try: @@ -339,16 +386,8 @@ async def run(self, request: TaskRequest[ClaudeCodeConfig]) -> TaskResult: paths_block = "\n".join(attachment_paths) user_text = f"{user_text}\n\n{paths_block}" if user_text else paths_block - cfg = request.config - context_id = request.context_id - async with self._contexts_lock: - self._active_contexts.add(context_id) - # Bound before the CLI starts, so a live trajectory read or a snapshot save finds this turn's session. - session_id = self.session_ref_for_context(context_id) - resume = session_id is not None - if not resume: - session_id = str(uuid.uuid4()) - self.set_session_ref_for_context(context_id, session_id) + # A turn that failed before the CLI wrote its transcript left no session to resume. + resume = session_jsonl_path(session_id).exists() try: result = await run_claude( prompt=user_text, @@ -372,8 +411,6 @@ async def run(self, request: TaskRequest[ClaudeCodeConfig]) -> TaskResult: logger.error(f"claude CLI invocation raised: {e}", exc_info=True) return (TaskResult.builder().failed("infra_error", str(e)[:_ERROR_MESSAGE_LIMIT], error_type="infra_error") .add_text(str(e)).build()) - finally: - self._active_contexts.discard(context_id) # The CLI keeps the session id it's given; if it ever reports another, the next turn resumes that one. if result.session_id and result.session_id != session_id: session_id = result.session_id @@ -499,8 +536,9 @@ async def load_snapshot(self, request: ObjectSnapshotLoadRequest) -> ObjectSnaps if self._active_contexts: raise HTTPException(status_code=409, detail="cannot load a workspace while an agent turn is running") if has_workspace_archive: + keep = [session_jsonl_path(session) for session in self.bound_session_refs()] try: - await asyncio.to_thread(_restore_workspace_archive, archive_path) + await asyncio.to_thread(_restore_workspace_archive, archive_path, keep) except (tarfile.TarError, OSError, ValueError) as exc: raise HTTPException(status_code=400, detail=f"invalid workspace archive: {exc}") from exc SESSION_JSONL_DIR.mkdir(parents=True, exist_ok=True) diff --git a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py index c63a8ac6..72bdfecc 100644 --- a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py +++ b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py @@ -429,6 +429,8 @@ async def _started(spawn): _STDOUT_READ_BYTES = 64 * 1024 +# How much of a line the CLI shouldn't have printed is logged. +_LOGGED_LINE_CHARS = 200 async def _stream_cli(proc, stdin_data: bytes, on_line: Callable[[str], None]) -> bytes: @@ -550,10 +552,10 @@ def on_line(line: str) -> None: try: event = json.loads(line) except json.JSONDecodeError: - logger.warning(f"claude CLI emitted non-JSON line: {line[:200]}") + logger.warning(f"claude CLI emitted non-JSON line: {line[:_LOGGED_LINE_CHARS]}") return if not isinstance(event, dict): - logger.warning(f"claude CLI emitted a non-object JSON line: {line[:200]}") + logger.warning(f"claude CLI emitted a non-object JSON line: {line[:_LOGGED_LINE_CHARS]}") return trajectory.append(event) etype = event.get("type") @@ -589,7 +591,7 @@ def on_line(line: str) -> None: except asyncio.TimeoutError: _kill_cli(proc) await _reap(proc) - return ClaudeResult(response="", tool_call_count=0, session_id=session_id, + return ClaudeResult(response="", tool_call_count=tool_count, session_id=session_id, error=f"claude CLI timed out after {timeout_s}s") except Exception: # Reading failed, as when an event_callback raises: the CLI mustn't keep running. diff --git a/src/agent_env/examples/agents/agents/claude-code/kit.py b/src/agent_env/examples/agents/agents/claude-code/kit.py index b1809ca4..c03b3d59 100644 --- a/src/agent_env/examples/agents/agents/claude-code/kit.py +++ b/src/agent_env/examples/agents/agents/claude-code/kit.py @@ -42,6 +42,10 @@ def set_session_ref_for_context(self, context_id: str, session_ref: str) -> None super().set_session_ref_for_context(context_id, session_ref) self.__dict__.setdefault(_REFS_ATTR, {})[context_id] = session_ref + def bound_session_refs(self) -> list[str]: + """The session reference of every context this process has bound.""" + return list(self.__dict__.get(_REFS_ATTR, {}).values()) + _NO_BODY_STATUSES = frozenset({204, 205, 304}) @@ -101,6 +105,9 @@ async def install_skill(request: InlineSkillRequest | BundleSkillRequest, skills _URL_QUERY_RE = re.compile(r"(?i)\b(https?://[^\s?#]+)\?(?!)\S*") _LOGGED_URL_RE = re.compile(r"(?i)\b(?Phttps?)://(?:[^/?#@\s]*@)?(?P[^/?#\s]+)\S*") _REDACT_LOGGED_URLS: ContextVar[bool] = ContextVar("claude_code_filepart_download", default=False) +_FILEPART_DOWNLOAD_TIMEOUT_S = 60 +# Two parts of one message sharing a name are told apart by this much of a uuid. +_NAME_PREFIX_CHARS = 8 class _RedactDownloadUrls(logging.Filter): @@ -173,7 +180,7 @@ def _download_uri_to(uri: str, target: Path) -> None: if scheme not in ("http", "https"): raise FilePartMaterializeError(f"unsupported URI scheme {scheme!r} in {_shown(uri)}") try: - with _redacting_logged_urls(), httpx.Client(timeout=60, follow_redirects=True) as client: + with _redacting_logged_urls(), httpx.Client(timeout=_FILEPART_DOWNLOAD_TIMEOUT_S, follow_redirects=True) as client: response = client.get(uri) response.raise_for_status() target.write_bytes(response.content) @@ -190,7 +197,7 @@ def _materialize(part: FilePart, target_dir: Path) -> str: name = _safe_name(part.name) target = target_dir / name if target.exists(): - target = target_dir / f"{uuid.uuid4().hex[:8]}-{name}" + target = target_dir / f"{uuid.uuid4().hex[:_NAME_PREFIX_CHARS]}-{name}" if part.bytes is not None: try: target.write_bytes(base64.b64decode(part.bytes, validate=True)) diff --git a/tst/unit/examples/claude_code/test_claude_bridge_stdin.py b/tst/unit/examples/claude_code/test_claude_bridge_stdin.py index b727d7d8..a6a7e0a9 100644 --- a/tst/unit/examples/claude_code/test_claude_bridge_stdin.py +++ b/tst/unit/examples/claude_code/test_claude_bridge_stdin.py @@ -114,3 +114,16 @@ def test_timeout_kills_process_and_returns_error(spawn_capture_hang): assert "timed out" in result.error assert result.response == "" assert spawn_capture_hang["proc"].killed is True + + +def test_a_timed_out_turn_keeps_the_tool_calls_it_made(monkeypatch): + tool_use = {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Bash", "input": {}}]}} + + async def _exec(*cmd, **kwargs): + return FakeCLI((json.dumps(tool_use) + "\n").encode(), hang=True) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _exec) + + result = asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False, timeout_s=1)) + + assert "timed out" in result.error and result.tool_call_count == 1 diff --git a/tst/unit/examples/claude_code/test_message_contract.py b/tst/unit/examples/claude_code/test_message_contract.py index 41e119f6..0c380892 100644 --- a/tst/unit/examples/claude_code/test_message_contract.py +++ b/tst/unit/examples/claude_code/test_message_contract.py @@ -31,7 +31,12 @@ def calls(monkeypatch) -> Calls: async def _run_claude(**kwargs): recorded.append(kwargs) - return recorded.result_for(kwargs) + result = recorded.result_for(kwargs) + # As the CLI does: the session's transcript is on disk once a turn has run. + transcript = claude_bridge.session_jsonl_path(result.session_id or kwargs["session_id"]) + transcript.parent.mkdir(parents=True, exist_ok=True) + transcript.write_text('{"type": "assistant"}\n') + return result monkeypatch.setattr(a2a_server, "run_claude", _run_claude) return recorded @@ -177,3 +182,34 @@ def test_the_default_timeout_is_thirty_minutes(calls, client): def test_session_references_outlive_the_frameworks_bounded_record(): assert issubclass(a2a_server.ClaudeCodeAgent, kit.DurableSessionRefs) + + +def test_a_turn_that_failed_before_the_cli_wrote_a_transcript_starts_its_session_afresh(monkeypatch, client): + seen = [] + + async def _run_claude(**kwargs): + seen.append((kwargs["session_id"], kwargs["resume"])) + if len(seen) == 1: + raise ValueError("output_format must be {...}") + return claude_bridge.ClaudeResult(response="ok", tool_call_count=0, session_id=kwargs["session_id"]) + + monkeypatch.setattr(a2a_server, "run_claude", _run_claude) + + assert terminal(send(client, "one"))[0] == "failed" + assert terminal(send(client, "two"))[0] == "completed" + (first, _), (second, resumed) = seen + assert second == first and resumed is False + + +def test_attachments_are_written_while_the_turn_holds_a_snapshot_load_off(monkeypatch, agent, calls, client): + active = [] + + async def _materialize(part, target_dir): + active.append("ctx-1" in agent._active_contexts) + return str(target_dir / "a.png") + + monkeypatch.setattr(a2a_server, "materialize_file_part", _materialize) + + send(client, [{"kind": "file", "file": {"name": "a.png", "mimeType": "image/png", "bytes": "cG5n"}}]) + + assert active == [True] and agent._active_contexts == set() diff --git a/tst/unit/examples/claude_code/test_object_transfer.py b/tst/unit/examples/claude_code/test_object_transfer.py index f0a60721..baa8a88e 100644 --- a/tst/unit/examples/claude_code/test_object_transfer.py +++ b/tst/unit/examples/claude_code/test_object_transfer.py @@ -223,6 +223,9 @@ async def scenario(): turn = asyncio.create_task(agent.run(request)) await asyncio.sleep(0.1) assert started == [] + transcript = claude_bridge.session_jsonl_path("restored-session") + transcript.parent.mkdir(parents=True, exist_ok=True) + transcript.write_text('{"type": "user", "sessionId": "restored-session"}\n') agent.set_session_ref_for_context("restored", "restored-session") return await turn @@ -252,3 +255,23 @@ def test_a_transfer_error_answers_with_the_typed_body(client, store): "error": {"code": "transfer_rejected", "message": "The object store rejected the transfer.", "retryable": False} } assert "sig=r" not in response.text + + +def test_a_snapshot_load_keeps_the_transcripts_of_the_other_conversations(monkeypatch, tmp_path, agent, client, store): + conversation = (json.dumps({"type": "user", "sessionId": "s-1", "message": "hello"}) + "\n").encode() + _roots(monkeypatch, tmp_path, "s-1", conversation) + agent.set_session_ref_for_context("ctx-1", "s-1") + saved = client.post(SNAPSHOT_PATH, json={"context_id": "ctx-1", "objects": { + "trajectory": write_grant("snap/trajectory"), "workspace": write_grant("snap/workspace")}}) + assert saved.status_code == 200, saved.text + other = a2a_server.SESSION_JSONL_DIR / "s-other.jsonl" + other.write_text(json.dumps({"type": "assistant", "sessionId": "s-other", "message": {"content": "mine"}}) + "\n") + agent.set_session_ref_for_context("ctx-other", "s-other") + + loaded = client.put(SNAPSHOT_PATH, json={"objects": { + "trajectory": read_grant("snap/trajectory", len(conversation)), + "workspace": read_grant("snap/workspace", len(store.objects["snap/workspace"]))}}) + + assert loaded.status_code == 200, loaded.text + assert json.loads(other.read_text())["sessionId"] == "s-other" + assert client.post(TRAJECTORY_PATH, json={"context_id": "ctx-other"}).status_code == 200 diff --git a/tst/unit/examples/claude_code/test_workspace_snapshot.py b/tst/unit/examples/claude_code/test_workspace_snapshot.py index 7bb778fe..a1d3be27 100644 --- a/tst/unit/examples/claude_code/test_workspace_snapshot.py +++ b/tst/unit/examples/claude_code/test_workspace_snapshot.py @@ -297,3 +297,22 @@ def test_load_fetch_failure_is_a_typed_502(agent, client, store): assert response.json() == { "error": {"code": "transfer_rejected", "message": "The object store rejected the transfer.", "retryable": False} } + + +def test_a_directory_link_is_archived_as_a_link_and_one_the_load_would_refuse_is_left_out(monkeypatch, tmp_path): + workspace, _ = _snapshot_roots(monkeypatch, tmp_path) + (workspace / "current").symlink_to("report", target_is_directory=True) + (workspace / "elsewhere").symlink_to("/etc", target_is_directory=True) + (workspace / "up").symlink_to("../..", target_is_directory=True) + archive = tmp_path / "workspace.tar.gz" + + a2a_server._build_workspace_archive(archive) + with tarfile.open(archive, mode="r:gz") as tar: + links = {member.name: member.linkname for member in tar.getmembers() if member.issym()} + for child in list(workspace.iterdir()): + if child.is_symlink(): + child.unlink() + a2a_server._restore_workspace_archive(archive) + + assert links == {"current": "report"} + assert (workspace / "current").is_symlink() and (workspace / "current" / "answer.txt").read_text() == "done" From def74319171d4b8f12de9452766a122c3ff784f2 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 12:49:16 -0700 Subject: [PATCH 10/24] feat(agents): Claude Code serves install/v1, into a task container or onto a sandbox's host install_agent naming no agent now installs it. install.sh, in the agent's folder, brings Claude Code's native build for the target's CPU and libc and uv, each checked against its published checksum, and the agent's requirements in a Python of uv's own, so the target's Python is untouched. A target with neither bash nor zsh, which the CLI runs its commands in, gets bash from its package manager. The model endpoint reaches the agent in a 0600 env file. The install returns once the agent answers on its port, and fails with the end of its log if the agent exits first. The agent and the CLI work in the container's working directory, and the CLI's transcripts follow it. install_agent gains a trust_env param: on a sandbox on this machine, the local transfer CA goes into the container, or beside the work dir on a host, and the variables that find it reach the install. A snapshot load restores the live transcript of every other conversation the agent holds, not the archive's older copy. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 8 +- src/agent_env/examples/agents/README.md | 6 + .../agents/agents/claude-code/Dockerfile | 3 +- .../agents/agents/claude-code/a2a_server.py | 35 +++-- .../agents/claude-code/claude_bridge.py | 23 ++- .../agents/agents/claude-code/install.sh | 133 ++++++++++++++++++ .../examples/agents/agents/claude-code/run.sh | 15 ++ .../sandbox_providers/local_sandbox.py | 21 ++- .../task_step/task_steps/install_agent.py | 35 ++++- .../claude_code_install/alpine/Dockerfile | 5 + .../claude_code_install/alpine/artifact.toml | 2 + .../claude_code_install/debian/Dockerfile | 5 + .../claude_code_install/debian/artifact.toml | 2 + .../run_bundle_claude_code_install_test.py | 130 +++++++++++++++++ tst/unit/examples/claude_code/conftest.py | 3 +- .../claude_code/test_card_contract.py | 50 ++++++- tst/unit/examples/claude_code/test_cli_env.py | 29 +++- .../claude_code/test_image_contract.py | 3 +- .../claude_code/test_object_transfer.py | 20 +++ tst/unit/task_step/install_agent_test.py | 59 ++++++++ 20 files changed, 557 insertions(+), 30 deletions(-) create mode 100755 src/agent_env/examples/agents/agents/claude-code/install.sh create mode 100755 src/agent_env/examples/agents/agents/claude-code/run.sh create mode 100644 tst/data/claude_code_install/alpine/Dockerfile create mode 100644 tst/data/claude_code_install/alpine/artifact.toml create mode 100644 tst/data/claude_code_install/debian/Dockerfile create mode 100644 tst/data/claude_code_install/debian/artifact.toml create mode 100644 tst/integration/cli/run_bundle_claude_code_install_test.py diff --git a/AGENTS.md b/AGENTS.md index ccdebcd0..71fcae04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,13 +79,17 @@ or one of them only where it is used. `install_commands_host` on this machine, with your privileges. Every command a local sandbox runs carries `AGENTENV_SANDBOX=`, and teardown stops the processes that carry it, then removes what steps staged in `/tmp` under that id. Those processes are found through `/proc`, so on macOS - they keep running; files the install wrote elsewhere (`/opt`, packages) stay. + they keep running; files the install wrote elsewhere (`/opt`, packages) stay. Such an agent fetches the + local store's grants only when `grant_advertise_host` names an address this machine reaches, the + bridge address say: `host.docker.internal`, which grant URLs name by default, resolves only in containers. - `[agents] default_a2a_agent_id`: the agent a `deploy_agent` step without an id deploys; built-in `@local/agentenv-framework/agents/claude-code`, the `agents` bundle's Claude Code (`src/agent_env/examples/agents/`), overridable by `configure(default_a2a_agent_id=...)`. Any step's agent field left unset falls back to it (`entity.refs.default_agent_refs`), so a bundle run plans the default for it, pulling the built-in one from the installed `agents` bundle. Claude Code needs a model - endpoint serving Anthropic's Messages API, such as a LiteLLM gateway. + endpoint serving Anthropic's Messages API, such as a LiteLLM gateway. It declares `install/v1` too: its + folder's `install.sh` installs it into a task container or onto a VM sandbox's host, with its own Claude + Code, uv and Python, adding bash only to a target with neither bash nor zsh. - Secrets never live in the file: use `secret:KEY` or `env:NAME` references, resolved through `[stores.secret]`. - agent-env has no stage concept: "dev" versus "prod" is which file `AGENT_ENV_CONFIG` names. A diff --git a/src/agent_env/examples/agents/README.md b/src/agent_env/examples/agents/README.md index e43fc4a9..9ef0d339 100644 --- a/src/agent_env/examples/agents/README.md +++ b/src/agent_env/examples/agents/README.md @@ -11,4 +11,10 @@ on every model call. Building the image downloads Claude Code, which is Anthropi [Commercial Terms](https://www.anthropic.com/legal/commercial-terms); the image is built where it runs and never published. The CLI's version is the Dockerfile's `CLAUDE_CODE_VERSION`. +`install_agent` installs it too, into a container a task already runs or onto a `vm` sandbox's Linux host, with +`install.sh` from its folder: Claude Code's native build for the target's CPU and libc, uv, and the agent's requirements +in a Python of its own, so the target's Python is left alone. The CLI runs its commands in bash or zsh, so a target with +neither, Alpine's for one, gets bash from its own package manager. The agent works in the container's working directory, +or in `/app` on the host. + Run its smoke task with `agent-env run agents`. diff --git a/src/agent_env/examples/agents/agents/claude-code/Dockerfile b/src/agent_env/examples/agents/agents/claude-code/Dockerfile index 491421bc..27dc20d6 100644 --- a/src/agent_env/examples/agents/agents/claude-code/Dockerfile +++ b/src/agent_env/examples/agents/agents/claude-code/Dockerfile @@ -18,7 +18,8 @@ RUN set -eu; \ WORKDIR /opt/agent COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt -COPY a2a_server.py claude_bridge.py kit.py entrypoint.sh ./ +# install.sh and run.sh are install/v1's: in the image, so they stay in its build context. +COPY a2a_server.py claude_bridge.py kit.py entrypoint.sh install.sh run.sh ./ # The CLI's cwd, /app, and its state, fixed so neither follows the uid: Modal runs the image as root. RUN useradd --create-home --uid 10001 agent && mkdir -p /app /home/agent/.claude \ && chown agent:agent /app /home/agent/.claude && chmod 0755 entrypoint.sh diff --git a/src/agent_env/examples/agents/agents/claude-code/a2a_server.py b/src/agent_env/examples/agents/agents/claude-code/a2a_server.py index 920f3e37..3a060503 100644 --- a/src/agent_env/examples/agents/agents/claude-code/a2a_server.py +++ b/src/agent_env/examples/agents/agents/claude-code/a2a_server.py @@ -22,6 +22,7 @@ from a2a.types import AgentSkill from agentenv_protocol.a2a_agent import ( + INSTALL_V1, MCP_CONFIG_V1, SKILL_CONFIG_V1, SNAPSHOT_V1, @@ -56,6 +57,7 @@ from claude_bridge import ( CLAUDE_STATE_DIR, SESSION_JSONL_DIR, + WORKSPACE_DIR, read_partial_session_trajectory, run_claude, session_jsonl_path, @@ -75,8 +77,6 @@ HARNESS = "claude-code" # The versioned format of the stream-json events a turn's trajectory holds. TRAJECTORY_FORMAT = "claude-code-stream-json/1" -# The CLI's cwd: what the model writes lands here, and collect_artifacts sees only that. -WORKSPACE_DIR = Path("/app") # The CLI loads skills from /skills//SKILL.md. SKILLS_DIR = CLAUDE_STATE_DIR / "skills" # FileParts are written here, one folder per task, and their paths appended to the prompt: the CLI reads a path in @@ -89,6 +89,17 @@ # How much of a changing workspace file one read stages. _STAGE_CHUNK_BYTES = 1024 * 1024 +# install/v1: the folder, unpacked on the sandbox's host, installs itself (install.sh); install_agent fills each {param} +# in, quoted. The endpoint and CA go in the environment, not on the agent's command line. +_A2A_PORT = 8000 +_UNPACK = "mkdir -p {work_dir}/agent && tar -xzf {agent_ctx_tar} -C {work_dir}/agent" +_ENDPOINT = ("LITELLM_API_KEY={litellm_api_key} LITELLM_BASE_URL={litellm_base_url} " + "LITELLM_EXTRA_HEADERS={litellm_extra_headers} AGENT_TRUST_ENV={trust_env} ") +_ENDPOINT_PARAMS = ["litellm_api_key", "litellm_base_url", "litellm_extra_headers", "trust_env"] +_INSTALL_COMMANDS = [_UNPACK, _ENDPOINT + "bash {work_dir}/agent/install.sh container {container} {a2a_port}"] +# On a host there's no container working directory: the agent works in /app, as in its image. +_INSTALL_COMMANDS_HOST = [_UNPACK, _ENDPOINT + "bash {work_dir}/agent/install.sh host {agent_name} {a2a_port} /app"] + class ClaudeCodeConfig(AgentConfig): timeout_seconds: int = 1800 @@ -260,8 +271,8 @@ def _copy_directory_contents(source: Path, destination: Path) -> None: def _restore_workspace_archive(archive_path: Path, keep: Iterable[Path] = ()) -> None: - """Restore the workspace's files, and the archive's `.claude` to the CLI's state directory, keeping the files in - ``keep`` the archive doesn't hold: the transcripts of the other conversations this agent resumes.""" + """Restore the workspace's files, and the archive's `.claude` to the CLI's state directory, keeping the current + copy of each file in ``keep``: the transcripts of the conversations this agent resumes.""" with tempfile.TemporaryDirectory(prefix="claude-snapshot-load-") as raw_stage: stage, kept = Path(raw_stage) / "archive", Path(raw_stage) / "kept" kept.mkdir() @@ -288,9 +299,8 @@ def _restore_workspace_archive(archive_path: Path, keep: Iterable[Path] = ()) -> _clear_directory(CLAUDE_STATE_DIR) _copy_directory_contents(staged_state, CLAUDE_STATE_DIR) for path, copy in held: - if not path.exists(): - path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(copy, path) + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(copy, path) def _answer(uploaded: Uploaded) -> dict[str, Any]: @@ -332,8 +342,17 @@ async def _fetch_granted_snapshot(objects: SnapshotReadObjects, archive_path: Pa enable(MCP_CONFIG_V1, description="Register MCP servers the Claude Code CLI should connect to."), enable(SKILL_CONFIG_V1, description="Register a skill the Claude Code CLI loads from its skills directory."), enable(SNAPSHOT_V1, description=( - "Capture and restore the Claude Code conversation, the /app workspace and the CLI's state, " + "Capture and restore the Claude Code conversation, its workspace and the CLI's state, " "through agent-env's object-transfer grants.")), + enable( + INSTALL_V1, + description="Commands to install this agent into a running task container, or onto its sandbox's host.", + install_commands=_INSTALL_COMMANDS, + a2a_port=_A2A_PORT, + required_params=["container", "work_dir", "agent_ctx_tar", "a2a_port", *_ENDPOINT_PARAMS], + install_commands_host=_INSTALL_COMMANDS_HOST, + required_params_host=["work_dir", "agent_ctx_tar", "a2a_port", "agent_name", *_ENDPOINT_PARAMS], + ), enable(TRIGGERS_V1, description=( "Register a typed trigger script and decide the stakeholder reply per turn " "(step / env_trigger / conversational / all / any conditions -> say / end actions).")), diff --git a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py index 72bdfecc..9e85857b 100644 --- a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py +++ b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py @@ -20,8 +20,25 @@ # The CLI's own state: settings, skills and session transcripts. The image fixes it, so it doesn't follow the uid. CLAUDE_STATE_DIR = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude") -# The CLI keeps a session's transcript under a directory named for its cwd, which is always /app. -SESSION_JSONL_DIR = CLAUDE_STATE_DIR / "projects" / "-app" + + +def _workspace() -> Path: + """Where the CLI runs and the model's files land: AGENT_WORKSPACE_DIR, else this process's working directory (the + image's /app, or an installed agent's container's), except the filesystem root, which is never one.""" + if configured := os.environ.get("AGENT_WORKSPACE_DIR"): + return Path(configured) + cwd = Path.cwd() + return Path("/app") if cwd == Path(cwd.anchor) else cwd + + +def project_dir(workspace: Path) -> Path: + """Where the CLI keeps the transcripts of sessions run in ``workspace``: a directory named for it, each character + but a letter or digit as -.""" + return CLAUDE_STATE_DIR / "projects" / re.sub(r"[^A-Za-z0-9]", "-", str(workspace)) + + +WORKSPACE_DIR = _workspace() +SESSION_JSONL_DIR = project_dir(WORKSPACE_DIR) # The model aliases the CLI resolves for itself, pinned to the run's model on a gateway that may serve no other. _MODEL_ALIASES = ( @@ -528,6 +545,7 @@ async def _spawn_claude( system_prompt=system_prompt, output_format=output_format, role=role, effort=effort, max_turns=max_turns) logger.info(f"Running claude CLI (resume={resume}, session_id={session_id}, model={model})") + WORKSPACE_DIR.mkdir(parents=True, exist_ok=True) proc = await _started(asyncio.create_subprocess_exec( *cmd, stdin=asyncio.subprocess.PIPE, @@ -535,6 +553,7 @@ async def _spawn_claude( stderr=asyncio.subprocess.PIPE, env=cli_env(os.environ, model=model, project_id=project_id, task_id=task_id, max_thinking_tokens=max_thinking_tokens), + cwd=WORKSPACE_DIR, start_new_session=True, )) trajectory: list[dict] = [] diff --git a/src/agent_env/examples/agents/agents/claude-code/install.sh b/src/agent_env/examples/agents/agents/claude-code/install.sh new file mode 100755 index 00000000..c4cf1b4f --- /dev/null +++ b/src/agent_env/examples/agents/agents/claude-code/install.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# Install this agent from the folder this script is in, and start it on PORT, without an image of its own: +# +# install.sh container NAME PORT into the running container NAME, as root, in its working directory +# install.sh host NAME PORT WORKDIR onto this Linux host, beside this script, as agent NAME, in WORKDIR +# +# It brings Claude Code's native build and uv, each checked against its published checksum, and the agent's +# requirements in a private Python, so the target's own Python and packages are never touched; only a target with +# neither bash nor zsh, which the CLI runs its commands in, gets bash from its own package manager. The model endpoint's +# LITELLM_API_KEY, LITELLM_BASE_URL and LITELLM_EXTRA_HEADERS, and AGENT_TRUST_ENV, the KEY='VALUE' lines of the CA +# variables the agent needs, come in the environment and are kept in a 0600 file the agent reads. +set -euo pipefail +umask 077 + +mode=$1 target=$2 port=$3 +here=$(cd "$(dirname "$0")" && pwd) + +UV_VERSION=0.13.1 +UV_SHA256_X86_64=6c86040a8f045ab9adc9e3ec4cdafa03fd00e9a988ed2bf28971c7fc2d0debd0 +UV_SHA256_AARCH64=345a8308e4d1835cdfaadf692836fd3137a6d45864773f05ad7bf59442b1ba16 +PYTHON_VERSION=3.12 +START_TIMEOUT_S=120 +CLAUDE_CODE_VERSION=$(sed -n 's/^ARG CLAUDE_CODE_VERSION=//p' "$here/Dockerfile") +AGENT_FILES="a2a_server.py claude_bridge.py kit.py requirements.txt run.sh" + +case $mode in + container) + root=/opt/agentenv-agent + workdir= + on() { docker exec -u 0 "$target" sh -c "$1"; } + put() { docker cp "$1" "$target:$2" > /dev/null; } + ;; + host) + [ "$(uname -s)" = Linux ] || { echo "a host install needs a Linux host, not $(uname -s)" >&2; exit 1; } + root=$here/../agent-$target + workdir=$4 + on() { sh -c "$1"; } + put() { cp "$1" "$2"; } + ;; + *) echo "usage: install.sh container NAME PORT | install.sh host NAME PORT WORKDIR" >&2; exit 2 ;; +esac + +case $(on 'uname -m') in + x86_64|amd64) cpu=x64 uv_cpu=x86_64 uv_sha256=$UV_SHA256_X86_64 ;; + aarch64|arm64) cpu=arm64 uv_cpu=aarch64 uv_sha256=$UV_SHA256_AARCH64 ;; + *) echo "Claude Code has no build for $(on 'uname -m')" >&2; exit 1 ;; +esac +platform=linux-$cpu +if on 'for f in /lib/ld-musl-*; do [ -e "$f" ] && exit 0; done; exit 1'; then + platform=$platform-musl +fi + +if ! on 'for shell in bash zsh; do command -v "$shell" >/dev/null && exit 0; done; exit 1'; then + on 'if command -v apk >/dev/null; then apk add --no-cache --quiet bash + elif command -v apt-get >/dev/null; then apt-get update -qq && apt-get install -y -qq --no-install-recommends bash + elif command -v dnf >/dev/null; then dnf install -y -q bash + elif command -v microdnf >/dev/null; then microdnf install -y bash + else exit 1; fi' > /dev/null \ + || { echo "Claude Code runs its commands in bash or zsh: the target has neither, and bash can't be added" >&2 + exit 1; } +fi + +sha256() { if command -v sha256sum >/dev/null; then sha256sum "$1"; else shasum -a 256 "$1"; fi | cut -d' ' -f1; } +fetch() { curl -fsSL --retry 3 "$1" -o "$2"; } +checked() { # checked FILE SHA256: FILE is what was published, or the install stops + [ "$(sha256 "$1")" = "$2" ] || { echo "$(basename "$1") doesn't match its published checksum" >&2; exit 1; } +} +quoted() { printf "'%s'" "$(printf %s "$1" | sed "s/'/'\\\\''/g")"; } + +# Beside this script, in the work dir the sandbox's teardown removes, so a killed install leaves nothing elsewhere. +stage=$(mktemp -d "$here/../stage.XXXXXX") +trap 'rm -rf "$stage"' EXIT + +release=https://downloads.claude.ai/claude-code-releases/$CLAUDE_CODE_VERSION +fetch "$release/manifest.json" "$stage/manifest.json" +manifest=$(tr -d '\n' < "$stage/manifest.json") +pattern="\"$platform\"[[:space:]]*:[[:space:]]*[{][^{}]*\"checksum\"[[:space:]]*:[[:space:]]*\"([a-f0-9]{64})\"" +[[ $manifest =~ $pattern ]] || { echo "Claude Code $CLAUDE_CODE_VERSION has no $platform build" >&2; exit 1; } +fetch "$release/$platform/claude" "$stage/claude" +checked "$stage/claude" "${BASH_REMATCH[1]}" + +uv_archive=uv-$uv_cpu-unknown-linux-musl.tar.gz +fetch "https://github.com/astral-sh/uv/releases/download/$UV_VERSION/$uv_archive" "$stage/$uv_archive" +checked "$stage/$uv_archive" "$uv_sha256" +tar -xzf "$stage/$uv_archive" -C "$stage" +chmod 0755 "$stage/claude" "$stage/uv-$uv_cpu-unknown-linux-musl/uv" + +# The model endpoint and CA, as the agent's environment, readable by its user only. +{ + for name in LITELLM_API_KEY LITELLM_BASE_URL LITELLM_EXTRA_HEADERS; do + printf '%s=%s\n' "$name" "$(quoted "${!name:-}")" + done + [ -z "${AGENT_TRUST_ENV:-}" ] || printf '%s\n' "$AGENT_TRUST_ENV" + printf 'A2A_PORT=%s\nCLAUDE_CONFIG_DIR=%s\n' "$port" "$root/claude" + printf 'DISABLE_AUTOUPDATER=1\nCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1\n' +} > "$stage/agent.env" + +on "mkdir -p '$root/bin' '$root/app' '$root/claude'" +put "$stage/claude" "$root/bin/claude" +put "$stage/uv-$uv_cpu-unknown-linux-musl/uv" "$root/bin/uv" +put "$stage/agent.env" "$root/agent.env" +for file in $AGENT_FILES; do + put "$here/$file" "$root/app/$file" +done +on "chmod 0600 '$root/agent.env' && chmod 0755 '$root/bin/claude' '$root/bin/uv' '$root/app/run.sh'" + +# A private Python, uv's own build of it and never the target's, with the agent's requirements; no cache is left behind. +on "cd '$root' && export UV_PYTHON_PREFERENCE=only-managed UV_PYTHON_INSTALL_DIR='$root/python' \ + UV_CACHE_DIR='$root/cache' && bin/uv venv --quiet --python $PYTHON_VERSION venv \ + && bin/uv pip install --quiet --python venv/bin/python -r app/requirements.txt \ + && rm -rf '$root/cache'" + +# An earlier install's agent, if there is one, gives up its port. +on "[ -f '$root/agent.pid' ] && kill \$(cat '$root/agent.pid') 2>/dev/null; rm -f '$root/agent.pid'; true" + +if [ "$mode" = container ]; then + docker exec -d -u 0 "$target" sh -c "exec '$root/app/run.sh' '$root' > '$root/agent.log' 2>&1" +else + mkdir -p "$workdir" + cd "$workdir" + nohup "$root/app/run.sh" "$root" > "$root/agent.log" 2>&1 < /dev/null & +fi + +# Done once the agent answers on its port; an agent that exits first fails the install with the end of its log. +health="import urllib.request; urllib.request.urlopen('http://127.0.0.1:$port/health', timeout=2)" +for _ in $(seq "$START_TIMEOUT_S"); do + on "'$root/venv/bin/python' -c \"$health\"" 2> /dev/null && exit 0 + on "[ ! -f '$root/agent.pid' ] || kill -0 \$(cat '$root/agent.pid')" 2> /dev/null || break + sleep 1 +done +echo "the agent didn't start; the end of its log:" >&2 +on "tail -n 40 '$root/agent.log'" >&2 || true +exit 1 diff --git a/src/agent_env/examples/agents/agents/claude-code/run.sh b/src/agent_env/examples/agents/agents/claude-code/run.sh new file mode 100755 index 00000000..6d430dc3 --- /dev/null +++ b/src/agent_env/examples/agents/agents/claude-code/run.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Start an installed agent from ROOT, in this shell's working directory: its environment from ROOT/agent.env, and its +# own Python and Claude Code ahead of the target's. +set -e +root=$1 +set -a +. "$root/agent.env" +set +a +# The CLI refuses --dangerously-skip-permissions as root unless it's told it's sandboxed. +if [ "$(id -u)" = "0" ]; then + export IS_SANDBOX=1 +fi +echo $$ > "$root/agent.pid" +export PATH="$root/bin:$PATH" PYTHONPATH="$root/app" +exec "$root/venv/bin/uvicorn" a2a_server:app --host 0.0.0.0 --port "$A2A_PORT" diff --git a/src/agent_env/providers/sandbox_providers/local_sandbox.py b/src/agent_env/providers/sandbox_providers/local_sandbox.py index de46188f..55912b85 100644 --- a/src/agent_env/providers/sandbox_providers/local_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/local_sandbox.py @@ -86,12 +86,19 @@ def _runs_in_container(cmd: list[str]) -> bool: # Where a container finds the local transfer CA's trust files, and the variables that point TLS clients at them: # SSL_CERT_FILE replaces a client's roots, so it gets the public roots plus the CA; NODE_EXTRA_CA_CERTS adds. -_TRUST_DIR = "/etc/agentenv" -LOCAL_TRUST_ENV = { - "SSL_CERT_FILE": f"{_TRUST_DIR}/ca-bundle.pem", - "REQUESTS_CA_BUNDLE": f"{_TRUST_DIR}/ca-bundle.pem", - "NODE_EXTRA_CA_CERTS": f"{_TRUST_DIR}/ca.pem", -} +LOCAL_TRUST_DIR = "/etc/agentenv" + + +def trust_env(directory: str) -> dict[str, str]: + """The variables that point TLS clients at the local transfer CA's trust files in ``directory``.""" + return { + "SSL_CERT_FILE": f"{directory}/ca-bundle.pem", + "REQUESTS_CA_BUNDLE": f"{directory}/ca-bundle.pem", + "NODE_EXTRA_CA_CERTS": f"{directory}/ca.pem", + } + + +LOCAL_TRUST_ENV = trust_env(LOCAL_TRUST_DIR) # Marker dropped in the work dir when this sandbox runs a container, so a later get_sandbox() # (post-run teardown reconstructs the sandbox from disk) knows it owns that container. @@ -529,7 +536,7 @@ async def start_trusting(sandbox: VmSandbox, container: str, trust_dir: Path) -> reach it, then start it. A container that cannot be given the files or started is removed, so its name is free for the next attempt.""" try: - await sandbox.copy_trust_dir_into_container(trust_dir, container, _TRUST_DIR) + await sandbox.copy_trust_dir_into_container(trust_dir, container, LOCAL_TRUST_DIR) await sandbox.exec_script(f"docker start {shlex.quote(container)} > /dev/null") except Exception: await sandbox.exec_script(f"docker rm -f {shlex.quote(container)} >/dev/null 2>&1 || true") diff --git a/src/agent_env/task_step/task_steps/install_agent.py b/src/agent_env/task_step/task_steps/install_agent.py index 768d9788..52d43ee8 100644 --- a/src/agent_env/task_step/task_steps/install_agent.py +++ b/src/agent_env/task_step/task_steps/install_agent.py @@ -24,7 +24,13 @@ from agent_env.a2a_agent import A2AAgent from agent_env.config import get_config from agent_env.entity.scopes import FieldScope -from agent_env.providers.sandbox_providers.local_sandbox import host_url_for +from agent_env.providers.sandbox_providers.local_sandbox import ( + LOCAL_TRUST_DIR, + LOCAL_TRUST_ENV, + host_url_for, + local_grant_trust, + trust_env, +) from agent_env.store.base import NotFoundError from agent_env.task_step.context import DeployedAgent, TaskStepContext from agent_env.entity.refs import EntityRef @@ -59,6 +65,7 @@ def _build_param_resolvers( workspace_dir: Optional[str] = None, litellm_api_key_override: Optional[str] = None, sandbox_type: Optional[str] = None, + trust_lines: str = "", ) -> dict[str, Callable[[], str]]: """Per-install registry: required_params name -> zero-arg resolver returning the raw value. @@ -101,6 +108,9 @@ def _resolve_container() -> str: "litellm_base_url": lambda: host_url_for(config.get_litellm_base_url(), sandbox_type), # A JSON object, {} when [model.headers] is unset. "litellm_extra_headers": lambda: json.dumps(config.get_model_headers(config.get_litellm_base_url())), + # KEY='VALUE' lines pointing TLS clients at the local transfer CA, which grants on a sandbox on this machine + # need; empty elsewhere. Asking for it puts the CA's files where the lines point. + "trust_env": lambda: trust_lines, } @@ -254,10 +264,13 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: logger.info(f"Downloading agent build context {agent.docker_image_artifact.build_context_object_url} -> {agent_ctx_tar}") await sandbox.load_object_file(agent.docker_image_artifact.build_context_object_url, agent_ctx_tar) + container = sandbox.scoped_name(self.container_name) if self.container_name else None + trust_lines = await self._trust_lines(sandbox, container, work_dir) if "trust_env" in required_params else "" + # Resolve each required_param via the registry, shlex.quote so str.format # substitution produces shell-safe tokens. resolvers = _build_param_resolvers( - container_name=sandbox.scoped_name(self.container_name) if self.container_name else None, + container_name=container, work_dir=work_dir, agent_ctx_tar=agent_ctx_tar, # On the host, the agent listens where the sandbox publishes its port, which is where its URL points. @@ -268,6 +281,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: litellm_api_key_override=user_overrides.get("litellm_api_key"), # A host install on a local sandbox runs on this machine itself, not in a container. sandbox_type=None if host_mode else sandbox.type, + trust_lines=trust_lines, ) resolved: dict[str, str] = {} for name in required_params: @@ -324,6 +338,23 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: return context + @staticmethod + async def _trust_lines(sandbox, container: Optional[str], work_dir: str) -> str: + """The ``trust_env`` lines for an agent installed on ``sandbox``: on a sandbox on this machine, whose agents use + the local transfer CA's grants, the CA's files are copied into ``container``, or onto the host under + ``work_dir``, and the lines point there. Nothing elsewhere.""" + if not sandbox.ON_THIS_MACHINE or (trust_dir := await asyncio.to_thread(local_grant_trust)) is None: + return "" + if container is not None: + await sandbox.copy_trust_dir_into_container(trust_dir, container, LOCAL_TRUST_DIR) + env = LOCAL_TRUST_ENV + else: + directory = f"{work_dir}/trust" + for path in sorted(trust_dir.iterdir()): + await sandbox.write_host_file(await asyncio.to_thread(path.read_bytes), f"{directory}/{path.name}") + env = trust_env(directory) + return "".join(f"{name}={shlex.quote(value)}\n" for name, value in env.items()) + def _register_instance(self, agent, a2a_url: str, ds, agent_card: dict): """Register the installed agent in the instance store, same as A2AAgent.deploy(), so store-resolving consumers (AddSkillsTaskStep, the a2a-agent CLIs) can find it.""" diff --git a/tst/data/claude_code_install/alpine/Dockerfile b/tst/data/claude_code_install/alpine/Dockerfile new file mode 100644 index 00000000..d7d775bd --- /dev/null +++ b/tst/data/claude_code_install/alpine/Dockerfile @@ -0,0 +1,5 @@ +# A musl task container, with busybox's shell and no bash or Python. +FROM public.ecr.aws/docker/library/alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 +WORKDIR /workspace/task_repo +RUN echo notes-from-the-task > notes.txt +CMD ["tail", "-f", "/dev/null"] diff --git a/tst/data/claude_code_install/alpine/artifact.toml b/tst/data/claude_code_install/alpine/artifact.toml new file mode 100644 index 00000000..302ff036 --- /dev/null +++ b/tst/data/claude_code_install/alpine/artifact.toml @@ -0,0 +1,2 @@ +# A build context run_docker_container builds, so its files, not an image this bundle builds. +type = "file_artifact_universe" diff --git a/tst/data/claude_code_install/debian/Dockerfile b/tst/data/claude_code_install/debian/Dockerfile new file mode 100644 index 00000000..b4de87fc --- /dev/null +++ b/tst/data/claude_code_install/debian/Dockerfile @@ -0,0 +1,5 @@ +# A task container with no Python, curl or CA certificates, working in a directory of its own. +FROM public.ecr.aws/docker/library/debian:bookworm-slim@sha256:7c7b2c966bc9ee8cedfeef67e0e279108992c77681fa595db4a9d65c06ccc587 +WORKDIR /workspace/task_repo +RUN echo notes-from-the-task > notes.txt +CMD ["tail", "-f", "/dev/null"] diff --git a/tst/data/claude_code_install/debian/artifact.toml b/tst/data/claude_code_install/debian/artifact.toml new file mode 100644 index 00000000..302ff036 --- /dev/null +++ b/tst/data/claude_code_install/debian/artifact.toml @@ -0,0 +1,2 @@ +# A build context run_docker_container builds, so its files, not an image this bundle builds. +type = "file_artifact_universe" diff --git a/tst/integration/cli/run_bundle_claude_code_install_test.py b/tst/integration/cli/run_bundle_claude_code_install_test.py new file mode 100644 index 00000000..7a4b642f --- /dev/null +++ b/tst/integration/cli/run_bundle_claude_code_install_test.py @@ -0,0 +1,130 @@ +"""``install_agent`` naming no agent installs the agents bundle's Claude Code, from its folder, into a running task +container, a Debian and a musl one with no Python of their own, or onto the sandbox's host. A skill reaches it through +a read grant, over the local transfer CA's TLS, and the agent answers through a fake Anthropic Messages endpoint after +running a command where the task's files are. Needs a Docker daemon, and network access for Claude Code, uv, Python and +the agent's requirements.""" + +import json +import logging +import platform +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from agent_env.artifact.store import reset_artifact_store +from agent_env.cli import cli +from agent_env.config import configure, reset_config +from agent_env.providers.sandbox_providers.local_sandbox import _host_ips +from agent_env.store.object_store.local.grant_server import grant_server +from tst.util.fake_anthropic import FakeAnthropic, tool_then_answer + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +TASK_IMAGES = Path(__file__).resolve().parents[2] / "data" / "claude_code_install" +NOTES = "notes-from-the-task" +SKILL_BODY = "Read the task's notes first." +SKILL_MD = f"---\nname: notes\ndescription: Where the task's notes are.\n---\n{SKILL_BODY}\n" +# The installed skill's last line, as the CLI's shell finds it. +READ_SKILL = 'tail -n 1 "$CLAUDE_CONFIG_DIR/skills/notes/SKILL.md"' + + +@pytest.fixture +def endpoint(): + """The fake endpoint, where this machine's containers reach it: on Linux, the Docker bridge address.""" + with FakeAnthropic(host=_host_ips()[-1]) as fake: + yield fake + + +@pytest.fixture +def state(monkeypatch, tmp_path, endpoint): + """Local stores under this test's folder and [model] pointed at the fake endpoint. Whatever ran, no sandbox work + folder may be left behind.""" + sandboxes = tmp_path / "sandboxes" + config = tmp_path / "config.toml" + config.write_text(f'[model]\nbase_url = "{endpoint.url}"\napi_key = "fake-key"\ndefault = "claude-fake-1"\n') + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("AGENT_ENV_CONFIG", str(config)) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + monkeypatch.setenv("AGENT_ENV_DOCUMENT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_LOCAL_SANDBOX_DIR", str(sandboxes)) + for name in ("LITELLM_API_KEY", "LITELLM_BASE_URL"): + monkeypatch.delenv(name, raising=False) + configure() + reset_artifact_store() + logging.disable(logging.CRITICAL) # pytest's live logging would take CliRunner's stdout + try: + yield tmp_path + assert not sandboxes.exists() or not any(sandboxes.iterdir()), "a run left a sandbox work folder" + finally: + logging.disable(logging.NOTSET) + grant_server(None, None).close() + reset_artifact_store() + reset_config() + + +def _bundle(root: Path, task_image: str | None) -> Path: + """A task that installs the default agent, into a container built from ``task_image`` or onto the host, gives it + a skill, and asks it to read the task's notes.""" + steps = [{"id": "box", "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "vm", + "sandbox_type": "local", "exposed_ports": [8000]}] + install = {"id": "coder", "type": "install_agent", "sandbox_name": "box", "agent_name": "coder"} + if task_image is not None: + shutil.copytree(TASK_IMAGES / task_image, root / "artifacts" / "task-image") + steps.append({"id": "repo", "type": "run_docker_container", "sandbox_name": "box", "container_name": "repo", + "docker_context_artifact_id": "task-image", "ports": [8000], "depends_on": ["box"]}) + install |= {"container_name": "repo", "depends_on": ["repo"]} + else: + install["depends_on"] = ["box"] + (root / "skills/notes").mkdir(parents=True) + (root / "skills/notes/SKILL.md").write_text(SKILL_MD) + steps += [ + install, + {"id": "skill", "type": "add_skills", "agent_name": "coder", "skills": [{"skill_artifact_id": "notes"}], + "depends_on": ["coder"]}, + {"id": "ask", "type": "prompt_agent", "agent_name": "coder", "prompt_id": "ask", "depends_on": ["skill"], + "prompt": "What do the task's notes say?"}, + {"id": "check", "type": "agent_prompt_response_verifier", "prompt_id": "ask", "verifier_id": "read", + "criteria": [{"type": "response_contains", "needles": [NOTES]}], "depends_on": ["ask"]}, + {"id": "down", "type": "teardown_sandboxes", "sandbox_names": ["box"], "depends_on": ["check"]}, + ] + (root / "tasks").mkdir() + (root / "tasks/install.json").write_text(json.dumps(steps)) + return root + + +@pytest.mark.parametrize("task_image", ["debian", "alpine"]) +def test_install_agent_naming_no_agent_installs_claude_code_into_a_task_container(state, endpoint, task_image): + endpoint.respond = tool_then_answer("Bash", {"command": f"cat notes.txt; pwd; {READ_SKILL}"}) + + result = CliRunner().invoke(cli, ["run", str(_bundle(state / "bundle", task_image))]) + + seen = (result.output, endpoint.tool_results()) + assert result.exit_code == 0 and "tasks/install.json v1: passed" in result.output, seen + # The CLI ran where the task container works, read the task's own file there, and the skill's. + assert endpoint.tool_results() == [f"{NOTES}\n/workspace/task_repo\n{SKILL_BODY}"], seen + + +@pytest.mark.skipif(platform.system() != "Linux", reason="a host install needs a Linux host") +def test_install_agent_with_no_container_installs_claude_code_onto_the_sandboxs_host(monkeypatch, state, endpoint): + # A process on the host can't resolve host.docker.internal: the grants name the bridge address, which it reaches. + bridge = _host_ips()[-1] + config = state / "config.toml" + config.write_text(config.read_text() + ( + '[stores.object]\nimpl = "agent_env.store.object_store:LocalFilesystemObjectStore"\n' + f'config = {{ grant_bind_host = "{bridge}", grant_advertise_host = "{bridge}" }}\n')) + monkeypatch.delenv("AGENT_ENV_OBJECT_STORE") + configure() + reset_artifact_store() + endpoint.respond = tool_then_answer("Bash", {"command": f"echo {NOTES}; {READ_SKILL}"}) + + try: + result = CliRunner().invoke(cli, ["run", str(_bundle(state / "bundle", None))]) + finally: + grant_server(bridge, bridge).close() + + seen = (result.output, endpoint.tool_results()) + assert result.exit_code == 0 and "tasks/install.json v1: passed" in result.output, seen + assert endpoint.tool_results() == [f"{NOTES}\n{SKILL_BODY}"], seen diff --git a/tst/unit/examples/claude_code/conftest.py b/tst/unit/examples/claude_code/conftest.py index 4e20dffe..76d4f7e3 100644 --- a/tst/unit/examples/claude_code/conftest.py +++ b/tst/unit/examples/claude_code/conftest.py @@ -21,7 +21,8 @@ def agent_dirs(monkeypatch, tmp_path): monkeypatch.setattr(module, "CLAUDE_STATE_DIR", state) monkeypatch.setattr(module, "SESSION_JSONL_DIR", sessions) monkeypatch.setattr(a2a_server, "SKILLS_DIR", state / "skills") - monkeypatch.setattr(a2a_server, "WORKSPACE_DIR", tmp_path / "app") + for module in (a2a_server, claude_bridge): + monkeypatch.setattr(module, "WORKSPACE_DIR", tmp_path / "app") monkeypatch.setattr(a2a_server, "INPUTS_DIR", tmp_path / "app" / "inputs") return state diff --git a/tst/unit/examples/claude_code/test_card_contract.py b/tst/unit/examples/claude_code/test_card_contract.py index 20b7454d..18780fe8 100644 --- a/tst/unit/examples/claude_code/test_card_contract.py +++ b/tst/unit/examples/claude_code/test_card_contract.py @@ -4,9 +4,12 @@ import json import os +import re import subprocess import sys +from agent_env.task_step.task_steps.install_agent import _build_param_resolvers + from tst.unit.examples.claude_code.agent import AGENT_DIR from tst.unit.examples.claude_code.support import card @@ -52,6 +55,7 @@ def test_extensions_and_their_routes(app): "urn:agentenv:mcp-config/v1": ("/ext/mcp-config", {"add": ("POST", None), "list": ("GET", None)}), "urn:agentenv:skill-config/v1": ("/ext/skill-config", {"add": ("POST", None), "list": ("GET", None)}), "urn:agentenv:snapshot/v1": ("/ext/snapshot", {"save": ("POST", None), "load": ("PUT", None)}), + "urn:agentenv:install/v1": (None, {}), "urn:agentenv:triggers/v1": ("/ext/triggers", { "register": ("POST", None), "decide": ("POST", "/ext/triggers/decide"), "state": ("GET", None), }), @@ -120,9 +124,12 @@ def test_the_card_and_health_are_served(client): assert health.json() == {"status": "ok"} -def test_install_agent_reads_the_card_and_finds_no_install_extension(): - """install_agent runs this script in the image, with no agent environment, and parses its stdout as the card: - the agent declares no install/v1, so it says so rather than failing to import.""" +def _install(app) -> dict: + return _extensions(app)["urn:agentenv:install/v1"]["params"] + + +def test_install_agent_reads_the_card_and_its_install_commands_from_the_image(): + """install_agent runs this script in the image, with no agent environment, and parses its stdout as the card.""" script = ("import sys, json; sys.path.insert(0, '/app'); " "from a2a_server import AGENT_CARD; " "print(json.dumps(AGENT_CARD.model_dump()))") @@ -130,5 +137,38 @@ def test_install_agent_reads_the_card_and_finds_no_install_extension(): "PYTHONPATH": str(AGENT_DIR)} out = subprocess.run([sys.executable, "-c", script], cwd=AGENT_DIR, env=env, capture_output=True, text=True, check=True) - uris = [ext["uri"] for ext in json.loads(out.stdout)["capabilities"]["extensions"]] - assert "urn:agentenv:agent-config/v1" in uris and "urn:agentenv:install/v1" not in uris + (install,) = [ext for ext in json.loads(out.stdout)["capabilities"]["extensions"] + if ext["uri"] == "urn:agentenv:install/v1"] + assert install["params"]["install_commands"] and install["params"]["install_commands_host"] + + +def test_each_install_mode_asks_only_for_params_install_agent_supplies(app): + params = _install(app) + supplied = set(_build_param_resolvers(container_name="c", work_dir="/w", agent_ctx_tar="/w/a.tgz", a2a_port=8000, + config=None, agent_name="a")) + assert params["a2a_port"] == 8000 + for commands, required in (("install_commands", "required_params"), + ("install_commands_host", "required_params_host")): + placeholders = {name for command in params[commands] for name in re.findall(r"\{(\w+)\}", command)} + assert placeholders == set(params[required]) and set(params[required]) <= supplied, commands + assert "${" not in " ".join(params[commands]) + + +def test_the_install_runs_the_folders_own_script_in_each_mode(app): + params = _install(app) + container, host = params["install_commands"][-1], params["install_commands_host"][-1] + assert "bash {work_dir}/agent/install.sh container {container} {a2a_port}" in container + assert "bash {work_dir}/agent/install.sh host {agent_name} {a2a_port} /app" in host + for command in (container, host): + # The model key reaches the script in its environment, never as an argument. + assert command.startswith("LITELLM_API_KEY={litellm_api_key} ") and command.count("{litellm_api_key}") == 1 + + +def test_the_install_scripts_parse(): + subprocess.run(["bash", "-n", str(AGENT_DIR / "install.sh")], check=True) + subprocess.run(["sh", "-n", str(AGENT_DIR / "run.sh")], check=True) + + +def test_install_sh_installs_the_cli_version_the_image_pins(): + pinned = re.search(r"^ARG CLAUDE_CODE_VERSION=(\S+)$", (AGENT_DIR / "Dockerfile").read_text(), re.MULTILINE) + assert pinned and "sed -n 's/^ARG CLAUDE_CODE_VERSION=//p'" in (AGENT_DIR / "install.sh").read_text() diff --git a/tst/unit/examples/claude_code/test_cli_env.py b/tst/unit/examples/claude_code/test_cli_env.py index 6b6eca2e..029b78cd 100644 --- a/tst/unit/examples/claude_code/test_cli_env.py +++ b/tst/unit/examples/claude_code/test_cli_env.py @@ -6,6 +6,7 @@ import asyncio import json +from pathlib import Path import pytest @@ -77,7 +78,7 @@ def spawned(monkeypatch): seen: dict = {} async def _exec(*cmd, **kwargs): - seen["cmd"], seen["env"] = list(cmd), kwargs["env"] + seen["cmd"], seen["env"], seen["cwd"] = list(cmd), kwargs["env"], kwargs.get("cwd") return FakeCLI(b'{"type": "result", "subtype": "success", "result": "ok", "session_id": "s"}\n') monkeypatch.setattr(asyncio, "create_subprocess_exec", _exec) @@ -120,3 +121,29 @@ async def _exec(*cmd, **kwargs): "claude CLI exited with code 1: API Error: 404 Not Found; https://router.example.com/v1 answered 404: Claude " "Code needs an endpoint serving Anthropic's Messages API (/v1/messages), such as a LiteLLM gateway, " "that knows the model") + + +@pytest.mark.parametrize("workspace, project", [ + ("/app", "-app"), + ("/tmp/my_ws.x-y/Sub Dir", "-tmp-my-ws-x-y-Sub-Dir"), # as the CLI names it, checked against 2.1.280 +]) +def test_a_sessions_transcripts_are_where_the_cli_keeps_them_for_its_workspace(workspace, project): + assert claude_bridge.project_dir(Path(workspace)) == claude_bridge.CLAUDE_STATE_DIR / "projects" / project + + +def test_the_workspace_is_the_configured_one_else_the_working_directory_but_never_the_root(monkeypatch, tmp_path): + monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path / "set")) + assert claude_bridge._workspace() == tmp_path / "set" + monkeypatch.delenv("AGENT_WORKSPACE_DIR") + monkeypatch.chdir(tmp_path) + assert claude_bridge._workspace() == tmp_path + monkeypatch.chdir("/") + assert claude_bridge._workspace() == Path("/app") + + +def test_the_cli_runs_in_the_workspace(spawned, monkeypatch, tmp_path): + monkeypatch.setattr(claude_bridge, "WORKSPACE_DIR", tmp_path / "repo") + + asyncio.run(claude_bridge.run_claude(prompt="p", session_id="s", resume=False)) + + assert spawned["cwd"] == tmp_path / "repo" and (tmp_path / "repo").is_dir() diff --git a/tst/unit/examples/claude_code/test_image_contract.py b/tst/unit/examples/claude_code/test_image_contract.py index c5412782..bea5361c 100644 --- a/tst/unit/examples/claude_code/test_image_contract.py +++ b/tst/unit/examples/claude_code/test_image_contract.py @@ -14,7 +14,8 @@ def test_every_file_the_image_copies_is_in_the_folder(): copied = [name for line in re.findall(r"^COPY (.+) \S+$", DOCKERFILE, re.MULTILINE) for name in line.split()] assert copied and all((AGENT_DIR / name).is_file() for name in copied), copied - assert {"a2a_server.py", "claude_bridge.py", "kit.py", "entrypoint.sh"} <= set(copied) + # install.sh and run.sh are install/v1's; a build context pruned to what the Dockerfile reads keeps them so. + assert {"a2a_server.py", "claude_bridge.py", "kit.py", "entrypoint.sh", "install.sh", "run.sh"} <= set(copied) def test_the_image_installs_the_protocol_alone_from_its_requirements(): diff --git a/tst/unit/examples/claude_code/test_object_transfer.py b/tst/unit/examples/claude_code/test_object_transfer.py index baa8a88e..81303f5e 100644 --- a/tst/unit/examples/claude_code/test_object_transfer.py +++ b/tst/unit/examples/claude_code/test_object_transfer.py @@ -275,3 +275,23 @@ def test_a_snapshot_load_keeps_the_transcripts_of_the_other_conversations(monkey assert loaded.status_code == 200, loaded.text assert json.loads(other.read_text())["sessionId"] == "s-other" assert client.post(TRAJECTORY_PATH, json={"context_id": "ctx-other"}).status_code == 200 + + +def test_a_snapshot_load_keeps_the_live_transcript_of_a_conversation_the_archive_holds_an_older_copy_of( + monkeypatch, tmp_path, agent, client, store): + conversation = (json.dumps({"type": "user", "sessionId": "s-1", "message": "hello"}) + "\n").encode() + _roots(monkeypatch, tmp_path, "s-1", conversation) + agent.set_session_ref_for_context("ctx-1", "s-1") + saved = client.post(SNAPSHOT_PATH, json={"context_id": "ctx-1", "objects": { + "trajectory": write_grant("snap/trajectory"), "workspace": write_grant("snap/workspace")}}) + assert saved.status_code == 200, saved.text + live = a2a_server.session_jsonl_path("s-1") + later = json.dumps({"type": "assistant", "sessionId": "s-1", "message": {"content": "after the save"}}) + "\n" + live.write_text(live.read_text() + later) + + loaded = client.put(SNAPSHOT_PATH, json={"objects": { + "trajectory": read_grant("snap/trajectory", len(conversation)), + "workspace": read_grant("snap/workspace", len(store.objects["snap/workspace"]))}}) + + assert loaded.status_code == 200, loaded.text + assert live.read_text().endswith(later) diff --git a/tst/unit/task_step/install_agent_test.py b/tst/unit/task_step/install_agent_test.py index 67231e0b..17ba0691 100644 --- a/tst/unit/task_step/install_agent_test.py +++ b/tst/unit/task_step/install_agent_test.py @@ -13,6 +13,7 @@ from agent_env.providers.sandbox_providers import sandbox_provider from agent_env.store.secret_store.local_secret_store import LocalSecretStore from agent_env.task_step.context import DeployedSandbox, TaskStepContext +from agent_env.task_step.task_steps import install_agent as install_agent_module from agent_env.task_step.task_steps.install_agent import ( InstallAgentTaskStep, _build_param_resolvers, @@ -251,3 +252,61 @@ def get(agent_id, version=None): await step.execute(context) assert asked == [local_stores.get_default_a2a_agent_id()] + + +class _TrustingSandbox: + """A sandbox that records the CA files it's given.""" + + def __init__(self, on_this_machine): + self.ON_THIS_MACHINE = on_this_machine + self.copied, self.written = [], {} + + async def copy_trust_dir_into_container(self, trust_dir, container, destination): + self.copied.append((trust_dir, container, destination)) + + async def write_host_file(self, data, vm_path): + self.written[vm_path] = data + + +@pytest.fixture +def ca(monkeypatch, tmp_path): + trust = tmp_path / "trust" + trust.mkdir() + (trust / "ca.pem").write_text("ca") + (trust / "ca-bundle.pem").write_text("bundle") + monkeypatch.setattr(install_agent_module, "local_grant_trust", lambda: trust) + return trust + + +def test_an_install_in_a_container_on_this_machine_gets_the_ca_copied_in_and_the_lines_that_find_it(ca): + sandbox = _TrustingSandbox(on_this_machine=True) + + lines = asyncio.run(InstallAgentTaskStep._trust_lines(sandbox, "box-task", "/tmp/work")) + + assert sandbox.copied == [(ca, "box-task", "/etc/agentenv")] + assert lines.splitlines() == ["SSL_CERT_FILE=/etc/agentenv/ca-bundle.pem", + "REQUESTS_CA_BUNDLE=/etc/agentenv/ca-bundle.pem", + "NODE_EXTRA_CA_CERTS=/etc/agentenv/ca.pem"] + + +def test_an_install_on_a_host_on_this_machine_gets_the_ca_beside_its_work_dir(ca): + sandbox = _TrustingSandbox(on_this_machine=True) + + lines = asyncio.run(InstallAgentTaskStep._trust_lines(sandbox, None, "/tmp/work dir")) + + assert sandbox.written == {"/tmp/work dir/trust/ca-bundle.pem": b"bundle", "/tmp/work dir/trust/ca.pem": b"ca"} + assert lines.splitlines()[-1] == "NODE_EXTRA_CA_CERTS='/tmp/work dir/trust/ca.pem'" + + +def test_an_install_elsewhere_or_without_a_local_ca_gets_no_lines(ca, monkeypatch): + remote = _TrustingSandbox(on_this_machine=False) + assert asyncio.run(InstallAgentTaskStep._trust_lines(remote, "box-task", "/tmp/work")) == "" + monkeypatch.setattr(install_agent_module, "local_grant_trust", lambda: None) + assert asyncio.run(InstallAgentTaskStep._trust_lines(_TrustingSandbox(True), "box-task", "/tmp/work")) == "" + assert remote.copied == [] and remote.written == {} + + +def test_the_trust_lines_resolve_as_the_trust_env_param(): + resolvers = _build_param_resolvers(container_name="c", work_dir="/w", agent_ctx_tar="/w/a.tgz", a2a_port=8000, + config=None, agent_name="a", trust_lines="SSL_CERT_FILE=/x\n") + assert resolvers["trust_env"]() == "SSL_CERT_FILE=/x\n" From f025888e0ba8fbc83240b56bc95331d1f0fd6ced Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 13:12:56 -0700 Subject: [PATCH 11/24] fix(agents): two agents installed in one container keep their own installs, and a name with a space installs A container install lived at /opt/agentenv-agent whatever its port, so a second agent there rewrote the first's environment and stopped it: each now lives under its port. The env file and the commands an install runs in the target quote every path, so a host install whose agent name has a space starts. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/agents/claude-code/install.sh | 25 ++++---- .../run_bundle_claude_code_install_test.py | 64 +++++++++++++------ 2 files changed, 57 insertions(+), 32 deletions(-) diff --git a/src/agent_env/examples/agents/agents/claude-code/install.sh b/src/agent_env/examples/agents/agents/claude-code/install.sh index c4cf1b4f..d6af51c9 100755 --- a/src/agent_env/examples/agents/agents/claude-code/install.sh +++ b/src/agent_env/examples/agents/agents/claude-code/install.sh @@ -25,7 +25,7 @@ AGENT_FILES="a2a_server.py claude_bridge.py kit.py requirements.txt run.sh" case $mode in container) - root=/opt/agentenv-agent + root=/opt/agentenv-agent-$port # one per agent: two in a container listen on two ports workdir= on() { docker exec -u 0 "$target" sh -c "$1"; } put() { docker cp "$1" "$target:$2" > /dev/null; } @@ -66,6 +66,7 @@ checked() { # checked FILE SHA256: FILE is what was published, or the install s [ "$(sha256 "$1")" = "$2" ] || { echo "$(basename "$1") doesn't match its published checksum" >&2; exit 1; } } quoted() { printf "'%s'" "$(printf %s "$1" | sed "s/'/'\\\\''/g")"; } +r=$(quoted "$root") # $root, as the commands run in the target read it # Beside this script, in the work dir the sandbox's teardown removes, so a killed install leaves nothing elsewhere. stage=$(mktemp -d "$here/../stage.XXXXXX") @@ -91,30 +92,30 @@ chmod 0755 "$stage/claude" "$stage/uv-$uv_cpu-unknown-linux-musl/uv" printf '%s=%s\n' "$name" "$(quoted "${!name:-}")" done [ -z "${AGENT_TRUST_ENV:-}" ] || printf '%s\n' "$AGENT_TRUST_ENV" - printf 'A2A_PORT=%s\nCLAUDE_CONFIG_DIR=%s\n' "$port" "$root/claude" + printf 'A2A_PORT=%s\nCLAUDE_CONFIG_DIR=%s\n' "$(quoted "$port")" "$(quoted "$root/claude")" printf 'DISABLE_AUTOUPDATER=1\nCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1\n' } > "$stage/agent.env" -on "mkdir -p '$root/bin' '$root/app' '$root/claude'" +on "mkdir -p $r/bin $r/app $r/claude" put "$stage/claude" "$root/bin/claude" put "$stage/uv-$uv_cpu-unknown-linux-musl/uv" "$root/bin/uv" put "$stage/agent.env" "$root/agent.env" for file in $AGENT_FILES; do put "$here/$file" "$root/app/$file" done -on "chmod 0600 '$root/agent.env' && chmod 0755 '$root/bin/claude' '$root/bin/uv' '$root/app/run.sh'" +on "chmod 0600 $r/agent.env && chmod 0755 $r/bin/claude $r/bin/uv $r/app/run.sh" # A private Python, uv's own build of it and never the target's, with the agent's requirements; no cache is left behind. -on "cd '$root' && export UV_PYTHON_PREFERENCE=only-managed UV_PYTHON_INSTALL_DIR='$root/python' \ - UV_CACHE_DIR='$root/cache' && bin/uv venv --quiet --python $PYTHON_VERSION venv \ +on "cd $r && export UV_PYTHON_PREFERENCE=only-managed UV_PYTHON_INSTALL_DIR=$r/python \ + UV_CACHE_DIR=$r/cache && bin/uv venv --quiet --python $PYTHON_VERSION venv \ && bin/uv pip install --quiet --python venv/bin/python -r app/requirements.txt \ - && rm -rf '$root/cache'" + && rm -rf $r/cache" # An earlier install's agent, if there is one, gives up its port. -on "[ -f '$root/agent.pid' ] && kill \$(cat '$root/agent.pid') 2>/dev/null; rm -f '$root/agent.pid'; true" +on "[ -f $r/agent.pid ] && kill \$(cat $r/agent.pid) 2>/dev/null; rm -f $r/agent.pid; true" if [ "$mode" = container ]; then - docker exec -d -u 0 "$target" sh -c "exec '$root/app/run.sh' '$root' > '$root/agent.log' 2>&1" + docker exec -d -u 0 "$target" sh -c "exec $r/app/run.sh $r > $r/agent.log 2>&1" else mkdir -p "$workdir" cd "$workdir" @@ -124,10 +125,10 @@ fi # Done once the agent answers on its port; an agent that exits first fails the install with the end of its log. health="import urllib.request; urllib.request.urlopen('http://127.0.0.1:$port/health', timeout=2)" for _ in $(seq "$START_TIMEOUT_S"); do - on "'$root/venv/bin/python' -c \"$health\"" 2> /dev/null && exit 0 - on "[ ! -f '$root/agent.pid' ] || kill -0 \$(cat '$root/agent.pid')" 2> /dev/null || break + on "$r/venv/bin/python -c \"$health\"" 2> /dev/null && exit 0 + on "[ ! -f $r/agent.pid ] || kill -0 \$(cat $r/agent.pid)" 2> /dev/null || break sleep 1 done echo "the agent didn't start; the end of its log:" >&2 -on "tail -n 40 '$root/agent.log'" >&2 || true +on "tail -n 40 $r/agent.log" >&2 || true exit 1 diff --git a/tst/integration/cli/run_bundle_claude_code_install_test.py b/tst/integration/cli/run_bundle_claude_code_install_test.py index 7a4b642f..7aa74dbe 100644 --- a/tst/integration/cli/run_bundle_claude_code_install_test.py +++ b/tst/integration/cli/run_bundle_claude_code_install_test.py @@ -24,6 +24,7 @@ TASK_IMAGES = Path(__file__).resolve().parents[2] / "data" / "claude_code_install" NOTES = "notes-from-the-task" +A2A_PORT = 8000 SKILL_BODY = "Read the task's notes first." SKILL_MD = f"---\nname: notes\ndescription: Where the task's notes are.\n---\n{SKILL_BODY}\n" # The installed skill's last line, as the CLI's shell finds it. @@ -65,31 +66,42 @@ def state(monkeypatch, tmp_path, endpoint): reset_config() -def _bundle(root: Path, task_image: str | None) -> Path: - """A task that installs the default agent, into a container built from ``task_image`` or onto the host, gives it - a skill, and asks it to read the task's notes.""" +def _slug(name: str) -> str: + return name.replace(" ", "-") + + +def _bundle(root: Path, task_image: str | None, agents: tuple[str, ...] = ("coder",)) -> Path: + """A task that installs the default agent as each of ``agents``, on ports from 8000, into a container built from + ``task_image`` or onto the host, then gives each a skill and asks it to read the task's notes, in turn.""" + ports = [A2A_PORT + i for i in range(len(agents))] steps = [{"id": "box", "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "vm", - "sandbox_type": "local", "exposed_ports": [8000]}] - install = {"id": "coder", "type": "install_agent", "sandbox_name": "box", "agent_name": "coder"} + "sandbox_type": "local", "exposed_ports": ports}] + after = ["box"] if task_image is not None: shutil.copytree(TASK_IMAGES / task_image, root / "artifacts" / "task-image") steps.append({"id": "repo", "type": "run_docker_container", "sandbox_name": "box", "container_name": "repo", - "docker_context_artifact_id": "task-image", "ports": [8000], "depends_on": ["box"]}) - install |= {"container_name": "repo", "depends_on": ["repo"]} - else: - install["depends_on"] = ["box"] + "docker_context_artifact_id": "task-image", "ports": ports, "depends_on": after}) + after = ["repo"] + for name, port in zip(agents, ports): + install = {"id": f"install-{_slug(name)}", "type": "install_agent", "sandbox_name": "box", "agent_name": name, + "a2a_port": port, "depends_on": after} + steps.append(install | ({"container_name": "repo"} if task_image is not None else {})) + after = [install["id"]] (root / "skills/notes").mkdir(parents=True) (root / "skills/notes/SKILL.md").write_text(SKILL_MD) - steps += [ - install, - {"id": "skill", "type": "add_skills", "agent_name": "coder", "skills": [{"skill_artifact_id": "notes"}], - "depends_on": ["coder"]}, - {"id": "ask", "type": "prompt_agent", "agent_name": "coder", "prompt_id": "ask", "depends_on": ["skill"], - "prompt": "What do the task's notes say?"}, - {"id": "check", "type": "agent_prompt_response_verifier", "prompt_id": "ask", "verifier_id": "read", - "criteria": [{"type": "response_contains", "needles": [NOTES]}], "depends_on": ["ask"]}, - {"id": "down", "type": "teardown_sandboxes", "sandbox_names": ["box"], "depends_on": ["check"]}, - ] + for name in agents: + slug = _slug(name) + steps += [ + {"id": f"skill-{slug}", "type": "add_skills", "agent_name": name, + "skills": [{"skill_artifact_id": "notes"}], "depends_on": after}, + {"id": f"ask-{slug}", "type": "prompt_agent", "agent_name": name, "prompt_id": f"ask-{slug}", + "depends_on": [f"skill-{slug}"], "prompt": "What do the task's notes say?"}, + {"id": f"check-{slug}", "type": "agent_prompt_response_verifier", "prompt_id": f"ask-{slug}", + "verifier_id": f"read-{slug}", "criteria": [{"type": "response_contains", "needles": [NOTES]}], + "depends_on": [f"ask-{slug}"]}, + ] + after = [f"check-{slug}"] + steps.append({"id": "down", "type": "teardown_sandboxes", "sandbox_names": ["box"], "depends_on": after}) (root / "tasks").mkdir() (root / "tasks/install.json").write_text(json.dumps(steps)) return root @@ -107,6 +119,17 @@ def test_install_agent_naming_no_agent_installs_claude_code_into_a_task_containe assert endpoint.tool_results() == [f"{NOTES}\n/workspace/task_repo\n{SKILL_BODY}"], seen +def test_two_agents_installed_into_one_container_each_keep_their_own_install(state, endpoint): + """The second install leaves the first agent running: the first is asked after the second is installed.""" + endpoint.respond = tool_then_answer("Bash", {"command": f"cat notes.txt; pwd; {READ_SKILL}"}) + + result = CliRunner().invoke(cli, ["run", str(_bundle(state / "bundle", "debian", agents=("coder", "helper")))]) + + seen = (result.output, endpoint.tool_results()) + assert result.exit_code == 0 and "tasks/install.json v1: passed" in result.output, seen + assert endpoint.tool_results() == [f"{NOTES}\n/workspace/task_repo\n{SKILL_BODY}"] * 2, seen + + @pytest.mark.skipif(platform.system() != "Linux", reason="a host install needs a Linux host") def test_install_agent_with_no_container_installs_claude_code_onto_the_sandboxs_host(monkeypatch, state, endpoint): # A process on the host can't resolve host.docker.internal: the grants name the bridge address, which it reaches. @@ -121,7 +144,8 @@ def test_install_agent_with_no_container_installs_claude_code_onto_the_sandboxs_ endpoint.respond = tool_then_answer("Bash", {"command": f"echo {NOTES}; {READ_SKILL}"}) try: - result = CliRunner().invoke(cli, ["run", str(_bundle(state / "bundle", None))]) + # A name with a space, which the install's paths carry. + result = CliRunner().invoke(cli, ["run", str(_bundle(state / "bundle", None, agents=("code helper",)))]) finally: grant_server(bridge, bridge).close() From d778f4386ad8fe12e5a34f8672e544eb9e4439e5 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 14:19:53 -0700 Subject: [PATCH 12/24] fix(agents): a retried install replaces the agent an earlier attempt left in the container A step retried from install_agent installs again into the same container and port, where the first attempt's agent still runs. uv refused the existing venv, so the retry failed. Now the old agent stops first, freeing its port, files are replaced by a rename so a binary it still runs can be swapped, the venv is recreated, and the CLI's state starts afresh, so the retry's skills install again. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/agents/claude-code/install.sh | 19 +++++++++------ .../run_bundle_claude_code_install_test.py | 23 ++++++++++++++++++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/agent_env/examples/agents/agents/claude-code/install.sh b/src/agent_env/examples/agents/agents/claude-code/install.sh index d6af51c9..5130d28b 100755 --- a/src/agent_env/examples/agents/agents/claude-code/install.sh +++ b/src/agent_env/examples/agents/agents/claude-code/install.sh @@ -20,22 +20,24 @@ UV_SHA256_X86_64=6c86040a8f045ab9adc9e3ec4cdafa03fd00e9a988ed2bf28971c7fc2d0debd UV_SHA256_AARCH64=345a8308e4d1835cdfaadf692836fd3137a6d45864773f05ad7bf59442b1ba16 PYTHON_VERSION=3.12 START_TIMEOUT_S=120 +STOP_TIMEOUT_S=10 CLAUDE_CODE_VERSION=$(sed -n 's/^ARG CLAUDE_CODE_VERSION=//p' "$here/Dockerfile") AGENT_FILES="a2a_server.py claude_bridge.py kit.py requirements.txt run.sh" +# put SRC DEST replaces DEST by a rename, so a binary an earlier install's agent still runs can be replaced. case $mode in container) root=/opt/agentenv-agent-$port # one per agent: two in a container listen on two ports workdir= on() { docker exec -u 0 "$target" sh -c "$1"; } - put() { docker cp "$1" "$target:$2" > /dev/null; } + put() { docker cp "$1" "$target:$2.new" > /dev/null && on "mv -f $(quoted "$2.new") $(quoted "$2")"; } ;; host) [ "$(uname -s)" = Linux ] || { echo "a host install needs a Linux host, not $(uname -s)" >&2; exit 1; } root=$here/../agent-$target workdir=$4 on() { sh -c "$1"; } - put() { cp "$1" "$2"; } + put() { cp "$1" "$2.new" && mv -f "$2.new" "$2"; } ;; *) echo "usage: install.sh container NAME PORT | install.sh host NAME PORT WORKDIR" >&2; exit 2 ;; esac @@ -96,7 +98,13 @@ chmod 0755 "$stage/claude" "$stage/uv-$uv_cpu-unknown-linux-musl/uv" printf 'DISABLE_AUTOUPDATER=1\nCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1\n' } > "$stage/agent.env" -on "mkdir -p $r/bin $r/app $r/claude" +# An earlier install's agent, a retried step's say, stops first: its files are replaced and its port is freed. +on "if [ -f $r/agent.pid ]; then pid=\$(cat $r/agent.pid); kill \$pid 2> /dev/null; i=0; \ + while [ \$i -lt $STOP_TIMEOUT_S ] && kill -0 \$pid 2> /dev/null; do sleep 1; i=\$((i + 1)); done; \ + kill -9 \$pid 2> /dev/null; rm -f $r/agent.pid; fi; true" + +# The agent starts afresh, with no skills or sessions of one it replaces. +on "rm -rf $r/claude && mkdir -p $r/bin $r/app $r/claude" put "$stage/claude" "$root/bin/claude" put "$stage/uv-$uv_cpu-unknown-linux-musl/uv" "$root/bin/uv" put "$stage/agent.env" "$root/agent.env" @@ -107,13 +115,10 @@ on "chmod 0600 $r/agent.env && chmod 0755 $r/bin/claude $r/bin/uv $r/app/run.sh" # A private Python, uv's own build of it and never the target's, with the agent's requirements; no cache is left behind. on "cd $r && export UV_PYTHON_PREFERENCE=only-managed UV_PYTHON_INSTALL_DIR=$r/python \ - UV_CACHE_DIR=$r/cache && bin/uv venv --quiet --python $PYTHON_VERSION venv \ + UV_CACHE_DIR=$r/cache && bin/uv venv --quiet --clear --python $PYTHON_VERSION venv \ && bin/uv pip install --quiet --python venv/bin/python -r app/requirements.txt \ && rm -rf $r/cache" -# An earlier install's agent, if there is one, gives up its port. -on "[ -f $r/agent.pid ] && kill \$(cat $r/agent.pid) 2>/dev/null; rm -f $r/agent.pid; true" - if [ "$mode" = container ]; then docker exec -d -u 0 "$target" sh -c "exec $r/app/run.sh $r > $r/agent.log 2>&1" else diff --git a/tst/integration/cli/run_bundle_claude_code_install_test.py b/tst/integration/cli/run_bundle_claude_code_install_test.py index 7aa74dbe..5ccd759a 100644 --- a/tst/integration/cli/run_bundle_claude_code_install_test.py +++ b/tst/integration/cli/run_bundle_claude_code_install_test.py @@ -18,7 +18,7 @@ from agent_env.config import configure, reset_config from agent_env.providers.sandbox_providers.local_sandbox import _host_ips from agent_env.store.object_store.local.grant_server import grant_server -from tst.util.fake_anthropic import FakeAnthropic, tool_then_answer +from tst.util.fake_anthropic import FakeAnthropic, Fault, tool_then_answer pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -130,6 +130,27 @@ def test_two_agents_installed_into_one_container_each_keep_their_own_install(sta assert endpoint.tool_results() == [f"{NOTES}\n/workspace/task_repo\n{SKILL_BODY}"] * 2, seen +def test_a_retried_install_replaces_the_agent_an_earlier_attempt_left_running_in_the_container(state, endpoint): + """The first turn fails, and its retry installs again into the same container, on the same port, while the + first install's agent still runs there.""" + endpoint.respond = tool_then_answer("Bash", {"command": f"cat notes.txt; pwd; {READ_SKILL}"}) + endpoint.fail_next(Fault("status", status=400)) + bundle = _bundle(state / "bundle", "debian") + task = bundle / "tasks/install.json" + steps = json.loads(task.read_text()) + for step in steps: + if step["id"] == "ask-coder": + step["retry_config"] = {"retry_from_step_id": "install-coder", "max_retries": 1} + task.write_text(json.dumps(steps)) + + result = CliRunner().invoke(cli, ["run", str(bundle)]) + + seen = (result.output, endpoint.tool_results()) + assert result.exit_code == 0 and "tasks/install.json v1: passed" in result.output, seen + assert result.output.count("install-coder (install_agent)") == 2, seen + assert endpoint.tool_results() == [f"{NOTES}\n/workspace/task_repo\n{SKILL_BODY}"], seen + + @pytest.mark.skipif(platform.system() != "Linux", reason="a host install needs a Linux host") def test_install_agent_with_no_container_installs_claude_code_onto_the_sandboxs_host(monkeypatch, state, endpoint): # A process on the host can't resolve host.docker.internal: the grants name the bridge address, which it reaches. From 245cead8499968e1e4186d4454ce385880b661e4 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 14:27:28 -0700 Subject: [PATCH 13/24] fix(agents): the CLI keeps LITELLM_API_KEY, so the model's scripts can call the endpoint The key was dropped from the CLI's environment once mapped to ANTHROPIC_AUTH_TOKEN, and LITELLM_EXTRA_HEADERS with it, so a script the model ran could no longer reach the gateway as $LITELLM_API_KEY. The CLI's own tools can read the bearer token anyway, so dropping them protected nothing. Both are kept. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/examples/agents/README.md | 3 ++- .../agents/agents/claude-code/claude_bridge.py | 9 ++++----- tst/unit/examples/claude_code/test_cli_env.py | 10 +++++++++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/agent_env/examples/agents/README.md b/src/agent_env/examples/agents/README.md index 9ef0d339..c9dd3e16 100644 --- a/src/agent_env/examples/agents/README.md +++ b/src/agent_env/examples/agents/README.md @@ -7,7 +7,8 @@ from another bundle as `@local/agentenv-framework/agents/claude-code`. It needs Docker, and a model endpoint serving Anthropic's Messages API, `/v1/messages`, which a LiteLLM gateway does: the `[model]` base URL and key reach it as `ANTHROPIC_BASE_URL` and a bearer token, and `[model.headers]` are sent -on every model call. Building the image downloads Claude Code, which is Anthropic's software under its +on every model call. The `LITELLM_*` variables stay in its environment too, so a script the model runs can call the +endpoint. Building the image downloads Claude Code, which is Anthropic's software under its [Commercial Terms](https://www.anthropic.com/legal/commercial-terms); the image is built where it runs and never published. The CLI's version is the Dockerfile's `CLAUDE_CODE_VERSION`. diff --git a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py index 9e85857b..a93a05c0 100644 --- a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py +++ b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py @@ -143,8 +143,9 @@ def _model_headers(raw: str | None) -> dict[str, str]: def cli_env(environ: Mapping[str, str], *, model: str | None = None, project_id: str | None = None, task_id: str | None = None, max_thinking_tokens: int | None = None) -> dict[str, str]: """The CLI's environment: this process's, pointed at the endpoint agent-env configured (its key as a bearer - token, so an API key too would conflict, and no longer as LITELLM_API_KEY), with the endpoint's headers and the - run's attribution, and the model's aliases pinned to ``model`` on that endpoint. A variable already set is kept.""" + token, so an API key too would conflict), with the endpoint's headers and the run's attribution, and the model's + aliases pinned to ``model`` on that endpoint. A variable already set is kept, LITELLM_* too, so a script the model + runs can call the endpoint.""" env = dict(environ) if not env.get("ANTHROPIC_AUTH_TOKEN") and env.get("LITELLM_API_KEY"): env["ANTHROPIC_AUTH_TOKEN"] = env["LITELLM_API_KEY"] @@ -155,9 +156,7 @@ def cli_env(environ: Mapping[str, str], *, model: str | None = None, project_id: if model and env.get("ANTHROPIC_BASE_URL"): for alias in _MODEL_ALIASES: env.setdefault(alias, model) - headers = {**_model_headers(env.pop(MODEL_HEADERS_ENV, None)), **_attribution_headers(project_id, task_id)} - # Mapped to Anthropic's variables above, so the CLI's tools don't get the key twice. - env.pop("LITELLM_API_KEY", None) + headers = {**_model_headers(env.get(MODEL_HEADERS_ENV)), **_attribution_headers(project_id, task_id)} if headers: env["ANTHROPIC_CUSTOM_HEADERS"] = "\n".join(f"{name}: {value}" for name, value in headers.items()) if max_thinking_tokens: diff --git a/tst/unit/examples/claude_code/test_cli_env.py b/tst/unit/examples/claude_code/test_cli_env.py index 029b78cd..feb855f6 100644 --- a/tst/unit/examples/claude_code/test_cli_env.py +++ b/tst/unit/examples/claude_code/test_cli_env.py @@ -23,10 +23,18 @@ def test_the_configured_endpoint_is_the_clis_with_its_key_as_a_bearer_token(): assert (env["ANTHROPIC_AUTH_TOKEN"], env["ANTHROPIC_BASE_URL"], env["ANTHROPIC_API_KEY"]) == ( "gateway-key", "https://gateway.example.com", "") - assert "LITELLM_API_KEY" not in env and "LITELLM_EXTRA_HEADERS" not in env assert env["CLAUDE_CODE_ATTRIBUTION_HEADER"] == "0" +def test_a_script_the_model_runs_can_call_the_endpoint_with_the_litellm_variables(): + headers = json.dumps({"X-Bill-To": "team-a"}) + + env = claude_bridge.cli_env({**GATEWAY, "LITELLM_EXTRA_HEADERS": headers}) + + kept = {name: env[name] for name in (*GATEWAY, "LITELLM_EXTRA_HEADERS")} + assert kept == {**GATEWAY, "LITELLM_EXTRA_HEADERS": headers} + + def test_anthropic_variables_already_set_are_kept(): env = claude_bridge.cli_env({**GATEWAY, "ANTHROPIC_AUTH_TOKEN": "own", "ANTHROPIC_BASE_URL": "https://own.example"}) From 87fbe1db954126e064c0c473cd6ced425a48f8f2 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 14:41:12 -0700 Subject: [PATCH 14/24] feat(agents): the image gives the model's scripts node and a pinned boto3 The scripts the model runs had no node, so a coding task that runs JavaScript broke, and Python without boto3. node comes alone, without npm or npx, from a pinned node:22-slim; boto3 is pinned in model-requirements.txt, which the image installs beside the agent's own requirements. The image grows from 192 MB to 254 MB and builds 5 to 6 s slower. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/examples/agents/README.md | 3 ++- .../agents/agents/claude-code/Dockerfile | 8 ++++++-- .../agents/claude-code/model-requirements.txt | 3 +++ .../claude_code/test_image_contract.py | 19 ++++++++++++++++++- 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 src/agent_env/examples/agents/agents/claude-code/model-requirements.txt diff --git a/src/agent_env/examples/agents/README.md b/src/agent_env/examples/agents/README.md index c9dd3e16..041ab57a 100644 --- a/src/agent_env/examples/agents/README.md +++ b/src/agent_env/examples/agents/README.md @@ -10,7 +10,8 @@ does: the `[model]` base URL and key reach it as `ANTHROPIC_BASE_URL` and a bear on every model call. The `LITELLM_*` variables stay in its environment too, so a script the model runs can call the endpoint. Building the image downloads Claude Code, which is Anthropic's software under its [Commercial Terms](https://www.anthropic.com/legal/commercial-terms); the image is built where it runs and never -published. The CLI's version is the Dockerfile's `CLAUDE_CODE_VERSION`. +published. The CLI's version is the Dockerfile's `CLAUDE_CODE_VERSION`. The image gives the scripts the model runs +`node`, without npm or npx, and the Python packages `model-requirements.txt` pins. `install_agent` installs it too, into a container a task already runs or onto a `vm` sandbox's Linux host, with `install.sh` from its folder: Claude Code's native build for the target's CPU and libc, uv, and the agent's requirements diff --git a/src/agent_env/examples/agents/agents/claude-code/Dockerfile b/src/agent_env/examples/agents/agents/claude-code/Dockerfile index 27dc20d6..bfe40329 100644 --- a/src/agent_env/examples/agents/agents/claude-code/Dockerfile +++ b/src/agent_env/examples/agents/agents/claude-code/Dockerfile @@ -1,3 +1,6 @@ +# Node for the scripts the model runs, without npm or npx; Claude Code itself is a native build. +FROM public.ecr.aws/docker/library/node:22-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 AS node + FROM public.ecr.aws/docker/library/python:3.11-slim@sha256:c20888b6acdd1e63e1c433a185bf3ad162c0288fe484616ce062e0d28add2900 # poppler-utils: the CLI's Read tool runs pdftoppm for a PDF's pages. RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl poppler-utils \ @@ -15,9 +18,10 @@ RUN set -eu; \ echo "$checksum /usr/local/bin/claude" | sha256sum -c -; \ chmod 0755 /usr/local/bin/claude; \ rm /tmp/manifest.json +COPY --from=node /usr/local/bin/node /usr/local/bin/node WORKDIR /opt/agent -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt +COPY requirements.txt model-requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt -r model-requirements.txt # install.sh and run.sh are install/v1's: in the image, so they stay in its build context. COPY a2a_server.py claude_bridge.py kit.py entrypoint.sh install.sh run.sh ./ # The CLI's cwd, /app, and its state, fixed so neither follows the uid: Modal runs the image as root. diff --git a/src/agent_env/examples/agents/agents/claude-code/model-requirements.txt b/src/agent_env/examples/agents/agents/claude-code/model-requirements.txt new file mode 100644 index 00000000..150d9797 --- /dev/null +++ b/src/agent_env/examples/agents/agents/claude-code/model-requirements.txt @@ -0,0 +1,3 @@ +# What the image gives the Python the model runs, beside the agent's own requirements; an installed agent's model +# uses the task container's Python instead. +boto3==1.43.115 diff --git a/tst/unit/examples/claude_code/test_image_contract.py b/tst/unit/examples/claude_code/test_image_contract.py index bea5361c..09a89f5f 100644 --- a/tst/unit/examples/claude_code/test_image_contract.py +++ b/tst/unit/examples/claude_code/test_image_contract.py @@ -11,7 +11,8 @@ def test_every_file_the_image_copies_is_in_the_folder(): - copied = [name for line in re.findall(r"^COPY (.+) \S+$", DOCKERFILE, re.MULTILINE) for name in line.split()] + copied = [name for line in re.findall(r"^COPY (?!--from=)(.+) \S+$", DOCKERFILE, re.MULTILINE) + for name in line.split()] assert copied and all((AGENT_DIR / name).is_file() for name in copied), copied # install.sh and run.sh are install/v1's; a build context pruned to what the Dockerfile reads keeps them so. @@ -25,6 +26,22 @@ def test_the_image_installs_the_protocol_alone_from_its_requirements(): assert requirements == ["agentenv-framework-protocol[agent]"] +def test_the_image_gives_the_model_node_alone_from_a_pinned_image(): + assert re.search(r"^FROM public\.ecr\.aws/docker/library/node:\d+-slim@sha256:[0-9a-f]{64} AS node$", DOCKERFILE, + re.MULTILINE) + copied = re.findall(r"^COPY --from=node (.+)$", DOCKERFILE, re.MULTILINE) + assert copied == ["/usr/local/bin/node /usr/local/bin/node"] + + +def test_the_packages_the_image_gives_the_models_python_are_pinned(): + packages = [line for line in (AGENT_DIR / "model-requirements.txt").read_text().splitlines() + if line and not line.startswith("#")] + + assert packages and all(re.fullmatch(r"[A-Za-z0-9_.-]+==[0-9][0-9A-Za-z.]*", line) for line in packages), packages + assert any(line.startswith("boto3==") for line in packages) + assert "-r requirements.txt -r model-requirements.txt" in DOCKERFILE + + def test_the_image_pins_its_cli_checks_its_checksum_and_keeps_tls_on(): assert re.search(r"^ARG CLAUDE_CODE_VERSION=\d+\.\d+\.\d+$", DOCKERFILE, re.MULTILINE) assert "sha256sum -c" in DOCKERFILE From 0b75d6ac49b25d151ec7b3887f7b713fff536147 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 16:24:47 -0700 Subject: [PATCH 15/24] test(agents): a project id posted over agent-config attributes every model call A host attributes an agent's spend by posting the run's project_id over agent-config before each prompting step. With the real CLI against the fake endpoint, every model call that follows carries it as x-litellm-customer-id, and the project and task as x-litellm-tags. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/run_bundle_claude_code_test.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tst/integration/cli/run_bundle_claude_code_test.py b/tst/integration/cli/run_bundle_claude_code_test.py index 4df70345..efca061c 100644 --- a/tst/integration/cli/run_bundle_claude_code_test.py +++ b/tst/integration/cli/run_bundle_claude_code_test.py @@ -2,17 +2,24 @@ built from the shipped folder with real docker, the real CLI, and a fake Anthropic Messages endpoint as the configured model endpoint, so no model key is needed. Needs a Docker daemon and network access for the image's downloads.""" +import asyncio import json import logging import pytest from click.testing import CliRunner +from agent_env.a2a_agent import A2AAgent, protocol from agent_env.artifact.store import reset_artifact_store from agent_env.cli import cli from agent_env.config import configure, reset_config from agent_env.providers.sandbox_providers.local_sandbox import _host_ips from agent_env.store.object_store.local.grant_server import grant_server +from agent_env.store.routing import namespace_routing +from agent_env.task.teardown import teardown_run +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep +from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep from tst.util.fake_anthropic import FakeAnthropic, answer, tool_then_answer pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -117,3 +124,41 @@ def test_the_shipped_claude_code_calls_the_tools_of_the_env_it_is_deployed_with( # The fake never repeats the prompt, so the text reached the answer through the tool. assert any(name.endswith("__echo") for name in offered), seen assert ["ping-from-tool" in result for result in endpoint.tool_results()] == [True], seen + + +async def _send_project_id(step, context): + """What a host's runner does before each step to attribute an agent's spend to the run's project: post the + run's project_id over agent-config to the agent a prompting step names, when its card takes it.""" + project_id = context.metadata.get("project_id") + agent = next((a for a in context.deployed_agents if a.agent_name == getattr(step, "agent_name", None)), None) + if step.type != "prompt_agent" or not project_id or agent is None: + return + negotiated = A2AAgent.negotiate_agent_config(agent.a2a_card or {}, {"project_id": project_id}) + assert negotiated and "project_id" in negotiated.fields, "the card doesn't take project_id over agent-config" + await protocol.post_agent_config((agent.a2a_url or agent.api_url) + negotiated.endpoint, negotiated.fields) + + +def test_a_project_id_posted_over_agent_config_attributes_every_model_call_to_the_project(state, endpoint): + endpoint.respond = answer("pong") + # A bundle run writes the shipped agent and its image to this test's stores. + assert _run(_bundle(state / "one", "ask", _ask("Say pong.", "pong"))).exit_code == 0 + before = len(endpoint.messages()) + context = TaskStepContext(metadata={"project_id": "project-attr-1", "task_id": "task-attr-1"}) + steps = [DeployAgentTaskStep(id="agent", version=None, sandbox_type="local"), + PromptAgentTaskStep(id="ask", version=None, prompt_id="ask", prompt="Say pong.")] + + async def run(): + try: + for step in steps: + await _send_project_id(step, context) + await step.execute(context) + finally: + await teardown_run(context) + + with namespace_routing(): + asyncio.run(run()) + + calls = endpoint.messages()[before:] + assert calls, "the CLI never called the endpoint" + assert {call.headers.get("x-litellm-customer-id") for call in calls} == {"project-attr-1"} + assert {call.headers.get("x-litellm-tags") for call in calls} == {"projectId:project-attr-1,taskId:task-attr-1"} From ba255c1fac4034aaaadecfa0b959020fe8889054 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 20:24:57 -0700 Subject: [PATCH 16/24] test(agents): a file part naming a stored object reaches the CLI through a grant core sends an object one of its stores owns as an https grant, and the agent downloads it into the task's inputs, where the CLI reads it. That's now proven end to end with the real CLI, and the agent's own https download, a failed one that keeps the URL's signature out of the error, and its refusal of any other scheme have unit tests. That refusal now says how a stored object arrives. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../examples/agents/agents/claude-code/kit.py | 4 +- .../cli/run_bundle_claude_code_test.py | 31 +++++++++++- .../claude_code/test_message_contract.py | 47 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/agent_env/examples/agents/agents/claude-code/kit.py b/src/agent_env/examples/agents/agents/claude-code/kit.py index c03b3d59..17eef298 100644 --- a/src/agent_env/examples/agents/agents/claude-code/kit.py +++ b/src/agent_env/examples/agents/agents/claude-code/kit.py @@ -178,7 +178,9 @@ def _safe_name(candidate: str | None) -> str: def _download_uri_to(uri: str, target: Path) -> None: scheme = urlsplit(uri).scheme.lower() if scheme not in ("http", "https"): - raise FilePartMaterializeError(f"unsupported URI scheme {scheme!r} in {_shown(uri)}") + raise FilePartMaterializeError( + f"unsupported URI scheme {scheme!r} in {_shown(uri)}: a file part reaches this agent as bytes or an " + "http(s) URL, and agent-env sends an object its stores own as an https one") try: with _redacting_logged_urls(), httpx.Client(timeout=_FILEPART_DOWNLOAD_TIMEOUT_S, follow_redirects=True) as client: response = client.get(uri) diff --git a/tst/integration/cli/run_bundle_claude_code_test.py b/tst/integration/cli/run_bundle_claude_code_test.py index efca061c..3fc5f5a9 100644 --- a/tst/integration/cli/run_bundle_claude_code_test.py +++ b/tst/integration/cli/run_bundle_claude_code_test.py @@ -5,6 +5,7 @@ import asyncio import json import logging +import uuid import pytest from click.testing import CliRunner @@ -12,7 +13,7 @@ from agent_env.a2a_agent import A2AAgent, protocol from agent_env.artifact.store import reset_artifact_store from agent_env.cli import cli -from agent_env.config import configure, reset_config +from agent_env.config import configure, get_config, reset_config from agent_env.providers.sandbox_providers.local_sandbox import _host_ips from agent_env.store.object_store.local.grant_server import grant_server from agent_env.store.routing import namespace_routing @@ -162,3 +163,31 @@ async def run(): assert calls, "the CLI never called the endpoint" assert {call.headers.get("x-litellm-customer-id") for call in calls} == {"project-attr-1"} assert {call.headers.get("x-litellm-tags") for call in calls} == {"projectId:project-attr-1,taskId:task-attr-1"} + + +def test_a_file_part_naming_a_stored_object_reaches_the_cli_through_a_grant(state, endpoint): + endpoint.respond = answer("pong") + # A bundle run writes the shipped agent and its image to this test's stores. + assert _run(_bundle(state / "one", "ask", _ask("Say pong.", "pong"))).exit_code == 0 + notes = "notes-from-a-stored-object" + uri = get_config().get_object_store().put(f"file-parts/{uuid.uuid4().hex}/notes.txt", notes.encode(), "text/plain") + # The agent reads bytes or http(s) only, so it can read this object only through the grant core sends instead. + assert not uri.startswith(("http://", "https://")) + endpoint.respond = tool_then_answer("Bash", {"command": "cat inputs/*/*"}) + context = TaskStepContext() + steps = [DeployAgentTaskStep(id="agent", version=None, sandbox_type="local"), + PromptAgentTaskStep(id="ask", version=None, prompt_id="ask", parts=[ + {"kind": "text", "text": "Read the attached notes."}, + {"kind": "file", "file": {"name": "notes.txt", "mimeType": "text/plain", "uri": uri}}])] + + async def run(): + try: + for step in steps: + await step.execute(context) + finally: + await teardown_run(context) + + with namespace_routing(): + asyncio.run(run()) + + assert endpoint.tool_results() == [notes] diff --git a/tst/unit/examples/claude_code/test_message_contract.py b/tst/unit/examples/claude_code/test_message_contract.py index 0c380892..6b02921f 100644 --- a/tst/unit/examples/claude_code/test_message_contract.py +++ b/tst/unit/examples/claude_code/test_message_contract.py @@ -5,6 +5,7 @@ import base64 +import httpx import pytest from agentenv_protocol.a2a_agent import AGENT_CONFIG_V1 @@ -213,3 +214,49 @@ async def _materialize(part, target_dir): send(client, [{"kind": "file", "file": {"name": "a.png", "mimeType": "image/png", "bytes": "cG5n"}}]) assert active == [True] and agent._active_contexts == set() + + +def _serving(monkeypatch, routes: dict[str, tuple[int, bytes]]) -> None: + """The agent's file-part downloads answered from ``routes``, a URL's status and body, with no network.""" + client_class = httpx.Client + + def answer(request: httpx.Request) -> httpx.Response: + status, body = routes[str(request.url)] + return httpx.Response(status, content=body) + + monkeypatch.setattr(kit.httpx, "Client", + lambda **kwargs: client_class(transport=httpx.MockTransport(answer), **kwargs)) + + +def test_a_file_part_naming_an_https_url_is_downloaded_where_the_cli_reads_it(monkeypatch, tmp_path, calls, client): + monkeypatch.setattr(a2a_server, "INPUTS_DIR", tmp_path / "inputs") + url = "https://store.example.com/bucket/report.pdf?X-Amz-Signature=sig-123" + _serving(monkeypatch, {url: (200, b"%PDF-1.7 report")}) + parts = [{"kind": "text", "text": "summarize"}, + {"kind": "file", "file": {"name": "report.pdf", "mimeType": "application/pdf", "uri": url}}] + + task = send(client, parts) + + (call,) = calls + path = tmp_path / "inputs" / task["id"] / "report.pdf" + assert call["prompt"] == f"summarize\n\n{path}" and path.read_bytes() == b"%PDF-1.7 report" + + +def test_a_failed_download_fails_the_turn_without_the_urls_signature(monkeypatch, calls, client): + url = "https://store.example.com/bucket/a.png?X-Amz-Signature=sig-123" + _serving(monkeypatch, {url: (403, b"denied")}) + parts = [{"kind": "file", "file": {"name": "a.png", "mimeType": "image/png", "uri": url}}] + + state, text, data = terminal(send(client, parts)) + + assert state == "failed" and "HTTP 403" in text and "sig-123" not in text + assert data[0]["error_code"] == "filepart_error" and calls == [] + + +def test_a_raw_store_uri_is_refused_saying_how_a_stored_object_arrives(calls, client): + parts = [{"kind": "file", "file": {"name": "a.png", "mimeType": "image/png", "uri": "s3://bucket/a.png"}}] + + state, text, _ = terminal(send(client, parts)) + + assert state == "failed" and text.startswith("unsupported URI scheme 's3' in s3://bucket/a.png") + assert "agent-env sends an object its stores own as an https one" in text and calls == [] From e957b6d34ca0cf88af778a35f98341626b050d5a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 09:28:09 -0700 Subject: [PATCH 17/24] fix(agents): a key an API error echoes stays out of what the agent streams, keeps and snapshots The pinned CLI writes a failed call's error text, which can echo the endpoint's key, to three places: an error `assistant` event and the error `result` on stdout, and an `isApiErrorMessage` record in its session transcript. The bridge redacted only the error it returned. Now: - the error events are redacted as they stream, before they're kept or reach the live trajectory; - the transcript's error records are redacted once each CLI attempt exits, and on every read of the transcript for a trajectory or a snapshot, which may happen while a turn runs; - the transcript directory is named for the workspace's real path, as the CLI names it, so a workspace reached through a symlink still finds its transcripts. A workspace holding a named pipe, socket or device now leaves it out of its archive. A snapshot that held one saved, but the load's data filter refused to restore it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/agents/claude-code/a2a_server.py | 30 ++++---- .../agents/claude-code/claude_bridge.py | 72 +++++++++++++++++-- .../test_claude_bridge_failure_message.py | 20 +++++- tst/unit/examples/claude_code/test_cli_env.py | 11 ++- .../claude_code/test_workspace_snapshot.py | 28 ++++++++ 5 files changed, 140 insertions(+), 21 deletions(-) diff --git a/src/agent_env/examples/agents/agents/claude-code/a2a_server.py b/src/agent_env/examples/agents/agents/claude-code/a2a_server.py index 3a060503..1ab90f1e 100644 --- a/src/agent_env/examples/agents/agents/claude-code/a2a_server.py +++ b/src/agent_env/examples/agents/agents/claude-code/a2a_server.py @@ -13,6 +13,7 @@ import os import posixpath import shutil +import stat import tarfile import tempfile import uuid @@ -59,6 +60,7 @@ SESSION_JSONL_DIR, WORKSPACE_DIR, read_partial_session_trajectory, + redact_session_records, run_claude, session_jsonl_path, ) @@ -148,10 +150,14 @@ def _restorable_link(path: Path, arcname: str) -> bool: def _add_workspace_entry(tar: tarfile.TarFile, path: Path, arcname: str, *, stage_source: bool) -> bool: try: - if path.is_symlink() and not _restorable_link(path, arcname): + mode = path.lstat().st_mode + if stat.S_ISLNK(mode) and not _restorable_link(path, arcname): return False except OSError: return False + # The load's data filter refuses pipes, sockets and devices, so leaving them out keeps the snapshot loadable. + if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode) or stat.S_ISLNK(mode)): + return False if not stage_source: try: tar.add(path, arcname=arcname, recursive=False) @@ -233,20 +239,20 @@ def _build_workspace_archive(archive_path: Path) -> tuple[int, int]: def _read_complete_session_jsonl(path: Path) -> bytes: - """The transcript's newline-complete records, while the CLI may still be appending.""" + """The transcript's newline-complete records, while the CLI may still be appending, its API error records + redacted.""" data = path.read_bytes() if not data: raise ValueError("session transcript is empty") - if data.endswith(b"\n"): - return data - complete, separator, tail = data.rpartition(b"\n") - if separator: - return complete + b"\n" - try: - json.loads(tail) - except (json.JSONDecodeError, UnicodeDecodeError) as exc: - raise ValueError("session transcript has no complete JSONL record") from exc - return tail + b"\n" + if not data.endswith(b"\n"): + complete, separator, tail = data.rpartition(b"\n") + if not separator: + try: + json.loads(tail) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError("session transcript has no complete JSONL record") from exc + data = (complete if separator else tail) + b"\n" + return redact_session_records(data) def _clear_directory(path: Path) -> None: diff --git a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py index a93a05c0..2030f700 100644 --- a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py +++ b/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py @@ -10,6 +10,7 @@ import os import re import signal +import stat import time from collections.abc import Callable, Mapping from dataclasses import dataclass, field, replace @@ -32,9 +33,9 @@ def _workspace() -> Path: def project_dir(workspace: Path) -> Path: - """Where the CLI keeps the transcripts of sessions run in ``workspace``: a directory named for it, each character - but a letter or digit as -.""" - return CLAUDE_STATE_DIR / "projects" / re.sub(r"[^A-Za-z0-9]", "-", str(workspace)) + """Where the CLI keeps the transcripts of sessions run in ``workspace``: a directory named for its real path, as + the CLI's working directory has it, each character but a letter or digit as -.""" + return CLAUDE_STATE_DIR / "projects" / re.sub(r"[^A-Za-z0-9]", "-", os.path.realpath(workspace)) WORKSPACE_DIR = _workspace() @@ -96,7 +97,7 @@ def read_partial_session_trajectory(session_id: str) -> tuple[list[dict], int]: if not isinstance(record, dict): continue session_lines += 1 - event = _session_record_to_stream_event(record) + event = _session_record_to_stream_event(_redact_values(record) if _is_api_error(record) else record) if event is not None: trajectory.append(event) return trajectory, session_lines @@ -278,6 +279,57 @@ def _redact(text: str) -> str: return _SECRET_PATTERN.sub("[redacted]", _HEADER_VALUE_PATTERN.sub(r"\1[redacted]'", text)) +def _is_api_error(record: dict) -> bool: + """A record of a failed call to the endpoint, whose text can echo its key: the stream's error `result` and + `assistant` events, and the transcript's error messages.""" + return bool(record.get("is_error") or record.get("is_api_error_message") or record.get("isApiErrorMessage")) + + +def _redact_values(value: object) -> object: + """``value`` with every string in it redacted.""" + if isinstance(value, str): + return _redact(value) + if isinstance(value, list): + return [_redact_values(item) for item in value] + if isinstance(value, dict): + return {key: _redact_values(item) for key, item in value.items()} + return value + + +def redact_session_records(jsonl: bytes) -> bytes: + """A session transcript with its API error records redacted, and every other line as it was.""" + lines = jsonl.split(b"\n") + for index, line in enumerate(lines): + if b"isApiErrorMessage" not in line: + continue + try: + record = json.loads(line) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + if isinstance(record, dict) and _is_api_error(record): + lines[index] = json.dumps(_redact_values(record), ensure_ascii=False, separators=(",", ":")).encode() + return b"\n".join(lines) + + +def scrub_session_transcript(session_id: str | None) -> None: + """Redact the API error records an exited CLI left in the session's transcript, which trajectories and snapshots + read.""" + if not session_id: + return + path = session_jsonl_path(session_id) + try: + data = path.read_bytes() + except FileNotFoundError: + return + scrubbed = redact_session_records(data) + if scrubbed == data: + return + staged = path.with_name(f".{path.name}.scrubbed") + staged.write_bytes(scrubbed) + os.chmod(staged, stat.S_IMODE(path.stat().st_mode)) + os.replace(staged, path) + + def _one_line(text: str) -> str: return _LINE_BREAK_PATTERN.sub(" ", text).strip() @@ -353,8 +405,13 @@ async def run_claude( attempts += 1 attempt_timeout_s = (timeout_s if attempts == 1 else max(int(deadline - time.monotonic()) - _DEADLINE_HEADROOM_S, 1)) - result = await spawn(prompt=attempt_prompt, session_id=attempt_session, resume=attempt_resume, - timeout_s=attempt_timeout_s) + try: + result = await spawn(prompt=attempt_prompt, session_id=attempt_session, resume=attempt_resume, + timeout_s=attempt_timeout_s) + finally: + await asyncio.to_thread(scrub_session_transcript, attempt_session) + if result.session_id != attempt_session: + await asyncio.to_thread(scrub_session_transcript, result.session_id) tool_calls += result.tool_call_count failure = _resumable_api_failure(result) @@ -575,6 +632,9 @@ def on_line(line: str) -> None: if not isinstance(event, dict): logger.warning(f"claude CLI emitted a non-object JSON line: {line[:_LOGGED_LINE_CHARS]}") return + if _is_api_error(event): + # A failure's text can echo the endpoint's key, and the event is kept and streamed like any other. + event = _redact_values(event) trajectory.append(event) etype = event.get("type") if etype == "system" and event.get("subtype") == "init": diff --git a/tst/unit/examples/claude_code/test_claude_bridge_failure_message.py b/tst/unit/examples/claude_code/test_claude_bridge_failure_message.py index 22e21365..c7b38a14 100644 --- a/tst/unit/examples/claude_code/test_claude_bridge_failure_message.py +++ b/tst/unit/examples/claude_code/test_claude_bridge_failure_message.py @@ -49,14 +49,14 @@ def run(monkeypatch, tmp_path): monkeypatch.delenv("CLAUDE_API_ERROR_RETRIES", raising=False) (tmp_path / "sess-1.jsonl").write_text('{"type":"assistant"}\n') - def _run(*invocations): + def _run(*invocations, **kwargs): queue = list(invocations) async def _exec(*cmd, **kwargs): return FakeCLI(*queue.pop(0)) monkeypatch.setattr(asyncio, "create_subprocess_exec", _exec) - return asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + return asyncio.run(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False, **kwargs)) return _run @@ -212,6 +212,22 @@ def test_a_key_in_the_cli_error_is_redacted_from_error_response_and_logs(run, ca assert _FAKE_KEY not in text +def test_a_key_a_failed_call_echoes_is_redacted_from_the_events_streamed_and_kept_and_from_the_transcript(run, tmp_path): + text = f"API Error: 400 Budget has been exceeded! Key={_FAKE_KEY}" + # The pinned CLI's failure, captured: an error `assistant` event, the error `result`, and the transcript's record. + failed = {"type": "assistant", "is_api_error_message": True, "error": "unknown", "session_id": "sess-1", + "message": {"role": "assistant", "content": [{"type": "text", "text": text}]}} + transcript = tmp_path / "sess-1.jsonl" + transcript.write_text(json.dumps({"type": "assistant", "isApiErrorMessage": True, "message": failed["message"]}) + "\n") + streamed: list[dict] = [] + + result = run((_stdout(failed, _api_error(text, api_error_status=400)), 1), event_callback=streamed.append) + + kept = json.dumps([streamed, result.trajectory, claude_bridge.read_partial_session_trajectory("sess-1")[0]]) + assert _FAKE_KEY not in kept and _FAKE_KEY not in transcript.read_text() + assert "[redacted]" in kept and "[redacted]" in transcript.read_text() + + def test_redaction_leaves_masked_keys_and_ids_alone(): text = ("Received API Key = sk-...0000 for task-abcdefghijkl; raw sk-abcd1234efgh; " "google AIzaFAKEfake0000fake0000fake0000fake000; " diff --git a/tst/unit/examples/claude_code/test_cli_env.py b/tst/unit/examples/claude_code/test_cli_env.py index feb855f6..982997af 100644 --- a/tst/unit/examples/claude_code/test_cli_env.py +++ b/tst/unit/examples/claude_code/test_cli_env.py @@ -133,12 +133,21 @@ async def _exec(*cmd, **kwargs): @pytest.mark.parametrize("workspace, project", [ ("/app", "-app"), - ("/tmp/my_ws.x-y/Sub Dir", "-tmp-my-ws-x-y-Sub-Dir"), # as the CLI names it, checked against 2.1.280 + ("/workspaces/my_ws.x-y/Sub Dir", "-workspaces-my-ws-x-y-Sub-Dir"), # as the CLI names it, checked against 2.1.280 ]) def test_a_sessions_transcripts_are_where_the_cli_keeps_them_for_its_workspace(workspace, project): assert claude_bridge.project_dir(Path(workspace)) == claude_bridge.CLAUDE_STATE_DIR / "projects" / project +def test_a_workspace_reached_through_a_link_keeps_its_transcripts_where_the_cli_does_for_its_real_path(tmp_path): + # The CLI names the directory for its working directory, which the OS gives it resolved. + (tmp_path / "real").mkdir() + (tmp_path / "link").symlink_to(tmp_path / "real", target_is_directory=True) + + assert claude_bridge.project_dir(tmp_path / "link") == claude_bridge.project_dir(tmp_path / "real") + assert "-link" not in claude_bridge.project_dir(tmp_path / "link").name + + def test_the_workspace_is_the_configured_one_else_the_working_directory_but_never_the_root(monkeypatch, tmp_path): monkeypatch.setenv("AGENT_WORKSPACE_DIR", str(tmp_path / "set")) assert claude_bridge._workspace() == tmp_path / "set" diff --git a/tst/unit/examples/claude_code/test_workspace_snapshot.py b/tst/unit/examples/claude_code/test_workspace_snapshot.py index a1d3be27..bb5e0b66 100644 --- a/tst/unit/examples/claude_code/test_workspace_snapshot.py +++ b/tst/unit/examples/claude_code/test_workspace_snapshot.py @@ -4,6 +4,7 @@ import hashlib import io import json +import os import tarfile from pathlib import Path @@ -316,3 +317,30 @@ def test_a_directory_link_is_archived_as_a_link_and_one_the_load_would_refuse_is assert links == {"current": "report"} assert (workspace / "current").is_symlink() and (workspace / "current" / "answer.txt").read_text() == "done" + + +def test_a_named_pipe_is_left_out_so_the_workspace_still_loads(monkeypatch, tmp_path): + workspace, _ = _snapshot_roots(monkeypatch, tmp_path) + os.mkfifo(workspace / "report" / "pipe") + archive = tmp_path / "workspace.tar.gz" + + file_count, _ = a2a_server._build_workspace_archive(archive) + (workspace / "report" / "pipe").unlink() + a2a_server._restore_workspace_archive(archive) + + assert file_count == 2 + assert (workspace / "report" / "answer.txt").read_text() == "done" and not (workspace / "report" / "pipe").exists() + + +def test_a_saved_conversation_holds_no_key_an_api_error_echoed(monkeypatch, tmp_path): + _, state = _snapshot_roots(monkeypatch, tmp_path) + key = "sk-FAKEfake0000fake0000fak" + ok = json.dumps({"type": "assistant", "message": {"role": "assistant", "content": "working"}}) + failed = json.dumps({"type": "assistant", "isApiErrorMessage": True, "error": "unknown", "message": { + "role": "assistant", "content": [{"type": "text", "text": f"API Error: 400 Budget exceeded! Key={key}"}]}}) + transcript = state / "projects" / "-app" / "session-1.jsonl" + transcript.write_text(f"{ok}\n{failed}\n") + + saved = a2a_server._read_complete_session_jsonl(transcript).decode() + + assert key not in saved and "[redacted]" in saved and saved.startswith(f"{ok}\n") From 4ba0faa5c4c4b1549e11a0a8294dc53633c15953 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 09:45:16 -0700 Subject: [PATCH 18/24] feat(agents): Claude Code delegates to the task's other agents through the SDK's peer tools The agent advertises peer-agents/v1 without handlers of its own, so the protocol serves the routing table and registers its peer_agents MCP server with every task, which the CLI connects to like the env's. The image needs the first protocol release that serves those tools, so requirements.txt names it as a floor; a release still pins the exact version it ships. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/agents/claude-code/a2a_server.py | 3 ++ .../agents/claude-code/requirements.txt | 5 +-- .../cli/run_bundle_claude_code_test.py | 36 ++++++++++++++++++- .../claude_code/test_card_contract.py | 1 + .../examples/claude_code/test_ext_mcp_name.py | 5 +-- .../claude_code/test_image_contract.py | 2 +- .../claude_code/test_message_contract.py | 9 +++-- tst/util/fake_anthropic.py | 17 +++++++++ 8 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/agent_env/examples/agents/agents/claude-code/a2a_server.py b/src/agent_env/examples/agents/agents/claude-code/a2a_server.py index 1ab90f1e..c1272850 100644 --- a/src/agent_env/examples/agents/agents/claude-code/a2a_server.py +++ b/src/agent_env/examples/agents/agents/claude-code/a2a_server.py @@ -25,6 +25,7 @@ from agentenv_protocol.a2a_agent import ( INSTALL_V1, MCP_CONFIG_V1, + PEER_AGENTS_V1, SKILL_CONFIG_V1, SNAPSHOT_V1, TRAJECTORY_V1, @@ -346,6 +347,8 @@ async def _fetch_granted_snapshot(objects: SnapshotReadObjects, archive_path: Pa "Retrieve a task's Claude Code stream-json trajectory, also while it runs, " "or a context's cumulative trajectory including a live run.")), enable(MCP_CONFIG_V1, description="Register MCP servers the Claude Code CLI should connect to."), + enable(PEER_AGENTS_V1, description=( + "Delegate to the task's other agents: the CLI reaches them through the SDK's peer_agents MCP tools.")), enable(SKILL_CONFIG_V1, description="Register a skill the Claude Code CLI loads from its skills directory."), enable(SNAPSHOT_V1, description=( "Capture and restore the Claude Code conversation, its workspace and the CLI's state, " diff --git a/src/agent_env/examples/agents/agents/claude-code/requirements.txt b/src/agent_env/examples/agents/agents/claude-code/requirements.txt index 45a09831..0fbd1aa1 100644 --- a/src/agent_env/examples/agents/agents/claude-code/requirements.txt +++ b/src/agent_env/examples/agents/agents/claude-code/requirements.txt @@ -1,2 +1,3 @@ -# A release pins agentenv-framework-protocol to the version it was released with. -agentenv-framework-protocol[agent] +# A release pins agentenv-framework-protocol to the version it was released with. The floor is the first that +# serves peer-agents/v1's tools, which the agent advertises without handlers of its own. +agentenv-framework-protocol[agent]>=0.1.351 diff --git a/tst/integration/cli/run_bundle_claude_code_test.py b/tst/integration/cli/run_bundle_claude_code_test.py index 3fc5f5a9..8d5448bf 100644 --- a/tst/integration/cli/run_bundle_claude_code_test.py +++ b/tst/integration/cli/run_bundle_claude_code_test.py @@ -19,9 +19,10 @@ from agent_env.store.routing import namespace_routing from agent_env.task.teardown import teardown_run from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_step import TaskStep from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep -from tst.util.fake_anthropic import FakeAnthropic, answer, tool_then_answer +from tst.util.fake_anthropic import FakeAnthropic, answer, delegate_then_answer, tool_then_answer pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -191,3 +192,36 @@ async def run(): asyncio.run(run()) assert endpoint.tool_results() == [notes] + + +def test_the_shipped_claude_code_delegates_to_its_peer_through_the_sdks_peer_tools(state, endpoint): + token = f"review-{uuid.uuid4().hex[:8]}" + endpoint.respond = delegate_then_answer("Delegate the review", "reviewer", "Send the review.", f"reviewed {token}") + lead = TaskStep.DEFAULT_AGENT_NAME + shipped = get_config().get_default_a2a_agent_id() + root = _bundle(state / "peers", "peers", [ + {"id": "lead", "type": "deploy_agent", "env_ids": [], "sandbox_type": "local"}, + {"id": "peer", "type": "deploy_agent", "env_ids": [], "sandbox_type": "local", "agent_name": "reviewer"}, + {"id": "peers", "type": "peer_agents", "peerings": [{"source_agent_name": lead, "peer_agent_names": ["reviewer"]}], + "depends_on": ["lead", "peer"]}, + {"id": "ask", "type": "prompt_agent", "prompt_id": "ask", "prompt": "Delegate the review to the reviewer.", + "depends_on": ["peers"]}, + {"id": "check", "type": "agent_prompt_response_verifier", "prompt_id": "ask", "verifier_id": "answer", + "criteria": [{"type": "response_contains", "needles": [token]}], "depends_on": ["ask"]}, + {"id": "verify", "type": "verify_a2a_peer_agents", "a2a_agent_id": shipped, "a2a_agent_version": None, + "peer_prompt_id": "ask", "expected_token": token, "depends_on": ["check"]}, + ]) + + result = _run(root) + + offered = sorted({tool["name"] for call in endpoint.messages() for tool in call.body.get("tools") or [] + if tool["name"].startswith("mcp__peer_agents__")}) + seen = (result.output, offered, endpoint.tool_results()) + assert result.exit_code == 0 and "tasks/peers.json v1: passed" in result.output, seen + assert "mcp__peer_agents__peer_send_message" in offered, seen + # Only the reviewer's CLI answers with the token, so it reached the lead through the tool. + (relayed,) = endpoint.tool_results() + assert json.loads(relayed)["response"] == f"reviewed {token}", seen + with namespace_routing(): # where a bundle run keeps the agents it pulled + validated = A2AAgent.get(shipped).metadata["validated_a2a_extensions"][A2AAgent.EXT_PEER_AGENTS] + assert validated["supported"], validated diff --git a/tst/unit/examples/claude_code/test_card_contract.py b/tst/unit/examples/claude_code/test_card_contract.py index 18780fe8..412f4d72 100644 --- a/tst/unit/examples/claude_code/test_card_contract.py +++ b/tst/unit/examples/claude_code/test_card_contract.py @@ -53,6 +53,7 @@ def test_extensions_and_their_routes(app): "urn:agentenv:agent-config/v1": ("/ext/agent-config", {"set": ("POST", None), "get": ("GET", None)}), "urn:agentenv:trajectory/v1": ("/ext/trajectory", {"get": ("POST", None)}), "urn:agentenv:mcp-config/v1": ("/ext/mcp-config", {"add": ("POST", None), "list": ("GET", None)}), + "urn:agentenv:peer-agents/v1": ("/ext/peer-agents", {"set": ("POST", None), "list": ("GET", None)}), "urn:agentenv:skill-config/v1": ("/ext/skill-config", {"add": ("POST", None), "list": ("GET", None)}), "urn:agentenv:snapshot/v1": ("/ext/snapshot", {"save": ("POST", None), "load": ("PUT", None)}), "urn:agentenv:install/v1": (None, {}), diff --git a/tst/unit/examples/claude_code/test_ext_mcp_name.py b/tst/unit/examples/claude_code/test_ext_mcp_name.py index ffe06782..ff3e9e88 100644 --- a/tst/unit/examples/claude_code/test_ext_mcp_name.py +++ b/tst/unit/examples/claude_code/test_ext_mcp_name.py @@ -1,4 +1,5 @@ -"""mcp-config add keeps the name the caller sends, and mints mcp_ when it sends none.""" +"""mcp-config add keeps the name the caller sends, and mints mcp_ when it sends none. The SDK's peer_agents +server is listed beside them.""" from tst.unit.examples.claude_code.agent import a2a_server @@ -11,7 +12,7 @@ def test_add_mcp_uses_the_callers_name_and_mints_one_otherwise(client): assert named["name"] == "env" minted = client.post(MCP_CONFIG_PATH, json={"url": "https://gw2/mcp"}).json() assert minted["name"].startswith("mcp_") and len(minted["name"]) == 12 - assert set(client.get(MCP_CONFIG_PATH).json()["mcp_servers"]) == {"env", minted["name"]} + assert set(client.get(MCP_CONFIG_PATH).json()["mcp_servers"]) == {"env", minted["name"], "peer_agents"} def test_a_second_server_under_the_same_name_is_refused(client): diff --git a/tst/unit/examples/claude_code/test_image_contract.py b/tst/unit/examples/claude_code/test_image_contract.py index 09a89f5f..535d4929 100644 --- a/tst/unit/examples/claude_code/test_image_contract.py +++ b/tst/unit/examples/claude_code/test_image_contract.py @@ -23,7 +23,7 @@ def test_the_image_installs_the_protocol_alone_from_its_requirements(): requirements = [line for line in (AGENT_DIR / "requirements.txt").read_text().splitlines() if line and not line.startswith("#")] - assert requirements == ["agentenv-framework-protocol[agent]"] + assert requirements == ["agentenv-framework-protocol[agent]>=0.1.351"] def test_the_image_gives_the_model_node_alone_from_a_pinned_image(): diff --git a/tst/unit/examples/claude_code/test_message_contract.py b/tst/unit/examples/claude_code/test_message_contract.py index 6b02921f..6bcc774c 100644 --- a/tst/unit/examples/claude_code/test_message_contract.py +++ b/tst/unit/examples/claude_code/test_message_contract.py @@ -8,12 +8,15 @@ import httpx import pytest from agentenv_protocol.a2a_agent import AGENT_CONFIG_V1 +from agentenv_protocol.a2a_agent.peer_agents import PEER_MCP_PATH from tst.unit.examples.claude_code.support import send, terminal from tst.unit.examples.claude_code.agent import a2a_server, claude_bridge, kit AGENT_CONFIG_PATH = AGENT_CONFIG_V1.endpoint MCP_CONFIG_PATH = a2a_server.MCP_CONFIG_V1.endpoint +# The SDK's peer-agents tools, on the port the test client serves on. +PEER_TOOLS = {"url": f"http://127.0.0.1:80{PEER_MCP_PATH}", "headers": None} class Calls(list): @@ -172,13 +175,15 @@ def test_agent_config_and_mcp_servers_reach_the_cli(calls, client): key: value for key, value in config.items() if key != "timeout_seconds" } assert call["timeout_s"] == 42 - assert call["mcp_servers"] == {"env": {"url": "https://gw/mcp", "headers": {"CF-Access-Client-Id": "id"}}} + assert call["mcp_servers"] == {"env": {"url": "https://gw/mcp", "headers": {"CF-Access-Client-Id": "id"}}, + "peer_agents": PEER_TOOLS} def test_the_default_timeout_is_thirty_minutes(calls, client): send(client, "go") - assert calls[0]["timeout_s"] == 1800 and calls[0]["model"] is None and calls[0]["mcp_servers"] == {} + assert calls[0]["timeout_s"] == 1800 and calls[0]["model"] is None + assert calls[0]["mcp_servers"] == {"peer_agents": PEER_TOOLS} # the SDK's, served whether or not peers are set def test_session_references_outlive_the_frameworks_bounded_record(): diff --git a/tst/util/fake_anthropic.py b/tst/util/fake_anthropic.py index 2d3e8a12..78e4acec 100644 --- a/tst/util/fake_anthropic.py +++ b/tst/util/fake_anthropic.py @@ -66,6 +66,23 @@ def respond(body: dict) -> Text | ToolUse: return respond +def delegate_then_answer(cue: str, peer: str, prompt: str, reply: str) -> Responder: + """For a lead agent and its peer, both calling this endpoint: a request whose messages hold ``cue``, the lead's, + calls ``peer_send_message`` to send ``peer`` the ``prompt``, then answers with the tool result's text; every other + request, the peer's, is answered with ``reply``.""" + + def respond(body: dict) -> Text | ToolUse: + result = _tool_result_text(body) + if result is not None: + return Text(result) + if cue not in json.dumps(body.get("messages") or []): + return Text(reply) + tool = next(t["name"] for t in body.get("tools") or [] if t.get("name", "").endswith("__peer_send_message")) + return ToolUse(tool, {"name": peer, "prompt": prompt}) + + return respond + + def _tool_result_text(body: dict) -> str | None: """The text of a tool result in the latest user turn, or None. The CLI may follow it with system messages.""" user = next((m for m in reversed(body.get("messages") or []) if m.get("role") == "user"), None) From 831838e3c8355a52583685d3c53bd28cd9608a59 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 10:16:06 -0700 Subject: [PATCH 19/24] refactor(agents): the agents bundle ships from agent_env/agents, beside the core packages, not under examples Claude Code is the agent every task gets by default, not an example to copy, so the agents bundle moves to src/agent_env/agents/ (entry point agents = "agent_env"). Its ids don't change: an installed bundle's id is @local//, whatever package holds its folder. So that more agents need no further wiring: - The release pins the protocol in every shipped env's and agent's requirements.txt, which .github/scripts/shipped_bundles.py lists from the agent_env.bundles entry points. The workflow assigns the list first, so the step stops if the script fails or finds nothing, rather than shipping unpinned images. - The clean-install gate checks the tracked files of every shipped bundle's folder, not only examples/. - A unit test parses, resolves and checks every shipped bundle, and plans a task naming no agent with the shipped default. A run pulling the default agent reads the whole agents bundle, so one broken agent folder would fail every run that names no agent. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/scripts/clean_install.py | 19 ++++---- .github/scripts/shipped_bundles.py | 27 +++++++++++ .github/workflows/publish-pypi.yml | 7 ++- AGENTS.md | 11 +++-- pyproject.toml | 2 +- src/agent_env/{examples => }/agents/README.md | 3 +- .../agents/agents/claude-code/Dockerfile | 0 .../agents/agents/claude-code/a2a_server.py | 0 .../agents/claude-code/claude_bridge.py | 0 .../agents/agents/claude-code/entrypoint.sh | 0 .../agents/agents/claude-code/install.sh | 0 .../agents/agents/claude-code/kit.py | 0 .../agents/claude-code/model-requirements.txt | 0 .../agents/claude-code/requirements.txt | 0 .../agents/agents/claude-code/run.sh | 0 .../agents/tasks/claude-code-smoke.json | 0 tst/unit/{examples => agents}/__init__.py | 0 .../claude_code/__init__.py | 0 .../{examples => agents}/claude_code/agent.py | 2 +- .../claude_code/conftest.py | 4 +- .../claude_code/support.py | 0 .../claude_code/test_card_contract.py | 4 +- .../claude_code/test_claude_bridge_cancel.py | 2 +- .../test_claude_bridge_failure_message.py | 4 +- .../claude_code/test_claude_bridge_live.py | 4 +- .../claude_code/test_claude_bridge_retry.py | 4 +- .../claude_code/test_claude_bridge_stdin.py | 4 +- .../claude_code/test_cli_env.py | 4 +- .../claude_code/test_ext_mcp_name.py | 2 +- .../claude_code/test_extensions.py | 4 +- .../claude_code/test_image_contract.py | 2 +- .../claude_code/test_message_contract.py | 4 +- .../claude_code/test_object_transfer.py | 4 +- .../claude_code/test_partial_trajectory.py | 6 +-- .../claude_code/test_workspace_snapshot.py | 4 +- tst/unit/bundle/shipped_bundles_test.py | 46 +++++++++++++++++++ tst/unit/test_clean_install_wheel.py | 13 +++++- tst/unit/test_release_protocol_pins.py | 31 +++++++++++-- 38 files changed, 165 insertions(+), 52 deletions(-) create mode 100644 .github/scripts/shipped_bundles.py rename src/agent_env/{examples => }/agents/README.md (93%) rename src/agent_env/{examples => }/agents/agents/claude-code/Dockerfile (100%) rename src/agent_env/{examples => }/agents/agents/claude-code/a2a_server.py (100%) rename src/agent_env/{examples => }/agents/agents/claude-code/claude_bridge.py (100%) rename src/agent_env/{examples => }/agents/agents/claude-code/entrypoint.sh (100%) rename src/agent_env/{examples => }/agents/agents/claude-code/install.sh (100%) rename src/agent_env/{examples => }/agents/agents/claude-code/kit.py (100%) rename src/agent_env/{examples => }/agents/agents/claude-code/model-requirements.txt (100%) rename src/agent_env/{examples => }/agents/agents/claude-code/requirements.txt (100%) rename src/agent_env/{examples => }/agents/agents/claude-code/run.sh (100%) rename src/agent_env/{examples => }/agents/tasks/claude-code-smoke.json (100%) rename tst/unit/{examples => agents}/__init__.py (100%) rename tst/unit/{examples => agents}/claude_code/__init__.py (100%) rename tst/unit/{examples => agents}/claude_code/agent.py (80%) rename tst/unit/{examples => agents}/claude_code/conftest.py (92%) rename tst/unit/{examples => agents}/claude_code/support.py (100%) rename tst/unit/{examples => agents}/claude_code/test_card_contract.py (98%) rename tst/unit/{examples => agents}/claude_code/test_claude_bridge_cancel.py (98%) rename tst/unit/{examples => agents}/claude_code/test_claude_bridge_failure_message.py (98%) rename tst/unit/{examples => agents}/claude_code/test_claude_bridge_live.py (97%) rename tst/unit/{examples => agents}/claude_code/test_claude_bridge_retry.py (99%) rename tst/unit/{examples => agents}/claude_code/test_claude_bridge_stdin.py (97%) rename tst/unit/{examples => agents}/claude_code/test_cli_env.py (98%) rename tst/unit/{examples => agents}/claude_code/test_ext_mcp_name.py (94%) rename tst/unit/{examples => agents}/claude_code/test_extensions.py (97%) rename tst/unit/{examples => agents}/claude_code/test_image_contract.py (97%) rename tst/unit/{examples => agents}/claude_code/test_message_contract.py (98%) rename tst/unit/{examples => agents}/claude_code/test_object_transfer.py (98%) rename tst/unit/{examples => agents}/claude_code/test_partial_trajectory.py (98%) rename tst/unit/{examples => agents}/claude_code/test_workspace_snapshot.py (98%) create mode 100644 tst/unit/bundle/shipped_bundles_test.py diff --git a/.github/scripts/clean_install.py b/.github/scripts/clean_install.py index e35ef1f6..0e282203 100644 --- a/.github/scripts/clean_install.py +++ b/.github/scripts/clean_install.py @@ -1,11 +1,11 @@ -"""The clean-install gate: build both distributions as the release does, check that the wheel ships every example -and its entry point, install the two wheels into a fresh venv from public PyPI, run a shipped bundle twice by name, +"""The clean-install gate: build both distributions as the release does, check that the wheel ships every shipped +bundle's files and its entry point, install the two wheels into a fresh venv from public PyPI, run a shipped bundle twice by name, and check what the runs left in the local store. Every child process gets an environment built from an allowlist, so no config file, credential, package index or installed package of the calling shell can make the gate pass. It builds in place from the checkout: hatchling ignores ``.gitignore`` when the build root's own path matches one of its patterns, as a copy under ``/tmp`` does, so -it also refuses a tracked example file that ``.gitignore`` matches, wherever the checkout is. +it also refuses a tracked bundle file that ``.gitignore`` matches, wherever the checkout is. """ from __future__ import annotations @@ -21,9 +21,9 @@ import zipfile from pathlib import Path +from shipped_bundles import BUNDLES, bundle_roots + REPO = Path(__file__).resolve().parents[2] -EXAMPLES = "src/agent_env/examples" -BUNDLES = "agent_env.bundles" PYPI = "https://pypi.org/simple" CHECK = Path(__file__).with_name("check_clean_install.py") @@ -53,9 +53,10 @@ def main() -> int: protocol = _one(work / "dist", "agentenv_framework_protocol-*.whl") framework = _one(work / "dist", "agentenv_framework-*.whl") - _step(f"Check {framework.name} ships every tracked example and its entry points") - tracked = _listed([git, "ls-files", "-z", EXAMPLES], env) - ignored = _listed([git, "ls-files", "-z", "--cached", "--ignored", "--exclude-standard", EXAMPLES], env) + _step(f"Check {framework.name} ships every tracked file of each shipped bundle, and their entry points") + folders = [root.relative_to(REPO).as_posix() for root in bundle_roots().values()] + tracked = _listed([git, "ls-files", "-z", *folders], env) + ignored = _listed([git, "ls-files", "-z", "--cached", "--ignored", "--exclude-standard", *folders], env) declared = tomllib.loads((REPO / "pyproject.toml").read_text())["project"].get("entry-points", {}).get(BUNDLES, {}) problems = [f"::error file={path}::{path} is tracked but .gitignore matches it, so hatch leaves it out of the wheel" for path in ignored] @@ -85,7 +86,7 @@ def main() -> int: def wheel_problems(wheel: Path, tracked: list[str], declared: dict[str, str]) -> list[str]: - """Why ``wheel`` doesn't ship the tracked example files under ``src/`` and the declared bundle entry points.""" + """Why ``wheel`` doesn't ship the tracked bundle files under ``src/`` and the declared bundle entry points.""" with zipfile.ZipFile(wheel) as archive: names = set(archive.namelist()) entry_points = next((name for name in names if name.endswith(".dist-info/entry_points.txt")), None) diff --git a/.github/scripts/shipped_bundles.py b/.github/scripts/shipped_bundles.py new file mode 100644 index 00000000..8d3b0f95 --- /dev/null +++ b/.github/scripts/shipped_bundles.py @@ -0,0 +1,27 @@ +"""The bundles agentenv-framework ships, from its ``agent_env.bundles`` entry points: `` = ""`` ships the +folder ``src///``. Run, it prints each shipped env's, agent's and artifact's requirements.txt, +the files a release pins agentenv-framework-protocol in.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +BUNDLES = "agent_env.bundles" + + +def bundle_roots(repo: Path = REPO) -> dict[str, Path]: + """Each shipped bundle's folder, by its name.""" + project = tomllib.loads((repo / "pyproject.toml").read_text())["project"] + declared = project.get("entry-points", {}).get(BUNDLES, {}) + return {name: repo / "src" / Path(*package.split(".")) / name for name, package in sorted(declared.items())} + + +def requirements_files(repo: Path = REPO) -> list[Path]: + """Every ``///requirements.txt`` a shipped bundle holds.""" + return sorted(path for root in bundle_roots(repo).values() for path in root.glob("*/*/requirements.txt")) + + +if __name__ == "__main__": + print("\n".join(path.relative_to(REPO).as_posix() for path in requirements_files())) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index b5747e04..6c8497b0 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -93,8 +93,11 @@ jobs: # workspace members, and the bare name keys [tool.uv.sources]; none of those may be rewritten. sed -i "s/\"agentenv-framework-protocol[^\"]*\"/\"agentenv-framework-protocol==${protocol}\"/" pyproject.toml grep -q "\"agentenv-framework-protocol==${protocol}\"" pyproject.toml - # Each shipped example's image installs the protocol this release ships with, keeping its extras. - for requirements in src/agent_env/examples/*/*/*/requirements.txt; do + # Each shipped bundle's images install the protocol this release ships with, keeping its extras. The list is + # assigned first, so the step stops if the script fails or finds nothing. + requirements_files=$(python .github/scripts/shipped_bundles.py) + [ -n "$requirements_files" ] + for requirements in $requirements_files; do grep -q "^agentenv-framework-protocol" "$requirements" || continue sed -i -E "s/^agentenv-framework-protocol(\[[^]]*\])?.*/agentenv-framework-protocol\1==${protocol}/" "$requirements" grep -qE "^agentenv-framework-protocol(\[[^]]*\])?==${protocol}$" "$requirements" diff --git a/AGENTS.md b/AGENTS.md index 28ef9dc8..9c79d1d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ or one of them only where it is used. bridge address say: `host.docker.internal`, which grant URLs name by default, resolves only in containers. - `[agents] default_a2a_agent_id`: the agent a `deploy_agent` step without an id deploys; built-in `@local/agentenv-framework/agents/claude-code`, the `agents` bundle's Claude Code - (`src/agent_env/examples/agents/`), overridable by `configure(default_a2a_agent_id=...)`. Any step's + (`src/agent_env/agents/`), overridable by `configure(default_a2a_agent_id=...)`. Any step's agent field left unset falls back to it (`entity.refs.default_agent_refs`), so a bundle run plans the default for it, pulling the built-in one from the installed `agents` bundle. Claude Code needs a model endpoint serving Anthropic's Messages API, such as a LiteLLM gateway. It declares `install/v1` too: its @@ -118,7 +118,8 @@ and deserialized through a registry. | `plugins/` | Entry-point plugins (see Extension points): the group names, `inventory()`, which `agent-env plugin list/show/check` report, `load_failures` and `settings`. The top level is public; the underscored modules are internal. | | `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. | | `cli/` | Click CLI with the groups `a2a-agent`, `artifact`, `bundle`, `config`, `env`, `eval`, `plugin`, `run`, `task`, `up`. | -| `examples/` | The bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker), registered under `agent_env.bundles` in `pyproject.toml`. Tests run them, but they are examples for users, not fixtures. | +| `agents/` | The `agents` bundle: the agents agent-env ships, one folder each under `agents/agents/`, with a smoke task each under `agents/tasks/`. Its Claude Code, `@local/agentenv-framework/agents/claude-code`, is the default agent. An agent's Python packages are its folder's `requirements.txt`, installed in its image, never in agent-env's own environment. A run that pulls one of its agents reads the whole bundle, so `tst/unit/bundle/shipped_bundles_test.py` parses and checks every shipped bundle. | +| `examples/` | The example bundles agent-env ships (`hello`, and `echo-mcp`, which needs Docker). Tests run them, but they are examples for users, not fixtures. Every shipped bundle is registered under `agent_env.bundles` in `pyproject.toml`, ` = ""` shipping `src///`. | ## Extension points @@ -259,14 +260,16 @@ request, title edits included, and on `main`: `.github/scripts/check_plugin_api. plugin surface (its `BASES` and `USED`) between `HEAD^1` and `HEAD` with griffe, pinned in the `dev` extra, and fails on a break the title does not mark with `!`. `.github/workflows/clean-install.yml` runs the `clean-install` job on every pull request and on `main`: `.github/scripts/clean_install.py` builds both distributions as the -release does, fails if the wheel leaves out a file tracked under `src/agent_env/examples` or a +release does, fails if the wheel leaves out a file tracked in a shipped bundle's folder or an `agent_env.bundles` entry point, installs the two wheels into a fresh venv from public PyPI with an allowlisted environment (no AWS, no config, no plugin), and runs `agent-env run hello` twice by name; `.github/scripts/check_clean_install.py`, run by that venv, checks the local store and that the runs left no sandbox work folder. `.github/workflows/publish-pypi.yml` runs on the `v*` tag the release automation pushes: it waits for `main`'s CI to pass on the tagged commit, publishes `agentenv-framework-protocol` and then -`agentenv-framework` to PyPI, and creates the GitHub release, whose notes are the titles of the pull +`agentenv-framework` to PyPI, pinning the protocol in `pyproject.toml` and in each shipped env's and agent's +`requirements.txt` (`.github/scripts/shipped_bundles.py` lists them, so a new agent is pinned with no workflow change), +and creates the GitHub release, whose notes are the titles of the pull requests merged since the previous release. Actions are pinned to commit SHAs; Dependabot updates them, the pinned uv, `uv.lock` and the UI's npm packages. diff --git a/pyproject.toml b/pyproject.toml index fed5fcf4..9f2a650f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,7 +107,7 @@ smol = [ agent-env = "agent_env.cli:cli" [project.entry-points."agent_env.bundles"] -agents = "agent_env.examples" +agents = "agent_env" hello = "agent_env.examples" echo-mcp = "agent_env.examples" diff --git a/src/agent_env/examples/agents/README.md b/src/agent_env/agents/README.md similarity index 93% rename from src/agent_env/examples/agents/README.md rename to src/agent_env/agents/README.md index 041ab57a..39a89d17 100644 --- a/src/agent_env/examples/agents/README.md +++ b/src/agent_env/agents/README.md @@ -19,4 +19,5 @@ in a Python of its own, so the target's Python is left alone. The CLI runs its c neither, Alpine's for one, gets bash from its own package manager. The agent works in the container's working directory, or in `/app` on the host. -Run its smoke task with `agent-env run agents`. +`agent-env run agents` runs every agent's smoke task, building each image; `--task claude-code-smoke` runs Claude +Code's alone. diff --git a/src/agent_env/examples/agents/agents/claude-code/Dockerfile b/src/agent_env/agents/agents/claude-code/Dockerfile similarity index 100% rename from src/agent_env/examples/agents/agents/claude-code/Dockerfile rename to src/agent_env/agents/agents/claude-code/Dockerfile diff --git a/src/agent_env/examples/agents/agents/claude-code/a2a_server.py b/src/agent_env/agents/agents/claude-code/a2a_server.py similarity index 100% rename from src/agent_env/examples/agents/agents/claude-code/a2a_server.py rename to src/agent_env/agents/agents/claude-code/a2a_server.py diff --git a/src/agent_env/examples/agents/agents/claude-code/claude_bridge.py b/src/agent_env/agents/agents/claude-code/claude_bridge.py similarity index 100% rename from src/agent_env/examples/agents/agents/claude-code/claude_bridge.py rename to src/agent_env/agents/agents/claude-code/claude_bridge.py diff --git a/src/agent_env/examples/agents/agents/claude-code/entrypoint.sh b/src/agent_env/agents/agents/claude-code/entrypoint.sh similarity index 100% rename from src/agent_env/examples/agents/agents/claude-code/entrypoint.sh rename to src/agent_env/agents/agents/claude-code/entrypoint.sh diff --git a/src/agent_env/examples/agents/agents/claude-code/install.sh b/src/agent_env/agents/agents/claude-code/install.sh similarity index 100% rename from src/agent_env/examples/agents/agents/claude-code/install.sh rename to src/agent_env/agents/agents/claude-code/install.sh diff --git a/src/agent_env/examples/agents/agents/claude-code/kit.py b/src/agent_env/agents/agents/claude-code/kit.py similarity index 100% rename from src/agent_env/examples/agents/agents/claude-code/kit.py rename to src/agent_env/agents/agents/claude-code/kit.py diff --git a/src/agent_env/examples/agents/agents/claude-code/model-requirements.txt b/src/agent_env/agents/agents/claude-code/model-requirements.txt similarity index 100% rename from src/agent_env/examples/agents/agents/claude-code/model-requirements.txt rename to src/agent_env/agents/agents/claude-code/model-requirements.txt diff --git a/src/agent_env/examples/agents/agents/claude-code/requirements.txt b/src/agent_env/agents/agents/claude-code/requirements.txt similarity index 100% rename from src/agent_env/examples/agents/agents/claude-code/requirements.txt rename to src/agent_env/agents/agents/claude-code/requirements.txt diff --git a/src/agent_env/examples/agents/agents/claude-code/run.sh b/src/agent_env/agents/agents/claude-code/run.sh similarity index 100% rename from src/agent_env/examples/agents/agents/claude-code/run.sh rename to src/agent_env/agents/agents/claude-code/run.sh diff --git a/src/agent_env/examples/agents/tasks/claude-code-smoke.json b/src/agent_env/agents/tasks/claude-code-smoke.json similarity index 100% rename from src/agent_env/examples/agents/tasks/claude-code-smoke.json rename to src/agent_env/agents/tasks/claude-code-smoke.json diff --git a/tst/unit/examples/__init__.py b/tst/unit/agents/__init__.py similarity index 100% rename from tst/unit/examples/__init__.py rename to tst/unit/agents/__init__.py diff --git a/tst/unit/examples/claude_code/__init__.py b/tst/unit/agents/claude_code/__init__.py similarity index 100% rename from tst/unit/examples/claude_code/__init__.py rename to tst/unit/agents/claude_code/__init__.py diff --git a/tst/unit/examples/claude_code/agent.py b/tst/unit/agents/claude_code/agent.py similarity index 80% rename from tst/unit/examples/claude_code/agent.py rename to tst/unit/agents/claude_code/agent.py index ee6be756..f7d454ad 100644 --- a/tst/unit/examples/claude_code/agent.py +++ b/tst/unit/agents/claude_code/agent.py @@ -7,7 +7,7 @@ import agent_env -AGENT_DIR = Path(agent_env.__file__).parent / "examples" / "agents" / "agents" / "claude-code" +AGENT_DIR = Path(agent_env.__file__).parent / "agents" / "agents" / "claude-code" sys.path.insert(0, str(AGENT_DIR)) a2a_server = importlib.import_module("a2a_server") claude_bridge = importlib.import_module("claude_bridge") diff --git a/tst/unit/examples/claude_code/conftest.py b/tst/unit/agents/claude_code/conftest.py similarity index 92% rename from tst/unit/examples/claude_code/conftest.py rename to tst/unit/agents/claude_code/conftest.py index 76d4f7e3..ee1bd367 100644 --- a/tst/unit/examples/claude_code/conftest.py +++ b/tst/unit/agents/claude_code/conftest.py @@ -8,8 +8,8 @@ from agentenv_protocol import transfers as protocol_transfers from starlette.testclient import TestClient -from tst.unit.examples.claude_code.agent import a2a_server, claude_bridge -from tst.unit.examples.claude_code.support import FakeStore +from tst.unit.agents.claude_code.agent import a2a_server, claude_bridge +from tst.unit.agents.claude_code.support import FakeStore @pytest.fixture(autouse=True) diff --git a/tst/unit/examples/claude_code/support.py b/tst/unit/agents/claude_code/support.py similarity index 100% rename from tst/unit/examples/claude_code/support.py rename to tst/unit/agents/claude_code/support.py diff --git a/tst/unit/examples/claude_code/test_card_contract.py b/tst/unit/agents/claude_code/test_card_contract.py similarity index 98% rename from tst/unit/examples/claude_code/test_card_contract.py rename to tst/unit/agents/claude_code/test_card_contract.py index 412f4d72..bd964ead 100644 --- a/tst/unit/examples/claude_code/test_card_contract.py +++ b/tst/unit/agents/claude_code/test_card_contract.py @@ -10,8 +10,8 @@ from agent_env.task_step.task_steps.install_agent import _build_param_resolvers -from tst.unit.examples.claude_code.agent import AGENT_DIR -from tst.unit.examples.claude_code.support import card +from tst.unit.agents.claude_code.agent import AGENT_DIR +from tst.unit.agents.claude_code.support import card _CLAUDE_SKILL = { "id": "claude-code", diff --git a/tst/unit/examples/claude_code/test_claude_bridge_cancel.py b/tst/unit/agents/claude_code/test_claude_bridge_cancel.py similarity index 98% rename from tst/unit/examples/claude_code/test_claude_bridge_cancel.py rename to tst/unit/agents/claude_code/test_claude_bridge_cancel.py index 36959363..af23967b 100644 --- a/tst/unit/examples/claude_code/test_claude_bridge_cancel.py +++ b/tst/unit/agents/claude_code/test_claude_bridge_cancel.py @@ -10,7 +10,7 @@ import time import pytest -from tst.unit.examples.claude_code.agent import claude_bridge +from tst.unit.agents.claude_code.agent import claude_bridge diff --git a/tst/unit/examples/claude_code/test_claude_bridge_failure_message.py b/tst/unit/agents/claude_code/test_claude_bridge_failure_message.py similarity index 98% rename from tst/unit/examples/claude_code/test_claude_bridge_failure_message.py rename to tst/unit/agents/claude_code/test_claude_bridge_failure_message.py index c7b38a14..57330835 100644 --- a/tst/unit/examples/claude_code/test_claude_bridge_failure_message.py +++ b/tst/unit/agents/claude_code/test_claude_bridge_failure_message.py @@ -12,8 +12,8 @@ import pytest -from tst.unit.examples.claude_code.support import FakeCLI -from tst.unit.examples.claude_code.agent import claude_bridge +from tst.unit.agents.claude_code.support import FakeCLI +from tst.unit.agents.claude_code.agent import claude_bridge # What agent-env keeps of an agent's error_message, and a2a_server's own cap. _AGENT_ENV_KEEPS = 500 diff --git a/tst/unit/examples/claude_code/test_claude_bridge_live.py b/tst/unit/agents/claude_code/test_claude_bridge_live.py similarity index 97% rename from tst/unit/examples/claude_code/test_claude_bridge_live.py rename to tst/unit/agents/claude_code/test_claude_bridge_live.py index 726af099..049dc845 100644 --- a/tst/unit/examples/claude_code/test_claude_bridge_live.py +++ b/tst/unit/agents/claude_code/test_claude_bridge_live.py @@ -7,8 +7,8 @@ import pytest -from tst.unit.examples.claude_code.support import FakeCLI -from tst.unit.examples.claude_code.agent import claude_bridge +from tst.unit.agents.claude_code.support import FakeCLI +from tst.unit.agents.claude_code.agent import claude_bridge _INIT = {"type": "system", "subtype": "init", "session_id": "sess-1"} _TOOL = {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Bash", "input": {}}]}} diff --git a/tst/unit/examples/claude_code/test_claude_bridge_retry.py b/tst/unit/agents/claude_code/test_claude_bridge_retry.py similarity index 99% rename from tst/unit/examples/claude_code/test_claude_bridge_retry.py rename to tst/unit/agents/claude_code/test_claude_bridge_retry.py index 12fe46dc..35e1999e 100644 --- a/tst/unit/examples/claude_code/test_claude_bridge_retry.py +++ b/tst/unit/agents/claude_code/test_claude_bridge_retry.py @@ -10,8 +10,8 @@ import pytest -from tst.unit.examples.claude_code.support import FakeCLI -from tst.unit.examples.claude_code.agent import claude_bridge +from tst.unit.agents.claude_code.support import FakeCLI +from tst.unit.agents.claude_code.agent import claude_bridge def _event(**kw): diff --git a/tst/unit/examples/claude_code/test_claude_bridge_stdin.py b/tst/unit/agents/claude_code/test_claude_bridge_stdin.py similarity index 97% rename from tst/unit/examples/claude_code/test_claude_bridge_stdin.py rename to tst/unit/agents/claude_code/test_claude_bridge_stdin.py index a6a7e0a9..02a775bc 100644 --- a/tst/unit/examples/claude_code/test_claude_bridge_stdin.py +++ b/tst/unit/agents/claude_code/test_claude_bridge_stdin.py @@ -8,8 +8,8 @@ import pytest -from tst.unit.examples.claude_code.support import FakeCLI -from tst.unit.examples.claude_code.agent import claude_bridge +from tst.unit.agents.claude_code.support import FakeCLI +from tst.unit.agents.claude_code.agent import claude_bridge MAX_ARG_STRLEN = 131072 # Linux single-argument limit (PAGE_SIZE * 32) diff --git a/tst/unit/examples/claude_code/test_cli_env.py b/tst/unit/agents/claude_code/test_cli_env.py similarity index 98% rename from tst/unit/examples/claude_code/test_cli_env.py rename to tst/unit/agents/claude_code/test_cli_env.py index 982997af..8381c644 100644 --- a/tst/unit/examples/claude_code/test_cli_env.py +++ b/tst/unit/agents/claude_code/test_cli_env.py @@ -10,8 +10,8 @@ import pytest -from tst.unit.examples.claude_code.agent import claude_bridge -from tst.unit.examples.claude_code.support import FakeCLI +from tst.unit.agents.claude_code.agent import claude_bridge +from tst.unit.agents.claude_code.support import FakeCLI GATEWAY = {"LITELLM_API_KEY": "gateway-key", "LITELLM_BASE_URL": "https://gateway.example.com"} ALIASES = ("ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", diff --git a/tst/unit/examples/claude_code/test_ext_mcp_name.py b/tst/unit/agents/claude_code/test_ext_mcp_name.py similarity index 94% rename from tst/unit/examples/claude_code/test_ext_mcp_name.py rename to tst/unit/agents/claude_code/test_ext_mcp_name.py index ff3e9e88..a371de41 100644 --- a/tst/unit/examples/claude_code/test_ext_mcp_name.py +++ b/tst/unit/agents/claude_code/test_ext_mcp_name.py @@ -1,7 +1,7 @@ """mcp-config add keeps the name the caller sends, and mints mcp_ when it sends none. The SDK's peer_agents server is listed beside them.""" -from tst.unit.examples.claude_code.agent import a2a_server +from tst.unit.agents.claude_code.agent import a2a_server MCP_CONFIG_PATH = a2a_server.MCP_CONFIG_V1.endpoint diff --git a/tst/unit/examples/claude_code/test_extensions.py b/tst/unit/agents/claude_code/test_extensions.py similarity index 97% rename from tst/unit/examples/claude_code/test_extensions.py rename to tst/unit/agents/claude_code/test_extensions.py index 9d15c05f..50920180 100644 --- a/tst/unit/examples/claude_code/test_extensions.py +++ b/tst/unit/agents/claude_code/test_extensions.py @@ -5,8 +5,8 @@ import pytest from agentenv_protocol.a2a_agent import AGENT_CONFIG_V1 -from tst.unit.examples.claude_code.support import card -from tst.unit.examples.claude_code.agent import a2a_server +from tst.unit.agents.claude_code.support import card +from tst.unit.agents.claude_code.agent import a2a_server AGENT_CONFIG_PATH = AGENT_CONFIG_V1.endpoint SKILL_CONFIG_PATH = a2a_server.SKILL_CONFIG_V1.endpoint diff --git a/tst/unit/examples/claude_code/test_image_contract.py b/tst/unit/agents/claude_code/test_image_contract.py similarity index 97% rename from tst/unit/examples/claude_code/test_image_contract.py rename to tst/unit/agents/claude_code/test_image_contract.py index 535d4929..b3ec360e 100644 --- a/tst/unit/examples/claude_code/test_image_contract.py +++ b/tst/unit/agents/claude_code/test_image_contract.py @@ -5,7 +5,7 @@ import re -from tst.unit.examples.claude_code.agent import AGENT_DIR +from tst.unit.agents.claude_code.agent import AGENT_DIR DOCKERFILE = (AGENT_DIR / "Dockerfile").read_text() diff --git a/tst/unit/examples/claude_code/test_message_contract.py b/tst/unit/agents/claude_code/test_message_contract.py similarity index 98% rename from tst/unit/examples/claude_code/test_message_contract.py rename to tst/unit/agents/claude_code/test_message_contract.py index 6bcc774c..3c4cd65f 100644 --- a/tst/unit/examples/claude_code/test_message_contract.py +++ b/tst/unit/agents/claude_code/test_message_contract.py @@ -10,8 +10,8 @@ from agentenv_protocol.a2a_agent import AGENT_CONFIG_V1 from agentenv_protocol.a2a_agent.peer_agents import PEER_MCP_PATH -from tst.unit.examples.claude_code.support import send, terminal -from tst.unit.examples.claude_code.agent import a2a_server, claude_bridge, kit +from tst.unit.agents.claude_code.support import send, terminal +from tst.unit.agents.claude_code.agent import a2a_server, claude_bridge, kit AGENT_CONFIG_PATH = AGENT_CONFIG_V1.endpoint MCP_CONFIG_PATH = a2a_server.MCP_CONFIG_V1.endpoint diff --git a/tst/unit/examples/claude_code/test_object_transfer.py b/tst/unit/agents/claude_code/test_object_transfer.py similarity index 98% rename from tst/unit/examples/claude_code/test_object_transfer.py rename to tst/unit/agents/claude_code/test_object_transfer.py index 81303f5e..c1ef005b 100644 --- a/tst/unit/examples/claude_code/test_object_transfer.py +++ b/tst/unit/agents/claude_code/test_object_transfer.py @@ -11,8 +11,8 @@ from pathlib import Path -from tst.unit.examples.claude_code.support import card, extension_methods, read_grant, send, write_grant -from tst.unit.examples.claude_code.agent import a2a_server, claude_bridge +from tst.unit.agents.claude_code.support import card, extension_methods, read_grant, send, write_grant +from tst.unit.agents.claude_code.agent import a2a_server, claude_bridge SKILL_CONFIG_PATH = a2a_server.SKILL_CONFIG_V1.endpoint TRAJECTORY_PATH = a2a_server.TRAJECTORY_V1.endpoint diff --git a/tst/unit/examples/claude_code/test_partial_trajectory.py b/tst/unit/agents/claude_code/test_partial_trajectory.py similarity index 98% rename from tst/unit/examples/claude_code/test_partial_trajectory.py rename to tst/unit/agents/claude_code/test_partial_trajectory.py index 2d6b46c7..fe10a237 100644 --- a/tst/unit/examples/claude_code/test_partial_trajectory.py +++ b/tst/unit/agents/claude_code/test_partial_trajectory.py @@ -8,9 +8,9 @@ import httpx import pytest -from tst.unit.examples.claude_code.support import extension_methods, send -from tst.unit.examples.claude_code.support import FakeCLI -from tst.unit.examples.claude_code.agent import a2a_server, claude_bridge +from tst.unit.agents.claude_code.support import extension_methods, send +from tst.unit.agents.claude_code.support import FakeCLI +from tst.unit.agents.claude_code.agent import a2a_server, claude_bridge TRAJECTORY_PATH = a2a_server.TRAJECTORY_V1.endpoint diff --git a/tst/unit/examples/claude_code/test_workspace_snapshot.py b/tst/unit/agents/claude_code/test_workspace_snapshot.py similarity index 98% rename from tst/unit/examples/claude_code/test_workspace_snapshot.py rename to tst/unit/agents/claude_code/test_workspace_snapshot.py index bb5e0b66..ff0b2960 100644 --- a/tst/unit/examples/claude_code/test_workspace_snapshot.py +++ b/tst/unit/agents/claude_code/test_workspace_snapshot.py @@ -10,8 +10,8 @@ from agentenv_protocol.transfers import Uploaded -from tst.unit.examples.claude_code.support import extension_methods, read_grant, write_grant -from tst.unit.examples.claude_code.agent import a2a_server, claude_bridge +from tst.unit.agents.claude_code.support import extension_methods, read_grant, write_grant +from tst.unit.agents.claude_code.agent import a2a_server, claude_bridge SNAPSHOT_PATH = a2a_server.SNAPSHOT_V1.endpoint diff --git a/tst/unit/bundle/shipped_bundles_test.py b/tst/unit/bundle/shipped_bundles_test.py new file mode 100644 index 00000000..1f2d190a --- /dev/null +++ b/tst/unit/bundle/shipped_bundles_test.py @@ -0,0 +1,46 @@ +"""Every bundle agentenv-framework ships parses, resolves and passes the checks a run makes, and a task naming no agent +plans with the shipped default. A run that pulls one entity from an installed bundle reads all of that bundle, so one +broken folder in a shipped bundle would fail every run pulling from it, the default agent's among them.""" + +from __future__ import annotations + +import json + +import pytest + +from agent_env.bundle import BundleKind, parse_bundle +from agent_env.bundle.installed import CORE, installed_bundles +from agent_env.bundle.plan import check_bundle, plan_bundle +from agent_env.bundle.resolve import resolve_bundle +from agent_env.config import get_config +from tst.unit.bundle._support import layout + + +def _shipped() -> dict: + return {bundle.name: bundle for bundle in installed_bundles() if bundle.dist == CORE} + + +SHIPPED = sorted(_shipped()) + + +def test_agentenv_framework_ships_its_bundles(): + assert SHIPPED == ["agents", "echo-mcp", "hello"] + + +@pytest.mark.parametrize("name", SHIPPED) +def test_a_shipped_bundle_parses_resolves_and_passes_the_checks_a_run_makes(name): + bundle = _shipped()[name] + assert bundle.problem is None and bundle.root is not None, bundle.problem + + check_bundle(resolve_bundle(parse_bundle(bundle.root, id_root=bundle.id_root)), installed=installed_bundles) + + +def test_a_task_naming_no_agent_plans_with_the_shipped_default(tmp_path, monkeypatch, local_stores, cli_routing): + monkeypatch.setenv("HOME", str(tmp_path)) + layout(tmp_path / "bare", {"tasks/t.json": json.dumps([{"id": "agent", "type": "deploy_agent", "env_ids": []}])}) + default = get_config().get_default_a2a_agent_id() + + plan = plan_bundle(resolve_bundle(parse_bundle(tmp_path / "bare")), installed=installed_bundles) + + assert (BundleKind.AGENT, default) in [(write.kind, write.id) for write in plan.writes] + assert plan.origins and {bundle.id_root for bundle in plan.origins.values()} == {_shipped()["agents"].id_root} diff --git a/tst/unit/test_clean_install_wheel.py b/tst/unit/test_clean_install_wheel.py index 42cc6819..41313f38 100644 --- a/tst/unit/test_clean_install_wheel.py +++ b/tst/unit/test_clean_install_wheel.py @@ -1,15 +1,17 @@ -"""The wheel check in ``.github/scripts/clean_install.py``: every tracked example file and bundle entry point -must be in the built wheel.""" +"""The wheel check in ``.github/scripts/clean_install.py``: every tracked file of each shipped bundle, and every bundle +entry point, must be in the built wheel.""" from __future__ import annotations import importlib.util +import sys import zipfile from pathlib import Path import pytest SCRIPT = Path(__file__).resolve().parents[2] / ".github" / "scripts" / "clean_install.py" +sys.path.insert(0, str(SCRIPT.parent)) # as running the script does, so it imports its sibling shipped_bundles _spec = importlib.util.spec_from_file_location("clean_install", SCRIPT) clean_install = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(clean_install) @@ -55,3 +57,10 @@ def test_bundle_entry_points_that_differ_from_pyproject_are_named(tmp_path, entr assert problem.startswith("::error file=pyproject.toml::agentenv_framework-1.0-py3-none-any.whl registers the " "agent_env.bundles entry points") assert problem.endswith("but pyproject.toml declares {'hello': 'agent_env.examples'}") + + +def test_the_gate_checks_the_folder_of_every_shipped_bundle(): + roots = {name: root.relative_to(SCRIPT.parents[2]).as_posix() for name, root in clean_install.bundle_roots().items()} + + assert roots == {"agents": "src/agent_env/agents", "echo-mcp": "src/agent_env/examples/echo-mcp", + "hello": "src/agent_env/examples/hello"} diff --git a/tst/unit/test_release_protocol_pins.py b/tst/unit/test_release_protocol_pins.py index f666ec1e..7be67622 100644 --- a/tst/unit/test_release_protocol_pins.py +++ b/tst/unit/test_release_protocol_pins.py @@ -3,23 +3,46 @@ from __future__ import annotations +import importlib.util import re from pathlib import Path REPO = Path(__file__).resolve().parents[2] WORKFLOW = (REPO / ".github" / "workflows" / "publish-pypi.yml").read_text() +_spec = importlib.util.spec_from_file_location("shipped_bundles", REPO / ".github" / "scripts" / "shipped_bundles.py") +shipped_bundles = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(shipped_bundles) def test_the_pyproject_holds_the_one_quoted_protocol_requirement_the_release_pins(): assert len(re.findall(r'"agentenv-framework-protocol[^"]*"', (REPO / "pyproject.toml").read_text())) == 1 -def test_each_shipped_example_requirements_file_the_release_pins_holds_one_protocol_line(): - (pattern,) = re.findall(r"^\s*for requirements in (\S+); do$", WORKFLOW, re.MULTILINE) +def test_the_release_pins_the_requirements_files_the_shipped_bundles_hold(): + assert "requirements_files=$(python .github/scripts/shipped_bundles.py)" in WORKFLOW + assert re.search(r"^\s*for requirements in \$requirements_files; do$", WORKFLOW, re.MULTILINE) + + +def test_each_shipped_requirements_file_the_release_pins_holds_one_protocol_line(): pinned = {path.relative_to(REPO).as_posix(): [line for line in path.read_text().splitlines() if line.startswith("agentenv-framework-protocol")] - for path in REPO.glob(pattern)} + for path in shipped_bundles.requirements_files()} assert {"src/agent_env/examples/echo-mcp/envs/echo/requirements.txt", - "src/agent_env/examples/agents/agents/claude-code/requirements.txt"} <= set(pinned) + "src/agent_env/agents/agents/claude-code/requirements.txt"} <= set(pinned) assert all(len(lines) == 1 for lines in pinned.values()), pinned + + +def test_an_agent_added_to_a_shipped_bundle_is_pinned_with_the_rest(tmp_path): + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n\n[project.entry-points."agent_env.bundles"]\n' + 'agents = "agent_env"\nhello = "agent_env.examples"\n') + (tmp_path / "src/agent_env/examples/hello/tasks").mkdir(parents=True) + for agent in ("claude-code", "another"): + folder = tmp_path / "src/agent_env/agents/agents" / agent + folder.mkdir(parents=True) + (folder / "requirements.txt").write_text("agentenv-framework-protocol[agent]\n") + + assert [path.relative_to(tmp_path).as_posix() for path in shipped_bundles.requirements_files(tmp_path)] == [ + "src/agent_env/agents/agents/another/requirements.txt", + "src/agent_env/agents/agents/claude-code/requirements.txt", + ] From 95e481b2e805740fb794e6866b2719e27f25b233 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 10:21:40 -0700 Subject: [PATCH 20/24] fix(agents): a cancelled turn ends only once its transcript scrub has The scrub ran in a thread, so a second cancel interrupted its wait while the thread ran on. The context was then free for another turn, whose records the scrub's replace could erase. The scrub now runs inline, which no cancel can interrupt: a 50 MB transcript takes about 50 ms. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/claude-code/claude_bridge.py | 6 ++-- .../claude_code/test_claude_bridge_cancel.py | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/agent_env/agents/agents/claude-code/claude_bridge.py b/src/agent_env/agents/agents/claude-code/claude_bridge.py index 2030f700..ff9dbfaf 100644 --- a/src/agent_env/agents/agents/claude-code/claude_bridge.py +++ b/src/agent_env/agents/agents/claude-code/claude_bridge.py @@ -405,13 +405,15 @@ async def run_claude( attempts += 1 attempt_timeout_s = (timeout_s if attempts == 1 else max(int(deadline - time.monotonic()) - _DEADLINE_HEADROOM_S, 1)) + # The scrubs run here, not in a thread: a cancelled wait would release the context while one could still + # replace the transcript a later turn appends to. A 50 MB transcript takes about 50 ms. try: result = await spawn(prompt=attempt_prompt, session_id=attempt_session, resume=attempt_resume, timeout_s=attempt_timeout_s) finally: - await asyncio.to_thread(scrub_session_transcript, attempt_session) + scrub_session_transcript(attempt_session) if result.session_id != attempt_session: - await asyncio.to_thread(scrub_session_transcript, result.session_id) + scrub_session_transcript(result.session_id) tool_calls += result.tool_call_count failure = _resumable_api_failure(result) diff --git a/tst/unit/agents/claude_code/test_claude_bridge_cancel.py b/tst/unit/agents/claude_code/test_claude_bridge_cancel.py index af23967b..18ddb11c 100644 --- a/tst/unit/agents/claude_code/test_claude_bridge_cancel.py +++ b/tst/unit/agents/claude_code/test_claude_bridge_cancel.py @@ -121,3 +121,35 @@ async def cancel_while_starting(): cli, tool = asyncio.run(cancel_while_starting()) assert _gone(cli) and _gone(tool) + + +def test_a_turn_cancelled_again_while_it_scrubs_its_transcript_ends_only_once_the_scrub_has(monkeypatch, tmp_path): + scrubbed = [] + real_scrub = claude_bridge.scrub_session_transcript + + def slow_scrub(session_id): + time.sleep(0.3) + real_scrub(session_id) + scrubbed.append(session_id) + + spawn = asyncio.create_subprocess_exec + + async def cli_that_hangs(*_cmd, **kwargs): + return await spawn("sleep", "600", **kwargs) + + monkeypatch.setattr(claude_bridge, "SESSION_JSONL_DIR", tmp_path) + monkeypatch.setattr(claude_bridge, "scrub_session_transcript", slow_scrub) + monkeypatch.setattr(asyncio, "create_subprocess_exec", cli_that_hangs) + + async def cancel_twice(): + turn = asyncio.create_task(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) + await asyncio.sleep(0.2) + turn.cancel() + await asyncio.sleep(0.1) + turn.cancel() # mid-scrub, as a caller giving up on a turn that is slow to end does + with pytest.raises(asyncio.CancelledError): + await turn + # The turn is over, so its context can take another: no scrub may be left to replace that turn's transcript. + return list(scrubbed) + + assert asyncio.run(cancel_twice()) == ["sess-1"] From cf0dc2ecfe7a29a850daf5e7754ba834456df576 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 11:11:32 -0700 Subject: [PATCH 21/24] fix(agents): a retried host install stops the agent an earlier attempt started install_agent empties its work dir before each attempt, and a host install kept its files and agent.pid there, so a retry found no agent to stop: the earlier one kept the port, and the retry's health check could pass on it. The host install now lives in /tmp/agentenv-agent---, outside the work dir, as a container's lives in /opt. A local sandbox's teardown still removes it, since the name ends in the sandbox's id. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/agents/claude-code/install.sh | 7 ++- .../run_bundle_claude_code_install_test.py | 47 ++++++++++++++++--- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/agent_env/agents/agents/claude-code/install.sh b/src/agent_env/agents/agents/claude-code/install.sh index 5130d28b..d7e780d9 100755 --- a/src/agent_env/agents/agents/claude-code/install.sh +++ b/src/agent_env/agents/agents/claude-code/install.sh @@ -2,7 +2,7 @@ # Install this agent from the folder this script is in, and start it on PORT, without an image of its own: # # install.sh container NAME PORT into the running container NAME, as root, in its working directory -# install.sh host NAME PORT WORKDIR onto this Linux host, beside this script, as agent NAME, in WORKDIR +# install.sh host NAME PORT WORKDIR onto this Linux host, as agent NAME, in WORKDIR # # It brings Claude Code's native build and uv, each checked against its published checksum, and the agent's # requirements in a private Python, so the target's own Python and packages are never touched; only a target with @@ -34,7 +34,10 @@ case $mode in ;; host) [ "$(uname -s)" = Linux ] || { echo "a host install needs a Linux host, not $(uname -s)" >&2; exit 1; } - root=$here/../agent-$target + # Outside the work dir install_agent empties before each attempt, as a container's is, so a retry stops the agent an + # earlier attempt started. It ends in the sandbox's id, which names what a local sandbox's teardown removes from + # /tmp; a VM's host goes with the VM. + root=/tmp/agentenv-agent-$target-$port${AGENTENV_SANDBOX:+-$AGENTENV_SANDBOX} workdir=$4 on() { sh -c "$1"; } put() { cp "$1" "$2.new" && mv -f "$2.new" "$2"; } diff --git a/tst/integration/cli/run_bundle_claude_code_install_test.py b/tst/integration/cli/run_bundle_claude_code_install_test.py index 5ccd759a..7ef041bf 100644 --- a/tst/integration/cli/run_bundle_claude_code_install_test.py +++ b/tst/integration/cli/run_bundle_claude_code_install_test.py @@ -4,6 +4,7 @@ running a command where the task's files are. Needs a Docker daemon, and network access for Claude Code, uv, Python and the agent's requirements.""" +import glob import json import logging import platform @@ -151,8 +152,9 @@ def test_a_retried_install_replaces_the_agent_an_earlier_attempt_left_running_in assert endpoint.tool_results() == [f"{NOTES}\n/workspace/task_repo\n{SKILL_BODY}"], seen -@pytest.mark.skipif(platform.system() != "Linux", reason="a host install needs a Linux host") -def test_install_agent_with_no_container_installs_claude_code_onto_the_sandboxs_host(monkeypatch, state, endpoint): +def _run_on_the_host(monkeypatch, state: Path, bundle: Path): + """``agent-env run`` on ``bundle``, with the grants a process on the host reaches, and what it left in /tmp of a + host install's root.""" # A process on the host can't resolve host.docker.internal: the grants name the bridge address, which it reaches. bridge = _host_ips()[-1] config = state / "config.toml" @@ -162,14 +164,45 @@ def test_install_agent_with_no_container_installs_claude_code_onto_the_sandboxs_ monkeypatch.delenv("AGENT_ENV_OBJECT_STORE") configure() reset_artifact_store() - endpoint.respond = tool_then_answer("Bash", {"command": f"echo {NOTES}; {READ_SKILL}"}) - + before = set(glob.glob("/tmp/agentenv-agent-*")) try: - # A name with a space, which the install's paths carry. - result = CliRunner().invoke(cli, ["run", str(_bundle(state / "bundle", None, agents=("code helper",)))]) + result = CliRunner().invoke(cli, ["run", str(bundle)]) finally: grant_server(bridge, bridge).close() + return result, set(glob.glob("/tmp/agentenv-agent-*")) - before - seen = (result.output, endpoint.tool_results()) + +@pytest.mark.skipif(platform.system() != "Linux", reason="a host install needs a Linux host") +def test_install_agent_with_no_container_installs_claude_code_onto_the_sandboxs_host(monkeypatch, state, endpoint): + endpoint.respond = tool_then_answer("Bash", {"command": f"echo {NOTES}; {READ_SKILL}"}) + + # A name with a space, which the install's paths carry. + result, left = _run_on_the_host(monkeypatch, state, _bundle(state / "bundle", None, agents=("code helper",))) + + seen = (result.output, endpoint.tool_results(), left) + assert result.exit_code == 0 and "tasks/install.json v1: passed" in result.output, seen + assert endpoint.tool_results() == [f"{NOTES}\n{SKILL_BODY}"], seen + assert not left, seen # the sandbox's teardown removed the install + + +@pytest.mark.skipif(platform.system() != "Linux", reason="a host install needs a Linux host") +def test_a_retried_host_install_stops_the_agent_an_earlier_attempt_started(monkeypatch, state, endpoint): + """install_agent empties its work dir before each attempt, so the first install's agent must live outside it: + found there, it is stopped, rather than keeping the port the retry's agent needs.""" + endpoint.respond = tool_then_answer("Bash", {"command": f"echo {NOTES}; {READ_SKILL}"}) + endpoint.fail_next(Fault("status", status=400)) + bundle = _bundle(state / "bundle", None, agents=("helper",)) + task = bundle / "tasks/install.json" + steps = json.loads(task.read_text()) + for step in steps: + if step["id"] == "ask-helper": + step["retry_config"] = {"retry_from_step_id": "install-helper", "max_retries": 1} + task.write_text(json.dumps(steps)) + + result, left = _run_on_the_host(monkeypatch, state, bundle) + + seen = (result.output, endpoint.tool_results(), left) assert result.exit_code == 0 and "tasks/install.json v1: passed" in result.output, seen + assert result.output.count("install-helper (install_agent)") == 2, seen assert endpoint.tool_results() == [f"{NOTES}\n{SKILL_BODY}"], seen + assert not left, seen From d05dbd2738943b499b830b1e482e1ae88f8103fd Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 11:11:32 -0700 Subject: [PATCH 22/24] fix(agents): a cancelled turn's file-part download ends with it The download ran in a thread, which went on after the turn was cancelled and its context released, so it could write into a workspace a snapshot load had just restored. It is now awaited on the event loop, so a cancel stops the request and nothing is written after. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/agents/claude-code/kit.py | 23 +++++------ .../claude_code/test_message_contract.py | 38 +++++++++++++++++-- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/agent_env/agents/agents/claude-code/kit.py b/src/agent_env/agents/agents/claude-code/kit.py index 17eef298..d5ddae26 100644 --- a/src/agent_env/agents/agents/claude-code/kit.py +++ b/src/agent_env/agents/agents/claude-code/kit.py @@ -3,7 +3,6 @@ from __future__ import annotations -import asyncio import base64 import binascii import logging @@ -175,17 +174,18 @@ def _safe_name(candidate: str | None) -> str: return candidate or uuid.uuid4().hex -def _download_uri_to(uri: str, target: Path) -> None: +async def _download_uri_to(uri: str, target: Path) -> None: scheme = urlsplit(uri).scheme.lower() if scheme not in ("http", "https"): raise FilePartMaterializeError( f"unsupported URI scheme {scheme!r} in {_shown(uri)}: a file part reaches this agent as bytes or an " "http(s) URL, and agent-env sends an object its stores own as an https one") try: - with _redacting_logged_urls(), httpx.Client(timeout=_FILEPART_DOWNLOAD_TIMEOUT_S, follow_redirects=True) as client: - response = client.get(uri) - response.raise_for_status() - target.write_bytes(response.content) + with _redacting_logged_urls(): + async with httpx.AsyncClient(timeout=_FILEPART_DOWNLOAD_TIMEOUT_S, follow_redirects=True) as client: + response = await client.get(uri) + response.raise_for_status() + target.write_bytes(response.content) except httpx.HTTPStatusError as e: # not chained: httpx's messages can repeat the full URL raise FilePartMaterializeError( f"http(s) download failed for {_shown(uri)}: HTTP {e.response.status_code}") from None @@ -194,7 +194,7 @@ def _download_uri_to(uri: str, target: Path) -> None: f"http(s) download failed for {_shown(uri)}: {_without_credentials(str(e), uri)}") from None -def _materialize(part: FilePart, target_dir: Path) -> str: +async def _materialize(part: FilePart, target_dir: Path) -> str: target_dir.mkdir(parents=True, exist_ok=True) name = _safe_name(part.name) target = target_dir / name @@ -208,12 +208,13 @@ def _materialize(part: FilePart, target_dir: Path) -> str: except OSError as e: raise FilePartMaterializeError(f"failed to write FilePart '{name}': {e}") from e else: - _download_uri_to(part.uri, target) + await _download_uri_to(part.uri, target) logger.info(f"Materialized FilePart name='{name}' mime={part.mime_type} size={target.stat().st_size}B to {target}") return str(target) async def materialize_file_part(part: FilePart, target_dir: Path) -> str: - """Write a FilePart's base64 bytes or http(s) contents to ``target_dir``, off the event loop; the path. Two - parts of one message sharing a name are kept apart. Raises FilePartMaterializeError.""" - return await asyncio.to_thread(_materialize, part, target_dir) + """Write a FilePart's base64 bytes or http(s) contents to ``target_dir``; the path. Two parts of one message + sharing a name are kept apart. The download is awaited, not run in a thread, so a cancelled turn's ends with it + and writes nothing after. Raises FilePartMaterializeError.""" + return await _materialize(part, target_dir) diff --git a/tst/unit/agents/claude_code/test_message_contract.py b/tst/unit/agents/claude_code/test_message_contract.py index 3c4cd65f..2127b344 100644 --- a/tst/unit/agents/claude_code/test_message_contract.py +++ b/tst/unit/agents/claude_code/test_message_contract.py @@ -3,11 +3,13 @@ from __future__ import annotations +import asyncio import base64 +import time import httpx import pytest -from agentenv_protocol.a2a_agent import AGENT_CONFIG_V1 +from agentenv_protocol.a2a_agent import AGENT_CONFIG_V1, FilePart from agentenv_protocol.a2a_agent.peer_agents import PEER_MCP_PATH from tst.unit.agents.claude_code.support import send, terminal @@ -223,13 +225,13 @@ async def _materialize(part, target_dir): def _serving(monkeypatch, routes: dict[str, tuple[int, bytes]]) -> None: """The agent's file-part downloads answered from ``routes``, a URL's status and body, with no network.""" - client_class = httpx.Client + client_class = httpx.AsyncClient def answer(request: httpx.Request) -> httpx.Response: status, body = routes[str(request.url)] return httpx.Response(status, content=body) - monkeypatch.setattr(kit.httpx, "Client", + monkeypatch.setattr(kit.httpx, "AsyncClient", lambda **kwargs: client_class(transport=httpx.MockTransport(answer), **kwargs)) @@ -247,6 +249,36 @@ def test_a_file_part_naming_an_https_url_is_downloaded_where_the_cli_reads_it(mo assert call["prompt"] == f"summarize\n\n{path}" and path.read_bytes() == b"%PDF-1.7 report" +def test_a_cancelled_download_writes_nothing_after_it_is_cancelled(monkeypatch, tmp_path): + # Cancelling ends the turn, and with it the context a snapshot load waits on: nothing may write after that. + url = "https://store.example.com/bucket/a.png" + sync_client, async_client = httpx.Client, httpx.AsyncClient + + def slow(request: httpx.Request) -> httpx.Response: + time.sleep(0.5) + return httpx.Response(200, content=b"late") + + async def slow_async(request: httpx.Request) -> httpx.Response: + await asyncio.sleep(0.5) + return httpx.Response(200, content=b"late") + + monkeypatch.setattr(kit.httpx, "Client", lambda **kwargs: sync_client(transport=httpx.MockTransport(slow), **kwargs)) + monkeypatch.setattr(kit.httpx, "AsyncClient", + lambda **kwargs: async_client(transport=httpx.MockTransport(slow_async), **kwargs)) + + async def cancel_mid_download(): + download = asyncio.create_task(kit.materialize_file_part(FilePart(name="a.png", uri=url), tmp_path / "in")) + await asyncio.sleep(0.1) + download.cancel() + with pytest.raises(asyncio.CancelledError): + await download + await asyncio.sleep(0.8) # past when the download would have finished + + asyncio.run(cancel_mid_download()) + + assert not any((tmp_path / "in").glob("*")) + + def test_a_failed_download_fails_the_turn_without_the_urls_signature(monkeypatch, calls, client): url = "https://store.example.com/bucket/a.png?X-Amz-Signature=sig-123" _serving(monkeypatch, {url: (403, b"denied")}) From 9024a37ddaeec5983b20c7ed78f724d4201c9d5e Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 11:33:30 -0700 Subject: [PATCH 23/24] fix(agents): a host install's folders in /tmp are private, and one another user made is refused /tmp is shared, so another user could make the predictable folders a host install uses before it does, then read the model key the install writes there, or change the install's files. - install.sh creates the host install's folder 0700. One already there must be a folder of this user's own, not a link, or the install stops. - install_agent makes its work dir afresh with mkdir -m 0700, which fails, rather than reusing it, if another user's folder took its place. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/agents/claude-code/install.sh | 9 +++ .../task_step/task_steps/install_agent.py | 3 +- .../claude_code/test_install_sh_host_root.py | 67 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tst/unit/agents/claude_code/test_install_sh_host_root.py diff --git a/src/agent_env/agents/agents/claude-code/install.sh b/src/agent_env/agents/agents/claude-code/install.sh index d7e780d9..134f15df 100755 --- a/src/agent_env/agents/agents/claude-code/install.sh +++ b/src/agent_env/agents/agents/claude-code/install.sh @@ -45,6 +45,15 @@ case $mode in *) echo "usage: install.sh container NAME PORT | install.sh host NAME PORT WORKDIR" >&2; exit 2 ;; esac +if [ "$mode" = host ]; then + # /tmp is shared, and the folder will hold the model key: it is this user's alone, and one another made is refused. + if ! mkdir -m 0700 "$root" 2> /dev/null; then + [ -d "$root" ] && [ ! -L "$root" ] && [ -O "$root" ] \ + || { echo "$root is already there and isn't a folder of this user's own" >&2; exit 1; } + chmod 0700 "$root" + fi +fi + case $(on 'uname -m') in x86_64|amd64) cpu=x64 uv_cpu=x86_64 uv_sha256=$UV_SHA256_X86_64 ;; aarch64|arm64) cpu=arm64 uv_cpu=aarch64 uv_sha256=$UV_SHA256_AARCH64 ;; diff --git a/src/agent_env/task_step/task_steps/install_agent.py b/src/agent_env/task_step/task_steps/install_agent.py index 52d43ee8..40df1eb2 100644 --- a/src/agent_env/task_step/task_steps/install_agent.py +++ b/src/agent_env/task_step/task_steps/install_agent.py @@ -260,7 +260,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: # Stage the agent build context onto the VM. The install_commands docker-cp from this dir. work_dir = f"/tmp/install-agent-{sandbox.scoped_name(self.agent_name)}" agent_ctx_tar = f"{work_dir}/agent-ctx.tar.gz" - await sandbox.exec_script(f"rm -rf {shlex.quote(work_dir)} && mkdir -p {shlex.quote(work_dir)}") + # Private, and made afresh: /tmp is shared, and a folder another user made in its place would hold the install. + await sandbox.exec_script(f"rm -rf {shlex.quote(work_dir)} && mkdir -m 0700 {shlex.quote(work_dir)}") logger.info(f"Downloading agent build context {agent.docker_image_artifact.build_context_object_url} -> {agent_ctx_tar}") await sandbox.load_object_file(agent.docker_image_artifact.build_context_object_url, agent_ctx_tar) diff --git a/tst/unit/agents/claude_code/test_install_sh_host_root.py b/tst/unit/agents/claude_code/test_install_sh_host_root.py new file mode 100644 index 00000000..9a986ec3 --- /dev/null +++ b/tst/unit/agents/claude_code/test_install_sh_host_root.py @@ -0,0 +1,67 @@ +"""A host install's folder in the shared /tmp holds the model key, so ``install.sh`` makes it this user's alone and +refuses one that is already there and isn't.""" + +from __future__ import annotations + +import os +import platform +import shutil +import stat +import subprocess +import uuid +from pathlib import Path + +import pytest + +from tst.unit.agents.claude_code.agent import AGENT_DIR + +pytestmark = pytest.mark.skipif(platform.system() != "Linux", reason="install.sh installs onto a Linux host only") + + +def _install(tmp_path: Path, root_named) -> subprocess.CompletedProcess: + """``install.sh host`` from a copy of the agent's folder, with a curl that fails so it stops at its first download. + ``root_named`` is given the folder it will use, before it runs.""" + folder = tmp_path / "agent" + folder.mkdir() + for name in ("install.sh", "Dockerfile"): + shutil.copy(AGENT_DIR / name, folder / name) + (tmp_path / "bin").mkdir() + (tmp_path / "bin" / "curl").write_text("#!/bin/sh\nexit 22\n") + (tmp_path / "bin" / "curl").chmod(0o755) + agent, port, sandbox = f"t-{uuid.uuid4().hex[:8]}", "18999", f"sb-{uuid.uuid4().hex[:8]}" + root_named(Path(f"/tmp/agentenv-agent-{agent}-{port}-{sandbox}")) + return subprocess.run(["bash", str(folder / "install.sh"), "host", agent, port, str(tmp_path / "app")], + env={"PATH": f"{tmp_path / 'bin'}:/usr/bin:/bin", "HOME": str(tmp_path), + "AGENTENV_SANDBOX": sandbox}, + capture_output=True, text=True, timeout=60) + + +def test_a_host_install_makes_its_folder_this_users_alone(tmp_path): + roots: list[Path] = [] + try: + result = _install(tmp_path, roots.append) + (root,) = roots + assert root.is_dir() and stat.S_IMODE(root.stat().st_mode) == 0o700, result.stderr + assert root.stat().st_uid == os.getuid() + finally: + for root in roots: + shutil.rmtree(root, ignore_errors=True) + + +def test_a_host_install_refuses_a_folder_it_did_not_make_in_its_place(tmp_path): + planted = tmp_path / "elsewhere" + planted.mkdir() + roots: list[Path] = [] + + def plant(root: Path) -> None: + root.symlink_to(planted) # as another user could, pointing where they read + roots.append(root) + + try: + result = _install(tmp_path, plant) + finally: + for root in roots: + root.unlink() + + assert result.returncode == 1 and "isn't a folder of this user's own" in result.stderr, result.stderr + assert not any(planted.iterdir()) From a9ce01b1a5c9559df3db99d6e7347a778f3b3874 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 11:33:30 -0700 Subject: [PATCH 24/24] fix(agents): file writes and the transcript scrub run off the event loop, each awaited to its end The last two fixes moved them onto the event loop, so a large attachment or transcript stalled health checks and other conversations. They now run in a thread, which in_thread_to_its_end awaits until it ends even when the turn is cancelled: the cancel is raised after, so nothing outlives the turn. The download itself stays an awaited request, so a cancel stops it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/claude-code/claude_bridge.py | 10 +++-- .../agents/agents/claude-code/kit.py | 40 ++++++++++++++++--- .../claude_code/test_claude_bridge_cancel.py | 15 ++++++- .../claude_code/test_message_contract.py | 35 ++++++++++++++++ 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/src/agent_env/agents/agents/claude-code/claude_bridge.py b/src/agent_env/agents/agents/claude-code/claude_bridge.py index ff9dbfaf..fffe306f 100644 --- a/src/agent_env/agents/agents/claude-code/claude_bridge.py +++ b/src/agent_env/agents/agents/claude-code/claude_bridge.py @@ -17,6 +17,8 @@ from functools import partial from pathlib import Path +from kit import in_thread_to_its_end + logger = logging.getLogger(__name__) # The CLI's own state: settings, skills and session transcripts. The image fixes it, so it doesn't follow the uid. @@ -405,15 +407,15 @@ async def run_claude( attempts += 1 attempt_timeout_s = (timeout_s if attempts == 1 else max(int(deadline - time.monotonic()) - _DEADLINE_HEADROOM_S, 1)) - # The scrubs run here, not in a thread: a cancelled wait would release the context while one could still - # replace the transcript a later turn appends to. A 50 MB transcript takes about 50 ms. + # Each scrub ends before the turn does, cancelled or not: a later turn on the context mustn't start while it + # can still replace the transcript that turn appends to. try: result = await spawn(prompt=attempt_prompt, session_id=attempt_session, resume=attempt_resume, timeout_s=attempt_timeout_s) finally: - scrub_session_transcript(attempt_session) + await in_thread_to_its_end(scrub_session_transcript, attempt_session) if result.session_id != attempt_session: - scrub_session_transcript(result.session_id) + await in_thread_to_its_end(scrub_session_transcript, result.session_id) tool_calls += result.tool_call_count failure = _resumable_api_failure(result) diff --git a/src/agent_env/agents/agents/claude-code/kit.py b/src/agent_env/agents/agents/claude-code/kit.py index d5ddae26..03e8fb1e 100644 --- a/src/agent_env/agents/agents/claude-code/kit.py +++ b/src/agent_env/agents/agents/claude-code/kit.py @@ -3,6 +3,7 @@ from __future__ import annotations +import asyncio import base64 import binascii import logging @@ -10,10 +11,11 @@ import shutil import tempfile import uuid -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import contextmanager from contextvars import ContextVar from pathlib import Path +from typing import TypeVar from urllib.parse import urlsplit, urlunsplit import httpx @@ -131,6 +133,34 @@ def _redacting_logged_urls() -> Iterator[None]: _REDACT_LOGGED_URLS.reset(token) +_T = TypeVar("_T") + + +async def in_thread_to_its_end(func: Callable[..., _T], /, *args: object) -> _T: + """``func(*args)`` in a thread, off the event loop, and awaited to its end even when the caller is cancelled, so + nothing it does outlives a cancelled turn; the cancel is raised once it has ended.""" + work = asyncio.ensure_future(asyncio.to_thread(func, *args)) + cancelled = False + while not work.done(): + try: + await asyncio.shield(work) + except asyncio.CancelledError: + cancelled = True + except Exception: + break + if cancelled: + raise asyncio.CancelledError from (None if work.cancelled() else work.exception()) + return work.result() + + +def _write_bytes(target: Path, data: bytes) -> None: + target.write_bytes(data) + + +def _write_base64(target: Path, data: str) -> None: + _write_bytes(target, base64.b64decode(data, validate=True)) + + class FilePartMaterializeError(Exception): """A FilePart that can't be written to disk, its message free of the URI's credentials.""" @@ -185,13 +215,13 @@ async def _download_uri_to(uri: str, target: Path) -> None: async with httpx.AsyncClient(timeout=_FILEPART_DOWNLOAD_TIMEOUT_S, follow_redirects=True) as client: response = await client.get(uri) response.raise_for_status() - target.write_bytes(response.content) except httpx.HTTPStatusError as e: # not chained: httpx's messages can repeat the full URL raise FilePartMaterializeError( f"http(s) download failed for {_shown(uri)}: HTTP {e.response.status_code}") from None except httpx.HTTPError as e: raise FilePartMaterializeError( f"http(s) download failed for {_shown(uri)}: {_without_credentials(str(e), uri)}") from None + await in_thread_to_its_end(_write_bytes, target, response.content) async def _materialize(part: FilePart, target_dir: Path) -> str: @@ -202,7 +232,7 @@ async def _materialize(part: FilePart, target_dir: Path) -> str: target = target_dir / f"{uuid.uuid4().hex[:_NAME_PREFIX_CHARS]}-{name}" if part.bytes is not None: try: - target.write_bytes(base64.b64decode(part.bytes, validate=True)) + await in_thread_to_its_end(_write_base64, target, part.bytes) except (binascii.Error, ValueError) as e: raise FilePartMaterializeError(f"malformed base64 in FilePart '{name}': {e}") from e except OSError as e: @@ -215,6 +245,6 @@ async def _materialize(part: FilePart, target_dir: Path) -> str: async def materialize_file_part(part: FilePart, target_dir: Path) -> str: """Write a FilePart's base64 bytes or http(s) contents to ``target_dir``; the path. Two parts of one message - sharing a name are kept apart. The download is awaited, not run in a thread, so a cancelled turn's ends with it - and writes nothing after. Raises FilePartMaterializeError.""" + sharing a name are kept apart. The download is awaited and the decoding and writing done off the event loop, each + to its end, so a cancelled turn's ends before the turn does. Raises FilePartMaterializeError.""" return await _materialize(part, target_dir) diff --git a/tst/unit/agents/claude_code/test_claude_bridge_cancel.py b/tst/unit/agents/claude_code/test_claude_bridge_cancel.py index 18ddb11c..137a4817 100644 --- a/tst/unit/agents/claude_code/test_claude_bridge_cancel.py +++ b/tst/unit/agents/claude_code/test_claude_bridge_cancel.py @@ -125,12 +125,14 @@ async def cancel_while_starting(): def test_a_turn_cancelled_again_while_it_scrubs_its_transcript_ends_only_once_the_scrub_has(monkeypatch, tmp_path): scrubbed = [] + ticks: list[int] = [] real_scrub = claude_bridge.scrub_session_transcript def slow_scrub(session_id): + before = len(ticks) time.sleep(0.3) real_scrub(session_id) - scrubbed.append(session_id) + scrubbed.append((session_id, len(ticks) - before)) spawn = asyncio.create_subprocess_exec @@ -142,6 +144,12 @@ async def cli_that_hangs(*_cmd, **kwargs): monkeypatch.setattr(asyncio, "create_subprocess_exec", cli_that_hangs) async def cancel_twice(): + async def tick(): + while True: + ticks.append(1) + await asyncio.sleep(0.01) + + ticker = asyncio.create_task(tick()) turn = asyncio.create_task(claude_bridge.run_claude(prompt="p", session_id="sess-1", resume=False)) await asyncio.sleep(0.2) turn.cancel() @@ -149,7 +157,10 @@ async def cancel_twice(): turn.cancel() # mid-scrub, as a caller giving up on a turn that is slow to end does with pytest.raises(asyncio.CancelledError): await turn + ticker.cancel() # The turn is over, so its context can take another: no scrub may be left to replace that turn's transcript. return list(scrubbed) - assert asyncio.run(cancel_twice()) == ["sess-1"] + ((session, ticks_while_scrubbing),) = asyncio.run(cancel_twice()) + + assert session == "sess-1" and ticks_while_scrubbing >= 5 # off the event loop, which ran meanwhile diff --git a/tst/unit/agents/claude_code/test_message_contract.py b/tst/unit/agents/claude_code/test_message_contract.py index 2127b344..22f8e434 100644 --- a/tst/unit/agents/claude_code/test_message_contract.py +++ b/tst/unit/agents/claude_code/test_message_contract.py @@ -279,6 +279,41 @@ async def cancel_mid_download(): assert not any((tmp_path / "in").glob("*")) +def test_an_attachment_is_written_off_the_event_loop_and_a_cancel_waits_for_the_write(monkeypatch, tmp_path): + ticks: list[int] = [] + written: list[int] = [] + write = kit._write_bytes + + def slow_write(target, data): + before = len(ticks) + time.sleep(0.3) + write(target, data) + written.append(len(ticks) - before) + + monkeypatch.setattr(kit, "_write_bytes", slow_write) + + async def cancel_mid_write(): + async def tick(): + while True: + ticks.append(1) + await asyncio.sleep(0.01) + + ticker = asyncio.create_task(tick()) + part = FilePart(name="a.png", bytes=base64.b64encode(b"png").decode()) + writing = asyncio.create_task(kit.materialize_file_part(part, tmp_path / "in")) + await asyncio.sleep(0.1) + writing.cancel() + with pytest.raises(asyncio.CancelledError): + await writing + ticker.cancel() + return list(written) # as the cancel reached the caller + + (ticks_while_writing,) = asyncio.run(cancel_mid_write()) + + assert ticks_while_writing >= 5 # health checks and other turns ran meanwhile + assert (tmp_path / "in" / "a.png").read_bytes() == b"png" + + def test_a_failed_download_fails_the_turn_without_the_urls_signature(monkeypatch, calls, client): url = "https://store.example.com/bucket/a.png?X-Amz-Signature=sig-123" _serving(monkeypatch, {url: (403, b"denied")})