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/.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 48282736..6c8497b0 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -93,10 +93,15 @@ 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 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" + 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/AGENTS.md b/AGENTS.md index 7e9d49c2..ae173562 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,10 +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 - `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/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 + 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 @@ -111,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`. `task run` and `eval run` hand their runs to the configured `[runner]` and follow them. | -| `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 @@ -252,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 b4e7ef57..9f2a650f 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" hello = "agent_env.examples" echo-mcp = "agent_env.examples" diff --git a/src/agent_env/agents/README.md b/src/agent_env/agents/README.md new file mode 100644 index 00000000..39a89d17 --- /dev/null +++ b/src/agent_env/agents/README.md @@ -0,0 +1,23 @@ +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. 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`. 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 +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. + +`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/agents/agents/claude-code/Dockerfile b/src/agent_env/agents/agents/claude-code/Dockerfile new file mode 100644 index 00000000..bfe40329 --- /dev/null +++ b/src/agent_env/agents/agents/claude-code/Dockerfile @@ -0,0 +1,36 @@ +# 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 \ + && 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 +COPY --from=node /usr/local/bin/node /usr/local/bin/node +WORKDIR /opt/agent +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. +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/agents/agents/claude-code/a2a_server.py b/src/agent_env/agents/agents/claude-code/a2a_server.py new file mode 100644 index 00000000..c1272850 --- /dev/null +++ b/src/agent_env/agents/agents/claude-code/a2a_server.py @@ -0,0 +1,590 @@ +"""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 posixpath +import shutil +import stat +import tarfile +import tempfile +import uuid +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +from a2a.types import AgentSkill +from agentenv_protocol.a2a_agent import ( + INSTALL_V1, + MCP_CONFIG_V1, + PEER_AGENTS_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, + WORKSPACE_DIR, + read_partial_session_trajectory, + redact_session_records, + 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 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 +# 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 + 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 _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: + 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) + 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(_STAGE_CHUNK_BYTES, 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 + # 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) + 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, its API error records + redacted.""" + data = path.read_bytes() + if not data: + raise ValueError("session transcript is empty") + 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: + 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, keep: Iterable[Path] = ()) -> None: + """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() + 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") + 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) + for path, copy in held: + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(copy, path) + + +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(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, " + "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).")), + ), +) +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) + 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: + 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 + + # 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, + 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()) + # 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: + keep = [session_jsonl_path(session) for session in self.bound_session_refs()] + try: + 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) + 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/agents/agents/claude-code/claude_bridge.py b/src/agent_env/agents/agents/claude-code/claude_bridge.py new file mode 100644 index 00000000..fffe306f --- /dev/null +++ b/src/agent_env/agents/agents/claude-code/claude_bridge.py @@ -0,0 +1,700 @@ +"""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 stat +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field, replace +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. +CLAUDE_STATE_DIR = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude") + + +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 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() +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 = ( + "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(_redact_values(record) if _is_api_error(record) else 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, 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"] + 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 _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() + + +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)) + # 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: + await in_thread_to_its_end(scrub_session_transcript, attempt_session) + if result.session_id != attempt_session: + await in_thread_to_its_end(scrub_session_transcript, result.session_id) + 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 + + +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 +# 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: + """``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})") + WORKSPACE_DIR.mkdir(parents=True, exist_ok=True) + proc = await _started(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), + cwd=WORKSPACE_DIR, + 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[:_LOGGED_LINE_CHARS]}") + return + 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": + 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=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. + _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/agents/agents/claude-code/entrypoint.sh b/src/agent_env/agents/agents/claude-code/entrypoint.sh new file mode 100755 index 00000000..292b5398 --- /dev/null +++ b/src/agent_env/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/agents/agents/claude-code/install.sh b/src/agent_env/agents/agents/claude-code/install.sh new file mode 100755 index 00000000..134f15df --- /dev/null +++ b/src/agent_env/agents/agents/claude-code/install.sh @@ -0,0 +1,151 @@ +#!/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, 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 +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.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; } + # 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"; } + ;; + *) 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 ;; + *) 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")"; } +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") +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' "$(quoted "$port")" "$(quoted "$root/claude")" + printf 'DISABLE_AUTOUPDATER=1\nCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1\n' +} > "$stage/agent.env" + +# 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" +for file in $AGENT_FILES; do + put "$here/$file" "$root/app/$file" +done +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 --clear --python $PYTHON_VERSION venv \ + && bin/uv pip install --quiet --python venv/bin/python -r app/requirements.txt \ + && rm -rf $r/cache" + +if [ "$mode" = container ]; then + 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" + 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 "$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 $r/agent.log" >&2 || true +exit 1 diff --git a/src/agent_env/agents/agents/claude-code/kit.py b/src/agent_env/agents/agents/claude-code/kit.py new file mode 100644 index 00000000..03e8fb1e --- /dev/null +++ b/src/agent_env/agents/agents/claude-code/kit.py @@ -0,0 +1,250 @@ +"""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 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 +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 + + 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}) + + +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) +_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): + """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) + + +_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.""" + + 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 + + +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(): + async with httpx.AsyncClient(timeout=_FILEPART_DOWNLOAD_TIMEOUT_S, follow_redirects=True) as client: + response = await client.get(uri) + response.raise_for_status() + 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: + 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[:_NAME_PREFIX_CHARS]}-{name}" + if part.bytes is not None: + try: + 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: + raise FilePartMaterializeError(f"failed to write FilePart '{name}': {e}") from e + else: + 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``; the path. Two parts of one message + 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/src/agent_env/agents/agents/claude-code/model-requirements.txt b/src/agent_env/agents/agents/claude-code/model-requirements.txt new file mode 100644 index 00000000..150d9797 --- /dev/null +++ b/src/agent_env/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/src/agent_env/agents/agents/claude-code/requirements.txt b/src/agent_env/agents/agents/claude-code/requirements.txt new file mode 100644 index 00000000..0fbd1aa1 --- /dev/null +++ b/src/agent_env/agents/agents/claude-code/requirements.txt @@ -0,0 +1,3 @@ +# 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/src/agent_env/agents/agents/claude-code/run.sh b/src/agent_env/agents/agents/claude-code/run.sh new file mode 100755 index 00000000..6d430dc3 --- /dev/null +++ b/src/agent_env/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/agents/tasks/claude-code-smoke.json b/src/agent_env/agents/tasks/claude-code-smoke.json new file mode 100644 index 00000000..c28c5ece --- /dev/null +++ b/src/agent_env/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/src/agent_env/config/runtime.py b/src/agent_env/config/runtime.py index 31d33014..952d931d 100644 --- a/src/agent_env/config/runtime.py +++ b/src/agent_env/config/runtime.py @@ -78,7 +78,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: @@ -386,7 +386,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/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..40df1eb2 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, } @@ -250,14 +260,18 @@ 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) + 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 +282,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 +339,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/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 diff --git a/tst/integration/cli/bundle_init_scopes_test.py b/tst/integration/cli/bundle_init_scopes_test.py index 58847e39..039d393c 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, @@ -169,14 +169,17 @@ 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 "registry.acme.example/solver:v1" in (machines.root / "agents-user" / "agents" / "acme-solver" / "agent.toml").read_text() assert "Unknown sandbox backend: 'acme_vm'" 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 "Unknown sandbox backend: 'acme_vm'" in _refused("run", machines.root / "agents-user", "--dry-run") 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..7ef041bf --- /dev/null +++ b/tst/integration/cli/run_bundle_claude_code_install_test.py @@ -0,0 +1,208 @@ +"""``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 glob +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, Fault, 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" +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. +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 _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": 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": 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) + 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 + + +@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 + + +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 + + +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 + + +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" + 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() + before = set(glob.glob("/tmp/agentenv-agent-*")) + try: + result = CliRunner().invoke(cli, ["run", str(bundle)]) + finally: + grant_server(bridge, bridge).close() + return result, set(glob.glob("/tmp/agentenv-agent-*")) - before + + +@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 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..8d5448bf --- /dev/null +++ b/tst/integration/cli/run_bundle_claude_code_test.py @@ -0,0 +1,227 @@ +"""``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 asyncio +import json +import logging +import uuid + +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, 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 +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, delegate_then_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}: 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" + 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.", "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) + + 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 + + +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"} + + +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] + + +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/agents/__init__.py b/tst/unit/agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tst/unit/agents/claude_code/__init__.py b/tst/unit/agents/claude_code/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tst/unit/agents/claude_code/agent.py b/tst/unit/agents/claude_code/agent.py new file mode 100644 index 00000000..f7d454ad --- /dev/null +++ b/tst/unit/agents/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 / "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/agents/claude_code/conftest.py b/tst/unit/agents/claude_code/conftest.py new file mode 100644 index 00000000..ee1bd367 --- /dev/null +++ b/tst/unit/agents/claude_code/conftest.py @@ -0,0 +1,53 @@ +"""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.agents.claude_code.agent import a2a_server, claude_bridge +from tst.unit.agents.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") + 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 + + +@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/agents/claude_code/support.py b/tst/unit/agents/claude_code/support.py new file mode 100644 index 00000000..df39ea61 --- /dev/null +++ b/tst/unit/agents/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/agents/claude_code/test_card_contract.py b/tst/unit/agents/claude_code/test_card_contract.py new file mode 100644 index 00000000..bd964ead --- /dev/null +++ b/tst/unit/agents/claude_code/test_card_contract.py @@ -0,0 +1,175 @@ +"""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 re +import subprocess +import sys + +from agent_env.task_step.task_steps.install_agent import _build_param_resolvers + +from tst.unit.agents.claude_code.agent import AGENT_DIR +from tst.unit.agents.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: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, {}), + "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 _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()))") + 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) + (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/agents/claude_code/test_claude_bridge_cancel.py b/tst/unit/agents/claude_code/test_claude_bridge_cancel.py new file mode 100644 index 00000000..137a4817 --- /dev/null +++ b/tst/unit/agents/claude_code/test_claude_bridge_cancel.py @@ -0,0 +1,166 @@ +"""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.agents.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()) + + +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) + + +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, len(ticks) - before)) + + 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(): + 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() + 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 + 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) + + ((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_claude_bridge_failure_message.py b/tst/unit/agents/claude_code/test_claude_bridge_failure_message.py new file mode 100644 index 00000000..57330835 --- /dev/null +++ b/tst/unit/agents/claude_code/test_claude_bridge_failure_message.py @@ -0,0 +1,241 @@ +"""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.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 +_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, **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, **kwargs)) + + 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_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; " + '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/agents/claude_code/test_claude_bridge_live.py b/tst/unit/agents/claude_code/test_claude_bridge_live.py new file mode 100644 index 00000000..049dc845 --- /dev/null +++ b/tst/unit/agents/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.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": {}}]}} +_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/agents/claude_code/test_claude_bridge_retry.py b/tst/unit/agents/claude_code/test_claude_bridge_retry.py new file mode 100644 index 00000000..35e1999e --- /dev/null +++ b/tst/unit/agents/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.agents.claude_code.support import FakeCLI +from tst.unit.agents.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/agents/claude_code/test_claude_bridge_stdin.py b/tst/unit/agents/claude_code/test_claude_bridge_stdin.py new file mode 100644 index 00000000..02a775bc --- /dev/null +++ b/tst/unit/agents/claude_code/test_claude_bridge_stdin.py @@ -0,0 +1,129 @@ +"""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.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) + + +_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 + + +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/agents/claude_code/test_cli_env.py b/tst/unit/agents/claude_code/test_cli_env.py new file mode 100644 index 00000000..8381c644 --- /dev/null +++ b/tst/unit/agents/claude_code/test_cli_env.py @@ -0,0 +1,166 @@ +"""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 +from pathlib import Path + +import pytest + +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", + "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_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"}) + + 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"], 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) + 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") + + +@pytest.mark.parametrize("workspace, project", [ + ("/app", "-app"), + ("/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" + 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/agents/claude_code/test_ext_mcp_name.py b/tst/unit/agents/claude_code/test_ext_mcp_name.py new file mode 100644 index 00000000..a371de41 --- /dev/null +++ b/tst/unit/agents/claude_code/test_ext_mcp_name.py @@ -0,0 +1,21 @@ +"""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.agents.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"], "peer_agents"} + + +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/agents/claude_code/test_extensions.py b/tst/unit/agents/claude_code/test_extensions.py new file mode 100644 index 00000000..50920180 --- /dev/null +++ b/tst/unit/agents/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.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 + + +# ── 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/agents/claude_code/test_image_contract.py b/tst/unit/agents/claude_code/test_image_contract.py new file mode 100644 index 00000000..b3ec360e --- /dev/null +++ b/tst/unit/agents/claude_code/test_image_contract.py @@ -0,0 +1,54 @@ +"""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.agents.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 (?!--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. + 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(): + requirements = [line for line in (AGENT_DIR / "requirements.txt").read_text().splitlines() + if line and not line.startswith("#")] + + assert requirements == ["agentenv-framework-protocol[agent]>=0.1.351"] + + +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 + 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/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()) diff --git a/tst/unit/agents/claude_code/test_message_contract.py b/tst/unit/agents/claude_code/test_message_contract.py new file mode 100644 index 00000000..22f8e434 --- /dev/null +++ b/tst/unit/agents/claude_code/test_message_contract.py @@ -0,0 +1,334 @@ +"""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 asyncio +import base64 +import time + +import httpx +import pytest +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 +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 +# 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): + """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) + 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 + + +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"}}, + "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 + 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(): + 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() + + +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.AsyncClient + + def answer(request: httpx.Request) -> httpx.Response: + status, body = routes[str(request.url)] + return httpx.Response(status, content=body) + + monkeypatch.setattr(kit.httpx, "AsyncClient", + 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_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_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")}) + 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 == [] diff --git a/tst/unit/agents/claude_code/test_object_transfer.py b/tst/unit/agents/claude_code/test_object_transfer.py new file mode 100644 index 00000000..c1ef005b --- /dev/null +++ b/tst/unit/agents/claude_code/test_object_transfer.py @@ -0,0 +1,297 @@ +"""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.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 +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 == [] + 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 + + 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 + + +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 + + +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/agents/claude_code/test_partial_trajectory.py b/tst/unit/agents/claude_code/test_partial_trajectory.py new file mode 100644 index 00000000..fe10a237 --- /dev/null +++ b/tst/unit/agents/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.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 + + +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/agents/claude_code/test_workspace_snapshot.py b/tst/unit/agents/claude_code/test_workspace_snapshot.py new file mode 100644 index 00000000..ff0b2960 --- /dev/null +++ b/tst/unit/agents/claude_code/test_workspace_snapshot.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import asyncio +import hashlib +import io +import json +import os +import tarfile +from pathlib import Path + +from agentenv_protocol.transfers import Uploaded + +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 + + +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} + } + + +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" + + +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") diff --git a/tst/unit/bundle/importer_test.py b/tst/unit/bundle/importer_test.py index 9bba0024..e9ce9ba7 100644 --- a/tst/unit/bundle/importer_test.py +++ b/tst/unit/bundle/importer_test.py @@ -22,7 +22,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 @@ -106,13 +106,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), ] @@ -244,7 +244,7 @@ def test_an_env_a_reference_names_with_no_version_is_written_at_its_latest(store assert [(entity.folder, entity.version) for entity in imported.written] == [("envs/crm-env", 2)] assert _steps(imported.root, "live")[0]["env_ids"] == ["crm-env"] assert [(ref.kind.value, ref.id, ref.where) for ref in imported.floating] == [ - ("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_has_writes_that_version_and_is_dropped_and_one_the_store_doesnt_hold_is_refused(store): @@ -593,8 +593,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/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/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/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(): 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" 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 e0b65e5e..7be67622 100644 --- a/tst/unit/test_release_protocol_pins.py +++ b/tst/unit/test_release_protocol_pins.py @@ -3,19 +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_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() +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) - assert len([line for line in lines if line.startswith("agentenv-framework-protocol")]) == 1 + +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 shipped_bundles.requirements_files()} + + assert {"src/agent_env/examples/echo-mcp/envs/echo/requirements.txt", + "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", + ] diff --git a/tst/util/fake_anthropic.py b/tst/util/fake_anthropic.py new file mode 100644 index 00000000..78e4acec --- /dev/null +++ b/tst/util/fake_anthropic.py @@ -0,0 +1,233 @@ +"""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 import deque +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 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) + 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 + + +@dataclass +class FakeAnthropic: + host: str + respond: Responder = field(default_factory=lambda: answer("pong")) + # 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) + _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 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 + + +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"}), + ]