diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07622ee7..72f32178 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,6 +197,11 @@ jobs: node-version: 24.16.0 - run: npm ci - run: npm run build + - name: Selected project root containment tests + run: | + command -v rg || brew install ripgrep + node --test dist/root-access.test.js + node --test --test-name-pattern='selected command|selected replay copy|programmatic probes reject' dist/native-sandbox.test.js - name: Native environment containment tests run: node --test dist/environment.test.js - name: Native ACL and credential lifecycle tests diff --git a/packages/code/README.md b/packages/code/README.md index 48bf769d..720a0b35 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -51,6 +51,50 @@ chosen non-overlapping project directories with `--workspace` or `--environment` Do not also register their parent directory. Treat the inventory as a snapshot; normal workspace admission must validate any directory selected from it. +## Register selected projects + +After pairing, use paths from `projects --root` to register individual checkouts: + +```bash +librechat-code run --project-root /srv/projects \ + --project web --project services/api \ + --allow-workspace-writes --allow-workspace-commands +``` + +Only the explicitly listed checkouts become execution roots. The discovery +directory is not registered, and adding a new sibling repository does not grant +access to it. In LibreChat, select the project in the existing workspace picker; +the conversation stores that selection for subsequent tools and approval resumes. +An agent's default workspace and the user's recent selection work as before. + +Project IDs are derived from the canonical discovery directory and relative +project path, not the branch or selection order. Keep both paths unchanged across +restarts to retain chat bindings. Moving a checkout changes its ID. These are +registration IDs, not the root-local IDs printed by the inventory command. + +Up to 32 selected projects are supported. Each must be a standalone Git checkout; +linked worktrees, symlink traversal, overlapping roots, and duplicate selections +are rejected. Existing native sandbox, command/write permissions, lease-slot and +quarantine rules still apply. This mode cannot be combined with `--environment`, +`--worker-dir`, `--workspace`, default-workspace, or workspace ID/name settings. +Existing registrations are not migrated automatically; use a new conversation +when switching registration mode. Non-Git directories still use the existing +workspace flags. Named environment setup/actions still use `--environment`. + +Selected projects require macOS or Linux (including WSL2). Each request opens +and verifies the admitted directory, then retains that descriptor through file +access, repository-instruction loading, command startup, and replay copying. +Renaming a project cannot redirect an in-flight request to a replacement checkout; +subsequent requests reject the changed identity. Restart with an explicitly +selected replacement to admit it. Descriptors close when requests settle, and +independent workspaces do not share a current directory or global execution lock. + +This reuses the existing workspace protocol. Programmatic tool calling requires +a LibreChat version that preserves the selected workspace across initial +execution and replay, plus the worker's normal programmatic prerequisites. +Installation alone does not restart workers or change registration; update your +worker service arguments explicitly. + ## Pair Hardened deployments use a one-time code instead of copying a long-lived diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 946b08d0..524c5853 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -6,6 +6,7 @@ import { basename, resolve, relative, isAbsolute, sep } from 'node:path'; import { pairBridgeWorker } from './pairing.js'; import { discoverProjects } from './projects.js'; +import { loadProjectRoots, projectRootArguments } from './project-roots.js'; import { loadCodeEnvironment, assertEnvironmentDefinitionsOutsideRoots, @@ -309,6 +310,26 @@ async function run( runtimeSessionId?: string, args: string[] = [], ): Promise { + const projectArgs = projectRootArguments(args); + if ( + projectArgs && + (runtimeSessionId != null || + args.some(arg => + ['--environment', '--worker-dir', '--default-workspace', + '--workspace', '--workspace-id', '--workspace-name'].some( + flag => arg === flag || arg.startsWith(`${flag}=`), + ), + ) || + [process.env.LIBRECHAT_CODE_WORKER_DIR, + process.env.LIBRECHAT_CODE_WORKSPACE_ID, + process.env.LIBRECHAT_CODE_WORKSPACE_NAME].some(value => value?.trim()) || + process.env.LIBRECHAT_CODE_DEFAULT_WORKSPACE?.trim().toLowerCase() === 'true') + ) { + throw new Error('Project selection cannot be combined with other workspace registration settings'); + } + const projectRoots = projectArgs + ? await loadProjectRoots(projectArgs.root, projectArgs.projects) + : []; const environmentPaths: string[] = []; for (let i = 0; i < args.length; i++) { if (args[i] === '--environment') { @@ -426,11 +447,13 @@ async function run( runtimeSessionId == null && (fileRelayUpstream?.length ?? 0) > 0; const workspaceId = + projectRoots[0]?.id ?? environments[0]?.definition.name ?? option(args, '--workspace-id') ?? process.env.LIBRECHAT_CODE_WORKSPACE_ID?.trim() ?? 'primary'; const explicitWorkerDirectory = + projectRoots[0]?.root ?? environments[0]?.definition.root ?? (runtimeSessionId == null ? nonEmpty( @@ -468,6 +491,9 @@ async function run( if (environments.length && commandSandboxMode !== 'native-srt') { throw new Error('Environment definitions require native-srt'); } + if (projectRoots.length && commandSandboxMode !== 'native-srt') { + throw new Error('Project selections require native-srt'); + } if ( environments.some(environment => environment.definition.setup) && !allowWorkspaceCommands @@ -575,8 +601,10 @@ async function run( { id: workspaceId, root: canonicalWorkerDirectory, + identity: projectRoots[0]?.identity, writable: allowWorkspaceWrites, name: + projectRoots[0]?.name ?? environments[0]?.definition.name ?? option(args, '--workspace-name') ?? process.env.LIBRECHAT_CODE_WORKSPACE_NAME?.trim() ?? @@ -597,6 +625,9 @@ async function run( writable: allowWorkspaceWrites, }); } + for (const project of projectRoots.slice(1)) { + roots.push({ ...project, writable: allowWorkspaceWrites }); + } await assertEnvironmentDefinitionsOutsideRoots(environments, roots); for (let i = 0; i < args.length; i++) { if ( @@ -882,6 +913,7 @@ async function run( }); const nativeOptions: NativeProcessSandboxOptions = { workspaceRoot: canonicalWorkerDirectory!, + workspaceIdentity: roots[0]?.identity, commandPolicy, protectedPaths: [ identityPath, @@ -921,7 +953,7 @@ async function run( new Map( roots.map(root => [ root.id, - { ...nativeOptions, workspaceRoot: root.root }, + { ...nativeOptions, workspaceRoot: root.root, workspaceIdentity: root.identity }, ]), ), workspaceLeaseSlots, diff --git a/packages/code/src/instructions.ts b/packages/code/src/instructions.ts index ee3e8127..1a1e8cad 100644 --- a/packages/code/src/instructions.ts +++ b/packages/code/src/instructions.ts @@ -1,5 +1,5 @@ import { constants } from 'node:fs'; -import { open, lstat, realpath, stat } from 'node:fs/promises'; +import { open, lstat, realpath, stat } from './root-access.js'; import { createHash } from 'node:crypto'; import { resolve, relative, isAbsolute, sep } from 'node:path'; diff --git a/packages/code/src/native-process.ts b/packages/code/src/native-process.ts index 81bbd9c4..70cb2bd1 100644 --- a/packages/code/src/native-process.ts +++ b/packages/code/src/native-process.ts @@ -336,6 +336,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan child.on('disconnect', lost); const { workspaceRoot, + workspaceIdentity, commandPolicy, protectedPaths, allowedDomains, @@ -348,6 +349,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan { options: { workspaceRoot, + workspaceIdentity, commandPolicy, protectedPaths, allowedDomains, diff --git a/packages/code/src/native-programmatic-live.test.ts b/packages/code/src/native-programmatic-live.test.ts index 2bc22356..92cab6ea 100644 --- a/packages/code/src/native-programmatic-live.test.ts +++ b/packages/code/src/native-programmatic-live.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { createServer } from 'node:http'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -8,17 +8,20 @@ import type { AddressInfo } from 'node:net'; import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; import { resolveNativeSrtCommandPolicy } from './native-policy.js'; -test('real SRT prevents speculative network effects under trusted-vm', { +for (const selected of [false, true]) { +test(`real SRT prevents speculative network effects under trusted-vm${selected ? ' for a selected project' : ''}`, { skip: process.env.LIBRECHAT_CODE_LIVE_SRT_TESTS !== '1', timeout: 30_000, }, async () => { - const root = await mkdtemp(join(tmpdir(), 'native-ptc-effects-')); + const root = await realpath(await mkdtemp(join(tmpdir(), 'native-ptc-effects-'))); + const identity = await stat(root, { bigint: true }); let effects = 0; const server = createServer((_req, res) => { effects += 1; res.end('ok'); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const port = (server.address() as AddressInfo).port; const executor = new NativeProcessWorkspaceCommandSandbox({ workspaceRoot: root, + ...(selected ? { workspaceIdentity: { path: root, dev: String(identity.dev), ino: String(identity.ino) } } : {}), commandPolicy: resolveNativeSrtCommandPolicy('trusted-vm'), programmaticFileUpstream: `http://127.0.0.1:${port}`, }); @@ -39,3 +42,4 @@ test('real SRT prevents speculative network effects under trusted-vm', { } } }); +} diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 3252ee50..e610a0fc 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; +import { mkdirSync, renameSync, writeFileSync } from 'node:fs'; import { access, chmod, @@ -236,6 +237,119 @@ test('programmatic probes use a copy-on-write workspace without mutating the pro assert.equal(await readFile(join(snapshot, 'state.txt'), 'utf8'), 'probe-only'); }); +test('selected command cwd stays bound when replacement happens while wrapping', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-command-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(await realpath(parent), 'project'); + await mkdir(root); + await writeFile(join(root, 'identity.txt'), 'original'); + const identity = await stat(root, { bigint: true }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + manager: fakeManager({ beforeWrap: async () => { + await rename(root, `${root}.old`); + await mkdir(root); + await writeFile(join(root, 'identity.txt'), 'replacement'); + } }).manager, + }); + t.after(() => sandbox.close()); + const result = await sandbox.execute({ ...request, command: 'cat identity.txt; printf written > result.txt' }); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.stdout, 'original'); + assert.equal(await readFile(join(`${root}.old`, 'result.txt'), 'utf8'), 'written'); + await assert.rejects(access(join(root, 'result.txt'))); +}); + +test('selected command cancellation kills the exec trampoline process group', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-cancel-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = await realpath(parent); + const identity = await stat(root, { bigint: true }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + manager: fakeManager().manager, + }); + t.after(() => sandbox.close()); + const controller = new AbortController(); + const running = sandbox.execute({ ...request, timeoutMs: 5000, + command: 'printf started > started; sleep 3; printf late > late' }, controller.signal); + const rejected = assert.rejects(running, error => error instanceof WorkspaceToolError && error.code === 'EXECUTION_ABORTED'); + const deadline = Date.now() + 3000; + while (true) { + try { await access(join(root, 'started')); break; } catch { /* Wait for the actual child. */ } + if (Date.now() > deadline) throw new Error('Selected command did not start'); + await new Promise(resolve => setTimeout(resolve, 20)); + } + controller.abort(); + await rejected; + await new Promise(resolve => setTimeout(resolve, 3100)); + await assert.rejects(access(join(root, 'late'))); +}); + +test('selected replay copy stays on the verified directory after pathname replacement', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-copy-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(await realpath(parent), 'project'); + await mkdir(root); + await writeFile(join(root, 'identity.txt'), 'original'); + const identity = await stat(root, { bigint: true }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: identity.dev.toString(), ino: identity.ino.toString() }, + manager: fakeManager().manager, + spawnCommand(command, args, options) { + assert.equal(command, process.execPath); + renameSync(root, `${root}.old`); + mkdirSync(root); + writeFileSync(join(root, 'identity.txt'), 'replacement'); + return spawn(command, args, options); + }, + }); + t.after(() => sandbox.close()); + const directory = await sandbox.createExecutionDirectory(); + let snapshot: string; + try { + snapshot = await sandbox.createProgrammaticProbeWorkspace(directory); + } catch (error) { + if (error instanceof CopyOnWriteCloneUnavailableError) { + t.skip('host filesystem does not support copy-on-write cloning'); + return; + } + throw error; + } + assert.equal(await readFile(join(root, 'identity.txt'), 'utf8'), 'replacement'); + assert.equal(await readFile(join(snapshot, 'identity.txt'), 'utf8'), 'original'); +}); + +test('programmatic probes reject a replaced selected project before copying', async t => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-project-probe-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(await realpath(parent), 'project'); + await mkdir(root); + const identity = await stat(root, { bigint: true }); + let copies = 0; + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + workspaceIdentity: { path: root, dev: identity.dev.toString(), ino: identity.ino.toString() }, + manager: fakeManager().manager, + spawnCommand() { + copies++; + throw new Error('must not copy a replaced project'); + }, + }); + t.after(() => sandbox.close()); + const executionDirectory = await sandbox.createExecutionDirectory(); + await rename(root, join(parent, 'original')); + await mkdir(root); + await assert.rejects( + sandbox.createProgrammaticProbeWorkspace(executionDirectory), + /Selected project changed before probe staging/, + ); + assert.equal(copies, 0); +}); + test('programmatic probes do not hide clone implementation failures as unsupported filesystems', async t => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 550d0d52..84602a35 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -13,6 +13,9 @@ import { import { constants as fsConstants } from 'node:fs'; import { access, mkdtemp, open, realpath, rm, stat } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; +import { matchesWorkspaceRoot } from './root-identity.js'; +import type { WorkspaceRootIdentity } from './root-identity.js'; +import { withWorkspaceRoot, WorkspaceRootAccessError, spawnWithinWorkspace, realpath as rootedRealpath, stat as rootedStat } from './root-access.js'; import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; @@ -157,6 +160,7 @@ type SpawnCommand = ( ) => ChildProcessWithoutNullStreams; export interface NativeSrtWorkspaceCommandSandboxOptions { + workspaceIdentity?: WorkspaceRootIdentity; workspaceRoot: string; commandPolicy?: NativeSrtCommandPolicy; /** Trusted worker files that must never become workspace-readable or writable. */ @@ -343,6 +347,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ); } const root = await realpath(this.options.workspaceRoot); + if (this.options.workspaceIdentity && !await matchesWorkspaceRoot(root, this.options.workspaceIdentity)) { + throw new WorkspaceToolError('Selected project changed before sandbox admission', 'REGISTRATION_INVALID'); + } if (!(await stat(root)).isDirectory()) { throw new WorkspaceToolError( 'Native sandbox workspace is unavailable', @@ -573,7 +580,20 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox executionDirectory: string, signal?: AbortSignal, ): Promise { + try { + return await withWorkspaceRoot(this.options.workspaceRoot, this.options.workspaceIdentity, + () => this.createBoundProgrammaticProbeWorkspace(executionDirectory, signal)); + } catch (error) { + if (error instanceof WorkspaceRootAccessError) throw new WorkspaceToolError('Selected project changed before probe staging', 'REGISTRATION_INVALID'); + throw error; + } + } + + private async createBoundProgrammaticProbeWorkspace(executionDirectory: string, signal?: AbortSignal): Promise { await this.initialize(); + if (this.options.workspaceIdentity && !await matchesWorkspaceRoot(this.options.workspaceRoot, this.options.workspaceIdentity)) { + throw new WorkspaceToolError('Selected project changed before probe staging', 'REGISTRATION_INVALID'); + } const scratchDirectory = this.scratchDirectory; const root = this.canonicalRoot; let parent: string; @@ -608,8 +628,11 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox this.platform === 'darwin' ? ['-cR', root, destination] : ['--archive', '--reflink=always', root, destination]; + const identity = this.options.workspaceIdentity; + const copyArgs = identity ? [...args.slice(0, -2), '.', destination] : args; await new Promise((resolveCopy, rejectCopy) => { - const child = this.spawnCommand('/bin/cp', args, { + const child = spawnWithinWorkspace(this.spawnCommand, '/bin/cp', copyArgs, { + ...(identity ? { cwd: root } : {}), env: { PATH: this.environment.PATH, LANG: this.environment.LANG, @@ -800,6 +823,26 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox sandboxScratchDirectory?: string, workspaceRoot?: string, ): Promise { + try { + return await withWorkspaceRoot(this.options.workspaceRoot, workspaceRoot ? undefined : this.options.workspaceIdentity, + () => this.executeBound(request, signal, trustedEnvironment, customConfig, sandboxScratchDirectory, workspaceRoot)); + } catch (error) { + if (error instanceof WorkspaceRootAccessError) throw new WorkspaceToolError(error.message, 'REGISTRATION_INVALID'); + throw error; + } + } + + private async executeBound( + request: WorkspaceExecuteCommandRequest, + signal?: AbortSignal, + trustedEnvironment?: NodeJS.ProcessEnv, + customConfig?: Partial, + sandboxScratchDirectory?: string, + workspaceRoot?: string, + ): Promise { + if (this.options.workspaceIdentity && !await matchesWorkspaceRoot(this.options.workspaceRoot, this.options.workspaceIdentity)) { + throw new WorkspaceToolError('Selected project changed after sandbox admission', 'REGISTRATION_INVALID'); + } if ( !isWorkspaceToolRequest(request) || request.operation !== 'execute_command' @@ -819,8 +862,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox const root = workspaceRoot ?? this.canonicalRoot!; let cwd: string; try { - cwd = await realpath(resolve(root, request.cwd ?? '.')); - if (!isWithin(root, cwd) || !(await stat(cwd)).isDirectory()) + cwd = await rootedRealpath(resolve(root, request.cwd ?? '.')); + if (!isWithin(root, cwd) || !(await rootedStat(cwd)).isDirectory()) throw new Error('invalid cwd'); } catch { throw new WorkspaceToolError( @@ -939,7 +982,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox (resolvePromise, reject) => { let child: ChildProcessWithoutNullStreams; try { - child = this.spawnCommand( + child = spawnWithinWorkspace(this.spawnCommand, wrapped.argv[0], wrapped.argv.slice(1), { diff --git a/packages/code/src/project-roots.test.ts b/packages/code/src/project-roots.test.ts new file mode 100644 index 00000000..f88e523f --- /dev/null +++ b/packages/code/src/project-roots.test.ts @@ -0,0 +1,383 @@ +import assert from 'node:assert/strict'; +import { execFile, spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { + mkdtemp, + mkdir, + readFile, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import type { TestContext } from 'node:test'; +import { loadProjectRoots, projectRootArguments } from './project-roots.js'; +import { LocalWorkspaceTools } from './workspace.js'; +import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; +import type { BridgeWorkerCapabilities } from './protocol.js'; +import { WorkspaceRootAccess } from './root-access.js'; + +const exec = promisify(execFile); +test('admission validates the captured root even while a replacement checkout occupies its path', async t => { + const root = await fixture(t); + const selected = join(root, 'app'); + await writeFile( + join(selected, '.git/commondir'), + '../../nested/api/.git\n', + ); + const originalOpen = WorkspaceRootAccess.open; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + const held = await originalOpen(...args); + await rename(selected, `${selected}-original`); + await exec('git', ['init', '--initial-branch=dev', selected]); + const originalClose = held.close.bind(held); + held.close = async () => { + await rm(selected, { recursive: true, force: true }); + await rename(`${selected}-original`, selected); + await originalClose(); + }; + return held; + }, + ); + await assert.rejects( + loadProjectRoots(root, ['app']), + /Git common directory/, + ); + assert.equal( + await readFile(join(selected, '.git/commondir'), 'utf8'), + '../../nested/api/.git\n', + ); +}); + +test('admission cannot borrow a replacement checkout Git validity', async t => { + const root = await fixture(t); + const selected = join(root, 'app'); + await rm(join(selected, '.git/HEAD')); + const originalOpen = WorkspaceRootAccess.open; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + const held = await originalOpen(...args); + await rename(selected, `${selected}-original`); + await exec('git', ['init', '--initial-branch=dev', selected]); + const originalClose = held.close.bind(held); + held.close = async () => { + await rm(selected, { recursive: true, force: true }); + await rename(`${selected}-original`, selected); + await originalClose(); + }; + return held; + }, + ); + await assert.rejects( + loadProjectRoots(root, ['app']), + /standalone Git checkout/, + ); +}); +test( + 'real worker CLI registers only explicitly selected project roots', + { timeout: 10000 }, + async t => { + const root = await fixture(t); + let accept: (capabilities: BridgeWorkerCapabilities) => void = () => {}; + const registered = new Promise(resolve => { + accept = resolve; + }); + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + response.setHeader('Content-Type', 'application/json'); + if (request.url?.endsWith('/bridge/workers/register')) { + const body = JSON.parse( + Buffer.concat(chunks).toString(), + ) as { + workerId: string; + incarnationId: string; + capabilities: BridgeWorkerCapabilities; + }; + accept(body.capabilities); + response.end( + JSON.stringify({ + protocolVersion: 1, + workerId: body.workerId, + incarnationId: body.incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60000, + }), + ); + } else + response.end( + JSON.stringify({ + protocolVersion: 1, + serverElapsedMs: 0, + }), + ); + }); + }); + await new Promise(resolve => + server.listen(0, '127.0.0.1', resolve), + ); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const child = spawn( + process.execPath, + [ + fileURLToPath(new URL('./cli.js', import.meta.url)), + 'run', + '--project-root', + root, + '--project', + 'app', + '--project', + 'nested/api', + ], + { + env: { + PATH: process.env.PATH, + LIBRECHAT_CODE_URL: `http://127.0.0.1:${address.port}/v1`, + LIBRECHAT_CODE_WORKER_ID: 'test-worker', + LIBRECHAT_CODE_WORKER_TOKEN: 'test-token', + }, + stdio: 'ignore', + }, + ); + t.after(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }); + const capabilities = await registered; + child.kill(); + await once(child, 'exit'); + assert.deepEqual( + capabilities.workspaceTools?.workspaces?.map( + workspace => workspace.name, + ), + ['app', 'nested/api'], + ); + assert.ok( + capabilities.workspaceTools?.workspaces?.every(workspace => + workspace.id.startsWith('project-'), + ), + ); + assert.equal(JSON.stringify(capabilities).includes(root), false); + }, +); + +async function fixture(t: TestContext) { + const root = await mkdtemp(join(tmpdir(), 'selected-projects-')); + t.after(() => rm(root, { recursive: true, force: true })); + for (const name of ['app', 'nested/api']) { + await mkdir(join(root, name), { recursive: true }); + await exec('git', ['init', '--initial-branch=dev', join(root, name)]); + } + return root; +} + +test('selected projects retain identity across order and branch changes without granting their parent', async t => { + const root = await fixture(t); + const selected = await loadProjectRoots(root, ['app', 'nested/api']); + assert.deepEqual( + selected.map(project => project.name), + ['app', 'nested/api'], + ); + assert.ok( + selected.every(project => project.root !== root && !project.writable), + ); + await exec('git', [ + '-C', + join(root, 'app'), + 'symbolic-ref', + 'HEAD', + 'refs/heads/next', + ]); + const restarted = await loadProjectRoots(root, ['nested/api', 'app']); + assert.equal(restarted[1].id, selected[0].id); + assert.equal(restarted[0].id, selected[1].id); + const otherRoot = await fixture(t); + assert.notEqual( + (await loadProjectRoots(otherRoot, ['app']))[0].id, + selected[0].id, + ); +}); + +test('real file operations use the selected project boundary', async t => { + const root = await fixture(t); + const selected = await loadProjectRoots(root, ['app', 'nested/api']); + const tools = await LocalWorkspaceTools.create({ + workspaces: selected.map(project => ({ ...project, writable: true })), + }); + await tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: selected[1].id, + path: 'created.txt', + content: 'second project', + }); + assert.equal( + await readFile(join(root, 'nested/api/created.txt'), 'utf8'), + 'second project', + ); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: selected[0].id, + path: '../nested/api/created.txt', + }), + ); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'nested/api/created.txt', + }), + ); + await assert.rejects(readFile(join(root, 'app/created.txt'))); +}); + +test('rejects missing, escaped, aliased, duplicate and linked-worktree selections', async t => { + const root = await fixture(t); + await mkdir(join(root, 'linked')); + await writeFile( + join(root, 'linked/.git'), + 'gitdir: ../app/.git/worktrees/linked\n', + ); + await symlink(join(root, 'app'), join(root, 'alias'), 'dir'); + for (const projects of [ + [], + ['..'], + ['/tmp'], + ['missing'], + ['alias'], + ['linked'], + ['app', './app'], + Array(33).fill('app'), + ]) { + await assert.rejects(loadProjectRoots(root, projects)); + } + await assert.rejects( + loadProjectRoots(root, ['.']), + /standalone Git checkout/, + ); +}); + +test('project CLI arguments require explicit bounded selections', () => { + assert.equal(projectRootArguments(['run']), undefined); + assert.deepEqual( + projectRootArguments([ + 'run', + '--project-root=/srv/projects', + '--project', + 'app', + '--project=nested/api', + ]), + { root: '/srv/projects', projects: ['app', 'nested/api'] }, + ); + for (const args of [ + ['--project-root'], + ['--project-root=/srv'], + ['--project=app'], + ['--project-root=/srv', '--project-root=/other', '--project=app'], + ['--project-root=/srv', '--project', '--allow-workspace-writes'], + ]) { + assert.throws(() => projectRootArguments(args)); + } +}); + +test('rejects a directory-form git marker redirecting to shared metadata', async t => { + const root = await fixture(t); + await writeFile( + join(root, 'app/.git/commondir'), + '../../nested/api/.git\n', + ); + await assert.rejects( + loadProjectRoots(root, ['app']), + /Git common directory/, + ); +}); + +test('replacement after selection cannot become a file or native execution root', async t => { + const root = await fixture(t); + const outside = await fixture(t); + const [selected] = await loadProjectRoots(root, ['app']); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ ...selected, writable: true }], + }); + await rename(selected.root, `${selected.root}-previous`); + await symlink(join(outside, 'app'), selected.root, 'dir'); + await assert.rejects( + LocalWorkspaceTools.create({ workspaces: [selected] }), + /Invalid workspace registration/, + ); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: selected.id, + path: 'escaped.txt', + content: 'blocked', + }), + /changed after admission/, + ); + const executor = new NativeProcessWorkspaceCommandSandbox({ + workspaceRoot: selected.root, + workspaceIdentity: selected.identity, + }); + try { + await assert.rejects(executor.prepare()); + } finally { + await executor.close().catch(() => undefined); + } + await assert.rejects(readFile(join(outside, 'app/escaped.txt')), { + code: 'ENOENT', + }); +}); + +test('CLI rejects mixed registration and overlapping selected projects before connecting', async t => { + const root = await fixture(t); + await exec('git', ['init', '--initial-branch=dev', root]); + for (const extra of [ + ['--worker-dir', root], + ['--project', '.'], + ]) { + const result = spawnSync( + process.execPath, + [ + fileURLToPath(new URL('./cli.js', import.meta.url)), + 'run', + '--project-root', + root, + '--project', + 'app', + ...extra, + ], + { + encoding: 'utf8', + timeout: 5000, + env: { + PATH: process.env.PATH, + LIBRECHAT_CODE_URL: 'http://127.0.0.1:1', + LIBRECHAT_CODE_WORKER_ID: 'test-worker', + LIBRECHAT_CODE_WORKER_TOKEN: 'test-token', + }, + }, + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /cannot be combined|must not overlap/); + } +}); diff --git a/packages/code/src/project-roots.ts b/packages/code/src/project-roots.ts new file mode 100644 index 00000000..06e910ec --- /dev/null +++ b/packages/code/src/project-roots.ts @@ -0,0 +1,167 @@ +import { createHash } from 'node:crypto'; +import { lstat, realpath } from 'node:fs/promises'; +import { basename, isAbsolute, relative, resolve, sep } from 'node:path'; +import { + lstat as rootedLstat, + spawn, + withWorkspaceRoot, +} from './root-access.js'; +import { matchesWorkspaceRoot } from './root-identity.js'; +import type { LocalWorkspaceConfig } from './workspace.js'; + +/** Validate the selected checkout itself, never rediscover it via its pathname. */ +async function validateCheckout(root: string): Promise { + const marker = await rootedLstat(resolve(root, '.git')).catch( + () => undefined, + ); + if (!marker?.isDirectory() || marker.isSymbolicLink()) + throw new Error( + 'Select a standalone Git checkout, not a parent directory or linked worktree', + ); + const common = await rootedLstat(resolve(root, '.git', 'commondir')).catch( + error => { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) + throw error; + return undefined; + }, + ); + if (common) + throw new Error( + 'Selected projects must not share a Git common directory', + ); + await new Promise((accept, reject) => { + const child = spawn( + 'git', + [ + '--no-optional-locks', + '--git-dir=.git', + '--work-tree=.', + '-c', + 'core.fsmonitor=false', + 'rev-parse', + '--is-inside-work-tree', + ], + { + cwd: root, + env: { + PATH: process.env.PATH, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + LC_ALL: 'C', + }, + }, + ); + let output = ''; + let exceeded = false; + const timer = setTimeout(() => { + exceeded = true; + child.kill('SIGKILL'); + }, 1500); + child.stdout.on('data', (chunk: Buffer) => { + if (output.length + chunk.length > 4096) { + exceeded = true; + child.kill('SIGKILL'); + } else output += chunk.toString(); + }); + child.stderr.resume(); + child.stdin.end(); + child.once('error', reject); + child.once('close', code => { + clearTimeout(timer); + if (!exceeded && code === 0 && output.trim() === 'true') accept(); + else + reject( + new Error( + 'Select a standalone Git checkout, not a parent directory or linked worktree', + ), + ); + }); + }); +} + +/** Explicit operator selections, not an automatically expanding execution grant. */ +export async function loadProjectRoots( + directory: string, + selections: string[], +): Promise { + if (!selections.length || selections.length > 32) + throw new Error('Choose between 1 and 32 projects'); + const root = await realpath(directory); + const paths = new Set(); + const projects: LocalWorkspaceConfig[] = []; + for (const selection of selections) { + if (!selection || isAbsolute(selection) || selection.includes('\0')) + throw new Error('Project paths must be relative to --project-root'); + const path = resolve(root, selection); + const rel = relative(root, path); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) + throw new Error('Project paths must stay inside --project-root'); + const canonical = await realpath(path); + const directoryIdentity = await lstat(path, { bigint: true }); + if (canonical !== path || !directoryIdentity.isDirectory()) + throw new Error( + 'Selected projects must be directories without symlink traversal', + ); + if (paths.has(canonical)) + throw new Error('Duplicate project selection'); + paths.add(canonical); + const portablePath = rel.split(sep).join('/') || '.'; + const identity = { + path: canonical, + dev: directoryIdentity.dev.toString(), + ino: directoryIdentity.ino.toString(), + }; + await withWorkspaceRoot(canonical, identity, () => + validateCheckout(canonical), + ); + if (!(await matchesWorkspaceRoot(canonical, identity))) + throw new Error('Selected project changed during admission'); + projects.push({ + identity, + id: `project-${createHash('sha256') + .update(`${root}\0${portablePath}`) + .digest('hex') + .slice(0, 32)}`, + name: (portablePath === '.' ? basename(root) : portablePath).slice( + 0, + 64, + ), + root: canonical, + }); + } + return projects; +} + +export function projectRootArguments( + args: string[], +): { root: string; projects: string[] } | undefined { + let root: string | undefined; + const projects: string[] = []; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + const flag = arg.split('=')[0]; + if (flag !== '--project-root' && flag !== '--project') continue; + const value = arg.includes('=') + ? arg.slice(flag.length + 1) + : args[++index]; + if (!value || value.startsWith('--')) + throw new Error(`${flag} requires a value`); + if (flag === '--project') projects.push(value); + else { + if (root !== undefined) + throw new Error('Only one --project-root may be supplied'); + root = value; + } + } + if (root === undefined && !projects.length) return undefined; + if (root === undefined || !projects.length) + throw new Error( + '--project-root requires at least one --project relative/path', + ); + return { root, projects }; +} diff --git a/packages/code/src/root-access.test.ts b/packages/code/src/root-access.test.ts new file mode 100644 index 00000000..bb757464 --- /dev/null +++ b/packages/code/src/root-access.test.ts @@ -0,0 +1,359 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { constants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + WorkspaceRootAccess, + withWorkspaceRoot, + open, + realpath, + stat, + lstat, + rename, + link, + unlink, + spawn, +} from './root-access.js'; +import { LocalWorkspaceTools } from './workspace.js'; +import type { WorkspaceToolRequest } from './protocol.js'; + +test('search-only directories support known files and command cwd without enumeration', async t => { + if (process.getuid?.() === 0) + return t.skip( + 'requires an unprivileged user to verify search permissions', + ); + const root = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-search-')), + ); + const nested = join(root, 'nested'); + await fs.mkdir(nested); + await fs.writeFile(join(nested, 'known'), 'known-value'); + const identity = await fs.stat(root, { bigint: true }); + t.after(async () => { + await fs.chmod(root, 0o700); + await fs.chmod(nested, 0o700); + await fs.rm(root, { recursive: true, force: true }); + }); + await fs.chmod(root, 0o111); + await fs.chmod(nested, 0o111); + await assert.rejects(fs.readdir(nested), { code: 'EACCES' }); + await withWorkspaceRoot( + root, + { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + async () => { + const reader = await open(join(nested, 'known'), 'r'); + try { + assert.equal(await reader.readFile('utf8'), 'known-value'); + } finally { + await reader.close(); + } + assert.equal((await stat(nested)).isDirectory(), true); + assert.equal(await realpath(nested), nested); + await new Promise((accept, reject) => { + const child = spawn('/bin/cat', ['known'], { cwd: nested }); + let output = ''; + child.stdout!.on('data', chunk => { + output += chunk.toString(); + }); + child.once('error', reject); + child.once('close', code => { + try { + assert.equal(code, 0); + assert.equal(output, 'known-value'); + accept(); + } catch (error) { + reject(error); + } + }); + }); + }, + ); +}); + +test('held roots allow internal directory links and reject external ancestors', async t => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-links-')), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const root = join(directory, 'root'); + await fs.mkdir(join(root, 'nested'), { recursive: true }); + await fs.mkdir(join(directory, 'outside')); + await fs.writeFile(join(root, 'nested', 'value'), 'inside'); + await fs.symlink('nested', join(root, 'inside')); + await fs.symlink('../outside', join(root, 'outside')); + const identity = await fs.stat(root, { bigint: true }); + const originalOpen = WorkspaceRootAccess.open; + let held: WorkspaceRootAccess | undefined; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + held = await originalOpen(...args); + return held; + }, + ); + await withWorkspaceRoot( + root, + { path: root, dev: String(identity.dev), ino: String(identity.ino) }, + async () => { + assert.equal( + (await lstat(join(root, 'inside'))).isSymbolicLink(), + true, + ); + const reader = await open(join(root, 'inside', 'value'), 'r'); + try { + assert.equal(await reader.readFile('utf8'), 'inside'); + } finally { + await reader.close(); + } + await assert.rejects( + open( + join(root, 'outside', 'escape'), + constants.O_CREAT | constants.O_WRONLY, + 0o600, + ), + { code: 'EACCES' }, + ); + }, + ); + assert.equal(held?.handle.fd, -1); + assert.deepEqual(await fs.readdir(join(directory, 'outside')), []); +}); + +test('held root file operations cannot be redirected by replacing its pathname', async () => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-access-')), + ); + const root = join(directory, 'project'); + await fs.mkdir(root); + await fs.writeFile(join(root, 'original'), 'original'); + const identity = await fs.stat(root, { bigint: true }); + try { + await withWorkspaceRoot( + root, + { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }, + async () => { + await fs.rename(root, `${root}.old`); + await fs.mkdir(root); + await fs.writeFile(join(root, 'original'), 'replacement'); + assert.equal( + await realpath(join(root, 'original')), + join(root, 'original'), + ); + assert.equal((await stat(root)).isDirectory(), true); + assert.equal( + (await lstat(join(root, 'original'))).isFile(), + true, + ); + const reader = await open(join(root, 'original'), 'r'); + try { + assert.equal(await reader.readFile('utf8'), 'original'); + } finally { + await reader.close(); + } + const writer = await open( + join(root, 'new'), + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, + 0o600, + ); + try { + await writer.writeFile('new'); + await writer.sync(); + } finally { + await writer.close(); + } + await link(join(root, 'new'), join(root, 'linked')); + await rename(join(root, 'new'), join(root, 'renamed')); + await unlink(join(root, 'linked')); + const child = spawn('/bin/sh', ['-c', 'cat original'], { + cwd: root, + }); + let output = ''; + let error = ''; + child.stdout.on('data', chunk => { + output += chunk; + }); + child.stderr.on('data', chunk => { + error += chunk; + }); + child.stdin.end(); + const code = await new Promise(resolve => + child.on('close', resolve), + ); + assert.equal(code, 0, error); + assert.equal(output, 'original'); + }, + ); + assert.equal( + await fs.readFile(join(root, 'original'), 'utf8'), + 'replacement', + ); + assert.equal( + await fs.readFile(join(`${root}.old`, 'renamed'), 'utf8'), + 'new', + ); + assert.deepEqual(await fs.readdir(root), ['original']); + await assert.rejects( + WorkspaceRootAccess.open(root, { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }), + ); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +}); + +for (const operation of [ + 'read_file', + 'write_file', + 'edit_file', + 'list_files', + 'search_text', + 'instructions', +] as const) { + test(`selected ${operation} stays bound when the root is replaced after acquisition`, async t => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-caller-')), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const root = join(directory, 'project'); + await fs.mkdir(root); + await fs.writeFile(join(root, 'original.txt'), 'original'); + await fs.writeFile(join(root, 'AGENTS.md'), 'Original instructions'); + const identity = await fs.stat(root, { bigint: true }); + const tools = await LocalWorkspaceTools.create({ + repositoryInstructions: true, + workspaces: [ + { + id: 'selected', + root, + writable: true, + identity: { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }, + }, + ], + }); + const originalOpen = WorkspaceRootAccess.open; + t.mock.method( + WorkspaceRootAccess, + 'open', + async (...args: Parameters) => { + const access = await originalOpen(...args); + await fs.rename(root, `${root}.old`); + await fs.mkdir(root); + await fs.writeFile( + join(root, 'replacement.txt'), + 'replacement', + ); + await fs.writeFile( + join(root, 'AGENTS.md'), + 'Replacement instructions', + ); + return access; + }, + ); + if (operation === 'instructions') { + const descriptors = await tools.instructionDescriptors(); + const { createHash } = await import('node:crypto'); + assert.equal( + descriptors?.get('selected')?.[0].sha256, + createHash('sha256') + .update('Original instructions') + .digest('hex'), + ); + } else { + const request = { + protocolVersion: 1, + workspaceId: 'selected', + operation, + ...(operation === 'write_file' + ? { path: 'new.txt', content: 'created', overwrite: false } + : {}), + ...(operation === 'read_file' ? { path: 'original.txt' } : {}), + ...(operation === 'edit_file' + ? { + path: 'original.txt', + oldText: 'original', + newText: 'edited', + } + : {}), + ...(operation === 'search_text' ? { query: 'original' } : {}), + } as WorkspaceToolRequest; + const result = await tools.execute(request); + assert.equal( + JSON.stringify(result).includes('replacement.txt'), + false, + ); + if (operation === 'read_file') + assert.equal( + (result as { content: string }).content, + 'original', + ); + if (operation === 'write_file') + assert.equal( + await fs.readFile(join(`${root}.old`, 'new.txt'), 'utf8'), + 'created', + ); + if (operation === 'edit_file') + assert.equal( + await fs.readFile( + join(`${root}.old`, 'original.txt'), + 'utf8', + ), + 'edited', + ); + } + assert.deepEqual((await fs.readdir(root)).sort(), [ + 'AGENTS.md', + 'replacement.txt', + ]); + }); +} + +test('simultaneous roots retain independent descriptor contexts and release on failure', async t => { + const directory = await fs.realpath( + await fs.mkdtemp(join(tmpdir(), 'root-concurrency-')), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + await Promise.all( + ['a', 'b'].map(async name => { + const root = join(directory, name); + await fs.mkdir(root); + await fs.writeFile(join(root, 'value'), name); + const identity = await fs.stat(root, { bigint: true }); + await assert.rejects( + withWorkspaceRoot( + root, + { + path: root, + dev: String(identity.dev), + ino: String(identity.ino), + }, + async () => { + await fs.rename(root, `${root}.old`); + await fs.mkdir(root); + const file = await open(join(root, 'value'), 'r'); + try { + assert.equal(await file.readFile('utf8'), name); + } finally { + await file.close(); + } + throw new Error('cancelled request'); + }, + ), + /cancelled request/, + ); + }), + ); +}); diff --git a/packages/code/src/root-access.ts b/packages/code/src/root-access.ts new file mode 100644 index 00000000..4aeb6100 --- /dev/null +++ b/packages/code/src/root-access.ts @@ -0,0 +1,460 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { spawn as spawnProcess } from 'node:child_process'; +import { constants, closeSync, fstatSync, readlinkSync } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import { + basename, + dirname, + isAbsolute, + relative, + resolve, + sep, +} from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import type { + SpawnOptionsWithoutStdio, + ChildProcessWithoutNullStreams, +} from 'node:child_process'; +import type { WorkspaceRootIdentity } from './root-identity.js'; + +type NativeCall = (...args: (string | number | Buffer)[]) => number; +interface NativeLibrary { + func(signature: string): NativeCall; +} +interface NativeRuntime { + load(path: null): NativeLibrary; + errno(): number; + os: { errno: Record }; +} +let nativeRuntime: NativeRuntime | undefined; +let library: NativeLibrary | undefined; +function runtime(): NativeRuntime { + // Code API imports workspace contracts without installing native worker + // dependencies. Load the POSIX implementation only for selected roots. + return (nativeRuntime ??= createRequire(import.meta.url)( + 'koffi', + ) as NativeRuntime); +} +function bind(signature: string): NativeCall | undefined { + if (!['darwin', 'linux'].includes(process.platform)) return undefined; + let call: NativeCall | undefined; + return (...args) => { + library ??= runtime().load(null); + call ??= library.func(signature); + return call(...args); + }; +} +const nativeOpenAt = bind( + 'int openat(int dirfd, const char *path, int flags, ...)', +); +const O_CLOEXEC = process.platform === 'darwin' ? 0x1000000 : 0x80000; +// Anchors need search, not directory enumeration permission. +const DIRECTORY_ACCESS = + constants.O_DIRECTORY | + (process.platform === 'darwin' + ? 0x40000000 /* O_SEARCH */ + : 0x200000) /* O_PATH */; +const openAt = nativeOpenAt + ? (fd: number, path: string, flags: number, mode: number): number => + nativeOpenAt(fd, path, flags | O_CLOEXEC, 'unsigned int', mode) + : undefined; +const renameAt = bind( + 'int renameat(int fromfd, const char *from, int tofd, const char *to)', +); +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 getPath = + process.platform === 'darwin' + ? bind('int fcntl(int fd, int command, ...)') + : undefined; + +function nativeError(): NodeJS.ErrnoException { + const errno = runtime().errno(); + const code = + Object.entries(runtime().os.errno).find( + ([, value]) => value === errno, + )?.[0] ?? 'EIO'; + return Object.assign( + new Error(`Workspace descriptor access failed: ${code}`), + { code }, + ); +} + +function checked(fd: number): number { + if (fd < 0) throw nativeError(); + return fd; +} + +function descriptorPath(fd: number): string { + return process.platform === 'linux' + ? `/proc/self/fd/${fd}` + : `/dev/fd/${fd}`; +} + +function physicalPath(fd: number): string { + if (process.platform === 'linux') return readlinkSync(descriptorPath(fd)); + const buffer = Buffer.alloc(1024); + if (!getPath || getPath(fd, 50 /* F_GETPATH */, 'void *', buffer) !== 0) + throw nativeError(); + return buffer.subarray(0, buffer.indexOf(0)).toString(); +} + +function offset(root: string, path: string): string { + const value = relative(root, path); + if (isAbsolute(value) || value === '..' || value.startsWith(`..${sep}`)) { + throw Object.assign(new Error('Path is outside the held workspace'), { + code: 'EACCES', + }); + } + return value || '.'; +} + +/** A request owns one directory descriptor, not a replaceable pathname grant. */ +export class WorkspaceRootAccessError extends Error {} +export class WorkspaceRootAccess { + private constructor( + readonly path: string, + readonly handle: fs.FileHandle, + ) {} + + static async open( + path: string, + identity: WorkspaceRootIdentity, + ): Promise { + if (!openAt || path !== identity.path) + throw new WorkspaceRootAccessError( + 'Selected project root access is unavailable', + ); + const handle = await fs + .open(path, DIRECTORY_ACCESS | constants.O_NOFOLLOW) + .catch(() => { + throw new WorkspaceRootAccessError( + 'Selected project changed after admission', + ); + }); + try { + const current = await handle.stat({ bigint: true }); + if ( + !current.isDirectory() || + current.dev.toString() !== identity.dev || + current.ino.toString() !== identity.ino + ) { + throw new WorkspaceRootAccessError( + 'Selected project changed after admission', + ); + } + return new WorkspaceRootAccess(path, handle); + } catch (error) { + await handle.close(); + throw error; + } + } + + close(): Promise { + return this.handle.close(); + } + + directory(path: string): number { + const fd = checked( + openAt!( + this.handle.fd, + offset(this.path, path), + DIRECTORY_ACCESS, + 0, + ), + ); + try { + this.assertDirectoryAncestor(fd); + this.canonical(fd); + return fd; + } catch (error) { + closeSync(fd); + throw error; + } + } + + private canonical(fd: number): string { + return resolve( + this.path, + offset(physicalPath(this.handle.fd), physicalPath(fd)), + ); + } + + /** Paths are presentation, not proof of ancestry: a renamed root can make + * an outside symlink target temporarily occupy its old textual prefix. */ + private assertDirectoryAncestor(fd: number): void { + const root = fstatSync(this.handle.fd, { bigint: true }); + let current = fd; + try { + for (let depth = 0; depth <= 128; depth++) { + const identity = fstatSync(current, { bigint: true }); + if (identity.dev === root.dev && identity.ino === root.ino) + return; + const parent = checked( + openAt!( + current, + '..', + DIRECTORY_ACCESS | constants.O_NOFOLLOW, + 0, + ), + ); + if (current !== fd) closeSync(current); + current = parent; + const ancestor = fstatSync(parent, { bigint: true }); + if ( + ancestor.dev === identity.dev && + ancestor.ino === identity.ino + ) + break; + } + throw Object.assign( + new Error( + 'Directory is outside the held workspace or exceeds its ancestry limit', + ), + { code: 'EACCES' }, + ); + } finally { + if (current !== fd) closeSync(current); + } + } + + private parent(path: string): { fd: number; name: string } { + const local = offset(this.path, path); + const fd = checked( + openAt!(this.handle.fd, dirname(local), DIRECTORY_ACCESS, 0), + ); + try { + this.assertDirectoryAncestor(fd); + this.canonical(fd); + return { fd, name: basename(local) }; + } catch (error) { + closeSync(fd); + throw error; + } + } + + async openFile( + path: string, + flags: number, + mode = 0o666, + ): Promise { + const parent = this.parent(path); + let fd: number | undefined; + try { + fd = checked(openAt!(parent.fd, parent.name, flags, mode)); + // Node owns the duplicate, so callers retain native FileHandle semantics + // for asynchronous I/O, fsync, ownership and deterministic close. + const accessMode = flags & (constants.O_WRONLY | constants.O_RDWR); + const duplicate = await fs.open( + descriptorPath(fd), + accessMode | constants.O_NONBLOCK, + ); + try { + const expected = fstatSync(fd, { bigint: true }); + const actual = await duplicate.stat({ bigint: true }); + if (expected.dev !== actual.dev || expected.ino !== actual.ino) + throw new Error('Descriptor duplication changed identity'); + return duplicate; + } catch (error) { + await duplicate.close(); + throw error; + } + } finally { + if (fd !== undefined) closeSync(fd); + closeSync(parent.fd); + } + } + + stat(path: string, follow = true): ReturnType { + const parent = this.parent(path); + let fd: number | undefined; + try { + const flags = + process.platform === 'linux' + ? 0x200000 /* O_PATH */ | + (follow ? 0 : constants.O_NOFOLLOW) + : 0x8000 /* O_EVTONLY */ | + (follow ? 0 : 0x200000); /* O_SYMLINK */ + fd = this.metadataDescriptor(parent.fd, parent.name, flags); + return fstatSync(fd); + } finally { + if (fd !== undefined) closeSync(fd); + closeSync(parent.fd); + } + } + + private metadataDescriptor( + parent: number, + name: string, + flags: number, + ): number { + if (process.platform === 'darwin') { + // O_EVTONLY still requests read permission on a directory. Search-only + // descriptors preserve known-path metadata/cwd access without enumeration. + const directory = openAt!( + parent, + name, + DIRECTORY_ACCESS | + (flags & 0x200000 /* O_SYMLINK */ + ? constants.O_NOFOLLOW + : 0), + 0, + ); + if (directory >= 0) return directory; + const error = nativeError(); + if (error.code !== 'ENOTDIR' && error.code !== 'ELOOP') throw error; + } + return checked(openAt!(parent, name, flags, 0)); + } + + realpath(path: string): string { + const parent = this.parent(path); + let fd: number | undefined; + try { + fd = this.metadataDescriptor( + parent.fd, + parent.name, + process.platform === 'linux' + ? 0x200000 /* O_PATH */ + : 0x8000 /* O_EVTONLY */, + ); + return this.canonical(fd); + } finally { + if (fd !== undefined) closeSync(fd); + closeSync(parent.fd); + } + } + + install(from: string, to: string, link: boolean): 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) + : renameAt!(source.fd, source.name, target.fd, target.name); + if (result !== 0) throw nativeError(); + } finally { + closeSync(source.fd); + if (target) closeSync(target.fd); + } + } + + unlink(path: string): void { + const parent = this.parent(path); + try { + if (unlinkAt!(parent.fd, parent.name, 0) !== 0) throw nativeError(); + } finally { + closeSync(parent.fd); + } + } +} + +const context = new AsyncLocalStorage(); +export async function withWorkspaceRoot( + root: string, + identity: WorkspaceRootIdentity | undefined, + action: () => Promise, +): Promise { + if (!identity) return action(); + const access = await WorkspaceRootAccess.open(root, identity); + try { + return await context.run(access, action); + } finally { + await access.close(); + } +} + +type SpawnCommand = ( + command: string, + args: string[], + options: SpawnOptionsWithoutStdio, +) => ChildProcessWithoutNullStreams; +export function spawnWithinWorkspace( + spawner: SpawnCommand, + command: string, + args: string[], + options: SpawnOptionsWithoutStdio, +): ChildProcessWithoutNullStreams { + const access = context.getStore(); + if (!access) return spawner(command, args, options); + if (typeof options.cwd !== 'string') + throw new Error('Workspace process requires a working directory'); + const fd = access.directory(options.cwd); + try { + const env = { ...(options.env ?? process.env) }; + delete env.NODE_OPTIONS; + delete env.NODE_PATH; + return spawner( + process.execPath, + [ + fileURLToPath(new URL('./root-exec.js', import.meta.url)), + command, + ...args, + ], + { + ...options, + cwd: '/', + env, + // The trusted bootstrap consumes fd 3 before exec. Commands receive no + // root descriptor, bridge socket or new long-lived supervising process. + stdio: [ + ...(Array.isArray(options.stdio) + ? options.stdio.slice(0, 3) + : ['pipe', 'pipe', 'pipe']), + fd, + ], + } as SpawnOptionsWithoutStdio, + ); + } finally { + closeSync(fd); + } +} + +export const spawn = (( + command: string, + args: string[], + options: SpawnOptionsWithoutStdio, +) => + spawnWithinWorkspace( + spawnProcess, + command, + args, + options, + )) as typeof spawnProcess; + +// Only workspace filesystem consumers import these adapters. Unselected legacy +// roots retain their existing behavior; concurrent selected roots never share fd state. +export const open = async ( + path: string, + flags: number | 'r', + mode?: number, +): Promise => + context + .getStore() + ?.openFile(path, flags === 'r' ? constants.O_RDONLY : flags, mode) ?? + fs.open(path, flags, mode); +export const stat = async (path: string) => + context.getStore()?.stat(path) ?? fs.stat(path); +export const lstat = async (path: string) => + context.getStore()?.stat(path, false) ?? fs.lstat(path); +export const realpath = async (path: string): Promise => + context.getStore()?.realpath(path) ?? fs.realpath(path); +export const rename = async (from: string, to: string): Promise => { + const access = context.getStore(); + if (access) access.install(from, to, false); + else await fs.rename(from, to); +}; +export const link = async (from: string, to: string): Promise => { + const access = context.getStore(); + if (access) access.install(from, to, true); + else await fs.link(from, to); +}; +export const unlink = async (path: string): Promise => { + const access = context.getStore(); + if (access) access.unlink(path); + else await fs.unlink(path); +}; diff --git a/packages/code/src/root-exec.ts b/packages/code/src/root-exec.ts new file mode 100644 index 00000000..4cd7132c --- /dev/null +++ b/packages/code/src/root-exec.ts @@ -0,0 +1,18 @@ +import koffi from 'koffi'; + +// Private exec trampoline. The parent supplies an already validated directory +// on fd 3. fchdir is process-local here and never changes the bridge's cwd. +const lib = koffi.load(null); +const fchdir = lib.func('int fchdir(int fd)'); +const close = lib.func('int close(int fd)'); +const execvp = lib.func('int execvp(const char *file, const char **argv)'); +const fcntl = lib.func('int fcntl(int fd, int command, ...)'); +const args = process.argv.slice(2); +if (args.length === 0 || fchdir(3) !== 0 || close(3) !== 0) process.exit(125); +// Node marks its standard streams close-on-exec during startup. Preserve only +// the three conventional streams; every internal descriptor stays closed. +for (const fd of [0, 1, 2]) { + if (fcntl(fd, 2 /* F_SETFD */, 'int', 0) !== 0) process.exit(125); +} +execvp(args[0], [...args, null]); +process.exit(126); diff --git a/packages/code/src/root-identity.ts b/packages/code/src/root-identity.ts new file mode 100644 index 00000000..3ddfe453 --- /dev/null +++ b/packages/code/src/root-identity.ts @@ -0,0 +1,27 @@ +import { lstat, realpath } from 'node:fs/promises'; + +export interface WorkspaceRootIdentity { + path: string; + dev: string; + ino: string; +} + +/** Revalidation of a trusted snapshot, never a fresh grant to a replacement. */ +export async function matchesWorkspaceRoot( + root: string, + identity: WorkspaceRootIdentity +): Promise { + if (root !== identity.path) return false; + try { + const current = await lstat(root, { bigint: true }); + return ( + current.isDirectory() && + !current.isSymbolicLink() && + current.dev.toString() === identity.dev && + current.ino.toString() === identity.ino && + (await realpath(root)) === identity.path + ); + } catch { + return false; + } +} diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 87b444e9..65757d39 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -1,10 +1,11 @@ -import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { constants } from 'node:fs'; -import { link, lstat, open, realpath, rename, stat, unlink } from 'node:fs/promises'; +import { link, lstat, open, realpath, rename, stat, unlink, spawn, withWorkspaceRoot, WorkspaceRootAccessError } from './root-access.js'; import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import type { FileHandle } from 'node:fs/promises'; +import { matchesWorkspaceRoot } from './root-identity.js'; +import type { WorkspaceRootIdentity } from './root-identity.js'; import { BRIDGE_PROTOCOL_VERSION, @@ -67,6 +68,7 @@ export type { }; export interface LocalWorkspaceConfig { + identity?: WorkspaceRootIdentity; id: string; name?: string; root: string; @@ -336,6 +338,7 @@ async function readConfinedFile( } interface WorkspaceRoot { + identity?: WorkspaceRootIdentity; root: string; writable: boolean; } @@ -1435,8 +1438,8 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { async instructionDescriptors() { if (!this.repositoryInstructions) return undefined; - const entries = await Promise.all([...this.roots].map(async ([id, { root }]) => { - const snapshot = await readRepositoryInstructions(root); + const entries = await Promise.all([...this.roots].map(async ([id, { root, identity }]) => { + const snapshot = await withWorkspaceRoot(root, identity, () => readRepositoryInstructions(root)); return [id, snapshot ? [snapshot.descriptor] : []] as const; })); return new Map(entries); @@ -1508,6 +1511,8 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { let canonicalRoot: string; try { canonicalRoot = await realpath(workspace.root); + if (workspace.identity && !await matchesWorkspaceRoot(canonicalRoot, workspace.identity)) throw new Error(); + await withWorkspaceRoot(canonicalRoot, workspace.identity, async () => undefined); if (!(await stat(canonicalRoot)).isDirectory()) throw new Error(); } catch { throw new WorkspaceToolError( @@ -1516,6 +1521,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { ); } roots.set(workspace.id, { + identity: workspace.identity, root: canonicalRoot, writable: workspace.writable === true, }); @@ -1536,6 +1542,20 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { async execute( request: WorkspaceToolRequest, signal?: AbortSignal, + ): Promise { + const workspace = this.roots.get(request?.workspaceId); + if (!workspace?.identity) return this.executeBound(request, signal); + try { + return await withWorkspaceRoot(workspace.root, workspace.identity, () => this.executeBound(request, signal)); + } catch (error) { + if (error instanceof WorkspaceRootAccessError) throw new WorkspaceToolError(error.message, 'REGISTRATION_INVALID'); + throw error; + } + } + + private async executeBound( + request: WorkspaceToolRequest, + signal?: AbortSignal, ): Promise { if (signal?.aborted) { throw new WorkspaceToolError(