diff --git a/packages/code/README.md b/packages/code/README.md index 47d3c3a0..a861efd3 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -1100,13 +1100,59 @@ Existing definitions without `reuse` retain the startup behavior. Fresh conversa 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. +deduplicate installed dependencies between worktrees unless snapshots below are +configured, or enforce disk quotas. Shared tool cache grants below do not attach another checkout's installed dependency tree. Keep monorepo links and mutable outputs checkout-local. Do not broaden the sandbox root or symlink another branch's full installation. Shared download caches alone do not reduce installed `node_modules` copies. +## Copy-on-write installed dependencies + +For matching fresh checkouts on the **same clone-capable filesystem**, add a +private snapshot store to the readiness contract: + +```yaml +setup: + command: npm ci + timeoutMs: 300000 + reuse: + inputs: [package.json, package-lock.json] + checkCommand: test -x node_modules/.bin/tsc + snapshot: + store: /srv/lia-state/dependency-snapshots + paths: [node_modules] + maxBytes: 4294967296 + maxFiles: 200000 +``` + +Create the external store as the worker account with mode `0700`. It must not +overlap any source root, definition, credential or shared tool cache. Commands +cannot read or write it. Use a separate store per project/trust domain. The +portable key includes project identity, declared input bytes, recipe, policy, +Node ABI and platform, but not the checkout inode. Kernel locks serialize setup +for one key; different keys remain independent. Incomplete clones are never +published. Cancellation is checked during bounded traversal. + +Matching snapshots restore only when **every** declared `node_modules` directory +is missing. The sandboxed readiness check must pass before accepting the restore. +Existing directories are never replaced by restoration; ordinary setup handles +repair. Include nested workspace installations explicitly. Relative checkout-local +package links are preserved; absolute/escaping links, hard-linked files, links into +Git/worktree control paths and special files are rejected. Hardlink-based package +manager layouts need a different adapter; this snapshot mode targets npm copies. +Changing one restored installation cannot modify the snapshot or another checkout. + +This requires APFS clones or Linux reflinks (for example a suitably configured +XFS/Btrfs volume). The worker verifies cloning before installation and rejects +unsupported filesystems instead of silently making full copies or writable hard +links. Ordinary ext4 workers should leave snapshots disabled and can still use +checkout-local preparation receipts and shared downloads. This is **not** Python +virtualenv relocation, automatic preparation of manually created linked lanes, +or a hard quota on arbitrary commands. Validate all install/postinstall inputs +and path-independent artifacts before opting in. + ## Shared tool and download resources Explicit operator-managed stores can live outside the checkouts: diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 04d94aaa..4c64c0ae 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -848,6 +848,13 @@ async function run( ...environments.map(environment => environment.path), ...rootQuarantinePaths.values(), ].filter((path): path is string => path != null)); await assertEnvironmentDefinitionsOutsideRoots(resourceRoots, roots); + const snapshotStores = environments.flatMap(environment => environment.snapshotStore ? [environment.snapshotStore] : []); + await assertEnvironmentResourceIsolation(snapshotStores.map(store => ({ kind: 'npm-cache' as const, path: store.store, access: 'read-only' as const })), roots.map(root => root.root), [ + identityPath, preparationDirectory, github.privateKeyPath, ...environments.map(environment => environment.path), + ...rootQuarantinePaths.values(), ...environments.flatMap(environment => (environment.resources ?? []).map(resource => resource.path)), + ].filter((path): path is string => path != null)); + await assertEnvironmentDefinitionsOutsideRoots(snapshotStores.map(store => ({ path: store.store, sourceParents: store.controlPaths, + definition: { name: 'dependency-store', root: store.store }, 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 @@ -1073,6 +1080,7 @@ async function run( protectedPaths: [ identityPath, ...(environments.some(environment => environment.definition.setup?.reuse) ? [preparationDirectory] : []), + ...snapshotStores.map(store => store.store), ...environments.map(environment => environment.path), ...rootQuarantinePaths.values(), github.privateKeyPath, @@ -1163,10 +1171,11 @@ async function run( ...(nativeCommandSandbox instanceof NativeWorkspaceCommandPool ? { prepareInstance: async (instance, signal) => { - const setup = environments.find( + const environment = environments.find( (environment) => environment.definition.name === instance.sourceWorkspaceId, - )?.definition.setup; + ); + const setup = environment?.definition.setup; if (!setup) return; if (admittedGitHubRepositories) { admittedGitHubRepositories.set( @@ -1181,6 +1190,8 @@ async function run( workspaceRoot: instance.root, }); await prepareCodeEnvironment({ + snapshotStore: environment!.snapshotStore, + snapshotScope: environment!.definition.repo ?? environment!.definition.name, root: instance.root, identity: instance.identity, setup, receiptPath: preparationReceipt(instance.root), context: JSON.stringify([serializeNativeSrtCommandPolicy(commandPolicy), commandAllowedDomains, github.policyIdentity, @@ -1353,6 +1364,11 @@ async function run( await guard.assertAvailable(); let armed = false; const preparation = await prepareCodeEnvironment({ + snapshotStore: environment.snapshotStore, + snapshotScope: environment.definition.repo ?? environment.definition.name, + beforeMutation: async () => { + if (!armed) { await guard.arm('Dependency restoration did not settle', 'setup'); armed = true; } + }, root: environment.definition.root, identity: roots.find(root => root.id === id)!.identity!, setup, receiptPath: preparationReceipt(environment.definition.root), diff --git a/packages/code/src/dependency-snapshots.test.ts b/packages/code/src/dependency-snapshots.test.ts new file mode 100644 index 00000000..b23ae70f --- /dev/null +++ b/packages/code/src/dependency-snapshots.test.ts @@ -0,0 +1,267 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + mkdtemp, + mkdir, + writeFile, + readFile, + lstat, + link, + realpath, + rm, + symlink, + rename, + readdir, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + parseDependencySnapshot, + loadDependencySnapshot, + publishDependencySnapshot, + restoreDependencySnapshot, + withDependencySnapshot, +} from './dependency-snapshots.js'; +import { prepareCodeEnvironment } from './environment-preparation.js'; +import { captureWorkspaceRootIdentity } from './root-identity.js'; +import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; +import type { EnvironmentPreparationOptions } from './environment-preparation.js'; + +async function fixture(t: test.TestContext) { + const directory = await realpath( + await mkdtemp(join(tmpdir(), 'dependency-snapshot-')), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const a = join(directory, 'a'), + b = join(directory, 'b'), + storePath = join(directory, 'store'); + for (const path of [a, b, storePath]) await mkdir(path, { mode: 0o700 }); + const store = await loadDependencySnapshot( + parseDependencySnapshot({ store: storePath, paths: ['node_modules'] }), + ); + const ai = await captureWorkspaceRootIdentity(a), + bi = await captureWorkspaceRootIdentity(b); + return { directory, a, b, store, ai, bi, key: 'a'.repeat(64) }; +} + +test('snapshot schema restricts installed directories, overlapping paths and finite bounds', () => { + const valid = { + store: '/private/store', + paths: ['node_modules', 'packages/api/node_modules'], + }; + assert.equal(parseDependencySnapshot(valid).maxFiles, 200_000); + for (const paths of [ + ['.git'], + ['../node_modules'], + ['node_modules', 'node_modules/pkg/node_modules'], + ['node_modules', 'node_modules'], + ]) + assert.throws(() => parseDependencySnapshot({ ...valid, paths })); + assert.throws(() => + parseDependencySnapshot({ ...valid, maxBytes: Infinity }), + ); + assert.throws(() => + parseDependencySnapshot({ ...valid, store: 'relative' }), + ); +}); + +// These assertions exercise real filesystem cloning, not a mocked successful copy. +test( + 'real clone snapshots isolate writable files, preserve checkout-local links and never replace installations', + { + skip: + process.platform !== 'darwin' && + process.env.LIBRECHAT_CODE_LIVE_SNAPSHOT_TESTS !== '1', + }, + async t => { + const { a, b, store, ai, bi, key } = await fixture(t); + await mkdir(join(a, 'node_modules')); + await writeFile( + join(a, 'node_modules', 'package.js'), + 'module.exports = 42', + ); + await mkdir(join(a, 'packages')); + await mkdir(join(b, 'packages')); + await symlink('../packages', join(a, 'node_modules', 'local')); + await withDependencySnapshot(store, key, () => + publishDependencySnapshot(store, key, a, ai), + ); + assert.equal( + await withDependencySnapshot(store, key, () => + restoreDependencySnapshot(store, key, b, bi), + ), + true, + ); + assert.notEqual( + (await lstat(join(a, 'node_modules', 'package.js'))).ino, + (await lstat(join(b, 'node_modules', 'package.js'))).ino, + ); + await writeFile(join(b, 'node_modules', 'package.js'), 'changed'); + assert.equal( + await readFile(join(a, 'node_modules', 'package.js'), 'utf8'), + 'module.exports = 42', + ); + assert.equal( + await readFile(join(store.store, key, '0', 'package.js'), 'utf8'), + 'module.exports = 42', + ); + assert.equal(await restoreDependencySnapshot(store, key, b, bi), false); + assert.equal( + await readFile(join(b, 'node_modules', 'package.js'), 'utf8'), + 'changed', + ); + }, +); + +test('unsafe links and bounded traversal never publish a partial snapshot', async t => { + const { a, store, ai, key } = await fixture(t); + await mkdir(join(a, 'node_modules')); + await symlink('/etc/passwd', join(a, 'node_modules', 'escape')); + await assert.rejects( + publishDependencySnapshot(store, key, a, ai), + /checkout-local/, + ); + assert.deepEqual(await readdir(store.store), []); + await rm(join(a, 'node_modules', 'escape')); + await writeFile(join(a, 'node_modules', 'large'), 'large'); + await assert.rejects( + publishDependencySnapshot({ ...store, maxBytes: 1 }, key, a, ai), + /maxBytes/, + ); + assert.deepEqual(await readdir(store.store), []); +}); + +test('hardlinks cannot copy outside file contents into a readable dependency snapshot', async t => { + const { a, store, ai, key, directory } = await fixture(t); + await mkdir(join(a, 'node_modules')); + const outside = join(directory, 'private-input'); + await writeFile(outside, 'private'); + await link(outside, join(a, 'node_modules', 'alias')); + await assert.rejects( + publishDependencySnapshot(store, key, a, ai), + /hard-linked/, + ); + assert.deepEqual(await readdir(store.store), []); +}); + +test('kernel lock serializes publishers, supports cancellation and rejects a replaced store', async t => { + const { store, key } = await fixture(t); + let release!: () => void; + const blocked = new Promise(resolve => { + release = resolve; + }); + let entered!: () => void; + const started = new Promise(resolve => { + entered = resolve; + }); + const first = withDependencySnapshot(store, key, async () => { + entered(); + await blocked; + }); + await started; + const controller = new AbortController(); + const second = withDependencySnapshot( + store, + key, + async () => assert.fail('cancelled lock ran'), + controller.signal, + ); + controller.abort(); + await assert.rejects(second); + release(); + await first; + await rename(store.store, `${store.store}-old`); + await mkdir(store.store, { mode: 0o700 }); + await assert.rejects( + withDependencySnapshot(store, key, async () => {}), + /changed after admission/, + ); +}); + +test( + 'real native preparation shares dependencies between checkouts, invalidates changed inputs and denies store access', + { + skip: + process.env.LIBRECHAT_CODE_LIVE_SRT_TESTS !== '1' || + (process.platform !== 'darwin' && + process.env.LIBRECHAT_CODE_LIVE_SNAPSHOT_TESTS !== '1'), + }, + async t => { + const { a, b, store, directory, ai, bi } = await fixture(t); + for (const root of [a, b]) + await writeFile(join(root, 'package-lock.json'), 'version-one'); + const commands: string[] = []; + const setup: EnvironmentPreparationOptions['setup'] = { + command: + "mkdir -p node_modules; printf 'module.exports = 42' > node_modules/package.js", + timeoutMs: 5000, + reuse: { + inputs: ['package-lock.json'], + checkCommand: 'test -f node_modules/package.js', + checkTimeoutMs: 3000, + snapshot: { + store: store.store, + paths: store.paths, + maxBytes: store.maxBytes, + maxFiles: store.maxFiles, + }, + }, + }; + const sandboxes = [ai, bi].map( + identity => + new NativeProcessWorkspaceCommandSandbox({ + workspaceRoot: identity.path, + workspaceIdentity: identity, + homeDirectory: directory, + protectedPaths: [store.store], + allowedDomains: [], + }), + ); + t.after(async () => { + for (const sandbox of sandboxes) await sandbox.close(); + }); + const run = (index: number) => { + const identity = [ai, bi][index]; + return prepareCodeEnvironment({ + root: identity.path, + identity, + setup, + context: 'policy', + snapshotStore: store, + snapshotScope: 'project', + receiptPath: join(store.store, `receipt-${index}.json`), + execute: async (command, timeoutMs) => { + commands.push(command); + return sandboxes[index].execute({ + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + command, + timeoutMs, + maxOutputBytes: 8192, + }); + }, + }); + }; + assert.equal(await run(0), 'prepared'); + assert.equal(await run(1), 'restored'); + assert.equal( + commands.filter(command => command === setup.command).length, + 1, + ); + await writeFile(join(b, 'package-lock.json'), 'version-two'); + assert.equal(await run(1), 'prepared'); + assert.equal( + commands.filter(command => command === setup.command).length, + 2, + ); + const denied = await sandboxes[0].execute({ + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + command: `ls '${store.store}'`, + timeoutMs: 3000, + }); + assert.notEqual(denied.exitCode, 0); + }, +); diff --git a/packages/code/src/dependency-snapshots.ts b/packages/code/src/dependency-snapshots.ts new file mode 100644 index 00000000..5c9ab173 --- /dev/null +++ b/packages/code/src/dependency-snapshots.ts @@ -0,0 +1,457 @@ +import { randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path'; +import { createRequire } from 'node:module'; +import * as rooted from './root-access.js'; +import { withProcessLock } from './process-lock.js'; +import { + captureWorkspaceRootIdentity, + matchesWorkspaceRoot, +} from './root-identity.js'; +import { loadEnvironmentResource } from './environment-resources.js'; +import { isSafePortableRelativePath } from './protocol.js'; +import type { WorkspaceRootIdentity } from './root-identity.js'; + +export interface DependencySnapshotConfig { + store: string; + paths: string[]; + maxBytes: number; + maxFiles: number; +} +export interface DependencySnapshotStore extends DependencySnapshotConfig { + identity: WorkspaceRootIdentity; + controlPaths: string[]; +} +export function parseDependencySnapshot( + value: unknown, +): DependencySnapshotConfig { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Invalid dependency snapshot'); + const v = value as Record; + const maxBytes = v.maxBytes ?? 4 * 1024 ** 3; + const maxFiles = v.maxFiles ?? 200_000; + if ( + Object.keys(v).some( + k => !['store', 'paths', 'maxBytes', 'maxFiles'].includes(k), + ) || + typeof v.store !== 'string' || + !isAbsolute(v.store) || + v.store.length > 4096 || + /[\0\r\n]/.test(v.store) || + !Array.isArray(v.paths) || + !v.paths.length || + v.paths.length > 32 || + v.paths.some( + p => + typeof p !== 'string' || + !isSafePortableRelativePath(p) || + basename(p) !== 'node_modules' || + p.split('/').some(c => c === '.git' || c === '.worktrees'), + ) || + new Set(v.paths).size !== v.paths.length || + v.paths.some(p => + (v.paths as unknown[]).some( + q => p !== q && (p as string).startsWith(`${q}/`), + ), + ) || + !Number.isSafeInteger(maxBytes) || + (maxBytes as number) < 1 || + !Number.isSafeInteger(maxFiles) || + (maxFiles as number) < 1 + ) + throw new Error('Invalid dependency snapshot'); + return { + store: v.store, + paths: v.paths as string[], + maxBytes: maxBytes as number, + maxFiles: maxFiles as number, + }; +} +export async function loadDependencySnapshot( + config: DependencySnapshotConfig, +): Promise { + const resource = await loadEnvironmentResource({ + kind: 'npm-cache', + path: config.store, + access: 'read-only', + }); + return { + ...config, + store: resource.path, + identity: resource.identity, + controlPaths: resource.controlPaths, + }; +} + +let native: + | { clone: (...args: any[]) => number; errno: () => number } + | undefined; +/** No copy fallback and no hardlinks: failure means the host cannot meet the contract. */ +async function cloneFile(source: fs.FileHandle, target: string): Promise { + if (!native) { + const koffi = createRequire(import.meta.url)('koffi'); + const library = koffi.load(null); + native = { + errno: () => koffi.errno(), + clone: + process.platform === 'darwin' + ? library.func( + 'int fclonefileat(int srcfd, int dstfd, const char *name, unsigned int flags)', + ) + : library.func( + 'int ioctl(int fd, unsigned long request, ...)', + ), + }; + } + if (process.platform === 'darwin') { + const parent = await rooted.open( + dirname(target), + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + try { + if (native.clone(source.fd, parent.fd, basename(target), 0) !== 0) + throw new Error( + `Copy-on-write dependency cloning unavailable (errno ${native.errno()})`, + ); + } finally { + await parent.close(); + } + } else if (process.platform === 'linux') { + const output = await rooted.open( + target, + constants.O_CREAT | + constants.O_EXCL | + constants.O_WRONLY | + constants.O_NOFOLLOW, + 0o600, + ); + try { + if (native.clone(output.fd, 0x40049409, 'int', source.fd) !== 0) + throw new Error( + `Copy-on-write dependency cloning unavailable (errno ${native.errno()})`, + ); + } finally { + await output.close(); + } + } else + throw new Error( + 'Dependency snapshots require a clone-capable POSIX filesystem', + ); +} + +function inside(root: string, path: string): boolean { + const p = relative(root, path); + return !isAbsolute(p) && p !== '..' && !p.startsWith(`..${sep}`); +} +async function removeTree(path: string): Promise { + const stat = await rooted.lstat(path).catch((e: NodeJS.ErrnoException) => { + if (e.code === 'ENOENT') return undefined; + throw e; + }); + if (!stat) return; + if (!stat.isDirectory()) return rooted.unlink(path); + for (const name of await rooted.readdir(path)) + await removeTree(join(path, name)); + await rooted.rmdir(path); +} +async function cloneTree( + sourceRoot: string, + sourceIdentity: WorkspaceRootIdentity, + targetRoot: string, + targetIdentity: WorkspaceRootIdentity, + paths: string[], + config: DependencySnapshotConfig, + checkout: string, + signal?: AbortSignal, +): Promise { + let bytes = 0, + files = 0; + const target = (operation: () => Promise) => + rooted.withWorkspaceRoot(targetRoot, targetIdentity, operation); + await rooted.withWorkspaceRoot(sourceRoot, sourceIdentity, async () => { + const visit = async ( + path: string, + out: string, + checkoutPath: string, + ): Promise => { + signal?.throwIfAborted(); + if (++files > config.maxFiles) + throw new Error('Dependency snapshot exceeds maxFiles'); + const before = await rooted.lstat(path); + if (before.isDirectory()) { + await target(() => rooted.mkdir(out)); + for (const name of await rooted.readdir( + path, + config.maxFiles - files, + )) + await visit( + join(path, name), + join(out, name), + join(checkoutPath, name), + ); + const after = await rooted.lstat(path); + if ( + before.ino !== after.ino || + before.dev !== after.dev || + before.mtimeMs !== after.mtimeMs || + before.ctimeMs !== after.ctimeMs + ) + throw new Error( + 'Dependencies changed during snapshot traversal', + ); + } else if (before.isSymbolicLink()) { + const link = await rooted.readlink(path); + const resolved = resolve(dirname(checkoutPath), link); + if ( + isAbsolute(link) || + !inside(checkout, resolved) || + relative(checkout, resolved).split(sep).includes('.git') || + relative(checkout, resolved) + .split(sep) + .includes('.worktrees') + ) + throw new Error( + 'Dependency snapshot link must remain checkout-local', + ); + await target(() => rooted.symlink(link, out)); + } else if (before.isFile()) { + if (before.nlink !== 1) + throw new Error( + 'Dependency snapshots reject hard-linked files', + ); + bytes += Number(before.size); + if (bytes > config.maxBytes) + throw new Error('Dependency snapshot exceeds maxBytes'); + const handle = await rooted.open( + path, + constants.O_RDONLY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK, + ); + try { + const opened = await handle.stat(); + if ( + !opened.isFile() || + opened.nlink !== 1 || + opened.ino !== before.ino || + opened.dev !== before.dev + ) + throw new Error( + 'Dependency entry changed before cloning', + ); + await target(async () => { + await cloneFile(handle, out); + const output = await rooted.open( + out, + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + try { + await output.chmod(opened.mode & 0o777); + } finally { + await output.close(); + } + }); + const after = await handle.stat(); + if ( + opened.size !== after.size || + after.nlink !== 1 || + opened.mtimeMs !== after.mtimeMs || + opened.ctimeMs !== after.ctimeMs + ) + throw new Error( + 'Dependency file changed during cloning', + ); + } finally { + await handle.close(); + } + } else + throw new Error( + 'Dependency snapshots accept regular files, directories and relative links only', + ); + }; + for (let i = 0; i < paths.length; i++) + await visit( + join(sourceRoot, paths[i]), + join(targetRoot, String(i)), + join(checkout, config.paths[i]), + ); + }); +} + +export async function withDependencySnapshot( + store: DependencySnapshotStore, + key: string, + operation: () => Promise, + signal?: AbortSignal, + verifyCloneSupport = false, +): Promise { + if (!/^[a-f0-9]{64}$/.test(key)) + throw new Error('Invalid dependency snapshot key'); + if (!(await matchesWorkspaceRoot(store.store, store.identity))) + throw new Error('Dependency snapshot store changed after admission'); + return withProcessLock( + join(store.store, `${key}.lock`), + async () => { + if (!(await matchesWorkspaceRoot(store.store, store.identity))) + throw new Error( + 'Dependency snapshot store changed while waiting', + ); + signal?.throwIfAborted(); + // Fail before installation, rather than discovering unsupported reflinks after npm ci. + if (verifyCloneSupport) { + const probe = await fs.mkdtemp(join(store.store, '.probe-')); + try { + await fs.writeFile(join(probe, 'source'), 'clone-support', { + mode: 0o600, + }); + const input = await fs.open( + join(probe, 'source'), + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + try { + await rooted.withWorkspaceRoot( + store.store, + store.identity, + () => cloneFile(input, join(probe, 'target')), + ); + } finally { + await input.close(); + } + } finally { + await fs.rm(probe, { recursive: true, force: true }); + } + } + signal?.throwIfAborted(); + return operation(); + }, + signal, + ); +} +/** Restores only when every declared installation is absent. Existing files are never replaced. */ +export async function restoreDependencySnapshot( + store: DependencySnapshotStore, + key: string, + checkout: string, + identity: WorkspaceRootIdentity, + signal?: AbortSignal, +): Promise { + return rooted.withWorkspaceRoot(checkout, identity, async () => { + for (const path of store.paths) { + const exists = await rooted.lstat(join(checkout, path)).then( + () => true, + (e: NodeJS.ErrnoException) => { + if (e.code === 'ENOENT') return false; + throw e; + }, + ); + if (exists) return false; + } + const source = join(store.store, key); + const metadata = await fs + .lstat(source) + .catch((e: NodeJS.ErrnoException) => { + if (e.code === 'ENOENT') return undefined; + throw e; + }); + if (!metadata) return false; + if (!metadata.isDirectory() || metadata.isSymbolicLink()) + throw new Error('Invalid dependency snapshot entry'); + const staging = join( + checkout, + `.librechat-dependencies-${randomUUID()}`, + ); + await rooted.mkdir(staging); + const installed: string[] = []; + try { + const stagingIdentity = await captureWorkspaceRootIdentity(staging); + await cloneTree( + source, + await captureWorkspaceRootIdentity(source), + staging, + stagingIdentity, + store.paths.map((_, i) => String(i)), + store, + checkout, + signal, + ); + for (let i = 0; i < store.paths.length; i++) { + signal?.throwIfAborted(); + const destination = join(checkout, store.paths[i]); + // rename is descriptor-anchored; reject a newly appeared entry instead of replacing it. + if ( + await rooted.lstat(destination).then( + () => true, + (e: NodeJS.ErrnoException) => { + if (e.code === 'ENOENT') return false; + throw e; + }, + ) + ) + throw new Error( + 'Dependency destination appeared during restore', + ); + await rooted.renameExclusive( + join(staging, String(i)), + destination, + ); + installed.push(destination); + } + return true; + } catch (error) { + // A partial restore is inspection-worthy; do not delete a destination another process may have changed. + if (installed.length) + throw new Error( + 'Dependency restore partially committed; inspect the checkout', + { cause: error }, + ); + throw error; + } finally { + await removeTree(staging); + } + }); +} +export async function publishDependencySnapshot( + store: DependencySnapshotStore, + key: string, + checkout: string, + identity: WorkspaceRootIdentity, + signal?: AbortSignal, +): Promise { + const destination = join(store.store, key); + if ( + await fs.lstat(destination).then( + () => true, + (e: NodeJS.ErrnoException) => { + if (e.code === 'ENOENT') return false; + throw e; + }, + ) + ) + return; + const staging = await fs.mkdtemp(join(store.store, '.staging-')); + try { + await cloneTree( + checkout, + identity, + staging, + await captureWorkspaceRootIdentity(staging), + store.paths, + store, + checkout, + signal, + ); + signal?.throwIfAborted(); + await fs.rename(staging, destination); + } finally { + await fs.rm(staging, { recursive: true, force: true }); + } +} diff --git a/packages/code/src/environment-preparation.ts b/packages/code/src/environment-preparation.ts index c2597f3b..73df85f3 100644 --- a/packages/code/src/environment-preparation.ts +++ b/packages/code/src/environment-preparation.ts @@ -8,6 +8,12 @@ import { } from './storage.js'; import type { CodeEnvironmentDefinition } from './environment.js'; import type { WorkspaceRootIdentity } from './root-identity.js'; +import { + withDependencySnapshot, + restoreDependencySnapshot, + publishDependencySnapshot, +} from './dependency-snapshots.js'; +import type { DependencySnapshotStore } from './dependency-snapshots.js'; // Safety bounds on operator-declared hashing, not a dependency-store quota. const MAX_INPUT_BYTES = 8 * 1024 * 1024; @@ -25,12 +31,40 @@ export interface EnvironmentPreparationOptions { timeoutMs: number, ): Promise<{ exitCode: number | null; timedOut: boolean }>; signal?: AbortSignal; + snapshotStore?: DependencySnapshotStore; + snapshotScope?: string; + beforeMutation?(): Promise; } /** Checkout-local reuse. Never transfers mutable installations between worktrees. */ export async function prepareCodeEnvironment( options: EnvironmentPreparationOptions, -): Promise<'prepared' | 'reused'> { +): Promise<'prepared' | 'reused' | 'restored'> { + if (options.snapshotStore) { + if (!options.setup.reuse?.snapshot || !options.snapshotScope) + throw new Error( + 'Dependency snapshot requires an explicit preparation scope', + ); + if (options.snapshotStore.identity.dev !== options.identity.dev) + throw new Error( + 'Dependency snapshots and checkouts must use the same clone-capable filesystem', + ); + const portable = await preparationKey(options, true); + return withDependencySnapshot( + options.snapshotStore, + portable!, + () => prepareInLock(options, portable), + options.signal, + true, + ); + } + return prepareInLock(options); +} + +async function prepareInLock( + options: EnvironmentPreparationOptions, + portable?: string, +): Promise<'prepared' | 'reused' | 'restored'> { options.signal?.throwIfAborted(); const key = await preparationKey(options); if ( @@ -48,6 +82,36 @@ export async function prepareCodeEnvironment( if (check.exitCode === 0 && (await preparationKey(options)) === key) return 'reused'; } + if (options.snapshotStore && portable) { + await options.beforeMutation?.(); + if ( + await restoreDependencySnapshot( + options.snapshotStore, + portable, + options.root, + options.identity, + options.signal, + ) + ) { + 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( + 'Restored dependency readiness check did not settle', + ); + if ( + check.exitCode === 0 && + (await preparationKey(options)) === key + ) { + await saveEnvironmentPreparationKey(options.receiptPath, key!); + return 'restored'; + } + } + } const result = await options.execute( options.setup.command, options.setup.timeoutMs, @@ -74,19 +138,28 @@ export async function prepareCodeEnvironment( 'Environment preparation inputs changed during readiness check', ); await saveEnvironmentPreparationKey(options.receiptPath, key); + if (options.snapshotStore && portable) + await publishDependencySnapshot( + options.snapshotStore, + portable, + options.root, + options.identity, + options.signal, + ); } return 'prepared'; } async function preparationKey( options: EnvironmentPreparationOptions, + portable = false, ): 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, + root: portable ? options.snapshotScope : options.identity, setup: options.setup, context: options.context, node: process.version, diff --git a/packages/code/src/environment.ts b/packages/code/src/environment.ts index 976ffe6d..ca859a2e 100644 --- a/packages/code/src/environment.ts +++ b/packages/code/src/environment.ts @@ -4,6 +4,8 @@ import { open, realpath, stat } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { parseDocument } from 'yaml'; import { parseEnvironmentResources, loadEnvironmentResource } from './environment-resources.js'; +import { parseDependencySnapshot, loadDependencySnapshot } from './dependency-snapshots.js'; +import type { DependencySnapshotConfig, DependencySnapshotStore } from './dependency-snapshots.js'; import type { EnvironmentResource, LoadedEnvironmentResource } from './environment-resources.js'; import { assertPrivateStorageAcl, @@ -32,7 +34,7 @@ export interface CodeEnvironmentDefinition { command: string; timeoutMs: number; /** Explicit readiness contract; absent preserves startup setup behavior. */ - reuse?: { inputs: string[]; checkCommand: string; checkTimeoutMs: number }; + reuse?: { inputs: string[]; checkCommand: string; checkTimeoutMs: number; snapshot?: DependencySnapshotConfig }; }; actions?: { name: string; command: string; timeoutMs: number }[]; resources?: EnvironmentResource[]; @@ -45,6 +47,7 @@ export interface LoadedCodeEnvironment { definition: CodeEnvironmentDefinition; fingerprint: string; resources?: LoadedEnvironmentResource[]; + snapshotStore?: DependencySnapshotStore; } function record(value: unknown): value is Record { @@ -124,7 +127,7 @@ export function parseCodeEnvironment( const candidate = value.setup.reuse; if ( !record(candidate) || - Object.keys(candidate).some(key => !['inputs', 'checkCommand', 'checkTimeoutMs'].includes(key)) || + Object.keys(candidate).some(key => !['inputs', 'checkCommand', 'checkTimeoutMs', 'snapshot'].includes(key)) || !Array.isArray(candidate.inputs) || candidate.inputs.length < 1 || candidate.inputs.length > 32 || candidate.inputs.some(path => typeof path !== 'string' || !isSafePortableRelativePath(path) || path === '.') || @@ -135,7 +138,8 @@ export function parseCodeEnvironment( 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 }; + reuse = { inputs: candidate.inputs as string[], checkCommand: candidate.checkCommand, checkTimeoutMs, + ...(candidate.snapshot !== undefined ? { snapshot: parseDependencySnapshot(candidate.snapshot) } : {}) }; } setup = { command: value.setup.command, timeoutMs, ...(reuse ? { reuse } : {}) }; } @@ -334,6 +338,7 @@ export async function loadCodeEnvironment( rootPaths, definition, ...(definition.resources ? { resources: await Promise.all(definition.resources.map(loadEnvironmentResource)) } : {}), + ...(definition.setup?.reuse?.snapshot ? { snapshotStore: await loadDependencySnapshot(definition.setup.reuse.snapshot) } : {}), fingerprint: createHash('sha256') .update(JSON.stringify(definition)) .digest('hex'), diff --git a/packages/code/src/root-access.ts b/packages/code/src/root-access.ts index 4aeb6100..b06de8d0 100644 --- a/packages/code/src/root-access.ts +++ b/packages/code/src/root-access.ts @@ -62,10 +62,16 @@ const openAt = nativeOpenAt const renameAt = bind( 'int renameat(int fromfd, const char *from, int tofd, const char *to)', ); +const renameExclusiveAt = bind(process.platform === 'darwin' + ? 'int renameatx_np(int fromfd, const char *from, int tofd, const char *to, unsigned int flags)' + : 'int renameat2(int fromfd, const char *from, int tofd, const char *to, unsigned int flags)'); const linkAt = bind( 'int linkat(int fromfd, const char *from, int tofd, const char *to, int flags)', ); const unlinkAt = bind('int unlinkat(int dirfd, const char *path, int flags)'); +const mkdirAt = bind('int mkdirat(int dirfd, const char *path, unsigned int mode)'); +const symlinkAt = bind('int symlinkat(const char *target, int dirfd, const char *path)'); +const readlinkAt = bind('int readlinkat(int dirfd, const char *path, void *buffer, size_t size)'); const getPath = process.platform === 'darwin' ? bind('int fcntl(int fd, int command, ...)') @@ -328,13 +334,14 @@ export class WorkspaceRootAccess { } } - install(from: string, to: string, link: boolean): void { + install(from: string, to: string, link: boolean, exclusive = false): void { const source = this.parent(from); let target: ReturnType | undefined; try { target = this.parent(to); const result = link ? linkAt!(source.fd, source.name, target.fd, target.name, 0) + : exclusive ? renameExclusiveAt!(source.fd, source.name, target.fd, target.name, process.platform === 'darwin' ? 4 : 1) : renameAt!(source.fd, source.name, target.fd, target.name); if (result !== 0) throw nativeError(); } finally { @@ -343,10 +350,27 @@ export class WorkspaceRootAccess { } } - unlink(path: string): void { + entry(path: string, operation: 'mkdir' | 'symlink' | 'readlink', target?: string): string | void { const parent = this.parent(path); try { - if (unlinkAt!(parent.fd, parent.name, 0) !== 0) throw nativeError(); + if (operation === 'readlink') { + const buffer = Buffer.alloc(4097); + const length = readlinkAt!(parent.fd, parent.name, buffer, buffer.length); + if (length < 0) throw nativeError(); + if (length === buffer.length) throw new Error('Workspace link exceeds its bounded contract'); + return buffer.subarray(0, length).toString(); + } + const result = operation === 'mkdir' + ? mkdirAt!(parent.fd, parent.name, 0o700) + : symlinkAt!(target!, parent.fd, parent.name); + if (result !== 0) throw nativeError(); + } finally { closeSync(parent.fd); } + } + + unlink(path: string, directory = false): void { + const parent = this.parent(path); + try { + if (unlinkAt!(parent.fd, parent.name, directory ? (process.platform === 'darwin' ? 0x80 : 0x200) : 0) !== 0) throw nativeError(); } finally { closeSync(parent.fd); } @@ -448,6 +472,11 @@ export const rename = async (from: string, to: string): Promise => { if (access) access.install(from, to, false); else await fs.rename(from, to); }; +export const renameExclusive = async (from: string, to: string): Promise => { + const access = context.getStore(); + if (!access) throw new Error('Exclusive installation requires a selected root'); + access.install(from, to, false, true); +}; export const link = async (from: string, to: string): Promise => { const access = context.getStore(); if (access) access.install(from, to, true); @@ -458,3 +487,35 @@ export const unlink = async (path: string): Promise => { if (access) access.unlink(path); else await fs.unlink(path); }; + +/** Enumeration and entry mutation retain the selected root descriptor. */ +export const readdir = async (path: string, maxEntries = 200_000): Promise => { + const access = context.getStore(); + const handle = access ? await access.openFile(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW) : undefined; + try { + const entries: string[] = []; + const directory = await fs.opendir(handle ? descriptorPath(handle.fd) : path); + for await (const entry of directory) { + if (entries.length >= maxEntries) throw new Error('Dependency directory exceeds maxFiles'); + entries.push(entry.name); + } + return entries; + } finally { await handle?.close(); } +}; +export const readlink = async (path: string): Promise => + context.getStore()?.entry(path, 'readlink') as string ?? fs.readlink(path); +export const mkdir = async (path: string): Promise => { + const access = context.getStore(); + if (access) access.entry(path, 'mkdir'); + else await fs.mkdir(path, { mode: 0o700 }); +}; +export const symlink = async (target: string, path: string): Promise => { + const access = context.getStore(); + if (access) access.entry(path, 'symlink', target); + else await fs.symlink(target, path); +}; +export const rmdir = async (path: string): Promise => { + const access = context.getStore(); + if (access) access.unlink(path, true); + else await fs.rmdir(path); +};