Skip to content
Closed
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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,16 +290,28 @@ Suite-level `withAspect` **before** hook that:
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

`timeoutMs` (optional) cancels a run that takes longer, see [Cancellation](#cancellation-and-timeouts).

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 …] -- <prompt>`
- 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 …] -- <prompt>`
- Cursor: `docker run` with credentials-only mount + `agent -p --force --output-format json [--model …]` with the prompt on stdin
- 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 …]` with the prompt on stdin
3. Sets `this.agentResult` to the parsed JSON

## Cancellation and timeouts

Every `docker run` gets a unique `--name`, and a run can be cancelled; cancelling force-removes the container so the agent stops working (and billing) at once. Three things cancel a run:

- `agent({ timeoutMs })`: the run is cancelled after that many milliseconds and `executing_the_agent` throws `agent run exceeded <n> ms`.
- The test finishing first, for example on Vitest's own timeout: the container is removed and no second error is reported.
- The process exiting while a run is still tracked: a best-effort `docker rm -f` on the way out. The signal handlers assume Vitest's default `forks` pool; under `threads` only the exit hook applies.

The prompt is sent to the container on stdin rather than as a command-line argument, so there is no size limit (Linux caps one argument at 128 KB) and the prompt never appears in `ps` output on the host.

## Exports

| Export | Role |
Expand Down
80 changes: 80 additions & 0 deletions e2e/cancel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { execFileSync } from "node:child_process";
import { describe, expect } from "vitest";
import test from "vitest-gwt";
import { BASE_IMAGE, runDocker } from "../src/index.js";

type Context = {
name: string;
controller: AbortController;
stdout: string;
};

function hasBaseImage(): boolean {
try {
execFileSync("docker", ["image", "inspect", BASE_IMAGE], { stdio: "ignore" });
return true;
} catch {
return false;
}
}

describe.skipIf(!hasBaseImage())("cancellation and stdin against real docker", () => {
test("an aborted run is rejected and its container is gone", {
given: {
a_named_container_and_a_controller_that_aborts_shortly,
},
when: {
running_a_sleeping_container,
},
then: {
expect_error: run_was_cancelled_and_container_is_gone,
},
});

test("a 300 KB prompt reaches the container on stdin", {
when: {
piping_a_large_stdin_through_wc,
},
then: {
byte_count_matches,
},
});
});

function a_named_container_and_a_controller_that_aborts_shortly(this: Context) {
this.name = `agent-gwt-e2e-cancel-${Date.now()}`;
this.controller = new AbortController();
setTimeout(() => this.controller.abort(new Error("test timeout")), 1_000);
}

async function running_a_sleeping_container(this: Context) {
await runDocker(["run", "--rm", "--name", this.name, BASE_IMAGE, "sleep", "60"], {
signal: this.controller.signal,
containerName: this.name,
});
}

async function run_was_cancelled_and_container_is_gone(this: Context, error: Error) {
expect(error.message).toContain("docker run cancelled: test timeout");
const listed = await runDocker([
"ps",
"-a",
"--filter",
`name=${this.name}`,
"--format",
"{{.Names}}",
]);
expect(listed.stdout.trim()).toBe("");
}

async function piping_a_large_stdin_through_wc(this: Context) {
const prompt = "x".repeat(300 * 1024);
const result = await runDocker(["run", "--rm", "-i", BASE_IMAGE, "sh", "-c", "wc -c"], {
stdin: prompt,
});
this.stdout = result.stdout;
}

function byte_count_matches(this: Context) {
expect(this.stdout.trim()).toBe(String(300 * 1024));
}
6 changes: 6 additions & 0 deletions src/agents/_containerName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { randomBytes } from "node:crypto";

/** A unique `--name` per run, so the container can be found and force-removed. */
export function containerName(agent: string): string {
return `agent-gwt-${agent}-${randomBytes(4).toString("hex")}`;
}
32 changes: 32 additions & 0 deletions src/agents/_containerRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { removeContainer } from "./_removeContainer.js";

const running = new Set<string>();
let hooksInstalled = false;

/** Track a running container so process exit or a termination signal can force-remove it. */
export function trackContainer(name: string): void {
running.add(name);

if (!hooksInstalled) {
hooksInstalled = true;
process.once("exit", removeAll);
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.once(signal, () => {
removeAll();
// re-raise so the process still terminates
process.kill(process.pid, signal);
});
}
}
}

export function untrackContainer(name: string): void {
running.delete(name);
}

function removeAll(): void {
for (const container of running) {
removeContainer(container);
}
running.clear();
}
6 changes: 6 additions & 0 deletions src/agents/_removeContainer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { spawnSync } from "node:child_process";

/** Synchronous so it also works from an exit handler and is not cut short by worker teardown. */
export function removeContainer(name: string): void {
spawnSync("docker", ["rm", "-f", name], { stdio: "ignore" });
}
93 changes: 93 additions & 0 deletions src/agents/_runProcess.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect } from "vitest";
import test from "vitest-gwt";

import { runProcess } from "./_runProcess.js";
import type { DockerRunResult } from "./types.js";

type Context = {
result: DockerRunResult;
controller: AbortController;
abortCalls: number;
};

const ECHO_STDIN =
"process.stdin.on('data', (d) => process.stdout.write(d)); process.stdin.on('end', () => process.exit(0));";
const HANG = "setTimeout(() => {}, 30_000);";

describe("runProcess", () => {
test("feeds stdin to the process and captures its output", {
when: {
running_an_echo_process_with_stdin,
},
then: {
stdout_is_the_stdin,
exit_code_is_zero,
},
});

test("kills the process, runs onAbort, and rejects when the signal aborts", {
given: {
a_controller_that_aborts_shortly,
},
when: {
running_a_hanging_process,
},
then: {
expect_error: error_says_cancelled_and_on_abort_ran,
},
});

test("rejects at once when the signal is already aborted", {
given: {
an_already_aborted_controller,
},
when: {
running_a_hanging_process,
},
then: {
expect_error: error_says_cancelled_with_the_reason,
},
});
});

async function running_an_echo_process_with_stdin(this: Context) {
this.result = await runProcess(process.execPath, ["-e", ECHO_STDIN], { stdin: "hello, stdin" });
}

function stdout_is_the_stdin(this: Context) {
expect(this.result.stdout).toBe("hello, stdin");
}

function exit_code_is_zero(this: Context) {
expect(this.result.exitCode).toBe(0);
}

function a_controller_that_aborts_shortly(this: Context) {
this.controller = new AbortController();
this.abortCalls = 0;
setTimeout(() => this.controller.abort(new Error("took too long")), 10);
}

function an_already_aborted_controller(this: Context) {
this.controller = new AbortController();
this.abortCalls = 0;
this.controller.abort(new Error("took too long"));
}

async function running_a_hanging_process(this: Context) {
await runProcess(process.execPath, ["-e", HANG], {
signal: this.controller.signal,
onAbort: () => {
this.abortCalls += 1;
},
});
}

function error_says_cancelled_with_the_reason(this: Context, error: Error) {
expect(error.message).toContain("run cancelled: took too long");
}

function error_says_cancelled_and_on_abort_ran(this: Context, error: Error) {
error_says_cancelled_with_the_reason.call(this, error);
expect(this.abortCalls).toBe(1);
}
84 changes: 84 additions & 0 deletions src/agents/_runProcess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { spawn } from "node:child_process";

import type { DockerRunOptions, DockerRunResult } from "./types.js";

type RunProcessOptions = Omit<DockerRunOptions, "containerName"> & {
/** Called once on abort, before the process is killed. */
onAbort?: () => void;
};

export function runProcess(
command: string,
args: string[],
options: RunProcessOptions = {},
): Promise<DockerRunResult> {
return new Promise((resolve, reject) => {
const { signal } = options;

if (signal?.aborted === true) {
reject(cancelledError(command, signal.reason));
return;
}

const child = spawn(command, args, {
stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
env: { ...process.env, ...options.env },
});

let stdout = "";
let stderr = "";
let cancelled: Error | undefined;
const inheritOutput = options.inheritOutput === true;

child.stdout?.on("data", (chunk: Buffer | string) => {
const text = chunk.toString();
stdout += text;
if (inheritOutput) {
process.stdout.write(text);
}
});
child.stderr?.on("data", (chunk: Buffer | string) => {
const text = chunk.toString();
stderr += text;
if (inheritOutput) {
process.stderr.write(text);
}
});

if (options.stdin !== undefined && child.stdin !== null) {
// Ignore EPIPE: the child may exit before draining stdin.
child.stdin.on("error", () => undefined);
child.stdin.end(options.stdin);
}

const onAbort = () => {
cancelled = cancelledError(command, signal?.reason);
try {
options.onAbort?.();
} finally {
child.kill("SIGKILL");
}
};
signal?.addEventListener("abort", onAbort, { once: true });

child.on("error", (error: Error) => {
signal?.removeEventListener("abort", onAbort);
const hint = command === "docker" ? " Is Docker installed and running?" : "";
reject(new Error(`Failed to start ${command}: ${error.message}.${hint}`));
});

child.on("close", (exitCode: number | null) => {
signal?.removeEventListener("abort", onAbort);
if (cancelled !== undefined) {
reject(cancelled);
return;
}
resolve({ exitCode, stdout, stderr });
});
});
}

function cancelledError(command: string, reason: unknown): Error {
const detail = reason instanceof Error ? reason.message : String(reason);
return new Error(`${command} run cancelled: ${detail}`);
}
11 changes: 5 additions & 6 deletions src/agents/claude/_buildDockerArgs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const envFlagValues = (args: string[]) => args.filter((arg, i) => args[i - 1] ==
const volumeMounts = (args: string[]) => args.filter((arg, i) => args[i - 1] === "-v");

describe("buildClaudeDockerArgs", () => {
test("runs as host user with an OAuth token forwarded by name only", {
test("runs as host user, named, with stdin open and an OAuth token forwarded by name only", {
given: {
oauth_token_credentials,
},
Expand Down Expand Up @@ -93,8 +93,8 @@ 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",
containerName: "agent-gwt-claude-test",
credentials: this.credentials,
uid: 1000,
gid: 1000,
Expand All @@ -104,8 +104,8 @@ function building_docker_args(this: Context) {
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",
containerName: "agent-gwt-claude-test",
credentials: this.credentials,
uid: 1000,
gid: 1000,
Expand Down Expand Up @@ -174,13 +174,12 @@ function invokes_claude_headless_with_json_output(this: Context) {
expect(this.args).toContain("--output-format");
expect(this.args).toContain("json");
expect(this.args).toContain("--dangerously-skip-permissions");
expect(this.args.at(-2)).toBe("--");
expect(this.args.at(-1)).toBe("Create a README");
expect(this.args).toContain("-i");
expect(this.args[this.args.indexOf("--name") + 1]).toBe("agent-gwt-claude-test");
}

function includes_model_flag(this: Context) {
const modelIndex = this.args.indexOf("--model");
expect(modelIndex).toBeGreaterThan(-1);
expect(this.args[modelIndex + 1]).toBe("sonnet");
expect(this.args.indexOf("--")).toBeGreaterThan(modelIndex);
}
Loading
Loading