Skip to content

Commit 5881a43

Browse files
mchwangclaude
andauthored
D4: Add bounded production agent adapters (#47)
* Add bounded production agent adapters * Harden adapter capture settlement * Pin adapter output to dedicated tmpfs * Close remaining adapter lifecycle races * Validate pinned output identities * Make cleanup and decode settlement retryable * Abort and await bounded adapter capture * Retain adapter cleanup ownership * Allow loaded Docker cleanup observation * Secure deferred output acknowledgement * Retain colliding cleanup recovery * Preserve adapter setup ownership * Bound cancellation with monotonic deadlines * Carry invocation ownership through settlement * Guard active profile and close deadline * Apply adapter timeout across setup * Bound adapter cleanup and final stderr * Keep decoder settlement timers alive * Retain recovery profile ownership * Revalidate container at launch boundary * Validate profile capability before cleanup * Preserve cleanup cancellation reasons * Update cleanup cancellation regression * Authenticate profiles at disposal boundary * Skip cleanup for unauthenticated profiles and stop on late close Commit pending work left in the D4 worktree: - Add isContainerProfileAuthentic and skip disposal when container validation fails for a profile whose identity was already removed. - Route a close observed after the deadline through stop('timeout') so a running decoder is aborted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reject duplicate attempts without touching the active container A duplicate attempt's profile never created a container, and its deterministic container name belongs to the active invocation, so rejection now releases only the duplicate profile's staging and network. Previously it tried to remove the container by name, which D2 now refuses for another invocation's container, leaving a cleanup handle that never settled. The regression reuses one captured invocation, since an attempt can only be captured once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep D4 Docker suites out of the parallel CI run The adapter and supervisor suites use Docker and already run one file at a time in the Agent isolation workflow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Retain captured output in fixed-size blocks Each output event was stored as its own Buffer slice, so a container could emit the byte ceiling as millions of one-byte writes and exhaust supervisor memory before the byte limit applied. Output is now copied into 64 KiB blocks, so retained objects scale with bytes rather than with write events, and slices no longer pin their original chunks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep killed creates unsettled across cleanups and publish strict UTF-8 - Record, per profile, when a killed docker create stops counting as in flight. Later cleanup (for example the supervisor's dispose after an early create-path cleanup failure) no longer treats absence inside that window as proof and releases the profile; it reports "did not settle" so ownership is retained and retried. Also round the create-path wait to whole milliseconds, which the deadline helper requires. - Decode final stdout and stderr with a fatal UTF-8 policy. Invalid output becomes a capture-failure with that stream withheld, so replacement characters cannot grow the result past its byte ceilings; an incomplete trailing character cut at a limit is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Treat an absent container as settled once the create window passes After a killed docker create, the create path waited out the settle window but still reported "did not settle" when no container appeared, leaving the profile's staging and network owned. Callers such as runContainer have no later cleanup, so those resources leaked. Absence now counts as settled on every path once the window has passed, matching storage and network cleanup; only inside the window is it reported as unsettled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fail closed on a trailing incomplete UTF-8 character unless truncated The final output decode used streaming mode on every stream, so output ending in a lone lead byte was silently trimmed and published as a success. Only output cut at a capture limit may now end in an incomplete character; otherwise the decode flushes and fails closed as a capture-failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Release only the profile when rejecting an invocation before creation Rejections before container creation (invalid limits, an expired deadline, a duplicate attempt) own no container, but they cleaned up with container-level disposal. If the deterministic name was held by another invocation or Docker inspect failed, that cleanup could never settle, and the recovery retried it forever while the profile's staging and network leaked. These paths now dispose only the profile, and recovery retries that same profile-only cleanup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f8c8d9f commit 5881a43

15 files changed

Lines changed: 1470 additions & 46 deletions

File tree

