From bc23f02d9238b154aaced841c865bcfa96104e44 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 13:03:53 -0400 Subject: [PATCH 1/6] feat(code): add confined local workspace tools --- packages/code/README.md | 22 ++ packages/code/package.json | 4 + packages/code/src/index.ts | 1 + packages/code/src/protocol.test.ts | 35 ++ packages/code/src/protocol.ts | 62 +++- packages/code/src/workspace.test.ts | 248 ++++++++++++++ packages/code/src/workspace.ts | 502 ++++++++++++++++++++++++++++ 7 files changed, 873 insertions(+), 1 deletion(-) create mode 100644 packages/code/src/workspace.test.ts create mode 100644 packages/code/src/workspace.ts diff --git a/packages/code/README.md b/packages/code/README.md index caf129b5..18d8c7b0 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -188,6 +188,28 @@ accepting another assignment. Reset or discard that session's local runner before restarting the worker; its workspace may contain mutations that Code API did not commit. +## Local workspace tools (library preview) + +`@librechat/code/workspace` provides the provider-neutral foundation for +coding-agent access to repositories that already live on the worker machine. +`LocalWorkspaceTools` registers opaque workspace IDs with optional display +names and exposes bounded `read_file` and literal `search_text` operations. +Only IDs, names, protocol version, and supported operations appear in worker +capabilities; absolute host paths remain local to the worker process. + +Reads reject absolute paths, traversal, escaping symlinks, non-regular files, +and files larger than 1 MiB. The opened file is checked against its canonical +in-workspace inode before it is read. Text search invokes `rg` without a shell, +respects its normal ignore rules, does not follow symlinks, limits individual +files to 10 MiB, limits returned line length, and stops after a bounded global +result count. The worker process still belongs inside the trusted BYOM boundary +and should receive filesystem access only to roots the operator intentionally +registers. + +This release exposes the library protocol only. The subsequent bridge-dispatch +layer will route signed, deadline-bound workspace tool assignments to it; until +that layer is configured, the CLI does not advertise or execute these tools. + After discarding or resetting that session's local runner, acknowledge recovery with `librechat-code reset-workspace `. The command uses the configured worker credentials, registers a fresh incarnation, and only clears diff --git a/packages/code/package.json b/packages/code/package.json index 0a4257d4..2bce9bc6 100644 --- a/packages/code/package.json +++ b/packages/code/package.json @@ -22,6 +22,10 @@ "./runtime": { "types": "./dist/runtime.d.ts", "import": "./dist/runtime.js" + }, + "./workspace": { + "types": "./dist/workspace.d.ts", + "import": "./dist/workspace.js" } }, "bin": { diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 0f142908..a7c0a830 100644 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -3,4 +3,5 @@ export * from './identity.js'; export * from './pairing.js'; export * from './storage.js'; export * from './runtime.js'; +export * from './workspace.js'; export * from './worker.js'; diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index 61c50922..793c7d0f 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -54,3 +54,38 @@ test('bridge worker capabilities enforce registration limits', () => { false, ); }); + +test('bridge worker capabilities accept only bounded public workspace descriptors', () => { + const valid = { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: 1, + operations: ['read_file', 'search_text'], + workspaces: [{ id: 'primary', name: 'LibreChat' }], + }, + }; + + assert.equal(isValidBridgeWorkerCapabilities(valid), true); + assert.equal( + isValidBridgeWorkerCapabilities({ + ...valid, + workspaceTools: { + ...valid.workspaceTools, + workspaces: [{ id: 'primary', root: '/Users/operator/private' }], + }, + }), + false, + ); + assert.equal( + isValidBridgeWorkerCapabilities({ + ...valid, + workspaceTools: { + ...valid.workspaceTools, + workspaces: [{ id: '../escape' }], + }, + }), + false, + ); +}); diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index a9ff5649..8a069c36 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -3,15 +3,31 @@ export const BRIDGE_WORKER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; export const BRIDGE_SANDBOX_PROFILE_MAX_LENGTH = 128; export const BRIDGE_RUNTIME_MAX_COUNT = 32; export const BRIDGE_RUNTIME_MAX_LENGTH = 64; +export const BRIDGE_WORKSPACE_MAX_COUNT = 32; +export const BRIDGE_WORKSPACE_NAME_MAX_LENGTH = 128; export type BridgeProtocolVersion = typeof BRIDGE_PROTOCOL_VERSION; +export type BridgeWorkspaceToolOperation = 'read_file' | 'search_text'; + +export interface BridgeWorkspaceDescriptor { + id: string; + name?: string; +} + +export interface BridgeWorkspaceToolCapabilities { + protocolVersion: BridgeProtocolVersion; + operations: BridgeWorkspaceToolOperation[]; + workspaces: BridgeWorkspaceDescriptor[]; +} + export interface BridgeWorkerCapabilities { statefulWorkspace: boolean; sandboxProfile: string; runtimes: string[]; policyDigest?: string; requiresReadyConfirmation?: boolean; + workspaceTools?: BridgeWorkspaceToolCapabilities; } export interface BridgeWorkerRegistration { @@ -121,6 +137,48 @@ export function isValidBridgeWorkerId(workerId: string): boolean { return BRIDGE_WORKER_ID_PATTERN.test(workerId); } +export function isValidBridgeWorkspaceToolCapabilities( + value: unknown, +): value is BridgeWorkspaceToolCapabilities { + if (typeof value !== 'object' || value === null) return false; + const capabilities = value as Record; + if ( + capabilities.protocolVersion !== BRIDGE_PROTOCOL_VERSION || + !Array.isArray(capabilities.operations) || + capabilities.operations.length < 1 || + capabilities.operations.length > 2 || + !capabilities.operations.every( + (operation) => operation === 'read_file' || operation === 'search_text', + ) || + new Set(capabilities.operations).size !== capabilities.operations.length || + !Array.isArray(capabilities.workspaces) || + capabilities.workspaces.length < 1 || + capabilities.workspaces.length > BRIDGE_WORKSPACE_MAX_COUNT + ) { + return false; + } + + const workspaceIds = new Set(); + return capabilities.workspaces.every((workspace) => { + if (typeof workspace !== 'object' || workspace === null) return false; + const descriptor = workspace as Record; + if ( + Object.keys(descriptor).some((key) => key !== 'id' && key !== 'name') || + typeof descriptor.id !== 'string' || + !isValidBridgeWorkerId(descriptor.id) || + workspaceIds.has(descriptor.id) || + (descriptor.name !== undefined && + (typeof descriptor.name !== 'string' || + descriptor.name.trim().length === 0 || + descriptor.name.length > BRIDGE_WORKSPACE_NAME_MAX_LENGTH)) + ) { + return false; + } + workspaceIds.add(descriptor.id); + return true; + }); +} + export function isValidBridgeWorkerCapabilities( value: unknown, ): value is BridgeWorkerCapabilities { @@ -143,6 +201,8 @@ export function isValidBridgeWorkerCapabilities( (typeof capabilities.policyDigest === 'string' && /^[a-f0-9]{64}$/.test(capabilities.policyDigest))) && (capabilities.requiresReadyConfirmation === undefined || - typeof capabilities.requiresReadyConfirmation === 'boolean') + typeof capabilities.requiresReadyConfirmation === 'boolean') && + (capabilities.workspaceTools === undefined || + isValidBridgeWorkspaceToolCapabilities(capabilities.workspaceTools)) ); } diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts new file mode 100644 index 00000000..34b11320 --- /dev/null +++ b/packages/code/src/workspace.test.ts @@ -0,0 +1,248 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { LocalWorkspaceTools } from './workspace.js'; + +test('reads a bounded range from a registered local workspace', 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'), + 'first\nsecond\nthird\nfourth\n', + ); + + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + assert.deepEqual( + await tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + startLine: 2, + maxLines: 2, + }), + { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + content: 'second\nthird', + startLine: 2, + endLine: 3, + truncated: true, + nextStartLine: 4, + }, + ); +}); + +test('rejects traversal outside a registered workspace without leaking its host path', async (t) => { + const parent = await mkdtemp( + join(tmpdir(), 'librechat-code-workspace-parent-'), + ); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(parent, 'repo'); + await mkdir(root); + await writeFile(join(parent, 'secret.txt'), 'host secret'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: '../secret.txt', + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /invalid workspace path/i); + assert.equal(error.message.includes(parent), false); + return true; + }, + ); +}); + +test('rejects a symlink that escapes a registered workspace', async (t) => { + const parent = await mkdtemp( + join(tmpdir(), 'librechat-code-workspace-parent-'), + ); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(parent, 'repo'); + await mkdir(root); + await writeFile(join(parent, 'secret.txt'), 'host secret'); + await symlink(join(parent, 'secret.txt'), join(root, 'linked-secret.txt')); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'linked-secret.txt', + }), + /invalid workspace path/i, + ); +}); + +test('searches workspace text with a hard global result bound', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'notes.txt'), 'needle one\nignore\nneedle two\n'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + assert.deepEqual( + await tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + maxResults: 1, + }), + { + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + matches: [{ path: 'notes.txt', line: 1, column: 1, text: 'needle one' }], + truncated: true, + }, + ); +}); + +test('advertises workspace IDs and names without exposing host roots', async (t) => { + 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', name: 'LibreChat', root }], + }); + + assert.deepEqual(tools.capabilities, { + protocolVersion: 1, + operations: ['read_file', 'search_text'], + workspaces: [{ id: 'primary', name: 'LibreChat' }], + }); + assert.equal(JSON.stringify(tools.capabilities).includes(root), false); +}); + +test('rejects unbounded file read parameters before reading the file', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'notes.txt'), 'safe'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'notes.txt', + startLine: 0, + }), + /invalid workspace read/i, + ); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'notes.txt', + maxLines: 501, + }), + /invalid workspace read/i, + ); +}); + +test('bounds bytes read from a workspace file', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'large.txt'), Buffer.alloc(1024 * 1024 + 1, 'a')); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'large.txt', + }), + /workspace file exceeds read limit/i, + ); +}); + +test('rejects ambiguous workspace registrations', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + + await assert.rejects( + LocalWorkspaceTools.create({ + workspaces: [ + { id: 'primary', root }, + { id: 'primary', root }, + ], + }), + /invalid workspace registration/i, + ); + await assert.rejects( + LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', name: '', root }], + }), + /invalid workspace registration/i, + ); +}); + +test('rejects unsupported workspace tool protocol versions', async (t) => { + 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 }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 2, + operation: 'read_file', + workspaceId: 'primary', + path: 'notes.txt', + } as never), + /invalid workspace tool request/i, + ); +}); + +test('does not start workspace I/O after its execution is aborted', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'notes.txt'), 'needle'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + const controller = new AbortController(); + controller.abort(); + + await assert.rejects( + tools.execute( + { + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + }, + controller.signal, + ), + /workspace tool execution aborted/i, + ); +}); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts new file mode 100644 index 00000000..4c4049f6 --- /dev/null +++ b/packages/code/src/workspace.ts @@ -0,0 +1,502 @@ +import { spawn } from 'node:child_process'; +import { constants } from 'node:fs'; +import { open, realpath, stat } from 'node:fs/promises'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +import type { FileHandle } from 'node:fs/promises'; + +import { + BRIDGE_PROTOCOL_VERSION, + isValidBridgeWorkerId, + isValidBridgeWorkspaceToolCapabilities, +} from './protocol.js'; + +import type { + BridgeProtocolVersion, + BridgeWorkspaceDescriptor, + BridgeWorkspaceToolCapabilities, +} from './protocol.js'; + +export interface LocalWorkspaceConfig { + id: string; + name?: string; + root: string; +} + +export interface LocalWorkspaceToolsOptions { + workspaces: LocalWorkspaceConfig[]; +} + +export interface WorkspaceReadFileRequest { + protocolVersion: BridgeProtocolVersion; + operation: 'read_file'; + workspaceId: string; + path: string; + startLine?: number; + maxLines?: number; +} + +export interface WorkspaceReadFileResult { + protocolVersion: BridgeProtocolVersion; + operation: 'read_file'; + workspaceId: string; + path: string; + content: string; + startLine: number; + endLine: number; + truncated: boolean; + nextStartLine?: number; +} + +export interface WorkspaceSearchTextRequest { + protocolVersion: BridgeProtocolVersion; + operation: 'search_text'; + workspaceId: string; + query: string; + path?: string; + maxResults?: number; +} + +export interface WorkspaceSearchMatch { + path: string; + line: number; + column: number; + text: string; +} + +export interface WorkspaceSearchTextResult { + protocolVersion: BridgeProtocolVersion; + operation: 'search_text'; + workspaceId: string; + matches: WorkspaceSearchMatch[]; + truncated: boolean; +} + +export type WorkspaceToolRequest = + | WorkspaceReadFileRequest + | WorkspaceSearchTextRequest; +export type WorkspaceToolResult = + | WorkspaceReadFileResult + | WorkspaceSearchTextResult; + +const MAX_READ_LINES = 500; +const MAX_READ_BYTES = 1024 * 1024; + +export class WorkspaceToolError extends Error { + constructor( + message: string, + public readonly code: + | 'INVALID_PATH' + | 'INVALID_REQUEST' + | 'READ_LIMIT_EXCEEDED' + | 'REGISTRATION_INVALID' + | 'EXECUTION_ABORTED' + | 'SEARCH_TIMEOUT' + | 'SEARCH_UNAVAILABLE', + ) { + super(message); + this.name = 'WorkspaceToolError'; + } +} + +export function isWorkspaceToolRequest( + value: unknown, +): value is WorkspaceToolRequest { + if (typeof value !== 'object' || value === null) return false; + const request = value as Record; + if ( + request.protocolVersion !== BRIDGE_PROTOCOL_VERSION || + typeof request.workspaceId !== 'string' || + !isValidBridgeWorkerId(request.workspaceId) + ) { + return false; + } + if (request.operation === 'read_file') { + return ( + typeof request.path === 'string' && + request.path.length > 0 && + request.path.length <= 4096 && + (request.startLine === undefined || + Number.isSafeInteger(request.startLine)) && + (request.maxLines === undefined || Number.isSafeInteger(request.maxLines)) + ); + } + if (request.operation === 'search_text') { + return ( + typeof request.query === 'string' && + request.query.length > 0 && + request.query.length <= 4096 && + (request.path === undefined || + (typeof request.path === 'string' && + request.path.length > 0 && + request.path.length <= 4096)) && + (request.maxResults === undefined || + Number.isSafeInteger(request.maxResults)) + ); + } + return false; +} + +function isWithinRoot(root: string, candidate: string): boolean { + const relativePath = relative(root, candidate); + return !( + relativePath === '..' || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ); +} + +function resolveWorkspacePath(root: string, requestedPath: string): string { + if ( + !requestedPath || + requestedPath.includes('\0') || + isAbsolute(requestedPath) + ) { + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } + const candidate = resolve(root, requestedPath); + if (!isWithinRoot(root, candidate)) { + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } + return candidate; +} + +async function readConfinedFile( + root: string, + requestedPath: string, +): Promise { + const candidate = resolveWorkspacePath(root, requestedPath); + let handle: FileHandle | undefined; + try { + handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW); + const [openedFile, canonicalPath] = await Promise.all([ + handle.stat(), + realpath(candidate), + ]); + const canonicalFile = await stat(canonicalPath); + if ( + !openedFile.isFile() || + !isWithinRoot(root, canonicalPath) || + openedFile.dev !== canonicalFile.dev || + openedFile.ino !== canonicalFile.ino + ) { + throw new Error('Invalid workspace path'); + } + if (openedFile.size > MAX_READ_BYTES) { + throw new WorkspaceToolError( + 'Workspace file exceeds read limit', + 'READ_LIMIT_EXCEEDED', + ); + } + const buffer = Buffer.allocUnsafe(MAX_READ_BYTES + 1); + let bytesRead = 0; + while (bytesRead <= MAX_READ_BYTES) { + const result = await handle.read( + buffer, + bytesRead, + buffer.length - bytesRead, + bytesRead, + ); + if (result.bytesRead === 0) break; + bytesRead += result.bytesRead; + } + if (bytesRead > MAX_READ_BYTES) { + throw new WorkspaceToolError( + 'Workspace file exceeds read limit', + 'READ_LIMIT_EXCEEDED', + ); + } + return buffer.subarray(0, bytesRead).toString('utf8'); + } catch (error) { + if (error instanceof WorkspaceToolError) throw error; + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } finally { + await handle?.close(); + } +} + +interface RipgrepMatch { + type?: string; + data?: { + path?: { text?: string }; + lines?: { text?: string }; + line_number?: number; + submatches?: Array<{ start?: number }>; + }; +} + +async function searchWorkspace( + root: string, + request: WorkspaceSearchTextRequest, + signal?: AbortSignal, +): Promise { + if ( + !request.query || + request.query.length > 4096 || + request.query.includes('\0') + ) { + throw new WorkspaceToolError('Invalid workspace search', 'INVALID_REQUEST'); + } + const maxResults = request.maxResults ?? 50; + if (!Number.isSafeInteger(maxResults) || maxResults < 1 || maxResults > 200) { + throw new WorkspaceToolError('Invalid workspace search', 'INVALID_REQUEST'); + } + + const searchPath = request.path ?? '.'; + const target = resolveWorkspacePath(root, searchPath); + let canonicalTarget: string; + try { + canonicalTarget = await realpath(target); + } catch { + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } + if (!isWithinRoot(root, canonicalTarget)) + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + const canonicalSearchPath = relative(root, canonicalTarget) || '.'; + + const matches: WorkspaceSearchMatch[] = []; + let truncated = false; + let pending = ''; + let stoppedForLimit = false; + await new Promise((resolvePromise, reject) => { + const child = spawn( + 'rg', + [ + '--json', + '--fixed-strings', + '--no-messages', + '--max-filesize', + '10M', + '--max-columns', + '2000', + '--max-columns-preview', + '--', + request.query, + canonicalSearchPath, + ], + { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }, + ); + let aborted = false; + let timedOut = false; + const abort = () => { + aborted = true; + child.kill(); + }; + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + const timeout = setTimeout(() => { + timedOut = true; + child.kill(); + }, 10_000); + const cleanup = () => { + clearTimeout(timeout); + signal?.removeEventListener('abort', abort); + }; + + const consumeLine = (line: string) => { + if (!line || stoppedForLimit) return; + let event: RipgrepMatch; + try { + event = JSON.parse(line) as RipgrepMatch; + } catch { + return; + } + if (event.type !== 'match' || !event.data) return; + const path = event.data.path?.text; + const lineNumber = event.data.line_number; + const column = event.data.submatches?.[0]?.start; + const text = event.data.lines?.text; + if ( + typeof path !== 'string' || + typeof lineNumber !== 'number' || + typeof column !== 'number' || + typeof text !== 'string' + ) { + return; + } + if (matches.length === maxResults) { + truncated = true; + stoppedForLimit = true; + child.kill(); + return; + } + matches.push({ + path: path.startsWith(`.${sep}`) ? path.slice(2) : path, + line: lineNumber, + column: column + 1, + text: text.replace(/[\r\n]+$/, '').slice(0, 2000), + }); + }; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + pending += chunk; + let newline = pending.indexOf('\n'); + while (newline >= 0) { + consumeLine(pending.slice(0, newline)); + pending = pending.slice(newline + 1); + newline = pending.indexOf('\n'); + } + }); + child.once('error', () => { + cleanup(); + reject( + new WorkspaceToolError( + 'Workspace search unavailable', + 'SEARCH_UNAVAILABLE', + ), + ); + }); + child.once('close', (code) => { + cleanup(); + consumeLine(pending); + if (aborted) { + reject( + new WorkspaceToolError( + 'Workspace tool execution aborted', + 'EXECUTION_ABORTED', + ), + ); + } else if (timedOut) { + reject( + new WorkspaceToolError( + 'Workspace search timed out', + 'SEARCH_TIMEOUT', + ), + ); + } else if (stoppedForLimit || code === 0 || code === 1) { + resolvePromise(); + } else { + reject( + new WorkspaceToolError( + 'Workspace search unavailable', + 'SEARCH_UNAVAILABLE', + ), + ); + } + }); + }); + + return { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'search_text', + workspaceId: request.workspaceId, + matches, + truncated, + }; +} + +export class LocalWorkspaceTools { + readonly capabilities: BridgeWorkspaceToolCapabilities; + + private constructor( + private readonly roots: ReadonlyMap, + workspaces: BridgeWorkspaceDescriptor[], + ) { + this.capabilities = { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['read_file', 'search_text'], + workspaces, + }; + } + + static async create( + options: LocalWorkspaceToolsOptions, + ): Promise { + const roots = new Map(); + const workspaces: BridgeWorkspaceDescriptor[] = options.workspaces.map( + (workspace) => ({ + id: workspace.id, + ...(workspace.name !== undefined ? { name: workspace.name } : {}), + }), + ); + const capabilities: BridgeWorkspaceToolCapabilities = { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['read_file', 'search_text'], + workspaces, + }; + if (!isValidBridgeWorkspaceToolCapabilities(capabilities)) { + throw new WorkspaceToolError( + 'Invalid workspace registration', + 'REGISTRATION_INVALID', + ); + } + for (const workspace of options.workspaces) { + let canonicalRoot: string; + try { + canonicalRoot = await realpath(workspace.root); + if (!(await stat(canonicalRoot)).isDirectory()) throw new Error(); + } catch { + throw new WorkspaceToolError( + 'Invalid workspace registration', + 'REGISTRATION_INVALID', + ); + } + roots.set(workspace.id, canonicalRoot); + } + return new LocalWorkspaceTools(roots, workspaces); + } + + async execute( + request: WorkspaceToolRequest, + signal?: AbortSignal, + ): Promise { + if (signal?.aborted) { + throw new WorkspaceToolError( + 'Workspace tool execution aborted', + 'EXECUTION_ABORTED', + ); + } + if (!isWorkspaceToolRequest(request)) { + throw new WorkspaceToolError( + 'Invalid workspace tool request', + 'INVALID_REQUEST', + ); + } + const root = this.roots.get(request.workspaceId); + if (!root) { + throw new WorkspaceToolError('Unknown workspace', 'INVALID_REQUEST'); + } + + if (request.operation === 'search_text') { + return searchWorkspace(root, request, signal); + } + + const startLine = request.startLine ?? 1; + const maxLines = request.maxLines ?? 200; + if ( + !Number.isSafeInteger(startLine) || + startLine < 1 || + !Number.isSafeInteger(maxLines) || + maxLines < 1 || + maxLines > MAX_READ_LINES + ) { + throw new Error('Invalid workspace read'); + } + const content = await readConfinedFile(root, request.path); + if (signal?.aborted) { + throw new WorkspaceToolError( + 'Workspace tool execution aborted', + 'EXECUTION_ABORTED', + ); + } + const lines = content.endsWith('\n') + ? content.slice(0, -1).split('\n') + : content.split('\n'); + const selected = lines.slice(startLine - 1, startLine - 1 + maxLines); + const endLine = startLine + selected.length - 1; + const truncated = endLine < lines.length; + + return { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'read_file', + workspaceId: request.workspaceId, + path: request.path, + content: selected.join('\n'), + startLine, + endLine, + truncated, + ...(truncated ? { nextStartLine: endLine + 1 } : {}), + }; + } +} From 8a7d63170c8af2f7f24d8498d6a197c52a52d20e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:34:04 -0400 Subject: [PATCH 2/6] fix(code): confine workspace text search --- packages/code/README.md | 13 +- packages/code/src/workspace.test.ts | 140 ++++++++++++++ packages/code/src/workspace.ts | 285 +++++++++++++++++----------- 3 files changed, 325 insertions(+), 113 deletions(-) diff --git a/packages/code/README.md b/packages/code/README.md index 18d8c7b0..cd48adb9 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -199,12 +199,13 @@ capabilities; absolute host paths remain local to the worker process. Reads reject absolute paths, traversal, escaping symlinks, non-regular files, and files larger than 1 MiB. The opened file is checked against its canonical -in-workspace inode before it is read. Text search invokes `rg` without a shell, -respects its normal ignore rules, does not follow symlinks, limits individual -files to 10 MiB, limits returned line length, and stops after a bounded global -result count. The worker process still belongs inside the trusted BYOM boundary -and should receive filesystem access only to roots the operator intentionally -registers. +in-workspace inode before it is read. Text search uses `rg` only to enumerate a +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. Search limits returned line length +and stops after a bounded global result count. The worker process still belongs +inside the trusted BYOM boundary and should receive filesystem access only to +roots the operator intentionally registers. This release exposes the library protocol only. The subsequent bridge-dispatch layer will route signed, deadline-bound workspace tool assignments to it; until diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 34b11320..6b179cb5 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -1,11 +1,15 @@ import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; +import { promisify } from 'node:util'; import { LocalWorkspaceTools } from './workspace.js'; +const execFileAsync = promisify(execFile); + test('reads a bounded range from a registered local workspace', async (t) => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); t.after(() => rm(root, { recursive: true, force: true })); @@ -120,6 +124,142 @@ test('searches workspace text with a hard global result bound', async (t) => { ); }); +test('search ignores ripgrep config that follows escaping symlinks', async (t) => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-code-search-parent-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(parent, 'repo'); + const outside = join(parent, 'outside'); + await mkdir(root); + await mkdir(outside); + await writeFile(join(outside, 'secret.txt'), 'needle secret'); + await symlink(outside, join(root, 'linked-outside')); + const config = join(parent, 'ripgrep.conf'); + await writeFile(config, '--follow\n'); + const previousConfig = process.env.RIPGREP_CONFIG_PATH; + process.env.RIPGREP_CONFIG_PATH = config; + t.after(() => { + if (previousConfig === undefined) delete process.env.RIPGREP_CONFIG_PATH; + else process.env.RIPGREP_CONFIG_PATH = previousConfig; + }); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + }); + + if (result.operation !== 'search_text') assert.fail('expected search result'); + assert.deepEqual(result.matches, []); +}); + +test('search does not read an explicitly targeted escaping symlink', async (t) => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-code-search-parent-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(parent, 'repo'); + await mkdir(root); + await writeFile(join(parent, 'secret.txt'), 'needle secret'); + await symlink(join(parent, 'secret.txt'), join(root, 'linked-secret.txt')); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + path: 'linked-secret.txt', + }), + /invalid workspace path/i, + ); +}); + +test('search returns a bounded match for a very long line', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'long.txt'), `${'a'.repeat(512 * 1024)} needle`); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + }); + + if (result.operation !== 'search_text') assert.fail('expected search result'); + assert.equal(result.matches.length, 1); + assert.equal(result.matches[0]?.text.length, 2000); +}); + +test('search rejects multiline literal queries', async (t) => { + 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 }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'first\nsecond', + }), + /invalid workspace search/i, + ); +}); + +test('search handles invalid UTF-8 without silently dropping an ASCII match', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile( + join(root, 'legacy.txt'), + Buffer.concat([Buffer.from([0xff]), Buffer.from(' needle\n')]), + ); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + }); + + if (result.operation !== 'search_text') assert.fail('expected search result'); + assert.equal(result.matches.length, 1); + assert.equal(result.matches[0]?.path, 'legacy.txt'); +}); + +test('read rejects a FIFO without waiting for a writer', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + const fifo = join(root, 'pipe'); + await execFileAsync('mkfifo', [fifo]); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'pipe', + }), + /invalid workspace path/i, + ); +}); + test('advertises workspace IDs and names without exposing host roots', async (t) => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 4c4049f6..0eb2bf04 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -73,14 +73,15 @@ export interface WorkspaceSearchTextResult { } export type WorkspaceToolRequest = - | WorkspaceReadFileRequest - | WorkspaceSearchTextRequest; + WorkspaceReadFileRequest | WorkspaceSearchTextRequest; export type WorkspaceToolResult = - | WorkspaceReadFileResult - | WorkspaceSearchTextResult; + WorkspaceReadFileResult | WorkspaceSearchTextResult; const MAX_READ_LINES = 500; const MAX_READ_BYTES = 1024 * 1024; +const MAX_SEARCH_CANDIDATE_BYTES = 1024 * 1024; +const MAX_SEARCH_CANDIDATES = 20_000; +const SEARCH_TIMEOUT_MS = 10_000; export class WorkspaceToolError extends Error { constructor( @@ -161,14 +162,17 @@ function resolveWorkspacePath(root: string, requestedPath: string): string { return candidate; } -async function readConfinedFile( +async function readConfinedFileBuffer( root: string, requestedPath: string, -): Promise { +): Promise { const candidate = resolveWorkspacePath(root, requestedPath); let handle: FileHandle | undefined; try { - handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW); + handle = await open( + candidate, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); const [openedFile, canonicalPath] = await Promise.all([ handle.stat(), realpath(candidate), @@ -206,7 +210,7 @@ async function readConfinedFile( 'READ_LIMIT_EXCEEDED', ); } - return buffer.subarray(0, bytesRead).toString('utf8'); + return buffer.subarray(0, bytesRead); } catch (error) { if (error instanceof WorkspaceToolError) throw error; throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); @@ -215,64 +219,39 @@ async function readConfinedFile( } } -interface RipgrepMatch { - type?: string; - data?: { - path?: { text?: string }; - lines?: { text?: string }; - line_number?: number; - submatches?: Array<{ start?: number }>; - }; -} - -async function searchWorkspace( +async function readConfinedFile( root: string, - request: WorkspaceSearchTextRequest, - signal?: AbortSignal, -): Promise { - if ( - !request.query || - request.query.length > 4096 || - request.query.includes('\0') - ) { - throw new WorkspaceToolError('Invalid workspace search', 'INVALID_REQUEST'); - } - const maxResults = request.maxResults ?? 50; - if (!Number.isSafeInteger(maxResults) || maxResults < 1 || maxResults > 200) { - throw new WorkspaceToolError('Invalid workspace search', 'INVALID_REQUEST'); - } + requestedPath: string, +): Promise { + return (await readConfinedFileBuffer(root, requestedPath)).toString('utf8'); +} - const searchPath = request.path ?? '.'; - const target = resolveWorkspacePath(root, searchPath); - let canonicalTarget: string; - try { - canonicalTarget = await realpath(target); - } catch { - throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); - } - if (!isWithinRoot(root, canonicalTarget)) - throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); - const canonicalSearchPath = relative(root, canonicalTarget) || '.'; +interface SearchCandidates { + paths: string[]; + truncated: boolean; +} - const matches: WorkspaceSearchMatch[] = []; - let truncated = false; - let pending = ''; - let stoppedForLimit = false; - await new Promise((resolvePromise, reject) => { +async function listSearchCandidates( + root: string, + searchPath: string, + signal: AbortSignal | undefined, + deadline: number, +): Promise { + return new Promise((resolvePromise, reject) => { + const chunks: Buffer[] = []; + let outputBytes = 0; + let stoppedForLimit = false; const child = spawn( 'rg', [ - '--json', - '--fixed-strings', - '--no-messages', + '--files', + '--no-config', + '--no-follow', + '--null', '--max-filesize', - '10M', - '--max-columns', - '2000', - '--max-columns-preview', + '1M', '--', - request.query, - canonicalSearchPath, + searchPath, ], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }, ); @@ -284,59 +263,30 @@ async function searchWorkspace( }; signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort(); - const timeout = setTimeout(() => { - timedOut = true; - child.kill(); - }, 10_000); + const timeout = setTimeout( + () => { + timedOut = true; + child.kill(); + }, + Math.max(0, deadline - Date.now()), + ); const cleanup = () => { clearTimeout(timeout); signal?.removeEventListener('abort', abort); }; - const consumeLine = (line: string) => { - if (!line || stoppedForLimit) return; - let event: RipgrepMatch; - try { - event = JSON.parse(line) as RipgrepMatch; - } catch { - return; - } - if (event.type !== 'match' || !event.data) return; - const path = event.data.path?.text; - const lineNumber = event.data.line_number; - const column = event.data.submatches?.[0]?.start; - const text = event.data.lines?.text; - if ( - typeof path !== 'string' || - typeof lineNumber !== 'number' || - typeof column !== 'number' || - typeof text !== 'string' - ) { - return; - } - if (matches.length === maxResults) { - truncated = true; + child.stdout.on('data', (chunk: Buffer) => { + if (stoppedForLimit) return; + const remaining = MAX_SEARCH_CANDIDATE_BYTES - outputBytes; + if (chunk.length > remaining) { + if (remaining > 0) chunks.push(chunk.subarray(0, remaining)); + outputBytes = MAX_SEARCH_CANDIDATE_BYTES; stoppedForLimit = true; child.kill(); return; } - matches.push({ - path: path.startsWith(`.${sep}`) ? path.slice(2) : path, - line: lineNumber, - column: column + 1, - text: text.replace(/[\r\n]+$/, '').slice(0, 2000), - }); - }; - - child.stdout.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - pending += chunk; - let newline = pending.indexOf('\n'); - while (newline >= 0) { - consumeLine(pending.slice(0, newline)); - pending = pending.slice(newline + 1); - newline = pending.indexOf('\n'); - } + chunks.push(chunk); + outputBytes += chunk.length; }); child.once('error', () => { cleanup(); @@ -349,7 +299,6 @@ async function searchWorkspace( }); child.once('close', (code) => { cleanup(); - consumeLine(pending); if (aborted) { reject( new WorkspaceToolError( @@ -357,25 +306,147 @@ async function searchWorkspace( 'EXECUTION_ABORTED', ), ); - } else if (timedOut) { + return; + } + if (timedOut) { reject( new WorkspaceToolError( 'Workspace search timed out', 'SEARCH_TIMEOUT', ), ); - } else if (stoppedForLimit || code === 0 || code === 1) { - resolvePromise(); - } else { + return; + } + if (!stoppedForLimit && code !== 0 && code !== 1) { reject( new WorkspaceToolError( 'Workspace search unavailable', 'SEARCH_UNAVAILABLE', ), ); + return; } + + const output = Buffer.concat(chunks); + const paths: string[] = []; + let start = 0; + let end = output.indexOf(0, start); + while (end >= 0 && paths.length < MAX_SEARCH_CANDIDATES) { + if (end > start) + paths.push(output.subarray(start, end).toString('utf8')); + start = end + 1; + end = output.indexOf(0, start); + } + resolvePromise({ + paths, + truncated: + stoppedForLimit || + paths.length === MAX_SEARCH_CANDIDATES || + start < output.length, + }); }); }); +} + +async function searchWorkspace( + root: string, + request: WorkspaceSearchTextRequest, + signal?: AbortSignal, +): Promise { + if ( + !request.query || + request.query.length > 4096 || + request.query.includes('\0') || + request.query.includes('\n') || + request.query.includes('\r') + ) { + throw new WorkspaceToolError('Invalid workspace search', 'INVALID_REQUEST'); + } + const maxResults = request.maxResults ?? 50; + if (!Number.isSafeInteger(maxResults) || maxResults < 1 || maxResults > 200) { + throw new WorkspaceToolError('Invalid workspace search', 'INVALID_REQUEST'); + } + + const searchPath = request.path ?? '.'; + const target = resolveWorkspacePath(root, searchPath); + let canonicalTarget: string; + try { + canonicalTarget = await realpath(target); + } catch { + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } + if (!isWithinRoot(root, canonicalTarget)) + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + const canonicalSearchPath = relative(root, canonicalTarget) || '.'; + + const deadline = Date.now() + SEARCH_TIMEOUT_MS; + const candidates = await listSearchCandidates( + root, + canonicalSearchPath, + signal, + deadline, + ); + const query = Buffer.from(request.query); + const matches: WorkspaceSearchMatch[] = []; + let truncated = candidates.truncated; + for (const candidate of candidates.paths) { + if (signal?.aborted) { + throw new WorkspaceToolError( + 'Workspace tool execution aborted', + 'EXECUTION_ABORTED', + ); + } + if (Date.now() >= deadline) { + throw new WorkspaceToolError( + 'Workspace search timed out', + 'SEARCH_TIMEOUT', + ); + } + const path = candidate.startsWith(`.${sep}`) + ? candidate.slice(2) + : candidate; + let content: Buffer; + try { + content = await readConfinedFileBuffer(root, path); + } catch (error) { + if ( + error instanceof WorkspaceToolError && + (error.code === 'INVALID_PATH' || error.code === 'READ_LIMIT_EXCEEDED') + ) { + continue; + } + throw error; + } + let lineStart = 0; + let lineNumber = 1; + while (lineStart <= content.length) { + const newline = content.indexOf(0x0a, lineStart); + const lineEnd = newline < 0 ? content.length : newline; + const line = content.subarray( + lineStart, + lineEnd > lineStart && content[lineEnd - 1] === 0x0d + ? lineEnd - 1 + : lineEnd, + ); + const column = line.indexOf(query); + if (column >= 0) { + if (matches.length === maxResults) { + truncated = true; + break; + } + matches.push({ + path, + line: lineNumber, + column: column + 1, + text: line.toString('utf8').slice(0, 2000), + }); + } + if (newline < 0) break; + lineStart = newline + 1; + lineNumber += 1; + } + if (matches.length === maxResults && truncated) break; + } return { protocolVersion: BRIDGE_PROTOCOL_VERSION, From 5203de855e3b6953a4908d5b917cf6057d61d2bb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:40:20 -0400 Subject: [PATCH 3/6] fix(code): center bounded search previews --- packages/code/src/workspace.test.ts | 1 + packages/code/src/workspace.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 6b179cb5..4b0ea306 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -197,6 +197,7 @@ test('search returns a bounded match for a very long line', async (t) => { if (result.operation !== 'search_text') assert.fail('expected search result'); assert.equal(result.matches.length, 1); assert.equal(result.matches[0]?.text.length, 2000); + assert.match(result.matches[0]?.text ?? '', /needle/); }); test('search rejects multiline literal queries', async (t) => { diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 0eb2bf04..f46a8a23 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -434,11 +434,20 @@ async function searchWorkspace( truncated = true; break; } + const previewStart = Math.min( + Math.max( + 0, + column - Math.floor((2000 - Math.min(query.length, 2000)) / 2), + ), + Math.max(0, line.length - 2000), + ); matches.push({ path, line: lineNumber, column: column + 1, - text: line.toString('utf8').slice(0, 2000), + text: line + .subarray(previewStart, previewStart + 2000) + .toString('utf8'), }); } if (newline < 0) break; From 1227bfa97c388db15d474e6f3a5fd8dadf33df24 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:50:19 -0400 Subject: [PATCH 4/6] fix(code): tighten workspace result semantics --- packages/code/src/workspace.test.ts | 49 +++++++++++++++++++++++++++-- packages/code/src/workspace.ts | 43 ++++++++++++++++++------- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 4b0ea306..2c0e5d95 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import test from 'node:test'; import { promisify } from 'node:util'; -import { LocalWorkspaceTools } from './workspace.js'; +import { LocalWorkspaceTools, WorkspaceToolError } from './workspace.js'; const execFileAsync = promisify(execFile); @@ -218,6 +218,47 @@ test('search rejects multiline literal queries', async (t) => { ); }); +test('search rejects queries larger than its bounded preview', async (t) => { + 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 }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'a'.repeat(2001), + }), + /invalid workspace search/i, + ); +}); + +test('search keeps valid UTF-8 intact in a centered preview', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile( + join(root, 'unicode.txt'), + `${'é'.repeat(2500)} needle ${'é'.repeat(2500)}`, + ); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + }); + + if (result.operation !== 'search_text') assert.fail('expected search result'); + assert.equal(result.matches[0]?.text.includes('\ufffd'), false); + assert.match(result.matches[0]?.text ?? '', /needle/); +}); + test('search handles invalid UTF-8 without silently dropping an ASCII match', async (t) => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); t.after(() => rm(root, { recursive: true, force: true })); @@ -292,7 +333,8 @@ test('rejects unbounded file read parameters before reading the file', async (t) path: 'notes.txt', startLine: 0, }), - /invalid workspace read/i, + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'INVALID_REQUEST', ); await assert.rejects( tools.execute({ @@ -302,7 +344,8 @@ test('rejects unbounded file read parameters before reading the file', async (t) path: 'notes.txt', maxLines: 501, }), - /invalid workspace read/i, + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'INVALID_REQUEST', ); }); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index f46a8a23..3a35a7e0 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -79,6 +79,7 @@ export type WorkspaceToolResult = const MAX_READ_LINES = 500; const MAX_READ_BYTES = 1024 * 1024; +const MAX_SEARCH_PREVIEW_LENGTH = 2000; const MAX_SEARCH_CANDIDATE_BYTES = 1024 * 1024; const MAX_SEARCH_CANDIDATES = 20_000; const SEARCH_TIMEOUT_MS = 10_000; @@ -331,18 +332,18 @@ async function listSearchCandidates( const paths: string[] = []; let start = 0; let end = output.indexOf(0, start); - while (end >= 0 && paths.length < MAX_SEARCH_CANDIDATES) { + while (end >= 0 && paths.length <= MAX_SEARCH_CANDIDATES) { if (end > start) paths.push(output.subarray(start, end).toString('utf8')); start = end + 1; end = output.indexOf(0, start); } + const exceededCandidateLimit = paths.length > MAX_SEARCH_CANDIDATES; + if (exceededCandidateLimit) paths.pop(); resolvePromise({ paths, truncated: - stoppedForLimit || - paths.length === MAX_SEARCH_CANDIDATES || - start < output.length, + stoppedForLimit || exceededCandidateLimit || start < output.length, }); }); }); @@ -353,9 +354,11 @@ async function searchWorkspace( request: WorkspaceSearchTextRequest, signal?: AbortSignal, ): Promise { + const encodedQuery = Buffer.from(request.query); if ( !request.query || request.query.length > 4096 || + encodedQuery.length > MAX_SEARCH_PREVIEW_LENGTH || request.query.includes('\0') || request.query.includes('\n') || request.query.includes('\r') @@ -386,7 +389,7 @@ async function searchWorkspace( signal, deadline, ); - const query = Buffer.from(request.query); + const query = encodedQuery; const matches: WorkspaceSearchMatch[] = []; let truncated = candidates.truncated; for (const candidate of candidates.paths) { @@ -417,6 +420,18 @@ async function searchWorkspace( } throw error; } + if (signal?.aborted) { + throw new WorkspaceToolError( + 'Workspace tool execution aborted', + 'EXECUTION_ABORTED', + ); + } + if (Date.now() >= deadline) { + throw new WorkspaceToolError( + 'Workspace search timed out', + 'SEARCH_TIMEOUT', + ); + } let lineStart = 0; let lineNumber = 1; while (lineStart <= content.length) { @@ -434,20 +449,26 @@ async function searchWorkspace( truncated = true; break; } + const decodedLine = line.toString('utf8'); + const decodedColumn = decodedLine.indexOf(request.query); const previewStart = Math.min( Math.max( 0, - column - Math.floor((2000 - Math.min(query.length, 2000)) / 2), + decodedColumn - + Math.floor( + (MAX_SEARCH_PREVIEW_LENGTH - request.query.length) / 2, + ), ), - Math.max(0, line.length - 2000), + Math.max(0, decodedLine.length - MAX_SEARCH_PREVIEW_LENGTH), ); matches.push({ path, line: lineNumber, column: column + 1, - text: line - .subarray(previewStart, previewStart + 2000) - .toString('utf8'), + text: decodedLine.slice( + previewStart, + previewStart + MAX_SEARCH_PREVIEW_LENGTH, + ), }); } if (newline < 0) break; @@ -551,7 +572,7 @@ export class LocalWorkspaceTools { maxLines < 1 || maxLines > MAX_READ_LINES ) { - throw new Error('Invalid workspace read'); + throw new WorkspaceToolError('Invalid workspace read', 'INVALID_REQUEST'); } const content = await readConfinedFile(root, request.path); if (signal?.aborted) { From 0c5f188588ffb13d18a959c4ac0eca1b6e7402d8 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 15:41:18 -0400 Subject: [PATCH 5/6] fix(code): harden workspace text search --- packages/code/Dockerfile | 4 ++- packages/code/src/workspace.test.ts | 55 +++++++++++++++++++++++++++++ packages/code/src/workspace.ts | 28 ++++++++------- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/code/Dockerfile b/packages/code/Dockerfile index 21fb09bd..015ca2d0 100644 --- a/packages/code/Dockerfile +++ b/packages/code/Dockerfile @@ -7,7 +7,9 @@ RUN npm run build FROM node:24-alpine ENV NODE_ENV=production -RUN addgroup -S librechat-code && adduser -S librechat-code -G librechat-code +RUN apk add --no-cache ripgrep \ + && addgroup -S librechat-code \ + && adduser -S librechat-code -G librechat-code WORKDIR /app COPY --from=build /app/package.json ./package.json COPY --from=build /app/dist ./dist diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 2c0e5d95..378c5e13 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -236,6 +236,26 @@ test('search rejects queries larger than its bounded preview', async (t) => { ); }); +test('search rejects non-scalar Unicode queries', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'notes.txt'), '\ufffd'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: '\ud800', + }), + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'INVALID_REQUEST', + ); +}); + test('search keeps valid UTF-8 intact in a centered preview', async (t) => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); t.after(() => rm(root, { recursive: true, force: true })); @@ -282,6 +302,41 @@ test('search handles invalid UTF-8 without silently dropping an ASCII match', as assert.equal(result.matches[0]?.path, 'legacy.txt'); }); +test('search decodes BOM-marked UTF-16 text', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + const utf16le = Buffer.from('before needle after', 'utf16le'); + const utf16be = Buffer.from(utf16le); + utf16be.swap16(); + await writeFile( + join(root, 'little-endian.txt'), + Buffer.concat([Buffer.from([0xff, 0xfe]), utf16le]), + ); + await writeFile( + join(root, 'big-endian.txt'), + Buffer.concat([Buffer.from([0xfe, 0xff]), utf16be]), + ); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + }); + + if (result.operation !== 'search_text') assert.fail('expected search result'); + assert.deepEqual( + result.matches.map(({ path, column, text }) => ({ path, column, text })), + [ + { path: 'big-endian.txt', column: 8, text: 'before needle after' }, + { path: 'little-endian.txt', column: 8, text: 'before needle after' }, + ], + ); +}); + test('read rejects a FIFO without waiting for a writer', async (t) => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 3a35a7e0..c57ae6dd 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -359,6 +359,7 @@ async function searchWorkspace( !request.query || request.query.length > 4096 || encodedQuery.length > MAX_SEARCH_PREVIEW_LENGTH || + encodedQuery.toString('utf8') !== request.query || request.query.includes('\0') || request.query.includes('\n') || request.query.includes('\r') @@ -389,7 +390,6 @@ async function searchWorkspace( signal, deadline, ); - const query = encodedQuery; const matches: WorkspaceSearchMatch[] = []; let truncated = candidates.truncated; for (const candidate of candidates.paths) { @@ -432,40 +432,44 @@ async function searchWorkspace( 'SEARCH_TIMEOUT', ); } + const decodedContent = + content[0] === 0xff && content[1] === 0xfe + ? new TextDecoder('utf-16le').decode(content) + : content[0] === 0xfe && content[1] === 0xff + ? new TextDecoder('utf-16be').decode(content) + : content.toString('utf8'); let lineStart = 0; let lineNumber = 1; - while (lineStart <= content.length) { - const newline = content.indexOf(0x0a, lineStart); - const lineEnd = newline < 0 ? content.length : newline; - const line = content.subarray( + while (lineStart <= decodedContent.length) { + const newline = decodedContent.indexOf('\n', lineStart); + const lineEnd = newline < 0 ? decodedContent.length : newline; + const line = decodedContent.slice( lineStart, - lineEnd > lineStart && content[lineEnd - 1] === 0x0d + lineEnd > lineStart && decodedContent[lineEnd - 1] === '\r' ? lineEnd - 1 : lineEnd, ); - const column = line.indexOf(query); + const column = line.indexOf(request.query); if (column >= 0) { if (matches.length === maxResults) { truncated = true; break; } - const decodedLine = line.toString('utf8'); - const decodedColumn = decodedLine.indexOf(request.query); const previewStart = Math.min( Math.max( 0, - decodedColumn - + column - Math.floor( (MAX_SEARCH_PREVIEW_LENGTH - request.query.length) / 2, ), ), - Math.max(0, decodedLine.length - MAX_SEARCH_PREVIEW_LENGTH), + Math.max(0, line.length - MAX_SEARCH_PREVIEW_LENGTH), ); matches.push({ path, line: lineNumber, column: column + 1, - text: decodedLine.slice( + text: line.slice( previewStart, previewStart + MAX_SEARCH_PREVIEW_LENGTH, ), From e7de946f0dc6b0958813119b064a71fa37d8434c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 15:50:45 -0400 Subject: [PATCH 6/6] fix(code): normalize workspace text boundaries --- packages/code/src/workspace.test.ts | 90 +++++++++++++++++++++++++++++ packages/code/src/workspace.ts | 64 ++++++++++++++++---- 2 files changed, 144 insertions(+), 10 deletions(-) diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 378c5e13..0fd9ad23 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -74,6 +74,37 @@ test('rejects traversal outside a registered workspace without leaking its host ); }); +test('rejects non-scalar Unicode workspace paths', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, '\ufffd.txt'), 'needle'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: '\ud800.txt', + }), + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'INVALID_REQUEST', + ); + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + path: '\ud800.txt', + }), + (error: unknown) => + error instanceof WorkspaceToolError && error.code === 'INVALID_REQUEST', + ); +}); + test('rejects a symlink that escapes a registered workspace', async (t) => { const parent = await mkdtemp( join(tmpdir(), 'librechat-code-workspace-parent-'), @@ -279,6 +310,54 @@ test('search keeps valid UTF-8 intact in a centered preview', async (t) => { assert.match(result.matches[0]?.text ?? '', /needle/); }); +test('search does not split surrogate pairs in a centered preview', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile( + join(root, 'emoji.txt'), + `${'\ud83d\ude00'.repeat(1500)}needle${'\ud83d\ude00'.repeat(1500)}`, + ); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + }); + + if (result.operation !== 'search_text') assert.fail('expected search result'); + const preview = result.matches[0]?.text ?? ''; + assert.equal(Buffer.from(preview).toString('utf8'), preview); + assert.match(preview, /needle/); +}); + +test('search strips a UTF-8 BOM before reporting columns', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile( + join(root, 'utf8-bom.txt'), + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('needle')]), + ); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + }); + + if (result.operation !== 'search_text') assert.fail('expected search result'); + assert.deepEqual(result.matches, [ + { path: 'utf8-bom.txt', line: 1, column: 1, text: 'needle' }, + ]); +}); + test('search handles invalid UTF-8 without silently dropping an ASCII match', async (t) => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); t.after(() => rm(root, { recursive: true, force: true })); @@ -335,6 +414,17 @@ test('search decodes BOM-marked UTF-16 text', async (t) => { { path: 'little-endian.txt', column: 8, text: 'before needle after' }, ], ); + + for (const path of ['big-endian.txt', 'little-endian.txt']) { + const read = await tools.execute({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path, + }); + if (read.operation !== 'read_file') assert.fail('expected read result'); + assert.equal(read.content, 'before needle after'); + } }); test('read rejects a FIFO without waiting for a writer', async (t) => { diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index c57ae6dd..b56179c8 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -84,6 +84,51 @@ const MAX_SEARCH_CANDIDATE_BYTES = 1024 * 1024; const MAX_SEARCH_CANDIDATES = 20_000; const SEARCH_TIMEOUT_MS = 10_000; +function isUtf8ScalarString(value: string): boolean { + return Buffer.from(value).toString('utf8') === value; +} + +function decodeWorkspaceText(content: Buffer): string { + if (content[0] === 0xff && content[1] === 0xfe) { + return new TextDecoder('utf-16le').decode(content.subarray(2)); + } + if (content[0] === 0xfe && content[1] === 0xff) { + return new TextDecoder('utf-16be').decode(content.subarray(2)); + } + const utf8Start = + content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf ? 3 : 0; + return content.subarray(utf8Start).toString('utf8'); +} + +function sliceWithoutSplittingSurrogates( + value: string, + start: number, + maxLength: number, +): string { + let safeStart = start; + if ( + safeStart > 0 && + safeStart < value.length && + value.charCodeAt(safeStart) >= 0xdc00 && + value.charCodeAt(safeStart) <= 0xdfff && + value.charCodeAt(safeStart - 1) >= 0xd800 && + value.charCodeAt(safeStart - 1) <= 0xdbff + ) { + safeStart += 1; + } + let safeEnd = Math.min(value.length, safeStart + maxLength); + if ( + safeEnd < value.length && + value.charCodeAt(safeEnd - 1) >= 0xd800 && + value.charCodeAt(safeEnd - 1) <= 0xdbff && + value.charCodeAt(safeEnd) >= 0xdc00 && + value.charCodeAt(safeEnd) <= 0xdfff + ) { + safeEnd -= 1; + } + return value.slice(safeStart, safeEnd); +} + export class WorkspaceToolError extends Error { constructor( message: string, @@ -118,6 +163,7 @@ export function isWorkspaceToolRequest( typeof request.path === 'string' && request.path.length > 0 && request.path.length <= 4096 && + isUtf8ScalarString(request.path) && (request.startLine === undefined || Number.isSafeInteger(request.startLine)) && (request.maxLines === undefined || Number.isSafeInteger(request.maxLines)) @@ -131,7 +177,8 @@ export function isWorkspaceToolRequest( (request.path === undefined || (typeof request.path === 'string' && request.path.length > 0 && - request.path.length <= 4096)) && + request.path.length <= 4096 && + isUtf8ScalarString(request.path))) && (request.maxResults === undefined || Number.isSafeInteger(request.maxResults)) ); @@ -152,6 +199,7 @@ function resolveWorkspacePath(root: string, requestedPath: string): string { if ( !requestedPath || requestedPath.includes('\0') || + !isUtf8ScalarString(requestedPath) || isAbsolute(requestedPath) ) { throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); @@ -224,7 +272,7 @@ async function readConfinedFile( root: string, requestedPath: string, ): Promise { - return (await readConfinedFileBuffer(root, requestedPath)).toString('utf8'); + return decodeWorkspaceText(await readConfinedFileBuffer(root, requestedPath)); } interface SearchCandidates { @@ -432,12 +480,7 @@ async function searchWorkspace( 'SEARCH_TIMEOUT', ); } - const decodedContent = - content[0] === 0xff && content[1] === 0xfe - ? new TextDecoder('utf-16le').decode(content) - : content[0] === 0xfe && content[1] === 0xff - ? new TextDecoder('utf-16be').decode(content) - : content.toString('utf8'); + const decodedContent = decodeWorkspaceText(content); let lineStart = 0; let lineNumber = 1; while (lineStart <= decodedContent.length) { @@ -469,9 +512,10 @@ async function searchWorkspace( path, line: lineNumber, column: column + 1, - text: line.slice( + text: sliceWithoutSplittingSurrogates( + line, previewStart, - previewStart + MAX_SEARCH_PREVIEW_LENGTH, + MAX_SEARCH_PREVIEW_LENGTH, ), }); }