Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
82 changes: 82 additions & 0 deletions agents/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/** Lane D/F boundary. Only the runner may construct requests after admission. */
export interface TaskClone {
readonly id: string;
readonly taskId: string;
/** Staging clone, NOT a container-ready mount. D2 must allocate bounded storage. */
readonly directory: string;
readonly head: string;
}

export type Phase = 'planning' | 'questions' | 'review' | 'execute' | 'fix';
export interface InvocationContext {
readonly snapshotId: string;
readonly planId: string;
readonly planRevision: number;
readonly assignmentId: string;
readonly referencedCodeHash: string;
readonly stateVersion: number;
}
export interface InvocationInput {
readonly clone: TaskClone;
readonly phase: Phase;
readonly vendor: 'claude' | 'codex';
readonly approvedArgv: readonly (readonly string[])[];
readonly deadline: number;
readonly attemptId: string;
readonly context: InvocationContext;
}
export type StopReason = 'cancelled' | 'timeout' | 'shutdown' | 'output-limit' | 'capture-failure';
export interface InvocationResult {
readonly attemptId: string;
readonly context: InvocationContext;
readonly exitCode: number | null;
readonly signal: string | null;
readonly stopReason?: StopReason;
readonly stdout: string;
readonly stderr: string;
}
/**
* F owns persisted pending/stale and admission; D owns running invocations.
* cancel() records the first reason and requests termination, never settlement.
* settled resolves only after the container AND capture processes terminate.
* completed/failed/cancelled records are published by F using attemptId + context
* CAS; discarded stale output still must settle before releasing D's slot.
* Closing rejects admission before draining requests, cancelling, and awaiting
* settlement. No retry may replace an active invocation, even after lease expiry.
*/
export interface InvocationHandle {
readonly attemptId: string;
readonly settled: Promise<InvocationResult>;
cancel(reason: StopReason): void;
}

const nonempty = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && !value.includes('\0');
const integer = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0;

/** Capture a deep immutable request so caller edits cannot change an active run. */
export function captureInvocation(input: InvocationInput, now = Date.now()): InvocationInput {
if (!input || !input.clone || !input.context) throw new Error('Missing invocation context.');
if (!['planning', 'questions', 'review', 'execute', 'fix'].includes(input.phase)
|| !['claude', 'codex'].includes(input.vendor)) throw new Error('Unsupported invocation profile.');
if (!nonempty(input.attemptId) || !nonempty(input.clone.id) || !nonempty(input.clone.taskId)
|| !nonempty(input.clone.directory) || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(input.clone.head))
throw new Error('Invalid task clone or attempt identity.');
if (!Number.isFinite(now) || !Number.isSafeInteger(input.deadline) || input.deadline <= now)
throw new Error('Invocation requires a finite future deadline.');
const context = input.context;
if (![context.snapshotId, context.planId, context.assignmentId, context.referencedCodeHash].every(nonempty)
|| !integer(context.planRevision) || !integer(context.stateVersion)) throw new Error('Invalid captured context.');
if (!Array.isArray(input.approvedArgv) || Array.from(input.approvedArgv).some(argv => !Array.isArray(argv)
|| argv.length === 0 || !nonempty(argv[0]) || Array.from(argv).some(arg => typeof arg !== 'string' || arg.includes('\0'))))
throw new Error('Commands must be complete literal argv arrays.');
if (['planning', 'questions'].includes(input.phase) && input.approvedArgv.length)
throw new Error('Read-only authoring and questions cannot execute commands.');
return Object.freeze({ ...input, clone: Object.freeze({ ...input.clone }), context: Object.freeze({ ...context }),
approvedArgv: Object.freeze(input.approvedArgv.map(argv => Object.freeze([...argv]))) });
}

/** Dispatcher predicate, not a sandbox. An adapter must enforce this externally. */
export function permitsCommand(input: InvocationInput, argv: readonly string[]): boolean {
return !['planning', 'questions'].includes(input.phase) && input.approvedArgv.some(approved =>
approved.length === argv.length && approved.every((arg, index) => arg === argv[index]));
}
87 changes: 87 additions & 0 deletions git/clone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { lstatSync, mkdtempSync, opendirSync, realpathSync, rmSync } from 'node:fs';
import { isAbsolute, join, relative, resolve } from 'node:path';
import type { TaskClone } from '../agents/contract.ts';

