Files
shockbot/test/run.ts
T
Colin McDonnell 76879b27ec docker testing rewrite: bake the image, drop the allowlist, kill the quoting (#750)
* docker testing rewrite: bake the image, drop the allowlist, kill the quoting

- new `pnpm gha <script>` wrapper. one entry point for running any node
  script in the GHA-like container; replaces the runtime apt-get +
  useradd + chown ceremony in `action/utils/docker.ts`.
- `action/Dockerfile` bakes ubuntu:24.04 + node 24 + gh + jq + sudo +
  testuser at uid 1000. `action/docker-entrypoint.sh` remaps to the host
  uid/gid and `exec`s the requested command — no `bash -c` nesting, no
  `escapeForDoubleQuotes`.
- env passthrough: full `process.env` (+ `.env` via dotenv) flows through
  `--env-file`, multi-line values via `-e` fallback. drops
  `EnvFilterMode` / `testEnvAllowList`.
- image rebuild is content-hash gated on Dockerfile + entrypoint; volume
  is versioned by hash so a stale `node_modules` cache from an old image
  can't poison a new one.
- `action/play.ts` slimmed to a CLI; `run()` extracted to
  `action/utils/runFixture.ts`. drops the `--local` / `PLAY_LOCAL` dual
  mode in favor of explicit `play:local` / `runtest:local` scripts.
- `action/test/run.ts` no longer self-relaunches into docker — that's
  `gha`'s job now.
- `action/test/coverage.ts` `ALWAYS_RUN_ALL` updated to track the new
  files.
- `wiki/docker.md` rewritten (243 → 105 lines). `wiki/action-tests.md`,
  `wiki/billing.md`, `wiki/adversarial.md`, `README.md`, `AGENTS.md` all
  updated to drop `--local` / `PLAY_LOCAL` references.

verified end-to-end: `pnpm play` runs the default fixture against
pullfrog/scratch, exit 0; `sudo unshare --pid` still works inside the
container; `pnpm runtest` boots through the wrapper.

* gha: address review feedback + 3 related issues found locally

review-flagged:
- bare `pnpm gha --build` now builds the image and exits 0 (was
  printing help and exiting 1 — docs claimed it was a valid standalone)
- `initVolumeOwnership` skipped when the named volume already exists;
  saves the ~240ms `docker run … chown` on every warm invocation
- `GIT_SSH_COMMAND` gate widened to any `id_*` private key (was hard-
  coded to `id_rsa`, leaving ed25519-only linux contributors with the
  default ssh config). dropped `-i` so ssh picks whichever key exists
- new `action/.dockerignore` — partial mitigation noted: BuildKit
  (default since docker 23) only sends files referenced by the
  Dockerfile (~42B in practice), so the perf concern is mostly
  hypothetical. file is still worth keeping for `DOCKER_BUILDKIT=0`
  fallback and as documented intent for future `COPY . .` additions

related issues found while validating locally:
- `parseArgs` now stops flag-parsing at the first positional (or
  literal `--`); `pnpm gha test/run.ts --build` previously
  intercepted `--build` as a gha flag instead of forwarding to
  `test/run.ts`
- new `pnpm gha --clean` command prunes orphan `pullfrog-gha:*`
  images and `pullfrog-gha-node-modules-*` volumes whose hash
  doesn't match the current Dockerfile (each Dockerfile/entrypoint
  edit creates a fresh hash and orphans the prior pair, ~600MB +
  ~200MB each — without a cleaner they accumulate silently)
- `--shell` without a TTY now fails fast with an actionable message
  before docker is invoked, instead of producing the confusing
  `the input device is not a TTY` from docker run

wiki updated: documents `--clean`, the parseArgs passthrough rule,
and a new "Reclaiming disk" section.

* gha: fidelity, flexibility, and signal-safety improvements

investigated local fidelity vs the real GHA ubuntu-24.04 runner and
addressed the gaps that have actually bitten contributors or could.

fidelity (image now matches GHA closer):
- bake build-essential, wget, xz-utils, file alongside the existing
  toolset. gh, jq, git, python3, sudo, ssh, build-essential, wget,
  xz, file, unzip, curl all present. native module builds (node-gyp,
  any package missing arm64 prebuilts) now work; common agent shell
  calls don't hit ENOENT
- `host.docker.internal:host-gateway` flag wires the host into the
  container's DNS on linux (macOS Docker Desktop bakes it in). lets
  scripts that hit a local dev server use `API_URL=http://host.docker.
  internal:3100` and work identically on both platforms
