Skip to content

Commit 489066e

Browse files
committed
Add D1 invocation contract and independent task clones
1 parent 5eec4b5 commit 489066e

4 files changed

Lines changed: 287 additions & 0 deletions

File tree

‎agents/contract.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/** Lane D/F boundary. Only the runner may construct requests after admission. */
2+
export interface TaskClone {
3+
readonly id: string;
4+
readonly taskId: string;
5+
/** Staging clone, NOT a container-ready mount. D2 must allocate bounded storage. */
6+
readonly directory: string;
7+
readonly head: string;
8+
}
9+
10+
export type Phase = 'planning' | 'questions' | 'review' | 'execute' | 'fix';
11+
export interface InvocationContext {
12+
readonly snapshotId: string;
13+
readonly planId: string;
14+
readonly planRevision: number;
15+
readonly assignmentId: string;
16+
readonly referencedCodeHash: string;
17+
readonly stateVersion: number;
18+
}
19+
export interface InvocationInput {
20+
readonly clone: TaskClone;
21+
readonly phase: Phase;
22+
readonly vendor: 'claude' | 'codex';
23+
readonly approvedArgv: readonly (readonly string[])[];
24+
readonly deadline: number;
25+
readonly attemptId: string;
26+
readonly context: InvocationContext;
27+
}
28+
export type StopReason = 'cancelled' | 'timeout' | 'shutdown' | 'output-limit' | 'capture-failure';
29+
export interface InvocationResult {
30+
readonly attemptId: string;
31+
readonly context: InvocationContext;
32+
readonly exitCode: number | null;
33+
readonly signal: string | null;
34+
readonly stopReason?: StopReason;
35+
readonly stdout: string;
36+
readonly stderr: string;
37+
}
38+
/**
39+
* F owns persisted pending/stale and admission; D owns running invocations.
40+
* cancel() records the first reason and requests termination, never settlement.
41+
* settled resolves only after the container AND capture processes terminate.
42+
* completed/failed/cancelled records are published by F using attemptId + context
43+
* CAS; discarded stale output still must settle before releasing D's slot.
44+
* Closing rejects admission before draining requests, cancelling, and awaiting
45+
* settlement. No retry may replace an active invocation, even after lease expiry.
46+
*/
47+
export interface InvocationHandle {
48+
readonly attemptId: string;
49+
readonly settled: Promise<InvocationResult>;
50+
cancel(reason: StopReason): void;
51+
}
52+
53+
const nonempty = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && !value.includes('\0');
54+
const integer = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0;
55+
56+
/** Capture a deep immutable request so caller edits cannot change an active run. */
57+
export function captureInvocation(input: InvocationInput, now = Date.now()): InvocationInput {
58+
if (!input || !input.clone || !input.context) throw new Error('Missing invocation context.');
59+
if (!['planning', 'questions', 'review', 'execute', 'fix'].includes(input.phase)
60+
|| !['claude', 'codex'].includes(input.vendor)) throw new Error('Unsupported invocation profile.');
61+
if (!nonempty(input.attemptId) || !nonempty(input.clone.id) || !nonempty(input.clone.taskId)
62+
|| !nonempty(input.clone.directory) || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(input.clone.head))
63+
throw new Error('Invalid task clone or attempt identity.');
64+
if (!Number.isFinite(now) || !Number.isSafeInteger(input.deadline) || input.deadline <= now)
65+
throw new Error('Invocation requires a finite future deadline.');
66+
const context = input.context;
67+
if (![context.snapshotId, context.planId, context.assignmentId, context.referencedCodeHash].every(nonempty)
68+
|| !integer(context.planRevision) || !integer(context.stateVersion)) throw new Error('Invalid captured context.');
69+
if (!Array.isArray(input.approvedArgv) || input.approvedArgv.some(argv => !Array.isArray(argv)
70+
|| argv.length === 0 || !nonempty(argv[0]) || argv.some(arg => typeof arg !== 'string' || arg.includes('\0'))))
71+
throw new Error('Commands must be complete literal argv arrays.');
72+
if (['planning', 'questions'].includes(input.phase) && input.approvedArgv.length)
73+
throw new Error('Read-only authoring and questions cannot execute commands.');
74+
return Object.freeze({ ...input, clone: Object.freeze({ ...input.clone }), context: Object.freeze({ ...context }),
75+
approvedArgv: Object.freeze(input.approvedArgv.map(argv => Object.freeze([...argv]))) });
76+
}
77+
78+
/** Dispatcher predicate, not a sandbox. An adapter must enforce this externally. */
79+
export function permitsCommand(input: InvocationInput, argv: readonly string[]): boolean {
80+
return !['planning', 'questions'].includes(input.phase) && input.approvedArgv.some(approved =>
81+
approved.length === argv.length && approved.every((arg, index) => arg === argv[index]));
82+
}

