diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 1cc4e64740..3fd5b38b8a 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -45,6 +45,7 @@ const { AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE, isFatalAgentInitializationError, codeExecutionAuthHeaders, + createAttachedWorkspaceBashTool, resolveCodeExecutionContext, resolveCallerCapabilityProjectionSnapshot, LIST_WORKSPACE_FILES_TOOL_NAME, @@ -2122,14 +2123,21 @@ async function loadToolsForExecution({ } if (isBashTool) { try { - const bashTool = createBashExecutionTool({ - authHeaders: () => - codeExecutionAuthHeaders( - (bridgeWorkerId) => getCodeApiAuthHeaders(req, bridgeWorkerId), - codeExecutionContext, - ), - ...codeExecutionContext, - }); + const authHeaders = () => + codeExecutionAuthHeaders( + (bridgeWorkerId) => getCodeApiAuthHeaders(req, bridgeWorkerId), + codeExecutionContext, + ); + const bashTool = + codeExecutionContext.environmentType === 'attached' + ? createAttachedWorkspaceBashTool({ + authHeaders, + baseUrl: codeExecutionContext.baseUrl, + }) + : createBashExecutionTool({ + authHeaders, + ...codeExecutionContext, + }); allLoadedTools.push(bashTool); } catch (error) { logger.error( diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 4ca1cec371..500017c591 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -18,6 +18,7 @@ const mockGetMCPServerTools = jest.fn(); const mockGetCachedTools = jest.fn(); const mockSendEvent = jest.fn(); const mockEmitChunk = jest.fn(); +const mockCreateAttachedWorkspaceBashTool = jest.fn(() => ({ name: AgentConstants.BASH_TOOL })); const mockResolveCodeExecutionContext = jest.fn( ({ statefulSessions, environment, userId, agentId, conversationId }) => { if (!statefulSessions) { @@ -80,6 +81,7 @@ jest.mock('@librechat/api', () => ({ emitChunk: (...args) => mockEmitChunk(...args), }, resolveCodeExecutionContext: (...args) => mockResolveCodeExecutionContext(...args), + createAttachedWorkspaceBashTool: (...args) => mockCreateAttachedWorkspaceBashTool(...args), })); const mockLoadToolsUtil = jest.fn(); @@ -2631,6 +2633,47 @@ describe('ToolService - Action Capability Gating', () => { } }); + it('loads bash execution through the attached worker transport', async () => { + const capabilities = [ + AgentCapabilities.tools, + AgentCapabilities.execute_code, + AgentCapabilities.stateful_code_sessions, + ]; + const req = createMockReq(capabilities); + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockResolveCodeExecutionContext.mockReturnValueOnce({ + baseUrl: 'http://attached-code.test/v1', + codeSessionKey: 'execute_code:stateful:attached', + executionProfile: 'stateful', + statefulSessions: true, + environmentType: 'attached', + bridgeWorkerId: 'worker-abc', + }); + const toolRegistry = new Map([ + [AgentConstants.BASH_TOOL, { name: AgentConstants.BASH_TOOL }], + ]); + + const result = await loadToolsForExecution({ + req, + res: {}, + agent: { + id: 'attached-agent', + tools: [Tools.execute_code], + stateful_code_sessions: true, + stateful_code_environment: 'agent-user', + }, + toolNames: [AgentConstants.BASH_TOOL], + toolRegistry, + actionsEnabled: false, + }); + + expect(mockCreateAttachedWorkspaceBashTool).toHaveBeenCalledWith({ + authHeaders: expect.any(Function), + baseUrl: 'http://attached-code.test/v1', + }); + expect(result.loadedTools).toContainEqual({ name: AgentConstants.BASH_TOOL }); + }); + it('resolves stateful routing when handle_skill is the only requested tool', async () => { const capabilities = [ AgentCapabilities.tools, diff --git a/packages/api/src/agents/tools.spec.ts b/packages/api/src/agents/tools.spec.ts index 50ee81a1f0..7b24a7a1cf 100644 --- a/packages/api/src/agents/tools.spec.ts +++ b/packages/api/src/agents/tools.spec.ts @@ -23,8 +23,16 @@ jest.mock('@librechat/agents', () => ({ BashExecutionToolDefinition: { name: 'bash_tool', description: 'bash', - schema: { type: 'object', properties: {} }, + schema: { + type: 'object', + properties: { + command: { type: 'string' }, + args: { type: 'array', items: { type: 'string' } }, + }, + required: ['command'], + }, }, + BashToolOutputReferencesGuide: '{{toolturn}}', /** * Deterministic stub mirroring the SDK's `buildBashExecutionToolDescription`: * appends an LLM-facing reference-syntax marker only when @@ -486,6 +494,7 @@ describe('registerCodeExecutionTools', () => { }); const readFile = result.toolDefinitions.find((definition) => definition.name === 'read_file'); + const bashTool = result.toolDefinitions.find((definition) => definition.name === 'bash_tool'); const searchWorkspace = result.toolDefinitions.find( (definition) => definition.name === 'search_workspace', ); @@ -500,6 +509,17 @@ describe('registerCodeExecutionTools', () => { max_lines: { type: 'integer', maximum: 500 }, }, }); + expect(bashTool?.description).toContain('selected attached environment'); + expect(bashTool?.description).toContain('empty directory'); + expect(bashTool?.description).toContain('Network access follows the sandbox policy'); + expect(bashTool?.description).not.toContain('/mnt/data'); + expect(bashTool?.parameters).toMatchObject({ + properties: { + command: { type: 'string' }, + args: { type: 'array' }, + }, + required: ['command'], + }); expect(searchWorkspace).toMatchObject({ name: 'search_workspace', parameters: { diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index 4337f3b0cf..3a7e02bd9e 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -13,6 +13,10 @@ import { import type { AgentToolOptions, GraphEdge } from 'librechat-data-provider'; import type { LCTool, LCToolRegistry } from '@librechat/agents'; import type { ReachableAgent } from './traversal'; +import { + ATTACHED_WORKSPACE_BASH_SCHEMA, + buildAttachedWorkspaceBashDescription, +} from '~/code/command'; import { toolkitExpansion } from '~/tools/toolkits/mapping'; import { normalizeAgentToolKeys } from '~/mcp/utils'; import { collectReachableAgents } from './traversal'; @@ -765,15 +769,23 @@ export function isFileAuthoringToolDefinition(def: LCTool | undefined): boolean * intent of the original constant while keeping the per-agent gate * behavior introduced for tool-output references. */ -function createBashToolDef(enableToolOutputReferences: boolean, statefulSessions = false): LCTool { +function createBashToolDef( + enableToolOutputReferences: boolean, + statefulSessions = false, + workspaceTools = false, +): LCTool { /* Passed as a variable (not an inline literal) so the extra * `statefulSessions` key stays assignable against pinned SDK versions * whose builder predates it (ignored at runtime there). */ const descriptionOpts = { enableToolOutputReferences, statefulSessions }; return Object.freeze({ name: BashExecutionToolDefinition.name, - description: buildBashExecutionToolDescription(descriptionOpts), - parameters: BashExecutionToolDefinition.schema as unknown as LCTool['parameters'], + description: workspaceTools + ? buildAttachedWorkspaceBashDescription(enableToolOutputReferences) + : buildBashExecutionToolDescription(descriptionOpts), + parameters: (workspaceTools + ? ATTACHED_WORKSPACE_BASH_SCHEMA + : BashExecutionToolDefinition.schema) as unknown as LCTool['parameters'], }) as LCTool; } @@ -783,11 +795,16 @@ const BASH_TOOL_DEF_WITHOUT_OUTPUT_REFS = createBashToolDef(false); function buildBashToolDef(opts: { enableToolOutputReferences: boolean; statefulSessions?: boolean; + workspaceTools?: boolean; }): LCTool { /* Stateful defs are built on demand: the stateless pair covers the * default path, and per-run construction is negligible next to init. */ - if (opts.statefulSessions === true) { - return createBashToolDef(opts.enableToolOutputReferences, true); + if (opts.statefulSessions === true || opts.workspaceTools === true) { + return createBashToolDef( + opts.enableToolOutputReferences, + opts.statefulSessions === true, + opts.workspaceTools === true, + ); } return opts.enableToolOutputReferences ? BASH_TOOL_DEF_WITH_OUTPUT_REFS @@ -823,7 +840,10 @@ export function registerCodeExecutionTools( const readFileDef = buildReadFileDef(includeSkillFileInstructions, workspaceTools); const codeTools: LCTool[] = includeBash - ? [readFileDef, buildBashToolDef({ enableToolOutputReferences, statefulSessions })] + ? [ + readFileDef, + buildBashToolDef({ enableToolOutputReferences, statefulSessions, workspaceTools }), + ] : [readFileDef]; const candidates = workspaceTools ? [...codeTools, SEARCH_WORKSPACE_TOOL_DEF, LIST_WORKSPACE_FILES_TOOL_DEF] diff --git a/packages/api/src/code/command.spec.ts b/packages/api/src/code/command.spec.ts new file mode 100644 index 0000000000..168fd79ce3 --- /dev/null +++ b/packages/api/src/code/command.spec.ts @@ -0,0 +1,95 @@ +import type { CodeBridgeFetch } from './bridge'; +import { createAttachedWorkspaceBashTool } from './command'; + +function commandResponse(overrides: Record = {}): Response { + return new Response( + JSON.stringify({ + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + exitCode: 0, + stdout: 'ready\n', + stderr: '', + truncated: false, + timedOut: false, + ...overrides, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); +} + +describe('createAttachedWorkspaceBashTool', () => { + test('executes in the attached default workspace with per-user bridge authentication', async () => { + const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse()); + const authHeaders = jest.fn().mockResolvedValue({ + Authorization: 'Bearer jwt', + 'X-LibreChat-Code-Worker-ID': 'user-worker', + }); + const bashTool = createAttachedWorkspaceBashTool({ + baseUrl: 'https://code.example.com/v1/', + authHeaders, + fetchImpl, + }); + + await expect(bashTool.func({ command: 'pwd' }, undefined, {})).resolves.toEqual([ + 'stdout:\nready\n\n[exit code: 0]', + {}, + ]); + + expect(authHeaders).toHaveBeenCalledTimes(1); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://code.example.com/v1/workspace-tools/execute', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer jwt', + 'X-LibreChat-Code-Worker-ID': 'user-worker', + }), + }), + ); + const request = JSON.parse(String((fetchImpl as jest.Mock).mock.calls[0][1]?.body)); + expect(request).toEqual({ + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + command: 'pwd', + maxOutputBytes: 256 * 1024, + }); + }); + + test('preserves legacy positional args without interpolating shell metacharacters', async () => { + const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse()); + const bashTool = createAttachedWorkspaceBashTool({ + baseUrl: 'https://code.example.com/v1', + authHeaders: () => ({}), + fetchImpl, + }); + + await bashTool.func({ command: 'printf "%s" "$1"', args: ["a'b; echo unsafe"] }, undefined, {}); + + const request = JSON.parse(String((fetchImpl as jest.Mock).mock.calls[0][1]?.body)); + expect(request.command).toBe(`bash -c 'printf "%s" "$1"' -- 'a'"'"'b; echo unsafe'`); + }); + + test('reports termination, timeouts, and truncation without hiding stderr', async () => { + const fetchImpl: CodeBridgeFetch = jest.fn(async () => + commandResponse({ + exitCode: null, + signal: 'SIGKILL', + stdout: '', + stderr: 'deadline reached', + truncated: true, + timedOut: true, + }), + ); + const bashTool = createAttachedWorkspaceBashTool({ + baseUrl: 'https://code.example.com/v1', + authHeaders: () => ({}), + fetchImpl, + }); + + await expect(bashTool.func({ command: 'sleep 60' }, undefined, {})).resolves.toEqual([ + 'stderr:\ndeadline reached\n[terminated by SIGKILL][timed out][output truncated]', + {}, + ]); + }); +}); diff --git a/packages/api/src/code/command.ts b/packages/api/src/code/command.ts new file mode 100644 index 0000000000..d5aa5d6156 --- /dev/null +++ b/packages/api/src/code/command.ts @@ -0,0 +1,111 @@ +import { tool } from '@librechat/agents/langchain/tools'; +import { BashExecutionToolDefinition, BashToolOutputReferencesGuide } from '@librechat/agents'; +import type { DynamicStructuredTool } from '@librechat/agents/langchain/tools'; +import type { LCTool } from '@librechat/agents'; +import type { WorkspaceExecuteCommandResult } from './workspace'; +import type { CodeBridgeFetch } from './bridge'; +import { executeWorkspaceTool } from './workspace'; + +const DEFAULT_WORKSPACE_ID = 'primary'; +const DEFAULT_OUTPUT_BYTES = 256 * 1024; + +export const ATTACHED_WORKSPACE_BASH_DESCRIPTION = `Runs bash commands inside the selected attached environment and returns stdout/stderr. The workspace may be an existing project, a Git repository, or an empty directory; Git is not required. + +Session behavior: +- Files in the registered workspace persist between calls. +- Each call runs in a fresh sandboxed process; shell variables, the working directory, temporary files, and background processes do not survive the call. +- Network access follows the sandbox policy configured on the worker and may be unavailable. +- Commands and file access remain confined by the worker's runtime policy. +- Input code is already displayed to the user; do not repeat it unless asked. +- Explicitly print every result the user should see. +- Never use this tool to execute malicious commands.`; + +const bashSchema = BashExecutionToolDefinition.schema as { + properties?: NonNullable['properties']; +}; +const attachedCommandSchema: NonNullable = { + ...bashSchema.properties?.command, + type: 'string', + description: + 'The bash command or script to execute from the attached workspace root. Files written in the workspace persist between calls, but each call starts a fresh process.', +}; + +export const ATTACHED_WORKSPACE_BASH_SCHEMA: NonNullable = Object.freeze({ + type: 'object', + properties: { + ...bashSchema.properties, + command: attachedCommandSchema, + }, + required: ['command'], +}); + +export function buildAttachedWorkspaceBashDescription(enableToolOutputReferences: boolean): string { + return enableToolOutputReferences + ? `${ATTACHED_WORKSPACE_BASH_DESCRIPTION}\n\n${BashToolOutputReferencesGuide}` + : ATTACHED_WORKSPACE_BASH_DESCRIPTION; +} + +function quoteShellArgument(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function commandWithArguments(command: string, args: string[] | undefined): string { + if (!args?.length) return command; + return `bash -c ${quoteShellArgument(command)} -- ${args.map(quoteShellArgument).join(' ')}`; +} + +function formatCommandResult(result: WorkspaceExecuteCommandResult): string { + let output = ''; + if (result.stdout.length > 0) output += `stdout:\n${result.stdout}\n`; + if (result.stderr.length > 0) output += `stderr:\n${result.stderr}\n`; + if (output.length === 0) output = 'Command completed with no output.\n'; + if (result.exitCode != null) output += `[exit code: ${result.exitCode}]`; + if (result.signal != null) output += `[terminated by ${result.signal}]`; + if (result.timedOut) output += '[timed out]'; + if (result.truncated) output += '[output truncated]'; + return output; +} + +export function createAttachedWorkspaceBashTool({ + baseUrl, + authHeaders, + workspaceId = DEFAULT_WORKSPACE_ID, + fetchImpl, +}: { + baseUrl: string; + authHeaders: () => Promise> | Record; + workspaceId?: string; + fetchImpl?: CodeBridgeFetch; +}): DynamicStructuredTool { + return tool( + async ( + rawInput: { command: string; args?: string[]; intent?: string }, + config, + ): Promise<[string, Record]> => { + const command = commandWithArguments(rawInput.command, rawInput.args); + const result = await executeWorkspaceTool({ + baseURL: baseUrl, + authHeaders: await authHeaders(), + request: { + protocolVersion: 1, + operation: 'execute_command', + workspaceId, + command, + maxOutputBytes: DEFAULT_OUTPUT_BYTES, + }, + signal: config?.signal, + fetchImpl, + }); + if (result.operation !== 'execute_command') { + throw new Error('Attached workspace returned an unexpected command result.'); + } + return [formatCommandResult(result), {}]; + }, + { + name: BashExecutionToolDefinition.name, + description: ATTACHED_WORKSPACE_BASH_DESCRIPTION, + schema: ATTACHED_WORKSPACE_BASH_SCHEMA, + responseFormat: 'content_and_artifact', + }, + ) as unknown as DynamicStructuredTool; +} diff --git a/packages/api/src/code/index.ts b/packages/api/src/code/index.ts index 7b1bdf229f..9c42b2e3b2 100644 --- a/packages/api/src/code/index.ts +++ b/packages/api/src/code/index.ts @@ -4,3 +4,4 @@ export * from './config'; export * from './bridge'; export * from './lifecycle'; export * from './workspace'; +export * from './command'; diff --git a/packages/api/src/code/workspace.spec.ts b/packages/api/src/code/workspace.spec.ts index 3d6a654ca7..422ed54633 100644 --- a/packages/api/src/code/workspace.spec.ts +++ b/packages/api/src/code/workspace.spec.ts @@ -560,4 +560,78 @@ describe('executeWorkspaceTool', () => { ).rejects.toMatchObject({ reason: 'invalid' }); expect(fetchImpl).not.toHaveBeenCalled(); }); + + test('accepts a bounded command result from the selected attached worker', async () => { + const fetchImpl: CodeBridgeFetch = jest.fn(async () => + Response.json({ + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + exitCode: 2, + stdout: '', + stderr: 'not found', + truncated: false, + timedOut: false, + }), + ); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: { Authorization: 'Bearer jwt' }, + request: { + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + command: 'test -f package.json', + maxOutputBytes: 256 * 1024, + }, + fetchImpl, + }), + ).resolves.toMatchObject({ exitCode: 2, stderr: 'not found' }); + }); + + test('rejects command requests and results outside protocol limits', async () => { + const fetchImpl: CodeBridgeFetch = jest.fn(async () => + Response.json({ + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + exitCode: 0, + stdout: 'x'.repeat(9), + stderr: '', + truncated: false, + timedOut: false, + }), + ); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: {}, + request: { + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + command: 'printf x', + maxOutputBytes: 8, + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + + await expect( + executeWorkspaceTool({ + baseURL: 'https://code.example.com/v1', + authHeaders: {}, + request: { + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + command: 'x'.repeat(32 * 1024 + 1), + }, + fetchImpl, + }), + ).rejects.toMatchObject({ reason: 'invalid' }); + }); }); diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index 6335c8ec64..e16aa3b2d6 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -8,6 +8,13 @@ const MAX_READ_LINES = 500; const MAX_SEARCH_RESULTS = 200; const MAX_SEARCH_TEXT_LENGTH = 2000; const MAX_LIST_RESULTS = 500; +const MAX_COMMAND_BYTES = 32 * 1024; +const DEFAULT_COMMAND_TIMEOUT_MS = 30_000; +const MAX_COMMAND_TIMEOUT_MS = 5 * 60_000; +const DEFAULT_COMMAND_OUTPUT_BYTES = 256 * 1024; +const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024; +const MAX_COMMAND_SIGNAL_LENGTH = 32; +const WORKSPACE_COMMAND_TRANSPORT_GRACE_MS = 5_000; const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const READ_RESULT_KEYS = new Set([ 'protocolVersion', @@ -36,6 +43,17 @@ const LIST_RESULT_KEYS = new Set([ 'truncated', 'nextAfterPath', ]); +const COMMAND_RESULT_KEYS = new Set([ + 'protocolVersion', + 'operation', + 'workspaceId', + 'exitCode', + 'signal', + 'stdout', + 'stderr', + 'truncated', + 'timedOut', +]); export interface WorkspaceReadRequest { protocolVersion: 1; @@ -64,10 +82,21 @@ export interface WorkspaceListRequest { afterPath?: string; } +export interface WorkspaceExecuteCommandRequest { + protocolVersion: 1; + operation: 'execute_command'; + workspaceId: string; + command: string; + cwd?: string; + timeoutMs?: number; + maxOutputBytes?: number; +} + export type WorkspaceToolRequest = | WorkspaceReadRequest | WorkspaceSearchRequest - | WorkspaceListRequest; + | WorkspaceListRequest + | WorkspaceExecuteCommandRequest; export interface WorkspaceReadResult { protocolVersion: 1; @@ -98,7 +127,23 @@ export interface WorkspaceListResult { nextAfterPath?: string; } -export type WorkspaceToolResult = WorkspaceReadResult | WorkspaceSearchResult | WorkspaceListResult; +export interface WorkspaceExecuteCommandResult { + protocolVersion: 1; + operation: 'execute_command'; + workspaceId: string; + exitCode: number | null; + signal?: string; + stdout: string; + stderr: string; + truncated: boolean; + timedOut: boolean; +} + +export type WorkspaceToolResult = + | WorkspaceReadResult + | WorkspaceSearchResult + | WorkspaceListResult + | WorkspaceExecuteCommandResult; export class WorkspaceToolHttpError extends Error { constructor( @@ -133,6 +178,14 @@ function isPositiveInteger(value: unknown, maximum: number): boolean { return Number.isSafeInteger(value) && Number(value) >= 1 && Number(value) <= maximum; } +function isUtf8StringWithinBytes(value: unknown, maximum: number): value is string { + return ( + typeof value === 'string' && + Buffer.from(value).toString('utf8') === value && + new TextEncoder().encode(value).byteLength <= maximum + ); +} + function hasOnlyKeys(value: Record, allowed: ReadonlySet): boolean { return Object.keys(value).every((key) => allowed.has(key)); } @@ -232,6 +285,17 @@ function isValidRequest(request: WorkspaceToolRequest): boolean { (request.maxResults == null || isPositiveInteger(request.maxResults, MAX_LIST_RESULTS)) ); } + if (request.operation === 'execute_command') { + return ( + isUtf8StringWithinBytes(request.command, MAX_COMMAND_BYTES) && + request.command.trim().length > 0 && + !request.command.includes('\0') && + (request.cwd == null || isSafePath(request.cwd)) && + (request.timeoutMs == null || isPositiveInteger(request.timeoutMs, MAX_COMMAND_TIMEOUT_MS)) && + (request.maxOutputBytes == null || + isPositiveInteger(request.maxOutputBytes, MAX_COMMAND_OUTPUT_BYTES)) + ); + } if (request.operation !== 'search_text') { return false; } @@ -314,6 +378,32 @@ function isValidResult( ? value.paths.length > 0 && value.nextAfterPath === value.paths[value.paths.length - 1] : value.nextAfterPath == null; } + if (request.operation === 'execute_command') { + const stdout = typeof value.stdout === 'string' ? value.stdout : null; + const stderr = typeof value.stderr === 'string' ? value.stderr : null; + const outputLimit = request.maxOutputBytes ?? DEFAULT_COMMAND_OUTPUT_BYTES; + return ( + hasOnlyKeys(value, COMMAND_RESULT_KEYS) && + stdout != null && + stderr != null && + Buffer.from(stdout).toString('utf8') === stdout && + Buffer.from(stderr).toString('utf8') === stderr && + new TextEncoder().encode(stdout).byteLength + new TextEncoder().encode(stderr).byteLength <= + outputLimit && + (value.exitCode === null || + (Number.isSafeInteger(value.exitCode) && + Number(value.exitCode) >= 0 && + Number(value.exitCode) <= 255)) && + (value.signal == null || + (typeof value.signal === 'string' && + value.signal.length <= MAX_COMMAND_SIGNAL_LENGTH && + /^SIG[A-Z0-9]+$/.test(value.signal))) && + typeof value.timedOut === 'boolean' && + (value.exitCode === null + ? value.timedOut === true || value.signal != null + : value.timedOut === false && value.signal == null) + ); + } const maxResults = request.maxResults ?? 50; return ( hasOnlyKeys(value, SEARCH_RESULT_KEYS) && @@ -333,6 +423,11 @@ function isValidResult( ); } +function getWorkspaceToolTimeoutMs(request: WorkspaceToolRequest): number { + if (request.operation !== 'execute_command') return WORKSPACE_TOOL_TIMEOUT_MS; + return (request.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS) + WORKSPACE_COMMAND_TRANSPORT_GRACE_MS; +} + export async function executeWorkspaceTool({ baseURL, authHeaders, @@ -350,7 +445,7 @@ export async function executeWorkspaceTool({ throw new WorkspaceToolHttpError('invalid'); } try { - const timeoutSignal = AbortSignal.timeout(WORKSPACE_TOOL_TIMEOUT_MS); + const timeoutSignal = AbortSignal.timeout(getWorkspaceToolTimeoutMs(request)); const requestSignal = signal != null && typeof AbortSignal.any === 'function' ? AbortSignal.any([signal, timeoutSignal])