- `--init` makes tini PID 1, fixing signal forwarding during the
  pre-exec warmup window (Ctrl-C was previously taking up to 10s to
  tear down because bash-as-PID-1 swallowed the signal)
- pnpm version is correctly pinned via the workspace's
  `packageManager` field — corepack resolves it at install time;
  verified via the new `--doctor` command

flexibility (new affordances):
- `pnpm gha --doctor` runs an inside-the-container fidelity audit:
  os + arch + node/pnpm/python versions, version snapshots of every
  baked tool, env vars (CI, HOME, TMPDIR), uid/gid, and the
  host.docker.internal resolution. useful for "works in CI fails
  locally" or vice versa
- `pnpm gha --build --no-cache` busts the docker layer cache when
  an apt mirror, base image, or external download has changed
  upstream
- entrypoint's `pnpm install` warmup is now wrapped in a `flock` on
  a file in the shared node_modules volume — concurrent `pnpm gha`
  invocations (e.g. play in one terminal, runtest in another)
  serialize their install instead of racing

docs:
- new "Gaps (known)" section in wiki/docker.md explicitly calling
  out the things this system can't do yet, including the missing
  `uses: ./action` semantics gap that
  `.github/workflows/action-gha-e2e-adhoc.yml` currently fills via
  GHA only (designing a local `pnpm gha-action <fixture>` is on the
  roadmap), service containers, parallel-run sharing, and arch
  differences (arm64 vs amd64)

* docs: audit + corrections after testing fronts

self-audit pass for stale references and incomplete pointers:

- wiki/browser.md: `Docker (node:24)` → `pnpm gha container (ubuntu:24.04)`.
  the substance was right (chrome not preinstalled) but the base image
  reference was stale.
- wiki/docker.md: the "Permission errors" troubleshooting line claimed
  the node_modules volume is chowned on every run; now correctly says
  "owned by the host uid on first creation; warm runs skip the chown"
  to match the actual behavior after the initVolumeOwnership fix.
- wiki/action-tests.md: `API_URL` env-var doc now mentions BOTH paths
  (`localhost:` from play:local, `host.docker.internal:` from inside
  the container). Proxy/router recipe now shows both invocations
  side-by-side instead of saying "must use play:local".
- wiki/billing.md: same dual-recipe update for the loop-including-the-
  action proxy walkthrough.
- gha.ts header: expanded the usage block to include --clean / --doctor /
  --no-cache / --shell-TTY, added the host.docker.internal note, and
  pointed at wiki/docker.md for design rationale.

self-document check: a future agent landing on this code can answer
"how do I run a fixture / debug in shell / add a tool / diagnose
fidelity / reach a local dev server" purely from gha.ts header +
wiki/docker.md without spelunking through the entrypoint or git
history.
2026-05-16 03:12:25 +00:00

514 lines
16 KiB
TypeScript

