Skip to content

Commit 1fdee6c

Browse files
authored
D1: Add agent invocation contract and isolated task clones (#31)
* Add D1 invocation contract and independent task clones * Harden clone containment traversal and deadline validation
1 parent 62ad463 commit 1fdee6c

4 files changed

Lines changed: 348 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) || Array.from(input.approvedArgv).some(argv => !Array.isArray(argv)
70+
|| argv.length === 0 || !nonempty(argv[0]) || Array.from(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: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { randomUUID } from 'node:crypto';
3+
import { lstatSync, mkdtempSync, opendirSync, 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[]) => {
36+
const result = execFileSync('git', [
37+
'--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=',
38+
'-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', ...args,
39+
], { cwd, env, timeout: remaining(), killSignal: 'SIGKILL', maxBuffer: 1024 * 1024,
40+
stdio: ['ignore', 'pipe', 'pipe'] });
41+
remaining();
42+
return result.toString().trim();
43+
};
44+
const common = realpathSync(resolve(source, run(source, 'rev-parse', '--git-common-dir')));
45+
if (within(common, parent)) throw new Error('Task storage must be outside source metadata.');
46+
function audit(metadata: string, independent: boolean) {
47+
for (const name of ['shallow', 'info/grafts', 'objects/info/alternates', 'objects/info/http-alternates']) {
48+
if (lstatSync(join(metadata, name), { throwIfNoEntry: false })) throw new Error(`Unsupported Git storage: ${name}`);
49+
}
50+
const pending = [join(metadata, 'objects')];
51+
let count = 0;
52+
while (pending.length) {
53+
remaining();
54+
if (++count > 100_000) throw new Error('Object storage exceeds inspection limit.');
55+
const path = pending.pop()!, stat = lstatSync(path);
56+
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error('Unsupported object entry.');
57+
if (independent && stat.isFile() && stat.nlink !== 1) throw new Error('Task objects must not be hard-linked.');
58+
if (stat.isDirectory()) {
59+
const directory = opendirSync(path, { bufferSize: 1 });
60+
try {
61+
for (let entry = directory.readSync(); entry; entry = directory.readSync()) {
62+
remaining();
63+
if (count + pending.length >= 100_000) throw new Error('Object storage exceeds inspection limit.');
64+
pending.push(join(path, entry.name));
65+
}
66+
} finally { directory.closeSync(); }
67+
}
68+
}
69+
}
70+
audit(common, false);
71+
if (run(source, 'for-each-ref', '--format=%(refname)', 'refs/replace')) throw new Error('Replacement objects are unsupported.');
72+
if (run(source, 'rev-parse', '--verify', `${options.head}^{commit}`) !== options.head) throw new Error('Head is not a commit.');
73+
const directory = mkdtempSync(join(parent, 'codeboost-task-'));
74+
try {
75+
run(parent, '-c', 'protocol.file.allow=always', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, directory);
76+
const metadata = join(directory, '.git');
77+
if (!lstatSync(metadata).isDirectory()) throw new Error('Task requires standalone Git metadata.');
78+
audit(metadata, true);
79+
run(directory, 'remote', 'remove', 'origin');
80+
run(directory, 'checkout', '--detach', options.head);
81+
if (run(directory, 'rev-parse', 'HEAD') !== options.head) throw new Error('Task head changed during clone.');
82+
return Object.freeze({ id: randomUUID(), taskId: options.taskId, directory, head: options.head });
83+
} catch (error) {
84+
rmSync(directory, { recursive: true, force: true });
85+
throw error;
86+
}
87+
}

‎test/agent-clone.test.ts‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, lstatSync, symlinkSync, writeFileSync, renameSync, opendirSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
6+
import { createTaskClone } from '../git/clone.ts';
7+
8+
vi.mock('node:fs', async importOriginal => {
9+
const actual = await importOriginal<typeof import('node:fs')>();
10+
return { ...actual, readdirSync: vi.fn(actual.readdirSync), opendirSync: vi.fn(actual.opendirSync) };
11+
});
12+
vi.mock('node:child_process', async importOriginal => {
13+
const actual = await importOriginal<typeof import('node:child_process')>();
14+
return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
15+
});
16+
17+
const roots: string[] = [];
18+
const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args],
19+
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
20+
function fixture() {
21+
const root = mkdtempSync(join(tmpdir(), 'clone-test-')); roots.push(root);
22+
const source = join(root, 'source'), parent = join(root, 'tasks');
23+
mkdirSync(source); mkdirSync(parent);
24+
git(source, 'init'); git(source, 'config', 'user.name', 'Test'); git(source, 'config', 'user.email', 'test@example.com');
25+
writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline');
26+
return { source, parent, head: git(source, 'rev-parse', 'HEAD'), taskId: 'task-1' };
27+
}
28+
afterEach(() => { vi.restoreAllMocks(); vi.resetAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
29+
describe('isolated staging clone', () => {
30+
it('copies objects, ignores dirty source changes, and has no origin or shared metadata', () => {
31+
const input = fixture();
32+
writeFileSync(join(input.source, 'file.txt'), 'uncommitted');
33+
const clone = createTaskClone(input);
34+
expect(readFileSync(join(clone.directory, 'file.txt'), 'utf8')).toBe('trusted\n');
35+
expect(lstatSync(join(clone.directory, '.git')).isDirectory()).toBe(true);
36+
expect(git(clone.directory, 'status', '--porcelain')).toBe('');
37+
expect(git(clone.directory, 'remote')).toBe('');
38+
const hash = git(input.source, 'rev-parse', 'HEAD:file.txt');
39+
const path = join('objects', hash.slice(0, 2), hash.slice(2));
40+
const sourceObject = join(input.source, '.git', path), cloneObject = join(clone.directory, '.git', path);
41+
const before = readFileSync(sourceObject);
42+
expect(lstatSync(cloneObject).nlink).toBe(1);
43+
expect(lstatSync(cloneObject).ino).not.toBe(lstatSync(sourceObject).ino);
44+
writeFileSync(cloneObject, 'corrupted disposable task object');
45+
expect(readFileSync(sourceObject)).toEqual(before);
46+
expect(git(input.source, 'cat-file', '-p', hash)).toBe('trusted');
47+
});
48+
it('supports linked-worktree sources but produces standalone metadata', () => {
49+
const input = fixture(), linked = join(input.parent, 'linked');
50+
git(input.source, 'worktree', 'add', '--detach', linked, input.head);
51+
const clone = createTaskClone({ ...input, source: linked });
52+
expect(lstatSync(join(clone.directory, '.git')).isDirectory()).toBe(true);
53+
expect(git(clone.directory, 'rev-parse', 'HEAD')).toBe(input.head);
54+
});
55+
it.each(['objects/info/alternates', 'objects/info/http-alternates', 'info/grafts', 'shallow'])('rejects %s before allocating a clone', name => {
56+
const input = fixture();
57+
writeFileSync(join(input.source, '.git', name), '');
58+
expect(() => createTaskClone(input)).toThrow('Unsupported Git storage');
59+
expect(readdirSync(input.parent)).toEqual([]);
60+
});
61+
it('rejects object symlinks and replacements', () => {
62+
const input = fixture();
63+
symlinkSync('/tmp', join(input.source, '.git/objects/linked'));
64+
expect(() => createTaskClone(input)).toThrow('object entry');
65+
rmSync(join(input.source, '.git/objects/linked'));
66+
git(input.source, 'update-ref', `refs/replace/${input.head}`, input.head);
67+
expect(() => createTaskClone(input)).toThrow('Replacement');
68+
});
69+
it('rejects nested storage, including paths through symlinks', () => {
70+
const input = fixture(), nested = join(input.source, 'tasks'), alias = join(input.parent, 'alias');
71+
mkdirSync(nested); symlinkSync(nested, alias);
72+
expect(() => createTaskClone({ ...input, parent: alias })).toThrow('outside');
73+
});
74+
it('requires a full existing commit and finite time budget', () => {
75+
const input = fixture();
76+
expect(() => createTaskClone({ ...input, head: 'HEAD' })).toThrow('full committed');
77+
expect(() => createTaskClone({ ...input, head: 'f'.repeat(40) })).toThrow();
78+
expect(() => createTaskClone({ ...input, timeoutMs: Infinity })).toThrow('deadline');
79+
expect(readdirSync(input.parent)).toEqual([]);
80+
});
81+
it('canonicalizes symlinked common metadata before checking storage containment', () => {
82+
const input = fixture(), metadata = join(input.parent, 'metadata');
83+
renameSync(join(input.source, '.git'), metadata);
84+
symlinkSync(metadata, join(input.source, '.git'));
85+
const parent = join(metadata, 'tasks'); mkdirSync(parent);
86+
// Disputed intermediate state: Git reports a lexical path through the link.
87+
expect(git(input.source, 'rev-parse', '--git-common-dir')).toBe('.git');
88+
expect(lstatSync(join(input.source, '.git')).isSymbolicLink()).toBe(true);
89+
expect(() => createTaskClone({ ...input, parent })).toThrow('outside source metadata');
90+
expect(readdirSync(parent)).toEqual([]);
91+
});
92+
it('never materializes an entire object directory before checking its entry budget', () => {
93+
const input = fixture();
94+
// A bulk enumeration is forbidden even for a small fixture; this asserts
95+
// the disputed intermediate representation, not only a later limit error.
96+
vi.mocked(readdirSync).mockClear();
97+
createTaskClone(input);
98+
expect(readdirSync).not.toHaveBeenCalled();
99+
expect(opendirSync).toHaveBeenCalled();
100+
});
101+
it('rejects a successful final Git call that returns after the overall deadline', async () => {
102+
const input = fixture();
103+
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process');
104+
let elapsed = 0, lateResult = false;
105+
vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
106+
vi.mocked(execFileSync).mockImplementation(((file: string, args: string[], options: object) => {
107+
const result = actual.execFileSync(file, args, options);
108+
if (args.at(-2) === 'rev-parse' && args.at(-1) === 'HEAD') {
109+
elapsed = 1001; lateResult = true;
110+
}
111+
return result;
112+
}) as typeof execFileSync);
113+
expect(() => createTaskClone({ ...input, timeoutMs: 1000 })).toThrow('deadline');
114+
expect(lateResult).toBe(true);
115+
expect(readdirSync(input.parent)).toEqual([]);
116+
vi.mocked(execFileSync).mockImplementation(actual.execFileSync);
117+
});
118+
it('does not inherit Git directory, index, configuration or object overrides', () => {
119+
const input = fixture();
120+
const keys = ['GIT_DIR', 'GIT_INDEX_FILE', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0'];
121+
const old = keys.map(key => process.env[key]);
122+
try {
123+
Object.assign(process.env, { GIT_DIR: '/missing', GIT_INDEX_FILE: '/missing', GIT_CONFIG_COUNT: '1',
124+
GIT_CONFIG_KEY_0: 'core.bare', GIT_CONFIG_VALUE_0: 'true' });
125+
const clone = createTaskClone(input);
126+
expect(readFileSync(join(clone.directory, 'file.txt'), 'utf8')).toBe('trusted\n');
127+
} finally {
128+
keys.forEach((key, i) => { if (old[i] === undefined) delete process.env[key]; else process.env[key] = old[i]; });
129+
}
130+
});
131+
});

0 commit comments

Comments
 (0)