From 1dce6237268f49ebaf112190e17e07ea0b6784bc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:17:20 -0400 Subject: [PATCH 1/4] feat(code): list attached workspace files --- docs/remote-bridge/README.md | 11 +- packages/code/README.md | 25 +- packages/code/src/protocol.test.ts | 52 +++++ packages/code/src/protocol.ts | 83 ++++++- packages/code/src/worker.ts | 88 ++++++- packages/code/src/workspace-worker.test.ts | 140 +++++++++++ packages/code/src/workspace.test.ts | 168 ++++++++++++- packages/code/src/workspace.ts | 260 ++++++++++++++++++++- service/src/bridge/router.test.ts | 5 + service/src/bridge/router.ts | 5 + service/src/workspace-tools/router.test.ts | 2 + service/src/workspace-tools/router.ts | 14 +- 12 files changed, 815 insertions(+), 38 deletions(-) diff --git a/docs/remote-bridge/README.md b/docs/remote-bridge/README.md index 5fbb5b3..60882dc 100644 --- a/docs/remote-bridge/README.md +++ b/docs/remote-bridge/README.md @@ -118,14 +118,15 @@ The endpoint uses the same authenticated principal-bound worker selection, tenant fence, lease deadline, cancellation, and settlement lifecycle as remote sandbox execution. Requests must name a workspace and operation advertised by that worker. Results are validated against the originating request before they -leave Code API, and are bounded to 1 MiB/500 lines for reads or 200 matches for -searches. Absolute paths, traversal, backslashes, symlink escapes, unexpected -fields, and host roots are rejected. +leave Code API, and are bounded to 1 MiB/500 lines for reads, 200 matches for +searches, or 500 relative paths for file listings. Absolute paths, traversal, +backslashes, symlink escapes, unexpected fields, and host roots are rejected. The workspace root can be an existing project, a Git repository, or an empty directory; Git is not required. This boundary keeps that directory local to the -operator's machine, but the selected file contents, search matches, and later -tool results necessarily cross the outbound bridge to Code API and the model. +operator's machine, but selected file contents, search matches, relative file +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; future mutation and shell operations must be diff --git a/packages/code/README.md b/packages/code/README.md index 076f831..be42176 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -195,7 +195,8 @@ coding-agent access to workspace directories on the worker machine. A workspace may be an existing project, a Git repository, or a newly created empty directory; Git is optional. `LocalWorkspaceTools` registers opaque workspace IDs with optional display -names and exposes bounded `read_file` and literal `search_text` operations. +names and exposes bounded `read_file`, literal `search_text`, and deterministic +`list_files` operations. Only IDs, names, protocol version, and supported operations appear in worker capabilities; absolute host paths remain local to the worker process. @@ -204,10 +205,11 @@ and files larger than 1 MiB. The opened file is checked against its canonical 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. +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. Register one directory already present on the worker machine with the worker-directory option: @@ -220,17 +222,18 @@ The default public workspace ID is `primary` and the default display name is the directory basename. Operators can use `--workspace-id` and `--workspace-name`, or `LIBRECHAT_CODE_WORKER_DIR`, `LIBRECHAT_CODE_WORKSPACE_ID`, and `LIBRECHAT_CODE_WORKSPACE_NAME`, to set them -explicitly. `rg` must be installed on the worker for `search_text`. +explicitly. `rg` must be installed on the worker for `search_text` and +`list_files`. The worker advertises these capabilities only when a directory is configured and executes matching assignments under the bridge's existing lease, deadline, cancellation, credential-refresh, and settlement fencing. The workspace itself remains on the worker. As with Cursor's self-hosted agents, -text deliberately selected by `read_file` or `search_text` crosses the outbound -bridge so the remote agent/model can reason over it. Host paths are never part -of that payload. The Code API workspace-tool endpoint is delivered as a -dependent layer; deployments without it continue to use sandbox assignments -unchanged. +text and relative paths deliberately selected by `read_file`, `search_text`, or +`list_files` cross the outbound bridge so the remote agent/model can reason over +them. Host paths are never part of that payload. The Code API workspace-tool +endpoint is delivered as a dependent layer; deployments without it continue to +use sandbox assignments unchanged. After discarding or resetting that session's local runner, acknowledge recovery with `librechat-code reset-workspace `. The command uses the diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index 793c7d0..ab19ff0 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -4,6 +4,8 @@ import { bridgeWorkerPath, isValidBridgeWorkerCapabilities, isValidBridgeWorkerId, + isWorkspaceToolRequest, + isWorkspaceToolResult, } from './protocol.js'; test('bridgeWorkerPath encodes worker-controlled path segments', () => { @@ -89,3 +91,53 @@ test('bridge worker capabilities accept only bounded public workspace descriptor false, ); }); + +test('workspace file listing accepts only bounded portable requests and results', () => { + const request = { + protocolVersion: 1 as const, + operation: 'list_files' as const, + workspaceId: 'primary', + path: 'src', + maxResults: 20, + }; + assert.equal(isWorkspaceToolRequest(request), true); + assert.equal( + isWorkspaceToolRequest({ ...request, path: '../outside' }), + false, + ); + assert.equal(isWorkspaceToolRequest({ ...request, maxResults: 501 }), false); + + const result = { + protocolVersion: 1 as const, + operation: 'list_files' as const, + workspaceId: 'primary', + paths: ['src/app.ts', 'src/worker.ts'], + truncated: false, + }; + assert.equal(isWorkspaceToolResult(request, result), true); + assert.equal( + isWorkspaceToolResult(request, { + ...result, + paths: ['/Users/operator/private'], + }), + false, + ); + assert.equal( + isWorkspaceToolResult(request, { ...result, paths: ['outside.txt'] }), + false, + ); + assert.equal( + isWorkspaceToolResult(request, { + ...result, + paths: ['src/app.ts', 'src/app.ts'], + }), + false, + ); + assert.equal( + isWorkspaceToolResult(request, { + ...result, + root: '/private/workspace', + }), + false, + ); +}); diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 286b812..f88a342 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -11,10 +11,14 @@ export const BRIDGE_WORKSPACE_READ_MAX_BYTES = 1024 * 1024; 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; +export const BRIDGE_WORKSPACE_LIST_MAX_RESULTS = 500; export type BridgeProtocolVersion = typeof BRIDGE_PROTOCOL_VERSION; -export type BridgeWorkspaceToolOperation = 'read_file' | 'search_text'; +export type BridgeWorkspaceToolOperation = + | 'read_file' + | 'search_text' + | 'list_files'; export interface BridgeWorkspaceDescriptor { id: string; @@ -72,12 +76,30 @@ export interface WorkspaceSearchTextResult { truncated: boolean; } +export interface WorkspaceListFilesRequest { + protocolVersion: BridgeProtocolVersion; + operation: 'list_files'; + workspaceId: string; + path?: string; + maxResults?: number; +} + +export interface WorkspaceListFilesResult { + protocolVersion: BridgeProtocolVersion; + operation: 'list_files'; + workspaceId: string; + paths: string[]; + truncated: boolean; +} + export type WorkspaceToolRequest = | WorkspaceReadFileRequest - | WorkspaceSearchTextRequest; + | WorkspaceSearchTextRequest + | WorkspaceListFilesRequest; export type WorkspaceToolResult = | WorkspaceReadFileResult - | WorkspaceSearchTextResult; + | WorkspaceSearchTextResult + | WorkspaceListFilesResult; const WORKSPACE_READ_REQUEST_KEYS = new Set([ 'protocolVersion', @@ -95,6 +117,13 @@ const WORKSPACE_SEARCH_REQUEST_KEYS = new Set([ 'path', 'maxResults', ]); +const WORKSPACE_LIST_REQUEST_KEYS = new Set([ + 'protocolVersion', + 'operation', + 'workspaceId', + 'path', + 'maxResults', +]); const WORKSPACE_READ_RESULT_KEYS = new Set([ 'protocolVersion', 'operation', @@ -113,6 +142,13 @@ const WORKSPACE_SEARCH_RESULT_KEYS = new Set([ 'matches', 'truncated', ]); +const WORKSPACE_LIST_RESULT_KEYS = new Set([ + 'protocolVersion', + 'operation', + 'workspaceId', + 'paths', + 'truncated', +]); const WORKSPACE_SEARCH_MATCH_KEYS = new Set([ 'path', 'line', @@ -144,6 +180,8 @@ export interface BridgeWorkerRegistrationResponse { registrationGeneration?: number; registeredAt: string; leaseTtlMs: number; + /** Operations this Code API can dispatch after the worker advertises them. */ + supportedWorkspaceToolOperations?: BridgeWorkspaceToolOperation[]; } export interface BridgePairingRedemption { @@ -212,6 +250,8 @@ export type WorkspaceToolErrorCode = | 'READ_LIMIT_EXCEEDED' | 'REGISTRATION_INVALID' | 'EXECUTION_ABORTED' + | 'LIST_TIMEOUT' + | 'LIST_UNAVAILABLE' | 'SEARCH_TIMEOUT' | 'SEARCH_UNAVAILABLE'; @@ -221,6 +261,8 @@ const WORKSPACE_TOOL_ERROR_CODES = new Set([ 'READ_LIMIT_EXCEEDED', 'REGISTRATION_INVALID', 'EXECUTION_ABORTED', + 'LIST_TIMEOUT', + 'LIST_UNAVAILABLE', 'SEARCH_TIMEOUT', 'SEARCH_UNAVAILABLE', ]); @@ -266,7 +308,7 @@ export function isValidBridgeWorkerId(workerId: string): boolean { return BRIDGE_WORKER_ID_PATTERN.test(workerId); } -function isSafePortableRelativePath(value: unknown): value is string { +export function isSafePortableRelativePath(value: unknown): value is string { if ( typeof value !== 'string' || value.length === 0 || @@ -354,6 +396,17 @@ export function isWorkspaceToolRequest( Number(request.maxResults) <= BRIDGE_WORKSPACE_SEARCH_MAX_RESULTS)) ); } + if (request.operation === 'list_files') { + return ( + hasOnlyKeys(request, WORKSPACE_LIST_REQUEST_KEYS) && + (request.path === undefined || + isSafePortableRelativePath(request.path)) && + (request.maxResults === undefined || + (Number.isSafeInteger(request.maxResults) && + Number(request.maxResults) >= 1 && + Number(request.maxResults) <= BRIDGE_WORKSPACE_LIST_MAX_RESULTS)) + ); + } return false; } @@ -405,6 +458,21 @@ export function isWorkspaceToolResult( ); } + if (request.operation === 'list_files') { + const maxResults = request.maxResults ?? 100; + return ( + hasOnlyKeys(result, WORKSPACE_LIST_RESULT_KEYS) && + Array.isArray(result.paths) && + result.paths.length <= maxResults && + new Set(result.paths).size === result.paths.length && + result.paths.every( + (path) => + isSafePortableRelativePath(path) && + isWithinRequestedPath(path, request.path), + ) + ); + } + if (!Array.isArray(result.matches)) return false; const maxResults = request.maxResults ?? 50; return ( @@ -438,9 +506,12 @@ export function isValidBridgeWorkspaceToolCapabilities( capabilities.protocolVersion !== BRIDGE_PROTOCOL_VERSION || !Array.isArray(capabilities.operations) || capabilities.operations.length < 1 || - capabilities.operations.length > 2 || + capabilities.operations.length > 3 || !capabilities.operations.every( - (operation) => operation === 'read_file' || operation === 'search_text', + (operation) => + operation === 'read_file' || + operation === 'search_text' || + operation === 'list_files', ) || new Set(capabilities.operations).size !== capabilities.operations.length || !Array.isArray(capabilities.workspaces) || diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 2752674..98eb997 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -137,6 +137,42 @@ function workspaceCapabilitiesMatch( ); } +function registrationCompatibleCapabilities( + capabilities: BridgeWorkerCapabilities, +): BridgeWorkerCapabilities { + const workspaceTools = capabilities.workspaceTools; + if ( + workspaceTools == null || + !workspaceTools.operations.includes('list_files') + ) { + return capabilities; + } + const operations = workspaceTools.operations.filter( + (operation) => operation !== 'list_files', + ); + if (operations.length === 0) { + const { workspaceTools: _workspaceTools, ...compatible } = capabilities; + return compatible; + } + return { + ...capabilities, + workspaceTools: { ...workspaceTools, operations }, + }; +} + +function supportsDesiredWorkspaceTools( + registration: BridgeWorkerRegistrationResponse, + capabilities: BridgeWorkerCapabilities, +): boolean { + const desired = capabilities.workspaceTools?.operations; + const supported = registration.supportedWorkspaceToolOperations; + return ( + desired != null && + Array.isArray(supported) && + desired.every((operation) => supported.includes(operation)) + ); +} + export class BridgeWorkspaceQuarantinedError extends Error { constructor( message: string, @@ -152,6 +188,8 @@ export class BridgeWorker { private readonly codeApiUrl: string; private readonly runtimeSupervisor: RuntimeSupervisor; private readonly incarnationId: string; + private readonly compatibleCapabilities: BridgeWorkerCapabilities; + private registrationCapabilities: BridgeWorkerCapabilities; private registrationTtlMs = DEFAULT_REGISTRATION_TTL_MS; private lastRegisteredAtMs = 0; private serverClockOffsetMs = MAX_PROOF_CLOCK_SKEW_MS; @@ -194,6 +232,10 @@ export class BridgeWorker { }); this.incarnationId = options.incarnationId ?? randomBytes(18).toString('base64url'); + this.compatibleCapabilities = registrationCompatibleCapabilities( + options.capabilities, + ); + this.registrationCapabilities = this.compatibleCapabilities; } async register( @@ -218,16 +260,42 @@ export class BridgeWorker { const registrationStartedAtMs = Date.now(); let registration: BridgeWorkerRegistrationResponse; try { - registration = await this.request( - `${this.codeApiUrl}/bridge/workers/register`, - { - protocolVersion: BRIDGE_PROTOCOL_VERSION, - workerId: this.options.workerId, - incarnationId: this.incarnationId, - capabilities: this.options.capabilities, - }, - registrationController.signal, - ); + const register = (capabilities: BridgeWorkerCapabilities) => + this.request( + `${this.codeApiUrl}/bridge/workers/register`, + { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: this.options.workerId, + incarnationId: this.incarnationId, + capabilities, + }, + registrationController.signal, + ); + try { + registration = await register(this.registrationCapabilities); + } catch (error) { + if ( + !(error instanceof BridgeProtocolError) || + error.status !== 400 || + this.registrationCapabilities === this.compatibleCapabilities + ) { + throw error; + } + this.registrationCapabilities = this.compatibleCapabilities; + registration = await register(this.registrationCapabilities); + } + if ( + this.registrationCapabilities !== this.options.capabilities && + supportsDesiredWorkspaceTools(registration, this.options.capabilities) + ) { + this.registrationCapabilities = this.options.capabilities; + try { + registration = await register(this.registrationCapabilities); + } catch (error) { + this.registrationCapabilities = this.compatibleCapabilities; + if (signal?.aborted) throw error; + } + } } finally { clearTimeout(timeout); signal?.removeEventListener('abort', abortRegistration); diff --git a/packages/code/src/workspace-worker.test.ts b/packages/code/src/workspace-worker.test.ts index 352cbe3..742808e 100644 --- a/packages/code/src/workspace-worker.test.ts +++ b/packages/code/src/workspace-worker.test.ts @@ -6,6 +6,146 @@ import { WorkspaceToolError } from './workspace.js'; const incarnationId = 'incarnation-00000001'; +const listWorkspaceCapabilities = { + protocolVersion: 1 as const, + operations: [ + 'read_file' as const, + 'search_text' as const, + 'list_files' as const, + ], + workspaces: [{ id: 'primary' }], +}; + +function registrationResponse(supportsList: boolean): Response { + return Response.json({ + protocolVersion: 1, + workerId: 'vm-1', + incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60_000, + ...(supportsList + ? { + supportedWorkspaceToolOperations: [ + 'read_file', + 'search_text', + 'list_files', + ], + } + : {}), + }); +} + +function listWorkspaceExecutor() { + return { + capabilities: listWorkspaceCapabilities, + async execute() { + return { + protocolVersion: 1 as const, + operation: 'list_files' as const, + workspaceId: 'primary', + paths: [], + truncated: false, + }; + }, + }; +} + +test('worker keeps v1 registration compatible until list_files support is advertised', async () => { + const registrations: string[][] = []; + 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: listWorkspaceExecutor(), + fetchImpl: async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { + capabilities: { workspaceTools?: { operations: string[] } }; + }; + registrations.push(body.capabilities.workspaceTools?.operations ?? []); + return registrationResponse(false); + }, + }); + + await worker.register(); + + assert.deepEqual(registrations, [['read_file', 'search_text']]); +}); + +test('worker re-registers list_files after the Code API advertises support', async () => { + const registrations: string[][] = []; + 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: listWorkspaceExecutor(), + fetchImpl: async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { + capabilities: { workspaceTools?: { operations: string[] } }; + }; + registrations.push(body.capabilities.workspaceTools?.operations ?? []); + return registrationResponse(true); + }, + }); + + await worker.register(); + + assert.deepEqual(registrations, [ + ['read_file', 'search_text'], + ['read_file', 'search_text', 'list_files'], + ]); +}); + +test('worker retains a compatible registration when list_files promotion times out', async () => { + let registrationRequests = 0; + 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', + registrationTransportTimeoutMs: 20, + capabilities: { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: listWorkspaceCapabilities, + }, + workspaceTools: listWorkspaceExecutor(), + fetchImpl: async (_input, init) => { + registrationRequests += 1; + if (registrationRequests === 1) return registrationResponse(true); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason ?? new Error('aborted')), + { once: true }, + ); + }); + }, + }); + + const registration = await worker.register(); + + assert.equal(registration.workerId, 'vm-1'); + assert.equal(registrationRequests, 2); +}); + test('worker preserves bounded workspace rejection codes', async () => { let settlement: Record | undefined; const workspaceCapabilities = { diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index e8ee53b..eaf65e8 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -2,7 +2,7 @@ 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 { join, sep } from 'node:path'; import test from 'node:test'; import { promisify } from 'node:util'; @@ -159,6 +159,170 @@ test('searches workspace text with a hard global result bound', async (t) => { ); }); +test('lists workspace files deterministically with a hard result bound', 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 mkdir(join(root, 'docs')); + await writeFile(join(root, 'src', 'app.ts'), 'export {}'); + await writeFile(join(root, 'src', 'worker.ts'), 'export {}'); + await writeFile(join(root, 'docs', 'guide.md'), '# Guide'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + assert.deepEqual( + await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + maxResults: 2, + }), + { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['docs/guide.md', 'src/app.ts'], + truncated: true, + }, + ); + assert.deepEqual( + await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: 'src', + maxResults: 10, + }), + { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['src/app.ts', 'src/worker.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-'), + ); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(parent, 'workspace'); + const outside = join(parent, 'outside'); + await mkdir(root); + await mkdir(outside); + await writeFile(join(outside, 'secret.txt'), 'host secret'); + await symlink(outside, join(root, 'linked-outside')); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + await assert.rejects( + tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: 'linked-outside', + }), + /invalid workspace path/i, + ); +}); + +test('listing preserves an in-workspace symlink namespace', 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'), 'export const app = true;'); + await symlink(join(root, 'src'), join(root, 'alias')); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: 'alias', + }); + + if (result.operation !== 'list_files') assert.fail('expected list result'); + assert.deepEqual(result.paths, ['alias/app.ts']); +}); + +test('listing ignores ripgrep config that follows escaping symlinks', async (t) => { + const parent = await mkdtemp(join(tmpdir(), 'librechat-code-list-parent-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const root = join(parent, 'workspace'); + const outside = join(parent, 'outside'); + await mkdir(root); + await mkdir(outside); + await writeFile(join(root, 'safe.txt'), 'safe'); + await writeFile(join(outside, 'secret.txt'), '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: 'list_files', + workspaceId: 'primary', + }); + + if (result.operation !== 'list_files') assert.fail('expected list result'); + assert.deepEqual(result.paths, ['safe.txt']); +}); + +test('listing skips filenames the portable protocol cannot represent', async (t) => { + if (sep === '\\') return; + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'invalid\\name.txt'), 'invalid'); + await writeFile(join(root, 'safe.txt'), 'safe'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + }); + + if (result.operation !== 'list_files') assert.fail('expected list result'); + assert.deepEqual(result.paths, ['safe.txt']); +}); + +test('listing excludes a non-regular explicit target', async (t) => { + if (sep === '\\') return; + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await execFileAsync('mkfifo', [join(root, 'events.pipe')]); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: 'events.pipe', + }); + + if (result.operation !== 'list_files') assert.fail('expected list result'); + assert.deepEqual(result.paths, []); +}); + 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 })); @@ -491,7 +655,7 @@ test('advertises workspace IDs and names without exposing host roots', async (t) assert.deepEqual(tools.capabilities, { protocolVersion: 1, - operations: ['read_file', 'search_text'], + operations: ['read_file', 'search_text', 'list_files'], workspaces: [{ id: 'primary', name: 'LibreChat' }], }); assert.equal(JSON.stringify(tools.capabilities).includes(root), false); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 3e6c27c..8efb310 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -9,8 +9,10 @@ import { BRIDGE_PROTOCOL_VERSION, BRIDGE_WORKSPACE_READ_MAX_BYTES, BRIDGE_WORKSPACE_READ_MAX_LINES, + BRIDGE_WORKSPACE_LIST_MAX_RESULTS, BRIDGE_WORKSPACE_SEARCH_MAX_RESULTS, BRIDGE_WORKSPACE_SEARCH_TEXT_MAX_LENGTH, + isSafePortableRelativePath, isValidBridgeWorkspaceToolCapabilities, isWorkspaceToolRequest, isWorkspaceToolResult, @@ -21,6 +23,8 @@ import type { BridgeWorkspaceToolCapabilities, WorkspaceReadFileRequest, WorkspaceReadFileResult, + WorkspaceListFilesRequest, + WorkspaceListFilesResult, WorkspaceSearchMatch, WorkspaceSearchTextRequest, WorkspaceSearchTextResult, @@ -33,6 +37,8 @@ export { isWorkspaceToolRequest, isWorkspaceToolResult }; export type { WorkspaceReadFileRequest, WorkspaceReadFileResult, + WorkspaceListFilesRequest, + WorkspaceListFilesResult, WorkspaceSearchMatch, WorkspaceSearchTextRequest, WorkspaceSearchTextResult, @@ -61,6 +67,7 @@ export interface WorkspaceToolExecutor { const MAX_SEARCH_CANDIDATE_BYTES = 1024 * 1024; const MAX_SEARCH_CANDIDATES = 20_000; const SEARCH_TIMEOUT_MS = 10_000; +const LIST_TIMEOUT_MS = 10_000; function isUtf8ScalarString(value: string): boolean { return Buffer.from(value).toString('utf8') === value; @@ -499,6 +506,252 @@ async function searchWorkspace( }; } +async function listWorkspaceFiles( + root: string, + request: WorkspaceListFilesRequest, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + LIST_TIMEOUT_MS; + const maxResults = request.maxResults ?? 100; + if ( + !Number.isSafeInteger(maxResults) || + maxResults < 1 || + maxResults > BRIDGE_WORKSPACE_LIST_MAX_RESULTS + ) { + throw new WorkspaceToolError( + 'Invalid workspace listing', + 'INVALID_REQUEST', + ); + } + + const listPath = request.path ?? '.'; + const target = resolveWorkspacePath(root, listPath); + let canonicalTarget: string; + try { + canonicalTarget = await withinListDeadline( + realpath(target), + signal, + deadline, + ); + } catch (error) { + if (error instanceof WorkspaceToolError) throw error; + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } + if (!isWithinRoot(root, canonicalTarget)) { + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } + const canonicalListPath = relative(root, canonicalTarget) || '.'; + const portableCanonicalListPath = canonicalListPath.split(sep).join('/'); + const normalizedRequestedResultPath = request.path + ?.split('/') + .filter((segment) => segment.length > 0 && segment !== '.') + .join('/'); + const requestedResultPath = normalizedRequestedResultPath || undefined; + + const candidates: Array<{ filesystemPath: string; resultPath: string }> = []; + let truncated = false; + let pending = ''; + let stoppedForLimit = false; + await new Promise((resolvePromise, reject) => { + const child = spawn( + 'rg', + [ + '--files', + '--no-config', + '--no-follow', + '--no-messages', + '--sort', + 'path', + '--null', + '--', + canonicalListPath, + ], + { 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(); + }, Math.max(0, deadline - Date.now())); + const cleanup = () => { + clearTimeout(timeout); + signal?.removeEventListener('abort', abort); + }; + const consumePath = (path: string) => { + if (!path || stoppedForLimit) return; + if (candidates.length === maxResults + BRIDGE_WORKSPACE_LIST_MAX_RESULTS) { + truncated = true; + stoppedForLimit = true; + child.kill(); + return; + } + const portablePath = sep === '\\' ? path.split(sep).join('/') : path; + const normalizedPath = portablePath.startsWith('./') + ? portablePath.slice(2) + : portablePath; + const resultPath = + requestedResultPath == null + ? normalizedPath + : portableCanonicalListPath === '.' + ? `${requestedResultPath}/${normalizedPath}` + : normalizedPath === portableCanonicalListPath || + normalizedPath.startsWith(`${portableCanonicalListPath}/`) + ? `${requestedResultPath}${normalizedPath.slice(portableCanonicalListPath.length)}` + : normalizedPath; + if (!isSafePortableRelativePath(resultPath)) return; + candidates.push({ filesystemPath: normalizedPath, resultPath }); + }; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + pending += chunk; + let delimiter = pending.indexOf('\0'); + while (delimiter >= 0) { + consumePath(pending.slice(0, delimiter)); + pending = pending.slice(delimiter + 1); + delimiter = pending.indexOf('\0'); + } + }); + child.once('error', () => { + cleanup(); + reject( + new WorkspaceToolError( + 'Workspace listing unavailable', + 'LIST_UNAVAILABLE', + ), + ); + }); + child.once('close', (code) => { + cleanup(); + consumePath(pending); + if (aborted) { + reject( + new WorkspaceToolError( + 'Workspace tool execution aborted', + 'EXECUTION_ABORTED', + ), + ); + } else if (timedOut) { + reject( + new WorkspaceToolError('Workspace listing timed out', 'LIST_TIMEOUT'), + ); + } else if (stoppedForLimit || code === 0 || code === 1) { + resolvePromise(); + } else { + reject( + new WorkspaceToolError( + 'Workspace listing unavailable', + 'LIST_UNAVAILABLE', + ), + ); + } + }); + }); + + const paths: string[] = []; + const seenPaths = new Set(); + for (const candidate of candidates) { + let canonicalPath: string; + try { + canonicalPath = await withinListDeadline( + realpath(resolveWorkspacePath(root, candidate.filesystemPath)), + signal, + deadline, + ); + } catch (error) { + if (error instanceof WorkspaceToolError) throw error; + continue; + } + if (!isWithinRoot(root, canonicalPath)) { + continue; + } + let regularFile = false; + try { + regularFile = ( + await withinListDeadline(stat(canonicalPath), signal, deadline) + ).isFile(); + } catch (error) { + if (error instanceof WorkspaceToolError) throw error; + continue; + } + if (!regularFile || seenPaths.has(candidate.resultPath)) continue; + if (paths.length === maxResults) { + truncated = true; + break; + } + seenPaths.add(candidate.resultPath); + paths.push(candidate.resultPath); + } + + return { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'list_files', + workspaceId: request.workspaceId, + paths, + truncated, + }; +} + +async function withinListDeadline( + operation: Promise, + signal: AbortSignal | undefined, + deadline: number, +): Promise { + if (signal?.aborted) { + throw new WorkspaceToolError( + 'Workspace tool execution aborted', + 'EXECUTION_ABORTED', + ); + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw new WorkspaceToolError('Workspace listing timed out', 'LIST_TIMEOUT'); + } + return new Promise((resolvePromise, reject) => { + let settled = false; + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + signal?.removeEventListener('abort', abort); + callback(); + }; + const abort = () => + settle(() => + reject( + new WorkspaceToolError( + 'Workspace tool execution aborted', + 'EXECUTION_ABORTED', + ), + ), + ); + const timeout = setTimeout( + () => + settle(() => + reject( + new WorkspaceToolError( + 'Workspace listing timed out', + 'LIST_TIMEOUT', + ), + ), + ), + remainingMs, + ); + signal?.addEventListener('abort', abort, { once: true }); + operation.then( + (value) => settle(() => resolvePromise(value)), + (error: unknown) => settle(() => reject(error)), + ); + }); +} + export class LocalWorkspaceTools implements WorkspaceToolExecutor { readonly capabilities: BridgeWorkspaceToolCapabilities; @@ -508,7 +761,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { ) { this.capabilities = { protocolVersion: BRIDGE_PROTOCOL_VERSION, - operations: ['read_file', 'search_text'], + operations: ['read_file', 'search_text', 'list_files'], workspaces, }; } @@ -525,7 +778,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { ); const capabilities: BridgeWorkspaceToolCapabilities = { protocolVersion: BRIDGE_PROTOCOL_VERSION, - operations: ['read_file', 'search_text'], + operations: ['read_file', 'search_text', 'list_files'], workspaces, }; if (!isValidBridgeWorkspaceToolCapabilities(capabilities)) { @@ -574,6 +827,9 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { if (request.operation === 'search_text') { return searchWorkspace(root, request, signal); } + if (request.operation === 'list_files') { + return listWorkspaceFiles(root, request, signal); + } const startLine = request.startLine ?? 1; const maxLines = request.maxLines ?? 200; diff --git a/service/src/bridge/router.test.ts b/service/src/bridge/router.test.ts index 7e6e0fc..2e5343f 100644 --- a/service/src/bridge/router.test.ts +++ b/service/src/bridge/router.test.ts @@ -279,6 +279,11 @@ describe('paired bridge HTTP API', () => { workerId: 'vm-1', incarnationId: 'incarnation-00000001', registrationGeneration: 1, + supportedWorkspaceToolOperations: [ + 'read_file', + 'search_text', + 'list_files', + ], }); const crossDeploymentRevoke = await fetch( diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index 0905993..f4c2b69 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -379,6 +379,11 @@ router.post( registrationGeneration, registeredAt: new Date().toISOString(), leaseTtlMs: 60_000, + supportedWorkspaceToolOperations: [ + 'read_file', + 'search_text', + 'list_files', + ], }); } catch (error) { if (error instanceof BridgeStoreError) { diff --git a/service/src/workspace-tools/router.test.ts b/service/src/workspace-tools/router.test.ts index aa9c210..2889391 100644 --- a/service/src/workspace-tools/router.test.ts +++ b/service/src/workspace-tools/router.test.ts @@ -71,6 +71,8 @@ test('rejects new workspace dispatches while the service is shutting down', asyn test.each([ ['SEARCH_TIMEOUT', 504], ['SEARCH_UNAVAILABLE', 503], + ['LIST_TIMEOUT', 504], + ['LIST_UNAVAILABLE', 503], ] as const)('maps worker %s rejections to HTTP %i', async (errorCode, expectedStatus) => { const app = express(); app.use(json()); diff --git a/service/src/workspace-tools/router.ts b/service/src/workspace-tools/router.ts index 13a4bd9..5daf83e 100644 --- a/service/src/workspace-tools/router.ts +++ b/service/src/workspace-tools/router.ts @@ -101,8 +101,18 @@ export function createWorkspaceToolsRouter(options: WorkspaceToolsRouterOptions) }); if (settlement.status === 'rejected') { let status = 422; - if (settlement.errorCode === 'SEARCH_TIMEOUT') status = 504; - if (settlement.errorCode === 'SEARCH_UNAVAILABLE') status = 503; + if ( + settlement.errorCode === 'SEARCH_TIMEOUT' || + settlement.errorCode === 'LIST_TIMEOUT' + ) { + status = 504; + } + if ( + settlement.errorCode === 'SEARCH_UNAVAILABLE' || + settlement.errorCode === 'LIST_UNAVAILABLE' + ) { + status = 503; + } res.status(status).json({ error: settlement.error, code: settlement.errorCode ?? 'WORKSPACE_TOOL_REJECTED', From d0383e8ce0165368eed0259866406f4d6bf05269 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 20:41:12 -0400 Subject: [PATCH 2/4] fix: preserve canonical workspace listings --- packages/code/src/protocol.test.ts | 7 +++++++ packages/code/src/protocol.ts | 31 +++++++++++++++++++---------- packages/code/src/workspace.test.ts | 31 +++++++++++++++++++++++++++++ packages/code/src/workspace.ts | 25 +++++++++++++---------- 4 files changed, 73 insertions(+), 21 deletions(-) diff --git a/packages/code/src/protocol.test.ts b/packages/code/src/protocol.test.ts index ab19ff0..55b67b2 100644 --- a/packages/code/src/protocol.test.ts +++ b/packages/code/src/protocol.test.ts @@ -133,6 +133,13 @@ test('workspace file listing accepts only bounded portable requests and results' }), false, ); + assert.equal( + isWorkspaceToolResult(request, { + ...result, + paths: ['src/app.ts', 'src/./app.ts'], + }), + false, + ); assert.equal( isWorkspaceToolResult(request, { ...result, diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index f88a342..9d06523 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -460,17 +460,26 @@ export function isWorkspaceToolResult( if (request.operation === 'list_files') { const maxResults = request.maxResults ?? 100; - return ( - hasOnlyKeys(result, WORKSPACE_LIST_RESULT_KEYS) && - Array.isArray(result.paths) && - result.paths.length <= maxResults && - new Set(result.paths).size === result.paths.length && - result.paths.every( - (path) => - isSafePortableRelativePath(path) && - isWithinRequestedPath(path, request.path), - ) - ); + if ( + !hasOnlyKeys(result, WORKSPACE_LIST_RESULT_KEYS) || + !Array.isArray(result.paths) || + result.paths.length > maxResults + ) { + return false; + } + const normalizedPaths = new Set(); + for (const path of result.paths) { + if ( + !isSafePortableRelativePath(path) || + !isWithinRequestedPath(path, request.path) + ) { + return false; + } + const normalizedPath = normalizePortableRelativePath(path); + if (normalizedPaths.has(normalizedPath)) return false; + normalizedPaths.add(normalizedPath); + } + return true; } if (!Array.isArray(result.matches)) return false; diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index eaf65e8..4a431bb 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -303,6 +303,37 @@ test('listing skips filenames the portable protocol cannot represent', async (t) assert.deepEqual(result.paths, ['safe.txt']); }); +test('listing rejects invalid UTF-8 bytes instead of aliasing a valid filename', async (t) => { + if (sep === '\\') return; + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + const invalidPath = Buffer.concat([ + Buffer.from(`${root}${sep}`), + Buffer.from([0xff]), + Buffer.from('.txt'), + ]); + try { + await writeFile(invalidPath, 'invalid'); + } catch { + t.skip('filesystem does not support non-UTF-8 filenames'); + return; + } + await writeFile(join(root, '\ufffd.txt'), 'valid but ignored'); + await writeFile(join(root, '.ignore'), '\ufffd.txt\n'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + }); + + if (result.operation !== 'list_files') assert.fail('expected list result'); + assert.equal(result.paths.includes('\ufffd.txt'), false); +}); + test('listing excludes a non-regular explicit target', async (t) => { if (sep === '\\') return; const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 8efb310..8d29a83 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -550,7 +550,7 @@ async function listWorkspaceFiles( const candidates: Array<{ filesystemPath: string; resultPath: string }> = []; let truncated = false; - let pending = ''; + let pending: Buffer = Buffer.alloc(0); let stoppedForLimit = false; await new Promise((resolvePromise, reject) => { const child = spawn( @@ -584,8 +584,14 @@ async function listWorkspaceFiles( clearTimeout(timeout); signal?.removeEventListener('abort', abort); }; - const consumePath = (path: string) => { - if (!path || stoppedForLimit) return; + const consumePath = (rawPath: Buffer) => { + if (rawPath.length === 0 || stoppedForLimit) return; + let path: string; + try { + path = new TextDecoder('utf-8', { fatal: true }).decode(rawPath); + } catch { + return; + } if (candidates.length === maxResults + BRIDGE_WORKSPACE_LIST_MAX_RESULTS) { truncated = true; stoppedForLimit = true; @@ -609,14 +615,13 @@ async function listWorkspaceFiles( candidates.push({ filesystemPath: normalizedPath, resultPath }); }; - child.stdout.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - pending += chunk; - let delimiter = pending.indexOf('\0'); + child.stdout.on('data', (chunk: Buffer) => { + pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]); + let delimiter = pending.indexOf(0); while (delimiter >= 0) { - consumePath(pending.slice(0, delimiter)); - pending = pending.slice(delimiter + 1); - delimiter = pending.indexOf('\0'); + consumePath(pending.subarray(0, delimiter)); + pending = pending.subarray(delimiter + 1); + delimiter = pending.indexOf(0); } }); child.once('error', () => { From 5c716c714e63e910b54697566903b56d2e7fd394 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 20:47:51 -0400 Subject: [PATCH 3/4] fix: preserve exact UTF-8 workspace paths --- packages/code/src/workspace.test.ts | 21 +++++++++++++++++++++ packages/code/src/workspace.ts | 7 ++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 4a431bb..652b4b6 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -334,6 +334,27 @@ test('listing rejects invalid UTF-8 bytes instead of aliasing a valid filename', assert.equal(result.paths.includes('\ufffd.txt'), false); }); +test('listing preserves a leading UTF-8 BOM in a filename', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, '\ufefffoo.txt'), 'bom filename'); + await writeFile(join(root, 'foo.txt'), 'ignored sibling'); + await writeFile(join(root, '.ignore'), 'foo.txt\n'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + }); + + if (result.operation !== 'list_files') assert.fail('expected list result'); + assert.equal(result.paths.includes('\ufefffoo.txt'), true); + assert.equal(result.paths.includes('foo.txt'), false); +}); + test('listing excludes a non-regular explicit target', async (t) => { if (sep === '\\') return; const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 8d29a83..53af517 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -584,14 +584,19 @@ async function listWorkspaceFiles( clearTimeout(timeout); signal?.removeEventListener('abort', abort); }; + const pathDecoder = new TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true, + }); const consumePath = (rawPath: Buffer) => { if (rawPath.length === 0 || stoppedForLimit) return; let path: string; try { - path = new TextDecoder('utf-8', { fatal: true }).decode(rawPath); + path = pathDecoder.decode(rawPath); } catch { return; } + if (!Buffer.from(path).equals(rawPath)) return; if (candidates.length === maxResults + BRIDGE_WORKSPACE_LIST_MAX_RESULTS) { truncated = true; stoppedForLimit = true; From b602bb21897767d3a5f79d39c6c60f22e61ad28a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 20:55:30 -0400 Subject: [PATCH 4/4] fix: preserve workspace listing policy --- packages/code/src/workspace.test.ts | 41 ++++++++++++++++++++ packages/code/src/workspace.ts | 59 +++++++++++++++++++++++------ 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 652b4b6..b033550 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -283,6 +283,47 @@ test('listing ignores ripgrep config that follows escaping symlinks', async (t) assert.deepEqual(result.paths, ['safe.txt']); }); +test('listing preserves ignore rules for an explicitly requested subtree', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, 'vendor')); + await writeFile(join(root, 'vendor', 'dependency.js'), 'ignored'); + await writeFile(join(root, '.ignore'), 'vendor/\n'); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: 'vendor', + }); + + if (result.operation !== 'list_files') assert.fail('expected list result'); + assert.deepEqual(result.paths, []); +}); + +test('listing excludes an explicitly requested symlink file alias', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(join(root, 'target.txt'), 'target'); + await symlink(join(root, 'target.txt'), join(root, 'alias.txt')); + const tools = await LocalWorkspaceTools.create({ + workspaces: [{ id: 'primary', root }], + }); + + const result = await tools.execute({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: 'alias.txt', + }); + + if (result.operation !== 'list_files') assert.fail('expected list result'); + assert.deepEqual(result.paths, []); +}); + test('listing skips filenames the portable protocol cannot represent', async (t) => { if (sep === '\\') return; const root = await mkdtemp(join(tmpdir(), 'librechat-code-workspace-')); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index 53af517..4311451 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { constants } from 'node:fs'; -import { open, realpath, stat } from 'node:fs/promises'; +import { lstat, open, realpath, stat } from 'node:fs/promises'; import { isAbsolute, relative, resolve, sep } from 'node:path'; import type { FileHandle } from 'node:fs/promises'; @@ -542,6 +542,15 @@ async function listWorkspaceFiles( } const canonicalListPath = relative(root, canonicalTarget) || '.'; const portableCanonicalListPath = canonicalListPath.split(sep).join('/'); + let canonicalTargetIsDirectory = false; + try { + canonicalTargetIsDirectory = ( + await withinListDeadline(stat(canonicalTarget), signal, deadline) + ).isDirectory(); + } catch (error) { + if (error instanceof WorkspaceToolError) throw error; + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } const normalizedRequestedResultPath = request.path ?.split('/') .filter((segment) => segment.length > 0 && segment !== '.') @@ -553,19 +562,27 @@ async function listWorkspaceFiles( let pending: Buffer = Buffer.alloc(0); let stoppedForLimit = false; await new Promise((resolvePromise, reject) => { + const args = [ + '--files', + '--no-config', + '--no-follow', + '--no-messages', + '--sort', + 'path', + '--null', + ]; + if (portableCanonicalListPath !== '.') { + args.push( + '--glob', + canonicalTargetIsDirectory + ? `${portableCanonicalListPath}/**` + : portableCanonicalListPath, + ); + } + args.push('--', '.'); const child = spawn( 'rg', - [ - '--files', - '--no-config', - '--no-follow', - '--no-messages', - '--sort', - 'path', - '--null', - '--', - canonicalListPath, - ], + args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }, ); let aborted = false; @@ -682,6 +699,24 @@ async function listWorkspaceFiles( if (!isWithinRoot(root, canonicalPath)) { continue; } + const reportedPath = resolveWorkspacePath(root, candidate.resultPath); + try { + const reportedPathStat = await withinListDeadline( + lstat(reportedPath), + signal, + deadline, + ); + if (reportedPathStat.isSymbolicLink()) continue; + const canonicalReportedPath = await withinListDeadline( + realpath(reportedPath), + signal, + deadline, + ); + if (canonicalReportedPath !== canonicalPath) continue; + } catch (error) { + if (error instanceof WorkspaceToolError) throw error; + continue; + } let regularFile = false; try { regularFile = (