From 5a578fec11a5a91f52bb78d560dd44242e49f7f0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 13:50:50 -0400 Subject: [PATCH 01/10] feat(code): read attached workspace files --- .../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 | 51 ++++ packages/api/src/agents/handlers.spec.ts | 183 ++++++++++++ packages/api/src/agents/handlers.ts | 137 ++++++++- packages/api/src/agents/initialize.ts | 5 + packages/api/src/agents/skills.ts | 4 + packages/api/src/agents/tools.spec.ts | 47 ++++ packages/api/src/agents/tools.ts | 59 +++- packages/api/src/code/index.ts | 1 + packages/api/src/code/workspace.spec.ts | 171 ++++++++++++ packages/api/src/code/workspace.ts | 260 ++++++++++++++++++ 13 files changed, 967 insertions(+), 6 deletions(-) create mode 100644 packages/api/src/code/workspace.spec.ts create mode 100644 packages/api/src/code/workspace.ts diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js index b58983da9af..7b7b6aa219a 100644 --- a/api/server/services/Endpoints/agents/skillDeps.js +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -4,6 +4,7 @@ const { batchUploadCodeEnvFiles } = require('~/server/services/Files/Code/crud') const { getSessionInfo, checkIfActive, + readWorkspaceFile, readSandboxFile, readSandboxImage, writeSandboxFile, @@ -380,6 +381,7 @@ const skillToolDeps = { updateSkillFileCodeEnvIds: deploymentSkillMethods.updateSkillFileCodeEnvIds, getSkillFileByPath: deploymentSkillMethods.getSkillFileByPath, updateSkillFileContent: deploymentSkillMethods.updateSkillFileContent, + readWorkspaceFile, /** * `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 3782a4664a0..084e505803e 100644 --- a/api/server/services/Endpoints/agents/skillDeps.spec.js +++ b/api/server/services/Endpoints/agents/skillDeps.spec.js @@ -5,6 +5,7 @@ const mockGetFileStrategy = jest.fn(); const mockGetStorageMetadata = jest.fn(); const mockResolveRequestTenantId = jest.fn(); const mockCreateDeploymentSkillMethods = jest.fn((methods) => methods); +const mockReadWorkspaceFile = jest.fn(); jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args), @@ -18,6 +19,7 @@ jest.mock('~/server/services/Files/Code/process', () => ({ getSessionInfo: jest.fn(), checkIfActive: jest.fn(), readSandboxFile: jest.fn(), + readWorkspaceFile: (...args) => mockReadWorkspaceFile(...args), writeSandboxFile: jest.fn(), })); @@ -79,6 +81,12 @@ describe('skillDeps saveSkillFileContent', () => { mockDb.getSkillFileByPath.mockResolvedValue(null); }); + it('exposes the stable attached-workspace reader to agent handlers', () => { + expect(getSkillToolDeps().readWorkspaceFile).toBeDefined(); + getSkillToolDeps().readWorkspaceFile({ file_path: 'src/app.ts' }); + expect(mockReadWorkspaceFile).toHaveBeenCalledWith({ file_path: 'src/app.ts' }); + }); + 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 479386d4590..fbb1702f2b1 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -27,6 +27,7 @@ const { getCodeExecutionBaseUrl, buildCodeEnvDownloadQuery, codeExecutionHeaders, + executeWorkspaceTool, claimCodeDestination, createCodeDestinationSet, CODE_OUTPUT_PREFLIGHT_MAX_BYTES, @@ -1650,6 +1651,49 @@ async function readSandboxFile({ } } +/** + * Reads a bounded range from the workspace directory registered by an attached worker. + * The authenticated worker route is derived from the selected environment and + * the host path remains private to the worker. + * + * @param {Object} params + * @param {string} params.file_path + * @param {string} params.workspace_id + * @param {number} params.start_line + * @param {number} params.max_lines + * @param {string} params.codeApiBaseUrl + * @param {'default' | 'stateful'} params.executionProfile + * @param {string} [params.bridgeWorkerId] + * @param {ServerRequest} [params.req] + */ +async function readWorkspaceFile({ + file_path, + workspace_id, + start_line, + max_lines, + codeApiBaseUrl, + executionProfile, + bridgeWorkerId, + req, +}) { + const authHeaders = await getCodeApiAuthHeaders(req, bridgeWorkerId); + return executeWorkspaceTool({ + baseURL: codeApiBaseUrl, + authHeaders: { + ...authHeaders, + ...codeExecutionHeaders({ executionProfile, bridgeWorkerId }), + }, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: workspace_id, + path: file_path, + startLine: start_line, + maxLines: max_lines, + }, + }); +} + /** * 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 @@ -1916,6 +1960,7 @@ module.exports = { getSessionInfo, processCodeOutput, prepareCodeOutputForInspection, + readWorkspaceFile, readSandboxFile, readSandboxImage, writeSandboxFile, diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index 15b7fa9d3b7..411fb4e5806 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -48,6 +48,7 @@ const mockExtractCodeArtifactInspectionText = jest.fn(async () => ({ complete: false, })); const mockExtractCodeArtifactText = jest.fn(async () => null); +const mockExecuteWorkspaceTool = jest.fn(); const mockGetExtractedTextFormat = jest.fn((_name, _mime, text) => (text == null ? null : 'text')); /* `hasOfficeHtmlPath` gates the persist-then-render split: when true, processCodeOutput * returns `{ file, finalize }` with the file persisted at `status: 'pending'` @@ -70,6 +71,7 @@ jest.mock('@librechat/api', () => { sanitizeArtifactPath: jest.fn((name) => name), flattenArtifactPath: jest.fn((name) => name.replace(/\//g, '__')), createAxiosInstance: jest.fn(() => mockAxios), + executeWorkspaceTool: (...args) => mockExecuteWorkspaceTool(...args), getCodeApiAuthHeaders: jest.fn(async () => ({})), /* Windowing, sizing and rate-limit policy are real code in * `packages/api` with their own tests (`files/code/image.spec.ts`, @@ -240,6 +242,7 @@ const { prepareCodeOutputForInspection, getSessionInfo, readSandboxFile, + readWorkspaceFile, readSandboxImage, writeSandboxFile, primeFiles, @@ -1832,6 +1835,54 @@ describe('Code Process', () => { }); }); + describe('readWorkspaceFile', () => { + it('forwards authenticated reads to the selected attached worker', async () => { + const result = { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + content: 'const ready = true;', + startLine: 1, + endLine: 1, + truncated: false, + }; + getCodeApiAuthHeaders.mockResolvedValueOnce({ Authorization: 'Bearer workspace-token' }); + mockExecuteWorkspaceTool.mockResolvedValueOnce(result); + + await expect( + readWorkspaceFile({ + file_path: 'src/app.ts', + workspace_id: 'primary', + start_line: 1, + max_lines: 200, + codeApiBaseUrl: 'https://attached-code.example.com/v1', + executionProfile: 'stateful', + bridgeWorkerId: 'worker-user-1', + req: mockReq, + }), + ).resolves.toBe(result); + + expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(mockReq, 'worker-user-1'); + 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: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + startLine: 1, + maxLines: 200, + }, + }); + }); + }); + 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 1c17b12d59d..ea2dbdbb98a 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -4548,6 +4548,7 @@ describe('createToolExecuteHandler', () => { skillAuthoringAvailable?: boolean; codeExecutionContext?: CodeExecutionContext; req?: unknown; + readWorkspaceFile?: ToolExecuteOptions['readWorkspaceFile']; readSandboxFile?: ToolExecuteOptions['readSandboxFile']; readSandboxImage?: ToolExecuteOptions['readSandboxImage']; getSkillByName?: ToolExecuteOptions['getSkillByName']; @@ -4569,11 +4570,193 @@ describe('createToolExecuteHandler', () => { loadTools, getSkillByName: params.getSkillByName, getAuthorSkillByName: params.getAuthorSkillByName, + readWorkspaceFile: params.readWorkspaceFile, readSandboxFile: params.readSandboxFile, readSandboxImage: params.readSandboxImage, }); } + it('routes explicit workspace paths to the selected attached worker', async () => { + const readWorkspaceFile = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'read_file' as const, + workspaceId: 'primary', + path: 'src/app.ts', + content: 'const ready = true;', + startLine: 1, + endLine: 1, + truncated: false, + })); + const readSandboxFile = jest.fn(); + const handler = makeReadFileHandler({ + codeEnvAvailable: true, + accessibleSkillIds: skillsInScope(), + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + environmentType: 'attached', + bridgeWorkerId: 'personal-worker-1', + statefulSessions: true, + }, + readWorkspaceFile, + readSandboxFile, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_workspace_read', + name: Constants.READ_FILE, + args: { path: 'workspace/src/app.ts' }, + }, + ]); + + expect(readWorkspaceFile).toHaveBeenCalledWith({ + file_path: 'src/app.ts', + workspace_id: 'primary', + start_line: 1, + max_lines: 200, + codeApiBaseUrl: 'https://code.example.com/v1', + executionProfile: 'stateful', + bridgeWorkerId: 'personal-worker-1', + }); + expect(readSandboxFile).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + status: 'success', + content: '1 | const ready = true;', + }); + }); + + it('uses bounded pagination for attached workspace reads', async () => { + const readWorkspaceFile = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'read_file' as const, + workspaceId: 'primary', + path: 'notes.txt', + content: 'third\nfourth', + startLine: 3, + endLine: 4, + truncated: true, + nextStartLine: 5, + })); + 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, + }, + readWorkspaceFile, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_workspace_page', + name: Constants.READ_FILE, + args: { path: 'workspace/notes.txt', start_line: 3, max_lines: 2 }, + }, + ]); + + expect(readWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ start_line: 3, max_lines: 2 }), + ); + expect(result.content).toContain('3 | third\n4 | fourth'); + expect(result.content).toContain('start_line 5'); + }); + + it('rejects workspace paths unless the selected environment is attached', async () => { + const readWorkspaceFile = jest.fn(); + const handler = makeReadFileHandler({ + codeEnvAvailable: true, + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + codeSessionKey: 'execute_code:stateful:managed', + executionProfile: 'stateful', + environmentType: 'managed', + statefulSessions: true, + }, + readWorkspaceFile, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_managed_workspace_read', + name: Constants.READ_FILE, + args: { path: 'workspace/src/app.ts' }, + }, + ]); + + expect(result).toMatchObject({ + status: 'error', + errorMessage: 'workspace/ paths require an attached code environment.', + }); + expect(readWorkspaceFile).not.toHaveBeenCalled(); + }); + + it('rejects empty and unbounded attached workspace reads before dispatch', async () => { + const readWorkspaceFile = jest.fn(); + 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, + }, + readWorkspaceFile, + }); + + const [emptyPath] = await invokeHandler(handler, [ + { id: 'call_empty_workspace', name: Constants.READ_FILE, args: { path: 'workspace/' } }, + ]); + const [unbounded] = await invokeHandler(handler, [ + { + id: 'call_unbounded_workspace', + name: Constants.READ_FILE, + args: { path: 'workspace/src/app.ts', max_lines: 501 }, + }, + ]); + + expect(emptyPath.errorMessage).toContain('relative path'); + expect(unbounded.errorMessage).toContain('between 1 and 500'); + expect(readWorkspaceFile).not.toHaveBeenCalled(); + }); + + it('does not expose attached-worker failure details to the model', async () => { + const readWorkspaceFile = jest.fn(async () => { + throw new Error('upstream response contained /Users/operator/private'); + }); + 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, + }, + readWorkspaceFile, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_failed_workspace', + name: Constants.READ_FILE, + args: { path: 'workspace/src/app.ts' }, + }, + ]); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toContain('could not be read'); + expect(result.errorMessage).not.toContain('/Users/operator/private'); + }); + it('routes /mnt/data/ paths to the sandbox fallback when codeEnv is available', async () => { const readSandboxFile = jest.fn(async () => ({ content: 'hello-world' })); const handler = makeReadFileHandler({ diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 2287d15d28f..791661454f4 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -25,6 +25,7 @@ import type { import type { SkillFileRecord, PrimeSkillFilesResult } from './skillFiles'; import type { BackgroundToolResultState } from './harvest'; import type { CodeExecutionContext } from './execution'; +import type { WorkspaceReadResult } from '~/code/workspace'; import type { TextContentFragment } from '~/protection'; import type { ServerRequest } from '~/types'; import { @@ -440,6 +441,17 @@ export interface ToolExecuteOptions { relativePath: string, update: { content?: string; isBinary?: boolean }, ) => Promise; + /** Reads a bounded text range from an attached worker's logical workspace. */ + readWorkspaceFile?: (params: { + file_path: string; + workspace_id: string; + start_line: number; + max_lines: number; + codeApiBaseUrl: string; + executionProfile: CodeExecutionContext['executionProfile']; + bridgeWorkerId?: string; + req?: ServerRequest; + }) => Promise; /** * Reads a code-execution sandbox file by shelling `cat` through the * sandbox `/exec` endpoint. The host implementation supplies the @@ -752,10 +764,12 @@ function getValueShape(value: unknown): string { return typeof value; } -function addLineNumbers(content: string): string { +function addLineNumbers(content: string, startLine = 1): string { const lines = content.split('\n'); - const w = String(lines.length).length; - return lines.map((l, i) => `${String(i + 1).padStart(w, ' ')} | ${l}`).join('\n'); + const w = String(startLine + lines.length - 1).length; + return lines + .map((line, index) => `${String(startLine + index).padStart(w, ' ')} | ${line}`) + .join('\n'); } type AuthoringSkill = NonNullable< @@ -2147,6 +2161,105 @@ async function handleSandboxFileFallback( } } +async function handleWorkspaceFileRead( + tc: ToolCallRequest, + filePath: string, + options: ToolExecuteOptions, + req: ServerRequest | undefined, + codeExecutionContext: CodeExecutionContext, +): Promise { + const { readWorkspaceFile } = options; + if (!readWorkspaceFile) { + return { + toolCallId: tc.id, + status: 'error', + content: '', + errorMessage: 'Attached workspace reading is not configured.', + }; + } + const args = tc.args as { start_line?: number; max_lines?: number }; + const startLine = args.start_line ?? 1; + const maxLines = args.max_lines ?? 200; + if (filePath.length === 0) { + return { + toolCallId: tc.id, + status: 'error', + content: '', + errorMessage: 'A relative path after workspace/ is required.', + }; + } + if ( + !Number.isSafeInteger(startLine) || + startLine < 1 || + !Number.isSafeInteger(maxLines) || + maxLines < 1 || + maxLines > 500 + ) { + return { + toolCallId: tc.id, + status: 'error', + content: '', + errorMessage: 'start_line must be positive and max_lines must be between 1 and 500.', + }; + } + const filteredName = filteredFileNameResult(tc, req, filePath); + if (filteredName != null) { + return filteredName; + } + + try { + const result = await readWorkspaceFile({ + file_path: filePath, + workspace_id: 'primary', + start_line: startLine, + max_lines: maxLines, + codeApiBaseUrl: codeExecutionContext.baseUrl, + executionProfile: codeExecutionContext.executionProfile, + ...(codeExecutionContext.bridgeWorkerId + ? { bridgeWorkerId: codeExecutionContext.bridgeWorkerId } + : {}), + ...(req ? { req } : {}), + }); + const filtered = filteredFileResult(tc, req, filePath, result.content); + if (filtered != null) { + return filtered; + } + if (looksBinary(result.content)) { + return { + toolCallId: tc.id, + status: 'error', + content: '', + errorMessage: `"${filePath}" appears to be a binary file and cannot be read as text.`, + }; + } + let payload = result.content; + let locallyTruncated = false; + if (payload.length > MAX_READABLE_BYTES) { + payload = payload.slice(0, MAX_READABLE_BYTES); + locallyTruncated = true; + } + let numbered = addLineNumbers(payload, result.startLine); + if (locallyTruncated) { + numbered += `\n\n[truncated at ${MAX_READABLE_BYTES} bytes]`; + } else if (result.truncated && result.nextStartLine != null) { + numbered += `\n\n[more content is available; call read_file again with path "workspace/${filePath}" and start_line ${result.nextStartLine}]`; + } + return { + toolCallId: tc.id, + status: 'success', + content: numbered, + }; + } catch (error) { + logger.warn('[handleReadFileCall] Attached workspace read failed', getSafeErrorMetadata(error)); + return { + toolCallId: tc.id, + status: 'error', + content: '', + errorMessage: `"${filePath}" could not be read from the attached workspace.`, + }; + } +} + function sandboxSessionContext( tc: ToolCallRequest, override?: SandboxSessionContext, @@ -3556,6 +3669,24 @@ async function handleReadFileCall( const codeExecutionContext = getCodeExecutionContext(mergedConfigurable); let accessibleIds = (mergedConfigurable?.accessibleSkillIds as Types.ObjectId[]) ?? []; + if (args.path.startsWith('workspace/')) { + if (!codeEnvAvailable || codeExecutionContext?.environmentType !== 'attached') { + return { + toolCallId: tc.id, + status: 'error', + content: '', + errorMessage: 'workspace/ paths require an attached code environment.', + }; + } + return handleWorkspaceFileRead( + tc, + args.path.slice('workspace/'.length), + options, + req, + codeExecutionContext, + ); + } + /** * Short-circuit absolute code-env paths: the path can never be a skill * reference (skill paths are relative `{skillName}/...`), and consulting diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 61b762eb0a4..dc43ff1ccce 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -996,6 +996,8 @@ export async function initializeAgent( agentId: agent.id, conversationId, }); + const attachedWorkspaceTools = + effectiveCodeEnvAvailable && codeExecutionContext.environmentType === 'attached'; const requestFileIds = [ ...new Set( requestFiles @@ -1475,6 +1477,7 @@ export async function initializeAgent( includeSkillFileInstructions: false, enableToolOutputReferences: effectiveCodeEnvAvailable, statefulSessions: effectiveStatefulSessions, + workspaceTools: attachedWorkspaceTools, }); toolDefinitions = codeExecResult.toolDefinitions; recordCapabilityToolNames(AgentCapabilities.execute_code, codeExecResult.toolNames); @@ -1523,6 +1526,7 @@ export async function initializeAgent( includeBash: false, includeSkillFileInstructions: true, enableToolOutputReferences: effectiveCodeEnvAvailable, + workspaceTools: attachedWorkspaceTools, }); toolDefinitions = skillReadResult.toolDefinitions; recordCapabilityToolNames(AgentCapabilities.skills, skillReadResult.toolNames); @@ -1683,6 +1687,7 @@ export async function initializeAgent( listSkillsByAccess: db?.listSkillsByAccess, codeEnvAvailable: effectiveCodeEnvAvailable, statefulSessions: effectiveStatefulSessions, + workspaceTools: attachedWorkspaceTools, userId: user?.id, skillStates: params.skillStates, defaultActiveOnShare: params.defaultActiveOnShare, diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts index ae40ab12baa..f194d79b6fa 100644 --- a/packages/api/src/agents/skills.ts +++ b/packages/api/src/agents/skills.ts @@ -428,6 +428,8 @@ export interface InjectSkillCatalogParams { codeEnvAvailable?: boolean; /** When true, bash_tool registers with the hedged stateful-session description. */ statefulSessions?: boolean; + /** When true, read_file exposes the attached worker's workspace namespace. */ + workspaceTools?: boolean; /** Current user ID — used to determine skill ownership for active-state resolution. */ userId?: string; /** Per-user skill overrides: `{ [skillId]: boolean }`. Missing entries use the default. */ @@ -606,6 +608,7 @@ export async function injectSkillCatalog( listSkillsByAccess, codeEnvAvailable, statefulSessions, + workspaceTools, userId, skillStates, defaultActiveOnShare = false, @@ -756,6 +759,7 @@ export async function injectSkillCatalog( includeBash: codeEnvAvailable === true, enableToolOutputReferences: codeEnvAvailable === true, statefulSessions: statefulSessions === true, + workspaceTools: workspaceTools === true, }); workingDefs = codeExecResult.toolDefinitions; diff --git a/packages/api/src/agents/tools.spec.ts b/packages/api/src/agents/tools.spec.ts index 7903d302ebd..6b10f044fdd 100644 --- a/packages/api/src/agents/tools.spec.ts +++ b/packages/api/src/agents/tools.spec.ts @@ -474,6 +474,26 @@ describe('registerCodeExecutionTools', () => { expect(JSON.stringify(readFile?.parameters)).not.toContain('{skillName}'); }); + it('advertises explicit workspace paths and pagination for attached environments', () => { + const result = registerCodeExecutionTools({ + toolRegistry: makeRegistry(), + toolDefinitions: [], + includeBash: true, + includeSkillFileInstructions: false, + workspaceTools: true, + }); + + const readFile = result.toolDefinitions.find((definition) => definition.name === 'read_file'); + expect(readFile?.description).toContain('workspace/'); + expect(readFile?.description).toContain('attached'); + expect(readFile?.parameters).toMatchObject({ + properties: { + start_line: { type: 'integer' }, + max_lines: { type: 'integer', maximum: 500 }, + }, + }); + }); + it('upgrades a code-only read_file definition when skills are enabled later in the run', () => { const toolRegistry = makeRegistry(); const codeOnly = registerCodeExecutionTools({ @@ -497,6 +517,33 @@ describe('registerCodeExecutionTools', () => { expect(toolRegistry.get('read_file')?.description).toBe(readFile?.description); }); + it('preserves attached workspace instructions when skills upgrade read_file', () => { + const toolRegistry = makeRegistry(); + const codeOnly = registerCodeExecutionTools({ + toolRegistry, + toolDefinitions: [], + includeBash: true, + includeSkillFileInstructions: false, + workspaceTools: true, + }); + const upgraded = registerCodeExecutionTools({ + toolRegistry, + toolDefinitions: codeOnly.toolDefinitions, + includeBash: false, + includeSkillFileInstructions: true, + workspaceTools: true, + }); + + const readFile = upgraded.toolDefinitions.find( + (definition) => definition.name === 'read_file', + ); + expect(readFile?.description).toContain('skills/{skillName}/'); + expect(readFile?.description).toContain('workspace/'); + expect(readFile?.parameters).toMatchObject({ + properties: { max_lines: { maximum: 500 } }, + }); + }); + it('preserves pre-existing unrelated tool definitions', () => { const toolRegistry = makeRegistry(); const existing: LCTool[] = [ diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index afcae5c8e91..84fad0a1188 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -341,6 +341,11 @@ export interface RegisterCodeExecutionToolsParams { * prompted to discover skills that are not enabled for the run. */ includeSkillFileInstructions?: boolean; + /** + * When `true`, `read_file` advertises the explicit `workspace/` namespace + * backed by the selected attached worker, including bounded line pagination. + */ + workspaceTools?: boolean; /** * When `true`, the registered `bash_tool` description includes the * LLM-facing `{{toolturn}}` reference syntax guide so the @@ -406,6 +411,8 @@ 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 CODE_READ_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ type: 'object', properties: { @@ -418,6 +425,29 @@ const CODE_READ_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ required: ['path'], }) as LCTool['parameters']; +const ATTACHED_WORKSPACE_READ_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ + type: 'object', + properties: { + 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".', + }, + start_line: { + type: 'integer', + minimum: 1, + description: 'Optional one-based line at which to start reading a workspace text file.', + }, + max_lines: { + type: 'integer', + minimum: 1, + maximum: 500, + description: 'Optional maximum number of workspace text-file lines to return.', + }, + }, + required: ['path'], +}) as LCTool['parameters']; + const CODE_READ_FILE_DEF: LCTool = Object.freeze({ name: ReadFileToolDefinition.name, description: CODE_READ_FILE_DESCRIPTION, @@ -425,6 +455,21 @@ const CODE_READ_FILE_DEF: LCTool = Object.freeze({ responseFormat: ReadFileToolDefinition.responseFormat, }) as LCTool; +function createAttachedWorkspaceReadFileDef(includeSkillFileInstructions: boolean): LCTool { + const baseDescription = includeSkillFileInstructions + ? SKILL_READ_FILE_DESCRIPTION + : CODE_READ_FILE_DESCRIPTION; + return Object.freeze({ + name: ReadFileToolDefinition.name, + description: `${baseDescription}\n\n${ATTACHED_WORKSPACE_READ_FILE_INSTRUCTIONS}`, + parameters: ATTACHED_WORKSPACE_READ_FILE_PARAMETERS, + responseFormat: ReadFileToolDefinition.responseFormat, + }) as LCTool; +} + +const ATTACHED_CODE_READ_FILE_DEF = createAttachedWorkspaceReadFileDef(false); +const ATTACHED_SKILL_READ_FILE_DEF = createAttachedWorkspaceReadFileDef(true); + const SKILL_CREATE_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ type: 'object', properties: { @@ -597,7 +642,12 @@ const CODE_EDIT_FILE_DEF: LCTool = Object.freeze({ responseFormat: 'content_and_artifact' as LCTool['responseFormat'], }) as LCTool; -function buildReadFileDef(includeSkillFileInstructions: boolean): LCTool { +function buildReadFileDef(includeSkillFileInstructions: boolean, workspaceTools: boolean): LCTool { + if (workspaceTools) { + return includeSkillFileInstructions + ? ATTACHED_SKILL_READ_FILE_DEF + : ATTACHED_CODE_READ_FILE_DEF; + } return includeSkillFileInstructions ? READ_FILE_DEF : CODE_READ_FILE_DEF; } @@ -609,7 +659,9 @@ function buildFileAuthoringDefs(includeSkillFileInstructions: boolean): LCTool[] function isCodeOnlyReadFileDef(def: LCTool | undefined): boolean { return ( - def?.name === ReadFileToolDefinition.name && def?.description === CODE_READ_FILE_DESCRIPTION + def?.name === ReadFileToolDefinition.name && + (def?.description === CODE_READ_FILE_DESCRIPTION || + def?.description === ATTACHED_CODE_READ_FILE_DEF.description) ); } @@ -701,11 +753,12 @@ export function registerCodeExecutionTools( toolDefinitions, includeBash, includeSkillFileInstructions = true, + workspaceTools = false, enableToolOutputReferences = false, statefulSessions = false, } = params; - const readFileDef = buildReadFileDef(includeSkillFileInstructions); + const readFileDef = buildReadFileDef(includeSkillFileInstructions, workspaceTools); const candidates: LCTool[] = includeBash ? [readFileDef, buildBashToolDef({ enableToolOutputReferences, statefulSessions })] : [readFileDef]; diff --git a/packages/api/src/code/index.ts b/packages/api/src/code/index.ts index 787a58b1dee..7b1bdf229f1 100644 --- a/packages/api/src/code/index.ts +++ b/packages/api/src/code/index.ts @@ -3,3 +3,4 @@ export * from './http'; export * from './config'; export * from './bridge'; export * from './lifecycle'; +export * from './workspace'; diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts new file mode 100644 index 00000000000..9a24a0cc3ae --- /dev/null +++ b/packages/api/src/code/workspace.spec.ts @@ -0,0 +1,171 @@ +import { executeWorkspaceTool } from './workspace'; + +describe('executeWorkspaceTool', () => { + test('sends an authenticated bounded read to the selected attached worker', async () => { + const fetchImpl = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + content: 'const ready = true;', + startLine: 1, + endLine: 1, + truncated: false, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1/', + authHeaders: { + Authorization: 'Bearer jwt', + 'X-LibreChat-Code-Worker-ID': 'user-worker', + }, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + startLine: 1, + maxLines: 200, + }, + fetchImpl, + }), + ).resolves.toMatchObject({ content: 'const ready = true;' }); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://code.example.com/v1/workspace-tools/execute', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer jwt', + 'Content-Type': 'application/json', + 'X-LibreChat-Code-Worker-ID': 'user-worker', + }), + }), + ); + }); + + test('rejects a malformed worker result before it reaches the model', async () => { + const fetchImpl = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: '/Users/operator/.ssh/id_ed25519', + content: 'secret', + startLine: 1, + endLine: 1, + truncated: false, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + }); + + test('rejects unexpected result fields that could disclose worker metadata', async () => { + const fetchImpl = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + content: 'safe', + startLine: 1, + endLine: 1, + truncated: false, + root: '/Users/operator/private', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + }); + + test('surfaces bounded upstream failures without returning their body', async () => { + const fetchImpl = jest + .fn() + .mockResolvedValue(new Response('internal details', { status: 503 })); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'search_text', + workspaceId: 'primary', + query: 'needle', + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'rejected', upstreamStatus: 503 }); + }); + + test('rejects an oversized response before parsing worker-controlled JSON', async () => { + const json = jest.fn().mockResolvedValue({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + content: 'safe', + startLine: 1, + endLine: 1, + truncated: false, + }); + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'Content-Length': String(5 * 1024 * 1024) }), + json, + } as unknown as Response); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + expect(json).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts new file mode 100644 index 00000000000..d2bcfbd4a97 --- /dev/null +++ b/packages/api/src/code/workspace.ts @@ -0,0 +1,260 @@ +import type { CodeBridgeFetch } from './bridge'; + +const WORKSPACE_TOOL_TIMEOUT_MS = 30_000; +const MAX_PATH_LENGTH = 4096; +const MAX_QUERY_LENGTH = 4096; +const MAX_READ_BYTES = 1024 * 1024; +const MAX_READ_LINES = 500; +const MAX_SEARCH_RESULTS = 200; +const MAX_SEARCH_TEXT_LENGTH = 2000; +const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +const READ_RESULT_KEYS = new Set([ + 'protocolVersion', + 'operation', + 'workspaceId', + 'path', + 'content', + 'startLine', + 'endLine', + 'truncated', + 'nextStartLine', +]); +const SEARCH_RESULT_KEYS = new Set([ + 'protocolVersion', + 'operation', + 'workspaceId', + 'matches', + 'truncated', +]); +const SEARCH_MATCH_KEYS = new Set(['path', 'line', 'column', 'text']); + +export interface WorkspaceReadRequest { + protocolVersion: 1; + operation: 'read_file'; + workspaceId: string; + path: string; + startLine?: number; + maxLines?: number; +} + +export interface WorkspaceSearchRequest { + protocolVersion: 1; + operation: 'search_text'; + workspaceId: string; + query: string; + path?: string; + maxResults?: number; +} + +export type WorkspaceToolRequest = WorkspaceReadRequest | WorkspaceSearchRequest; + +export interface WorkspaceReadResult { + protocolVersion: 1; + operation: 'read_file'; + workspaceId: string; + path: string; + content: string; + startLine: number; + endLine: number; + truncated: boolean; + nextStartLine?: number; +} + +export interface WorkspaceSearchResult { + protocolVersion: 1; + operation: 'search_text'; + workspaceId: string; + matches: Array<{ path: string; line: number; column: number; text: string }>; + truncated: boolean; +} + +export type WorkspaceToolResult = WorkspaceReadResult | WorkspaceSearchResult; + +export class WorkspaceToolHttpError extends Error { + constructor( + public readonly reason: 'rejected' | 'invalid' | 'timeout' | 'failed', + public readonly upstreamStatus?: number, + ) { + super(`Workspace tool request ${reason}`); + this.name = 'WorkspaceToolHttpError'; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value != null; +} + +function isSafePath(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_PATH_LENGTH && + !value.includes('\0') && + !value.includes('\\') && + !value.startsWith('/') && + !/^[A-Za-z]:/.test(value) && + value.split('/').every((segment) => segment !== '..') + ); +} + +function isPositiveInteger(value: unknown, maximum: number): boolean { + return Number.isSafeInteger(value) && Number(value) >= 1 && Number(value) <= maximum; +} + +function hasOnlyKeys(value: Record, allowed: ReadonlySet): boolean { + return Object.keys(value).every((key) => allowed.has(key)); +} + +async function readBoundedJson(response: Response): Promise { + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + throw new WorkspaceToolHttpError('invalid'); + } + + if (!response.body) { + throw new WorkspaceToolHttpError('invalid'); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let byteLength = 0; + let body = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + byteLength += value.byteLength; + if (byteLength > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new WorkspaceToolHttpError('invalid'); + } + body += decoder.decode(value, { stream: true }); + } + body += decoder.decode(); + return JSON.parse(body) as unknown; + } catch (error) { + if (error instanceof WorkspaceToolHttpError) throw error; + throw new WorkspaceToolHttpError('invalid'); + } finally { + reader.releaseLock(); + } +} + +function isValidRequest(request: WorkspaceToolRequest): boolean { + if ( + request.protocolVersion !== 1 || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(request.workspaceId) + ) { + return false; + } + if (request.operation === 'read_file') { + return ( + isSafePath(request.path) && + (request.startLine == null || + isPositiveInteger(request.startLine, Number.MAX_SAFE_INTEGER)) && + (request.maxLines == null || isPositiveInteger(request.maxLines, MAX_READ_LINES)) + ); + } + return ( + typeof request.query === 'string' && + request.query.length > 0 && + request.query.length <= MAX_QUERY_LENGTH && + !request.query.includes('\0') && + (request.path == null || isSafePath(request.path)) && + (request.maxResults == null || isPositiveInteger(request.maxResults, MAX_SEARCH_RESULTS)) + ); +} + +function isValidResult( + request: WorkspaceToolRequest, + value: unknown, +): value is WorkspaceToolResult { + if ( + !isRecord(value) || + value.protocolVersion !== 1 || + value.operation !== request.operation || + value.workspaceId !== request.workspaceId || + typeof value.truncated !== 'boolean' + ) { + return false; + } + if (request.operation === 'read_file') { + const startLine = request.startLine ?? 1; + const maxLines = request.maxLines ?? 200; + return ( + hasOnlyKeys(value, READ_RESULT_KEYS) && + value.path === request.path && + isSafePath(value.path) && + typeof value.content === 'string' && + new TextEncoder().encode(value.content).byteLength <= MAX_READ_BYTES && + value.startLine === startLine && + Number.isSafeInteger(value.endLine) && + Number(value.endLine) >= startLine - 1 && + Number(value.endLine) < startLine + maxLines && + (value.truncated === true + ? Number.isSafeInteger(value.nextStartLine) && + Number(value.nextStartLine) === Number(value.endLine) + 1 + : value.nextStartLine == null) + ); + } + const maxResults = request.maxResults ?? 50; + return ( + hasOnlyKeys(value, SEARCH_RESULT_KEYS) && + Array.isArray(value.matches) && + value.matches.length <= maxResults && + value.matches.every( + (match) => + isRecord(match) && + hasOnlyKeys(match, SEARCH_MATCH_KEYS) && + isSafePath(match.path) && + isPositiveInteger(match.line, Number.MAX_SAFE_INTEGER) && + isPositiveInteger(match.column, Number.MAX_SAFE_INTEGER) && + typeof match.text === 'string' && + match.text.length <= MAX_SEARCH_TEXT_LENGTH, + ) + ); +} + +export async function executeWorkspaceTool({ + baseURL, + authHeaders, + request, + fetchImpl = fetch, +}: { + baseURL: string; + authHeaders: Record; + request: WorkspaceToolRequest; + fetchImpl?: CodeBridgeFetch; +}): Promise { + if (!isValidRequest(request)) { + throw new WorkspaceToolHttpError('invalid'); + } + try { + const response = await fetchImpl( + `${baseURL.trim().replace(/\/+$/, '')}/workspace-tools/execute`, + { + method: 'POST', + headers: { + ...authHeaders, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + redirect: 'error', + signal: AbortSignal.timeout(WORKSPACE_TOOL_TIMEOUT_MS), + }, + ); + if (!response.ok) { + throw new WorkspaceToolHttpError('rejected', response.status); + } + const result = await readBoundedJson(response); + if (!isValidResult(request, result)) { + throw new WorkspaceToolHttpError('invalid'); + } + return result; + } catch (error) { + if (error instanceof WorkspaceToolHttpError) throw error; + if (error instanceof Error && error.name === 'TimeoutError') { + throw new WorkspaceToolHttpError('timeout'); + } + throw new WorkspaceToolHttpError('failed'); + } +} From 49c51b9126901bd4ce2c5856809209acf67f11d5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:00:23 -0400 Subject: [PATCH 02/10] style(api): sort workspace handler import --- packages/api/src/agents/handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 791661454f4..f1ca1206aeb 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -23,9 +23,9 @@ import type { BackgroundToolWakeupRegistration, } from './backgroundCompletion'; import type { SkillFileRecord, PrimeSkillFilesResult } from './skillFiles'; +import type { WorkspaceReadResult } from '~/code/workspace'; import type { BackgroundToolResultState } from './harvest'; import type { CodeExecutionContext } from './execution'; -import type { WorkspaceReadResult } from '~/code/workspace'; import type { TextContentFragment } from '~/protection'; import type { ServerRequest } from '~/types'; import { From da63667f942162848a4e6fbca924c1ad2b882bec Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:09:36 -0400 Subject: [PATCH 03/10] fix(code): abort attached workspace reads --- api/server/services/Files/Code/process.js | 3 + .../services/Files/Code/process.spec.js | 3 + packages/api/src/agents/handlers.spec.ts | 44 +++++++++++++ packages/api/src/agents/handlers.ts | 19 +++++- packages/api/src/code/workspace.spec.ts | 66 +++++++++++++++++++ packages/api/src/code/workspace.ts | 15 ++++- 6 files changed, 147 insertions(+), 3 deletions(-) diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index fbb1702f2b1..07cae5a98b1 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1665,6 +1665,7 @@ async function readSandboxFile({ * @param {'default' | 'stateful'} params.executionProfile * @param {string} [params.bridgeWorkerId] * @param {ServerRequest} [params.req] + * @param {AbortSignal} [params.signal] */ async function readWorkspaceFile({ file_path, @@ -1675,6 +1676,7 @@ async function readWorkspaceFile({ executionProfile, bridgeWorkerId, req, + signal, }) { const authHeaders = await getCodeApiAuthHeaders(req, bridgeWorkerId); return executeWorkspaceTool({ @@ -1691,6 +1693,7 @@ async function readWorkspaceFile({ startLine: start_line, maxLines: max_lines, }, + ...(signal ? { signal } : {}), }); } diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index 411fb4e5806..f96ab50200c 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -1837,6 +1837,7 @@ describe('Code Process', () => { describe('readWorkspaceFile', () => { it('forwards authenticated reads to the selected attached worker', async () => { + const controller = new AbortController(); const result = { protocolVersion: 1, operation: 'read_file', @@ -1860,6 +1861,7 @@ describe('Code Process', () => { executionProfile: 'stateful', bridgeWorkerId: 'worker-user-1', req: mockReq, + signal: controller.signal, }), ).resolves.toBe(result); @@ -1879,6 +1881,7 @@ describe('Code Process', () => { startLine: 1, maxLines: 200, }, + signal: controller.signal, }); }); }); diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index ea2dbdbb98a..0a4b1b0b365 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -4627,6 +4627,50 @@ describe('createToolExecuteHandler', () => { }); }); + it('forwards the run abort signal to attached workspace reads', async () => { + const readWorkspaceFile = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'read_file' as const, + workspaceId: 'primary', + path: 'notes.txt', + content: 'ready', + startLine: 1, + endLine: 1, + 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, + }, + readWorkspaceFile, + }); + const controller = new AbortController(); + + await new Promise((resolve, reject) => { + handler.handle('on_tool_execute', { + toolCalls: [ + { + id: 'call_workspace_abort', + name: Constants.READ_FILE, + args: { path: 'workspace/notes.txt' }, + }, + ], + signal: controller.signal, + resolve, + reject, + } as ToolExecuteBatchRequest); + }); + + expect(readWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ signal: controller.signal }), + ); + }); + it('uses bounded pagination for attached workspace reads', async () => { const readWorkspaceFile = jest.fn(async () => ({ protocolVersion: 1 as const, diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index f1ca1206aeb..1c5adb63cd4 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -451,6 +451,7 @@ export interface ToolExecuteOptions { executionProfile: CodeExecutionContext['executionProfile']; bridgeWorkerId?: string; req?: ServerRequest; + signal?: AbortSignal; }) => Promise; /** * Reads a code-execution sandbox file by shelling `cat` through the @@ -2167,6 +2168,7 @@ async function handleWorkspaceFileRead( options: ToolExecuteOptions, req: ServerRequest | undefined, codeExecutionContext: CodeExecutionContext, + signal?: AbortSignal, ): Promise { const { readWorkspaceFile } = options; if (!readWorkspaceFile) { @@ -2219,6 +2221,7 @@ async function handleWorkspaceFileRead( ? { bridgeWorkerId: codeExecutionContext.bridgeWorkerId } : {}), ...(req ? { req } : {}), + ...(signal ? { signal } : {}), }); const filtered = filteredFileResult(tc, req, filePath, result.content); if (filtered != null) { @@ -2250,6 +2253,7 @@ async function handleWorkspaceFileRead( content: numbered, }; } catch (error) { + if (signal?.aborted === true && isAbortError(error)) throw error; logger.warn('[handleReadFileCall] Attached workspace read failed', getSafeErrorMetadata(error)); return { toolCallId: tc.id, @@ -3652,6 +3656,7 @@ async function handleReadFileCall( options: ToolExecuteOptions, req?: ServerRequest, onSandboxReadSuccess?: () => void, + signal?: AbortSignal, ): Promise { const { getSkillByName, getSkillFileByPath, getStrategyFunctions, updateSkillFileContent } = options; @@ -3684,6 +3689,7 @@ async function handleReadFileCall( options, req, codeExecutionContext, + signal, ); } @@ -5723,6 +5729,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand () => { sandboxReadSucceeded = true; }, + runSignal, ); } else if (tc.name === CREATE_FILE_TOOL_NAME && isFileAuthoringCall) { handlerResult = await handleCreateFileCall( @@ -5763,10 +5770,18 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand }); return filteredError; } - logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error`, { + const context = { ...logContext, toolCallArgsShape: getValueShape(tc.args), - }); + }; + if (runSignal?.aborted === true && isAbortError(toolError)) { + logger.debug( + `[ON_TOOL_EXECUTE] Tool ${tc.name} cancelled by run abort`, + context, + ); + } else { + logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error`, context); + } return { toolCallId: tc.id, status: 'error' as const, diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index 9a24a0cc3ae..5b7ab18c7d0 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -50,6 +50,72 @@ describe('executeWorkspaceTool', () => { ); }); + test('combines the caller abort signal with the workspace request timeout', async () => { + const controller = new AbortController(); + let requestSignal: AbortSignal | undefined; + const fetchImpl = jest.fn(async (_url: string, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined; + return new Response( + JSON.stringify({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'notes.txt', + content: 'ready', + startLine: 1, + endLine: 1, + truncated: false, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + + await executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + signal: controller.signal, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'notes.txt', + }, + fetchImpl, + }); + + expect(requestSignal?.aborted).toBe(false); + controller.abort(); + expect(requestSignal?.aborted).toBe(true); + }); + + test('preserves caller cancellation instead of relabeling it as a transport failure', async () => { + const controller = new AbortController(); + const fetchImpl = jest.fn( + async (_url: string, init?: RequestInit) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { + once: true, + }); + }), + ); + + const request = executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + signal: controller.signal, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'notes.txt', + }, + fetchImpl, + }); + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + }); + test('rejects a malformed worker result before it reaches the model', async () => { const fetchImpl = jest.fn().mockResolvedValue( new Response( diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index d2bcfbd4a97..a1df06620fe 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -218,17 +218,24 @@ export async function executeWorkspaceTool({ baseURL, authHeaders, request, + signal, fetchImpl = fetch, }: { baseURL: string; authHeaders: Record; request: WorkspaceToolRequest; + signal?: AbortSignal; fetchImpl?: CodeBridgeFetch; }): Promise { if (!isValidRequest(request)) { throw new WorkspaceToolHttpError('invalid'); } try { + const timeoutSignal = AbortSignal.timeout(WORKSPACE_TOOL_TIMEOUT_MS); + const requestSignal = + signal != null && typeof AbortSignal.any === 'function' + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal; const response = await fetchImpl( `${baseURL.trim().replace(/\/+$/, '')}/workspace-tools/execute`, { @@ -239,7 +246,7 @@ export async function executeWorkspaceTool({ }, body: JSON.stringify(request), redirect: 'error', - signal: AbortSignal.timeout(WORKSPACE_TOOL_TIMEOUT_MS), + signal: requestSignal, }, ); if (!response.ok) { @@ -252,6 +259,12 @@ export async function executeWorkspaceTool({ return result; } catch (error) { if (error instanceof WorkspaceToolHttpError) throw error; + if ( + signal?.aborted === true && + (error === signal.reason || (isRecord(error) && error.name === 'AbortError')) + ) { + throw error; + } if (error instanceof Error && error.name === 'TimeoutError') { throw new WorkspaceToolHttpError('timeout'); } From ee99a42593ffb9b47189b8247c06454b6b1ae950 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 14:24:24 -0400 Subject: [PATCH 04/10] test(code): type workspace fetch doubles --- packages/api/src/code/workspace.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index 5b7ab18c7d0..23d4fa8bce5 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -1,4 +1,5 @@ import { executeWorkspaceTool } from './workspace'; +import type { CodeBridgeFetch } from './bridge'; describe('executeWorkspaceTool', () => { test('sends an authenticated bounded read to the selected attached worker', async () => { @@ -53,7 +54,7 @@ describe('executeWorkspaceTool', () => { test('combines the caller abort signal with the workspace request timeout', async () => { const controller = new AbortController(); let requestSignal: AbortSignal | undefined; - const fetchImpl = jest.fn(async (_url: string, init?: RequestInit) => { + const fetchImpl: CodeBridgeFetch = jest.fn(async (_url, init) => { requestSignal = init?.signal ?? undefined; return new Response( JSON.stringify({ @@ -90,8 +91,8 @@ describe('executeWorkspaceTool', () => { test('preserves caller cancellation instead of relabeling it as a transport failure', async () => { const controller = new AbortController(); - const fetchImpl = jest.fn( - async (_url: string, init?: RequestInit) => + const fetchImpl: CodeBridgeFetch = jest.fn( + async (_url, init) => await new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true, From 71289be3fb50cc359d3f6445a939ce4002b8038e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 15:15:34 -0400 Subject: [PATCH 05/10] style(code): sort workspace bridge imports --- packages/api/src/code/workspace.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index 23d4fa8bce5..851ceecbed0 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -1,5 +1,5 @@ -import { executeWorkspaceTool } from './workspace'; import type { CodeBridgeFetch } from './bridge'; +import { executeWorkspaceTool } from './workspace'; describe('executeWorkspaceTool', () => { test('sends an authenticated bounded read to the selected attached worker', async () => { From 0372db775c9385e6352e1336fdf35a53af61f86f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 17:33:17 -0400 Subject: [PATCH 06/10] fix(code): Preserve workspace read boundaries --- packages/api/src/agents/handlers.spec.ts | 40 ++++++++++++++ packages/api/src/agents/handlers.ts | 11 +++- packages/api/src/code/workspace.spec.ts | 69 ++++++++++++++++++++++++ packages/api/src/code/workspace.ts | 29 ++++++++-- 4 files changed, 144 insertions(+), 5 deletions(-) diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 0a4b1b0b365..196bfcc2595 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -4711,6 +4711,46 @@ describe('createToolExecuteHandler', () => { expect(result.content).toContain('start_line 5'); }); + it('continues from the first line omitted by local workspace truncation', async () => { + const firstLine = 'a'.repeat(140_000); + const secondLine = 'b'.repeat(140_000); + const readWorkspaceFile = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'read_file' as const, + workspaceId: 'primary', + path: 'notes.txt', + content: `${firstLine}\n${secondLine}`, + startLine: 10, + endLine: 11, + truncated: true, + nextStartLine: 12, + })); + const handler = makeReadFileHandler({ + codeEnvAvailable: true, + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + environmentType: 'attached', + statefulSessions: true, + }, + readWorkspaceFile, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_workspace_local_page', + name: Constants.READ_FILE, + args: { path: 'workspace/notes.txt', start_line: 10, max_lines: 2 }, + }, + ]); + + expect(result.content).toContain('10 | '); + expect(result.content).not.toContain('11 | '); + expect(result.content).toContain('start_line 11'); + expect(result.content).not.toContain('start_line 12'); + }); + it('rejects workspace paths unless the selected environment is attached', async () => { const readWorkspaceFile = jest.fn(); const handler = makeReadFileHandler({ diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 1c5adb63cd4..f1df19bafcc 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -2237,13 +2237,22 @@ async function handleWorkspaceFileRead( } let payload = result.content; let locallyTruncated = false; + let localNextStartLine: number | undefined; if (payload.length > MAX_READABLE_BYTES) { payload = payload.slice(0, MAX_READABLE_BYTES); locallyTruncated = true; + const lastCompleteLine = payload.lastIndexOf('\n'); + if (lastCompleteLine >= 0) { + payload = payload.slice(0, lastCompleteLine); + localNextStartLine = result.startLine + payload.split('\n').length; + } } let numbered = addLineNumbers(payload, result.startLine); if (locallyTruncated) { - numbered += `\n\n[truncated at ${MAX_READABLE_BYTES} bytes]`; + numbered += + localNextStartLine != null + ? `\n\n[truncated at ${MAX_READABLE_BYTES} bytes; more content is available; call read_file again with path "workspace/${filePath}" and start_line ${localNextStartLine}]` + : `\n\n[the line was truncated at ${MAX_READABLE_BYTES} bytes and cannot be paged by line]`; } else if (result.truncated && result.nextStartLine != null) { numbered += `\n\n[more content is available; call read_file again with path "workspace/${filePath}" and start_line ${result.nextStartLine}]`; } diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index 851ceecbed0..cee4a6101de 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -117,6 +117,42 @@ describe('executeWorkspaceTool', () => { await expect(request).rejects.toMatchObject({ name: 'AbortError' }); }); + test('preserves caller cancellation while reading the response body', async () => { + const controller = new AbortController(); + const fetchImpl: CodeBridgeFetch = jest.fn(async (_url, init) => { + const body = new ReadableStream({ + start(streamController) { + init?.signal?.addEventListener( + 'abort', + () => streamController.error(init.signal?.reason), + { once: true }, + ); + }, + }); + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + const request = executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + signal: controller.signal, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'notes.txt', + }, + fetchImpl, + }); + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + }); + test('rejects a malformed worker result before it reaches the model', async () => { const fetchImpl = jest.fn().mockResolvedValue( new Response( @@ -149,6 +185,39 @@ describe('executeWorkspaceTool', () => { ).rejects.toMatchObject({ reason: 'invalid' }); }); + test('rejects read content that exceeds its declared line range', async () => { + const fetchImpl = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + content: 'first\nsecond', + startLine: 1, + endLine: 1, + truncated: false, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path: 'src/app.ts', + maxLines: 1, + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + }); + test('rejects unexpected result fields that could disclose worker metadata', async () => { const fetchImpl = jest.fn().mockResolvedValue( new Response( diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index a1df06620fe..3f60785b8d2 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -105,7 +105,7 @@ function hasOnlyKeys(value: Record, allowed: ReadonlySet allowed.has(key)); } -async function readBoundedJson(response: Response): Promise { +async function readBoundedJson(response: Response, signal?: AbortSignal): Promise { const declaredLength = Number(response.headers.get('content-length')); if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { throw new WorkspaceToolHttpError('invalid'); @@ -133,6 +133,16 @@ async function readBoundedJson(response: Response): Promise { return JSON.parse(body) as unknown; } catch (error) { if (error instanceof WorkspaceToolHttpError) throw error; + if ( + signal?.aborted === true && + (error === signal.reason || + (isRecord(error) && (error.name === 'AbortError' || error.name === 'TimeoutError'))) + ) { + throw signal.reason ?? error; + } + if (isRecord(error) && (error.name === 'AbortError' || error.name === 'TimeoutError')) { + throw error; + } throw new WorkspaceToolHttpError('invalid'); } finally { reader.releaseLock(); @@ -180,16 +190,27 @@ function isValidResult( if (request.operation === 'read_file') { const startLine = request.startLine ?? 1; const maxLines = request.maxLines ?? 200; + const content = typeof value.content === 'string' ? value.content : null; + const reportedLineCount = + Number.isSafeInteger(value.endLine) && Number(value.endLine) >= startLine - 1 + ? Number(value.endLine) - startLine + 1 + : -1; + const actualLineCount = + content == null ? -1 : content.length === 0 ? reportedLineCount : content.split('\n').length; return ( hasOnlyKeys(value, READ_RESULT_KEYS) && value.path === request.path && isSafePath(value.path) && - typeof value.content === 'string' && - new TextEncoder().encode(value.content).byteLength <= MAX_READ_BYTES && + content != null && + new TextEncoder().encode(content).byteLength <= MAX_READ_BYTES && value.startLine === startLine && Number.isSafeInteger(value.endLine) && Number(value.endLine) >= startLine - 1 && Number(value.endLine) < startLine + maxLines && + reportedLineCount >= 0 && + reportedLineCount <= maxLines && + (content.length !== 0 || reportedLineCount <= 1) && + actualLineCount === reportedLineCount && (value.truncated === true ? Number.isSafeInteger(value.nextStartLine) && Number(value.nextStartLine) === Number(value.endLine) + 1 @@ -252,7 +273,7 @@ export async function executeWorkspaceTool({ if (!response.ok) { throw new WorkspaceToolHttpError('rejected', response.status); } - const result = await readBoundedJson(response); + const result = await readBoundedJson(response, requestSignal); if (!isValidResult(request, result)) { throw new WorkspaceToolHttpError('invalid'); } From 187d4e178092516baf848893672cf0cc03cb46b0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 17:40:06 -0400 Subject: [PATCH 07/10] style(code): Flatten workspace line validation --- packages/api/src/code/workspace.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index 3f60785b8d2..626137c935a 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -195,8 +195,10 @@ function isValidResult( Number.isSafeInteger(value.endLine) && Number(value.endLine) >= startLine - 1 ? Number(value.endLine) - startLine + 1 : -1; - const actualLineCount = - content == null ? -1 : content.length === 0 ? reportedLineCount : content.split('\n').length; + let actualLineCount = -1; + if (content != null) { + actualLineCount = content.length === 0 ? reportedLineCount : content.split('\n').length; + } return ( hasOnlyKeys(value, READ_RESULT_KEYS) && value.path === request.path && From 2158f385b3c81d3c7a72d255befe1e0e9d424c8e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 09:39:25 -0400 Subject: [PATCH 08/10] fix(code): Bound attached workspace responses --- packages/api/src/agents/handlers.spec.ts | 39 ++++++++++++++++++++++++ packages/api/src/agents/handlers.ts | 17 +++++++++-- packages/api/src/code/workspace.spec.ts | 13 ++++++-- packages/api/src/code/workspace.ts | 1 + 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 196bfcc2595..90701c7b63b 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -4751,6 +4751,45 @@ describe('createToolExecuteHandler', () => { expect(result.content).not.toContain('start_line 12'); }); + it('applies local workspace truncation in UTF-8 bytes', async () => { + const firstLine = '界'.repeat(80_000); + const secondLine = '界'.repeat(10_000); + const readWorkspaceFile = jest.fn(async () => ({ + protocolVersion: 1 as const, + operation: 'read_file' as const, + workspaceId: 'primary', + path: 'multibyte.txt', + content: `${firstLine}\n${secondLine}`, + startLine: 1, + endLine: 2, + 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, + }, + readWorkspaceFile, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_workspace_multibyte', + name: Constants.READ_FILE, + args: { path: 'workspace/multibyte.txt', max_lines: 2 }, + }, + ]); + + expect(result.content).toContain('1 | '); + expect(result.content).not.toContain('2 | '); + expect(result.content).toContain('start_line 2'); + expect(Buffer.byteLength(result.content as string, 'utf8')).toBeLessThan(262_300); + }); + it('rejects workspace paths unless the selected environment is attached', async () => { const readWorkspaceFile = jest.fn(); const handler = makeReadFileHandler({ diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index f1df19bafcc..b5bf2afb5ae 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -533,6 +533,19 @@ export interface ToolExecuteOptions { const MAX_READABLE_BYTES = 262_144; const MAX_BINARY_BYTES = 5 * 1024 * 1024; + +function truncateUtf8(value: string, maxBytes: number): string { + const bytes = Buffer.from(value, 'utf8'); + if (bytes.byteLength <= maxBytes) { + return value; + } + let end = maxBytes; + while (end > 0 && (bytes[end] & 0xc0) === 0x80) { + end -= 1; + } + return bytes.subarray(0, end).toString('utf8'); +} + /** * Inline ceiling for images pulled out of the code-execution sandbox — * deliberately tighter than {@link MAX_BINARY_BYTES}, which governs the @@ -2238,8 +2251,8 @@ async function handleWorkspaceFileRead( let payload = result.content; let locallyTruncated = false; let localNextStartLine: number | undefined; - if (payload.length > MAX_READABLE_BYTES) { - payload = payload.slice(0, MAX_READABLE_BYTES); + if (Buffer.byteLength(payload, 'utf8') > MAX_READABLE_BYTES) { + payload = truncateUtf8(payload, MAX_READABLE_BYTES); locallyTruncated = true; const lastCompleteLine = payload.lastIndexOf('\n'); if (lastCompleteLine >= 0) { diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index cee4a6101de..3f1a7acf45a 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -252,9 +252,15 @@ describe('executeWorkspaceTool', () => { }); test('surfaces bounded upstream failures without returning their body', async () => { - const fetchImpl = jest - .fn() - .mockResolvedValue(new Response('internal details', { status: 503 })); + const cancel = jest.fn(); + const fetchImpl = jest.fn().mockResolvedValue( + new Response( + new ReadableStream({ + cancel, + }), + { status: 503 }, + ), + ); await expect( executeWorkspaceTool({ @@ -269,6 +275,7 @@ describe('executeWorkspaceTool', () => { fetchImpl, }), ).rejects.toMatchObject({ reason: 'rejected', upstreamStatus: 503 }); + expect(cancel).toHaveBeenCalledTimes(1); }); test('rejects an oversized response before parsing worker-controlled JSON', async () => { diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index 626137c935a..eefa9845071 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -273,6 +273,7 @@ export async function executeWorkspaceTool({ }, ); if (!response.ok) { + await response.body?.cancel().catch(() => undefined); throw new WorkspaceToolHttpError('rejected', response.status); } const result = await readBoundedJson(response, requestSignal); From 2684e0310c9cab10d81872d36e6a04c4b30783d7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 09:42:56 -0400 Subject: [PATCH 09/10] fix(code): Require canonical workspace paths --- packages/api/src/agents/handlers.ts | 5 ++++- packages/api/src/code/workspace.spec.ts | 22 ++++++++++++++++++++++ packages/api/src/code/workspace.ts | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index b5bf2afb5ae..c184f4e51b5 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -2276,7 +2276,10 @@ async function handleWorkspaceFileRead( }; } catch (error) { if (signal?.aborted === true && isAbortError(error)) throw error; - logger.warn('[handleReadFileCall] Attached workspace read failed', getSafeErrorMetadata(error)); + logger.warn( + '[handleWorkspaceFileRead] Attached workspace read failed', + getSafeErrorMetadata(error), + ); return { toolCallId: tc.id, status: 'error', diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index 3f1a7acf45a..092223e6ff7 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -185,6 +185,28 @@ describe('executeWorkspaceTool', () => { ).rejects.toMatchObject({ reason: 'invalid' }); }); + test.each(['./src/app.ts', 'src/./app.ts', 'src//app.ts'])( + 'rejects the non-canonical request path %s before dispatch', + async (path) => { + const fetchImpl = jest.fn(); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'read_file', + workspaceId: 'primary', + path, + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }, + ); + test('rejects read content that exceeds its declared line range', async () => { const fetchImpl = jest.fn().mockResolvedValue( new Response( diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index eefa9845071..d3f71bb0ba8 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -93,7 +93,7 @@ function isSafePath(value: unknown): value is string { !value.includes('\\') && !value.startsWith('/') && !/^[A-Za-z]:/.test(value) && - value.split('/').every((segment) => segment !== '..') + value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..') ); } From d9bb9704ff3a1f755888c446d220b14907ab6a39 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 23:51:07 -0400 Subject: [PATCH 10/10] fix(code): fence workspace bridge operations --- packages/api/src/code/workspace.spec.ts | 22 ++++++++++++++++++++++ packages/api/src/code/workspace.ts | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index 092223e6ff7..b2806747be5 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -301,6 +301,7 @@ describe('executeWorkspaceTool', () => { }); test('rejects an oversized response before parsing worker-controlled JSON', async () => { + const cancel = jest.fn(); const json = jest.fn().mockResolvedValue({ protocolVersion: 1, operation: 'read_file', @@ -315,6 +316,7 @@ describe('executeWorkspaceTool', () => { ok: true, status: 200, headers: new Headers({ 'Content-Length': String(5 * 1024 * 1024) }), + body: new ReadableStream({ cancel }), json, } as unknown as Response); @@ -332,5 +334,25 @@ describe('executeWorkspaceTool', () => { }), ).rejects.toMatchObject({ reason: 'invalid' }); expect(json).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + test('rejects unrecognized workspace operations before dispatch', async () => { + const fetchImpl = jest.fn(); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'delete_file', + workspaceId: 'primary', + query: 'ignored', + } as never, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + expect(fetchImpl).not.toHaveBeenCalled(); }); }); diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index d3f71bb0ba8..60e1fae0b2c 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -108,6 +108,7 @@ function hasOnlyKeys(value: Record, allowed: ReadonlySet { const declaredLength = Number(response.headers.get('content-length')); if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + await response.body?.cancel().catch(() => undefined); throw new WorkspaceToolHttpError('invalid'); } @@ -164,6 +165,9 @@ function isValidRequest(request: WorkspaceToolRequest): boolean { (request.maxLines == null || isPositiveInteger(request.maxLines, MAX_READ_LINES)) ); } + if (request.operation !== 'search_text') { + return false; + } return ( typeof request.query === 'string' && request.query.length > 0 &&