diff --git a/docs/remote-bridge/worker-runbook.md b/docs/remote-bridge/worker-runbook.md index aeeef4c4..8cbeda98 100644 --- a/docs/remote-bridge/worker-runbook.md +++ b/docs/remote-bridge/worker-runbook.md @@ -255,7 +255,10 @@ Important semantics: existing ID such as `primary` to preserve agent/conversation bindings. - `repo` and `ref` are labels. They do not clone, fetch, or check out anything. - `root` must already exist. Relative roots resolve from the definition file. -- Setup runs before registration on every worker start. It must be idempotent. +- Setup runs before registration on every worker start by default. It must be + idempotent. Optional `setup.reuse` declares fingerprint inputs and a sandboxed + readiness check to avoid reinstalling an unchanged, still-ready checkout. See + [preparation reuse](../../packages/code/README.md#reusing-a-prepared-checkout). - A setup failure or timeout prevents registration and leaves a durable quarantine marker for operator inspection. - Actions are fixed operator commands. The model selects only the action name diff --git a/packages/code/README.md b/packages/code/README.md index d421e444..0e157153 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -1035,7 +1035,7 @@ worker runs. This inspection happens at startup, not on the command hot path. Setup is an operator-authorized startup command under the configured native sandbox policy. It requires commands to be enabled, runs once per worker startup before -registration, and must be idempotent for restarts. Its timeout is bounded to five +registration by default, and must be idempotent for restarts. Its timeout is bounded to five minutes and captured output to 8 KiB. Setup failure prevents registration. A nonzero exit, timeout, crash or uncertain termination retains the workspace quarantine marker; inspect the workspace before running `librechat-code clear-workspace-quarantine @@ -1043,6 +1043,55 @@ inspect the workspace before running `librechat-code clear-workspace-quarantine deployment and identity configuration. Only use the separate `--reset-workspace-quarantine ` run option afterward if a server fence also needs clearing. Only successful setup automatically clears its marker. + +## Reusing a prepared checkout + +Opt in to checkout-local preparation reuse when setup is an installation rather +than work that must run on every startup: + +```yaml +setup: + command: npm ci + timeoutMs: 300000 + reuse: + inputs: + - package.json + - package-lock.json + - packages/api/package.json + - packages/data-provider/package.json + checkCommand: test -f node_modules/.package-lock.json && test -x node_modules/.bin/tsc + checkTimeoutMs: 10000 +``` + +Declare **all** relevant manifests, installation configuration and lifecycle-script +inputs. There is no globbing or automatic monorepo discovery. Inputs must be +existing root-confined regular files: at most 32, 8 MiB per file and 32 MiB total. +Include an operator-maintained toolchain/version file if installation uses tools +other than the worker's Node runtime. Lockfile equality alone does not prove that +arbitrary postinstall scripts are reusable. + +The worker fingerprints declared file bytes, the setup recipe, checkout inode, +Node version/ABI, platform/architecture and native command policy. A matching +worker-owned receipt runs the readiness check instead of setup. A nonzero check +reruns setup; a timed-out, signalled or aborted check fails without starting a +replacement command. After successful setup, the check must pass and inputs must +remain unchanged before the worker publishes a receipt. Receipts are bounded, +owner-only files alongside the identity, outside all registered roots and denied +to native commands. Deleting one causes setup to run again; it never clears a +quarantine. Checks are operator commands under the same sandbox and mutation guard +as setup, not unsandboxed host scripts. Keep them cheap and non-mutating. + +Existing definitions without `reuse` retain the startup behavior. Fresh conversation +instances use the same preparation contract, with independent checkout receipts. +This does not attach another checkout's `node_modules`, provision linked lanes on +command admission, or recheck existing instances on every command. It does not +deduplicate installed dependencies between worktrees or enforce disk quotas. + +The next resource-store slice must explicitly grant shared cache paths under SRT, +keep monorepo links and mutable outputs checkout-local, and bound retention. Do not +work around that missing grant by broadening the sandbox root or symlinking another +branch's full installation. Shared download caches alone do not reduce installed +`node_modules` copies. No setup output is sent to the model. Named actions are fixed commands without model-supplied substitution. The bridge diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 410caf25..054074ed 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -2,7 +2,7 @@ import { createHash, createHmac, randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { readdir, realpath, stat } from 'node:fs/promises'; -import { basename, join, resolve, relative, isAbsolute, sep } from 'node:path'; +import { basename, dirname, join, resolve, relative, isAbsolute, sep } from 'node:path'; import { pairBridgeWorker } from './pairing.js'; import { discoverProjects } from './projects.js'; @@ -12,6 +12,7 @@ import { assertEnvironmentDefinitionsOutsideRoots, EnvironmentWorkspaceTools, } from './environment.js'; +import { prepareCodeEnvironment } from './environment-preparation.js'; import { startFileRelay } from './relay.js'; import { DockerFileRelaySupervisor } from './relay-runtime.js'; import { @@ -26,6 +27,7 @@ import { loadWorkspaceMutationQuarantine, saveBridgeIdentity, saveWorkspaceMutationQuarantine, + prepareEnvironmentPreparationDirectory, } from './storage.js'; import { BridgeWorker } from './worker.js'; import { LocalWorkspaceTools, SandboxWorkspaceTools } from './workspace.js'; @@ -826,6 +828,17 @@ async function run( }), ]), ); + const preparationDirectory = join(dirname(identityPath ?? defaultBridgeIdentityPath(workerId)), 'environment-preparation'); + if (environments.some(environment => environment.definition.setup?.reuse)) { + const sourceParents = await prepareEnvironmentPreparationDirectory(preparationDirectory); + // Reuse the mount/ancestor isolation checks for this worker-owned state directory. + await assertEnvironmentDefinitionsOutsideRoots([{ + path: preparationDirectory, sourceParents, + definition: { name: 'preparation-state', root: preparationDirectory }, fingerprint: '', + }], roots); + } + const preparationReceipt = (root: string) => join(preparationDirectory, + `${createHash('sha256').update(JSON.stringify([codeApiUrl, workerId, root])).digest('hex')}.json`); // Keep an admission boundary even when trusted-VM checkout routing uses a // nested repository's remote for the current command. const admittedGitHubRepositories = github.provider && github.repositoryRouting @@ -1048,6 +1061,7 @@ async function run( commandPolicy, protectedPaths: [ identityPath, + ...(environments.some(environment => environment.definition.setup?.reuse) ? [preparationDirectory] : []), ...environments.map(environment => environment.path), ...rootQuarantinePaths.values(), github.privateKeyPath, @@ -1151,22 +1165,20 @@ async function run( workspaceIdentity: instance.identity, workspaceRoot: instance.root, }); - const result = await nativeCommandSandbox.execute( - { + await prepareCodeEnvironment({ + root: instance.root, identity: instance.identity, setup, + receiptPath: preparationReceipt(instance.root), + context: JSON.stringify([serializeNativeSrtCommandPolicy(commandPolicy), commandAllowedDomains, github.policyIdentity]), + signal, + execute: (command, timeoutMs) => nativeCommandSandbox.execute({ protocolVersion: 1, operation: 'execute_command', workspaceId: id, - command: setup.command, - timeoutMs: setup.timeoutMs, + command, + timeoutMs, maxOutputBytes: 8192, - }, - signal, - ); - if (result.exitCode !== 0 || result.timedOut) { - throw new Error( - `Environment ${instance.sourceWorkspaceId} setup failed for its conversation worktree`, - ); - } + }, signal), + }); }, discardInstance: async (instance) => { await nativeCommandSandbox.unregisterRoot( @@ -1323,26 +1335,36 @@ async function run( incarnationId, ); await guard.assertAvailable(); - await guard.arm('Environment setup did not settle', 'setup'); - const result = await nativeCommandSandbox.execute( - { + let armed = false; + const preparation = await prepareCodeEnvironment({ + root: environment.definition.root, + identity: roots.find(root => root.id === id)!.identity!, + setup, receiptPath: preparationReceipt(environment.definition.root), + context: JSON.stringify([serializeNativeSrtCommandPolicy(commandPolicy), commandAllowedDomains, github.policyIdentity]), + signal: controller.signal, + execute: async (command, timeoutMs) => { + if (!armed) { + await guard.arm('Environment preparation did not settle', 'setup'); + armed = true; + } + return nativeCommandSandbox.execute({ protocolVersion: 1, operation: 'execute_command', workspaceId: id, - command: setup.command, - timeoutMs: setup.timeoutMs, + command, + timeoutMs, maxOutputBytes: 8192, + }, controller.signal); }, - controller.signal, - ); - if (result.exitCode !== 0 || result.timedOut) { + }).catch(error => { throw new Error( `Environment ${id} setup failed; inspect the workspace and use clear-workspace-quarantine with its root and workspace ID before restarting`, + { cause: error }, ); - } + }); await guard.clear('setup'); process.stdout.write( - `librechat-code: environment ${id} prepared\n`, + `librechat-code: environment ${id} ${preparation}\n`, ); } } catch (error) { diff --git a/packages/code/src/environment-preparation.test.ts b/packages/code/src/environment-preparation.test.ts new file mode 100644 index 00000000..51149441 --- /dev/null +++ b/packages/code/src/environment-preparation.test.ts @@ -0,0 +1,367 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { + chmod, + mkdtemp, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import test from 'node:test'; +import { prepareCodeEnvironment } from './environment-preparation.js'; +import { parseCodeEnvironment } from './environment.js'; +import { captureWorkspaceRootIdentity } from './root-identity.js'; +import { prepareEnvironmentPreparationDirectory } from './storage.js'; +import type { EnvironmentPreparationOptions } from './environment-preparation.js'; +import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; + +async function fixture(t: test.TestContext) { + const directory = await mkdtemp(join(tmpdir(), 'code-preparation-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const root = join(directory, 'checkout'); + const state = join(directory, 'private'); + await mkdir(root); + await prepareEnvironmentPreparationDirectory(state); + await writeFile(join(root, 'package-lock.json'), 'lock-v1'); + const commands: string[] = []; + const options: EnvironmentPreparationOptions = { + root: (await captureWorkspaceRootIdentity(root)).path, + identity: await captureWorkspaceRootIdentity(root), + receiptPath: join(state, 'receipt.json'), + context: 'policy-v1', + setup: { + command: 'install', + timeoutMs: 1000, + reuse: { + inputs: ['package-lock.json'], + checkCommand: 'check', + checkTimeoutMs: 100, + }, + }, + async execute(command) { + commands.push(command); + return { exitCode: 0, timedOut: false }; + }, + }; + return { root, state, commands, options }; +} + +test('unchanged checkout checks readiness without repeating installation, including after restart', async t => { + const { options, commands } = await fixture(t); + assert.equal(await prepareCodeEnvironment(options), 'prepared'); + assert.equal(await prepareCodeEnvironment({ ...options }), 'reused'); + assert.deepEqual(commands, ['install', 'check', 'check']); + assert.equal( + (await readFile(options.receiptPath, 'utf8')).includes('lock-v1'), + false, + ); +}); + +test('changed inputs, recipe and toolchain/policy context invalidate preparation', async t => { + const { options, root, commands } = await fixture(t); + await prepareCodeEnvironment(options); + await writeFile(join(root, 'package-lock.json'), 'lock-v2'); + await prepareCodeEnvironment(options); + await prepareCodeEnvironment({ ...options, context: 'policy-v2' }); + await prepareCodeEnvironment({ + ...options, + context: 'policy-v2', + setup: { ...options.setup, command: 'install-v2' }, + }); + assert.deepEqual(commands, [ + 'install', + 'check', + 'install', + 'check', + 'install', + 'check', + 'install-v2', + 'check', + ]); +}); + +test('missing installed artifacts trigger repair; a failed setup is never stamped', async t => { + const { options, commands } = await fixture(t); + await prepareCodeEnvironment(options); + let installed = false; + await prepareCodeEnvironment({ + ...options, + async execute(command) { + commands.push(command); + if (command === 'install') installed = true; + return { exitCode: installed ? 0 : 1, timedOut: false }; + }, + }); + assert.deepEqual(commands, [ + 'install', + 'check', + 'check', + 'install', + 'check', + ]); + const failed = { + ...options, + receiptPath: join(options.receiptPath, '..', 'failed.json'), + execute: async () => ({ exitCode: 1, timedOut: false }), + }; + await assert.rejects(prepareCodeEnvironment(failed), /setup failed/); + await assert.rejects(readFile(failed.receiptPath), { code: 'ENOENT' }); +}); + +test('timed-out readiness and cancellation do not fall through to another setup', async t => { + const { options, commands } = await fixture(t); + await prepareCodeEnvironment(options); + await assert.rejects( + prepareCodeEnvironment({ + ...options, + execute: async command => { + commands.push(command); + return { exitCode: 0, timedOut: true }; + }, + }), + /did not settle/, + ); + assert.deepEqual(commands, ['install', 'check', 'check']); + const controller = new AbortController(); + await assert.rejects( + prepareCodeEnvironment({ + ...options, + signal: controller.signal, + execute: async () => { + controller.abort(); + return { exitCode: 0, timedOut: false }; + }, + }), + { name: 'AbortError' }, + ); +}); + +test('changing inputs during setup or readiness never publishes success', async t => { + const { options, root } = await fixture(t); + await assert.rejects( + prepareCodeEnvironment({ + ...options, + execute: async () => { + await writeFile(join(root, 'package-lock.json'), 'changed'); + return { exitCode: 0, timedOut: false }; + }, + }), + /inputs changed/, + ); + await assert.rejects(readFile(options.receiptPath), { code: 'ENOENT' }); + await assert.rejects( + prepareCodeEnvironment({ + ...options, + execute: async command => { + if (command === 'check') + await writeFile( + join(root, 'package-lock.json'), + 'changed-again', + ); + return { exitCode: 0, timedOut: false }; + }, + }), + /inputs changed/, + ); +}); + +test('input hashing is bounded and refuses symlink traversal and non-files', async t => { + const { options, root } = await fixture(t); + const outside = join(root, '..', 'outside'); + await mkdir(outside); + await writeFile(join(outside, 'secret'), 'do-not-read'); + await symlink(outside, join(root, 'escape')); + for (const path of ['escape/secret', 'escape']) { + await assert.rejects( + prepareCodeEnvironment({ + ...options, + setup: { + ...options.setup, + reuse: { ...options.setup.reuse!, inputs: [path] }, + }, + }), + ); + } + await writeFile(join(root, 'big'), Buffer.alloc(8 * 1024 * 1024 + 1)); + await assert.rejects( + prepareCodeEnvironment({ + ...options, + setup: { + ...options.setup, + reuse: { ...options.setup.reuse!, inputs: ['big'] }, + }, + }), + /bounded regular-file/, + ); +}); + +test('legacy setup always runs and does not create preparation state', async t => { + const { options, commands } = await fixture(t); + const legacy = { + ...options, + setup: { command: 'install', timeoutMs: 1000 }, + }; + await prepareCodeEnvironment(legacy); + await prepareCodeEnvironment(legacy); + assert.deepEqual(commands, ['install', 'install']); + await assert.rejects(readFile(options.receiptPath), { code: 'ENOENT' }); +}); + +test('a foreign checkout receipt is not installation evidence and exposed state fails closed', async t => { + const { options, commands } = await fixture(t); + await prepareCodeEnvironment(options); + const other = join(options.root, '..', 'other'); + await mkdir(other); + await writeFile(join(other, 'package-lock.json'), 'lock-v1'); + await prepareCodeEnvironment({ + ...options, + root: (await captureWorkspaceRootIdentity(other)).path, + identity: await captureWorkspaceRootIdentity(other), + }); + assert.deepEqual(commands, ['install', 'check', 'install', 'check']); + await chmod(options.receiptPath, 0o644); + await assert.rejects( + prepareCodeEnvironment({ + ...options, + root: (await captureWorkspaceRootIdentity(other)).path, + identity: await captureWorkspaceRootIdentity(other), + }), + /owner-only/, + ); +}); + +test('reuse YAML requires bounded explicit inputs and readiness check', () => { + assert.ok( + parseCodeEnvironment( + 'name: app\nroot: app\nsetup:\n command: npm ci\n reuse:\n inputs: [package.json, package-lock.json]\n checkCommand: test -d node_modules\n', + ).setup?.reuse, + ); + for (const reuse of [ + 'inputs: []\ncheckCommand: ready', + 'inputs: [../outside]\ncheckCommand: ready', + 'inputs: [package.json, package.json]\ncheckCommand: ready', + 'inputs: [package.json]', + 'inputs: [package.json]\ncheckCommand: ready\ncheckTimeoutMs: 0', + 'inputs: [package.json]\ncheckCommand: ready\nunknown: true', + ]) + assert.throws(() => + parseCodeEnvironment( + `name: app\nroot: app\nsetup:\n command: install\n reuse:\n${reuse + .split('\n') + .map(line => ` ${line}`) + .join('\n')}\n`, + ), + ); +}); + +test('real local shell canary: installation reused, deleted output repaired, changed lockfile reinstalled', async t => { + const { options } = await fixture(t); + const run = promisify(execFile); + const ready = join(options.root, 'installed'); + const configured: EnvironmentPreparationOptions = { + ...options, + setup: { + command: 'printf "install\\n" >> installs.log; mkdir -p installed', + timeoutMs: 1000, + reuse: { + inputs: ['package-lock.json'], + checkCommand: 'test -d installed', + checkTimeoutMs: 1000, + }, + }, + async execute(command, timeout) { + try { + await run('/bin/sh', ['-c', command], { + cwd: options.root, + timeout, + }); + return { exitCode: 0, timedOut: false }; + } catch (error) { + return { + exitCode: 1, + timedOut: Boolean((error as { killed?: boolean }).killed), + }; + } + }, + }; + await prepareCodeEnvironment(configured); + assert.equal(await prepareCodeEnvironment(configured), 'reused'); + await rm(ready, { recursive: true }); + await prepareCodeEnvironment(configured); + await writeFile(resolve(options.root, 'package-lock.json'), 'new-lock'); + await prepareCodeEnvironment(configured); + assert.equal( + await readFile(join(options.root, 'installs.log'), 'utf8'), + 'install\ninstall\ninstall\n', + ); +}); + +test( + 'real native SRT preparation reuse keeps receipts inaccessible to checkout commands', + { + skip: process.env.LIBRECHAT_CODE_LIVE_SRT_TESTS !== '1', + timeout: 30_000, + }, + async t => { + const { options, state } = await fixture(t); + const sandbox = new NativeProcessWorkspaceCommandSandbox({ + workspaceRoot: options.root, + workspaceIdentity: options.identity, + protectedPaths: [state], + }); + t.after(() => sandbox.close()); + await sandbox.prepare(); + const execute: EnvironmentPreparationOptions['execute'] = ( + command, + timeoutMs, + ) => + sandbox.execute({ + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + command, + timeoutMs, + maxOutputBytes: 1024, + }); + const configured = { + ...options, + execute, + setup: { + command: + 'printf "install\\n" >> installs.log; mkdir -p installed', + timeoutMs: 5000, + reuse: { + inputs: ['package-lock.json'], + checkCommand: 'test -d installed', + checkTimeoutMs: 5000, + }, + }, + }; + assert.equal(await prepareCodeEnvironment(configured), 'prepared'); + assert.equal(await prepareCodeEnvironment(configured), 'reused'); + assert.notEqual( + (await execute(`cat '${options.receiptPath}'`, 5000)).exitCode, + 0, + ); + assert.notEqual( + (await execute(`printf forged > '${options.receiptPath}'`, 5000)) + .exitCode, + 0, + ); + assert.equal( + await readFile(join(options.root, 'installs.log'), 'utf8'), + 'install\n', + ); + await rm(join(options.root, 'installed'), { recursive: true }); + assert.equal(await prepareCodeEnvironment(configured), 'prepared'); + assert.equal( + await readFile(join(options.root, 'installs.log'), 'utf8'), + 'install\ninstall\n', + ); + }, +); diff --git a/packages/code/src/environment-preparation.ts b/packages/code/src/environment-preparation.ts new file mode 100644 index 00000000..c2597f3b --- /dev/null +++ b/packages/code/src/environment-preparation.ts @@ -0,0 +1,150 @@ +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { resolve } from 'node:path'; +import { open, withWorkspaceRoot } from './root-access.js'; +import { + loadEnvironmentPreparationKey, + saveEnvironmentPreparationKey, +} from './storage.js'; +import type { CodeEnvironmentDefinition } from './environment.js'; +import type { WorkspaceRootIdentity } from './root-identity.js'; + +// Safety bounds on operator-declared hashing, not a dependency-store quota. +const MAX_INPUT_BYTES = 8 * 1024 * 1024; +const MAX_TOTAL_INPUT_BYTES = 32 * 1024 * 1024; + +export interface EnvironmentPreparationOptions { + root: string; + identity: WorkspaceRootIdentity; + setup: NonNullable; + receiptPath: string; + /** Includes the worker's policy and toolchain configuration. */ + context: string; + execute( + command: string, + timeoutMs: number, + ): Promise<{ exitCode: number | null; timedOut: boolean }>; + signal?: AbortSignal; +} + +/** Checkout-local reuse. Never transfers mutable installations between worktrees. */ +export async function prepareCodeEnvironment( + options: EnvironmentPreparationOptions, +): Promise<'prepared' | 'reused'> { + options.signal?.throwIfAborted(); + const key = await preparationKey(options); + if ( + key && + (await loadEnvironmentPreparationKey(options.receiptPath)) === key + ) { + const reuse = options.setup.reuse!; + const check = await options.execute( + reuse.checkCommand, + reuse.checkTimeoutMs, + ); + options.signal?.throwIfAborted(); + if (check.timedOut || check.exitCode === null) + throw new Error('Environment readiness check did not settle'); + if (check.exitCode === 0 && (await preparationKey(options)) === key) + return 'reused'; + } + const result = await options.execute( + options.setup.command, + options.setup.timeoutMs, + ); + options.signal?.throwIfAborted(); + if (result.timedOut || result.exitCode !== 0) + throw new Error('Environment setup failed'); + if (key) { + // Do not stamp an installation against inputs that changed during setup. + if ((await preparationKey(options)) !== key) + throw new Error( + 'Environment preparation inputs changed during setup', + ); + const reuse = options.setup.reuse!; + const check = await options.execute( + reuse.checkCommand, + reuse.checkTimeoutMs, + ); + options.signal?.throwIfAborted(); + if (check.timedOut || check.exitCode !== 0) + throw new Error('Environment readiness check failed after setup'); + if ((await preparationKey(options)) !== key) + throw new Error( + 'Environment preparation inputs changed during readiness check', + ); + await saveEnvironmentPreparationKey(options.receiptPath, key); + } + return 'prepared'; +} + +async function preparationKey( + options: EnvironmentPreparationOptions, +): Promise { + if (!options.setup.reuse) return undefined; + return withWorkspaceRoot(options.root, options.identity, async () => { + const hash = createHash('sha256').update( + JSON.stringify({ + version: 1, + root: options.identity, + setup: options.setup, + context: options.context, + node: process.version, + abi: process.versions.modules, + platform: process.platform, + arch: process.arch, + }), + ); + let total = 0; + for (const path of options.setup.reuse!.inputs) { + options.signal?.throwIfAborted(); + const handle = await open( + resolve(options.root, path), + constants.O_RDONLY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK, + ); + try { + const before = await handle.stat(); + if ( + !before.isFile() || + before.size > MAX_INPUT_BYTES || + total + before.size > MAX_TOTAL_INPUT_BYTES + ) + throw new Error( + 'Environment preparation inputs exceed the bounded regular-file contract', + ); + const buffer = Buffer.alloc(before.size + 1); + let length = 0; + while (length < buffer.length) { + options.signal?.throwIfAborted(); + const read = await handle.read( + buffer, + length, + buffer.length - length, + length, + ); + if (!read.bytesRead) break; + length += read.bytesRead; + } + const after = await handle.stat(); + if ( + length !== before.size || + after.size !== before.size || + after.mtimeMs !== before.mtimeMs || + after.ctimeMs !== before.ctimeMs + ) + throw new Error( + 'Environment preparation input changed while hashing', + ); + total += length; + hash.update(JSON.stringify([path, length])).update( + buffer.subarray(0, length), + ); + } finally { + await handle.close(); + } + } + return hash.digest('hex'); + }); +} diff --git a/packages/code/src/environment.ts b/packages/code/src/environment.ts index c2b2cf1f..406e3850 100644 --- a/packages/code/src/environment.ts +++ b/packages/code/src/environment.ts @@ -10,6 +10,7 @@ import { import { BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS, BRIDGE_WORKSPACE_COMMAND_MAX_BYTES, + isSafePortableRelativePath, } from './protocol.js'; import type { LocalWorkspaceConfig } from './workspace.js'; import { WorkspaceToolError } from './workspace.js'; @@ -25,7 +26,12 @@ export interface CodeEnvironmentDefinition { root: string; repo?: string; ref?: string; - setup?: { command: string; timeoutMs: number }; + setup?: { + command: string; + timeoutMs: number; + /** Explicit readiness contract; absent preserves startup setup behavior. */ + reuse?: { inputs: string[]; checkCommand: string; checkTimeoutMs: number }; + }; actions?: { name: string; command: string; timeoutMs: number }[]; } @@ -89,7 +95,7 @@ export function parseCodeEnvironment( if ( !record(value.setup) || Object.keys(value.setup).some( - key => !['command', 'timeoutMs'].includes(key), + key => !['command', 'timeoutMs', 'reuse'].includes(key), ) || !text(value.setup.command, 16_384) || Buffer.byteLength(value.setup.command) > @@ -109,7 +115,25 @@ export function parseCodeEnvironment( `Environment setup timeout must be between 1 and ${BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS} ms`, ); } - setup = { command: value.setup.command, timeoutMs }; + let reuse: NonNullable['reuse']; + if (value.setup.reuse !== undefined) { + const candidate = value.setup.reuse; + if ( + !record(candidate) || + Object.keys(candidate).some(key => !['inputs', 'checkCommand', 'checkTimeoutMs'].includes(key)) || + !Array.isArray(candidate.inputs) || + candidate.inputs.length < 1 || candidate.inputs.length > 32 || + candidate.inputs.some(path => typeof path !== 'string' || !isSafePortableRelativePath(path) || path === '.') || + new Set(candidate.inputs).size !== candidate.inputs.length || + !text(candidate.checkCommand, 16_384) || + Buffer.byteLength(candidate.checkCommand) > BRIDGE_WORKSPACE_COMMAND_MAX_BYTES + ) throw new Error('Invalid environment setup reuse'); + const checkTimeoutMs = candidate.checkTimeoutMs ?? 10_000; + if (typeof checkTimeoutMs !== 'number' || !Number.isSafeInteger(checkTimeoutMs) || checkTimeoutMs < 1 || checkTimeoutMs > BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS) + throw new Error('Invalid environment readiness check timeout'); + reuse = { inputs: candidate.inputs as string[], checkCommand: candidate.checkCommand, checkTimeoutMs }; + } + setup = { command: value.setup.command, timeoutMs, ...(reuse ? { reuse } : {}) }; } let actions: CodeEnvironmentDefinition['actions']; if (value.actions !== undefined) { diff --git a/packages/code/src/storage.ts b/packages/code/src/storage.ts index 1cbb7093..904a8562 100644 --- a/packages/code/src/storage.ts +++ b/packages/code/src/storage.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes } from 'node:crypto'; +import { constants } from 'node:fs'; import { chmod, lstat, @@ -201,11 +202,15 @@ async function assertOwnedByWorker(path: string): Promise { async function readGuardedFile( path: string, exposed: (mode: string) => string, + maxBytes = Infinity, ): Promise { await assertReadPathPrivate(path); const handle = await open(path, 'r'); try { const stats = await handle.stat(); + if (!stats.isFile() || stats.size > maxBytes) { + throw new BridgeProtocolError('Invalid private storage file size or type'); + } const self = process.getuid?.(); if (self !== undefined && !isTrustedOwner(stats.uid, self)) { throw new BridgeProtocolError( @@ -423,6 +428,44 @@ export async function saveBridgeIdentity( path: string, identity: PairedBridgeWorkerIdentity, ): Promise { + return savePrivateJson(path, identity); +} + +/** Preparation receipts are worker state, never a workspace-authored success marker. */ +export async function prepareEnvironmentPreparationDirectory(path: string): Promise { + await assertPrivateStorageAncestors(path, true); + await ensureDurableDirectory(path); + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const metadata = await handle.stat(); + if (!metadata.isDirectory() || (metadata.mode & 0o077) !== 0) + throw new BridgeProtocolError('Environment preparation directory must be owner-only'); + await assertPrivateStorageAcl(handle, path, true); + } finally { await handle.close(); } + return assertPrivateStorageAncestors(path); +} + +export async function loadEnvironmentPreparationKey(path: string): Promise { + let content: string; + try { + content = await readGuardedFile(path, () => 'Environment preparation receipt must be owner-only', 256); + } catch (error) { + if (isMissingPathError(error)) return undefined; + throw error; + } + try { + const value: unknown = JSON.parse(content); + if (isRecord(value) && value.version === 1 && typeof value.key === 'string' && /^[a-f0-9]{64}$/.test(value.key)) return value.key; + } catch { /* Invalid receipts are cache misses, not readiness evidence. */ } + return undefined; +} + +export async function saveEnvironmentPreparationKey(path: string, key: string): Promise { + if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Invalid environment preparation key'); + await savePrivateJson(path, { version: 1, key }); +} + +async function savePrivateJson(path: string, value: unknown): Promise { assertPrivateStorageSupported(); await assertWriteContainerPrivate(path); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); @@ -435,13 +478,14 @@ export async function saveBridgeIdentity( await removePrivateStorageAcl(file, path); await file.chmod(0o600); await assertOwnerOnlyFile(file, path); - await file.writeFile(`${JSON.stringify(identity, null, 2)}\n`, 'utf8'); + await file.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8'); await file.sync(); } finally { await file.close(); } await rename(temporaryPath, path); await chmod(path, 0o600); + await syncParentDirectory(path); } catch (error) { await rm(temporaryPath, { force: true }); throw error;