Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
19a8183
Remove test objects made without a runner label however the test ends…
mchwang Oct 21, 2026
6c1034a
Give the test-run sweep its own budget and an afterAll pass (#189)
mchwang Oct 21, 2026
8828d58
Remove containers that hold a swept network; record the stand-in firs…
mchwang Oct 21, 2026
c6b27fe
Remove only exact holders of a swept network; test the holder sweep (…
mchwang Oct 21, 2026
489569e
Close the remaining label-less leaks the review found (#189)
mchwang Oct 21, 2026
d055625
Record label-less objects first in agent-network; remove networks by …
mchwang Oct 21, 2026
e6fd22c
Test removal by full network ID; say what the runner sweep removes (#…
mchwang Oct 21, 2026
dffb63f
Merge remote-tracking branch 'origin/main' into fix/189-test-object-c…
mchwang Oct 21, 2026
1fadd86
Remove swept containers by full ID; track the blocker before it is ma…
mchwang Oct 21, 2026
faf549d
Remove the foreign-runner storage in its test; clean every storage in…
mchwang Oct 21, 2026
8efbd1b
Read network names as JSON; name the seccomp probe; test --no-trunc (…
mchwang Oct 21, 2026
7075dd8
Never throw on output the sweep cannot parse; foreign creates inside …
mchwang Oct 21, 2026
e85c690
Merge remote-tracking branch 'origin/main' into fix/189-test-object-c…
mchwang Oct 21, 2026
ee972b8
Sweep the failed-allocation test by allocation; guard unreadable line…
mchwang Oct 21, 2026
31e669f
Budget the in-test sweeps; exact wording (#189)
mchwang Oct 21, 2026
a9162f7
Sweep both runners of the recovery test; keep the first failure (#189)
mchwang Oct 21, 2026
3818b53
Check the traps are live in the holder test; report every cleanup fai…
mchwang Oct 21, 2026
9019dce
Keep the first failure in the replaced-keeper test; exact sweep wordi…
mchwang Oct 21, 2026
699ea70
Check the partial-inspect answer; sweep the replaced keeper; budget m…
mchwang Oct 21, 2026
31a5614
Budget the replaced-keeper sweep; say what the holder test's budget c…
mchwang Oct 21, 2026
d74abca
One per-file sweep in afterAll; real docker for the sweep; timed-out …
mchwang Oct 21, 2026
3c2c64f
Track the race and legacy volumes; cleanup loops inside try with real…
mchwang Oct 21, 2026
6c4c2ee
Sweep other runners' objects when the test finishes, timeouts include…
mchwang Oct 21, 2026
18ba72b
Real docker in every shim; foreign-name containers swept on finish; k…
mchwang Oct 21, 2026
1c1f004
Budget the supervisor finish hooks; name a signal kill; exact comment…
mchwang Oct 21, 2026
078b189
Register late-landing agent-network objects by name; exact comments (…
mchwang Oct 21, 2026
052848b
Track the holder test's objects for afterAll, removed by exact name (…
mchwang Oct 21, 2026
f8e9749
Report removeByName failures; records keep every name; shim scoped (#…
mchwang Oct 21, 2026
f0c8278
Test removeByName's exact-name guard and reports; records outside shi…
mchwang Oct 21, 2026
fcf4257
removeByName treats only Docker's own missing answers as missing; tes…
mchwang Oct 21, 2026
0183f5a
Real-Docker guard check on a free network; real error text in the fak…
mchwang Oct 21, 2026
5b30289
Holder test checks removeByName's exact removal and silence on a gone…
mchwang Oct 21, 2026
1227ec1
The stand-in shim acts only on an exec in a proxy (#189)
mchwang Oct 21, 2026
716c6e3
Merge branch 'main' of https://github.com/codeabovelab/codeboost into…
mchwang Oct 21, 2026
9cb8324
Doc: a test may sweep its own runner owner with removeLabelledObjects…
mchwang Oct 21, 2026
08d1a65
Say what the killed-call fake leaves out (#189)
mchwang Oct 21, 2026
88ba062
Sweeps keep the Docker daemon set when they loaded (#189)
mchwang Oct 21, 2026
26e9103
Comment and naming fixes (#189)
mchwang Oct 21, 2026
6497cf7
The racer shim acts only on its own allocation's create (#189)
mchwang Oct 21, 2026
b1176fd
removeByName's reports name the stage and the command run (#189)
mchwang Oct 21, 2026
115a2b2
Sweep reports name their stage; a gone holder is not a failure; loade…
mchwang Oct 21, 2026
e4e048d
Track the never-started unlabelled agent container by name; comment f…
mchwang Oct 21, 2026
ab215c6
Label agent-network's unnamed helper containers for afterAll; track t…
mchwang Oct 21, 2026
29744c7
Track the peer, stand-in proxy and foreign keeper by name; holder tes…
mchwang Oct 21, 2026
3d86291
Track the rogue-volume container before its create (#189)
mchwang Oct 21, 2026
c6cac80
Merge branch 'main' of https://github.com/codeabovelab/codeboost into…
mchwang Oct 21, 2026
4ead18d
Read the holder test's sweep warnings before restoring the spy (#189)
mchwang Oct 21, 2026
698f341
Storage release after a pass fails the test again; mark the invented …
mchwang Oct 21, 2026
bcf1e9f
removeByName: tested on unreadable output, quiet on a network gone be…
mchwang Oct 21, 2026
6fe98b9
Restore two failures main's cleanups had; report an inspect answer wi…
mchwang Oct 21, 2026
59ed09f
Comment fixes: what main's check was; what removeByName reads (#189)
mchwang Oct 21, 2026
a0bcb1d
Test that the real docker is found once at load and that shims refuse…
mchwang Oct 21, 2026
515fe0c
Test the load-time docker as the default and a relative PATH entry; c…
mchwang Oct 21, 2026
820c2a2
Every Docker client call the branch adds or moves in a test has a tim…
mchwang Oct 21, 2026
067c713
Track the ack-script container by name; time limits on the moved and …
mchwang Oct 21, 2026
607f7ed
Name a failed foreign-keeper removal after a pass (#189)
mchwang Oct 21, 2026
582f786
Workflow limits for the longer teardown; full test of the loaded daem…
mchwang Oct 21, 2026
2af834e
Live probes limit counts agent-planning's probe and teardown; clearer…
mchwang Oct 21, 2026
07938e2
Sweep quiet on a network gone since its listing; full loaded-daemon t…
mchwang Oct 21, 2026
008c2d0
Holder test checks its gone-holder shim fired; header and workflow co…
mchwang Oct 21, 2026
6411bda
Rewrap two comment blocks (#189)
mchwang Oct 21, 2026
07e4a7b
The loaded-daemon fake gives the volume its own holder (#189)
mchwang Oct 21, 2026
50285e3
Track the supervisor's foreign-name containers for afterAll; stage in…
mchwang Oct 21, 2026
4de7621
Failure messages name the docker command; record checks first (#189)
mchwang Oct 21, 2026
df7eca1
Exact command in the listing's failure; holder test checks every swee…
mchwang Oct 21, 2026
0183899
Report sweep lines of the wrong shape; exact commands in removeByName…
mchwang Oct 21, 2026
3faffdd
Gone checks need Docker's missing answer; sweep checks listed IDs and…
mchwang Oct 21, 2026
96cf19a
agent-network's survival checks use objectExists (#189)
mchwang Oct 21, 2026
c23d896
Every ID passed on as an argument has a checked shape (#189)
mchwang Oct 21, 2026
43a4510
removeByName refuses a name that could read as an option; fake notes …
mchwang Oct 21, 2026
0fd77f7
Test objectExists; gone cases reach their inspect; per-record errors …
mchwang Oct 21, 2026
10b2bf8
Gone checks in the touched tests use objectExists; fake and comment f…
mchwang Oct 21, 2026
196a225
Every gone check in a changed test uses objectExists (#189)
mchwang Oct 21, 2026
7ea7fc8
The proxy-start test's gone check uses objectExists; assert the raced…
mchwang Oct 21, 2026
2f30016
Shape checks before the gone checks; objectExists refuses an option-l…
mchwang Oct 21, 2026
59c3183
Trim the listing's stderr in its failure message (#189)
mchwang Oct 21, 2026
9571be5
Pin every docker call's time limit; volume refusal reason; guarded re…
mchwang Oct 21, 2026
e11583b
Time-limit test runs all thirteen calls; holder search and no-space l…
mchwang Oct 21, 2026
f6bf790
Test the fixture's remaining guards; exact reasons in refusals and re…
mchwang Oct 21, 2026
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
4 changes: 3 additions & 1 deletion .github/workflows/agent-isolation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ permissions:
jobs:
real-docker:
runs-on: ubuntu-latest
# Covers the 10 min image build, 2 min teardown and the per-test Docker budgets.
# Covers the typecheck, the ordinary Vitest project, the 10 min image build and ordinary run times of the
# Docker-backed files. It does not cover every test and hook at its own Vitest limit: the five afterAll hooks that
# call the shared sweep allow 46 min between them (#189), which only a stalled daemon reaches.
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
Expand Down
15 changes: 8 additions & 7 deletions .github/workflows/live-probes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ concurrency:
jobs:
live-probes:
runs-on: ubuntu-latest
# The probes' own time limits total 33 min (6, 8, 8 and 11). agent-container and agent-supervisor may each spend
# up to 10 min building the image before their probes; agent-question builds inside its probe's own 11 min. The
# teardown hooks have Vitest limits that total 9 min: agent-container 2 after each test and 2 at the end,
# agent-supervisor 3 and agent-question 2. That is 62 min, plus setup. These are only Vitest's limits: the afterAll
# hooks in agent-container and agent-question call docker through spawnSync with no timeout of their own, and
# Vitest's hook timer cannot fire while such a call blocks. The 75 min job limit caps the run.
timeout-minutes: 75
# The probes' own time limits total 44 min (6, 8, 8, 11 and 11). agent-container and agent-supervisor may each spend
# up to 10 min building the image before their probes; agent-question and agent-planning build inside their probes'
# own 11 min. The teardown hooks have Vitest limits that total 26 min: agent-container 2 after each test and 13 at
# the end (two sweeps of up to 5.5 min each, #189), agent-supervisor 7, agent-question 2 and agent-planning 2. That
# is 90 min, plus setup. These are only Vitest's limits: Vitest's hook timer cannot fire while a synchronous docker
# call blocks, and some calls in these hooks have no timeout of their own (the sweeps' calls have 30 s each). The
# 100 min job limit caps the run.
timeout-minutes: 100
steps:
# Fail at once when the secret is missing, so a missing secret never looks like a passing run.
- name: Require the Claude token
Expand Down
469 changes: 359 additions & 110 deletions test/agent-container.test.ts

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions test/agent-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ const writableMetadata = tmpfs('/work/.git', 'size=16m,nr_inodes=512');
const boundedScratch = tmpfs('/tmp', 'size=32m,nr_inodes=4096');

beforeAll(() => { imageId = buildAgentImage(); }, 10 * 60_000);
// The file has no other cleanup: a probe container whose client timed out may still be running.
afterAll(() => removeRunnerObjects(TEST_RUNNER_OWNER), 4 * 60_000);
// The file has no other cleanup: a probe container whose client timed out may still be running. The budget is the
// sweep's worst case (eleven calls of 30 s) and half a minute.
afterAll(() => removeRunnerObjects(TEST_RUNNER_OWNER), 6 * 60_000);

describe('isolation gate detects breaches', () => {
it.each([
Expand Down
180 changes: 133 additions & 47 deletions test/agent-network.test.ts

Large diffs are not rendered by default.

54 changes: 40 additions & 14 deletions test/agent-supervisor.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { randomBytes, randomUUID } from 'node:crypto';
import { execFileSync, spawnSync } from 'node:child_process';
import { removeRunnerObjects } from './fixtures/docker-sweep.ts';
import { CLIENT_LIMIT, objectExists, removeByName, removeRunnerObjects, requireRealDocker } from './fixtures/docker-sweep.ts';
import { fixtureGit } from './fixtures/git.ts';
import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest';
import { startClaudeInvocation } from '../agents/adapters/claude.ts';
import { readCodexOutput } from '../agents/adapters/codex.ts';
import { ACKNOWLEDGEMENT_SCRIPT, isInvocationActive, readBoundedContainerFile, retainSetupCleanup,
Expand All @@ -30,6 +30,11 @@ const testOwner = (attemptId = 'fixture') => ({ runnerOwner: TEST_RUNNER_OWNER,

const roots: string[] = [], profiles: ContainerProfile[] = [];
const allocations: ReturnType<typeof prepareTaskFilesystems>[] = [];
/**
* Containers without this run's runner label, by name, removed in afterAll should their create land after their test's
* finally (#189).
*/
const unlabelled = new Set<string>();
let imageId = '';
const git = fixtureGit;

Expand Down Expand Up @@ -70,6 +75,7 @@ async function profile(data: ReturnType<typeof fixture>, probe: IsolationProbe,
beforeAll(async () => { imageId = buildAgentImage(); }, 10 * 60_000);
afterAll(async () => {
try {
for (const name of unlabelled) removeByName('container', name, 'afterAll');
for (const profile of profiles) await disposeContainerProfile(profile);
for (const allocation of allocations.reverse()) removeTaskFilesystems(allocation);
for (const root of roots.reverse()) {
Expand All @@ -79,9 +85,11 @@ afterAll(async () => {
} finally {
// Then remove whatever this run still owns, even when a test failed or the cleanup above threw: an agent that
// ignores SIGTERM, or storage whose removal did not settle. It may not run before a hook timeout.
// The hook's 7 minutes leave a minute and a half over this sweep's worst case (eleven calls of 30 s), for the
// cleanup before it.
removeRunnerObjects(TEST_RUNNER_OWNER);
}
}, 3 * 60_000);
}, 7 * 60_000);

describe('container invocation supervisor', () => {
it('preserves the first cancellation reason while retained setup cleanup settles', async () => {
Expand Down Expand Up @@ -330,7 +338,7 @@ describe('container invocation supervisor', () => {
const hangingDocker = (match: string) => {
const shim = mkdtempSync(join(tmpdir(), 'codeboost-slow-docker-')); roots.push(shim);
mkdirSync(join(shim, 'input')); // afterAll resets this path's mode
const realDocker = execFileSync('sh', ['-c', 'command -v docker'], { encoding: 'utf8' }).trim();
const realDocker = requireRealDocker();
writeFileSync(join(shim, 'docker'), ['#!/bin/sh', `case "$*" in ${match}) exec sleep 60;; esac`,
`exec '${realDocker}' "$@"`].join('\n'), { mode: 0o755 });
return `${shim}:${process.env.PATH}`;
Expand Down Expand Up @@ -385,20 +393,30 @@ describe('container invocation supervisor', () => {

it('releases its own resources, and leaves a same-named container another invocation owns', async () => {
const current = await profile(fixture(), 'finite-output', 'foreign-name');
execFileSync('docker', ['create', '--name', current.name, '--label', 'io.codeboost.invocation=foreign',
'--entrypoint', 'true', imageId], { stdio: 'ignore' });
// Removed by name when the test finishes, timeouts included: its label value is shared across runs (#189). Tracked
// for afterAll too, should its create land after that.
unlabelled.add(current.name);
onTestFinished(() => removeByName('container', current.name, 'onTestFinished'), 90_000); // two calls of up to 30 s
let passed = false;
try {
// Inside the try, so its removal runs even if the create's client fails after the daemon made it (#189).
execFileSync('docker', ['create', '--name', current.name, '--label', 'io.codeboost.invocation=foreign',
'--entrypoint', 'true', imageId], { stdio: 'ignore', ...CLIENT_LIMIT });
const result = await startProfileInvocation(current).settled;
expect(result.stopReason).toBe('capture-failure');
expect(result.stderr).toMatch(/already in use|Conflict/);
expect(result.unreleased).toBeUndefined();
expect(isInvocationActive(attempt('foreign-name'))).toBe(false);
expect(isContainerProfileAuthentic(current)).toBe(false);
expect(spawnSync('docker', ['network', 'inspect', current.network.name], { stdio: 'ignore' }).status).not.toBe(0);
expect(objectExists('network', current.network.name)).toBe(false);
expect(execFileSync('docker', ['container', 'inspect', '--format',
'{{index .Config.Labels "io.codeboost.invocation"}}', current.name], { encoding: 'utf8' }).trim()).toBe('foreign');
passed = true;
} finally {
execFileSync('docker', ['rm', '--force', current.name], { stdio: 'ignore' });
// A failure above stays the one reported, whatever exit status this Docker version gives for a missing name. After
// a pass the container exists, and its removal must succeed, as on main.
const removed = spawnSync('docker', ['rm', '--force', current.name], { encoding: 'utf8', ...CLIENT_LIMIT });
if (passed && removed.status !== 0) throw new Error(`docker rm --force ${current.name} failed: ${removed.error?.message ?? (removed.signal ? `killed by ${removed.signal}` : removed.stderr?.trim())}`);
}
}, 3 * 60_000);

Expand Down Expand Up @@ -436,9 +454,13 @@ describe('container invocation supervisor', () => {
it('releases only the profile when rejecting before creation, even if its name is held elsewhere', async () => {
const current = await profile(fixture(), 'noop');
// A foreign container occupies the deterministic name, so container-level cleanup could never settle.
execFileSync('docker', ['create', '--name', current.name, '--label', 'io.codeboost.invocation=someone-else',
'--entrypoint', 'true', imageId], { stdio: 'ignore' });
// Removed by name when the test finishes, timeouts included: its label value is shared across runs (#189). Tracked
// for afterAll too, should its create land after that.
unlabelled.add(current.name);
onTestFinished(() => removeByName('container', current.name, 'onTestFinished'), 90_000); // two calls of up to 30 s
try {
execFileSync('docker', ['create', '--name', current.name, '--label', 'io.codeboost.invocation=someone-else',
'--entrypoint', 'true', imageId], { stdio: 'ignore', ...CLIENT_LIMIT });
await expectRejected(startProfileInvocation(current, { timeoutMs: 10 * 60_000 + 1 }), 'ceiling');
expect(isContainerProfileAuthentic(current)).toBe(false);
expect(spawnSync('docker', ['container', 'inspect', current.name], { stdio: 'ignore' }).status).toBe(0);
Expand Down Expand Up @@ -641,11 +663,15 @@ describe('container invocation supervisor', () => {
it('writes the deferred output acknowledgement idempotently, repairing a partial one', () => {
const token = randomUUID(), file = `/run/codeboost-control/collected-${token}`;
const name = `codeboost-ack-script-${randomUUID()}`;
execFileSync('docker', ['run', '-d', '--rm', '--name', name, '--network', 'none', '--cap-drop=ALL',
'--security-opt=no-new-privileges', '--user', '10001:10001', '--entrypoint', 'sleep',
'--tmpfs', '/run/codeboost-control:rw,nosuid,nodev,noexec,size=65536,nr_inodes=16,uid=0,gid=0,mode=0711',
imageId, '120'], { stdio: 'ignore' });
// Tracked before it is made: it has no labels, and a create that lands after its client was killed is never started,
// so `--rm` does not apply and only removal by name finds it (#189).
unlabelled.add(name);
try {
// Inside the try: the container has no labels, so the finally below removes it, and afterAll by name (#189).
execFileSync('docker', ['run', '-d', '--rm', '--name', name, '--network', 'none', '--cap-drop=ALL',
'--security-opt=no-new-privileges', '--user', '10001:10001', '--entrypoint', 'sleep',
'--tmpfs', '/run/codeboost-control:rw,nosuid,nodev,noexec,size=65536,nr_inodes=16,uid=0,gid=0,mode=0711',
imageId, '120'], { stdio: 'ignore', ...CLIENT_LIMIT });
const acknowledge = () => spawnSync('docker', ['exec', '--user', '0', name, 'node', '-e', ACKNOWLEDGEMENT_SCRIPT,
token], { encoding: 'utf8' });
const content = () => execFileSync('docker', ['exec', name, 'cat', file], { encoding: 'utf8' });
Expand Down
Loading
Loading