/**
* Prepare an independent committed snapshot. This is trusted staging, not the
* writable execution filesystem: D2 must reserve bounded storage and separate
* metadata before mounting it. Source must stay quiescent during this operation.
* No hooks, filters from user config, credentials, submodules or network access.
*/
export function createTaskClone(options: {
source: string; parent: string; taskId: string; head: string; timeoutMs?: number;
}): TaskClone {
if (!options.taskId || options.taskId.includes('\0')) throw new Error('Task identity is required.');
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(options.head)) throw new Error('A full committed head is required.');
const timeout = options.timeoutMs ?? 30_000;
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 120_000) throw new Error('Invalid clone deadline.');
const deadline = performance.now() + timeout;
const remaining = () => {
const value = Math.ceil(deadline - performance.now());
if (value <= 0) throw new Error('Clone deadline exceeded.');
return value;
};
const source = realpathSync(options.source), parent = realpathSync(options.parent);
const within = (base: string, path: string) => {
const rel = relative(base, path);
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../'));
};
if (within(source, parent)) throw new Error('Task storage must be outside the source repository.');
// Deliberately do not inherit Git variables or credential/config environment.
const env = { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1', GIT_GRAFT_FILE: '/dev/null' };
const run = (cwd: string, ...args: string[]) => {
const result = execFileSync('git', [
'--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=',
'-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', ...args,
], { cwd, env, timeout: remaining(), killSignal: 'SIGKILL', maxBuffer: 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'] });
remaining();
return result.toString().trim();
};
const common = realpathSync(resolve(source, run(source, 'rev-parse', '--git-common-dir')));
if (within(common, parent)) throw new Error('Task storage must be outside source metadata.');
function audit(metadata: string, independent: boolean) {
for (const name of ['shallow', 'info/grafts', 'objects/info/alternates', 'objects/info/http-alternates']) {
if (lstatSync(join(metadata, name), { throwIfNoEntry: false })) throw new Error(`Unsupported Git storage: ${name}`);
}
const pending = [join(metadata, 'objects')];
let count = 0;
while (pending.length) {
remaining();
if (++count > 100_000) throw new Error('Object storage exceeds inspection limit.');
const path = pending.pop()!, stat = lstatSync(path);
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error('Unsupported object entry.');
if (independent && stat.isFile() && stat.nlink !== 1) throw new Error('Task objects must not be hard-linked.');
if (stat.isDirectory()) {
const directory = opendirSync(path, { bufferSize: 1 });
try {
for (let entry = directory.readSync(); entry; entry = directory.readSync()) {
remaining();
if (count + pending.length >= 100_000) throw new Error('Object storage exceeds inspection limit.');
pending.push(join(path, entry.name));
}
} finally { directory.closeSync(); }
}
}
}
audit(common, false);
if (run(source, 'for-each-ref', '--format=%(refname)', 'refs/replace')) throw new Error('Replacement objects are unsupported.');
if (run(source, 'rev-parse', '--verify', `${options.head}^{commit}`) !== options.head) throw new Error('Head is not a commit.');
const directory = mkdtempSync(join(parent, 'codeboost-task-'));
try {
run(parent, '-c', 'protocol.file.allow=always', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, directory);
const metadata = join(directory, '.git');
if (!lstatSync(metadata).isDirectory()) throw new Error('Task requires standalone Git metadata.');
audit(metadata, true);
run(directory, 'remote', 'remove', 'origin');
run(directory, 'checkout', '--detach', options.head);
if (run(directory, 'rev-parse', 'HEAD') !== options.head) throw new Error('Task head changed during clone.');
return Object.freeze({ id: randomUUID(), taskId: options.taskId, directory, head: options.head });
} catch (error) {
rmSync(directory, { recursive: true, force: true });
throw error;
}
}
131 changes: 131 additions & 0 deletions test/agent-clone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, lstatSync, symlinkSync, writeFileSync, renameSync, opendirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createTaskClone } from '../git/clone.ts';

vi.mock('node:fs', async importOriginal => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, readdirSync: vi.fn(actual.readdirSync), opendirSync: vi.fn(actual.opendirSync) };
});
vi.mock('node:child_process', async importOriginal => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
});

