From 8532ed65aefafe83d75427df8f1c7bc24ec7f51a Mon Sep 17 00:00:00 2001 From: Matthew Wright Date: Sat, 29 Aug 2026 18:51:50 -0500 Subject: [PATCH 1/2] feat: add Claude Code agent Register a `claude` agent alongside `cursor` so the same Given/When/Then steps can drive Claude Code runs. Closes #3. Agent binding (src/agents/claude/, docker/claude/Dockerfile) - Image installs the Claude Code native binary on agent-gwt/base:local, relocated to /usr/local/bin so any host uid can run it. Auto-update, telemetry and error reporting are disabled before any build-time invocation. ~/.claude is pre-created so a read-only credentials mount cannot leave it root-owned. - Runs `claude -p --output-format json --dangerously-skip-permissions [--model ..] -- ` as the host uid/gid with only the workspace mounted. `--bare` is deliberately not used: it disables OAuth, which subscription users need. - Credentials resolve in order: CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`), ANTHROPIC_API_KEY, then a Linux host's ~/.claude/.credentials.json mounted read-only. macOS keeps the token in the Keychain, so env is the only option there. Secrets are forwarded to `docker run` by name (`-e NAME`) through the docker CLI's environment and never appear on the host argv. - Result JSON is typed as ClaudeAgentResult; a run reporting `is_error: true` throws with Claude's own message so the real cause surfaces instead of a downstream assertion. Shared changes - buildDockerRunArgs gains `envPassthrough` (`-e NAME`) and runDocker accepts `env` for the docker CLI process. - New agentRunError formats non-zero `docker run` exits for both agents, with a build hint naming the image and `buildAgentImage("")`. Cursor's copy pointed at a `docker:build` script that does not exist. - CONTAINER_HOME / CONTAINER_WORKSPACE move to agents/base; the public barrel exports them from there. Same names and values. - docker/base: enable pacman's DisableSandboxSyscalls. The seccomp part of pacman 7's download sandbox cannot load under Rosetta/QEMU emulation (Docker Desktop on Apple Silicon), which broke every image build there. Upstream already disables the Landlock part; the alpm user drop and NO_NEW_PRIVS are kept, and native x86_64 hosts see no practical difference. A grep guard fails the build loudly if the config line is ever renamed. Docs and tests - README: Claude auth prerequisites and usage beside the existing Cursor examples, ClaudeAgentResult, Apple Silicon note (DOCKER_DEFAULT_PLATFORM=linux/amd64). CONTRIBUTING: agents/claude row and the pnpm pr-tag cache tip. - Specs mirror the Cursor patterns: claude/run.spec.ts (args, credential resolution, secret handling, error surfacing), run-error.spec.ts, and curried steps in agent.spec.ts / build-agent-image.spec.ts covering both agents. Verified end to end on Apple Silicon: `buildAgentImage("claude")` builds both images, and a vitest-gwt suite (Sonnet, OAuth token) writes a file into the workspace and returns a typed result. --- CONTRIBUTING.md | 9 +- README.md | 64 +++- docker/base/Dockerfile | 13 +- docker/claude/Dockerfile | 33 ++ e2e/claude.spec.ts | 53 +++ e2e/global-setup.ts | 5 + e2e/vitest.e2e.config.ts | 12 + package.json | 2 + src/agents/base/constants.ts | 5 + src/agents/base/index.ts | 7 +- src/agents/build-agent-image.spec.ts | 46 ++- src/agents/claude/agent.ts | 11 + src/agents/claude/constants.ts | 14 + src/agents/claude/index.ts | 17 + src/agents/claude/run.spec.ts | 466 +++++++++++++++++++++++++++ src/agents/claude/run.ts | 204 ++++++++++++ src/agents/cursor/constants.ts | 4 +- src/agents/cursor/index.ts | 2 - src/agents/cursor/run.spec.ts | 3 +- src/agents/cursor/run.ts | 23 +- src/agents/docker.spec.ts | 26 ++ src/agents/docker.ts | 7 + src/agents/registry.ts | 2 + src/agents/run-error.spec.ts | 93 ++++++ src/agents/run-error.ts | 28 ++ src/agents/types.ts | 9 + src/given/agent.spec.ts | 67 ++-- src/index.ts | 25 +- src/package-root.spec.ts | 9 +- 29 files changed, 1183 insertions(+), 76 deletions(-) create mode 100644 docker/claude/Dockerfile create mode 100644 e2e/claude.spec.ts create mode 100644 e2e/global-setup.ts create mode 100644 e2e/vitest.e2e.config.ts create mode 100644 src/agents/claude/agent.ts create mode 100644 src/agents/claude/constants.ts create mode 100644 src/agents/claude/index.ts create mode 100644 src/agents/claude/run.spec.ts create mode 100644 src/agents/claude/run.ts create mode 100644 src/agents/run-error.spec.ts create mode 100644 src/agents/run-error.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dab4db5..bf32344 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,12 @@ Open PRs against `main` publish a prerelease under the dist-tag `pr-`: pnpm add -D agent-gwt@pr-123 ``` +pnpm caches aggressively, so after the pipeline publishes a newer build to the same tag, force a re-resolve: + +```bash +pnpm update agent-gwt@pr-123 +``` + ## Releasing Merging a PR stages the exact prerelease bits as the next semver (not live until approved): @@ -28,8 +34,9 @@ Bump size is controlled by PR labels (`major` > `minor` > patch default). See [P | `agents/` | Shared `createAgent`, Docker invoke, image ensure/build | | `agents/base/` | Shared Arch base image constants (`agent-gwt/base:local`) | | `agents/cursor/` | Cursor bindings only (Dockerfile path, image, auth, CLI run) | +| `agents/claude/` | Claude Code bindings only (Dockerfile path, image, credentials, CLI run) | | `docker/base/` | Shared Arch + yay Dockerfile (all agents `FROM` this tag) | | `docker//` | Per-agent Dockerfile (`FROM agent-gwt/base:local` + that product’s CLI) | | `package-root` | Relative resolve to this package’s root (`src/` or `lib/` parent) — no directory scans | -Additional agents (Devin, Claude, Copilot, …) add `docker//Dockerfile` on the shared base, a folder under `agents/`, and a registry entry. +Additional agents (Devin, Copilot, …) add `docker//Dockerfile` on the shared base, a folder under `agents/`, and a registry entry. diff --git a/README.md b/README.md index ae425bd..b64fe6a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ GWT step functions for repeatable **agent** tests. Works with [vitest-gwt](https://github.com/devzeebo/vitest-gwt) / [gwt-runner](https://github.com/devzeebo/gwt-runner). -v1 ships the **Cursor** agent: create a temp workspace, mount **only** Cursor credentials, run the agent as your host user, and put parsed `--output-format json` on the test context. +Ships the **Cursor** and **Claude Code** agents: create a temp workspace, mount **only** the agent's credentials, run the agent as your host user, and put parsed `--output-format json` on the test context. ## Install @@ -13,12 +13,19 @@ pnpm add -D agent-gwt vitest vitest-gwt ## Prerequisites 1. Docker -2. Host Cursor CLI login (`agent login`) so `~/.config/cursor/auth.json` exists +2. Host login for the agent(s) you use: + - **Cursor:** `agent login` so `~/.config/cursor/auth.json` exists + - **Claude Code:** `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token` — Claude subscription) **or** `ANTHROPIC_API_KEY` in the environment, **or** a Linux host's `~/.claude/.credentials.json`. Checked in that order. macOS keeps Claude Code's login in the Keychain, so on a Mac set one of the two variables: + + ```bash + claude setup-token # prints a long-lived token + export CLAUDE_CODE_OAUTH_TOKEN= # or: export ANTHROPIC_API_KEY=sk-ant-... + ``` 3. Build the agent Docker image **once per suite** via vitest `globalSetup` (or manually) ## Setup -Build images once in `globalSetup` so parallel test files do not race: +Build images once in `globalSetup` so parallel test files do not race. Build only the agents your suite uses: ```ts // vitest.global-setup.ts @@ -26,6 +33,7 @@ import { buildAgentImage } from "agent-gwt"; export default async function setup() { await buildAgentImage("cursor"); + await buildAgentImage("claude"); } ``` @@ -44,6 +52,13 @@ export default defineConfig({ Wire the agent and a disposable workspace with `withAspect`, then write Given/When/Then tests. Agent runs are slow — raise the timeout. +Pick the agent with `agent({ name, model })`; nothing else in the test changes: + +```ts +withAspect(agent({ name: "cursor", model: "auto" })); // Cursor CLI +withAspect(agent({ name: "claude", model: "sonnet" })); // Claude Code +``` + ### Simple prompt ```ts @@ -159,14 +174,43 @@ async function question_is_answered(this: Context) { } ``` +### Inspecting the result + +`this.agentResult` is the parsed JSON the CLI printed, for either agent. For Claude Code, `ClaudeAgentResult` types the useful fields: + +```ts +import type { ClaudeAgentResult } from "agent-gwt"; + +function used_one_turn(this: Context) { + const result = this.agentResult as ClaudeAgentResult; + + expect(result.is_error).toBe(false); + expect(result.num_turns).toBeGreaterThan(0); + expect(result.total_cost_usd).toBeLessThan(0.5); +} +``` + +A Claude run whose JSON reports `is_error: true` throws from `executing_the_agent` with the agent's message, so a failing run surfaces as the real cause rather than a downstream assertion. + ## Docker images | Image | Role | | --- | --- | | `agent-gwt/base:local` | Shared Arch Linux base (`yay` + `aur` user). Used by all agents. | | `agent-gwt/cursor-cli:local` | Cursor CLI on top of the base | +| `agent-gwt/claude-code:local` | Claude Code CLI (native binary) on top of the base | + +`buildAgentImage("cursor")` builds the base first, then the Cursor image; `buildAgentImage("claude")` does the same for Claude Code. + +### Apple Silicon + +The official `archlinux` image is x86_64-only. On an arm64 Docker host, build and run under amd64 emulation: + +```bash +export DOCKER_DEFAULT_PLATFORM=linux/amd64 +``` -`buildAgentImage("cursor")` builds the base first, then the Cursor image. +Docker Desktop applies this to both `docker build` and `docker run`, so nothing in the library changes. ### Extending with toolchains @@ -175,6 +219,7 @@ Install packages in a child image, then point tests at that tag: ```dockerfile # docker/agent.Dockerfile FROM agent-gwt/cursor-cli:local +# or: FROM agent-gwt/claude-code:local # Official Arch packages (as root) RUN pacman -Sy --noconfirm --needed nodejs npm python rust \ @@ -218,7 +263,9 @@ Pair workspace lifecycle separately: `withAspect(a_workspace, cleanup_workspace) ## What `executing_the_agent` does 1. Requires `this.workspace`, `this.prompt`, and `this.agent` -2. Calls `this.agent.run(...)` with `this.image` (Cursor: `docker run` with credentials-only mount + `agent -p --force --output-format json`) +2. Calls `this.agent.run(...)` with `this.image`: + - Cursor: `docker run` with credentials-only mount + `agent -p --force --output-format json [--model …] -- ` + - Claude: `docker run` with the workspace mount and credentials forwarded by env **name** (the value never appears on the host command line) or a read-only `.credentials.json` mount + `claude -p --output-format json --dangerously-skip-permissions [--model …] -- ` 3. Sets `this.agentResult` to the parsed JSON ## Exports @@ -226,7 +273,7 @@ Pair workspace lifecycle separately: `withAspect(a_workspace, cleanup_workspace) | Export | Role | | --- | --- | | `AgentContext` | Extensible context type (`workspace`, `prompt`, `agent`, `image`, …) | -| `agent(opts)` | `withAspect` before — `{ name, model?, image? }` | +| `agent(opts)` | `withAspect` before — `{ name: "cursor" \| "claude", model?, image? }` | | `buildAgentImage(name)` | Suite setup — builds base + agent image (use in vitest `globalSetup`) | | `buildBaseImage()` | Builds `agent-gwt/base:local` only | | `buildDockerImage(...)` | Builds an arbitrary Dockerfile (e.g. toolchain overlay) | @@ -234,11 +281,14 @@ Pair workspace lifecycle separately: `withAspect(a_workspace, cleanup_workspace) | `cleanup_workspace` | Remove the temp workspace (use in `withAspect` after) | | `the_prompt(text)` | Curried `given` — sets `this.prompt` | | `executing_the_agent` | `when` — runs `this.agent.run(...)` | +| `ClaudeAgentResult` | Type for Claude Code's JSON result (`is_error`, `result`, `num_turns`, `total_cost_usd`, …) | +| `ClaudeCredentials` / `resolveClaudeCredentials()` | Credential source for the Claude container (token, API key, or file) | ## Isolation notes -- **Credentials only:** settings, MCP config, projects, and skills from `~/.cursor` are not mounted. +- **Credentials only:** settings, MCP config, projects, and skills from `~/.cursor` are not mounted. Likewise nothing from `~/.claude` (settings, MCP servers, plugins, skills, projects, hooks) reaches the Claude container, and its auto-updater, telemetry, and error reporting are disabled in the image. - **Non-root:** the container process uses your host uid/gid so workspace files are owned by you. +- **Workspace is the only writable host path.** A `CLAUDE.md` seeded into the workspace is honoured, because Claude Code reads it from the working directory. ## Contributing diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index fc22d33..1ebb17d 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -1,9 +1,18 @@ -# Shared base for agent-gwt agent images (cursor, and future agents). +# Shared base for agent-gwt agent images (cursor, claude, and future agents). # Extend with: FROM agent-gwt/base:local # AUR installs (build-time only): USER aur && yay -S --noconfirm ... && USER root FROM archlinux:latest -RUN pacman -Sy --noconfirm --needed \ +# pacman 7 sandboxes its downloader: drops to the `alpm` user, then applies a +# Landlock filesystem rule and a seccomp syscall denylist. Upstream already +# disables the Landlock half (no Landlock in container kernels). The seccomp +# half cannot load under Rosetta/QEMU user-mode emulation (every seccomp entry +# point returns EINVAL, regardless of --privileged or seccomp=unconfined), which +# breaks builds on Apple Silicon. Disabling only the syscall filter keeps the +# user drop + NO_NEW_PRIVS and is a no-op difference on native x86_64 hosts. +RUN sed -i 's/^#DisableSandboxSyscalls/DisableSandboxSyscalls/' /etc/pacman.conf \ + && grep -q '^DisableSandboxSyscalls' /etc/pacman.conf \ + && pacman -Sy --noconfirm --needed \ base-devel git sudo curl ca-certificates \ && useradd -m aur \ && echo 'aur ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/aur \ diff --git a/docker/claude/Dockerfile b/docker/claude/Dockerfile new file mode 100644 index 0000000..90a9836 --- /dev/null +++ b/docker/claude/Dockerfile @@ -0,0 +1,33 @@ +# Claude Code agent image. Shared Arch/yay base is agent-gwt/base:local. +# Extend with toolchains: FROM agent-gwt/claude-code:local +FROM agent-gwt/base:local + +# Never self-update or phone home from inside the container — build steps included. +ENV DISABLE_AUTOUPDATER=1 +ENV DISABLE_TELEMETRY=1 +ENV DISABLE_ERROR_REPORTING=1 + +# Official native installer drops a single self-contained binary under +# ~/.local/share/claude/versions/. Relocate it to a world-readable +# path so arbitrary host UIDs (docker --user) can run claude. +RUN curl -fsSL https://claude.ai/install.sh | bash \ + && VERSION_BIN="$(find /root/.local/share/claude/versions -mindepth 1 -maxdepth 1 -type f | sort -V | tail -1)" \ + && test -n "$VERSION_BIN" \ + && install -m 0755 "$VERSION_BIN" /usr/local/bin/claude \ + && rm -rf /root/.local/share/claude /root/.local/bin/claude \ + && claude --version + +# Empty home for arbitrary host UIDs. ~/.claude is pre-created so a read-only +# .credentials.json bind mount does not leave the directory root-owned. +RUN mkdir -p /home/agent/.claude \ + && chmod -R 0777 /home/agent + +ENV HOME=/home/agent +ENV PATH="/usr/local/bin:${PATH}" + +WORKDIR /workspace + +# Runtime identity is set by agent-gwt via --user :. +# Credentials arrive as env (CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY) or a +# read-only ~/.claude/.credentials.json mount — see src/agents/claude/run.ts. +# No ENTRYPOINT — the library passes `claude ...` as the container command. diff --git a/e2e/claude.spec.ts b/e2e/claude.spec.ts new file mode 100644 index 0000000..a0e0d88 --- /dev/null +++ b/e2e/claude.spec.ts @@ -0,0 +1,53 @@ +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect } from "vitest"; +import test, { withAspect } from "vitest-gwt"; +import { + type AgentContext, + type ClaudeAgentResult, + a_workspace, + agent, + cleanup_workspace, + executing_the_agent, + the_prompt, +} from "../src/index.js"; + +describe("claude agent (e2e)", () => { + withAspect(agent({ name: "claude", model: "sonnet" })); + withAspect(a_workspace, cleanup_workspace); + + test("writes the readme", { + given: { + the_prompt: the_prompt("Write 'Hello World' to README.md"), + }, + when: { + executing_the_agent, + }, + then: { + readme_exists, + readme_contains_HELLO_WORLD, + result_is_a_successful_claude_run, + }, + }); +}); + +type Context = AgentContext; + +async function readme_exists(this: Context) { + await access(join(this.workspace, "README.md")); +} + +async function readme_contains_HELLO_WORLD(this: Context) { + const contents = await readFile(join(this.workspace, "README.md"), "utf-8"); + + expect(contents.toLowerCase()).toContain("hello world"); +} + +function result_is_a_successful_claude_run(this: Context) { + const result = this.agentResult as ClaudeAgentResult; + + expect(result.type).toBe("result"); + expect(result.is_error).toBe(false); + expect(result.num_turns).toBeGreaterThan(0); + console.log(`claude: ${result.num_turns} turns, $${result.total_cost_usd.toFixed(4)}, session ${result.session_id}`); +} diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..fcdd2f9 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,5 @@ +import { buildAgentImage } from "../src/index.js"; + +export default async function setup() { + await buildAgentImage("claude"); +} diff --git a/e2e/vitest.e2e.config.ts b/e2e/vitest.e2e.config.ts new file mode 100644 index 0000000..81ab76a --- /dev/null +++ b/e2e/vitest.e2e.config.ts @@ -0,0 +1,12 @@ +// Scratch e2e config — not part of the package. Run with: +// pnpm exec vitest run --config e2e/vitest.e2e.config.ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["e2e/**/*.spec.ts"], + globalSetup: ["./e2e/global-setup.ts"], + testTimeout: 180_000, + hookTimeout: 600_000, + }, +}); diff --git a/package.json b/package.json index 9a6af01..d1b5eaa 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "description": "GWT step functions for repeatable agent tests", "keywords": [ "agent", + "claude", + "claude-code", "cursor", "gwt", "testing", diff --git a/src/agents/base/constants.ts b/src/agents/base/constants.ts index 239a37f..af7f417 100644 --- a/src/agents/base/constants.ts +++ b/src/agents/base/constants.ts @@ -1,2 +1,7 @@ export const BASE_IMAGE = "agent-gwt/base:local"; export const BASE_DOCKERFILE_RELATIVE = "docker/base/Dockerfile"; + +/** Home directory inside every agent image; world-writable so any host uid can use it. */ +export const CONTAINER_HOME = "/home/agent"; +/** Bind-mount target for the test workspace inside every agent image. */ +export const CONTAINER_WORKSPACE = "/workspace"; diff --git a/src/agents/base/index.ts b/src/agents/base/index.ts index 50cf7e3..27ab512 100644 --- a/src/agents/base/index.ts +++ b/src/agents/base/index.ts @@ -1 +1,6 @@ -export { BASE_IMAGE, BASE_DOCKERFILE_RELATIVE } from "./constants.js"; +export { + BASE_IMAGE, + BASE_DOCKERFILE_RELATIVE, + CONTAINER_HOME, + CONTAINER_WORKSPACE, +} from "./constants.js"; diff --git a/src/agents/build-agent-image.spec.ts b/src/agents/build-agent-image.spec.ts index f85c43e..86ea2cc 100644 --- a/src/agents/build-agent-image.spec.ts +++ b/src/agents/build-agent-image.spec.ts @@ -11,7 +11,7 @@ import { resetBuiltImages, } from "./build-agent-image.js"; import { BASE_DOCKERFILE_RELATIVE, BASE_IMAGE } from "./base/constants.js"; -import { agentRegistry } from "./registry.js"; +import { agentRegistry, type AgentName } from "./registry.js"; import type { DockerRunOptions, DockerRunner } from "./types.js"; type BuildContext = { @@ -125,13 +125,25 @@ describe("buildBaseImage", () => { describe("buildAgentImage", () => { test("delegates to the resolved agent's buildImage", { given: { - stub_cursor_build_image, + stub_agent_build_image: stub_agent_build_image("cursor"), }, when: { - building_cursor_image, + building_agent_image: building_agent_image("cursor"), }, then: { - agent_build_was_called, + agent_build_was_called: agent_build_was_called("cursor"), + }, + }); + + test("delegates to the claude agent's buildImage", { + given: { + stub_agent_build_image: stub_agent_build_image("claude"), + }, + when: { + building_agent_image: building_agent_image("claude"), + }, + then: { + agent_build_was_called: agent_build_was_called("claude"), }, }); }); @@ -273,18 +285,24 @@ function error_mentions_failed_build(this: BuildContext) { expect(this.error?.message).toContain("build boom"); } -function stub_cursor_build_image(this: AgentBuildContext) { - this.buildCalls = 0; - vi.spyOn(agentRegistry.cursor, "buildImage").mockImplementation(async () => { - this.buildCalls += 1; - }); +function stub_agent_build_image(name: AgentName) { + return function (this: AgentBuildContext) { + this.buildCalls = 0; + vi.spyOn(agentRegistry[name], "buildImage").mockImplementation(async () => { + this.buildCalls += 1; + }); + }; } -async function building_cursor_image() { - await buildAgentImage("cursor"); +function building_agent_image(name: AgentName) { + return async () => { + await buildAgentImage(name); + }; } -function agent_build_was_called(this: AgentBuildContext) { - expect(this.buildCalls).toBe(1); - expect(agentRegistry.cursor.buildImage).toHaveBeenCalledWith(); +function agent_build_was_called(name: AgentName) { + return function (this: AgentBuildContext) { + expect(this.buildCalls).toBe(1); + expect(agentRegistry[name].buildImage).toHaveBeenCalledWith(); + }; } diff --git a/src/agents/claude/agent.ts b/src/agents/claude/agent.ts new file mode 100644 index 0000000..c9709aa --- /dev/null +++ b/src/agents/claude/agent.ts @@ -0,0 +1,11 @@ +import { PACKAGE_ROOT } from "../../package-root.js"; +import { createAgent } from "../create-agent.js"; +import { CLAUDE_DOCKERFILE_RELATIVE, CLAUDE_IMAGE } from "./constants.js"; +import { runClaudeInDocker } from "./run.js"; + +export const claudeAgent = createAgent({ + dockerfileRelative: CLAUDE_DOCKERFILE_RELATIVE, + packageRoot: PACKAGE_ROOT, + image: CLAUDE_IMAGE, + run: runClaudeInDocker, +}); diff --git a/src/agents/claude/constants.ts b/src/agents/claude/constants.ts new file mode 100644 index 0000000..3cc939e --- /dev/null +++ b/src/agents/claude/constants.ts @@ -0,0 +1,14 @@ +import { CONTAINER_HOME } from "../base/constants.js"; + +export const CLAUDE_IMAGE = "agent-gwt/claude-code:local"; +export const CLAUDE_DOCKERFILE_RELATIVE = "docker/claude/Dockerfile"; +export const CLAUDE_CONTAINER_CREDENTIALS_PATH = `${CONTAINER_HOME}/.claude/.credentials.json`; + +/** Long-lived OAuth token from `claude setup-token` (Claude subscription). */ +export const CLAUDE_OAUTH_TOKEN_ENV = "CLAUDE_CODE_OAUTH_TOKEN"; +/** Anthropic API key (pay-as-you-go). */ +export const CLAUDE_API_KEY_ENV = "ANTHROPIC_API_KEY"; + +/** Linux hosts persist OAuth credentials here; macOS uses the Keychain instead. */ +export const defaultClaudeHostCredentialsFile = (home: string): string => + `${home}/.claude/.credentials.json`; diff --git a/src/agents/claude/index.ts b/src/agents/claude/index.ts new file mode 100644 index 0000000..1a087ef --- /dev/null +++ b/src/agents/claude/index.ts @@ -0,0 +1,17 @@ +export { + CLAUDE_API_KEY_ENV, + CLAUDE_CONTAINER_CREDENTIALS_PATH, + CLAUDE_DOCKERFILE_RELATIVE, + CLAUDE_IMAGE, + CLAUDE_OAUTH_TOKEN_ENV, + defaultClaudeHostCredentialsFile, +} from "./constants.js"; +export { + buildClaudeDockerArgs, + resolveClaudeCredentials, + runClaudeInDocker, + type ClaudeAgentResult, + type ClaudeCredentials, + type RunClaudeInDockerOptions, +} from "./run.js"; +export { claudeAgent } from "./agent.js"; diff --git a/src/agents/claude/run.spec.ts b/src/agents/claude/run.spec.ts new file mode 100644 index 0000000..c8f0cab --- /dev/null +++ b/src/agents/claude/run.spec.ts @@ -0,0 +1,466 @@ +import { afterEach, describe, expect } from "vitest"; +import test from "vitest-gwt"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; +import { + CLAUDE_API_KEY_ENV, + CLAUDE_CONTAINER_CREDENTIALS_PATH, + CLAUDE_OAUTH_TOKEN_ENV, +} from "./constants.js"; +import { + buildClaudeDockerArgs, + resolveClaudeCredentials, + runClaudeInDocker, + type ClaudeCredentials, +} from "./run.js"; +import type { DockerRunOptions, DockerRunner } from "../types.js"; + +const SECRET = "sk-ant-oat01-super-secret"; + +type Context = { + args: string[]; + result: unknown; + credentials: ClaudeCredentials; + resolved: ClaudeCredentials; + dockerRunner: DockerRunner; + lastArgs: string[]; + lastRunOptions: DockerRunOptions | undefined; + hostEnv: NodeJS.ProcessEnv; + home: string; +}; + +const envFlagValues = (args: string[]) => args.filter((arg, i) => args[i - 1] === "-e"); +const volumeMounts = (args: string[]) => args.filter((arg, i) => args[i - 1] === "-v"); + +const tempRoots: string[] = []; + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("buildClaudeDockerArgs", () => { + test("runs as host user with an OAuth token forwarded by name only", { + given: { + oauth_token_credentials, + }, + when: { + building_docker_args, + }, + then: { + uses_host_uid_gid, + sets_container_home, + mounts_workspace, + forwards_oauth_token_env_by_name, + secret_is_not_on_argv, + does_not_mount_credentials_file, + invokes_claude_headless_with_json_output, + }, + }); + + test("forwards an API key by name only", { + given: { + api_key_credentials, + }, + when: { + building_docker_args, + }, + then: { + forwards_api_key_env_by_name, + }, + }); + + test("mounts a credentials file read-only", { + given: { + credentials_file_credentials, + }, + when: { + building_docker_args, + }, + then: { + mounts_credentials_file_read_only, + does_not_mount_dot_claude_directory, + forwards_no_secret_env, + }, + }); + + test("includes --model when a model is provided", { + given: { + oauth_token_credentials, + }, + when: { + building_docker_args_with_model, + }, + then: { + includes_model_flag, + }, + }); +}); + +function oauth_token_credentials(this: Context) { + this.credentials = { kind: "oauth-token", token: SECRET }; +} + +function api_key_credentials(this: Context) { + this.credentials = { kind: "api-key", apiKey: SECRET }; +} + +function credentials_file_credentials(this: Context) { + this.credentials = { kind: "credentials-file", file: "/home/dev/.claude/.credentials.json" }; +} + +function building_docker_args(this: Context) { + this.args = buildClaudeDockerArgs({ + workspace: "/tmp/.agents-gwt/ws-abc", + prompt: "Create a README", + image: "agent-gwt/claude-code:local", + credentials: this.credentials, + uid: 1000, + gid: 1000, + }); +} + +function building_docker_args_with_model(this: Context) { + this.args = buildClaudeDockerArgs({ + workspace: "/tmp/.agents-gwt/ws-abc", + prompt: "Create a README", + image: "agent-gwt/claude-code:local", + credentials: this.credentials, + uid: 1000, + gid: 1000, + model: "sonnet", + }); +} + +function uses_host_uid_gid(this: Context) { + expect(this.args).toContain("--user"); + expect(this.args[this.args.indexOf("--user") + 1]).toBe("1000:1000"); +} + +function sets_container_home(this: Context) { + expect(envFlagValues(this.args)).toContain(`HOME=${CONTAINER_HOME}`); +} + +function mounts_workspace(this: Context) { + expect(this.args).toContain(`/tmp/.agents-gwt/ws-abc:${CONTAINER_WORKSPACE}`); +} + +function forwards_oauth_token_env_by_name(this: Context) { + const env = envFlagValues(this.args); + expect(env).toContain(CLAUDE_OAUTH_TOKEN_ENV); + expect(env).not.toContain(CLAUDE_API_KEY_ENV); +} + +function forwards_api_key_env_by_name(this: Context) { + const env = envFlagValues(this.args); + expect(env).toContain(CLAUDE_API_KEY_ENV); + expect(env).not.toContain(CLAUDE_OAUTH_TOKEN_ENV); +} + +function forwards_no_secret_env(this: Context) { + const env = envFlagValues(this.args); + expect(env).not.toContain(CLAUDE_OAUTH_TOKEN_ENV); + expect(env).not.toContain(CLAUDE_API_KEY_ENV); +} + +function secret_is_not_on_argv(this: Context) { + for (const arg of this.args) { + expect(arg.includes(SECRET)).toBe(false); + } +} + +function does_not_mount_credentials_file(this: Context) { + for (const mount of volumeMounts(this.args)) { + expect(mount.includes(CLAUDE_CONTAINER_CREDENTIALS_PATH)).toBe(false); + } +} + +function mounts_credentials_file_read_only(this: Context) { + expect(this.args).toContain( + `/home/dev/.claude/.credentials.json:${CLAUDE_CONTAINER_CREDENTIALS_PATH}:ro`, + ); +} + +function does_not_mount_dot_claude_directory(this: Context) { + for (const mount of volumeMounts(this.args)) { + expect(mount.includes("/.claude:")).toBe(false); + } +} + +function invokes_claude_headless_with_json_output(this: Context) { + expect(this.args).toContain("claude"); + expect(this.args).toContain("-p"); + expect(this.args).toContain("--output-format"); + expect(this.args).toContain("json"); + expect(this.args).toContain("--dangerously-skip-permissions"); + expect(this.args.at(-2)).toBe("--"); + expect(this.args.at(-1)).toBe("Create a README"); +} + +function includes_model_flag(this: Context) { + const modelIndex = this.args.indexOf("--model"); + expect(modelIndex).toBeGreaterThan(-1); + expect(this.args[modelIndex + 1]).toBe("sonnet"); + expect(this.args.indexOf("--")).toBeGreaterThan(modelIndex); +} + +describe("resolveClaudeCredentials", () => { + test("prefers an OAuth token over an API key and a credentials file", { + given: { + home_with_credentials_file, + host_env_with_token_and_api_key, + }, + when: { + resolving_credentials, + }, + then: { + resolves_oauth_token, + }, + }); + + test("falls back to an API key", { + given: { + home_with_credentials_file, + host_env_with_api_key, + }, + when: { + resolving_credentials, + }, + then: { + resolves_api_key, + }, + }); + + test("falls back to a readable credentials file", { + given: { + home_with_credentials_file, + empty_host_env, + }, + when: { + resolving_credentials, + }, + then: { + resolves_credentials_file, + }, + }); + + test("throws with guidance when nothing is configured", { + given: { + home_without_credentials_file, + empty_host_env, + }, + when: { + resolving_credentials, + }, + then: { + expect_error: error_explains_how_to_authenticate, + }, + }); +}); + +async function home_with_credentials_file(this: Context) { + this.home = await mkdtemp(join(tmpdir(), "agent-gwt-home-")); + tempRoots.push(this.home); + await mkdir(join(this.home, ".claude"), { recursive: true }); + await writeFile(join(this.home, ".claude", ".credentials.json"), "{}\n"); +} + +async function home_without_credentials_file(this: Context) { + this.home = await mkdtemp(join(tmpdir(), "agent-gwt-home-")); + tempRoots.push(this.home); +} + +function host_env_with_token_and_api_key(this: Context) { + this.hostEnv = { [CLAUDE_OAUTH_TOKEN_ENV]: SECRET, [CLAUDE_API_KEY_ENV]: "sk-ant-api" }; +} + +function host_env_with_api_key(this: Context) { + this.hostEnv = { [CLAUDE_API_KEY_ENV]: "sk-ant-api" }; +} + +function empty_host_env(this: Context) { + this.hostEnv = {}; +} + +async function resolving_credentials(this: Context) { + this.resolved = await resolveClaudeCredentials({ env: this.hostEnv, home: this.home }); +} + +function resolves_oauth_token(this: Context) { + expect(this.resolved).toEqual({ kind: "oauth-token", token: SECRET }); +} + +function resolves_api_key(this: Context) { + expect(this.resolved).toEqual({ kind: "api-key", apiKey: "sk-ant-api" }); +} + +function resolves_credentials_file(this: Context) { + expect(this.resolved).toEqual({ + kind: "credentials-file", + file: join(this.home, ".claude", ".credentials.json"), + }); +} + +function error_explains_how_to_authenticate(this: Context, error: Error) { + expect(error.message).toContain("claude setup-token"); + expect(error.message).toContain(CLAUDE_OAUTH_TOKEN_ENV); + expect(error.message).toContain(CLAUDE_API_KEY_ENV); +} + +describe("runClaudeInDocker", () => { + test("parses JSON from a successful run and hands the OAuth token to the docker CLI env", { + given: { + successful_docker_runner, + oauth_token_credentials, + }, + when: { + running_claude_in_docker, + }, + then: { + agent_result_is_parsed, + docker_runner_received_oauth_token_env, + docker_runner_received_claude_args, + }, + }); + + test("hands an API key to the docker CLI env", { + given: { + successful_docker_runner, + api_key_credentials, + }, + when: { + running_claude_in_docker, + }, + then: { + docker_runner_received_api_key_env, + }, + }); + + test("throws when docker exits non-zero", { + given: { + failing_docker_runner, + oauth_token_credentials, + }, + when: { + running_claude_in_docker, + }, + then: { + expect_error: error_includes_exit_code, + }, + }); + + test("surfaces claude's reported message when docker exits non-zero with a JSON result", { + given: { + failing_docker_runner_with_error_result, + oauth_token_credentials, + }, + when: { + running_claude_in_docker, + }, + then: { + expect_error: error_includes_exit_code_and_claude_message, + }, + }); + + test("throws when claude reports is_error in its result", { + given: { + error_result_docker_runner, + oauth_token_credentials, + }, + when: { + running_claude_in_docker, + }, + then: { + expect_error: error_includes_claude_message, + }, + }); +}); + +function successful_docker_runner(this: Context) { + this.dockerRunner = async (args, options) => { + this.lastArgs = args; + this.lastRunOptions = options; + return { + exitCode: 0, + stdout: '{"type":"result","subtype":"success","is_error":false,"result":"done"}', + stderr: "", + }; + }; +} + +function failing_docker_runner(this: Context) { + this.dockerRunner = async () => ({ + exitCode: 1, + stdout: "", + stderr: "boom", + }); +} + +function failing_docker_runner_with_error_result(this: Context) { + this.dockerRunner = async () => ({ + exitCode: 1, + stdout: + '{"type":"result","subtype":"success","is_error":true,"result":"Not logged in · Please run /login","terminal_reason":"api_error"}', + stderr: "", + }); +} + +function error_result_docker_runner(this: Context) { + this.dockerRunner = async () => ({ + exitCode: 0, + stdout: + '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Invalid API key"}', + stderr: "", + }); +} + +async function running_claude_in_docker(this: Context) { + this.result = await runClaudeInDocker( + { + workspace: "/tmp/.agents-gwt/ws-abc", + prompt: "hi", + image: "agent-gwt/claude-code:local", + credentials: this.credentials, + uid: 1000, + gid: 1000, + }, + this.dockerRunner, + ); +} + +function agent_result_is_parsed(this: Context) { + expect(this.result).toEqual({ + type: "result", + subtype: "success", + is_error: false, + result: "done", + }); +} + +function docker_runner_received_oauth_token_env(this: Context) { + expect(this.lastRunOptions?.env).toEqual({ [CLAUDE_OAUTH_TOKEN_ENV]: SECRET }); +} + +function docker_runner_received_api_key_env(this: Context) { + expect(this.lastRunOptions?.env).toEqual({ [CLAUDE_API_KEY_ENV]: SECRET }); +} + +function docker_runner_received_claude_args(this: Context) { + expect(this.lastArgs).toContain("claude"); + expect(this.lastArgs.at(-1)).toBe("hi"); +} + +function error_includes_exit_code(this: Context, error: Error) { + expect(error.message).toContain("exited with code 1"); +} + +function error_includes_exit_code_and_claude_message(this: Context, error: Error) { + expect(error.message).toContain("exited with code 1: api_error: Not logged in"); +} + +function error_includes_claude_message(this: Context, error: Error) { + expect(error.message).toContain("Invalid API key"); + expect(error.message).toContain("error_during_execution"); +} diff --git a/src/agents/claude/run.ts b/src/agents/claude/run.ts new file mode 100644 index 0000000..41e37d4 --- /dev/null +++ b/src/agents/claude/run.ts @@ -0,0 +1,204 @@ +import { access } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { homedir } from "node:os"; + +import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; +import { buildDockerRunArgs, runDocker } from "../docker.js"; +import { parseAgentJsonOutput } from "../parse-result.js"; +import { agentRunError } from "../run-error.js"; +import type { AgentRunBindingsOptions, DockerRunner, DockerVolumeMount } from "../types.js"; +import { + CLAUDE_API_KEY_ENV, + CLAUDE_CONTAINER_CREDENTIALS_PATH, + CLAUDE_OAUTH_TOKEN_ENV, + defaultClaudeHostCredentialsFile, +} from "./constants.js"; + +/** + * How Claude Code authenticates inside the container. Env-backed kinds are forwarded + * to `docker run` by name (never on argv); the file kind is bind-mounted read-only. + * See `constants.ts` for which host setups produce each. + */ +export type ClaudeCredentials = + | { kind: "oauth-token"; token: string } + | { kind: "api-key"; apiKey: string } + | { kind: "credentials-file"; file: string }; + +export type RunClaudeInDockerOptions = AgentRunBindingsOptions & { + /** Defaults to `resolveClaudeCredentials()`. */ + credentials?: ClaudeCredentials; + uid?: number; + gid?: number; +}; + +/** The parts of `claude -p --output-format json` stdout most tests care about. */ +export type ClaudeAgentResult = { + type: "result"; + subtype: string; + is_error: boolean; + result: string; + session_id: string; + num_turns: number; + duration_ms: number; + total_cost_usd: number; + stop_reason?: string; + terminal_reason?: string; + permission_denials?: unknown[]; + usage?: Record; +}; + +/** Env OAuth token, then env API key, then a readable host credentials file. */ +export async function resolveClaudeCredentials( + options: { env?: NodeJS.ProcessEnv; home?: string } = {}, +): Promise { + const env = options.env ?? process.env; + const home = options.home ?? homedir(); + + const token = env[CLAUDE_OAUTH_TOKEN_ENV]; + if (token !== undefined && token !== "") { + return { kind: "oauth-token", token }; + } + + const apiKey = env[CLAUDE_API_KEY_ENV]; + if (apiKey !== undefined && apiKey !== "") { + return { kind: "api-key", apiKey }; + } + + const file = defaultClaudeHostCredentialsFile(home); + try { + await access(file, fsConstants.R_OK); + } catch { + throw new Error( + `Claude Code credentials not found. Set ${CLAUDE_OAUTH_TOKEN_ENV} (run \`claude setup-token\` on the host) ` + + `or ${CLAUDE_API_KEY_ENV}, or provide ${file} (Linux hosts; macOS keeps credentials in the Keychain).`, + ); + } + + return { kind: "credentials-file", file }; +} + +export function buildClaudeDockerArgs(options: { + workspace: string; + prompt: string; + image: string; + credentials: ClaudeCredentials; + uid: number; + gid: number; + model?: string; +}): string[] { + const claudeArgs = ["claude", "-p", "--output-format", "json", "--dangerously-skip-permissions"]; + + if (options.model !== undefined && options.model !== "") { + claudeArgs.push("--model", options.model); + } + + claudeArgs.push("--", options.prompt); + + const volumes: DockerVolumeMount[] = [ + { host: options.workspace, container: CONTAINER_WORKSPACE }, + ]; + + if (options.credentials.kind === "credentials-file") { + volumes.push({ + host: options.credentials.file, + container: CLAUDE_CONTAINER_CREDENTIALS_PATH, + mode: "ro", + }); + } + + return buildDockerRunArgs({ + image: options.image, + uid: options.uid, + gid: options.gid, + workdir: CONTAINER_WORKSPACE, + env: { HOME: CONTAINER_HOME }, + // Names only; the values reach the container through the docker CLI's own environment. + envPassthrough: Object.keys(credentialsEnv(options.credentials)), + volumes, + command: claudeArgs, + }); +} + +export async function runClaudeInDocker( + options: RunClaudeInDockerOptions, + dockerRunner: DockerRunner = runDocker, +): Promise { + const credentials = options.credentials ?? (await resolveClaudeCredentials()); + const uid = options.uid ?? process.getuid?.() ?? 0; + const gid = options.gid ?? process.getgid?.() ?? 0; + + const args = buildClaudeDockerArgs({ + workspace: options.workspace, + prompt: options.prompt, + image: options.image, + credentials, + uid, + gid, + ...(options.model !== undefined ? { model: options.model } : {}), + }); + + const result = await dockerRunner(args, { env: credentialsEnv(credentials) }); + + if (result.exitCode !== 0) { + throw agentRunError({ + agent: "Claude", + name: "claude", + image: options.image, + result, + detail: reportedErrorMessage(result.stdout), + }); + } + + const parsed = parseAgentJsonOutput(result.stdout); + + if (isErrorResult(parsed)) { + throw new Error(`Claude agent reported an error: ${describeErrorResult(parsed)}`); + } + + return parsed; +} + +/** Secret values for the docker CLI process, keyed by the env names `buildClaudeDockerArgs` forwards. */ +function credentialsEnv(credentials: ClaudeCredentials): Record { + switch (credentials.kind) { + case "oauth-token": + return { [CLAUDE_OAUTH_TOKEN_ENV]: credentials.token }; + case "api-key": + return { [CLAUDE_API_KEY_ENV]: credentials.apiKey }; + case "credentials-file": + return {}; + } +} + +function isErrorResult(value: unknown): value is ClaudeAgentResult { + return ( + typeof value === "object" && + value !== null && + (value as { is_error?: unknown }).is_error === true + ); +} + +/** Claude prints its JSON result even when it fails; surface the human-readable part. */ +function reportedErrorMessage(stdout: string): string | undefined { + try { + const parsed = parseAgentJsonOutput(stdout); + return isErrorResult(parsed) ? describeErrorResult(parsed) : undefined; + } catch { + return undefined; + } +} + +/** `terminal_reason` (e.g. `api_error`) is the useful label; `subtype` can read `success` even when `is_error` is true. */ +function describeErrorResult(result: ClaudeAgentResult): string { + const message = String(result.result); + + if (result.terminal_reason !== undefined && result.terminal_reason !== "completed") { + return `${result.terminal_reason}: ${message}`; + } + + if (result.subtype !== "success") { + return `${result.subtype}: ${message}`; + } + + return message; +} diff --git a/src/agents/cursor/constants.ts b/src/agents/cursor/constants.ts index c7dcb16..175680d 100644 --- a/src/agents/cursor/constants.ts +++ b/src/agents/cursor/constants.ts @@ -1,7 +1,7 @@ +import { CONTAINER_HOME } from "../base/constants.js"; + export const CURSOR_IMAGE = "agent-gwt/cursor-cli:local"; -export const CONTAINER_HOME = "/home/agent"; export const CONTAINER_AUTH_PATH = `${CONTAINER_HOME}/.config/cursor/auth.json`; -export const CONTAINER_WORKSPACE = "/workspace"; export const CURSOR_DOCKERFILE_RELATIVE = "docker/cursor/Dockerfile"; export const defaultHostAuthFile = (home: string): string => `${home}/.config/cursor/auth.json`; diff --git a/src/agents/cursor/index.ts b/src/agents/cursor/index.ts index c796ab5..c6fb9fb 100644 --- a/src/agents/cursor/index.ts +++ b/src/agents/cursor/index.ts @@ -1,7 +1,5 @@ export { - CONTAINER_HOME, CONTAINER_AUTH_PATH, - CONTAINER_WORKSPACE, CURSOR_DOCKERFILE_RELATIVE, CURSOR_IMAGE, defaultHostAuthFile, diff --git a/src/agents/cursor/run.spec.ts b/src/agents/cursor/run.spec.ts index e3cac9e..334737e 100644 --- a/src/agents/cursor/run.spec.ts +++ b/src/agents/cursor/run.spec.ts @@ -2,7 +2,8 @@ import { describe, expect } from "vitest"; import test from "vitest-gwt"; import { join } from "node:path"; -import { CONTAINER_AUTH_PATH, CONTAINER_HOME, CONTAINER_WORKSPACE } from "./constants.js"; +import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; +import { CONTAINER_AUTH_PATH } from "./constants.js"; import { buildDockerArgs, runCursorInDocker } from "./run.js"; import type { DockerRunner } from "../types.js"; diff --git a/src/agents/cursor/run.ts b/src/agents/cursor/run.ts index cc3a772..862f01d 100644 --- a/src/agents/cursor/run.ts +++ b/src/agents/cursor/run.ts @@ -2,15 +2,12 @@ import { access } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import { homedir } from "node:os"; +import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; import { buildDockerRunArgs, runDocker } from "../docker.js"; import { parseAgentJsonOutput } from "../parse-result.js"; +import { agentRunError } from "../run-error.js"; import type { DockerRunner, AgentRunBindingsOptions } from "../types.js"; -import { - CONTAINER_AUTH_PATH, - CONTAINER_HOME, - CONTAINER_WORKSPACE, - defaultHostAuthFile, -} from "./constants.js"; +import { CONTAINER_AUTH_PATH, defaultHostAuthFile } from "./constants.js"; export type RunCursorInDockerOptions = AgentRunBindingsOptions & { authFile?: string; @@ -75,17 +72,11 @@ export async function runCursorInDocker( ...(options.model !== undefined ? { model: options.model } : {}), }); - const { exitCode, stdout, stderr } = await dockerRunner(args); + const result = await dockerRunner(args); - if (exitCode !== 0) { - const hint = - stderr.includes("Unable to find image") || stderr.includes("not found") - ? `\nBuild the image with: pnpm run docker:build` - : ""; - throw new Error( - `Cursor agent exited with code ${exitCode}.${hint}\nstderr:\n${stderr}\nstdout:\n${stdout}`, - ); + if (result.exitCode !== 0) { + throw agentRunError({ agent: "Cursor", name: "cursor", image: options.image, result }); } - return parseAgentJsonOutput(stdout); + return parseAgentJsonOutput(result.stdout); } diff --git a/src/agents/docker.spec.ts b/src/agents/docker.spec.ts index f5860f9..e643770 100644 --- a/src/agents/docker.spec.ts +++ b/src/agents/docker.spec.ts @@ -29,6 +29,15 @@ describe("buildDockerRunArgs", () => { rw_mount_has_no_mode_suffix, }, }); + + test("forwards passthrough env by name only", { + when: { + building_args_with_env_passthrough, + }, + then: { + passthrough_env_is_name_only, + }, + }); }); function building_args(this: Context) { @@ -86,3 +95,20 @@ function rw_mount_has_no_mode_suffix(this: Context) { expect(this.args).toContain("/tmp/ws:/workspace"); expect(this.args.some((arg) => arg.includes("/tmp/ws:/workspace:"))).toBe(false); } + +function building_args_with_env_passthrough(this: Context) { + this.args = buildDockerRunArgs({ + image: "example:local", + uid: 1, + gid: 1, + workdir: "/workspace", + env: { HOME: "/home/agent" }, + envPassthrough: ["SECRET_TOKEN"], + command: ["true"], + }); +} + +function passthrough_env_is_name_only(this: Context) { + const envValues = this.args.filter((arg, i) => this.args[i - 1] === "-e"); + expect(envValues).toEqual(["HOME=/home/agent", "SECRET_TOKEN"]); +} diff --git a/src/agents/docker.ts b/src/agents/docker.ts index 9c2c9e2..635e1b2 100644 --- a/src/agents/docker.ts +++ b/src/agents/docker.ts @@ -14,6 +14,7 @@ export const runDocker: DockerRunner = (args, options = {}) => new Promise((resolve, reject) => { const child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ...options.env }, }); let stdout = ""; @@ -55,6 +56,12 @@ export function buildDockerRunArgs(options: BuildDockerRunArgsOptions): string[] } } + if (options.envPassthrough !== undefined) { + for (const name of options.envPassthrough) { + args.push("-e", name); + } + } + if (options.volumes !== undefined) { for (const volume of options.volumes) { const modeSuffix = volume.mode === "ro" ? ":ro" : ""; diff --git a/src/agents/registry.ts b/src/agents/registry.ts index aa7b9f5..eba6f5c 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -1,8 +1,10 @@ +import { claudeAgent } from "./claude/agent.js"; import { cursorAgent } from "./cursor/agent.js"; import type { Agent } from "./types.js"; export const agentRegistry = { cursor: cursorAgent, + claude: claudeAgent, } as const; export type AgentName = keyof typeof agentRegistry; diff --git a/src/agents/run-error.spec.ts b/src/agents/run-error.spec.ts new file mode 100644 index 0000000..fe39496 --- /dev/null +++ b/src/agents/run-error.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect } from "vitest"; +import test from "vitest-gwt"; + +import { agentRunError } from "./run-error.js"; + +type Context = { + error: Error; +}; + +describe("agentRunError", () => { + test("names the agent and exit code and dumps both streams", { + when: { + building_error_for_generic_failure, + }, + then: { + message_has_headline, + message_has_streams, + message_has_no_build_hint, + }, + }); + + test("adds a build hint when docker cannot find the image", { + when: { + building_error_for_missing_image, + }, + then: { + message_has_build_hint, + }, + }); + + test("puts the agent-reported detail in the headline", { + when: { + building_error_with_detail, + }, + then: { + message_has_detail_headline, + }, + }); +}); + +function building_error_for_generic_failure(this: Context) { + this.error = agentRunError({ + agent: "Cursor", + name: "cursor", + image: "agent-gwt/cursor-cli:local", + result: { exitCode: 2, stdout: "out", stderr: "boom" }, + }); +} + +function building_error_for_missing_image(this: Context) { + this.error = agentRunError({ + agent: "Claude", + name: "claude", + image: "agent-gwt/claude-code:local", + result: { + exitCode: 125, + stdout: "", + stderr: "Unable to find image 'agent-gwt/claude-code:local' locally", + }, + }); +} + +function building_error_with_detail(this: Context) { + this.error = agentRunError({ + agent: "Claude", + name: "claude", + image: "agent-gwt/claude-code:local", + result: { exitCode: 1, stdout: "{}", stderr: "" }, + detail: "api_error: Not logged in", + }); +} + +function message_has_headline(this: Context) { + expect(this.error.message).toContain("Cursor agent exited with code 2."); +} + +function message_has_streams(this: Context) { + expect(this.error.message).toContain("stderr:\nboom"); + expect(this.error.message).toContain("stdout:\nout"); +} + +function message_has_no_build_hint(this: Context) { + expect(this.error.message.includes("buildAgentImage")).toBe(false); +} + +function message_has_build_hint(this: Context) { + expect(this.error.message).toContain('buildAgentImage("claude")'); + expect(this.error.message).toContain("agent-gwt/claude-code:local"); +} + +function message_has_detail_headline(this: Context) { + expect(this.error.message).toContain("Claude agent exited with code 1: api_error: Not logged in"); +} diff --git a/src/agents/run-error.ts b/src/agents/run-error.ts new file mode 100644 index 0000000..1dd96b5 --- /dev/null +++ b/src/agents/run-error.ts @@ -0,0 +1,28 @@ +import type { AgentName } from "./registry.js"; +import type { DockerRunResult } from "./types.js"; + +export type AgentRunErrorOptions = { + /** Display name for the message, e.g. "Cursor". */ + agent: string; + /** Registry name, for the `buildAgentImage(...)` hint. */ + name: AgentName; + image: string; + result: DockerRunResult; + /** Failure message the CLI itself reported, when it printed one. */ + detail?: string | undefined; +}; + +/** Error for a non-zero `docker run` exit, with a build hint when docker could not find the image. */ +export function agentRunError(options: AgentRunErrorOptions): Error { + const { exitCode, stdout, stderr } = options.result; + const headline = + options.detail === undefined + ? `${options.agent} agent exited with code ${exitCode}.` + : `${options.agent} agent exited with code ${exitCode}: ${options.detail}`; + const hint = + stderr.includes("Unable to find image") || stderr.includes("not found") + ? `\nDocker image ${options.image} not found; build it with buildAgentImage("${options.name}") in vitest globalSetup.` + : ""; + + return new Error(`${headline}${hint}\nstderr:\n${stderr}\nstdout:\n${stdout}`); +} diff --git a/src/agents/types.ts b/src/agents/types.ts index d5a03e3..8569b2b 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -34,6 +34,12 @@ export type DockerRunResult = { export type DockerRunOptions = { /** Forward docker stdout/stderr to the parent process while still capturing. */ inheritOutput?: boolean; + /** + * Extra environment for the `docker` CLI process itself. Pair with + * `envPassthrough` to hand a secret to the container without it ever + * appearing on the host command line. + */ + env?: Record; }; export type DockerRunner = (args: string[], options?: DockerRunOptions) => Promise; @@ -50,7 +56,10 @@ export type BuildDockerRunArgsOptions = { gid: number; workdir: string; command: string[]; + /** `-e NAME=value` — value is visible on the host command line. */ env?: Record; + /** `-e NAME` — value is read from the docker CLI's own environment, never on argv. */ + envPassthrough?: string[]; volumes?: DockerVolumeMount[]; }; diff --git a/src/given/agent.spec.ts b/src/given/agent.spec.ts index 8c685e5..f3d8798 100644 --- a/src/given/agent.spec.ts +++ b/src/given/agent.spec.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, vi } from "vitest"; import test from "vitest-gwt"; import * as ensureImageModule from "../agents/ensure-image.js"; -import { agentRegistry } from "../agents/registry.js"; +import { agentRegistry, type AgentName } from "../agents/registry.js"; +import { CLAUDE_IMAGE } from "../agents/claude/constants.js"; import { CURSOR_IMAGE } from "../agents/cursor/constants.js"; import { agent } from "./agent.js"; import type { AgentContext } from "../types.js"; @@ -25,10 +26,10 @@ describe("agent", () => { applying_agent: agent({ name: "cursor", model: "auto" }), }, then: { - agent_is_cursor, - model_is_set, - image_is_set_from_agent, - ensure_was_called_with_cursor_image, + agent_is: agent_is("cursor"), + model_is: model_is("auto"), + image_is: image_is(CURSOR_IMAGE), + ensure_was_called_with: ensure_was_called_with(CURSOR_IMAGE), }, }); @@ -40,9 +41,24 @@ describe("agent", () => { applying_agent: agent({ name: "cursor", image: "my-app/agent:local" }), }, then: { - agent_is_cursor, - image_is_override, - ensure_was_called_with_override_image, + agent_is: agent_is("cursor"), + image_is: image_is("my-app/agent:local"), + ensure_was_called_with: ensure_was_called_with("my-app/agent:local"), + }, + }); + + test("resolves the claude agent by name", { + given: { + stub_ensure_docker_image, + }, + when: { + applying_agent: agent({ name: "claude", model: "sonnet" }), + }, + then: { + agent_is: agent_is("claude"), + model_is: model_is("sonnet"), + image_is: image_is(CLAUDE_IMAGE), + ensure_was_called_with: ensure_was_called_with(CLAUDE_IMAGE), }, }); }); @@ -55,28 +71,27 @@ function stub_ensure_docker_image(this: Context) { }); } -function agent_is_cursor(this: Context) { - expect(this.agent).toBe(agentRegistry.cursor); -} - -function model_is_set(this: Context) { - expect(this.model).toBe("auto"); -} - -function image_is_set_from_agent(this: Context) { - expect(this.image).toBe(CURSOR_IMAGE); +function agent_is(name: AgentName) { + return function (this: Context) { + expect(this.agent).toBe(agentRegistry[name]); + }; } -function image_is_override(this: Context) { - expect(this.image).toBe("my-app/agent:local"); +function model_is(model: string) { + return function (this: Context) { + expect(this.model).toBe(model); + }; } -function ensure_was_called_with_cursor_image(this: Context) { - expect(this.ensureCalls).toBe(1); - expect(this.ensuredImage).toBe(CURSOR_IMAGE); +function image_is(image: string) { + return function (this: Context) { + expect(this.image).toBe(image); + }; } -function ensure_was_called_with_override_image(this: Context) { - expect(this.ensureCalls).toBe(1); - expect(this.ensuredImage).toBe("my-app/agent:local"); +function ensure_was_called_with(image: string) { + return function (this: Context) { + expect(this.ensureCalls).toBe(1); + expect(this.ensuredImage).toBe(image); + }; } diff --git a/src/index.ts b/src/index.ts index 4909d98..00f3cdc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,9 +13,7 @@ export { executing_the_agent } from "./when/executing_the_agent.js"; export { agent } from "./given/agent.js"; export { - CONTAINER_HOME, CONTAINER_AUTH_PATH, - CONTAINER_WORKSPACE, CURSOR_DOCKERFILE_RELATIVE, CURSOR_IMAGE, defaultHostAuthFile, @@ -25,6 +23,22 @@ export { type RunCursorInDockerOptions, } from "./agents/cursor/index.js"; +export { + CLAUDE_API_KEY_ENV, + CLAUDE_CONTAINER_CREDENTIALS_PATH, + CLAUDE_DOCKERFILE_RELATIVE, + CLAUDE_IMAGE, + CLAUDE_OAUTH_TOKEN_ENV, + defaultClaudeHostCredentialsFile, + buildClaudeDockerArgs, + resolveClaudeCredentials, + runClaudeInDocker, + claudeAgent, + type ClaudeAgentResult, + type ClaudeCredentials, + type RunClaudeInDockerOptions, +} from "./agents/claude/index.js"; + export { resolveAgent, agentRegistry } from "./agents/registry.js"; export { createAgent, type CreateAgentBindings } from "./agents/create-agent.js"; export { @@ -34,7 +48,12 @@ export { resetBuiltImages, type BuildBaseImageOptions, } from "./agents/build-agent-image.js"; -export { BASE_IMAGE, BASE_DOCKERFILE_RELATIVE } from "./agents/base/index.js"; +export { + BASE_IMAGE, + BASE_DOCKERFILE_RELATIVE, + CONTAINER_HOME, + CONTAINER_WORKSPACE, +} from "./agents/base/index.js"; export { PACKAGE_ROOT } from "./package-root.js"; export { ensureDockerImage } from "./agents/ensure-image.js"; export { parseAgentJsonOutput } from "./agents/parse-result.js"; diff --git a/src/package-root.spec.ts b/src/package-root.spec.ts index 0865ba7..43deda7 100644 --- a/src/package-root.spec.ts +++ b/src/package-root.spec.ts @@ -8,16 +8,18 @@ import { PACKAGE_ROOT } from "./package-root.js"; type Context = { baseDockerfile: string; cursorDockerfile: string; + claudeDockerfile: string; }; describe("PACKAGE_ROOT", () => { - test("points at the package root that contains the base and Cursor Dockerfiles", { + test("points at the package root that contains the base, Cursor, and Claude Dockerfiles", { when: { resolving_dockerfiles, }, then: { base_dockerfile_exists, cursor_dockerfile_exists, + claude_dockerfile_exists, }, }); }); @@ -25,6 +27,7 @@ describe("PACKAGE_ROOT", () => { function resolving_dockerfiles(this: Context) { this.baseDockerfile = join(PACKAGE_ROOT, "docker", "base", "Dockerfile"); this.cursorDockerfile = join(PACKAGE_ROOT, "docker", "cursor", "Dockerfile"); + this.claudeDockerfile = join(PACKAGE_ROOT, "docker", "claude", "Dockerfile"); } function base_dockerfile_exists(this: Context) { @@ -34,3 +37,7 @@ function base_dockerfile_exists(this: Context) { function cursor_dockerfile_exists(this: Context) { expect(existsSync(this.cursorDockerfile)).toBe(true); } + +function claude_dockerfile_exists(this: Context) { + expect(existsSync(this.claudeDockerfile)).toBe(true); +} From 6e5a55d6155dfc48e4b132a1496f2e13ee63906f Mon Sep 17 00:00:00 2001 From: Matthew Wright Date: Sat, 29 Aug 2026 19:19:39 -0500 Subject: [PATCH 2/2] test: credential-gated e2e suite for both agents The e2e/ folder went in as a Claude-only scratch suite. Make it a proper end-to-end suite that covers Cursor and Claude the same way: - e2e/credentials.ts: hasCursorCredential() and hasClaudeCredential(), the same checks the run functions make, as sync yes/no answers. - global-setup builds the image for each agent whose credential is present and prints a notice for the rest. - cursor.spec.ts and claude.spec.ts run the same "writes the readme" scenario with shared then-steps (e2e/steps.ts); only agent({ name }) and the result assertion differ. Each suite is wrapped in describe.skipIf, so a host without that credential reports "skipped", exit 0. - `pnpm run test:e2e` (wireit) runs it. Unit tests, lint and the published package are unaffected: e2e/ stays outside src/ and the `files` list. - CONTRIBUTING documents the requirements per agent, plus DOCKER_DEFAULT_PLATFORM=linux/amd64 on Apple Silicon. --- CONTRIBUTING.md | 4 ++++ e2e/claude.spec.ts | 24 +++++++----------------- e2e/credentials.ts | 26 ++++++++++++++++++++++++++ e2e/cursor.spec.ts | 35 +++++++++++++++++++++++++++++++++++ e2e/global-setup.ts | 23 +++++++++++++++++++++-- e2e/steps.ts | 17 +++++++++++++++++ e2e/vitest.e2e.config.ts | 8 ++++++-- package.json | 8 ++++++++ 8 files changed, 124 insertions(+), 21 deletions(-) create mode 100644 e2e/credentials.ts create mode 100644 e2e/cursor.spec.ts create mode 100644 e2e/steps.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf32344..04e576d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,10 @@ pnpm stage approve Bump size is controlled by PR labels (`major` > `minor` > patch default). See [Publishing](PUBLISHING.md) for trusted-publisher setup. +## Testing + +`pnpm test` runs the unit suite with Docker mocked. `pnpm run test:e2e` runs `e2e/` against the real agent images. It needs Docker, and each agent's tests run only when that agent's credential is present on the host: Cursor needs `agent login` (`~/.config/cursor/auth.json`), Claude needs `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` in the environment. `globalSetup` builds the images for the agents that have credentials and the rest skip cleanly. On Apple Silicon export `DOCKER_DEFAULT_PLATFORM=linux/amd64` first. + ## Architecture | Layer | Role | diff --git a/e2e/claude.spec.ts b/e2e/claude.spec.ts index a0e0d88..cc0e8d1 100644 --- a/e2e/claude.spec.ts +++ b/e2e/claude.spec.ts @@ -1,5 +1,3 @@ -import { access, readFile } from "node:fs/promises"; -import { join } from "node:path"; import { describe, expect } from "vitest"; import test, { withAspect } from "vitest-gwt"; import { @@ -11,8 +9,10 @@ import { executing_the_agent, the_prompt, } from "../src/index.js"; +import { hasClaudeCredential } from "./credentials.js"; +import { readme_contains_HELLO_WORLD, readme_exists } from "./steps.js"; -describe("claude agent (e2e)", () => { +describe.skipIf(!hasClaudeCredential())("claude agent (e2e)", () => { withAspect(agent({ name: "claude", model: "sonnet" })); withAspect(a_workspace, cleanup_workspace); @@ -31,23 +31,13 @@ describe("claude agent (e2e)", () => { }); }); -type Context = AgentContext; - -async function readme_exists(this: Context) { - await access(join(this.workspace, "README.md")); -} - -async function readme_contains_HELLO_WORLD(this: Context) { - const contents = await readFile(join(this.workspace, "README.md"), "utf-8"); - - expect(contents.toLowerCase()).toContain("hello world"); -} - -function result_is_a_successful_claude_run(this: Context) { +function result_is_a_successful_claude_run(this: AgentContext) { const result = this.agentResult as ClaudeAgentResult; expect(result.type).toBe("result"); expect(result.is_error).toBe(false); expect(result.num_turns).toBeGreaterThan(0); - console.log(`claude: ${result.num_turns} turns, $${result.total_cost_usd.toFixed(4)}, session ${result.session_id}`); + console.log( + `claude: ${result.num_turns} turns, $${result.total_cost_usd.toFixed(4)}, session ${result.session_id}`, + ); } diff --git a/e2e/credentials.ts b/e2e/credentials.ts new file mode 100644 index 0000000..c366b1e --- /dev/null +++ b/e2e/credentials.ts @@ -0,0 +1,26 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; + +import { + CLAUDE_API_KEY_ENV, + CLAUDE_OAUTH_TOKEN_ENV, + defaultClaudeHostCredentialsFile, + defaultHostAuthFile, +} from "../src/index.js"; + +/** Same sources as resolveClaudeCredentials(), as a sync yes/no for skip decisions. */ +export function hasClaudeCredential(env: NodeJS.ProcessEnv = process.env): boolean { + const token = env[CLAUDE_OAUTH_TOKEN_ENV]; + const apiKey = env[CLAUDE_API_KEY_ENV]; + + return ( + (token !== undefined && token !== "") || + (apiKey !== undefined && apiKey !== "") || + existsSync(defaultClaudeHostCredentialsFile(homedir())) + ); +} + +/** Same check runCursorInDocker() makes before it starts the container. */ +export function hasCursorCredential(): boolean { + return existsSync(defaultHostAuthFile(homedir())); +} diff --git a/e2e/cursor.spec.ts b/e2e/cursor.spec.ts new file mode 100644 index 0000000..7663c9d --- /dev/null +++ b/e2e/cursor.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect } from "vitest"; +import test, { withAspect } from "vitest-gwt"; +import { + type AgentContext, + a_workspace, + agent, + cleanup_workspace, + executing_the_agent, + the_prompt, +} from "../src/index.js"; +import { hasCursorCredential } from "./credentials.js"; +import { readme_contains_HELLO_WORLD, readme_exists } from "./steps.js"; + +describe.skipIf(!hasCursorCredential())("cursor agent (e2e)", () => { + withAspect(agent({ name: "cursor", model: "auto" })); + withAspect(a_workspace, cleanup_workspace); + + test("writes the readme", { + given: { + the_prompt: the_prompt("Write 'Hello World' to README.md"), + }, + when: { + executing_the_agent, + }, + then: { + readme_exists, + readme_contains_HELLO_WORLD, + result_is_parsed_json, + }, + }); +}); + +function result_is_parsed_json(this: AgentContext) { + expect(this.agentResult).toBeTypeOf("object"); +} diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index fcdd2f9..a2ecd32 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -1,5 +1,24 @@ -import { buildAgentImage } from "../src/index.js"; +import { type AgentName, buildAgentImage } from "../src/index.js"; +import { hasClaudeCredential, hasCursorCredential } from "./credentials.js"; + +const agents: Array<{ name: AgentName; available: boolean; hint: string }> = [ + { name: "cursor", available: hasCursorCredential(), hint: "run `agent login` on the host" }, + { + name: "claude", + available: hasClaudeCredential(), + hint: "set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY", + }, +]; export default async function setup() { - await buildAgentImage("claude"); + for (const agent of agents) { + if (!agent.available) { + process.stderr.write( + `[e2e] No ${agent.name} credential (${agent.hint}); skipping its image build and tests.\n`, + ); + continue; + } + + await buildAgentImage(agent.name); + } } diff --git a/e2e/steps.ts b/e2e/steps.ts new file mode 100644 index 0000000..ceb33eb --- /dev/null +++ b/e2e/steps.ts @@ -0,0 +1,17 @@ +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import type { AgentContext } from "../src/index.js"; + +// Shared "then" steps: the same assertions run against whichever agent wrote the file. + +export async function readme_exists(this: AgentContext) { + await access(join(this.workspace, "README.md")); +} + +export async function readme_contains_HELLO_WORLD(this: AgentContext) { + const contents = await readFile(join(this.workspace, "README.md"), "utf-8"); + + expect(contents.toLowerCase()).toContain("hello world"); +} diff --git a/e2e/vitest.e2e.config.ts b/e2e/vitest.e2e.config.ts index 81ab76a..bbec825 100644 --- a/e2e/vitest.e2e.config.ts +++ b/e2e/vitest.e2e.config.ts @@ -1,5 +1,9 @@ -// Scratch e2e config — not part of the package. Run with: -// pnpm exec vitest run --config e2e/vitest.e2e.config.ts +// End-to-end suite: builds the real agent images and runs each agent in Docker. +// Each agent's tests run only when its credential is present on the host and skip +// cleanly otherwise (Cursor: `agent login`; Claude: CLAUDE_CODE_OAUTH_TOKEN or +// ANTHROPIC_API_KEY). On Apple Silicon export DOCKER_DEFAULT_PLATFORM=linux/amd64. +// +// pnpm run test:e2e import { defineConfig } from "vitest/config"; export default defineConfig({ diff --git a/package.json b/package.json index d1b5eaa..f330c8d 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "scripts": { "build": "wireit", "test": "wireit", + "test:e2e": "wireit", "lint": "wireit", "test:coverage": "wireit", "prepublishOnly": "wireit" @@ -83,6 +84,13 @@ "vite.config.ts" ] }, + "test:e2e": { + "command": "vitest run --config e2e/vitest.e2e.config.ts", + "files": [ + "src/**/*.ts", + "e2e/**/*.ts" + ] + }, "test:coverage": { "command": "vitest run --coverage", "files": [