‎git/clone.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { randomUUID } from 'node:crypto';
3+
import { lstatSync, mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs';
4+
import { isAbsolute, join, relative, resolve } from 'node:path';
5+
import type { TaskClone } from '../agents/contract.ts';
6+
7+
/**
8+
* Prepare an independent committed snapshot. This is trusted staging, not the
9+
* writable execution filesystem: D2 must reserve bounded storage and separate
10+
* metadata before mounting it. Source must stay quiescent during this operation.
11+
* No hooks, filters from user config, credentials, submodules or network access.
12+
*/
13+
export function createTaskClone(options: {
14+
source: string; parent: string; taskId: string; head: string; timeoutMs?: number;
15+
}): TaskClone {
16+
if (!options.taskId || options.taskId.includes('\0')) throw new Error('Task identity is required.');
17+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(options.head)) throw new Error('A full committed head is required.');
18+
const timeout = options.timeoutMs ?? 30_000;
19+
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 120_000) throw new Error('Invalid clone deadline.');
20+
const deadline = performance.now() + timeout;
21+
const remaining = () => {
22+
const value = Math.ceil(deadline - performance.now());
23+
if (value <= 0) throw new Error('Clone deadline exceeded.');
24+
return value;
25+
};
26+
const source = realpathSync(options.source), parent = realpathSync(options.parent);
27+
const within = (base: string, path: string) => {
28+
const rel = relative(base, path);
29+
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../'));
30+
};
31+
if (within(source, parent)) throw new Error('Task storage must be outside the source repository.');
32+
// Deliberately do not inherit Git variables or credential/config environment.
33+
const env = { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
34+
GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1', GIT_GRAFT_FILE: '/dev/null' };
35+
const run = (cwd: string, ...args: string[]) => execFileSync('git', [
36+
'--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=',
37+
'-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', ...args,
38+
], { cwd, env, timeout: remaining(), killSignal: 'SIGKILL', maxBuffer: 1024 * 1024,
39+
stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
40+
const common = resolve(source, run(source, 'rev-parse', '--git-common-dir'));
41+
if (within(common, parent)) throw new Error('Task storage must be outside source metadata.');
42+
function audit(metadata: string, independent: boolean) {
43+
for (const name of ['shallow', 'info/grafts', 'objects/info/alternates', 'objects/info/http-alternates']) {
44+
if (lstatSync(join(metadata, name), { throwIfNoEntry: false })) throw new Error(`Unsupported Git storage: ${name}`);
45+
}
46+
const pending = [join(metadata, 'objects')];
47+
let count = 0;
48+
while (pending.length) {
49+
remaining();
50+
if (++count > 100_000) throw new Error('Object storage exceeds inspection limit.');
51+
const path = pending.pop()!, stat = lstatSync(path);
52+
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error('Unsupported object entry.');
53+
if (independent && stat.isFile() && stat.nlink !== 1) throw new Error('Task objects must not be hard-linked.');
54+
if (stat.isDirectory()) {
55+
const children = readdirSync(path);
56+
if (count + pending.length + children.length > 100_000) throw new Error('Object storage exceeds inspection limit.');
57+
pending.push(...children.map(child => join(path, child)));
58+
}
59+
}
60+
}
61+
audit(common, false);
62+
if (run(source, 'for-each-ref', '--format=%(refname)', 'refs/replace')) throw new Error('Replacement objects are unsupported.');
63+
if (run(source, 'rev-parse', '--verify', `${options.head}^{commit}`) !== options.head) throw new Error('Head is not a commit.');
64+
const directory = mkdtempSync(join(parent, 'codeboost-task-'));
65+
try {
66+
run(parent, '-c', 'protocol.file.allow=always', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, directory);
67+
const metadata = join(directory, '.git');
68+
if (!lstatSync(metadata).isDirectory()) throw new Error('Task requires standalone Git metadata.');
69+
audit(metadata, true);
70+
run(directory, 'remote', 'remove', 'origin');
71+
run(directory, 'checkout', '--detach', options.head);
72+
if (run(directory, 'rev-parse', 'HEAD') !== options.head) throw new Error('Task head changed during clone.');
73+
return Object.freeze({ id: randomUUID(), taskId: options.taskId, directory, head: options.head });
74+
} catch (error) {
75+
rmSync(directory, { recursive: true, force: true });
76+
throw error;
77+
}
78+
}

‎test/agent-clone.test.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, lstatSync, symlinkSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { afterEach, describe, expect, it } from 'vitest';
6+
import { createTaskClone } from '../git/clone.ts';
7+
8+
const roots: string[] = [];
9+
const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args],
10+
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
11+
function fixture() {
12+
const root = mkdtempSync(join(tmpdir(), 'clone-test-')); roots.push(root);
13+
const source = join(root, 'source'), parent = join(root, 'tasks');
14+
mkdirSync(source); mkdirSync(parent);
15+
git(source, 'init'); git(source, 'config', 'user.name', 'Test'); git(source, 'config', 'user.email', 'test@example.com');
16+
writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline');
17+
return { source, parent, head: git(source, 'rev-parse', 'HEAD'), taskId: 'task-1' };
18+
}
19+
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
20+
describe('isolated staging clone', () => {
21+
it('copies objects, ignores dirty source changes, and has no origin or shared metadata', () => {
22+
const input = fixture();
23+
writeFileSync(join(input.source, 'file.txt'), 'uncommitted');
24+
const clone = createTaskClone(input);
25+
expect(readFileSync(join(clone.directory, 'file.txt'), 'utf8')).toBe('trusted\n');
26+
expect(lstatSync(join(clone.directory, '.git')).isDirectory()).toBe(true);
27+
expect(git(clone.directory, 'status', '--porcelain')).toBe('');
28+
expect(git(clone.directory, 'remote')).toBe('');
29+
const hash = git(input.source, 'rev-parse', 'HEAD:file.txt');
30+
const path = join('objects', hash.slice(0, 2), hash.slice(2));
31+
const sourceObject = join(input.source, '.git', path), cloneObject = join(clone.directory, '.git', path);
32+
const before = readFileSync(sourceObject);
33+
expect(lstatSync(cloneObject).nlink).toBe(1);
34+
expect(lstatSync(cloneObject).ino).not.toBe(lstatSync(sourceObject).ino);
35+
writeFileSync(cloneObject, 'corrupted disposable task object');
36+
expect(readFileSync(sourceObject)).toEqual(before);
37+
expect(git(input.source, 'cat-file', '-p', hash)).toBe('trusted');
38+
});
39+
it('supports linked-worktree sources but produces standalone metadata', () => {
40+
const input = fixture(), linked = join(input.parent, 'linked');
41+
git(input.source, 'worktree', 'add', '--detach', linked, input.head);
42+
const clone = createTaskClone({ ...input, source: linked });
43+
expect(lstatSync(join(clone.directory, '.git')).isDirectory()).toBe(true);
44+
expect(git(clone.directory, 'rev-parse', 'HEAD')).toBe(input.head);
45+
});
46+
it.each(['objects/info/alternates', 'objects/info/http-alternates', 'info/grafts', 'shallow'])('rejects %s before allocating a clone', name => {
47+
const input = fixture();
48+
writeFileSync(join(input.source, '.git', name), '');
49+
expect(() => createTaskClone(input)).toThrow('Unsupported Git storage');
50+
expect(readdirSync(input.parent)).toEqual([]);
51+
});
52+
it('rejects object symlinks and replacements', () => {
53+
const input = fixture();
54+
symlinkSync('/tmp', join(input.source, '.git/objects/linked'));
55+
expect(() => createTaskClone(input)).toThrow('object entry');
56+
rmSync(join(input.source, '.git/objects/linked'));
57+
git(input.source, 'update-ref', `refs/replace/${input.head}`, input.head);
58+
expect(() => createTaskClone(input)).toThrow('Replacement');
59+
});
60+
it('rejects nested storage, including paths through symlinks', () => {
61+
const input = fixture(), nested = join(input.source, 'tasks'), alias = join(input.parent, 'alias');
62+
mkdirSync(nested); symlinkSync(nested, alias);
63+
expect(() => createTaskClone({ ...input, parent: alias })).toThrow('outside');
64+
});
65+
it('requires a full existing commit and finite time budget', () => {
66+
const input = fixture();
67+
expect(() => createTaskClone({ ...input, head: 'HEAD' })).toThrow('full committed');
68+
expect(() => createTaskClone({ ...input, head: 'f'.repeat(40) })).toThrow();
69+
expect(() => createTaskClone({ ...input, timeoutMs: Infinity })).toThrow('deadline');
70+
expect(readdirSync(input.parent)).toEqual([]);
71+
});
72+
it('does not inherit Git directory, index, configuration or object overrides', () => {
73+
const input = fixture();
74+
const keys = ['GIT_DIR', 'GIT_INDEX_FILE', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0'];
75+
const old = keys.map(key => process.env[key]);
76+
try {
77+
Object.assign(process.env, { GIT_DIR: '/missing', GIT_INDEX_FILE: '/missing', GIT_CONFIG_COUNT: '1',
78+
GIT_CONFIG_KEY_0: 'core.bare', GIT_CONFIG_VALUE_0: 'true' });
79+
const clone = createTaskClone(input);
80+
expect(readFileSync(join(clone.directory, 'file.txt'), 'utf8')).toBe('trusted\n');
81+
} finally {
82+
keys.forEach((key, i) => { if (old[i] === undefined) delete process.env[key]; else process.env[key] = old[i]; });
83+
}
84+
});
85+
});

‎test/agent-contract.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { captureInvocation, permitsCommand, type InvocationInput } from '../agents/contract.ts';
3+
4+
const request = (): InvocationInput => ({
5+
clone: { id: 'clone-1', taskId: 'task-1', directory: '/tasks/one', head: 'a'.repeat(40) },
6+
vendor: 'codex', phase: 'review', approvedArgv: [['npm', 'test']], deadline: 2000, attemptId: 'attempt-1',
7+
context: { snapshotId: 'snapshot-1', planId: 'plan-1', planRevision: 1, assignmentId: 'assignment-1',
8+
referencedCodeHash: 'hash-1', stateVersion: 3 },
9+
});
10+
describe('invocation boundary', () => {
11+
it('captures identity, context and exact argv independently of mutable caller state', () => {
12+
const original = request();
13+
const captured = captureInvocation(original, 1000);
14+
(original.approvedArgv[0] as string[]).push('--changed');
15+
(original.context as { stateVersion: number }).stateVersion = 4;
16+
expect(captured.context.stateVersion).toBe(3);
17+
expect(captured.approvedArgv).toEqual([['npm', 'test']]);
18+
expect(Object.isFrozen(captured.clone)).toBe(true);
19+
expect(Object.isFrozen(captured.context)).toBe(true);
20+
expect(Object.isFrozen(captured.approvedArgv[0])).toBe(true);
21+
expect(permitsCommand(captured, ['npm', 'test'])).toBe(true);
22+
expect(permitsCommand(captured, ['npm', 'test', '--changed'])).toBe(false);
23+
expect(permitsCommand(captured, ['npm'])).toBe(false);
24+
expect(permitsCommand(captured, ['sh', '-c', 'npm test'])).toBe(false);
25+
});
26+
it.each(['planning', 'questions'] as const)('%s cannot acquire command permission', phase => {
27+
expect(() => captureInvocation({ ...request(), phase }, 1000)).toThrow('cannot execute');
28+
const input = captureInvocation({ ...request(), phase, approvedArgv: [] }, 1000);
29+
expect(permitsCommand(input, ['npm', 'test'])).toBe(false);
30+
});
31+
it.each([NaN, Infinity, -1, 999, 1000, 1000.1])('rejects invalid deadline %s', deadline => {
32+
expect(() => captureInvocation({ ...request(), deadline }, 1000)).toThrow('deadline');
33+
});
34+
it.each([[], [''], ['npm', '\0'], 'npm test'])('rejects malformed argv %j', argv => {
35+
expect(() => captureInvocation({ ...request(), approvedArgv: [argv] } as InvocationInput, 1000)).toThrow('argv');
36+
});
37+
it('rejects missing context and unsupported profiles', () => {
38+
expect(() => captureInvocation({ ...request(), context: { ...request().context, stateVersion: -1 } }, 1000)).toThrow('context');
39+
expect(() => captureInvocation({ ...request(), phase: 'shell' } as unknown as InvocationInput, 1000)).toThrow('profile');
40+
expect(() => captureInvocation({ ...request(), attemptId: '' }, 1000)).toThrow('identity');
41+
});
42+
});

0 commit comments

Comments
 (0)