mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
🛻 feat: Route Bash Through Attached Workspaces (#15546)
* feat(code): Route bash through attached workspaces * fix(code): harden attached bash schema adapter
This commit is contained in:
parent
2656f44c03
commit
fddf7e31f2
9 changed files with 485 additions and 18 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: '{{tool<idx>turn<turn>}}',
|
||||
/**
|
||||
* 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: {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
95
packages/api/src/code/command.spec.ts
Normal file
95
packages/api/src/code/command.spec.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { CodeBridgeFetch } from './bridge';
|
||||
import { createAttachedWorkspaceBashTool } from './command';
|
||||
|
||||
function commandResponse(overrides: Record<string, unknown> = {}): 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]',
|
||||
{},
|
||||
]);
|
||||
});
|
||||
});
|
||||
111
packages/api/src/code/command.ts
Normal file
111
packages/api/src/code/command.ts
Normal file
|
|
@ -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<LCTool['parameters']>['properties'];
|
||||
};
|
||||
const attachedCommandSchema: NonNullable<LCTool['parameters']> = {
|
||||
...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<LCTool['parameters']> = 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<string, string>> | Record<string, string>;
|
||||
workspaceId?: string;
|
||||
fetchImpl?: CodeBridgeFetch;
|
||||
}): DynamicStructuredTool {
|
||||
return tool(
|
||||
async (
|
||||
rawInput: { command: string; args?: string[]; intent?: string },
|
||||
config,
|
||||
): Promise<[string, Record<string, never>]> => {
|
||||
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;
|
||||
}
|
||||
|
|
@ -4,3 +4,4 @@ export * from './config';
|
|||
export * from './bridge';
|
||||
export * from './lifecycle';
|
||||
export * from './workspace';
|
||||
export * from './command';
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>, allowed: ReadonlySet<string>): 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])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue