From 8285f2ae2596dc7e24350807bce900f86b73a510 Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Sun, 30 Aug 2026 10:53:58 -0500 Subject: [PATCH 1/7] feat: add named toolchain image variants for agent runs Allow consumers to build a per-repo content-hashed layer on the agent image and select it via agent({ variant }) without threading tags through every test (#5). --- README.md | 23 +- src/agents/build-toolchain-image.spec.ts | 374 +++++++++++++++++++++++ src/agents/build-toolchain-image.ts | 141 +++++++++ src/agents/types.ts | 5 + src/given/agent.spec.ts | 131 +++++++- src/given/agent.ts | 29 +- src/index.ts | 6 + 7 files changed, 697 insertions(+), 12 deletions(-) create mode 100644 src/agents/build-toolchain-image.spec.ts create mode 100644 src/agents/build-toolchain-image.ts diff --git a/README.md b/README.md index 785c6a2..29c9a35 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ Docker Desktop applies this to both `docker build` and `docker run`, so nothing ### Extending with toolchains -Install packages in a child image, then point tests at that tag: +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 @@ -260,21 +260,23 @@ USER root ```ts // vitest.global-setup.ts -import { buildAgentImage, buildDockerImage } from "agent-gwt"; +import { buildToolchainImage } from "agent-gwt"; export default async function setup() { - await buildAgentImage("cursor"); - await buildDockerImage("my-app/agent:local", { + await buildToolchainImage("node18", { + agent: "cursor", dockerfileRelative: "docker/agent.Dockerfile", - packageRoot: process.cwd(), }); } ``` ```ts -agent({ name: "cursor", image: "my-app/agent:local", model: "auto" }); +agent({ name: "cursor", variant: "node18", model: "auto" }); +// omit variant → stock agent-gwt/cursor-cli:local ``` +`buildToolchainImage` builds the agent image first, tags a per-repo content-hashed image (`agent-gwt/toolchain--:`), and registers the variant for the current working directory (in-memory and on disk under `/tmp/.agent-gwt/…`, so vitest `globalSetup` is visible to test workers). Changing the Dockerfile produces a new tag so Docker rebuilds; unchanged files reuse the cached image. `image` remains available as a low-level override and is mutually exclusive with `variant`. + The base uses Arch/`pacman` (glibc). Alpine will not run the Cursor CLI. ## What `agent` does @@ -282,8 +284,8 @@ The base uses Arch/`pacman` (glibc). Alpine will not run the Cursor CLI. 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` or the resolved agent -3. Asserts that Docker image already exists (`docker image inspect`) — it does **not** build. Build once in `globalSetup` with `buildAgentImage(...)` so parallel test files do not race +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)`. @@ -300,10 +302,11 @@ Pair workspace lifecycle separately: `withAspect(a_workspace, cleanup_workspace) | Export | Role | | -------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `AgentContext` | Extensible context type (`workspace`, `prompt`, `agent`, `image`, …) | -| `agent(opts)` | `withAspect` before — `{ name: "cursor" \| "claude", model?, 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 (e.g. toolchain overlay) | +| `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) | diff --git a/src/agents/build-toolchain-image.spec.ts b/src/agents/build-toolchain-image.spec.ts new file mode 100644 index 0000000..f3ec477 --- /dev/null +++ b/src/agents/build-toolchain-image.spec.ts @@ -0,0 +1,374 @@ +import { afterEach, describe, expect, vi } from "vitest"; +import test 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, +} 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; + dockerRunner: DockerRunner; + inspectCalls: number; + buildCalls: number; + lastInspectImage: string | undefined; + lastBuildArgs: string[] | undefined; + lastBuildOptions: DockerRunOptions | undefined; + error: Error | undefined; + agentBuildCalls: number; + firstImage: string | undefined; + secondImage: string | undefined; +}; + +const tempRoots: string[] = []; + +afterEach(async () => { + resetToolchainImages(); + resetBuiltImages(); + vi.restoreAllMocks(); + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("buildToolchainImage", () => { + test("builds a content-hashed tag and registers the variant", { + given: { + reset_state, + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_fails_then_build_succeeds, + }, + when: { + building_toolchain, + }, + then: { + agent_image_was_built, + inspect_was_called, + build_was_called, + build_targeted_hashed_image, + variant_is_registered, + }, + }); + + test("skips docker build when the hashed image already exists", { + given: { + reset_state, + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_succeeds, + }, + when: { + building_toolchain, + }, + then: { + inspect_was_called, + build_was_not_called, + variant_is_registered, + }, + }); + + test("memoizes so a second build does not re-inspect", { + given: { + reset_state, + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_succeeds, + }, + when: { + building_toolchain_twice, + }, + then: { + inspect_called_once, + build_was_not_called, + }, + }); + + test("uses a new tag when the Dockerfile content changes", { + given: { + reset_state, + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_fails_then_build_succeeds, + }, + when: { + building_then_changing_dockerfile_and_rebuilding, + }, + then: { + rebuilt_with_new_content_digest, + }, + }); + + test("scopes tags by package root so repos do not collide", { + given: { + reset_state, + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_fails_then_build_succeeds, + }, + when: { + building_same_dockerfile_in_two_roots, + }, + then: { + tags_differ_by_repo_digest, + }, + }); + + test("surfaces a clear error when the Dockerfile is missing", { + given: { + reset_state, + variant_name, + package_without_dockerfile, + stub_agent_build, + }, + when: { + building_toolchain_catching_error, + }, + then: { + error_mentions_missing_dockerfile, + }, + }); + + test("resolves a variant from the persisted registry after memory is cleared", { + given: { + reset_state, + variant_name, + package_with_dockerfile, + stub_agent_build, + inspect_succeeds, + }, + when: { + building_then_clearing_memory_and_resolving, + }, + then: { + variant_resolved_from_disk, + }, + }); +}); + +function reset_state() { + resetToolchainImages(); + resetBuiltImages(); +} + +function variant_name(this: Context) { + this.variant = "node18"; + this.inspectCalls = 0; + this.buildCalls = 0; + this.agentBuildCalls = 0; + 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-")); + tempRoots.push(this.packageRoot); + await mkdir(join(this.packageRoot, "docker"), { recursive: true }); + await writeFile(join(this.packageRoot, this.dockerfileRelative), this.dockerfileContents); +} + +async function package_without_dockerfile(this: Context) { + this.packageRoot = await mkdtemp(join(tmpdir(), "agent-gwt-tc-missing-")); + 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_succeeds(this: Context) { + this.dockerRunner = async (args) => { + if (args[0] === "image" && args[1] === "inspect") { + this.inspectCalls += 1; + this.lastInspectImage = args[2]; + return { exitCode: 0, stdout: "[]", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; +} + +function inspect_fails_then_build_succeeds(this: Context) { + this.dockerRunner = async (args, options) => { + if (args[0] === "image" && args[1] === "inspect") { + this.inspectCalls += 1; + this.lastInspectImage = args[2]; + 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(" ")}`); + }; +} + +async function building_toolchain(this: Context) { + await buildToolchainImage(this.variant, { + agent: "cursor", + dockerfileRelative: this.dockerfileRelative, + packageRoot: this.packageRoot, + dockerRunner: this.dockerRunner, + }); +} + +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 { + await buildToolchainImage(this.variant, { + agent: "cursor", + dockerfileRelative: this.dockerfileRelative, + packageRoot: this.packageRoot, + }); + } 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); + + 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); +} + +async function building_same_dockerfile_in_two_roots(this: Context) { + await building_toolchain.call(this); + this.firstImage = resolveToolchainImage("cursor", this.variant); + + const secondRoot = await mkdtemp(join(tmpdir(), "agent-gwt-tc-other-")); + 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); +} + +async function building_then_clearing_memory_and_resolving(this: Context) { + await building_toolchain.call(this); + this.firstImage = resolveToolchainImage("cursor", this.variant); + clearToolchainImageMemory(); + this.secondImage = resolveToolchainImage("cursor", this.variant); +} + +function variant_resolved_from_disk(this: Context) { + expect(this.firstImage).toBeDefined(); + expect(this.secondImage).toBe(this.firstImage); +} + +function agent_image_was_built(this: Context) { + expect(this.agentBuildCalls).toBe(1); + expect(buildAgentImageModule.buildAgentImage).toHaveBeenCalledWith("cursor"); +} + +function inspect_was_called(this: Context) { + expect(this.inspectCalls).toBe(1); +} + +function inspect_called_once(this: Context) { + expect(this.inspectCalls).toBe(1); +} + +function build_was_called(this: Context) { + expect(this.buildCalls).toBe(1); +} + +function build_was_not_called(this: Context) { + expect(this.buildCalls).toBe(0); +} + +function build_targeted_hashed_image(this: Context) { + const expected = expectedImage(this.packageRoot, this.dockerfileContents); + expect(this.lastInspectImage).toBe(expected); + expect(this.lastBuildArgs).toEqual([ + "build", + "--progress=plain", + "-t", + expected, + "-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)).toBe( + expectedImage(this.packageRoot, this.dockerfileContents), + ); +} + +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)); + 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 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 expectedImage(packageRoot: string, contents: string): string { + const repoDigest = createHash("sha256").update(resolve(packageRoot)).digest("hex").slice(0, 12); + const contentDigest = contentDigestOf(contents); + return `agent-gwt/toolchain-cursor-${repoDigest}:${contentDigest}`; +} + +function contentDigestOf(contents: string): string { + return createHash("sha256").update(contents).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 new file mode 100644 index 0000000..33d08eb --- /dev/null +++ b/src/agents/build-toolchain-image.ts @@ -0,0 +1,141 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname } from "node:path"; + +import { buildAgentImage, buildDockerImage } from "./build-agent-image.js"; +import type { AgentName } from "./registry.js"; +import type { DockerRunner } from "./types.js"; + +const DIGEST_LENGTH = 12; + +const toolchainImages = new Map(); + +export type BuildToolchainImageOptions = { + agent: AgentName; + dockerfileRelative: string; + /** Defaults to `process.cwd()` (consuming repo). */ + packageRoot?: string; + dockerRunner?: DockerRunner; +}; + +export function resetToolchainImages(): void { + const file = registryFilePath(); + const persisted = readRegistryFile(); + for (const key of toolchainImages.keys()) { + delete persisted[key]; + } + toolchainImages.clear(); + + if (Object.keys(persisted).length === 0) { + if (existsSync(file)) { + rmSync(file, { force: true }); + } + return; + } + + writeRegistryFile(persisted); +} + +/** Clears the in-process cache without deleting the persisted registry file. */ +export function clearToolchainImageMemory(): void { + toolchainImages.clear(); +} + +/** + * Resolve a variant registered by `buildToolchainImage`. + * Checks in-process memory first, then the cwd-scoped registry file + * (so vitest `globalSetup` registrations are visible to test workers). + */ +export function resolveToolchainImage(agent: AgentName, variant: string): string | undefined { + const key = registryKey(agent, variant); + const cached = toolchainImages.get(key); + if (cached !== undefined) { + return cached; + } + + const persisted = readRegistryFile(); + const image = persisted[key]; + if (image === undefined) { + return undefined; + } + + toolchainImages.set(key, 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); + + if (!existsSync(dockerfile)) { + throw new Error(`Dockerfile not found at ${dockerfile}`); + } + + await buildAgentImage(options.agent); + + const contentDigest = digest(readFileSync(dockerfile)); + const repoDigest = digest(packageRoot); + const image = `agent-gwt/toolchain-${options.agent}-${repoDigest}:${contentDigest}`; + + await buildDockerImage(image, { + dockerfileRelative: options.dockerfileRelative, + packageRoot, + ...(options.dockerRunner !== undefined ? { dockerRunner: options.dockerRunner } : {}), + }); + + registerToolchainImage(options.agent, variant, image); +} + +function registerToolchainImage(agent: AgentName, variant: string, image: string): void { + const key = registryKey(agent, variant); + toolchainImages.set(key, image); + + const persisted = readRegistryFile(); + persisted[key] = image; + writeRegistryFile(persisted); +} + +function registryKey(agent: AgentName, variant: string): string { + return `${agent}::${variant}`; +} + +function registryFilePath(): string { + return join(tmpdir(), ".agent-gwt", "toolchains", digest(resolve(process.cwd())), "variants.json"); +} + +function readRegistryFile(): Record { + const file = registryFilePath(); + if (!existsSync(file)) { + return {}; + } + + try { + const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + return parsed as Record; + } catch { + return {}; + } +} + +function writeRegistryFile(entries: Record): void { + const file = registryFilePath(); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(entries, null, 2)}\n`); +} + +function digest(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex").slice(0, DIGEST_LENGTH); +} diff --git a/src/agents/types.ts b/src/agents/types.ts index 8569b2b..0473556 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -21,6 +21,11 @@ export type Agent = { 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; }; diff --git a/src/given/agent.spec.ts b/src/given/agent.spec.ts index f3d8798..6bd2cbe 100644 --- a/src/given/agent.spec.ts +++ b/src/given/agent.spec.ts @@ -1,20 +1,39 @@ 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 * as buildAgentImageModule from "../agents/build-agent-image.js"; +import { resetBuiltImages } from "../agents/build-agent-image.js"; +import { + buildToolchainImage, + resetToolchainImages, +} 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 type { DockerRunner } from "../agents/types.js"; import { agent } from "./agent.js"; import type { AgentContext } from "../types.js"; type Context = AgentContext & { ensureCalls: number; ensuredImage?: string; + error?: Error; + packageRoot: string; + variant: string; + toolchainImage: string; }; -afterEach(() => { +const tempRoots: string[] = []; + +afterEach(async () => { + resetToolchainImages(); + resetBuiltImages(); vi.restoreAllMocks(); + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); describe("agent", () => { @@ -47,6 +66,45 @@ describe("agent", () => { }, }); + test("resolves a registered toolchain variant", { + given: { + stub_ensure_docker_image, + registered_toolchain_variant, + }, + when: { + applying_agent_with_variant, + }, + then: { + agent_is: agent_is("cursor"), + image_is_toolchain_variant, + 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", { + given: { + stub_ensure_docker_image, + }, + when: { + applying_agent_with_image_and_variant, + }, + then: { + error_mentions_mutual_exclusion, + }, + }); + test("resolves the claude agent by name", { given: { stub_ensure_docker_image, @@ -71,6 +129,59 @@ function stub_ensure_docker_image(this: Context) { }); } +async function registered_toolchain_variant(this: Context) { + this.variant = "agent-spec-node18"; + this.packageRoot = await mkdtemp(join(tmpdir(), "agent-gwt-agent-tc-")); + tempRoots.push(this.packageRoot); + const dockerfileRelative = join("docker", "agent.Dockerfile"); + await mkdir(join(this.packageRoot, "docker"), { recursive: true }); + await writeFile( + join(this.packageRoot, dockerfileRelative), + "FROM agent-gwt/cursor-cli:local\n", + ); + + const dockerRunner: DockerRunner = async (args) => { + if (args[0] === "image" && args[1] === "inspect") { + this.toolchainImage = args[2] ?? ""; + return { exitCode: 0, stdout: "[]", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + vi.spyOn(buildAgentImageModule, "buildAgentImage").mockResolvedValue(); + + await buildToolchainImage(this.variant, { + agent: "cursor", + dockerfileRelative, + packageRoot: this.packageRoot, + dockerRunner, + }); +} + +async function applying_agent_with_variant(this: Context) { + await agent({ name: "cursor", variant: this.variant, model: "auto" }).call(this); +} + +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_image_and_variant(this: Context) { + try { + await agent({ + name: "cursor", + image: "my-app/agent:local", + variant: "node18", + }).call(this); + } catch (error) { + this.error = error as Error; + } +} + function agent_is(name: AgentName) { return function (this: Context) { expect(this.agent).toBe(agentRegistry[name]); @@ -89,9 +200,27 @@ function image_is(image: string) { }; } +function image_is_toolchain_variant(this: Context) { + expect(this.image).toBe(this.toolchainImage); +} + function ensure_was_called_with(image: string) { return function (this: Context) { expect(this.ensureCalls).toBe(1); expect(this.ensuredImage).toBe(image); }; } + +function ensure_was_called_with_toolchain(this: Context) { + expect(this.ensureCalls).toBe(1); + 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"); +} diff --git a/src/given/agent.ts b/src/given/agent.ts index dd24925..501a4af 100644 --- a/src/given/agent.ts +++ b/src/given/agent.ts @@ -1,3 +1,4 @@ +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"; @@ -12,7 +13,7 @@ export function agent(options: ConfigureAgentOptions) { return async function (this: AgentContext): Promise { this.agent = resolved; - this.image = options.image ?? resolved.image; + this.image = resolveAgentImage(options, resolved.image); if (options.model !== undefined) { this.model = options.model; @@ -21,3 +22,29 @@ export function agent(options: ConfigureAgentOptions) { 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 index c762376..58591a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,12 @@ export { 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, From c59f101316815421d076d553becc2fc9368e3f7d Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Sun, 30 Aug 2026 10:59:25 -0500 Subject: [PATCH 2/7] ignore codegraph folder --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fbf4e4a..a72145c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ lib/ coverage/ *.tsbuildinfo .DS_Store -.wireit/ \ No newline at end of file +.wireit/ +.codegraph/ From 1976259eb8bf099d12edc07a9fc394f3c8e303d3 Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Sun, 30 Aug 2026 11:11:28 -0500 Subject: [PATCH 3/7] Revert "ignore codegraph folder" This reverts commit c59f101316815421d076d553becc2fc9368e3f7d. --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index a72145c..fbf4e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,4 @@ lib/ coverage/ *.tsbuildinfo .DS_Store -.wireit/ -.codegraph/ +.wireit/ \ No newline at end of file From 1a98dbb957abce7dc1d9afd57ec25f74d5a0bdc0 Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Sun, 30 Aug 2026 11:13:20 -0500 Subject: [PATCH 4/7] fix: harden toolchain variants for review feedback Include parent image ID in the digest, force docker rebuilds, pass AGENT_IMAGE as a build-arg with FROM validation, and use per-key registry files under the packageRoot digest. Update missing-image hints and README caveats; drop the unrelated .codegraph gitignore. --- README.md | 12 +- src/agents/build-agent-image.ts | 35 ++- src/agents/build-toolchain-image.spec.ts | 273 ++++++++++++++++------- src/agents/build-toolchain-image.ts | 161 ++++++++----- src/agents/ensure-image.spec.ts | 1 + src/agents/ensure-image.ts | 2 +- src/agents/run-error.spec.ts | 28 +++ src/agents/run-error.ts | 10 +- src/agents/types.ts | 4 + src/given/agent.spec.ts | 72 ++---- 10 files changed, 396 insertions(+), 202 deletions(-) diff --git a/README.md b/README.md index 29c9a35..87b689c 100644 --- a/README.md +++ b/README.md @@ -245,8 +245,9 @@ Install packages in a child image that derives from the agent image, register a ```dockerfile # docker/agent.Dockerfile -FROM agent-gwt/cursor-cli:local -# or: FROM agent-gwt/claude-code:local +ARG AGENT_IMAGE=agent-gwt/cursor-cli:local +FROM ${AGENT_IMAGE} +# or default: agent-gwt/claude-code:local # Official Arch packages (as root) RUN pacman -Sy --noconfirm --needed nodejs npm python rust \ @@ -275,7 +276,12 @@ agent({ name: "cursor", variant: "node18", model: "auto" }); // omit variant → stock agent-gwt/cursor-cli:local ``` -`buildToolchainImage` builds the agent image first, tags a per-repo content-hashed image (`agent-gwt/toolchain--:`), and registers the variant for the current working directory (in-memory and on disk under `/tmp/.agent-gwt/…`, so vitest `globalSetup` is visible to test workers). Changing the Dockerfile produces a new tag so Docker rebuilds; unchanged files reuse the cached image. `image` remains available as a low-level override and is mutually exclusive with `variant`. +`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. The base uses Arch/`pacman` (glibc). Alpine will not run the Cursor CLI. diff --git a/src/agents/build-agent-image.ts b/src/agents/build-agent-image.ts index cf2fbc1..fb64388 100644 --- a/src/agents/build-agent-image.ts +++ b/src/agents/build-agent-image.ts @@ -20,19 +20,22 @@ export async function buildDockerImage( ): Promise { const dockerRunner = options.dockerRunner ?? runDocker; const { packageRoot } = options; - const memoKey = `${image}::${options.dockerfileRelative}::${packageRoot}`; + 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).catch( - (error: unknown) => { - builtImages.delete(memoKey); - throw error; - }, - ); + const pending = doBuild(image, options.dockerfileRelative, dockerRunner, packageRoot, { + force, + buildArgs, + }).catch((error: unknown) => { + builtImages.delete(memoKey); + throw error; + }); builtImages.set(memoKey, pending); return pending; @@ -60,11 +63,14 @@ async function doBuild( dockerfileRelative: string, dockerRunner: DockerRunner, packageRoot: string, + options: { force: boolean; buildArgs: Record }, ): Promise { - const inspect = await dockerRunner(["image", "inspect", image]); - if (inspect.exitCode === 0) { - process.stderr.write(`[agent-gwt] Docker image ${image} already present\n`); - return; + 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); @@ -74,8 +80,13 @@ async function doBuild( 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, "-f", dockerfile, packageRoot], + ["build", "--progress=plain", "-t", image, ...buildArgs, "-f", dockerfile, packageRoot], { inheritOutput: true }, ); diff --git a/src/agents/build-toolchain-image.spec.ts b/src/agents/build-toolchain-image.spec.ts index f3ec477..e8a9cdf 100644 --- a/src/agents/build-toolchain-image.spec.ts +++ b/src/agents/build-toolchain-image.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, vi } from "vitest"; -import test from "vitest-gwt"; +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"; @@ -11,6 +11,7 @@ import { clearToolchainImageMemory, resetToolchainImages, resolveToolchainImage, + type BuildToolchainImageOptions, } from "./build-toolchain-image.js"; import { resetBuiltImages } from "./build-agent-image.js"; import type { DockerRunOptions, DockerRunner } from "./types.js"; @@ -20,90 +21,79 @@ type Context = { packageRoot: string; dockerfileRelative: string; dockerfileContents: string; - dockerRunner: DockerRunner; + parentImageId: string; + dockerRunner: DockerRunner | undefined; inspectCalls: number; buildCalls: number; - lastInspectImage: string | undefined; + lastInspectArgs: string[] | undefined; lastBuildArgs: string[] | undefined; lastBuildOptions: DockerRunOptions | undefined; error: Error | undefined; agentBuildCalls: number; firstImage: string | undefined; secondImage: string | undefined; + tempRoots: string[]; }; -const tempRoots: string[] = []; - -afterEach(async () => { - resetToolchainImages(); - resetBuiltImages(); - vi.restoreAllMocks(); - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); -}); - describe("buildToolchainImage", () => { + withAspect(reset_toolchain_state, cleanup_temp_roots); + test("builds a content-hashed tag and registers the variant", { given: { - reset_state, variant_name, package_with_dockerfile, stub_agent_build, - inspect_fails_then_build_succeeds, + inspect_parent_then_force_build, }, when: { building_toolchain, }, then: { agent_image_was_built, - inspect_was_called, + parent_image_was_inspected, build_was_called, - build_targeted_hashed_image, + build_targeted_hashed_image_with_agent_arg, variant_is_registered, }, }); - test("skips docker build when the hashed image already exists", { + test("force-rebuilds even when the hashed image already exists", { given: { - reset_state, variant_name, package_with_dockerfile, stub_agent_build, - inspect_succeeds, + parent_present_and_target_present_still_builds, }, when: { building_toolchain, }, then: { - inspect_was_called, - build_was_not_called, + build_was_called, variant_is_registered, }, }); - test("memoizes so a second build does not re-inspect", { + test("memoizes the docker build so a second call does not rebuild", { given: { - reset_state, variant_name, package_with_dockerfile, stub_agent_build, - inspect_succeeds, + inspect_parent_then_force_build, }, when: { building_toolchain_twice, }, then: { - inspect_called_once, - build_was_not_called, + build_called_once, }, }); test("uses a new tag when the Dockerfile content changes", { given: { - reset_state, variant_name, package_with_dockerfile, stub_agent_build, - inspect_fails_then_build_succeeds, + inspect_parent_then_force_build, }, when: { building_then_changing_dockerfile_and_rebuilding, @@ -113,13 +103,27 @@ describe("buildToolchainImage", () => { }, }); + 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: { - reset_state, variant_name, package_with_dockerfile, stub_agent_build, - inspect_fails_then_build_succeeds, + inspect_parent_then_force_build, }, when: { building_same_dockerfile_in_two_roots, @@ -131,7 +135,6 @@ describe("buildToolchainImage", () => { test("surfaces a clear error when the Dockerfile is missing", { given: { - reset_state, variant_name, package_without_dockerfile, stub_agent_build, @@ -144,13 +147,26 @@ describe("buildToolchainImage", () => { }, }); + 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: { - reset_state, variant_name, package_with_dockerfile, stub_agent_build, - inspect_succeeds, + inspect_parent_then_force_build, }, when: { building_then_clearing_memory_and_resolving, @@ -161,30 +177,53 @@ describe("buildToolchainImage", () => { }); }); -function reset_state() { +afterEach(() => { + vi.restoreAllMocks(); +}); + +function reset_toolchain_state(this: Context) { + resetToolchainImages(); + 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) { resetToolchainImages(); resetBuiltImages(); + await Promise.all(this.tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); } function variant_name(this: Context) { this.variant = "node18"; - this.inspectCalls = 0; - this.buildCalls = 0; - this.agentBuildCalls = 0; 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-")); - tempRoots.push(this.packageRoot); + 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-")); - tempRoots.push(this.packageRoot); + this.tempRoots.push(this.packageRoot); this.dockerfileRelative = join("docker", "missing.Dockerfile"); } @@ -194,23 +233,38 @@ function stub_agent_build(this: Context) { }); } -function inspect_succeeds(this: Context) { - this.dockerRunner = async (args) => { +function inspect_parent_then_force_build(this: Context) { + this.dockerRunner = async (args, options) => { if (args[0] === "image" && args[1] === "inspect") { this.inspectCalls += 1; - this.lastInspectImage = args[2]; - return { exitCode: 0, stdout: "[]", stderr: "" }; + 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 inspect_fails_then_build_succeeds(this: Context) { +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.lastInspectImage = args[2]; - return { exitCode: 1, stdout: "", stderr: "No such image" }; + 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; @@ -225,12 +279,15 @@ function inspect_fails_then_build_succeeds(this: Context) { } async function building_toolchain(this: Context) { - await buildToolchainImage(this.variant, { + const options: BuildToolchainImageOptions = { agent: "cursor", dockerfileRelative: this.dockerfileRelative, packageRoot: this.packageRoot, - dockerRunner: this.dockerRunner, - }); + }; + if (this.dockerRunner !== undefined) { + options.dockerRunner = this.dockerRunner; + } + await buildToolchainImage(this.variant, options); } async function building_toolchain_twice(this: Context) { @@ -240,11 +297,15 @@ async function building_toolchain_twice(this: Context) { async function building_toolchain_catching_error(this: Context) { try { - await buildToolchainImage(this.variant, { + 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; } @@ -252,7 +313,9 @@ async function building_toolchain_catching_error(this: Context) { async function building_then_changing_dockerfile_and_rebuilding(this: Context) { await building_toolchain.call(this); - this.firstImage = resolveToolchainImage("cursor", this.variant); + 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); @@ -261,34 +324,56 @@ async function building_then_changing_dockerfile_and_rebuilding(this: Context) { this.inspectCalls = 0; await building_toolchain.call(this); - this.secondImage = resolveToolchainImage("cursor", this.variant); + 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); + this.firstImage = resolveToolchainImage("cursor", this.variant, { + packageRoot: this.packageRoot, + }); const secondRoot = await mkdtemp(join(tmpdir(), "agent-gwt-tc-other-")); - tempRoots.push(secondRoot); + 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); + 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); + this.firstImage = resolveToolchainImage("cursor", this.variant, { + packageRoot: this.packageRoot, + }); clearToolchainImageMemory(); - this.secondImage = resolveToolchainImage("cursor", this.variant); -} - -function variant_resolved_from_disk(this: Context) { - expect(this.firstImage).toBeDefined(); - expect(this.secondImage).toBe(this.firstImage); + this.secondImage = resolveToolchainImage("cursor", this.variant, { + packageRoot: this.packageRoot, + }); } function agent_image_was_built(this: Context) { @@ -296,30 +381,34 @@ function agent_image_was_built(this: Context) { expect(buildAgentImageModule.buildAgentImage).toHaveBeenCalledWith("cursor"); } -function inspect_was_called(this: Context) { - expect(this.inspectCalls).toBe(1); -} - -function inspect_called_once(this: Context) { - expect(this.inspectCalls).toBe(1); +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_was_not_called(this: Context) { - expect(this.buildCalls).toBe(0); +function build_called_once(this: Context) { + expect(this.buildCalls).toBe(1); } -function build_targeted_hashed_image(this: Context) { - const expected = expectedImage(this.packageRoot, this.dockerfileContents); - expect(this.lastInspectImage).toBe(expected); +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, @@ -328,8 +417,8 @@ function build_targeted_hashed_image(this: Context) { } function variant_is_registered(this: Context) { - expect(resolveToolchainImage("cursor", this.variant)).toBe( - expectedImage(this.packageRoot, this.dockerfileContents), + expect(resolveToolchainImage("cursor", this.variant, { packageRoot: this.packageRoot })).toBe( + expectedImage(this.packageRoot, this.dockerfileContents, this.parentImageId), ); } @@ -337,7 +426,19 @@ 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)); + 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); } @@ -349,19 +450,29 @@ function tags_differ_by_repo_digest(this: Context) { 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 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 expectedImage(packageRoot: string, contents: string): string { +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); + const contentDigest = contentDigestOf(contents, parentId); return `agent-gwt/toolchain-cursor-${repoDigest}:${contentDigest}`; } -function contentDigestOf(contents: string): string { - return createHash("sha256").update(contents).digest("hex").slice(0, 12); +function contentDigestOf(contents: string, parentId: string): string { + return createHash("sha256").update(`${contents}\n${parentId}`).digest("hex").slice(0, 12); } function tagOf(image: string): string { diff --git a/src/agents/build-toolchain-image.ts b/src/agents/build-toolchain-image.ts index 33d08eb..bf9aa4e 100644 --- a/src/agents/build-toolchain-image.ts +++ b/src/agents/build-toolchain-image.ts @@ -1,72 +1,85 @@ -import { createHash } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, + renameSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve, dirname } from "node:path"; +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; - /** Defaults to `process.cwd()` (consuming repo). */ + /** + * 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 function resetToolchainImages(): void { - const file = registryFilePath(); - const persisted = readRegistryFile(); - for (const key of toolchainImages.keys()) { - delete persisted[key]; - } - toolchainImages.clear(); +export type ResolveToolchainImageOptions = { + /** Defaults to `process.cwd()` — must match the `packageRoot` used at build time. */ + packageRoot?: string; +}; - if (Object.keys(persisted).length === 0) { - if (existsSync(file)) { - rmSync(file, { force: true }); - } - return; +export function resetToolchainImages(options: ResolveToolchainImageOptions = {}): void { + toolchainImages.clear(); + const dir = registryDir(options.packageRoot ?? process.cwd()); + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); } - - writeRegistryFile(persisted); } -/** Clears the in-process cache without deleting the persisted registry file. */ +/** 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 cwd-scoped registry file + * 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): string | undefined { +export function resolveToolchainImage( + agent: AgentName, + variant: string, + options: ResolveToolchainImageOptions = {}, +): string | undefined { + const packageRoot = resolve(options.packageRoot ?? process.cwd()); const key = registryKey(agent, variant); - const cached = toolchainImages.get(key); + const cacheKey = `${digest(packageRoot)}::${key}`; + const cached = toolchainImages.get(cacheKey); if (cached !== undefined) { return cached; } - const persisted = readRegistryFile(); - const image = persisted[key]; - if (image === undefined) { + 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(key, image); + toolchainImages.set(cacheKey, image); return image; } @@ -76,64 +89,104 @@ export async function buildToolchainImage( ): 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 contentDigest = digest(readFileSync(dockerfile)); + 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(options.agent, variant, image); + registerToolchainImage(packageRoot, options.agent, variant, image); } -function registerToolchainImage(agent: AgentName, variant: string, image: string): void { +function registerToolchainImage( + packageRoot: string, + agent: AgentName, + variant: string, + image: string, +): void { const key = registryKey(agent, variant); - toolchainImages.set(key, image); - - const persisted = readRegistryFile(); - persisted[key] = image; - writeRegistryFile(persisted); + 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 registryKey(agent: AgentName, variant: string): string { - return `${agent}::${variant}`; -} +function assertDockerfileUsesAgentImage(contents: string, agentImage: string): void { + const fromLine = contents + .split("\n") + .map((line) => line.trim()) + .find((line) => /^FROM\s+/i.test(line)); -function registryFilePath(): string { - return join(tmpdir(), ".agent-gwt", "toolchains", digest(resolve(process.cwd())), "variants.json"); + 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}`, + ); + } } -function readRegistryFile(): Record { - const file = registryFilePath(); - if (!existsSync(file)) { - return {}; +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}`, + ); } - try { - const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return {}; - } - return parsed as Record; - } catch { - return {}; + 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 writeRegistryFile(entries: Record): void { - const file = registryFilePath(); - mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, `${JSON.stringify(entries, null, 2)}\n`); +function registryEntryPath(packageRoot: string, agent: AgentName, variant: string): string { + const safeVariant = variant.replace(/[^a-zA-Z0-9._-]/g, "_"); + return join(registryDir(packageRoot), `${agent}--${safeVariant}`); } function digest(value: string | Buffer): string { diff --git a/src/agents/ensure-image.spec.ts b/src/agents/ensure-image.spec.ts index 426e5a1..2425215 100644 --- a/src/agents/ensure-image.spec.ts +++ b/src/agents/ensure-image.spec.ts @@ -97,4 +97,5 @@ function build_was_not_called(this: Context) { 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"); } diff --git a/src/agents/ensure-image.ts b/src/agents/ensure-image.ts index f87b5eb..38bf844 100644 --- a/src/agents/ensure-image.ts +++ b/src/agents/ensure-image.ts @@ -12,6 +12,6 @@ export async function ensureDockerImage( } throw new Error( - `Docker image ${image} not found. Call buildAgentImage(...) from vitest globalSetup (or build the image manually) before running agent tests.`, + `Docker image ${image} not found. Call buildAgentImage(...) or buildToolchainImage(...) from vitest globalSetup (or build the image manually) before running agent tests.`, ); } diff --git a/src/agents/run-error.spec.ts b/src/agents/run-error.spec.ts index fe39496..47cd484 100644 --- a/src/agents/run-error.spec.ts +++ b/src/agents/run-error.spec.ts @@ -28,6 +28,15 @@ 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, @@ -60,6 +69,19 @@ function building_error_for_missing_image(this: Context) { }); } +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", + }, + }); +} + function building_error_with_detail(this: Context) { this.error = agentRunError({ agent: "Claude", @@ -88,6 +110,12 @@ function message_has_build_hint(this: Context) { 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); +} + function message_has_detail_headline(this: Context) { expect(this.error.message).toContain("Claude agent exited with code 1: api_error: Not logged in"); } diff --git a/src/agents/run-error.ts b/src/agents/run-error.ts index 1dd96b5..9629fc4 100644 --- a/src/agents/run-error.ts +++ b/src/agents/run-error.ts @@ -21,8 +21,16 @@ 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") - ? `\nDocker image ${options.image} not found; build it with buildAgentImage("${options.name}") in vitest globalSetup.` + ? missingImageHint(options) : ""; 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/src/agents/types.ts b/src/agents/types.ts index 0473556..82c8ee7 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -76,4 +76,8 @@ 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; }; diff --git a/src/given/agent.spec.ts b/src/given/agent.spec.ts index 6bd2cbe..bfce72e 100644 --- a/src/given/agent.spec.ts +++ b/src/given/agent.spec.ts @@ -1,42 +1,25 @@ -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 * as buildAgentImageModule from "../agents/build-agent-image.js"; -import { resetBuiltImages } from "../agents/build-agent-image.js"; -import { - buildToolchainImage, - resetToolchainImages, -} from "../agents/build-toolchain-image.js"; +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 type { DockerRunner } from "../agents/types.js"; import { agent } from "./agent.js"; import type { AgentContext } from "../types.js"; type Context = AgentContext & { ensureCalls: number; - ensuredImage?: string; - error?: Error; - packageRoot: string; + ensuredImage: string | undefined; + error: Error | undefined; variant: string; toolchainImage: string; }; -const tempRoots: string[] = []; - -afterEach(async () => { - resetToolchainImages(); - resetBuiltImages(); - vi.restoreAllMocks(); - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); -}); - describe("agent", () => { + withAspect(reset_agent_test_state, undefined); + test("sets agent, model, and image from the resolved agent", { given: { stub_ensure_docker_image, @@ -121,6 +104,13 @@ describe("agent", () => { }); }); +function reset_agent_test_state(this: Context) { + vi.restoreAllMocks(); + this.ensureCalls = 0; + this.ensuredImage = undefined; + this.error = undefined; +} + function stub_ensure_docker_image(this: Context) { this.ensureCalls = 0; vi.spyOn(ensureImageModule, "ensureDockerImage").mockImplementation(async (image) => { @@ -129,32 +119,14 @@ function stub_ensure_docker_image(this: Context) { }); } -async function registered_toolchain_variant(this: Context) { - this.variant = "agent-spec-node18"; - this.packageRoot = await mkdtemp(join(tmpdir(), "agent-gwt-agent-tc-")); - tempRoots.push(this.packageRoot); - const dockerfileRelative = join("docker", "agent.Dockerfile"); - await mkdir(join(this.packageRoot, "docker"), { recursive: true }); - await writeFile( - join(this.packageRoot, dockerfileRelative), - "FROM agent-gwt/cursor-cli:local\n", - ); - - const dockerRunner: DockerRunner = async (args) => { - if (args[0] === "image" && args[1] === "inspect") { - this.toolchainImage = args[2] ?? ""; - return { exitCode: 0, stdout: "[]", stderr: "" }; +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; } - throw new Error(`unexpected docker args: ${args.join(" ")}`); - }; - - vi.spyOn(buildAgentImageModule, "buildAgentImage").mockResolvedValue(); - - await buildToolchainImage(this.variant, { - agent: "cursor", - dockerfileRelative, - packageRoot: this.packageRoot, - dockerRunner, + return undefined; }); } From cba3710f297d06090544e0579d7c4da9776c65f3 Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Sun, 30 Aug 2026 11:19:31 -0500 Subject: [PATCH 5/7] lint and include codegraph gitignore --- .gitignore | 3 ++- src/agents/build-toolchain-image.spec.ts | 8 +++++++- src/agents/build-toolchain-image.ts | 9 +-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index fbf4e4a..a72145c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ lib/ coverage/ *.tsbuildinfo .DS_Store -.wireit/ \ No newline at end of file +.wireit/ +.codegraph/ diff --git a/src/agents/build-toolchain-image.spec.ts b/src/agents/build-toolchain-image.spec.ts index e8a9cdf..f807f9b 100644 --- a/src/agents/build-toolchain-image.spec.ts +++ b/src/agents/build-toolchain-image.spec.ts @@ -198,9 +198,15 @@ function reset_toolchain_state(this: Context) { } async function cleanup_temp_roots(this: Context) { + const roots = [...this.tempRoots]; resetToolchainImages(); + for (const root of roots) { + resetToolchainImages({ packageRoot: root }); + } resetBuiltImages(); - await Promise.all(this.tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + await Promise.all( + this.tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); } function variant_name(this: Context) { diff --git a/src/agents/build-toolchain-image.ts b/src/agents/build-toolchain-image.ts index bf9aa4e..89c40ec 100644 --- a/src/agents/build-toolchain-image.ts +++ b/src/agents/build-toolchain-image.ts @@ -1,12 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import { - existsSync, - mkdirSync, - readFileSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; From 2c2c6f0c3d0118f4da58bee29fa4d2adc8c8795d Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Sun, 30 Aug 2026 11:24:33 -0500 Subject: [PATCH 6/7] fix: hash toolchain registry keys and tighten follow-up review items Use a digest of agent::variant for on-disk filenames so variants cannot collide, clear temp packageRoot registries in aspect hooks, note first-FROM-only validation, and cover force/buildArgs on buildDockerImage. --- README.md | 2 +- src/agents/build-agent-image.spec.ts | 82 ++++++++++++++++++++++++ src/agents/build-toolchain-image.spec.ts | 53 +++++++++++++-- src/agents/build-toolchain-image.ts | 5 +- 4 files changed, 135 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 87b689c..f1972ea 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ agent({ name: "cursor", variant: "node18", model: "auto" }); 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. +- 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. diff --git a/src/agents/build-agent-image.spec.ts b/src/agents/build-agent-image.spec.ts index 86ea2cc..1bd6818 100644 --- a/src/agents/build-agent-image.spec.ts +++ b/src/agents/build-agent-image.spec.ts @@ -102,6 +102,38 @@ describe("buildDockerImage", () => { 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", () => { @@ -238,6 +270,24 @@ function inspect_fails_then_build_fails(this: BuildContext) { }; } +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, @@ -246,6 +296,24 @@ async function building_image(this: BuildContext) { }); } +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); @@ -280,6 +348,20 @@ function build_uses_plain_progress_and_streams_output(this: BuildContext) { 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"); diff --git a/src/agents/build-toolchain-image.spec.ts b/src/agents/build-toolchain-image.spec.ts index f807f9b..abbc477 100644 --- a/src/agents/build-toolchain-image.spec.ts +++ b/src/agents/build-toolchain-image.spec.ts @@ -175,6 +175,21 @@ describe("buildToolchainImage", () => { 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(() => { @@ -182,7 +197,11 @@ afterEach(() => { }); 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; @@ -198,15 +217,14 @@ function reset_toolchain_state(this: Context) { } async function cleanup_temp_roots(this: Context) { - const roots = [...this.tempRoots]; + const roots = [...(this.tempRoots ?? [])]; resetToolchainImages(); for (const root of roots) { resetToolchainImages({ packageRoot: root }); } resetBuiltImages(); - await Promise.all( - this.tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), - ); + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + this.tempRoots = []; } function variant_name(this: Context) { @@ -382,6 +400,20 @@ async function building_then_clearing_memory_and_resolving(this: Context) { }); } +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"); @@ -461,6 +493,19 @@ function variant_resolved_from_disk(this: Context) { 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)); diff --git a/src/agents/build-toolchain-image.ts b/src/agents/build-toolchain-image.ts index 89c40ec..dbbfe1a 100644 --- a/src/agents/build-toolchain-image.ts +++ b/src/agents/build-toolchain-image.ts @@ -128,6 +128,7 @@ function registerToolchainImage( } 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()) @@ -178,8 +179,8 @@ function registryDir(packageRoot: string): string { } function registryEntryPath(packageRoot: string, agent: AgentName, variant: string): string { - const safeVariant = variant.replace(/[^a-zA-Z0-9._-]/g, "_"); - return join(registryDir(packageRoot), `${agent}--${safeVariant}`); + // 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 { From c39c52863c4c3192131db76b8ed9ed6ab092eb61 Mon Sep 17 00:00:00 2001 From: Eric Siebeneich Date: Sun, 30 Aug 2026 11:29:23 -0500 Subject: [PATCH 7/7] clearer readme instructions for toolchain image --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f1972ea..8baf8b1 100644 --- a/README.md +++ b/README.md @@ -249,13 +249,10 @@ ARG AGENT_IMAGE=agent-gwt/cursor-cli:local FROM ${AGENT_IMAGE} # or default: agent-gwt/claude-code:local -# Official Arch packages (as root) -RUN pacman -Sy --noconfirm --needed nodejs npm python rust \ - && pacman -Scc --noconfirm - -# AUR packages (build-time only — yay refuses root) +# 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 some-aur-package +RUN yay -S --noconfirm --needed nodejs npm python rust some-aur-package USER root ```