const roots: string[] = [];
const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args],
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'clone-test-')); roots.push(root);
const source = join(root, 'source'), parent = join(root, 'tasks');
mkdirSync(source); mkdirSync(parent);
git(source, 'init'); git(source, 'config', 'user.name', 'Test'); git(source, 'config', 'user.email', 'test@example.com');
writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline');
return { source, parent, head: git(source, 'rev-parse', 'HEAD'), taskId: 'task-1' };
}
afterEach(() => { vi.restoreAllMocks(); vi.resetAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
describe('isolated staging clone', () => {
it('copies objects, ignores dirty source changes, and has no origin or shared metadata', () => {
const input = fixture();
writeFileSync(join(input.source, 'file.txt'), 'uncommitted');
const clone = createTaskClone(input);
expect(readFileSync(join(clone.directory, 'file.txt'), 'utf8')).toBe('trusted\n');
expect(lstatSync(join(clone.directory, '.git')).isDirectory()).toBe(true);
expect(git(clone.directory, 'status', '--porcelain')).toBe('');
expect(git(clone.directory, 'remote')).toBe('');
const hash = git(input.source, 'rev-parse', 'HEAD:file.txt');
const path = join('objects', hash.slice(0, 2), hash.slice(2));
const sourceObject = join(input.source, '.git', path), cloneObject = join(clone.directory, '.git', path);
const before = readFileSync(sourceObject);
expect(lstatSync(cloneObject).nlink).toBe(1);
expect(lstatSync(cloneObject).ino).not.toBe(lstatSync(sourceObject).ino);
writeFileSync(cloneObject, 'corrupted disposable task object');
expect(readFileSync(sourceObject)).toEqual(before);
expect(git(input.source, 'cat-file', '-p', hash)).toBe('trusted');
});
it('supports linked-worktree sources but produces standalone metadata', () => {
const input = fixture(), linked = join(input.parent, 'linked');
git(input.source, 'worktree', 'add', '--detach', linked, input.head);
const clone = createTaskClone({ ...input, source: linked });
expect(lstatSync(join(clone.directory, '.git')).isDirectory()).toBe(true);
expect(git(clone.directory, 'rev-parse', 'HEAD')).toBe(input.head);
});
it.each(['objects/info/alternates', 'objects/info/http-alternates', 'info/grafts', 'shallow'])('rejects %s before allocating a clone', name => {
const input = fixture();
writeFileSync(join(input.source, '.git', name), '');
expect(() => createTaskClone(input)).toThrow('Unsupported Git storage');
expect(readdirSync(input.parent)).toEqual([]);
});
it('rejects object symlinks and replacements', () => {
const input = fixture();
symlinkSync('/tmp', join(input.source, '.git/objects/linked'));
expect(() => createTaskClone(input)).toThrow('object entry');
rmSync(join(input.source, '.git/objects/linked'));
git(input.source, 'update-ref', `refs/replace/${input.head}`, input.head);
expect(() => createTaskClone(input)).toThrow('Replacement');
});
it('rejects nested storage, including paths through symlinks', () => {
const input = fixture(), nested = join(input.source, 'tasks'), alias = join(input.parent, 'alias');
mkdirSync(nested); symlinkSync(nested, alias);
expect(() => createTaskClone({ ...input, parent: alias })).toThrow('outside');
});
it('requires a full existing commit and finite time budget', () => {
const input = fixture();
expect(() => createTaskClone({ ...input, head: 'HEAD' })).toThrow('full committed');
expect(() => createTaskClone({ ...input, head: 'f'.repeat(40) })).toThrow();
expect(() => createTaskClone({ ...input, timeoutMs: Infinity })).toThrow('deadline');
expect(readdirSync(input.parent)).toEqual([]);
});
it('canonicalizes symlinked common metadata before checking storage containment', () => {
const input = fixture(), metadata = join(input.parent, 'metadata');
renameSync(join(input.source, '.git'), metadata);
symlinkSync(metadata, join(input.source, '.git'));
const parent = join(metadata, 'tasks'); mkdirSync(parent);
// Disputed intermediate state: Git reports a lexical path through the link.
expect(git(input.source, 'rev-parse', '--git-common-dir')).toBe('.git');
expect(lstatSync(join(input.source, '.git')).isSymbolicLink()).toBe(true);
expect(() => createTaskClone({ ...input, parent })).toThrow('outside source metadata');
expect(readdirSync(parent)).toEqual([]);
});
it('never materializes an entire object directory before checking its entry budget', () => {
const input = fixture();
// A bulk enumeration is forbidden even for a small fixture; this asserts
// the disputed intermediate representation, not only a later limit error.
vi.mocked(readdirSync).mockClear();
createTaskClone(input);
expect(readdirSync).not.toHaveBeenCalled();
expect(opendirSync).toHaveBeenCalled();
});
it('rejects a successful final Git call that returns after the overall deadline', async () => {
const input = fixture();
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process');
let elapsed = 0, lateResult = false;
vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
vi.mocked(execFileSync).mockImplementation(((file: string, args: string[], options: object) => {
const result = actual.execFileSync(file, args, options);
if (args.at(-2) === 'rev-parse' && args.at(-1) === 'HEAD') {
elapsed = 1001; lateResult = true;
}
return result;
}) as typeof execFileSync);
expect(() => createTaskClone({ ...input, timeoutMs: 1000 })).toThrow('deadline');
expect(lateResult).toBe(true);
expect(readdirSync(input.parent)).toEqual([]);
vi.mocked(execFileSync).mockImplementation(actual.execFileSync);
});
it('does not inherit Git directory, index, configuration or object overrides', () => {
const input = fixture();
const keys = ['GIT_DIR', 'GIT_INDEX_FILE', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0'];
const old = keys.map(key => process.env[key]);
try {
Object.assign(process.env, { GIT_DIR: '/missing', GIT_INDEX_FILE: '/missing', GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.bare', GIT_CONFIG_VALUE_0: 'true' });
const clone = createTaskClone(input);
expect(readFileSync(join(clone.directory, 'file.txt'), 'utf8')).toBe('trusted\n');
} finally {
keys.forEach((key, i) => { if (old[i] === undefined) delete process.env[key]; else process.env[key] = old[i]; });
}
});
});
Loading
Loading