From 626ba735cc8eb9c440e3737d474b8865ecfff777 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 08:39:45 -0400 Subject: [PATCH 01/14] feat(code): add native SRT command sandbox --- packages/code/Dockerfile | 2 + packages/code/README.md | 77 +++- packages/code/package-lock.json | 56 ++- packages/code/package.json | 9 +- packages/code/src/cli.test.ts | 23 ++ packages/code/src/cli.ts | 90 ++++- packages/code/src/index.ts | 1 + packages/code/src/native-sandbox.test.ts | 230 ++++++++++++ packages/code/src/native-sandbox.ts | 455 +++++++++++++++++++++++ packages/code/src/workspace-cli.test.ts | 12 +- 10 files changed, 919 insertions(+), 36 deletions(-) create mode 100644 packages/code/src/native-sandbox.test.ts create mode 100644 packages/code/src/native-sandbox.ts diff --git a/packages/code/Dockerfile b/packages/code/Dockerfile index 015ca2d0..8b22ba14 100644 --- a/packages/code/Dockerfile +++ b/packages/code/Dockerfile @@ -12,6 +12,8 @@ RUN apk add --no-cache ripgrep \ && adduser -S librechat-code -G librechat-code WORKDIR /app COPY --from=build /app/package.json ./package.json +COPY --from=build /app/package-lock.json ./package-lock.json +RUN npm ci --omit=dev COPY --from=build /app/dist ./dist USER librechat-code ENTRYPOINT ["node", "dist/cli.js"] diff --git a/packages/code/README.md b/packages/code/README.md index 2d6e9fb8..95c20404 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -3,9 +3,11 @@ Provider-neutral protocol and worker CLI for attaching a stateful, sandboxed code environment to LibreChat Code API. -The CLI owns the runtime-supervisor seam. The bundled endpoint adapter connects -to an already-running loopback Code Interpreter sandbox; future adapters create -and isolate the runtime themselves. It connects outbound to Code API, +The CLI owns the runtime-supervisor seam. Native workspace commands use +Anthropic's open-source Sandbox Runtime (SRT) on the worker machine. The +bundled endpoint adapter can also connect to an already-running loopback Code +Interpreter sandbox, while the optional Docker adapter provides a stronger +container/NsJail profile. The worker connects outbound to Code API, long-polls for assignments, sends them to the local runtime, and returns fenced results. The VM does not need an inbound public port. @@ -37,7 +39,52 @@ Use `--identity ` while pairing and `LIBRECHAT_CODE_IDENTITY_FILE=` while running to override the identity file location. -## Docker runtime supervisor (programmatic adapter) +## Native BYOM sandbox (default) + +The MVP command sandbox runs directly on the user's chosen laptop or VM. It +does not require Docker. Enable commands for an existing project or for a new +application-owned directory: + +```bash +librechat-code run --worker-dir /path/to/project --allow-workspace-commands + +# Git is optional; this creates and reuses an empty workspace. +librechat-code run --default-workspace --allow-workspace-commands +``` + +`native-srt` is the default command sandbox unless a Docker/NsJail runtime was +selected. It uses `@anthropic-ai/sandbox-runtime`: Seatbelt on macOS, +bubblewrap plus seccomp on Linux, and the SRT restricted-account helper on +Windows. Startup fails before worker registration when the platform or its +dependencies are unavailable. There is no unsandboxed command fallback. + +The bridge worker remains outside the sandbox so it can maintain its outbound +Code API connection. Each command and its descendants run inside SRT with: + +- write access restricted to the one canonical registered workspace; +- read access denied to the worker's home directory except for that workspace; +- paired identity and mutation-quarantine files explicitly denied; +- `LIBRECHAT_CODE_*` and nonessential inherited environment variables removed; +- network egress denied by default, local binding denied, and Unix sockets + denied; and +- bounded time and aggregate output, followed by process-tree termination. + +Linux hosts need `bubblewrap`, `socat`, and `ripgrep`; macOS uses system +facilities. Follow SRT's one-time restricted-account setup when using Windows. +An operator may allow explicit egress destinations with the comma-separated +`LIBRECHAT_CODE_COMMAND_ALLOWED_DOMAINS` setting. Treat that as a security +policy: an allowed destination can receive workspace data. The normalized +allowlist is included in the worker policy digest. Tool approval hooks remain +the user-facing allow/deny boundary for each invocation. + +Select the backend explicitly when desired: + +```bash +LIBRECHAT_CODE_COMMAND_SANDBOX=native-srt librechat-code run \ + --worker-dir /path/to/project --allow-workspace-commands +``` + +## Docker runtime supervisor (optional hardened adapter) `DockerRuntimeSupervisor` is the first self-contained local OCI adapter. It owns one named container per runtime session, does not publish the runner port, @@ -158,7 +205,9 @@ librechat-code run Optional environment variables: -- `LIBRECHAT_CODE_SANDBOX_PROFILE`: capability label; defaults to `nsjail`. +- `LIBRECHAT_CODE_SANDBOX_PROFILE`: capability label; defaults to + `anthropic-srt` for native workspace commands, `oci-docker` for Docker, and + the existing `nsjail` label otherwise. - `LIBRECHAT_CODE_RUNTIMES`: comma-separated capability labels. - `LIBRECHAT_CODE_POLICY`: local policy description hashed into the worker's registration; defaults to `default-deny`. @@ -219,8 +268,9 @@ capabilities; absolute host paths remain local to the worker process. The protocol also defines a bounded `execute_command` request and result for a sandbox-backed executor. Commands are treated as workspace mutations and cannot be advertised without durable quarantine storage. `LocalWorkspaceTools` never -runs them in the worker host process; the CLI does not advertise command support -until a sandbox runtime executor is configured. +runs them directly in the trusted worker process; the CLI does not advertise +command support until its selected SRT or Docker/NsJail sandbox has passed +startup checks. `SandboxWorkspaceTools` is the composition boundary for that runtime. It adds `execute_command` only to workspace IDs explicitly backed by a @@ -228,8 +278,8 @@ until a sandbox runtime executor is configured. executor, and validates the sandbox's complete result before returning it. It does not include a shell fallback. Invalid responses and unknown sandbox errors are reported as potentially committed mutations so the worker's durable -quarantine remains armed. A concrete runtime adapter must prove its mount and -identity behavior before the CLI can enable this composition. +quarantine remains armed. The concrete adapter must pass its platform and +identity checks before the CLI can enable this composition. The built-in Docker/NsJail adapter can be enabled explicitly for one registered directory: @@ -239,6 +289,7 @@ LIBRECHAT_CODE_RUNTIME_SUPERVISOR=docker-nsjail \ LIBRECHAT_CODE_RUNTIME_IMAGE=librechat-code-runtime:local \ LIBRECHAT_CODE_DOCKER_SECCOMP_PROFILE=./seccomp/nsjail.json \ LIBRECHAT_CODE_DOCKER_PACKAGES_PATH=./data/pkgs \ +LIBRECHAT_CODE_COMMAND_SANDBOX=runtime \ librechat-code run --worker-dir /path/to/workspace --allow-workspace-commands ``` @@ -247,9 +298,11 @@ bind-mounts only that canonical directory into an unexposed runtime container and submits commands to a private, capability-authenticated runner route. The runner maps the mounted directory owner into NsJail without chowning the directory, disables network access by default, rejects an escaping `cwd`, -and bounds command, time, stdout, and stderr. The endpoint supervisor cannot -enable this feature. This operator switch controls availability; LibreChat tool -approval hooks remain the user-facing allow/deny boundary for each invocation. +and bounds command, time, stdout, and stderr. The endpoint supervisor cannot be +used as the `runtime` command backend, but it can coexist with the default +native SRT command backend. This operator switch controls availability; +LibreChat tool approval hooks remain the user-facing allow/deny boundary for +each invocation. Reads reject absolute paths, traversal, escaping symlinks, non-regular files, and files larger than 1 MiB. The opened file is checked against its canonical diff --git a/packages/code/package-lock.json b/packages/code/package-lock.json index 15ca1ad6..a2da04a0 100644 --- a/packages/code/package-lock.json +++ b/packages/code/package-lock.json @@ -8,6 +8,9 @@ "name": "@librechat/code", "version": "0.1.0", "license": "Apache-2.0", + "dependencies": { + "@anthropic-ai/sandbox-runtime": "0.0.75" + }, "bin": { "librechat-code": "dist/cli.js" }, @@ -16,9 +19,33 @@ "typescript": "^5.5.4" }, "engines": { - "node": ">=20" + "node": ">=20.11" } }, + "node_modules/@anthropic-ai/sandbox-runtime": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.75.tgz", + "integrity": "sha512-oqAKi6QtkT2DpLwFoDCDD757zw2i6ftpLTyV8rNSV9QWF53q2m1JxEs0RYXv2CIXtCoje4RGYQylagn15RKmww==", + "license": "Apache-2.0", + "dependencies": { + "@pondwader/socks5-server": "^1.0.10", + "commander": "^12.1.0", + "node-forge": "^1.4.0", + "zod": "^3.24.1" + }, + "bin": { + "srt": "dist/cli.js" + }, + "engines": { + "node": ">=20.11.0" + } + }, + "node_modules/@pondwader/socks5-server": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", + "integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -29,6 +56,24 @@ "undici-types": "~6.21.0" } }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -49,6 +94,15 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/packages/code/package.json b/packages/code/package.json index 5c1d3aef..cfb97ddd 100644 --- a/packages/code/package.json +++ b/packages/code/package.json @@ -30,6 +30,10 @@ "./workspace-runtime": { "types": "./dist/workspace-runtime.d.ts", "import": "./dist/workspace-runtime.js" + }, + "./native-sandbox": { + "types": "./dist/native-sandbox.d.ts", + "import": "./dist/native-sandbox.js" } }, "bin": { @@ -49,6 +53,9 @@ "typescript": "^5.5.4" }, "engines": { - "node": ">=20" + "node": ">=20.11" + }, + "dependencies": { + "@anthropic-ai/sandbox-runtime": "0.0.75" } } diff --git a/packages/code/src/cli.test.ts b/packages/code/src/cli.test.ts index 1f081f86..7bdb3d30 100644 --- a/packages/code/src/cli.test.ts +++ b/packages/code/src/cli.test.ts @@ -71,6 +71,29 @@ test('CLI rejects an unknown runtime supervisor before entering the run loop', ( ); }); +test('CLI rejects an unknown command sandbox before entering the run loop', () => { + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL('./cli.js', import.meta.url))], + { + encoding: 'utf8', + env: { + ...process.env, + LIBRECHAT_CODE_URL: 'https://code.example/v1', + LIBRECHAT_CODE_WORKER_TOKEN: 'worker-secret', + LIBRECHAT_CODE_WORKER_ID: 'engineering-vm', + LIBRECHAT_CODE_COMMAND_SANDBOX: 'host-shell', + }, + }, + ); + + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /LIBRECHAT_CODE_COMMAND_SANDBOX must be native-srt or runtime/, + ); +}); + test('CLI requires a runtime image for Docker supervision', () => { const result = spawnSync( process.execPath, diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index fcbece02..af9336fd 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -20,12 +20,13 @@ import { saveWorkspaceMutationQuarantine, } from './storage.js'; import { BridgeWorker } from './worker.js'; -import { DockerRuntimeSupervisor, EndpointRuntimeSupervisor } from './runtime.js'; import { LocalWorkspaceTools, SandboxWorkspaceTools, } from './workspace.js'; +import { DockerRuntimeSupervisor, EndpointRuntimeSupervisor } from './runtime.js'; import { RuntimeWorkspaceCommandSandbox } from './workspace-runtime.js'; +import { NativeSrtWorkspaceCommandSandbox } from './native-sandbox.js'; import type { RuntimeSupervisor } from './runtime.js'; import type { WorkspaceToolExecutor } from './workspace.js'; import { @@ -267,6 +268,18 @@ async function run(runtimeSessionId?: string, args: string[] = []): Promise path != null, + ), + allowedDomains: commandAllowedDomains, + }) + : undefined; + await nativeCommandSandbox?.prepare(); if (allowWorkspaceCommands && workspaceTools) { workspaceTools = new SandboxWorkspaceTools({ workspaceTools, commandWorkspaces: [workspaceId], - commandSandbox: new RuntimeWorkspaceCommandSandbox({ - runtimeSupervisor, - workerId, - incarnationId, - }), + commandSandbox: + nativeCommandSandbox ?? + new RuntimeWorkspaceCommandSandbox({ + runtimeSupervisor, + workerId, + incarnationId, + }), }); } const capabilities = { statefulWorkspace, sandboxProfile: process.env.LIBRECHAT_CODE_SANDBOX_PROFILE ?? - (runtimeMode.startsWith('docker') ? 'oci-docker' : 'nsjail'), + (allowWorkspaceCommands && commandSandboxMode === 'native-srt' + ? 'anthropic-srt' + : runtimeMode.startsWith('docker') + ? 'oci-docker' + : 'nsjail'), runtimes: list(process.env.LIBRECHAT_CODE_RUNTIMES), - policyDigest: createHash('sha256').update(policy).digest('hex'), + policyDigest: createHash('sha256') + .update(policy) + .update( + allowWorkspaceCommands && commandSandboxMode === 'native-srt' + ? `\0native-srt\0${commandAllowedDomains.join('\0')}` + : '', + ) + .digest('hex'), ...(fileRelayEnabled ? { requiresReadyConfirmation: true } : {}), ...(workspaceTools ? { workspaceTools: workspaceTools.capabilities } : {}), }; @@ -587,6 +636,7 @@ async function run(runtimeSessionId?: string, args: string[] = []): Promise true, + async checkDependenciesAsync() { + return { warnings: [], errors: options.dependencyErrors ?? [] }; + }, + async initialize(value: SandboxRuntimeConfig) { + config = value; + }, + async wrapWithSandboxArgv(command: string) { + return { + argv: ['/bin/bash', '-c', command], + env: { PATH: process.env.PATH }, + }; + }, + annotateStderrWithSandboxFailures(_commandId: string, stderr: string) { + return stderr; + }, + cleanupAfterCommand() {}, + async reset() { + reset = true; + }, + }; + return { + manager, + get config() { + return config; + }, + get reset() { + return reset; + }, + }; +} + +test('initializes SRT with a default-deny network and scrubbed worker credentials', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + const identity = join(tmpdir(), 'librechat-code-identity.json'); + t.after(() => rm(root, { recursive: true, force: true })); + const fake = fakeManager(); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + protectedPaths: [identity], + environment: { + PATH: '/usr/bin', + Path: '/windows/system32', + LANG: 'en_US.UTF-8', + LIBRECHAT_CODE_WORKER_TOKEN: 'secret', + AWS_SECRET_ACCESS_KEY: 'secret', + }, + manager: fake.manager, + }); + + await sandbox.prepare(); + const canonicalRoot = await realpath(root); + const canonicalIdentity = await realpath(identity).catch(async () => + join(await realpath(tmpdir()), 'librechat-code-identity.json'), + ); + const canonicalHome = await realpath(homedir()); + assert.deepEqual(fake.config?.network.allowedDomains, []); + assert.equal(fake.config?.network.strictAllowlist, true); + assert.equal(fake.config?.network.allowAllUnixSockets, false); + assert.deepEqual(fake.config?.filesystem.allowRead, [canonicalRoot]); + assert.deepEqual(fake.config?.filesystem.allowWrite, [canonicalRoot]); + assert.ok(fake.config?.filesystem.denyRead.includes(canonicalHome)); + assert.ok(fake.config?.filesystem.denyWrite.includes(canonicalIdentity)); + const denied = fake.config?.credentials?.envVars?.map(({ name }) => name); + assert.ok(denied?.includes('LIBRECHAT_CODE_WORKER_TOKEN')); + assert.ok(denied?.includes('AWS_SECRET_ACCESS_KEY')); + assert.ok(!denied?.includes('PATH')); + assert.ok(!denied?.includes('Path')); + await sandbox.close(); + assert.equal(fake.reset, true); +}); + +test('fails closed when SRT dependencies are unavailable', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const fake = fakeManager({ dependencyErrors: ['bubblewrap missing'] }); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + manager: fake.manager, + }); + + await assert.rejects( + sandbox.prepare(), + (error: unknown) => + error instanceof WorkspaceToolError && + error.code === 'COMMAND_UNAVAILABLE' && + /bubblewrap missing/.test(error.message), + ); +}); + +test('refuses workspace roots that expose worker home or control files', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + const controlDirectory = join(root, '.control'); + await mkdir(controlDirectory); + const controlFile = join(controlDirectory, 'identity.json'); + await writeFile(controlFile, '{}'); + t.after(() => rm(root, { recursive: true, force: true })); + + await assert.rejects( + new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: homedir(), + manager: fakeManager().manager, + }).prepare(), + /cannot contain the worker home directory/i, + ); + await assert.rejects( + new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + protectedPaths: [controlFile], + manager: fakeManager().manager, + }).prepare(), + /cannot contain worker control files/i, + ); +}); + +test('executes in the canonical workspace and bounds aggregate output', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + await mkdir(join(root, 'src')); + t.after(() => rm(root, { recursive: true, force: true })); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + manager: fakeManager().manager, + }); + + assert.equal(sandbox.mutationFailuresAreAtomic, true); + assert.deepEqual( + await sandbox.execute({ + ...request, + command: "printf '1234567890'; printf 'abcdefghij' >&2", + cwd: 'src', + maxOutputBytes: 12, + }), + { + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + exitCode: 0, + stdout: '1234567890', + stderr: 'ab', + truncated: true, + timedOut: false, + }, + ); +}); + +test('rejects an escaping or unavailable command working directory', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + manager: fakeManager().manager, + }); + + await assert.rejects( + sandbox.execute({ ...request, cwd: '..' }), + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'INVALID_REQUEST', + ); +}); + +test('terminates detached command descendants before returning', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + manager: fakeManager().manager, + }); + + const result = await sandbox.execute({ + ...request, + command: '(sleep 0.2; printf late > late.txt) >/dev/null 2>&1 &', + }); + assert.equal(result.exitCode, 0); + await new Promise((resolve) => setTimeout(resolve, 350)); + await assert.rejects(access(join(root, 'late.txt'))); +}); + +test('reports cancellation after command start as a potentially committed mutation', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + manager: fakeManager().manager, + }); + const controller = new AbortController(); + const execution = sandbox.execute( + { ...request, command: 'sleep 30' }, + controller.signal, + ); + setTimeout(() => controller.abort(), 25); + + await assert.rejects( + execution, + (error: unknown) => + error instanceof WorkspaceToolError && + error.code === 'EXECUTION_ABORTED' && + error.mutationMayHaveCommitted === true, + ); +}); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts new file mode 100644 index 00000000..aea7b912 --- /dev/null +++ b/packages/code/src/native-sandbox.ts @@ -0,0 +1,455 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { homedir } from 'node:os'; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path'; +import { realpath, stat } from 'node:fs/promises'; + +import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; + +import { + BRIDGE_PROTOCOL_VERSION, + BRIDGE_WORKSPACE_COMMAND_DEFAULT_OUTPUT_BYTES, + BRIDGE_WORKSPACE_COMMAND_DEFAULT_TIMEOUT_MS, + isWorkspaceToolRequest, +} from './protocol.js'; +import { WorkspaceToolError } from './workspace.js'; + +import type { + ChildProcessWithoutNullStreams, + SpawnOptionsWithoutStdio, +} from 'node:child_process'; +import type { SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime'; +import type { + WorkspaceExecuteCommandRequest, + WorkspaceExecuteCommandResult, +} from './protocol.js'; +import type { WorkspaceCommandSandbox } from './workspace.js'; + +const SAFE_CHILD_ENV_NAMES = new Set([ + 'COLORTERM', + 'HOME', + 'LANG', + 'LC_ALL', + 'LOGNAME', + 'NO_COLOR', + 'PATH', + 'SHELL', + 'TERM', + 'TMPDIR', + 'USER', +]); + +interface NativeSandboxManager { + isSupportedPlatform(): boolean; + checkDependenciesAsync(): Promise<{ warnings: string[]; errors: string[] }>; + initialize(config: SandboxRuntimeConfig): Promise; + wrapWithSandboxArgv( + command: string, + binShell?: string, + customConfig?: Partial, + abortSignal?: AbortSignal, + cwd?: string, + options?: { commandId?: string; commandText?: string }, + ): Promise<{ argv: string[]; env: NodeJS.ProcessEnv }>; + annotateStderrWithSandboxFailures(commandId: string, stderr: string): string; + cleanupAfterCommand(): void; + reset(): Promise; +} + +type SpawnCommand = ( + command: string, + args: readonly string[], + options: SpawnOptionsWithoutStdio, +) => ChildProcessWithoutNullStreams; + +export interface NativeSrtWorkspaceCommandSandboxOptions { + workspaceRoot: string; + /** Trusted worker files that must never become workspace-readable or writable. */ + protectedPaths?: string[]; + allowedDomains?: string[]; + environment?: NodeJS.ProcessEnv; + manager?: NativeSandboxManager; + spawnCommand?: SpawnCommand; + homeDirectory?: string; + platform?: NodeJS.Platform; +} + +function isWithin(root: string, candidate: string): boolean { + const path = relative(root, candidate); + return ( + path === '' || + (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path)) + ); +} + +async function canonicalPath(path: string): Promise { + const absolute = resolve(path); + let cursor = absolute; + const missingSegments: string[] = []; + for (;;) { + try { + return join(await realpath(cursor), ...missingSegments); + } catch { + const parent = dirname(cursor); + if (parent === cursor) + throw new Error(`Cannot canonicalize protected path: ${path}`); + missingSegments.unshift(basename(cursor)); + cursor = parent; + } + } +} + +function boundedUtf8(buffer: Buffer, budget: number): string { + let end = Math.min(buffer.byteLength, budget); + while (end > 0) { + const value = buffer.subarray(0, end).toString('utf8'); + if (Buffer.byteLength(value) <= budget) return value; + end -= 1; + } + return ''; +} + +function safeEnvironmentNames(environment: NodeJS.ProcessEnv): string[] { + return Object.keys(environment) + .filter((name) => { + const normalized = name.toUpperCase(); + return ( + normalized.startsWith('LIBRECHAT_CODE_') || + (!SAFE_CHILD_ENV_NAMES.has(normalized) && !normalized.startsWith('LC_')) + ); + }) + .sort(); +} + +export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox { + readonly mutationFailuresAreAtomic = true as const; + private readonly manager: NativeSandboxManager; + private readonly spawnCommand: SpawnCommand; + private readonly environment: NodeJS.ProcessEnv; + private readonly platform: NodeJS.Platform; + private initialized?: Promise; + private canonicalRoot?: string; + + constructor( + private readonly options: NativeSrtWorkspaceCommandSandboxOptions, + ) { + this.manager = options.manager ?? SandboxManager; + this.spawnCommand = options.spawnCommand ?? spawn; + this.environment = { ...(options.environment ?? process.env) }; + this.platform = options.platform ?? process.platform; + } + + /** Fail closed before the worker advertises command execution. */ + async prepare(): Promise { + await this.initialize(); + } + + private async initialize(): Promise { + if (this.initialized) return this.initialized; + this.initialized = this.initializeOnce().catch(async (error) => { + await this.manager.reset().catch(() => undefined); + this.initialized = undefined; + throw error; + }); + return this.initialized; + } + + private async initializeOnce(): Promise { + if (!this.manager.isSupportedPlatform()) { + throw new WorkspaceToolError( + 'Native sandbox is unsupported on this platform', + 'COMMAND_UNAVAILABLE', + ); + } + const root = await realpath(this.options.workspaceRoot); + if (!(await stat(root)).isDirectory()) { + throw new WorkspaceToolError( + 'Native sandbox workspace is unavailable', + 'COMMAND_UNAVAILABLE', + ); + } + const home = await canonicalPath(this.options.homeDirectory ?? homedir()); + if (isWithin(root, home)) { + throw new WorkspaceToolError( + 'Native sandbox workspace cannot contain the worker home directory', + 'REGISTRATION_INVALID', + ); + } + const protectedPaths = await Promise.all( + (this.options.protectedPaths ?? []).map(canonicalPath), + ); + if (protectedPaths.some((path) => isWithin(root, path))) { + throw new WorkspaceToolError( + 'Native sandbox workspace cannot contain worker control files', + 'REGISTRATION_INVALID', + ); + } + const dependencies = await this.manager.checkDependenciesAsync(); + if (dependencies.errors.length > 0) { + throw new WorkspaceToolError( + `Native sandbox dependencies are unavailable: ${dependencies.errors.join('; ')}`, + 'COMMAND_UNAVAILABLE', + ); + } + const config: SandboxRuntimeConfig = { + network: { + allowedDomains: [...(this.options.allowedDomains ?? [])], + deniedDomains: [], + strictAllowlist: true, + allowAllUnixSockets: false, + allowLocalBinding: false, + }, + filesystem: { + denyRead: [home], + allowRead: [root], + allowWrite: [root], + denyWrite: protectedPaths, + allowGitConfig: false, + }, + credentials: { + files: protectedPaths.map((path) => ({ + path, + mode: 'deny' as const, + })), + envVars: safeEnvironmentNames(this.environment).map((name) => ({ + name, + mode: 'deny' as const, + })), + }, + allowAppleEvents: false, + enableWeakerNestedSandbox: false, + enableWeakerNetworkIsolation: false, + git: { safeDirectories: [root] }, + }; + await this.manager.initialize(config); + this.canonicalRoot = root; + } + + async execute( + request: WorkspaceExecuteCommandRequest, + signal?: AbortSignal, + ): Promise { + if ( + !isWorkspaceToolRequest(request) || + request.operation !== 'execute_command' + ) { + throw new WorkspaceToolError( + 'Invalid native sandbox command', + 'INVALID_REQUEST', + ); + } + if (signal?.aborted) { + throw new WorkspaceToolError( + 'Workspace command execution aborted', + 'EXECUTION_ABORTED', + ); + } + await this.initialize(); + const root = this.canonicalRoot!; + let cwd: string; + try { + cwd = await realpath(resolve(root, request.cwd ?? '.')); + if (!isWithin(root, cwd) || !(await stat(cwd)).isDirectory()) + throw new Error('invalid cwd'); + } catch { + throw new WorkspaceToolError( + 'Command working directory is unavailable', + 'INVALID_PATH', + ); + } + const commandId = `librechat-code-${randomUUID()}`; + let wrapped: Awaited< + ReturnType + >; + try { + wrapped = await this.manager.wrapWithSandboxArgv( + request.command, + this.platform === 'win32' ? undefined : '/bin/bash', + undefined, + signal, + cwd, + { commandId, commandText: request.command }, + ); + } catch (error) { + if (signal?.aborted) { + throw new WorkspaceToolError( + 'Workspace command execution aborted', + 'EXECUTION_ABORTED', + ); + } + throw new WorkspaceToolError( + 'Native sandbox command could not start', + 'COMMAND_UNAVAILABLE', + ); + } + if (signal?.aborted) { + throw new WorkspaceToolError( + 'Workspace command execution aborted', + 'EXECUTION_ABORTED', + ); + } + return await this.runWrapped(request, wrapped, cwd, commandId, signal); + } + + private async runWrapped( + request: WorkspaceExecuteCommandRequest, + wrapped: { argv: string[]; env: NodeJS.ProcessEnv }, + cwd: string, + commandId: string, + signal?: AbortSignal, + ): Promise { + const outputLimit = + request.maxOutputBytes ?? BRIDGE_WORKSPACE_COMMAND_DEFAULT_OUTPUT_BYTES; + const timeoutMs = + request.timeoutMs ?? BRIDGE_WORKSPACE_COMMAND_DEFAULT_TIMEOUT_MS; + return await new Promise( + (resolvePromise, reject) => { + let child: ChildProcessWithoutNullStreams; + try { + child = this.spawnCommand(wrapped.argv[0], wrapped.argv.slice(1), { + cwd, + env: wrapped.env, + detached: this.platform !== 'win32', + shell: false, + windowsHide: true, + }); + } catch { + reject( + new WorkspaceToolError( + 'Native sandbox command could not start', + 'COMMAND_UNAVAILABLE', + ), + ); + return; + } + let settled = false; + let timedOut = false; + let outputBytes = 0; + let truncated = false; + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const append = (target: Buffer[], chunk: Buffer): void => { + const remaining = outputLimit - outputBytes; + if (remaining <= 0) { + truncated = true; + return; + } + const accepted = chunk.subarray(0, remaining); + target.push(accepted); + outputBytes += accepted.byteLength; + if (accepted.byteLength !== chunk.byteLength) truncated = true; + }; + child.stdout.on('data', (chunk: Buffer) => append(stdout, chunk)); + child.stderr.on('data', (chunk: Buffer) => append(stderr, chunk)); + const abort = (): void => { + if (settled) return; + this.killCommandTree(child); + }; + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + const timer = setTimeout(() => { + if (settled) return; + timedOut = true; + this.killCommandTree(child); + }, timeoutMs); + const cleanup = (): void => { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + try { + this.manager.cleanupAfterCommand(); + } catch { + // Cleanup is retried by close(); command settlement must still finish. + } + }; + child.once('error', () => { + if (settled) return; + settled = true; + const mayHaveStarted = child.pid != null; + this.killCommandTree(child); + cleanup(); + reject( + new WorkspaceToolError( + 'Native sandbox command could not start', + 'COMMAND_UNAVAILABLE', + mayHaveStarted, + ), + ); + }); + child.once('close', (code, childSignal) => { + if (settled) return; + settled = true; + this.killCommandTree(child); + cleanup(); + if (signal?.aborted) { + reject( + new WorkspaceToolError( + 'Workspace command execution aborted', + 'EXECUTION_ABORTED', + true, + ), + ); + return; + } + const stdoutValue = boundedUtf8(Buffer.concat(stdout), outputLimit); + const stderrBudget = Math.max( + 0, + outputLimit - Buffer.byteLength(stdoutValue), + ); + const rawStderr = Buffer.concat(stderr).toString('utf8'); + let annotatedStderr = rawStderr; + try { + annotatedStderr = this.manager.annotateStderrWithSandboxFailures( + commandId, + rawStderr, + ); + } catch { + // Preserve the bounded child error if optional violation annotation fails. + } + const stderrValue = boundedUtf8( + Buffer.from(annotatedStderr), + stderrBudget, + ); + resolvePromise({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'execute_command', + workspaceId: request.workspaceId, + exitCode: timedOut || childSignal ? null : (code ?? 1), + ...(childSignal ? { signal: childSignal } : {}), + stdout: stdoutValue, + stderr: stderrValue, + truncated: + truncated || Buffer.byteLength(annotatedStderr) > stderrBudget, + timedOut, + }); + }); + }, + ); + } + + private killCommandTree(child: ChildProcessWithoutNullStreams): void { + try { + if (this.platform !== 'win32' && child.pid != null) { + process.kill(-child.pid, 'SIGKILL'); + } else { + child.kill('SIGKILL'); + } + } catch { + // The command group has already exited. + } + } + + async close(): Promise { + if (!this.initialized) return; + await this.manager.reset(); + this.initialized = undefined; + this.canonicalRoot = undefined; + } +} diff --git a/packages/code/src/workspace-cli.test.ts b/packages/code/src/workspace-cli.test.ts index 7fe28016..169bdf50 100644 --- a/packages/code/src/workspace-cli.test.ts +++ b/packages/code/src/workspace-cli.test.ts @@ -62,7 +62,7 @@ test('CLI trims an environment-configured worker directory', async (t) => { assert.doesNotMatch(result.stderr, /invalid workspace registration/i); }); -test('CLI refuses workspace commands without its Docker sandbox profile', async (t) => { +test('CLI supports native SRT by default and validates explicit runtime mode', async (t) => { const workspaceRoot = await mkdtemp( join(tmpdir(), 'librechat-code-command-workspace-'), ); @@ -83,11 +83,15 @@ test('CLI refuses workspace commands without its Docker sandbox profile', async LIBRECHAT_CODE_URL: 'http://127.0.0.1:1/v1', LIBRECHAT_CODE_WORKER_TOKEN: 'worker-secret', LIBRECHAT_CODE_WORKER_ID: 'engineering-vm', + LIBRECHAT_CODE_COMMAND_SANDBOX: 'runtime', }, }, ); assert.notEqual(endpoint.status, 0); - assert.match(endpoint.stderr, /require.*docker-nsjail runtime supervisor/i); + assert.match( + endpoint.stderr, + /runtime command sandbox requires.*docker-nsjail/i, + ); const noWorkspace = spawnSync( process.execPath, @@ -198,6 +202,10 @@ test('CLI advertises explicitly enabled writes without exposing the workspace ro child.kill(); await once(child, 'exit'); + assert.equal( + (body.capabilities as Record).sandboxProfile, + 'nsjail', + ); assert.deepEqual( (body.capabilities as Record).workspaceTools, { From 68a05383771421c1f77408bff2d6a322cc810dac Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 08:53:38 -0400 Subject: [PATCH 02/14] fix(code): harden native SRT command lifecycle --- packages/code/Dockerfile | 2 +- packages/code/src/cli.ts | 7 +++- packages/code/src/native-sandbox.test.ts | 45 ++++++++++++++++++++++++ packages/code/src/native-sandbox.ts | 10 +++++- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/code/Dockerfile b/packages/code/Dockerfile index 8b22ba14..62e734c4 100644 --- a/packages/code/Dockerfile +++ b/packages/code/Dockerfile @@ -7,7 +7,7 @@ RUN npm run build FROM node:24-alpine ENV NODE_ENV=production -RUN apk add --no-cache ripgrep \ +RUN apk add --no-cache bubblewrap ripgrep socat \ && addgroup -S librechat-code \ && adduser -S librechat-code -G librechat-code WORKDIR /app diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index af9336fd..abdd547f 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -504,7 +504,12 @@ async function run(runtimeSessionId?: string, args: string[] = []): Promise undefined); + throw error; + } if (allowWorkspaceCommands && workspaceTools) { workspaceTools = new SandboxWorkspaceTools({ workspaceTools, diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 7f2f2692..9688cba0 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; import { access, mkdtemp, @@ -9,9 +10,11 @@ import { } from 'node:fs/promises'; import { tmpdir, homedir } from 'node:os'; import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; import test from 'node:test'; import type { SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; import { NativeSrtWorkspaceCommandSandbox } from './native-sandbox.js'; import { WorkspaceToolError } from './workspace.js'; @@ -228,3 +231,45 @@ test('reports cancellation after command start as a potentially committed mutati error.mutationMayHaveCommitted === true, ); }); + +test('closes stdin immediately when the command protocol provides no input', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + manager: fakeManager().manager, + }); + + const result = await sandbox.execute({ + ...request, + command: 'cat', + timeoutMs: 250, + }); + assert.equal(result.exitCode, 0); + assert.equal(result.timedOut, false); +}); + +test('maps platform-native exit statuses into the bridge protocol range', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const spawnCommand = () => { + const child = new EventEmitter() as ChildProcessWithoutNullStreams; + Object.assign(child, { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + pid: undefined, + kill: () => true, + }); + queueMicrotask(() => child.emit('close', 300, null)); + return child; + }; + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + manager: fakeManager().manager, + spawnCommand, + }); + + const result = await sandbox.execute(request); + assert.equal(result.exitCode, 1); +}); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index aea7b912..79e353d8 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -321,6 +321,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox shell: false, windowsHide: true, }); + child.stdin.end(); } catch { reject( new WorkspaceToolError( @@ -421,7 +422,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox protocolVersion: BRIDGE_PROTOCOL_VERSION, operation: 'execute_command', workspaceId: request.workspaceId, - exitCode: timedOut || childSignal ? null : (code ?? 1), + exitCode: + timedOut || childSignal ? null : this.protocolExitCode(code), ...(childSignal ? { signal: childSignal } : {}), stdout: stdoutValue, stderr: stderrValue, @@ -446,6 +448,12 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox } } + private protocolExitCode(code: number | null): number { + return Number.isSafeInteger(code) && code != null && code >= 0 && code <= 255 + ? code + : 1; + } + async close(): Promise { if (!this.initialized) return; await this.manager.reset(); From deb3122a7be4a9a415a493fd57d22a57cbd461b4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 09:02:06 -0400 Subject: [PATCH 03/14] fix(code): clarify native SRT containment --- docs/remote-bridge/README.md | 40 +++++++++++++++++++++++------------- packages/code/Dockerfile | 2 +- packages/code/README.md | 11 +++++++++- packages/code/src/cli.ts | 7 +++++-- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/docs/remote-bridge/README.md b/docs/remote-bridge/README.md index fc0d86c3..ff78057c 100644 --- a/docs/remote-bridge/README.md +++ b/docs/remote-bridge/README.md @@ -137,27 +137,39 @@ listings, and later tool results necessarily cross the outbound bridge to Code API and the model. Treat them as explicit tool outputs, apply the same retention and audit policy as chat content, and do not register a directory containing secrets. The -default operations are read-only. The bridge protocol reserves a bounded -`execute_command` operation, but the local filesystem executor and CLI do not -advertise it. A later layer must bind it to a sandboxed process boundary and -LibreChat's tool-approval hooks before it becomes dispatchable. +default operations are read-only. Operators can explicitly add bounded +`execute_command` support with `--allow-workspace-commands` (or +`LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS=true`). The CLI prepares its sandbox +before registration, so Code API cannot dispatch commands to an unavailable +boundary. LibreChat's tool-approval hooks remain the per-call decision point. The package exposes `SandboxWorkspaceTools` for composing that boundary without -ever invoking a host shell. It requires an explicit sandbox implementation and -an allowlist of workspace IDs, preserves per-workspace operation restrictions, -validates bounded results, and treats an unknown command failure as an uncertain -mutation. The built-in CLI remains command-disabled until its supported NsJail -adapter can safely map a registered directory without changing host ownership. - -The supported `docker-nsjail` adapter enables that mapping only with +ever invoking an unsandboxed host shell. It requires an explicit sandbox +implementation and an allowlist of workspace IDs, preserves per-workspace +operation restrictions, validates bounded results, and treats an unknown +command failure as an uncertain mutation. + +Native SRT is the MVP and default command backend on a user's chosen laptop or +VM. It uses Seatbelt on macOS, bubblewrap/seccomp on Linux, and the SRT +restricted-account helper on Windows. It confines writes to the registered +workspace, denies reads of the worker home and control files, strips worker +credentials, and denies network egress by default. Startup fails closed when +the platform dependencies are unavailable; there is no unsandboxed fallback. +Use `LIBRECHAT_CODE_COMMAND_ALLOWED_DOMAINS` for an explicit comma-separated +egress allowlist. + +The optional `docker-nsjail` adapter enables a stronger container boundary with `--allow-workspace-commands` (or `LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS=true`) and a registered worker/default directory. It mounts only the canonical workspace, keeps the runner port unpublished, authenticates its dedicated command route with an ephemeral container capability, and runs Bash inside the existing NsJail profile. Direct -endpoint mode is rejected. This deployment permission does not replace the -per-call approval decision: LibreChat must apply its configurable tool-approval -hooks before dispatching `execute_command`. +endpoint mode cannot be used as the `runtime` command backend, but endpoint +runtime supervision can coexist with native SRT commands. Set +`LIBRECHAT_CODE_COMMAND_SANDBOX=runtime` to select Docker/NsJail explicitly. +This deployment permission does not replace the per-call approval decision: +LibreChat must apply its configurable tool-approval hooks before dispatching +`execute_command`. Stateful deployments must also set `LIBRECHAT_CODE_STATEFUL_WORKSPACE=true` and route the CLI's `{runtimeSessionId}` endpoint template to an isolated, diff --git a/packages/code/Dockerfile b/packages/code/Dockerfile index 62e734c4..93ed9c57 100644 --- a/packages/code/Dockerfile +++ b/packages/code/Dockerfile @@ -7,7 +7,7 @@ RUN npm run build FROM node:24-alpine ENV NODE_ENV=production -RUN apk add --no-cache bubblewrap ripgrep socat \ +RUN apk add --no-cache bash bubblewrap ripgrep socat \ && addgroup -S librechat-code \ && adduser -S librechat-code -G librechat-code WORKDIR /app diff --git a/packages/code/README.md b/packages/code/README.md index 95c20404..0bbcce40 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -67,7 +67,16 @@ Code API connection. Each command and its descendants run inside SRT with: - `LIBRECHAT_CODE_*` and nonessential inherited environment variables removed; - network egress denied by default, local binding denied, and Unix sockets denied; and -- bounded time and aggregate output, followed by process-tree termination. +- bounded time and aggregate output, with best-effort process-group termination + on cancellation, timeout, and completion. + +SRT restrictions remain inherited by descendants. Windows additionally uses a +kill-on-close Job Object. Native macOS does not provide an equivalent hard +process-lifetime boundary: a deliberately daemonized descendant can outlive +the command while remaining confined to the approved workspace and network +policy. This matches the personal-machine SRT trust model; use the Docker/NsJail +backend or a dedicated VM boundary when hard teardown of adversarial process +trees is required. Linux hosts need `bubblewrap`, `socat`, and `ripgrep`; macOS uses system facilities. Follow SRT's one-time restricted-account setup when using Windows. diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index abdd547f..78541aec 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -641,8 +641,11 @@ async function run(runtimeSessionId?: string, args: string[] = []): Promise Date: Thu, 3 Sep 2026 09:16:27 -0400 Subject: [PATCH 04/14] fix(code): close native sandbox review gaps --- docs/remote-bridge/README.md | 3 ++ packages/code/README.md | 2 +- packages/code/src/cli.ts | 13 ++++--- packages/code/src/native-sandbox.test.ts | 47 +++++++++++++++++++++++- packages/code/src/native-sandbox.ts | 28 +++++++++++--- 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/docs/remote-bridge/README.md b/docs/remote-bridge/README.md index ff78057c..ab7e2f64 100644 --- a/docs/remote-bridge/README.md +++ b/docs/remote-bridge/README.md @@ -157,6 +157,9 @@ credentials, and denies network egress by default. Startup fails closed when the platform dependencies are unavailable; there is no unsandboxed fallback. Use `LIBRECHAT_CODE_COMMAND_ALLOWED_DOMAINS` for an explicit comma-separated egress allowlist. +Linux hosts must provide Bash at `/bin/bash`, `bubblewrap`, `socat`, and +`ripgrep`; macOS uses system facilities. Windows requires SRT's one-time +restricted-account setup. The optional `docker-nsjail` adapter enables a stronger container boundary with `--allow-workspace-commands` (or diff --git a/packages/code/README.md b/packages/code/README.md index 0bbcce40..3e8bf72d 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -78,7 +78,7 @@ policy. This matches the personal-machine SRT trust model; use the Docker/NsJail backend or a dedicated VM boundary when hard teardown of adversarial process trees is required. -Linux hosts need `bubblewrap`, `socat`, and `ripgrep`; macOS uses system +Linux hosts need Bash at `/bin/bash`, `bubblewrap`, `socat`, and `ripgrep`; macOS uses system facilities. Follow SRT's one-time restricted-account setup when using Windows. An operator may allow explicit egress destinations with the comma-separated `LIBRECHAT_CODE_COMMAND_ALLOWED_DOMAINS` setting. Treat that as a security diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 78541aec..06aec93b 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -504,12 +504,6 @@ async function run(runtimeSessionId?: string, args: string[] = []): Promise undefined); - throw error; - } if (allowWorkspaceCommands && workspaceTools) { workspaceTools = new SandboxWorkspaceTools({ workspaceTools, @@ -545,10 +539,17 @@ async function run(runtimeSessionId?: string, args: string[] = []): Promise undefined); throw new Error( 'LIBRECHAT_CODE_SANDBOX_PROFILE or LIBRECHAT_CODE_RUNTIMES is invalid', ); } + try { + await nativeCommandSandbox?.prepare(); + } catch (error) { + await fileRelaySupervisor?.stop().catch(() => undefined); + throw error; + } try { const worker = new BridgeWorker({ codeApiUrl, diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 9688cba0..8cc7c6cf 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -76,6 +76,7 @@ test('initializes SRT with a default-deny network and scrubbed worker credential PATH: '/usr/bin', Path: '/windows/system32', LANG: 'en_US.UTF-8', + lc_api_token: 'lowercase-secret', LIBRECHAT_CODE_WORKER_TOKEN: 'secret', AWS_SECRET_ACCESS_KEY: 'secret', }, @@ -99,11 +100,55 @@ test('initializes SRT with a default-deny network and scrubbed worker credential assert.ok(denied?.includes('LIBRECHAT_CODE_WORKER_TOKEN')); assert.ok(denied?.includes('AWS_SECRET_ACCESS_KEY')); assert.ok(!denied?.includes('PATH')); - assert.ok(!denied?.includes('Path')); + assert.ok(denied?.includes('Path')); + assert.ok(denied?.includes('lc_api_token')); await sandbox.close(); assert.equal(fake.reset, true); }); +test('filters environment names case-insensitively only on Windows', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const fake = fakeManager(); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + platform: 'win32', + environment: { + PATH: '/usr/bin', + Path: 'C:\\Windows\\System32', + LC_API_TOKEN: 'secret', + librechat_code_worker_token: 'secret', + }, + manager: fake.manager, + }); + + await sandbox.prepare(); + const denied = fake.config?.credentials?.envVars?.map(({ name }) => name); + assert.ok(!denied?.includes('PATH')); + assert.ok(!denied?.includes('Path')); + assert.ok(!denied?.includes('LC_API_TOKEN')); + assert.ok(denied?.includes('librechat_code_worker_token')); +}); + +test('fails closed when the configured POSIX shell is unavailable', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ + workspaceRoot: root, + platform: 'linux', + shellPath: join(root, 'missing-bash'), + manager: fakeManager().manager, + }); + + await assert.rejects( + sandbox.prepare(), + (error: unknown) => + error instanceof WorkspaceToolError && + error.code === 'COMMAND_UNAVAILABLE' && + /shell is unavailable/i.test(error.message), + ); +}); + test('fails closed when SRT dependencies are unavailable', async (t) => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 79e353d8..2136f9b3 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -10,7 +10,8 @@ import { resolve, sep, } from 'node:path'; -import { realpath, stat } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { access, realpath, stat } from 'node:fs/promises'; import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; @@ -80,6 +81,8 @@ export interface NativeSrtWorkspaceCommandSandboxOptions { spawnCommand?: SpawnCommand; homeDirectory?: string; platform?: NodeJS.Platform; + /** Trusted shell path used by SRT on POSIX hosts. */ + shellPath?: string; } function isWithin(root: string, candidate: string): boolean { @@ -117,10 +120,13 @@ function boundedUtf8(buffer: Buffer, budget: number): string { return ''; } -function safeEnvironmentNames(environment: NodeJS.ProcessEnv): string[] { +function safeEnvironmentNames( + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): string[] { return Object.keys(environment) .filter((name) => { - const normalized = name.toUpperCase(); + const normalized = platform === 'win32' ? name.toUpperCase() : name; return ( normalized.startsWith('LIBRECHAT_CODE_') || (!SAFE_CHILD_ENV_NAMES.has(normalized) && !normalized.startsWith('LC_')) @@ -199,6 +205,16 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox 'COMMAND_UNAVAILABLE', ); } + if (this.platform !== 'win32') { + try { + await access(this.options.shellPath ?? '/bin/bash', fsConstants.X_OK); + } catch { + throw new WorkspaceToolError( + `Native sandbox shell is unavailable: ${this.options.shellPath ?? '/bin/bash'}`, + 'COMMAND_UNAVAILABLE', + ); + } + } const config: SandboxRuntimeConfig = { network: { allowedDomains: [...(this.options.allowedDomains ?? [])], @@ -219,7 +235,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox path, mode: 'deny' as const, })), - envVars: safeEnvironmentNames(this.environment).map((name) => ({ + envVars: safeEnvironmentNames(this.environment, this.platform).map((name) => ({ name, mode: 'deny' as const, })), @@ -272,7 +288,9 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox try { wrapped = await this.manager.wrapWithSandboxArgv( request.command, - this.platform === 'win32' ? undefined : '/bin/bash', + this.platform === 'win32' + ? undefined + : this.options.shellPath ?? '/bin/bash', undefined, signal, cwd, From 3944cd4827b47ae6c21084af4de0c813e8446933 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 22:33:03 -0400 Subject: [PATCH 05/14] =?UTF-8?q?=F0=9F=88=B3=20feat:=20Create=20Workspace?= =?UTF-8?q?=20Files=20Only=20When=20Paths=20Are=20Vacant=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * šŸ›– feat: Run BYOM Commands in Native Sandboxes (#101) * feat(code): add native SRT command sandbox * fix(code): harden native SRT command lifecycle * fix(code): clarify native SRT containment * fix(code): close native sandbox review gaps * feat(code): Add atomic workspace creates * fix(code): negotiate durable create-only writes --- packages/code/README.md | 9 +- packages/code/src/protocol.test.ts | 37 +++++++ packages/code/src/protocol.ts | 29 +++++- packages/code/src/worker.ts | 29 +++++- packages/code/src/workspace-worker.test.ts | 74 ++++++++++++++ packages/code/src/workspace.test.ts | 108 +++++++++++++++++++++ packages/code/src/workspace.ts | 57 ++++++++++- service/src/bridge/router.test.ts | 1 + service/src/bridge/router.ts | 1 + service/src/bridge/store.ts | 11 ++- service/src/bridge/workspace-store.test.ts | 35 +++++++ 11 files changed, 379 insertions(+), 12 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index 3e8bf72d..256066e0 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -271,8 +271,13 @@ names and exposes bounded `read_file`, literal `search_text`, and deterministic can explicitly add confined `write_file` and exact-match `edit_file` operations with `--allow-workspace-writes` or `LIBRECHAT_CODE_ALLOW_WORKSPACE_WRITES=true`. -Only IDs, names, protocol version, and supported operations appear in worker -capabilities; absolute host paths remain local to the worker process. +`write_file` preserves its overwrite behavior by default; callers can set +`overwrite: false` to require an atomic create that returns `EDIT_CONFLICT` if +the target already exists. Code API dispatches that mode only after the worker +and server negotiate `create` in `writeFileModes`. +Only IDs, names, protocol version, supported operations, and negotiated write +modes appear in worker capabilities; absolute host paths remain local to the +worker process. The protocol also defines a bounded `execute_command` request and result for a sandbox-backed executor. Commands are treated as workspace mutations and cannot diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index 26a9928f..2ad6d7f0 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -70,6 +70,27 @@ test('bridge worker capabilities accept only bounded public workspace descriptor }; assert.equal(isValidBridgeWorkerCapabilities(valid), true); + assert.equal( + isValidBridgeWorkerCapabilities({ + ...valid, + workspaceTools: { + ...valid.workspaceTools, + operations: ['read_file', 'write_file'], + writeFileModes: ['replace', 'create'], + }, + }), + true, + ); + assert.equal( + isValidBridgeWorkerCapabilities({ + ...valid, + workspaceTools: { + ...valid.workspaceTools, + writeFileModes: ['create'], + }, + }), + false, + ); assert.equal( isValidBridgeWorkerCapabilities({ ...valid, @@ -156,8 +177,13 @@ test('workspace mutations accept bounded UTF-8 requests and exact result shapes' workspaceId: 'primary', path: 'notes.txt', content: 'hello', + overwrite: false, }; assert.equal(isWorkspaceToolRequest(writeRequest), true); + assert.equal( + isWorkspaceToolRequest({ ...writeRequest, overwrite: 'false' }), + false, + ); assert.equal( isWorkspaceToolRequest({ ...writeRequest, @@ -176,6 +202,17 @@ test('workspace mutations accept bounded UTF-8 requests and exact result shapes' }), true, ); + assert.equal( + isWorkspaceToolResult(writeRequest, { + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path: 'notes.txt', + created: false, + bytesWritten: 5, + }), + false, + ); const editRequest = { protocolVersion: 1 as const, diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 28e99f90..4598e346 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -30,6 +30,8 @@ export type BridgeWorkspaceToolOperation = | 'edit_file' | 'execute_command'; +export type WorkspaceWriteFileMode = 'replace' | 'create'; + export interface BridgeWorkspaceDescriptor { id: string; name?: string; @@ -41,6 +43,8 @@ export interface BridgeWorkspaceToolCapabilities { protocolVersion: BridgeProtocolVersion; operations: BridgeWorkspaceToolOperation[]; workspaces: BridgeWorkspaceDescriptor[]; + /** Omitted by legacy workers, which only accept replacement writes. */ + writeFileModes?: WorkspaceWriteFileMode[]; } export interface WorkspaceReadFileRequest { @@ -110,6 +114,8 @@ export interface WorkspaceWriteFileRequest { workspaceId: string; path: string; content: string; + /** False requires an atomic create and refuses to replace an existing file. */ + overwrite?: boolean; } export interface WorkspaceWriteFileResult { @@ -208,6 +214,7 @@ const WORKSPACE_WRITE_REQUEST_KEYS = new Set([ 'workspaceId', 'path', 'content', + 'overwrite', ]); const WORKSPACE_EDIT_REQUEST_KEYS = new Set([ 'protocolVersion', @@ -311,6 +318,8 @@ export interface BridgeWorkerRegistrationResponse { leaseTtlMs: number; /** Operations this Code API can dispatch after the worker advertises them. */ supportedWorkspaceToolOperations?: BridgeWorkspaceToolOperation[]; + /** Write modes this Code API can safely route to a capability-aware worker. */ + supportedWorkspaceWriteFileModes?: WorkspaceWriteFileMode[]; } export interface BridgePairingRedemption { @@ -557,7 +566,9 @@ export function isWorkspaceToolRequest( typeof request.content === 'string' && Buffer.from(request.content).toString('utf8') === request.content && new TextEncoder().encode(request.content).byteLength <= - BRIDGE_WORKSPACE_WRITE_MAX_BYTES + BRIDGE_WORKSPACE_WRITE_MAX_BYTES && + (request.overwrite === undefined || + typeof request.overwrite === 'boolean') ); } if (request.operation === 'edit_file') { @@ -681,6 +692,7 @@ export function isWorkspaceToolResult( hasOnlyKeys(result, WORKSPACE_WRITE_RESULT_KEYS) && result.path === request.path && typeof result.created === 'boolean' && + (request.overwrite !== false || result.created === true) && Number.isSafeInteger(result.bytesWritten) && Number(result.bytesWritten) === new TextEncoder().encode(request.content).byteLength @@ -779,6 +791,21 @@ export function isValidBridgeWorkspaceToolCapabilities( return false; } + if ( + capabilities.writeFileModes !== undefined && + (!Array.isArray(capabilities.writeFileModes) || + capabilities.writeFileModes.length < 1 || + capabilities.writeFileModes.length > 2 || + !capabilities.operations.includes('write_file') || + !capabilities.writeFileModes.every( + (mode) => mode === 'replace' || mode === 'create', + ) || + new Set(capabilities.writeFileModes).size !== + capabilities.writeFileModes.length) + ) { + return false; + } + const workspaceIds = new Set(); return capabilities.workspaces.every((workspace) => { if (typeof workspace !== 'object' || workspace === null) return false; diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 0b6f5c13..880c5b6d 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -137,6 +137,10 @@ function workspaceCapabilitiesMatch( advertised.operations.every( (operation, index) => operation === executor.operations[index], ) && + advertised.writeFileModes?.length === executor.writeFileModes?.length && + (advertised.writeFileModes?.every( + (mode, index) => mode === executor.writeFileModes?.[index], + ) ?? executor.writeFileModes == null) && advertised.workspaces.length === executor.workspaces.length && advertised.workspaces.every( (workspace, index) => @@ -191,10 +195,12 @@ function registrationCompatibleCapabilities( const { workspaceTools: _workspaceTools, ...compatible } = capabilities; return compatible; } + const { writeFileModes: _writeFileModes, ...compatibleWorkspaceTools } = + workspaceTools; return { ...capabilities, workspaceTools: { - ...workspaceTools, + ...compatibleWorkspaceTools, operations, workspaces, }, @@ -222,12 +228,19 @@ function supportedWorkspaceCapabilities( : [{ ...workspace, operations: workspaceOperations }]; }); if (workspaces.length === 0) return undefined; + const writeFileModes = desired.writeFileModes?.filter((mode) => + registration.supportedWorkspaceWriteFileModes?.includes(mode), + ); + const { writeFileModes: _writeFileModes, ...compatibleDesired } = desired; return { ...capabilities, workspaceTools: { - ...desired, + ...compatibleDesired, operations, workspaces, + ...(operations.includes('write_file') && writeFileModes?.length + ? { writeFileModes } + : {}), }, }; } @@ -875,6 +888,18 @@ export class BridgeWorker { 'Workspace tool operation is not advertised for workspace', ); } + if ( + workspaceRequest.operation === 'write_file' && + workspaceRequest.overwrite !== undefined + ) { + const mode = + workspaceRequest.overwrite === false ? 'create' : 'replace'; + if (!advertised.writeFileModes?.includes(mode)) { + throw new BridgeProtocolError( + 'Workspace write mode is not advertised', + ); + } + } const isMutation = workspaceRequest.operation === 'write_file' || workspaceRequest.operation === 'edit_file' || diff --git a/packages/code/src/workspace-worker.test.ts b/packages/code/src/workspace-worker.test.ts index c98182b0..485168c1 100644 --- a/packages/code/src/workspace-worker.test.ts +++ b/packages/code/src/workspace-worker.test.ts @@ -220,6 +220,7 @@ test('worker promotes only operations understood by an older Code API', async () const registrations: Array<{ operations: string[]; workspaces: Array>; + writeFileModes?: string[]; }> = []; const workspaceCapabilities = { protocolVersion: 1 as const, @@ -230,6 +231,7 @@ test('worker promotes only operations understood by an older Code API', async () 'write_file' as const, 'edit_file' as const, ], + writeFileModes: ['replace' as const, 'create' as const], workspaces: [ { id: 'primary', @@ -268,6 +270,7 @@ test('worker promotes only operations understood by an older Code API', async () workspaceTools: { operations: string[]; workspaces: Array>; + writeFileModes?: string[]; }; }; }; @@ -312,6 +315,7 @@ test('worker retains per-workspace restrictions during partial mutation promotio const registrations: Array<{ operations: string[]; workspaces: Array>; + writeFileModes?: string[]; }> = []; const workspaceCapabilities = { protocolVersion: 1 as const, @@ -322,6 +326,7 @@ test('worker retains per-workspace restrictions during partial mutation promotio 'write_file' as const, 'edit_file' as const, ], + writeFileModes: ['replace' as const, 'create' as const], workspaces: [ { id: 'readonly', @@ -368,6 +373,7 @@ test('worker retains per-workspace restrictions during partial mutation promotio workspaceTools: { operations: string[]; workspaces: Array>; + writeFileModes?: string[]; }; }; }; @@ -384,6 +390,7 @@ test('worker retains per-workspace restrictions during partial mutation promotio 'list_files', 'write_file', ], + supportedWorkspaceWriteFileModes: ['replace', 'create'], }); }, }); @@ -393,6 +400,7 @@ test('worker retains per-workspace restrictions during partial mutation promotio assert.deepEqual(registrations[1], { protocolVersion: 1, operations: ['read_file', 'search_text', 'list_files', 'write_file'], + writeFileModes: ['replace', 'create'], workspaces: [ { id: 'readonly', @@ -411,6 +419,72 @@ test('worker retains per-workspace restrictions during partial mutation promotio }); }); +test('worker omits write modes not negotiated by an older Code API', async () => { + const registrations: Array> = []; + const workspaceCapabilities = { + protocolVersion: 1 as const, + operations: ['read_file' as const, 'write_file' as const], + writeFileModes: ['replace' as const, 'create' as const], + workspaces: [ + { + id: 'primary', + operations: ['read_file' as const, 'write_file' as const], + }, + ], + }; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', + token: 'worker-secret', + workerId: 'vm-1', + incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: workspaceCapabilities, + }, + workspaceTools: { + capabilities: workspaceCapabilities, + async execute() { + throw new Error('not executed'); + }, + }, + workspaceMutationQuarantine: mutationQuarantine(), + fetchImpl: async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { + capabilities: { workspaceTools?: Record }; + }; + registrations.push(body.capabilities.workspaceTools ?? {}); + return Response.json({ + protocolVersion: 1, + workerId: 'vm-1', + incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60_000, + supportedWorkspaceToolOperations: ['read_file', 'write_file'], + }); + }, + }); + + await worker.register(); + + assert.deepEqual(registrations, [ + { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: 'primary' }], + }, + { + protocolVersion: 1, + operations: ['read_file', 'write_file'], + workspaces: [ + { id: 'primary', operations: ['read_file', 'write_file'] }, + ], + }, + ]); +}); + test('worker retains per-workspace restrictions during read-only promotion', async () => { const registrations: Array<{ operations: string[]; diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index e0cacb48..b249afe8 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -820,6 +820,7 @@ test('writable workspaces create, replace, and exactly edit files', async (t) => ], }, ], + writeFileModes: ['replace', 'create'], }); await tools.execute({ protocolVersion: 1, @@ -847,6 +848,76 @@ test('writable workspaces create, replace, and exactly edit files', async (t) => assert.equal(await readFile(join(root, 'notes.txt'), 'utf8'), 'hello BYOM'); }); +test('workspace writes can require an atomic create without replacement', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'existing.txt'), 'preserve me'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root, writable: true }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path: 'existing.txt', + content: 'replace me', + overwrite: false, + }), + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'EDIT_CONFLICT', + ); + assert.equal(await readFile(join(root, 'existing.txt'), 'utf8'), 'preserve me'); + + const created = await tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path: 'created.txt', + content: 'new file', + overwrite: false, + }); + assert.deepEqual(created, { + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path: 'created.txt', + created: true, + bytesWritten: 8, + }); + assert.equal(await readFile(join(root, 'created.txt'), 'utf8'), 'new file'); + + const competingWrites = await Promise.allSettled([ + tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path: 'raced.txt', + content: 'first', + overwrite: false, + }), + tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path: 'raced.txt', + content: 'second', + overwrite: false, + }), + ]); + assert.equal( + competingWrites.filter((result) => result.status === 'fulfilled').length, + 1, + ); + const rejected = competingWrites.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + assert.ok(rejected?.reason instanceof WorkspaceToolError); + assert.equal(rejected.reason.code, 'EDIT_CONFLICT'); + assert.match(await readFile(join(root, 'raced.txt'), 'utf8'), /^(first|second)$/); +}); + test('workspace mutations sync the containing directory after replacement', async (t) => { if (process.platform === 'win32') { t.skip('Directory fsync is unavailable on Windows'); @@ -889,6 +960,42 @@ test('workspace mutations sync the containing directory after replacement', asyn assert.equal(syncCalls, 5); }); +test('atomic creates remove staging before syncing the directory', async (t) => { + if (process.platform === 'win32') { + t.skip('Directory fsync is unavailable on Windows'); + return; + } + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root, writable: true }], + }); + const probe = await open(root, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(probe) as { + sync(): Promise; + }; + await probe.close(); + const originalSync = fileHandlePrototype.sync; + let syncCalls = 0; + t.mock.method(fileHandlePrototype, 'sync', async function (this: FileHandle) { + syncCalls += 1; + if (syncCalls === 2) { + assert.deepEqual(await readdir(root), ['created.txt']); + } + await originalSync.call(this); + }); + + await tools.execute({ + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path: 'created.txt', + content: 'durable create', + overwrite: false, + }); + assert.equal(syncCalls, 2); +}); + test('workspace mutations report uncertain commit when directory sync fails', async (t) => { if (process.platform === 'win32') { t.skip('Directory fsync is unavailable on Windows'); @@ -1591,6 +1698,7 @@ test('composes sandboxed commands without exposing them on unconfigured workspac 'edit_file', 'execute_command', ]); + assert.deepEqual(tools.capabilities.writeFileModes, ['replace', 'create']); assert.deepEqual( tools.capabilities.workspaces.find(({ id }) => id === 'sandboxed')?.operations, tools.capabilities.operations, diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 3f8ed7b6..dcfc4ab9 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { constants } from 'node:fs'; -import { lstat, open, realpath, rename, stat, unlink } from 'node:fs/promises'; +import { link, lstat, open, realpath, rename, stat, unlink } from 'node:fs/promises'; import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import type { FileHandle } from 'node:fs/promises'; @@ -455,6 +455,7 @@ async function atomicWriteConfinedFile( content: Buffer, signal?: AbortSignal, expected?: { dev: bigint | number; ino: bigint | number; content: Buffer }, + allowOverwrite = true, ): Promise<{ created: boolean }> { throwIfAborted(signal); if (content.byteLength > BRIDGE_WORKSPACE_WRITE_MAX_BYTES) { @@ -488,6 +489,12 @@ async function atomicWriteConfinedFile( if (existing?.isSymbolicLink() || (existing != null && !existing.isFile())) { throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); } + if (!allowOverwrite && existing != null) { + throw new WorkspaceToolError( + 'Workspace file already exists', + 'EDIT_CONFLICT', + ); + } if ( expected != null && (existing == null || @@ -506,6 +513,7 @@ async function atomicWriteConfinedFile( ); const installTarget = resolve(canonicalParent, basename(candidate)); let handle: FileHandle | undefined; + let temporaryNeedsCleanup = true; let staged: { dev: bigint | number; ino: bigint | number; content: Buffer }; try { handle = await open( @@ -588,10 +596,36 @@ async function atomicWriteConfinedFile( staged, signal, ); + temporaryNeedsCleanup = false; return { created: false }; } throwIfAborted(signal); - await rename(temporary, installTarget); + if (allowOverwrite) { + await rename(temporary, installTarget); + temporaryNeedsCleanup = false; + } else { + try { + await link(temporary, installTarget); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new WorkspaceToolError( + 'Workspace file already exists', + 'EDIT_CONFLICT', + ); + } + throw error; + } + try { + await unlink(temporary); + temporaryNeedsCleanup = false; + } catch { + throw new WorkspaceToolError( + 'Workspace create cleanup could not be confirmed', + 'WRITE_UNAVAILABLE', + true, + ); + } + } await confirmInstalledMutation(root, installTarget, staged); return { created: existing == null }; } catch (error) { @@ -602,7 +636,9 @@ async function atomicWriteConfinedFile( ); } finally { await handle?.close().catch(() => undefined); - await unlink(temporary).catch(() => undefined); + if (temporaryNeedsCleanup) { + await unlink(temporary).catch(() => undefined); + } } } @@ -617,6 +653,8 @@ async function writeWorkspaceFile( request.path, content, signal, + undefined, + request.overwrite !== false, ); return { protocolVersion: BRIDGE_PROTOCOL_VERSION, @@ -1298,11 +1336,13 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { private readonly roots: ReadonlyMap, operations: BridgeWorkspaceToolCapabilities['operations'], workspaces: BridgeWorkspaceDescriptor[], + writeFileModes?: BridgeWorkspaceToolCapabilities['writeFileModes'], ) { this.capabilities = { protocolVersion: BRIDGE_PROTOCOL_VERSION, operations, workspaces, + ...(writeFileModes != null ? { writeFileModes } : {}), }; } @@ -1335,6 +1375,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { protocolVersion: BRIDGE_PROTOCOL_VERSION, operations, workspaces, + ...(anyWritable ? { writeFileModes: ['replace', 'create'] } : {}), }; if (!isValidBridgeWorkspaceToolCapabilities(capabilities)) { throw new WorkspaceToolError( @@ -1358,7 +1399,12 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { writable: workspace.writable === true, }); } - return new LocalWorkspaceTools(roots, operations, workspaces); + return new LocalWorkspaceTools( + roots, + operations, + workspaces, + capabilities.writeFileModes, + ); } async execute( @@ -1482,6 +1528,9 @@ export class SandboxWorkspaceTools implements WorkspaceToolExecutor { this.capabilities = { protocolVersion: BRIDGE_PROTOCOL_VERSION, operations: [...new Set([...base.operations, 'execute_command' as const])], + ...(base.writeFileModes != null + ? { writeFileModes: base.writeFileModes } + : {}), workspaces: base.workspaces.map((workspace) => ({ ...workspace, operations: [ diff --git a/service/src/bridge/router.test.ts b/service/src/bridge/router.test.ts index 2c0d8998..3d421566 100644 --- a/service/src/bridge/router.test.ts +++ b/service/src/bridge/router.test.ts @@ -287,6 +287,7 @@ describe('paired bridge HTTP API', () => { 'edit_file', 'execute_command', ], + supportedWorkspaceWriteFileModes: ['replace', 'create'], }); const crossDeploymentRevoke = await fetch( diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index b764bb39..bb8413d7 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -387,6 +387,7 @@ router.post( 'edit_file', 'execute_command', ], + supportedWorkspaceWriteFileModes: ['replace', 'create'], }); } catch (error) { if (error instanceof BridgeStoreError) { diff --git a/service/src/bridge/store.ts b/service/src/bridge/store.ts index b0a5c2fa..361ef2b4 100644 --- a/service/src/bridge/store.ts +++ b/service/src/bridge/store.ts @@ -76,13 +76,18 @@ function supportsWorkspaceTool( const workspace = capabilities?.workspaces.find( (candidate) => candidate.id === request.workspaceId, ); - return ( + const supportsOperation = capabilities != null && capabilities.operations.includes(request.operation) && workspace != null && (workspace.operations == null || - workspace.operations.includes(request.operation)) - ); + workspace.operations.includes(request.operation)); + if (!supportsOperation || request.operation !== 'write_file') { + return supportsOperation; + } + if (request.overwrite === undefined) return true; + const mode = request.overwrite === false ? 'create' : 'replace'; + return capabilities?.writeFileModes?.includes(mode) === true; } function workerKey(workerId: string): string { diff --git a/service/src/bridge/workspace-store.test.ts b/service/src/bridge/workspace-store.test.ts index 44ab66fe..a6316413 100644 --- a/service/src/bridge/workspace-store.test.ts +++ b/service/src/bridge/workspace-store.test.ts @@ -143,6 +143,41 @@ test('rejects an operation omitted from the selected workspace capability', asyn expect(await redis.keys('codeapi:bridge:v1:assignment:*')).toHaveLength(0); }); +test('rejects create-only writes from workers without the negotiated mode', async () => { + await store.register({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'workspace-worker', + incarnationId, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['write_file'], + workspaces: [{ id: 'primary' }], + }, + }, + }); + + await expect( + store.dispatchWorkspaceTool({ + workerId: 'workspace-worker', + request: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'write_file', + workspaceId: 'primary', + path: 'notes.txt', + content: 'create me', + overwrite: false, + }, + deadlineAtMs: Date.now() + 1_000, + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ code: 'WORKER_MISMATCH' }); + expect(await redis.keys('codeapi:bridge:v1:assignment:*')).toHaveLength(0); +}); + test('rejects a fulfilled workspace settlement that violates the result contract', async () => { await store.register({ protocolVersion: BRIDGE_PROTOCOL_VERSION, From db1545c535c1c8d61923b200daaa3423bb0ab458 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 22:53:34 -0400 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=A7=BA=20feat:=20Commit=20Ordered?= =?UTF-8?q?=20Workspace=20Edit=20Batches=20Atomically=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(code): Apply workspace edit batches atomically * fix(code): negotiate atomic edit batches --- packages/code/README.md | 4 + packages/code/src/protocol.test.ts | 93 +++++++++++++++++ packages/code/src/protocol.ts | 113 ++++++++++++++++++--- packages/code/src/worker.ts | 35 ++++++- packages/code/src/workspace-worker.test.ts | 66 ++++++++++++ packages/code/src/workspace.test.ts | 47 +++++++++ packages/code/src/workspace.ts | 41 +++++--- service/src/bridge/router.test.ts | 1 + service/src/bridge/router.ts | 1 + service/src/bridge/store.ts | 16 ++- service/src/bridge/workspace-store.test.ts | 34 +++++++ 11 files changed, 418 insertions(+), 33 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index 256066e0..6f53c72d 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -275,6 +275,10 @@ with `--allow-workspace-writes` or `overwrite: false` to require an atomic create that returns `EDIT_CONFLICT` if the target already exists. Code API dispatches that mode only after the worker and server negotiate `create` in `writeFileModes`. +`edit_file` accepts either the legacy `oldText`/`newText` pair or an ordered +`edits` array; every exact replacement is validated before the updated file is +installed as one atomic mutation. Code API dispatches the batch form only after +the worker and server negotiate `batch` in `editFileModes`. Only IDs, names, protocol version, supported operations, and negotiated write modes appear in worker capabilities; absolute host paths remain local to the worker process. diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index 2ad6d7f0..d5e0de3e 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -7,6 +7,37 @@ import { isWorkspaceToolRequest, isWorkspaceToolResult, } from './protocol.js'; +import type { WorkspaceEditFileRequest } from './protocol.js'; + +const validSingleEditRequest: WorkspaceEditFileRequest = { + protocolVersion: 1, + operation: 'edit_file', + workspaceId: 'primary', + path: 'notes.txt', + oldText: 'before', + newText: 'after', +}; +const validBatchEditRequest: WorkspaceEditFileRequest = { + protocolVersion: 1, + operation: 'edit_file', + workspaceId: 'primary', + path: 'notes.txt', + edits: [{ oldText: 'before', newText: 'after' }], +}; +// @ts-expect-error An edit request must choose a complete single or batch form. +const invalidEmptyEditRequest: WorkspaceEditFileRequest = { + protocolVersion: 1, + operation: 'edit_file', + workspaceId: 'primary', + path: 'notes.txt', +}; +// @ts-expect-error Single and batch edit forms are mutually exclusive. +const invalidMixedEditRequest: WorkspaceEditFileRequest = { + ...validSingleEditRequest, + edits: validBatchEditRequest.edits, +}; +void invalidEmptyEditRequest; +void invalidMixedEditRequest; test('bridgeWorkerPath encodes worker-controlled path segments', () => { assert.equal( @@ -81,6 +112,27 @@ test('bridge worker capabilities accept only bounded public workspace descriptor }), true, ); + assert.equal( + isValidBridgeWorkerCapabilities({ + ...valid, + workspaceTools: { + ...valid.workspaceTools, + operations: ['read_file', 'edit_file'], + editFileModes: ['single', 'batch'], + }, + }), + true, + ); + assert.equal( + isValidBridgeWorkerCapabilities({ + ...valid, + workspaceTools: { + ...valid.workspaceTools, + editFileModes: ['batch'], + }, + }), + false, + ); assert.equal( isValidBridgeWorkerCapabilities({ ...valid, @@ -235,6 +287,47 @@ test('workspace mutations accept bounded UTF-8 requests and exact result shapes' }), true, ); + const batchEditRequest = { + protocolVersion: 1 as const, + operation: 'edit_file' as const, + workspaceId: 'primary', + path: 'notes.txt', + edits: [ + { oldText: 'hello', newText: 'goodbye' }, + { oldText: 'world', newText: 'BYOM' }, + ], + }; + assert.equal(isWorkspaceToolRequest(batchEditRequest), true); + assert.equal( + isWorkspaceToolRequest({ ...batchEditRequest, oldText: 'mixed' }), + false, + ); + assert.equal(isWorkspaceToolRequest({ ...batchEditRequest, edits: [] }), false); + assert.equal( + isWorkspaceToolRequest({ + ...batchEditRequest, + edits: Array.from({ length: 101 }, () => ({ oldText: 'a', newText: 'b' })), + }), + false, + ); + assert.equal( + isWorkspaceToolRequest({ + ...batchEditRequest, + edits: [{ oldText: 'a'.repeat(600_000), newText: 'b'.repeat(600_000) }], + }), + false, + ); + assert.equal( + isWorkspaceToolResult(batchEditRequest, { + protocolVersion: 1, + operation: 'edit_file', + workspaceId: 'primary', + path: 'notes.txt', + replacements: 2, + bytesWritten: 12, + }), + true, + ); }); test('workspace commands require bounded sandbox inputs and outputs', () => { diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 4598e346..206d83b5 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -9,6 +9,7 @@ export const BRIDGE_WORKSPACE_PATH_MAX_LENGTH = 4096; export const BRIDGE_WORKSPACE_QUERY_MAX_LENGTH = 4096; export const BRIDGE_WORKSPACE_READ_MAX_BYTES = 1024 * 1024; export const BRIDGE_WORKSPACE_WRITE_MAX_BYTES = 1024 * 1024; +export const BRIDGE_WORKSPACE_EDIT_MAX_EDITS = 100; export const BRIDGE_WORKSPACE_READ_MAX_LINES = 500; export const BRIDGE_WORKSPACE_SEARCH_MAX_RESULTS = 200; export const BRIDGE_WORKSPACE_SEARCH_TEXT_MAX_LENGTH = 2000; @@ -31,6 +32,7 @@ export type BridgeWorkspaceToolOperation = | 'execute_command'; export type WorkspaceWriteFileMode = 'replace' | 'create'; +export type WorkspaceEditFileMode = 'single' | 'batch'; export interface BridgeWorkspaceDescriptor { id: string; @@ -45,6 +47,8 @@ export interface BridgeWorkspaceToolCapabilities { workspaces: BridgeWorkspaceDescriptor[]; /** Omitted by legacy workers, which only accept replacement writes. */ writeFileModes?: WorkspaceWriteFileMode[]; + /** Omitted by legacy workers, which only accept single exact replacements. */ + editFileModes?: WorkspaceEditFileMode[]; } export interface WorkspaceReadFileRequest { @@ -127,11 +131,35 @@ export interface WorkspaceWriteFileResult { bytesWritten: number; } -export interface WorkspaceEditFileRequest { +interface WorkspaceEditFileRequestBase { protocolVersion: BridgeProtocolVersion; operation: 'edit_file'; workspaceId: string; path: string; +} + +export interface WorkspaceSingleEditFileRequest + extends WorkspaceEditFileRequestBase { + /** Legacy single-edit form. */ + oldText: string; + /** Legacy single-edit form. */ + newText: string; + edits?: never; +} + +export interface WorkspaceBatchEditFileRequest + extends WorkspaceEditFileRequestBase { + /** Ordered exact replacements applied atomically as one file mutation. */ + edits: WorkspaceTextEdit[]; + oldText?: never; + newText?: never; +} + +export type WorkspaceEditFileRequest = + | WorkspaceSingleEditFileRequest + | WorkspaceBatchEditFileRequest; + +export interface WorkspaceTextEdit { oldText: string; newText: string; } @@ -141,7 +169,7 @@ export interface WorkspaceEditFileResult { operation: 'edit_file'; workspaceId: string; path: string; - replacements: 1; + replacements: number; bytesWritten: number; } @@ -223,7 +251,9 @@ const WORKSPACE_EDIT_REQUEST_KEYS = new Set([ 'path', 'oldText', 'newText', + 'edits', ]); +const WORKSPACE_TEXT_EDIT_KEYS = new Set(['oldText', 'newText']); const WORKSPACE_COMMAND_REQUEST_KEYS = new Set([ 'protocolVersion', 'operation', @@ -320,6 +350,8 @@ export interface BridgeWorkerRegistrationResponse { supportedWorkspaceToolOperations?: BridgeWorkspaceToolOperation[]; /** Write modes this Code API can safely route to a capability-aware worker. */ supportedWorkspaceWriteFileModes?: WorkspaceWriteFileMode[]; + /** Edit modes this Code API can safely route to a capability-aware worker. */ + supportedWorkspaceEditFileModes?: WorkspaceEditFileMode[]; } export interface BridgePairingRedemption { @@ -496,6 +528,55 @@ function isWithinRequestedPath(candidate: string, requested?: string): boolean { ); } +function isValidWorkspaceEditRequest(request: Record): boolean { + const hasBatch = request.edits !== undefined; + if (hasBatch && (request.oldText !== undefined || request.newText !== undefined)) { + return false; + } + const edits = hasBatch + ? request.edits + : [{ oldText: request.oldText, newText: request.newText }]; + if ( + !Array.isArray(edits) || + edits.length < 1 || + edits.length > BRIDGE_WORKSPACE_EDIT_MAX_EDITS + ) { + return false; + } + let totalBytes = 0; + for (const edit of edits) { + if ( + typeof edit !== 'object' || + edit === null || + !hasOnlyKeys(edit as Record, WORKSPACE_TEXT_EDIT_KEYS) + ) { + return false; + } + const candidate = edit as Record; + if ( + typeof candidate.oldText !== 'string' || + candidate.oldText.length === 0 || + Buffer.from(candidate.oldText).toString('utf8') !== candidate.oldText || + typeof candidate.newText !== 'string' || + Buffer.from(candidate.newText).toString('utf8') !== candidate.newText + ) { + return false; + } + const oldBytes = new TextEncoder().encode(candidate.oldText).byteLength; + const newBytes = new TextEncoder().encode(candidate.newText).byteLength; + totalBytes += oldBytes + newBytes; + if ( + (hasBatch && totalBytes > BRIDGE_WORKSPACE_WRITE_MAX_BYTES) || + (!hasBatch && + (oldBytes > BRIDGE_WORKSPACE_WRITE_MAX_BYTES || + newBytes > BRIDGE_WORKSPACE_WRITE_MAX_BYTES)) + ) { + return false; + } + } + return true; +} + function hasOnlyKeys( value: Record, allowed: ReadonlySet, @@ -575,15 +656,7 @@ export function isWorkspaceToolRequest( return ( hasOnlyKeys(request, WORKSPACE_EDIT_REQUEST_KEYS) && isSafePortableRelativePath(request.path) && - typeof request.oldText === 'string' && - request.oldText.length > 0 && - Buffer.from(request.oldText).toString('utf8') === request.oldText && - new TextEncoder().encode(request.oldText).byteLength <= - BRIDGE_WORKSPACE_WRITE_MAX_BYTES && - typeof request.newText === 'string' && - Buffer.from(request.newText).toString('utf8') === request.newText && - new TextEncoder().encode(request.newText).byteLength <= - BRIDGE_WORKSPACE_WRITE_MAX_BYTES + isValidWorkspaceEditRequest(request) ); } if (request.operation === 'execute_command') { @@ -700,10 +773,11 @@ export function isWorkspaceToolResult( } if (request.operation === 'edit_file') { + const replacements = request.edits?.length ?? 1; return ( hasOnlyKeys(result, WORKSPACE_EDIT_RESULT_KEYS) && result.path === request.path && - result.replacements === 1 && + result.replacements === replacements && Number.isSafeInteger(result.bytesWritten) && Number(result.bytesWritten) >= 0 && Number(result.bytesWritten) <= BRIDGE_WORKSPACE_WRITE_MAX_BYTES @@ -806,6 +880,21 @@ export function isValidBridgeWorkspaceToolCapabilities( return false; } + if ( + capabilities.editFileModes !== undefined && + (!Array.isArray(capabilities.editFileModes) || + capabilities.editFileModes.length < 1 || + capabilities.editFileModes.length > 2 || + !capabilities.operations.includes('edit_file') || + !capabilities.editFileModes.every( + (mode) => mode === 'single' || mode === 'batch', + ) || + new Set(capabilities.editFileModes).size !== + capabilities.editFileModes.length) + ) { + return false; + } + const workspaceIds = new Set(); return capabilities.workspaces.every((workspace) => { if (typeof workspace !== 'object' || workspace === null) return false; diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 880c5b6d..05c3a397 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -141,6 +141,10 @@ function workspaceCapabilitiesMatch( (advertised.writeFileModes?.every( (mode, index) => mode === executor.writeFileModes?.[index], ) ?? executor.writeFileModes == null) && + advertised.editFileModes?.length === executor.editFileModes?.length && + (advertised.editFileModes?.every( + (mode, index) => mode === executor.editFileModes?.[index], + ) ?? executor.editFileModes == null) && advertised.workspaces.length === executor.workspaces.length && advertised.workspaces.every( (workspace, index) => @@ -195,8 +199,11 @@ function registrationCompatibleCapabilities( const { workspaceTools: _workspaceTools, ...compatible } = capabilities; return compatible; } - const { writeFileModes: _writeFileModes, ...compatibleWorkspaceTools } = - workspaceTools; + const { + writeFileModes: _writeFileModes, + editFileModes: _editFileModes, + ...compatibleWorkspaceTools + } = workspaceTools; return { ...capabilities, workspaceTools: { @@ -231,7 +238,14 @@ function supportedWorkspaceCapabilities( const writeFileModes = desired.writeFileModes?.filter((mode) => registration.supportedWorkspaceWriteFileModes?.includes(mode), ); - const { writeFileModes: _writeFileModes, ...compatibleDesired } = desired; + const editFileModes = desired.editFileModes?.filter((mode) => + registration.supportedWorkspaceEditFileModes?.includes(mode), + ); + const { + writeFileModes: _writeFileModes, + editFileModes: _editFileModes, + ...compatibleDesired + } = desired; return { ...capabilities, workspaceTools: { @@ -241,6 +255,9 @@ function supportedWorkspaceCapabilities( ...(operations.includes('write_file') && writeFileModes?.length ? { writeFileModes } : {}), + ...(operations.includes('edit_file') && editFileModes?.length + ? { editFileModes } + : {}), }, }; } @@ -900,6 +917,18 @@ export class BridgeWorker { ); } } + if (workspaceRequest.operation === 'edit_file') { + const mode = workspaceRequest.edits === undefined ? 'single' : 'batch'; + const modes = advertised.editFileModes; + if ( + (modes == null && mode !== 'single') || + (modes != null && !modes.includes(mode)) + ) { + throw new BridgeProtocolError( + 'Workspace edit mode is not advertised', + ); + } + } const isMutation = workspaceRequest.operation === 'write_file' || workspaceRequest.operation === 'edit_file' || diff --git a/packages/code/src/workspace-worker.test.ts b/packages/code/src/workspace-worker.test.ts index 485168c1..1c5df7da 100644 --- a/packages/code/src/workspace-worker.test.ts +++ b/packages/code/src/workspace-worker.test.ts @@ -485,6 +485,72 @@ test('worker omits write modes not negotiated by an older Code API', async () => ]); }); +test('worker omits batch edits not negotiated by an older Code API', async () => { + const registrations: Array> = []; + const workspaceCapabilities = { + protocolVersion: 1 as const, + operations: ['read_file' as const, 'edit_file' as const], + editFileModes: ['single' as const, 'batch' as const], + workspaces: [ + { + id: 'primary', + operations: ['read_file' as const, 'edit_file' as const], + }, + ], + }; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', + token: 'worker-secret', + workerId: 'vm-1', + incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: workspaceCapabilities, + }, + workspaceTools: { + capabilities: workspaceCapabilities, + async execute() { + throw new Error('not executed'); + }, + }, + workspaceMutationQuarantine: mutationQuarantine(), + fetchImpl: async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { + capabilities: { workspaceTools?: Record }; + }; + registrations.push(body.capabilities.workspaceTools ?? {}); + return Response.json({ + protocolVersion: 1, + workerId: 'vm-1', + incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60_000, + supportedWorkspaceToolOperations: ['read_file', 'edit_file'], + }); + }, + }); + + await worker.register(); + + assert.deepEqual(registrations, [ + { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: 'primary' }], + }, + { + protocolVersion: 1, + operations: ['read_file', 'edit_file'], + workspaces: [ + { id: 'primary', operations: ['read_file', 'edit_file'] }, + ], + }, + ]); +}); + test('worker retains per-workspace restrictions during read-only promotion', async () => { const registrations: Array<{ operations: string[]; diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index b249afe8..6e67edfa 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -821,6 +821,7 @@ test('writable workspaces create, replace, and exactly edit files', async (t) => }, ], writeFileModes: ['replace', 'create'], + editFileModes: ['single', 'batch'], }); await tools.execute({ protocolVersion: 1, @@ -918,6 +919,51 @@ test('workspace writes can require an atomic create without replacement', async assert.match(await readFile(join(root, 'raced.txt'), 'utf8'), /^(first|second)$/); }); +test('workspace batch edits commit all replacements atomically', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'batch.txt'), 'alpha beta gamma'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root, writable: true }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'edit_file', + workspaceId: 'primary', + path: 'batch.txt', + edits: [ + { oldText: 'alpha', newText: 'one' }, + { oldText: 'gamma', newText: 'three' }, + ], + }); + assert.deepEqual(result, { + protocolVersion: 1, + operation: 'edit_file', + workspaceId: 'primary', + path: 'batch.txt', + replacements: 2, + bytesWritten: 14, + }); + assert.equal(await readFile(join(root, 'batch.txt'), 'utf8'), 'one beta three'); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'edit_file', + workspaceId: 'primary', + path: 'batch.txt', + edits: [ + { oldText: 'one', newText: 'partial' }, + { oldText: 'missing', newText: 'never' }, + ], + }), + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'EDIT_CONFLICT', + ); + assert.equal(await readFile(join(root, 'batch.txt'), 'utf8'), 'one beta three'); +}); + test('workspace mutations sync the containing directory after replacement', async (t) => { if (process.platform === 'win32') { t.skip('Directory fsync is unavailable on Windows'); @@ -1699,6 +1745,7 @@ test('composes sandboxed commands without exposing them on unconfigured workspac 'execute_command', ]); assert.deepEqual(tools.capabilities.writeFileModes, ['replace', 'create']); + assert.deepEqual(tools.capabilities.editFileModes, ['single', 'batch']); assert.deepEqual( tools.capabilities.workspaces.find(({ id }) => id === 'sandboxed')?.operations, tools.capabilities.operations, diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index dcfc4ab9..f1e00d17 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -708,20 +708,26 @@ async function editWorkspaceFile( 'INVALID_REQUEST', ); } - const first = text.indexOf(request.oldText); - if ( - first < 0 || - text.indexOf(request.oldText, first + 1) >= 0 - ) { - throw new WorkspaceToolError( - 'Workspace edit must match exactly once', - 'EDIT_CONFLICT', - ); + const edits = request.edits ?? [ + { oldText: request.oldText ?? '', newText: request.newText ?? '' }, + ]; + let updatedText = text; + for (const edit of edits) { + const first = updatedText.indexOf(edit.oldText); + if ( + first < 0 || + updatedText.indexOf(edit.oldText, first + 1) >= 0 + ) { + throw new WorkspaceToolError( + 'Workspace edit must match exactly once', + 'EDIT_CONFLICT', + ); + } + updatedText = + updatedText.slice(0, first) + + edit.newText + + updatedText.slice(first + edit.oldText.length); } - const updatedText = - text.slice(0, first) + - request.newText + - text.slice(first + request.oldText.length); const updatedBody = Buffer.from(updatedText, 'utf8'); const updated = hasBom ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), updatedBody]) @@ -742,7 +748,7 @@ async function editWorkspaceFile( operation: 'edit_file', workspaceId: request.workspaceId, path: request.path, - replacements: 1, + replacements: edits.length, bytesWritten: updated.byteLength, }; } catch (error) { @@ -1337,12 +1343,14 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { operations: BridgeWorkspaceToolCapabilities['operations'], workspaces: BridgeWorkspaceDescriptor[], writeFileModes?: BridgeWorkspaceToolCapabilities['writeFileModes'], + editFileModes?: BridgeWorkspaceToolCapabilities['editFileModes'], ) { this.capabilities = { protocolVersion: BRIDGE_PROTOCOL_VERSION, operations, workspaces, ...(writeFileModes != null ? { writeFileModes } : {}), + ...(editFileModes != null ? { editFileModes } : {}), }; } @@ -1376,6 +1384,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { operations, workspaces, ...(anyWritable ? { writeFileModes: ['replace', 'create'] } : {}), + ...(anyWritable ? { editFileModes: ['single', 'batch'] } : {}), }; if (!isValidBridgeWorkspaceToolCapabilities(capabilities)) { throw new WorkspaceToolError( @@ -1404,6 +1413,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { operations, workspaces, capabilities.writeFileModes, + capabilities.editFileModes, ); } @@ -1531,6 +1541,9 @@ export class SandboxWorkspaceTools implements WorkspaceToolExecutor { ...(base.writeFileModes != null ? { writeFileModes: base.writeFileModes } : {}), + ...(base.editFileModes != null + ? { editFileModes: base.editFileModes } + : {}), workspaces: base.workspaces.map((workspace) => ({ ...workspace, operations: [ diff --git a/service/src/bridge/router.test.ts b/service/src/bridge/router.test.ts index 3d421566..61278aa4 100644 --- a/service/src/bridge/router.test.ts +++ b/service/src/bridge/router.test.ts @@ -288,6 +288,7 @@ describe('paired bridge HTTP API', () => { 'execute_command', ], supportedWorkspaceWriteFileModes: ['replace', 'create'], + supportedWorkspaceEditFileModes: ['single', 'batch'], }); const crossDeploymentRevoke = await fetch( diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index bb8413d7..a073491f 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -388,6 +388,7 @@ router.post( 'execute_command', ], supportedWorkspaceWriteFileModes: ['replace', 'create'], + supportedWorkspaceEditFileModes: ['single', 'batch'], }); } catch (error) { if (error instanceof BridgeStoreError) { diff --git a/service/src/bridge/store.ts b/service/src/bridge/store.ts index 361ef2b4..8b180e39 100644 --- a/service/src/bridge/store.ts +++ b/service/src/bridge/store.ts @@ -82,12 +82,20 @@ function supportsWorkspaceTool( workspace != null && (workspace.operations == null || workspace.operations.includes(request.operation)); - if (!supportsOperation || request.operation !== 'write_file') { + if (!supportsOperation) { return supportsOperation; } - if (request.overwrite === undefined) return true; - const mode = request.overwrite === false ? 'create' : 'replace'; - return capabilities?.writeFileModes?.includes(mode) === true; + if (request.operation === 'write_file') { + if (request.overwrite === undefined) return true; + const mode = request.overwrite === false ? 'create' : 'replace'; + return capabilities?.writeFileModes?.includes(mode) === true; + } + if (request.operation === 'edit_file') { + const mode = request.edits === undefined ? 'single' : 'batch'; + const modes = capabilities?.editFileModes; + return modes == null ? mode === 'single' : modes.includes(mode); + } + return true; } function workerKey(workerId: string): string { diff --git a/service/src/bridge/workspace-store.test.ts b/service/src/bridge/workspace-store.test.ts index a6316413..82124548 100644 --- a/service/src/bridge/workspace-store.test.ts +++ b/service/src/bridge/workspace-store.test.ts @@ -178,6 +178,40 @@ test('rejects create-only writes from workers without the negotiated mode', asyn expect(await redis.keys('codeapi:bridge:v1:assignment:*')).toHaveLength(0); }); +test('rejects batch edits from workers without the negotiated mode', async () => { + await store.register({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'workspace-worker', + incarnationId, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['edit_file'], + workspaces: [{ id: 'primary' }], + }, + }, + }); + + await expect( + store.dispatchWorkspaceTool({ + workerId: 'workspace-worker', + request: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'edit_file', + workspaceId: 'primary', + path: 'notes.txt', + edits: [{ oldText: 'before', newText: 'after' }], + }, + deadlineAtMs: Date.now() + 1_000, + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ code: 'WORKER_MISMATCH' }); + expect(await redis.keys('codeapi:bridge:v1:assignment:*')).toHaveLength(0); +}); + test('rejects a fulfilled workspace settlement that violates the result contract', async () => { await store.register({ protocolVersion: BRIDGE_PROTOCOL_VERSION, From 36403c21f2ddc160e527222705f7e7d3cfad7eb0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 11:54:18 -0400 Subject: [PATCH 07/14] feat(code): Fence inspected workspace edits --- packages/code/src/protocol.test.ts | 32 ++++++ packages/code/src/protocol.ts | 77 ++++++++++++++- packages/code/src/workspace-cli.test.ts | 3 + packages/code/src/workspace.test.ts | 41 ++++++++ packages/code/src/workspace.ts | 124 +++++++++++++++++------- service/src/bridge/router.test.ts | 1 + service/src/bridge/router.ts | 1 + 7 files changed, 241 insertions(+), 38 deletions(-) diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index d5e0de3e..f0c41dbf 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -328,6 +328,38 @@ test('workspace mutations accept bounded UTF-8 requests and exact result shapes' }), true, ); + const previewRequest = { + ...batchEditRequest, + operation: 'preview_edit' as const, + }; + assert.equal(isWorkspaceToolRequest(previewRequest), true); + assert.equal( + isWorkspaceToolResult(previewRequest, { + protocolVersion: 1, + operation: 'preview_edit', + workspaceId: 'primary', + path: 'notes.txt', + content: 'goodbye BYOM', + baseSha256: 'a'.repeat(64), + replacements: 2, + bytesWritten: 12, + }), + true, + ); + assert.equal( + isWorkspaceToolRequest({ + ...editRequest, + expectedBaseSha256: 'b'.repeat(64), + }), + true, + ); + assert.equal( + isWorkspaceToolRequest({ + ...editRequest, + expectedBaseSha256: 'not-a-sha', + }), + false, + ); }); test('workspace commands require bounded sandbox inputs and outputs', () => { diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 206d83b5..d1d6577f 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -28,6 +28,7 @@ export type BridgeWorkspaceToolOperation = | 'search_text' | 'list_files' | 'write_file' + | 'preview_edit' | 'edit_file' | 'execute_command'; @@ -136,6 +137,8 @@ interface WorkspaceEditFileRequestBase { operation: 'edit_file'; workspaceId: string; path: string; + /** Refuses the mutation unless current file bytes match this preview revision. */ + expectedBaseSha256?: string; } export interface WorkspaceSingleEditFileRequest @@ -173,6 +176,27 @@ export interface WorkspaceEditFileResult { bytesWritten: number; } +export interface WorkspacePreviewEditRequest { + protocolVersion: BridgeProtocolVersion; + operation: 'preview_edit'; + workspaceId: string; + path: string; + oldText?: string; + newText?: string; + edits?: WorkspaceTextEdit[]; +} + +export interface WorkspacePreviewEditResult { + protocolVersion: BridgeProtocolVersion; + operation: 'preview_edit'; + workspaceId: string; + path: string; + content: string; + baseSha256: string; + replacements: number; + bytesWritten: number; +} + export interface WorkspaceExecuteCommandRequest { protocolVersion: BridgeProtocolVersion; operation: 'execute_command'; @@ -203,6 +227,7 @@ export type WorkspaceToolRequest = | WorkspaceSearchTextRequest | WorkspaceListFilesRequest | WorkspaceWriteFileRequest + | WorkspacePreviewEditRequest | WorkspaceEditFileRequest | WorkspaceExecuteCommandRequest; export type WorkspaceToolResult = @@ -210,6 +235,7 @@ export type WorkspaceToolResult = | WorkspaceSearchTextResult | WorkspaceListFilesResult | WorkspaceWriteFileResult + | WorkspacePreviewEditResult | WorkspaceEditFileResult | WorkspaceExecuteCommandResult; @@ -252,6 +278,16 @@ const WORKSPACE_EDIT_REQUEST_KEYS = new Set([ 'oldText', 'newText', 'edits', + 'expectedBaseSha256', +]); +const WORKSPACE_PREVIEW_EDIT_REQUEST_KEYS = new Set([ + 'protocolVersion', + 'operation', + 'workspaceId', + 'path', + 'oldText', + 'newText', + 'edits', ]); const WORKSPACE_TEXT_EDIT_KEYS = new Set(['oldText', 'newText']); const WORKSPACE_COMMAND_REQUEST_KEYS = new Set([ @@ -304,6 +340,16 @@ const WORKSPACE_EDIT_RESULT_KEYS = new Set([ 'replacements', 'bytesWritten', ]); +const WORKSPACE_PREVIEW_EDIT_RESULT_KEYS = new Set([ + 'protocolVersion', + 'operation', + 'workspaceId', + 'path', + 'content', + 'baseSha256', + 'replacements', + 'bytesWritten', +]); const WORKSPACE_COMMAND_RESULT_KEYS = new Set([ 'protocolVersion', 'operation', @@ -652,10 +698,20 @@ export function isWorkspaceToolRequest( typeof request.overwrite === 'boolean') ); } + if (request.operation === 'preview_edit') { + return ( + hasOnlyKeys(request, WORKSPACE_PREVIEW_EDIT_REQUEST_KEYS) && + isSafePortableRelativePath(request.path) && + isValidWorkspaceEditRequest(request) + ); + } if (request.operation === 'edit_file') { return ( hasOnlyKeys(request, WORKSPACE_EDIT_REQUEST_KEYS) && isSafePortableRelativePath(request.path) && + (request.expectedBaseSha256 === undefined || + (typeof request.expectedBaseSha256 === 'string' && + /^[a-f0-9]{64}$/.test(request.expectedBaseSha256))) && isValidWorkspaceEditRequest(request) ); } @@ -784,6 +840,24 @@ export function isWorkspaceToolResult( ); } + if (request.operation === 'preview_edit') { + const replacements = request.edits?.length ?? 1; + const content = typeof result.content === 'string' ? result.content : null; + return ( + hasOnlyKeys(result, WORKSPACE_PREVIEW_EDIT_RESULT_KEYS) && + result.path === request.path && + content !== null && + Buffer.from(content).toString('utf8') === content && + typeof result.baseSha256 === 'string' && + /^[a-f0-9]{64}$/.test(result.baseSha256) && + result.replacements === replacements && + Number.isSafeInteger(result.bytesWritten) && + Number(result.bytesWritten) === + new TextEncoder().encode(content).byteLength && + Number(result.bytesWritten) <= BRIDGE_WORKSPACE_WRITE_MAX_BYTES + ); + } + if (request.operation === 'execute_command') { const stdout = typeof result.stdout === 'string' ? result.stdout : null; const stderr = typeof result.stderr === 'string' ? result.stderr : null; @@ -847,13 +921,14 @@ export function isValidBridgeWorkspaceToolCapabilities( capabilities.protocolVersion !== BRIDGE_PROTOCOL_VERSION || !Array.isArray(capabilities.operations) || capabilities.operations.length < 1 || - capabilities.operations.length > 6 || + capabilities.operations.length > 7 || !capabilities.operations.every( (operation) => operation === 'read_file' || operation === 'search_text' || operation === 'list_files' || operation === 'write_file' || + operation === 'preview_edit' || operation === 'edit_file' || operation === 'execute_command', ) || diff --git a/packages/code/src/workspace-cli.test.ts b/packages/code/src/workspace-cli.test.ts index 169bdf50..640b7379 100644 --- a/packages/code/src/workspace-cli.test.ts +++ b/packages/code/src/workspace-cli.test.ts @@ -151,6 +151,7 @@ test('CLI advertises explicitly enabled writes without exposing the workspace ro 'search_text', 'list_files', 'write_file', + 'preview_edit', 'edit_file', ], }), @@ -215,6 +216,7 @@ test('CLI advertises explicitly enabled writes without exposing the workspace ro 'search_text', 'list_files', 'write_file', + 'preview_edit', 'edit_file', ], workspaces: [ @@ -226,6 +228,7 @@ test('CLI advertises explicitly enabled writes without exposing the workspace ro 'search_text', 'list_files', 'write_file', + 'preview_edit', 'edit_file', ], }, diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 6e67edfa..6ce2f9bc 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -805,6 +805,7 @@ test('writable workspaces create, replace, and exactly edit files', async (t) => 'search_text', 'list_files', 'write_file', + 'preview_edit', 'edit_file', ], workspaces: [ @@ -816,6 +817,7 @@ test('writable workspaces create, replace, and exactly edit files', async (t) => 'search_text', 'list_files', 'write_file', + 'preview_edit', 'edit_file', ], }, @@ -964,6 +966,44 @@ test('workspace batch edits commit all replacements atomically', async (t) => { assert.equal(await readFile(join(root, 'batch.txt'), 'utf8'), 'one beta three'); }); +test('workspace edit previews are non-mutating and fence the commit revision', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'preview.txt'), 'prefix SEC suffix'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root, writable: true }], + }); + + const preview = await tools.execute({ + protocolVersion: 1, + operation: 'preview_edit', + workspaceId: 'primary', + path: 'preview.txt', + oldText: ' suffix', + newText: 'RET suffix', + }); + assert.equal(preview.operation, 'preview_edit'); + assert.equal(preview.content, 'prefix SECRET suffix'); + assert.match(preview.baseSha256, /^[a-f0-9]{64}$/); + assert.equal(await readFile(join(root, 'preview.txt'), 'utf8'), 'prefix SEC suffix'); + + await writeFile(join(root, 'preview.txt'), 'changed SEC suffix'); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'edit_file', + workspaceId: 'primary', + path: 'preview.txt', + oldText: ' suffix', + newText: 'RET suffix', + expectedBaseSha256: preview.baseSha256, + }), + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'EDIT_CONFLICT', + ); + assert.equal(await readFile(join(root, 'preview.txt'), 'utf8'), 'changed SEC suffix'); +}); + test('workspace mutations sync the containing directory after replacement', async (t) => { if (process.platform === 'win32') { t.skip('Directory fsync is unavailable on Windows'); @@ -1741,6 +1781,7 @@ test('composes sandboxed commands without exposing them on unconfigured workspac 'search_text', 'list_files', 'write_file', + 'preview_edit', 'edit_file', 'execute_command', ]); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index f1e00d17..c3f50fef 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { randomBytes } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import { constants } from 'node:fs'; import { link, lstat, open, realpath, rename, stat, unlink } from 'node:fs/promises'; import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; @@ -27,6 +27,8 @@ import type { WorkspaceReadFileResult, WorkspaceEditFileRequest, WorkspaceEditFileResult, + WorkspacePreviewEditRequest, + WorkspacePreviewEditResult, WorkspaceExecuteCommandRequest, WorkspaceExecuteCommandResult, WorkspaceListFilesRequest, @@ -47,6 +49,8 @@ export type { WorkspaceReadFileResult, WorkspaceEditFileRequest, WorkspaceEditFileResult, + WorkspacePreviewEditRequest, + WorkspacePreviewEditResult, WorkspaceExecuteCommandRequest, WorkspaceExecuteCommandResult, WorkspaceListFilesRequest, @@ -119,7 +123,7 @@ const READ_OPERATIONS = [ 'search_text', 'list_files', ] as const; -const WRITE_OPERATIONS = ['write_file', 'edit_file'] as const; +const WRITE_OPERATIONS = ['write_file', 'preview_edit', 'edit_file'] as const; function isUtf8ScalarString(value: string): boolean { return Buffer.from(value).toString('utf8') === value; @@ -698,40 +702,17 @@ async function editWorkspaceFile( ); } const original = await readBoundedEditFile(opened); - const hasBom = - original[0] === 0xef && original[1] === 0xbb && original[2] === 0xbf; - const body = hasBom ? original.subarray(3) : original; - const text = body.toString('utf8'); - if (!Buffer.from(text, 'utf8').equals(body)) { + if ( + request.expectedBaseSha256 !== undefined && + createHash('sha256').update(original).digest('hex') !== + request.expectedBaseSha256 + ) { throw new WorkspaceToolError( - 'Workspace file is not UTF-8 text', - 'INVALID_REQUEST', + 'Workspace file changed after edit preview', + 'EDIT_CONFLICT', ); } - const edits = request.edits ?? [ - { oldText: request.oldText ?? '', newText: request.newText ?? '' }, - ]; - let updatedText = text; - for (const edit of edits) { - const first = updatedText.indexOf(edit.oldText); - if ( - first < 0 || - updatedText.indexOf(edit.oldText, first + 1) >= 0 - ) { - throw new WorkspaceToolError( - 'Workspace edit must match exactly once', - 'EDIT_CONFLICT', - ); - } - updatedText = - updatedText.slice(0, first) + - edit.newText + - updatedText.slice(first + edit.oldText.length); - } - const updatedBody = Buffer.from(updatedText, 'utf8'); - const updated = hasBom - ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), updatedBody]) - : updatedBody; + const { updated, replacements } = applyWorkspaceEdits(original, request); await atomicWriteConfinedFile( root, request.path, @@ -748,7 +729,7 @@ async function editWorkspaceFile( operation: 'edit_file', workspaceId: request.workspaceId, path: request.path, - replacements: edits.length, + replacements, bytesWritten: updated.byteLength, }; } catch (error) { @@ -759,6 +740,68 @@ async function editWorkspaceFile( } } +function applyWorkspaceEdits( + original: Buffer, + request: WorkspaceEditFileRequest | WorkspacePreviewEditRequest, +): { updated: Buffer; replacements: number } { + const hasBom = + original[0] === 0xef && original[1] === 0xbb && original[2] === 0xbf; + const body = hasBom ? original.subarray(3) : original; + const text = body.toString('utf8'); + if (!Buffer.from(text, 'utf8').equals(body)) { + throw new WorkspaceToolError( + 'Workspace file is not UTF-8 text', + 'INVALID_REQUEST', + ); + } + const edits = request.edits ?? [ + { oldText: request.oldText ?? '', newText: request.newText ?? '' }, + ]; + let updatedText = text; + for (const edit of edits) { + const first = updatedText.indexOf(edit.oldText); + if (first < 0 || updatedText.indexOf(edit.oldText, first + 1) >= 0) { + throw new WorkspaceToolError( + 'Workspace edit must match exactly once', + 'EDIT_CONFLICT', + ); + } + updatedText = + updatedText.slice(0, first) + + edit.newText + + updatedText.slice(first + edit.oldText.length); + } + const updatedBody = Buffer.from(updatedText, 'utf8'); + const updated = hasBom + ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), updatedBody]) + : updatedBody; + if (updated.byteLength > BRIDGE_WORKSPACE_WRITE_MAX_BYTES) { + throw new WorkspaceToolError( + 'Workspace file exceeds write limit', + 'WRITE_LIMIT_EXCEEDED', + ); + } + return { updated, replacements: edits.length }; +} + +async function previewWorkspaceEdit( + root: string, + request: WorkspacePreviewEditRequest, +): Promise { + const original = await readConfinedFileBuffer(root, request.path); + const { updated, replacements } = applyWorkspaceEdits(original, request); + return { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'preview_edit', + workspaceId: request.workspaceId, + path: request.path, + content: updated.toString('utf8'), + baseSha256: createHash('sha256').update(original).digest('hex'), + replacements, + bytesWritten: updated.byteLength, + }; +} + interface SearchCandidates { paths: string[]; truncated: boolean; @@ -1439,7 +1482,11 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { } const { root } = workspace; - if (request.operation === 'write_file' || request.operation === 'edit_file') { + if ( + request.operation === 'write_file' || + request.operation === 'preview_edit' || + request.operation === 'edit_file' + ) { if (!workspace.writable) { throw new WorkspaceToolError( 'Workspace mutations are disabled by the worker', @@ -1452,8 +1499,11 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { 'EXECUTION_ABORTED', ); } - return request.operation === 'write_file' - ? writeWorkspaceFile(root, request, signal) + if (request.operation === 'write_file') { + return writeWorkspaceFile(root, request, signal); + } + return request.operation === 'preview_edit' + ? previewWorkspaceEdit(root, request) : editWorkspaceFile(root, request, signal); } diff --git a/service/src/bridge/router.test.ts b/service/src/bridge/router.test.ts index 61278aa4..7fc25eb8 100644 --- a/service/src/bridge/router.test.ts +++ b/service/src/bridge/router.test.ts @@ -284,6 +284,7 @@ describe('paired bridge HTTP API', () => { 'search_text', 'list_files', 'write_file', + 'preview_edit', 'edit_file', 'execute_command', ], diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index a073491f..2b698e5a 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -384,6 +384,7 @@ router.post( 'search_text', 'list_files', 'write_file', + 'preview_edit', 'edit_file', 'execute_command', ], From 87a47b8ecb6238fd4426298ed6c913d89fbf396e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 12:14:23 -0400 Subject: [PATCH 08/14] fix(code): Normalize edit preview results --- packages/code/src/protocol.test.ts | 1 + packages/code/src/protocol.ts | 6 +++++- packages/code/src/workspace.test.ts | 24 ++++++++++++++++++++++++ packages/code/src/workspace.ts | 20 ++++++++++++++++++-- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index f0c41dbf..94ae1afb 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -340,6 +340,7 @@ test('workspace mutations accept bounded UTF-8 requests and exact result shapes' workspaceId: 'primary', path: 'notes.txt', content: 'goodbye BYOM', + hasUtf8Bom: false, baseSha256: 'a'.repeat(64), replacements: 2, bytesWritten: 12, diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index d1d6577f..5e8102a3 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -192,6 +192,7 @@ export interface WorkspacePreviewEditResult { workspaceId: string; path: string; content: string; + hasUtf8Bom: boolean; baseSha256: string; replacements: number; bytesWritten: number; @@ -346,6 +347,7 @@ const WORKSPACE_PREVIEW_EDIT_RESULT_KEYS = new Set([ 'workspaceId', 'path', 'content', + 'hasUtf8Bom', 'baseSha256', 'replacements', 'bytesWritten', @@ -848,12 +850,14 @@ export function isWorkspaceToolResult( result.path === request.path && content !== null && Buffer.from(content).toString('utf8') === content && + typeof result.hasUtf8Bom === 'boolean' && typeof result.baseSha256 === 'string' && /^[a-f0-9]{64}$/.test(result.baseSha256) && result.replacements === replacements && Number.isSafeInteger(result.bytesWritten) && Number(result.bytesWritten) === - new TextEncoder().encode(content).byteLength && + new TextEncoder().encode(content).byteLength + + (result.hasUtf8Bom ? 3 : 0) && Number(result.bytesWritten) <= BRIDGE_WORKSPACE_WRITE_MAX_BYTES ); } diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 6ce2f9bc..e24e3a03 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -984,6 +984,7 @@ test('workspace edit previews are non-mutating and fence the commit revision', a }); assert.equal(preview.operation, 'preview_edit'); assert.equal(preview.content, 'prefix SECRET suffix'); + assert.equal(preview.hasUtf8Bom, false); assert.match(preview.baseSha256, /^[a-f0-9]{64}$/); assert.equal(await readFile(join(root, 'preview.txt'), 'utf8'), 'prefix SEC suffix'); @@ -1004,6 +1005,29 @@ test('workspace edit previews are non-mutating and fence the commit revision', a assert.equal(await readFile(join(root, 'preview.txt'), 'utf8'), 'changed SEC suffix'); }); +test('workspace edit previews strip a UTF-8 BOM while retaining its byte count', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'bom.txt'), Buffer.from('\ufeffbefore', 'utf8')); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root, writable: true }], + }); + + const preview = await tools.execute({ + protocolVersion: 1, + operation: 'preview_edit', + workspaceId: 'primary', + path: 'bom.txt', + oldText: 'before', + newText: 'after', + }); + assert.equal(preview.operation, 'preview_edit'); + assert.equal(preview.content, 'after'); + assert.equal(preview.hasUtf8Bom, true); + assert.equal(preview.bytesWritten, 8); + assert.equal(await readFile(join(root, 'bom.txt'), 'utf8'), '\ufeffbefore'); +}); + test('workspace mutations sync the containing directory after replacement', async (t) => { if (process.platform === 'win32') { t.skip('Directory fsync is unavailable on Windows'); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index c3f50fef..1138778e 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -787,15 +787,31 @@ function applyWorkspaceEdits( async function previewWorkspaceEdit( root: string, request: WorkspacePreviewEditRequest, + signal?: AbortSignal, ): Promise { const original = await readConfinedFileBuffer(root, request.path); + if (signal?.aborted) { + throw new WorkspaceToolError( + 'Workspace tool execution aborted', + 'EXECUTION_ABORTED', + ); + } const { updated, replacements } = applyWorkspaceEdits(original, request); + if (signal?.aborted) { + throw new WorkspaceToolError( + 'Workspace tool execution aborted', + 'EXECUTION_ABORTED', + ); + } + const hasUtf8Bom = + updated[0] === 0xef && updated[1] === 0xbb && updated[2] === 0xbf; return { protocolVersion: BRIDGE_PROTOCOL_VERSION, operation: 'preview_edit', workspaceId: request.workspaceId, path: request.path, - content: updated.toString('utf8'), + content: decodeWorkspaceText(updated), + hasUtf8Bom, baseSha256: createHash('sha256').update(original).digest('hex'), replacements, bytesWritten: updated.byteLength, @@ -1503,7 +1519,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { return writeWorkspaceFile(root, request, signal); } return request.operation === 'preview_edit' - ? previewWorkspaceEdit(root, request) + ? previewWorkspaceEdit(root, request, signal) : editWorkspaceFile(root, request, signal); } From 5f89e6a81b6359e8aa307802974f559780db1755 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 22:59:25 -0400 Subject: [PATCH 09/14] fix(code): negotiate preview edit fencing --- packages/code/README.md | 2 + packages/code/src/protocol.test.ts | 47 +++++++++++++- packages/code/src/protocol.ts | 41 +++++++++++-- packages/code/src/worker.ts | 31 +++++++++- packages/code/src/workspace-cli.test.ts | 6 ++ packages/code/src/workspace-worker.test.ts | 5 +- packages/code/src/workspace.test.ts | 4 ++ packages/code/src/workspace.ts | 9 +++ service/src/bridge/router.test.ts | 1 + service/src/bridge/router.ts | 1 + service/src/bridge/store.ts | 14 ++++- service/src/bridge/workspace-store.test.ts | 71 ++++++++++++++++++++++ 12 files changed, 221 insertions(+), 11 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index 6f53c72d..3538aa6c 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -279,6 +279,8 @@ and server negotiate `create` in `writeFileModes`. `edits` array; every exact replacement is validated before the updated file is installed as one atomic mutation. Code API dispatches the batch form only after the worker and server negotiate `batch` in `editFileModes`. +Revision-fenced edits likewise require the negotiated +`expected_base_sha256` entry in `editFileFeatures`. Only IDs, names, protocol version, supported operations, and negotiated write modes appear in worker capabilities; absolute host paths remain local to the worker process. diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index 94ae1afb..b585d7ab 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -7,7 +7,10 @@ import { isWorkspaceToolRequest, isWorkspaceToolResult, } from './protocol.js'; -import type { WorkspaceEditFileRequest } from './protocol.js'; +import type { + WorkspaceEditFileRequest, + WorkspacePreviewEditRequest, +} from './protocol.js'; const validSingleEditRequest: WorkspaceEditFileRequest = { protocolVersion: 1, @@ -39,6 +42,26 @@ const invalidMixedEditRequest: WorkspaceEditFileRequest = { void invalidEmptyEditRequest; void invalidMixedEditRequest; +// @ts-expect-error A preview request must choose a complete single or batch form. +const invalidEmptyPreviewRequest: WorkspacePreviewEditRequest = { + protocolVersion: 1, + operation: 'preview_edit', + workspaceId: 'primary', + path: 'notes.txt', +}; +// @ts-expect-error Single and batch preview forms are mutually exclusive. +const invalidMixedPreviewRequest: WorkspacePreviewEditRequest = { + protocolVersion: 1, + operation: 'preview_edit', + workspaceId: 'primary', + path: 'notes.txt', + oldText: 'before', + newText: 'after', + edits: [{ oldText: 'before', newText: 'after' }], +}; +void invalidEmptyPreviewRequest; +void invalidMixedPreviewRequest; + test('bridgeWorkerPath encodes worker-controlled path segments', () => { assert.equal( bridgeWorkerPath('vm/example worker'), @@ -112,6 +135,17 @@ test('bridge worker capabilities accept only bounded public workspace descriptor }), true, ); + assert.equal( + isValidBridgeWorkerCapabilities({ + ...valid, + workspaceTools: { + ...valid.workspaceTools, + operations: ['read_file', 'preview_edit'], + editFileModes: ['single', 'batch'], + }, + }), + true, + ); assert.equal( isValidBridgeWorkerCapabilities({ ...valid, @@ -119,10 +153,21 @@ test('bridge worker capabilities accept only bounded public workspace descriptor ...valid.workspaceTools, operations: ['read_file', 'edit_file'], editFileModes: ['single', 'batch'], + editFileFeatures: ['expected_base_sha256'], }, }), true, ); + assert.equal( + isValidBridgeWorkerCapabilities({ + ...valid, + workspaceTools: { + ...valid.workspaceTools, + editFileFeatures: ['expected_base_sha256'], + }, + }), + false, + ); assert.equal( isValidBridgeWorkerCapabilities({ ...valid, diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 5e8102a3..9912512b 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -34,6 +34,7 @@ export type BridgeWorkspaceToolOperation = export type WorkspaceWriteFileMode = 'replace' | 'create'; export type WorkspaceEditFileMode = 'single' | 'batch'; +export type WorkspaceEditFileFeature = 'expected_base_sha256'; export interface BridgeWorkspaceDescriptor { id: string; @@ -50,6 +51,8 @@ export interface BridgeWorkspaceToolCapabilities { writeFileModes?: WorkspaceWriteFileMode[]; /** Omitted by legacy workers, which only accept single exact replacements. */ editFileModes?: WorkspaceEditFileMode[]; + /** Omitted by workers that cannot fence edits against a preview revision. */ + editFileFeatures?: WorkspaceEditFileFeature[]; } export interface WorkspaceReadFileRequest { @@ -176,16 +179,31 @@ export interface WorkspaceEditFileResult { bytesWritten: number; } -export interface WorkspacePreviewEditRequest { +interface WorkspacePreviewEditRequestBase { protocolVersion: BridgeProtocolVersion; operation: 'preview_edit'; workspaceId: string; path: string; - oldText?: string; - newText?: string; - edits?: WorkspaceTextEdit[]; } +export interface WorkspaceSinglePreviewEditRequest + extends WorkspacePreviewEditRequestBase { + oldText: string; + newText: string; + edits?: never; +} + +export interface WorkspaceBatchPreviewEditRequest + extends WorkspacePreviewEditRequestBase { + edits: WorkspaceTextEdit[]; + oldText?: never; + newText?: never; +} + +export type WorkspacePreviewEditRequest = + | WorkspaceSinglePreviewEditRequest + | WorkspaceBatchPreviewEditRequest; + export interface WorkspacePreviewEditResult { protocolVersion: BridgeProtocolVersion; operation: 'preview_edit'; @@ -400,6 +418,8 @@ export interface BridgeWorkerRegistrationResponse { supportedWorkspaceWriteFileModes?: WorkspaceWriteFileMode[]; /** Edit modes this Code API can safely route to a capability-aware worker. */ supportedWorkspaceEditFileModes?: WorkspaceEditFileMode[]; + /** Edit features this Code API can safely route to a capability-aware worker. */ + supportedWorkspaceEditFileFeatures?: WorkspaceEditFileFeature[]; } export interface BridgePairingRedemption { @@ -964,7 +984,8 @@ export function isValidBridgeWorkspaceToolCapabilities( (!Array.isArray(capabilities.editFileModes) || capabilities.editFileModes.length < 1 || capabilities.editFileModes.length > 2 || - !capabilities.operations.includes('edit_file') || + (!capabilities.operations.includes('edit_file') && + !capabilities.operations.includes('preview_edit')) || !capabilities.editFileModes.every( (mode) => mode === 'single' || mode === 'batch', ) || @@ -974,6 +995,16 @@ export function isValidBridgeWorkspaceToolCapabilities( return false; } + if ( + capabilities.editFileFeatures !== undefined && + (!Array.isArray(capabilities.editFileFeatures) || + capabilities.editFileFeatures.length !== 1 || + !capabilities.operations.includes('edit_file') || + capabilities.editFileFeatures[0] !== 'expected_base_sha256') + ) { + return false; + } + const workspaceIds = new Set(); return capabilities.workspaces.every((workspace) => { if (typeof workspace !== 'object' || workspace === null) return false; diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 05c3a397..784a28f9 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -145,6 +145,11 @@ function workspaceCapabilitiesMatch( (advertised.editFileModes?.every( (mode, index) => mode === executor.editFileModes?.[index], ) ?? executor.editFileModes == null) && + advertised.editFileFeatures?.length === + executor.editFileFeatures?.length && + (advertised.editFileFeatures?.every( + (feature, index) => feature === executor.editFileFeatures?.[index], + ) ?? executor.editFileFeatures == null) && advertised.workspaces.length === executor.workspaces.length && advertised.workspaces.every( (workspace, index) => @@ -202,6 +207,7 @@ function registrationCompatibleCapabilities( const { writeFileModes: _writeFileModes, editFileModes: _editFileModes, + editFileFeatures: _editFileFeatures, ...compatibleWorkspaceTools } = workspaceTools; return { @@ -224,6 +230,8 @@ function supportedWorkspaceCapabilities( const operations = desired.operations.filter((operation) => supported.includes(operation), ); + const supportsEditRequests = + operations.includes('edit_file') || operations.includes('preview_edit'); if (operations.length === 0) return undefined; const workspaces = desired.workspaces.flatMap((workspace) => { if (workspace.operations == null) return [workspace]; @@ -241,9 +249,13 @@ function supportedWorkspaceCapabilities( const editFileModes = desired.editFileModes?.filter((mode) => registration.supportedWorkspaceEditFileModes?.includes(mode), ); + const editFileFeatures = desired.editFileFeatures?.filter((feature) => + registration.supportedWorkspaceEditFileFeatures?.includes(feature), + ); const { writeFileModes: _writeFileModes, editFileModes: _editFileModes, + editFileFeatures: _editFileFeatures, ...compatibleDesired } = desired; return { @@ -255,9 +267,12 @@ function supportedWorkspaceCapabilities( ...(operations.includes('write_file') && writeFileModes?.length ? { writeFileModes } : {}), - ...(operations.includes('edit_file') && editFileModes?.length + ...(supportsEditRequests && editFileModes?.length ? { editFileModes } : {}), + ...(operations.includes('edit_file') && editFileFeatures?.length + ? { editFileFeatures } + : {}), }, }; } @@ -917,7 +932,10 @@ export class BridgeWorker { ); } } - if (workspaceRequest.operation === 'edit_file') { + if ( + workspaceRequest.operation === 'preview_edit' || + workspaceRequest.operation === 'edit_file' + ) { const mode = workspaceRequest.edits === undefined ? 'single' : 'batch'; const modes = advertised.editFileModes; if ( @@ -928,6 +946,15 @@ export class BridgeWorker { 'Workspace edit mode is not advertised', ); } + if ( + workspaceRequest.operation === 'edit_file' && + workspaceRequest.expectedBaseSha256 !== undefined && + !advertised.editFileFeatures?.includes('expected_base_sha256') + ) { + throw new BridgeProtocolError( + 'Workspace edit feature is not advertised', + ); + } } const isMutation = workspaceRequest.operation === 'write_file' || diff --git a/packages/code/src/workspace-cli.test.ts b/packages/code/src/workspace-cli.test.ts index 640b7379..c1bc7fce 100644 --- a/packages/code/src/workspace-cli.test.ts +++ b/packages/code/src/workspace-cli.test.ts @@ -154,6 +154,9 @@ test('CLI advertises explicitly enabled writes without exposing the workspace ro 'preview_edit', 'edit_file', ], + supportedWorkspaceWriteFileModes: ['replace', 'create'], + supportedWorkspaceEditFileModes: ['single', 'batch'], + supportedWorkspaceEditFileFeatures: ['expected_base_sha256'], }), ); return; @@ -219,6 +222,9 @@ test('CLI advertises explicitly enabled writes without exposing the workspace ro 'preview_edit', 'edit_file', ], + writeFileModes: ['replace', 'create'], + editFileModes: ['single', 'batch'], + editFileFeatures: ['expected_base_sha256'], workspaces: [ { id: 'root-workspace', diff --git a/packages/code/src/workspace-worker.test.ts b/packages/code/src/workspace-worker.test.ts index 1c5df7da..b0b83405 100644 --- a/packages/code/src/workspace-worker.test.ts +++ b/packages/code/src/workspace-worker.test.ts @@ -485,12 +485,13 @@ test('worker omits write modes not negotiated by an older Code API', async () => ]); }); -test('worker omits batch edits not negotiated by an older Code API', async () => { +test('worker advertises only edit modes and features negotiated by Code API', async () => { const registrations: Array> = []; const workspaceCapabilities = { protocolVersion: 1 as const, operations: ['read_file' as const, 'edit_file' as const], editFileModes: ['single' as const, 'batch' as const], + editFileFeatures: ['expected_base_sha256' as const], workspaces: [ { id: 'primary', @@ -529,6 +530,7 @@ test('worker omits batch edits not negotiated by an older Code API', async () => registeredAt: new Date().toISOString(), leaseTtlMs: 60_000, supportedWorkspaceToolOperations: ['read_file', 'edit_file'], + supportedWorkspaceEditFileModes: ['single', 'batch'], }); }, }); @@ -544,6 +546,7 @@ test('worker omits batch edits not negotiated by an older Code API', async () => { protocolVersion: 1, operations: ['read_file', 'edit_file'], + editFileModes: ['single', 'batch'], workspaces: [ { id: 'primary', operations: ['read_file', 'edit_file'] }, ], diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index e24e3a03..3ece68b0 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -824,6 +824,7 @@ test('writable workspaces create, replace, and exactly edit files', async (t) => ], writeFileModes: ['replace', 'create'], editFileModes: ['single', 'batch'], + editFileFeatures: ['expected_base_sha256'], }); await tools.execute({ protocolVersion: 1, @@ -1811,6 +1812,9 @@ test('composes sandboxed commands without exposing them on unconfigured workspac ]); assert.deepEqual(tools.capabilities.writeFileModes, ['replace', 'create']); assert.deepEqual(tools.capabilities.editFileModes, ['single', 'batch']); + assert.deepEqual(tools.capabilities.editFileFeatures, [ + 'expected_base_sha256', + ]); assert.deepEqual( tools.capabilities.workspaces.find(({ id }) => id === 'sandboxed')?.operations, tools.capabilities.operations, diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 1138778e..3e7e1f10 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -1403,6 +1403,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { workspaces: BridgeWorkspaceDescriptor[], writeFileModes?: BridgeWorkspaceToolCapabilities['writeFileModes'], editFileModes?: BridgeWorkspaceToolCapabilities['editFileModes'], + editFileFeatures?: BridgeWorkspaceToolCapabilities['editFileFeatures'], ) { this.capabilities = { protocolVersion: BRIDGE_PROTOCOL_VERSION, @@ -1410,6 +1411,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { workspaces, ...(writeFileModes != null ? { writeFileModes } : {}), ...(editFileModes != null ? { editFileModes } : {}), + ...(editFileFeatures != null ? { editFileFeatures } : {}), }; } @@ -1444,6 +1446,9 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { workspaces, ...(anyWritable ? { writeFileModes: ['replace', 'create'] } : {}), ...(anyWritable ? { editFileModes: ['single', 'batch'] } : {}), + ...(anyWritable + ? { editFileFeatures: ['expected_base_sha256'] } + : {}), }; if (!isValidBridgeWorkspaceToolCapabilities(capabilities)) { throw new WorkspaceToolError( @@ -1473,6 +1478,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { workspaces, capabilities.writeFileModes, capabilities.editFileModes, + capabilities.editFileFeatures, ); } @@ -1610,6 +1616,9 @@ export class SandboxWorkspaceTools implements WorkspaceToolExecutor { ...(base.editFileModes != null ? { editFileModes: base.editFileModes } : {}), + ...(base.editFileFeatures != null + ? { editFileFeatures: base.editFileFeatures } + : {}), workspaces: base.workspaces.map((workspace) => ({ ...workspace, operations: [ diff --git a/service/src/bridge/router.test.ts b/service/src/bridge/router.test.ts index 7fc25eb8..dca0d6a5 100644 --- a/service/src/bridge/router.test.ts +++ b/service/src/bridge/router.test.ts @@ -290,6 +290,7 @@ describe('paired bridge HTTP API', () => { ], supportedWorkspaceWriteFileModes: ['replace', 'create'], supportedWorkspaceEditFileModes: ['single', 'batch'], + supportedWorkspaceEditFileFeatures: ['expected_base_sha256'], }); const crossDeploymentRevoke = await fetch( diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index 2b698e5a..15d8b679 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -390,6 +390,7 @@ router.post( ], supportedWorkspaceWriteFileModes: ['replace', 'create'], supportedWorkspaceEditFileModes: ['single', 'batch'], + supportedWorkspaceEditFileFeatures: ['expected_base_sha256'], }); } catch (error) { if (error instanceof BridgeStoreError) { diff --git a/service/src/bridge/store.ts b/service/src/bridge/store.ts index 8b180e39..57588e59 100644 --- a/service/src/bridge/store.ts +++ b/service/src/bridge/store.ts @@ -90,10 +90,20 @@ function supportsWorkspaceTool( const mode = request.overwrite === false ? 'create' : 'replace'; return capabilities?.writeFileModes?.includes(mode) === true; } - if (request.operation === 'edit_file') { + if ( + request.operation === 'preview_edit' || + request.operation === 'edit_file' + ) { const mode = request.edits === undefined ? 'single' : 'batch'; const modes = capabilities?.editFileModes; - return modes == null ? mode === 'single' : modes.includes(mode); + const supportsMode = modes == null ? mode === 'single' : modes.includes(mode); + if (request.operation === 'preview_edit') return supportsMode; + return ( + supportsMode && + (request.expectedBaseSha256 === undefined || + capabilities?.editFileFeatures?.includes('expected_base_sha256') === + true) + ); } return true; } diff --git a/service/src/bridge/workspace-store.test.ts b/service/src/bridge/workspace-store.test.ts index 82124548..55325ca3 100644 --- a/service/src/bridge/workspace-store.test.ts +++ b/service/src/bridge/workspace-store.test.ts @@ -212,6 +212,77 @@ test('rejects batch edits from workers without the negotiated mode', async () => expect(await redis.keys('codeapi:bridge:v1:assignment:*')).toHaveLength(0); }); +test('rejects fenced edits from workers without the negotiated feature', async () => { + await store.register({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'workspace-worker', + incarnationId, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['edit_file'], + editFileModes: ['single'], + workspaces: [{ id: 'primary' }], + }, + }, + }); + + await expect( + store.dispatchWorkspaceTool({ + workerId: 'workspace-worker', + request: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'edit_file', + workspaceId: 'primary', + path: 'notes.txt', + oldText: 'before', + newText: 'after', + expectedBaseSha256: 'a'.repeat(64), + }, + deadlineAtMs: Date.now() + 1_000, + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ code: 'WORKER_MISMATCH' }); + expect(await redis.keys('codeapi:bridge:v1:assignment:*')).toHaveLength(0); +}); + +test('rejects batch previews from workers without the negotiated mode', async () => { + await store.register({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'workspace-worker', + incarnationId, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['preview_edit'], + workspaces: [{ id: 'primary' }], + }, + }, + }); + + await expect( + store.dispatchWorkspaceTool({ + workerId: 'workspace-worker', + request: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'preview_edit', + workspaceId: 'primary', + path: 'notes.txt', + edits: [{ oldText: 'before', newText: 'after' }], + }, + deadlineAtMs: Date.now() + 1_000, + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ code: 'WORKER_MISMATCH' }); + expect(await redis.keys('codeapi:bridge:v1:assignment:*')).toHaveLength(0); +}); + test('rejects a fulfilled workspace settlement that violates the result contract', async () => { await store.register({ protocolVersion: BRIDGE_PROTOCOL_VERSION, From 49c827034974772889b94b0599c4d9b478c4187e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 23:06:04 -0400 Subject: [PATCH 10/14] fix(code): drop incompatible edit capabilities --- packages/code/src/worker.ts | 51 +++++++++++--- packages/code/src/workspace-worker.test.ts | 79 ++++++++++++++++++++++ 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 784a28f9..2c8c05ef 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -19,6 +19,7 @@ import type { BridgeWorkerCapabilities, BridgeWorkerCredentialResponse, BridgeWorkerRegistrationResponse, + BridgeWorkspaceToolOperation, } from './protocol.js'; import type { RuntimeLease, RuntimeSupervisor } from './runtime.js'; import type { WorkspaceToolExecutor } from './workspace.js'; @@ -227,12 +228,50 @@ function supportedWorkspaceCapabilities( const desired = capabilities.workspaceTools; const supported = registration.supportedWorkspaceToolOperations; if (desired == null || !Array.isArray(supported)) return undefined; - const operations = desired.operations.filter((operation) => + let operations = desired.operations.filter((operation) => supported.includes(operation), ); - const supportsEditRequests = - operations.includes('edit_file') || operations.includes('preview_edit'); if (operations.length === 0) return undefined; + let writeFileModes: typeof desired.writeFileModes; + if (operations.includes('write_file')) { + const desiredModes = desired.writeFileModes ?? ['replace']; + const serverModes = registration.supportedWorkspaceWriteFileModes ?? [ + 'replace', + ]; + const commonModes = desiredModes.filter((mode) => + serverModes.includes(mode), + ); + if (commonModes.length === 0) { + operations = operations.filter((operation) => operation !== 'write_file'); + } else if (registration.supportedWorkspaceWriteFileModes != null) { + writeFileModes = commonModes; + } + } + const editOperations = new Set([ + 'preview_edit', + 'edit_file', + ]); + let editFileModes: typeof desired.editFileModes; + if (operations.some((operation) => editOperations.has(operation))) { + const desiredModes = desired.editFileModes ?? ['single']; + const serverModes = registration.supportedWorkspaceEditFileModes ?? [ + 'single', + ]; + const commonModes = desiredModes.filter((mode) => + serverModes.includes(mode), + ); + if (commonModes.length === 0) { + operations = operations.filter( + (operation) => !editOperations.has(operation), + ); + } else if (registration.supportedWorkspaceEditFileModes != null) { + editFileModes = commonModes; + } + } + if (operations.length === 0) return undefined; + const supportsEditRequests = operations.some((operation) => + editOperations.has(operation), + ); const workspaces = desired.workspaces.flatMap((workspace) => { if (workspace.operations == null) return [workspace]; const workspaceOperations = workspace.operations.filter((operation) => @@ -243,12 +282,6 @@ function supportedWorkspaceCapabilities( : [{ ...workspace, operations: workspaceOperations }]; }); if (workspaces.length === 0) return undefined; - const writeFileModes = desired.writeFileModes?.filter((mode) => - registration.supportedWorkspaceWriteFileModes?.includes(mode), - ); - const editFileModes = desired.editFileModes?.filter((mode) => - registration.supportedWorkspaceEditFileModes?.includes(mode), - ); const editFileFeatures = desired.editFileFeatures?.filter((feature) => registration.supportedWorkspaceEditFileFeatures?.includes(feature), ); diff --git a/packages/code/src/workspace-worker.test.ts b/packages/code/src/workspace-worker.test.ts index b0b83405..3f2931d7 100644 --- a/packages/code/src/workspace-worker.test.ts +++ b/packages/code/src/workspace-worker.test.ts @@ -554,6 +554,85 @@ test('worker advertises only edit modes and features negotiated by Code API', as ]); }); +test('worker drops file operations when no request mode is compatible', async () => { + const registrations: Array> = []; + const workspaceCapabilities = { + protocolVersion: 1 as const, + operations: [ + 'read_file' as const, + 'write_file' as const, + 'preview_edit' as const, + ], + writeFileModes: ['create' as const], + editFileModes: ['batch' as const], + workspaces: [ + { + id: 'primary', + operations: [ + 'read_file' as const, + 'write_file' as const, + 'preview_edit' as const, + ], + }, + ], + }; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', + token: 'worker-secret', + workerId: 'vm-1', + incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: workspaceCapabilities, + }, + workspaceTools: { + capabilities: workspaceCapabilities, + async execute() { + throw new Error('not executed'); + }, + }, + workspaceMutationQuarantine: mutationQuarantine(), + fetchImpl: async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { + capabilities: { workspaceTools?: Record }; + }; + registrations.push(body.capabilities.workspaceTools ?? {}); + return Response.json({ + protocolVersion: 1, + workerId: 'vm-1', + incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60_000, + supportedWorkspaceToolOperations: [ + 'read_file', + 'write_file', + 'preview_edit', + ], + supportedWorkspaceWriteFileModes: ['replace'], + supportedWorkspaceEditFileModes: ['single'], + }); + }, + }); + + await worker.register(); + + assert.deepEqual(registrations, [ + { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: 'primary' }], + }, + { + protocolVersion: 1, + operations: ['read_file'], + workspaces: [{ id: 'primary', operations: ['read_file'] }], + }, + ]); +}); + test('worker retains per-workspace restrictions during read-only promotion', async () => { const registrations: Array<{ operations: string[]; From f90eccbd036d19d69c7a87c3bb685d0587004bc0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 4 Sep 2026 00:19:20 -0400 Subject: [PATCH 11/14] feat(code): Continue Bounded Workspace Listings --- packages/code/README.md | 8 ++-- packages/code/src/protocol.test.ts | 31 ++++++++++++++- packages/code/src/protocol.ts | 42 +++++++++++++++++++- packages/code/src/workspace.test.ts | 59 +++++++++++++++++++++++++++++ packages/code/src/workspace.ts | 11 ++++++ 5 files changed, 144 insertions(+), 7 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index 3538aa6c..bd6817e2 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -331,9 +331,11 @@ bounded set of ignored-aware candidates with configuration and symlink following disabled. It then opens and verifies each candidate through the same confined 1 MiB read boundary before matching locally. File listing invokes `rg` without a shell, with configuration and symlink following disabled. Both operations -stop after bounded global result counts. The worker process still belongs inside -the trusted BYOM boundary and should receive filesystem access only to roots the -operator intentionally registers. +stop after bounded global result counts. A truncated `list_files` result includes +`nextAfterPath`; pass that value back as `afterPath` with the same workspace and +path to continue deterministically beyond the 500-file protocol ceiling. The +worker process still belongs inside the trusted BYOM boundary and should receive +filesystem access only to roots the operator intentionally registers. Writes are limited to 1 MiB of UTF-8 text and require an existing directory inside the registered root. They reject traversal, symlink targets, and diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index b585d7ab..21140069 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -217,6 +217,7 @@ test('workspace file listing accepts only bounded portable requests and results' workspaceId: 'primary', path: 'src', maxResults: 20, + afterPath: 'src/app.ts', }; assert.equal(isWorkspaceToolRequest(request), true); assert.equal( @@ -224,15 +225,41 @@ test('workspace file listing accepts only bounded portable requests and results' false, ); assert.equal(isWorkspaceToolRequest({ ...request, maxResults: 501 }), false); + assert.equal( + isWorkspaceToolRequest({ ...request, afterPath: 'outside/app.ts' }), + false, + ); const result = { protocolVersion: 1 as const, operation: 'list_files' as const, workspaceId: 'primary', - paths: ['src/app.ts', 'src/worker.ts'], - truncated: false, + paths: ['src/worker.ts', 'src/z.ts'], + truncated: true, + nextAfterPath: 'src/z.ts', }; assert.equal(isWorkspaceToolResult(request, result), true); + assert.equal( + isWorkspaceToolResult(request, { + ...result, + paths: ['src/\uE000.ts', 'src/\u{10000}.ts'], + truncated: false, + nextAfterPath: undefined, + }), + true, + ); + assert.equal( + isWorkspaceToolResult(request, { ...result, nextAfterPath: 'src/worker.ts' }), + false, + ); + assert.equal( + isWorkspaceToolResult(request, { + ...result, + paths: ['src/z.ts', 'src/worker.ts'], + nextAfterPath: 'src/worker.ts', + }), + false, + ); assert.equal( isWorkspaceToolResult(request, { ...result, diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 9912512b..b0d0af0b 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -106,6 +106,8 @@ export interface WorkspaceListFilesRequest { workspaceId: string; path?: string; maxResults?: number; + /** Continue strictly after this canonical path from a previous page. */ + afterPath?: string; } export interface WorkspaceListFilesResult { @@ -114,6 +116,8 @@ export interface WorkspaceListFilesResult { workspaceId: string; paths: string[]; truncated: boolean; + /** Last returned path; pass as afterPath to fetch the next page. */ + nextAfterPath?: string; } export interface WorkspaceWriteFileRequest { @@ -280,6 +284,7 @@ const WORKSPACE_LIST_REQUEST_KEYS = new Set([ 'workspaceId', 'path', 'maxResults', + 'afterPath', ]); const WORKSPACE_WRITE_REQUEST_KEYS = new Set([ 'protocolVersion', @@ -342,6 +347,7 @@ const WORKSPACE_LIST_RESULT_KEYS = new Set([ 'workspaceId', 'paths', 'truncated', + 'nextAfterPath', ]); const WORKSPACE_WRITE_RESULT_KEYS = new Set([ 'protocolVersion', @@ -585,6 +591,19 @@ function normalizePortableRelativePath(value: string): string { ); } +/** Compare portable paths by their UTF-8 wire representation on every worker OS. */ +export function comparePortableRelativePaths(left: string, right: string): number { + const encoder = new TextEncoder(); + const leftBytes = encoder.encode(left); + const rightBytes = encoder.encode(right); + const sharedLength = Math.min(leftBytes.length, rightBytes.length); + for (let index = 0; index < sharedLength; index += 1) { + const difference = leftBytes[index] - rightBytes[index]; + if (difference !== 0) return difference; + } + return leftBytes.length - rightBytes.length; +} + function isWithinRequestedPath(candidate: string, requested?: string): boolean { if (requested == null) return true; const normalizedCandidate = normalizePortableRelativePath(candidate); @@ -702,6 +721,10 @@ export function isWorkspaceToolRequest( hasOnlyKeys(request, WORKSPACE_LIST_REQUEST_KEYS) && (request.path === undefined || isSafePortableRelativePath(request.path)) && + (request.afterPath === undefined || + (isSafePortableRelativePath(request.afterPath) && + normalizePortableRelativePath(request.afterPath) === request.afterPath && + isWithinRequestedPath(request.afterPath, request.path))) && (request.maxResults === undefined || (Number.isSafeInteger(request.maxResults) && Number(request.maxResults) >= 1 && @@ -824,6 +847,11 @@ export function isWorkspaceToolResult( return false; } const normalizedPaths = new Set(); + const normalizedAfterPath = + request.afterPath === undefined + ? undefined + : normalizePortableRelativePath(request.afterPath); + let previousPath = normalizedAfterPath; for (const path of result.paths) { if ( !isSafePortableRelativePath(path) || @@ -832,10 +860,20 @@ export function isWorkspaceToolResult( return false; } const normalizedPath = normalizePortableRelativePath(path); - if (normalizedPaths.has(normalizedPath)) return false; + if ( + normalizedPaths.has(normalizedPath) || + (previousPath !== undefined && + comparePortableRelativePaths(normalizedPath, previousPath) <= 0) + ) { + return false; + } normalizedPaths.add(normalizedPath); + previousPath = normalizedPath; } - return true; + return result.truncated === true + ? result.paths.length > 0 && + result.nextAfterPath === result.paths[result.paths.length - 1] + : result.nextAfterPath === undefined; } if (request.operation === 'write_file') { diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 3ece68b0..f0ba9d94 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -201,6 +201,23 @@ test('lists workspace files deterministically with a hard result bound', async ( workspaceId: 'primary', paths: ['docs/guide.md', 'src/app.ts'], truncated: true, + nextAfterPath: 'src/app.ts', + }, + ); + assert.deepEqual( + await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + maxResults: 2, + afterPath: 'src/app.ts', + }), + { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['src/worker.ts'], + truncated: false, }, ); assert.deepEqual( @@ -221,6 +238,48 @@ test('lists workspace files deterministically with a hard result bound', async ( ); }); +test('continues a workspace listing beyond the protocol result ceiling', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await Promise.all( + Array.from({ length: 501 }, (_, index) => + writeFile(join(root, `file-${String(index).padStart(3, '0')}.txt`), 'x'), + ), + ); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const firstPage = await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + maxResults: 500, + }); + assert.equal(firstPage.operation, 'list_files'); + if (firstPage.operation !== 'list_files') assert.fail('expected list result'); + assert.equal(firstPage.paths.length, 500); + assert.equal(firstPage.truncated, true); + assert.equal(firstPage.nextAfterPath, 'file-499.txt'); + + assert.deepEqual( + await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + maxResults: 500, + afterPath: firstPage.nextAfterPath, + }), + { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['file-500.txt'], + truncated: false, + }, + ); +}); + test('rejects listing through a directory symlink that leaves the workspace', async (t) => { const parent = await mkdtemp( join(tmpdir(), 'librechat-code-workspace-parent-'), diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 3e7e1f10..c9900f25 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -14,6 +14,7 @@ import { BRIDGE_WORKSPACE_LIST_MAX_RESULTS, BRIDGE_WORKSPACE_SEARCH_MAX_RESULTS, BRIDGE_WORKSPACE_SEARCH_TEXT_MAX_LENGTH, + comparePortableRelativePaths, isSafePortableRelativePath, isValidBridgeWorkspaceToolCapabilities, isWorkspaceToolRequest, @@ -1152,6 +1153,7 @@ async function listWorkspaceFiles( .filter((segment) => segment.length > 0 && segment !== '.') .join('/'); const requestedResultPath = normalizedRequestedResultPath || undefined; + const afterPath = request.afterPath; const candidates: Array<{ filesystemPath: string; resultPath: string }> = []; let truncated = false; @@ -1230,6 +1232,12 @@ async function listWorkspaceFiles( ? `${requestedResultPath}${normalizedPath.slice(portableCanonicalListPath.length)}` : normalizedPath; if (!isSafePortableRelativePath(resultPath)) return; + if ( + afterPath !== undefined && + comparePortableRelativePaths(resultPath, afterPath) <= 0 + ) { + return; + } candidates.push({ filesystemPath: normalizedPath, resultPath }); }; @@ -1337,6 +1345,9 @@ async function listWorkspaceFiles( workspaceId: request.workspaceId, paths, truncated, + ...(truncated && paths.length > 0 + ? { nextAfterPath: paths[paths.length - 1] } + : {}), }; } From a0cc57349dbf8dc394aa6a5afa643b2bb36a16ae Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 4 Sep 2026 09:46:57 -0400 Subject: [PATCH 12/14] fix(code): harden workspace list pagination --- packages/code/README.md | 9 ++- packages/code/src/protocol.test.ts | 36 +++++++++ packages/code/src/protocol.ts | 39 ++++++++-- packages/code/src/worker.ts | 33 ++++++++ packages/code/src/workspace-worker.test.ts | 70 ++++++++++++++++- packages/code/src/workspace.test.ts | 51 +++++++++++++ packages/code/src/workspace.ts | 7 ++ service/src/bridge/router.test.ts | 1 + service/src/bridge/router.ts | 1 + service/src/bridge/store.ts | 35 ++++++--- service/src/bridge/workspace-store.test.ts | 88 +++++++++++++++++++++- 11 files changed, 345 insertions(+), 25 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index bd6817e2..f53741a8 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -333,9 +333,12 @@ disabled. It then opens and verifies each candidate through the same confined a shell, with configuration and symlink following disabled. Both operations stop after bounded global result counts. A truncated `list_files` result includes `nextAfterPath`; pass that value back as `afterPath` with the same workspace and -path to continue deterministically beyond the 500-file protocol ceiling. The -worker process still belongs inside the trusted BYOM boundary and should receive -filesystem access only to roots the operator intentionally registers. +path to continue deterministically beyond the 500-file protocol ceiling. +Continuation is advertised and negotiated as the `after_path` list-file feature, +so mixed Code API and worker versions keep the legacy bounded response shape +during rolling upgrades. The worker process still belongs inside the trusted +BYOM boundary and should receive filesystem access only to roots the operator +intentionally registers. Writes are limited to 1 MiB of UTF-8 text and require an existing directory inside the registered root. They reject traversal, symlink targets, and diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index 21140069..b159c35e 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { bridgeWorkerPath, + comparePortableRelativePaths, isValidBridgeWorkerCapabilities, isValidBridgeWorkerId, isWorkspaceToolRequest, @@ -168,6 +169,27 @@ test('bridge worker capabilities accept only bounded public workspace descriptor }), false, ); + assert.equal( + isValidBridgeWorkerCapabilities({ + ...valid, + workspaceTools: { + ...valid.workspaceTools, + operations: ['read_file', 'list_files'], + listFileFeatures: ['after_path'], + }, + }), + true, + ); + assert.equal( + isValidBridgeWorkerCapabilities({ + ...valid, + workspaceTools: { + ...valid.workspaceTools, + listFileFeatures: ['after_path'], + }, + }), + false, + ); assert.equal( isValidBridgeWorkerCapabilities({ ...valid, @@ -285,6 +307,14 @@ test('workspace file listing accepts only bounded portable requests and results' }), false, ); + assert.equal( + isWorkspaceToolResult(request, { + ...result, + paths: ['src//worker.ts'], + nextAfterPath: 'src//worker.ts', + }), + false, + ); assert.equal( isWorkspaceToolResult(request, { ...result, @@ -294,6 +324,12 @@ test('workspace file listing accepts only bounded portable requests and results' ); }); +test('workspace path ordering matches sorted depth-first traversal', () => { + assert.ok(comparePortableRelativePaths('src/app.ts', 'src.ts') < 0); + assert.ok(comparePortableRelativePaths('src/app.ts', 'src/worker.ts') < 0); + assert.ok(comparePortableRelativePaths('src.ts', 'src/app.ts') > 0); +}); + test('workspace mutations accept bounded UTF-8 requests and exact result shapes', () => { const writeRequest = { protocolVersion: 1 as const, diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index b0d0af0b..33add7c1 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -35,6 +35,7 @@ export type BridgeWorkspaceToolOperation = export type WorkspaceWriteFileMode = 'replace' | 'create'; export type WorkspaceEditFileMode = 'single' | 'batch'; export type WorkspaceEditFileFeature = 'expected_base_sha256'; +export type WorkspaceListFileFeature = 'after_path'; export interface BridgeWorkspaceDescriptor { id: string; @@ -53,6 +54,8 @@ export interface BridgeWorkspaceToolCapabilities { editFileModes?: WorkspaceEditFileMode[]; /** Omitted by workers that cannot fence edits against a preview revision. */ editFileFeatures?: WorkspaceEditFileFeature[]; + /** Omitted by workers that cannot continue a bounded file listing. */ + listFileFeatures?: WorkspaceListFileFeature[]; } export interface WorkspaceReadFileRequest { @@ -426,6 +429,8 @@ export interface BridgeWorkerRegistrationResponse { supportedWorkspaceEditFileModes?: WorkspaceEditFileMode[]; /** Edit features this Code API can safely route to a capability-aware worker. */ supportedWorkspaceEditFileFeatures?: WorkspaceEditFileFeature[]; + /** Listing features this Code API can safely route to a capability-aware worker. */ + supportedWorkspaceListFileFeatures?: WorkspaceListFileFeature[]; } export interface BridgePairingRedemption { @@ -591,17 +596,24 @@ function normalizePortableRelativePath(value: string): string { ); } -/** Compare portable paths by their UTF-8 wire representation on every worker OS. */ +/** Compare path segments in ripgrep's sorted, depth-first traversal order. */ export function comparePortableRelativePaths(left: string, right: string): number { const encoder = new TextEncoder(); - const leftBytes = encoder.encode(left); - const rightBytes = encoder.encode(right); - const sharedLength = Math.min(leftBytes.length, rightBytes.length); - for (let index = 0; index < sharedLength; index += 1) { - const difference = leftBytes[index] - rightBytes[index]; - if (difference !== 0) return difference; + const leftSegments = left.split('/'); + const rightSegments = right.split('/'); + const segmentCount = Math.min(leftSegments.length, rightSegments.length); + for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) { + const leftBytes = encoder.encode(leftSegments[segmentIndex]); + const rightBytes = encoder.encode(rightSegments[segmentIndex]); + const byteCount = Math.min(leftBytes.length, rightBytes.length); + for (let byteIndex = 0; byteIndex < byteCount; byteIndex += 1) { + const difference = leftBytes[byteIndex] - rightBytes[byteIndex]; + if (difference !== 0) return difference; + } + const lengthDifference = leftBytes.length - rightBytes.length; + if (lengthDifference !== 0) return lengthDifference; } - return leftBytes.length - rightBytes.length; + return leftSegments.length - rightSegments.length; } function isWithinRequestedPath(candidate: string, requested?: string): boolean { @@ -861,6 +873,7 @@ export function isWorkspaceToolResult( } const normalizedPath = normalizePortableRelativePath(path); if ( + normalizedPath !== path || normalizedPaths.has(normalizedPath) || (previousPath !== undefined && comparePortableRelativePaths(normalizedPath, previousPath) <= 0) @@ -1043,6 +1056,16 @@ export function isValidBridgeWorkspaceToolCapabilities( return false; } + if ( + capabilities.listFileFeatures !== undefined && + (!Array.isArray(capabilities.listFileFeatures) || + capabilities.listFileFeatures.length !== 1 || + !capabilities.operations.includes('list_files') || + capabilities.listFileFeatures[0] !== 'after_path') + ) { + return false; + } + const workspaceIds = new Set(); return capabilities.workspaces.every((workspace) => { if (typeof workspace !== 'object' || workspace === null) return false; diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 2c8c05ef..b83bec73 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -151,6 +151,11 @@ function workspaceCapabilitiesMatch( (advertised.editFileFeatures?.every( (feature, index) => feature === executor.editFileFeatures?.[index], ) ?? executor.editFileFeatures == null) && + advertised.listFileFeatures?.length === + executor.listFileFeatures?.length && + (advertised.listFileFeatures?.every( + (feature, index) => feature === executor.listFileFeatures?.[index], + ) ?? executor.listFileFeatures == null) && advertised.workspaces.length === executor.workspaces.length && advertised.workspaces.every( (workspace, index) => @@ -209,6 +214,7 @@ function registrationCompatibleCapabilities( writeFileModes: _writeFileModes, editFileModes: _editFileModes, editFileFeatures: _editFileFeatures, + listFileFeatures: _listFileFeatures, ...compatibleWorkspaceTools } = workspaceTools; return { @@ -285,10 +291,14 @@ function supportedWorkspaceCapabilities( const editFileFeatures = desired.editFileFeatures?.filter((feature) => registration.supportedWorkspaceEditFileFeatures?.includes(feature), ); + const listFileFeatures = desired.listFileFeatures?.filter((feature) => + registration.supportedWorkspaceListFileFeatures?.includes(feature), + ); const { writeFileModes: _writeFileModes, editFileModes: _editFileModes, editFileFeatures: _editFileFeatures, + listFileFeatures: _listFileFeatures, ...compatibleDesired } = desired; return { @@ -306,6 +316,9 @@ function supportedWorkspaceCapabilities( ...(operations.includes('edit_file') && editFileFeatures?.length ? { editFileFeatures } : {}), + ...(operations.includes('list_files') && listFileFeatures?.length + ? { listFileFeatures } + : {}), }, }; } @@ -989,6 +1002,15 @@ export class BridgeWorker { ); } } + if ( + workspaceRequest.operation === 'list_files' && + workspaceRequest.afterPath !== undefined && + !advertised.listFileFeatures?.includes('after_path') + ) { + throw new BridgeProtocolError( + 'Workspace listing feature is not advertised', + ); + } const isMutation = workspaceRequest.operation === 'write_file' || workspaceRequest.operation === 'edit_file' || @@ -1012,6 +1034,17 @@ export class BridgeWorker { workspaceRequest, executionController.signal, ); + if ( + workspaceRequest.operation === 'list_files' && + !advertised.listFileFeatures?.includes('after_path') && + 'nextAfterPath' in payload + ) { + const { + nextAfterPath: _nextAfterPath, + ...compatiblePayload + } = payload; + payload = compatiblePayload; + } workspaceMutationApplied = isMutation; if (isMutation && !isWorkspaceToolResult(workspaceRequest, payload)) { throw new BridgeProtocolError( diff --git a/packages/code/src/workspace-worker.test.ts b/packages/code/src/workspace-worker.test.ts index 3f2931d7..098ee764 100644 --- a/packages/code/src/workspace-worker.test.ts +++ b/packages/code/src/workspace-worker.test.ts @@ -15,9 +15,13 @@ const listWorkspaceCapabilities = { 'list_files' as const, ], workspaces: [{ id: 'primary' }], + listFileFeatures: ['after_path' as const], }; -function registrationResponse(supportsList: boolean): Response { +function registrationResponse( + supportsList: boolean, + supportsPagination = false, +): Response { return Response.json({ protocolVersion: 1, workerId: 'vm-1', @@ -31,6 +35,9 @@ function registrationResponse(supportsList: boolean): Response { 'search_text', 'list_files', ], + ...(supportsPagination + ? { supportedWorkspaceListFileFeatures: ['after_path'] } + : {}), } : {}), }); @@ -131,6 +138,67 @@ test('worker re-registers list_files after the Code API advertises support', asy ]); }); +test('worker omits pagination fields until Code API negotiates them', async () => { + let settlement: Record | undefined; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', + token: 'worker-secret', + workerId: 'vm-1', + incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: listWorkspaceCapabilities, + }, + workspaceTools: { + capabilities: listWorkspaceCapabilities, + async execute() { + return { + protocolVersion: 1 as const, + operation: 'list_files' as const, + workspaceId: 'primary', + paths: ['first.txt'], + truncated: true, + nextAfterPath: 'first.txt', + }; + }, + }, + fetchImpl: async (input, init) => { + if (String(input).endsWith('/register')) return registrationResponse(true); + settlement = JSON.parse(String(init?.body)) as Record; + return Response.json({ protocolVersion: 1, accepted: true }); + }, + }); + + await worker.register(); + await worker.executeAndSettle({ + protocolVersion: 1, + assignmentId: 'assignment-list-legacy-consumer', + workerId: 'vm-1', + incarnationId, + generation: 1, + leaseToken: 'lease-token-that-is-long-enough-for-testing', + expiresAt: new Date(Date.now() + 5_000).toISOString(), + executionKind: 'workspace_tool', + request: { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + maxResults: 1, + }, + }); + + assert.deepEqual(settlement?.result, { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['first.txt'], + truncated: true, + }); +}); + test('worker omits restricted workspaces that legacy registration would widen', async () => { const registrations: Array<{ operations: string[]; diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index f0ba9d94..25894773 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -280,6 +280,54 @@ test('continues a workspace listing beyond the protocol result ceiling', async ( ); }); +test( + 'continues listings across directory and file prefix siblings', + async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, 'src')); + await writeFile(join(root, 'src', 'app.ts'), 'nested'); + await writeFile(join(root, 'src.ts'), 'sibling'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const firstPage = await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + maxResults: 1, + }); + assert.deepEqual(firstPage, { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['src/app.ts'], + truncated: true, + nextAfterPath: 'src/app.ts', + }); + if (firstPage.operation !== 'list_files') { + assert.fail('expected list result'); + } + assert.deepEqual( + await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + maxResults: 1, + afterPath: firstPage.nextAfterPath, + }), + { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['src.ts'], + truncated: false, + }, + ); + }, +); + test('rejects listing through a directory symlink that leaves the workspace', async (t) => { const parent = await mkdtemp( join(tmpdir(), 'librechat-code-workspace-parent-'), @@ -826,6 +874,7 @@ test('advertises workspace IDs and names without exposing host roots', async (t) protocolVersion: 1, operations: ['read_file', 'search_text', 'list_files'], workspaces: [{ id: 'primary', name: 'LibreChat' }], + listFileFeatures: ['after_path'], }); assert.equal(JSON.stringify(tools.capabilities).includes(root), false); }); @@ -884,6 +933,7 @@ test('writable workspaces create, replace, and exactly edit files', async (t) => writeFileModes: ['replace', 'create'], editFileModes: ['single', 'batch'], editFileFeatures: ['expected_base_sha256'], + listFileFeatures: ['after_path'], }); await tools.execute({ protocolVersion: 1, @@ -1874,6 +1924,7 @@ test('composes sandboxed commands without exposing them on unconfigured workspac assert.deepEqual(tools.capabilities.editFileFeatures, [ 'expected_base_sha256', ]); + assert.deepEqual(tools.capabilities.listFileFeatures, ['after_path']); assert.deepEqual( tools.capabilities.workspaces.find(({ id }) => id === 'sandboxed')?.operations, tools.capabilities.operations, diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index c9900f25..727955cd 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -1415,6 +1415,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { writeFileModes?: BridgeWorkspaceToolCapabilities['writeFileModes'], editFileModes?: BridgeWorkspaceToolCapabilities['editFileModes'], editFileFeatures?: BridgeWorkspaceToolCapabilities['editFileFeatures'], + listFileFeatures?: BridgeWorkspaceToolCapabilities['listFileFeatures'], ) { this.capabilities = { protocolVersion: BRIDGE_PROTOCOL_VERSION, @@ -1423,6 +1424,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { ...(writeFileModes != null ? { writeFileModes } : {}), ...(editFileModes != null ? { editFileModes } : {}), ...(editFileFeatures != null ? { editFileFeatures } : {}), + ...(listFileFeatures != null ? { listFileFeatures } : {}), }; } @@ -1460,6 +1462,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { ...(anyWritable ? { editFileFeatures: ['expected_base_sha256'] } : {}), + listFileFeatures: ['after_path'], }; if (!isValidBridgeWorkspaceToolCapabilities(capabilities)) { throw new WorkspaceToolError( @@ -1490,6 +1493,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { capabilities.writeFileModes, capabilities.editFileModes, capabilities.editFileFeatures, + capabilities.listFileFeatures, ); } @@ -1630,6 +1634,9 @@ export class SandboxWorkspaceTools implements WorkspaceToolExecutor { ...(base.editFileFeatures != null ? { editFileFeatures: base.editFileFeatures } : {}), + ...(base.listFileFeatures != null + ? { listFileFeatures: base.listFileFeatures } + : {}), workspaces: base.workspaces.map((workspace) => ({ ...workspace, operations: [ diff --git a/service/src/bridge/router.test.ts b/service/src/bridge/router.test.ts index dca0d6a5..11b0eb31 100644 --- a/service/src/bridge/router.test.ts +++ b/service/src/bridge/router.test.ts @@ -291,6 +291,7 @@ describe('paired bridge HTTP API', () => { supportedWorkspaceWriteFileModes: ['replace', 'create'], supportedWorkspaceEditFileModes: ['single', 'batch'], supportedWorkspaceEditFileFeatures: ['expected_base_sha256'], + supportedWorkspaceListFileFeatures: ['after_path'], }); const crossDeploymentRevoke = await fetch( diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index 15d8b679..7fa87b93 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -391,6 +391,7 @@ router.post( supportedWorkspaceWriteFileModes: ['replace', 'create'], supportedWorkspaceEditFileModes: ['single', 'batch'], supportedWorkspaceEditFileFeatures: ['expected_base_sha256'], + supportedWorkspaceListFileFeatures: ['after_path'], }); } catch (error) { if (error instanceof BridgeStoreError) { diff --git a/service/src/bridge/store.ts b/service/src/bridge/store.ts index 57588e59..1171b8b7 100644 --- a/service/src/bridge/store.ts +++ b/service/src/bridge/store.ts @@ -85,6 +85,9 @@ function supportsWorkspaceTool( if (!supportsOperation) { return supportsOperation; } + if (request.operation === 'list_files' && request.afterPath !== undefined) { + return capabilities?.listFileFeatures?.includes('after_path') === true; + } if (request.operation === 'write_file') { if (request.overwrite === undefined) return true; const mode = request.overwrite === false ? 'create' : 'replace'; @@ -508,22 +511,29 @@ export class RedisBridgeStore { 'Invalid workspace tool request', ); } - const settlement = (await this.dispatch({ + return (await this.dispatch({ ...args, body: {} as t.PayloadBody, headers: {}, workspaceRequest: args.request, + finalize: async (settlement, registration) => { + if ( + settlement.status === 'fulfilled' && + (!isWorkspaceToolResult(args.request, settlement.result) || + (args.request.operation === 'list_files' && + 'nextAfterPath' in settlement.result && + !registration.capabilities.workspaceTools?.listFileFeatures?.includes( + 'after_path', + ))) + ) { + throw new BridgeStoreError( + 'RESULT_INVALID', + 'Bridge worker returned an invalid workspace tool result', + ); + } + return settlement; + }, })) as unknown as CodeBridgeWorkspaceSettlement; - if ( - settlement.status === 'fulfilled' && - !isWorkspaceToolResult(args.request, settlement.result) - ) { - throw new BridgeStoreError( - 'RESULT_INVALID', - 'Bridge worker returned an invalid workspace tool result', - ); - } - return settlement; } async dispatch(args: { @@ -538,6 +548,7 @@ export class RedisBridgeStore { signal: AbortSignal; finalize?: ( settlement: CodeBridgeSettlement, + registration: RegisteredBridgeWorker, ) => Promise; }): Promise { this.assertDispatchActive(args.signal, args.deadlineAtMs); @@ -717,7 +728,7 @@ export class RedisBridgeStore { const result = args.finalize == null ? settlement - : await args.finalize(settlement); + : await args.finalize(settlement, registration); await this.commitPendingWorkspace( assignment, settlement, diff --git a/service/src/bridge/workspace-store.test.ts b/service/src/bridge/workspace-store.test.ts index 55325ca3..270fc7b1 100644 --- a/service/src/bridge/workspace-store.test.ts +++ b/service/src/bridge/workspace-store.test.ts @@ -44,7 +44,11 @@ test('dispatches a workspace tool only to a worker advertising its workspace and signal: new AbortController().signal, }); - const assignment = await store.lease('workspace-worker', incarnationId, 1_000); + const assignment = await store.lease( + 'workspace-worker', + incarnationId, + 1_000, + ); expect(assignment).toMatchObject({ executionKind: 'workspace_tool', request, @@ -106,6 +110,88 @@ test('rejects a workspace tool that the selected worker did not advertise', asyn expect(await redis.keys('codeapi:bridge:v1:assignment:*')).toHaveLength(0); }); +test('rejects listing continuation without the negotiated feature', async () => { + await store.register({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'workspace-worker', + incarnationId, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['list_files'], + workspaces: [{ id: 'primary' }], + }, + }, + }); + + await expect( + store.dispatchWorkspaceTool({ + workerId: 'workspace-worker', + request: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'list_files', + workspaceId: 'primary', + maxResults: 10, + afterPath: 'src/app.ts', + }, + deadlineAtMs: Date.now() + 1_000, + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ code: 'WORKER_MISMATCH' }); + expect(await redis.keys('codeapi:bridge:v1:assignment:*')).toHaveLength(0); +}); + +test('rejects pagination fields from a worker without the negotiated feature', async () => { + await store.register({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'workspace-worker', + incarnationId, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['list_files'], + workspaces: [{ id: 'primary' }], + }, + }, + }); + const completion = store.dispatchWorkspaceTool({ + workerId: 'workspace-worker', + request: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'list_files', + workspaceId: 'primary', + maxResults: 1, + }, + deadlineAtMs: Date.now() + 5_000, + signal: new AbortController().signal, + }); + + const assignment = await store.lease('workspace-worker', incarnationId, 1_000); + await store.settle('workspace-worker', assignment?.assignmentId ?? '', { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + generation: assignment?.generation ?? 0, + leaseToken: assignment?.leaseToken ?? '', + incarnationId, + status: 'fulfilled', + result: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'list_files', + workspaceId: 'primary', + paths: ['first.txt'], + truncated: true, + nextAfterPath: 'first.txt', + }, + }); + + await expect(completion).rejects.toMatchObject({ code: 'RESULT_INVALID' }); +}); + test('rejects an operation omitted from the selected workspace capability', async () => { await store.register({ protocolVersion: BRIDGE_PROTOCOL_VERSION, From 079350b6f50402b61622c6820474a81158e09c31 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 4 Sep 2026 09:57:16 -0400 Subject: [PATCH 13/14] fix(code): preserve negotiated workspace defaults --- packages/code/src/protocol.test.ts | 16 ++++ packages/code/src/protocol.ts | 16 +++- packages/code/src/worker.ts | 11 +-- packages/code/src/workspace-worker.test.ts | 58 ++++++++++++++ service/src/bridge/store.ts | 17 +++-- service/src/bridge/workspace-store.test.ts | 89 ++++++++++++++++++++++ 6 files changed, 190 insertions(+), 17 deletions(-) diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index b159c35e..6bb7eb17 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -261,6 +261,22 @@ test('workspace file listing accepts only bounded portable requests and results' nextAfterPath: 'src/z.ts', }; assert.equal(isWorkspaceToolResult(request, result), true); + assert.equal( + isWorkspaceToolResult( + { ...request, afterPath: undefined }, + { ...result, nextAfterPath: undefined }, + {}, + ), + true, + ); + assert.equal( + isWorkspaceToolResult( + { ...request, afterPath: undefined }, + { ...result, nextAfterPath: undefined }, + { listFileFeatures: ['after_path'] }, + ), + false, + ); assert.equal( isWorkspaceToolResult(request, { ...result, diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 33add7c1..31106ccf 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -800,6 +800,7 @@ export function isWorkspaceToolRequest( export function isWorkspaceToolResult( request: WorkspaceToolRequest, value: unknown, + capabilities?: Pick, ): value is WorkspaceToolResult { if (typeof value !== 'object' || value === null) return false; const result = value as Record; @@ -883,10 +884,17 @@ export function isWorkspaceToolResult( normalizedPaths.add(normalizedPath); previousPath = normalizedPath; } - return result.truncated === true - ? result.paths.length > 0 && - result.nextAfterPath === result.paths[result.paths.length - 1] - : result.nextAfterPath === undefined; + if (result.truncated !== true) return result.nextAfterPath === undefined; + if (!capabilities?.listFileFeatures?.includes('after_path')) { + return capabilities === undefined + ? result.paths.length > 0 && + result.nextAfterPath === result.paths[result.paths.length - 1] + : result.nextAfterPath === undefined; + } + return ( + result.paths.length > 0 && + result.nextAfterPath === result.paths[result.paths.length - 1] + ); } if (request.operation === 'write_file') { diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index b83bec73..657ba726 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -966,13 +966,14 @@ export class BridgeWorker { 'Workspace tool operation is not advertised for workspace', ); } - if ( - workspaceRequest.operation === 'write_file' && - workspaceRequest.overwrite !== undefined - ) { + if (workspaceRequest.operation === 'write_file') { const mode = workspaceRequest.overwrite === false ? 'create' : 'replace'; - if (!advertised.writeFileModes?.includes(mode)) { + const modes = advertised.writeFileModes; + if ( + (workspaceRequest.overwrite !== undefined && modes == null) || + (modes != null && !modes.includes(mode)) + ) { throw new BridgeProtocolError( 'Workspace write mode is not advertised', ); diff --git a/packages/code/src/workspace-worker.test.ts b/packages/code/src/workspace-worker.test.ts index 098ee764..43c820fe 100644 --- a/packages/code/src/workspace-worker.test.ts +++ b/packages/code/src/workspace-worker.test.ts @@ -2416,3 +2416,61 @@ test('worker rejects workspace operations outside its advertised capability', as assert.equal(settlement?.status, 'rejected'); assert.match(String(settlement?.error), /operation is not advertised/i); }); + +test('worker rejects legacy replacement writes outside its advertised mode', async () => { + let executions = 0; + let settlement: Record | undefined; + const workspaceCapabilities = { + protocolVersion: 1 as const, + operations: ['write_file' as const], + writeFileModes: ['create' as const], + workspaces: [{ id: 'primary' }], + }; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', + token: 'worker-secret', + workerId: 'vm-1', + incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: workspaceCapabilities, + }, + workspaceTools: { + capabilities: workspaceCapabilities, + async execute() { + executions += 1; + throw new Error('must not execute'); + }, + }, + workspaceMutationQuarantine: mutationQuarantine(), + fetchImpl: async (_input, init) => { + settlement = JSON.parse(String(init?.body)) as Record; + return Response.json({ protocolVersion: 1, accepted: true }); + }, + }); + + await worker.executeAndSettle({ + protocolVersion: 1, + assignmentId: 'assignment-workspace-replace-mode', + workerId: 'vm-1', + incarnationId, + generation: 4, + leaseToken: 'lease-token-that-is-long-enough-for-testing', + expiresAt: new Date(Date.now() + 5_000).toISOString(), + executionKind: 'workspace_tool', + request: { + protocolVersion: 1, + operation: 'write_file', + workspaceId: 'primary', + path: 'notes.txt', + content: 'blocked', + }, + }); + + assert.equal(executions, 0); + assert.equal(settlement?.status, 'rejected'); + assert.match(String(settlement?.error), /write mode is not advertised/i); +}); diff --git a/service/src/bridge/store.ts b/service/src/bridge/store.ts index 1171b8b7..03694b1b 100644 --- a/service/src/bridge/store.ts +++ b/service/src/bridge/store.ts @@ -89,9 +89,11 @@ function supportsWorkspaceTool( return capabilities?.listFileFeatures?.includes('after_path') === true; } if (request.operation === 'write_file') { - if (request.overwrite === undefined) return true; const mode = request.overwrite === false ? 'create' : 'replace'; - return capabilities?.writeFileModes?.includes(mode) === true; + const modes = capabilities?.writeFileModes; + return request.overwrite === undefined && modes == null + ? true + : modes?.includes(mode) === true; } if ( request.operation === 'preview_edit' || @@ -519,12 +521,11 @@ export class RedisBridgeStore { finalize: async (settlement, registration) => { if ( settlement.status === 'fulfilled' && - (!isWorkspaceToolResult(args.request, settlement.result) || - (args.request.operation === 'list_files' && - 'nextAfterPath' in settlement.result && - !registration.capabilities.workspaceTools?.listFileFeatures?.includes( - 'after_path', - ))) + !isWorkspaceToolResult( + args.request, + settlement.result, + registration.capabilities.workspaceTools, + ) ) { throw new BridgeStoreError( 'RESULT_INVALID', diff --git a/service/src/bridge/workspace-store.test.ts b/service/src/bridge/workspace-store.test.ts index 270fc7b1..1fd9cc16 100644 --- a/service/src/bridge/workspace-store.test.ts +++ b/service/src/bridge/workspace-store.test.ts @@ -192,6 +192,60 @@ test('rejects pagination fields from a worker without the negotiated feature', a await expect(completion).rejects.toMatchObject({ code: 'RESULT_INVALID' }); }); +test('accepts a legacy truncated listing without a pagination cursor', async () => { + await store.register({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'workspace-worker', + incarnationId, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['list_files'], + workspaces: [{ id: 'primary' }], + }, + }, + }); + const completion = store.dispatchWorkspaceTool({ + workerId: 'workspace-worker', + request: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'list_files', + workspaceId: 'primary', + maxResults: 1, + }, + deadlineAtMs: Date.now() + 5_000, + signal: new AbortController().signal, + }); + + const assignment = await store.lease( + 'workspace-worker', + incarnationId, + 1_000, + ); + await store.settle('workspace-worker', assignment?.assignmentId ?? '', { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + generation: assignment?.generation ?? 0, + leaseToken: assignment?.leaseToken ?? '', + incarnationId, + status: 'fulfilled', + result: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'list_files', + workspaceId: 'primary', + paths: ['first.txt'], + truncated: true, + }, + }); + + await expect(completion).resolves.toMatchObject({ + status: 'fulfilled', + result: { paths: ['first.txt'], truncated: true }, + }); +}); + test('rejects an operation omitted from the selected workspace capability', async () => { await store.register({ protocolVersion: BRIDGE_PROTOCOL_VERSION, @@ -264,6 +318,41 @@ test('rejects create-only writes from workers without the negotiated mode', asyn expect(await redis.keys('codeapi:bridge:v1:assignment:*')).toHaveLength(0); }); +test('rejects legacy replacement writes from create-only workers', async () => { + await store.register({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'workspace-worker', + incarnationId, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['write_file'], + writeFileModes: ['create'], + workspaces: [{ id: 'primary' }], + }, + }, + }); + + await expect( + store.dispatchWorkspaceTool({ + workerId: 'workspace-worker', + request: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'write_file', + workspaceId: 'primary', + path: 'notes.txt', + content: 'replace me', + }, + deadlineAtMs: Date.now() + 1_000, + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ code: 'WORKER_MISMATCH' }); + expect(await redis.keys('codeapi:bridge:v1:assignment:*')).toHaveLength(0); +}); + test('rejects batch edits from workers without the negotiated mode', async () => { await store.register({ protocolVersion: BRIDGE_PROTOCOL_VERSION, From 54ef47e91cc57bacfa96e07290438f789276b910 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 4 Sep 2026 10:05:52 -0400 Subject: [PATCH 14/14] fix(code): preserve legacy list result ordering --- packages/code/src/protocol.test.ts | 12 ++++++++++-- packages/code/src/protocol.ts | 19 ++++++++++--------- service/src/bridge/workspace-store.test.ts | 6 +++--- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index 6bb7eb17..fd426783 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -264,7 +264,11 @@ test('workspace file listing accepts only bounded portable requests and results' assert.equal( isWorkspaceToolResult( { ...request, afterPath: undefined }, - { ...result, nextAfterPath: undefined }, + { + ...result, + paths: ['src//z.ts', 'src/worker.ts'], + nextAfterPath: undefined, + }, {}, ), true, @@ -272,7 +276,11 @@ test('workspace file listing accepts only bounded portable requests and results' assert.equal( isWorkspaceToolResult( { ...request, afterPath: undefined }, - { ...result, nextAfterPath: undefined }, + { + ...result, + paths: ['src//z.ts', 'src/worker.ts'], + nextAfterPath: undefined, + }, { listFileFeatures: ['after_path'] }, ), false, diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 31106ccf..3bbd83cb 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -864,6 +864,9 @@ export function isWorkspaceToolResult( request.afterPath === undefined ? undefined : normalizePortableRelativePath(request.afterPath); + const enforcesPaginationContract = + capabilities === undefined || + capabilities.listFileFeatures?.includes('after_path') === true; let previousPath = normalizedAfterPath; for (const path of result.paths) { if ( @@ -874,23 +877,21 @@ export function isWorkspaceToolResult( } const normalizedPath = normalizePortableRelativePath(path); if ( - normalizedPath !== path || normalizedPaths.has(normalizedPath) || - (previousPath !== undefined && - comparePortableRelativePaths(normalizedPath, previousPath) <= 0) + (enforcesPaginationContract && + (normalizedPath !== path || + (previousPath !== undefined && + comparePortableRelativePaths(normalizedPath, previousPath) <= 0))) ) { return false; } normalizedPaths.add(normalizedPath); previousPath = normalizedPath; } - if (result.truncated !== true) return result.nextAfterPath === undefined; - if (!capabilities?.listFileFeatures?.includes('after_path')) { - return capabilities === undefined - ? result.paths.length > 0 && - result.nextAfterPath === result.paths[result.paths.length - 1] - : result.nextAfterPath === undefined; + if (!enforcesPaginationContract) { + return result.nextAfterPath === undefined; } + if (result.truncated !== true) return result.nextAfterPath === undefined; return ( result.paths.length > 0 && result.nextAfterPath === result.paths[result.paths.length - 1] diff --git a/service/src/bridge/workspace-store.test.ts b/service/src/bridge/workspace-store.test.ts index 1fd9cc16..a23274d8 100644 --- a/service/src/bridge/workspace-store.test.ts +++ b/service/src/bridge/workspace-store.test.ts @@ -214,7 +214,7 @@ test('accepts a legacy truncated listing without a pagination cursor', async () protocolVersion: BRIDGE_PROTOCOL_VERSION, operation: 'list_files', workspaceId: 'primary', - maxResults: 1, + maxResults: 2, }, deadlineAtMs: Date.now() + 5_000, signal: new AbortController().signal, @@ -235,14 +235,14 @@ test('accepts a legacy truncated listing without a pagination cursor', async () protocolVersion: BRIDGE_PROTOCOL_VERSION, operation: 'list_files', workspaceId: 'primary', - paths: ['first.txt'], + paths: ['src//z.ts', 'src/a.ts'], truncated: true, }, }); await expect(completion).resolves.toMatchObject({ status: 'fulfilled', - result: { paths: ['first.txt'], truncated: true }, + result: { paths: ['src//z.ts', 'src/a.ts'], truncated: true }, }); });