‎.github/workflows/agent-isolation.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,4 @@ jobs:
2929
- run: npm ci --ignore-scripts
3030
- run: npm run typecheck
3131
# The Docker suites share one image tag and daemon, so run test files one at a time.
32-
- run: npx vitest run --no-file-parallelism test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts test/agent-network.test.ts test/agent-policy.test.ts test/agent-proxy.test.ts
32+
- run: npx vitest run --no-file-parallelism test/agent-contract.test.ts test/agent-clone.test.ts test/agent-container.test.ts test/agent-network.test.ts test/agent-policy.test.ts test/agent-proxy.test.ts test/agent-adapter.test.ts test/agent-supervisor.test.ts test/agent-output.test.ts

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ jobs:
1818
- run: npm run typecheck
1919
# The Docker agent suites run one file at a time in the Agent isolation workflow; running them here
2020
# would put them in parallel against the same image tag and daemon.
21-
- run: npm test -- --exclude test/agent-container.test.ts --exclude test/agent-network.test.ts
21+
- run: npm test -- --exclude test/agent-container.test.ts --exclude test/agent-network.test.ts --exclude test/agent-adapter.test.ts --exclude test/agent-supervisor.test.ts
2222
- run: npx playwright install --with-deps chromium
2323
- run: npm run test:browser

‎agents/adapters/claude.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { InvocationHandle } from '../contract.ts';
2+
import { createContainerProfile, ProfileCreationCleanupError } from '../container/profile.ts';
3+
import { createVendorNetwork, removeVendorNetwork, VendorNetworkCreationCleanupError,
4+
type VendorNetwork } from '../network/network.ts';
5+
import { createClaudeCommand, createPhasePolicy } from '../policy.ts';
6+
import { retainNetworkCleanup, retainSetupCleanup, startProfileInvocation } from './supervisor.ts';
7+
import { createAdapterInvocationBudget, type AgentAdapterOptions, type AgentAdapterRequest } from './types.ts';
8+
9+
export function parseClaudeOutput(raw: Buffer): { text: string; providerFailed: boolean } {
10+
const envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)) as
11+
{ result?: unknown; is_error?: unknown };
12+
if (typeof envelope.result !== 'string' || typeof envelope.is_error !== 'boolean')
13+
throw new Error('Claude returned a malformed output envelope.');
14+
return Object.freeze({ text: envelope.result, providerFailed: envelope.is_error });
15+
}
16+
17+
export function startClaudeInvocation(request: AgentAdapterRequest,
18+
oauthToken: string, options: AgentAdapterOptions = {}): InvocationHandle {
19+
if (!oauthToken || oauthToken.includes('\0')) throw new Error('Claude OAuth token is malformed.');
20+
const policy = createPhasePolicy(request.invocation);
21+
const remaining = createAdapterInvocationBudget(request.invocation, options.timeoutMs);
22+
let network: VendorNetwork;
23+
try { network = createVendorNetwork(request.invocation, request.imageId, Math.min(60_000, remaining())); }
24+
catch (error) {
25+
if (error instanceof VendorNetworkCreationCleanupError)
26+
return retainSetupCleanup(request.invocation, error.retryCleanup, error.startupError, error,
27+
'network creation cleanup');
28+
throw error;
29+
}
30+
try {
31+
const profile = createContainerProfile({ ...request, policy, network,
32+
command: createClaudeCommand(policy, request.prompt), claudeToken: oauthToken,
33+
timeoutMs: Math.min(60_000, remaining()) });
34+
return startProfileInvocation(profile, { ...options, secrets: { CLAUDE_CODE_OAUTH_TOKEN: oauthToken },
35+
invocationBudget: remaining,
36+
decode: (_profile, raw) => parseClaudeOutput(raw) });
37+
} catch (error) {
38+
if (error instanceof ProfileCreationCleanupError) {
39+
const retryCleanup = (networkTimeoutMs = 30_000) => {
40+
const failures: unknown[] = [];
41+
try { error.retryCleanup(); } catch (cleanupError) { failures.push(cleanupError); }
42+
try { removeVendorNetwork(network, networkTimeoutMs); } catch (cleanupError) { failures.push(cleanupError); }
43+
if (failures.length) throw new AggregateError(failures, 'Adapter setup cleanup did not settle.');
44+
};
45+
try { retryCleanup(Math.min(30_000, remaining())); }
46+
catch (cleanupError) { return retainSetupCleanup(request.invocation, () => retryCleanup(),
47+
error.startupError, cleanupError, 'profile and network cleanup'); }
48+
throw error.startupError;
49+
}
50+
try { removeVendorNetwork(network, Math.min(30_000, remaining())); }
51+
catch (cleanupError) { return retainNetworkCleanup(request.invocation, network, error, cleanupError); }
52+
throw error;
53+
}
54+
}