import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { config } from "dotenv";
import { killTrackedChildren, setSignalHandler } from "../utils/subprocess.ts";
import {
type AgentResult,
agents,
getPrefix,
printResults,
printSingleValidation,
runAgentStreaming,
type TestRunnerOptions,
type TestTag,
type ValidationResult,
validateResult,
} from "./utils.ts";
/**
* unified test runner for all agent tests.
*
* usage: node test/run.ts [filters...]
*
* filters can be test names, tags, or agent names:
* node test/run.ts # run all tests (excludes adhoc-tagged tests)
* node test/run.ts smoke # run tests named "smoke" or tagged "smoke"
* node test/run.ts opencode # run all tests for opencode only
* node test/run.ts security # run all tests tagged "security"
* node test/run.ts agnostic # run all agnostic-tagged tests (with opencode)
* node test/run.ts adhoc # run all adhoc-tagged tests
* node test/run.ts smoke opencode # run smoke tests for opencode only
*
* special tags:
* - "agnostic": runs with opencode only, excluded when filtering by agent
* - "adhoc": excluded from default runs, must be explicitly requested
*
* runs in-process. for the GHA-like Linux container, invoke via
* `pnpm gha test/run.ts […]` (the `runtest` package script does this).
*/
const __dirname = dirname(fileURLToPath(import.meta.url));
export const actionDir = join(__dirname, "..");
config({ path: join(actionDir, ".env") });
config({ path: join(actionDir, "..", ".env") });
const mcpPortBase = 49000;
let nextMcpPort = mcpPortBase;
function allocateMcpPort(): number {
const port = nextMcpPort;
nextMcpPort += 1;
return port;
}
type TestInfo = {
name: string;
config: TestRunnerOptions;
};
type CancelState = {
canceled: boolean;
signal: NodeJS.Signals | null;
};
type TestModule = {
test?: TestRunnerOptions;
tests?: Record<string, TestRunnerOptions>;
};
// load all tests from all directories
async function loadAllTests(): Promise<TestInfo[]> {
const testInfos: TestInfo[] = [];
const dirs = ["crossagent", "agnostic", "adhoc"];
for (const dir of dirs) {
const dirPath = join(__dirname, dir);
if (!existsSync(dirPath)) continue;
const files = readdirSync(dirPath).filter((f) => f.endsWith(".ts"));
for (const file of files) {
const filePath = join(dirPath, file);
const module = (await import(filePath)) as TestModule;
if (module.test) {
testInfos.push({ name: module.test.name, config: module.test });
} else if (module.tests) {
const entries = Object.entries(module.tests);
for (const entry of entries) {
testInfos.push({ name: entry[0], config: entry[1] });
}
}
}
}
return testInfos;
}
// check if test has a specific tag
function hasTag(test: TestInfo, tag: TestTag): boolean {
return test.config.tags?.includes(tag) ?? false;
}
type ParsedArgs = {
filters: string[]; // test names or tags
agentFilters: string[];
};
function parseArgs(args: string[], allTests: TestInfo[]): ParsedArgs {
const testNames = new Set(allTests.map((t) => t.name));
const allTags = new Set(allTests.flatMap((t) => t.config.tags ?? []));
const filters: string[] = [];
const agentFilters: string[] = [];
for (const arg of args) {
if (agents.includes(arg as (typeof agents)[number])) {
agentFilters.push(arg);
} else if (testNames.has(arg) || allTags.has(arg as TestTag)) {
filters.push(arg);
} else {
console.error(`unknown argument: ${arg}`);
console.error(`available tests: ${[...testNames].join(", ")}`);
console.error(`available tags: ${[...allTags].join(", ")}`);
console.error(`available agents: ${agents.join(", ")}`);
process.exit(1);
}
}
return { filters, agentFilters };
}
// filter tests based on filters (names or tags)
function filterTests(allTests: TestInfo[], filters: string[]): TestInfo[] {
if (filters.length === 0) {
// default: exclude adhoc tests
return allTests.filter((t) => !hasTag(t, "adhoc"));
}
// match tests by name or tag
return allTests.filter((t) => {
for (const filter of filters) {
if (t.name === filter || hasTag(t, filter as TestTag)) {
return true;
}
}
return false;
});
}
type RunContext = {
testInfo: TestInfo;
agent: string;
cancelState: CancelState;
results: Map<string, ValidationResult>;
};
function getRunKey(test: string, agent: string): string {
return `${test}::${agent}`;
}
type CanceledValidationContext = {
testInfo: TestInfo;
agent: string;
signal: NodeJS.Signals;
};
function buildCanceledValidation(ctx: CanceledValidationContext): ValidationResult {
return {
test: ctx.testInfo.name,
agent: ctx.agent,
passed: false,
canceled: true,
checks: [{ name: "canceled", passed: false }],
output: `canceled by ${ctx.signal}`,
};
}
const MAX_RETRIES = 2;
const RATE_LIMIT_BACKOFF_MS = 60_000; // 1 minute for rate limits
const FLAKY_RETRY_BACKOFF_MS = 5_000; // 5 seconds for transient failures
type RetryDecision = { retry: false } | { retry: true; reason: string; backoffMs: number };
/**
* determine if a failed test run should be retried.
*
* retryable (transient infrastructure failures):
* - rate limit errors from API providers
* - agent crashed/errored but no security-relevant checks failed
* (e.g., agent didn't call set_output due to MCP connection drop)
* - set_output not called — all output-dependent checks cascade fail
*
* NOT retryable (genuine test failures):
* - security checks failed (sandbox breach, token leak, etc.)
* - agent successfully ran and called set_output but produced wrong results
*/
// detect rate limit / quota errors across all providers. `\b429\b` uses word
// boundaries because a bare "429" substring false-matches UUIDs (e.g. MCP
// session ids like `...-4429-...`) and microsecond timestamps in agent stdout,
// which used to send transient failures down the 60s rate-limit retry path
// and push retries past the per-step CI timeout.
const RATE_LIMIT_PATTERNS: RegExp[] = [
/rate limit reached/i, // anthropic
/resource has been exhausted/i, // google/gemini
/quota exceeded/i, // google/gemini
/\b429\b/, // generic HTTP 429
/too many requests/i, // generic
];
function isRateLimited(output: string): boolean {
return RATE_LIMIT_PATTERNS.some((p) => p.test(output));
}
function shouldRetry(result: AgentResult, validation: ValidationResult): RetryDecision {
// rate limit / quota exhaustion: agent never got to run properly
if (!result.success && isRateLimited(result.output)) {
return { retry: true, reason: "rate limited", backoffMs: RATE_LIMIT_BACKOFF_MS };
}
// already passed — no retry needed
if (validation.passed) {
return { retry: false };
}
// if the test has a set_output check and it failed, other check failures are
// cascade failures — validators gate their checks on `setOutputCalled && ...`
// so they always fail when there's no structured output.
// security-relevant checks (like no_leak_filtered, native_blocked) are designed
// to PASS when set_output wasn't called (defensive coding). so cascade failures
// are never genuine security findings — they're transient instruction-following
// issues (MCP connection drop, agent confusion, etc.).
const setOutputCheck = validation.checks.find((c) => c.name === "set_output");
if (setOutputCheck && !setOutputCheck.passed) {
// if the output contains rate limit indicators, use the longer backoff
// (the agent process may have succeeded but hit quota limits mid-run)
const rateLimited = isRateLimited(result.output);
return {
retry: true,
reason: rateLimited ? "rate limited (set_output cascade)" : "set_output not called (cascade)",
backoffMs: rateLimited ? RATE_LIMIT_BACKOFF_MS : FLAKY_RETRY_BACKOFF_MS,
};
}
// set_output was called (or test has no set_output check) — if any other check
// failed, that's a genuine test failure with real data, not a cascade. don't retry.
const otherCheckFailed = validation.checks.some((c) => !c.passed && c.name !== "set_output");
if (otherCheckFailed) {
return { retry: false };
}
// agent process failed (non-zero exit) but no structured output to validate
if (!result.success) {
return { retry: true, reason: "agent process failed", backoffMs: FLAKY_RETRY_BACKOFF_MS };
}
return { retry: false };
}
async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
const testConfig = ctx.testInfo.config;
const env: Record<string, string> = {};
if (testConfig.env) {
const entries = Object.entries(testConfig.env);
for (const entry of entries) {
env[entry[0]] = entry[1];
}
}
if (testConfig.agentEnv) {
const agentEnv = testConfig.agentEnv.get(ctx.agent);
if (agentEnv) {
const entries = Object.entries(agentEnv);
for (const entry of entries) {
env[entry[0]] = entry[1];
}
}
}
env.PULLFROG_AGENT = ctx.agent;
// override DB model to avoid mismatch when PULLFROG_AGENT forces a specific agent
// (DB model may belong to a different provider than the forced agent supports).
// precedence: testConfig.env > process.env.PULLFROG_MODEL > per-agent default.
// the process.env pass-through lets CI (models-live matrix) pin an alias per job.
if (!Object.hasOwn(env, "PULLFROG_MODEL")) {
if (process.env.PULLFROG_MODEL) {
env.PULLFROG_MODEL = process.env.PULLFROG_MODEL;
} else {
const defaultModels: Record<string, string> = {
claude: "anthropic/claude-sonnet-4-6",
opencode: "anthropic/claude-sonnet-4-6",
};
const model = defaultModels[ctx.agent];
if (model) {
env.PULLFROG_MODEL = model;
}
}
}
if (!Object.hasOwn(env, "PULLFROG_MCP_PORT")) {
env.PULLFROG_MCP_PORT = String(allocateMcpPort());
}
// pass repo setup commands to play.ts for pre-agent execution
if (testConfig.repoSetup) {
env.PULLFROG_TEST_REPO_SETUP = testConfig.repoSetup;
}
// build file-based env vars for MCP servers that don't inherit parent env
let fileEnv: Record<string, string> | undefined;
if (testConfig.fileAgentEnv) {
const agentFileEnv = testConfig.fileAgentEnv.get(ctx.agent);
if (agentFileEnv) {
fileEnv = {};
const entries = Object.entries(agentFileEnv);
for (const entry of entries) {
fileEnv[entry[0]] = entry[1];
}
}
}
const prefix = getPrefix({ test: ctx.testInfo.name, agent: ctx.agent });
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (ctx.cancelState.canceled) break;
// allocate a fresh port on retries (previous server is gone)
if (attempt > 0) {
env.PULLFROG_MCP_PORT = String(allocateMcpPort());
}
const result = await runAgentStreaming({
test: ctx.testInfo.name,
agent: ctx.agent,
fixture: testConfig.fixture,
env,
fileEnv,
isCanceled: () => ctx.cancelState.canceled,
});
const validation = validateResult(result, testConfig.validator, {
test: ctx.testInfo.name,
expectFailure: testConfig.expectFailure,
});
// check if we should retry
if (attempt < MAX_RETRIES) {
const decision = shouldRetry(result, validation);
if (decision.retry) {
console.log(
`\n${prefix} ${decision.reason} — retrying in ${decision.backoffMs / 1000}s (retry ${attempt + 1}/${MAX_RETRIES})...\n`
);
await new Promise((r) => setTimeout(r, decision.backoffMs));
continue;
}
}
ctx.results.set(getRunKey(ctx.testInfo.name, ctx.agent), validation);
return validation;
}
// should not reach here, but handle canceled state
return buildCanceledValidation({
testInfo: ctx.testInfo,
agent: ctx.agent,
signal: ctx.cancelState.signal ?? "SIGTERM",
});
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const allTests = await loadAllTests();
const parsed = parseArgs(args, allTests);
// filter tests
const filteredTests = filterTests(allTests, parsed.filters);
if (filteredTests.length === 0) {
console.error("no tests to run");
process.exit(1);
}
// determine which agents to run
const agentsToRun = parsed.agentFilters.length > 0 ? parsed.agentFilters : [...agents];
// build list of test runs
type TestRun = { testInfo: TestInfo; agent: string };
const runs: TestRun[] = [];
for (const testInfo of filteredTests) {
const isAgnostic = hasTag(testInfo, "agnostic");
if (isAgnostic) {
// agnostic tests: skip if only filtering by agent, otherwise run with opencode
if (parsed.filters.length === 0 && parsed.agentFilters.length > 0) {
continue;
}
runs.push({ testInfo, agent: "opencode" });
} else {
// determine which agents to run for this test
const testAgents = testInfo.config.agents ?? agents;
const effectiveAgents = agentsToRun.filter((a) => testAgents.includes(a));
for (const agent of effectiveAgents) {
runs.push({ testInfo, agent });
}
}
}
if (runs.length === 0) {
console.error("no test runs after filtering");
process.exit(1);
}
// describe what we're running
const runTestNames = [...new Set(runs.map((r) => r.testInfo.name))];
const runAgentNames = [...new Set(runs.map((r) => r.agent))];
console.log(`running ${runTestNames.join(", ")} for: ${runAgentNames.join(", ")}\n`);
const cancelState: CancelState = { canceled: false, signal: null };
const results = new Map<string, ValidationResult>();
let resultsPrinted = false;
function printAndExit(validations: ValidationResult[]): void {
if (resultsPrinted) return;
resultsPrinted = true;
console.log();
for (const v of validations) {
printSingleValidation(v);
}
printResults(validations);
const allPassed = validations.every((v) => v.passed);
process.exit(allPassed ? 0 : 1);
}
function handleCancel(signal: NodeJS.Signals): void {
if (cancelState.canceled) return;
cancelState.canceled = true;
cancelState.signal = signal;
killTrackedChildren();
const validations: ValidationResult[] = [];
for (const run of runs) {
const key = getRunKey(run.testInfo.name, run.agent);
const existing = results.get(key);
if (existing) {
validations.push(existing);
} else {
validations.push(
buildCanceledValidation({
testInfo: run.testInfo,
agent: run.agent,
signal,
})
);
}
}
printAndExit(validations);
}
setSignalHandler(handleCancel);
// run tests with limited concurrency to avoid overwhelming agent APIs
const maxConcurrency = 5;
const validations = await runWithConcurrencyLimit(runs, maxConcurrency, (run) =>
runTestForAgent({
testInfo: run.testInfo,
agent: run.agent,
cancelState,
results,
})
);
if (!cancelState.canceled) {
printAndExit(validations);
}
}
// simple concurrency limiter
async function runWithConcurrencyLimit<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = [];
const executing: Promise<void>[] = [];
for (const item of items) {
const p = fn(item).then(
(result) => {
results.push(result);
},
(err: unknown) => {
console.error("runWithConcurrencyLimit: fn rejected unexpectedly", err);
throw err;
}
);
const e = p.then(() => {
executing.splice(executing.indexOf(e), 1);
});
executing.push(e);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
await Promise.all(executing);
return results;
}
main();