|
| 1 | +import { createHash } from 'node:crypto'; |
| 2 | +import { lstatSync, readdirSync, realpathSync } from 'node:fs'; |
| 3 | +import type { InvocationInput, Phase } from '../contract.ts'; |
| 4 | +import { AGENT_IMAGE } from './image.ts'; |
| 5 | + |
| 6 | +export interface TaskFilesystems { |
| 7 | + readonly keeper: string; |
| 8 | + readonly workVolume: string; |
| 9 | + readonly metadataVolume: string; |
| 10 | + readonly workBytes: number; |
| 11 | + readonly workInodes: number; |
| 12 | + readonly metadataBytes: number; |
| 13 | + readonly metadataInodes: number; |
| 14 | +} |
| 15 | +export interface ContainerProfile { |
| 16 | + readonly name: string; |
| 17 | + readonly args: readonly string[]; |
| 18 | + readonly expectedImage: string; |
| 19 | + readonly phase: Phase; |
| 20 | + readonly vendor: 'claude' | 'codex'; |
| 21 | + readonly networkMode: 'none' | 'bridge'; |
| 22 | + readonly filesystems: TaskFilesystems; |
| 23 | + readonly inputDirectory: string; |
| 24 | + readonly codexAuthFile?: string; |
| 25 | + readonly command: readonly string[]; |
| 26 | +} |
| 27 | +export interface ProfileOptions { |
| 28 | + readonly invocation: InvocationInput; |
| 29 | + readonly filesystems: TaskFilesystems; |
| 30 | + readonly inputDirectory: string; |
| 31 | + readonly command: readonly string[]; |
| 32 | + readonly codexAuthFile?: string; |
| 33 | + readonly claudeToken?: string; |
| 34 | +} |
| 35 | + |
| 36 | +const safeName = (value: string) => { |
| 37 | + const prefix = value.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 24); |
| 38 | + return `${prefix}-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`; |
| 39 | +}; |
| 40 | +const mount = (parts: Record<string, string | boolean>) => Object.entries(parts) |
| 41 | + .map(([key, value]) => value === true ? key : `${key}=${value}`).join(','); |
| 42 | +const mountSource = (path: string, kind: string) => { |
| 43 | + if (!path || /[\0\n,]/.test(path)) throw new Error(`${kind} path cannot be represented as a Docker mount.`); |
| 44 | + return path; |
| 45 | +}; |
| 46 | + |
| 47 | +export function createContainerProfile(options: ProfileOptions): ContainerProfile { |
| 48 | + const { invocation, filesystems } = options; |
| 49 | + if (!options.command.length || options.command.some(value => typeof value !== 'string' || value.includes('\0'))) |
| 50 | + throw new Error('Container command must be a complete literal argv array.'); |
| 51 | + const inputStat = options.inputDirectory ? lstatSync(options.inputDirectory) : undefined; |
| 52 | + if (!inputStat?.isDirectory() || (inputStat.mode & 0o005) !== 0o005) throw new Error('Schema input directory must be container-readable.'); |
| 53 | + const inputDirectory = mountSource(realpathSync(options.inputDirectory), 'Schema input'); |
| 54 | + const entries = readdirSync(inputDirectory); |
| 55 | + const schema = entries.length === 1 && entries[0] === 'schema.json' ? lstatSync(`${inputDirectory}/schema.json`) : undefined; |
| 56 | + if (!schema?.isFile() || schema.isSymbolicLink() || schema.nlink !== 1 || schema.size > 1024 * 1024 |
| 57 | + || (schema.mode & 0o004) === 0) |
| 58 | + throw new Error('Schema input must contain only one bounded, unlinked regular schema.json file.'); |
| 59 | + if (invocation.vendor === 'codex' && (!options.codexAuthFile || options.claudeToken)) |
| 60 | + throw new Error('Codex requires only its auth file.'); |
| 61 | + if (invocation.vendor === 'claude' && (!options.claudeToken || options.codexAuthFile)) |
| 62 | + throw new Error('Claude requires only its OAuth token.'); |
| 63 | + if (options.claudeToken?.includes('\0')) throw new Error('Claude OAuth token is malformed.'); |
| 64 | + if (!/^codeboost-work-[0-9a-f-]+$/.test(filesystems.workVolume) |
| 65 | + || !/^codeboost-metadata-[0-9a-f-]+$/.test(filesystems.metadataVolume) |
| 66 | + || !/^codeboost-keeper-[0-9a-f-]+$/.test(filesystems.keeper)) throw new Error('Task filesystem identity is invalid.'); |
| 67 | + if (options.codexAuthFile && !lstatSync(options.codexAuthFile).isFile()) |
| 68 | + throw new Error('Codex auth must be a direct regular file, not a link.'); |
| 69 | + const codexAuthFile = options.codexAuthFile ? mountSource(realpathSync(options.codexAuthFile), 'Codex auth') : undefined; |
| 70 | + if (codexAuthFile) { |
| 71 | + const auth = lstatSync(codexAuthFile); |
| 72 | + if (!auth.isFile() || auth.isSymbolicLink() || auth.size > 1024 * 1024) throw new Error('Codex auth must be a bounded regular file.'); |
| 73 | + } |
| 74 | + const name = `codeboost-agent-${safeName(invocation.attemptId)}`; |
| 75 | + const readOnlyWork = ['planning', 'questions', 'review'].includes(invocation.phase); |
| 76 | + const networkMode = 'none'; |
| 77 | + const args = ['create', '--name', name, '--read-only', '--user', '10001:10001', '--cap-drop=ALL', |
| 78 | + '--security-opt=no-new-privileges', '--pids-limit=128', '--memory=512m', '--cpus=1', |
| 79 | + `--network=${networkMode}`, '--env', 'HOME=/home/codeboost', '--env', `CODEBOOST_PHASE=${invocation.phase}`, |
| 80 | + '--env', `CODEBOOST_VENDOR=${invocation.vendor}`, '--env', 'npm_config_cache=/tmp/npm-cache', |
| 81 | + '--env', `CODEBOOST_WORK_BYTES=${filesystems.workBytes}`, '--env', `CODEBOOST_WORK_INODES=${filesystems.workInodes}`, |
| 82 | + '--env', `CODEBOOST_METADATA_BYTES=${filesystems.metadataBytes}`, '--env', `CODEBOOST_METADATA_INODES=${filesystems.metadataInodes}`, |
| 83 | + '--env', 'XDG_CACHE_HOME=/tmp/xdg-cache', |
| 84 | + '--tmpfs', '/tmp:rw,nosuid,nodev,size=33554432,nr_inodes=4096,mode=1777', |
| 85 | + '--tmpfs', '/home/codeboost:rw,nosuid,nodev,size=1048576,nr_inodes=128,uid=10001,gid=10001,mode=0700', |
| 86 | + '--mount', mount({ type: 'volume', source: filesystems.workVolume, target: '/work', readonly: readOnlyWork }), |
| 87 | + '--mount', mount({ type: 'volume', source: filesystems.metadataVolume, target: '/work/.git', readonly: true }), |
| 88 | + '--mount', mount({ type: 'bind', source: inputDirectory, target: '/run/codeboost-input', readonly: true })]; |
| 89 | + if (invocation.vendor === 'codex') { |
| 90 | + args.push('--env', 'CODEX_HOME=/run/codeboost-auth/codex', |
| 91 | + '--tmpfs', '/run/codeboost-auth/codex:rw,nosuid,nodev,size=4194304,nr_inodes=256,uid=10001,gid=10001,mode=0700', |
| 92 | + '--mount', mount({ type: 'bind', source: codexAuthFile!, target: '/run/codeboost-auth/codex/auth.json', readonly: true })); |
| 93 | + } else args.push('--env', 'CLAUDE_CODE_OAUTH_TOKEN'); |
| 94 | + args.push(AGENT_IMAGE, ...options.command); |
| 95 | + const capturedFilesystems = Object.freeze({ ...filesystems }); |
| 96 | + return Object.freeze({ name, args: Object.freeze(args), expectedImage: AGENT_IMAGE, |
| 97 | + phase: invocation.phase, vendor: invocation.vendor, networkMode, |
| 98 | + filesystems: capturedFilesystems, inputDirectory, codexAuthFile, |
| 99 | + command: Object.freeze([...options.command]) }); |
| 100 | +} |
0 commit comments