Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ lib/
coverage/
*.tsbuildinfo
.DS_Store
.wireit/
.wireit/
.codegraph/
42 changes: 24 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,49 +241,54 @@ 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
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 \
&& 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
```

```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, passes `--build-arg AGENT_IMAGE=…`, tags a per-repo image (`agent-gwt/toolchain-<agent>-<repoDigest>:<digest>`), and registers the variant under `/tmp/.agents-gwt/toolchains/<packageRootDigest>/` (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` 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)`.

Expand All @@ -300,10 +305,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) |
Expand Down
82 changes: 82 additions & 0 deletions src/agents/build-agent-image.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
35 changes: 23 additions & 12 deletions src/agents/build-agent-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,22 @@ export async function buildDockerImage(
): Promise<void> {
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;
Expand Down Expand Up @@ -60,11 +63,14 @@ async function doBuild(
dockerfileRelative: string,
dockerRunner: DockerRunner,
packageRoot: string,
options: { force: boolean; buildArgs: Record<string, string> },
): Promise<void> {
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);
Expand All @@ -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 },
);

Expand Down
Loading