diff --git a/src/main/tape/domain/executionContract.ts b/src/main/tape/domain/executionContract.ts index 82badcb600..4c0b7532ce 100644 --- a/src/main/tape/domain/executionContract.ts +++ b/src/main/tape/domain/executionContract.ts @@ -161,6 +161,7 @@ export class ExecutionContractDispatchError extends Error { export interface ExecutionContractDispatchInput { request: DeepChatExecutionContractRequest currentTool: MCPToolDefinition + parentTool?: MCPToolDefinition currentWorkspace: DeepChatExecutionWorkspaceCeiling currentMaxSubagentDepth: number requestedSubagentDepth: number @@ -943,8 +944,10 @@ export function assertExecutionContractAllowsDispatch( } let currentTool: DeepChatExecutionToolCeiling + let dispatchTool: DeepChatExecutionToolCeiling try { currentTool = buildExecutionToolCeiling(input.currentTool) + dispatchTool = input.parentTool ? buildExecutionToolCeiling(input.parentTool) : currentTool } catch (error) { throw new ExecutionContractDispatchError( 'Current tool authority is missing a valid stable identity or execution policy.', @@ -953,31 +956,34 @@ export function assertExecutionContractAllowsDispatch( ) } const ceiling = contract.ceilings.tools.find( - (candidate) => candidate.target.providerVisibleName === currentTool.target.providerVisibleName + (candidate) => candidate.target.providerVisibleName === dispatchTool.target.providerVisibleName ) if (!ceiling) { throw new ExecutionContractDispatchError( - `Tool '${currentTool.target.providerVisibleName}' is outside the frozen View ceiling.`, + `Tool '${dispatchTool.target.providerVisibleName}' is outside the frozen View ceiling.`, 'tool_not_allowed' ) } if ( - buildExecutionToolTargetKey(ceiling.target) !== buildExecutionToolTargetKey(currentTool.target) + buildExecutionToolTargetKey(ceiling.target) !== buildExecutionToolTargetKey(dispatchTool.target) ) { throw new ExecutionContractDispatchError( - `Tool '${currentTool.target.providerVisibleName}' no longer resolves to the frozen target.`, + `Tool '${dispatchTool.target.providerVisibleName}' no longer resolves to the frozen target.`, 'target_mismatch' ) } - if (!isToolEffectWithinCeiling(currentTool.execution.effect, ceiling.execution.effect)) { + if ( + !isToolEffectWithinCeiling(dispatchTool.execution.effect, ceiling.execution.effect) || + !isToolEffectWithinCeiling(currentTool.execution.effect, dispatchTool.execution.effect) + ) { throw new ExecutionContractDispatchError( `Tool '${currentTool.target.providerVisibleName}' exceeds its frozen effect ceiling.`, 'effect_exceeds_ceiling' ) } - if (currentTool.execution.mode !== ceiling.execution.mode) { + if (dispatchTool.execution.mode !== ceiling.execution.mode) { throw new ExecutionContractDispatchError( - `Tool '${currentTool.target.providerVisibleName}' execution mode changed after View assembly.`, + `Tool '${dispatchTool.target.providerVisibleName}' execution mode changed after View assembly.`, 'execution_mode_mismatch' ) } diff --git a/src/main/tool/index.ts b/src/main/tool/index.ts index b721a2a419..f0629b81db 100644 --- a/src/main/tool/index.ts +++ b/src/main/tool/index.ts @@ -88,7 +88,7 @@ import { } from '@/cli/programmaticToolParentRegistry' import { buildToolSearchDefinition } from './agentTools/toolSearchTool' -type MainProcessToolCallOptions = ToolCallOptions & { +type MainProcessToolCallOptions = InternalToolCallOptions & { readonly toolSurfaceDeferredDispatch?: ToolSurfaceDeferredDispatch readonly toolSurfaceContext?: ToolSurfaceExecutionContext readonly toolSurfaceSnapshot?: ToolSurfaceSnapshot @@ -227,10 +227,12 @@ type ConversationToolModeContext = { mode: ToolModeConfiguration['mode'] frontend: 'codex' | 'function' executionCatalog: readonly MCPToolDefinition[] + codeEntryTools?: readonly MCPToolDefinition[] } type InternalToolCallOptions = ToolCallOptions & { codeModeNested?: boolean + codeModeParentTool?: MCPToolDefinition } type CodexExecInput = { @@ -631,17 +633,20 @@ export class ToolService implements ToolServicePort { if (input.mode === 'code') this.assertCodeModeCatalog(frontend, executionCatalog) this.publishConfiguredCatalog(conversationId, [...executionCatalog, ...codeModeDirectTools]) + const codeEntryTools = + input.mode === 'agent' + ? [] + : frontend === 'codex' + ? createCodexCodeModeToolDefinitions(decoratedCatalog) + : [createRunCodeToolDefinition()] this.conversationToolModes.set(conversationId, { mode: input.mode, frontend, - executionCatalog + executionCatalog, + codeEntryTools }) if (input.mode === 'agent') return executionCatalog - const codeEntryTools = - frontend === 'codex' - ? createCodexCodeModeToolDefinitions(decoratedCatalog) - : [createRunCodeToolDefinition()] return [...codeEntryTools, ...codeModeDirectTools] } @@ -1295,6 +1300,12 @@ export class ToolService implements ToolServicePort { : this.parseAgentToolArguments(request.function.arguments, toolName), target: { serverName: CODE_MODE_TOOL_SERVER_NAME, originalName: toolName } } + const runtimeOptions: InternalToolCallOptions = { + ...options, + codeModeParentTool: context.codeEntryTools?.find( + (definition) => definition.function.name === toolName + ) + } const result = waitInput ? await this.runCodeRuntime.wait({ sessionId, @@ -1304,7 +1315,7 @@ export class ToolService implements ToolServicePort { maxTokens: waitInput.maxTokens, terminate: waitInput.terminate, outerDispatch, - options: options ?? {} + options: runtimeOptions }) : await this.runCodeRuntime.execute({ sessionId, @@ -1321,7 +1332,7 @@ export class ToolService implements ToolServicePort { : {}), executionCatalog: context.executionCatalog, outerDispatch, - options: options ?? {} + options: runtimeOptions }) const rawData = result.rawData ?? { content: result.content, @@ -1700,6 +1711,7 @@ export class ToolService implements ToolServicePort { requestSeq: requestSeq as number }, currentTool: currentDefinition, + parentTool: options?.codeModeNested ? options.codeModeParentTool : undefined, currentWorkspace: currentProjectDir ? { kind: 'path', path: currentProjectDir } : { kind: 'runtime_default' }, diff --git a/test/main/tape/executionContract.test.ts b/test/main/tape/executionContract.test.ts index 370e9c67c7..03ba07a101 100644 --- a/test/main/tape/executionContract.test.ts +++ b/test/main/tape/executionContract.test.ts @@ -556,6 +556,86 @@ describe('ExecutionContract domain', () => { ).toThrow(expect.objectContaining({ code: 'target_mismatch' })) }) + it('bounds nested tools by their frozen parent without requiring child View membership', () => { + const parentTool = agentTool('run_code', TOOL_EXECUTION.write) + const contract = buildExecutionContract(buildInput({ tools: [parentTool] })) + const dispatchInput = { + request: contract.request, + parentTool, + currentTool: agentTool('read'), + currentWorkspace: { kind: 'path' as const, path: '/workspace/project' }, + currentMaxSubagentDepth: 1, + requestedSubagentDepth: 0 + } + + expect(() => assertExecutionContractAllowsDispatch(contract, dispatchInput)).not.toThrow() + expect(() => + assertExecutionContractAllowsDispatch(contract, { + ...dispatchInput, + currentTool: agentTool('exec', TOOL_EXECUTION.write) + }) + ).not.toThrow() + expect(() => + assertExecutionContractAllowsDispatch(contract, { ...dispatchInput, parentTool: undefined }) + ).toThrow(expect.objectContaining({ code: 'tool_not_allowed' })) + expect(() => + assertExecutionContractAllowsDispatch(contract, { + ...dispatchInput, + parentTool: agentTool('missing_parent', TOOL_EXECUTION.write) + }) + ).toThrow(expect.objectContaining({ code: 'tool_not_allowed' })) + expect(() => + assertExecutionContractAllowsDispatch(contract, { + ...dispatchInput, + parentTool: { ...parentTool, server: { ...parentTool.server, name: 'other-server' } } + }) + ).toThrow(expect.objectContaining({ code: 'target_mismatch' })) + + const readParent = agentTool('run_code', TOOL_EXECUTION.read.sequential) + const readContract = buildExecutionContract(buildInput({ tools: [readParent] })) + expect(() => + assertExecutionContractAllowsDispatch(readContract, { + ...dispatchInput, + parentTool: readParent + }) + ).not.toThrow() + expect(() => + assertExecutionContractAllowsDispatch(readContract, { + ...dispatchInput, + parentTool: readParent, + currentTool: agentTool('exec', TOOL_EXECUTION.write) + }) + ).toThrow(expect.objectContaining({ code: 'effect_exceeds_ceiling' })) + expect(() => assertExecutionContractAllowsDispatch(readContract, dispatchInput)).toThrow( + expect.objectContaining({ code: 'effect_exceeds_ceiling' }) + ) + expect(() => + assertExecutionContractAllowsDispatch(contract, { + ...dispatchInput, + parentTool: agentTool('run_code', TOOL_EXECUTION.read.parallel) + }) + ).toThrow(expect.objectContaining({ code: 'execution_mode_mismatch' })) + expect(() => + assertExecutionContractAllowsDispatch(contract, { + ...dispatchInput, + currentWorkspace: { kind: 'path', path: '/workspace/other' } + }) + ).toThrow(expect.objectContaining({ code: 'workspace_mismatch' })) + expect(() => + assertExecutionContractAllowsDispatch(contract, { + ...dispatchInput, + requestedSubagentDepth: 1, + currentMaxSubagentDepth: 0 + }) + ).toThrow(expect.objectContaining({ code: 'subagent_depth_exceeded' })) + expect(() => + assertExecutionContractAllowsDispatch(contract, { + ...dispatchInput, + request: { ...contract.request, requestSeq: 3 } + }) + ).toThrow(expect.objectContaining({ code: 'identity_mismatch' })) + }) + it('binds paused execution to the complete provider View identity and contract hash', () => { const contract = buildExecutionContract(buildInput()) const binding = buildExecutionContractBinding(contract) diff --git a/test/main/tool/toolService.test.ts b/test/main/tool/toolService.test.ts index 000e10e804..df2bb17261 100644 --- a/test/main/tool/toolService.test.ts +++ b/test/main/tool/toolService.test.ts @@ -1,5 +1,7 @@ +import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { TOOL_EXECUTION, type MCPToolDefinition } from '@shared/types/mcp' +import { RUN_CODE_PROTOCOL_VERSION, type RunCodeParentMessage } from '@shared/codeModeProtocol' import { ToolService } from '@/tool' import { RunCodeRuntimeManager } from '@/tool/codeMode/runCodeRuntimeManager' import { POSIX_COMMAND_SHELL } from '../../helpers/commandShell' @@ -1682,6 +1684,148 @@ describe('ToolService', () => { ).toThrow("both map to 'mcp_read'") }) + it.each(['deepseek', 'openai-codex'])( + 'dispatches Code Mode subagent children under the %s entrypoint contract', + async (providerId) => { + const definitions: MCPToolDefinition[] = [ + ...['exec', 'read', 'glob'].map((name) => ({ + ...buildToolDefinition(name, 'agent-filesystem'), + source: 'agent' as const, + server: { name: 'agent-filesystem', icons: '', description: 'Filesystem tools' }, + execution: name === 'exec' ? TOOL_EXECUTION.write : TOOL_EXECUTION.read.parallel + })), + buildContractMcpDefinition() + ] + const authority = { + sessionId: 'session-1', + agentId: 'agent-1', + projectDir: '/workspace', + sessionKind: 'subagent' as const, + parentSessionId: 'parent-1', + disabledAgentTools: [], + enabledMcpServerIds: [definitions[3].server.id!], + subagentCapability: { + available: false as const, + reason: 'policy_disabled' as const, + cacheKey: 'off' + } + } + const mcpService = { + callTool: vi.fn(async () => ({ content: 'remote result', rawData: {} })) + } as any + const toolService = new ToolService({ + skillSettings: { isEnabled: () => false } as any, + mcpService, + agentSettings: { resolveDeepChatAgentConfig: vi.fn(async () => ({})) } as any, + providerSettings: { getModelConfig: vi.fn() } as any, + settings: { get: vi.fn() }, + commandPermissionHandler: new CommandPermissionService(), + agentTools: buildAgentToolRuntimeMock({ + resolveConversationSessionInfo: vi.fn(async (sessionId: string) => + sessionId === 'parent-1' + ? { ...authority, sessionId, sessionKind: 'regular' } + : authority + ), + resolveConversationExecutionAuthority: vi.fn(async () => authority), + resolveConversationExecutionAuthorityNow: vi.fn(() => authority) + }) + }) + const agentToolManager = (toolService as any).ensureAgentToolManager(null) + agentToolManager.callTool = vi.fn(async () => 'native result') + const [entrypoint] = toolService.configureToolMode({ + conversationId: 'session-1', + mode: 'code', + providerId, + commandShell: POSIX_COMMAND_SHELL, + executionCatalog: definitions + }) + const nestedResults: RunCodeParentMessage[] = [] + const host = Object.assign(new EventEmitter(), { + pid: 42, + kill: vi.fn(() => true), + postMessage(message: RunCodeParentMessage) { + if (message.type === 'START') { + queueMicrotask(() => { + for (const binding of message.bindings) { + host.emit('message', { + type: 'NESTED_CALL', + version: RUN_CODE_PROTOCOL_VERSION, + cellId: message.cellId, + callId: binding.id, + bindingId: binding.id, + arguments: {} + }) + } + }) + } else if (message.type === 'NESTED_RESULT') { + nestedResults.push(message) + if (nestedResults.length === definitions.length) { + queueMicrotask(() => + host.emit('message', { + type: 'RESULT', + version: RUN_CODE_PROTOCOL_VERSION, + cellId: message.cellId, + output: [], + store: {} + }) + ) + } + } else if (message.type === 'STOP') { + queueMicrotask(() => host.emit('exit', 0)) + } + } + }) + const runtime = (toolService as any).runCodeRuntime + runtime.managerOptions.spawnHost = async () => { + setTimeout( + () => + host.emit('message', { + type: 'READY', + version: RUN_CODE_PROTOCOL_VERSION, + pid: host.pid + }), + 0 + ) + return host + } + + try { + await toolService.callTool( + { + id: 'code-call-1', + type: 'function', + function: { + name: entrypoint.function.name, + arguments: + providerId === 'openai-codex' + ? 'await tools.exec({})' + : JSON.stringify({ + code: 'await tools.exec({})', + description: 'Run nested tools' + }) + }, + conversationId: 'session-1' + }, + { + runId: CONTRACT_RUN_ID, + messageId: 'message-1', + requestSeq: 1, + executionContract: buildToolExecutionContract(entrypoint), + permissionMode: 'full_access' + } + ) + expect(nestedResults).toHaveLength(definitions.length) + for (const result of nestedResults) { + expect(result).toMatchObject({ type: 'NESTED_RESULT', ok: true }) + } + expect(agentToolManager.callTool).toHaveBeenCalledTimes(3) + expect(mcpService.callTool).toHaveBeenCalledOnce() + } finally { + await toolService.shutdownCodeRuntime() + } + } + ) + it('meets the frozen execution contract with current authority at dispatch', async () => { const definition = buildContractMcpDefinition() const resolveConversationExecutionAuthority = vi.fn(async (sessionId: string) => ({