diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d5ee5eb..9dbce1a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -52,6 +52,7 @@ jobs: RUN_NUMBER: ${{ github.run_number }} run: | set -euo pipefail + # Root package.json is the canonical version (kept in sync with both packages). base_version="$(git show "${BASE_SHA}:package.json" | jq -r .version)" next="$(node .github/scripts/next-version.mjs "${base_version}" "${LABELS}")" prerelease="${next}-build.${RUN_NUMBER}" @@ -60,11 +61,13 @@ jobs: echo "prerelease=${prerelease}" >> "$GITHUB_OUTPUT" echo "Prerelease version: ${prerelease} (from ${base_version})" - - name: Set package version + - name: Set package versions run: | set -euo pipefail - jq --arg v "${{ steps.version.outputs.prerelease }}" '.version = $v' package.json > package.json.tmp - mv package.json.tmp package.json + for pkg in package.json packages/agent-gwt/package.json packages/clanker-cleanroom/package.json; do + jq --arg v "${{ steps.version.outputs.prerelease }}" '.version = $v' "${pkg}" > "${pkg}.tmp" + mv "${pkg}.tmp" "${pkg}" + done - name: Lint run: pnpm run lint @@ -75,14 +78,16 @@ jobs: - name: Build run: pnpm run build - - name: Publish prerelease + - name: Publish prereleases run: | set -euo pipefail - pnpm publish \ - --access public \ - --tag "pr-${{ github.event.pull_request.number }}" \ - --no-git-checks \ - --ignore-scripts + for filter in clanker-cleanroom agent-gwt; do + pnpm --filter "${filter}" publish \ + --access public \ + --tag "pr-${{ github.event.pull_request.number }}" \ + --no-git-checks \ + --ignore-scripts + done release: if: >- @@ -126,69 +131,52 @@ jobs: echo "pr_tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT" echo "Release version: ${next} (from ${base_version}, tag pr-${PR_NUMBER})" - - name: Download prerelease tarball - id: pack + - name: Download and stage packages + id: stage env: PR_TAG: ${{ steps.version.outputs.pr_tag }} NEXT: ${{ steps.version.outputs.next }} run: | set -euo pipefail - # Work outside the git checkout — pnpm stage publish refuses unclean trees workdir="${RUNNER_TEMP}/agent-gwt-stage" mkdir -p "${workdir}" - cd "${workdir}" - # Avoid empty _authToken from setup-node's .npmrc when fetching a public package - npm pack "agent-gwt@${PR_TAG}" --userconfig /dev/null --registry https://registry.npmjs.org - tarball="$(ls -1 agent-gwt-*.tgz | head -n1)" - mkdir -p staged - tar -xzf "${tarball}" -C staged - jq --arg v "${NEXT}" '.version = $v' staged/package/package.json > staged/package/package.json.tmp - mv staged/package/package.json.tmp staged/package/package.json - echo "package_dir=${workdir}/staged/package" >> "$GITHUB_OUTPUT" - echo "Rewrote staged package version to ${NEXT} at ${workdir}/staged/package" - - - name: Stage release - id: stage - env: - PACKAGE_DIR: ${{ steps.pack.outputs.package_dir }} - run: | - set -euo pipefail - # Keep stderr (WARN/OIDC logs) out of --json stdout so jq can parse. - err="$(mktemp)" - set +e - output="$(pnpm stage publish "${PACKAGE_DIR}" --access public --tag latest --json 2>"${err}")" - status=$? - set -e - cat "${err}" >&2 || true - rm -f "${err}" - printf '%s\n' "${output}" - if [[ "${status}" -ne 0 ]]; then - exit "${status}" - fi - if ! printf '%s\n' "${output}" | jq -e 'type == "object"' >/dev/null; then - echo "Stage publish did not return JSON" >&2 - exit 1 - fi - if printf '%s\n' "${output}" | jq -e '.error' >/dev/null; then - echo "Stage publish returned an error payload" >&2 - exit 1 - fi - stage_id="$(printf '%s\n' "${output}" | jq -r 'to_entries[0].value.stageId // empty')" - echo "stage_id=${stage_id}" >> "$GITHUB_OUTPUT" + stage_ids=() + for name in clanker-cleanroom agent-gwt; do + cd "${workdir}" + rm -rf staged "${name}"-*.tgz + npm pack "${name}@${PR_TAG}" --userconfig /dev/null --registry https://registry.npmjs.org + tarball="$(ls -1 ${name}-*.tgz | head -n1)" + mkdir -p staged + tar -xzf "${tarball}" -C staged + jq --arg v "${NEXT}" ' + .version = $v + | if .dependencies["clanker-cleanroom"] then .dependencies["clanker-cleanroom"] = $v else . end + ' staged/package/package.json > staged/package/package.json.tmp + mv staged/package/package.json.tmp staged/package/package.json + err="$(mktemp)" + set +e + output="$(pnpm stage publish "${workdir}/staged/package" --access public --tag latest --json 2>"${err}")" + status=$? + set -e + cat "${err}" >&2 || true + rm -f "${err}" + printf '%s\n' "${output}" + if [[ "${status}" -ne 0 ]]; then + exit "${status}" + fi + stage_id="$(printf '%s\n' "${output}" | jq -r 'to_entries[0].value.stageId // empty')" + stage_ids+=("${name}:${stage_id}") + done { echo "## Staged release" echo "" - echo "Version **${{ steps.version.outputs.next }}** was staged from \`${{ steps.version.outputs.pr_tag }}\`." + echo "Version **${NEXT}** was staged from \`${PR_TAG}\`." echo "" - if [[ -n "${stage_id}" ]]; then - echo "Approve with:" - echo "" - echo "\`\`\`bash" - echo "pnpm stage approve ${stage_id}" - echo "\`\`\`" - else - echo "Approve from the [Staged Packages](https://www.npmjs.com/stages) tab or via \`pnpm stage list\` / \`pnpm stage approve\`." - fi + for entry in "${stage_ids[@]}"; do + pkg="${entry%%:*}" + sid="${entry#*:}" + echo "- **${pkg}**: \`pnpm stage approve ${sid}\`" + done echo "" echo "Approval requires 2FA and cannot run via OIDC." } >> "$GITHUB_STEP_SUMMARY" @@ -198,16 +186,25 @@ jobs: NEXT: ${{ steps.version.outputs.next }} run: | set -euo pipefail - jq --arg v "${NEXT}" '.version = $v' package.json > package.json.tmp - mv package.json.tmp package.json + packages=(package.json packages/agent-gwt/package.json packages/clanker-cleanroom/package.json) + for pkg in "${packages[@]}"; do + if [[ ! -f "${pkg}" ]]; then + echo "Missing ${pkg}; expected synced monorepo versions" >&2 + exit 1 + fi + jq --arg v "${NEXT}" '.version = $v' "${pkg}" > "${pkg}.tmp" + mv "${pkg}.tmp" "${pkg}" + done git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add package.json + git add "${packages[@]}" git commit -m "chore: release v${NEXT}" git push origin main - - name: Remove PR dist-tag + - name: Remove PR dist-tags continue-on-error: true env: PR_TAG: ${{ steps.version.outputs.pr_tag }} - run: npm dist-tag rm agent-gwt "${PR_TAG}" || true + run: | + npm dist-tag rm agent-gwt "${PR_TAG}" || true + npm dist-tag rm clanker-cleanroom "${PR_TAG}" || true diff --git a/.gitignore b/.gitignore index a72145c..c7233d8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage/ .DS_Store .wireit/ .codegraph/ +clanker-cleanroom.images.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 473b70f..bd600a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,16 +2,16 @@ ## PR builds -Open PRs against `main` publish a prerelease under the dist-tag `pr-`: +Open PRs against `main` publish prereleases under the dist-tag `pr-` for **both** workspace packages: ```bash -pnpm add -D agent-gwt@pr-123 +pnpm add -D clanker-cleanroom@pr-123 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 +pnpm update clanker-cleanroom@pr-123 agent-gwt@pr-123 ``` ## Releasing @@ -27,20 +27,38 @@ Bump size is controlled by PR labels (`major` > `minor` > patch default). See [P ## 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. +```bash +pnpm install +pnpm run build +pnpm run test +pnpm run lint +``` + +`pnpm test` runs unit suites (Docker mocked) in both workspace packages. `pnpm run test:e2e` runs `packages/agent-gwt/e2e/` against 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` calls `buildImages()` when any credential is present. On Apple Silicon export `DOCKER_DEFAULT_PLATFORM=linux/amd64` first. ## Architecture -| Layer | Role | -| ----------------- | -------------------------------------------------------------------------------------- | -| `given` / `when` | Agent-agnostic GWT DSL (no imports of `agents/`) | -| `agents/registry` | Maps agent names → `Agent` | -| `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, Copilot, …) add `docker//Dockerfile` on the shared base, a folder under `agents/`, and a registry entry. +pnpm workspace with two packages: + +| Package | Role | +| ---------------------------- | ------------------------------------------------------------------- | +| `packages/clanker-cleanroom` | Docker folder-graph builds, image registry JSON, agent run bindings | +| `packages/agent-gwt` | GWT steps (`given` / `when`) that call into `clanker-cleanroom` | + +### `clanker-cleanroom` + +| Layer | Role | +| --------------------- | --------------------------------------------------------------------------------- | +| `docker/*.Dockerfile` | Stock images; first line `# clanker-cleanroom/`, `FROM` local tags for deps | +| `images/` | Parse folder → DAG → `buildImages` → `clanker-cleanroom.images.json` | +| `agents/` | Cursor/Claude bindings, `Agent` class, `runDocker` | +| `package-root` | Resolves installed package root so stock Dockerfiles come from `node_modules` | + +Additional agents add a `*.Dockerfile` (with `# clanker-cleanroom/` + `FROM clanker-cleanroom/base`), a folder under `agents/`, and a registry entry. + +### `agent-gwt` + +| Layer | Role | +| ---------------- | ------------------------------------------------------------ | +| `given` / `when` | Agent-agnostic GWT DSL | +| Re-exports | Soft-break surface for `buildImages`, agents, docker helpers | diff --git a/PUBLISHING.md b/PUBLISHING.md index 762c4fe..6cb7f27 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -2,21 +2,39 @@ CI publishes via [npm trusted publishers](https://docs.npmjs.com/trusted-publishers) (OIDC) from [`.github/workflows/publish.yml`](.github/workflows/publish.yml). There is no `NPM_TOKEN` secret. +This repo is a pnpm workspace with two publishable packages: + +| Package | Path | +| ------------------- | ---------------------------------------------------------- | +| `clanker-cleanroom` | [`packages/clanker-cleanroom`](packages/clanker-cleanroom) | +| `agent-gwt` | [`packages/agent-gwt`](packages/agent-gwt) | + +**Versions stay in sync** across three `package.json` files: + +1. Root [`package.json`](package.json) (canonical for CI bumps) +2. [`packages/agent-gwt/package.json`](packages/agent-gwt/package.json) +3. [`packages/clanker-cleanroom/package.json`](packages/clanker-cleanroom/package.json) + +CI publishes `clanker-cleanroom` first, then `agent-gwt` (so the rewritten `workspace:*` dependency resolves). + ## One-time setup -### 1. Bootstrap the package on npm +### 1. Bootstrap each package on npm -Staged publishing requires the package to already exist. From a clean build, publish the initial version once (manually or with a temporary token): +Staged publishing and OIDC trusted publishers require the package name to already exist. From a clean build, publish each package once (manually or with a temporary token), **`clanker-cleanroom` first**: ```bash pnpm install pnpm run lint && pnpm run test && pnpm run build -pnpm publish --access public --ignore-scripts +pnpm --filter clanker-cleanroom publish --access public --ignore-scripts +pnpm --filter agent-gwt publish --access public --ignore-scripts ``` -### 2. Configure the trusted publisher +`agent-gwt` is already on npm; only `clanker-cleanroom` needs a first-time create if it has never been published. + +### 2. Configure the trusted publisher (each package) -On [npmjs.com](https://www.npmjs.com) → `agent-gwt` → Settings → Trusted Publisher: +On [npmjs.com](https://www.npmjs.com) → package → Settings → Trusted Publisher, for **both** `clanker-cleanroom` and `agent-gwt`: | Field | Value | | -------------------- | --------------------------------------------------- | @@ -39,28 +57,42 @@ The release job commits `chore: release vX.Y.Z` to `main` with `GITHUB_TOKEN` (t ## How it works -| Event | What happens | -| --------------------------------- | ---------------------------------------------------------------------------------------------------- | -| PR open/sync/label against `main` | Build → `pnpm publish --tag pr-` as `-build.` | -| PR merged to `main` | `npm pack agent-gwt@pr-` → rewrite version → `pnpm stage publish` → bump `package.json` on `main` | -| Maintainer | `pnpm stage approve ` (2FA) or Approve on npmjs.com | +| Event | What happens | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| PR open/sync/label against `main` | Build → set all three versions → publish **both** packages under `--tag pr-` as `-build.` | +| PR merged to `main` | `npm pack` each `@pr-` → rewrite version → `pnpm stage publish` each → bump all three `package.json` files on `main` | +| Maintainer | `pnpm stage approve ` (2FA) or Approve on npmjs.com **for each staged package** | ### Version labels -| Label | Bump from `main`'s `package.json` | -| -------- | --------------------------------- | -| _(none)_ | patch | -| `minor` | minor | -| `major` | major | +| Label | Bump from root `package.json` on `main` | +| -------- | --------------------------------------- | +| _(none)_ | patch | +| `minor` | minor | +| `major` | major | -If both `major` and `minor` are present, `major` wins. The release version is recomputed from `main` at merge time so concurrent PRs stay monotonic. +If both `major` and `minor` are present, `major` wins. The release version is recomputed from `main` at merge time so concurrent PRs stay monotonic. Root + both workspace packages are set to that same version. ## Approving a staged release +Each package gets its own stage id (see the workflow job summary): + ```bash pnpm stage list pnpm stage view -pnpm stage approve +pnpm stage approve # once per package ``` Or use the Staged Packages UI on npmjs.com. Approve/reject require interactive 2FA and cannot use OIDC. + +## Install prereleases + +```bash +pnpm add -D clanker-cleanroom@pr-123 agent-gwt@pr-123 +``` + +After a newer build is published to the same tag: + +```bash +pnpm update clanker-cleanroom@pr-123 agent-gwt@pr-123 +``` diff --git a/README.md b/README.md index 8baf8b1..dff814f 100644 --- a/README.md +++ b/README.md @@ -1,329 +1,17 @@ -# agent-gwt +# agent-gwt workspace -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). +pnpm monorepo: -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 +| Package | Role | +| ------------------------------------------------- | ----------------------------------------------------- | +| [`clanker-cleanroom`](packages/clanker-cleanroom) | Build/run agent Docker images with workspace bindings | +| [`agent-gwt`](packages/agent-gwt) | GWT step functions for repeatable agent tests | ```bash -pnpm add -D agent-gwt vitest vitest-gwt -``` - -## Prerequisites - -1. Docker -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 only the agents your suite uses: - -```ts -// vitest.global-setup.ts -import { buildAgentImage } from "agent-gwt"; - -export default async function setup() { - await buildAgentImage("cursor"); - await buildAgentImage("claude"); -} -``` - -```ts -// vite.config.ts (or vitest.config.ts) -import { defineConfig } from "vite-plus"; // or "vitest/config" - -export default defineConfig({ - test: { - globalSetup: ["./vitest.global-setup.ts"], - }, -}); +pnpm install +pnpm run build +pnpm run test +pnpm run lint ``` -## Usage - -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 -import { access, readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import test, { withAspect, withTestOptions } from "vitest-gwt"; -import { - type AgentContext, - a_workspace, - agent, - cleanup_workspace, - executing_the_agent, - the_prompt, -} from "agent-gwt"; - -describe("simple prompt", () => { - withAspect(agent({ name: "cursor", model: "auto" })); - withAspect(a_workspace, cleanup_workspace); - - withTestOptions((opts) => (opts.timeout = 60 * 1000)); - - 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, - }, - }); -}); - -type Context = AgentContext & { - // extend with your own fields -}; - -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"); -} -``` - -### Seeded workspace - -Seed files in `given` before `executing_the_agent`. The agent sees them under `this.workspace`: - -```ts -import { access, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { describe, expect } from "vitest"; -import test, { withAspect, withTestOptions } from "vitest-gwt"; -import { - type AgentContext, - a_workspace, - agent, - cleanup_workspace, - executing_the_agent, - the_prompt, -} from "agent-gwt"; - -describe("with a workspace", () => { - withAspect(agent({ name: "cursor", model: "auto" })); - withAspect(a_workspace, cleanup_workspace); - - withTestOptions((opts) => (opts.timeout = 60 * 1000)); - - test("can read the workspace", { - given: { - the_prompt: the_prompt( - "read @sample.txt, and answer the question. Write a new file 'answer.sentinel' with your answer", - ), - sample_text_file, - }, - when: { - executing_the_agent, - }, - then: { - sentinel_file_exists, - question_is_answered, - }, - }); -}); - -type Context = AgentContext & { - // extend with your own fields -}; - -async function sample_text_file(this: Context) { - await writeFile( - join(this.workspace, "sample.txt"), - "what is the answer to life, the universe, and everything?", - "utf-8", - ); -} - -async function sentinel_file_exists(this: Context) { - await access(join(this.workspace, "answer.sentinel")); -} - -async function question_is_answered(this: Context) { - const contents = await readFile(join(this.workspace, "answer.sentinel"), "utf-8"); - - expect(contents.toLowerCase()).toContain("42"); -} -``` - -To copy a fixture tree instead of writing files one by one, use `copy_to_workspace`. Globs are resolved from the **current Vitest spec file’s directory** (not `process.cwd()`). A relative `from` is also resolved against that spec directory (absolute `from` is used as-is): - -```ts -import { join } from "node:path"; -import { copy_to_workspace } from "agent-gwt"; - -async function tests_are_in_workspace(this: Context) { - await copy_to_workspace(this.workspace, ["fixtures/**/*.spec.ts"]); -} - -async function shared_fixtures_are_in_workspace(this: Context) { - await copy_to_workspace(this.workspace, ["**/*.spec.ts"], { - from: join(import.meta.dirname, "../shared-fixtures"), - }); -} -``` - -Optional `base` strips a matching path prefix (`*` = one folder segment). `base: "fixtures/*"` turns `fixtures/suite-a/foo.spec.ts` into `foo.spec.ts` and `fixtures/suite-b/nested/bar.spec.ts` into `nested/bar.spec.ts`. - -```ts -await copy_to_workspace(this.workspace, ["fixtures/**/*.spec.ts"], { - base: "fixtures/*", -}); -``` - -An empty `globs` array, a glob that matches no files, a `base` that does not match every file, or two sources mapping to the same destination all throw, and nothing is copied. - -### 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 -``` - -Docker Desktop applies this to both `docker build` and `docker run`, so nothing in the library changes. - -### Extending with toolchains - -Install packages in a child image that derives from the agent image, register a named **variant** in `globalSetup`, then select it from `agent()`: - -```dockerfile -# docker/agent.Dockerfile -ARG AGENT_IMAGE=agent-gwt/cursor-cli:local -FROM ${AGENT_IMAGE} -# or default: agent-gwt/claude-code:local - -# Prefer yay for everything (it wraps pacman) so official + AUR deps share one layer. -# yay refuses root — switch to the aur user for the install. -USER aur -RUN yay -S --noconfirm --needed nodejs npm python rust some-aur-package -USER root -``` - -```ts -// vitest.global-setup.ts -import { buildToolchainImage } from "agent-gwt"; - -export default async function setup() { - await buildToolchainImage("node18", { - agent: "cursor", - dockerfileRelative: "docker/agent.Dockerfile", - }); -} -``` - -```ts -agent({ name: "cursor", variant: "node18", model: "auto" }); -// omit variant → stock agent-gwt/cursor-cli:local -``` - -`buildToolchainImage` builds the agent image first, passes `--build-arg AGENT_IMAGE=…`, tags a per-repo image (`agent-gwt/toolchain--:`), and registers the variant under `/tmp/.agents-gwt/toolchains//` (one file per variant, so parallel registration is safe). The digest covers **Dockerfile bytes + parent image ID**; `docker build` is always run (daemon cache applies) so `COPY`/`ADD` context changes are picked up on the next `buildToolchainImage` call. `packageRoot` defaults to `process.cwd()` and must match the cwd used when resolving `agent({ variant })`. `image` remains a low-level override and is mutually exclusive with `variant`. - -Caveats: - -- Vitest watch does not re-run `globalSetup` — restart after Dockerfile or parent-image changes, or the variant still points at the previous tag until you rebuild. -- Prefer `FROM ${AGENT_IMAGE}` (or a literal `FROM` matching the agent) so `agent: "claude"` cannot silently wrap a Cursor base. Validation checks the **first** `FROM` only (single-stage Dockerfiles). - -The base uses Arch/`pacman` (glibc). Alpine will not run the Cursor CLI. - -## What `agent` does - -Suite-level `withAspect` **before** hook that: - -1. Resolves `name` via the agents registry and sets `this.agent` -2. Sets `this.model` when provided; sets `this.image` from `options.image`, a registered `options.variant`, or the resolved agent -3. Asserts that Docker image already exists (`docker image inspect`) — it does **not** build. Build once in `globalSetup` with `buildAgentImage(...)` / `buildToolchainImage(...)` so parallel test files do not race - -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 [--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 - -| Export | Role | -| -------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `AgentContext` | Extensible context type (`workspace`, `prompt`, `agent`, `image`, …) | -| `agent(opts)` | `withAspect` before — `{ name: "cursor" \| "claude", model?, variant?, image? }` | -| `buildAgentImage(name)` | Suite setup — builds base + agent image (use in vitest `globalSetup`) | -| `buildToolchainImage(variant, opts)` | Suite setup — builds a per-repo toolchain layer and registers `variant` for `agent()` | -| `buildBaseImage()` | Builds `agent-gwt/base:local` only | -| `buildDockerImage(...)` | Builds an arbitrary Dockerfile (low-level; prefer `buildToolchainImage` for toolchains) | -| `a_workspace` | Creates `/tmp/.agents-gwt/ws-*` (use in `withAspect` before, or in `given`) | -| `copy_to_workspace(workspace, globs, options?)` | Copy glob-matched files into `workspace` from the current spec directory (`from`, `base`) | -| `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. 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 - -See [CONTRIBUTING.md](CONTRIBUTING.md) for PR prereleases, architecture, and how to add agents. Publishing details live in [PUBLISHING.md](PUBLISHING.md). +See [packages/clanker-cleanroom/README.md](packages/clanker-cleanroom/README.md) and [packages/agent-gwt/README.md](packages/agent-gwt/README.md) for usage. diff --git a/context7.json b/context7.json index 2706b79..67e0217 100644 --- a/context7.json +++ b/context7.json @@ -4,10 +4,10 @@ "description": "GWT step functions for repeatable agent tests with Cursor and Claude Code via vitest-gwt", "folders": [], "excludeFolders": [ - "src", - "e2e", - "lib", - "docker", + "packages/*/src", + "packages/*/e2e", + "packages/*/lib", + "packages/*/docker", "node_modules", ".wireit", ".codegraph", @@ -15,18 +15,13 @@ ".vscode", ".github" ], - "excludeFiles": [ - "CONTRIBUTING.md", - "PUBLISHING.md", - "LICENSE", - "pnpm-lock.yaml" - ], + "excludeFiles": ["CONTRIBUTING.md", "PUBLISHING.md", "LICENSE", "pnpm-lock.yaml"], "rules": [ "Use with vitest-gwt: withAspect(agent({ name, model })), withAspect(a_workspace, cleanup_workspace), then Given/When/Then", - "Build Docker images once in vitest globalSetup with buildAgentImage — agent() only asserts the image exists", + "Build Docker images once in vitest globalSetup with buildImages — agent() only asserts the image exists", "Raise Vitest timeouts for agent runs (often 60s or more)", "Require Docker plus host agent login: Cursor ~/.config/cursor/auth.json, or Claude CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY", - "Prefer agent({ name, model, variant? }) and buildToolchainImage for toolchains over raw image overrides", + "Prefer agent({ name, model }) with registry tag names (e.g. cursor:node) and buildImages({ dir }) for toolchains over raw image overrides", "Seed workspace files in given before executing_the_agent; use copy_to_workspace for fixture trees" ] } diff --git a/package.json b/package.json index 62225dd..16ed506 100644 --- a/package.json +++ b/package.json @@ -1,135 +1,18 @@ { - "name": "agent-gwt", + "name": "agent-gwt-workspace", "version": "0.2.3", - "description": "GWT step functions for repeatable agent tests", - "keywords": [ - "agent", - "claude", - "claude-code", - "cursor", - "gwt", - "testing", - "vitest", - "vitest-gwt" - ], - "homepage": "https://github.com/improving/agent-gwt#readme", - "bugs": { - "url": "https://github.com/improving/agent-gwt/issues" - }, - "license": "MIT", - "author": "Eric Siebeneich (https://github.com/improving)", - "repository": { - "type": "git", - "url": "git+https://github.com/improving/agent-gwt.git" - }, - "files": [ - "lib", - "src", - "docker", - "!**/*.spec.ts" - ], + "private": true, "type": "module", - "main": "./lib/index.cjs", - "module": "./lib/index.mjs", - "types": "./lib/index.d.cts", - "exports": { - ".": { - "import": { - "types": "./lib/index.d.mts", - "default": "./lib/index.mjs" - }, - "require": { - "types": "./lib/index.d.cts", - "default": "./lib/index.cjs" - } - }, - "./package.json": "./package.json" - }, - "publishConfig": { - "access": "public" - }, "scripts": { - "build": "wireit", - "test": "wireit", - "test:e2e": "wireit", - "lint": "wireit", - "test:coverage": "wireit", - "prepublishOnly": "wireit" - }, - "wireit": { - "build": { - "command": "vp pack", - "files": [ - "src/**/*.ts", - "tsconfig.json", - "vite.config.ts", - "!src/**/*.spec.ts" - ], - "output": [ - "lib/**" - ] - }, - "lint": { - "command": "vp lint src", - "files": [ - "src/**/*.ts", - "tsconfig.json", - "vite.config.ts" - ] - }, - "test": { - "command": "vitest run", - "files": [ - "src/**/*.ts", - "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": [ - "src/**/*.ts", - "vite.config.ts" - ], - "output": [ - "coverage/**" - ] - }, - "prepublishOnly": { - "dependencies": [ - "lint", - "test", - "build" - ] - } + "build": "pnpm -r run build", + "test": "pnpm -r run test", + "lint": "pnpm -r run lint", + "test:coverage": "pnpm -r run test:coverage", + "test:e2e": "pnpm --filter agent-gwt run test:e2e" }, "devDependencies": { - "@types/node": "^26.2.0", - "@vitest/coverage-v8": "catalog:", - "typescript": "^7.0.2", - "vite": "catalog:", "vite-plus": "catalog:", - "vitest": "catalog:", - "vitest-gwt": "^4.1.4", - "wireit": "^0.14.13" - }, - "peerDependencies": { - "vitest": ">=4.0.0", - "vitest-gwt": ">=4.0.0" - }, - "peerDependenciesMeta": { - "vitest": { - "optional": true - }, - "vitest-gwt": { - "optional": true - } + "vitest": "catalog:" }, "packageManager": "pnpm@11.22.0" } diff --git a/packages/agent-gwt/README.md b/packages/agent-gwt/README.md new file mode 100644 index 0000000..751a0a9 --- /dev/null +++ b/packages/agent-gwt/README.md @@ -0,0 +1,309 @@ +# agent-gwt + +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). + +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 + +```bash +pnpm add -D agent-gwt vitest vitest-gwt +``` + +## Prerequisites + +1. Docker +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 stock images once in `globalSetup` (from `clanker-cleanroom` via this package): + +```ts +// vitest.global-setup.ts +import { buildImages } from "agent-gwt"; + +export default async function setup() { + await buildImages(); +} +``` + +```ts +// vite.config.ts (or vitest.config.ts) +import { defineConfig } from "vite-plus"; // or "vitest/config" + +export default defineConfig({ + test: { + globalSetup: ["./vitest.global-setup.ts"], + }, +}); +``` + +## Usage + +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 })`; `name` is a stock short name or a registry tag (same as `new Agent(name)`): + +```ts +withAspect(agent({ name: "cursor", model: "auto" })); // Cursor CLI +withAspect(agent({ name: "claude", model: "sonnet" })); // Claude Code +withAspect(agent({ name: "cursor:node", model: "auto" })); // toolchain image +``` + +### Simple prompt + +```ts +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect } from "vitest"; +import test, { withAspect, withTestOptions } from "vitest-gwt"; +import { + type AgentContext, + a_workspace, + agent, + cleanup_workspace, + executing_the_agent, + the_prompt, +} from "agent-gwt"; + +describe("simple prompt", () => { + withAspect(agent({ name: "cursor", model: "auto" })); + withAspect(a_workspace, cleanup_workspace); + + withTestOptions((opts) => (opts.timeout = 60 * 1000)); + + 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, + }, + }); +}); + +type Context = AgentContext & { + // extend with your own fields +}; + +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"); +} +``` + +### Seeded workspace + +Seed files in `given` before `executing_the_agent`. The agent sees them under `this.workspace`: + +```ts +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect } from "vitest"; +import test, { withAspect, withTestOptions } from "vitest-gwt"; +import { + type AgentContext, + a_workspace, + agent, + cleanup_workspace, + executing_the_agent, + the_prompt, +} from "agent-gwt"; + +describe("with a workspace", () => { + withAspect(agent({ name: "cursor", model: "auto" })); + withAspect(a_workspace, cleanup_workspace); + + withTestOptions((opts) => (opts.timeout = 60 * 1000)); + + test("can read the workspace", { + given: { + the_prompt: the_prompt( + "read @sample.txt, and answer the question. Write a new file 'answer.sentinel' with your answer", + ), + sample_text_file, + }, + when: { + executing_the_agent, + }, + then: { + sentinel_file_exists, + question_is_answered, + }, + }); +}); + +type Context = AgentContext & { + // extend with your own fields +}; + +async function sample_text_file(this: Context) { + await writeFile( + join(this.workspace, "sample.txt"), + "what is the answer to life, the universe, and everything?", + "utf-8", + ); +} + +async function sentinel_file_exists(this: Context) { + await access(join(this.workspace, "answer.sentinel")); +} + +async function question_is_answered(this: Context) { + const contents = await readFile(join(this.workspace, "answer.sentinel"), "utf-8"); + + expect(contents.toLowerCase()).toContain("42"); +} +``` + +To copy a fixture tree instead of writing files one by one, use `copy_to_workspace`. Globs are resolved from the **current Vitest spec file’s directory** (not `process.cwd()`). A relative `from` is also resolved against that spec directory (absolute `from` is used as-is): + +```ts +import { join } from "node:path"; +import { copy_to_workspace } from "agent-gwt"; + +async function tests_are_in_workspace(this: Context) { + await copy_to_workspace(this.workspace, ["fixtures/**/*.spec.ts"]); +} + +async function shared_fixtures_are_in_workspace(this: Context) { + await copy_to_workspace(this.workspace, ["**/*.spec.ts"], { + from: join(import.meta.dirname, "../shared-fixtures"), + }); +} +``` + +Optional `base` strips a matching path prefix (`*` = one folder segment). `base: "fixtures/*"` turns `fixtures/suite-a/foo.spec.ts` into `foo.spec.ts` and `fixtures/suite-b/nested/bar.spec.ts` into `nested/bar.spec.ts`. + +```ts +await copy_to_workspace(this.workspace, ["fixtures/**/*.spec.ts"], { + base: "fixtures/*", +}); +``` + +An empty `globs` array, a glob that matches no files, a `base` that does not match every file, or two sources mapping to the same destination all throw, and nothing is copied. + +### Inspecting the result + +`this.agentResult` is normalized metrics (`AgentRunResult`) — duration, cost, and token usage. Dialog text is not included. Missing fields are `null` (Cursor has no dollar cost in CLI output today): + +```ts +function cheap_enough(this: Context) { + expect(this.agentResult.durationMs).not.toBeNull(); + expect(this.agentResult.costUsd).toBeLessThan(0.5); // Claude; null for Cursor +} +``` + +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 + +Stock images ship inside the `clanker-cleanroom` package (resolved from `node_modules`). First-line tag comments + `FROM` deps drive the build order: + +| Image | Role | +| -------------------------- | ------------------------------------------- | +| `clanker-cleanroom/base` | Shared Arch Linux base (`yay` + `aur` user) | +| `clanker-cleanroom/cursor` | Cursor CLI on top of the base | +| `clanker-cleanroom/claude` | Claude Code CLI on top of the base | + +`buildImages()` builds the whole stock folder in dependency order and records tags in `clanker-cleanroom.images.json` at the project root (build once, run many). + +### 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 +``` + +Docker Desktop applies this to both `docker build` and `docker run`, so nothing in the library changes. + +### Extending with toolchains + +Put app Dockerfiles in a folder (first line = tag, `FROM clanker-cleanroom/cursor`): + +```dockerfile +# cursor:node +FROM clanker-cleanroom/cursor + +USER aur +RUN yay -S --noconfirm --needed nodejs npm +USER root +``` + +```ts +// vitest.global-setup.ts +import { buildImages } from "agent-gwt"; + +export default async function setup() { + await buildImages(); // stock images from clanker-cleanroom + await buildImages({ dir: "./docker/toolchains" }); +} +``` + +```ts +agent({ name: "cursor:node", model: "auto" }); +``` + +`name` must be a stock agent (`cursor` / `claude`) or a tag recorded in `clanker-cleanroom.images.json` (which also stores which stock binding to use). `image` remains a low-level override of the resolved Docker tag. + +## What `agent` does + +Suite-level `withAspect` **before** hook that: + +1. Resolves `name` via `new Agent(name)` and sets `this.agent` +2. Sets `this.model` when provided; sets `this.image` from `options.image` or the resolved agent +3. Asserts that Docker image already exists (`docker image inspect`) — it does **not** build. Build once in `globalSetup` with `buildImages()` so parallel test files do not race + +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 [--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 normalized metrics (`durationMs`, `costUsd`, `usage`) + +## Exports + +| Export | Role | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `AgentContext` | Extensible context type (`workspace`, `prompt`, `agent`, `image`, …) | +| `agent(opts)` | `withAspect` before — `{ name: stock \| registry tag, model?, image? }` | +| `buildImages(opts?)` | Suite setup — topo-build a Dockerfile folder (default: stock images) | +| `a_workspace` | Creates `/tmp/.agents-gwt/ws-*` (use in `withAspect` before, or in `given`) | +| `copy_to_workspace(workspace, globs, options?)` | Copy glob-matched files into `workspace` from the current spec directory (`from`, `base`) | +| `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(...)` | +| `ClaudeCredentials` / `resolveClaudeCredentials()` | Credential source for the Claude container (token, API key, or file) | +| `AgentRunResult` | Normalized metrics: `durationMs`, `costUsd`, `usage` (null when unavailable) | + +## Isolation notes + +- **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 + +See [CONTRIBUTING.md](../../CONTRIBUTING.md) for PR prereleases, architecture, and how to add agents. Publishing details live in [PUBLISHING.md](../../PUBLISHING.md). diff --git a/e2e/claude.spec.ts b/packages/agent-gwt/e2e/claude.spec.ts similarity index 65% rename from e2e/claude.spec.ts rename to packages/agent-gwt/e2e/claude.spec.ts index cc0e8d1..235dc14 100644 --- a/e2e/claude.spec.ts +++ b/packages/agent-gwt/e2e/claude.spec.ts @@ -2,7 +2,6 @@ import { describe, expect } from "vitest"; import test, { withAspect } from "vitest-gwt"; import { type AgentContext, - type ClaudeAgentResult, a_workspace, agent, cleanup_workspace, @@ -26,18 +25,15 @@ describe.skipIf(!hasClaudeCredential())("claude agent (e2e)", () => { then: { readme_exists, readme_contains_HELLO_WORLD, - result_is_a_successful_claude_run, + result_has_metrics, }, }); }); -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); +function result_has_metrics(this: AgentContext) { + expect(this.agentResult.durationMs).not.toBeNull(); + expect(this.agentResult.costUsd).not.toBeNull(); console.log( - `claude: ${result.num_turns} turns, $${result.total_cost_usd.toFixed(4)}, session ${result.session_id}`, + `claude: ${this.agentResult.durationMs}ms, $${(this.agentResult.costUsd ?? 0).toFixed(4)}`, ); } diff --git a/e2e/credentials.ts b/packages/agent-gwt/e2e/credentials.ts similarity index 90% rename from e2e/credentials.ts rename to packages/agent-gwt/e2e/credentials.ts index c366b1e..c6aa621 100644 --- a/e2e/credentials.ts +++ b/packages/agent-gwt/e2e/credentials.ts @@ -20,7 +20,7 @@ export function hasClaudeCredential(env: NodeJS.ProcessEnv = process.env): boole ); } -/** Same check runCursorInDocker() makes before it starts the container. */ +/** Same check cursorBinding.prepare makes before the container starts. */ export function hasCursorCredential(): boolean { return existsSync(defaultHostAuthFile(homedir())); } diff --git a/e2e/cursor.spec.ts b/packages/agent-gwt/e2e/cursor.spec.ts similarity index 100% rename from e2e/cursor.spec.ts rename to packages/agent-gwt/e2e/cursor.spec.ts diff --git a/e2e/global-setup.ts b/packages/agent-gwt/e2e/global-setup.ts similarity index 59% rename from e2e/global-setup.ts rename to packages/agent-gwt/e2e/global-setup.ts index a2ecd32..d45ee2a 100644 --- a/e2e/global-setup.ts +++ b/packages/agent-gwt/e2e/global-setup.ts @@ -1,4 +1,4 @@ -import { type AgentName, buildAgentImage } from "../src/index.js"; +import { type AgentName, buildImages } from "../src/index.js"; import { hasClaudeCredential, hasCursorCredential } from "./credentials.js"; const agents: Array<{ name: AgentName; available: boolean; hint: string }> = [ @@ -11,14 +11,19 @@ const agents: Array<{ name: AgentName; available: boolean; hint: string }> = [ ]; export default async function setup() { + const anyAvailable = agents.some((agent) => agent.available); + if (!anyAvailable) { + process.stderr.write("[e2e] No agent credentials present; skipping image builds.\n"); + return; + } + 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`, + `[e2e] No ${agent.name} credential (${agent.hint}); its tests will skip.\n`, ); - continue; } - - await buildAgentImage(agent.name); } + + await buildImages(); } diff --git a/e2e/steps.ts b/packages/agent-gwt/e2e/steps.ts similarity index 100% rename from e2e/steps.ts rename to packages/agent-gwt/e2e/steps.ts diff --git a/e2e/vitest.e2e.config.ts b/packages/agent-gwt/e2e/vitest.e2e.config.ts similarity index 100% rename from e2e/vitest.e2e.config.ts rename to packages/agent-gwt/e2e/vitest.e2e.config.ts diff --git a/packages/agent-gwt/package.json b/packages/agent-gwt/package.json new file mode 100644 index 0000000..ffc4fac --- /dev/null +++ b/packages/agent-gwt/package.json @@ -0,0 +1,154 @@ +{ + "name": "agent-gwt", + "version": "0.2.3", + "description": "GWT step functions for repeatable agent tests", + "keywords": [ + "agent", + "claude", + "claude-code", + "cursor", + "gwt", + "testing", + "vitest", + "vitest-gwt" + ], + "homepage": "https://github.com/improving/agent-gwt#readme", + "bugs": { + "url": "https://github.com/improving/agent-gwt/issues" + }, + "license": "MIT", + "author": "Eric Siebeneich (https://github.com/improving)", + "repository": { + "type": "git", + "url": "git+https://github.com/improving/agent-gwt.git", + "directory": "packages/agent-gwt" + }, + "files": [ + "lib", + "src", + "!**/*.spec.ts" + ], + "type": "module", + "main": "./lib/index.cjs", + "module": "./lib/index.mjs", + "types": "./lib/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./lib/index.d.mts", + "default": "./lib/index.mjs" + }, + "require": { + "types": "./lib/index.d.cts", + "default": "./lib/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "wireit", + "test": "wireit", + "test:e2e": "wireit", + "lint": "wireit", + "test:coverage": "wireit", + "prepublishOnly": "wireit" + }, + "wireit": { + "build": { + "command": "vp pack", + "files": [ + "src/**/*.ts", + "tsconfig.json", + "vite.config.ts", + "!src/**/*.spec.ts" + ], + "output": [ + "lib/**" + ], + "dependencies": [ + "../clanker-cleanroom:build" + ] + }, + "lint": { + "command": "vp lint src", + "files": [ + "src/**/*.ts", + "tsconfig.json", + "vite.config.ts" + ], + "dependencies": [ + "../clanker-cleanroom:build" + ] + }, + "test": { + "command": "vitest run", + "files": [ + "src/**/*.ts", + "vite.config.ts" + ], + "dependencies": [ + "../clanker-cleanroom:build" + ] + }, + "test:e2e": { + "command": "vitest run --config e2e/vitest.e2e.config.ts", + "files": [ + "src/**/*.ts", + "e2e/**/*.ts" + ], + "dependencies": [ + "../clanker-cleanroom:build", + "build" + ] + }, + "test:coverage": { + "command": "vitest run --coverage", + "files": [ + "src/**/*.ts", + "vite.config.ts" + ], + "output": [ + "coverage/**" + ], + "dependencies": [ + "../clanker-cleanroom:build" + ] + }, + "prepublishOnly": { + "dependencies": [ + "lint", + "test", + "build" + ] + } + }, + "dependencies": { + "clanker-cleanroom": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:", + "vitest-gwt": "catalog:", + "wireit": "catalog:" + }, + "peerDependencies": { + "vitest": ">=4.0.0", + "vitest-gwt": ">=4.0.0" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + }, + "vitest-gwt": { + "optional": true + } + }, + "packageManager": "pnpm@11.22.0" +} diff --git a/src/given/a_workspace.spec.ts b/packages/agent-gwt/src/given/a_workspace.spec.ts similarity index 100% rename from src/given/a_workspace.spec.ts rename to packages/agent-gwt/src/given/a_workspace.spec.ts diff --git a/src/given/a_workspace.ts b/packages/agent-gwt/src/given/a_workspace.ts similarity index 100% rename from src/given/a_workspace.ts rename to packages/agent-gwt/src/given/a_workspace.ts diff --git a/src/given/agent.spec.ts b/packages/agent-gwt/src/given/agent.spec.ts similarity index 52% rename from src/given/agent.spec.ts rename to packages/agent-gwt/src/given/agent.spec.ts index bfce72e..39b41a1 100644 --- a/src/given/agent.spec.ts +++ b/packages/agent-gwt/src/given/agent.spec.ts @@ -1,11 +1,13 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { describe, expect, vi } from "vitest"; import test, { withAspect } from "vitest-gwt"; -import * as toolchainModule from "../agents/build-toolchain-image.js"; -import * as ensureImageModule from "../agents/ensure-image.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 { CLAUDE_IMAGE, CURSOR_IMAGE, resetRegistry, upsertRegistryEntry } from "clanker-cleanroom"; +import * as cleanroom from "clanker-cleanroom"; + import { agent } from "./agent.js"; import type { AgentContext } from "../types.js"; @@ -13,7 +15,8 @@ type Context = AgentContext & { ensureCalls: number; ensuredImage: string | undefined; error: Error | undefined; - variant: string; + packageRoot: string; + toolchainName: string; toolchainImage: string; }; @@ -28,7 +31,7 @@ describe("agent", () => { applying_agent: agent({ name: "cursor", model: "auto" }), }, then: { - agent_is: agent_is("cursor"), + agent_name_is: agent_name_is("cursor"), model_is: model_is("auto"), image_is: image_is(CURSOR_IMAGE), ensure_was_called_with: ensure_was_called_with(CURSOR_IMAGE), @@ -43,48 +46,37 @@ describe("agent", () => { applying_agent: agent({ name: "cursor", image: "my-app/agent:local" }), }, then: { - agent_is: agent_is("cursor"), + agent_name_is: agent_name_is("cursor"), image_is: image_is("my-app/agent:local"), ensure_was_called_with: ensure_was_called_with("my-app/agent:local"), }, }); - test("resolves a registered toolchain variant", { + test("resolves a registered toolchain by name", { given: { stub_ensure_docker_image, - registered_toolchain_variant, + registered_toolchain, }, when: { - applying_agent_with_variant, + applying_agent_with_toolchain, }, then: { - agent_is: agent_is("cursor"), - image_is_toolchain_variant, + agent_name_is_toolchain, + image_is_toolchain, ensure_was_called_with_toolchain, }, }); - test("throws when the toolchain variant is unknown", { - given: { - stub_ensure_docker_image, - }, - when: { - applying_agent_with_unknown_variant, - }, - then: { - error_mentions_unknown_variant, - }, - }); - - test("throws when both image and variant are set", { + test("throws when the toolchain name is unknown", { given: { stub_ensure_docker_image, + empty_registry_root, }, when: { - applying_agent_with_image_and_variant, + applying_unknown_toolchain, }, then: { - error_mentions_mutual_exclusion, + error_mentions_unknown_agent, }, }); @@ -96,7 +88,7 @@ describe("agent", () => { applying_agent: agent({ name: "claude", model: "sonnet" }), }, then: { - agent_is: agent_is("claude"), + agent_name_is: agent_name_is("claude"), model_is: model_is("sonnet"), image_is: image_is(CLAUDE_IMAGE), ensure_was_called_with: ensure_was_called_with(CLAUDE_IMAGE), @@ -113,53 +105,59 @@ function reset_agent_test_state(this: Context) { function stub_ensure_docker_image(this: Context) { this.ensureCalls = 0; - vi.spyOn(ensureImageModule, "ensureDockerImage").mockImplementation(async (image) => { + vi.spyOn(cleanroom, "ensureDockerImage").mockImplementation(async (image) => { this.ensureCalls += 1; this.ensuredImage = image; }); } -function registered_toolchain_variant(this: Context) { - this.variant = "node18"; - this.toolchainImage = "agent-gwt/toolchain-cursor-abcd1234ef00:fedcba987654"; - vi.spyOn(toolchainModule, "resolveToolchainImage").mockImplementation((agentName, variant) => { - if (agentName === "cursor" && variant === this.variant) { - return this.toolchainImage; - } - return undefined; - }); +function empty_registry_root(this: Context) { + this.packageRoot = mkdtempSync(join(tmpdir(), "agent-gwt-reg-")); + resetRegistry({ packageRoot: this.packageRoot }); } -async function applying_agent_with_variant(this: Context) { - await agent({ name: "cursor", variant: this.variant, model: "auto" }).call(this); +function registered_toolchain(this: Context) { + empty_registry_root.call(this); + this.toolchainName = "cursor:node"; + this.toolchainImage = "cursor:node"; + upsertRegistryEntry( + this.toolchainName, + { + image: this.toolchainImage, + dockerfile: "node.Dockerfile", + builtAt: new Date().toISOString(), + agent: "cursor", + }, + { packageRoot: this.packageRoot }, + ); } -async function applying_agent_with_unknown_variant(this: Context) { - try { - await agent({ name: "cursor", variant: "missing" }).call(this); - } catch (error) { - this.error = error as Error; - } +async function applying_agent_with_toolchain(this: Context) { + await agent({ + name: this.toolchainName, + model: "auto", + packageRoot: this.packageRoot, + }).call(this); } -async function applying_agent_with_image_and_variant(this: Context) { +async function applying_unknown_toolchain(this: Context) { try { - await agent({ - name: "cursor", - image: "my-app/agent:local", - variant: "node18", - }).call(this); + await agent({ name: "missing:toolchain", packageRoot: this.packageRoot }).call(this); } catch (error) { this.error = error as Error; } } -function agent_is(name: AgentName) { +function agent_name_is(name: string) { return function (this: Context) { - expect(this.agent).toBe(agentRegistry[name]); + expect(this.agent.name).toBe(name); }; } +function agent_name_is_toolchain(this: Context) { + expect(this.agent.name).toBe(this.toolchainName); +} + function model_is(model: string) { return function (this: Context) { expect(this.model).toBe(model); @@ -172,7 +170,7 @@ function image_is(image: string) { }; } -function image_is_toolchain_variant(this: Context) { +function image_is_toolchain(this: Context) { expect(this.image).toBe(this.toolchainImage); } @@ -188,11 +186,7 @@ function ensure_was_called_with_toolchain(this: Context) { expect(this.ensuredImage).toBe(this.toolchainImage); } -function error_mentions_unknown_variant(this: Context) { - expect(this.error?.message).toContain('Unknown toolchain variant "missing"'); - expect(this.error?.message).toContain("buildToolchainImage"); -} - -function error_mentions_mutual_exclusion(this: Context) { - expect(this.error?.message).toContain("cannot set both image and variant"); +function error_mentions_unknown_agent(this: Context) { + expect(this.error?.message).toContain('Unknown agent "missing:toolchain"'); + expect(this.error?.message).toContain("buildImages"); } diff --git a/packages/agent-gwt/src/given/agent.ts b/packages/agent-gwt/src/given/agent.ts new file mode 100644 index 0000000..d768988 --- /dev/null +++ b/packages/agent-gwt/src/given/agent.ts @@ -0,0 +1,28 @@ +import { Agent, ensureDockerImage, type RegistryOptions } from "clanker-cleanroom"; + +import type { AgentContext } from "../types.js"; + +export type ConfigureAgentOptions = { + /** Stock short name (`cursor`, `claude`) or a registry tag (`cursor:node`). */ + name: string; + model?: string; + /** Override the resolved Docker image tag. */ + image?: string; +} & RegistryOptions; + +export function agent(options: ConfigureAgentOptions) { + const registryOptions = + options.packageRoot !== undefined ? { packageRoot: options.packageRoot } : {}; + const resolved = new Agent(options.name, registryOptions); + + return async function (this: AgentContext): Promise { + this.agent = resolved; + this.image = options.image ?? resolved.image; + + if (options.model !== undefined) { + this.model = options.model; + } + + await ensureDockerImage(this.image); + }; +} diff --git a/src/given/copy_to_workspace.fixtures/hello.txt b/packages/agent-gwt/src/given/copy_to_workspace.fixtures/hello.txt similarity index 100% rename from src/given/copy_to_workspace.fixtures/hello.txt rename to packages/agent-gwt/src/given/copy_to_workspace.fixtures/hello.txt diff --git a/src/given/copy_to_workspace.spec.ts b/packages/agent-gwt/src/given/copy_to_workspace.spec.ts similarity index 100% rename from src/given/copy_to_workspace.spec.ts rename to packages/agent-gwt/src/given/copy_to_workspace.spec.ts diff --git a/src/given/copy_to_workspace.ts b/packages/agent-gwt/src/given/copy_to_workspace.ts similarity index 100% rename from src/given/copy_to_workspace.ts rename to packages/agent-gwt/src/given/copy_to_workspace.ts diff --git a/src/given/the_prompt.spec.ts b/packages/agent-gwt/src/given/the_prompt.spec.ts similarity index 100% rename from src/given/the_prompt.spec.ts rename to packages/agent-gwt/src/given/the_prompt.spec.ts diff --git a/src/given/the_prompt.ts b/packages/agent-gwt/src/given/the_prompt.ts similarity index 100% rename from src/given/the_prompt.ts rename to packages/agent-gwt/src/given/the_prompt.ts diff --git a/packages/agent-gwt/src/index.ts b/packages/agent-gwt/src/index.ts new file mode 100644 index 0000000..110eebb --- /dev/null +++ b/packages/agent-gwt/src/index.ts @@ -0,0 +1,61 @@ +export type { + AgentContext, + AgentRunResult, + AgentOptions, + AgentName, + ConfigureAgentOptions, +} from "./types.js"; + +export { a_workspace, cleanup_workspace, AGENTS_GWT_TMP_ROOT } from "./given/a_workspace.js"; +export { copy_to_workspace, type CopyToWorkspaceOptions } from "./given/copy_to_workspace.js"; +export { the_prompt } from "./given/the_prompt.js"; +export { executing_the_agent } from "./when/executing_the_agent.js"; +export { agent } from "./given/agent.js"; + +export { + Agent, + CONTAINER_AUTH_PATH, + CURSOR_IMAGE, + defaultHostAuthFile, + cursorAgent, + cursorBinding, + CLAUDE_API_KEY_ENV, + CLAUDE_CONTAINER_CREDENTIALS_PATH, + CLAUDE_IMAGE, + CLAUDE_OAUTH_TOKEN_ENV, + defaultClaudeHostCredentialsFile, + resolveClaudeCredentials, + claudeAgent, + claudeBinding, + type ClaudeCredentials, + bindingRegistry, + createAgent, + type CreateAgentBindings, + type AgentBinding, + type TokenUsage, + type StockAgentName, + buildImages, + resetBuildMemo, + type BuildImagesOptions, + resolveImage, + readRegistry, + resetRegistry, + upsertRegistryEntry, + BASE_IMAGE, + CONTAINER_HOME, + CONTAINER_WORKSPACE, + PACKAGE_ROOT, + ensureDockerImage, + parseAgentJsonOutput, + buildDockerRunArgs, + invokeDocker, + runDocker, + runBoundAgent, + type DockerRunner, + type DockerRunOptions, + type DockerRunResult, + type BuildDockerRunArgsOptions, + type DockerVolumeMount, + type RunAgentOptions, + type AgentRunBindingsOptions, +} from "clanker-cleanroom"; diff --git a/packages/agent-gwt/src/types.ts b/packages/agent-gwt/src/types.ts new file mode 100644 index 0000000..65d7ab8 --- /dev/null +++ b/packages/agent-gwt/src/types.ts @@ -0,0 +1,13 @@ +import type { Agent, AgentName, AgentOptions, AgentRunResult } from "clanker-cleanroom"; + +export type { AgentRunResult, Agent, AgentOptions, AgentName }; +export type { ConfigureAgentOptions } from "./given/agent.js"; + +export type AgentContext = { + workspace: string; + prompt: string; + agentResult: AgentRunResult; + agent: Agent; + image: string; + model?: string; +}; diff --git a/src/when/executing_the_agent.spec.ts b/packages/agent-gwt/src/when/executing_the_agent.spec.ts similarity index 73% rename from src/when/executing_the_agent.spec.ts rename to packages/agent-gwt/src/when/executing_the_agent.spec.ts index 69b9f3c..ad8fbcf 100644 --- a/src/when/executing_the_agent.spec.ts +++ b/packages/agent-gwt/src/when/executing_the_agent.spec.ts @@ -1,11 +1,13 @@ import { describe, expect, vi } from "vitest"; import test from "vitest-gwt"; -import type { Agent, AgentContext } from "../types.js"; +import { Agent } from "clanker-cleanroom"; + +import type { AgentContext } from "../types.js"; import { executing_the_agent } from "./executing_the_agent.js"; type Context = AgentContext & { - runMock: ReturnType; + runSpy: ReturnType; }; describe("executing_the_agent", () => { @@ -71,14 +73,29 @@ describe("executing_the_agent", () => { }); }); +const stubResult = { + durationMs: 10, + costUsd: null, + usage: { + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + }, +} as const; + function stubAgent(this: Context): void { - this.runMock = vi.fn(async () => ({ type: "result", result: "done" })); - this.agent = { - image: "agent-gwt/test:local", - ensureImage: async () => undefined, - buildImage: async () => undefined, - run: this.runMock as Agent["run"], - }; + this.agent = Agent.fromBinding({ + image: "clanker-cleanroom/cursor", + displayName: "Cursor", + command: () => ["agent"], + prepare: async () => ({}), + parseResult: () => ({ ...stubResult, usage: { ...stubResult.usage } }), + }); + this.runSpy = vi.spyOn(this.agent, "run").mockResolvedValue({ + ...stubResult, + usage: { ...stubResult.usage }, + }); } function workspace_prompt_and_agent(this: Context) { @@ -117,22 +134,31 @@ function workspace_and_prompt_without_agent(this: Context) { } function agent_result_is_set(this: Context) { - expect(this.agentResult).toEqual({ type: "result", result: "done" }); + expect(this.agentResult).toEqual({ + durationMs: 10, + costUsd: null, + usage: { + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + }, + }); } function agent_was_called_with_workspace_and_prompt(this: Context) { - expect(this.runMock).toHaveBeenCalledWith({ + expect(this.runSpy).toHaveBeenCalledWith({ workspace: "/tmp/.agents-gwt/ws-test", prompt: "Create a README", - image: "agent-gwt/test:local", + image: "clanker-cleanroom/cursor", }); } function agent_was_called_with_model(this: Context) { - expect(this.runMock).toHaveBeenCalledWith({ + expect(this.runSpy).toHaveBeenCalledWith({ workspace: "/tmp/.agents-gwt/ws-test", prompt: "Create a README", - image: "agent-gwt/test:local", + image: "clanker-cleanroom/cursor", model: "composer-2", }); } diff --git a/src/when/executing_the_agent.ts b/packages/agent-gwt/src/when/executing_the_agent.ts similarity index 100% rename from src/when/executing_the_agent.ts rename to packages/agent-gwt/src/when/executing_the_agent.ts diff --git a/tsconfig.json b/packages/agent-gwt/tsconfig.json similarity index 100% rename from tsconfig.json rename to packages/agent-gwt/tsconfig.json diff --git a/packages/agent-gwt/vite.config.ts b/packages/agent-gwt/vite.config.ts new file mode 100644 index 0000000..4c0a3b2 --- /dev/null +++ b/packages/agent-gwt/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + pack: { + entry: "src/index.ts", + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + outDir: "lib", + platform: "node", + root: "src", + }, + lint: { + ignorePatterns: ["lib/**", "coverage/**"], + overrides: [ + { + files: ["**/*.spec.ts"], + rules: { + "unicorn/no-thenable": "off", + }, + }, + ], + }, + test: { + include: ["src/**/*.spec.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.spec.ts"], + }, + }, +}); diff --git a/packages/clanker-cleanroom/README.md b/packages/clanker-cleanroom/README.md new file mode 100644 index 0000000..5618b69 --- /dev/null +++ b/packages/clanker-cleanroom/README.md @@ -0,0 +1,146 @@ +# clanker-cleanroom + +Build and run coding-agent Docker images with a disposable workspace and **credentials-only** mounts. + +Stock images ship inside this package. Agent-specific folders own command argv, credential mounts/env, and parsing CLI JSON into normalized metrics. Shared orchestration (`runBoundAgent`) owns `docker run`. + +For Given/When/Then test steps on top of this library, see [`agent-gwt`](../agent-gwt). + +## Install + +```bash +pnpm add -D clanker-cleanroom +``` + +## Prerequisites + +1. Docker +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`) **or** `ANTHROPIC_API_KEY` **or** a Linux host's `~/.claude/.credentials.json` (checked in that order). macOS keeps Claude login in the Keychain — set a token or API key on a Mac. + +## Build images + +```ts +import { buildImages } from "clanker-cleanroom"; + +await buildImages(); // stock docker/ from this package +``` + +`buildImages()` topo-sorts `*.Dockerfile` files by local `FROM` tags, builds in order, and records tags in `clanker-cleanroom.images.json` at the project root (build once, run many). Each entry stores the Docker tag and, when applicable, which stock agent binding to use (`cursor` or `claude`) inferred from the `FROM` chain. + +| Image | Role | +| -------------------------- | ------------------------------------------- | +| `clanker-cleanroom/base` | Shared Arch Linux base (`yay` + `aur` user) | +| `clanker-cleanroom/cursor` | Cursor CLI on top of the base | +| `clanker-cleanroom/claude` | Claude Code CLI on top of the base | + +Each Dockerfile's **first line** is the image tag (and the name you pass to `new Agent(...)`): + +```dockerfile +# clanker-cleanroom/cursor +FROM clanker-cleanroom/base +… +``` + +### Apple Silicon + +The official `archlinux` image is x86_64-only. On an arm64 Docker host: + +```bash +export DOCKER_DEFAULT_PLATFORM=linux/amd64 +``` + +### Extending with toolchains + +```dockerfile +# cursor:node +FROM clanker-cleanroom/cursor + +USER aur +RUN yay -S --noconfirm --needed nodejs npm +USER root +``` + +```ts +await buildImages(); // stock +await buildImages({ dir: "./docker/toolchains" }); +``` + +The registry records `agent: "cursor"` for `cursor:node` automatically. No need to say which base agent it came from when running. + +## Run an agent + +One lookup for stock and toolchain names: + +```ts +import { Agent } from "clanker-cleanroom"; + +await new Agent("cursor").run({ + workspace: "/tmp/ws", + prompt: "Write Hello to README.md", + model: "auto", +}); + +await new Agent("cursor:node").run({ + workspace: "/tmp/ws", + prompt: "Install deps and run tests", + model: "auto", +}); + +// result: AgentRunResult — durationMs, costUsd, usage (null when unavailable) +``` + +Convenience instances `cursorAgent` / `claudeAgent` are `new Agent("cursor")` / `new Agent("claude")`. + +### Custom binding + +```ts +import { Agent, type AgentBinding } from "clanker-cleanroom"; + +const binding: AgentBinding = { + image: "my-agent:latest", + displayName: "MyAgent", + command: ({ prompt, model }) => [/* argv */], + prepare: async () => ({ volumes: [/* … */], env: {/* docker CLI env */} }), + parseResult: (stdout) => ({/* AgentRunResult */}), +}; + +await Agent.fromBinding(binding).run({ workspace, prompt }); +``` + +## What a run does + +1. `prepare` resolves host credentials → volume mounts and/or docker-CLI env (secrets never appear on argv when using env passthrough) +2. Mounts the workspace at `/workspace` and runs as your host uid/gid +3. Invokes the agent CLI with JSON output +4. Returns `AgentRunResult` metrics (`durationMs`, `costUsd`, `usage`) — dialog text is discarded + +Cursor mounts `~/.config/cursor/auth.json` read-only (`costUsd` is always `null` today). Claude forwards `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY` by env **name**, or mounts `.credentials.json` read-only. + +## Isolation + +- **Credentials only** — no host `~/.cursor` / `~/.claude` settings, MCP, skills, or projects +- **Non-root** — container process uses host uid/gid so workspace files are owned by you +- **Workspace is the only writable host path** + +## Exports + +| Export | Role | +| -------------------------------------------- | -------------------------------------------------------------- | +| `Agent` | `new Agent(name)` — stock short name or registry tag | +| `buildImages(opts?)` | Topo-build a Dockerfile folder (default: package stock images) | +| `cursorAgent` / `claudeAgent` | `new Agent("cursor")` / `new Agent("claude")` | +| `cursorBinding` / `claudeBinding` | Command, prepare, parseResult for each CLI | +| `bindingRegistry` | Stock bindings keyed by `"cursor"` \| `"claude"` | +| `createAgent(binding)` / `Agent.fromBinding` | Wrap a custom binding | +| `runBoundAgent(binding, options)` | Shared docker orchestration | +| `AgentRunResult` | Normalized metrics; missing fields are `null` | +| `resolveImage` / `readRegistry` | Read `clanker-cleanroom.images.json` | +| `ensureDockerImage` | Assert an image exists (`docker image inspect`) | +| `buildDockerRunArgs` / `runDocker` | Lower-level docker helpers | +| `PACKAGE_ROOT` | Absolute path to this package (stock `docker/` lives here) | + +## Contributing + +See [CONTRIBUTING.md](../../CONTRIBUTING.md) and [PUBLISHING.md](../../PUBLISHING.md). diff --git a/docker/base/Dockerfile b/packages/clanker-cleanroom/docker/base.Dockerfile similarity index 91% rename from docker/base/Dockerfile rename to packages/clanker-cleanroom/docker/base.Dockerfile index 1ebb17d..52cfa1a 100644 --- a/docker/base/Dockerfile +++ b/packages/clanker-cleanroom/docker/base.Dockerfile @@ -1,5 +1,5 @@ -# Shared base for agent-gwt agent images (cursor, claude, and future agents). -# Extend with: FROM agent-gwt/base:local +# clanker-cleanroom/base +# Shared Arch/yay base for cursor, claude, and future agents. # AUR installs (build-time only): USER aur && yay -S --noconfirm ... && USER root FROM archlinux:latest diff --git a/docker/claude/Dockerfile b/packages/clanker-cleanroom/docker/claude.Dockerfile similarity index 79% rename from docker/claude/Dockerfile rename to packages/clanker-cleanroom/docker/claude.Dockerfile index 90a9836..e5b5503 100644 --- a/docker/claude/Dockerfile +++ b/packages/clanker-cleanroom/docker/claude.Dockerfile @@ -1,6 +1,5 @@ -# 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 +# clanker-cleanroom/claude +FROM clanker-cleanroom/base # Never self-update or phone home from inside the container — build steps included. ENV DISABLE_AUTOUPDATER=1 @@ -27,7 +26,7 @@ ENV PATH="/usr/local/bin:${PATH}" WORKDIR /workspace -# Runtime identity is set by agent-gwt via --user :. +# Runtime identity is set 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. +# read-only ~/.claude/.credentials.json mount. # No ENTRYPOINT — the library passes `claude ...` as the container command. diff --git a/docker/cursor/Dockerfile b/packages/clanker-cleanroom/docker/cursor.Dockerfile similarity index 81% rename from docker/cursor/Dockerfile rename to packages/clanker-cleanroom/docker/cursor.Dockerfile index a6c6e04..c3547eb 100644 --- a/docker/cursor/Dockerfile +++ b/packages/clanker-cleanroom/docker/cursor.Dockerfile @@ -1,6 +1,5 @@ -# Cursor agent image. Shared Arch/yay base is agent-gwt/base:local. -# Extend with toolchains: FROM agent-gwt/cursor-cli:local -FROM agent-gwt/base:local +# clanker-cleanroom/cursor +FROM clanker-cleanroom/base # Official Cursor CLI installer; install under a world-readable path so # arbitrary host UIDs (docker --user) can run agent. @@ -23,5 +22,5 @@ ENV PATH="/usr/local/bin:${PATH}" WORKDIR /workspace -# Runtime identity is set by agent-gwt via --user :. +# Runtime identity is set via --user :. # No ENTRYPOINT — the library passes `agent ...` as the container command. diff --git a/packages/clanker-cleanroom/package.json b/packages/clanker-cleanroom/package.json new file mode 100644 index 0000000..1cc154e --- /dev/null +++ b/packages/clanker-cleanroom/package.json @@ -0,0 +1,113 @@ +{ + "name": "clanker-cleanroom", + "version": "0.2.3", + "description": "Build and run agent Docker images with workspace bindings", + "keywords": [ + "agent", + "claude", + "cleanroom", + "cursor", + "docker" + ], + "homepage": "https://github.com/improving/agent-gwt#readme", + "bugs": { + "url": "https://github.com/improving/agent-gwt/issues" + }, + "license": "MIT", + "author": "Eric Siebeneich (https://github.com/improving)", + "repository": { + "type": "git", + "url": "git+https://github.com/improving/agent-gwt.git", + "directory": "packages/clanker-cleanroom" + }, + "files": [ + "lib", + "src", + "docker", + "!**/*.spec.ts" + ], + "type": "module", + "main": "./lib/index.cjs", + "module": "./lib/index.mjs", + "types": "./lib/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./lib/index.d.mts", + "default": "./lib/index.mjs" + }, + "require": { + "types": "./lib/index.d.cts", + "default": "./lib/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "wireit", + "test": "wireit", + "lint": "wireit", + "test:coverage": "wireit", + "prepublishOnly": "wireit" + }, + "wireit": { + "build": { + "command": "vp pack", + "files": [ + "src/**/*.ts", + "tsconfig.json", + "vite.config.ts", + "!src/**/*.spec.ts" + ], + "output": [ + "lib/**" + ] + }, + "lint": { + "command": "vp lint src", + "files": [ + "src/**/*.ts", + "tsconfig.json", + "vite.config.ts" + ] + }, + "test": { + "command": "vitest run", + "files": [ + "src/**/*.ts", + "vite.config.ts" + ] + }, + "test:coverage": { + "command": "vitest run --coverage", + "files": [ + "src/**/*.ts", + "vite.config.ts" + ], + "output": [ + "coverage/**" + ] + }, + "prepublishOnly": { + "dependencies": [ + "lint", + "test", + "build" + ] + } + }, + "devDependencies": { + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:", + "vitest-gwt": "catalog:", + "wireit": "catalog:" + }, + "packageManager": "pnpm@11.22.0" +} diff --git a/packages/clanker-cleanroom/src/agents/agent.spec.ts b/packages/clanker-cleanroom/src/agents/agent.spec.ts new file mode 100644 index 0000000..0a61930 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/agent.spec.ts @@ -0,0 +1,219 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, vi } from "vitest"; +import test from "vitest-gwt"; + +import { Agent } from "./agent.js"; +import * as ensureImageModule from "./ensure-image.js"; +import * as buildImagesModule from "../images/build.js"; +import { resetRegistry, upsertRegistryEntry } from "../images/registry.js"; +import * as runBoundModule from "./run-bound.js"; +import { CURSOR_IMAGE } from "./cursor/constants.js"; +import { BASE_IMAGE } from "./base/constants.js"; +import type { AgentRunResult } from "./types.js"; + +type Context = { + packageRoot: string; + agent?: Agent; + result?: AgentRunResult; + error?: Error; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Agent", () => { + test("resolves stock cursor by short name", { + when: { + constructing_cursor, + }, + then: { + name_and_image_are_cursor, + }, + }); + + test("resolves a registry toolchain tag using stored agent", { + given: { + package_root_with_toolchain, + }, + when: { + constructing_toolchain, + }, + then: { + name_and_image_are_toolchain, + }, + }); + + test("runs through runBoundAgent with the resolved image", { + given: { + stub_run_bound, + }, + when: { + constructing_and_running_cursor, + }, + then: { + run_bound_used_cursor_image, + }, + }); + + test("throws for an unknown name", { + given: { + empty_package_root, + }, + when: { + constructing_unknown_catching, + }, + then: { + error_mentions_unknown, + }, + }); + + test("throws for a non-agent registry image", { + given: { + package_root_with_base, + }, + when: { + constructing_base_catching, + }, + then: { + error_mentions_not_runnable, + }, + }); + + test("buildImage forwards packageRoot from the constructor", { + given: { + package_root_with_toolchain, + stub_build_images, + }, + when: { + constructing_toolchain_and_building, + }, + then: { + build_images_used_package_root, + }, + }); +}); + +function empty_package_root(this: Context) { + this.packageRoot = mkdtempSync(join(tmpdir(), "clanker-agent-")); + resetRegistry({ packageRoot: this.packageRoot }); +} + +function package_root_with_toolchain(this: Context) { + empty_package_root.call(this); + upsertRegistryEntry( + "cursor:node", + { + image: "cursor:node", + dockerfile: "node.Dockerfile", + builtAt: new Date().toISOString(), + agent: "cursor", + }, + { packageRoot: this.packageRoot }, + ); +} + +function package_root_with_base(this: Context) { + empty_package_root.call(this); + upsertRegistryEntry( + BASE_IMAGE, + { + image: BASE_IMAGE, + dockerfile: "base.Dockerfile", + builtAt: new Date().toISOString(), + }, + { packageRoot: this.packageRoot }, + ); +} + +function stub_build_images() { + vi.spyOn(buildImagesModule, "buildImages").mockResolvedValue(); +} + +function stub_run_bound(this: Context) { + vi.spyOn(ensureImageModule, "ensureDockerImage").mockResolvedValue(); + vi.spyOn(buildImagesModule, "buildImages").mockResolvedValue(); + vi.spyOn(runBoundModule, "runBoundAgent").mockResolvedValue({ + durationMs: 10, + costUsd: null, + usage: { + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + }, + }); +} + +function constructing_cursor(this: Context) { + this.agent = new Agent("cursor"); +} + +function constructing_toolchain(this: Context) { + this.agent = new Agent("cursor:node", { packageRoot: this.packageRoot }); +} + +async function constructing_toolchain_and_building(this: Context) { + constructing_toolchain.call(this); + await this.agent!.buildImage(); +} + +async function constructing_and_running_cursor(this: Context) { + this.agent = new Agent("cursor"); + this.result = await this.agent.run({ workspace: "/tmp/ws", prompt: "hi" }); +} + +function constructing_unknown_catching(this: Context) { + try { + this.agent = new Agent("missing", { packageRoot: this.packageRoot }); + } catch (error) { + this.error = error as Error; + } +} + +function constructing_base_catching(this: Context) { + try { + this.agent = new Agent(BASE_IMAGE, { packageRoot: this.packageRoot }); + } catch (error) { + this.error = error as Error; + } +} + +function name_and_image_are_cursor(this: Context) { + expect(this.agent?.name).toBe("cursor"); + expect(this.agent?.image).toBe(CURSOR_IMAGE); +} + +function name_and_image_are_toolchain(this: Context) { + expect(this.agent?.name).toBe("cursor:node"); + expect(this.agent?.image).toBe("cursor:node"); +} + +function run_bound_used_cursor_image(this: Context) { + expect(runBoundModule.runBoundAgent).toHaveBeenCalledWith( + expect.objectContaining({ image: CURSOR_IMAGE }), + expect.objectContaining({ + workspace: "/tmp/ws", + prompt: "hi", + image: CURSOR_IMAGE, + }), + ); + expect(this.result?.durationMs).toBe(10); +} + +function error_mentions_unknown(this: Context) { + expect(this.error?.message).toContain('Unknown agent "missing"'); +} + +function error_mentions_not_runnable(this: Context) { + expect(this.error?.message).toContain("not a runnable agent"); +} + +function build_images_used_package_root(this: Context) { + expect(buildImagesModule.buildImages).toHaveBeenCalledWith({ + packageRoot: this.packageRoot, + }); +} diff --git a/packages/clanker-cleanroom/src/agents/agent.ts b/packages/clanker-cleanroom/src/agents/agent.ts new file mode 100644 index 0000000..bd91fd2 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/agent.ts @@ -0,0 +1,89 @@ +import { buildImages, type BuildImagesOptions } from "../images/build.js"; +import { readRegistry, type RegistryOptions } from "../images/registry.js"; +import { resolveBinding } from "./binding-registry.js"; +import { ensureDockerImage } from "./ensure-image.js"; +import { runBoundAgent } from "./run-bound.js"; +import { isStockAgentName } from "./stock.js"; +import type { AgentBinding, AgentRunResult, RunAgentOptions } from "./types.js"; + +type FromBinding = { + readonly __fromBinding: AgentBinding; +}; + +/** + * Resolve a stock short name (`cursor`, `claude`) or a registry image tag + * (`cursor:node`) and run it with the matching binding. + */ +export class Agent { + readonly name: string; + readonly image: string; + private readonly binding: AgentBinding; + private readonly registryOptions: RegistryOptions; + + constructor(name: string, options?: RegistryOptions); + constructor(fromBinding: FromBinding); + constructor(nameOrBinding: string | FromBinding, options: RegistryOptions = {}) { + if (typeof nameOrBinding !== "string") { + const binding = nameOrBinding.__fromBinding; + this.name = binding.image; + this.image = binding.image; + this.binding = binding; + this.registryOptions = {}; + return; + } + + const resolved = lookupAgent(nameOrBinding, options); + this.name = nameOrBinding; + this.image = resolved.image; + this.binding = resolved.binding; + this.registryOptions = options; + } + + /** Wrap a custom binding that is not registered by name. */ + static fromBinding(binding: AgentBinding): Agent { + return new Agent({ __fromBinding: binding }); + } + + async ensureImage(): Promise { + await ensureDockerImage(this.image); + } + + async buildImage(options?: BuildImagesOptions): Promise { + await buildImages({ ...this.registryOptions, ...options }); + } + + run(options: RunAgentOptions): Promise { + return runBoundAgent(this.binding, { + workspace: options.workspace, + prompt: options.prompt, + image: options.image ?? this.image, + ...(options.model !== undefined ? { model: options.model } : {}), + }); + } +} + +function lookupAgent( + name: string, + options: RegistryOptions, +): { image: string; binding: AgentBinding } { + if (isStockAgentName(name)) { + const binding = resolveBinding(name); + return { image: binding.image, binding }; + } + + const entry = readRegistry(options).images[name]; + if (entry === undefined) { + throw new Error( + `Unknown agent "${name}". Use a stock name ("cursor", "claude") or a tag ` + + `recorded in clanker-cleanroom.images.json via buildImages().`, + ); + } + + if (entry.agent === undefined) { + throw new Error(`Image "${name}" is not a runnable agent (no stock agent in its FROM chain).`); + } + + const stockBinding = resolveBinding(entry.agent); + const binding: AgentBinding = { ...stockBinding, image: entry.image }; + return { image: entry.image, binding }; +} diff --git a/src/agents/base/constants.ts b/packages/clanker-cleanroom/src/agents/base/constants.ts similarity index 69% rename from src/agents/base/constants.ts rename to packages/clanker-cleanroom/src/agents/base/constants.ts index af7f417..c9fccd0 100644 --- a/src/agents/base/constants.ts +++ b/packages/clanker-cleanroom/src/agents/base/constants.ts @@ -1,5 +1,4 @@ -export const BASE_IMAGE = "agent-gwt/base:local"; -export const BASE_DOCKERFILE_RELATIVE = "docker/base/Dockerfile"; +export const BASE_IMAGE = "clanker-cleanroom/base"; /** Home directory inside every agent image; world-writable so any host uid can use it. */ export const CONTAINER_HOME = "/home/agent"; diff --git a/packages/clanker-cleanroom/src/agents/base/index.ts b/packages/clanker-cleanroom/src/agents/base/index.ts new file mode 100644 index 0000000..6fbf917 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/base/index.ts @@ -0,0 +1 @@ +export { BASE_IMAGE, CONTAINER_HOME, CONTAINER_WORKSPACE } from "./constants.js"; diff --git a/packages/clanker-cleanroom/src/agents/binding-registry.ts b/packages/clanker-cleanroom/src/agents/binding-registry.ts new file mode 100644 index 0000000..7a6fd4a --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/binding-registry.ts @@ -0,0 +1,13 @@ +import { claudeBinding } from "./claude/binding.js"; +import { cursorBinding } from "./cursor/binding.js"; +import type { StockAgentName } from "./stock.js"; +import type { AgentBinding } from "./types.js"; + +export const bindingRegistry: Record = { + cursor: cursorBinding, + claude: claudeBinding, +}; + +export function resolveBinding(name: StockAgentName): AgentBinding { + return bindingRegistry[name]; +} diff --git a/packages/clanker-cleanroom/src/agents/claude/agent.ts b/packages/clanker-cleanroom/src/agents/claude/agent.ts new file mode 100644 index 0000000..db44802 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/claude/agent.ts @@ -0,0 +1,3 @@ +import { Agent } from "../agent.js"; + +export const claudeAgent = new Agent("claude"); diff --git a/src/agents/claude/_buildDockerArgs.spec.ts b/packages/clanker-cleanroom/src/agents/claude/binding.command.spec.ts similarity index 79% rename from src/agents/claude/_buildDockerArgs.spec.ts rename to packages/clanker-cleanroom/src/agents/claude/binding.command.spec.ts index e29f72f..ab4f4b4 100644 --- a/src/agents/claude/_buildDockerArgs.spec.ts +++ b/packages/clanker-cleanroom/src/agents/claude/binding.command.spec.ts @@ -2,8 +2,11 @@ import { describe, expect } from "vitest"; import test from "vitest-gwt"; import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; -import { buildClaudeDockerArgs } from "./_buildDockerArgs.js"; -import type { ClaudeCredentials } from "./_resolveCredentials.js"; +import { buildDockerRunArgs } from "../docker.js"; +import type { DockerVolumeMount } from "../types.js"; +import { claudeBinding } from "./binding.js"; +import type { ClaudeCredentials } from "./credentials.js"; +import { credentialsEnv } from "./credentials.js"; import { CLAUDE_API_KEY_ENV, CLAUDE_CONTAINER_CREDENTIALS_PATH, @@ -20,7 +23,7 @@ type Context = { const envFlagValues = (args: string[]) => args.filter((arg, i) => args[i - 1] === "-e"); const volumeMounts = (args: string[]) => args.filter((arg, i) => args[i - 1] === "-v"); -describe("buildClaudeDockerArgs", () => { +describe("claudeBinding.command", () => { test("runs as host user with an OAuth token forwarded by name only", { given: { oauth_token_credentials, @@ -91,25 +94,37 @@ function credentials_file_credentials(this: Context) { } 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, - }); + this.args = dockerArgs(this.credentials); } 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, + this.args = dockerArgs(this.credentials, { model: "sonnet" }); +} + +function dockerArgs(credentials: ClaudeCredentials, options: { model?: string } = {}): string[] { + const volumes: DockerVolumeMount[] = [ + { host: "/tmp/.agents-gwt/ws-abc", container: CONTAINER_WORKSPACE }, + ]; + if (credentials.kind === "credentials-file") { + volumes.push({ + host: credentials.file, + container: CLAUDE_CONTAINER_CREDENTIALS_PATH, + mode: "ro", + }); + } + + return buildDockerRunArgs({ + image: "clanker-cleanroom/claude", uid: 1000, gid: 1000, - model: "sonnet", + workdir: CONTAINER_WORKSPACE, + env: { HOME: CONTAINER_HOME }, + envPassthrough: Object.keys(credentialsEnv(credentials)), + volumes, + command: claudeBinding.command({ + prompt: "Create a README", + ...(options.model !== undefined ? { model: options.model } : {}), + }), }); } diff --git a/packages/clanker-cleanroom/src/agents/claude/binding.spec.ts b/packages/clanker-cleanroom/src/agents/claude/binding.spec.ts new file mode 100644 index 0000000..c2c0a5a --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/claude/binding.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect } from "vitest"; +import test from "vitest-gwt"; + +import { claudeBinding } from "./binding.js"; + +type Context = { + result?: ReturnType; + error?: Error; +}; + +describe("claudeBinding.parseResult", () => { + test("maps duration, cost, and usage tokens", { + when: { + parsing_claude_json, + }, + then: { + metrics_mapped, + }, + }); + + test("throws when is_error is true", { + when: { + parsing_error_json_catching, + }, + then: { + error_mentions_claude, + }, + }); +}); + +function parsing_claude_json(this: Context) { + this.result = claudeBinding.parseResult( + JSON.stringify({ + type: "result", + is_error: false, + duration_ms: 500, + total_cost_usd: 0.0123, + usage: { + input_tokens: 11, + output_tokens: 22, + cache_read_input_tokens: 33, + cache_creation_input_tokens: 44, + }, + result: "ignored dialog", + }), + ); +} + +function parsing_error_json_catching(this: Context) { + try { + claudeBinding.parseResult( + JSON.stringify({ + type: "result", + is_error: true, + subtype: "error", + result: "Not logged in", + }), + ); + } catch (error) { + this.error = error as Error; + } +} + +function metrics_mapped(this: Context) { + expect(this.result).toEqual({ + durationMs: 500, + costUsd: 0.0123, + usage: { + inputTokens: 11, + outputTokens: 22, + cacheReadTokens: 33, + cacheWriteTokens: 44, + }, + }); +} + +function error_mentions_claude(this: Context) { + expect(this.error?.message).toContain("Claude agent reported an error"); + expect(this.error?.message).toContain("Not logged in"); +} diff --git a/packages/clanker-cleanroom/src/agents/claude/binding.ts b/packages/clanker-cleanroom/src/agents/claude/binding.ts new file mode 100644 index 0000000..449efd9 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/claude/binding.ts @@ -0,0 +1,109 @@ +import { parseAgentJsonOutput } from "../parse-result.js"; +import type { AgentBinding, AgentRunResult, DockerVolumeMount } from "../types.js"; +import { readTokenCount } from "../types.js"; +import { CLAUDE_CONTAINER_CREDENTIALS_PATH, CLAUDE_IMAGE } from "./constants.js"; +import { credentialsEnv, resolveClaudeCredentials } from "./credentials.js"; + +export const claudeBinding: AgentBinding = { + image: CLAUDE_IMAGE, + displayName: "Claude", + command: ({ prompt, model }) => { + const claudeArgs = [ + "claude", + "-p", + "--output-format", + "json", + "--dangerously-skip-permissions", + ]; + if (model !== undefined && model !== "") { + claudeArgs.push("--model", model); + } + claudeArgs.push("--", prompt); + return claudeArgs; + }, + prepare: async () => { + const credentials = await resolveClaudeCredentials(); + const volumes: DockerVolumeMount[] = []; + if (credentials.kind === "credentials-file") { + volumes.push({ + host: credentials.file, + container: CLAUDE_CONTAINER_CREDENTIALS_PATH, + mode: "ro", + }); + } + return { + volumes, + env: credentialsEnv(credentials), + }; + }, + parseResult: parseClaudeResult, + describeFailure: describeClaudeFailure, +}; + +function parseClaudeResult(stdout: string): AgentRunResult { + const parsed = parseAgentJsonOutput(stdout); + if (isErrorResult(parsed)) { + throw new Error(`Claude agent reported an error: ${describeErrorResult(parsed)}`); + } + + const record = asRecord(parsed); + const usage = asRecord(record?.usage); + + return { + durationMs: readTokenCount(record?.duration_ms), + costUsd: readTokenCount(record?.total_cost_usd), + usage: { + inputTokens: readTokenCount(usage?.input_tokens ?? usage?.inputTokens), + outputTokens: readTokenCount(usage?.output_tokens ?? usage?.outputTokens), + cacheReadTokens: readTokenCount(usage?.cache_read_input_tokens ?? usage?.cacheReadTokens), + cacheWriteTokens: readTokenCount( + usage?.cache_creation_input_tokens ?? usage?.cacheWriteTokens, + ), + }, + }; +} + +function describeClaudeFailure(stdout: string): string | undefined { + try { + const parsed = parseAgentJsonOutput(stdout); + return isErrorResult(parsed) ? describeErrorResult(parsed) : undefined; + } catch { + return undefined; + } +} + +type ClaudeErrorResult = { + is_error: true; + result: string; + subtype?: string; + terminal_reason?: string; +}; + +function isErrorResult(value: unknown): value is ClaudeErrorResult { + return ( + typeof value === "object" && + value !== null && + (value as { is_error?: unknown }).is_error === true + ); +} + +function describeErrorResult(result: ClaudeErrorResult): string { + const message = String(result.result); + + if (result.terminal_reason !== undefined && result.terminal_reason !== "completed") { + return `${result.terminal_reason}: ${message}`; + } + + if (result.subtype !== undefined && result.subtype !== "success") { + return `${result.subtype}: ${message}`; + } + + return message; +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + return value as Record; +} diff --git a/src/agents/claude/constants.ts b/packages/clanker-cleanroom/src/agents/claude/constants.ts similarity index 82% rename from src/agents/claude/constants.ts rename to packages/clanker-cleanroom/src/agents/claude/constants.ts index 3cc939e..0516cb3 100644 --- a/src/agents/claude/constants.ts +++ b/packages/clanker-cleanroom/src/agents/claude/constants.ts @@ -1,7 +1,6 @@ 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_IMAGE = "clanker-cleanroom/claude"; export const CLAUDE_CONTAINER_CREDENTIALS_PATH = `${CONTAINER_HOME}/.claude/.credentials.json`; /** Long-lived OAuth token from `claude setup-token` (Claude subscription). */ diff --git a/src/agents/claude/_resolveCredentials.spec.ts b/packages/clanker-cleanroom/src/agents/claude/credentials.spec.ts similarity index 99% rename from src/agents/claude/_resolveCredentials.spec.ts rename to packages/clanker-cleanroom/src/agents/claude/credentials.spec.ts index 9357de0..5aa3d57 100644 --- a/src/agents/claude/_resolveCredentials.spec.ts +++ b/packages/clanker-cleanroom/src/agents/claude/credentials.spec.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { type ClaudeCredentials, resolveClaudeCredentials } from "./_resolveCredentials.js"; +import { type ClaudeCredentials, resolveClaudeCredentials } from "./credentials.js"; import { CLAUDE_API_KEY_ENV, CLAUDE_OAUTH_TOKEN_ENV } from "./constants.js"; const SECRET = "sk-ant-oat01-super-secret"; diff --git a/src/agents/claude/_resolveCredentials.ts b/packages/clanker-cleanroom/src/agents/claude/credentials.ts similarity index 79% rename from src/agents/claude/_resolveCredentials.ts rename to packages/clanker-cleanroom/src/agents/claude/credentials.ts index e65427e..5247508 100644 --- a/src/agents/claude/_resolveCredentials.ts +++ b/packages/clanker-cleanroom/src/agents/claude/credentials.ts @@ -47,3 +47,15 @@ export async function resolveClaudeCredentials( return { kind: "credentials-file", file }; } + +/** Secret values for the docker CLI process, keyed by env names forwarded via envPassthrough. */ +export 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 {}; + } +} diff --git a/packages/clanker-cleanroom/src/agents/claude/index.ts b/packages/clanker-cleanroom/src/agents/claude/index.ts new file mode 100644 index 0000000..c3268ef --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/claude/index.ts @@ -0,0 +1,10 @@ +export { + CLAUDE_API_KEY_ENV, + CLAUDE_CONTAINER_CREDENTIALS_PATH, + CLAUDE_IMAGE, + CLAUDE_OAUTH_TOKEN_ENV, + defaultClaudeHostCredentialsFile, +} from "./constants.js"; +export { claudeBinding } from "./binding.js"; +export { resolveClaudeCredentials, credentialsEnv, type ClaudeCredentials } from "./credentials.js"; +export { claudeAgent } from "./agent.js"; diff --git a/packages/clanker-cleanroom/src/agents/create-agent.spec.ts b/packages/clanker-cleanroom/src/agents/create-agent.spec.ts new file mode 100644 index 0000000..a8d11ac --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/create-agent.spec.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, vi } from "vitest"; +import test from "vitest-gwt"; + +import { Agent } from "./agent.js"; +import { createAgent } from "./create-agent.js"; +import * as ensureImageModule from "./ensure-image.js"; +import * as buildImagesModule from "../images/build.js"; +import * as runBoundModule from "./run-bound.js"; +import type { AgentBinding, AgentRunResult } from "./types.js"; + +type Context = { + agent: Agent; + binding: AgentBinding; + result?: AgentRunResult; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("createAgent", () => { + test("exposes image and delegates run to runBoundAgent", { + given: { + stub_binding_and_runner, + }, + when: { + creating_and_running_agent, + }, + then: { + image_is_set, + run_bound_was_called, + }, + }); + + test("ensureImage asserts the bound image exists", { + given: { + stub_binding_and_runner, + }, + when: { + creating_and_ensuring_image, + }, + then: { + ensure_docker_image_used_bound_image, + }, + }); + + test("buildImage builds the stock image folder", { + given: { + stub_binding_and_runner, + }, + when: { + creating_and_building_image, + }, + then: { + build_images_was_called, + }, + }); +}); + +function stub_binding_and_runner(this: Context) { + this.binding = { + image: "clanker-cleanroom/cursor", + displayName: "Cursor", + command: () => ["agent"], + prepare: async () => ({}), + parseResult: () => ({ + durationMs: 1, + costUsd: null, + usage: { + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + }, + }), + }; + vi.spyOn(ensureImageModule, "ensureDockerImage").mockResolvedValue(); + vi.spyOn(buildImagesModule, "buildImages").mockResolvedValue(); + vi.spyOn(runBoundModule, "runBoundAgent").mockResolvedValue(this.binding.parseResult("")); +} + +async function creating_and_running_agent(this: Context) { + this.agent = createAgent(this.binding); + this.result = await this.agent.run({ + workspace: "/tmp/ws", + prompt: "hello", + }); +} + +async function creating_and_ensuring_image(this: Context) { + this.agent = createAgent(this.binding); + await this.agent.ensureImage(); +} + +async function creating_and_building_image(this: Context) { + this.agent = createAgent(this.binding); + await this.agent.buildImage(); +} + +function image_is_set(this: Context) { + expect(this.agent.image).toBe("clanker-cleanroom/cursor"); +} + +function run_bound_was_called(this: Context) { + expect(runBoundModule.runBoundAgent).toHaveBeenCalledWith( + expect.objectContaining({ + image: "clanker-cleanroom/cursor", + displayName: "Cursor", + }), + { + workspace: "/tmp/ws", + prompt: "hello", + image: "clanker-cleanroom/cursor", + }, + ); + expect(this.result?.durationMs).toBe(1); +} + +function ensure_docker_image_used_bound_image() { + expect(ensureImageModule.ensureDockerImage).toHaveBeenCalledWith("clanker-cleanroom/cursor"); +} + +function build_images_was_called() { + expect(buildImagesModule.buildImages).toHaveBeenCalledWith({}); +} diff --git a/packages/clanker-cleanroom/src/agents/create-agent.ts b/packages/clanker-cleanroom/src/agents/create-agent.ts new file mode 100644 index 0000000..16f1f48 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/create-agent.ts @@ -0,0 +1,9 @@ +import { Agent } from "./agent.js"; +import type { AgentBinding } from "./types.js"; + +export type CreateAgentBindings = AgentBinding; + +/** Wrap a custom binding that is not registered by name. */ +export function createAgent(binding: AgentBinding): Agent { + return Agent.fromBinding(binding); +} diff --git a/packages/clanker-cleanroom/src/agents/cursor/agent.ts b/packages/clanker-cleanroom/src/agents/cursor/agent.ts new file mode 100644 index 0000000..0bf33b0 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/cursor/agent.ts @@ -0,0 +1,3 @@ +import { Agent } from "../agent.js"; + +export const cursorAgent = new Agent("cursor"); diff --git a/src/agents/cursor/_buildDockerArgs.spec.ts b/packages/clanker-cleanroom/src/agents/cursor/binding.command.spec.ts similarity index 74% rename from src/agents/cursor/_buildDockerArgs.spec.ts rename to packages/clanker-cleanroom/src/agents/cursor/binding.command.spec.ts index 011378c..6af4691 100644 --- a/src/agents/cursor/_buildDockerArgs.spec.ts +++ b/packages/clanker-cleanroom/src/agents/cursor/binding.command.spec.ts @@ -2,14 +2,15 @@ import { describe, expect } from "vitest"; import test from "vitest-gwt"; import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; -import { buildDockerArgs } from "./_buildDockerArgs.js"; +import { buildDockerRunArgs } from "../docker.js"; +import { cursorBinding } from "./binding.js"; import { CONTAINER_AUTH_PATH } from "./constants.js"; type Context = { args: string[]; }; -describe("buildDockerArgs", () => { +describe("cursorBinding.command", () => { test("runs as host user with credentials-only and workspace mounts", { when: { building_docker_args, @@ -35,25 +36,32 @@ describe("buildDockerArgs", () => { }); function building_docker_args(this: Context) { - this.args = buildDockerArgs({ - workspace: "/tmp/.agents-gwt/ws-abc", - prompt: "Create a README", - image: "agent-gwt/cursor-cli:local", - authFile: "/home/dev/.config/cursor/auth.json", - uid: 1000, - gid: 1000, - }); + this.args = dockerArgs(); } function building_docker_args_with_model(this: Context) { - this.args = buildDockerArgs({ - workspace: "/tmp/.agents-gwt/ws-abc", - prompt: "Create a README", - image: "agent-gwt/cursor-cli:local", - authFile: "/home/dev/.config/cursor/auth.json", + this.args = dockerArgs({ model: "composer-2" }); +} + +function dockerArgs(options: { model?: string } = {}): string[] { + return buildDockerRunArgs({ + image: "clanker-cleanroom/cursor", uid: 1000, gid: 1000, - model: "composer-2", + workdir: CONTAINER_WORKSPACE, + env: { HOME: CONTAINER_HOME }, + volumes: [ + { host: "/tmp/.agents-gwt/ws-abc", container: CONTAINER_WORKSPACE }, + { + host: "/home/dev/.config/cursor/auth.json", + container: CONTAINER_AUTH_PATH, + mode: "ro", + }, + ], + command: cursorBinding.command({ + prompt: "Create a README", + ...(options.model !== undefined ? { model: options.model } : {}), + }), }); } diff --git a/packages/clanker-cleanroom/src/agents/cursor/binding.spec.ts b/packages/clanker-cleanroom/src/agents/cursor/binding.spec.ts new file mode 100644 index 0000000..39def99 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/cursor/binding.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect } from "vitest"; +import test from "vitest-gwt"; + +import { cursorBinding } from "./binding.js"; + +type Context = { + result?: ReturnType; + error?: Error; +}; + +describe("cursorBinding.parseResult", () => { + test("maps duration and usage tokens; costUsd is null", { + when: { + parsing_cursor_json, + }, + then: { + metrics_mapped, + }, + }); + + test("nulls missing usage fields", { + when: { + parsing_minimal_json, + }, + then: { + usage_fields_null, + }, + }); +}); + +function parsing_cursor_json(this: Context) { + this.result = cursorBinding.parseResult( + JSON.stringify({ + type: "result", + duration_ms: 1200, + usage: { + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 40, + }, + result: "ignored dialog", + }), + ); +} + +function parsing_minimal_json(this: Context) { + this.result = cursorBinding.parseResult(JSON.stringify({ type: "result", result: "hi" })); +} + +function metrics_mapped(this: Context) { + expect(this.result).toEqual({ + durationMs: 1200, + costUsd: null, + usage: { + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 40, + }, + }); +} + +function usage_fields_null(this: Context) { + expect(this.result).toEqual({ + durationMs: null, + costUsd: null, + usage: { + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + }, + }); +} diff --git a/packages/clanker-cleanroom/src/agents/cursor/binding.ts b/packages/clanker-cleanroom/src/agents/cursor/binding.ts new file mode 100644 index 0000000..f888267 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/cursor/binding.ts @@ -0,0 +1,59 @@ +import { access } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { homedir } from "node:os"; + +import { parseAgentJsonOutput } from "../parse-result.js"; +import type { AgentBinding, AgentRunResult } from "../types.js"; +import { readTokenCount } from "../types.js"; +import { CONTAINER_AUTH_PATH, CURSOR_IMAGE, defaultHostAuthFile } from "./constants.js"; + +export const cursorBinding: AgentBinding = { + image: CURSOR_IMAGE, + displayName: "Cursor", + command: ({ prompt, model }) => { + const agentArgs = ["agent", "-p", "--force", "--output-format", "json"]; + if (model !== undefined && model !== "") { + agentArgs.push("--model", model); + } + agentArgs.push("--", prompt); + return agentArgs; + }, + prepare: async () => { + const authFile = defaultHostAuthFile(homedir()); + try { + await access(authFile, fsConstants.R_OK); + } catch { + throw new Error( + `Cursor credentials not found at ${authFile}. Run \`agent login\` on the host first.`, + ); + } + return { + volumes: [{ host: authFile, container: CONTAINER_AUTH_PATH, mode: "ro" }], + }; + }, + parseResult: parseCursorResult, +}; + +function parseCursorResult(stdout: string): AgentRunResult { + const parsed = parseAgentJsonOutput(stdout); + const record = asRecord(parsed); + const usage = asRecord(record?.usage); + + return { + durationMs: readTokenCount(record?.duration_ms), + costUsd: null, + usage: { + inputTokens: readTokenCount(usage?.inputTokens), + outputTokens: readTokenCount(usage?.outputTokens), + cacheReadTokens: readTokenCount(usage?.cacheReadTokens), + cacheWriteTokens: readTokenCount(usage?.cacheWriteTokens), + }, + }; +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + return value as Record; +} diff --git a/src/agents/cursor/constants.ts b/packages/clanker-cleanroom/src/agents/cursor/constants.ts similarity index 64% rename from src/agents/cursor/constants.ts rename to packages/clanker-cleanroom/src/agents/cursor/constants.ts index 175680d..6148bfe 100644 --- a/src/agents/cursor/constants.ts +++ b/packages/clanker-cleanroom/src/agents/cursor/constants.ts @@ -1,7 +1,6 @@ import { CONTAINER_HOME } from "../base/constants.js"; -export const CURSOR_IMAGE = "agent-gwt/cursor-cli:local"; +export const CURSOR_IMAGE = "clanker-cleanroom/cursor"; export const CONTAINER_AUTH_PATH = `${CONTAINER_HOME}/.config/cursor/auth.json`; -export const CURSOR_DOCKERFILE_RELATIVE = "docker/cursor/Dockerfile"; export const defaultHostAuthFile = (home: string): string => `${home}/.config/cursor/auth.json`; diff --git a/packages/clanker-cleanroom/src/agents/cursor/index.ts b/packages/clanker-cleanroom/src/agents/cursor/index.ts new file mode 100644 index 0000000..8d28072 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/cursor/index.ts @@ -0,0 +1,3 @@ +export { CONTAINER_AUTH_PATH, CURSOR_IMAGE, defaultHostAuthFile } from "./constants.js"; +export { cursorBinding } from "./binding.js"; +export { cursorAgent } from "./agent.js"; diff --git a/src/agents/docker.spec.ts b/packages/clanker-cleanroom/src/agents/docker.spec.ts similarity index 100% rename from src/agents/docker.spec.ts rename to packages/clanker-cleanroom/src/agents/docker.spec.ts diff --git a/src/agents/docker.ts b/packages/clanker-cleanroom/src/agents/docker.ts similarity index 100% rename from src/agents/docker.ts rename to packages/clanker-cleanroom/src/agents/docker.ts diff --git a/src/agents/ensure-image.spec.ts b/packages/clanker-cleanroom/src/agents/ensure-image.spec.ts similarity index 77% rename from src/agents/ensure-image.spec.ts rename to packages/clanker-cleanroom/src/agents/ensure-image.spec.ts index 2425215..4f20930 100644 --- a/src/agents/ensure-image.spec.ts +++ b/packages/clanker-cleanroom/src/agents/ensure-image.spec.ts @@ -8,7 +8,6 @@ type Context = { image: string; dockerRunner: DockerRunner; inspectCalls: number; - buildCalls: number; error?: Error; }; @@ -23,7 +22,6 @@ describe("ensureDockerImage", () => { }, then: { inspect_was_called, - build_was_not_called, }, }); @@ -37,15 +35,13 @@ describe("ensureDockerImage", () => { }, then: { error_mentions_missing_image, - build_was_not_called, }, }); }); function image_name(this: Context) { - this.image = "agent-gwt/test:local"; + this.image = "clanker-cleanroom/cursor"; this.inspectCalls = 0; - this.buildCalls = 0; } function inspect_succeeds(this: Context) { @@ -64,10 +60,6 @@ function inspect_fails(this: Context) { this.inspectCalls += 1; return { exitCode: 1, stdout: "", stderr: "No such image" }; } - if (args[0] === "build") { - this.buildCalls += 1; - return { exitCode: 0, stdout: "done", stderr: "" }; - } throw new Error(`unexpected docker args: ${args.join(" ")}`); }; } @@ -90,12 +82,7 @@ function inspect_was_called(this: Context) { expect(this.inspectCalls).toBe(1); } -function build_was_not_called(this: Context) { - expect(this.buildCalls).toBe(0); -} - function error_mentions_missing_image(this: Context) { - expect(this.error?.message).toContain("agent-gwt/test:local"); - expect(this.error?.message).toContain("buildAgentImage"); - expect(this.error?.message).toContain("buildToolchainImage"); + expect(this.error?.message).toContain("clanker-cleanroom/cursor"); + expect(this.error?.message).toContain("buildImages"); } diff --git a/src/agents/ensure-image.ts b/packages/clanker-cleanroom/src/agents/ensure-image.ts similarity index 70% rename from src/agents/ensure-image.ts rename to packages/clanker-cleanroom/src/agents/ensure-image.ts index 38bf844..d471cd5 100644 --- a/src/agents/ensure-image.ts +++ b/packages/clanker-cleanroom/src/agents/ensure-image.ts @@ -12,6 +12,6 @@ export async function ensureDockerImage( } throw new Error( - `Docker image ${image} not found. Call buildAgentImage(...) or buildToolchainImage(...) from vitest globalSetup (or build the image manually) before running agent tests.`, + `Docker image ${image} not found. Call buildImages() from vitest globalSetup (or build the image manually) before running agent tests.`, ); } diff --git a/src/agents/parse-result.spec.ts b/packages/clanker-cleanroom/src/agents/parse-result.spec.ts similarity index 100% rename from src/agents/parse-result.spec.ts rename to packages/clanker-cleanroom/src/agents/parse-result.spec.ts diff --git a/src/agents/parse-result.ts b/packages/clanker-cleanroom/src/agents/parse-result.ts similarity index 100% rename from src/agents/parse-result.ts rename to packages/clanker-cleanroom/src/agents/parse-result.ts diff --git a/packages/clanker-cleanroom/src/agents/registry.ts b/packages/clanker-cleanroom/src/agents/registry.ts new file mode 100644 index 0000000..331c79c --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/registry.ts @@ -0,0 +1,7 @@ +import { bindingRegistry } from "./binding-registry.js"; +import type { StockAgentName } from "./stock.js"; + +export type AgentName = StockAgentName; + +export { bindingRegistry }; +export type { StockAgentName }; diff --git a/packages/clanker-cleanroom/src/agents/run-bound.spec.ts b/packages/clanker-cleanroom/src/agents/run-bound.spec.ts new file mode 100644 index 0000000..154b270 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/run-bound.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect } from "vitest"; +import test from "vitest-gwt"; + +import { runBoundAgent } from "./run-bound.js"; +import type { AgentBinding, DockerRunner } from "./types.js"; + +type Context = { + binding: AgentBinding; + dockerRunner: DockerRunner; + dockerArgs?: string[]; + dockerEnv?: Record; + result?: Awaited>; + error?: Error; +}; + +describe("runBoundAgent", () => { + test("mounts workspace, runs docker, and returns parseResult", { + given: { + stub_binding, + docker_succeeds, + }, + when: { + running_bound_agent, + }, + then: { + docker_received_workspace_mount, + result_from_parse, + }, + }); + + test("throws agentRunError when docker exits non-zero", { + given: { + stub_binding, + docker_fails, + }, + when: { + running_bound_agent_catching, + }, + then: { + error_names_agent, + }, + }); +}); + +function stub_binding(this: Context) { + this.binding = { + image: "test/image", + displayName: "TestAgent", + command: ({ prompt }) => ["tool", "--", prompt], + prepare: async () => ({ + volumes: [{ host: "/tmp/secret", container: "/secret", mode: "ro" }], + env: { SECRET: "value" }, + }), + parseResult: () => ({ + durationMs: 42, + costUsd: 0.01, + usage: { + inputTokens: 1, + outputTokens: 2, + cacheReadTokens: null, + cacheWriteTokens: null, + }, + }), + }; +} + +function docker_succeeds(this: Context) { + this.dockerRunner = async (args, options) => { + this.dockerArgs = args; + this.dockerEnv = options?.env ?? {}; + return { exitCode: 0, stdout: "{}", stderr: "" }; + }; +} + +function docker_fails(this: Context) { + this.dockerRunner = async () => ({ + exitCode: 1, + stdout: "", + stderr: "boom", + }); +} + +async function running_bound_agent(this: Context) { + this.result = await runBoundAgent( + this.binding, + { workspace: "/tmp/ws", prompt: "hi", image: "test/image", uid: 1, gid: 1 }, + this.dockerRunner, + ); +} + +async function running_bound_agent_catching(this: Context) { + try { + await running_bound_agent.call(this); + } catch (error) { + this.error = error as Error; + } +} + +function docker_received_workspace_mount(this: Context) { + expect(this.dockerArgs).toContain("/tmp/ws:/workspace"); + expect(this.dockerArgs).toContain("/tmp/secret:/secret:ro"); + expect(this.dockerEnv).toEqual({ SECRET: "value" }); + expect(this.dockerArgs).toContain("SECRET"); +} + +function result_from_parse(this: Context) { + expect(this.result).toEqual({ + durationMs: 42, + costUsd: 0.01, + usage: { + inputTokens: 1, + outputTokens: 2, + cacheReadTokens: null, + cacheWriteTokens: null, + }, + }); +} + +function error_names_agent(this: Context) { + expect(this.error?.message).toContain("TestAgent agent exited with code 1"); +} diff --git a/packages/clanker-cleanroom/src/agents/run-bound.ts b/packages/clanker-cleanroom/src/agents/run-bound.ts new file mode 100644 index 0000000..d08dc72 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/run-bound.ts @@ -0,0 +1,58 @@ +import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "./base/constants.js"; +import { buildDockerRunArgs, runDocker } from "./docker.js"; +import { agentRunError } from "./run-error.js"; +import type { + AgentBinding, + AgentRunBindingsOptions, + AgentRunResult, + DockerRunner, + DockerVolumeMount, +} from "./types.js"; + +export type RunBoundAgentOptions = AgentRunBindingsOptions & { + uid?: number; + gid?: number; +}; + +export async function runBoundAgent( + binding: AgentBinding, + options: RunBoundAgentOptions, + dockerRunner: DockerRunner = runDocker, +): Promise { + const uid = options.uid ?? process.getuid?.() ?? 0; + const gid = options.gid ?? process.getgid?.() ?? 0; + const prepared = await binding.prepare({ workspace: options.workspace }); + + const volumes: DockerVolumeMount[] = [ + { host: options.workspace, container: CONTAINER_WORKSPACE }, + ...(prepared.volumes ?? []), + ]; + + const env = prepared.env ?? {}; + const args = buildDockerRunArgs({ + image: options.image, + uid, + gid, + workdir: CONTAINER_WORKSPACE, + env: { HOME: CONTAINER_HOME }, + envPassthrough: Object.keys(env), + volumes, + command: binding.command({ + prompt: options.prompt, + ...(options.model !== undefined ? { model: options.model } : {}), + }), + }); + + const result = await dockerRunner(args, { env }); + + if (result.exitCode !== 0) { + throw agentRunError({ + agent: binding.displayName, + image: options.image, + result, + detail: binding.describeFailure?.(result.stdout), + }); + } + + return binding.parseResult(result.stdout); +} diff --git a/src/agents/run-error.spec.ts b/packages/clanker-cleanroom/src/agents/run-error.spec.ts similarity index 58% rename from src/agents/run-error.spec.ts rename to packages/clanker-cleanroom/src/agents/run-error.spec.ts index 47cd484..fb4e95e 100644 --- a/src/agents/run-error.spec.ts +++ b/packages/clanker-cleanroom/src/agents/run-error.spec.ts @@ -28,15 +28,6 @@ describe("agentRunError", () => { }, }); - test("hints buildToolchainImage when a toolchain tag is missing", { - when: { - building_error_for_missing_toolchain_image, - }, - then: { - message_has_toolchain_build_hint, - }, - }); - test("puts the agent-reported detail in the headline", { when: { building_error_with_detail, @@ -50,8 +41,7 @@ describe("agentRunError", () => { function building_error_for_generic_failure(this: Context) { this.error = agentRunError({ agent: "Cursor", - name: "cursor", - image: "agent-gwt/cursor-cli:local", + image: "clanker-cleanroom/cursor", result: { exitCode: 2, stdout: "out", stderr: "boom" }, }); } @@ -59,25 +49,11 @@ function building_error_for_generic_failure(this: Context) { function building_error_for_missing_image(this: Context) { this.error = agentRunError({ agent: "Claude", - name: "claude", - image: "agent-gwt/claude-code:local", + image: "clanker-cleanroom/claude", result: { exitCode: 125, stdout: "", - stderr: "Unable to find image 'agent-gwt/claude-code:local' locally", - }, - }); -} - -function building_error_for_missing_toolchain_image(this: Context) { - this.error = agentRunError({ - agent: "Cursor", - name: "cursor", - image: "agent-gwt/toolchain-cursor-abcd1234ef00:fedcba987654", - result: { - exitCode: 125, - stdout: "", - stderr: "Unable to find image 'agent-gwt/toolchain-cursor-abcd1234ef00:fedcba987654' locally", + stderr: "Unable to find image 'clanker-cleanroom/claude' locally", }, }); } @@ -85,8 +61,7 @@ function building_error_for_missing_toolchain_image(this: Context) { function building_error_with_detail(this: Context) { this.error = agentRunError({ agent: "Claude", - name: "claude", - image: "agent-gwt/claude-code:local", + image: "clanker-cleanroom/claude", result: { exitCode: 1, stdout: "{}", stderr: "" }, detail: "api_error: Not logged in", }); @@ -102,18 +77,12 @@ function message_has_streams(this: Context) { } function message_has_no_build_hint(this: Context) { - expect(this.error.message.includes("buildAgentImage")).toBe(false); + expect(this.error.message.includes("buildImages")).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_toolchain_build_hint(this: Context) { - expect(this.error.message).toContain("buildToolchainImage"); - expect(this.error.message).toContain("agent-gwt/toolchain-cursor-abcd1234ef00:fedcba987654"); - expect(this.error.message.includes("buildAgentImage")).toBe(false); + expect(this.error.message).toContain("buildImages()"); + expect(this.error.message).toContain("clanker-cleanroom/claude"); } function message_has_detail_headline(this: Context) { diff --git a/src/agents/run-error.ts b/packages/clanker-cleanroom/src/agents/run-error.ts similarity index 63% rename from src/agents/run-error.ts rename to packages/clanker-cleanroom/src/agents/run-error.ts index 9629fc4..dda01c9 100644 --- a/src/agents/run-error.ts +++ b/packages/clanker-cleanroom/src/agents/run-error.ts @@ -1,11 +1,8 @@ -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. */ @@ -21,16 +18,8 @@ export function agentRunError(options: AgentRunErrorOptions): Error { : `${options.agent} agent exited with code ${exitCode}: ${options.detail}`; const hint = stderr.includes("Unable to find image") || stderr.includes("not found") - ? missingImageHint(options) + ? `\nDocker image ${options.image} not found; build it with buildImages() in vitest globalSetup.` : ""; return new Error(`${headline}${hint}\nstderr:\n${stderr}\nstdout:\n${stdout}`); } - -function missingImageHint(options: AgentRunErrorOptions): string { - if (options.image.includes("/toolchain-")) { - return `\nDocker image ${options.image} not found; build it with buildToolchainImage(...) in vitest globalSetup.`; - } - - return `\nDocker image ${options.image} not found; build it with buildAgentImage("${options.name}") in vitest globalSetup.`; -} diff --git a/packages/clanker-cleanroom/src/agents/stock.ts b/packages/clanker-cleanroom/src/agents/stock.ts new file mode 100644 index 0000000..2868d24 --- /dev/null +++ b/packages/clanker-cleanroom/src/agents/stock.ts @@ -0,0 +1,27 @@ +import { CURSOR_IMAGE } from "./cursor/constants.js"; +import { CLAUDE_IMAGE } from "./claude/constants.js"; +import { BASE_IMAGE } from "./base/constants.js"; + +export type StockAgentName = "cursor" | "claude"; + +export const STOCK_AGENT_IMAGES = { + cursor: CURSOR_IMAGE, + claude: CLAUDE_IMAGE, +} as const satisfies Record; + +export { BASE_IMAGE }; + +/** Map a Docker image tag to a stock agent name, if it is a stock agent image. */ +export function stockAgentNameForImage(image: string): StockAgentName | undefined { + if (image === CURSOR_IMAGE) { + return "cursor"; + } + if (image === CLAUDE_IMAGE) { + return "claude"; + } + return undefined; +} + +export function isStockAgentName(name: string): name is StockAgentName { + return name === "cursor" || name === "claude"; +} diff --git a/src/agents/types.ts b/packages/clanker-cleanroom/src/agents/types.ts similarity index 51% rename from src/agents/types.ts rename to packages/clanker-cleanroom/src/agents/types.ts index 82c8ee7..c892ec3 100644 --- a/src/agents/types.ts +++ b/packages/clanker-cleanroom/src/agents/types.ts @@ -1,10 +1,22 @@ -export type AgentResult = unknown; +export type TokenUsage = { + inputTokens: number | null; + outputTokens: number | null; + cacheReadTokens: number | null; + cacheWriteTokens: number | null; +}; + +/** Normalized metrics from an agent run. Dialog/transcript is not included. */ +export type AgentRunResult = { + durationMs: number | null; + costUsd: number | null; + usage: TokenUsage; +}; export type RunAgentOptions = { workspace: string; prompt: string; model?: string; - /** Override the agent's default image (e.g. a toolchain-extended tag). */ + /** Override the agent's default image (e.g. for one-off local tags). */ image?: string; }; @@ -12,20 +24,8 @@ export type AgentRunBindingsOptions = RunAgentOptions & { image: string; }; -export type Agent = { - image: string; - ensureImage: () => Promise; - buildImage: () => Promise; - run: (options: RunAgentOptions) => Promise; -}; - export type AgentOptions = { model?: string; - /** - * Named toolchain registered via `buildToolchainImage(variant, ...)`. - * Mutually exclusive with `image`. - */ - variant?: string; /** Docker image tag to ensure and run (defaults to the resolved agent's image). */ image?: string; }; @@ -72,12 +72,35 @@ export type EnsureDockerImageOptions = { dockerRunner?: DockerRunner; }; -export type BuildDockerImageOptions = { - dockerfileRelative: string; - packageRoot: string; - dockerRunner?: DockerRunner; - /** When true, always run `docker build` even if the tag already exists. */ - force?: boolean; - /** Extra `--build-arg KEY=VALUE` pairs passed to `docker build`. */ - buildArgs?: Record; +export type AgentPrepareResult = { + volumes?: DockerVolumeMount[]; + /** Values for the docker CLI process (never on argv). */ + env?: Record; }; + +export type AgentBinding = { + image: string; + displayName: string; + command: (opts: { prompt: string; model?: string }) => string[]; + /** + * Resolve host-side secrets into mounts + docker-CLI env. + * Workspace → CONTAINER_WORKSPACE is always added by the shared runner. + */ + prepare: (opts: { workspace: string }) => Promise; + /** Map stdout → normalized metrics (throw on agent-reported failure). */ + parseResult: (stdout: string) => AgentRunResult; + describeFailure?: (stdout: string) => string | undefined; +}; + +export function emptyTokenUsage(): TokenUsage { + return { + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + }; +} + +export function readTokenCount(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/packages/clanker-cleanroom/src/images/build.spec.ts b/packages/clanker-cleanroom/src/images/build.spec.ts new file mode 100644 index 0000000..2ec3311 --- /dev/null +++ b/packages/clanker-cleanroom/src/images/build.spec.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect } from "vitest"; +import test from "vitest-gwt"; + +import type { DockerRunner } from "../agents/types.js"; +import { buildImages, resetBuildMemo } from "./build.js"; +import { readRegistry, resetRegistry, upsertRegistryEntry } from "./registry.js"; + +type Context = { + dir: string; + packageRoot: string; + dockerRunner: DockerRunner; + buildTags: string[]; + error?: Error; +}; + +describe("buildImages", () => { + test("builds in dependency order and writes the registry with agent", { + given: { + dockerfile_folder, + docker_runner_that_builds, + }, + when: { + building_images, + }, + then: { + built_base_then_cursor, + registry_lists_base_without_agent, + registry_lists_cursor_with_agent, + }, + }); + + test("records agent on toolchain FROM stock cursor", { + given: { + toolchain_from_cursor_folder, + docker_runner_that_builds, + stock_cursor_already_in_registry, + }, + when: { + building_images, + }, + then: { + registry_toolchain_has_cursor_agent, + }, + }); + + test("inherits agent through a nested local toolchain", { + given: { + nested_toolchain_folder, + docker_runner_that_builds, + }, + when: { + building_images, + }, + then: { + nested_toolchain_has_cursor_agent, + }, + }); + + test("skips rebuild when registry and image already exist", { + given: { + dockerfile_folder, + docker_runner_that_inspects, + prior_registry_entry, + }, + when: { + building_images, + }, + then: { + did_not_build, + }, + }); +}); + +function dockerfile_folder(this: Context) { + resetBuildMemo(); + this.dir = mkdtempSync(join(tmpdir(), "clanker-build-")); + this.packageRoot = mkdtempSync(join(tmpdir(), "clanker-root-")); + resetRegistry({ packageRoot: this.packageRoot }); + writeFileSync( + join(this.dir, "base.Dockerfile"), + "# clanker-cleanroom/base\nFROM archlinux:latest\n", + ); + writeFileSync( + join(this.dir, "cursor.Dockerfile"), + "# clanker-cleanroom/cursor\nFROM clanker-cleanroom/base\n", + ); + this.buildTags = []; +} + +function toolchain_from_cursor_folder(this: Context) { + resetBuildMemo(); + this.dir = mkdtempSync(join(tmpdir(), "clanker-build-")); + this.packageRoot = mkdtempSync(join(tmpdir(), "clanker-root-")); + resetRegistry({ packageRoot: this.packageRoot }); + writeFileSync( + join(this.dir, "node.Dockerfile"), + "# cursor:node\nFROM clanker-cleanroom/cursor\n", + ); + this.buildTags = []; +} + +function nested_toolchain_folder(this: Context) { + resetBuildMemo(); + this.dir = mkdtempSync(join(tmpdir(), "clanker-build-")); + this.packageRoot = mkdtempSync(join(tmpdir(), "clanker-root-")); + resetRegistry({ packageRoot: this.packageRoot }); + writeFileSync( + join(this.dir, "base.Dockerfile"), + "# clanker-cleanroom/base\nFROM archlinux:latest\n", + ); + writeFileSync( + join(this.dir, "cursor.Dockerfile"), + "# clanker-cleanroom/cursor\nFROM clanker-cleanroom/base\n", + ); + writeFileSync( + join(this.dir, "node.Dockerfile"), + "# cursor:node\nFROM clanker-cleanroom/cursor\n", + ); + writeFileSync(join(this.dir, "node-git.Dockerfile"), "# cursor:node-git\nFROM cursor:node\n"); + this.buildTags = []; +} + +function stock_cursor_already_in_registry(this: Context) { + upsertRegistryEntry( + "clanker-cleanroom/cursor", + { + image: "clanker-cleanroom/cursor", + dockerfile: "cursor.Dockerfile", + builtAt: new Date().toISOString(), + agent: "cursor", + }, + { packageRoot: this.packageRoot }, + ); +} + +function docker_runner_that_builds(this: Context) { + this.dockerRunner = async (args) => { + if (args[0] === "image" && args[1] === "inspect") { + return { exitCode: 1, stdout: "", stderr: "missing" }; + } + if (args[0] === "build") { + const tagIndex = args.indexOf("-t"); + const tag = args[tagIndex + 1]; + if (tag !== undefined) { + this.buildTags.push(tag); + } + return { exitCode: 0, stdout: "ok", stderr: "" }; + } + throw new Error(`unexpected: ${args.join(" ")}`); + }; +} + +function docker_runner_that_inspects(this: Context) { + this.dockerRunner = async (args) => { + if (args[0] === "image" && args[1] === "inspect") { + return { exitCode: 0, stdout: "[]", stderr: "" }; + } + if (args[0] === "build") { + const tagIndex = args.indexOf("-t"); + const tag = args[tagIndex + 1]; + if (tag !== undefined) { + this.buildTags.push(tag); + } + return { exitCode: 0, stdout: "ok", stderr: "" }; + } + throw new Error(`unexpected: ${args.join(" ")}`); + }; +} + +function prior_registry_entry(this: Context) { + upsertRegistryEntry( + "clanker-cleanroom/base", + { + image: "clanker-cleanroom/base", + dockerfile: "base.Dockerfile", + builtAt: new Date().toISOString(), + }, + { packageRoot: this.packageRoot }, + ); + upsertRegistryEntry( + "clanker-cleanroom/cursor", + { + image: "clanker-cleanroom/cursor", + dockerfile: "cursor.Dockerfile", + builtAt: new Date().toISOString(), + agent: "cursor", + }, + { packageRoot: this.packageRoot }, + ); +} + +async function building_images(this: Context) { + await buildImages({ + dir: this.dir, + packageRoot: this.packageRoot, + dockerRunner: this.dockerRunner, + }); +} + +function built_base_then_cursor(this: Context) { + expect(this.buildTags).toEqual(["clanker-cleanroom/base", "clanker-cleanroom/cursor"]); +} + +function registry_lists_base_without_agent(this: Context) { + const registry = readRegistry({ packageRoot: this.packageRoot }); + expect(registry.version).toBe(2); + expect(registry.images["clanker-cleanroom/base"]).toEqual( + expect.objectContaining({ + image: "clanker-cleanroom/base", + }), + ); + expect(registry.images["clanker-cleanroom/base"]?.agent).toBeUndefined(); +} + +function registry_lists_cursor_with_agent(this: Context) { + const registry = readRegistry({ packageRoot: this.packageRoot }); + expect(registry.images["clanker-cleanroom/cursor"]?.agent).toBe("cursor"); +} + +function registry_toolchain_has_cursor_agent(this: Context) { + const registry = readRegistry({ packageRoot: this.packageRoot }); + expect(registry.images["cursor:node"]?.agent).toBe("cursor"); + expect(registry.images["cursor:node"]?.image).toBe("cursor:node"); +} + +function nested_toolchain_has_cursor_agent(this: Context) { + const registry = readRegistry({ packageRoot: this.packageRoot }); + expect(registry.images["cursor:node"]?.agent).toBe("cursor"); + expect(registry.images["cursor:node-git"]?.agent).toBe("cursor"); +} + +function did_not_build(this: Context) { + expect(this.buildTags).toEqual([]); +} diff --git a/packages/clanker-cleanroom/src/images/build.ts b/packages/clanker-cleanroom/src/images/build.ts new file mode 100644 index 0000000..bb4d2cf --- /dev/null +++ b/packages/clanker-cleanroom/src/images/build.ts @@ -0,0 +1,114 @@ +import { join } from "node:path"; + +import { runDocker } from "../agents/docker.js"; +import { PACKAGE_ROOT } from "../package-root.js"; +import type { DockerRunner } from "../agents/types.js"; +import { topoSort } from "./graph.js"; +import { inferAgent } from "./infer-agent.js"; +import { parseDockerfiles, type DockerfileEntry } from "./parse.js"; +import { + readRegistry, + upsertRegistryEntry, + type ImageRegistry, + type RegistryOptions, +} from "./registry.js"; + +export type BuildImagesOptions = RegistryOptions & { + /** Folder of `*.Dockerfile` files. Defaults to this package's `docker/`. */ + dir?: string; + dockerRunner?: DockerRunner; + /** When true, rebuild even if the registry and local image already exist. */ + force?: boolean; +}; + +const inFlight = new Map>(); + +export function resetBuildMemo(): void { + inFlight.clear(); +} + +/** + * Scan a Dockerfile folder, topo-sort by local FROM tags, build each image, and + * record tags in `clanker-cleanroom.images.json` at packageRoot (default cwd). + */ +export async function buildImages(options: BuildImagesOptions = {}): Promise { + const dir = options.dir ?? join(PACKAGE_ROOT, "docker"); + const packageRoot = options.packageRoot ?? process.cwd(); + const force = options.force === true; + const dockerRunner = options.dockerRunner ?? runDocker; + const memoKey = `${dir}::${packageRoot}::${force}`; + + const existing = inFlight.get(memoKey); + if (existing !== undefined) { + return existing; + } + + const pending = doBuild(dir, packageRoot, force, dockerRunner).catch((error: unknown) => { + inFlight.delete(memoKey); + throw error; + }); + + inFlight.set(memoKey, pending); + return pending; +} + +async function doBuild( + dir: string, + packageRoot: string, + force: boolean, + dockerRunner: DockerRunner, +): Promise { + const entries = topoSort(parseDockerfiles(dir)); + const localByTag = new Map(entries.map((entry) => [entry.tag, entry])); + let registry = readRegistry({ packageRoot }); + + for (const entry of entries) { + if (!force) { + const recorded = registry.images[entry.tag]; + if (recorded !== undefined) { + const inspect = await dockerRunner(["image", "inspect", entry.tag]); + if (inspect.exitCode === 0) { + process.stderr.write(`[clanker-cleanroom] Docker image ${entry.tag} already present\n`); + continue; + } + } + } + + process.stderr.write(`[clanker-cleanroom] Building Docker image ${entry.tag}...\n`); + + const build = await dockerRunner( + ["build", "--progress=plain", "-t", entry.tag, "-f", entry.file, dir], + { inheritOutput: true }, + ); + + if (build.exitCode !== 0) { + throw new Error( + `Failed to build image ${entry.tag}.\nstderr:\n${build.stderr}\nstdout:\n${build.stdout}`, + ); + } + + process.stderr.write(`[clanker-cleanroom] Built Docker image ${entry.tag}\n`); + + registry = recordBuiltImage(entry, localByTag, registry, packageRoot); + } +} + +function recordBuiltImage( + entry: DockerfileEntry, + localByTag: Map, + registry: ImageRegistry, + packageRoot: string, +): ImageRegistry { + const agent = inferAgent(entry, localByTag, registry); + upsertRegistryEntry( + entry.tag, + { + image: entry.tag, + dockerfile: entry.relative, + builtAt: new Date().toISOString(), + ...(agent !== undefined ? { agent } : {}), + }, + { packageRoot }, + ); + return readRegistry({ packageRoot }); +} diff --git a/packages/clanker-cleanroom/src/images/graph.ts b/packages/clanker-cleanroom/src/images/graph.ts new file mode 100644 index 0000000..4f7015a --- /dev/null +++ b/packages/clanker-cleanroom/src/images/graph.ts @@ -0,0 +1,41 @@ +import type { DockerfileEntry } from "./parse.js"; + +/** + * Topological sort of Dockerfile entries by local FROM dependencies. + * Throws if a cycle is detected. + */ +export function topoSort(entries: DockerfileEntry[]): DockerfileEntry[] { + const byTag = new Map(entries.map((entry) => [entry.tag, entry])); + const visiting = new Set(); + const visited = new Set(); + const ordered: DockerfileEntry[] = []; + + function visit(tag: string, path: string[]): void { + if (visited.has(tag)) { + return; + } + if (visiting.has(tag)) { + throw new Error(`Dockerfile dependency cycle detected: ${[...path, tag].join(" -> ")}`); + } + + visiting.add(tag); + const entry = byTag.get(tag); + if (entry === undefined) { + throw new Error(`Unknown Dockerfile tag "${tag}"`); + } + + for (const dep of entry.dependencies) { + visit(dep, [...path, tag]); + } + + visiting.delete(tag); + visited.add(tag); + ordered.push(entry); + } + + for (const entry of entries) { + visit(entry.tag, []); + } + + return ordered; +} diff --git a/packages/clanker-cleanroom/src/images/infer-agent.ts b/packages/clanker-cleanroom/src/images/infer-agent.ts new file mode 100644 index 0000000..a5b8619 --- /dev/null +++ b/packages/clanker-cleanroom/src/images/infer-agent.ts @@ -0,0 +1,69 @@ +import { BASE_IMAGE, type StockAgentName, stockAgentNameForImage } from "../agents/stock.js"; +import type { DockerfileEntry } from "./parse.js"; +import type { ImageRegistry } from "./registry.js"; + +/** + * Infer which stock agent binding an image should use by walking FROM images. + * Returns undefined for non-agent images (e.g. base). + */ +export function inferAgent( + entry: DockerfileEntry, + localByTag: Map, + registry: ImageRegistry, + visiting: Set = new Set(), +): StockAgentName | undefined { + const stockForTag = stockAgentNameForImage(entry.tag); + if (stockForTag !== undefined) { + return stockForTag; + } + + if (entry.tag === BASE_IMAGE) { + return undefined; + } + + if (visiting.has(entry.tag)) { + throw new Error(`Cycle while inferring agent for "${entry.tag}"`); + } + visiting.add(entry.tag); + + try { + for (const from of entry.fromImages) { + const stock = stockAgentNameForImage(from); + if (stock !== undefined) { + return stock; + } + if (from === BASE_IMAGE) { + continue; + } + + const local = localByTag.get(from); + if (local !== undefined) { + const inferred = inferAgent(local, localByTag, registry, visiting); + if (inferred !== undefined) { + return inferred; + } + continue; + } + + const recorded = registry.images[from]?.agent; + if (recorded !== undefined) { + return recorded; + } + } + + const referencesBuiltImage = entry.fromImages.some( + (from) => + from !== BASE_IMAGE && (localByTag.has(from) || registry.images[from] !== undefined), + ); + if (!referencesBuiltImage) { + return undefined; + } + + throw new Error( + `Cannot determine stock agent for image "${entry.tag}". ` + + `FROM chain must reach clanker-cleanroom/cursor or clanker-cleanroom/claude.`, + ); + } finally { + visiting.delete(entry.tag); + } +} diff --git a/packages/clanker-cleanroom/src/images/parse.spec.ts b/packages/clanker-cleanroom/src/images/parse.spec.ts new file mode 100644 index 0000000..190a0c3 --- /dev/null +++ b/packages/clanker-cleanroom/src/images/parse.spec.ts @@ -0,0 +1,130 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect } from "vitest"; +import test from "vitest-gwt"; + +import { topoSort } from "./graph.js"; +import { parseDockerfiles } from "./parse.js"; + +type Context = { + dir: string; + error?: Error; + tags?: string[]; +}; + +describe("parseDockerfiles + topoSort", () => { + test("orders images by FROM dependencies", { + given: { + folder_with_base_and_cursor, + }, + when: { + parsing_and_sorting, + }, + then: { + base_before_cursor, + }, + }); + + test("errors when the first line is not a tag comment", { + given: { + folder_missing_tag_comment, + }, + when: { + parsing_catching_error, + }, + then: { + error_mentions_tag_comment, + }, + }); + + test("errors on a dependency cycle", { + given: { + folder_with_cycle, + }, + when: { + parsing_and_sorting_catching_error, + }, + then: { + error_mentions_cycle, + }, + }); + + test("errors on duplicate tags", { + given: { + folder_with_duplicate_tags, + }, + when: { + parsing_catching_error, + }, + then: { + error_mentions_duplicate, + }, + }); +}); + +function folder_with_base_and_cursor(this: Context) { + this.dir = mkdtempSync(join(tmpdir(), "clanker-parse-")); + writeFileSync( + join(this.dir, "base.Dockerfile"), + "# clanker-cleanroom/base\nFROM archlinux:latest\n", + ); + writeFileSync( + join(this.dir, "cursor.Dockerfile"), + "# clanker-cleanroom/cursor\nFROM clanker-cleanroom/base\n", + ); +} + +function folder_missing_tag_comment(this: Context) { + this.dir = mkdtempSync(join(tmpdir(), "clanker-parse-")); + writeFileSync(join(this.dir, "bad.Dockerfile"), "FROM archlinux:latest\n"); +} + +function folder_with_cycle(this: Context) { + this.dir = mkdtempSync(join(tmpdir(), "clanker-parse-")); + writeFileSync(join(this.dir, "a.Dockerfile"), "# a\nFROM b\n"); + writeFileSync(join(this.dir, "b.Dockerfile"), "# b\nFROM a\n"); +} + +function folder_with_duplicate_tags(this: Context) { + this.dir = mkdtempSync(join(tmpdir(), "clanker-parse-")); + writeFileSync(join(this.dir, "one.Dockerfile"), "# same\nFROM archlinux:latest\n"); + writeFileSync(join(this.dir, "two.Dockerfile"), "# same\nFROM archlinux:latest\n"); +} + +function parsing_and_sorting(this: Context) { + this.tags = topoSort(parseDockerfiles(this.dir)).map((entry) => entry.tag); +} + +function parsing_catching_error(this: Context) { + try { + parseDockerfiles(this.dir); + } catch (error) { + this.error = error as Error; + } +} + +function parsing_and_sorting_catching_error(this: Context) { + try { + topoSort(parseDockerfiles(this.dir)); + } catch (error) { + this.error = error as Error; + } +} + +function base_before_cursor(this: Context) { + expect(this.tags).toEqual(["clanker-cleanroom/base", "clanker-cleanroom/cursor"]); +} + +function error_mentions_tag_comment(this: Context) { + expect(this.error?.message).toContain("tag comment"); +} + +function error_mentions_cycle(this: Context) { + expect(this.error?.message).toContain("cycle"); +} + +function error_mentions_duplicate(this: Context) { + expect(this.error?.message).toContain("Duplicate"); +} diff --git a/packages/clanker-cleanroom/src/images/parse.ts b/packages/clanker-cleanroom/src/images/parse.ts new file mode 100644 index 0000000..52279e8 --- /dev/null +++ b/packages/clanker-cleanroom/src/images/parse.ts @@ -0,0 +1,77 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export type DockerfileEntry = { + /** Absolute path to the Dockerfile. */ + file: string; + /** Basename (e.g. `cursor.Dockerfile`). */ + relative: string; + /** First-line tag / Docker image name (e.g. `clanker-cleanroom/cursor`). */ + tag: string; + /** Every image referenced by a FROM line. */ + fromImages: string[]; + /** Tags from this folder that appear in FROM lines (for topo-sort). */ + dependencies: string[]; +}; + +const TAG_COMMENT = /^#\s*(\S+)\s*$/; +const FROM_LINE = /^FROM\s+(?:--platform=\S+\s+)?([^\s]+)(?:\s+AS\s+\S+)?\s*$/i; + +/** + * Discover `*.Dockerfile` files in `dir` and parse first-line tags + FROM images. + */ +export function parseDockerfiles(dir: string): DockerfileEntry[] { + const names = readdirSync(dir) + .filter((name) => name.endsWith(".Dockerfile")) + .sort(); + if (names.length === 0) { + throw new Error(`No *.Dockerfile files found in ${dir}`); + } + + const entries: DockerfileEntry[] = []; + const tags = new Set(); + + for (const name of names) { + const file = join(dir, name); + const contents = readFileSync(file, "utf8"); + const lines = contents.split(/\r?\n/); + const first = lines[0] ?? ""; + const tagMatch = TAG_COMMENT.exec(first); + if (tagMatch === null) { + throw new Error( + `Dockerfile ${name} must start with a tag comment "# " (e.g. "# clanker-cleanroom/cursor"). Got: ${JSON.stringify(first)}`, + ); + } + + const tag = tagMatch[1]!; + if (tags.has(tag)) { + throw new Error(`Duplicate Dockerfile tag "${tag}" in ${dir}`); + } + tags.add(tag); + + const fromImages: string[] = []; + for (const line of lines) { + const trimmed = line.trim(); + const fromMatch = FROM_LINE.exec(trimmed); + if (fromMatch !== null) { + fromImages.push(fromMatch[1]!); + } + } + + entries.push({ + file, + relative: name, + tag, + fromImages, + dependencies: [], + }); + } + + const tagSet = new Set(entries.map((entry) => entry.tag)); + + for (const entry of entries) { + entry.dependencies = entry.fromImages.filter((image) => tagSet.has(image)); + } + + return entries; +} diff --git a/packages/clanker-cleanroom/src/images/registry.ts b/packages/clanker-cleanroom/src/images/registry.ts new file mode 100644 index 0000000..410e9eb --- /dev/null +++ b/packages/clanker-cleanroom/src/images/registry.ts @@ -0,0 +1,85 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +import type { StockAgentName } from "../agents/stock.js"; + +export const IMAGES_REGISTRY_FILENAME = "clanker-cleanroom.images.json"; +export const IMAGE_REGISTRY_VERSION = 2 as const; + +export type ImageRegistryEntry = { + image: string; + dockerfile: string; + builtAt: string; + /** Stock binding this image runs with. Absent for non-agent images (e.g. base). */ + agent?: StockAgentName; +}; + +export type ImageRegistry = { + version: typeof IMAGE_REGISTRY_VERSION; + images: Record; +}; + +export type RegistryOptions = { + /** Directory that owns the registry file. Defaults to `process.cwd()`. */ + packageRoot?: string; +}; + +export function registryPath(options: RegistryOptions = {}): string { + return join(resolve(options.packageRoot ?? process.cwd()), IMAGES_REGISTRY_FILENAME); +} + +export function emptyRegistry(): ImageRegistry { + return { version: IMAGE_REGISTRY_VERSION, images: {} }; +} + +export function readRegistry(options: RegistryOptions = {}): ImageRegistry { + const file = registryPath(options); + if (!existsSync(file)) { + return emptyRegistry(); + } + + const parsed = JSON.parse(readFileSync(file, "utf8")) as { + version?: unknown; + images?: unknown; + }; + if (parsed.version !== IMAGE_REGISTRY_VERSION) { + throw new Error( + `Image registry at ${file} has version ${String(parsed.version)}; expected ${IMAGE_REGISTRY_VERSION}. ` + + `Delete it and run buildImages() again.`, + ); + } + if (typeof parsed.images !== "object" || parsed.images === null) { + throw new Error(`Invalid image registry at ${file}`); + } + + return parsed as ImageRegistry; +} + +export function writeRegistry(registry: ImageRegistry, options: RegistryOptions = {}): void { + const file = registryPath(options); + mkdirSync(dirname(file), { recursive: true }); + const temp = `${file}.${process.pid}.tmp`; + writeFileSync(temp, `${JSON.stringify(registry, null, 2)}\n`); + renameSync(temp, file); +} + +export function upsertRegistryEntry( + tag: string, + entry: ImageRegistryEntry, + options: RegistryOptions = {}, +): void { + const registry = readRegistry(options); + registry.images[tag] = entry; + writeRegistry(registry, options); +} + +export function resolveImage(tag: string, options: RegistryOptions = {}): string | undefined { + return readRegistry(options).images[tag]?.image; +} + +export function resetRegistry(options: RegistryOptions = {}): void { + const file = registryPath(options); + if (existsSync(file)) { + writeFileSync(file, `${JSON.stringify(emptyRegistry(), null, 2)}\n`); + } +} diff --git a/packages/clanker-cleanroom/src/index.ts b/packages/clanker-cleanroom/src/index.ts new file mode 100644 index 0000000..3cc6e39 --- /dev/null +++ b/packages/clanker-cleanroom/src/index.ts @@ -0,0 +1,68 @@ +export type { + AgentBinding, + AgentOptions, + AgentPrepareResult, + AgentRunBindingsOptions, + AgentRunResult, + BuildDockerRunArgsOptions, + DockerRunOptions, + DockerRunResult, + DockerRunner, + DockerVolumeMount, + EnsureDockerImageOptions, + RunAgentOptions, + TokenUsage, +} from "./agents/types.js"; + +export { emptyTokenUsage, readTokenCount } from "./agents/types.js"; + +export { buildDockerRunArgs, invokeDocker, runDocker } from "./agents/docker.js"; + +export { ensureDockerImage } from "./agents/ensure-image.js"; +export { parseAgentJsonOutput } from "./agents/parse-result.js"; +export { Agent } from "./agents/agent.js"; +export { createAgent, type CreateAgentBindings } from "./agents/create-agent.js"; +export { bindingRegistry, type AgentName, type StockAgentName } from "./agents/registry.js"; +export { isStockAgentName, stockAgentNameForImage, STOCK_AGENT_IMAGES } from "./agents/stock.js"; +export { runBoundAgent, type RunBoundAgentOptions } from "./agents/run-bound.js"; + +export { + CONTAINER_AUTH_PATH, + CURSOR_IMAGE, + defaultHostAuthFile, + cursorAgent, + cursorBinding, +} from "./agents/cursor/index.js"; + +export { + CLAUDE_API_KEY_ENV, + CLAUDE_CONTAINER_CREDENTIALS_PATH, + CLAUDE_IMAGE, + CLAUDE_OAUTH_TOKEN_ENV, + defaultClaudeHostCredentialsFile, + resolveClaudeCredentials, + credentialsEnv, + claudeAgent, + claudeBinding, + type ClaudeCredentials, +} from "./agents/claude/index.js"; + +export { BASE_IMAGE, CONTAINER_HOME, CONTAINER_WORKSPACE } from "./agents/base/index.js"; + +export { PACKAGE_ROOT } from "./package-root.js"; + +export { buildImages, resetBuildMemo, type BuildImagesOptions } from "./images/build.js"; +export { + resolveImage, + readRegistry, + resetRegistry, + registryPath, + upsertRegistryEntry, + IMAGES_REGISTRY_FILENAME, + IMAGE_REGISTRY_VERSION, + type ImageRegistry, + type ImageRegistryEntry, + type RegistryOptions, +} from "./images/registry.js"; +export { parseDockerfiles, type DockerfileEntry } from "./images/parse.js"; +export { topoSort } from "./images/graph.js"; diff --git a/src/package-root.spec.ts b/packages/clanker-cleanroom/src/package-root.spec.ts similarity index 80% rename from src/package-root.spec.ts rename to packages/clanker-cleanroom/src/package-root.spec.ts index 43deda7..1799e51 100644 --- a/src/package-root.spec.ts +++ b/packages/clanker-cleanroom/src/package-root.spec.ts @@ -25,9 +25,9 @@ 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"); + 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) { diff --git a/src/package-root.ts b/packages/clanker-cleanroom/src/package-root.ts similarity index 57% rename from src/package-root.ts rename to packages/clanker-cleanroom/src/package-root.ts index b358230..b18fc5a 100644 --- a/src/package-root.ts +++ b/packages/clanker-cleanroom/src/package-root.ts @@ -1,4 +1,4 @@ import { fileURLToPath } from "node:url"; -/** Absolute path to the agent-gwt package root (parent of `src/` or `lib/`). */ +/** Absolute path to the clanker-cleanroom package root (parent of `src/` or `lib/`). */ export const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url)); diff --git a/packages/clanker-cleanroom/tsconfig.json b/packages/clanker-cleanroom/tsconfig.json new file mode 100644 index 0000000..eeb8dea --- /dev/null +++ b/packages/clanker-cleanroom/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2022", + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "types": ["node"] + }, + "include": ["./src"] +} diff --git a/packages/clanker-cleanroom/vite.config.ts b/packages/clanker-cleanroom/vite.config.ts new file mode 100644 index 0000000..4c0a3b2 --- /dev/null +++ b/packages/clanker-cleanroom/vite.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + pack: { + entry: "src/index.ts", + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + outDir: "lib", + platform: "node", + root: "src", + }, + lint: { + ignorePatterns: ["lib/**", "coverage/**"], + overrides: [ + { + files: ["**/*.spec.ts"], + rules: { + "unicorn/no-thenable": "off", + }, + }, + ], + }, + test: { + include: ["src/**/*.spec.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.spec.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fca98b4..62f95ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,15 @@ settings: catalogs: default: + '@types/node': + specifier: ^26.2.0 + version: 26.4.0 '@vitest/coverage-v8': specifier: 4.1.11 version: 4.1.11 + typescript: + specifier: ^7.0.2 + version: 7.0.2 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.3.0 version: 0.3.0 @@ -18,6 +24,12 @@ catalogs: vitest: specifier: 4.1.11 version: 4.1.11 + vitest-gwt: + specifier: 4.1.4 + version: 4.1.4 + wireit: + specifier: ^0.14.13 + version: 0.14.13 overrides: vite@*: npm:@voidzero-dev/vite-plus-core@0.3.0 @@ -26,15 +38,28 @@ overrides: importers: .: + devDependencies: + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) + + packages/agent-gwt: + dependencies: + clanker-cleanroom: + specifier: workspace:* + version: link:../clanker-cleanroom devDependencies: '@types/node': - specifier: ^26.2.0 + specifier: 'catalog:' version: 26.4.0 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) typescript: - specifier: ^7.0.2 + specifier: 'catalog:' version: 7.0.2 vite: specifier: 'catalog:' @@ -44,12 +69,39 @@ importers: version: 0.3.0(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(typescript@7.0.2) vitest: specifier: 'catalog:' - version: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(vitest@4.1.11))(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) + version: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) vitest-gwt: - specifier: ^4.1.4 + specifier: 'catalog:' version: 4.1.4(vitest@4.1.11) wireit: - specifier: ^0.14.13 + specifier: 'catalog:' + version: 0.14.13 + + packages/clanker-cleanroom: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 26.4.0 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)' + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(typescript@7.0.2) + vitest: + specifier: 'catalog:' + version: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) + vitest-gwt: + specifier: 'catalog:' + version: 4.1.4(vitest@4.1.11) + wireit: + specifier: 'catalog:' version: 0.14.13 packages: @@ -1604,7 +1656,7 @@ snapshots: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.6(@testing-library/dom@10.4.1) '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(vitest@4.1.11) - vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(vitest@4.1.11))(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) transitivePeerDependencies: - bufferutil - msw @@ -1620,7 +1672,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(vitest@4.1.11))(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) ws: 8.21.3 transitivePeerDependencies: - bufferutil @@ -1640,7 +1692,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(vitest@4.1.11))(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) optionalDependencies: '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(vitest@4.1.11) @@ -2192,7 +2244,7 @@ snapshots: oxfmt: 0.64.0(vite-plus@0.3.0(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(typescript@7.0.2)) oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(typescript@7.0.2)) oxlint-tsgolint: 7.0.2001 - vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(vitest@4.1.11))(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.3.0 '@voidzero-dev/vite-plus-darwin-x64': 0.3.0 @@ -2236,9 +2288,9 @@ snapshots: vitest-gwt@4.1.4(vitest@4.1.11): dependencies: gwt-runner: 3.0.0 - vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(vitest@4.1.11))(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) - vitest@4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2))(vitest@4.1.11))(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)): + vitest@4.1.11(@types/node@26.4.0)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@26.4.0)(typescript@7.0.2)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f0e94f3..f1d02f2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,15 @@ +packages: + - "packages/*" + catalog: + "@types/node": ^26.2.0 + "@vitest/coverage-v8": 4.1.11 + typescript: ^7.0.2 vite: npm:@voidzero-dev/vite-plus-core@0.3.0 vite-plus: 0.3.0 vitest: 4.1.11 - "@vitest/coverage-v8": 4.1.11 + vitest-gwt: 4.1.4 + wireit: ^0.14.13 minimumReleaseAgeExclude: - vitest-gwt@4.1.4 overrides: diff --git a/src/agents/base/index.ts b/src/agents/base/index.ts deleted file mode 100644 index 27ab512..0000000 --- a/src/agents/base/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 1bd6818..0000000 --- a/src/agents/build-agent-image.spec.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { afterEach, describe, expect, vi } from "vitest"; -import test from "vitest-gwt"; -import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -import { - buildAgentImage, - buildBaseImage, - buildDockerImage, - resetBuiltImages, -} from "./build-agent-image.js"; -import { BASE_DOCKERFILE_RELATIVE, BASE_IMAGE } from "./base/constants.js"; -import { agentRegistry, type AgentName } from "./registry.js"; -import type { DockerRunOptions, DockerRunner } from "./types.js"; - -type BuildContext = { - image: string; - packageRoot: string; - dockerfileRelative: string; - dockerRunner: DockerRunner; - inspectCalls: number; - buildCalls: number; - lastBuildArgs?: string[]; - lastBuildOptions?: DockerRunOptions; - error?: Error; -}; - -type AgentBuildContext = { - buildCalls: number; -}; - -const tempRoots: string[] = []; - -afterEach(async () => { - resetBuiltImages(); - vi.restoreAllMocks(); - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); -}); - -describe("buildDockerImage", () => { - test("skips build when the image already exists", { - given: { - reset_memo, - image_name, - package_with_dockerfile, - inspect_succeeds, - }, - when: { - building_image, - }, - then: { - inspect_was_called, - build_was_not_called, - }, - }); - - test("builds the image when inspect fails", { - given: { - reset_memo, - image_name, - package_with_dockerfile, - inspect_fails_then_build_succeeds, - }, - when: { - building_image, - }, - then: { - inspect_was_called, - build_was_called, - build_uses_plain_progress_and_streams_output, - }, - }); - - test("memoizes so a second build does not re-inspect", { - given: { - reset_memo, - image_name, - package_with_dockerfile, - inspect_succeeds, - }, - when: { - building_image_twice, - }, - then: { - inspect_called_once, - build_was_not_called, - }, - }); - - test("surfaces a clear error when build fails", { - given: { - reset_memo, - image_name, - package_with_dockerfile, - inspect_fails_then_build_fails, - }, - when: { - building_image_catching_error, - }, - then: { - error_mentions_failed_build, - }, - }); - - test("force rebuilds even when the image already exists", { - given: { - reset_memo, - image_name, - package_with_dockerfile, - inspect_succeeds_but_force_still_builds, - }, - when: { - building_image_with_force, - }, - then: { - build_was_called, - build_uses_plain_progress_and_streams_output, - }, - }); - - test("passes build args through to docker build", { - given: { - reset_memo, - image_name, - package_with_dockerfile, - inspect_fails_then_build_succeeds, - }, - when: { - building_image_with_build_args, - }, - then: { - build_was_called, - build_includes_build_args, - }, - }); -}); - -describe("buildBaseImage", () => { - test("builds the shared base image from the package root", { - given: { - reset_memo, - package_with_base_dockerfile, - inspect_fails_then_build_succeeds, - }, - when: { - building_base_image, - }, - then: { - inspect_was_called, - build_was_called, - build_targeted_base_image, - }, - }); -}); - -describe("buildAgentImage", () => { - test("delegates to the resolved agent's buildImage", { - given: { - stub_agent_build_image: stub_agent_build_image("cursor"), - }, - when: { - building_agent_image: building_agent_image("cursor"), - }, - then: { - 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"), - }, - }); -}); - -async function package_with_dockerfile(this: BuildContext) { - this.dockerfileRelative = join("docker", "cursor", "Dockerfile"); - this.packageRoot = await mkdtemp(join(tmpdir(), "agent-gwt-pkg-")); - tempRoots.push(this.packageRoot); - await mkdir(join(this.packageRoot, "docker", "cursor"), { recursive: true }); - await writeFile(join(this.packageRoot, this.dockerfileRelative), "FROM scratch\n"); -} - -async function package_with_base_dockerfile(this: BuildContext) { - this.image = BASE_IMAGE; - this.dockerfileRelative = BASE_DOCKERFILE_RELATIVE; - this.packageRoot = await mkdtemp(join(tmpdir(), "agent-gwt-pkg-")); - tempRoots.push(this.packageRoot); - this.inspectCalls = 0; - this.buildCalls = 0; - await mkdir(join(this.packageRoot, "docker", "base"), { recursive: true }); - await writeFile(join(this.packageRoot, this.dockerfileRelative), "FROM scratch\n"); -} - -async function building_base_image(this: BuildContext) { - await buildBaseImage({ - packageRoot: this.packageRoot, - dockerRunner: this.dockerRunner, - }); -} - -function build_targeted_base_image(this: BuildContext) { - expect(this.lastBuildArgs).toEqual([ - "build", - "--progress=plain", - "-t", - BASE_IMAGE, - "-f", - join(this.packageRoot, BASE_DOCKERFILE_RELATIVE), - this.packageRoot, - ]); -} - -function reset_memo() { - resetBuiltImages(); -} - -function image_name(this: BuildContext) { - this.image = "agent-gwt/test:local"; - this.inspectCalls = 0; - this.buildCalls = 0; -} - -function inspect_succeeds(this: BuildContext) { - this.dockerRunner = async (args) => { - if (args[0] === "image" && args[1] === "inspect") { - this.inspectCalls += 1; - return { exitCode: 0, stdout: "[]", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; -} - -function inspect_fails_then_build_succeeds(this: BuildContext) { - this.dockerRunner = async (args, options) => { - if (args[0] === "image" && args[1] === "inspect") { - this.inspectCalls += 1; - return { exitCode: 1, stdout: "", stderr: "No such image" }; - } - if (args[0] === "build") { - this.buildCalls += 1; - this.lastBuildArgs = args; - if (options !== undefined) { - this.lastBuildOptions = options; - } - return { exitCode: 0, stdout: "done", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; -} - -function inspect_fails_then_build_fails(this: BuildContext) { - this.dockerRunner = async (args) => { - if (args[0] === "image" && args[1] === "inspect") { - this.inspectCalls += 1; - return { exitCode: 1, stdout: "", stderr: "No such image" }; - } - if (args[0] === "build") { - this.buildCalls += 1; - return { exitCode: 1, stdout: "", stderr: "build boom" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; -} - -function inspect_succeeds_but_force_still_builds(this: BuildContext) { - this.dockerRunner = async (args, options) => { - if (args[0] === "image" && args[1] === "inspect") { - this.inspectCalls += 1; - return { exitCode: 0, stdout: "[]", stderr: "" }; - } - if (args[0] === "build") { - this.buildCalls += 1; - this.lastBuildArgs = args; - if (options !== undefined) { - this.lastBuildOptions = options; - } - return { exitCode: 0, stdout: "done", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; -} - -async function building_image(this: BuildContext) { - await buildDockerImage(this.image, { - dockerfileRelative: this.dockerfileRelative, - packageRoot: this.packageRoot, - dockerRunner: this.dockerRunner, - }); -} - -async function building_image_with_force(this: BuildContext) { - await buildDockerImage(this.image, { - dockerfileRelative: this.dockerfileRelative, - packageRoot: this.packageRoot, - dockerRunner: this.dockerRunner, - force: true, - }); -} - -async function building_image_with_build_args(this: BuildContext) { - await buildDockerImage(this.image, { - dockerfileRelative: this.dockerfileRelative, - packageRoot: this.packageRoot, - dockerRunner: this.dockerRunner, - buildArgs: { AGENT_IMAGE: "agent-gwt/cursor-cli:local" }, - }); -} - -async function building_image_twice(this: BuildContext) { - await building_image.call(this); - await building_image.call(this); -} - -async function building_image_catching_error(this: BuildContext) { - try { - await building_image.call(this); - } catch (error) { - this.error = error as Error; - } -} - -function inspect_was_called(this: BuildContext) { - expect(this.inspectCalls).toBe(1); -} - -function inspect_called_once(this: BuildContext) { - expect(this.inspectCalls).toBe(1); -} - -function build_was_called(this: BuildContext) { - expect(this.buildCalls).toBe(1); -} - -function build_was_not_called(this: BuildContext) { - expect(this.buildCalls).toBe(0); -} - -function build_uses_plain_progress_and_streams_output(this: BuildContext) { - expect(this.lastBuildArgs).toContain("--progress=plain"); - expect(this.lastBuildOptions).toEqual({ inheritOutput: true }); -} - -function build_includes_build_args(this: BuildContext) { - expect(this.lastBuildArgs).toEqual([ - "build", - "--progress=plain", - "-t", - this.image, - "--build-arg", - "AGENT_IMAGE=agent-gwt/cursor-cli:local", - "-f", - join(this.packageRoot, this.dockerfileRelative), - this.packageRoot, - ]); -} - -function error_mentions_failed_build(this: BuildContext) { - expect(this.error?.message).toContain("Failed to build image"); - expect(this.error?.message).toContain("build boom"); -} - -function stub_agent_build_image(name: AgentName) { - return function (this: AgentBuildContext) { - this.buildCalls = 0; - vi.spyOn(agentRegistry[name], "buildImage").mockImplementation(async () => { - this.buildCalls += 1; - }); - }; -} - -function building_agent_image(name: AgentName) { - return async () => { - await buildAgentImage(name); - }; -} - -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/build-agent-image.ts b/src/agents/build-agent-image.ts deleted file mode 100644 index fb64388..0000000 --- a/src/agents/build-agent-image.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; - -import { PACKAGE_ROOT } from "../package-root.js"; -import { BASE_DOCKERFILE_RELATIVE, BASE_IMAGE } from "./base/constants.js"; -import { runDocker } from "./docker.js"; -import type { AgentName } from "./registry.js"; -import { resolveAgent } from "./registry.js"; -import type { BuildDockerImageOptions, DockerRunner } from "./types.js"; - -const builtImages = new Map>(); - -export function resetBuiltImages(): void { - builtImages.clear(); -} - -export async function buildDockerImage( - image: string, - options: BuildDockerImageOptions, -): Promise { - const dockerRunner = options.dockerRunner ?? runDocker; - const { packageRoot } = options; - const force = options.force === true; - const buildArgs = options.buildArgs ?? {}; - const memoKey = `${image}::${options.dockerfileRelative}::${packageRoot}::${force}::${JSON.stringify(buildArgs)}`; - - const existing = builtImages.get(memoKey); - if (existing !== undefined) { - return existing; - } - - const pending = doBuild(image, options.dockerfileRelative, dockerRunner, packageRoot, { - force, - buildArgs, - }).catch((error: unknown) => { - builtImages.delete(memoKey); - throw error; - }); - - builtImages.set(memoKey, pending); - return pending; -} - -export type BuildBaseImageOptions = { - dockerRunner?: DockerRunner; - packageRoot?: string; -}; - -export async function buildBaseImage(options: BuildBaseImageOptions = {}): Promise { - await buildDockerImage(BASE_IMAGE, { - dockerfileRelative: BASE_DOCKERFILE_RELATIVE, - packageRoot: options.packageRoot ?? PACKAGE_ROOT, - ...(options.dockerRunner !== undefined ? { dockerRunner: options.dockerRunner } : {}), - }); -} - -export async function buildAgentImage(name: AgentName): Promise { - await resolveAgent(name).buildImage(); -} - -async function doBuild( - image: string, - dockerfileRelative: string, - dockerRunner: DockerRunner, - packageRoot: string, - options: { force: boolean; buildArgs: Record }, -): Promise { - if (!options.force) { - const inspect = await dockerRunner(["image", "inspect", image]); - if (inspect.exitCode === 0) { - process.stderr.write(`[agent-gwt] Docker image ${image} already present\n`); - return; - } - } - - const dockerfile = join(packageRoot, dockerfileRelative); - if (!existsSync(dockerfile)) { - throw new Error(`Dockerfile not found at ${dockerfile}`); - } - - process.stderr.write(`[agent-gwt] Building Docker image ${image}...\n`); - - const buildArgs: string[] = []; - for (const [key, value] of Object.entries(options.buildArgs)) { - buildArgs.push("--build-arg", `${key}=${value}`); - } - - const build = await dockerRunner( - ["build", "--progress=plain", "-t", image, ...buildArgs, "-f", dockerfile, packageRoot], - { inheritOutput: true }, - ); - - if (build.exitCode !== 0) { - throw new Error( - `Failed to build image ${image}.\nstderr:\n${build.stderr}\nstdout:\n${build.stdout}`, - ); - } - - process.stderr.write(`[agent-gwt] Built Docker image ${image}\n`); -} diff --git a/src/agents/build-toolchain-image.spec.ts b/src/agents/build-toolchain-image.spec.ts deleted file mode 100644 index abbc477..0000000 --- a/src/agents/build-toolchain-image.spec.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { afterEach, describe, expect, vi } from "vitest"; -import test, { withAspect } from "vitest-gwt"; -import { createHash } from "node:crypto"; -import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import { tmpdir } from "node:os"; - -import * as buildAgentImageModule from "./build-agent-image.js"; -import { - buildToolchainImage, - clearToolchainImageMemory, - resetToolchainImages, - resolveToolchainImage, - type BuildToolchainImageOptions, -} from "./build-toolchain-image.js"; -import { resetBuiltImages } from "./build-agent-image.js"; -import type { DockerRunOptions, DockerRunner } from "./types.js"; - -type Context = { - variant: string; - packageRoot: string; - dockerfileRelative: string; - dockerfileContents: string; - parentImageId: string; - dockerRunner: DockerRunner | undefined; - inspectCalls: number; - buildCalls: number; - lastInspectArgs: string[] | undefined; - lastBuildArgs: string[] | undefined; - lastBuildOptions: DockerRunOptions | undefined; - error: Error | undefined; - agentBuildCalls: number; - firstImage: string | undefined; - secondImage: string | undefined; - tempRoots: string[]; -}; - -describe("buildToolchainImage", () => { - withAspect(reset_toolchain_state, cleanup_temp_roots); - - test("builds a content-hashed tag and registers the variant", { - given: { - variant_name, - package_with_dockerfile, - stub_agent_build, - inspect_parent_then_force_build, - }, - when: { - building_toolchain, - }, - then: { - agent_image_was_built, - parent_image_was_inspected, - build_was_called, - build_targeted_hashed_image_with_agent_arg, - variant_is_registered, - }, - }); - - test("force-rebuilds even when the hashed image already exists", { - given: { - variant_name, - package_with_dockerfile, - stub_agent_build, - parent_present_and_target_present_still_builds, - }, - when: { - building_toolchain, - }, - then: { - build_was_called, - variant_is_registered, - }, - }); - - test("memoizes the docker build so a second call does not rebuild", { - given: { - variant_name, - package_with_dockerfile, - stub_agent_build, - inspect_parent_then_force_build, - }, - when: { - building_toolchain_twice, - }, - then: { - build_called_once, - }, - }); - - test("uses a new tag when the Dockerfile content changes", { - given: { - variant_name, - package_with_dockerfile, - stub_agent_build, - inspect_parent_then_force_build, - }, - when: { - building_then_changing_dockerfile_and_rebuilding, - }, - then: { - rebuilt_with_new_content_digest, - }, - }); - - test("uses a new tag when the parent image id changes", { - given: { - variant_name, - package_with_dockerfile, - stub_agent_build, - inspect_parent_then_force_build, - }, - when: { - building_then_changing_parent_id_and_rebuilding, - }, - then: { - rebuilt_with_new_parent_digest, - }, - }); - - test("scopes tags by package root so repos do not collide", { - given: { - variant_name, - package_with_dockerfile, - stub_agent_build, - inspect_parent_then_force_build, - }, - when: { - building_same_dockerfile_in_two_roots, - }, - then: { - tags_differ_by_repo_digest, - }, - }); - - test("surfaces a clear error when the Dockerfile is missing", { - given: { - variant_name, - package_without_dockerfile, - stub_agent_build, - }, - when: { - building_toolchain_catching_error, - }, - then: { - error_mentions_missing_dockerfile, - }, - }); - - test("rejects a Dockerfile FROM that does not match the agent image", { - given: { - variant_name, - package_with_mismatched_from, - stub_agent_build, - }, - when: { - building_toolchain_catching_error, - }, - then: { - error_mentions_from_mismatch, - }, - }); - - test("resolves a variant from the persisted registry after memory is cleared", { - given: { - variant_name, - package_with_dockerfile, - stub_agent_build, - inspect_parent_then_force_build, - }, - when: { - building_then_clearing_memory_and_resolving, - }, - then: { - variant_resolved_from_disk, - }, - }); - - test("keeps distinct registry entries for variants that sanitize to the same name", { - given: { - variant_name, - package_with_dockerfile, - stub_agent_build, - inspect_parent_then_force_build, - }, - when: { - building_colliding_sanitized_variant_names, - }, - then: { - colliding_variants_resolve_independently, - }, - }); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -function reset_toolchain_state(this: Context) { - const priorRoots = this.tempRoots ?? []; - resetToolchainImages(); - for (const root of priorRoots) { - resetToolchainImages({ packageRoot: root }); - } - resetBuiltImages(); - this.tempRoots = []; - this.inspectCalls = 0; - this.buildCalls = 0; - this.agentBuildCalls = 0; - this.error = undefined; - this.firstImage = undefined; - this.secondImage = undefined; - this.lastInspectArgs = undefined; - this.lastBuildArgs = undefined; - this.lastBuildOptions = undefined; - this.parentImageId = "sha256:parent-image-id-1"; -} - -async function cleanup_temp_roots(this: Context) { - const roots = [...(this.tempRoots ?? [])]; - resetToolchainImages(); - for (const root of roots) { - resetToolchainImages({ packageRoot: root }); - } - resetBuiltImages(); - await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); - this.tempRoots = []; -} - -function variant_name(this: Context) { - this.variant = "node18"; - this.dockerfileRelative = join("docker", "agent.Dockerfile"); - this.dockerfileContents = "FROM agent-gwt/cursor-cli:local\n"; -} - -async function package_with_dockerfile(this: Context) { - this.packageRoot = await mkdtemp(join(tmpdir(), "agent-gwt-tc-")); - this.tempRoots.push(this.packageRoot); - await mkdir(join(this.packageRoot, "docker"), { recursive: true }); - await writeFile(join(this.packageRoot, this.dockerfileRelative), this.dockerfileContents); -} - -async function package_with_mismatched_from(this: Context) { - this.dockerfileContents = "FROM agent-gwt/claude-code:local\n"; - await package_with_dockerfile.call(this); -} - -async function package_without_dockerfile(this: Context) { - this.packageRoot = await mkdtemp(join(tmpdir(), "agent-gwt-tc-missing-")); - this.tempRoots.push(this.packageRoot); - this.dockerfileRelative = join("docker", "missing.Dockerfile"); -} - -function stub_agent_build(this: Context) { - vi.spyOn(buildAgentImageModule, "buildAgentImage").mockImplementation(async () => { - this.agentBuildCalls += 1; - }); -} - -function inspect_parent_then_force_build(this: Context) { - this.dockerRunner = async (args, options) => { - if (args[0] === "image" && args[1] === "inspect") { - this.inspectCalls += 1; - this.lastInspectArgs = args; - if (args.includes("--format")) { - return { exitCode: 0, stdout: `${this.parentImageId}\n`, stderr: "" }; - } - return { exitCode: 1, stdout: "", stderr: "No such image" }; - } - if (args[0] === "build") { - this.buildCalls += 1; - this.lastBuildArgs = args; - if (options !== undefined) { - this.lastBuildOptions = options; - } - return { exitCode: 0, stdout: "done", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; -} - -function parent_present_and_target_present_still_builds(this: Context) { - this.dockerRunner = async (args, options) => { - if (args[0] === "image" && args[1] === "inspect") { - this.inspectCalls += 1; - this.lastInspectArgs = args; - if (args.includes("--format")) { - return { exitCode: 0, stdout: `${this.parentImageId}\n`, stderr: "" }; - } - // Target tag exists — force should still build. - return { exitCode: 0, stdout: "[]", stderr: "" }; - } - if (args[0] === "build") { - this.buildCalls += 1; - this.lastBuildArgs = args; - if (options !== undefined) { - this.lastBuildOptions = options; - } - return { exitCode: 0, stdout: "done", stderr: "" }; - } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; -} - -async function building_toolchain(this: Context) { - const options: BuildToolchainImageOptions = { - agent: "cursor", - dockerfileRelative: this.dockerfileRelative, - packageRoot: this.packageRoot, - }; - if (this.dockerRunner !== undefined) { - options.dockerRunner = this.dockerRunner; - } - await buildToolchainImage(this.variant, options); -} - -async function building_toolchain_twice(this: Context) { - await building_toolchain.call(this); - await building_toolchain.call(this); -} - -async function building_toolchain_catching_error(this: Context) { - try { - const options: BuildToolchainImageOptions = { - agent: "cursor", - dockerfileRelative: this.dockerfileRelative, - packageRoot: this.packageRoot, - }; - if (this.dockerRunner !== undefined) { - options.dockerRunner = this.dockerRunner; - } - await buildToolchainImage(this.variant, options); - } catch (error) { - this.error = error as Error; - } -} - -async function building_then_changing_dockerfile_and_rebuilding(this: Context) { - await building_toolchain.call(this); - this.firstImage = resolveToolchainImage("cursor", this.variant, { - packageRoot: this.packageRoot, - }); - - this.dockerfileContents = "FROM agent-gwt/cursor-cli:local\nRUN echo changed\n"; - await writeFile(join(this.packageRoot, this.dockerfileRelative), this.dockerfileContents); - resetBuiltImages(); - this.buildCalls = 0; - this.inspectCalls = 0; - - await building_toolchain.call(this); - this.secondImage = resolveToolchainImage("cursor", this.variant, { - packageRoot: this.packageRoot, - }); -} - -async function building_then_changing_parent_id_and_rebuilding(this: Context) { - await building_toolchain.call(this); - this.firstImage = resolveToolchainImage("cursor", this.variant, { - packageRoot: this.packageRoot, - }); - - this.parentImageId = "sha256:parent-image-id-2"; - resetBuiltImages(); - this.buildCalls = 0; - this.inspectCalls = 0; - - await building_toolchain.call(this); - this.secondImage = resolveToolchainImage("cursor", this.variant, { - packageRoot: this.packageRoot, - }); -} - -async function building_same_dockerfile_in_two_roots(this: Context) { - await building_toolchain.call(this); - this.firstImage = resolveToolchainImage("cursor", this.variant, { - packageRoot: this.packageRoot, - }); - - const secondRoot = await mkdtemp(join(tmpdir(), "agent-gwt-tc-other-")); - this.tempRoots.push(secondRoot); - await mkdir(join(secondRoot, "docker"), { recursive: true }); - await writeFile(join(secondRoot, this.dockerfileRelative), this.dockerfileContents); - - resetBuiltImages(); - this.packageRoot = secondRoot; - await building_toolchain.call(this); - this.secondImage = resolveToolchainImage("cursor", this.variant, { - packageRoot: this.packageRoot, - }); -} - -async function building_then_clearing_memory_and_resolving(this: Context) { - await building_toolchain.call(this); - this.firstImage = resolveToolchainImage("cursor", this.variant, { - packageRoot: this.packageRoot, - }); - clearToolchainImageMemory(); - this.secondImage = resolveToolchainImage("cursor", this.variant, { - packageRoot: this.packageRoot, - }); -} - -async function building_colliding_sanitized_variant_names(this: Context) { - this.variant = "node/18"; - await building_toolchain.call(this); - this.firstImage = resolveToolchainImage("cursor", "node/18", { - packageRoot: this.packageRoot, - }); - - this.variant = "node_18"; - await building_toolchain.call(this); - this.secondImage = resolveToolchainImage("cursor", "node_18", { - packageRoot: this.packageRoot, - }); -} - -function agent_image_was_built(this: Context) { - expect(this.agentBuildCalls).toBe(1); - expect(buildAgentImageModule.buildAgentImage).toHaveBeenCalledWith("cursor"); -} - -function parent_image_was_inspected(this: Context) { - expect(this.inspectCalls).toBeGreaterThanOrEqual(1); - expect(this.lastInspectArgs).toEqual([ - "image", - "inspect", - "--format", - "{{.Id}}", - "agent-gwt/cursor-cli:local", - ]); -} - -function build_was_called(this: Context) { - expect(this.buildCalls).toBe(1); -} - -function build_called_once(this: Context) { - expect(this.buildCalls).toBe(1); -} - -function build_targeted_hashed_image_with_agent_arg(this: Context) { - const expected = expectedImage(this.packageRoot, this.dockerfileContents, this.parentImageId); - expect(this.lastBuildArgs).toEqual([ - "build", - "--progress=plain", - "-t", - expected, - "--build-arg", - "AGENT_IMAGE=agent-gwt/cursor-cli:local", - "-f", - join(this.packageRoot, this.dockerfileRelative), - this.packageRoot, - ]); - expect(this.lastBuildOptions).toEqual({ inheritOutput: true }); -} - -function variant_is_registered(this: Context) { - expect(resolveToolchainImage("cursor", this.variant, { packageRoot: this.packageRoot })).toBe( - expectedImage(this.packageRoot, this.dockerfileContents, this.parentImageId), - ); -} - -function rebuilt_with_new_content_digest(this: Context) { - expect(this.firstImage).toBeDefined(); - expect(this.secondImage).toBeDefined(); - expect(this.firstImage).not.toBe(this.secondImage); - expect(tagOf(this.secondImage!)).toBe( - contentDigestOf(this.dockerfileContents, this.parentImageId), - ); - expect(this.buildCalls).toBe(1); -} - -function rebuilt_with_new_parent_digest(this: Context) { - expect(this.firstImage).toBeDefined(); - expect(this.secondImage).toBeDefined(); - expect(this.firstImage).not.toBe(this.secondImage); - expect(tagOf(this.secondImage!)).toBe( - contentDigestOf(this.dockerfileContents, this.parentImageId), - ); - expect(this.buildCalls).toBe(1); -} - -function tags_differ_by_repo_digest(this: Context) { - expect(this.firstImage).toBeDefined(); - expect(this.secondImage).toBeDefined(); - expect(this.firstImage).not.toBe(this.secondImage); - expect(repoDigestOf(this.firstImage!)).not.toBe(repoDigestOf(this.secondImage!)); - expect(tagOf(this.firstImage!)).toBe(tagOf(this.secondImage!)); -} - -function variant_resolved_from_disk(this: Context) { - expect(this.firstImage).toBeDefined(); - expect(this.secondImage).toBe(this.firstImage); -} - -function colliding_variants_resolve_independently(this: Context) { - expect(this.firstImage).toBeDefined(); - expect(this.secondImage).toBeDefined(); - // Same Dockerfile + parent → same image tag, but both keys must still resolve (no lost update). - expect(this.firstImage).toBe(this.secondImage); - expect(resolveToolchainImage("cursor", "node/18", { packageRoot: this.packageRoot })).toBe( - this.firstImage, - ); - expect(resolveToolchainImage("cursor", "node_18", { packageRoot: this.packageRoot })).toBe( - this.secondImage, - ); -} - -function error_mentions_missing_dockerfile(this: Context) { - expect(this.error?.message).toContain("Dockerfile not found"); - expect(this.error?.message).toContain(join(this.packageRoot, this.dockerfileRelative)); -} - -function error_mentions_from_mismatch(this: Context) { - expect(this.error?.message).toContain("Dockerfile FROM must resolve to"); - expect(this.error?.message).toContain("agent-gwt/cursor-cli:local"); -} - -function expectedImage(packageRoot: string, contents: string, parentId: string): string { - const repoDigest = createHash("sha256").update(resolve(packageRoot)).digest("hex").slice(0, 12); - const contentDigest = contentDigestOf(contents, parentId); - return `agent-gwt/toolchain-cursor-${repoDigest}:${contentDigest}`; -} - -function contentDigestOf(contents: string, parentId: string): string { - return createHash("sha256").update(`${contents}\n${parentId}`).digest("hex").slice(0, 12); -} - -function tagOf(image: string): string { - return image.slice(image.lastIndexOf(":") + 1); -} - -function repoDigestOf(image: string): string { - const name = image.slice(0, image.lastIndexOf(":")); - return name.slice(name.lastIndexOf("-") + 1); -} diff --git a/src/agents/build-toolchain-image.ts b/src/agents/build-toolchain-image.ts deleted file mode 100644 index dbbfe1a..0000000 --- a/src/agents/build-toolchain-image.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -import { buildAgentImage, buildDockerImage } from "./build-agent-image.js"; -import { runDocker } from "./docker.js"; -import type { AgentName } from "./registry.js"; -import { resolveAgent } from "./registry.js"; -import type { DockerRunner } from "./types.js"; - -const DIGEST_LENGTH = 12; -const AGENT_IMAGE_BUILD_ARG = "AGENT_IMAGE"; - -const toolchainImages = new Map(); - -export type BuildToolchainImageOptions = { - agent: AgentName; - dockerfileRelative: string; - /** - * Repo root that owns the Dockerfile. Defaults to `process.cwd()`. - * Must match the cwd used when resolving variants via `agent({ variant })` - * (registry paths are keyed by this digest). - */ - packageRoot?: string; - dockerRunner?: DockerRunner; -}; - -export type ResolveToolchainImageOptions = { - /** Defaults to `process.cwd()` — must match the `packageRoot` used at build time. */ - packageRoot?: string; -}; - -export function resetToolchainImages(options: ResolveToolchainImageOptions = {}): void { - toolchainImages.clear(); - const dir = registryDir(options.packageRoot ?? process.cwd()); - if (existsSync(dir)) { - rmSync(dir, { recursive: true, force: true }); - } -} - -/** Clears the in-process cache without deleting persisted registry files. */ -export function clearToolchainImageMemory(): void { - toolchainImages.clear(); -} - -/** - * Resolve a variant registered by `buildToolchainImage`. - * Checks in-process memory first, then the packageRoot-scoped registry file - * (so vitest `globalSetup` registrations are visible to test workers). - */ -export function resolveToolchainImage( - agent: AgentName, - variant: string, - options: ResolveToolchainImageOptions = {}, -): string | undefined { - const packageRoot = resolve(options.packageRoot ?? process.cwd()); - const key = registryKey(agent, variant); - const cacheKey = `${digest(packageRoot)}::${key}`; - const cached = toolchainImages.get(cacheKey); - if (cached !== undefined) { - return cached; - } - - const file = registryEntryPath(packageRoot, agent, variant); - if (!existsSync(file)) { - return undefined; - } - - const image = readFileSync(file, "utf8").trim(); - if (image.length === 0) { - return undefined; - } - - toolchainImages.set(cacheKey, image); - return image; -} - -export async function buildToolchainImage( - variant: string, - options: BuildToolchainImageOptions, -): Promise { - const packageRoot = resolve(options.packageRoot ?? process.cwd()); - const dockerfile = join(packageRoot, options.dockerfileRelative); - const dockerRunner = options.dockerRunner ?? runDocker; - const agentImage = resolveAgent(options.agent).image; - - if (!existsSync(dockerfile)) { - throw new Error(`Dockerfile not found at ${dockerfile}`); - } - - const dockerfileContents = readFileSync(dockerfile, "utf8"); - assertDockerfileUsesAgentImage(dockerfileContents, agentImage); - - await buildAgentImage(options.agent); - - const parentId = await inspectImageId(agentImage, dockerRunner); - const repoDigest = digest(packageRoot); - const contentDigest = digest(`${dockerfileContents}\n${parentId}`); - const image = `agent-gwt/toolchain-${options.agent}-${repoDigest}:${contentDigest}`; - - await buildDockerImage(image, { - dockerfileRelative: options.dockerfileRelative, - packageRoot, - force: true, - buildArgs: { [AGENT_IMAGE_BUILD_ARG]: agentImage }, - ...(options.dockerRunner !== undefined ? { dockerRunner: options.dockerRunner } : {}), - }); - - registerToolchainImage(packageRoot, options.agent, variant, image); -} - -function registerToolchainImage( - packageRoot: string, - agent: AgentName, - variant: string, - image: string, -): void { - const key = registryKey(agent, variant); - const cacheKey = `${digest(packageRoot)}::${key}`; - toolchainImages.set(cacheKey, image); - - const file = registryEntryPath(packageRoot, agent, variant); - mkdirSync(registryDir(packageRoot), { recursive: true }); - const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; - writeFileSync(temp, `${image}\n`); - renameSync(temp, file); -} - -function assertDockerfileUsesAgentImage(contents: string, agentImage: string): void { - // Single-stage only: first FROM must be the agent (multi-stage final-FROM layouts are out of scope). - const fromLine = contents - .split("\n") - .map((line) => line.trim()) - .find((line) => /^FROM\s+/i.test(line)); - - if (fromLine === undefined) { - throw new Error( - `Dockerfile must start FROM ${agentImage} or FROM \${${AGENT_IMAGE_BUILD_ARG}}`, - ); - } - - const usesBuildArg = - fromLine.includes(`\${${AGENT_IMAGE_BUILD_ARG}}`) || - fromLine.includes(`$${AGENT_IMAGE_BUILD_ARG}`); - const usesLiteral = fromLine.includes(agentImage); - - if (!usesBuildArg && !usesLiteral) { - throw new Error( - `Dockerfile FROM must resolve to ${agentImage} ` + - `(use FROM ${agentImage} or ARG ${AGENT_IMAGE_BUILD_ARG} / FROM \${${AGENT_IMAGE_BUILD_ARG}}). ` + - `Got: ${fromLine}`, - ); - } -} - -async function inspectImageId(image: string, dockerRunner: DockerRunner): Promise { - const inspect = await dockerRunner(["image", "inspect", "--format", "{{.Id}}", image]); - if (inspect.exitCode !== 0) { - throw new Error( - `Docker image ${image} not found after buildAgentImage. stderr:\n${inspect.stderr}`, - ); - } - - const id = inspect.stdout.trim(); - if (id.length === 0) { - throw new Error(`Docker image inspect returned an empty Id for ${image}`); - } - - return id; -} - -function registryKey(agent: AgentName, variant: string): string { - return `${agent}::${variant}`; -} - -function registryDir(packageRoot: string): string { - return join(tmpdir(), ".agents-gwt", "toolchains", digest(resolve(packageRoot))); -} - -function registryEntryPath(packageRoot: string, agent: AgentName, variant: string): string { - // Hash the raw key so distinct variants never collide on disk (e.g. node/18 vs node_18). - return join(registryDir(packageRoot), digest(registryKey(agent, variant))); -} - -function digest(value: string | Buffer): string { - return createHash("sha256").update(value).digest("hex").slice(0, DIGEST_LENGTH); -} diff --git a/src/agents/claude/_buildDockerArgs.ts b/src/agents/claude/_buildDockerArgs.ts deleted file mode 100644 index bf21dac..0000000 --- a/src/agents/claude/_buildDockerArgs.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; -import { buildDockerRunArgs } from "../docker.js"; -import type { DockerVolumeMount } from "../types.js"; -import { credentialsEnv } from "./_credentialsEnv.js"; -import type { ClaudeCredentials } from "./_resolveCredentials.js"; -import { CLAUDE_CONTAINER_CREDENTIALS_PATH } from "./constants.js"; - -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, - }); -} diff --git a/src/agents/claude/_credentialsEnv.ts b/src/agents/claude/_credentialsEnv.ts deleted file mode 100644 index 93b5711..0000000 --- a/src/agents/claude/_credentialsEnv.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ClaudeCredentials } from "./_resolveCredentials.js"; -import { CLAUDE_API_KEY_ENV, CLAUDE_OAUTH_TOKEN_ENV } from "./constants.js"; - -/** Secret values for the docker CLI process, keyed by the env names `buildClaudeDockerArgs` forwards. */ -export 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 {}; - } -} diff --git a/src/agents/claude/agent.ts b/src/agents/claude/agent.ts deleted file mode 100644 index c9709aa..0000000 --- a/src/agents/claude/agent.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/index.ts b/src/agents/claude/index.ts deleted file mode 100644 index d116e21..0000000 --- a/src/agents/claude/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - CLAUDE_API_KEY_ENV, - CLAUDE_CONTAINER_CREDENTIALS_PATH, - CLAUDE_DOCKERFILE_RELATIVE, - CLAUDE_IMAGE, - CLAUDE_OAUTH_TOKEN_ENV, - defaultClaudeHostCredentialsFile, -} from "./constants.js"; -export { buildClaudeDockerArgs } from "./_buildDockerArgs.js"; -export { resolveClaudeCredentials, type ClaudeCredentials } from "./_resolveCredentials.js"; -export { runClaudeInDocker, type ClaudeAgentResult, 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 deleted file mode 100644 index 8add4b5..0000000 --- a/src/agents/claude/run.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, expect } from "vitest"; -import test from "vitest-gwt"; - -import type { ClaudeCredentials } from "./_resolveCredentials.js"; -import { CLAUDE_API_KEY_ENV, CLAUDE_OAUTH_TOKEN_ENV } from "./constants.js"; -import { runClaudeInDocker } from "./run.js"; -import type { DockerRunOptions, DockerRunner } from "../types.js"; - -const SECRET = "sk-ant-oat01-super-secret"; - -type Context = { - result: unknown; - credentials: ClaudeCredentials; - dockerRunner: DockerRunner; - lastArgs: string[]; - lastRunOptions: DockerRunOptions | undefined; -}; - -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: "", - }); -} - -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 }; -} - -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 deleted file mode 100644 index 6083178..0000000 --- a/src/agents/claude/run.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { runDocker } from "../docker.js"; -import { parseAgentJsonOutput } from "../parse-result.js"; -import { agentRunError } from "../run-error.js"; -import type { AgentRunBindingsOptions, DockerRunner } from "../types.js"; -import { buildClaudeDockerArgs } from "./_buildDockerArgs.js"; -import { credentialsEnv } from "./_credentialsEnv.js"; -import { type ClaudeCredentials, resolveClaudeCredentials } from "./_resolveCredentials.js"; - -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; -}; - -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; -} - -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/create-agent.spec.ts b/src/agents/create-agent.spec.ts deleted file mode 100644 index 312d632..0000000 --- a/src/agents/create-agent.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { afterEach, describe, expect, vi } from "vitest"; -import test from "vitest-gwt"; - -import * as buildAgentImageModule from "./build-agent-image.js"; -import { createAgent } from "./create-agent.js"; -import * as ensureImageModule from "./ensure-image.js"; -import type { Agent, AgentRunBindingsOptions, RunAgentOptions } from "./types.js"; - -type Context = { - agent: Agent; - runCalls: number; - runOptions?: AgentRunBindingsOptions; - result?: unknown; -}; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("createAgent", () => { - test("exposes image and injects it when delegating run", { - given: { - stub_ensure_and_build, - }, - when: { - creating_and_running_agent, - }, - then: { - image_is_set, - run_was_delegated_with_image, - }, - }); - - test("prefers an image override on run options", { - given: { - stub_ensure_and_build, - }, - when: { - creating_and_running_agent_with_image_override, - }, - then: { - run_was_delegated_with_override_image, - }, - }); - - test("ensureImage asserts the bound image exists", { - given: { - stub_ensure_and_build, - }, - when: { - creating_and_ensuring_image, - }, - then: { - ensure_docker_image_used_bound_image, - }, - }); - - test("buildImage builds base then the bound agent image", { - given: { - stub_ensure_and_build, - }, - when: { - creating_and_building_image, - }, - then: { - base_then_agent_image_were_built, - }, - }); -}); - -function stub_ensure_and_build(this: Context) { - this.runCalls = 0; - vi.spyOn(ensureImageModule, "ensureDockerImage").mockResolvedValue(); - vi.spyOn(buildAgentImageModule, "buildBaseImage").mockResolvedValue(); - vi.spyOn(buildAgentImageModule, "buildDockerImage").mockResolvedValue(); -} - -async function creating_and_running_agent(this: Context) { - this.agent = createAgent({ - dockerfileRelative: "docker/cursor/Dockerfile", - packageRoot: "/resolved/package", - image: "agent-gwt/test:local", - run: async (options) => { - this.runCalls += 1; - this.runOptions = options; - return { ok: true }; - }, - }); - - const options: RunAgentOptions = { - workspace: "/tmp/ws", - prompt: "hello", - }; - this.result = await this.agent.run(options); -} - -async function creating_and_running_agent_with_image_override(this: Context) { - this.agent = createAgent({ - dockerfileRelative: "docker/cursor/Dockerfile", - packageRoot: "/resolved/package", - image: "agent-gwt/test:local", - run: async (options) => { - this.runCalls += 1; - this.runOptions = options; - return { ok: true }; - }, - }); - - this.result = await this.agent.run({ - workspace: "/tmp/ws", - prompt: "hello", - image: "my-app/agent:local", - }); -} - -async function creating_and_ensuring_image(this: Context) { - this.agent = createAgent({ - dockerfileRelative: "docker/cursor/Dockerfile", - packageRoot: "/resolved/package", - image: "agent-gwt/test:local", - run: async () => ({}), - }); - - await this.agent.ensureImage(); -} - -async function creating_and_building_image(this: Context) { - this.agent = createAgent({ - dockerfileRelative: "docker/cursor/Dockerfile", - packageRoot: "/resolved/package", - image: "agent-gwt/test:local", - run: async () => ({}), - }); - - await this.agent.buildImage(); -} - -function image_is_set(this: Context) { - expect(this.agent.image).toBe("agent-gwt/test:local"); -} - -function run_was_delegated_with_image(this: Context) { - expect(this.runCalls).toBe(1); - expect(this.runOptions).toEqual({ - workspace: "/tmp/ws", - prompt: "hello", - image: "agent-gwt/test:local", - }); - expect(this.result).toEqual({ ok: true }); -} - -function run_was_delegated_with_override_image(this: Context) { - expect(this.runCalls).toBe(1); - expect(this.runOptions).toEqual({ - workspace: "/tmp/ws", - prompt: "hello", - image: "my-app/agent:local", - }); -} - -function ensure_docker_image_used_bound_image() { - expect(ensureImageModule.ensureDockerImage).toHaveBeenCalledWith("agent-gwt/test:local"); -} - -function base_then_agent_image_were_built() { - expect(buildAgentImageModule.buildBaseImage).toHaveBeenCalledWith(); - expect(buildAgentImageModule.buildDockerImage).toHaveBeenCalledWith("agent-gwt/test:local", { - dockerfileRelative: "docker/cursor/Dockerfile", - packageRoot: "/resolved/package", - }); -} diff --git a/src/agents/create-agent.ts b/src/agents/create-agent.ts deleted file mode 100644 index a10a349..0000000 --- a/src/agents/create-agent.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { buildBaseImage, buildDockerImage } from "./build-agent-image.js"; -import { ensureDockerImage } from "./ensure-image.js"; -import type { Agent, AgentResult, AgentRunBindingsOptions, RunAgentOptions } from "./types.js"; - -export type CreateAgentBindings = { - dockerfileRelative: string; - packageRoot: string; - image: string; - run: (options: AgentRunBindingsOptions) => Promise; -}; - -export function createAgent(bindings: CreateAgentBindings): Agent { - return { - image: bindings.image, - ensureImage: async () => { - await ensureDockerImage(bindings.image); - }, - buildImage: async () => { - await buildBaseImage(); - await buildDockerImage(bindings.image, { - dockerfileRelative: bindings.dockerfileRelative, - packageRoot: bindings.packageRoot, - }); - }, - run: (options: RunAgentOptions) => - bindings.run({ - ...options, - image: options.image ?? bindings.image, - }), - }; -} diff --git a/src/agents/cursor/_buildDockerArgs.ts b/src/agents/cursor/_buildDockerArgs.ts deleted file mode 100644 index cb752c3..0000000 --- a/src/agents/cursor/_buildDockerArgs.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CONTAINER_HOME, CONTAINER_WORKSPACE } from "../base/constants.js"; -import { buildDockerRunArgs } from "../docker.js"; -import { CONTAINER_AUTH_PATH } from "./constants.js"; - -export function buildDockerArgs(options: { - workspace: string; - prompt: string; - image: string; - authFile: string; - uid: number; - gid: number; - model?: string; -}): string[] { - const agentArgs = ["agent", "-p", "--force", "--output-format", "json"]; - - if (options.model !== undefined && options.model !== "") { - agentArgs.push("--model", options.model); - } - - agentArgs.push("--", options.prompt); - - return buildDockerRunArgs({ - image: options.image, - uid: options.uid, - gid: options.gid, - workdir: CONTAINER_WORKSPACE, - env: { HOME: CONTAINER_HOME }, - volumes: [ - { host: options.workspace, container: CONTAINER_WORKSPACE }, - { host: options.authFile, container: CONTAINER_AUTH_PATH, mode: "ro" }, - ], - command: agentArgs, - }); -} diff --git a/src/agents/cursor/agent.ts b/src/agents/cursor/agent.ts deleted file mode 100644 index b3bb69f..0000000 --- a/src/agents/cursor/agent.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PACKAGE_ROOT } from "../../package-root.js"; -import { createAgent } from "../create-agent.js"; -import { CURSOR_DOCKERFILE_RELATIVE, CURSOR_IMAGE } from "./constants.js"; -import { runCursorInDocker } from "./run.js"; - -export const cursorAgent = createAgent({ - dockerfileRelative: CURSOR_DOCKERFILE_RELATIVE, - packageRoot: PACKAGE_ROOT, - image: CURSOR_IMAGE, - run: runCursorInDocker, -}); diff --git a/src/agents/cursor/index.ts b/src/agents/cursor/index.ts deleted file mode 100644 index 181f12f..0000000 --- a/src/agents/cursor/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - CONTAINER_AUTH_PATH, - CURSOR_DOCKERFILE_RELATIVE, - CURSOR_IMAGE, - defaultHostAuthFile, -} from "./constants.js"; -export { buildDockerArgs } from "./_buildDockerArgs.js"; -export { runCursorInDocker, type RunCursorInDockerOptions } from "./run.js"; -export { cursorAgent } from "./agent.js"; diff --git a/src/agents/cursor/run.spec.ts b/src/agents/cursor/run.spec.ts deleted file mode 100644 index 4929da1..0000000 --- a/src/agents/cursor/run.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect } from "vitest"; -import test from "vitest-gwt"; -import { join } from "node:path"; - -import { runCursorInDocker } from "./run.js"; -import type { DockerRunner } from "../types.js"; - -type Context = { - result: unknown; - dockerRunner: DockerRunner; - authFile: string; -}; - -describe("runCursorInDocker", () => { - test("parses JSON from a successful docker run", { - given: { - successful_docker_runner, - existing_auth_file, - }, - when: { - running_cursor_in_docker, - }, - then: { - agent_result_is_parsed, - }, - }); - - test("throws when docker exits non-zero", { - given: { - failing_docker_runner, - existing_auth_file, - }, - when: { - running_cursor_in_docker, - }, - then: { - expect_error: error_includes_exit_code, - }, - }); -}); - -function successful_docker_runner(this: Context) { - this.dockerRunner = async () => ({ - exitCode: 0, - stdout: '{"ok":true}', - stderr: "", - }); -} - -function failing_docker_runner(this: Context) { - this.dockerRunner = async () => ({ - exitCode: 1, - stdout: "", - stderr: "boom", - }); -} - -function existing_auth_file(this: Context) { - this.authFile = join(process.cwd(), "package.json"); -} - -async function running_cursor_in_docker(this: Context) { - this.result = await runCursorInDocker( - { - workspace: "/tmp/.agents-gwt/ws-abc", - prompt: "hi", - image: "agent-gwt/cursor-cli:local", - authFile: this.authFile, - uid: 1000, - gid: 1000, - }, - this.dockerRunner, - ); -} - -function agent_result_is_parsed(this: Context) { - expect(this.result).toEqual({ ok: true }); -} - -function error_includes_exit_code(this: Context, error: Error) { - expect(error.message).toContain("exited with code 1"); -} diff --git a/src/agents/cursor/run.ts b/src/agents/cursor/run.ts deleted file mode 100644 index 2a6425e..0000000 --- a/src/agents/cursor/run.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { access } from "node:fs/promises"; -import { constants as fsConstants } from "node:fs"; -import { homedir } from "node:os"; - -import { runDocker } from "../docker.js"; -import { parseAgentJsonOutput } from "../parse-result.js"; -import { agentRunError } from "../run-error.js"; -import type { DockerRunner, AgentRunBindingsOptions } from "../types.js"; -import { buildDockerArgs } from "./_buildDockerArgs.js"; -import { defaultHostAuthFile } from "./constants.js"; - -export type RunCursorInDockerOptions = AgentRunBindingsOptions & { - authFile?: string; - uid?: number; - gid?: number; -}; - -export async function runCursorInDocker( - options: RunCursorInDockerOptions, - dockerRunner: DockerRunner = runDocker, -): Promise { - const authFile = options.authFile ?? defaultHostAuthFile(homedir()); - const uid = options.uid ?? process.getuid?.() ?? 0; - const gid = options.gid ?? process.getgid?.() ?? 0; - - try { - await access(authFile, fsConstants.R_OK); - } catch { - throw new Error( - `Cursor credentials not found at ${authFile}. Run \`agent login\` on the host first.`, - ); - } - - const args = buildDockerArgs({ - workspace: options.workspace, - prompt: options.prompt, - image: options.image, - authFile, - uid, - gid, - ...(options.model !== undefined ? { model: options.model } : {}), - }); - - const result = await dockerRunner(args); - - if (result.exitCode !== 0) { - throw agentRunError({ agent: "Cursor", name: "cursor", image: options.image, result }); - } - - return parseAgentJsonOutput(result.stdout); -} diff --git a/src/agents/registry.ts b/src/agents/registry.ts deleted file mode 100644 index eba6f5c..0000000 --- a/src/agents/registry.ts +++ /dev/null @@ -1,14 +0,0 @@ -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; - -export function resolveAgent(name: AgentName): Agent { - return agentRegistry[name]; -} diff --git a/src/given/agent.ts b/src/given/agent.ts deleted file mode 100644 index 501a4af..0000000 --- a/src/given/agent.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { resolveToolchainImage } from "../agents/build-toolchain-image.js"; -import type { AgentName } from "../agents/registry.js"; -import { resolveAgent } from "../agents/registry.js"; -import { ensureDockerImage } from "../agents/ensure-image.js"; -import type { AgentContext, AgentOptions } from "../types.js"; - -export type ConfigureAgentOptions = AgentOptions & { - name: AgentName; -}; - -export function agent(options: ConfigureAgentOptions) { - const resolved = resolveAgent(options.name); - - return async function (this: AgentContext): Promise { - this.agent = resolved; - this.image = resolveAgentImage(options, resolved.image); - - if (options.model !== undefined) { - this.model = options.model; - } - - await ensureDockerImage(this.image); - }; -} - -function resolveAgentImage(options: ConfigureAgentOptions, defaultImage: string): string { - if (options.image !== undefined && options.variant !== undefined) { - throw new Error( - `agent({ name: "${options.name}" }) cannot set both image and variant; pick one.`, - ); - } - - if (options.image !== undefined) { - return options.image; - } - - if (options.variant !== undefined) { - const image = resolveToolchainImage(options.name, options.variant); - if (image === undefined) { - throw new Error( - `Unknown toolchain variant "${options.variant}" for agent "${options.name}". ` + - `Call buildToolchainImage("${options.variant}", { agent: "${options.name}", ... }) ` + - `from vitest globalSetup before running tests.`, - ); - } - return image; - } - - return defaultImage; -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 58591a4..0000000 --- a/src/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -export type { - AgentContext, - AgentResult, - Agent, - AgentOptions, - AgentName, - ConfigureAgentOptions, -} from "./types.js"; - -export { a_workspace, cleanup_workspace, AGENTS_GWT_TMP_ROOT } from "./given/a_workspace.js"; -export { copy_to_workspace, type CopyToWorkspaceOptions } from "./given/copy_to_workspace.js"; -export { the_prompt } from "./given/the_prompt.js"; -export { executing_the_agent } from "./when/executing_the_agent.js"; -export { agent } from "./given/agent.js"; - -export { - CONTAINER_AUTH_PATH, - CURSOR_DOCKERFILE_RELATIVE, - CURSOR_IMAGE, - defaultHostAuthFile, - buildDockerArgs, - runCursorInDocker, - cursorAgent, - 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 { - buildAgentImage, - buildBaseImage, - buildDockerImage, - resetBuiltImages, - type BuildBaseImageOptions, -} from "./agents/build-agent-image.js"; -export { - buildToolchainImage, - resetToolchainImages, - resolveToolchainImage, - type BuildToolchainImageOptions, -} from "./agents/build-toolchain-image.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"; - -export { - buildDockerRunArgs, - invokeDocker, - runDocker, - type DockerRunner, - type DockerRunOptions, - type DockerRunResult, - type BuildDockerRunArgsOptions, - type DockerVolumeMount, -} from "./agents/docker.js"; - -export type { RunAgentOptions, AgentRunBindingsOptions } from "./agents/types.js"; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index b51ae19..0000000 --- a/src/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Agent, AgentOptions, AgentResult } from "./agents/types.js"; - -export type { AgentResult, Agent, AgentOptions }; -export type { AgentName } from "./agents/registry.js"; -export type { ConfigureAgentOptions } from "./given/agent.js"; - -export type AgentContext = { - workspace: string; - prompt: string; - agentResult: AgentResult; - agent: Agent; - image: string; - model?: string; -}; diff --git a/vite.config.ts b/vite.config.ts index fae36ba..3761119 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,36 +1,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ - pack: { - entry: "src/index.ts", - format: ["esm", "cjs"], - dts: true, - sourcemap: true, - outDir: "lib", - platform: "node", - root: "src", - }, - lint: { - ignorePatterns: ["lib/**", "coverage/**"], - options: { - typeAware: true, - typeCheck: true, - }, - overrides: [ - { - files: ["**/*.spec.ts"], - rules: { - "unicorn/no-thenable": "off", - }, - }, - ], - }, test: { - include: ["src/**/*.spec.ts"], - coverage: { - provider: "v8", - include: ["src/**/*.ts"], - exclude: ["src/**/*.spec.ts"], - }, + projects: ["packages/agent-gwt", "packages/clanker-cleanroom"], }, });