From b5b988a132f38dc51667bb5519bc06fda1988325 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 28 Sep 2026 23:25:39 -0400 Subject: [PATCH 1/9] feat: Schedule Linked Worktrees as Their Own Workspace Lanes --- docs/remote-bridge/README.md | 5 +- docs/remote-bridge/projects.md | 6 +- packages/code/README.md | 40 +++ packages/code/src/cli.ts | 82 ++++- packages/code/src/linked-worktrees.test.ts | 243 +++++++++++++++ packages/code/src/linked-worktrees.ts | 331 +++++++++++++++++++++ packages/code/src/native-sandbox.ts | 68 ++++- packages/code/src/protocol.test.ts | 61 ++++ packages/code/src/protocol.ts | 79 ++++- packages/code/src/worker-slots.test.ts | 68 +++++ packages/code/src/worker.ts | 123 +++++++- packages/code/src/workspace-cli.test.ts | 41 +++ service/src/bridge/linked-worktree.test.ts | 174 +++++++++++ service/src/bridge/router.ts | 1 + service/src/bridge/slots.test.ts | 62 +++- service/src/bridge/slots.ts | 38 ++- service/src/bridge/store.ts | 56 +++- service/src/service/programmatic-router.ts | 20 +- service/src/service/programmatic-state.ts | 21 +- service/src/service/replay-state.ts | 2 + service/src/types/service.ts | 4 + 21 files changed, 1485 insertions(+), 40 deletions(-) create mode 100644 packages/code/src/linked-worktrees.test.ts create mode 100644 packages/code/src/linked-worktrees.ts create mode 100644 service/src/bridge/linked-worktree.test.ts diff --git a/docs/remote-bridge/README.md b/docs/remote-bridge/README.md index 464e4763..a37745a2 100644 --- a/docs/remote-bridge/README.md +++ b/docs/remote-bridge/README.md @@ -307,7 +307,10 @@ execution. - Code API negotiates a bounded number of active workspace assignments per worker. The lower API or worker slot ceiling wins, and assignments sharing the same workspace isolation key remain serialized while independent - conversation worktrees may run concurrently. + conversation worktrees may run concurrently. A linked-worktree lane + (`worktree: `) nests beneath its checkout's key: sibling lanes run + concurrently, while a lane and its checkout exclude each other, and a lane + cannot start while its checkout is quarantined. - Workspace tool admission waits for capacity up to 30 seconds without a `X-LibreChat-Workspace-Queue-Wait-Ms` header. A caller can advertise a longer per-request allowance, bounded by five minutes and any server queue ceiling. diff --git a/docs/remote-bridge/projects.md b/docs/remote-bridge/projects.md index 987ce112..dafeee8a 100644 --- a/docs/remote-bridge/projects.md +++ b/docs/remote-bridge/projects.md @@ -41,7 +41,11 @@ workspace registration remains available for independent project directories. filesystem boundary and coordination for the common Git directory. - A worktree beneath its parent checkout overlaps that checkout. Either use disjoint execution roots under a discovery grant or explicitly exclude and - coordinate descendant worktrees before relaxing root exclusion. + coordinate descendant worktrees before relaxing root exclusion. Linked + worktree lanes (`--linked-worktree-lanes`) take the second approach for + `.worktrees/`: hierarchical admission keeps a lane and its checkout + exclusive, and each lane's sandbox keeps shared Git configuration, hooks and + sibling metadata read-only. - Setup and dependency links must remain within the execution policy. Sharing writable dependency directories between supposedly isolated worktrees reintroduces overlap and requires an explicit operator decision. diff --git a/packages/code/README.md b/packages/code/README.md index 95d86485..cc62e7a4 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -791,6 +791,46 @@ Legacy requests without a conversation identity continue to use the selected source root. Older Code API deployments do not negotiate the capability, so the worker omits it until every request path understands the isolation boundary. +#### Linked worktree lanes + +Agents that keep one checkout and give each task its own linked worktree +(`git worktree add .worktrees/`) can run those tasks concurrently: + +```sh +librechat-code run \ + --worker-dir /projects/LibreChat \ + --workspace-lease-slots 4 \ + --linked-worktree-lanes \ + --allow-workspace-writes \ + --allow-workspace-commands +``` + +`LIBRECHAT_CODE_LINKED_WORKTREE_LANES=true` is the environment equivalent. The +worker then advertises `workspaceScopes: ['git_linked_worktree']` for each +registered root, and a request that names `worktree: ` runs in its own +lane at `/.worktrees/`, with `cwd` and file paths relative to that +worktree. Sibling lanes run concurrently up to the negotiated slot count. A +lane and its checkout never run at the same time: requests without a +`worktree`, including `git worktree add` or `remove` run at the root, wait for +every lane beneath the checkout, and a waiting root request holds back newer +lanes so it cannot be starved. + +Before admission the worker verifies, without running Git, that the directory +is a real linked worktree of that checkout: no symlinks on the path, a `.git` +file pointing at `/.git/worktrees/`, and metadata whose `commondir` +and `gitdir` point back. A lane's sandbox can write only its worktree and the +shared Git directory; `.git/hooks`, `.git/config`, `.git/info` and every sibling's +`.git/worktrees/` metadata stay read-only, and automatic `gc` and +maintenance are disabled so one lane cannot repack storage under another. Each +lane has its own durable quarantine guard, and a lane cannot start while its +checkout is quarantined. + +Lanes require native-srt commands and at least two lease slots, and cannot yet +be combined with conversation worktrees. Code API must advertise +`supportedWorkspaceScopes`; older deployments do not, and the worker omits the +scope for them. Deploy consumers that read worker status (such as LibreChat) +with support for `workspaceScopes` before enabling lanes on a worker. + On an updated Code API, admission waits up to 30 seconds without the `X-LibreChat-Workspace-Queue-Wait-Ms` request header. A caller may advertise a positive integer millisecond allowance up to five minutes, capped by any server diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 6e3a0062..a78ab6a1 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -2,7 +2,7 @@ import { createHash, createHmac, randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { realpath, stat } from 'node:fs/promises'; -import { basename, resolve, relative, isAbsolute, sep } from 'node:path'; +import { basename, join, resolve, relative, isAbsolute, sep } from 'node:path'; import { pairBridgeWorker } from './pairing.js'; import { discoverProjects } from './projects.js'; @@ -37,6 +37,7 @@ import { RuntimeWorkspaceCommandSandbox } from './workspace-runtime.js'; import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; import { NativeWorkspaceCommandPool } from './native-pool.js'; import { GitWorktreeWorkspaceTools, internalWorkspaceId } from './workspace-instances.js'; +import { LINKED_WORKTREE_DIRECTORY, LinkedWorktreeWorkspaceTools } from './linked-worktrees.js'; import { GitWorktreeManager } from './worktrees.js'; import { captureWorkspaceRootIdentity } from './root-identity.js'; import { @@ -514,6 +515,11 @@ async function run( (args.includes('--allow-workspace-commands') || process.env.LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS?.trim().toLowerCase() === 'true'); + const linkedWorktreeLanes = + runtimeSessionId == null && + (args.includes('--linked-worktree-lanes') || + process.env.LIBRECHAT_CODE_LINKED_WORKTREE_LANES?.trim().toLowerCase() === + 'true'); const commandSandboxMode = option(args, '--command-sandbox') ?? process.env.LIBRECHAT_CODE_COMMAND_SANDBOX?.trim().toLowerCase() ?? @@ -783,6 +789,21 @@ async function run( 'Conversation worktrees require native-srt commands and at least two workspace lease slots', ); } + if ( + linkedWorktreeLanes && + (!allowWorkspaceCommands || + commandSandboxMode !== 'native-srt' || + workspaceLeaseSlots < 2) + ) { + throw new Error( + 'Linked worktree lanes require native-srt commands and at least two workspace lease slots', + ); + } + if (linkedWorktreeLanes && conversationWorktreeRoot) { + throw new Error( + 'Linked worktree lanes cannot be combined with conversation worktrees', + ); + } if ( roots.length > 1 && process.env.LIBRECHAT_CODE_WORKSPACE_QUARANTINE_FILE?.trim() @@ -1073,7 +1094,7 @@ async function run( }; const nativeCommandSandbox = allowWorkspaceCommands && commandSandboxMode === 'native-srt' - ? roots.length > 1 || workspaceLeaseSlots > 1 || conversationWorktreeRoot + ? roots.length > 1 || workspaceLeaseSlots > 1 || conversationWorktreeRoot || linkedWorktreeLanes ? new NativeWorkspaceCommandPool( new Map( roots.map(root => [ @@ -1199,6 +1220,38 @@ async function run( ), }); workspaceTools = conversationWorkspaceTools; + } + let linkedWorktreeTools: LinkedWorktreeWorkspaceTools | undefined; + if (linkedWorktreeLanes && workspaceTools) { + if (!(nativeCommandSandbox instanceof NativeWorkspaceCommandPool)) { + throw new Error('Linked worktree lanes require a native command pool'); + } + linkedWorktreeTools = new LinkedWorktreeWorkspaceTools({ + commandPool: nativeCommandSandbox, + delegate: workspaceTools, + programmaticDelegate: conversationWorkspaceTools ?? nativeCommandSandbox, + onResolve(workspaceId, root) { + if (admittedGitHubRepositories) { + admittedGitHubRepositories.set( + root, + repositoriesByWorkspace?.get(workspaceId), + ); + } + }, + sources: new Map( + roots.map((root) => [ + root.id, + { + root: root.root, + identity: root.identity, + command: nativeOptions, + repositoryInstructions: args.includes('--repository-instructions'), + writable: root.writable ?? false, + }, + ]), + ), + }); + workspaceTools = linkedWorktreeTools; } if (workspaceTools && environments.length) { workspaceTools = new EnvironmentWorkspaceTools( @@ -1304,7 +1357,7 @@ async function run( ...(nativeProgrammaticEnabled && nativeCommandSandbox ? { workspaceProgrammatic: - conversationWorkspaceTools ?? nativeCommandSandbox, + linkedWorktreeTools ?? conversationWorkspaceTools ?? nativeCommandSandbox, } : {}), ...(conversationWorktrees @@ -1331,6 +1384,29 @@ async function run( ), } : {}), + ...(linkedWorktreeTools + ? { + linkedWorktreeQuarantineResolver: ( + selectedWorkspaceId: string, + worktree: string, + ) => { + const source = roots.find((root) => root.id === selectedWorkspaceId); + if (!source) { + throw new BridgeProtocolError('Linked worktree source is not registered'); + } + return workspaceMutationGuard( + defaultWorkspaceQuarantinePath({ + codeApiUrl, + workerId, + workspaceRoot: join(source.root, LINKED_WORKTREE_DIRECTORY, worktree), + }), + workerId, + workspaceIsolationKey(selectedWorkspaceId, undefined, worktree), + incarnationId, + ); + }, + } + : {}), ...(workspaceLeaseSlots > 1 || roots.length > 1 ? { workspaceQuarantines: new Map( diff --git a/packages/code/src/linked-worktrees.test.ts b/packages/code/src/linked-worktrees.test.ts new file mode 100644 index 00000000..eb1d1714 --- /dev/null +++ b/packages/code/src/linked-worktrees.test.ts @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import test from 'node:test'; + +import { + LinkedWorktreeWorkspaceTools, + linkedWorktreeWorkspaceId, + verifyLinkedWorktree, +} from './linked-worktrees.js'; +import { LocalWorkspaceTools, WorkspaceToolError } from './workspace.js'; + +import type { NativeWorkspaceCommandPool } from './native-pool.js'; +import type { NativeProcessSandboxOptions } from './native-process.js'; +import type { WorkspaceToolRequest } from './protocol.js'; + +const execFileAsync = promisify(execFile); + +async function git(cwd: string, ...args: string[]): Promise { + await execFileAsync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@example.com', '-c', 'commit.gpgsign=false', ...args], + { cwd }, + ); +} + +async function checkout(): Promise<{ parent: string; root: string }> { + const parent = await realpath(await mkdtemp(join(tmpdir(), 'linked-worktree-'))); + const root = join(parent, 'repo'); + await mkdir(root); + await git(root, 'init', '-q', '-b', 'main'); + await writeFile(join(root, 'README.md'), 'root\n'); + await git(root, 'add', '.'); + await git(root, 'commit', '-q', '-m', 'init'); + await mkdir(join(root, '.worktrees')); + await git(root, 'worktree', 'add', '-q', '-b', 'task-a', '.worktrees/task-a'); + return { parent, root }; +} + +async function rejects(promise: Promise, code = 'INVALID_REQUEST'): Promise { + await assert.rejects(promise, (error: unknown) => error instanceof WorkspaceToolError && error.code === code); +} + +test('verifies a linked worktree of the checkout and lists the Git paths it may not write', async (t) => { + const { parent, root } = await checkout(); + t.after(() => rm(parent, { recursive: true, force: true })); + await git(root, 'worktree', 'add', '-q', '-b', 'task-b', '.worktrees/task-b'); + + const lane = await verifyLinkedWorktree(root, 'task-a'); + + assert.equal(lane.root, join(root, '.worktrees', 'task-a')); + assert.equal(lane.checkoutRoot, root); + assert.equal(lane.commonGitDir, join(root, '.git')); + assert.ok(lane.readOnlyGitPaths.includes(join(root, '.git', 'config'))); + assert.ok(lane.readOnlyGitPaths.includes(join(root, '.git', 'hooks'))); + assert.ok(lane.readOnlyGitPaths.includes(join(root, '.git', 'worktrees', 'task-b'))); + assert.ok(!lane.readOnlyGitPaths.includes(join(root, '.git', 'worktrees', 'task-a'))); +}); + +test('rejects directories that are not linked worktrees of this checkout', async (t) => { + const { parent, root } = await checkout(); + t.after(() => rm(parent, { recursive: true, force: true })); + + await mkdir(join(root, '.worktrees', 'plain')); + await rejects(verifyLinkedWorktree(root, 'plain')); + + await mkdir(join(root, '.worktrees', 'borrowed')); + await writeFile( + join(root, '.worktrees', 'borrowed', '.git'), + `gitdir: ${join(root, '.git', 'worktrees', 'task-a')}\n`, + ); + await rejects(verifyLinkedWorktree(root, 'borrowed')); + + await symlink(join(root, '.worktrees', 'task-a'), join(root, '.worktrees', 'alias')); + await rejects(verifyLinkedWorktree(root, 'alias')); + + const other = join(parent, 'other'); + await mkdir(other); + await git(other, 'init', '-q', '-b', 'main'); + await writeFile(join(other, 'file'), 'x\n'); + await git(other, 'add', '.'); + await git(other, 'commit', '-q', '-m', 'other'); + await git(other, 'worktree', 'add', '-q', '-b', 'foreign', join(root, '.worktrees', 'foreign')); + await rejects(verifyLinkedWorktree(root, 'foreign')); + + for (const name of ['..', '.git', 'a/b', 'task.lock']) { + await rejects(verifyLinkedWorktree(root, name)); + } + await rejects(verifyLinkedWorktree(root, 'missing')); +}); + +test('rejects a checkout whose .worktrees directory is a symlink', async (t) => { + const { parent, root } = await checkout(); + t.after(() => rm(parent, { recursive: true, force: true })); + const elsewhere = join(parent, 'elsewhere'); + await git(root, 'worktree', 'move', '.worktrees/task-a', elsewhere); + await rm(join(root, '.worktrees'), { recursive: true }); + await symlink(parent, join(root, '.worktrees')); + await rejects(verifyLinkedWorktree(root, 'elsewhere')); +}); + +function recordingPool(): { + pool: NativeWorkspaceCommandPool; + calls: Array<{ action: string; id: string; options?: NativeProcessSandboxOptions }>; +} { + const calls: Array<{ action: string; id: string; options?: NativeProcessSandboxOptions }> = []; + const pool = { + async registerRoot(id: string, options: NativeProcessSandboxOptions) { + calls.push({ action: 'register', id, options }); + }, + async unregisterRoot(id: string) { + calls.push({ action: 'unregister', id }); + }, + async execute(request: WorkspaceToolRequest) { + calls.push({ action: 'execute', id: request.workspaceId }); + return { + protocolVersion: 1, + operation: 'execute_command', + workspaceId: request.workspaceId, + exitCode: 0, + stdout: '', + stderr: '', + truncated: false, + timedOut: false, + }; + }, + async executeProgrammatic(id: string) { + calls.push({ action: 'programmatic', id }); + return {}; + }, + } as unknown as NativeWorkspaceCommandPool; + return { pool, calls }; +} + +async function laneTools(root: string, pool?: NativeWorkspaceCommandPool) { + const delegate = await LocalWorkspaceTools.create({ + repositoryInstructions: false, + workspaces: [{ id: 'repo', root, writable: true }], + }); + return new LinkedWorktreeWorkspaceTools({ + commandPool: pool, + delegate, + sources: new Map([ + [ + 'repo', + { + root, + command: { workspaceRoot: root } as NativeProcessSandboxOptions, + repositoryInstructions: false, + writable: true, + }, + ], + ]), + }); +} + +test('lane file tools are confined to the worktree and report the public workspace', async (t) => { + const { parent, root } = await checkout(); + t.after(() => rm(parent, { recursive: true, force: true })); + const tools = await laneTools(root); + + assert.deepEqual(tools.capabilities.workspaces[0]?.workspaceScopes, ['git_linked_worktree']); + const written = await tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'repo', + worktree: 'task-a', + path: 'lane.txt', + content: 'from the lane\n', + }); + assert.equal(written.workspaceId, 'repo'); + assert.equal(await readFile(join(root, '.worktrees', 'task-a', 'lane.txt'), 'utf8'), 'from the lane\n'); + await assert.rejects(stat(join(root, 'lane.txt'))); + + const read = await tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'repo', + path: 'README.md', + }); + assert.equal(read.operation === 'read_file' && read.content.trimEnd(), 'root'); + + await rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'repo', + worktree: 'task-a', + workspaceInstanceId: 'a'.repeat(64), + path: 'README.md', + }), + ); +}); + +test('lane commands register a confined root and refresh it when sibling worktrees change', async (t) => { + const { parent, root } = await checkout(); + t.after(() => rm(parent, { recursive: true, force: true })); + const { pool, calls } = recordingPool(); + const tools = await laneTools(root, pool); + const command = { + protocolVersion: 1 as const, + operation: 'execute_command' as const, + workspaceId: 'repo', + worktree: 'task-a', + command: 'git status', + }; + const id = linkedWorktreeWorkspaceId('repo', 'task-a'); + + const result = await tools.execute(command); + await tools.execute(command); + assert.equal(result.workspaceId, 'repo'); + assert.deepEqual( + calls.map((call) => call.action), + ['register', 'execute', 'execute'], + ); + const registered = calls[0]!.options!; + assert.equal(calls[0]!.id, id); + assert.equal(registered.workspaceRoot, join(root, '.worktrees', 'task-a')); + assert.equal(registered.linkedWorktree?.commonGitDir, join(root, '.git')); + assert.equal(registered.linkedWorktree?.checkoutRoot, root); + + await git(root, 'worktree', 'add', '-q', '-b', 'task-b', '.worktrees/task-b'); + await tools.execute(command); + assert.deepEqual( + calls.slice(3).map((call) => call.action), + ['unregister', 'register', 'execute'], + ); + assert.ok( + calls[4]!.options!.linkedWorktree!.readOnlyGitPaths.includes(join(root, '.git', 'worktrees', 'task-b')), + ); +}); diff --git a/packages/code/src/linked-worktrees.ts b/packages/code/src/linked-worktrees.ts new file mode 100644 index 00000000..039f7190 --- /dev/null +++ b/packages/code/src/linked-worktrees.ts @@ -0,0 +1,331 @@ +import { createHash } from 'node:crypto'; +import { lstat, open, readdir, realpath } from 'node:fs/promises'; +import { isAbsolute, join, resolve } from 'node:path'; + +import { isValidLinkedWorktreeName } from './protocol.js'; +import { + captureWorkspaceRootIdentity, + matchesWorkspaceRoot, +} from './root-identity.js'; +import { LocalWorkspaceTools, WorkspaceToolError } from './workspace.js'; + +import type { NativeWorkspaceCommandPool } from './native-pool.js'; +import type { NativeProcessSandboxOptions } from './native-process.js'; +import type { WorkspaceRootIdentity } from './root-identity.js'; +import type { + BridgeWorkspaceProgrammaticRequest, + WorkspaceExecuteCommandRequest, + WorkspaceToolRequest, + WorkspaceToolResult, +} from './protocol.js'; +import type { WorkspaceToolExecutor } from './workspace.js'; + +/** Linked worktrees are only admitted from this directory beneath a checkout. */ +export const LINKED_WORKTREE_DIRECTORY = '.worktrees'; +/** Git pointer files are a single line; anything larger is not one. */ +const GIT_POINTER_MAX_BYTES = 4096; + +export interface LinkedWorktreeSource { + root: string; + identity?: WorkspaceRootIdentity; + command?: NativeProcessSandboxOptions; + repositoryInstructions: boolean; + writable: boolean; +} + +export interface VerifiedLinkedWorktree { + root: string; + identity: WorkspaceRootIdentity; + checkoutRoot: string; + commonGitDir: string; + /** Existing paths beneath the shared Git directory a lane may not write. */ + readOnlyGitPaths: string[]; +} + +export interface LinkedWorktreeWorkspaceToolsOptions { + commandPool?: NativeWorkspaceCommandPool; + delegate: WorkspaceToolExecutor; + /** Called with each verified lane root, e.g. to route credentials for its repository. */ + onResolve?: (workspaceId: string, root: string) => void; + programmaticDelegate?: { + executeProgrammatic( + workspaceId: string, + request: BridgeWorkspaceProgrammaticRequest, + signal?: AbortSignal, + ): Promise; + }; + sources: ReadonlyMap; +} + +export function linkedWorktreeWorkspaceId(workspaceId: string, worktree: string): string { + return `lane-${createHash('sha256').update(`${workspaceId}\0${worktree}`).digest('hex')}`; +} + +function rejected(message: string): WorkspaceToolError { + return new WorkspaceToolError(message, 'INVALID_REQUEST'); +} + +async function realDirectory(path: string): Promise { + try { + const status = await lstat(path); + return status.isDirectory() && !status.isSymbolicLink(); + } catch { + return false; + } +} + +async function readPointer(path: string): Promise { + let handle; + try { + const status = await lstat(path); + if (!status.isFile() || status.isSymbolicLink() || status.size > GIT_POINTER_MAX_BYTES) { + return undefined; + } + handle = await open(path, 'r'); + const buffer = Buffer.alloc(GIT_POINTER_MAX_BYTES + 1); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (bytesRead > GIT_POINTER_MAX_BYTES) return undefined; + return buffer.subarray(0, bytesRead).toString('utf8'); + } catch { + return undefined; + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function canonicalOrUndefined(path: string): Promise { + try { + return await realpath(path); + } catch { + return undefined; + } +} + +function singleLine(value: string | undefined): string | undefined { + const line = value?.replace(/\r?\n$/, ''); + return line == null || line.length === 0 || /[\r\n\0]/.test(line) ? undefined : line; +} + +/** + * Verify, without running Git, that `/.worktrees/` is a linked + * worktree of that checkout: a real directory whose `.git` file points at + * `/.git/worktrees/`, whose metadata points back at it, and + * whose common directory is the checkout's own `.git`. Nothing on the path may + * be a symlink, so a forged or relocated worktree cannot borrow a lane. + */ +export async function verifyLinkedWorktree( + checkoutRoot: string, + name: string, + checkoutIdentity?: WorkspaceRootIdentity, +): Promise { + if (!isValidLinkedWorktreeName(name)) { + throw rejected('Invalid linked worktree name'); + } + const checkout = await canonicalOrUndefined(checkoutRoot); + if (checkout == null || !(await realDirectory(checkout))) { + throw new WorkspaceToolError('Selected project is unavailable', 'REGISTRATION_INVALID'); + } + if (checkoutIdentity != null && !(await matchesWorkspaceRoot(checkout, checkoutIdentity))) { + throw new WorkspaceToolError('Selected project changed before lane admission', 'REGISTRATION_INVALID'); + } + const commonGitDir = join(checkout, '.git'); + const worktreesDirectory = join(checkout, LINKED_WORKTREE_DIRECTORY); + const root = join(worktreesDirectory, name); + const metadata = join(commonGitDir, 'worktrees', name); + if ( + !(await realDirectory(commonGitDir)) || + !(await realDirectory(worktreesDirectory)) || + !(await realDirectory(root)) || + (await canonicalOrUndefined(root)) !== root || + !(await realDirectory(metadata)) || + (await canonicalOrUndefined(metadata)) !== metadata + ) { + throw rejected(`No linked worktree named ${name} in ${LINKED_WORKTREE_DIRECTORY}`); + } + const dotGit = join(root, '.git'); + const pointer = singleLine(await readPointer(dotGit))?.match(/^gitdir: (.+)$/)?.[1]; + const gitDir = pointer == null ? undefined : isAbsolute(pointer) ? pointer : resolve(root, pointer); + const commonPointer = singleLine(await readPointer(join(metadata, 'commondir'))); + const backPointer = singleLine(await readPointer(join(metadata, 'gitdir'))); + if ( + gitDir == null || + (await canonicalOrUndefined(gitDir)) !== metadata || + commonPointer == null || + (await canonicalOrUndefined(resolve(metadata, commonPointer))) !== commonGitDir || + backPointer == null || + (await canonicalOrUndefined(resolve(metadata, backPointer))) !== dotGit + ) { + throw rejected(`${LINKED_WORKTREE_DIRECTORY}/${name} is not a linked worktree of this project`); + } + let identity: WorkspaceRootIdentity; + try { + identity = await captureWorkspaceRootIdentity(root); + } catch { + throw rejected(`${LINKED_WORKTREE_DIRECTORY}/${name} is unavailable`); + } + const siblings = (await readdir(join(commonGitDir, 'worktrees')).catch(() => [] as string[])) + .filter((entry) => entry !== name) + .map((entry) => join(commonGitDir, 'worktrees', entry)); + const candidates = [ + join(commonGitDir, 'hooks'), + join(commonGitDir, 'config'), + join(commonGitDir, 'config.worktree'), + join(commonGitDir, 'info'), + ...siblings, + ]; + const readOnlyGitPaths: string[] = []; + for (const candidate of candidates) { + if ((await lstat(candidate).catch(() => undefined)) != null) readOnlyGitPaths.push(candidate); + } + return { root, identity, checkoutRoot: checkout, commonGitDir, readOnlyGitPaths: readOnlyGitPaths.sort() }; +} + +function publicResult(result: WorkspaceToolResult, workspaceId: string): WorkspaceToolResult { + return { ...result, workspaceId }; +} + +/** + * Route requests that name a linked worktree into their own isolated executor. + * Code API schedules each `.worktrees/` as its own lane beneath the + * checkout, so file tools and commands here run confined to that worktree while + * sibling lanes run concurrently. Requests without a worktree pass through. + */ +export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { + readonly mutationFailuresAreAtomic?: true; + readonly capabilities: WorkspaceToolExecutor['capabilities']; + private readonly executors = new Map< + string, + { fingerprint: string; value: Promise } + >(); + private readonly commandRoots = new Map(); + + constructor(private readonly options: LinkedWorktreeWorkspaceToolsOptions) { + this.mutationFailuresAreAtomic = options.delegate.mutationFailuresAreAtomic; + this.capabilities = { + ...options.delegate.capabilities, + workspaces: options.delegate.capabilities.workspaces.map((workspace) => ({ + ...workspace, + ...(options.sources.has(workspace.id) + ? { workspaceScopes: ['git_linked_worktree' as const] } + : {}), + })), + }; + } + + private async resolveLane( + workspaceId: string, + worktree: string, + workspaceInstanceId: string | undefined, + ): Promise<{ lane: VerifiedLinkedWorktree; source: LinkedWorktreeSource; internalId: string }> { + if (workspaceInstanceId != null) { + throw rejected('Linked worktree lanes are not available inside conversation worktrees'); + } + const source = this.options.sources.get(workspaceId); + if (!source) { + throw rejected('Workspace does not allow linked worktree lanes'); + } + const lane = await verifyLinkedWorktree(source.root, worktree, source.identity); + this.options.onResolve?.(workspaceId, lane.root); + return { lane, source, internalId: linkedWorktreeWorkspaceId(workspaceId, worktree) }; + } + + private async fileExecutor( + internalId: string, + lane: VerifiedLinkedWorktree, + source: LinkedWorktreeSource, + ): Promise { + const fingerprint = `${lane.identity.path}\0${lane.identity.dev}\0${lane.identity.ino}`; + let cached = this.executors.get(internalId); + if (cached == null || cached.fingerprint !== fingerprint) { + cached = { + fingerprint, + value: LocalWorkspaceTools.create({ + repositoryInstructions: source.repositoryInstructions, + workspaces: [ + { id: internalId, identity: lane.identity, root: lane.root, writable: source.writable }, + ], + }), + }; + this.executors.set(internalId, cached); + } + return await cached.value; + } + + /** Register the lane's command root, replacing it when its identity or read-only Git paths changed. */ + private async registerCommandRoot( + internalId: string, + lane: VerifiedLinkedWorktree, + source: LinkedWorktreeSource, + ): Promise { + const pool = this.options.commandPool; + if (!source.command || !pool) { + throw new WorkspaceToolError('Linked worktree commands are unavailable', 'COMMAND_DISABLED'); + } + const fingerprint = JSON.stringify([ + lane.identity.path, + lane.identity.dev, + lane.identity.ino, + lane.readOnlyGitPaths, + ]); + const registered = this.commandRoots.get(internalId); + if (registered !== fingerprint) { + if (registered != null) await pool.unregisterRoot(internalId); + await pool.registerRoot(internalId, { + ...source.command, + workspaceIdentity: lane.identity, + workspaceRoot: lane.root, + linkedWorktree: { + checkoutRoot: lane.checkoutRoot, + commonGitDir: lane.commonGitDir, + readOnlyGitPaths: lane.readOnlyGitPaths, + }, + }); + this.commandRoots.set(internalId, fingerprint); + } + return pool; + } + + async execute(request: WorkspaceToolRequest, signal?: AbortSignal): Promise { + if (request.worktree == null) { + return await this.options.delegate.execute(request, signal); + } + const { worktree, ...baseRequest } = request; + const { lane, source, internalId } = await this.resolveLane( + request.workspaceId, + worktree, + request.workspaceInstanceId, + ); + const laneRequest = { ...baseRequest, workspaceId: internalId } as WorkspaceToolRequest; + if (request.operation === 'execute_command') { + const pool = await this.registerCommandRoot(internalId, lane, source); + return publicResult( + await pool.execute(laneRequest as WorkspaceExecuteCommandRequest, signal), + request.workspaceId, + ); + } + const executor = await this.fileExecutor(internalId, lane, source); + return publicResult(await executor.execute(laneRequest, signal), request.workspaceId); + } + + async executeProgrammatic( + workspaceId: string, + request: BridgeWorkspaceProgrammaticRequest, + signal?: AbortSignal, + ): Promise { + const worktree = request.body.workspace_worktree; + if (worktree == null) { + if (!this.options.programmaticDelegate) { + throw new WorkspaceToolError('Workspace programmatic execution is unavailable', 'COMMAND_DISABLED'); + } + return await this.options.programmaticDelegate.executeProgrammatic(workspaceId, request, signal); + } + const { lane, source, internalId } = await this.resolveLane( + workspaceId, + worktree, + request.body.workspace_instance_id, + ); + const pool = await this.registerCommandRoot(internalId, lane, source); + const { workspace_worktree: _worktree, ...body } = request.body; + return await pool.executeProgrammatic(internalId, { ...request, body }, signal); + } +} diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 5bf61c50..1ef5190a 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -106,10 +106,15 @@ const TRUSTED_GIT_ENVIRONMENT = { GIT_CONFIG_KEY_3: 'filter.lfs.required', GIT_CONFIG_VALUE_3: 'true', } as const; -const { - GIT_CONFIG_COUNT: TRUSTED_GIT_CONFIG_COUNT, - ...TRUSTED_GIT_CONFIG_ENTRIES -} = TRUSTED_GIT_ENVIRONMENT; +/** Sibling lanes share object storage, so a lane never starts automatic gc or maintenance. */ +const LINKED_WORKTREE_GIT_ENVIRONMENT = { + ...TRUSTED_GIT_ENVIRONMENT, + GIT_CONFIG_COUNT: '6', + GIT_CONFIG_KEY_4: 'gc.auto', + GIT_CONFIG_VALUE_4: '0', + GIT_CONFIG_KEY_5: 'maintenance.auto', + GIT_CONFIG_VALUE_5: 'false', +} as const; const NATIVE_SANDBOX_SCRATCH_PREFIX = 'librechat-code-srt-'; // SRT grants these shared compatibility paths by default. A worker-specific @@ -162,6 +167,15 @@ type SpawnCommand = ( export interface NativeSrtWorkspaceCommandSandboxOptions { workspaceIdentity?: WorkspaceRootIdentity; workspaceRoot: string; + /** Present when `workspaceRoot` is a verified linked worktree lane of a checkout. */ + linkedWorktree?: { + /** The checkout that owns the worktree; trusted as a Git safe directory. */ + checkoutRoot: string; + /** `/.git`: shared objects and refs the lane must be able to write. */ + commonGitDir: string; + /** Paths beneath the shared Git directory that stay read-only: hooks, config, sibling metadata. */ + readOnlyGitPaths: string[]; + }; commandPolicy?: NativeSrtCommandPolicy; /** Trusted worker files that must never become workspace-readable or writable. */ protectedPaths?: string[]; @@ -298,6 +312,13 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox private runtimeConfig?: SandboxRuntimeConfig; private denyReadPaths: string[] = []; private denyWritePaths: string[] = []; + private get gitEnvironment(): + | typeof TRUSTED_GIT_ENVIRONMENT + | typeof LINKED_WORKTREE_GIT_ENVIRONMENT { + return this.options.linkedWorktree + ? LINKED_WORKTREE_GIT_ENVIRONMENT + : TRUSTED_GIT_ENVIRONMENT; + } private scratchDirectory?: string; private scratchHandle?: FileHandle; private execution?: Promise; @@ -424,6 +445,28 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ); } } + const lane = this.options.linkedWorktree; + const commonGitDir = lane ? await canonicalPath(lane.commonGitDir) : undefined; + const checkoutRoot = lane ? await canonicalPath(lane.checkoutRoot) : undefined; + const readOnlyGitPaths = lane + ? await Promise.all(lane.readOnlyGitPaths.map(canonicalPath)) + : []; + if ( + lane && + (commonGitDir == null || + checkoutRoot == null || + !isWithin(checkoutRoot, commonGitDir) || + !isWithin(checkoutRoot, root) || + isWithin(commonGitDir, home) || + protectedPaths.some(path => isWithin(commonGitDir, path)) || + readOnlyGitPaths.some(path => !isWithin(commonGitDir, path))) + ) { + throw new WorkspaceToolError( + 'Linked worktree Git storage is outside its checkout', + 'REGISTRATION_INVALID', + ); + } + const laneGitPaths = commonGitDir ? [commonGitDir] : []; const canonicalScratchDirectory = await this.createScratchDirectory(sharedScratchPaths); if ( @@ -459,17 +502,23 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ], allowRead: [ root, + ...laneGitPaths, ...(canonicalScratchDirectory ? [canonicalScratchDirectory] : []), ], allowWrite: [ root, + ...laneGitPaths, ...(canonicalScratchDirectory ? [canonicalScratchDirectory] : []), ], - denyWrite: [...protectedPaths, ...deniedInheritedWritablePaths], + denyWrite: [ + ...protectedPaths, + ...deniedInheritedWritablePaths, + ...readOnlyGitPaths, + ], allowGitConfig: false, }, credentials: { @@ -520,7 +569,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox allowAppleEvents: false, enableWeakerNestedSandbox: false, enableWeakerNetworkIsolation: false, - git: { safeDirectories: [root] }, + git: { safeDirectories: checkoutRoot ? [root, checkoutRoot] : [root] }, }; await this.manager.initialize( config, @@ -537,6 +586,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox this.denyWritePaths = [ ...protectedPaths, ...deniedInheritedWritablePaths, + ...readOnlyGitPaths, ]; } @@ -894,7 +944,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox : request.command; wrapped = await this.withTemporaryHostEnvironment( { - ...TRUSTED_GIT_ENVIRONMENT, + ...this.gitEnvironment, ...(credentialEnvironment ?? {}), ...this.scratchSelectorEnvironment(sandboxScratchDirectory), }, @@ -999,10 +1049,10 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ...wrapped.env, ...this.scratchEnvironment(), ...trustedEnvironment, - ...TRUSTED_GIT_CONFIG_ENTRIES, + ...this.gitEnvironment, GIT_CONFIG_COUNT: wrapped.env.GIT_CONFIG_COUNT ?? - TRUSTED_GIT_CONFIG_COUNT, + this.gitEnvironment.GIT_CONFIG_COUNT, GIT_CONFIG_GLOBAL: this.platform === 'win32' ? 'NUL' diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index 5c26c462..729b20e2 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -11,6 +11,8 @@ import { isWorkspaceToolRequest, isWorkspaceToolResult, workspaceIsolationKey, + workspaceIsolationKeysConflict, + workspaceIsolationParent, } from './protocol.js'; import type { WorkspaceEditFileRequest, @@ -816,3 +818,62 @@ test('bridge artifact policy and media types match the hardened gateway contract assert.equal(bridgeArtifactMediaType('reports/result.json'), 'application/json'); assert.equal(bridgeArtifactMediaType('Dockerfile'), 'application/octet-stream'); }); + +test('linked-worktree lanes nest beneath their checkout key and conflict only with it', () => { + const instanceId = 'a'.repeat(64); + const lane = workspaceIsolationKey('repo', undefined, 'task-a'); + const sibling = workspaceIsolationKey('repo', undefined, 'task-b'); + const nested = workspaceIsolationKey('repo', instanceId, 'task-a'); + assert.notEqual(lane, workspaceIsolationKey('repo')); + assert.notEqual(lane, workspaceIsolationKey('repo\0task-a')); + assert.notEqual(nested, lane); + assert.equal(workspaceIsolationParent(lane), 'repo'); + assert.equal(workspaceIsolationParent(nested), workspaceIsolationKey('repo', instanceId)); + assert.equal(workspaceIsolationParent('repo'), undefined); + assert.equal(workspaceIsolationParent(workspaceIsolationKey('repo', instanceId)), undefined); + assert.equal(workspaceIsolationKeysConflict(lane, 'repo'), true); + assert.equal(workspaceIsolationKeysConflict('repo', lane), true); + assert.equal(workspaceIsolationKeysConflict(lane, lane), true); + assert.equal(workspaceIsolationKeysConflict(lane, sibling), false); + assert.equal(workspaceIsolationKeysConflict(nested, 'repo'), false); + assert.equal(workspaceIsolationKeysConflict(nested, workspaceIsolationKey('repo', instanceId)), true); +}); + +test('workspace requests accept only a single safe worktree name', () => { + const base = { protocolVersion: 1, operation: 'read_file', workspaceId: 'repo', path: 'README.md' }; + for (const worktree of ['task-a', 'fix_16464', 'v2.0']) { + assert.equal(isWorkspaceToolRequest({ ...base, worktree }), true, worktree); + } + for (const worktree of ['', '.hidden', '..', 'a/b', 'a\\b', 'task.lock', 'x'.repeat(129), 7, null]) { + assert.equal(isWorkspaceToolRequest({ ...base, worktree }), false, String(worktree)); + } + const programmatic = (workspace_worktree: string) => ({ + headers: {}, + body: { + language: 'bash', + version: '5.2.0', + session_id: 'session', + files: [{ name: 'main.sh', content: 'true' }], + workspace_worktree, + }, + }); + assert.equal(isBridgeWorkspaceProgrammaticRequest(programmatic('task-a')), true); + assert.equal(isBridgeWorkspaceProgrammaticRequest(programmatic('../x')), false); +}); + +test('workspace capabilities advertise linked-worktree scopes exactly', () => { + const capabilities = (workspaceScopes: unknown) => ({ + statefulWorkspace: false, + sandboxProfile: 'anthropic-srt', + runtimes: [], + workspaceTools: { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: 'repo', workspaceScopes }], + }, + }); + assert.equal(isValidBridgeWorkerCapabilities(capabilities(['git_linked_worktree'])), true); + assert.equal(isValidBridgeWorkerCapabilities(capabilities(['git_worktree'])), false); + assert.equal(isValidBridgeWorkerCapabilities(capabilities([])), false); + assert.equal(isValidBridgeWorkerCapabilities(capabilities('git_linked_worktree')), false); +}); diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 7657fc9c..b23ad327 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -259,14 +259,51 @@ export function bridgeArtifactMediaType(name: string): string { export type BridgeProtocolVersion = typeof BRIDGE_PROTOCOL_VERSION; -/** Collision-free identity shared by scheduling and worker quarantine state. */ +/** One path segment naming a linked worktree at `/.worktrees/`. */ +export const BRIDGE_LINKED_WORKTREE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +export function isValidLinkedWorktreeName(value: unknown): value is string { + return ( + typeof value === 'string' && + BRIDGE_LINKED_WORKTREE_NAME_PATTERN.test(value) && + !value.endsWith('.lock') + ); +} + +const LINKED_WORKTREE_KEY_PREFIX = '\0linked-worktree\0'; + +/** Collision-free identity shared by scheduling and worker quarantine state. + * A linked worktree lane nests beneath the key of the checkout that owns it. */ export function workspaceIsolationKey( workspaceId: string, instanceId?: string, + worktree?: string, ): string { - return instanceId === undefined + const base = instanceId === undefined ? workspaceId : `\0git-worktree\0${workspaceId}\0${instanceId}`; + return worktree === undefined + ? base + : `${LINKED_WORKTREE_KEY_PREFIX}${base}\0${worktree}`; +} + +/** The checkout key a linked-worktree lane nests beneath, or undefined for a root key. + * Scheduling treats a lane and its parent as conflicting; sibling lanes do not. */ +export function workspaceIsolationParent(key: string): string | undefined { + if (!key.startsWith(LINKED_WORKTREE_KEY_PREFIX)) return undefined; + const separator = key.lastIndexOf('\0'); + return separator <= LINKED_WORKTREE_KEY_PREFIX.length + ? undefined + : key.slice(LINKED_WORKTREE_KEY_PREFIX.length, separator); +} + +/** Two isolation keys may not execute concurrently when either nests the other. */ +export function workspaceIsolationKeysConflict(left: string, right: string): boolean { + return ( + left === right || + workspaceIsolationParent(left) === right || + workspaceIsolationParent(right) === left + ); } export type BridgeWorkspaceToolOperation = @@ -292,6 +329,9 @@ export interface BridgeWorkspaceDescriptor { operations?: BridgeWorkspaceToolOperation[]; /** Worker-owned isolation schemes available beneath this selected root. */ workspaceInstances?: ['git_worktree']; + /** Scheduling scopes available beneath this root. `git_linked_worktree` gives each + * verified `.worktrees/` linked worktree its own lane. */ + workspaceScopes?: ['git_linked_worktree']; environment?: { fingerprint: string; repo?: string; @@ -321,6 +361,8 @@ export interface WorkspaceReadFileRequest { operation: 'read_file'; workspaceId: string; workspaceInstanceId?: string; + /** Linked worktree lane at `.worktrees/`; paths and cwd are relative to it. */ + worktree?: string; path: string; startLine?: number; maxLines?: number; @@ -364,6 +406,8 @@ export interface WorkspaceSearchTextRequest { operation: 'search_text'; workspaceId: string; workspaceInstanceId?: string; + /** Linked worktree lane at `.worktrees/`; paths and cwd are relative to it. */ + worktree?: string; query: string; path?: string; maxResults?: number; @@ -389,6 +433,8 @@ export interface WorkspaceListFilesRequest { operation: 'list_files'; workspaceId: string; workspaceInstanceId?: string; + /** Linked worktree lane at `.worktrees/`; paths and cwd are relative to it. */ + worktree?: string; path?: string; maxResults?: number; /** Continue strictly after this canonical path from a previous page. */ @@ -410,6 +456,8 @@ export interface WorkspaceWriteFileRequest { operation: 'write_file'; workspaceId: string; workspaceInstanceId?: string; + /** Linked worktree lane at `.worktrees/`; paths and cwd are relative to it. */ + worktree?: string; path: string; content: string; /** False requires an atomic create and refuses to replace an existing file. */ @@ -430,6 +478,8 @@ interface WorkspaceEditFileRequestBase { operation: 'edit_file'; workspaceId: string; workspaceInstanceId?: string; + /** Linked worktree lane at `.worktrees/`; paths and cwd are relative to it. */ + worktree?: string; path: string; /** Refuses the mutation unless current file bytes match this preview revision. */ expectedBaseSha256?: string; @@ -475,6 +525,8 @@ interface WorkspacePreviewEditRequestBase { operation: 'preview_edit'; workspaceId: string; workspaceInstanceId?: string; + /** Linked worktree lane at `.worktrees/`; paths and cwd are relative to it. */ + worktree?: string; path: string; } @@ -513,6 +565,8 @@ export interface WorkspaceExecuteCommandRequest { operation: 'execute_command'; workspaceId: string; workspaceInstanceId?: string; + /** Linked worktree lane at `.worktrees/`; paths and cwd are relative to it. */ + worktree?: string; /** Shell source evaluated only inside the selected sandbox runtime. */ command: string; /** Portable path relative to the workspace root; defaults to '.'. */ @@ -558,6 +612,7 @@ const WORKSPACE_READ_REQUEST_KEYS = new Set([ 'operation', 'workspaceId', 'workspaceInstanceId', + 'worktree', 'path', 'startLine', 'maxLines', @@ -567,6 +622,7 @@ const WORKSPACE_SEARCH_REQUEST_KEYS = new Set([ 'operation', 'workspaceId', 'workspaceInstanceId', + 'worktree', 'query', 'path', 'maxResults', @@ -576,6 +632,7 @@ const WORKSPACE_LIST_REQUEST_KEYS = new Set([ 'operation', 'workspaceId', 'workspaceInstanceId', + 'worktree', 'path', 'maxResults', 'afterPath', @@ -585,6 +642,7 @@ const WORKSPACE_WRITE_REQUEST_KEYS = new Set([ 'operation', 'workspaceId', 'workspaceInstanceId', + 'worktree', 'path', 'content', 'overwrite', @@ -594,6 +652,7 @@ const WORKSPACE_EDIT_REQUEST_KEYS = new Set([ 'operation', 'workspaceId', 'workspaceInstanceId', + 'worktree', 'path', 'oldText', 'newText', @@ -605,6 +664,7 @@ const WORKSPACE_PREVIEW_EDIT_REQUEST_KEYS = new Set([ 'operation', 'workspaceId', 'workspaceInstanceId', + 'worktree', 'path', 'oldText', 'newText', @@ -617,6 +677,7 @@ const WORKSPACE_COMMAND_REQUEST_KEYS = new Set([ 'operation', 'workspaceId', 'workspaceInstanceId', + 'worktree', 'command', 'cwd', 'timeoutMs', @@ -730,6 +791,8 @@ export interface BridgeWorkerRegistrationResponse { supportedWorkspaceProgrammaticLanguages?: WorkspaceProgrammaticLanguage[]; /** Workspace isolation schemes this Code API understands and can route. */ supportedWorkspaceInstanceTypes?: ['git_worktree']; + /** Scheduling scopes this Code API can admit as independent lanes. */ + supportedWorkspaceScopes?: ['git_linked_worktree']; } /** Administrator-visible liveness for a configured worker. Credentials, @@ -803,6 +866,8 @@ export interface BridgeWorkspaceProgrammaticBody { language: 'bash'; version: string; workspace_instance_id?: string; + /** Linked worktree lane at `.worktrees/` beneath the selected checkout. */ + workspace_worktree?: string; /** Stable identity shared by every replay iteration of one execution. */ execution_id?: string; /** Declared replay tools; zero allows the worker to skip the probe pass. */ @@ -970,6 +1035,8 @@ export function isBridgeWorkspaceProgrammaticRequest( (body.workspace_instance_id !== undefined && (typeof body.workspace_instance_id !== 'string' || !/^[a-f0-9]{64}$/.test(body.workspace_instance_id))) || + (body.workspace_worktree !== undefined && + !isValidLinkedWorktreeName(body.workspace_worktree)) || (body.execution_id !== undefined && (typeof body.execution_id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(body.execution_id))) || @@ -1222,7 +1289,8 @@ export function isWorkspaceToolRequest( !isValidBridgeWorkerId(request.workspaceId) || (request.workspaceInstanceId !== undefined && (typeof request.workspaceInstanceId !== 'string' || - !/^[a-f0-9]{64}$/.test(request.workspaceInstanceId))) + !/^[a-f0-9]{64}$/.test(request.workspaceInstanceId))) || + (request.worktree !== undefined && !isValidLinkedWorktreeName(request.worktree)) ) { return false; } @@ -1676,6 +1744,7 @@ export function isValidBridgeWorkspaceToolCapabilities( key !== 'name' && key !== 'operations' && key !== 'workspaceInstances' && + key !== 'workspaceScopes' && key !== 'instructions' && key !== 'environment', ) || @@ -1686,6 +1755,10 @@ export function isValidBridgeWorkspaceToolCapabilities( (!Array.isArray(descriptor.workspaceInstances) || descriptor.workspaceInstances.length !== 1 || descriptor.workspaceInstances[0] !== 'git_worktree')) || + (descriptor.workspaceScopes !== undefined && + (!Array.isArray(descriptor.workspaceScopes) || + descriptor.workspaceScopes.length !== 1 || + descriptor.workspaceScopes[0] !== 'git_linked_worktree')) || (descriptor.instructions !== undefined && (!Array.isArray(descriptor.instructions) || descriptor.instructions.length > 1 || !descriptor.instructions.every(isRepositoryInstructionDescriptor))) || (descriptor.environment !== undefined && !isValidCodeEnvironmentDescriptor(descriptor.environment)) || diff --git a/packages/code/src/worker-slots.test.ts b/packages/code/src/worker-slots.test.ts index fda6c659..f720af89 100644 --- a/packages/code/src/worker-slots.test.ts +++ b/packages/code/src/worker-slots.test.ts @@ -377,3 +377,71 @@ test('programmatic work on an independent workspace bypasses another root cleanu 'previous', ); }); + +test('sibling linked-worktree lanes run past each other, but a lane and its checkout wait for one another', async () => { + const worker = new BridgeWorker({ + codeApiUrl: 'http://localhost:1', + token: 'fixture', + workerId: 'worker', + sandboxEndpoint: 'http://localhost:2', + capabilities: { + statefulWorkspace: false, + sandboxProfile: 'fixture', + runtimes: [], + }, + }); + const internals = worker as unknown as { + activeWorkspaceAssignments: Map }>; + executeOwned: (assignment: BridgeAssignment) => Promise; + }; + const executed: string[] = []; + internals.executeOwned = async (assignment) => { + executed.push(assignment.assignmentId); + }; + const assignment = (assignmentId: string, worktree?: string) => + ({ + assignmentId, + executionKind: 'workspace_tool', + remainingMs: 1_000, + request: { + protocolVersion: 1, + workspaceId: 'repo', + operation: 'read_file', + path: 'README.md', + ...(worktree ? { worktree } : {}), + }, + }) as BridgeAssignment; + let releaseLane!: () => void; + internals.activeWorkspaceAssignments.set(workspaceIsolationKey('repo', undefined, 'task-a'), { + id: 'lane-a', + done: new Promise((resolve) => { + releaseLane = resolve; + }), + }); + + await worker.executeAndSettle(assignment('lane-b', 'task-b')); + assert.deepEqual(executed, ['lane-b']); + + const checkout = worker.executeAndSettle(assignment('checkout')); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.deepEqual(executed, ['lane-b']); + internals.activeWorkspaceAssignments.delete(workspaceIsolationKey('repo', undefined, 'task-a')); + releaseLane(); + await checkout; + assert.deepEqual(executed, ['lane-b', 'checkout']); + + let releaseCheckout!: () => void; + internals.activeWorkspaceAssignments.set(workspaceIsolationKey('repo'), { + id: 'root', + done: new Promise((resolve) => { + releaseCheckout = resolve; + }), + }); + const lane = worker.executeAndSettle(assignment('lane-c', 'task-c')); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.deepEqual(executed, ['lane-b', 'checkout']); + internals.activeWorkspaceAssignments.delete(workspaceIsolationKey('repo')); + releaseCheckout(); + await lane; + assert.deepEqual(executed, ['lane-b', 'checkout', 'lane-c']); +}); diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 2a5896fd..01c15d85 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -8,6 +8,8 @@ import { isBridgeWorkspaceProgrammaticRequest, isWorkspaceToolResult, workspaceIsolationKey, + workspaceIsolationKeysConflict, + workspaceIsolationParent, } from './protocol.js'; import { EndpointRuntimeSupervisor } from './runtime.js'; import { signBridgeRequest } from './identity.js'; @@ -62,6 +64,13 @@ export interface BridgeWorkerOptions { ) => | WorkspaceMutationQuarantine | Promise; + /** Resolve a durable guard for a linked-worktree lane beneath a registered root. */ + linkedWorktreeQuarantineResolver?: ( + workspaceId: string, + worktree: string, + ) => + | WorkspaceMutationQuarantine + | Promise; leaseWaitMs?: number; leaseTransportGraceMs?: number; registrationTransportTimeoutMs?: number; @@ -223,7 +232,13 @@ function workspaceCapabilitiesMatch( (instanceType, instanceIndex) => instanceType === executor.workspaces[index]?.workspaceInstances?.[instanceIndex], - ) ?? executor.workspaces[index]?.workspaceInstances == null), + ) ?? executor.workspaces[index]?.workspaceInstances == null) && + workspace.workspaceScopes?.length === + executor.workspaces[index]?.workspaceScopes?.length && + (workspace.workspaceScopes?.every( + (scope, scopeIndex) => + scope === executor.workspaces[index]?.workspaceScopes?.[scopeIndex], + ) ?? executor.workspaces[index]?.workspaceScopes == null), ) ); } @@ -239,7 +254,9 @@ function registrationCompatibleCapabilities( ) && workspaceTools.workspaces.every( (workspace) => - workspace.operations == null && workspace.workspaceInstances == null, + workspace.operations == null && + workspace.workspaceInstances == null && + workspace.workspaceScopes == null, )) ) { return capabilities; @@ -263,6 +280,7 @@ function registrationCompatibleCapabilities( const { operations: _operations, workspaceInstances: _workspaceInstances, + workspaceScopes: _workspaceScopes, ...compatibleWorkspace } = workspace; return [{ ...compatibleWorkspace, ...(workspace.environment ? { @@ -355,6 +373,10 @@ function supportedWorkspaceCapabilities( ) ? { workspaceInstances: workspace.workspaceInstances } : { workspaceInstances: undefined }), + ...(workspace.workspaceScopes != null && + registration.supportedWorkspaceScopes?.includes('git_linked_worktree') + ? { workspaceScopes: workspace.workspaceScopes } + : { workspaceScopes: undefined }), ...(workspace.operations ? { operations: workspaceOperations } : {}), ...(workspace.environment && !workspaceOperations.includes('execute_command') ? { environment: { ...workspace.environment, actions: [] }, @@ -506,7 +528,8 @@ export class BridgeWorker { ) === true && options.workspaceMutationQuarantine == null && options.workspaceQuarantines == null && - options.workspaceQuarantineResolver == null + options.workspaceQuarantineResolver == null && + options.linkedWorktreeQuarantineResolver == null ) { throw new BridgeProtocolError( 'Workspace mutation capabilities require durable quarantine storage', @@ -522,6 +545,17 @@ export class BridgeWorker { 'Workspace instance capabilities require a durable quarantine resolver', ); } + if ( + options.capabilities.workspaceTools?.workspaces.some( + (root) => (root.workspaceScopes?.length ?? 0) > 0, + ) && + (options.linkedWorktreeQuarantineResolver == null || + (options.capabilities.workspaceLeaseSlots ?? 1) < 2) + ) { + throw new BridgeProtocolError( + 'Linked worktree lanes require a durable quarantine resolver and at least two lease slots', + ); + } if ((options.capabilities.workspaceLeaseSlots ?? 1) > 1) { if ( options.capabilities.requiresReadyConfirmation !== true || @@ -1313,8 +1347,11 @@ export class BridgeWorker { ): Promise { const root = this.assignmentWorkspaceId(assignment); const waitingAt = Date.now(); - while (root != null && this.activeWorkspaceAssignments.has(root)) { - const active = this.activeWorkspaceAssignments.get(root)!; + for ( + let active = root == null ? undefined : this.conflictingActiveAssignment(root); + root != null && active != null; + active = this.conflictingActiveAssignment(root) + ) { if (active.id === assignment.assignmentId) throw new BridgeProtocolError( 'Code API replayed an active workspace assignment', @@ -1386,6 +1423,32 @@ export class BridgeWorker { } } + /** An active assignment on the same key, the key's parent checkout, or a lane beneath it. */ + private conflictingActiveAssignment( + key: string, + ): { id: string; done: Promise } | undefined { + for (const [activeKey, active] of this.activeWorkspaceAssignments) { + if (workspaceIsolationKeysConflict(activeKey, key)) return active; + } + return undefined; + } + + /** A lane may not run while the checkout it belongs to is quarantined. */ + private async assertLaneParentAvailable( + workspaceKey: string, + assignment: BridgeAssignment, + ): Promise { + const parent = workspaceIsolationParent(workspaceKey); + if (parent == null) return; + if (this.quarantinedWorkspaces.has(parent)) { + throw new Error('Parent workspace requires an explicit quarantine reset'); + } + const workspaceId = this.assignmentBaseWorkspaceId(assignment); + if (workspaceId != null && parent === workspaceId) { + await this.options.workspaceQuarantines?.get(workspaceId)?.assertAvailable(); + } + } + private workspaceGuard( assignment: BridgeAssignment, ): @@ -1394,6 +1457,10 @@ export class BridgeWorker { | undefined { const workspaceId = this.assignmentBaseWorkspaceId(assignment); const instanceId = this.assignmentWorkspaceInstanceId(assignment); + const worktree = this.assignmentWorktree(assignment); + if (workspaceId != null && worktree != null) { + return this.options.linkedWorktreeQuarantineResolver?.(workspaceId, worktree); + } if (workspaceId != null && instanceId != null) { return this.options.workspaceQuarantineResolver?.( workspaceId, @@ -1412,7 +1479,27 @@ export class BridgeWorker { const workspaceId = this.assignmentBaseWorkspaceId(assignment); if (workspaceId == null) return undefined; const instanceId = this.assignmentWorkspaceInstanceId(assignment); - return workspaceIsolationKey(workspaceId, instanceId); + return workspaceIsolationKey( + workspaceId, + instanceId, + this.assignmentWorktree(assignment), + ); + } + + private assignmentWorktree(assignment: BridgeAssignment): string | undefined { + if ( + assignment.executionKind === 'workspace_tool' && + isWorkspaceToolRequest(assignment.request) + ) { + return assignment.request.worktree; + } + if ( + assignment.executionKind === 'workspace_programmatic' && + isBridgeWorkspaceProgrammaticRequest(assignment.request) + ) { + return assignment.request.body.workspace_worktree; + } + return undefined; } private assignmentBaseWorkspaceId( @@ -1580,9 +1667,11 @@ export class BridgeWorker { } if ( this.options.workspaceQuarantines != null || - this.options.workspaceQuarantineResolver != null + this.options.workspaceQuarantineResolver != null || + this.options.linkedWorktreeQuarantineResolver != null ) { await guard?.assertAvailable(); + await this.assertLaneParentAvailable(workspaceKey, assignment); } } catch (error) { throw new BridgeWorkspaceQuarantinedError( @@ -1615,6 +1704,14 @@ export class BridgeWorker { 'Workspace instance type is not advertised', ); } + if ( + workspaceRequest.worktree != null && + workspace.workspaceScopes?.includes('git_linked_worktree') !== true + ) { + throw new BridgeProtocolError( + 'Linked worktree lanes are not advertised for workspace', + ); + } if ( workspace.operations != null && !workspace.operations.includes(workspaceRequest.operation) @@ -1739,9 +1836,11 @@ export class BridgeWorker { } if ( this.options.workspaceQuarantines != null || - this.options.workspaceQuarantineResolver != null + this.options.workspaceQuarantineResolver != null || + this.options.linkedWorktreeQuarantineResolver != null ) { await guard?.assertAvailable(); + await this.assertLaneParentAvailable(workspaceKey, assignment); } } catch (error) { throw new BridgeWorkspaceQuarantinedError( @@ -1772,6 +1871,14 @@ export class BridgeWorker { 'Workspace instance type is not advertised', ); } + if ( + assignment.request.body.workspace_worktree != null && + workspace.workspaceScopes?.includes('git_linked_worktree') !== true + ) { + throw new BridgeProtocolError( + 'Linked worktree lanes are not advertised for workspace', + ); + } this.mutationGuardArmed = true; try { this.armedWorkspaces.add(workspaceKey); diff --git a/packages/code/src/workspace-cli.test.ts b/packages/code/src/workspace-cli.test.ts index 1de68818..52743bae 100644 --- a/packages/code/src/workspace-cli.test.ts +++ b/packages/code/src/workspace-cli.test.ts @@ -147,6 +147,47 @@ test('CLI requires concurrent native slots for conversation worktrees', async (t assert.match(result.stderr, /at least two workspace lease slots/i); }); +test('CLI requires concurrent native slots and no conversation worktrees for linked worktree lanes', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-lanes-')); + const workspaceRoot = join(root, 'workspace'); + await mkdir(workspaceRoot); + t.after(() => rm(root, { recursive: true, force: true })); + const run = (...extra: string[]) => + spawnSync( + process.execPath, + [ + fileURLToPath(new URL('./cli.js', import.meta.url)), + 'run', + '--worker-dir', + workspaceRoot, + '--allow-workspace-writes', + '--allow-workspace-commands', + '--linked-worktree-lanes', + ...extra, + ], + { + encoding: 'utf8', + env: { + ...process.env, + LIBRECHAT_CODE_URL: 'http://127.0.0.1:1/v1', + LIBRECHAT_CODE_WORKER_TOKEN: 'worker-secret', + LIBRECHAT_CODE_WORKER_ID: 'engineering-vm', + }, + }, + ); + const serial = run(); + assert.notEqual(serial.status, 0); + assert.match(serial.stderr, /Linked worktree lanes require .*at least two workspace lease slots/i); + const combined = run( + '--workspace-lease-slots', + '2', + '--conversation-worktree-root', + join(root, 'conversations'), + ); + assert.notEqual(combined.status, 0); + assert.match(combined.stderr, /cannot be combined with conversation worktrees/i); +}); + test('CLI advertises explicitly enabled writes without exposing the workspace root', async (t) => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-cli-')); const workspaceRoot = join(root, ' '); diff --git a/service/src/bridge/linked-worktree.test.ts b/service/src/bridge/linked-worktree.test.ts new file mode 100644 index 00000000..bf6ac0e0 --- /dev/null +++ b/service/src/bridge/linked-worktree.test.ts @@ -0,0 +1,174 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { expect, test } from 'bun:test'; +import Redis from 'ioredis'; +import { RedisBridgeStore } from './store'; +import type { CodeBridgeAssignment } from './store'; +import type { WorkspaceToolRequest } from '../../../packages/code/src/protocol'; + +const redisUrl = process.env.BRIDGE_TEST_REDIS_URL; +const incarnationId = 'lane-incarnation'; + +function fenceKey(workerId: string, fence: string): string { + const hash = createHash('sha256').update(fence).digest('hex'); + return `codeapi:bridge:v1:worker:${encodeURIComponent(workerId)}:workspace:${hash}:quarantined`; +} + +async function withLaneWorker( + run: (context: { + store: RedisBridgeStore; + redis: Redis; + workerId: string; + dispatch: ( + request: Partial, + budgetMs?: number, + ) => Promise; + lease: (slot: number) => Promise; + settle: (assignment: CodeBridgeAssignment) => Promise; + }) => Promise, + scopes = true, +): Promise { + const redis = new Redis(redisUrl!); + const store = new RedisBridgeStore(redis, 60, 1000, 3); + const workerId = `lanes-${randomUUID()}`; + const controller = new AbortController(); + const pending: Promise[] = []; + try { + const generation = await store.register({ + protocolVersion: 1, + workerId, + incarnationId, + capabilities: { + statefulWorkspace: false, + sandboxProfile: 'native-srt', + runtimes: [], + workspaceLeaseSlots: 3, + requiresReadyConfirmation: true, + workspaceTools: { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [ + { + id: 'repo', + ...(scopes ? { workspaceScopes: ['git_linked_worktree' as const] } : {}), + }, + ], + }, + }, + }); + await store.confirmReady(workerId, incarnationId, generation); + await run({ + store, + redis, + workerId, + dispatch(request, budgetMs = 3000) { + const result = store.dispatchWorkspaceTool({ + workerId, + signal: controller.signal, + deadlineAtMs: Date.now() + budgetMs, + executionTimeoutMs: 5000, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'repo', + path: 'probe', + ...request, + } as WorkspaceToolRequest, + }); + void result.catch(() => undefined); + pending.push(result); + return result; + }, + lease: (slot) => store.lease(workerId, incarnationId, 1000, undefined, undefined, slot), + async settle(assignment) { + await store.acknowledgeLease( + workerId, + incarnationId, + assignment.assignmentId, + assignment.generation, + assignment.leaseToken, + ); + await store.settle(workerId, assignment.assignmentId, { + protocolVersion: 1, + incarnationId, + generation: assignment.generation, + leaseToken: assignment.leaseToken, + status: 'rejected', + error: 'probe complete', + }); + await store.confirmWorkspaceCleanup(workerId, assignment.assignmentId, { + protocolVersion: 1, + incarnationId, + generation: assignment.generation, + leaseToken: assignment.leaseToken, + status: 'rejected', + error: 'local cleanup confirmed', + }); + }, + }); + } finally { + controller.abort(); + await Promise.allSettled(pending); + await redis.quit(); + } +} + +test.skipIf(!redisUrl)( + 'sibling linked-worktree lanes run together while their checkout waits for both', + async () => { + await withLaneWorker(async ({ dispatch, lease, settle }) => { + const laneA = dispatch({ worktree: 'task-a' }); + const laneB = dispatch({ worktree: 'task-b' }); + const first = await lease(0); + const second = await lease(1); + expect( + [first?.request, second?.request].map( + (request) => (request as WorkspaceToolRequest).worktree, + ).sort(), + ).toEqual(['task-a', 'task-b']); + + const checkout = dispatch({}, 400); + expect(await lease(2)).toBeUndefined(); + await expect(checkout).rejects.toMatchObject({ code: 'WORKSPACE_QUEUE_TIMEOUT' }); + + await settle(first!); + await settle(second!); + await Promise.allSettled([laneA, laneB]); + }); + }, +); + +test.skipIf(!redisUrl)('a busy checkout holds every lane beneath it', async () => { + await withLaneWorker(async ({ dispatch, lease, settle }) => { + const checkout = dispatch({}); + const held = await lease(0); + expect((held?.request as WorkspaceToolRequest).worktree).toBeUndefined(); + + const lane = dispatch({ worktree: 'task-a' }); + expect(await lease(1)).toBeUndefined(); + + await settle(held!); + await Promise.allSettled([checkout]); + const admitted = await lease(0); + expect((admitted?.request as WorkspaceToolRequest).worktree).toBe('task-a'); + await settle(admitted!); + await Promise.allSettled([lane]); + }); +}); + +test.skipIf(!redisUrl)('a lane may not start while its checkout is quarantined', async () => { + await withLaneWorker(async ({ redis, workerId, dispatch }) => { + await redis.set(fenceKey(workerId, 'native-workspace:repo'), 'quarantined:earlier'); + await expect(dispatch({ worktree: 'task-a' })).rejects.toMatchObject({ + code: 'WORKSPACE_QUARANTINED', + }); + expect(await redis.exists(fenceKey(workerId, 'native-workspace:\0linked-worktree\0repo\0task-a'))).toBe(0); + }); +}); + +test.skipIf(!redisUrl)('a lane is refused unless the worker advertises linked-worktree scopes', async () => { + await withLaneWorker(async ({ dispatch }) => { + await expect(dispatch({ worktree: 'task-a' })).rejects.toMatchObject({ + code: 'WORKER_MISMATCH', + }); + }, false); +}); diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index b3d549b1..962c6242 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -539,6 +539,7 @@ router.post( supportedWorkspaceListFileFeatures: ['after_path'], supportedWorkspaceProgrammaticLanguages: ['bash'], supportedWorkspaceInstanceTypes: ['git_worktree'], + supportedWorkspaceScopes: ['git_linked_worktree'], }); } catch (error) { if (error instanceof BridgeStoreError) { diff --git a/service/src/bridge/slots.test.ts b/service/src/bridge/slots.test.ts index dfe89495..1efe3b97 100644 --- a/service/src/bridge/slots.test.ts +++ b/service/src/bridge/slots.test.ts @@ -3,6 +3,7 @@ import RedisMock from 'ioredis-mock'; import type Redis from 'ioredis'; import { BridgeAdmissionQueue } from './admission'; import { BridgeWorkspaceSlots } from './slots'; +import { workspaceIsolationKey } from '../../../packages/code/src/protocol'; const redis = new RedisMock() as unknown as Redis; const admission = new BridgeAdmissionQueue(redis); @@ -13,20 +14,27 @@ const prefix = `codeapi:bridge:v1:worker:${workerId}`; afterEach(async () => { await redis.flushall(); }); -async function enqueue(assignmentId: string, workspaceId: string) { +async function enqueue(assignmentId: string, workspaceId: string, capacity = 2) { await redis.set(`${prefix}:incarnation`, incarnationId); - await redis.set(`${prefix}:workspace-slot-capacity`, '2'); + await redis.set(`${prefix}:workspace-slot-capacity`, String(capacity)); await admission.enter(workerId, assignmentId, Date.now() + 5000, workspaceId); return { workerId, incarnationId, assignmentId, workspaceId, - capacity: 2, + capacity, expiresAtMs: Date.now() + 10000, }; } +async function finish(assignmentId: string) { + await slots.release(workerId, incarnationId, assignmentId); + await admission.leave(workerId, assignmentId); +} + +const lane = (name: string, parent = 'repo') => workspaceIsolationKey(parent, undefined, name); + test('slots admit independent workspaces, skip a busy root, and bound capacity', async () => { const a = await enqueue('a', 'root-a'); const a2 = await enqueue('a2', 'root-a'); @@ -91,3 +99,51 @@ test('releasing a long slot shortens the aggregate expiry to remaining work', as await slots.release(workerId, incarnationId, 'a'); expect(await redis.pttl(`${prefix}:lock`)).toBeLessThanOrEqual(3000); }); + +test('sibling linked-worktree lanes share their checkout, which waits for both', async () => { + const a = await enqueue('a', lane('task-a'), 3); + const b = await enqueue('b', lane('task-b'), 3); + const root = await enqueue('root', 'repo', 3); + expect(await slots.reserve(a)).toBe(0); + expect(await slots.reserve(b)).toBe(1); + expect(await slots.reserve(root)).toBeUndefined(); + await finish('a'); + expect(await slots.reserve(root)).toBeUndefined(); + await finish('b'); + expect(await slots.reserve(root)).toBe(0); +}); + +test('a busy checkout holds every lane beneath it but not other checkouts', async () => { + const root = await enqueue('root', 'repo', 3); + const a = await enqueue('a', lane('task-a'), 3); + const other = await enqueue('other', lane('task-a', 'other-repo'), 3); + expect(await slots.reserve(root)).toBe(0); + expect(await slots.reserve(a)).toBeUndefined(); + expect(await slots.reserve(other)).toBe(1); + await finish('root'); + expect(await slots.reserve(a)).toBe(0); +}); + +test('a waiting checkout holds back newer lanes so root work cannot starve', async () => { + const a = await enqueue('a', lane('task-a'), 3); + expect(await slots.reserve(a)).toBe(0); + const root = await enqueue('root', 'repo', 3); + const b = await enqueue('b', lane('task-b'), 3); + const other = await enqueue('other', 'other-repo', 3); + expect(await slots.reserve(root)).toBeUndefined(); + expect(await slots.reserve(b)).toBeUndefined(); + expect(await slots.reserve(other)).toBe(1); + await finish('a'); + expect(await slots.reserve(b)).toBeUndefined(); + expect(await slots.reserve(root)).toBe(0); +}); + +test('lanes nest beneath a conversation checkout, not the source root', async () => { + const instance = workspaceIsolationKey('repo', 'c'.repeat(64)); + const conversation = await enqueue('conversation', instance, 3); + const nested = await enqueue('nested', workspaceIsolationKey('repo', 'c'.repeat(64), 'task-a'), 3); + const source = await enqueue('source', lane('task-a'), 3); + expect(await slots.reserve(conversation)).toBe(0); + expect(await slots.reserve(nested)).toBeUndefined(); + expect(await slots.reserve(source)).toBe(1); +}); diff --git a/service/src/bridge/slots.ts b/service/src/bridge/slots.ts index 9c9e7d3b..81f60ebe 100644 --- a/service/src/bridge/slots.ts +++ b/service/src/bridge/slots.ts @@ -6,6 +6,12 @@ export const MAX_WORKSPACE_LEASE_SLOTS = 8; /** Reservations share the legacy admission queue and aggregate worker lock. * Thus a serial dispatcher or replacement incarnation cannot race active slots. * Workspace mutation uncertainty is fenced separately by the assignment store. + * + * Keys are hierarchical: a linked-worktree lane (`\0linked-worktree\0\0`) + * conflicts with itself and with its parent checkout, and a checkout conflicts + * with every lane beneath it. Sibling lanes run concurrently. A waiting checkout + * request also holds back newer lanes beneath it, so root operations such as + * `git worktree add` cannot be starved by a steady stream of lane work. */ export class BridgeWorkspaceSlots { constructor(private readonly redis: Redis) {} @@ -47,10 +53,26 @@ export class BridgeWorkspaceSlots { [ "if redis.call('GET', KEYS[4]) ~= ARGV[1] then return -2 end", "if (redis.call('GET', KEYS[8]) or '1') ~= ARGV[4] then return -2 end", + // Parent of a linked-worktree lane key; nil for a checkout key. + 'local lanePrefix = "\\0linked-worktree\\0"', + 'local function parentOf(key)', + ' if string.sub(key, 1, #lanePrefix) ~= lanePrefix then return nil end', + ' local last = nil', + ' local cursor = #lanePrefix + 1', + ' while true do', + ' local found = string.find(key, "\\0", cursor, true)', + ' if not found then break end', + ' last = found', + ' cursor = found + 1', + ' end', + ' if last == nil or last <= #lanePrefix + 1 then return nil end', + ' return string.sub(key, #lanePrefix + 1, last - 1)', + 'end', "local lock = redis.call('GET', KEYS[2])", "local owner = 'workspace-slots:' .. ARGV[1]", 'if lock and lock ~= owner then return -1 end', 'local busy = {}', + 'local busyChildren = {}', 'local free = nil', 'local latest = tonumber(ARGV[5])', `for slot = 0, ${MAX_WORKSPACE_LEASE_SLOTS - 1} do`, @@ -63,12 +85,20 @@ export class BridgeWorkspaceSlots { ' else', ' if entry[1] == ARGV[2] then return slot end', ' busy[entry[3]] = true', + ' local busyParent = parentOf(entry[3])', + ' if busyParent then busyChildren[busyParent] = true end', ' latest = math.max(latest, tonumber(entry[4]))', ' end', ' end', ' if not occupied and free == nil and slot < tonumber(ARGV[4]) then free = slot end', 'end', - 'if free == nil or busy[ARGV[3]] then return -1 end', + 'local function conflicts(key)', + ' if busy[key] then return true end', + ' local parent = parentOf(key)', + ' if parent then return busy[parent] == true end', + ' return busyChildren[key] == true', + 'end', + 'if free == nil or conflicts(ARGV[3]) then return -1 end', // Expiry removes queue metadata, never a workspace uncertainty fence. "local expired = redis.call('ZRANGEBYSCORE', KEYS[6], '-inf', ARGV[6])", 'for _, id in ipairs(expired) do', @@ -78,11 +108,15 @@ export class BridgeWorkspaceSlots { 'end', "local pending = redis.call('ZRANGE', KEYS[5], 0, 31)", 'local selected = nil', + 'local waitingCheckouts = {}', 'for _, id in ipairs(pending) do', " local workspace = redis.call('HGET', KEYS[7], id)", // An older serial request remains a barrier until its dispatcher finishes. ' if not workspace then return -1 end', - ' if not busy[workspace] then selected = id; break end', + ' local parent = parentOf(workspace)', + ' local held = conflicts(workspace) or (parent ~= nil and waitingCheckouts[parent] == true)', + ' if not held then selected = id; break end', + ' if parent == nil then waitingCheckouts[workspace] = true end', 'end', 'if selected ~= ARGV[2] then return -1 end', "redis.call('HSET', KEYS[1], 'a:' .. free, ARGV[2], 'i:' .. free, ARGV[1], 'w:' .. free, ARGV[3], 'e:' .. free, ARGV[5])", diff --git a/service/src/bridge/store.ts b/service/src/bridge/store.ts index 0ca75566..315c201e 100644 --- a/service/src/bridge/store.ts +++ b/service/src/bridge/store.ts @@ -18,6 +18,7 @@ import { isWorkspaceToolRequest, isWorkspaceToolResult, workspaceIsolationKey, + workspaceIsolationParent, } from '../../../packages/code/src/protocol'; import type { BridgeWorkerBinding } from './pairing'; import { BridgeAdmissionQueue } from './admission'; @@ -105,6 +106,20 @@ type AssignmentOwnership = Pick< | 'runtimeSessionId' >; +const NATIVE_WORKSPACE_FENCE_PREFIX = 'native-workspace:'; + +/** The fence of the checkout a linked-worktree lane nests beneath. While that + * checkout is quarantined, its lanes may not start either. */ +function workspaceFenceParent(fence: string): string | undefined { + if (!fence.startsWith(NATIVE_WORKSPACE_FENCE_PREFIX)) return undefined; + const parent = workspaceIsolationParent( + fence.slice(NATIVE_WORKSPACE_FENCE_PREFIX.length), + ); + return parent === undefined + ? undefined + : `${NATIVE_WORKSPACE_FENCE_PREFIX}${parent}`; +} + function workspaceFenceReceiptKey(assignmentId: string): string { return `${assignmentKey(assignmentId)}:workspace-fence-owner`; } @@ -151,6 +166,12 @@ function supportsWorkspaceTool( ) { return false; } + if ( + request.worktree !== undefined && + workspace.workspaceScopes?.includes('git_linked_worktree') !== true + ) { + return false; + } if (request.operation === 'list_files' && request.afterPath !== undefined) { return capabilities?.listFileFeatures?.includes('after_path') === true; } @@ -184,6 +205,7 @@ function supportsWorkspaceProgrammatic( workspaceId: string, language: string, workspaceInstanceId?: string, + worktree?: string, ): boolean { const capabilities = registration.capabilities.workspaceTools; const workspace = capabilities?.workspaces.find( @@ -193,6 +215,8 @@ function supportsWorkspaceProgrammatic( workspace != null && (workspaceInstanceId === undefined || workspace.workspaceInstances?.includes('git_worktree') === true) && + (worktree === undefined || + workspace.workspaceScopes?.includes('git_linked_worktree') === true) && capabilities?.operations.includes('execute_command') === true && (workspace.operations == null || workspace.operations.includes('execute_command')) && @@ -214,11 +238,24 @@ function workspaceInstanceId(body: t.PayloadBody): string | undefined { return undefined; } +function workspaceWorktree(body: t.PayloadBody): string | undefined { + if ( + typeof body === 'object' && + body != null && + 'workspace_worktree' in body && + typeof body.workspace_worktree === 'string' + ) { + return body.workspace_worktree; + } + return undefined; +} + export function workspaceAdmissionId( workspaceId: string, instanceId?: string, + worktree?: string, ): string { - return workspaceIsolationKey(workspaceId, instanceId); + return workspaceIsolationKey(workspaceId, instanceId, worktree); } function workerKey(workerId: string): string { @@ -862,6 +899,7 @@ export class RedisBridgeStore { args.workspaceId, args.body.language, workspaceInstanceId(args.body), + workspaceWorktree(args.body), ) ) { throw new BridgeStoreError( @@ -899,12 +937,15 @@ export class RedisBridgeStore { args.workspaceRequest?.workspaceId ?? args.workspaceId; const selectedWorkspaceInstanceId = args.workspaceRequest?.workspaceInstanceId ?? workspaceInstanceId(args.body); + const selectedWorktree = + args.workspaceRequest?.worktree ?? workspaceWorktree(args.body); const selectedWorkspaceAdmissionId = selectedWorkspaceId == null ? undefined : workspaceAdmissionId( selectedWorkspaceId, selectedWorkspaceInstanceId, + selectedWorktree, ); const workspaceSlots = selectedWorkspaceId != null && @@ -1033,6 +1074,7 @@ export class RedisBridgeStore { args.workspaceId, args.body.language, workspaceInstanceId(args.body), + workspaceWorktree(args.body), )) ) { throw new BridgeStoreError( @@ -1059,13 +1101,13 @@ export class RedisBridgeStore { leaseToken, leaseTokenHash: tokenHash(leaseToken), ...(selectedWorkspaceAdmissionId == null ? {} : { - workspaceFence: `native-workspace:${selectedWorkspaceAdmissionId}`, + workspaceFence: `${NATIVE_WORKSPACE_FENCE_PREFIX}${selectedWorkspaceAdmissionId}`, }), ...(workspaceLeaseSlot === undefined ? {} : { workspaceLeaseSlot, - workspaceFence: `native-workspace:${selectedWorkspaceAdmissionId!}`, + workspaceFence: `${NATIVE_WORKSPACE_FENCE_PREFIX}${selectedWorkspaceAdmissionId!}`, }), ...(registration.identityId != null ? { workerIdentityId: registration.identityId } @@ -1155,6 +1197,7 @@ export class RedisBridgeStore { args.workspaceId, args.body.language, workspaceInstanceId(args.body), + workspaceWorktree(args.body), ) ) { throw new BridgeStoreError( @@ -2191,9 +2234,10 @@ export class RedisBridgeStore { ...(assignment.workspaceLeaseSlot === undefined ? ['redis.call(\'SET\', KEYS[4], ARGV[1], \"PX\", ARGV[5])'] : []), + "if #KEYS >= 10 and redis.call('EXISTS', KEYS[10]) == 1 then return -1 end", 'redis.call(\'SET\', KEYS[5], "1", \"PXAT\", ARGV[6])', "if #KEYS >= 7 then redis.call('SET', KEYS[7], ARGV[4]) end", - 'if #KEYS == 9 then', + 'if #KEYS >= 9 then', " local epoch = redis.call('GET', KEYS[9])", " if type(epoch) ~= 'string' then epoch = '0'; redis.call('SET', KEYS[9], epoch, 'EX', ARGV[3]) end", " if redis.call('PTTL', KEYS[9]) < tonumber(ARGV[3]) * 1000 then redis.call('EXPIRE', KEYS[9], ARGV[3]) end", @@ -2238,6 +2282,10 @@ export class RedisBridgeStore { workspaceFenceReceiptKey(assignment.assignmentId), `${workspaceQuarantineKey(assignment.workerId, assignment.workspaceFence!)}:epoch`, ); + const parent = workspaceFenceParent(assignment.workspaceFence!); + if (parent !== undefined) { + keys.push(workspaceQuarantineKey(assignment.workerId, parent)); + } } const result = await this.redis.eval( script, diff --git a/service/src/service/programmatic-router.ts b/service/src/service/programmatic-router.ts index a646b9ad..1e2a367c 100644 --- a/service/src/service/programmatic-router.ts +++ b/service/src/service/programmatic-router.ts @@ -92,7 +92,11 @@ import { CODEAPI_BRIDGE_WORKSPACE_HEADER, resolveBridgeWorkerSelection, } from '../bridge/selection'; -import { isValidBridgeWorkerId, BRIDGE_WORKSPACE_PROGRAMMATIC_MAX_INPUT_FILES } from '../../../packages/code/src/protocol'; +import { + isValidBridgeWorkerId, + isValidLinkedWorktreeName, + BRIDGE_WORKSPACE_PROGRAMMATIC_MAX_INPUT_FILES, +} from '../../../packages/code/src/protocol'; import logger from '../logger'; import { type ExecutionState, @@ -505,6 +509,7 @@ async function handleReplayInitial( bridgeWorkerId?: string; workspaceId?: string; workspaceInstanceId?: string; + workspaceWorktree?: string; }, cancellation: ReplayRequestCancellation, ): Promise { @@ -514,6 +519,7 @@ async function handleReplayInitial( bridgeWorkerId, workspaceId, workspaceInstanceId, + workspaceWorktree, } = params; const { code, tools, user_id, files } = req.body as t.ProgrammaticRequestBody; @@ -671,6 +677,7 @@ async function handleReplayInitial( bridgeWorkerId, workspaceId, workspaceInstanceId, + workspaceWorktree, executionProfile: env.EXECUTION_PROFILE, executionProfileSource: env.EXECUTION_PROFILE_SOURCE, sandboxBackend: resolveReplayStateSandboxBackend({ @@ -1291,6 +1298,7 @@ router.post( let bridgeWorkerId: string | undefined; let workspaceId: string | undefined; let workspaceInstanceId: string | undefined; + let workspaceWorktree: string | undefined; if (continuation_token == null || continuation_token === '') { try { const bridgeSelection = resolveBridgeWorkerSelection({ @@ -1341,6 +1349,15 @@ router.post( principalId: principal.userId, }); } + const requestedWorktree = rawBody.workspace_worktree; + if (requestedWorktree !== undefined) { + if (workspaceId == null || !isValidLinkedWorktreeName(requestedWorktree)) { + return res.status(400).json({ + error: 'Invalid code workspace worktree', + }); + } + workspaceWorktree = requestedWorktree; + } } catch (error) { if (error instanceof BridgeWorkerSelectionError) { return res @@ -1458,6 +1475,7 @@ router.post( bridgeWorkerId, workspaceId, workspaceInstanceId, + workspaceWorktree, }, cancellation); } if (workspaceId != null) { diff --git a/service/src/service/programmatic-state.ts b/service/src/service/programmatic-state.ts index 38c72486..056fcbb5 100644 --- a/service/src/service/programmatic-state.ts +++ b/service/src/service/programmatic-state.ts @@ -40,6 +40,7 @@ export interface BuildReplayExecutionStateParams { bridgeWorkerId?: string; workspaceId?: string; workspaceInstanceId?: string; + workspaceWorktree?: string; sandboxBackend?: SandboxBackendName; executionProfile: ExecutionProfile; executionProfileSource: ExecutionProfileSource; @@ -70,6 +71,7 @@ export function buildReplayExecutionState( bridgeWorkerId: params.bridgeWorkerId, workspaceId: params.workspaceId, workspaceInstanceId: params.workspaceInstanceId, + workspaceWorktree: params.workspaceWorktree, sandboxBackend: params.sandboxBackend, executionProfile: params.executionProfile, executionProfileSource: params.executionProfileSource, @@ -86,12 +88,21 @@ export function buildReplayExecutionState( }; } -/** Bind the authenticated conversation checkout to every replay iteration. */ +/** Bind the authenticated conversation checkout and linked-worktree lane to every replay iteration. */ export function bindReplayWorkspaceInstance( payload: t.PayloadBody, - state: Pick, + state: Pick, ): t.PayloadBody { - return state.workspaceInstanceId == null - ? payload - : { ...payload, workspace_instance_id: state.workspaceInstanceId }; + if (state.workspaceInstanceId == null && state.workspaceWorktree == null) { + return payload; + } + return { + ...payload, + ...(state.workspaceInstanceId == null + ? {} + : { workspace_instance_id: state.workspaceInstanceId }), + ...(state.workspaceWorktree == null + ? {} + : { workspace_worktree: state.workspaceWorktree }), + }; } diff --git a/service/src/service/replay-state.ts b/service/src/service/replay-state.ts index fd4e90e3..003ce6b5 100644 --- a/service/src/service/replay-state.ts +++ b/service/src/service/replay-state.ts @@ -120,6 +120,8 @@ export interface ExecutionState { workspaceId?: string; /** Selected conversation checkout retained across every replay iteration. */ workspaceInstanceId?: string; + /** Selected linked-worktree lane retained across every replay iteration. */ + workspaceWorktree?: string; /** Original queue/backend target retained across replay continuations. */ sandboxBackend?: SandboxBackendName; /** Original producer profile retained so continuations use the same queue. */ diff --git a/service/src/types/service.ts b/service/src/types/service.ts index 91c3ed89..b0375b38 100644 --- a/service/src/types/service.ts +++ b/service/src/types/service.ts @@ -206,6 +206,8 @@ export interface PayloadBody { version: string; /** Opaque conversation checkout selected and authenticated by the API. */ workspace_instance_id?: string; + /** Linked worktree lane at `.worktrees/` beneath the selected checkout. */ + workspace_worktree?: string; /** Stable identity shared by all replay iterations of one execution. */ execution_id?: string; replay_tool_count?: number; @@ -397,6 +399,8 @@ export interface ProgrammaticRequestBody { lang?: 'python' | 'bash'; /** Opaque conversation checkout binding for a selected native workspace. */ workspace_instance_id?: string; + /** Linked worktree lane at `.worktrees/` beneath the selected checkout. */ + workspace_worktree?: string; } export interface ProgrammaticToolCall { From abf5349a230a6f0bce7c04b2ed1efafde7db5a44 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 28 Sep 2026 23:54:05 -0400 Subject: [PATCH 2/9] fix: Refuse quarantined-parent lanes before enqueue, forward lane policy, reset lane fences - Check the parent checkout fence before the assignment is stored or queued. - Forward the linked worktree policy to the forked native sandbox. - Protect shared Git config, hooks and info even before they exist. - Reset a lane fence with --reset-workspace-worktree. --- packages/code/README.md | 5 ++ packages/code/src/cli.ts | 7 +- packages/code/src/linked-worktrees.test.ts | 13 ++++ packages/code/src/linked-worktrees.ts | 18 ++--- packages/code/src/native-process.test.ts | 16 +++++ packages/code/src/native-process.ts | 2 + packages/code/src/worker-slots.test.ts | 80 ++++++++++++++++++++++ packages/code/src/worker.ts | 32 ++++++--- service/src/bridge/store.ts | 2 +- 9 files changed, 154 insertions(+), 21 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index cc62e7a4..487675f1 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -890,6 +890,11 @@ To recover a quarantined native root: 3. Run the normal worker command with all its root/slot options plus `--reset-workspace-quarantine second`. This verifies the local guard is cleared, resets the server fence, then exits. 4. Restart the normal worker command without the reset option. +A linked-worktree lane keeps its own guard and fence. Inspect or restore +`/.worktrees/`, clear its guard with +`--worker-dir /.worktrees/`, then add +`--reset-workspace-worktree ` to the reset command in step 3. + The workspace selector in LibreChat must preserve these registered IDs. Adding roots here does not grant a principal access or change an agent's selected root. # Named project environments diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index a78ab6a1..f58c2523 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -1521,15 +1521,20 @@ async function run( args, '--reset-workspace-instance', ); + const resetWorkspaceWorktree = option( + args, + '--reset-workspace-worktree', + ); await worker.refreshCredential(controller.signal); await worker.registerForMaintenance(controller.signal); await worker.resetNativeWorkspace( resetNativeRoot, controller.signal, resetWorkspaceInstance, + resetWorkspaceWorktree, ); process.stdout.write( - `librechat-code: reset acknowledged for native workspace ${resetNativeRoot}${resetWorkspaceInstance ? ` instance ${resetWorkspaceInstance}` : ''}\n`, + `librechat-code: reset acknowledged for native workspace ${resetNativeRoot}${resetWorkspaceInstance ? ` instance ${resetWorkspaceInstance}` : ''}${resetWorkspaceWorktree ? ` worktree ${resetWorkspaceWorktree}` : ''}\n`, ); return; } diff --git a/packages/code/src/linked-worktrees.test.ts b/packages/code/src/linked-worktrees.test.ts index eb1d1714..9077d2e1 100644 --- a/packages/code/src/linked-worktrees.test.ts +++ b/packages/code/src/linked-worktrees.test.ts @@ -69,6 +69,19 @@ test('verifies a linked worktree of the checkout and lists the Git paths it may assert.ok(!lane.readOnlyGitPaths.includes(join(root, '.git', 'worktrees', 'task-a'))); }); +test('protects shared Git paths that do not exist yet', async (t) => { + const { parent, root } = await checkout(); + t.after(() => rm(parent, { recursive: true, force: true })); + await rm(join(root, '.git', 'hooks'), { recursive: true, force: true }); + await rm(join(root, '.git', 'info'), { recursive: true, force: true }); + + const lane = await verifyLinkedWorktree(root, 'task-a'); + + for (const path of ['config', 'config.worktree', 'hooks', 'info']) { + assert.ok(lane.readOnlyGitPaths.includes(join(root, '.git', path)), path); + } +}); + test('rejects directories that are not linked worktrees of this checkout', async (t) => { const { parent, root } = await checkout(); t.after(() => rm(parent, { recursive: true, force: true })); diff --git a/packages/code/src/linked-worktrees.ts b/packages/code/src/linked-worktrees.ts index 039f7190..069e060a 100644 --- a/packages/code/src/linked-worktrees.ts +++ b/packages/code/src/linked-worktrees.ts @@ -22,6 +22,8 @@ import type { WorkspaceToolExecutor } from './workspace.js'; /** Linked worktrees are only admitted from this directory beneath a checkout. */ export const LINKED_WORKTREE_DIRECTORY = '.worktrees'; +/** Shared Git storage that affects every worktree of a checkout. */ +const LINKED_WORKTREE_PROTECTED_GIT_PATHS = ['config', 'config.worktree', 'hooks', 'info']; /** Git pointer files are a single line; anything larger is not one. */ const GIT_POINTER_MAX_BYTES = 4096; @@ -166,18 +168,12 @@ export async function verifyLinkedWorktree( const siblings = (await readdir(join(commonGitDir, 'worktrees')).catch(() => [] as string[])) .filter((entry) => entry !== name) .map((entry) => join(commonGitDir, 'worktrees', entry)); - const candidates = [ - join(commonGitDir, 'hooks'), - join(commonGitDir, 'config'), - join(commonGitDir, 'config.worktree'), - join(commonGitDir, 'info'), + /** Denied whether or not they exist yet, so a lane cannot create them for its siblings. */ + const readOnlyGitPaths = [ + ...LINKED_WORKTREE_PROTECTED_GIT_PATHS.map((path) => join(commonGitDir, path)), ...siblings, - ]; - const readOnlyGitPaths: string[] = []; - for (const candidate of candidates) { - if ((await lstat(candidate).catch(() => undefined)) != null) readOnlyGitPaths.push(candidate); - } - return { root, identity, checkoutRoot: checkout, commonGitDir, readOnlyGitPaths: readOnlyGitPaths.sort() }; + ].sort(); + return { root, identity, checkoutRoot: checkout, commonGitDir, readOnlyGitPaths }; } function publicResult(result: WorkspaceToolResult, workspaceId: string): WorkspaceToolResult { diff --git a/packages/code/src/native-process.test.ts b/packages/code/src/native-process.test.ts index 5f7eedbb..ab7c1295 100644 --- a/packages/code/src/native-process.test.ts +++ b/packages/code/src/native-process.test.ts @@ -150,6 +150,22 @@ test('executor bootstrap excludes bridge credentials and Node injection variable await sandbox.close(); }); +test('executor forwards the linked worktree policy to the sandbox process', async () => { + const fake = fixture(); + const linkedWorktree = { + checkoutRoot: '/checkout', + commonGitDir: '/checkout/.git', + readOnlyGitPaths: ['/checkout/.git/config', '/checkout/.git/hooks'], + }; + const sandbox = new NativeProcessWorkspaceCommandSandbox( + { workspaceRoot: '/checkout/.worktrees/task-a', linkedWorktree }, + fake.fork, + ); + await sandbox.prepare(); + assert.deepEqual(fake.messages[0].options.linkedWorktree, linkedWorktree); + await sandbox.close(); +}); + test('executor forwards the resolved command policy without worker credentials', async () => { const fake = fixture(); const sandbox = new NativeProcessWorkspaceCommandSandbox( diff --git a/packages/code/src/native-process.ts b/packages/code/src/native-process.ts index ba411042..77fe8269 100644 --- a/packages/code/src/native-process.ts +++ b/packages/code/src/native-process.ts @@ -343,6 +343,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan homeDirectory, shellPath, programmaticFileUpstream, + linkedWorktree, } = this.options; await this.rpc( 'prepare', @@ -356,6 +357,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan homeDirectory, shellPath, programmaticFileUpstream, + linkedWorktree, variables: this.options.maskedEnvironment?.variables, }, }, diff --git a/packages/code/src/worker-slots.test.ts b/packages/code/src/worker-slots.test.ts index f720af89..7600bc9a 100644 --- a/packages/code/src/worker-slots.test.ts +++ b/packages/code/src/worker-slots.test.ts @@ -445,3 +445,83 @@ test('sibling linked-worktree lanes run past each other, but a lane and its chec await lane; assert.deepEqual(executed, ['lane-b', 'checkout', 'lane-c']); }); + +test('a linked worktree lane fence is reset through its own guard and isolation key', async () => { + const bodies: Record[] = []; + const guarded: string[] = []; + const worker = new BridgeWorker({ + codeApiUrl: 'http://localhost:1', + token: 'fixture', + workerId: 'worker', + incarnationId: 'incarnation-reset', + sandboxEndpoint: 'http://localhost:2', + capabilities: { + statefulWorkspace: false, + sandboxProfile: 'native-srt', + runtimes: [], + workspaceLeaseSlots: 2, + requiresReadyConfirmation: true, + workspaceTools: { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: 'repo', workspaceScopes: ['git_linked_worktree'] }, { id: 'plain' }], + }, + }, + workspaceQuarantines: new Map( + ['repo', 'plain'].map((id) => [ + id, + { + assertAvailable: async () => undefined, + arm: async () => undefined, + clear: async () => undefined, + quarantine: async () => undefined, + }, + ]), + ), + workspaceTools: { + capabilities: { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: 'repo', workspaceScopes: ['git_linked_worktree'] }, { id: 'plain' }], + }, + async execute() { + throw new Error('must not execute'); + }, + }, + linkedWorktreeQuarantineResolver: (workspaceId, worktree) => { + guarded.push(`${workspaceId}/${worktree}`); + return { + assertAvailable: async () => undefined, + arm: async () => undefined, + clear: async () => undefined, + quarantine: async () => undefined, + }; + }, + fetchImpl: async (url, init) => { + assert.ok(new URL(String(url)).pathname.endsWith('/workspaces/reset')); + bodies.push(JSON.parse(String(init?.body))); + return Response.json({ protocolVersion: 1, reset: true }); + }, + }); + + await worker.resetNativeWorkspace('repo', undefined, undefined, 'task-a'); + + assert.deepEqual(guarded, ['repo/task-a']); + assert.equal( + bodies[0]?.runtimeSessionId, + `native-workspace:${workspaceIsolationKey('repo', undefined, 'task-a')}`, + ); + await assert.rejects( + worker.resetNativeWorkspace('plain', undefined, undefined, 'task-a'), + /worktree lanes/, + ); + await assert.rejects( + worker.resetNativeWorkspace('repo', undefined, 'a'.repeat(64), 'task-a'), + /worktree lanes/, + ); + await assert.rejects( + worker.resetNativeWorkspace('repo', undefined, undefined, '../task-a'), + /worktree lanes/, + ); + assert.equal(bodies.length, 1); +}); diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 01c15d85..0c5dc29a 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -6,6 +6,7 @@ import { BridgeProtocolError, bridgeWorkerPath, isBridgeWorkspaceProgrammaticRequest, + isValidLinkedWorktreeName, isWorkspaceToolResult, workspaceIsolationKey, workspaceIsolationKeysConflict, @@ -857,18 +858,33 @@ export class BridgeWorker { workspaceId: string, signal?: AbortSignal, workspaceInstanceId?: string, + worktree?: string, ): Promise { const workspace = this.options.capabilities.workspaceTools?.workspaces.find( (root) => root.id === workspaceId, ); - const key = workspaceIsolationKey(workspaceId, workspaceInstanceId); - const guard = - workspaceInstanceId == null - ? this.options.workspaceQuarantines?.get(workspaceId) - : await this.options.workspaceQuarantineResolver?.( - workspaceId, - workspaceInstanceId, - ); + if ( + worktree != null && + (workspaceInstanceId != null || + !isValidLinkedWorktreeName(worktree) || + workspace?.workspaceScopes?.includes('git_linked_worktree') !== true) + ) { + throw new BridgeProtocolError( + 'Linked worktree reset requires a registered checkout with worktree lanes', + ); + } + const key = workspaceIsolationKey(workspaceId, workspaceInstanceId, worktree); + let guard: WorkspaceMutationQuarantine | undefined; + if (worktree != null) { + guard = await this.options.linkedWorktreeQuarantineResolver?.(workspaceId, worktree); + } else if (workspaceInstanceId != null) { + guard = await this.options.workspaceQuarantineResolver?.( + workspaceId, + workspaceInstanceId, + ); + } else { + guard = this.options.workspaceQuarantines?.get(workspaceId); + } if ( !guard || this.activeWorkspaceAssignments.size > 0 || diff --git a/service/src/bridge/store.ts b/service/src/bridge/store.ts index 315c201e..1e2e5ace 100644 --- a/service/src/bridge/store.ts +++ b/service/src/bridge/store.ts @@ -2228,13 +2228,13 @@ export class RedisBridgeStore { "if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end", 'if ARGV[7] ~= "" and redis.call(\'GET\', KEYS[6]) ~= ARGV[7] then return 0 end', "if #KEYS >= 7 and redis.call('EXISTS', KEYS[7]) == 1 then return -1 end", + "if #KEYS >= 10 and redis.call('EXISTS', KEYS[10]) == 1 then return -1 end", 'redis.call(\'SET\', KEYS[2], ARGV[2], \"EX\", ARGV[3])', "redis.call('RPUSH', KEYS[3], ARGV[4])", "redis.call('EXPIRE', KEYS[3], ARGV[3])", ...(assignment.workspaceLeaseSlot === undefined ? ['redis.call(\'SET\', KEYS[4], ARGV[1], \"PX\", ARGV[5])'] : []), - "if #KEYS >= 10 and redis.call('EXISTS', KEYS[10]) == 1 then return -1 end", 'redis.call(\'SET\', KEYS[5], "1", \"PXAT\", ARGV[6])', "if #KEYS >= 7 then redis.call('SET', KEYS[7], ARGV[4]) end", 'if #KEYS >= 9 then', From 36e7efd979d2beed94ba2fa2638a17edb784e8bb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 29 Sep 2026 00:12:14 -0400 Subject: [PATCH 3/9] fix: Grant lanes an explicit Git write allowlist, hold checkouts on quarantined lanes, release stale lanes - A lane writes only shared objects, refs, ref logs, LFS storage and its own worktree metadata; the checkout HEAD, index, operation state, config and hooks stay read-only without per-path denies. - A checkout assignment waits on any quarantined lane beneath it. - Lane registrations are released when their worktree disappears and capped at 32, dropping command roots and credential routes. --- docs/remote-bridge/projects.md | 4 +- packages/code/README.md | 17 +++-- packages/code/src/cli.ts | 17 ++++- packages/code/src/linked-worktrees.test.ts | 64 ++++++++++++------ packages/code/src/linked-worktrees.ts | 78 ++++++++++++++++------ packages/code/src/native-process.test.ts | 2 +- packages/code/src/native-sandbox.test.ts | 38 +++++++++++ packages/code/src/native-sandbox.ts | 18 ++--- packages/code/src/worker-slots.test.ts | 53 +++++++++++++++ packages/code/src/worker.ts | 38 ++++++++--- 10 files changed, 260 insertions(+), 69 deletions(-) diff --git a/docs/remote-bridge/projects.md b/docs/remote-bridge/projects.md index dafeee8a..26887ee9 100644 --- a/docs/remote-bridge/projects.md +++ b/docs/remote-bridge/projects.md @@ -44,8 +44,8 @@ workspace registration remains available for independent project directories. coordinate descendant worktrees before relaxing root exclusion. Linked worktree lanes (`--linked-worktree-lanes`) take the second approach for `.worktrees/`: hierarchical admission keeps a lane and its checkout - exclusive, and each lane's sandbox keeps shared Git configuration, hooks and - sibling metadata read-only. + exclusive, and each lane's sandbox can write only its worktree, shared + object and ref storage, and its own worktree metadata. - Setup and dependency links must remain within the execution policy. Sharing writable dependency directories between supposedly isolated worktrees reintroduces overlap and requires an explicit operator decision. diff --git a/packages/code/README.md b/packages/code/README.md index 487675f1..147c6239 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -818,12 +818,17 @@ lanes so it cannot be starved. Before admission the worker verifies, without running Git, that the directory is a real linked worktree of that checkout: no symlinks on the path, a `.git` file pointing at `/.git/worktrees/`, and metadata whose `commondir` -and `gitdir` point back. A lane's sandbox can write only its worktree and the -shared Git directory; `.git/hooks`, `.git/config`, `.git/info` and every sibling's -`.git/worktrees/` metadata stay read-only, and automatic `gc` and -maintenance are disabled so one lane cannot repack storage under another. Each -lane has its own durable quarantine guard, and a lane cannot start while its -checkout is quarantined. +and `gitdir` point back. A lane's sandbox can write only its worktree, the +shared object and ref storage (`.git/objects`, `.git/refs`, `.git/logs/refs`, +`.git/lfs`) and its own `.git/worktrees/` metadata. Everything else in +`.git` stays read-only: configuration, hooks and `info`, the checkout's own +`HEAD`, index and merge or rebase state, and sibling metadata. Automatic `gc` +and maintenance are disabled, and `git gc` itself cannot run in a lane (it +needs to write `.git/gc.pid` and `packed-refs`); run storage maintenance from +the checkout. Deleting a branch or tag also needs the checkout, because Git +locks `packed-refs` for every ref deletion. Each lane has its own durable quarantine guard. A lane +cannot start while its checkout is quarantined, and a checkout cannot start +while any lane beneath it is. Lanes require native-srt commands and at least two lease slots, and cannot yet be combined with conversation worktrees. Code API must advertise diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index f58c2523..135b9f8e 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { createHash, createHmac, randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; -import { realpath, stat } from 'node:fs/promises'; +import { readdir, realpath, stat } from 'node:fs/promises'; import { basename, join, resolve, relative, isAbsolute, sep } from 'node:path'; import { pairBridgeWorker } from './pairing.js'; @@ -67,6 +67,7 @@ import { BridgeProtocolError, isValidBridgeWorkerCapabilities, isValidBridgeWorkerId, + isValidLinkedWorktreeName, workspaceIsolationKey, } from './protocol.js'; @@ -1238,6 +1239,9 @@ async function run( ); } }, + onRelease(root) { + admittedGitHubRepositories?.delete(root); + }, sources: new Map( roots.map((root) => [ root.id, @@ -1405,6 +1409,17 @@ async function run( incarnationId, ); }, + linkedWorktreeNames: async (selectedWorkspaceId: string) => { + const source = roots.find((root) => root.id === selectedWorkspaceId); + if (!source) return []; + try { + const entries = await readdir(join(source.root, LINKED_WORKTREE_DIRECTORY)); + return entries.filter(isValidLinkedWorktreeName); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + }, } : {}), ...(workspaceLeaseSlots > 1 || roots.length > 1 diff --git a/packages/code/src/linked-worktrees.test.ts b/packages/code/src/linked-worktrees.test.ts index 9077d2e1..ea7be30a 100644 --- a/packages/code/src/linked-worktrees.test.ts +++ b/packages/code/src/linked-worktrees.test.ts @@ -53,7 +53,7 @@ async function rejects(promise: Promise, code = 'INVALID_REQUEST'): Pro await assert.rejects(promise, (error: unknown) => error instanceof WorkspaceToolError && error.code === code); } -test('verifies a linked worktree of the checkout and lists the Git paths it may not write', async (t) => { +test('verifies a linked worktree of the checkout and lists the only Git paths it may write', async (t) => { const { parent, root } = await checkout(); t.after(() => rm(parent, { recursive: true, force: true })); await git(root, 'worktree', 'add', '-q', '-b', 'task-b', '.worktrees/task-b'); @@ -63,24 +63,15 @@ test('verifies a linked worktree of the checkout and lists the Git paths it may assert.equal(lane.root, join(root, '.worktrees', 'task-a')); assert.equal(lane.checkoutRoot, root); assert.equal(lane.commonGitDir, join(root, '.git')); - assert.ok(lane.readOnlyGitPaths.includes(join(root, '.git', 'config'))); - assert.ok(lane.readOnlyGitPaths.includes(join(root, '.git', 'hooks'))); - assert.ok(lane.readOnlyGitPaths.includes(join(root, '.git', 'worktrees', 'task-b'))); - assert.ok(!lane.readOnlyGitPaths.includes(join(root, '.git', 'worktrees', 'task-a'))); + assert.deepEqual(lane.writableGitPaths, [ + join(root, '.git', 'objects'), + join(root, '.git', 'refs'), + join(root, '.git', 'logs', 'refs'), + join(root, '.git', 'lfs'), + join(root, '.git', 'worktrees', 'task-a'), + ]); }); -test('protects shared Git paths that do not exist yet', async (t) => { - const { parent, root } = await checkout(); - t.after(() => rm(parent, { recursive: true, force: true })); - await rm(join(root, '.git', 'hooks'), { recursive: true, force: true }); - await rm(join(root, '.git', 'info'), { recursive: true, force: true }); - - const lane = await verifyLinkedWorktree(root, 'task-a'); - - for (const path of ['config', 'config.worktree', 'hooks', 'info']) { - assert.ok(lane.readOnlyGitPaths.includes(join(root, '.git', path)), path); - } -}); test('rejects directories that are not linked worktrees of this checkout', async (t) => { const { parent, root } = await checkout(); @@ -157,7 +148,11 @@ function recordingPool(): { return { pool, calls }; } -async function laneTools(root: string, pool?: NativeWorkspaceCommandPool) { +async function laneTools( + root: string, + pool?: NativeWorkspaceCommandPool, + onRelease?: (root: string) => void, +) { const delegate = await LocalWorkspaceTools.create({ repositoryInstructions: false, workspaces: [{ id: 'repo', root, writable: true }], @@ -165,6 +160,7 @@ async function laneTools(root: string, pool?: NativeWorkspaceCommandPool) { return new LinkedWorktreeWorkspaceTools({ commandPool: pool, delegate, + onRelease, sources: new Map([ [ 'repo', @@ -217,7 +213,7 @@ test('lane file tools are confined to the worktree and report the public workspa ); }); -test('lane commands register a confined root and refresh it when sibling worktrees change', async (t) => { +test('lane commands register a confined root once, whatever siblings come and go', async (t) => { const { parent, root } = await checkout(); t.after(() => rm(parent, { recursive: true, force: true })); const { pool, calls } = recordingPool(); @@ -244,13 +240,37 @@ test('lane commands register a confined root and refresh it when sibling worktre assert.equal(registered.linkedWorktree?.commonGitDir, join(root, '.git')); assert.equal(registered.linkedWorktree?.checkoutRoot, root); + assert.ok(!registered.linkedWorktree?.writableGitPaths.includes(join(root, '.git'))); + await git(root, 'worktree', 'add', '-q', '-b', 'task-b', '.worktrees/task-b'); await tools.execute(command); assert.deepEqual( calls.slice(3).map((call) => call.action), - ['unregister', 'register', 'execute'], + ['execute'], ); - assert.ok( - calls[4]!.options!.linkedWorktree!.readOnlyGitPaths.includes(join(root, '.git', 'worktrees', 'task-b')), +}); + +test('a removed lane releases its command root and credential route', async (t) => { + const { parent, root } = await checkout(); + t.after(() => rm(parent, { recursive: true, force: true })); + const { pool, calls } = recordingPool(); + const released: string[] = []; + const tools = await laneTools(root, pool, (lane) => released.push(lane)); + const command = { + protocolVersion: 1 as const, + operation: 'execute_command' as const, + workspaceId: 'repo', + worktree: 'task-a', + command: 'git status', + }; + await tools.execute(command); + + await git(root, 'worktree', 'remove', '.worktrees/task-a'); + await rejects(tools.execute(command)); + + assert.deepEqual( + calls.map((call) => call.action), + ['register', 'execute', 'unregister'], ); + assert.deepEqual(released, [join(root, '.worktrees', 'task-a')]); }); diff --git a/packages/code/src/linked-worktrees.ts b/packages/code/src/linked-worktrees.ts index 069e060a..f5bd2909 100644 --- a/packages/code/src/linked-worktrees.ts +++ b/packages/code/src/linked-worktrees.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { lstat, open, readdir, realpath } from 'node:fs/promises'; +import { lstat, open, realpath } from 'node:fs/promises'; import { isAbsolute, join, resolve } from 'node:path'; import { isValidLinkedWorktreeName } from './protocol.js'; @@ -22,8 +22,14 @@ import type { WorkspaceToolExecutor } from './workspace.js'; /** Linked worktrees are only admitted from this directory beneath a checkout. */ export const LINKED_WORKTREE_DIRECTORY = '.worktrees'; -/** Shared Git storage that affects every worktree of a checkout. */ -const LINKED_WORKTREE_PROTECTED_GIT_PATHS = ['config', 'config.worktree', 'hooks', 'info']; +/** + * Shared Git storage a lane may write beneath the common Git directory. Every + * other path there (config, hooks, the checkout's own HEAD, index and + * operation state, sibling metadata) stays read-only. + */ +const LINKED_WORKTREE_SHARED_GIT_PATHS = ['objects', 'refs', join('logs', 'refs'), 'lfs']; +/** Lane registrations kept per worker; the least recently used idle lanes are released first. */ +const LINKED_WORKTREE_LANE_LIMIT = 32; /** Git pointer files are a single line; anything larger is not one. */ const GIT_POINTER_MAX_BYTES = 4096; @@ -40,8 +46,8 @@ export interface VerifiedLinkedWorktree { identity: WorkspaceRootIdentity; checkoutRoot: string; commonGitDir: string; - /** Existing paths beneath the shared Git directory a lane may not write. */ - readOnlyGitPaths: string[]; + /** Shared object and ref storage plus the lane's own metadata; nothing else in the common Git directory. */ + writableGitPaths: string[]; } export interface LinkedWorktreeWorkspaceToolsOptions { @@ -49,6 +55,8 @@ export interface LinkedWorktreeWorkspaceToolsOptions { delegate: WorkspaceToolExecutor; /** Called with each verified lane root, e.g. to route credentials for its repository. */ onResolve?: (workspaceId: string, root: string) => void; + /** Called when a lane root is released, e.g. to drop its credential route. */ + onRelease?: (root: string) => void; programmaticDelegate?: { executeProgrammatic( workspaceId: string, @@ -165,15 +173,11 @@ export async function verifyLinkedWorktree( } catch { throw rejected(`${LINKED_WORKTREE_DIRECTORY}/${name} is unavailable`); } - const siblings = (await readdir(join(commonGitDir, 'worktrees')).catch(() => [] as string[])) - .filter((entry) => entry !== name) - .map((entry) => join(commonGitDir, 'worktrees', entry)); - /** Denied whether or not they exist yet, so a lane cannot create them for its siblings. */ - const readOnlyGitPaths = [ - ...LINKED_WORKTREE_PROTECTED_GIT_PATHS.map((path) => join(commonGitDir, path)), - ...siblings, - ].sort(); - return { root, identity, checkoutRoot: checkout, commonGitDir, readOnlyGitPaths }; + const writableGitPaths = [ + ...LINKED_WORKTREE_SHARED_GIT_PATHS.map((path) => join(commonGitDir, path)), + metadata, + ]; + return { root, identity, checkoutRoot: checkout, commonGitDir, writableGitPaths }; } function publicResult(result: WorkspaceToolResult, workspaceId: string): WorkspaceToolResult { @@ -194,6 +198,8 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { { fingerprint: string; value: Promise } >(); private readonly commandRoots = new Map(); + /** Verified lane roots by internal ID, least recently used first. */ + private readonly lanes = new Map(); constructor(private readonly options: LinkedWorktreeWorkspaceToolsOptions) { this.mutationFailuresAreAtomic = options.delegate.mutationFailuresAreAtomic; @@ -220,9 +226,43 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { if (!source) { throw rejected('Workspace does not allow linked worktree lanes'); } - const lane = await verifyLinkedWorktree(source.root, worktree, source.identity); + const internalId = linkedWorktreeWorkspaceId(workspaceId, worktree); + let lane: VerifiedLinkedWorktree; + try { + lane = await verifyLinkedWorktree(source.root, worktree, source.identity); + } catch (error) { + await this.release(internalId); + throw error; + } + this.lanes.delete(internalId); + this.lanes.set(internalId, lane.root); this.options.onResolve?.(workspaceId, lane.root); - return { lane, source, internalId: linkedWorktreeWorkspaceId(workspaceId, worktree) }; + await this.releaseIdleLanes(internalId); + return { lane, source, internalId }; + } + + /** Forget a lane's executors and routes; a lane still running a command is kept. */ + private async release(internalId: string): Promise { + const root = this.lanes.get(internalId); + if (root == null) return; + if (this.commandRoots.has(internalId)) { + try { + await this.options.commandPool?.unregisterRoot(internalId); + } catch { + return; + } + this.commandRoots.delete(internalId); + } + this.executors.delete(internalId); + this.lanes.delete(internalId); + this.options.onRelease?.(root); + } + + private async releaseIdleLanes(current: string): Promise { + for (const internalId of [...this.lanes.keys()]) { + if (this.lanes.size <= LINKED_WORKTREE_LANE_LIMIT) return; + if (internalId !== current) await this.release(internalId); + } } private async fileExecutor( @@ -247,7 +287,7 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { return await cached.value; } - /** Register the lane's command root, replacing it when its identity or read-only Git paths changed. */ + /** Register the lane's command root, replacing it when its identity or writable Git paths changed. */ private async registerCommandRoot( internalId: string, lane: VerifiedLinkedWorktree, @@ -261,7 +301,7 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { lane.identity.path, lane.identity.dev, lane.identity.ino, - lane.readOnlyGitPaths, + lane.writableGitPaths, ]); const registered = this.commandRoots.get(internalId); if (registered !== fingerprint) { @@ -273,7 +313,7 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { linkedWorktree: { checkoutRoot: lane.checkoutRoot, commonGitDir: lane.commonGitDir, - readOnlyGitPaths: lane.readOnlyGitPaths, + writableGitPaths: lane.writableGitPaths, }, }); this.commandRoots.set(internalId, fingerprint); diff --git a/packages/code/src/native-process.test.ts b/packages/code/src/native-process.test.ts index ab7c1295..8abf263c 100644 --- a/packages/code/src/native-process.test.ts +++ b/packages/code/src/native-process.test.ts @@ -155,7 +155,7 @@ test('executor forwards the linked worktree policy to the sandbox process', asyn const linkedWorktree = { checkoutRoot: '/checkout', commonGitDir: '/checkout/.git', - readOnlyGitPaths: ['/checkout/.git/config', '/checkout/.git/hooks'], + writableGitPaths: ['/checkout/.git/objects', '/checkout/.git/refs'], }; const sandbox = new NativeProcessWorkspaceCommandSandbox( { workspaceRoot: '/checkout/.worktrees/task-a', linkedWorktree }, diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index eab72966..6e8704f9 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1598,3 +1598,41 @@ test('cleans allocated command state exactly once on every execution exit', asyn } } }); + +test('a linked worktree lane may write only shared Git storage and its own metadata', async t => { + const checkoutRoot = await realpath(await mkdtemp(join(tmpdir(), 'librechat-code-lane-'))); + t.after(() => rm(checkoutRoot, { recursive: true, force: true })); + const commonGitDir = join(checkoutRoot, '.git'); + const lane = join(checkoutRoot, '.worktrees', 'task-a'); + const writableGitPaths = [ + join(commonGitDir, 'objects'), + join(commonGitDir, 'refs'), + join(commonGitDir, 'worktrees', 'task-a'), + ]; + await Promise.all( + [lane, ...writableGitPaths].map(path => mkdir(path, { recursive: true })), + ); + const prepare = async (paths: string[]) => { + const fake = fakeManager(); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: lane, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: paths }, + environment: { PATH: '/usr/bin' }, + manager: fake.manager, + }); + t.after(() => sandbox.close()); + await sandbox.prepare(); + return fake.config!; + }; + + const config = await prepare(writableGitPaths); + assert.deepEqual(config.filesystem.allowWrite.slice(0, 4), [lane, ...writableGitPaths]); + assert.ok(!config.filesystem.allowWrite.includes(commonGitDir)); + assert.ok(config.filesystem.allowRead?.includes(commonGitDir)); + + await assert.rejects( + prepare([commonGitDir]), + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'REGISTRATION_INVALID', + ); +}); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 1ef5190a..406e3298 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -171,10 +171,10 @@ export interface NativeSrtWorkspaceCommandSandboxOptions { linkedWorktree?: { /** The checkout that owns the worktree; trusted as a Git safe directory. */ checkoutRoot: string; - /** `/.git`: shared objects and refs the lane must be able to write. */ + /** `/.git`: readable, but writable only at `writableGitPaths`. */ commonGitDir: string; - /** Paths beneath the shared Git directory that stay read-only: hooks, config, sibling metadata. */ - readOnlyGitPaths: string[]; + /** Shared objects and refs plus the lane's own metadata beneath `commonGitDir`. */ + writableGitPaths: string[]; }; commandPolicy?: NativeSrtCommandPolicy; /** Trusted worker files that must never become workspace-readable or writable. */ @@ -448,8 +448,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox const lane = this.options.linkedWorktree; const commonGitDir = lane ? await canonicalPath(lane.commonGitDir) : undefined; const checkoutRoot = lane ? await canonicalPath(lane.checkoutRoot) : undefined; - const readOnlyGitPaths = lane - ? await Promise.all(lane.readOnlyGitPaths.map(canonicalPath)) + const writableGitPaths = lane + ? await Promise.all(lane.writableGitPaths.map(canonicalPath)) : []; if ( lane && @@ -459,7 +459,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox !isWithin(checkoutRoot, root) || isWithin(commonGitDir, home) || protectedPaths.some(path => isWithin(commonGitDir, path)) || - readOnlyGitPaths.some(path => !isWithin(commonGitDir, path))) + writableGitPaths.some( + path => path === commonGitDir || !isWithin(commonGitDir, path), + )) ) { throw new WorkspaceToolError( 'Linked worktree Git storage is outside its checkout', @@ -509,7 +511,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ], allowWrite: [ root, - ...laneGitPaths, + ...writableGitPaths, ...(canonicalScratchDirectory ? [canonicalScratchDirectory] : []), @@ -517,7 +519,6 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox denyWrite: [ ...protectedPaths, ...deniedInheritedWritablePaths, - ...readOnlyGitPaths, ], allowGitConfig: false, }, @@ -586,7 +587,6 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox this.denyWritePaths = [ ...protectedPaths, ...deniedInheritedWritablePaths, - ...readOnlyGitPaths, ]; } diff --git a/packages/code/src/worker-slots.test.ts b/packages/code/src/worker-slots.test.ts index 7600bc9a..72052974 100644 --- a/packages/code/src/worker-slots.test.ts +++ b/packages/code/src/worker-slots.test.ts @@ -525,3 +525,56 @@ test('a linked worktree lane fence is reset through its own guard and isolation ); assert.equal(bodies.length, 1); }); + +test('a checkout waits on quarantined linked worktrees beneath it', async () => { + const quarantinedLanes = new Set(['task-b']); + const worker = new BridgeWorker({ + codeApiUrl: 'http://localhost:1', + token: 'fixture', + workerId: 'worker', + sandboxEndpoint: 'http://localhost:2', + capabilities: { + statefulWorkspace: false, + sandboxProfile: 'fixture', + runtimes: [], + }, + linkedWorktreeQuarantineResolver: (_workspaceId, worktree) => ({ + assertAvailable: async () => { + if (quarantinedLanes.has(worktree)) throw new Error(`lane ${worktree} is quarantined`); + }, + arm: async () => undefined, + clear: async () => undefined, + quarantine: async () => undefined, + }), + linkedWorktreeNames: async () => ['task-a', 'task-b'], + }); + const internals = worker as unknown as { + quarantinedWorkspaces: Set; + assertLaneFamilyAvailable: (key: string, assignment: BridgeAssignment) => Promise; + }; + const assignment = (worktree?: string) => + ({ + assignmentId: 'probe', + executionKind: 'workspace_tool', + request: { + protocolVersion: 1, + workspaceId: 'repo', + operation: 'read_file', + path: 'README.md', + ...(worktree ? { worktree } : {}), + }, + }) as BridgeAssignment; + const check = (worktree?: string) => + internals.assertLaneFamilyAvailable( + workspaceIsolationKey('repo', undefined, worktree), + assignment(worktree), + ); + + await assert.rejects(check(), /task-b is quarantined/); + await check('task-a'); + + quarantinedLanes.clear(); + await check(); + internals.quarantinedWorkspaces.add(workspaceIsolationKey('repo', undefined, 'task-c')); + await assert.rejects(check(), /linked worktree/); +}); diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 0c5dc29a..37223b34 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -72,6 +72,8 @@ export interface BridgeWorkerOptions { ) => | WorkspaceMutationQuarantine | Promise; + /** Names of the linked worktrees currently beneath a registered root. */ + linkedWorktreeNames?: (workspaceId: string) => Promise; leaseWaitMs?: number; leaseTransportGraceMs?: number; registrationTransportTimeoutMs?: number; @@ -1450,19 +1452,37 @@ export class BridgeWorker { } /** A lane may not run while the checkout it belongs to is quarantined. */ - private async assertLaneParentAvailable( + /** + * A lane may not start while its checkout is quarantined, and a checkout may + * not start while any linked worktree beneath it is: root commands can reach + * `.worktrees/*`, including `git worktree remove`. + */ + private async assertLaneFamilyAvailable( workspaceKey: string, assignment: BridgeAssignment, ): Promise { + const workspaceId = this.assignmentBaseWorkspaceId(assignment); const parent = workspaceIsolationParent(workspaceKey); - if (parent == null) return; - if (this.quarantinedWorkspaces.has(parent)) { - throw new Error('Parent workspace requires an explicit quarantine reset'); + if (parent != null) { + if (this.quarantinedWorkspaces.has(parent)) { + throw new Error('Parent workspace requires an explicit quarantine reset'); + } + if (workspaceId != null && parent === workspaceId) { + await this.options.workspaceQuarantines?.get(workspaceId)?.assertAvailable(); + } + return; } - const workspaceId = this.assignmentBaseWorkspaceId(assignment); - if (workspaceId != null && parent === workspaceId) { - await this.options.workspaceQuarantines?.get(workspaceId)?.assertAvailable(); + const resolveLaneGuard = this.options.linkedWorktreeQuarantineResolver; + if (workspaceId == null || workspaceKey !== workspaceId || resolveLaneGuard == null) return; + for (const key of this.quarantinedWorkspaces) { + if (workspaceIsolationParent(key) === workspaceKey) { + throw new Error('A linked worktree in this workspace requires an explicit quarantine reset'); + } } + const names = (await this.options.linkedWorktreeNames?.(workspaceId)) ?? []; + await Promise.all( + names.map(async (name) => (await resolveLaneGuard(workspaceId, name)).assertAvailable()), + ); } private workspaceGuard( @@ -1687,7 +1707,7 @@ export class BridgeWorker { this.options.linkedWorktreeQuarantineResolver != null ) { await guard?.assertAvailable(); - await this.assertLaneParentAvailable(workspaceKey, assignment); + await this.assertLaneFamilyAvailable(workspaceKey, assignment); } } catch (error) { throw new BridgeWorkspaceQuarantinedError( @@ -1856,7 +1876,7 @@ export class BridgeWorker { this.options.linkedWorktreeQuarantineResolver != null ) { await guard?.assertAvailable(); - await this.assertLaneParentAvailable(workspaceKey, assignment); + await this.assertLaneFamilyAvailable(workspaceKey, assignment); } } catch (error) { throw new BridgeWorkspaceQuarantinedError( From e98451aab8b62feba6930c6a9948b2c7b93b7f22 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 29 Sep 2026 00:25:01 -0400 Subject: [PATCH 4/9] fix: Refuse a checkout while a lane keeps a stuck fence; let lane probes read shared Git - Code API indexes the lane fences enqueued beneath each checkout. A checkout reaches enqueue only once no lane holds a slot, so an indexed fence that still exists is stuck and the checkout is refused, surviving worker restarts. - Replay probes of a lane may read its common Git directory (read-only). --- packages/code/src/native-sandbox.test.ts | 13 +++++++++ packages/code/src/native-sandbox.ts | 6 +++++ service/src/bridge/linked-worktree.test.ts | 23 ++++++++++++++++ service/src/bridge/store.ts | 31 ++++++++++++++++++++-- 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 6e8704f9..748090a2 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1630,6 +1630,19 @@ test('a linked worktree lane may write only shared Git storage and its own metad assert.ok(!config.filesystem.allowWrite.includes(commonGitDir)); assert.ok(config.filesystem.allowRead?.includes(commonGitDir)); + const probed = fakeManager(); + const prober = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: lane, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths }, + environment: { PATH: '/usr/bin' }, + manager: probed.manager, + }); + t.after(() => prober.close()); + const dataDirectory = await prober.createExecutionDirectory(); + await prober.executeProgrammatic(request, dataDirectory, undefined, { probe: true }); + assert.ok(probed.customConfigSeenDuringWrap?.filesystem?.allowRead?.includes(commonGitDir)); + assert.ok(!probed.customConfigSeenDuringWrap?.filesystem?.allowWrite?.includes(commonGitDir)); + await assert.rejects( prepare([commonGitDir]), (error: unknown) => diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 406e3298..227b6d34 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -309,6 +309,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox private readonly platform: NodeJS.Platform; private initialized?: Promise; private canonicalRoot?: string; + /** A lane's shared Git directory; replay probes of the lane must read it too. */ + private canonicalCommonGitDir?: string; private runtimeConfig?: SandboxRuntimeConfig; private denyReadPaths: string[] = []; private denyWritePaths: string[] = []; @@ -577,6 +579,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox unrestrictedNetwork ? async () => true : undefined, ); this.canonicalRoot = root; + this.canonicalCommonGitDir = commonGitDir; this.runtimeConfig = config; this.denyReadPaths = [ home, @@ -808,6 +811,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox allowRead: [ canonicalWorkspaceRoot ?? this.canonicalRoot!, canonicalDataDirectory, + ...(this.canonicalCommonGitDir + ? [this.canonicalCommonGitDir] + : []), ], allowWrite: [ ...(canonicalWorkspaceRoot != null diff --git a/service/src/bridge/linked-worktree.test.ts b/service/src/bridge/linked-worktree.test.ts index bf6ac0e0..5a286726 100644 --- a/service/src/bridge/linked-worktree.test.ts +++ b/service/src/bridge/linked-worktree.test.ts @@ -165,6 +165,29 @@ test.skipIf(!redisUrl)('a lane may not start while its checkout is quarantined', }); }); +test.skipIf(!redisUrl)('a checkout is refused while a lane beneath it keeps a stuck fence', async () => { + await withLaneWorker(async ({ redis, workerId, dispatch, lease, settle }) => { + const settleNext = async (request: Partial) => { + const pending = dispatch(request); + const assignment = await lease(0); + expect((assignment?.request as WorkspaceToolRequest).worktree).toBe(request.worktree); + await settle(assignment!); + await Promise.allSettled([pending]); + }; + await settleNext({ worktree: 'task-a' }); + await settleNext({}); + + // A lane whose cleanup was never acknowledged keeps its fence after its slot is released. + await settleNext({ worktree: 'task-a' }); + await redis.set( + fenceKey(workerId, 'native-workspace:\0linked-worktree\0repo\0task-a'), + 'quarantined:cleanup-unacknowledged', + ); + + await expect(dispatch({})).rejects.toMatchObject({ code: 'WORKSPACE_QUARANTINED' }); + }); +}); + test.skipIf(!redisUrl)('a lane is refused unless the worker advertises linked-worktree scopes', async () => { await withLaneWorker(async ({ dispatch }) => { await expect(dispatch({ worktree: 'task-a' })).rejects.toMatchObject({ diff --git a/service/src/bridge/store.ts b/service/src/bridge/store.ts index 1e2e5ace..123e7ee2 100644 --- a/service/src/bridge/store.ts +++ b/service/src/bridge/store.ts @@ -108,6 +108,11 @@ type AssignmentOwnership = Pick< const NATIVE_WORKSPACE_FENCE_PREFIX = 'native-workspace:'; +/** Fence keys of the linked-worktree lanes enqueued beneath a checkout fence. */ +function workspaceLaneFenceIndexKey(checkoutFenceKey: string): string { + return `${checkoutFenceKey}:lanes`; +} + /** The fence of the checkout a linked-worktree lane nests beneath. While that * checkout is quarantined, its lanes may not start either. */ function workspaceFenceParent(fence: string): string | undefined { @@ -2228,7 +2233,13 @@ export class RedisBridgeStore { "if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end", 'if ARGV[7] ~= "" and redis.call(\'GET\', KEYS[6]) ~= ARGV[7] then return 0 end', "if #KEYS >= 7 and redis.call('EXISTS', KEYS[7]) == 1 then return -1 end", - "if #KEYS >= 10 and redis.call('EXISTS', KEYS[10]) == 1 then return -1 end", + // A lane refuses a quarantined checkout. A checkout reaches enqueue only once no + // lane beneath it holds a slot, so any lane fence still indexed here is stuck. + "if ARGV[9] == 'lane' and redis.call('EXISTS', KEYS[10]) == 1 then return -1 end", + "if ARGV[9] == 'checkout' then", + " for i = 11, #KEYS do if redis.call('EXISTS', KEYS[i]) == 1 then return -1 end end", + " for i = 11, #KEYS do redis.call('SREM', KEYS[10], KEYS[i]) end", + 'end', 'redis.call(\'SET\', KEYS[2], ARGV[2], \"EX\", ARGV[3])', "redis.call('RPUSH', KEYS[3], ARGV[4])", "redis.call('EXPIRE', KEYS[3], ARGV[3])", @@ -2237,6 +2248,7 @@ export class RedisBridgeStore { : []), 'redis.call(\'SET\', KEYS[5], "1", \"PXAT\", ARGV[6])', "if #KEYS >= 7 then redis.call('SET', KEYS[7], ARGV[4]) end", + "if ARGV[9] == 'lane' then redis.call('SADD', KEYS[11], KEYS[7]) end", 'if #KEYS >= 9 then', " local epoch = redis.call('GET', KEYS[9])", " if type(epoch) ~= 'string' then epoch = '0'; redis.call('SET', KEYS[9], epoch, 'EX', ARGV[3]) end", @@ -2277,6 +2289,7 @@ export class RedisBridgeStore { workerIdentityId: assignment.workerIdentityId, expiresAt: assignment.expiresAt, }; + let fenceScope: '' | 'lane' | 'checkout' = ''; if (assignment.workspaceLeaseSlot !== undefined) { keys.push( workspaceFenceReceiptKey(assignment.assignmentId), @@ -2284,7 +2297,20 @@ export class RedisBridgeStore { ); const parent = workspaceFenceParent(assignment.workspaceFence!); if (parent !== undefined) { - keys.push(workspaceQuarantineKey(assignment.workerId, parent)); + const parentFence = workspaceQuarantineKey(assignment.workerId, parent); + keys.push(parentFence, workspaceLaneFenceIndexKey(parentFence)); + fenceScope = 'lane'; + } else { + const index = workspaceLaneFenceIndexKey( + workspaceQuarantineKey(assignment.workerId, assignment.workspaceFence!), + ); + const laneFences = await boundedCommand( + this.redis.smembers(index), + this.redisCommandTimeoutMs, + 'Bridge linked worktree fence index read', + ); + keys.push(index, ...laneFences); + fenceScope = 'checkout'; } } const result = await this.redis.eval( @@ -2299,6 +2325,7 @@ export class RedisBridgeStore { String(Date.parse(assignment.expiresAt)), readyToken ?? '', JSON.stringify(receipt), + fenceScope, ); if (Number(result) === -1) { throw new BridgeStoreError( From b3143f1a2fc0c841e03b531653edd9a320c6523c Mon Sep 17 00:00:00 2001 From: Lia Date: Tue, 29 Sep 2026 11:43:43 +0000 Subject: [PATCH 5/9] fix: Guard linked-worktree Git grants and unresolved actions --- packages/code/README.md | 12 ++++++++--- packages/code/src/linked-worktrees.test.ts | 18 ++++++++++++++++ packages/code/src/linked-worktrees.ts | 25 +++++++++++++++++++--- packages/code/src/native-sandbox.test.ts | 10 +++++++++ packages/code/src/native-sandbox.ts | 5 ++++- 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index 147c6239..e3051930 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -818,15 +818,21 @@ lanes so it cannot be starved. Before admission the worker verifies, without running Git, that the directory is a real linked worktree of that checkout: no symlinks on the path, a `.git` file pointing at `/.git/worktrees/`, and metadata whose `commondir` -and `gitdir` point back. A lane's sandbox can write only its worktree, the +and `gitdir` point back. Shared Git storage paths granted for writes must be +real directories, not symlinks into sibling metadata; optional log and LFS +paths may be absent. A lane's sandbox can write only its worktree, the shared object and ref storage (`.git/objects`, `.git/refs`, `.git/logs/refs`, `.git/lfs`) and its own `.git/worktrees/` metadata. Everything else in `.git` stays read-only: configuration, hooks and `info`, the checkout's own `HEAD`, index and merge or rebase state, and sibling metadata. Automatic `gc` and maintenance are disabled, and `git gc` itself cannot run in a lane (it needs to write `.git/gc.pid` and `packed-refs`); run storage maintenance from -the checkout. Deleting a branch or tag also needs the checkout, because Git -locks `packed-refs` for every ref deletion. Each lane has its own durable quarantine guard. A lane +the checkout. Explicit `git prune --expire now`, `git repack -ad`, or LFS +pruning can still delete objects another lane is writing; the sandbox does +**not** prevent this race. Do not enable lanes for agents that run destructive +maintenance until those commands are blocked or serialized. Deleting a branch +or tag also needs the checkout, because Git locks `packed-refs` for every ref +deletion. Each lane has its own durable quarantine guard. A lane cannot start while its checkout is quarantined, and a checkout cannot start while any lane beneath it is. diff --git a/packages/code/src/linked-worktrees.test.ts b/packages/code/src/linked-worktrees.test.ts index ea7be30a..53ec156e 100644 --- a/packages/code/src/linked-worktrees.test.ts +++ b/packages/code/src/linked-worktrees.test.ts @@ -115,6 +115,18 @@ test('rejects a checkout whose .worktrees directory is a symlink', async (t) => await rejects(verifyLinkedWorktree(root, 'elsewhere')); }); +test('rejects shared Git storage symlinks into sibling worktree metadata', async (t) => { + const { parent, root } = await checkout(); + t.after(() => rm(parent, { recursive: true, force: true })); + await git(root, 'worktree', 'add', '-q', '-b', 'task-b', '.worktrees/task-b'); + const commonGitDir = join(root, '.git'); + const siblingMetadata = join(commonGitDir, 'worktrees', 'task-b'); + await rm(join(commonGitDir, 'objects'), { recursive: true }); + await symlink(siblingMetadata, join(commonGitDir, 'objects')); + + await rejects(verifyLinkedWorktree(root, 'task-a')); +}); + function recordingPool(): { pool: NativeWorkspaceCommandPool; calls: Array<{ action: string; id: string; options?: NativeProcessSandboxOptions }>; @@ -242,6 +254,12 @@ test('lane commands register a confined root once, whatever siblings come and go assert.ok(!registered.linkedWorktree?.writableGitPaths.includes(join(root, '.git'))); + await rejects(tools.execute({ + ...command, + environmentAction: { name: 'unresolved', fingerprint: 'a'.repeat(64) }, + })); + assert.equal(calls.length, 3, 'an unresolved action must never reach the command pool'); + await git(root, 'worktree', 'add', '-q', '-b', 'task-b', '.worktrees/task-b'); await tools.execute(command); assert.deepEqual( diff --git a/packages/code/src/linked-worktrees.ts b/packages/code/src/linked-worktrees.ts index f5bd2909..e049aa6e 100644 --- a/packages/code/src/linked-worktrees.ts +++ b/packages/code/src/linked-worktrees.ts @@ -84,6 +84,20 @@ async function realDirectory(path: string): Promise { } } +/** Optional Git paths may be absent, but cannot redirect a write grant into sibling metadata. */ +async function safeSharedGitStorage(commonGitDir: string): Promise { + for (const path of ['objects', 'refs', 'logs', join('logs', 'refs'), 'lfs']) { + try { + const status = await lstat(join(commonGitDir, path)); + if (!status.isDirectory() || status.isSymbolicLink()) return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || + path === 'objects' || path === 'refs') return false; + } + } + return true; +} + async function readPointer(path: string): Promise { let handle; try { @@ -120,8 +134,9 @@ function singleLine(value: string | undefined): string | undefined { * Verify, without running Git, that `/.worktrees/` is a linked * worktree of that checkout: a real directory whose `.git` file points at * `/.git/worktrees/`, whose metadata points back at it, and - * whose common directory is the checkout's own `.git`. Nothing on the path may - * be a symlink, so a forged or relocated worktree cannot borrow a lane. + * whose common directory is the checkout's own `.git`. Neither the lane path + * nor a writable shared Git storage directory may be a symlink, so a forged + * worktree cannot borrow a lane or redirect its Git write grants. */ export async function verifyLinkedWorktree( checkoutRoot: string, @@ -148,7 +163,8 @@ export async function verifyLinkedWorktree( !(await realDirectory(root)) || (await canonicalOrUndefined(root)) !== root || !(await realDirectory(metadata)) || - (await canonicalOrUndefined(metadata)) !== metadata + (await canonicalOrUndefined(metadata)) !== metadata || + !(await safeSharedGitStorage(commonGitDir)) ) { throw rejected(`No linked worktree named ${name} in ${LINKED_WORKTREE_DIRECTORY}`); } @@ -325,6 +341,9 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor { if (request.worktree == null) { return await this.options.delegate.execute(request, signal); } + if (request.operation === 'execute_command' && request.environmentAction) { + throw rejected('Environment action was not resolved by this worker'); + } const { worktree, ...baseRequest } = request; const { lane, source, internalId } = await this.resolveLane( request.workspaceId, diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 748090a2..7ecb06f3 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1648,4 +1648,14 @@ test('a linked worktree lane may write only shared Git storage and its own metad (error: unknown) => error instanceof WorkspaceToolError && error.code === 'REGISTRATION_INVALID', ); + + const siblingMetadata = join(commonGitDir, 'worktrees', 'task-b'); + await mkdir(siblingMetadata, { recursive: true }); + await rm(join(commonGitDir, 'objects'), { recursive: true }); + await symlink(siblingMetadata, join(commonGitDir, 'objects')); + await assert.rejects( + prepare(writableGitPaths), + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'REGISTRATION_INVALID', + ); }); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 227b6d34..6ac51acc 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -462,7 +462,10 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox isWithin(commonGitDir, home) || protectedPaths.some(path => isWithin(commonGitDir, path)) || writableGitPaths.some( - path => path === commonGitDir || !isWithin(commonGitDir, path), + (path, index) => + path !== resolve(lane.writableGitPaths[index]!) || + path === commonGitDir || + !isWithin(commonGitDir, path), )) ) { throw new WorkspaceToolError( From 51b7c86df2974868d3c7206501d49d05c1a8c618 Mon Sep 17 00:00:00 2001 From: Lia Date: Tue, 29 Sep 2026 11:56:06 +0000 Subject: [PATCH 6/9] fix: Guard linked-worktree lanes against accidental Git pruning --- packages/code/README.md | 19 +++-- packages/code/src/cli.ts | 3 + .../src/linked-worktree-git-guard.test.ts | 62 ++++++++++++++ .../code/src/linked-worktree-git-guard.ts | 78 ++++++++++++++++++ packages/code/src/native-sandbox.test.ts | 81 +++++++++++++++++++ packages/code/src/native-sandbox.ts | 46 ++++++++++- 6 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 packages/code/src/linked-worktree-git-guard.test.ts create mode 100644 packages/code/src/linked-worktree-git-guard.ts diff --git a/packages/code/README.md b/packages/code/README.md index e3051930..87e81adc 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -826,13 +826,18 @@ shared object and ref storage (`.git/objects`, `.git/refs`, `.git/logs/refs`, `.git` stays read-only: configuration, hooks and `info`, the checkout's own `HEAD`, index and merge or rebase state, and sibling metadata. Automatic `gc` and maintenance are disabled, and `git gc` itself cannot run in a lane (it -needs to write `.git/gc.pid` and `packed-refs`); run storage maintenance from -the checkout. Explicit `git prune --expire now`, `git repack -ad`, or LFS -pruning can still delete objects another lane is writing; the sandbox does -**not** prevent this race. Do not enable lanes for agents that run destructive -maintenance until those commands are blocked or serialized. Deleting a branch -or tag also needs the checkout, because Git locks `packed-refs` for every ref -deletion. Each lane has its own durable quarantine guard. A lane +needs to write `.git/gc.pid` and `packed-refs`). A lane's `PATH` starts with +a read-only Git wrapper that refuses `prune`, `gc`, `repack`, `prune-packed`, +`maintenance`, `multi-pack-index` and `git lfs prune`, including after Git +options such as `-C` or `-c`. Run storage maintenance from the checkout. +This is a guardrail against accidental commands, **not** a filesystem +boundary: invoking Git by an absolute path, resetting `PATH`, or using a +script that does either bypasses it. Such commands can still delete a sibling +lane's unpublished objects. Do not enable concurrent lanes for agents or +scripts that deliberately bypass the wrapper. The POSIX-only guard must be +available or lane commands are not admitted. Deleting a branch or tag also +needs the checkout, because Git locks `packed-refs` for every ref deletion. +Each lane has its own durable quarantine guard. A lane cannot start while its checkout is quarantined, and a checkout cannot start while any lane beneath it is. diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 135b9f8e..410caf25 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -805,6 +805,9 @@ async function run( 'Linked worktree lanes cannot be combined with conversation worktrees', ); } + if (linkedWorktreeLanes && process.platform === 'win32') { + throw new Error('Linked worktree Git guard requires a POSIX host'); + } if ( roots.length > 1 && process.env.LIBRECHAT_CODE_WORKSPACE_QUARANTINE_FILE?.trim() diff --git a/packages/code/src/linked-worktree-git-guard.test.ts b/packages/code/src/linked-worktree-git-guard.test.ts new file mode 100644 index 00000000..ac57155a --- /dev/null +++ b/packages/code/src/linked-worktree-git-guard.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { promisify } from 'node:util'; + +import { writeLinkedWorktreeGitGuard } from './linked-worktree-git-guard.js'; + +const execFileAsync = promisify(execFile); + +test('lane Git guard prevents destructive maintenance after global options without breaking ordinary Git', async t => { + if (process.platform === 'win32') return t.skip('the lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-git-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + await Promise.all([mkdir(bin), mkdir(repo)]); + await writeLinkedWorktreeGitGuard(bin); + const wrapper = join(bin, 'git'); + assert.equal((await stat(wrapper)).mode & 0o222, 0, 'the script is not writable'); + assert.match(await readFile(wrapper, 'utf8'), /run storage maintenance from the checkout/); + + const run = (args: string[]) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + assert.match((await run(['--version'])).stdout, /git version/); + assert.equal((await run(['-C', repo, '-c', 'core.quotepath=false', 'status', '--short'])).stdout, ''); + + // Git has created this object, but no ref points to it yet. Another lane can be + // in exactly this window between writing objects and updating a ref. + await writeFile(join(repo, 'unpublished.txt'), 'from a second lane\n'); + const object = (await run(['hash-object', '-w', 'unpublished.txt'])).stdout.trim(); + const blocked = [ + ['prune', '--expire', 'now'], + ['-C', repo, 'prune', '--expire=now'], + ['-C' + repo, '-c', 'gc.auto=0', '--git-dir=' + join(repo, '.git'), 'prune', '--expire=now'], + ['--no-pager', '-cgc.auto=0', 'gc', '--force'], + ['repack', '-ad'], + ['prune-packed'], + ['maintenance', 'run', '--task=gc'], + ['multi-pack-index', 'expire'], + ['lfs', 'prune'], + ]; + for (const args of blocked) { + await assert.rejects(run(args), (error: unknown) => { + const result = error as { code?: number; stderr?: string }; + return result.code === 1 && /run storage maintenance from the checkout/.test(result.stderr ?? ''); + }, `must reject git ${args.join(' ')}`); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } + await assert.rejects(run(['--unrecognized-option', 'prune']), /unsupported global option/); + assert.equal((await run(['-C', repo, 'status', '--short'])).stdout, '?? unpublished.txt\n'); + + // The guard is deliberately not a sandbox boundary: resetting PATH or using + // an absolute Git binary can still prune shared objects. + await execFileAsync('git', ['prune', '--expire', 'now'], { cwd: repo }); + await assert.rejects(run(['cat-file', '-e', object])); +}); diff --git a/packages/code/src/linked-worktree-git-guard.ts b/packages/code/src/linked-worktree-git-guard.ts new file mode 100644 index 00000000..c5fd301f --- /dev/null +++ b/packages/code/src/linked-worktree-git-guard.ts @@ -0,0 +1,78 @@ +import { constants as fsConstants } from 'node:fs'; +import { access, realpath, stat, writeFile } from 'node:fs/promises'; +import { join, sep } from 'node:path'; + +import { WorkspaceToolError } from './workspace.js'; + +// Match the system-only executable search used for native programmatic tools. +const GIT_BIN_DIRS = [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/bin', + '/home/linuxbrew/.linuxbrew/bin', +]; +const TRUSTED_GIT_DIRS = [ + ...GIT_BIN_DIRS, + '/opt/homebrew/Cellar', + '/usr/local/Cellar', + '/home/linuxbrew/.linuxbrew/Cellar', + '/usr/lib/git-core', +]; + +async function systemGitExecutable(): Promise { + for (const directory of GIT_BIN_DIRS) { + try { + const executable = await realpath(join(directory, 'git')); + if (!TRUSTED_GIT_DIRS.some(path => executable.startsWith(`${path}${sep}`))) continue; + if (!(await stat(executable)).isFile()) continue; + await access(executable, fsConstants.X_OK); + return executable; + } catch { + // A missing system installation is not a reason to run Git from the workspace or PATH. + } + } + throw new WorkspaceToolError('Trusted Git executable is unavailable for linked worktree lanes', 'COMMAND_UNAVAILABLE'); +} + +/** This guards accidental `git` calls, not absolute executable paths or shell aliases. */ +export async function writeLinkedWorktreeGitGuard(directory: string): Promise { + const git = await systemGitExecutable(); + const quotedGit = `'${git.replaceAll("'", "'\\''")}'`; + const script = [ + '#!/bin/sh', + 'check() {', + ' while [ "$#" -gt 0 ]; do', + ' case "$1" in', + ' -C|-c|--git-dir|--work-tree|--namespace|--config-env)', + ' if [ "$#" -lt 2 ]; then echo "git: missing global option argument" >&2; return 2; fi', + ' shift 2 ;;', + ' -C?*|-c?*|--git-dir=*|--work-tree=*|--namespace=*|--config-env=*|--exec-path=*)', + ' shift ;;', + ' -p|-P|--paginate|--no-pager|--no-replace-objects|--bare|--no-optional-locks)', + ' shift ;;', + ' --) shift; break ;;', + ' -v|--version|-h|--help|--exec-path|--html-path|--man-path|--info-path)', + ' return 0 ;;', + ' -*) echo "git: unsupported global option in linked worktree lane" >&2; return 2 ;;', + ' *) break ;;', + ' esac', + ' done', + ' case "${1:-}" in', + ' prune|gc|repack|prune-packed|maintenance|multi-pack-index)', + ' echo "git: run storage maintenance from the checkout, not a linked worktree lane" >&2', + ' return 1 ;;', + ' lfs)', + ' if [ "${2:-}" = prune ]; then', + ' echo "git: run storage maintenance from the checkout, not a linked worktree lane" >&2', + ' return 1', + ' fi ;;', + ' esac', + '}', + 'check "$@" || exit "$?"', + `exec ${quotedGit} "$@"`, + '', + ].join('\n'); + await writeFile(join(directory, 'git'), script, { flag: 'wx', mode: 0o500 }); + await access(join(directory, 'git'), fsConstants.X_OK); +} diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 7ecb06f3..b6fb25bc 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1629,6 +1629,11 @@ test('a linked worktree lane may write only shared Git storage and its own metad assert.deepEqual(config.filesystem.allowWrite.slice(0, 4), [lane, ...writableGitPaths]); assert.ok(!config.filesystem.allowWrite.includes(commonGitDir)); assert.ok(config.filesystem.allowRead?.includes(commonGitDir)); + const gitGuard = config.filesystem.allowRead?.find(path => path.includes('librechat-code-git-')); + assert.ok(gitGuard, 'lane Git guard must be readable'); + assert.ok(!config.filesystem.allowWrite.includes(gitGuard)); + assert.ok(config.filesystem.denyWrite.includes(gitGuard)); + assert.equal((await stat(join(gitGuard, 'git'))).mode & 0o222, 0); const probed = fakeManager(); const prober = new NativeSrtWorkspaceCommandSandbox({ @@ -1642,6 +1647,10 @@ test('a linked worktree lane may write only shared Git storage and its own metad await prober.executeProgrammatic(request, dataDirectory, undefined, { probe: true }); assert.ok(probed.customConfigSeenDuringWrap?.filesystem?.allowRead?.includes(commonGitDir)); assert.ok(!probed.customConfigSeenDuringWrap?.filesystem?.allowWrite?.includes(commonGitDir)); + const probeGuard = probed.config?.filesystem.allowRead?.find(path => path.includes('librechat-code-git-')); + assert.ok(probeGuard); + assert.ok(probed.customConfigSeenDuringWrap?.filesystem?.allowRead?.includes(probeGuard)); + assert.ok(probed.customConfigSeenDuringWrap?.filesystem?.denyWrite?.includes(probeGuard)); await assert.rejects( prepare([commonGitDir]), @@ -1659,3 +1668,75 @@ test('a linked worktree lane may write only shared Git storage and its own metad error instanceof WorkspaceToolError && error.code === 'REGISTRATION_INVALID', ); }); + +test('lane commands put the read-only Git guard ahead of the ordinary PATH', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const checkoutRoot = await realpath(await mkdtemp(join(tmpdir(), 'librechat-code-guard-'))); + t.after(() => rm(checkoutRoot, { recursive: true, force: true })); + const commonGitDir = join(checkoutRoot, '.git'); + const lane = join(checkoutRoot, '.worktrees', 'task-a'); + const writableGitPaths = [join(commonGitDir, 'objects'), join(commonGitDir, 'worktrees', 'task-a')]; + await Promise.all([lane, ...writableGitPaths].map(path => mkdir(path, { recursive: true }))); + const fake = fakeManager(); + let commandPath: string | undefined; + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: lane, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths }, + environment: { PATH: '/usr/bin:/bin' }, + manager: fake.manager, + spawnCommand(command, args, options) { + commandPath = options.env?.PATH; + return spawn(command, args, options); + }, + }); + t.after(() => sandbox.close()); + const result = await sandbox.execute({ ...request, command: 'git --version' }); + assert.equal(result.exitCode, 0); + assert.match(result.stdout, /git version/); + const guard = fake.config?.filesystem.allowRead?.find(path => path.includes('librechat-code-git-')); + assert.ok(guard); + assert.ok(commandPath?.startsWith(`${guard}:`)); + await sandbox.close(); + await assert.rejects(stat(guard), { code: 'ENOENT' }); +}); + +test('linked worktree Git guard is removed when sandbox initialization fails', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const checkoutRoot = await mkdtemp(join(tmpdir(), 'librechat-code-failed-guard-')); + t.after(() => rm(checkoutRoot, { recursive: true, force: true })); + const commonGitDir = join(checkoutRoot, '.git'); + const lane = join(checkoutRoot, '.worktrees', 'task-a'); + const objects = join(commonGitDir, 'objects'); + await Promise.all([lane, objects].map(path => mkdir(path, { recursive: true }))); + const fake = fakeManager({ initializeError: new Error('init failed') }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: lane, + linkedWorktree: { checkoutRoot, commonGitDir, writableGitPaths: [objects] }, + manager: fake.manager, + }); + await assert.rejects(sandbox.prepare(), /init failed/); + const guard = fake.config?.filesystem.allowRead?.find(path => path.includes('librechat-code-git-')); + assert.ok(guard); + await assert.rejects(stat(guard), { code: 'ENOENT' }); + await sandbox.close(); +}); + +test('linked worktree Git guard refuses Windows rather than admitting an unguarded lane', async t => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-win-guard-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + linkedWorktree: { + checkoutRoot: root, + commonGitDir: join(root, '.git'), + writableGitPaths: [join(root, '.git', 'objects')], + }, + platform: 'win32', + manager: fakeManager().manager, + }); + await assert.rejects(sandbox.prepare(), (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'COMMAND_UNAVAILABLE' && + /POSIX host/.test(error.message), + ); + await sandbox.close(); +}); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 6ac51acc..e635f8e7 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -32,6 +32,7 @@ import { } from './private-storage.js'; import { WorkspaceToolError } from './workspace.js'; import { restoreScratchTraversal } from './native-scratch.js'; +import { writeLinkedWorktreeGitGuard } from './linked-worktree-git-guard.js'; import type { ChildProcessWithoutNullStreams, @@ -323,6 +324,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox } private scratchDirectory?: string; private scratchHandle?: FileHandle; + /** A private sibling of scratch, readable but never writable by lane commands. */ + private gitGuardDirectory?: string; private execution?: Promise; private closing?: Promise; private resetFailed = false; @@ -361,6 +364,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox await this.manager.reset().catch(() => { this.resetFailed = true; }); + await this.removeLinkedWorktreeGitGuard().catch(() => undefined); await this.removeScratchDirectory().catch(() => undefined); if (!this.resetFailed) managerOwners.delete(this.manager); this.initialized = undefined; @@ -448,6 +452,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox } } const lane = this.options.linkedWorktree; + if (lane && this.platform === 'win32') { + throw new WorkspaceToolError('Linked worktree Git guard requires a POSIX host', 'COMMAND_UNAVAILABLE'); + } const commonGitDir = lane ? await canonicalPath(lane.commonGitDir) : undefined; const checkoutRoot = lane ? await canonicalPath(lane.checkoutRoot) : undefined; const writableGitPaths = lane @@ -485,6 +492,12 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox 'REGISTRATION_INVALID', ); } + if (lane && !canonicalScratchDirectory) { + throw new WorkspaceToolError('Linked worktree Git guard requires private scratch storage', 'COMMAND_UNAVAILABLE'); + } + const gitGuardDirectory = lane + ? await this.createLinkedWorktreeGitGuard(canonicalScratchDirectory!) + : undefined; const commandPolicy = normalizeNativeSrtCommandPolicy( this.options.commandPolicy, ); @@ -510,6 +523,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox allowRead: [ root, ...laneGitPaths, + ...(gitGuardDirectory ? [gitGuardDirectory] : []), ...(canonicalScratchDirectory ? [canonicalScratchDirectory] : []), @@ -524,6 +538,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox denyWrite: [ ...protectedPaths, ...deniedInheritedWritablePaths, + ...(gitGuardDirectory ? [gitGuardDirectory] : []), ], allowGitConfig: false, }, @@ -593,6 +608,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox this.denyWritePaths = [ ...protectedPaths, ...deniedInheritedWritablePaths, + ...(gitGuardDirectory ? [gitGuardDirectory] : []), ]; } @@ -817,6 +833,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ...(this.canonicalCommonGitDir ? [this.canonicalCommonGitDir] : []), + ...(this.gitGuardDirectory + ? [this.gitGuardDirectory] + : []), ], allowWrite: [ ...(canonicalWorkspaceRoot != null @@ -1067,6 +1086,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ? 'NUL' : '/dev/null', GIT_CONFIG_NOSYSTEM: '1', + ...(this.gitGuardDirectory ? { + PATH: `${this.gitGuardDirectory}:${wrapped.env.PATH || this.environment.PATH || '/usr/bin:/bin'}`, + } : {}), }, detached: this.platform !== 'win32', shell: false, @@ -1297,6 +1319,24 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox ); } + private async createLinkedWorktreeGitGuard(scratchDirectory: string): Promise { + if (this.gitGuardDirectory) { + throw new Error('Linked worktree Git guard cleanup is still pending'); + } + // Unlike scratch, this sibling directory is not in filesystem.allowWrite. + const directory = await mkdtemp(join(dirname(scratchDirectory), 'librechat-code-git-')); + this.gitGuardDirectory = directory; + await assertPrivateStorageAncestors(directory); + await writeLinkedWorktreeGitGuard(directory); + return directory; + } + + private async removeLinkedWorktreeGitGuard(): Promise { + if (!this.gitGuardDirectory) return; + await rm(this.gitGuardDirectory, { recursive: true, force: true }); + this.gitGuardDirectory = undefined; + } + private async removeScratchDirectory(): Promise { const scratchDirectory = this.scratchDirectory; if (!scratchDirectory) return; @@ -1340,6 +1380,10 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox } this.initialized = undefined; this.canonicalRoot = undefined; - await this.removeScratchDirectory(); + try { + await this.removeLinkedWorktreeGitGuard(); + } finally { + await this.removeScratchDirectory(); + } } } From 4242d448fb6e2db60c8d822fbdcef840db15d8d1 Mon Sep 17 00:00:00 2001 From: Lia Date: Tue, 29 Sep 2026 13:28:49 +0000 Subject: [PATCH 7/9] fix: Refuse Git Aliases and Allow Pathspec Flags in Worktree Lanes --- packages/code/README.md | 21 +++-- .../src/linked-worktree-git-guard.test.ts | 86 ++++++++++++++++++- .../code/src/linked-worktree-git-guard.ts | 34 ++++++-- 3 files changed, 128 insertions(+), 13 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index 87e81adc..5dfc5bc8 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -829,17 +829,24 @@ and maintenance are disabled, and `git gc` itself cannot run in a lane (it needs to write `.git/gc.pid` and `packed-refs`). A lane's `PATH` starts with a read-only Git wrapper that refuses `prune`, `gc`, `repack`, `prune-packed`, `maintenance`, `multi-pack-index` and `git lfs prune`, including after Git -options such as `-C` or `-c`. Run storage maintenance from the checkout. +options such as `-C` or `-c`. It also refuses **all configured Git aliases**, +including harmless ones: aliases can hide destructive maintenance through +local config, `-c`, `--config-env`, includes or shell commands. Call the +underlying Git command instead, or run the alias from the checkout. Git's +safe pathspec options (`--literal-pathspecs`, `--glob-pathspecs`, +`--noglob-pathspecs`, `--icase-pathspecs`) remain usable in lanes. Run storage +maintenance from the checkout. + This is a guardrail against accidental commands, **not** a filesystem boundary: invoking Git by an absolute path, resetting `PATH`, or using a script that does either bypasses it. Such commands can still delete a sibling lane's unpublished objects. Do not enable concurrent lanes for agents or -scripts that deliberately bypass the wrapper. The POSIX-only guard must be -available or lane commands are not admitted. Deleting a branch or tag also -needs the checkout, because Git locks `packed-refs` for every ref deletion. -Each lane has its own durable quarantine guard. A lane -cannot start while its checkout is quarantined, and a checkout cannot start -while any lane beneath it is. +scripts that deliberately bypass the wrapper. The guard requires `/bin/bash` +and a trusted system Git executable; without either, lane commands are not +admitted. Deleting a branch or tag also needs the checkout, because Git locks +`packed-refs` for every ref deletion. Each lane has its own durable quarantine +guard. A lane cannot start while its checkout is quarantined, and a checkout +cannot start while any lane beneath it is. Lanes require native-srt commands and at least two lease slots, and cannot yet be combined with conversation worktrees. Code API must advertise diff --git a/packages/code/src/linked-worktree-git-guard.test.ts b/packages/code/src/linked-worktree-git-guard.test.ts index ac57155a..61a8649d 100644 --- a/packages/code/src/linked-worktree-git-guard.test.ts +++ b/packages/code/src/linked-worktree-git-guard.test.ts @@ -20,6 +20,8 @@ test('lane Git guard prevents destructive maintenance after global options witho await writeLinkedWorktreeGitGuard(bin); const wrapper = join(bin, 'git'); assert.equal((await stat(wrapper)).mode & 0o222, 0, 'the script is not writable'); + assert.match(await readFile(wrapper, 'utf8'), /^#!\/bin\/bash/); + await execFileAsync('/bin/bash', ['-n', wrapper]); assert.match(await readFile(wrapper, 'utf8'), /run storage maintenance from the checkout/); const run = (args: string[]) => execFileAsync('git', args, { @@ -29,6 +31,12 @@ test('lane Git guard prevents destructive maintenance after global options witho await run(['init', '-q', '-b', 'main']); assert.match((await run(['--version'])).stdout, /git version/); assert.equal((await run(['-C', repo, '-c', 'core.quotepath=false', 'status', '--short'])).stdout, ''); + for (const option of ['--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs']) { + assert.equal((await run([option, '-C', repo, 'status', '--short'])).stdout, '', option); + } + await writeFile(join(repo, 'literal[abc].txt'), 'literal\n'); + await run(['--literal-pathspecs', 'add', '--', 'literal[abc].txt']); + assert.match((await run(['status', '--short'])).stdout, /A literal\[abc\]\.txt/); // Git has created this object, but no ref points to it yet. Another lane can be // in exactly this window between writing objects and updating a ref. @@ -44,6 +52,7 @@ test('lane Git guard prevents destructive maintenance after global options witho ['maintenance', 'run', '--task=gc'], ['multi-pack-index', 'expire'], ['lfs', 'prune'], + ['--literal-pathspecs', 'prune', '--expire', 'now'], ]; for (const args of blocked) { await assert.rejects(run(args), (error: unknown) => { @@ -53,10 +62,85 @@ test('lane Git guard prevents destructive maintenance after global options witho assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); } await assert.rejects(run(['--unrecognized-option', 'prune']), /unsupported global option/); - assert.equal((await run(['-C', repo, 'status', '--short'])).stdout, '?? unpublished.txt\n'); + assert.match((await run(['-C', repo, 'status', '--short'])).stdout, /\?\? unpublished\.txt\n/); // The guard is deliberately not a sandbox boundary: resetting PATH or using // an absolute Git binary can still prune shared objects. await execFileAsync('git', ['prune', '--expire', 'now'], { cwd: repo }); await assert.rejects(run(['cat-file', '-e', object])); }); + +test('lane Git guard rejects aliases from checkout config, -c, include.path and --config-env', async t => { + if (process.platform === 'win32') return t.skip('the lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-alias-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + const otherRepo = join(parent, 'other'); + await Promise.all([mkdir(bin), mkdir(repo), mkdir(otherRepo)]); + await writeLinkedWorktreeGitGuard(bin); + const run = (args: string[], extraEnv: Record = {}) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, ...extraEnv, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + await run(['config', 'alias.cleanup', 'prune --expire now']); + await run(['config', 'alias.inspect', 'status --short']); + await run(['config', 'alias.indirect', 'cleanup']); + await run(['-C', otherRepo, 'init', '-q', '-b', 'main']); + await run(['-C', otherRepo, 'config', 'alias.othercleanup', 'prune --expire now']); + await writeFile(join(parent, 'aliases.cfg'), '[alias]\n frominclude = prune --expire now\n'); + await writeFile(join(repo, 'unpublished.txt'), 'a different lane wrote this object\n'); + const object = (await run(['hash-object', '-w', 'unpublished.txt'])).stdout.trim(); + const blocked: Array<[string[], Record?]> = [ + [['cleanup']], + [['-C', repo, 'cleanup']], + [['-C', otherRepo, 'othercleanup']], + [['--git-dir=' + join(otherRepo, '.git'), 'othercleanup']], + [['inspect']], + [['indirect']], + [['-c', 'alias.fromoption=prune --expire now', 'fromoption']], + [['-c', 'alias.fromshell=!git prune --expire now', 'fromshell']], + [['--git-dir=' + join(repo, '.git'), '-c', 'alias.fromgitdir=prune --expire now', 'fromgitdir']], + [['-c', `include.path=${join(parent, 'aliases.cfg')}`, 'frominclude']], + [['--config-env=alias.fromenv=LANE_TEST_GIT_ALIAS', 'fromenv'], { LANE_TEST_GIT_ALIAS: 'prune --expire now' }], + ]; + for (const [args, environment] of blocked) { + await assert.rejects(run(args, environment), (error: unknown) => { + const result = error as { code?: number; stderr?: string }; + return result.code === 1 && /Git aliases are unavailable in linked worktree lanes/.test(result.stderr ?? ''); + }, `must reject Git alias in ${JSON.stringify(args)}`); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } + assert.match((await run(['status', '--short'])).stdout, /unpublished\.txt/); +}); + +test('a lane rejects a checkout-defined maintenance alias before pruning another lane’s unpublished object', async t => { + if (process.platform === 'win32') return t.skip('the lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-shared-alias-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const checkout = join(parent, 'checkout'); + const bin = join(parent, 'bin'); + await Promise.all([mkdir(checkout), mkdir(bin)]); + await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: checkout }); + await execFileAsync('git', [ + '-c', 'user.name=test', '-c', 'user.email=test@example.test', + '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-q', '-m', 'seed', + ], { cwd: checkout }); + await mkdir(join(checkout, '.worktrees')); + const lane = join(checkout, '.worktrees', 'task'); + await execFileAsync('git', ['worktree', 'add', '-q', '-b', 'task', lane], { cwd: checkout }); + await execFileAsync('git', ['config', 'alias.cleanup', 'prune --expire now'], { cwd: checkout }); + await writeLinkedWorktreeGitGuard(bin); + await writeFile(join(lane, 'pending.txt'), 'another lane has not updated its ref\n'); + const run = (args: string[]) => execFileAsync('git', args, { + cwd: lane, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + const object = (await run(['hash-object', '-w', 'pending.txt'])).stdout.trim(); + await assert.rejects(run(['cleanup']), (error: unknown) => { + const result = error as { code?: number; stderr?: string }; + return result.code === 1 && /Git aliases are unavailable in linked worktree lanes/.test(result.stderr ?? ''); + }); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); +}); diff --git a/packages/code/src/linked-worktree-git-guard.ts b/packages/code/src/linked-worktree-git-guard.ts index c5fd301f..1f5babce 100644 --- a/packages/code/src/linked-worktree-git-guard.ts +++ b/packages/code/src/linked-worktree-git-guard.ts @@ -35,21 +35,34 @@ async function systemGitExecutable(): Promise { throw new WorkspaceToolError('Trusted Git executable is unavailable for linked worktree lanes', 'COMMAND_UNAVAILABLE'); } -/** This guards accidental `git` calls, not absolute executable paths or shell aliases. */ +/** This guards accidental `git` calls, not absolute executable paths or caller shell aliases. */ export async function writeLinkedWorktreeGitGuard(directory: string): Promise { + try { + await access('/bin/bash', fsConstants.X_OK); + } catch { + throw new WorkspaceToolError('Linked worktree Git guard requires /bin/bash', 'COMMAND_UNAVAILABLE'); + } const git = await systemGitExecutable(); const quotedGit = `'${git.replaceAll("'", "'\\''")}'`; const script = [ - '#!/bin/sh', + // Bash arrays preserve global option arguments when checking the same + // effective Git configuration without eval or interpreting alias contents. + '#!/bin/bash', 'check() {', + ' local -a globals=()', + ' local aliasStatus=0', ' while [ "$#" -gt 0 ]; do', ' case "$1" in', ' -C|-c|--git-dir|--work-tree|--namespace|--config-env)', ' if [ "$#" -lt 2 ]; then echo "git: missing global option argument" >&2; return 2; fi', - ' shift 2 ;;', - ' -C?*|-c?*|--git-dir=*|--work-tree=*|--namespace=*|--config-env=*|--exec-path=*)', + ' globals+=("$1" "$2"); shift 2 ;;', + ' -C?*|-c?*|--git-dir=*|--work-tree=*|--namespace=*|--config-env=*)', + ' globals+=("$1"); shift ;;', + ' --bare)', + ' globals+=("$1"); shift ;;', + ' -p|-P|--paginate|--no-pager|--no-replace-objects|--no-optional-locks|--exec-path=*)', ' shift ;;', - ' -p|-P|--paginate|--no-pager|--no-replace-objects|--bare|--no-optional-locks)', + ' --literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs)', ' shift ;;', ' --) shift; break ;;', ' -v|--version|-h|--help|--exec-path|--html-path|--man-path|--info-path)', @@ -68,6 +81,17 @@ export async function writeLinkedWorktreeGitGuard(directory: string): Promise/dev/null 2>&1 || aliasStatus=$?`, + ' case "$aliasStatus" in', + ' 0) echo "git: Git aliases are unavailable in linked worktree lanes; run the underlying command or use the checkout" >&2; return 1 ;;', + ' 1) ;;', + ' *) echo "git: cannot verify Git aliases in linked worktree lane" >&2; return 2 ;;', + ' esac', '}', 'check "$@" || exit "$?"', `exec ${quotedGit} "$@"`, From f9c6131185ab72bb0ccde158a51c91e00caa6dd6 Mon Sep 17 00:00:00 2001 From: Lia Date: Tue, 29 Sep 2026 13:58:52 +0000 Subject: [PATCH 8/9] fix: Guard Worktree Git Against Indirect Pruning --- packages/code/README.md | 7 +- .../src/linked-worktree-git-guard.test.ts | 101 ++++++++++++++++++ .../code/src/linked-worktree-git-guard.ts | 35 ++++-- 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index 5dfc5bc8..4b38d5fd 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -829,7 +829,12 @@ and maintenance are disabled, and `git gc` itself cannot run in a lane (it needs to write `.git/gc.pid` and `packed-refs`). A lane's `PATH` starts with a read-only Git wrapper that refuses `prune`, `gc`, `repack`, `prune-packed`, `maintenance`, `multi-pack-index` and `git lfs prune`, including after Git -options such as `-C` or `-c`. It also refuses **all configured Git aliases**, +options such as `-C` or `-c`. It rejects `git lfs fetch` or `pull` with +`--prune`/`-p`, and `git fetch` or `pull` with `--auto-maintenance`/`--auto-gc`. +It enforces `maintenance.auto=false`, `gc.auto=0` and `help.autocorrect=0` +after caller-supplied Git options, preventing those overrides from restoring +automatic maintenance or correcting a misspelled command to `prune`. +It also refuses **all configured Git aliases**, including harmless ones: aliases can hide destructive maintenance through local config, `-c`, `--config-env`, includes or shell commands. Call the underlying Git command instead, or run the alias from the checkout. Git's diff --git a/packages/code/src/linked-worktree-git-guard.test.ts b/packages/code/src/linked-worktree-git-guard.test.ts index 61a8649d..8451a801 100644 --- a/packages/code/src/linked-worktree-git-guard.test.ts +++ b/packages/code/src/linked-worktree-git-guard.test.ts @@ -144,3 +144,104 @@ test('a lane rejects a checkout-defined maintenance alias before pruning another }); assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); }); + +test('a lane never lets a misspelled maintenance command autocorrect to prune', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-autocorrect-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + await Promise.all([mkdir(bin), mkdir(repo)]); + await writeLinkedWorktreeGitGuard(bin); + const run = (args: string[]) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + await run(['config', 'help.autocorrect', 'immediate']); + await writeFile(join(repo, 'unpublished'), 'a sibling has not made this blob reachable'); + const object = (await run(['hash-object', '-w', 'unpublished'])).stdout.trim(); + for (const args of [ + ['prun', '--expire', 'now'], + ['-c', 'help.autocorrect=immediate', 'prun', '--expire=now'], + ]) { + await assert.rejects(run(args), /not a git command|not a Git command|unknown Git command/i); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } +}); + +test('Git options cannot re-enable automatic maintenance for lane commands', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-maintenance-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + const remote = join(parent, 'remote.git'); + await Promise.all([mkdir(bin), mkdir(repo)]); + await writeLinkedWorktreeGitGuard(bin); + const run = (args: string[], additionalEnv: NodeJS.ProcessEnv = {}) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, ...additionalEnv, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + await execFileAsync('git', ['init', '-q', '--bare', remote]); + for (const [key, supplied, enforced] of [ + ['maintenance.auto', 'true', 'false'], + ['gc.auto', '1', '0'], + ['help.autocorrect', 'immediate', '0'], + ]) { + const { stdout } = await run(['-c', `${key}=${supplied}`, 'config', '--get', key]); + assert.equal(stdout.trim(), enforced, `${key} must be enforced after caller-supplied -c`); + } + const { stdout } = await run(['config', '--get', 'maintenance.auto'], { + GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'maintenance.auto', GIT_CONFIG_VALUE_0: 'true', + }); + assert.equal(stdout.trim(), 'false', 'inherited configuration must not re-enable maintenance'); + const configuredGc = await run(['--config-env=gc.auto=LANE_TEST_GC_AUTO', 'config', '--get', 'gc.auto'], { + LANE_TEST_GC_AUTO: '1', + }); + assert.equal(configuredGc.stdout.trim(), '0', 'config-env cannot override the enforced setting'); + await writeFile(join(repo, 'unpublished'), 'not referenced yet'); + const object = (await run(['hash-object', '-w', 'unpublished'])).stdout.trim(); + for (const args of [ + ['fetch', '--auto-maintenance', remote], + ['fetch', '--auto-maintenance=true', remote], + ['-c', 'maintenance.auto=true', 'fetch', '--auto-gc', remote], + ['fetch', '--auto-gc=true', remote], + ['-c', 'gc.auto=1', 'fetch', '--auto-maintenance', remote], + ['pull', '--auto-maintenance', remote], + ]) { + await assert.rejects(run(args), /run storage maintenance from the checkout/); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } + await run(['-c', 'maintenance.auto=true', '-c', 'gc.auto=1', 'fetch', '--no-auto-maintenance', remote, 'refs/heads/*:refs/remotes/origin/*']); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); +}); + +test('Git LFS fetch and pull cannot request pruning in a lane', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-lfs-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + await Promise.all([mkdir(bin), mkdir(repo)]); + await writeLinkedWorktreeGitGuard(bin); + const run = (args: string[]) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + await writeFile(join(repo, 'unpublished'), 'an LFS sibling has no ref yet'); + const object = (await run(['hash-object', '-w', 'unpublished'])).stdout.trim(); + for (const args of [ + ['lfs', 'fetch', '--prune'], + ['lfs', 'fetch', '-p'], + ['lfs', 'fetch', '-rp'], + ['lfs', 'fetch', '--prune', '--recent'], + ['lfs', 'pull', '--prune'], + ['-C', repo, 'lfs', 'fetch', '--prune'], + ]) { + await assert.rejects(run(args), /run storage maintenance from the checkout/); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } +}); diff --git a/packages/code/src/linked-worktree-git-guard.ts b/packages/code/src/linked-worktree-git-guard.ts index 1f5babce..59927077 100644 --- a/packages/code/src/linked-worktree-git-guard.ts +++ b/packages/code/src/linked-worktree-git-guard.ts @@ -49,22 +49,22 @@ export async function writeLinkedWorktreeGitGuard(directory: string): Promise&2; return 2; fi', - ' globals+=("$1" "$2"); shift 2 ;;', + ' globals+=("$1" "$2"); forwarded+=("$1" "$2"); shift 2 ;;', ' -C?*|-c?*|--git-dir=*|--work-tree=*|--namespace=*|--config-env=*)', - ' globals+=("$1"); shift ;;', + ' globals+=("$1"); forwarded+=("$1"); shift ;;', ' --bare)', - ' globals+=("$1"); shift ;;', + ' globals+=("$1"); forwarded+=("$1"); shift ;;', ' -p|-P|--paginate|--no-pager|--no-replace-objects|--no-optional-locks|--exec-path=*)', - ' shift ;;', + ' forwarded+=("$1"); shift ;;', ' --literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs)', - ' shift ;;', - ' --) shift; break ;;', + ' forwarded+=("$1"); shift ;;', + ' --) forwarded+=("$1"); shift; break ;;', ' -v|--version|-h|--help|--exec-path|--html-path|--man-path|--info-path)', ' return 0 ;;', ' -*) echo "git: unsupported global option in linked worktree lane" >&2; return 2 ;;', @@ -75,10 +75,27 @@ export async function writeLinkedWorktreeGitGuard(directory: string): Promise&2', ' return 1 ;;', + ' fetch|pull)', + ' for option in "${@:2}"; do', + ' case "$option" in', + ' --auto-maintenance|--auto-maintenance=*|--auto-gc|--auto-gc=*)', + ' echo "git: run storage maintenance from the checkout, not a linked worktree lane" >&2', + ' return 1 ;;', + ' esac', + ' done ;;', ' lfs)', ' if [ "${2:-}" = prune ]; then', ' echo "git: run storage maintenance from the checkout, not a linked worktree lane" >&2', ' return 1', + ' fi', + ' if [ "${2:-}" = fetch ] || [ "${2:-}" = pull ]; then', + ' for option in "${@:3}"; do', + ' case "$option" in', + ' --prune|--prune=*|-p|-p?*|-[!-]*p*)', + ' echo "git: run storage maintenance from the checkout, not a linked worktree lane" >&2', + ' return 1 ;;', + ' esac', + ' done', ' fi ;;', ' esac', ' if [ "$#" -eq 0 ]; then return 0; fi', @@ -92,6 +109,10 @@ export async function writeLinkedWorktreeGitGuard(directory: string): Promise&2; return 2 ;;', ' esac', + // Command-line -c values override both Git config files and GIT_CONFIG_COUNT. + // Add these after every caller global option: fetch can start maintenance + // internally without invoking the PATH guard a second time. + ` exec ${quotedGit} "\${forwarded[@]}" -c maintenance.auto=false -c gc.auto=0 -c help.autocorrect=0 "$@"`, '}', 'check "$@" || exit "$?"', `exec ${quotedGit} "$@"`, From c6e0703836bf16b9c4d90cb156f0fa3bde53a21e Mon Sep 17 00:00:00 2001 From: Lia Date: Tue, 29 Sep 2026 15:40:10 +0000 Subject: [PATCH 9/9] fix: Refuse Git Repository Dispatchers in Worktree Lanes --- packages/code/README.md | 6 ++-- .../src/linked-worktree-git-guard.test.ts | 29 +++++++++++++++++++ .../code/src/linked-worktree-git-guard.ts | 2 +- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index 4b38d5fd..a38523a3 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -828,8 +828,10 @@ shared object and ref storage (`.git/objects`, `.git/refs`, `.git/logs/refs`, and maintenance are disabled, and `git gc` itself cannot run in a lane (it needs to write `.git/gc.pid` and `packed-refs`). A lane's `PATH` starts with a read-only Git wrapper that refuses `prune`, `gc`, `repack`, `prune-packed`, -`maintenance`, `multi-pack-index` and `git lfs prune`, including after Git -options such as `-C` or `-c`. It rejects `git lfs fetch` or `pull` with +`maintenance`, `multi-pack-index`, `for-each-repo` and `git lfs prune`, +including after Git options such as `-C` or `-c`. `for-each-repo` is refused +entirely because Git dispatches its child commands without re-entering the +wrapper. It rejects `git lfs fetch` or `pull` with `--prune`/`-p`, and `git fetch` or `pull` with `--auto-maintenance`/`--auto-gc`. It enforces `maintenance.auto=false`, `gc.auto=0` and `help.autocorrect=0` after caller-supplied Git options, preventing those overrides from restoring diff --git a/packages/code/src/linked-worktree-git-guard.test.ts b/packages/code/src/linked-worktree-git-guard.test.ts index 8451a801..96f16b4a 100644 --- a/packages/code/src/linked-worktree-git-guard.test.ts +++ b/packages/code/src/linked-worktree-git-guard.test.ts @@ -218,6 +218,35 @@ test('Git options cannot re-enable automatic maintenance for lane commands', asy assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); }); +test('for-each-repo cannot dispatch maintenance behind the lane Git guard', async t => { + if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); + const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-for-each-repo-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const bin = join(parent, 'bin'); + const repo = join(parent, 'repo'); + await Promise.all([mkdir(bin), mkdir(repo)]); + await writeLinkedWorktreeGitGuard(bin); + const run = (args: string[]) => execFileAsync('git', args, { + cwd: repo, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}` }, + }); + await run(['init', '-q', '-b', 'main']); + await writeFile(join(repo, 'pending'), 'a sibling has not published this object yet\n'); + const object = (await run(['hash-object', '-w', 'pending'])).stdout.trim(); + const dispatcher = ['-c', `maintenance.repo=${repo}`, 'for-each-repo', '--config=maintenance.repo']; + for (const args of [ + [...dispatcher, 'prune', '--expire', 'now'], + [...dispatcher, 'status', '--short'], + ]) { + await assert.rejects(run(args), (error: unknown) => { + const result = error as { code?: number; stderr?: string }; + return result.code === 1 && /run storage maintenance from the checkout/.test(result.stderr ?? ''); + }, 'Git dispatchers cannot be inspected safely inside a lane'); + assert.equal((await run(['cat-file', '-t', object])).stdout.trim(), 'blob'); + } + assert.match((await run(['status', '--short'])).stdout, /pending/); +}); + test('Git LFS fetch and pull cannot request pruning in a lane', async t => { if (process.platform === 'win32') return t.skip('lane Git guard requires POSIX'); const parent = await mkdtemp(join(tmpdir(), 'linked-worktree-lfs-')); diff --git a/packages/code/src/linked-worktree-git-guard.ts b/packages/code/src/linked-worktree-git-guard.ts index 59927077..227bfd3f 100644 --- a/packages/code/src/linked-worktree-git-guard.ts +++ b/packages/code/src/linked-worktree-git-guard.ts @@ -72,7 +72,7 @@ export async function writeLinkedWorktreeGitGuard(directory: string): Promise&2', ' return 1 ;;', ' fetch|pull)',