From e23e13e7124d6451f28635a8aa91547928c52cd2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 4 Sep 2026 10:52:34 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=B3=20feat:=20List=20Attached=20Worksp?= =?UTF-8?q?ace=20Trees=20(#15527)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(code): list attached workspace files * test(code): include workspace list history * fix(code): validate workspace tool scope consistently * fix(code): Harden workspace listings * fix(code): Align workspace list validation * fix(code): Continue complete workspace listings * fix(code): preserve canonical path protections * test(code): keep workspace list specs type-safe --- .../Endpoints/agents/initialize.spec.js | 1 + .../services/Endpoints/agents/skillDeps.js | 2 + .../Endpoints/agents/skillDeps.spec.js | 8 + api/server/services/Files/Code/process.js | 45 ++++ .../services/Files/Code/process.spec.js | 48 +++++ api/server/services/ToolService.js | 7 +- .../services/__tests__/ToolService.spec.js | 33 +++ packages/api/src/agents/background.spec.ts | 9 + packages/api/src/agents/background.ts | 8 +- packages/api/src/agents/handlers.spec.ts | 198 ++++++++++++++++++ packages/api/src/agents/handlers.ts | 130 +++++++++++- packages/api/src/agents/tools.spec.ts | 18 ++ packages/api/src/agents/tools.ts | 41 +++- packages/api/src/code/workspace.spec.ts | 167 +++++++++++++++ packages/api/src/code/workspace.ts | 96 ++++++++- 15 files changed, 802 insertions(+), 9 deletions(-) diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 955aac2ab2..e6ca1f5e92 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( diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js index 1e0a96295d..29b5e70ed6 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 f9e87b9d22..eac12f7033 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 a344d1503f..bae25bbd41 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1754,6 +1754,50 @@ 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 {string} [params.after_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, + after_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 } : {}), + ...(after_path ? { afterPath: after_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 +2066,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 222acc2b5d..1f7deefa19 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,53 @@ 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', + after_path: 'src/app.ts', + 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', + afterPath: 'src/app.ts', + maxResults: 20, + }, + signal: controller.signal, + }); + }); + }); + describe('readSandboxFile', () => { /** * `readSandboxFile` shells `cat ` through the sandbox diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 3a0ca2d60d..1cc4e64740 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 d9a2169795..4ca1cec371 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/background.spec.ts b/packages/api/src/agents/background.spec.ts index 1939ed40c7..1be3ed6c71 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 f0d25f4bf5..02192fef93 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/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 742e066b0c..fc28cee788 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,202 @@ 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', after_path: 'src/app.ts', max_results: 20 }, + }, + ], + signal: controller.signal, + resolve, + reject, + } as ToolExecuteBatchRequest); + }); + + expect(listWorkspaceFiles).toHaveBeenCalledWith({ + workspace_id: 'primary', + path: 'src', + after_path: 'src/app.ts', + 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('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; 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[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); + 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 () => ({ + 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 50632e04e9..11935afe39 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,18 @@ 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; + after_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 +2396,108 @@ 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; 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, 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, + ...(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; + } + 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: `${content}${truncationNotice}`, + }; + } 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 +5983,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 +6020,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 962f8dfc5d..50ee81a1f0 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,19 @@ 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 }, + after_path: { type: 'string' }, + }, + }, + }); + expect(listWorkspaceFiles?.description).toContain('empty directory'); + expect(listWorkspaceFiles?.description).toContain('after_path'); + expect(filePathDescription(listWorkspaceFiles)).toContain('canonical relative'); }); it('upgrades a code-only read_file definition when skills are enabled later in the run', () => { @@ -770,6 +787,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 5f34571d24..4337f3b0cf 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'); @@ -417,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', @@ -437,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', @@ -489,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', @@ -502,6 +506,33 @@ 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. 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: { + path: { + type: 'string', + description: + 'Optional canonical relative file or directory within the attached workspace; do not use empty, ".", or ".." segments.', + }, + max_results: { + type: 'integer', + minimum: 1, + 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; + const SKILL_CREATE_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ type: 'object', properties: { @@ -794,7 +825,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 1ebadb19c2..3d6a654ca7 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -338,6 +338,173 @@ describe('executeWorkspaceTool', () => { ).rejects.toMatchObject({ reason: 'invalid' }); }); + test('rejects non-canonical dot-segment request paths before dispatch', 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, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + 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: true, + nextAfterPath: 'src/worker.ts', + }), + ); + + 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/000.ts', + maxResults: 20, + }, + fetchImpl, + }), + ).resolves.toMatchObject({ + paths: ['src/app.ts', 'src/worker.ts'], + nextAfterPath: 'src/worker.ts', + }); + + 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, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + 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 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 60e1fae0b2..6335c8ec64 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,14 @@ 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', + 'nextAfterPath', +]); export interface WorkspaceReadRequest { protocolVersion: 1; @@ -46,7 +55,19 @@ export interface WorkspaceSearchRequest { maxResults?: number; } -export type WorkspaceToolRequest = WorkspaceReadRequest | WorkspaceSearchRequest; +export interface WorkspaceListRequest { + protocolVersion: 1; + operation: 'list_files'; + workspaceId: string; + path?: string; + maxResults?: number; + afterPath?: string; +} + +export type WorkspaceToolRequest = + | WorkspaceReadRequest + | WorkspaceSearchRequest + | WorkspaceListRequest; export interface WorkspaceReadResult { protocolVersion: 1; @@ -68,7 +89,16 @@ export interface WorkspaceSearchResult { truncated: boolean; } -export type WorkspaceToolResult = WorkspaceReadResult | WorkspaceSearchResult; +export interface WorkspaceListResult { + protocolVersion: 1; + operation: 'list_files'; + workspaceId: string; + paths: string[]; + truncated: boolean; + nextAfterPath?: string; +} + +export type WorkspaceToolResult = WorkspaceReadResult | WorkspaceSearchResult | WorkspaceListResult; export class WorkspaceToolHttpError extends Error { constructor( @@ -90,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) && @@ -105,6 +137,32 @@ 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 { + const prefix = requestedPath == null ? '' : normalizeRelativePath(requestedPath); + if (prefix === '') return true; + const normalizedCandidate = normalizeRelativePath(candidate); + 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) { @@ -165,6 +223,15 @@ 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.afterPath == null || + (isSafePath(request.afterPath) && + isWithinRequestedPath(request.afterPath, request.path))) && + (request.maxResults == null || isPositiveInteger(request.maxResults, MAX_LIST_RESULTS)) + ); + } if (request.operation !== 'search_text') { return false; } @@ -223,6 +290,30 @@ function isValidResult( : value.nextStartLine == null) ); } + if (request.operation === 'list_files') { + const maxResults = request.maxResults ?? 100; + 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 ( hasOnlyKeys(value, SEARCH_RESULT_KEYS) && @@ -233,6 +324,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' &&