‎agents/adapters/codex.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { InvocationHandle } from '../contract.ts';
2+
import { createContainerProfile, ProfileCreationCleanupError } from '../container/profile.ts';
3+
import { createVendorNetwork, removeVendorNetwork, VendorNetworkCreationCleanupError,
4+
type VendorNetwork } from '../network/network.ts';
5+
import { createCodexCommand, createPhasePolicy } from '../policy.ts';
6+
import { readBoundedContainerFile, retainNetworkCleanup, retainSetupCleanup,
7+
startProfileInvocation } from './supervisor.ts';
8+
import { createAdapterInvocationBudget, type AgentAdapterOptions, type AgentAdapterRequest } from './types.ts';
9+
10+
export const CODEX_OUTPUT_FILE = '/run/codeboost-output/final.txt';
11+
12+
export async function readCodexOutput(container: string, maximumBytes: number, timeoutMs = 30_000,
13+
signal?: AbortSignal) {
14+
const output = await readBoundedContainerFile(container, CODEX_OUTPUT_FILE, maximumBytes, timeoutMs, signal);
15+
const text = new TextDecoder('utf-8', { fatal: true }).decode(output);
16+
return Object.freeze({ text, additionalBytes: output.length });
17+
}
18+
19+
export function startCodexInvocation(request: AgentAdapterRequest,
20+
authFile: string, options: AgentAdapterOptions = {}): InvocationHandle {
21+
if (!authFile || authFile.includes('\0')) throw new Error('Codex auth path is malformed.');
22+
const policy = createPhasePolicy(request.invocation);
23+
const remaining = createAdapterInvocationBudget(request.invocation, options.timeoutMs);
24+
let network: VendorNetwork;
25+
try { network = createVendorNetwork(request.invocation, request.imageId, Math.min(60_000, remaining())); }
26+
catch (error) {
27+
if (error instanceof VendorNetworkCreationCleanupError)
28+
return retainSetupCleanup(request.invocation, error.retryCleanup, error.startupError, error,
29+
'network creation cleanup');
30+
throw error;
31+
}
32+
try {
33+
const profile = createContainerProfile({ ...request, policy, network,
34+
command: createCodexCommand(policy, request.prompt), codexAuthFile: authFile, deferredOutput: true,
35+
timeoutMs: Math.min(60_000, remaining()) });
36+
return startProfileInvocation(profile, { ...options,
37+
invocationBudget: remaining,
38+
decode: (current, _raw, maximum, timeoutMs, signal) =>
39+
readCodexOutput(current.name, maximum, timeoutMs, signal) });
40+
} catch (error) {
41+
if (error instanceof ProfileCreationCleanupError) {
42+
const retryCleanup = (networkTimeoutMs = 30_000) => {
43+
const failures: unknown[] = [];
44+
try { error.retryCleanup(); } catch (cleanupError) { failures.push(cleanupError); }
45+
try { removeVendorNetwork(network, networkTimeoutMs); } catch (cleanupError) { failures.push(cleanupError); }
46+
if (failures.length) throw new AggregateError(failures, 'Adapter setup cleanup did not settle.');
47+
};
48+
try { retryCleanup(Math.min(30_000, remaining())); }
49+
catch (cleanupError) { return retainSetupCleanup(request.invocation, () => retryCleanup(),
50+
error.startupError, cleanupError, 'profile and network cleanup'); }
51+
throw error.startupError;
52+
}
53+
try { removeVendorNetwork(network, Math.min(30_000, remaining())); }
54+
catch (cleanupError) { return retainNetworkCleanup(request.invocation, network, error, cleanupError); }
55+
throw error;
56+
}
57+
}

0 commit comments

Comments
 (0)