From c3911371aec565c07cc80ee538ffb6f6dc8314cb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:24:56 -0400 Subject: [PATCH 1/8] feat(code): list attached workspace files --- .../services/Endpoints/agents/skillDeps.js | 2 + .../Endpoints/agents/skillDeps.spec.js | 8 ++ api/server/services/Files/Code/process.js | 42 +++++++ .../services/Files/Code/process.spec.js | 46 ++++++++ packages/api/src/agents/handlers.spec.ts | 107 ++++++++++++++++++ packages/api/src/agents/handlers.ts | 95 +++++++++++++++- packages/api/src/agents/tools.spec.ts | 15 +++ packages/api/src/agents/tools.ts | 28 ++++- packages/api/src/code/workspace.spec.ts | 50 ++++++++ packages/api/src/code/workspace.ts | 54 ++++++++- 10 files changed, 443 insertions(+), 4 deletions(-) diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js index 1e0a96295d5..29b5e70ed66 100644 --- a/api/server/services/Endpoints/agents/skillDeps.js +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -6,6 +6,7 @@ const { checkIfActive, readWorkspaceFile, searchWorkspace, + listWorkspaceFiles, readSandboxFile, readSandboxImage, writeSandboxFile, @@ -384,6 +385,7 @@ const skillToolDeps = { updateSkillFileContent: deploymentSkillMethods.updateSkillFileContent, readWorkspaceFile, searchWorkspace, + listWorkspaceFiles, /** * `read_file` falls back to a sandbox `cat` for `/mnt/data/...` paths * and for `{firstSegment}/...` paths whose first segment isn't a known diff --git a/api/server/services/Endpoints/agents/skillDeps.spec.js b/api/server/services/Endpoints/agents/skillDeps.spec.js index f9e87b9d224..eac12f70336 100644 --- a/api/server/services/Endpoints/agents/skillDeps.spec.js +++ b/api/server/services/Endpoints/agents/skillDeps.spec.js @@ -7,6 +7,7 @@ const mockResolveRequestTenantId = jest.fn(); const mockCreateDeploymentSkillMethods = jest.fn((methods) => methods); const mockReadWorkspaceFile = jest.fn(); const mockSearchWorkspace = jest.fn(); +const mockListWorkspaceFiles = jest.fn(); jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args), @@ -22,6 +23,7 @@ jest.mock('~/server/services/Files/Code/process', () => ({ readSandboxFile: jest.fn(), readWorkspaceFile: (...args) => mockReadWorkspaceFile(...args), searchWorkspace: (...args) => mockSearchWorkspace(...args), + listWorkspaceFiles: (...args) => mockListWorkspaceFiles(...args), writeSandboxFile: jest.fn(), })); @@ -95,6 +97,12 @@ describe('skillDeps saveSkillFileContent', () => { expect(mockSearchWorkspace).toHaveBeenCalledWith({ query: 'needle' }); }); + it('exposes the stable attached-workspace file lister to agent handlers', () => { + expect(getSkillToolDeps().listWorkspaceFiles).toBeDefined(); + getSkillToolDeps().listWorkspaceFiles({ path: 'src' }); + expect(mockListWorkspaceFiles).toHaveBeenCalledWith({ path: 'src' }); + }); + it('cleans up the uploaded object when metadata upsert returns no row', async () => { mockDb.upsertSkillFile.mockResolvedValue(null); diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index a344d1503f3..1d07f8330d2 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1754,6 +1754,47 @@ async function searchWorkspace({ }); } +/** + * Lists relative files within the workspace directory registered by an attached worker. + * + * @param {Object} params + * @param {string} params.workspace_id + * @param {string} [params.path] + * @param {number} params.max_results + * @param {string} params.codeApiBaseUrl + * @param {'default' | 'stateful'} params.executionProfile + * @param {string} [params.bridgeWorkerId] + * @param {ServerRequest} [params.req] + * @param {AbortSignal} [params.signal] + */ +async function listWorkspaceFiles({ + workspace_id, + path, + max_results, + codeApiBaseUrl, + executionProfile, + bridgeWorkerId, + req, + signal, +}) { + const authHeaders = await getCodeApiAuthHeaders(req, bridgeWorkerId); + return executeWorkspaceTool({ + baseURL: codeApiBaseUrl, + authHeaders: { + ...authHeaders, + ...codeExecutionHeaders({ executionProfile, bridgeWorkerId }), + }, + request: { + protocolVersion: 1, + operation: 'list_files', + workspaceId: workspace_id, + ...(path ? { path } : {}), + maxResults: max_results, + }, + ...(signal ? { signal } : {}), + }); +} + /** * Reads a small code artifact as base64 so `read_file` can surface it to * vision-capable models. Reuses bytes fetched by the current request's @@ -2022,6 +2063,7 @@ module.exports = { prepareCodeOutputForInspection, readWorkspaceFile, searchWorkspace, + listWorkspaceFiles, readSandboxFile, readSandboxImage, writeSandboxFile, diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index 222acc2b5d5..80e5e20251b 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -250,6 +250,7 @@ const { readSandboxFile, readWorkspaceFile, searchWorkspace, + listWorkspaceFiles, readSandboxImage, writeSandboxFile, primeFiles, @@ -1940,6 +1941,51 @@ describe('Code Process', () => { }); }); + describe('listWorkspaceFiles', () => { + it('forwards authenticated listing to the selected attached worker', async () => { + const controller = new AbortController(); + const result = { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['src/app.ts'], + truncated: false, + }; + getCodeApiAuthHeaders.mockResolvedValueOnce({ Authorization: 'Bearer workspace-token' }); + mockExecuteWorkspaceTool.mockResolvedValueOnce(result); + + await expect( + listWorkspaceFiles({ + workspace_id: 'primary', + path: 'src', + max_results: 20, + codeApiBaseUrl: 'https://attached-code.example.com/v1', + executionProfile: 'stateful', + bridgeWorkerId: 'worker-user-1', + req: mockReq, + signal: controller.signal, + }), + ).resolves.toBe(result); + + expect(mockExecuteWorkspaceTool).toHaveBeenCalledWith({ + baseURL: 'https://attached-code.example.com/v1', + authHeaders: { + Authorization: 'Bearer workspace-token', + 'X-CodeAPI-Expected-Profile': 'stateful', + 'X-LibreChat-Code-Worker-ID': 'worker-user-1', + }, + request: { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: 'src', + maxResults: 20, + }, + signal: controller.signal, + }); + }); + }); + describe('readSandboxFile', () => { /** * `readSandboxFile` shells `cat ` through the sandbox diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 742e066b0c7..6db4a526356 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -4695,6 +4695,7 @@ describe('createToolExecuteHandler', () => { req?: unknown; readWorkspaceFile?: ToolExecuteOptions['readWorkspaceFile']; searchWorkspace?: ToolExecuteOptions['searchWorkspace']; + listWorkspaceFiles?: ToolExecuteOptions['listWorkspaceFiles']; readSandboxFile?: ToolExecuteOptions['readSandboxFile']; readSandboxImage?: ToolExecuteOptions['readSandboxImage']; getSkillByName?: ToolExecuteOptions['getSkillByName']; @@ -4718,6 +4719,7 @@ describe('createToolExecuteHandler', () => { getAuthorSkillByName: params.getAuthorSkillByName, readWorkspaceFile: params.readWorkspaceFile, searchWorkspace: params.searchWorkspace, + listWorkspaceFiles: params.listWorkspaceFiles, readSandboxFile: params.readSandboxFile, readSandboxImage: params.readSandboxImage, }); @@ -5144,6 +5146,111 @@ describe('createToolExecuteHandler', () => { expect(searchWorkspace).not.toHaveBeenCalled(); }); + it('lists files through the selected attached worker and forwards cancellation', async () => { + const controller = new AbortController(); + const listWorkspaceFiles = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'list_files' as const, + workspaceId: 'primary', + paths: ['src/app.ts', 'src/worker.ts'], + truncated: false, + })); + const handler = makeReadFileHandler({ + codeEnvAvailable: true, + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + environmentType: 'attached', + bridgeWorkerId: 'personal-worker-1', + statefulSessions: true, + }, + listWorkspaceFiles, + }); + + const [result] = await new Promise((resolve, reject) => { + handler.handle('on_tool_execute', { + toolCalls: [ + { + id: 'call_workspace_list', + name: 'list_workspace_files', + args: { path: 'src', max_results: 20 }, + }, + ], + signal: controller.signal, + resolve, + reject, + } as ToolExecuteBatchRequest); + }); + + expect(listWorkspaceFiles).toHaveBeenCalledWith({ + workspace_id: 'primary', + path: 'src', + max_results: 20, + codeApiBaseUrl: 'https://code.example.com/v1', + executionProfile: 'stateful', + bridgeWorkerId: 'personal-worker-1', + signal: controller.signal, + }); + expect(result).toMatchObject({ + status: 'success', + content: 'workspace/src/app.ts\nworkspace/src/worker.ts', + }); + }); + + it('filters every listed workspace filename before returning any path', async () => { + const protectedValue = 'PROTECTED-WORKSPACE-NAME'; + const listWorkspaceFiles = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'list_files' as const, + workspaceId: 'primary', + paths: ['safe.txt', `${protectedValue}.txt`], + truncated: false, + })); + const handler = makeReadFileHandler({ + req: { + config: { + filters: { + files: { + pii: { + fields: ['name'], + starterPatterns: [], + customPatterns: [ + { + id: 'protected-workspace-name', + label: 'protected workspace name', + regex: protectedValue, + }, + ], + }, + }, + }, + }, + }, + codeEnvAvailable: true, + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + environmentType: 'attached', + statefulSessions: true, + }, + listWorkspaceFiles, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_filtered_workspace_list', + name: 'list_workspace_files', + args: {}, + }, + ]); + + expect(result.status).toBe('error'); + expect(result.content).not.toContain('safe.txt'); + expect(JSON.stringify(result)).not.toContain(protectedValue); + }); + it('filters every workspace search match before returning any result', async () => { const protectedValue = 'PROTECTED-WORKSPACE-MATCH'; const searchWorkspace = jest.fn(async () => ({ diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 50632e04e99..c479323a422 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -23,7 +23,11 @@ import type { BackgroundToolWakeupAdmission, BackgroundToolWakeupRegistration, } from './backgroundCompletion'; -import type { WorkspaceReadResult, WorkspaceSearchResult } from '~/code/workspace'; +import type { + WorkspaceListResult, + WorkspaceReadResult, + WorkspaceSearchResult, +} from '~/code/workspace'; import type { SkillFileRecord, PrimeSkillFilesResult } from './skillFiles'; import type { BackgroundToolResultState } from './harvest'; import type { CodeExecutionContext } from './execution'; @@ -63,6 +67,7 @@ import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME, HOST_FILE_AUTHORING_ARTIFACT_KEY, + LIST_WORKSPACE_FILES_TOOL_NAME, SEARCH_WORKSPACE_TOOL_NAME, isCodeSessionToolName, } from './tools'; @@ -468,6 +473,17 @@ export interface ToolExecuteOptions { req?: ServerRequest; signal?: AbortSignal; }) => Promise; + /** Lists relative file paths within an attached worker's logical workspace. */ + listWorkspaceFiles?: (params: { + workspace_id: string; + path?: string; + max_results: number; + codeApiBaseUrl: string; + executionProfile: CodeExecutionContext['executionProfile']; + bridgeWorkerId?: string; + req?: ServerRequest; + signal?: AbortSignal; + }) => Promise; /** * Reads a code-execution sandbox file by shelling `cat` through the * sandbox `/exec` endpoint. The host implementation supplies the @@ -2379,6 +2395,74 @@ async function handleWorkspaceSearchCall( } } +async function handleWorkspaceListCall( + tc: ToolCallRequest, + mergedConfigurable: Record | undefined, + options: ToolExecuteOptions, + req: ServerRequest | undefined, + signal?: AbortSignal, +): Promise { + const codeExecutionContext = getCodeExecutionContext(mergedConfigurable ?? {}); + if ( + mergedConfigurable?.codeEnvAvailable !== true || + codeExecutionContext?.environmentType !== 'attached' + ) { + return errorResult(tc, 'list_workspace_files requires an attached code environment.'); + } + if (!options.listWorkspaceFiles) { + return errorResult(tc, 'Attached workspace file listing is not configured.'); + } + + const args = tc.args as { path?: unknown; max_results?: unknown }; + const maxResults = args.max_results ?? 100; + if ( + (args.path != null && typeof args.path !== 'string') || + !Number.isSafeInteger(maxResults) || + Number(maxResults) < 1 || + Number(maxResults) > 500 + ) { + return errorResult(tc, 'path or max_results is invalid for workspace file listing.'); + } + + try { + const result = await options.listWorkspaceFiles({ + workspace_id: 'primary', + ...(typeof args.path === 'string' && args.path.length > 0 ? { path: args.path } : {}), + max_results: Number(maxResults), + codeApiBaseUrl: codeExecutionContext.baseUrl, + executionProfile: codeExecutionContext.executionProfile, + ...(codeExecutionContext.bridgeWorkerId + ? { bridgeWorkerId: codeExecutionContext.bridgeWorkerId } + : {}), + ...(req ? { req } : {}), + ...(signal ? { signal } : {}), + }); + + for (const path of result.paths) { + const filtered = filteredFileNameResult(tc, req, path); + if (filtered != null) return filtered; + } + const content = + result.paths.length === 0 + ? 'The attached workspace contains no discoverable files in that path.' + : result.paths.map((path) => `workspace/${path}`).join('\n'); + return { + toolCallId: tc.id, + status: 'success', + content: result.truncated + ? `${content}\n\n[results truncated; narrow path and list again]` + : content, + }; + } catch (error) { + if (signal?.aborted === true && isAbortError(error)) throw error; + logger.warn( + '[handleWorkspaceListCall] Attached workspace file listing failed', + getSafeErrorMetadata(error), + ); + return errorResult(tc, 'The attached workspace files could not be listed.'); + } +} + function sandboxSessionContext( tc: ToolCallRequest, override?: SandboxSessionContext, @@ -5864,6 +5948,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand tc.name === Constants.SKILL_TOOL || tc.name === Constants.READ_FILE || tc.name === SEARCH_WORKSPACE_TOOL_NAME || + tc.name === LIST_WORKSPACE_FILES_TOOL_NAME || isFileAuthoringCall ) { const req = mergedConfigurable?.req as ServerRequest | undefined; @@ -5900,6 +5985,14 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand req, runSignal, ); + } else if (tc.name === LIST_WORKSPACE_FILES_TOOL_NAME) { + handlerResult = await handleWorkspaceListCall( + tc, + mergedConfigurable, + options, + req, + runSignal, + ); } else if (tc.name === CREATE_FILE_TOOL_NAME && isFileAuthoringCall) { handlerResult = await handleCreateFileCall( tc, diff --git a/packages/api/src/agents/tools.spec.ts b/packages/api/src/agents/tools.spec.ts index 962f8dfc5df..3f47b786ecf 100644 --- a/packages/api/src/agents/tools.spec.ts +++ b/packages/api/src/agents/tools.spec.ts @@ -314,6 +314,7 @@ describe('buildHistoricalToolNames', () => { 'create_file', 'edit_file', 'search_workspace', + 'list_workspace_files', 'set_memory', 'delete_memory', 'skill', @@ -488,6 +489,9 @@ describe('registerCodeExecutionTools', () => { const searchWorkspace = result.toolDefinitions.find( (definition) => definition.name === 'search_workspace', ); + const listWorkspaceFiles = result.toolDefinitions.find( + (definition) => definition.name === 'list_workspace_files', + ); expect(readFile?.description).toContain('workspace/'); expect(readFile?.description).toContain('attached'); expect(readFile?.parameters).toMatchObject({ @@ -508,6 +512,16 @@ describe('registerCodeExecutionTools', () => { }, }); expect(searchWorkspace?.description).toContain('literal text'); + expect(listWorkspaceFiles).toMatchObject({ + name: 'list_workspace_files', + parameters: { + properties: { + path: { type: 'string' }, + max_results: { type: 'integer', maximum: 500 }, + }, + }, + }); + expect(listWorkspaceFiles?.description).toContain('empty directory'); }); it('upgrades a code-only read_file definition when skills are enabled later in the run', () => { @@ -770,6 +784,7 @@ describe('registerFileAuthoringTools', () => { it('recognizes host-side file authoring tools as code-session-aware without mutating the shared set', () => { expect(isCodeSessionToolName('bash_tool')).toBe(true); expect(isCodeSessionToolName('search_workspace')).toBe(true); + expect(isCodeSessionToolName('list_workspace_files')).toBe(true); expect(isCodeSessionToolName('create_file')).toBe(false); expect(isCodeSessionToolName('edit_file')).toBe(false); expect(isCodeSessionToolName('create_file', FILE_AUTHORING_TOOL_NAMES)).toBe(true); diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index 5f34571d249..192a50ffac6 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -20,6 +20,7 @@ import { collectReachableAgents } from './traversal'; export const CREATE_FILE_TOOL_NAME = 'create_file'; export const EDIT_FILE_TOOL_NAME = 'edit_file'; export const SEARCH_WORKSPACE_TOOL_NAME = 'search_workspace'; +export const LIST_WORKSPACE_FILES_TOOL_NAME = 'list_workspace_files'; export const HOST_FILE_AUTHORING_ARTIFACT_KEY = '__librechat_file_authoring'; export const FILE_AUTHORING_TOOL_NAMES: ReadonlySet = new Set([ CREATE_FILE_TOOL_NAME, @@ -33,6 +34,7 @@ export function isCodeSessionToolName( return ( CODE_EXECUTION_TOOLS.has(name) || name === SEARCH_WORKSPACE_TOOL_NAME || + name === LIST_WORKSPACE_FILES_TOOL_NAME || hostFileAuthoringToolNames?.has(name) === true ); } @@ -103,6 +105,7 @@ export function buildHistoricalToolNames(config: BuildHistoricalToolNamesConfig) toolNames.add(CREATE_FILE_TOOL_NAME); toolNames.add(EDIT_FILE_TOOL_NAME); toolNames.add(SEARCH_WORKSPACE_TOOL_NAME); + toolNames.add(LIST_WORKSPACE_FILES_TOOL_NAME); } if (config.memoryAvailable === true) { toolNames.add('set_memory'); @@ -502,6 +505,27 @@ const SEARCH_WORKSPACE_TOOL_DEF: LCTool = Object.freeze({ }) as LCTool['parameters'], }) as LCTool; +const LIST_WORKSPACE_FILES_TOOL_DEF: LCTool = Object.freeze({ + name: LIST_WORKSPACE_FILES_TOOL_NAME, + description: + 'List relative file paths in the attached worker workspace directory. Use this to discover files in an existing project, Git repository, or empty directory before reading or searching them. Respects normal ignore files, does not follow symlinks, and returns a bounded deterministic listing.', + parameters: Object.freeze({ + type: 'object', + properties: { + path: { + type: 'string', + description: 'Optional relative file or directory within the attached workspace.', + }, + max_results: { + type: 'integer', + minimum: 1, + maximum: 500, + description: 'Optional maximum number of relative file paths to return. Defaults to 100.', + }, + }, + }) as LCTool['parameters'], +}) as LCTool; + const SKILL_CREATE_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ type: 'object', properties: { @@ -794,7 +818,9 @@ export function registerCodeExecutionTools( const codeTools: LCTool[] = includeBash ? [readFileDef, buildBashToolDef({ enableToolOutputReferences, statefulSessions })] : [readFileDef]; - const candidates = workspaceTools ? [...codeTools, SEARCH_WORKSPACE_TOOL_DEF] : codeTools; + const candidates = workspaceTools + ? [...codeTools, SEARCH_WORKSPACE_TOOL_DEF, LIST_WORKSPACE_FILES_TOOL_DEF] + : codeTools; const toolNames = candidates.map((def) => def.name); const inputDefinitions = toolDefinitions ?? []; diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index 1ebadb19c25..cc5d969338c 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -338,6 +338,56 @@ describe('executeWorkspaceTool', () => { ).rejects.toMatchObject({ reason: 'invalid' }); }); + test('validates bounded file listings within the requested subtree', async () => { + const fetchImpl = jest.fn().mockResolvedValue( + Response.json({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['src/app.ts', 'src/worker.ts'], + truncated: false, + }), + ); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: 'src', + maxResults: 20, + }, + fetchImpl, + }), + ).resolves.toMatchObject({ paths: ['src/app.ts', 'src/worker.ts'] }); + + fetchImpl.mockResolvedValueOnce( + Response.json({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['outside.txt'], + truncated: false, + }), + ); + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: 'src', + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + }); + test('rejects an oversized response before parsing worker-controlled JSON', async () => { const cancel = jest.fn(); const json = jest.fn().mockResolvedValue({ diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index 60e1fae0b2c..ed1ffefbec3 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -7,6 +7,7 @@ const MAX_READ_BYTES = 1024 * 1024; const MAX_READ_LINES = 500; const MAX_SEARCH_RESULTS = 200; const MAX_SEARCH_TEXT_LENGTH = 2000; +const MAX_LIST_RESULTS = 500; const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const READ_RESULT_KEYS = new Set([ 'protocolVersion', @@ -27,6 +28,13 @@ const SEARCH_RESULT_KEYS = new Set([ 'truncated', ]); const SEARCH_MATCH_KEYS = new Set(['path', 'line', 'column', 'text']); +const LIST_RESULT_KEYS = new Set([ + 'protocolVersion', + 'operation', + 'workspaceId', + 'paths', + 'truncated', +]); export interface WorkspaceReadRequest { protocolVersion: 1; @@ -46,7 +54,18 @@ export interface WorkspaceSearchRequest { maxResults?: number; } -export type WorkspaceToolRequest = WorkspaceReadRequest | WorkspaceSearchRequest; +export interface WorkspaceListRequest { + protocolVersion: 1; + operation: 'list_files'; + workspaceId: string; + path?: string; + maxResults?: number; +} + +export type WorkspaceToolRequest = + | WorkspaceReadRequest + | WorkspaceSearchRequest + | WorkspaceListRequest; export interface WorkspaceReadResult { protocolVersion: 1; @@ -68,7 +87,15 @@ export interface WorkspaceSearchResult { truncated: boolean; } -export type WorkspaceToolResult = WorkspaceReadResult | WorkspaceSearchResult; +export interface WorkspaceListResult { + protocolVersion: 1; + operation: 'list_files'; + workspaceId: string; + paths: string[]; + truncated: boolean; +} + +export type WorkspaceToolResult = WorkspaceReadResult | WorkspaceSearchResult | WorkspaceListResult; export class WorkspaceToolHttpError extends Error { constructor( @@ -105,6 +132,12 @@ function hasOnlyKeys(value: Record, allowed: ReadonlySet allowed.has(key)); } +function isWithinRequestedPath(candidate: string, requestedPath: string | undefined): boolean { + if (requestedPath == null || requestedPath === '.') return true; + const prefix = requestedPath.replace(/\/+$/, ''); + return candidate === prefix || candidate.startsWith(`${prefix}/`); +} + async function readBoundedJson(response: Response, signal?: AbortSignal): Promise { const declaredLength = Number(response.headers.get('content-length')); if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { @@ -165,6 +198,12 @@ function isValidRequest(request: WorkspaceToolRequest): boolean { (request.maxLines == null || isPositiveInteger(request.maxLines, MAX_READ_LINES)) ); } + if (request.operation === 'list_files') { + return ( + (request.path == null || isSafePath(request.path)) && + (request.maxResults == null || isPositiveInteger(request.maxResults, MAX_LIST_RESULTS)) + ); + } if (request.operation !== 'search_text') { return false; } @@ -223,6 +262,16 @@ function isValidResult( : value.nextStartLine == null) ); } + if (request.operation === 'list_files') { + const maxResults = request.maxResults ?? 100; + return ( + hasOnlyKeys(value, LIST_RESULT_KEYS) && + Array.isArray(value.paths) && + value.paths.length <= maxResults && + new Set(value.paths).size === value.paths.length && + value.paths.every((path) => isSafePath(path) && isWithinRequestedPath(path, request.path)) + ); + } const maxResults = request.maxResults ?? 50; return ( hasOnlyKeys(value, SEARCH_RESULT_KEYS) && @@ -233,6 +282,7 @@ function isValidResult( isRecord(match) && hasOnlyKeys(match, SEARCH_MATCH_KEYS) && isSafePath(match.path) && + isWithinRequestedPath(match.path, request.path) && isPositiveInteger(match.line, Number.MAX_SAFE_INTEGER) && isPositiveInteger(match.column, Number.MAX_SAFE_INTEGER) && typeof match.text === 'string' && From 61f7133755c7da9d1f833458375bb2f68b3093ed Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 15:16:17 -0400 Subject: [PATCH 2/8] test(code): include workspace list history --- api/server/services/Endpoints/agents/initialize.spec.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 955aac2ab28..e6ca1f5e92f 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -1053,6 +1053,7 @@ describe('initializeClient — subagent loading', () => { 'create_file', 'edit_file', 'search_workspace', + 'list_workspace_files', ]); await expect( From 444c119679a6c06187f5c24d37be50ef19e72ca9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 15:31:45 -0400 Subject: [PATCH 3/8] fix(code): validate workspace tool scope consistently --- packages/api/src/agents/background.spec.ts | 9 ++++ packages/api/src/agents/background.ts | 8 +++- packages/api/src/code/workspace.spec.ts | 51 ++++++++++++++++++++++ packages/api/src/code/workspace.ts | 14 ++++-- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/api/src/agents/background.spec.ts b/packages/api/src/agents/background.spec.ts index 1939ed40c7f..1be3ed6c71d 100644 --- a/packages/api/src/agents/background.spec.ts +++ b/packages/api/src/agents/background.spec.ts @@ -516,6 +516,15 @@ describe('selection policy at injection time', () => { expect(backgroundToolNames).toEqual([]); }); + it('keeps host-side workspace file listing in the foreground', () => { + const { backgroundToolNames } = applyBackgroundToolCalls({ + toolDefinitions: [mcpDef('list_workspace_files')], + toolRegistry: undefined, + toolOptions: { list_workspace_files: { run_in_background: true } }, + }); + expect(backgroundToolNames).toEqual([]); + }); + it('rejects and diagnoses a marker whose runtime definitions are all excluded', () => { /** `runInBackground: ['memory']` used to record a successful-looking * option under the marker while set_memory/delete_memory — the diff --git a/packages/api/src/agents/background.ts b/packages/api/src/agents/background.ts index f0d25f4bf57..02192fef93e 100644 --- a/packages/api/src/agents/background.ts +++ b/packages/api/src/agents/background.ts @@ -55,6 +55,12 @@ import { type BackgroundToolDeadClaimRecovery, type BackgroundToolWakeupAdmission, } from './backgroundCompletion'; +import { + CREATE_FILE_TOOL_NAME, + EDIT_FILE_TOOL_NAME, + SEARCH_WORKSPACE_TOOL_NAME, + LIST_WORKSPACE_FILES_TOOL_NAME, +} from './tools'; import { resolveToolOption, getSelectionNames, @@ -62,7 +68,6 @@ import { synthesizeSelectionToolOptions, } from './selection'; import { SUBAGENT_WAKEUP_GUIDANCE, agentUsesSubagentCompletionWakeups } from './subagentDelivery'; -import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME, SEARCH_WORKSPACE_TOOL_NAME } from './tools'; import { SubagentTaskOwnerUnavailableError } from './subagentTaskRouting'; import { SET_MEMORY_TOOL_NAME, DELETE_MEMORY_TOOL_NAME } from './memory'; import { ASK_USER_QUESTION_TOOL_NAME } from './hitl/askUserQuestionTool'; @@ -113,6 +118,7 @@ const EXCLUDED_BACKGROUND_TOOL_NAMES: ReadonlySet = new Set([ CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME, SEARCH_WORKSPACE_TOOL_NAME, + LIST_WORKSPACE_FILES_TOOL_NAME, SET_MEMORY_TOOL_NAME, DELETE_MEMORY_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index cc5d969338c..de7b66c477c 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -338,6 +338,33 @@ describe('executeWorkspaceTool', () => { ).rejects.toMatchObject({ reason: 'invalid' }); }); + test('accepts canonical search results for dot-segment request paths', async () => { + const fetchImpl = jest.fn().mockResolvedValue( + Response.json({ + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + matches: [{ path: 'src/app.ts', line: 1, column: 1, text: 'needle' }], + truncated: false, + }), + ); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + path: './src', + }, + fetchImpl, + }), + ).resolves.toMatchObject({ matches: [{ path: 'src/app.ts' }] }); + }); + test('validates bounded file listings within the requested subtree', async () => { const fetchImpl = jest.fn().mockResolvedValue( Response.json({ @@ -364,6 +391,30 @@ describe('executeWorkspaceTool', () => { }), ).resolves.toMatchObject({ paths: ['src/app.ts', 'src/worker.ts'] }); + fetchImpl.mockResolvedValueOnce( + Response.json({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['src/app.ts', 'src/worker.ts'], + truncated: false, + }), + ); + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: './src', + maxResults: 20, + }, + fetchImpl, + }), + ).resolves.toMatchObject({ paths: ['src/app.ts', 'src/worker.ts'] }); + fetchImpl.mockResolvedValueOnce( Response.json({ protocolVersion: 1, diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index ed1ffefbec3..84a0b50d940 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -132,10 +132,18 @@ function hasOnlyKeys(value: Record, allowed: ReadonlySet allowed.has(key)); } +function normalizeRelativePath(value: string): string { + return value + .split('/') + .filter((segment) => segment !== '' && segment !== '.') + .join('/'); +} + function isWithinRequestedPath(candidate: string, requestedPath: string | undefined): boolean { - if (requestedPath == null || requestedPath === '.') return true; - const prefix = requestedPath.replace(/\/+$/, ''); - return candidate === prefix || candidate.startsWith(`${prefix}/`); + const prefix = requestedPath == null ? '' : normalizeRelativePath(requestedPath); + if (prefix === '') return true; + const normalizedCandidate = normalizeRelativePath(candidate); + return normalizedCandidate === prefix || normalizedCandidate.startsWith(`${prefix}/`); } async function readBoundedJson(response: Response, signal?: AbortSignal): Promise { From e51140575644ed7052159a6be966551330395c0b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 09:44:11 -0400 Subject: [PATCH 4/8] fix(code): Harden workspace listings --- api/server/services/ToolService.js | 7 +++- .../services/__tests__/ToolService.spec.js | 33 ++++++++++++++++ packages/api/src/agents/handlers.spec.ts | 39 +++++++++++++++++++ packages/api/src/agents/handlers.ts | 22 +++++++++-- 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 3a0ca2d60d9..1cc4e647404 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -47,6 +47,7 @@ const { codeExecutionAuthHeaders, resolveCodeExecutionContext, resolveCallerCapabilityProjectionSnapshot, + LIST_WORKSPACE_FILES_TOOL_NAME, SEARCH_WORKSPACE_TOOL_NAME, getTransactionsConfig, } = require('@librechat/api'); @@ -2002,8 +2003,12 @@ async function loadToolsForExecution({ const isBashToolRequested = toolNames.includes(AgentConstants.BASH_TOOL); const isLegacyExecuteCodeRequested = toolNames.includes(Tools.execute_code); const isWorkspaceSearchRequested = toolNames.includes(SEARCH_WORKSPACE_TOOL_NAME); + const isWorkspaceListRequested = toolNames.includes(LIST_WORKSPACE_FILES_TOOL_NAME); const isCodeExecutionToolRequested = - isBashToolRequested || isLegacyExecuteCodeRequested || isWorkspaceSearchRequested; + isBashToolRequested || + isLegacyExecuteCodeRequested || + isWorkspaceSearchRequested || + isWorkspaceListRequested; const isSkillToolRequested = toolNames.includes(AgentConstants.SKILL_TOOL); const isSandboxFileToolRequested = toolNames.some((name) => [AgentConstants.READ_FILE, AgentConstants.CREATE_FILE, AgentConstants.EDIT_FILE].includes(name), diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index d9a2169795c..4ca1cec3712 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -2598,6 +2598,39 @@ describe('ToolService - Action Capability Gating', () => { } }); + it('preserves attached routing when workspace listing is the only requested tool', async () => { + const capabilities = [ + AgentCapabilities.tools, + AgentCapabilities.execute_code, + AgentCapabilities.stateful_code_sessions, + ]; + const req = createMockReq(capabilities); + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'http://code-stateful.test/v1'; + + try { + const result = await loadToolsForExecution({ + req, + res: {}, + agent: { + id: 'stateful-agent', + tools: [Tools.execute_code], + stateful_code_sessions: true, + stateful_code_environment: 'agent-user', + }, + toolNames: ['list_workspace_files'], + actionsEnabled: false, + }); + + expect(result.configurable.codeExecutionContext.executionProfile).toBe('stateful'); + expect(mockResolveCodeExecutionContext).toHaveBeenLastCalledWith( + expect.objectContaining({ statefulSessions: true, environment: 'agent-user' }), + ); + } finally { + delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL; + } + }); + it('resolves stateful routing when handle_skill is the only requested tool', async () => { const capabilities = [ AgentCapabilities.tools, diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 6db4a526356..bb37b251d9b 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -5198,6 +5198,45 @@ describe('createToolExecuteHandler', () => { }); }); + it('bounds workspace listings without returning a partial path', async () => { + const listWorkspaceFiles = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'list_files' as const, + workspaceId: 'primary', + paths: Array.from( + { length: 500 }, + (_, index) => `src/${String(index).padStart(3, '0')}-${'a'.repeat(600)}.txt`, + ), + truncated: false, + })); + const handler = makeReadFileHandler({ + codeEnvAvailable: true, + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + environmentType: 'attached', + statefulSessions: true, + }, + listWorkspaceFiles, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_large_workspace_list', + name: 'list_workspace_files', + args: { max_results: 500 }, + }, + ]); + + const content = result.content as string; + const [listedPaths] = content.split('\n\n'); + expect(result.status).toBe('success'); + expect(content).toContain('[results truncated; narrow path and list again]'); + expect(Buffer.byteLength(content, 'utf8')).toBeLessThanOrEqual(262_144); + expect(listedPaths.split('\n').every((path) => path.endsWith('.txt'))).toBe(true); + }); + it('filters every listed workspace filename before returning any path', async () => { const protectedValue = 'PROTECTED-WORKSPACE-NAME'; const listWorkspaceFiles = jest.fn(async () => ({ diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index c479323a422..0cef6047f91 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -2442,16 +2442,30 @@ async function handleWorkspaceListCall( const filtered = filteredFileNameResult(tc, req, path); if (filtered != null) return filtered; } - const content = + const unboundedContent = result.paths.length === 0 ? 'The attached workspace contains no discoverable files in that path.' : result.paths.map((path) => `workspace/${path}`).join('\n'); + const truncationNotice = '\n\n[results truncated; narrow path and list again]'; + const locallyTruncated = + Buffer.byteLength(unboundedContent, 'utf8') > MAX_READABLE_BYTES; + const truncated = locallyTruncated || result.truncated; + let content = truncated + ? truncateUtf8( + unboundedContent, + MAX_READABLE_BYTES - Buffer.byteLength(truncationNotice, 'utf8'), + ) + : unboundedContent; + if (locallyTruncated) { + const lastCompletePath = content.lastIndexOf('\n'); + if (lastCompletePath >= 0) { + content = content.slice(0, lastCompletePath); + } + } return { toolCallId: tc.id, status: 'success', - content: result.truncated - ? `${content}\n\n[results truncated; narrow path and list again]` - : content, + content: truncated ? `${content}${truncationNotice}` : content, }; } catch (error) { if (signal?.aborted === true && isAbortError(error)) throw error; From fa9fc2a0d81da68f6ef653fb2ffe91c6d8162ed7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 09:55:25 -0400 Subject: [PATCH 5/8] fix(code): Align workspace list validation --- packages/api/src/agents/handlers.ts | 3 +-- packages/api/src/code/workspace.spec.ts | 17 +++++------------ 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 0cef6047f91..ad7166bf73f 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -2447,8 +2447,7 @@ async function handleWorkspaceListCall( ? 'The attached workspace contains no discoverable files in that path.' : result.paths.map((path) => `workspace/${path}`).join('\n'); const truncationNotice = '\n\n[results truncated; narrow path and list again]'; - const locallyTruncated = - Buffer.byteLength(unboundedContent, 'utf8') > MAX_READABLE_BYTES; + const locallyTruncated = Buffer.byteLength(unboundedContent, 'utf8') > MAX_READABLE_BYTES; const truncated = locallyTruncated || result.truncated; let content = truncated ? truncateUtf8( diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index de7b66c477c..c8c150988ce 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -338,7 +338,7 @@ describe('executeWorkspaceTool', () => { ).rejects.toMatchObject({ reason: 'invalid' }); }); - test('accepts canonical search results for dot-segment request paths', async () => { + test('rejects non-canonical dot-segment request paths before dispatch', async () => { const fetchImpl = jest.fn().mockResolvedValue( Response.json({ protocolVersion: 1, @@ -362,7 +362,8 @@ describe('executeWorkspaceTool', () => { }, fetchImpl, }), - ).resolves.toMatchObject({ matches: [{ path: 'src/app.ts' }] }); + ).rejects.toMatchObject({ reason: 'invalid' }); + expect(fetchImpl).not.toHaveBeenCalled(); }); test('validates bounded file listings within the requested subtree', async () => { @@ -391,15 +392,6 @@ describe('executeWorkspaceTool', () => { }), ).resolves.toMatchObject({ paths: ['src/app.ts', 'src/worker.ts'] }); - fetchImpl.mockResolvedValueOnce( - Response.json({ - protocolVersion: 1, - operation: 'list_files', - workspaceId: 'primary', - paths: ['src/app.ts', 'src/worker.ts'], - truncated: false, - }), - ); await expect( executeWorkspaceTool({ baseURL: 'https://code.example.com/v1', @@ -413,7 +405,8 @@ describe('executeWorkspaceTool', () => { }, fetchImpl, }), - ).resolves.toMatchObject({ paths: ['src/app.ts', 'src/worker.ts'] }); + ).rejects.toMatchObject({ reason: 'invalid' }); + expect(fetchImpl).toHaveBeenCalledTimes(1); fetchImpl.mockResolvedValueOnce( Response.json({ From 9c72a7e1a509a7782c4f8fc62dbfc3c7c106db8c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 4 Sep 2026 00:25:30 -0400 Subject: [PATCH 6/8] fix(code): Continue complete workspace listings --- api/server/services/Files/Code/process.js | 3 + .../services/Files/Code/process.spec.js | 2 + packages/api/src/agents/handlers.spec.ts | 54 ++++++++++++- packages/api/src/agents/handlers.ts | 62 ++++++++++----- packages/api/src/agents/tools.spec.ts | 5 ++ packages/api/src/agents/tools.ts | 7 +- packages/api/src/code/workspace.spec.ts | 77 ++++++++++++++++++- packages/api/src/code/workspace.ts | 46 +++++++++-- 8 files changed, 224 insertions(+), 32 deletions(-) diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 1d07f8330d2..bae25bbd41a 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1760,6 +1760,7 @@ async function searchWorkspace({ * @param {Object} params * @param {string} params.workspace_id * @param {string} [params.path] + * @param {string} [params.after_path] * @param {number} params.max_results * @param {string} params.codeApiBaseUrl * @param {'default' | 'stateful'} params.executionProfile @@ -1770,6 +1771,7 @@ async function searchWorkspace({ async function listWorkspaceFiles({ workspace_id, path, + after_path, max_results, codeApiBaseUrl, executionProfile, @@ -1789,6 +1791,7 @@ async function listWorkspaceFiles({ operation: 'list_files', workspaceId: workspace_id, ...(path ? { path } : {}), + ...(after_path ? { afterPath: after_path } : {}), maxResults: max_results, }, ...(signal ? { signal } : {}), diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index 80e5e20251b..1f7deefa199 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -1958,6 +1958,7 @@ describe('Code Process', () => { listWorkspaceFiles({ workspace_id: 'primary', path: 'src', + after_path: 'src/app.ts', max_results: 20, codeApiBaseUrl: 'https://attached-code.example.com/v1', executionProfile: 'stateful', @@ -1979,6 +1980,7 @@ describe('Code Process', () => { operation: 'list_files', workspaceId: 'primary', path: 'src', + afterPath: 'src/app.ts', maxResults: 20, }, signal: controller.signal, diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index bb37b251d9b..03619da5335 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -5174,7 +5174,7 @@ describe('createToolExecuteHandler', () => { { id: 'call_workspace_list', name: 'list_workspace_files', - args: { path: 'src', max_results: 20 }, + args: { path: 'src', after_path: 'src/app.ts', max_results: 20 }, }, ], signal: controller.signal, @@ -5186,6 +5186,7 @@ describe('createToolExecuteHandler', () => { expect(listWorkspaceFiles).toHaveBeenCalledWith({ workspace_id: 'primary', path: 'src', + after_path: 'src/app.ts', max_results: 20, codeApiBaseUrl: 'https://code.example.com/v1', executionProfile: 'stateful', @@ -5232,11 +5233,60 @@ describe('createToolExecuteHandler', () => { const content = result.content as string; const [listedPaths] = content.split('\n\n'); expect(result.status).toBe('success'); - expect(content).toContain('[results truncated; narrow path and list again]'); + expect(content).toContain('[results truncated; continue with after_path:'); expect(Buffer.byteLength(content, 'utf8')).toBeLessThanOrEqual(262_144); expect(listedPaths.split('\n').every((path) => path.endsWith('.txt'))).toBe(true); }); + it('reserves truncation-notice bytes without returning a partial final path', async () => { + const paths = Array.from( + { length: 64 }, + (_, index) => `src/${index}-${'a'.repeat(4074)}.txt`, + ); + const unboundedContent = paths.map((path) => `workspace/${path}`).join('\n'); + expect(Buffer.byteLength(unboundedContent, 'utf8')).toBeLessThanOrEqual(262_144); + + const listWorkspaceFiles = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'list_files' as const, + workspaceId: 'primary', + paths, + truncated: true, + nextAfterPath: paths[paths.length - 1], + })); + const handler = makeReadFileHandler({ + codeEnvAvailable: true, + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + environmentType: 'attached', + statefulSessions: true, + }, + listWorkspaceFiles, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_upstream_truncated_workspace_list', + name: 'list_workspace_files', + args: { max_results: 64 }, + }, + ]); + + const content = result.content as string; + const [listedPaths] = content.split('\n\n'); + const completePaths = listedPaths.split('\n'); + const lastContinuationPath = completePaths.at(-1)?.slice('workspace/'.length); + expect(result.status).toBe('success'); + expect(content).toContain('[results truncated; continue with after_path:'); + expect(Buffer.byteLength(content, 'utf8')).toBeLessThanOrEqual(262_144); + expect(completePaths.length).toBeGreaterThan(0); + expect(completePaths.length).toBeLessThan(paths.length); + expect(completePaths.every((path) => path.endsWith('.txt'))).toBe(true); + expect(content).toContain(`after_path: ${JSON.stringify(lastContinuationPath)}`); + }); + it('filters every listed workspace filename before returning any path', async () => { const protectedValue = 'PROTECTED-WORKSPACE-NAME'; const listWorkspaceFiles = jest.fn(async () => ({ diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index ad7166bf73f..11935afe397 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -477,6 +477,7 @@ export interface ToolExecuteOptions { listWorkspaceFiles?: (params: { workspace_id: string; path?: string; + after_path?: string; max_results: number; codeApiBaseUrl: string; executionProfile: CodeExecutionContext['executionProfile']; @@ -2413,21 +2414,28 @@ async function handleWorkspaceListCall( return errorResult(tc, 'Attached workspace file listing is not configured.'); } - const args = tc.args as { path?: unknown; max_results?: unknown }; + const args = tc.args as { path?: unknown; after_path?: unknown; max_results?: unknown }; const maxResults = args.max_results ?? 100; if ( (args.path != null && typeof args.path !== 'string') || + (args.after_path != null && typeof args.after_path !== 'string') || !Number.isSafeInteger(maxResults) || Number(maxResults) < 1 || Number(maxResults) > 500 ) { - return errorResult(tc, 'path or max_results is invalid for workspace file listing.'); + return errorResult( + tc, + 'path, after_path, or max_results is invalid for workspace file listing.', + ); } try { const result = await options.listWorkspaceFiles({ workspace_id: 'primary', ...(typeof args.path === 'string' && args.path.length > 0 ? { path: args.path } : {}), + ...(typeof args.after_path === 'string' && args.after_path.length > 0 + ? { after_path: args.after_path } + : {}), max_results: Number(maxResults), codeApiBaseUrl: codeExecutionContext.baseUrl, executionProfile: codeExecutionContext.executionProfile, @@ -2442,29 +2450,43 @@ async function handleWorkspaceListCall( const filtered = filteredFileNameResult(tc, req, path); if (filtered != null) return filtered; } - const unboundedContent = - result.paths.length === 0 - ? 'The attached workspace contains no discoverable files in that path.' - : result.paths.map((path) => `workspace/${path}`).join('\n'); - const truncationNotice = '\n\n[results truncated; narrow path and list again]'; - const locallyTruncated = Buffer.byteLength(unboundedContent, 'utf8') > MAX_READABLE_BYTES; - const truncated = locallyTruncated || result.truncated; - let content = truncated - ? truncateUtf8( - unboundedContent, - MAX_READABLE_BYTES - Buffer.byteLength(truncationNotice, 'utf8'), - ) - : unboundedContent; - if (locallyTruncated) { - const lastCompletePath = content.lastIndexOf('\n'); - if (lastCompletePath >= 0) { - content = content.slice(0, lastCompletePath); + if (result.paths.length === 0) { + return { + toolCallId: tc.id, + status: 'success', + content: 'The attached workspace contains no discoverable files in that path.', + }; + } + const renderedPaths: string[] = []; + let content = ''; + let contentBytes = 0; + for (const [index, path] of result.paths.entries()) { + const entry = `workspace/${path}`; + const separator = renderedPaths.length > 0 ? '\n' : ''; + const hasMore = index < result.paths.length - 1 || result.truncated; + const notice = hasMore + ? `\n\n[results truncated; continue with after_path: ${JSON.stringify(path)}]` + : ''; + const entryBytes = Buffer.byteLength(`${separator}${entry}`, 'utf8'); + if (contentBytes + entryBytes + Buffer.byteLength(notice, 'utf8') > MAX_READABLE_BYTES) { + break; } + renderedPaths.push(entry); + content += `${separator}${entry}`; + contentBytes += entryBytes; } + const locallyTruncated = renderedPaths.length < result.paths.length; + const continuationPath = locallyTruncated + ? result.paths[renderedPaths.length - 1] + : result.nextAfterPath; + const truncated = locallyTruncated || result.truncated; + const truncationNotice = truncated + ? `\n\n[results truncated; continue with after_path: ${JSON.stringify(continuationPath)}]` + : ''; return { toolCallId: tc.id, status: 'success', - content: truncated ? `${content}${truncationNotice}` : content, + content: `${content}${truncationNotice}`, }; } catch (error) { if (signal?.aborted === true && isAbortError(error)) throw error; diff --git a/packages/api/src/agents/tools.spec.ts b/packages/api/src/agents/tools.spec.ts index 3f47b786ecf..967dcbd92d0 100644 --- a/packages/api/src/agents/tools.spec.ts +++ b/packages/api/src/agents/tools.spec.ts @@ -518,10 +518,15 @@ describe('registerCodeExecutionTools', () => { properties: { path: { type: 'string' }, max_results: { type: 'integer', maximum: 500 }, + after_path: { type: 'string' }, }, }, }); expect(listWorkspaceFiles?.description).toContain('empty directory'); + expect(listWorkspaceFiles?.description).toContain('after_path'); + expect(listWorkspaceFiles?.parameters.properties.path.description).toContain( + 'canonical relative', + ); }); it('upgrades a code-only read_file definition when skills are enabled later in the run', () => { diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index 192a50ffac6..2f7477221dc 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -508,7 +508,7 @@ const SEARCH_WORKSPACE_TOOL_DEF: LCTool = Object.freeze({ const LIST_WORKSPACE_FILES_TOOL_DEF: LCTool = Object.freeze({ name: LIST_WORKSPACE_FILES_TOOL_NAME, description: - 'List relative file paths in the attached worker workspace directory. Use this to discover files in an existing project, Git repository, or empty directory before reading or searching them. Respects normal ignore files, does not follow symlinks, and returns a bounded deterministic listing.', + 'List relative file paths in the attached worker workspace directory. Use this to discover files in an existing project, Git repository, or empty directory before reading or searching them. Respects normal ignore files, does not follow symlinks, and returns a bounded deterministic listing. When a result supplies an after_path continuation, pass it unchanged with the same path to fetch the next page.', parameters: Object.freeze({ type: 'object', properties: { @@ -522,6 +522,11 @@ const LIST_WORKSPACE_FILES_TOOL_DEF: LCTool = Object.freeze({ maximum: 500, description: 'Optional maximum number of relative file paths to return. Defaults to 100.', }, + after_path: { + type: 'string', + description: + 'Optional canonical continuation path from the preceding truncated result. Pass it back unchanged with the same path.', + }, }, }) as LCTool['parameters'], }) as LCTool; diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index c8c150988ce..3d6a654ca71 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -373,7 +373,8 @@ describe('executeWorkspaceTool', () => { operation: 'list_files', workspaceId: 'primary', paths: ['src/app.ts', 'src/worker.ts'], - truncated: false, + truncated: true, + nextAfterPath: 'src/worker.ts', }), ); @@ -386,11 +387,15 @@ describe('executeWorkspaceTool', () => { operation: 'list_files', workspaceId: 'primary', path: 'src', + afterPath: 'src/000.ts', maxResults: 20, }, fetchImpl, }), - ).resolves.toMatchObject({ paths: ['src/app.ts', 'src/worker.ts'] }); + ).resolves.toMatchObject({ + paths: ['src/app.ts', 'src/worker.ts'], + nextAfterPath: 'src/worker.ts', + }); await expect( executeWorkspaceTool({ @@ -432,6 +437,74 @@ describe('executeWorkspaceTool', () => { ).rejects.toMatchObject({ reason: 'invalid' }); }); + test('rejects newline-delimited paths returned by an attached worker', async () => { + const fetchImpl = jest.fn().mockResolvedValue( + Response.json({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['src/safe.ts\nworkspace/src/injected.ts'], + truncated: false, + }), + ); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + }); + + test('rejects invalid workspace listing continuations on both sides of the bridge', async () => { + const fetchImpl = jest.fn(); + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: 'src', + afterPath: 'outside/file.ts', + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + expect(fetchImpl).not.toHaveBeenCalled(); + + fetchImpl.mockResolvedValueOnce( + Response.json({ + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + paths: ['src/worker.ts'], + truncated: true, + }), + ); + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'list_files', + workspaceId: 'primary', + path: 'src', + afterPath: 'src/app.ts', + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + }); + test('rejects an oversized response before parsing worker-controlled JSON', async () => { const cancel = jest.fn(); const json = jest.fn().mockResolvedValue({ diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index 84a0b50d940..9e540fcb317 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -34,6 +34,7 @@ const LIST_RESULT_KEYS = new Set([ 'workspaceId', 'paths', 'truncated', + 'nextAfterPath', ]); export interface WorkspaceReadRequest { @@ -60,6 +61,7 @@ export interface WorkspaceListRequest { workspaceId: string; path?: string; maxResults?: number; + afterPath?: string; } export type WorkspaceToolRequest = @@ -93,6 +95,7 @@ export interface WorkspaceListResult { workspaceId: string; paths: string[]; truncated: boolean; + nextAfterPath?: string; } export type WorkspaceToolResult = WorkspaceReadResult | WorkspaceSearchResult | WorkspaceListResult; @@ -146,6 +149,18 @@ function isWithinRequestedPath(candidate: string, requestedPath: string | undefi return normalizedCandidate === prefix || normalizedCandidate.startsWith(`${prefix}/`); } +function comparePortablePaths(left: string, right: string): number { + const encoder = new TextEncoder(); + const leftBytes = encoder.encode(left); + const rightBytes = encoder.encode(right); + const sharedLength = Math.min(leftBytes.length, rightBytes.length); + for (let index = 0; index < sharedLength; index += 1) { + const difference = leftBytes[index] - rightBytes[index]; + if (difference !== 0) return difference; + } + return leftBytes.length - rightBytes.length; +} + async function readBoundedJson(response: Response, signal?: AbortSignal): Promise { const declaredLength = Number(response.headers.get('content-length')); if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { @@ -209,6 +224,9 @@ function isValidRequest(request: WorkspaceToolRequest): boolean { if (request.operation === 'list_files') { return ( (request.path == null || isSafePath(request.path)) && + (request.afterPath == null || + (isSafePath(request.afterPath) && + isWithinRequestedPath(request.afterPath, request.path))) && (request.maxResults == null || isPositiveInteger(request.maxResults, MAX_LIST_RESULTS)) ); } @@ -272,13 +290,27 @@ function isValidResult( } if (request.operation === 'list_files') { const maxResults = request.maxResults ?? 100; - return ( - hasOnlyKeys(value, LIST_RESULT_KEYS) && - Array.isArray(value.paths) && - value.paths.length <= maxResults && - new Set(value.paths).size === value.paths.length && - value.paths.every((path) => isSafePath(path) && isWithinRequestedPath(path, request.path)) - ); + if ( + !hasOnlyKeys(value, LIST_RESULT_KEYS) || + !Array.isArray(value.paths) || + value.paths.length > maxResults + ) { + return false; + } + let previousPath = request.afterPath; + for (const path of value.paths) { + if ( + !isSafePath(path) || + !isWithinRequestedPath(path, request.path) || + (previousPath != null && comparePortablePaths(path, previousPath) <= 0) + ) { + return false; + } + previousPath = path; + } + return value.truncated === true + ? value.paths.length > 0 && value.nextAfterPath === value.paths[value.paths.length - 1] + : value.nextAfterPath == null; } const maxResults = request.maxResults ?? 50; return ( From 38f010e435ff6fc6d31517a1b1a82e458103c627 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 4 Sep 2026 10:24:54 -0400 Subject: [PATCH 7/8] fix(code): preserve canonical path protections --- packages/api/src/agents/tools.ts | 10 ++++++---- packages/api/src/code/workspace.ts | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index 2f7477221dc..4337f3b0cff 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -420,7 +420,7 @@ const CODE_READ_FILE_DESCRIPTION = `Read a known file from the code-execution sa Use for text, CSV, JSON, Markdown, logs, small source files, and images at paths returned by tool output, just written, or under /mnt/data/. Do not run ls/find just to rediscover known paths. Use bash_tool for other binary files, large files, transforms, metadata, or true filesystem discovery. /tmp is per-call scratch and unavailable later.`; -const ATTACHED_WORKSPACE_READ_FILE_INSTRUCTIONS = `For an attached environment, use "workspace/{relativePath}" to read a file from the workspace directory registered on the worker. It may be an existing project, a Git repository, or an empty directory; Git is not required. The worker's host path stays private. Use start_line and max_lines for bounded pagination.`; +const ATTACHED_WORKSPACE_READ_FILE_INSTRUCTIONS = `For an attached environment, use "workspace/{relativePath}" to read a file from the workspace directory registered on the worker. Use a canonical relative path without empty, ".", or ".." segments. It may be an existing project, a Git repository, or an empty directory; Git is not required. The worker's host path stays private. Use start_line and max_lines for bounded pagination.`; const CODE_READ_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ type: 'object', @@ -440,7 +440,7 @@ const ATTACHED_WORKSPACE_READ_FILE_PARAMETERS: LCTool['parameters'] = Object.fre path: { type: 'string', description: - 'Use "workspace/{relativePath}" for a file in the attached worker workspace directory, or a code-execution sandbox path such as "/mnt/data/result.csv".', + 'Use "workspace/{relativePath}" with a canonical relative path (no empty, ".", or ".." segments) for a file in the attached worker workspace directory, or a code-execution sandbox path such as "/mnt/data/result.csv".', }, start_line: { type: 'integer', @@ -492,7 +492,8 @@ const SEARCH_WORKSPACE_TOOL_DEF: LCTool = Object.freeze({ }, path: { type: 'string', - description: 'Optional relative file or directory within the attached workspace.', + description: + 'Optional canonical relative file or directory within the attached workspace; do not use empty, ".", or ".." segments.', }, max_results: { type: 'integer', @@ -514,7 +515,8 @@ const LIST_WORKSPACE_FILES_TOOL_DEF: LCTool = Object.freeze({ properties: { path: { type: 'string', - description: 'Optional relative file or directory within the attached workspace.', + description: + 'Optional canonical relative file or directory within the attached workspace; do not use empty, ".", or ".." segments.', }, max_results: { type: 'integer', diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index 9e540fcb317..6335c8ec649 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -120,6 +120,8 @@ function isSafePath(value: unknown): value is string { value.length > 0 && value.length <= MAX_PATH_LENGTH && !value.includes('\0') && + !value.includes('\r') && + !value.includes('\n') && !value.includes('\\') && !value.startsWith('/') && !/^[A-Za-z]:/.test(value) && From 03cc788676a59c104fc66bede23e32773bc10ed1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 4 Sep 2026 10:44:15 -0400 Subject: [PATCH 8/8] test(code): keep workspace list specs type-safe --- packages/api/src/agents/handlers.spec.ts | 4 +++- packages/api/src/agents/tools.spec.ts | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 03619da5335..fc28cee7889 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -5277,7 +5277,9 @@ describe('createToolExecuteHandler', () => { const content = result.content as string; const [listedPaths] = content.split('\n\n'); const completePaths = listedPaths.split('\n'); - const lastContinuationPath = completePaths.at(-1)?.slice('workspace/'.length); + const lastContinuationPath = completePaths[completePaths.length - 1]?.slice( + 'workspace/'.length, + ); expect(result.status).toBe('success'); expect(content).toContain('[results truncated; continue with after_path:'); expect(Buffer.byteLength(content, 'utf8')).toBeLessThanOrEqual(262_144); diff --git a/packages/api/src/agents/tools.spec.ts b/packages/api/src/agents/tools.spec.ts index 967dcbd92d0..50ee81a1f00 100644 --- a/packages/api/src/agents/tools.spec.ts +++ b/packages/api/src/agents/tools.spec.ts @@ -524,9 +524,7 @@ describe('registerCodeExecutionTools', () => { }); expect(listWorkspaceFiles?.description).toContain('empty directory'); expect(listWorkspaceFiles?.description).toContain('after_path'); - expect(listWorkspaceFiles?.parameters.properties.path.description).toContain( - 'canonical relative', - ); + expect(filePathDescription(listWorkspaceFiles)).toContain('canonical relative'); }); it('upgrades a code-only read_file definition when skills are enabled later in the run', () => {