diff --git a/extensions/codex/src/app-server/attempt-startup.test.ts b/extensions/codex/src/app-server/attempt-startup.test.ts index b0329ea46040..835d7a410862 100644 --- a/extensions/codex/src/app-server/attempt-startup.test.ts +++ b/extensions/codex/src/app-server/attempt-startup.test.ts @@ -88,6 +88,8 @@ const bundleMcpThreadConfig = { diagnostics: [], evaluated: false, fingerprint: undefined, + staticServerNames: [], + userStaticServerNames: [], } satisfies CodexBundleMcpThreadConfig; const HARNESS_REQUEST_TIMEOUT_MS = 15_000; diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index 3eb7bcdde39b..e153f9208d76 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -155,6 +155,8 @@ export async function startCodexAttemptThread(params: { buildFinalConfigPatch?: Parameters[0]["buildFinalConfigPatch"]; nativeHookRelayGeneration?: string; bundleMcpThreadConfig: CodexBundleMcpThreadConfig; + /** OpenClaw owns configured MCP dynamically for this scheduled turn. */ + configuredMcpOwnershipVersion?: 1; nativeToolSurfaceEnabled: boolean; nativeProviderWebSearchSupport: CodexNativeWebSearchSupport; sandboxExecServerEnabled: boolean; @@ -195,7 +197,9 @@ export async function startCodexAttemptThread(params: { }, operation: async () => { const threadConfig = mergeCodexThreadConfigs( - params.bundleMcpThreadConfig?.configPatch as JsonObject | undefined, + params.configuredMcpOwnershipVersion === 1 + ? undefined + : (params.bundleMcpThreadConfig?.configPatch as JsonObject | undefined), ); const pluginStartupPolicy = resolveCodexPluginThreadConfigStartupPolicy({ pluginConfig: params.pluginConfig, @@ -471,9 +475,18 @@ export async function startCodexAttemptThread(params: { nativeCodeModeEnabled: params.nativeToolSurfaceEnabled, nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport, nativeCodeModeOnlyEnabled: params.appServer.codeModeOnly, - userMcpServersEnabled: params.nativeToolSurfaceEnabled, - mcpServersFingerprint: params.bundleMcpThreadConfig.fingerprint, - mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated, + userMcpServersEnabled: + params.configuredMcpOwnershipVersion === 1 + ? false + : params.nativeToolSurfaceEnabled, + mcpServersFingerprint: + params.configuredMcpOwnershipVersion === 1 + ? undefined + : params.bundleMcpThreadConfig.fingerprint, + mcpServersFingerprintEvaluated: + params.configuredMcpOwnershipVersion === 1 || + params.bundleMcpThreadConfig.evaluated, + configuredMcpOwnershipVersion: params.configuredMcpOwnershipVersion, environmentSelection: startupEnvironmentSelection, appServerRuntimeFingerprint, contextEngineProjection: params.contextEngineProjection, diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index ecd7323d031b..780f1413239c 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -36,6 +36,7 @@ import { flattenCodexDynamicToolFunctions } from "./protocol.js"; import { createCodexTestModel } from "./test-support.js"; const hoisted = vi.hoisted(() => ({ + normalizeAgentRuntimeTools: vi.fn(), resolveWebSearchToolPolicy: vi.fn(), })); @@ -53,6 +54,17 @@ vi.mock("openclaw/plugin-sdk/agent-harness", async (importOriginal) => { }; }); +vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + normalizeAgentRuntimeTools: (...args: Parameters) => { + hoisted.normalizeAgentRuntimeTools(...args); + return actual.normalizeAgentRuntimeTools(...args); + }, + }; +}); + let tempDir: string; function setOpenClawCodingToolsFactoryForTests( @@ -175,6 +187,7 @@ describe("Codex app-server dynamic tool build", () => { }); beforeEach(async () => { + hoisted.normalizeAgentRuntimeTools.mockClear(); hoisted.resolveWebSearchToolPolicy.mockClear(); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-tools-")); }); @@ -1550,6 +1563,59 @@ describe("Codex app-server dynamic tool build", () => { expect(isToolWrappedWithBeforeToolCallHook(tool)).toBe(true); }); + it("builds the durable registered-tool superset without loading a provider runtime", async () => { + const sessionFile = path.join(tempDir, "session-registered-tools.jsonl"); + const workspaceDir = path.join(tempDir, "workspace-registered-tools"); + const params = createParams(sessionFile, workspaceDir); + params.disableTools = false; + const runtimePlan = createCodexRuntimePlanFixture(); + const planNormalize = vi.fn((tools: RuntimeDynamicToolForTest[]) => + tools.map((tool) => ({ ...tool, description: `turn:${tool.description}` })), + ); + runtimePlan.tools.normalize = planNormalize as typeof runtimePlan.tools.normalize; + params.runtimePlan = runtimePlan; + const messageTool = createRuntimeDynamicTool("message"); + const heartbeatTool = createRuntimeDynamicTool("heartbeat_respond"); + const invalidTool = { + ...createRuntimeDynamicTool("invalid_registered_tool"), + parameters: { type: "array", items: { type: "string" } }, + }; + setOpenClawCodingToolsFactoryForTests((options) => [ + messageTool, + ...(options?.enableHeartbeatTool === true ? [heartbeatTool, invalidTool] : []), + ]); + + const turnTools = await buildDynamicToolsForTest(params, workspaceDir, { + sandbox: null as never, + }); + const registeredTools = await buildDynamicToolsForTest(params, workspaceDir, { + forceHeartbeatTool: true, + ignoreDisableMessageTool: true, + ignoreRuntimePlan: true, + sandbox: null as never, + }); + + expect(planNormalize).toHaveBeenCalledOnce(); + expect(hoisted.normalizeAgentRuntimeTools).toHaveBeenCalledTimes(2); + expect(hoisted.normalizeAgentRuntimeTools.mock.calls[0]?.[0]).toMatchObject({ + runtimePlan, + }); + expect(hoisted.normalizeAgentRuntimeTools.mock.calls[1]?.[0]).toMatchObject({ + allowProviderRuntimePluginLoad: false, + runtimePlan: undefined, + }); + expect(hoisted.normalizeAgentRuntimeTools.mock.calls[1]?.[0]).not.toHaveProperty( + "runtimeHandle", + ); + expect(turnTools.map((tool) => tool.name)).toEqual(["message"]); + expect(turnTools[0]?.description).toBe(`turn:${messageTool.description}`); + expect(registeredTools.map((tool) => tool.name)).toEqual(["message", "heartbeat_respond"]); + expect(registeredTools.map((tool) => tool.description)).toEqual([ + messageTool.description, + heartbeatTool.description, + ]); + }); + it("passes runtime config into Codex exec dynamic tool construction", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 6ead79002742..59ba7135b0e9 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -19,6 +19,7 @@ import { type RuntimeToolSchemaDiagnostic, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; +import { runWithCronCreatorAuthorityResolver } from "openclaw/plugin-sdk/codex-mcp-projection"; import { isToolAllowed } from "openclaw/plugin-sdk/sandbox"; import { readCodexPluginConfig, type CodexPluginConfig } from "./config.js"; import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; @@ -95,6 +96,12 @@ type DynamicToolBuildParams = { sessionAgentId: string; pluginConfig: CodexPluginConfig; profilerEnabled?: boolean; + cronCreatorToolAllowlistRef?: OpenClawCodingToolsOptions["cronCreatorToolAllowlistRef"]; + cronCreatorToolAllowlistCaptureRef?: OpenClawCodingToolsOptions["cronCreatorToolAllowlistCaptureRef"]; + resolveCronCreatorToolAuthority?: Parameters< + typeof runWithCronCreatorAuthorityResolver + >[0]["resolve"]; + cronCreatorAuthorityUnavailableReason?: OpenClawCodingToolsOptions["cronCreatorAuthorityUnavailableReason"]; forceHeartbeatTool?: boolean; ignoreDisableMessageTool?: boolean; ignoreRuntimePlan?: boolean; @@ -237,107 +244,118 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) { toolBuildStages.mark("load-agent-harness-tools"); const sessionKeys = resolveOpenClawCodingToolsSessionKeys(params, input.sandboxSessionKey); const nativeExecutionPolicy = resolveCodexNativeExecutionPolicyForDynamicTools(input); - const allTools = createOpenClawCodingTools({ - agentId: input.sessionAgentId, - ...buildEmbeddedAttemptToolRunContext(params), - exec: { - ...params.execOverrides, - ...resolveCodexNodeExecToolOverrides(nativeExecutionPolicy), - config: params.config, - elevated: params.bashElevated, - }, - sandbox: input.sandbox, - messageProvider: resolveCodexMessageToolProvider(params), - toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel, - // Capability-gated tools (requiredClientCaps) need the originating client's - // declared caps in this sibling harness too, not only the embedded runner. - clientCaps: params.clientCaps, - chatType: params.chatType, - agentAccountId: params.agentAccountId, - messageTo: params.messageTo, - messageThreadId: params.messageThreadId, - nativeChannelId: params.chatId, - messageActionTurnCapability: params.messageActionTurnCapability, - groupId: params.groupId, - groupChannel: params.groupChannel, - groupSpace: params.groupSpace, - spawnedBy: params.spawnedBy, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - senderIsOwner: params.senderIsOwner, - inputProvenance: params.inputProvenance, - trustedInternalHandoff: params.trustedInternalHandoff, - scheduledToolPolicy: params.scheduledToolPolicy, - allowGatewaySubagentBinding: - params.allowGatewaySubagentBinding || isForcedPrivateQaCodexRuntime(), - ...sessionKeys, - sessionId: params.sessionId, - runId: params.runId, - approvalReviewerDeviceId: params.approvalReviewerDeviceId, - agentDir, - cwd: input.effectiveCwd ?? input.effectiveWorkspace, - workspaceDir: input.effectiveWorkspace, - spawnWorkspaceDir: - input.effectiveCwd && input.effectiveCwd !== input.effectiveWorkspace - ? input.resolvedWorkspace - : resolveAttemptSpawnWorkspaceDir({ - sandbox: input.sandbox, - resolvedWorkspace: input.resolvedWorkspace, - }), - config: params.config, - authProfileStore: params.toolAuthProfileStore ?? params.authProfileStore, - abortSignal: input.runAbortController.signal, - emitBeforeToolCallDiagnostics: false, - modelProvider: params.model.provider, - modelId: params.modelId, - modelCompat: - params.model.compat && typeof params.model.compat === "object" - ? (params.model.compat as OpenClawCodingToolsOptions["modelCompat"]) - : undefined, - modelApi: params.model.api, - modelContextWindowTokens: params.model.contextWindow, - delegationCapability: params.delegationCapability, - modelAuthMode: resolveModelAuthMode( - params.model.provider, - params.config, - params.toolAuthProfileStore ?? params.authProfileStore, - { - workspaceDir: input.effectiveWorkspace, + const buildOpenClawCodingTools = () => + createOpenClawCodingTools({ + agentId: input.sessionAgentId, + ...buildEmbeddedAttemptToolRunContext(params), + exec: { + ...params.execOverrides, + ...resolveCodexNodeExecToolOverrides(nativeExecutionPolicy), + config: params.config, + elevated: params.bashElevated, }, - ), - suppressManagedWebSearch: false, - currentChannelId: params.currentChannelId, - currentMessagingTarget: params.currentMessagingTarget, - hookChannelId: resolveCodexAppServerHookChannelId(params, input.sandboxSessionKey), - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - replyToMode: params.replyToMode, - hasRepliedRef: params.hasRepliedRef, - modelHasVision, - computerContextEpoch: input.computerContextEpoch, - requireExplicitMessageTarget: - params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey), - sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, - disableMessageTool: input.ignoreDisableMessageTool ? false : params.disableMessageTool, - forceMessageTool: shouldForceMessageTool(messagePolicyParams), - enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true, - forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true, - onYield: (message) => { - input.onYieldDetected(); - input.onCodexAppServerEvent?.({ - stream: "codex_app_server.tool", - data: { name: "sessions_yield", message }, - }); - }, - recordToolPrepStage: (name) => { - toolBuildStages.mark(name); - }, - onToolOutcome: params.onToolOutcome, - isTurnTainted: params.isTurnTainted, - allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal, - }); + sandbox: input.sandbox, + messageProvider: resolveCodexMessageToolProvider(params), + toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel, + // Capability-gated tools (requiredClientCaps) need the originating client's + // declared caps in this sibling harness too, not only the embedded runner. + clientCaps: params.clientCaps, + chatType: params.chatType, + agentAccountId: params.agentAccountId, + messageTo: params.messageTo, + messageThreadId: params.messageThreadId, + nativeChannelId: params.chatId, + messageActionTurnCapability: params.messageActionTurnCapability, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + senderIsOwner: params.senderIsOwner, + inputProvenance: params.inputProvenance, + trustedInternalHandoff: params.trustedInternalHandoff, + scheduledToolPolicy: params.scheduledToolPolicy, + allowGatewaySubagentBinding: + params.allowGatewaySubagentBinding || isForcedPrivateQaCodexRuntime(), + ...sessionKeys, + sessionId: params.sessionId, + runId: params.runId, + approvalReviewerDeviceId: params.approvalReviewerDeviceId, + agentDir, + cwd: input.effectiveCwd ?? input.effectiveWorkspace, + workspaceDir: input.effectiveWorkspace, + spawnWorkspaceDir: + input.effectiveCwd && input.effectiveCwd !== input.effectiveWorkspace + ? input.resolvedWorkspace + : resolveAttemptSpawnWorkspaceDir({ + sandbox: input.sandbox, + resolvedWorkspace: input.resolvedWorkspace, + }), + config: params.config, + authProfileStore: params.toolAuthProfileStore ?? params.authProfileStore, + abortSignal: input.runAbortController.signal, + emitBeforeToolCallDiagnostics: false, + modelProvider: params.model.provider, + modelId: params.modelId, + modelCompat: + params.model.compat && typeof params.model.compat === "object" + ? (params.model.compat as OpenClawCodingToolsOptions["modelCompat"]) + : undefined, + modelApi: params.model.api, + modelContextWindowTokens: params.model.contextWindow, + delegationCapability: params.delegationCapability, + modelAuthMode: resolveModelAuthMode( + params.model.provider, + params.config, + params.toolAuthProfileStore ?? params.authProfileStore, + { + workspaceDir: input.effectiveWorkspace, + }, + ), + suppressManagedWebSearch: false, + currentChannelId: params.currentChannelId, + currentMessagingTarget: params.currentMessagingTarget, + hookChannelId: resolveCodexAppServerHookChannelId(params, input.sandboxSessionKey), + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + replyToMode: params.replyToMode, + hasRepliedRef: params.hasRepliedRef, + modelHasVision, + computerContextEpoch: input.computerContextEpoch, + requireExplicitMessageTarget: + params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey), + sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, + disableMessageTool: input.ignoreDisableMessageTool ? false : params.disableMessageTool, + forceMessageTool: shouldForceMessageTool(messagePolicyParams), + enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true, + forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true, + onYield: (message) => { + input.onYieldDetected(); + input.onCodexAppServerEvent?.({ + stream: "codex_app_server.tool", + data: { name: "sessions_yield", message }, + }); + }, + recordToolPrepStage: (name) => { + toolBuildStages.mark(name); + }, + onToolOutcome: params.onToolOutcome, + isTurnTainted: params.isTurnTainted, + allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal, + cronCreatorToolAllowlistRef: input.cronCreatorToolAllowlistRef, + cronCreatorToolAllowlistCaptureRef: input.cronCreatorToolAllowlistCaptureRef, + cronCreatorAuthorityUnavailableReason: input.cronCreatorAuthorityUnavailableReason, + }); + const allTools = input.resolveCronCreatorToolAuthority + ? runWithCronCreatorAuthorityResolver({ + runId: params.runId, + resolve: input.resolveCronCreatorToolAuthority, + run: buildOpenClawCodingTools, + }) + : buildOpenClawCodingTools(); const codexScopedTools = addCodexMessageToolOnlyFinalControl( allTools, params.sourceReplyDeliveryMode, @@ -435,6 +453,9 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) { modelId: params.modelId, modelApi: params.model.api, model: params.model, + // Durable registration projects the prepared catalog; it must not activate + // a different provider runtime while building the thread-stable schema. + allowProviderRuntimePluginLoad: input.ignoreRuntimePlan ? false : undefined, onPreNormalizationSchemaDiagnostics: (diagnostics) => preNormalizationDiagnostics.push(...diagnostics), }); diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 0b69a065abee..ce35366cba7e 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -30,7 +30,10 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { createOpenClawTestState } from "openclaw/plugin-sdk/test-state"; import { estimateToolResultTextChars } from "openclaw/plugin-sdk/text-utility-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; +import { + createCodexDynamicToolBridge, + projectCodexExecutableDynamicTools, +} from "./dynamic-tools.js"; import { CODEX_OPENCLAW_DIRECT_DYNAMIC_TOOL_NAMESPACE, type CodexDynamicToolFunctionSpec, @@ -350,6 +353,7 @@ describe("createCodexDynamicToolBridge", () => { }); expect(specNames(bridge.availableSpecs)).toEqual(["message"]); + expect(bridge.availableTools.map((tool) => tool.name)).toEqual(["message"]); expect(specNames(bridge.specs)).toEqual([HEARTBEAT_RESPONSE_TOOL_NAME, "message"]); const result = await bridge.handleToolCall( @@ -813,6 +817,27 @@ describe("createCodexDynamicToolBridge", () => { expect(badExecute).not.toHaveBeenCalled(); }); + it("uses the bridge's executable projection for authority snapshots", () => { + const tools = [ + createTool({ name: "configured_ok" }), + createTool({ + name: "configured_unsupported", + parameters: { type: "array", items: { type: "string" } }, + }), + ]; + const projected = projectCodexExecutableDynamicTools({ tools }); + const bridge = createCodexDynamicToolBridge({ + tools, + signal: new AbortController().signal, + }); + + expect(projected.availableTools.map((tool) => tool.name)).toEqual( + bridge.availableTools.map((tool) => tool.name), + ); + expect(projected.availableTools.map((tool) => tool.name)).toEqual(["configured_ok"]); + expect(projected.quarantinedTools).toEqual(bridge.telemetry.quarantinedTools); + }); + it("quarantines unreadable dynamic tool descriptors without dropping healthy siblings", () => { const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined); const poisonedName = createTool({ @@ -3930,6 +3955,70 @@ describe("createCodexDynamicToolBridge", () => { expect(execute).not.toHaveBeenCalled(); }); + it("passes scheduled requester facts to hooks and rejects interactive approval", async () => { + const beforeToolCall = vi.fn(async () => ({ + requireApproval: { + pluginId: "test-plugin", + title: "Needs approval", + description: "Review before running", + }, + })); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]), + ); + const execute = vi.fn(async () => textToolResult("should not run")); + const bridge = createCodexDynamicToolBridge({ + tools: [createTool({ name: "exec", execute })], + signal: new AbortController().signal, + hookContext: { + trigger: "cron", + runId: "run-scheduled-hook", + sessionId: "session-scheduled-hook", + sessionKey: "agent:main:cron:job-1", + requester: { + channel: "telegram", + accountId: "bot-a", + senderId: "sender-a", + senderIsOwner: true, + roleIds: ["operator"], + }, + turnSourceChannel: "telegram", + turnSourceTo: "chat-a", + turnSourceAccountId: "bot-a", + turnSourceThreadId: "topic-a", + }, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-scheduled-hook", + namespace: null, + tool: "exec", + arguments: { command: "pwd" }, + }); + + expect(result).toMatchObject({ + success: false, + contentItems: [ + { + type: "inputText", + text: expect.stringContaining("cron runs have no approval-capable initiating surface"), + }, + ], + }); + expect(execute).not.toHaveBeenCalled(); + expect(callArg(beforeToolCall, 0, 1, "scheduled before_tool_call context")).toMatchObject({ + requester: { + channel: "telegram", + accountId: "bot-a", + senderId: "sender-a", + senderIsOwner: true, + roleIds: ["operator"], + }, + }); + }); + it("applies dynamic tool result middleware before after_tool_call observes the result", async () => { const events: string[] = []; const beforeToolCall = vi.fn(async () => { diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 8b1d85fc08d2..2a420becca6b 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -82,16 +82,11 @@ import { import { recordCodexSourceReplyDeliveryIntent } from "./source-reply-finality.js"; import { resolveCodexToolAbortTerminalReason } from "./tool-abort-terminal-reason.js"; -type CodexDynamicToolHookContext = { - agentId?: string; - config?: EmbeddedRunAttemptParams["config"]; - workspaceDir?: string; +type CodexDynamicToolHookContext = NonNullable< + Parameters[1] +> & { remoteWorkspaceRoot?: string; remoteWorkspaceRequestTimeoutMs?: number; - sessionId?: string; - sessionKey?: string; - runId?: string; - channelId?: string; currentChannelProvider?: string; contextWindowTokens?: number; currentChannelId?: string; @@ -101,8 +96,6 @@ type CodexDynamicToolHookContext = { replyToMode?: "off" | "first" | "all" | "batched"; hasRepliedRef?: { value: boolean }; sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"]; - onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"]; - allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"]; }; type CodexToolResultHookContext = Omit; @@ -356,6 +349,8 @@ function hasExplicitNonSourceMessageRoute( /** Runtime bridge returned to Codex app-server attempt code. */ export type CodexDynamicToolBridge = { + /** Final executable tools after schema projection and hook-wrapper quarantine. */ + availableTools: AnyAgentTool[]; availableSpecs: CodexDynamicToolSpec[]; specs: CodexDynamicToolSpec[]; resultContentSourceForTool: (toolName: string) => AnyAgentTool["resultContentSource"]; @@ -473,19 +468,16 @@ export function createCodexDynamicToolBridge(params: { contextWindowTokens > 0 ? Math.max(1, resolveLiveToolResultMaxChars({ contextWindowTokens })) : DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS; - const availableProjection = projectCodexDynamicTools(params.tools); + const availableProjection = projectCodexExecutableDynamicToolSurface( + params.tools, + params.hookContext, + ); const registeredProjection = params.registeredTools ? projectCodexDynamicTools(params.registeredTools) : availableProjection; - const wrappedAvailableProjection = wrapProjectedCodexDynamicTools( - availableProjection.tools, - params.hookContext, - ); - const availableTools = wrappedAvailableProjection.tools; + const availableTools = availableProjection.tools; const quarantinedAvailableToolNames = new Set( - [...availableProjection.quarantinedTools, ...wrappedAvailableProjection.quarantinedTools].map( - (tool) => tool.tool, - ), + availableProjection.quarantinedTools.map((tool) => tool.tool), ); const registeredSpecTools = ( params.registeredTools ? registeredProjection.tools : availableTools @@ -495,7 +487,6 @@ export function createCodexDynamicToolBridge(params: { const quarantinedTools = dedupeQuarantinedDynamicTools([ ...availableProjection.quarantinedTools, ...registeredProjection.quarantinedTools, - ...wrappedAvailableProjection.quarantinedTools, ]); warnQuarantinedDynamicTools(quarantinedTools); emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext); @@ -540,6 +531,7 @@ export function createCodexDynamicToolBridge(params: { ]); let readRemoteWorkspaceFile: CodexRemoteWorkspaceFileReader | undefined; return { + availableTools: availableTools.map((entry) => entry.tool), availableSpecs: createCodexDynamicToolSpecs({ entries: availableTools, loading: params.loading ?? "searchable", @@ -932,6 +924,39 @@ export function createCodexDynamicToolBridge(params: { }; } +function projectCodexExecutableDynamicToolSurface( + tools: readonly AnyAgentTool[], + hookContext: CodexDynamicToolHookContext | undefined, +): { + tools: ProjectedCodexDynamicTool[]; + quarantinedTools: CodexDynamicToolSchemaQuarantine[]; +} { + const projected = projectCodexDynamicTools(tools); + const wrapped = wrapProjectedCodexDynamicTools(projected.tools, hookContext); + return { + tools: wrapped.tools, + quarantinedTools: dedupeQuarantinedDynamicTools([ + ...projected.quarantinedTools, + ...wrapped.quarantinedTools, + ]), + }; +} + +/** Applies the exact schema and hook-wrapper projection used by the executable Codex bridge. */ +export function projectCodexExecutableDynamicTools(params: { + tools: readonly AnyAgentTool[]; + hookContext?: CodexDynamicToolHookContext; +}): { + availableTools: AnyAgentTool[]; + quarantinedTools: CodexDynamicToolSchemaQuarantine[]; +} { + const projected = projectCodexExecutableDynamicToolSurface(params.tools, params.hookContext); + return { + availableTools: projected.tools.map((entry) => entry.tool), + quarantinedTools: projected.quarantinedTools, + }; +} + function notifyAgentToolResult( observer: EmbeddedRunAttemptParams["onAgentToolResult"] | undefined, toolName: string, diff --git a/extensions/codex/src/app-server/effective-mcp-catalog.ts b/extensions/codex/src/app-server/effective-mcp-catalog.ts index 24e9dabd3c27..9bbc38fa7b9e 100644 --- a/extensions/codex/src/app-server/effective-mcp-catalog.ts +++ b/extensions/codex/src/app-server/effective-mcp-catalog.ts @@ -143,6 +143,20 @@ async function listCodexMcpServerStatuses( throw new Error("Codex mcpServerStatus/list exceeded the bounded page limit"); } +/** Loads the requested MCP inventory from the exact client/thread already selected for a run. */ +async function loadCodexEffectiveMcpCatalogFromThread(params: { + client: Pick; + threadId: string; + mcpServerNames: readonly string[]; + toolOverrides?: AgentHarnessMcpCatalogParams["toolOverrides"]; +}): Promise { + const allowedServerNames = new Set(params.mcpServerNames); + const statuses = (await listCodexMcpServerStatuses(params.client, params.threadId)).filter( + (status) => allowedServerNames.has(status.name), + ); + return buildCodexEffectiveMcpCatalog(statuses, params.toolOverrides); +} + /** Loads MCP inventory only from the already-bound Codex process and thread. */ export async function loadCodexEffectiveMcpCatalog( params: AgentHarnessMcpCatalogParams, @@ -164,13 +178,12 @@ export async function loadCodexEffectiveMcpCatalog( return undefined; } try { - const allowedServerNames = new Set(params.mcpServerNames); - return buildCodexEffectiveMcpCatalog( - (await listCodexMcpServerStatuses(retained.client, binding.threadId)).filter((status) => - allowedServerNames.has(status.name), - ), - params.toolOverrides, - ); + return loadCodexEffectiveMcpCatalogFromThread({ + client: retained.client, + threadId: binding.threadId, + mcpServerNames: params.mcpServerNames, + toolOverrides: params.toolOverrides, + }); } finally { retained.release(); } diff --git a/extensions/codex/src/app-server/protocol-mcp.ts b/extensions/codex/src/app-server/protocol-mcp.ts index 257ef021966e..98ba2b68564b 100644 --- a/extensions/codex/src/app-server/protocol-mcp.ts +++ b/extensions/codex/src/app-server/protocol-mcp.ts @@ -2,9 +2,19 @@ import type { JsonObject, JsonValue } from "./protocol-json.js"; export type CodexMcpServerStatus = { name: string; + /** Present only after the configured server completed MCP initialization. */ + serverInfo?: { + name: string; + title?: string | null; + version: string; + description?: string | null; + icons?: JsonValue[] | null; + websiteUrl?: string | null; + } | null; tools: JsonObject; resources?: JsonValue[]; resourceTemplates?: JsonValue[]; + authStatus?: "unsupported" | "notLoggedIn" | "bearerToken" | "oAuth"; }; export type CodexListMcpServerStatusResponse = { diff --git a/extensions/codex/src/app-server/run-attempt-cleanup.ts b/extensions/codex/src/app-server/run-attempt-cleanup.ts index 5f623817c1a5..3af65ffc93cb 100644 --- a/extensions/codex/src/app-server/run-attempt-cleanup.ts +++ b/extensions/codex/src/app-server/run-attempt-cleanup.ts @@ -171,6 +171,7 @@ export async function cleanupCodexAttempt( log: embeddedAgentLog, cleanup: async () => { await prompt.context.attemptTools.scopedMcpTools?.dispose(); + await prompt.context.attemptTools.scheduledConfiguredMcp?.dispose(); }, }); runAbortController.signal.removeEventListener("abort", abortListener); diff --git a/extensions/codex/src/app-server/run-attempt-runtime.authority.test.ts b/extensions/codex/src/app-server/run-attempt-runtime.authority.test.ts new file mode 100644 index 000000000000..2c2b72ccfd31 --- /dev/null +++ b/extensions/codex/src/app-server/run-attempt-runtime.authority.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { canResolveScheduledConfiguredMcpCreatorAuthority } from "./scheduled-configured-mcp-authority.js"; + +const eligible = { + trigger: "user", + connectionClass: "local-loopback", + bindingKind: "session", + bindingSessionKey: "agent:main:main", + sessionKey: "agent:main:main", + usesSupervisionConnection: false, + preservesNativeModel: false, + senderIsOwner: true, + hasStaticConfiguredMcp: true, +} as const; + +describe("canResolveScheduledConfiguredMcpCreatorAuthority", () => { + it("admits only the positive local durable operator case", () => { + expect(canResolveScheduledConfiguredMcpCreatorAuthority(eligible)).toBe(true); + }); + + it.each([ + ["non-user trigger", { trigger: "cron" }], + ["non-loopback connection", { connectionClass: "remote" }], + ["non-session binding", { bindingKind: "supervision" }], + ["missing durable binding key", { bindingSessionKey: undefined }], + ["incognito session", { sessionKey: "agent:main:dashboard:incognito-test" }], + ["supervision", { usesSupervisionConnection: true }], + ["preserved native model", { preservesNativeModel: true }], + ["non-owner", { senderIsOwner: false }], + ["external sender", { senderId: "sender-1" }], + ["input provenance", { inputProvenance: { kind: "external_user" } }], + ["trusted handoff", { trustedInternalHandoff: { kind: "completion" } }], + ["spawn lineage", { spawnedBy: "agent:main:parent" }], + ["scheduled policy", { scheduledToolPolicy: { version: 1 } }], + ["no static configured MCP", { hasStaticConfiguredMcp: false }], + ])("rejects %s", (_label, override) => { + expect(canResolveScheduledConfiguredMcpCreatorAuthority({ ...eligible, ...override })).toBe( + false, + ); + }); +}); diff --git a/extensions/codex/src/app-server/run-attempt-runtime.ts b/extensions/codex/src/app-server/run-attempt-runtime.ts index 6378d7a5975d..5914257eb43a 100644 --- a/extensions/codex/src/app-server/run-attempt-runtime.ts +++ b/extensions/codex/src/app-server/run-attempt-runtime.ts @@ -6,6 +6,7 @@ import { supportsModelTools, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { resolveCodexMcpToolOverridesForAgent } from "openclaw/plugin-sdk/codex-mcp-projection"; import { prepareCodexAppServerAuthBinding } from "./auth-binding.js"; import { resolveCodexAppServerAuthAccountCacheKey, @@ -20,9 +21,18 @@ import { import { resolveCodexProviderWebSearchSupport } from "./provider-capabilities.js"; import { prewarmCodexAttemptClient } from "./run-attempt-client-prewarm.js"; import type { CodexAttemptConnection } from "./run-attempt-connection.js"; +import { canResolveScheduledConfiguredMcpCreatorAuthority } from "./scheduled-configured-mcp-authority.js"; import { resolveCodexAppServerThreadModelSelection } from "./thread-lifecycle.js"; import { resolveCodexWebSearchPlan } from "./web-search.js"; +function resolveCodexAttemptBundleManifestRegistry( + preparedModelRuntime: EmbeddedRunAttemptParams["preparedModelRuntime"], +) { + const metadataSnapshot = preparedModelRuntime?.metadataSnapshot; + // Scoped snapshots are partial views and cannot replace complete bundle discovery. + return metadataSnapshot?.pluginIds === undefined ? metadataSnapshot?.manifestRegistry : undefined; +} + export async function prepareCodexAttemptRuntime(connection: CodexAttemptConnection) { const { params, @@ -138,14 +148,51 @@ export async function prepareCodexAttemptRuntime(connection: CodexAttemptConnect ? undefined : resolveCodexAppServerFallbackApiKeyCacheKey({ startOptions: appServer.start }); preDynamicStartupStages.mark("auth-cache"); + const codexMcpToolOverrides = resolveCodexMcpToolOverridesForAgent(params.config, { + agentId: sessionAgentId, + toolOverrides: params.toolOverrides, + }); + const bundleManifestRegistry = resolveCodexAttemptBundleManifestRegistry( + params.preparedModelRuntime, + ); const bundleMcpThreadConfig = await loadCodexBundleMcpThreadConfig({ workspaceDir: effectiveWorkspace, cfg: params.config, toolsEnabled: usesSupervisionConnection || supportsModelTools(params.model), disableTools: params.disableTools, toolsAllow: params.toolsAllow, - toolOverrides: params.toolOverrides, + manifestRegistry: bundleManifestRegistry, + toolOverrides: codexMcpToolOverrides, }); + const authenticatedScheduledMode = + params.trigger === "cron" && + params.scheduledToolPolicy !== undefined && + Array.isArray(params.toolsAllow); + const ownsScheduledConfiguredMcpSurface = + authenticatedScheduledMode && + (bundleMcpThreadConfig.staticServerNames.length > 0 || + mutable.startupBinding?.configuredMcpOwnershipVersion === 1); + const mayResolveScheduledConfiguredMcpCreatorAuthority = + !authenticatedScheduledMode && + canResolveScheduledConfiguredMcpCreatorAuthority({ + trigger: params.trigger, + connectionClass: appServer.connectionClass, + bindingKind: connection.bindingIdentity.kind, + bindingSessionKey: + connection.bindingIdentity.kind === "session" + ? connection.bindingIdentity.sessionKey + : undefined, + sessionKey: params.sessionKey, + usesSupervisionConnection, + preservesNativeModel: mutable.startupBinding?.preserveNativeModel === true, + senderIsOwner: params.senderIsOwner, + senderId: params.senderId, + inputProvenance: params.inputProvenance, + trustedInternalHandoff: params.trustedInternalHandoff, + spawnedBy: params.spawnedBy, + scheduledToolPolicy: params.scheduledToolPolicy, + hasStaticConfiguredMcp: bundleMcpThreadConfig.staticServerNames.length > 0, + }); preDynamicStartupStages.mark("bundle-mcp"); const sandboxExecServerEnabled = isCodexSandboxExecServerEnabled(pluginConfig); const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface( @@ -209,6 +256,12 @@ export async function prepareCodexAttemptRuntime(connection: CodexAttemptConnect startupAuthAccountCacheKey, startupEnvApiKeyCacheKey, bundleMcpThreadConfig, + bundleManifestRegistry, + authenticatedScheduledMode, + ownsScheduledConfiguredMcpSurface, + canResolveScheduledConfiguredMcpCreatorAuthority: + mayResolveScheduledConfiguredMcpCreatorAuthority, + codexMcpToolOverrides, sandboxExecServerEnabled, nativeToolSurfaceEnabled, nativeProviderWebSearchSupport, diff --git a/extensions/codex/src/app-server/run-attempt-start.ts b/extensions/codex/src/app-server/run-attempt-start.ts index 92fc6a14d13d..f4d3fba2d8d3 100644 --- a/extensions/codex/src/app-server/run-attempt-start.ts +++ b/extensions/codex/src/app-server/run-attempt-start.ts @@ -8,6 +8,7 @@ import { withCodexAppServerFastModeServiceTier, } from "./run-attempt-lifecycle.js"; import type { CodexAttemptResources } from "./run-attempt-resources.js"; +import { joinPresentSections } from "./run-attempt-state.js"; import { recordCodexTrajectoryContext } from "./trajectory.js"; export async function startCodexAttemptRuntime(resources: CodexAttemptResources) { @@ -43,6 +44,10 @@ export async function startCodexAttemptRuntime(resources: CodexAttemptResources) sandboxExecServerEnabled, } = runtime; const { toolBridge, toolState } = attemptTools; + const developerInstructions = joinPresentSections( + turnState.promptBuild.developerInstructions, + attemptTools.scheduledConfiguredMcp?.diagnosticNotice, + ); const { params, attemptClientFactory, @@ -94,9 +99,10 @@ export async function startCodexAttemptRuntime(resources: CodexAttemptResources) dynamicTools: toolBridge.specs, persistentWebSearchAllowed: toolState.persistentWebSearchAllowed, webSearchAllowed: toolState.webSearchAllowed, - developerInstructions: turnState.promptBuild.developerInstructions, + developerInstructions, buildFinalConfigPatch: buildNativeHookRelayFinalConfigPatch, bundleMcpThreadConfig, + configuredMcpOwnershipVersion: attemptTools.configuredMcpOwnershipVersion, nativeToolSurfaceEnabled, nativeProviderWebSearchSupport, sandboxExecServerEnabled, @@ -200,7 +206,10 @@ export async function startCodexAttemptRuntime(resources: CodexAttemptResources) recordCodexTrajectoryContext(trajectoryRecorder, { attempt: params, cwd: effectiveCwd, - developerInstructions: buildRenderedCodexDeveloperInstructions(), + developerInstructions: joinPresentSections( + buildRenderedCodexDeveloperInstructions(), + attemptTools.scheduledConfiguredMcp?.diagnosticNotice, + ), prompt: turnState.codexTurnPromptText, tools: toolBridge.availableSpecs, }); diff --git a/extensions/codex/src/app-server/run-attempt-tool-setup.ts b/extensions/codex/src/app-server/run-attempt-tool-setup.ts index 88b51d7aa5ba..043364199a07 100644 --- a/extensions/codex/src/app-server/run-attempt-tool-setup.ts +++ b/extensions/codex/src/app-server/run-attempt-tool-setup.ts @@ -4,6 +4,11 @@ import { materializeRequesterScopedMcpToolsForHarnessRun, resolveAgentDir, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + captureFinalCodexCronCreatorToolAllowlist, + materializeStaticMcpToolsForScheduledHarnessRun, +} from "openclaw/plugin-sdk/codex-mcp-projection"; +import { shouldAutoApproveCodexAppServerApprovals } from "./config.js"; import { buildDynamicTools, formatCodexDynamicToolBuildStageSummary, @@ -14,20 +19,32 @@ import { filterCodexDynamicTools, resolveCodexDynamicToolsLoadingForRuntime, } from "./dynamic-tool-profile.js"; -import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; +import { + createCodexDynamicToolBridge, + projectCodexExecutableDynamicTools, +} from "./dynamic-tools.js"; import { emitCodexAppServerEvent } from "./run-attempt-lifecycle.js"; import type { CodexAttemptRuntime } from "./run-attempt-runtime.js"; import { resolveCodexDynamicToolDirectNames } from "./run-attempt-tools.js"; +function isAuthorityResolutionOperationAbort(error: unknown, signal: AbortSignal | undefined) { + return signal?.aborted === true && error === signal.reason; +} + export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { const { connection, bundleMcpThreadConfig, + bundleManifestRegistry, runtimeParams, effectiveRuntimeModelId, nativeToolSurfaceEnabled, nativeProviderWebSearchSupport, hookChannelId, + codexMcpToolOverrides, + authenticatedScheduledMode, + ownsScheduledConfiguredMcpSurface, + canResolveScheduledConfiguredMcpCreatorAuthority, } = runtime; const { params, @@ -108,6 +125,23 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { frameToolCallId?: string; frameImageIdentity?: string; } = { value: 0 }; + const cronCreatorToolAllowlist: Array = []; + const cronCreatorToolAllowlistCaptureRef: { + value?: { version: 1; source: "final-executable-surface" }; + } = {}; + let toolBridge: ReturnType | undefined; + let creatorAuthorityPromise: + | Promise<{ + tools: readonly (string | { name: string; pluginId?: string })[]; + provenance: { version: 1; source: "final-executable-surface" }; + }> + | undefined; + let resolveCreatorAuthorityImpl: + | ((options?: { signal?: AbortSignal }) => Promise<{ + tools: readonly (string | { name: string; pluginId?: string })[]; + provenance: { version: 1; source: "final-executable-surface" }; + }>) + | undefined; const commonToolParams = { params: dynamicToolParams, resolvedWorkspace, @@ -121,6 +155,12 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { sessionAgentId, pluginConfig, profilerEnabled, + ...(params.cronCreatorAuthorityUnavailableReason === "queued-local-operator" && + bundleMcpThreadConfig.staticServerNames.length > 0 + ? { + cronCreatorAuthorityUnavailableReason: "queued-local-operator-configured-mcp" as const, + } + : {}), onYieldDetected: () => { toolState.yieldDetected = true; }, @@ -128,9 +168,37 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { void emitCodexAppServerEvent(params, event); }, computerContextEpoch, + ...(canResolveScheduledConfiguredMcpCreatorAuthority + ? { + resolveCronCreatorToolAuthority: (options?: { signal?: AbortSignal }) => { + if (!resolveCreatorAuthorityImpl) { + throw new Error("configured MCP authority resolver was invoked before tool setup"); + } + options?.signal?.throwIfAborted(); + if (creatorAuthorityPromise) { + return creatorAuthorityPromise; + } + const pending = resolveCreatorAuthorityImpl(options); + creatorAuthorityPromise = pending; + void pending.catch((error: unknown) => { + // A tool-call timeout does not poison later cron mutations in the + // same live turn. Substantive discovery/auth/policy failures stay cached. + if ( + creatorAuthorityPromise === pending && + isAuthorityResolutionOperationAbort(error, options?.signal) + ) { + creatorAuthorityPromise = undefined; + } + }); + return pending; + }, + } + : {}), }; const tools = await buildDynamicTools({ ...commonToolParams, + cronCreatorToolAllowlistRef: cronCreatorToolAllowlist, + cronCreatorToolAllowlistCaptureRef, onPersistentWebSearchPolicyResolved: (allowed) => { toolState.persistentWebSearchAllowed = allowed; }, @@ -144,89 +212,117 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { ignoreDisableMessageTool: true, ignoreRuntimePlan: true, }); + const policyContext = { + config: params.config, + sessionKey: sandboxSessionKey, + runSessionKey: + params.sessionKey && params.sessionKey !== sandboxSessionKey ? params.sessionKey : undefined, + sessionId: params.sessionId, + runId: params.runId, + agentId: sessionAgentId, + agentDir: agentDir ?? resolveAgentDir(params.config ?? {}, sessionAgentId), + agentAccountId: params.agentAccountId, + messageProvider: params.messageProvider ?? params.messageChannel, + messageChannel: params.messageChannel, + chatType: params.chatType, + messageTo: params.messageTo, + messageThreadId: params.messageThreadId, + currentChannelId: params.currentChannelId, + currentMessagingTarget: params.currentMessagingTarget, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + memberRoleIds: params.memberRoleIds, + spawnedBy: params.spawnedBy, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + senderIsOwner: params.senderIsOwner, + modelProvider: params.provider, + modelId: params.modelId, + modelApi: params.model.api, + modelContextWindowTokens: params.model.contextWindow, + modelHasVision: params.model.input?.includes("image") ?? false, + workspaceDir: effectiveWorkspace, + cwd: effectiveCwd ?? effectiveWorkspace, + sandboxToolPolicy: sandbox?.tools, + inputProvenance: params.inputProvenance, + trustedInternalHandoff: params.trustedInternalHandoff, + scheduledToolPolicy: params.scheduledToolPolicy, + }; + const reservedToolNames = [ + ...tools.map((tool) => tool.name), + ...registeredTools.map((tool) => tool.name), + ]; + const turnSourceChannel = params.messageChannel ?? params.messageProvider; + const turnSourceTo = params.currentMessagingTarget ?? params.currentChannelId; + const requester = { + ...(turnSourceChannel ? { channel: turnSourceChannel } : {}), + ...(params.agentAccountId ? { accountId: params.agentAccountId } : {}), + ...(params.senderId ? { senderId: params.senderId } : {}), + ...(params.senderIsOwner !== undefined ? { senderIsOwner: params.senderIsOwner } : {}), + ...(params.memberRoleIds?.length ? { roleIds: [...params.memberRoleIds] } : {}), + }; + const hasRequester = Object.keys(requester).length > 0; + const scheduledConfiguredMcp = ownsScheduledConfiguredMcpSurface + ? await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + workspaceDir: effectiveWorkspace, + agentDir: policyContext.agentDir, + cfg: params.config, + manifestRegistry: bundleManifestRegistry, + reservedToolNames, + toolsAllow: params.toolsAllow, + toolOverrides: codexMcpToolOverrides, + autoApproveCodexAppServerApprovals: shouldAutoApproveCodexAppServerApprovals( + connection.appServer, + ), + policyContext, + warn: (message) => embeddedAgentLog.warn(message), + }) + : undefined; // Requester-scoped MCP: dynamic tools on a shared thread (never harness-native MCP). // Specs come from the session advertised-catalog cache so fingerprints stay stable. - const scopedMcpTools = await materializeRequesterScopedMcpToolsForHarnessRun({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - workspaceDir: effectiveWorkspace, - agentDir: agentDir ?? resolveAgentDir(params.config ?? {}, sessionAgentId), - cfg: params.config, - requesterSenderId: params.senderId, - agentAccountId: params.agentAccountId, - messageChannel: params.messageChannel ?? params.messageProvider, - reservedToolNames: [ - ...tools.map((tool) => tool.name), - ...registeredTools.map((tool) => tool.name), - ], - toolsAllow: params.toolsAllow, - policyContext: { - config: params.config, - sessionKey: sandboxSessionKey, - runSessionKey: - params.sessionKey && params.sessionKey !== sandboxSessionKey - ? params.sessionKey - : undefined, - sessionId: params.sessionId, - runId: params.runId, - agentId: sessionAgentId, - agentDir: agentDir ?? resolveAgentDir(params.config ?? {}, sessionAgentId), - agentAccountId: params.agentAccountId, - messageProvider: params.messageProvider ?? params.messageChannel, - messageChannel: params.messageChannel, - chatType: params.chatType, - messageTo: params.messageTo, - messageThreadId: params.messageThreadId, - currentChannelId: params.currentChannelId, - currentMessagingTarget: params.currentMessagingTarget, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - groupId: params.groupId, - groupChannel: params.groupChannel, - groupSpace: params.groupSpace, - memberRoleIds: params.memberRoleIds, - spawnedBy: params.spawnedBy, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - senderIsOwner: params.senderIsOwner, - modelProvider: params.provider, - modelId: params.modelId, - modelApi: params.model.api, - modelContextWindowTokens: params.model.contextWindow, - modelHasVision: params.model.input?.includes("image") ?? false, - workspaceDir: effectiveWorkspace, - cwd: effectiveCwd ?? effectiveWorkspace, - sandboxToolPolicy: sandbox?.tools, - }, - warn: (message) => embeddedAgentLog.warn(message), - }); - // Restricted dynamic-tool profiles (private QA, exclusion lists) gate scoped - // MCP tools exactly like every other dynamic tool. Filter both lists with the - // same rule so execution and advertised specs stay name-aligned. - const scopedExecutable = scopedMcpTools - ? filterCodexDynamicTools(scopedMcpTools.tools, pluginConfig) - : []; - const scopedAdvertised = scopedMcpTools - ? filterCodexDynamicTools(scopedMcpTools.advertisedTools, pluginConfig) - : []; - const toolsWithScopedMcp = scopedExecutable.length > 0 ? [...tools, ...scopedExecutable] : tools; - const registeredWithScopedMcp = - scopedAdvertised.length > 0 ? [...registeredTools, ...scopedAdvertised] : registeredTools; - const toolBridge = createCodexDynamicToolBridge({ - tools: toolsWithScopedMcp, - registeredTools: registeredWithScopedMcp, - signal: runAbortController.signal, - computerContextEpoch, - loading: resolveCodexDynamicToolsLoadingForRuntime(pluginConfig, effectiveRuntimeModelId, { - connectionClass: connection.appServer.connectionClass, - }), - directToolNames: resolveCodexDynamicToolDirectNames( - params, - isHostScopedAgentToolActive("openclaw"), - ), - hookContext: { + let scopedMcpTools: Awaited> = + undefined; + try { + scopedMcpTools = authenticatedScheduledMode + ? undefined + : await materializeRequesterScopedMcpToolsForHarnessRun({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + workspaceDir: effectiveWorkspace, + agentDir: policyContext.agentDir, + cfg: params.config, + manifestRegistry: bundleManifestRegistry, + requesterSenderId: params.senderId, + agentAccountId: params.agentAccountId, + messageChannel: params.messageChannel ?? params.messageProvider, + reservedToolNames, + toolsAllow: params.toolsAllow, + policyContext, + warn: (message) => embeddedAgentLog.warn(message), + }); + // Restricted dynamic-tool profiles (private QA, exclusion lists) gate scoped + // MCP tools exactly like every other dynamic tool. Filter both lists with the + // same rule so execution and advertised specs stay name-aligned. + const scopedExecutable = filterCodexDynamicTools( + scheduledConfiguredMcp?.tools ?? scopedMcpTools?.tools ?? [], + pluginConfig, + ); + const scopedAdvertised = filterCodexDynamicTools( + scheduledConfiguredMcp?.tools ?? scopedMcpTools?.advertisedTools ?? [], + pluginConfig, + ); + const toolsWithScopedMcp = + scopedExecutable.length > 0 ? [...tools, ...scopedExecutable] : tools; + const registeredWithScopedMcp = + scopedAdvertised.length > 0 ? [...registeredTools, ...scopedAdvertised] : registeredTools; + const hookContext = { agentId: sessionAgentId, config: params.config, contextWindowTokens: params.contextTokenBudget ?? params.model.contextWindow, @@ -247,21 +343,136 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, onToolOutcome: onCodexToolOutcome, allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal, - }, - }); - return { - tools: toolsWithScopedMcp, - registeredTools: registeredWithScopedMcp, - scopedMcpTools, - dynamicToolParams, - computerContextEpoch, - toolBridge, - toolState, - toolOutcomeOrdinals, - suppressedDynamicToolOutcomeOrdinals, - onCodexToolOutcome, - allocateCodexToolOutcomeOrdinal, - }; + trigger: params.trigger, + approvalReviewerDeviceId: params.approvalReviewerDeviceId, + ...(hasRequester ? { requester } : {}), + ...(turnSourceChannel ? { turnSourceChannel } : {}), + ...(turnSourceTo ? { turnSourceTo } : {}), + ...(params.agentAccountId ? { turnSourceAccountId: params.agentAccountId } : {}), + ...(params.currentThreadTs !== undefined + ? { turnSourceThreadId: params.currentThreadTs } + : {}), + }; + toolBridge = createCodexDynamicToolBridge({ + tools: toolsWithScopedMcp, + registeredTools: registeredWithScopedMcp, + signal: runAbortController.signal, + computerContextEpoch, + loading: resolveCodexDynamicToolsLoadingForRuntime(pluginConfig, effectiveRuntimeModelId, { + connectionClass: connection.appServer.connectionClass, + }), + directToolNames: resolveCodexDynamicToolDirectNames( + params, + isHostScopedAgentToolActive("openclaw"), + ), + hookContext, + }); + await captureFinalCodexCronCreatorToolAllowlist( + cronCreatorToolAllowlist, + cronCreatorToolAllowlistCaptureRef, + toolBridge.availableTools, + ); + if ( + !authenticatedScheduledMode && + bundleMcpThreadConfig.staticServerNames.length > 0 && + !canResolveScheduledConfiguredMcpCreatorAuthority + ) { + // Native configured MCP is model-visible but absent from this dynamic-tool list. + // Keep the names for finite intersections, but never certify a partial default cap. + delete cronCreatorToolAllowlistCaptureRef.value; + } + if (canResolveScheduledConfiguredMcpCreatorAuthority) { + resolveCreatorAuthorityImpl = async (options) => { + options?.signal?.throwIfAborted(); + if (!toolBridge) { + throw new Error("configured MCP authority resolver lost the active tool bridge"); + } + const authorityRuntimeId = `cron-authority:${params.runId}`; + let materialized: Awaited< + ReturnType + >; + try { + materialized = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: authorityRuntimeId, + workspaceDir: effectiveWorkspace, + agentDir: policyContext.agentDir, + cfg: params.config, + manifestRegistry: bundleManifestRegistry, + reservedToolNames: toolBridge.availableTools.map((tool) => tool.name), + toolsAllow: params.toolsAllow, + toolOverrides: codexMcpToolOverrides, + autoApproveCodexAppServerApprovals: shouldAutoApproveCodexAppServerApprovals( + connection.appServer, + ), + policyContext, + warn: (message) => embeddedAgentLog.warn(message), + retireSessionRuntimeAfterDispose: true, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Configured MCP discovery failed while resolving inherited automation authority: ${detail}. Retry after the server is available, or provide an explicit finite toolsAllow list containing only currently visible tools; no automation changes were saved.`, + { cause: error }, + ); + } + try { + options?.signal?.throwIfAborted(); + if (materialized.diagnosticNotice) { + throw new Error( + `${materialized.diagnosticNotice} Sign in to the affected MCP server and retry, or provide an explicit finite toolsAllow list containing only currently visible tools. No automation changes were saved.`, + ); + } + const authorityTools: Array = []; + const captureRef: { + value?: { version: 1; source: "final-executable-surface" }; + } = {}; + // Default authority contains model-callable tools only. App-only projections + // gate view callbacks and must never become headless scheduled capability. + const projectedConfiguredMcp = projectCodexExecutableDynamicTools({ + tools: filterCodexDynamicTools(materialized.tools, pluginConfig), + hookContext, + }); + await captureFinalCodexCronCreatorToolAllowlist(authorityTools, captureRef, [ + ...toolBridge.availableTools, + ...projectedConfiguredMcp.availableTools, + ]); + if (!captureRef.value) { + throw new Error("configured MCP authority snapshot did not produce provenance"); + } + options?.signal?.throwIfAborted(); + return Object.freeze({ + tools: Object.freeze(authorityTools.map((entry) => Object.freeze(entry))), + provenance: Object.freeze(captureRef.value), + }); + } finally { + await materialized.dispose(); + } + }; + } + return { + tools: toolsWithScopedMcp, + registeredTools: registeredWithScopedMcp, + scopedMcpTools, + scheduledConfiguredMcp, + configuredMcpOwnershipVersion: ownsScheduledConfiguredMcpSurface ? (1 as const) : undefined, + cronCreatorToolAllowlist, + cronCreatorToolAllowlistCaptureRef, + dynamicToolParams, + computerContextEpoch, + toolBridge, + toolState, + toolOutcomeOrdinals, + suppressedDynamicToolOutcomeOrdinals, + onCodexToolOutcome, + allocateCodexToolOutcomeOrdinal, + }; + } catch (error) { + // Materialized runtimes are attempt-owned only after this function returns. + // Dispose here when filtering, schema projection, or bridge setup fails first. + await scopedMcpTools?.dispose(); + await scheduledConfiguredMcp?.dispose(); + throw error; + } } export type CodexAttemptTools = Awaited>; diff --git a/extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts b/extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts new file mode 100644 index 000000000000..3a06bc32d770 --- /dev/null +++ b/extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts @@ -0,0 +1,644 @@ +import path from "node:path"; +import { openFileBackedSessionManagerForTest } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mcpMocks = vi.hoisted(() => ({ + authorityResolvers: [] as Array< + (options?: { signal?: AbortSignal }) => Promise<{ + tools: readonly (string | { name: string; pluginId?: string })[]; + provenance: { version: 1; source: "final-executable-surface" }; + }> + >, + captureCalls: [] as Array<{ + sourceNames: string[]; + storedNames: string[]; + provenance?: unknown; + }>, + captureRefs: [] as Array<{ + value?: { version: 1; source: "final-executable-surface" }; + }>, + dispose: vi.fn(async () => undefined), + captureFacade: vi.fn(), + staticFacade: vi.fn(), + threadConfigFacade: vi.fn(), + requesterCalls: 0, + requesterParams: [] as Array>, + staticDiagnosticNotice: undefined as string | undefined, + staticFailure: undefined as Error | undefined, + staticFailureGate: undefined as Promise | undefined, + staticCalls: [] as Array>, + staticToolExecutes: [] as ReturnType[], + threadConfigCalls: [] as Array>, +})); + +vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + materializeRequesterScopedMcpToolsForHarnessRun: async ( + ...args: Parameters + ) => { + mcpMocks.requesterCalls += 1; + mcpMocks.requesterParams.push(args[0] as Record); + return undefined; + }, + loadCodexBundleMcpThreadConfig: async ( + ...args: Parameters + ) => { + const params = args[0] as Record; + mcpMocks.threadConfigCalls.push(params); + mcpMocks.threadConfigFacade(params); + const cfg = params.cfg as + | { mcp?: { servers?: Record> } } + | undefined; + const configuredServers = cfg?.mcp?.servers ?? {}; + const staticServerNames = Object.keys(configuredServers).toSorted(); + return { + configPatch: staticServerNames.length > 0 ? { mcp_servers: configuredServers } : undefined, + diagnostics: [], + evaluated: true, + fingerprint: staticServerNames.length > 0 ? "configured-mcp-test-fixture" : undefined, + staticServerNames, + userStaticServerNames: staticServerNames, + }; + }, + }; +}); + +vi.mock("openclaw/plugin-sdk/codex-mcp-projection", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runWithCronCreatorAuthorityResolver: (params: { + resolve: (options?: { signal?: AbortSignal }) => Promise<{ + tools: readonly (string | { name: string; pluginId?: string })[]; + provenance: { version: 1; source: "final-executable-surface" }; + }>; + run: () => T; + }) => { + mcpMocks.authorityResolvers.push(params.resolve); + return params.run(); + }, + materializeStaticMcpToolsForScheduledHarnessRun: async (params: Record) => { + mcpMocks.staticCalls.push(params); + mcpMocks.staticFacade(params); + if (mcpMocks.staticFailure) { + await mcpMocks.staticFailureGate; + throw mcpMocks.staticFailure; + } + const execute = vi.fn(async () => ({ + content: [{ type: "text" as const, text: "initial-result" }], + details: { status: "ok" }, + })); + mcpMocks.staticToolExecutes.push(execute); + return { + tools: mcpMocks.staticDiagnosticNotice + ? [] + : [ + { + name: "fake__show", + description: "Show the configured MCP fixture result.", + parameters: { type: "object", properties: {} }, + execute, + }, + ], + appTools: [ + { + name: "fake__app_only", + description: "App-view-only configured MCP fixture.", + parameters: { type: "object", properties: {} }, + execute, + }, + ], + ...(mcpMocks.staticDiagnosticNotice + ? { diagnosticNotice: mcpMocks.staticDiagnosticNotice } + : {}), + dispose: async () => { + await mcpMocks.dispose(); + }, + }; + }, + captureFinalCodexCronCreatorToolAllowlist: async ( + ...args: Parameters + ) => { + const [target, captureRef, tools] = args; + mcpMocks.captureRefs.push(captureRef); + mcpMocks.captureFacade(target, captureRef, tools); + target.length = 0; + for (const tool of tools) { + if ( + !target.some((entry) => (typeof entry === "string" ? entry : entry.name) === tool.name) + ) { + target.push({ name: tool.name }); + } + } + captureRef.value = { version: 1, source: "final-executable-surface" }; + mcpMocks.captureCalls.push({ + sourceNames: tools.map((tool) => tool.name).toSorted(), + storedNames: target + .map((entry) => (typeof entry === "string" ? entry : entry.name)) + .toSorted(), + provenance: captureRef.value, + }); + }, + }; +}); + +import { + assistantMessage, + createParams, + createCodexRuntimePlanFixture, + createStartedThreadHarness, + runCodexAppServerAttempt, + setCodexTestModelSupportsTools, + setupRunAttemptTestHooks, + tempDir, + userMessage, +} from "./run-attempt-test-harness.js"; +import { + readCodexAppServerBinding, + registerCodexTestSessionIdentity, + writeCodexAppServerBinding, +} from "./session-binding.test-helpers.js"; + +setupRunAttemptTestHooks(); + +beforeEach(() => { + mcpMocks.authorityResolvers.length = 0; + mcpMocks.captureCalls.length = 0; + mcpMocks.captureRefs.length = 0; + mcpMocks.staticCalls.length = 0; + mcpMocks.staticToolExecutes.length = 0; + mcpMocks.requesterCalls = 0; + mcpMocks.requesterParams.length = 0; + mcpMocks.threadConfigCalls.length = 0; + mcpMocks.staticDiagnosticNotice = undefined; + mcpMocks.staticFailure = undefined; + mcpMocks.staticFailureGate = undefined; + mcpMocks.dispose.mockClear(); + mcpMocks.captureFacade.mockClear(); + mcpMocks.staticFacade.mockClear(); + mcpMocks.threadConfigFacade.mockClear(); +}); + +function configureFakeMcp(params: ReturnType): void { + setCodexTestModelSupportsTools(params, true); + params.cleanupBundleMcpOnRunEnd = true; + params.runtimePlan = createCodexRuntimePlanFixture(); + params.preparedModelRuntime = { + metadataSnapshot: { manifestRegistry: { plugins: [] } }, + } as never; + params.config = { + ...params.config, + mcp: { + servers: { + fake: { + command: process.execPath, + args: [path.resolve("scripts/e2e/mcp-app-conformance-server.mjs")], + codex: { defaultToolsApprovalMode: "prompt" }, + }, + }, + }, + }; +} + +describe("runCodexAppServerAttempt configured MCP ownership", () => { + it("does not replace bundle discovery with partial prepared plugin metadata", async () => { + const sessionFile = path.join(tempDir, "session-partial-manifest-registry.jsonl"); + const params = createParams(sessionFile, path.join(tempDir, "workspace-partial-registry")); + configureFakeMcp(params); + const manifestRegistry = { plugins: [] }; + params.preparedModelRuntime = { + metadataSnapshot: { manifestRegistry, pluginIds: ["codex"] }, + } as never; + + const harness = createStartedThreadHarness(); + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + + expect(mcpMocks.threadConfigCalls[0]?.manifestRegistry).toBeUndefined(); + + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await expect(run).resolves.toBeDefined(); + }); + + it("projects scheduled static MCP dynamically under the exact stored cap", async () => { + const sessionFile = path.join(tempDir, "session-scheduled-static-mcp.jsonl"); + const params = createParams(sessionFile, path.join(tempDir, "workspace-scheduled-static-mcp")); + configureFakeMcp(params); + params.trigger = "cron"; + params.toolsAllow = ["*"]; + params.scheduledToolPolicy = { version: 1, mode: "trusted" }; + + const harness = createStartedThreadHarness(); + const run = runCodexAppServerAttempt(params, { + pluginConfig: { + appServer: { approvalPolicy: "never", sandbox: "danger-full-access" }, + }, + }); + await harness.waitForMethod("turn/start"); + + const threadStart = harness.requests.find((request) => request.method === "thread/start") + ?.params as { config?: Record; dynamicTools?: unknown } | undefined; + expect(mcpMocks.requesterCalls).toBe(0); + expect(mcpMocks.threadConfigCalls[0]?.manifestRegistry).toBe( + params.preparedModelRuntime?.metadataSnapshot.manifestRegistry, + ); + expect(mcpMocks.threadConfigFacade).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir: params.workspaceDir, + cfg: params.config, + toolsAllow: ["*"], + manifestRegistry: params.preparedModelRuntime?.metadataSnapshot.manifestRegistry, + }), + ); + expect(mcpMocks.staticCalls).toHaveLength(1); + expect(threadStart?.config).not.toHaveProperty("mcp_servers"); + expect(JSON.stringify(threadStart?.config ?? {})).not.toContain("fake-mcp"); + expect(JSON.stringify(threadStart?.dynamicTools ?? [])).toContain("fake__show"); + expect(mcpMocks.staticCalls[0]).not.toHaveProperty("requesterSenderId"); + expect(mcpMocks.staticCalls[0]).toMatchObject({ + toolsAllow: ["*"], + manifestRegistry: params.preparedModelRuntime?.metadataSnapshot.manifestRegistry, + autoApproveCodexAppServerApprovals: true, + }); + expect(mcpMocks.staticFacade).toHaveBeenCalledWith(mcpMocks.staticCalls[0]); + + const toolResult = await harness.handleServerRequest({ + id: "request-fake-ping", + method: "item/tool/call", + params: { + threadId: "thread-1", + turnId: "turn-1", + callId: "call-fake-ping", + namespace: null, + tool: "fake__show", + arguments: {}, + }, + }); + expect(toolResult).toMatchObject({ success: true }); + expect(JSON.stringify(toolResult)).toContain("initial-result"); + expect(mcpMocks.staticToolExecutes[0]).toHaveBeenCalledOnce(); + + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + expect(mcpMocks.captureCalls).toHaveLength(1); + expect(mcpMocks.captureCalls[0]).toMatchObject({ + sourceNames: expect.arrayContaining(["fake__show"]), + storedNames: expect.arrayContaining(["fake__show"]), + provenance: { version: 1, source: "final-executable-surface" }, + }); + expect(mcpMocks.captureCalls[0]!.storedNames).toEqual(mcpMocks.captureCalls[0]!.sourceNames); + expect(mcpMocks.captureFacade).toHaveBeenCalledOnce(); + expect(mcpMocks.dispose).toHaveBeenCalledOnce(); + const binding = await readCodexAppServerBinding(sessionFile); + expect(binding).toMatchObject({ configuredMcpOwnershipVersion: 1 }); + expect(binding).not.toHaveProperty("mcpServersFingerprint"); + expect(binding).not.toHaveProperty("userMcpServersFingerprint"); + }); + + it("preserves bounded canonical continuity when scheduled MCP replaces ordinary ownership", async () => { + const sessionFile = path.join(tempDir, "session-scheduled-mcp-ownership-continuity.jsonl"); + const workspaceDir = path.join(tempDir, "workspace-scheduled-mcp-ownership-continuity"); + const cutoff = Date.now(); + registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-ordinary", + cwd: workspaceDir, + model: "gpt-5.4-codex", + modelProvider: "openai", + dynamicToolsFingerprint: "[]", + mcpServersFingerprint: "configured-mcp-test-fixture", + historyCoveredThrough: new Date(cutoff).toISOString(), + }); + const sessionManager = openFileBackedSessionManagerForTest(sessionFile, { + sessionId: "session-1", + }); + sessionManager.appendMessage(userMessage("ordinary-thread covered context", cutoff - 1_000)); + for (let index = 0; index < 10; index += 1) { + sessionManager.appendMessage( + assistantMessage( + `scheduled ownership continuity block ${index}: ${"x".repeat(128_000)}`, + cutoff + 2_000 + index, + ), + ); + } + sessionManager.appendMessage(userMessage("new scheduled ownership question", cutoff + 20_000)); + sessionManager.appendMessage( + assistantMessage("recent scheduled ownership answer", cutoff + 21_000), + ); + + const params = createParams(sessionFile, workspaceDir); + configureFakeMcp(params); + params.prompt = "continue after the scheduled ownership transition"; + params.trigger = "cron"; + params.toolsAllow = ["*"]; + params.scheduledToolPolicy = { version: 1, mode: "trusted" }; + const harness = createStartedThreadHarness(async (method) => { + if (method === "thread/start") { + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-ordinary", + }); + } + return undefined; + }); + + const run = runCodexAppServerAttempt(params, { + pluginConfig: { + appServer: { approvalPolicy: "never", sandbox: "danger-full-access" }, + }, + }); + await harness.waitForMethod("turn/start"); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + expect(harness.requests.map((request) => request.method)).toContain("thread/start"); + expect(harness.requests.map((request) => request.method)).not.toContain("thread/resume"); + const turnStart = harness.requests.find((request) => request.method === "turn/start"); + const inputText = + (turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ?? + ""; + expect(inputText.length).toBeLessThanOrEqual(1 << 20); + expect(inputText).toContain("OpenClaw assembled context for this turn:"); + expect(inputText).toContain("new scheduled ownership question"); + expect(inputText).toContain("recent scheduled ownership answer"); + expect(inputText).toContain("Current user request:"); + expect(inputText).toContain("continue after the scheduled ownership transition"); + expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({ + threadId: "thread-1", + configuredMcpOwnershipVersion: 1, + }); + }); + + it("keeps ordinary configured MCP native without probing or stamping its inventory", async () => { + const sessionFile = path.join(tempDir, "session-native-mcp-auth-failure.jsonl"); + const params = createParams( + sessionFile, + path.join(tempDir, "workspace-native-mcp-auth-failure"), + ); + configureFakeMcp(params); + + const harness = createStartedThreadHarness(async (method) => { + if (method === "mcpServerStatus/list") { + return { + data: [ + { + name: "fake", + serverInfo: null, + authStatus: "notLoggedIn", + tools: {}, + }, + ], + nextCursor: null, + }; + } + return undefined; + }); + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await expect(run).resolves.toBeDefined(); + + expect(harness.requests.map((request) => request.method)).not.toContain("mcpServerStatus/list"); + expect(mcpMocks.staticCalls).toHaveLength(0); + expect(mcpMocks.requesterParams[0]?.manifestRegistry).toBe( + params.preparedModelRuntime?.metadataSnapshot.manifestRegistry, + ); + expect(mcpMocks.captureCalls).toHaveLength(1); + expect(mcpMocks.captureCalls[0]!.storedNames).not.toContain("fake__show"); + }); + + it("captures a restricted ordinary turn without inventing intentionally disabled native MCP", async () => { + const sessionFile = path.join(tempDir, "session-native-mcp-restricted.jsonl"); + const params = createParams(sessionFile, path.join(tempDir, "workspace-native-mcp-restricted")); + configureFakeMcp(params); + params.toolsAllow = ["cron", "fake__show"]; + + const harness = createStartedThreadHarness(); + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await expect(run).resolves.toBeDefined(); + + expect(harness.requests.map((request) => request.method)).not.toContain("mcpServerStatus/list"); + expect(mcpMocks.staticCalls).toHaveLength(0); + expect(mcpMocks.captureCalls).toHaveLength(1); + expect(mcpMocks.captureCalls[0]!.storedNames).not.toContain("fake__show"); + expect(mcpMocks.captureCalls[0]!.provenance).toEqual({ + version: 1, + source: "final-executable-surface", + }); + }); + + it("withholds final provenance when a sender-attributed turn cannot snapshot native MCP", async () => { + const sessionFile = path.join(tempDir, "session-sender-attributed-mcp.jsonl"); + const params = createParams(sessionFile, path.join(tempDir, "workspace-sender-attributed-mcp")); + configureFakeMcp(params); + params.trigger = "user"; + params.senderIsOwner = true; + params.senderId = "external-sender"; + + const harness = createStartedThreadHarness(); + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await expect(run).resolves.toBeDefined(); + + expect(mcpMocks.authorityResolvers).toHaveLength(0); + expect(mcpMocks.captureRefs).toHaveLength(1); + expect(mcpMocks.captureRefs[0]!.value).toBeUndefined(); + expect(mcpMocks.captureCalls[0]!.storedNames).not.toContain("fake__show"); + }); + + it("lazily snapshots configured MCP through the local-operator resolver without replacing native MCP", async () => { + const sessionFile = path.join(tempDir, "session-local-operator-mutation.jsonl"); + const params = createParams( + sessionFile, + path.join(tempDir, "workspace-local-operator-mutation"), + ); + configureFakeMcp(params); + params.trigger = "user"; + params.senderIsOwner = true; + + const harness = createStartedThreadHarness(); + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + const threadStart = harness.requests.find((request) => request.method === "thread/start") + ?.params as { config?: Record; dynamicTools?: unknown } | undefined; + expect(JSON.stringify(threadStart?.config ?? {})).toContain("fake"); + expect(JSON.stringify(threadStart?.dynamicTools ?? [])).not.toContain("fake__show"); + expect(mcpMocks.staticCalls).toHaveLength(0); + + expect(mcpMocks.authorityResolvers).toHaveLength(2); + const authority = await mcpMocks.authorityResolvers[0]!(); + expect(authority.provenance).toEqual({ version: 1, source: "final-executable-surface" }); + expect( + authority.tools.map((entry) => (typeof entry === "string" ? entry : entry.name)), + ).toContain("fake__show"); + expect( + authority.tools.map((entry) => (typeof entry === "string" ? entry : entry.name)), + ).not.toContain("fake__app_only"); + expect(mcpMocks.staticCalls).toHaveLength(1); + expect(mcpMocks.staticCalls[0]).toMatchObject({ + sessionId: `cron-authority:${params.runId}`, + manifestRegistry: params.preparedModelRuntime?.metadataSnapshot.manifestRegistry, + retireSessionRuntimeAfterDispose: true, + }); + expect(mcpMocks.staticCalls[0]).not.toHaveProperty("sessionKey"); + expect(mcpMocks.captureCalls.at(-1)?.storedNames).toContain("fake__show"); + expect(mcpMocks.dispose).toHaveBeenCalledOnce(); + + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await expect(run).resolves.toBeDefined(); + }); + + it("offers explicit finite tools when inherited configured MCP discovery is incomplete", async () => { + const sessionFile = path.join(tempDir, "session-local-operator-incomplete-mcp.jsonl"); + const params = createParams( + sessionFile, + path.join(tempDir, "workspace-local-operator-incomplete-mcp"), + ); + configureFakeMcp(params); + params.trigger = "user"; + params.senderIsOwner = true; + mcpMocks.staticDiagnosticNotice = + "Configured MCP is incomplete for this scheduled run: fake: authentication required."; + + const harness = createStartedThreadHarness(); + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + + await expect(mcpMocks.authorityResolvers[0]!()).rejects.toThrow( + "provide an explicit finite toolsAllow list containing only currently visible tools", + ); + expect(mcpMocks.dispose).toHaveBeenCalledOnce(); + + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await expect(run).resolves.toBeDefined(); + }); + + it("rematerializes after one cron operation aborts pending materialization", async () => { + const sessionFile = path.join(tempDir, "session-local-operator-aborted-mutation.jsonl"); + const params = createParams( + sessionFile, + path.join(tempDir, "workspace-local-operator-aborted-mutation"), + ); + configureFakeMcp(params); + params.trigger = "user"; + params.senderIsOwner = true; + + const harness = createStartedThreadHarness(); + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + const resolver = mcpMocks.authorityResolvers[0]!; + const firstOperation = new AbortController(); + const firstResolution = resolver({ signal: firstOperation.signal }); + firstOperation.abort(new Error("first cron call timed out")); + + await expect(firstResolution).rejects.toThrow("first cron call timed out"); + const secondResolution = await resolver({ signal: new AbortController().signal }); + + expect( + secondResolution.tools.map((entry) => (typeof entry === "string" ? entry : entry.name)), + ).toContain("fake__show"); + expect(mcpMocks.staticCalls).toHaveLength(2); + expect(mcpMocks.dispose).toHaveBeenCalledTimes(2); + + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await expect(run).resolves.toBeDefined(); + }); + + it("shares one configured-MCP materialization across concurrent active cron operations", async () => { + const sessionFile = path.join(tempDir, "session-local-operator-concurrent-mutation.jsonl"); + const params = createParams( + sessionFile, + path.join(tempDir, "workspace-local-operator-concurrent-mutation"), + ); + configureFakeMcp(params); + params.trigger = "user"; + params.senderIsOwner = true; + + const harness = createStartedThreadHarness(); + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + const resolver = mcpMocks.authorityResolvers[0]!; + const firstResolution = resolver({ signal: new AbortController().signal }); + const secondResolution = resolver({ signal: new AbortController().signal }); + + expect(secondResolution).toBe(firstResolution); + const [first, second] = await Promise.all([firstResolution, secondResolution]); + expect(second).toBe(first); + expect(mcpMocks.staticCalls).toHaveLength(1); + expect(mcpMocks.dispose).toHaveBeenCalledOnce(); + + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await expect(run).resolves.toBeDefined(); + }); + + it("retains an unrelated cached timeout when its operation signal aborts concurrently", async () => { + const sessionFile = path.join(tempDir, "session-local-operator-unrelated-timeout.jsonl"); + const params = createParams( + sessionFile, + path.join(tempDir, "workspace-local-operator-unrelated-timeout"), + ); + configureFakeMcp(params); + params.trigger = "user"; + params.senderIsOwner = true; + let releaseFailure!: () => void; + mcpMocks.staticFailureGate = new Promise((resolve) => { + releaseFailure = resolve; + }); + mcpMocks.staticFailure = Object.assign(new Error("configured MCP materialization timed out"), { + name: "TimeoutError", + }); + + const harness = createStartedThreadHarness(); + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + const resolver = mcpMocks.authorityResolvers[0]!; + const operation = new AbortController(); + const firstResolution = resolver({ signal: operation.signal }); + operation.abort(new Error("cron tool call was cancelled")); + releaseFailure(); + + await expect(firstResolution).rejects.toThrow( + "provide an explicit finite toolsAllow list containing only currently visible tools", + ); + const secondResolution = resolver({ signal: new AbortController().signal }); + expect(secondResolution).toBe(firstResolution); + await expect(secondResolution).rejects.toThrow("configured MCP materialization timed out"); + expect(mcpMocks.staticCalls).toHaveLength(1); + + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await expect(run).resolves.toBeDefined(); + }); + + it("keeps static discovery failures visible without stamping inherited authority", async () => { + const sessionFile = path.join(tempDir, "session-static-mcp-discovery-failure.jsonl"); + const params = createParams( + sessionFile, + path.join(tempDir, "workspace-static-mcp-discovery-failure"), + ); + configureFakeMcp(params); + params.trigger = "cron"; + params.toolsAllow = ["*"]; + params.scheduledToolPolicy = { version: 1, mode: "trusted" }; + mcpMocks.staticDiagnosticNotice = + "Configured MCP is incomplete for this scheduled run: fake: authentication required. " + + "Do not claim MCP-backed work succeeded; report this blocker to the operator."; + + const harness = createStartedThreadHarness(); + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + + const threadStart = harness.requests.find((request) => request.method === "thread/start"); + expect(JSON.stringify(threadStart?.params)).toContain("fake: authentication required"); + expect(mcpMocks.captureCalls).toHaveLength(1); + expect(mcpMocks.captureCalls[0]!.storedNames).not.toContain("fake__show"); + + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await expect(run).resolves.toBeDefined(); + expect(mcpMocks.dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/extensions/codex/src/app-server/scheduled-configured-mcp-authority.ts b/extensions/codex/src/app-server/scheduled-configured-mcp-authority.ts new file mode 100644 index 000000000000..2d1e7b180b13 --- /dev/null +++ b/extensions/codex/src/app-server/scheduled-configured-mcp-authority.ts @@ -0,0 +1,36 @@ +import { isIncognitoSessionKey } from "../incognito-session.js"; + +/** Limits fresh scheduled-authority capture to authenticated local durable operator turns. */ +export function canResolveScheduledConfiguredMcpCreatorAuthority(params: { + trigger?: string; + connectionClass: string; + bindingKind: string; + bindingSessionKey?: string; + sessionKey?: string; + usesSupervisionConnection: boolean; + preservesNativeModel: boolean; + senderIsOwner?: boolean; + senderId?: string | null; + inputProvenance?: unknown; + trustedInternalHandoff?: unknown; + spawnedBy?: string | null; + scheduledToolPolicy?: unknown; + hasStaticConfiguredMcp: boolean; +}): boolean { + return ( + params.trigger === "user" && + params.connectionClass === "local-loopback" && + params.bindingKind === "session" && + Boolean(params.bindingSessionKey) && + !isIncognitoSessionKey(params.sessionKey) && + !params.usesSupervisionConnection && + !params.preservesNativeModel && + params.senderIsOwner === true && + !params.senderId && + params.inputProvenance === undefined && + params.trustedInternalHandoff === undefined && + !params.spawnedBy && + params.scheduledToolPolicy === undefined && + params.hasStaticConfiguredMcp + ); +} diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index 1ea670f8d13d..c824ffbe010c 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -97,6 +97,70 @@ describe("Codex app-server binding store", () => { }); }); + it("replaces only the exact ordinary thread owner", async () => { + const { state } = createStateStore(); + const store = createCodexAppServerBindingStore(state); + const identity = { kind: "session" as const, agentId: "main", sessionId: "session-cas" }; + await store.mutate(identity, { + kind: "set", + binding: { threadId: "thread-old", cwd: "/repo" }, + }); + + await expect( + store.mutate(identity, { + kind: "replace-thread", + expectedThreadId: "thread-stale", + binding: { threadId: "thread-new", cwd: "/repo" }, + }), + ).resolves.toBe(false); + await expect(store.read(identity)).resolves.toMatchObject({ threadId: "thread-old" }); + + await expect( + store.mutate(identity, { + kind: "replace-thread", + expectedThreadId: "thread-old", + binding: { threadId: "thread-new", cwd: "/repo" }, + }), + ).resolves.toBe(true); + await expect(store.read(identity)).resolves.toMatchObject({ threadId: "thread-new" }); + }); + + it("rejects same-thread and supervision ownership through replacement CAS", async () => { + const { state } = createStateStore(); + const store = createCodexAppServerBindingStore(state); + const identity = { + kind: "session" as const, + agentId: "main", + sessionId: "session-cas-boundary", + }; + await store.mutate(identity, { + kind: "set", + binding: { threadId: "thread-old", cwd: "/repo" }, + }); + + await expect( + store.mutate(identity, { + kind: "replace-thread", + expectedThreadId: "thread-old", + binding: { threadId: "thread-old", cwd: "/repo" }, + }), + ).resolves.toBe(false); + await expect( + store.mutate(identity, { + kind: "replace-thread", + expectedThreadId: "thread-old", + binding: { + threadId: "thread-private", + cwd: "/repo", + connectionScope: "supervision", + supervisionSourceThreadId: "thread-private", + preserveNativeModel: true, + }, + }), + ).resolves.toBe(false); + await expect(store.read(identity)).resolves.toMatchObject({ threadId: "thread-old" }); + }); + it("does not report the exact session or conversation binding owner as another owner", async () => { const { state } = createStateStore(); const store = createCodexAppServerBindingStore(state); diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 25b11972e8ba..ce33d4d54882 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -251,6 +251,7 @@ const threadBindingSchema = z nativeSkillIsolationFingerprint: optionalStringSchema, userMcpServersFingerprint: optionalStringSchema, mcpServersFingerprint: optionalStringSchema, + configuredMcpOwnershipVersion: z.literal(1).optional().catch(undefined), ringZeroConfigFingerprint: optionalStringSchema, ringZeroClientInstanceId: optionalStringSchema, nativeHookRelayGeneration: optionalNonBlankStringSchema, @@ -367,6 +368,11 @@ type CodexAppServerBindingMutation = threadId: string; patch: Partial>; } + | { + kind: "replace-thread"; + expectedThreadId: string; + binding: CodexAppServerThreadBinding; + } | { kind: "patch-pending-supervision-branch"; expected: CodexAppServerPendingSupervisionBranch; @@ -927,6 +933,12 @@ export function createCodexAppServerBindingStore( mutation.threadId, mutation.expectedPendingSupervisionBranch, ); + const replacesExpectedOrdinaryOwner = + mutation.kind === "replace-thread" && + active?.binding.threadId === mutation.expectedThreadId && + active.binding.connectionScope !== "supervision" && + mutation.binding.connectionScope !== "supervision" && + mutation.binding.threadId !== mutation.expectedThreadId; if ( (mutation.kind === "set" && ((mutation.if?.kind === "absent" && storedActive) || @@ -935,6 +947,7 @@ export function createCodexAppServerBindingStore( (active?.binding.connectionScope === "supervision" && !preservesSupervisionOwner))) || (mutation.kind === "patch" && active?.binding.threadId !== mutation.threadId) || + (mutation.kind === "replace-thread" && !replacesExpectedOrdinaryOwner) || ((mutation.kind === "patch-pending-supervision-branch" || mutation.kind === "commit-pending-supervision-branch") && !matchesPendingSupervisionBranch(active?.binding, mutation.expected)) || @@ -962,7 +975,7 @@ export function createCodexAppServerBindingStore( }; } let binding: CodexAppServerThreadBinding; - if (mutation.kind === "set") { + if (mutation.kind === "set" || mutation.kind === "replace-thread") { binding = validateBindingForWrite(mutation.binding); } else if (mutation.kind === "patch-pending-supervision-branch") { binding = validateBindingForWrite({ diff --git a/extensions/codex/src/app-server/thread-lifecycle-io.ts b/extensions/codex/src/app-server/thread-lifecycle-io.ts index 2d49e0d622b3..43af34e0bbd0 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-io.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-io.ts @@ -101,6 +101,7 @@ type StartThreadContext = ThreadRequestContext & { prebuiltPluginThreadConfig?: CodexPluginThreadConfig; preserveExistingBinding: boolean; rotatedContextEngineBinding: boolean; + replacementPredecessor?: CodexAppServerThreadBinding; }; function resolveCodexThreadRolloutPath(thread: CodexThread): string | undefined { @@ -264,6 +265,7 @@ export async function resumeExistingCodexThread( nativeSkillIsolationFingerprint, userMcpServersFingerprint, mcpServersFingerprint: nextMcpServersFingerprint, + configuredMcpOwnershipVersion: params.configuredMcpOwnershipVersion, ringZeroConfigFingerprint, ringZeroClientInstanceId, networkProxyProfileName: params.appServer.networkProxy?.profileName, @@ -421,6 +423,7 @@ export async function startFreshCodexThread( prebuiltPluginThreadConfig, preserveExistingBinding, rotatedContextEngineBinding, + replacementPredecessor, } = context; const pluginThreadConfig = params.pluginThreadConfig?.enabled ? (prebuiltPluginThreadConfig ?? @@ -516,7 +519,25 @@ export async function startFreshCodexThread( throw error; } } - throwIfAborted(); + try { + throwIfAborted(); + } catch (error) { + if (replacementPredecessor) { + const cleanupConfirmed = await discardUnattestedCodexPluginThread({ + client: params.client, + threadId: response.thread.id, + ephemeral: startParams.ephemeral === true, + }); + if (!cleanupConfirmed) { + await (params.abandonClient ?? (() => closeCodexStartupClientBestEffort(params.client)))(); + throw new CodexAppServerUnsafeSubscriptionError( + "Codex successor cleanup failed after an aborted binding replacement", + { cause: error }, + ); + } + } + throw error; + } const modelProvider = resolveCodexAppServerModelProvider({ provider: params.params.provider, authProfileId: params.params.authProfileId, @@ -531,40 +552,75 @@ export async function startFreshCodexThread( const nextMcpServersFingerprint = params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : undefined; if (!preserveExistingBinding) { - const committed = await lifecycleTiming.measure("thread-start-write-binding", () => - params.bindingStore.mutate(bindingIdentity, { - kind: "set", - if: { kind: "absent" }, - binding: { - threadId: response.thread.id, - ...(clientId ? { clientId } : {}), - cwd: params.cwd, - ...(rolloutPath ? { rolloutPath } : {}), - authProfileId: params.params.authProfileId, - model: response.model ?? startParams.model ?? params.params.modelId, - modelProvider: bindingModelProvider, - dynamicToolsFingerprint, - dynamicToolsContainDeferred, - webSearchThreadConfigFingerprint, - nativeSkillIsolationFingerprint, - userMcpServersFingerprint, - mcpServersFingerprint: nextMcpServersFingerprint, - ringZeroConfigFingerprint, - ringZeroClientInstanceId, - networkProxyProfileName: params.appServer.networkProxy?.profileName, - networkProxyConfigFingerprint, - nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration, - appServerRuntimeFingerprint: params.appServerRuntimeFingerprint, - pluginAppsFingerprint: pluginThreadConfig?.fingerprint, - pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint, - pluginAppPolicyContext: pluginThreadConfig?.policyContext, - contextEngine: contextEngineBinding, - environmentSelectionFingerprint, - }, - }), - ); + const nextBinding: CodexAppServerThreadBinding = { + threadId: response.thread.id, + ...(clientId ? { clientId } : {}), + cwd: params.cwd, + ...(rolloutPath ? { rolloutPath } : {}), + authProfileId: params.params.authProfileId, + model: response.model ?? startParams.model ?? params.params.modelId, + modelProvider: bindingModelProvider, + dynamicToolsFingerprint, + dynamicToolsContainDeferred, + webSearchThreadConfigFingerprint, + nativeSkillIsolationFingerprint, + userMcpServersFingerprint, + mcpServersFingerprint: nextMcpServersFingerprint, + configuredMcpOwnershipVersion: params.configuredMcpOwnershipVersion, + ringZeroConfigFingerprint, + ringZeroClientInstanceId, + networkProxyProfileName: params.appServer.networkProxy?.profileName, + networkProxyConfigFingerprint, + nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration, + appServerRuntimeFingerprint: params.appServerRuntimeFingerprint, + pluginAppsFingerprint: pluginThreadConfig?.fingerprint, + pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint, + pluginAppPolicyContext: pluginThreadConfig?.policyContext, + contextEngine: contextEngineBinding, + environmentSelectionFingerprint, + }; + const cleanupUncommittedSuccessor = async (cause?: unknown) => { + const cleanupConfirmed = await discardUnattestedCodexPluginThread({ + client: params.client, + threadId: response.thread.id, + ephemeral: startParams.ephemeral === true, + }); + if (!cleanupConfirmed) { + await (params.abandonClient ?? (() => closeCodexStartupClientBestEffort(params.client)))(); + throw new CodexAppServerUnsafeSubscriptionError( + "Codex successor cleanup failed after a binding replacement conflict", + cause === undefined ? undefined : { cause }, + ); + } + }; + let committed: boolean; + try { + committed = await lifecycleTiming.measure("thread-start-write-binding", () => + params.bindingStore.mutate( + bindingIdentity, + replacementPredecessor + ? { + kind: "replace-thread", + expectedThreadId: replacementPredecessor.threadId, + binding: nextBinding, + } + : { kind: "set", if: { kind: "absent" }, binding: nextBinding }, + ), + ); + } catch (error) { + if (replacementPredecessor) { + await cleanupUncommittedSuccessor(error); + } + throw error; + } if (!committed) { - throw new CodexThreadBindingConflictError(response.thread.id, "committing a fresh thread"); + if (replacementPredecessor) { + await cleanupUncommittedSuccessor(); + } + throw new CodexThreadBindingConflictError( + replacementPredecessor?.threadId ?? response.thread.id, + "committing a fresh thread", + ); } if (contextEngineBinding) { embeddedAgentLog.info("codex app-server wrote context-engine thread binding", { @@ -600,6 +656,7 @@ export async function startFreshCodexThread( nativeSkillIsolationFingerprint, userMcpServersFingerprint, mcpServersFingerprint: nextMcpServersFingerprint, + configuredMcpOwnershipVersion: params.configuredMcpOwnershipVersion, ringZeroConfigFingerprint, ringZeroClientInstanceId, networkProxyProfileName: params.appServer.networkProxy?.profileName, diff --git a/extensions/codex/src/app-server/thread-lifecycle-run.ts b/extensions/codex/src/app-server/thread-lifecycle-run.ts index b5ada919a567..ba3756bc1b14 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-run.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-run.ts @@ -83,6 +83,7 @@ export async function startOrResumeThread( let binding = await lifecycleTiming.measure("read-binding", () => params.bindingStore.read(bindingIdentity), ); + let replacementPredecessor: CodexAppServerThreadBinding | undefined; const initialBoundThreadId = binding?.threadId; const initialBoundClientId = binding?.clientId; const normalizeBindingModelProvider = ( @@ -204,6 +205,7 @@ export async function startOrResumeThread( params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : pendingBinding.mcpServersFingerprint, + configuredMcpOwnershipVersion: params.configuredMcpOwnershipVersion, networkProxyProfileName: params.appServer.networkProxy?.profileName, networkProxyConfigFingerprint, nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration, @@ -329,6 +331,29 @@ export async function startOrResumeThread( params.persistentWebSearchAllowed !== false && transientWebSearchRestriction; const unknownProviderWebSearchSupport = params.nativeProviderWebSearchSupport === "unknown"; + const configuredMcpOwnershipChanged = + binding?.threadId && + ((params.configuredMcpOwnershipVersion === 1 && + (binding.configuredMcpOwnershipVersion !== 1 || + binding.dynamicToolsFingerprint === undefined || + binding.mcpServersFingerprint !== undefined || + binding.userMcpServersFingerprint !== undefined)) || + (params.configuredMcpOwnershipVersion !== 1 && + binding.configuredMcpOwnershipVersion === 1)); + if (configuredMcpOwnershipChanged && binding?.threadId) { + const predecessorBinding = binding; + // Scheduled configured MCP moved from Codex-native config to OpenClaw dynamic tools. + // A persistent main/named session has one binding: rotate its exact predecessor instead + // of retaining native and scheduled variants that could diverge or widen authority. + assertCodexBindingMayBeReplaced(predecessorBinding, "changing configured MCP ownership"); + embeddedAgentLog.debug( + "codex app-server configured MCP ownership changed; starting a new thread", + { threadId: predecessorBinding.threadId }, + ); + replacementPredecessor = predecessorBinding; + binding = undefined; + preserveExistingBinding = false; + } if ( binding?.threadId && params.mcpServersFingerprintEvaluated === true && @@ -632,10 +657,10 @@ export async function startOrResumeThread( } } - if (initialBoundThreadId && !preserveExistingBinding) { + if (initialBoundThreadId && !preserveExistingBinding && !replacementPredecessor) { await releaseRetainedThread(initialBoundThreadId); } - return await startFreshCodexThread(params, { + const started = await startFreshCodexThread(params, { bindingIdentity, startModelSelection, startModelProvider, @@ -660,6 +685,13 @@ export async function startOrResumeThread( prebuiltPluginThreadConfig, preserveExistingBinding, rotatedContextEngineBinding, + replacementPredecessor, }); + if (replacementPredecessor) { + // The predecessor remains authoritative through thread/start and exact-owner CAS. + // Release only that prior subscription after the successor has committed. + await releaseRetainedThread(replacementPredecessor.threadId, replacementPredecessor.clientId); + } + return started; }); } diff --git a/extensions/codex/src/app-server/thread-lifecycle-types.ts b/extensions/codex/src/app-server/thread-lifecycle-types.ts index 1edd06a464d8..2a44e057dcbd 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-types.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-types.ts @@ -66,6 +66,8 @@ export type CodexStartOrResumeThreadParams = { userMcpServersEnabled?: boolean; mcpServersFingerprint?: string; mcpServersFingerprintEvaluated?: boolean; + /** Versioned owner of configured MCP for scheduled dynamic-tool execution. */ + configuredMcpOwnershipVersion?: 1; environmentSelection?: CodexTurnEnvironmentParams[]; appServerRuntimeFingerprint?: string; pluginThreadConfig?: CodexPluginThreadConfigProvider; diff --git a/extensions/codex/src/app-server/thread-lifecycle.configured-mcp-ownership.test.ts b/extensions/codex/src/app-server/thread-lifecycle.configured-mcp-ownership.test.ts new file mode 100644 index 000000000000..bd91fbeb395f --- /dev/null +++ b/extensions/codex/src/app-server/thread-lifecycle.configured-mcp-ownership.test.ts @@ -0,0 +1,469 @@ +// Codex tests cover configured-MCP thread ownership transitions. +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + consumeCodexAppServerLiveThread, + ensureCodexAppServerClientRuntime, + retainCodexAppServerLiveThread, +} from "./client-runtime.js"; +import type { CodexAppServerBindingStore } from "./session-binding.js"; +import { + readCodexAppServerBinding, + registerCodexTestSessionIdentity, + resetCodexTestBindingStore, + testCodexAppServerBindingStore, + writeCodexAppServerBinding, +} from "./session-binding.test-helpers.js"; +import { useAutoCleanupTempDirTracker } from "./test-support.js"; +import { startOrResumeThread as startOrResumeThreadImpl } from "./thread-lifecycle.js"; +import { + createAppServerOptions, + createParams, + startOrResumeThread, + threadStartResult, +} from "./thread-lifecycle.test-fixtures.js"; + +const sharedClientMocks = vi.hoisted(() => ({ + retainByInstanceId: undefined as + | ((clientId: string | undefined) => { client: never; release: () => void } | undefined) + | undefined, +})); + +vi.mock("./shared-client.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + retainSharedCodexAppServerClientByInstanceId: (clientId: string | undefined) => + sharedClientMocks.retainByInstanceId + ? sharedClientMocks.retainByInstanceId(clientId) + : actual.retainSharedCodexAppServerClientByInstanceId(clientId), + }; +}); + +describe("startOrResumeThread — configured MCP ownership", () => { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + let tempDir = ""; + + beforeEach(() => { + sharedClientMocks.retainByInstanceId = undefined; + tempDir = tempDirs.make("openclaw-configured-mcp-ownership-"); + resetCodexTestBindingStore(); + }); + + it.each([ + { + name: "legacy native MCP fingerprint", + binding: { dynamicToolsFingerprint: "[]", mcpServersFingerprint: "mcp-v1" }, + }, + { name: "missing dynamic fingerprint", binding: {} }, + ])("rotates $name when scheduled dynamic MCP takes ownership", async ({ binding }) => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-legacy", + cwd: workspaceDir, + model: "gpt-5.4-codex", + modelProvider: "openai", + ...binding, + }); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + // The successor is not authoritative until its exact-predecessor CAS commits. + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-legacy", + }); + return threadStartResult("thread-scheduled-v1"); + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params: createParams(sessionFile, workspaceDir), + cwd: workspaceDir, + dynamicTools: [], + appServer: createAppServerOptions(), + configuredMcpOwnershipVersion: 1, + mcpServersFingerprintEvaluated: true, + nativeCodeModeEnabled: false, + userMcpServersEnabled: false, + }); + + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]); + expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({ + threadId: "thread-scheduled-v1", + configuredMcpOwnershipVersion: 1, + }); + }); + + it("replaces the single persistent main binding when scheduled MCP takes ownership", async () => { + const sessionFile = path.join(tempDir, "session-main.jsonl"); + const workspaceDir = path.join(tempDir, "workspace-main"); + registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:main"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-main-ordinary", + cwd: workspaceDir, + model: "gpt-5.4-codex", + modelProvider: "openai", + dynamicToolsFingerprint: "[]", + mcpServersFingerprint: "mcp-v1", + }); + const request = vi.fn(async (method: string) => { + if (method !== "thread/start") { + throw new Error(`unexpected method: ${method}`); + } + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-main-ordinary", + }); + return threadStartResult("thread-main-scheduled"); + }); + const params = createParams(sessionFile, workspaceDir); + params.sessionKey = "agent:main:main"; + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer: createAppServerOptions(), + configuredMcpOwnershipVersion: 1, + mcpServersFingerprintEvaluated: true, + nativeCodeModeEnabled: false, + userMcpServersEnabled: false, + }); + + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]); + expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({ + threadId: "thread-main-scheduled", + configuredMcpOwnershipVersion: 1, + }); + }); + + it("atomically alternates ordinary and scheduled ownership for a persistent named session without dual bindings", async () => { + const sessionFile = path.join(tempDir, "session-alternating.jsonl"); + const workspaceDir = path.join(tempDir, "workspace-alternating"); + registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-ordinary-old", + clientId: "client-old", + cwd: workspaceDir, + model: "gpt-5.4-codex", + modelProvider: "openai", + mcpServersFingerprint: "mcp-v1", + dynamicToolsFingerprint: "[]", + }); + + const released: string[] = []; + const oldClient = { + getInstanceId: () => "client-old", + request: vi.fn(async (method: string, requestParams: { threadId?: string }) => { + if (method === "thread/unsubscribe" && requestParams.threadId) { + released.push(requestParams.threadId); + return {}; + } + throw new Error(`unexpected method: ${method}`); + }), + addNotificationHandler: () => () => undefined, + addRequestHandler: () => () => undefined, + addCloseHandler: () => () => undefined, + } as never; + ensureCodexAppServerClientRuntime(oldClient, { agentDir: workspaceDir }); + await retainCodexAppServerLiveThread(oldClient, "thread-ordinary-old"); + const releaseOldClientLease = vi.fn(); + sharedClientMocks.retainByInstanceId = (clientId) => + clientId === "client-old" ? { client: oldClient, release: releaseOldClientLease } : undefined; + + const successorIds = ["thread-scheduled-v1", "thread-ordinary-new", "thread-scheduled-v2"]; + const currentRequest = vi.fn(async (method: string, requestParams?: { threadId?: string }) => { + if (method === "thread/start") { + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: + successorIds.length === 3 + ? "thread-ordinary-old" + : successorIds.length === 2 + ? "thread-scheduled-v1" + : "thread-ordinary-new", + }); + expect(released).toHaveLength( + successorIds.length === 3 ? 0 : successorIds.length === 2 ? 1 : 2, + ); + return threadStartResult(successorIds.shift()!); + } + if (method === "thread/unsubscribe" && requestParams?.threadId) { + released.push(requestParams.threadId); + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const currentClient = { + getInstanceId: () => "client-current", + request: currentRequest, + addNotificationHandler: () => () => undefined, + addRequestHandler: () => () => undefined, + addCloseHandler: () => () => undefined, + } as never; + ensureCodexAppServerClientRuntime(currentClient, { agentDir: workspaceDir }); + const releaseSibling = vi.fn(async () => undefined); + await retainCodexAppServerLiveThread(currentClient, "thread-sibling", releaseSibling); + const common = { + client: currentClient, + params: createParams(sessionFile, workspaceDir), + cwd: workspaceDir, + dynamicTools: [], + appServer: createAppServerOptions(), + mcpServersFingerprintEvaluated: true, + nativeCodeModeEnabled: false, + userMcpServersEnabled: false, + }; + + const scheduledV1 = await startOrResumeThread({ + ...common, + configuredMcpOwnershipVersion: 1, + }); + expect(scheduledV1).toMatchObject({ + threadId: "thread-scheduled-v1", + configuredMcpOwnershipVersion: 1, + }); + expect(released).toEqual(["thread-ordinary-old"]); + expect(releaseOldClientLease).toHaveBeenCalledOnce(); + await expect( + consumeCodexAppServerLiveThread(oldClient, "thread-ordinary-old"), + ).resolves.toBeUndefined(); + await retainCodexAppServerLiveThread( + currentClient, + scheduledV1.threadId, + undefined, + scheduledV1.liveThreadConfigFingerprint, + ); + + const ordinary = await startOrResumeThread({ + ...common, + mcpServersFingerprint: "mcp-v2", + }); + expect(ordinary).toMatchObject({ threadId: "thread-ordinary-new" }); + expect(ordinary.configuredMcpOwnershipVersion).toBeUndefined(); + expect(released).toEqual(["thread-ordinary-old", "thread-scheduled-v1"]); + await expect( + consumeCodexAppServerLiveThread(currentClient, "thread-scheduled-v1"), + ).resolves.toBeUndefined(); + await retainCodexAppServerLiveThread( + currentClient, + ordinary.threadId, + undefined, + ordinary.liveThreadConfigFingerprint, + ); + + const scheduledV2 = await startOrResumeThread({ + ...common, + configuredMcpOwnershipVersion: 1, + }); + expect(scheduledV2).toMatchObject({ + threadId: "thread-scheduled-v2", + configuredMcpOwnershipVersion: 1, + }); + expect(released).toEqual(["thread-ordinary-old", "thread-scheduled-v1", "thread-ordinary-new"]); + await expect( + consumeCodexAppServerLiveThread(currentClient, "thread-ordinary-new"), + ).resolves.toBeUndefined(); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-scheduled-v2", + clientId: "client-current", + configuredMcpOwnershipVersion: 1, + }); + + const sibling = await consumeCodexAppServerLiveThread(currentClient, "thread-sibling"); + expect(sibling).toBeDefined(); + expect(releaseSibling).not.toHaveBeenCalled(); + await sibling?.release("thread-sibling"); + expect(releaseSibling).toHaveBeenCalledWith("thread-sibling"); + }); + + it("preserves the configured-MCP predecessor when successor start fails", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-legacy", + clientId: "client-start-failure", + cwd: workspaceDir, + model: "gpt-5.4-codex", + modelProvider: "openai", + mcpServersFingerprint: "mcp-v1", + dynamicToolsFingerprint: "[]", + }); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + throw new Error("successor start failed"); + } + throw new Error(`unexpected method: ${method}`); + }); + const client = { + getInstanceId: () => "client-start-failure", + request, + addNotificationHandler: () => () => undefined, + addRequestHandler: () => () => undefined, + addCloseHandler: () => () => undefined, + } as never; + ensureCodexAppServerClientRuntime(client, { agentDir: workspaceDir }); + const releasePredecessor = vi.fn(async () => undefined); + await retainCodexAppServerLiveThread(client, "thread-legacy", releasePredecessor); + + await expect( + startOrResumeThread({ + client, + params: createParams(sessionFile, workspaceDir), + cwd: workspaceDir, + dynamicTools: [], + appServer: createAppServerOptions(), + configuredMcpOwnershipVersion: 1, + mcpServersFingerprintEvaluated: true, + nativeCodeModeEnabled: false, + userMcpServersEnabled: false, + }), + ).rejects.toThrow("successor start failed"); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-legacy", + }); + expect(releasePredecessor).not.toHaveBeenCalled(); + const predecessor = await consumeCodexAppServerLiveThread(client, "thread-legacy"); + expect(predecessor).toBeDefined(); + await predecessor?.release("thread-legacy"); + }); + + it.each(["conflict", "error"] as const)( + "cleans an uncommitted successor and preserves its predecessor after CAS $case", + async (caseName) => { + const sessionFile = path.join(tempDir, `session-${caseName}.jsonl`); + const workspaceDir = path.join(tempDir, "workspace"); + registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-legacy", + clientId: `client-cas-${caseName}`, + cwd: workspaceDir, + model: "gpt-5.4-codex", + modelProvider: "openai", + mcpServersFingerprint: "mcp-v1", + dynamicToolsFingerprint: "[]", + }); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-uncommitted"); + } + if (method === "thread/delete") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const client = { + getInstanceId: () => `client-cas-${caseName}`, + request, + addNotificationHandler: () => () => undefined, + addRequestHandler: () => () => undefined, + addCloseHandler: () => () => undefined, + } as never; + ensureCodexAppServerClientRuntime(client, { agentDir: workspaceDir }); + const releasePredecessor = vi.fn(async () => undefined); + await retainCodexAppServerLiveThread(client, "thread-legacy", releasePredecessor); + const bindingStore: CodexAppServerBindingStore = { + ...testCodexAppServerBindingStore, + mutate: async (identity, mutation) => { + if (mutation.kind === "replace-thread") { + if (caseName === "error") { + throw new Error("lost replacement lease"); + } + return false; + } + return await testCodexAppServerBindingStore.mutate(identity, mutation); + }, + }; + + await expect( + startOrResumeThreadImpl({ + bindingStore, + client, + params: createParams(sessionFile, workspaceDir), + cwd: workspaceDir, + dynamicTools: [], + appServer: createAppServerOptions(), + configuredMcpOwnershipVersion: 1, + mcpServersFingerprintEvaluated: true, + nativeCodeModeEnabled: false, + userMcpServersEnabled: false, + }), + ).rejects.toThrow( + caseName === "error" ? "lost replacement lease" : "Codex thread binding changed", + ); + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/start", + "thread/delete", + ]); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-legacy", + }); + expect(releasePredecessor).not.toHaveBeenCalled(); + const predecessor = await consumeCodexAppServerLiveThread(client, "thread-legacy"); + expect(predecessor).toBeDefined(); + await predecessor?.release("thread-legacy"); + }, + ); + + it("cleans the successor and preserves the predecessor on post-start abort", async () => { + const sessionFile = path.join(tempDir, "session-abort.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-legacy", + clientId: "client-abort", + cwd: workspaceDir, + model: "gpt-5.4-codex", + modelProvider: "openai", + mcpServersFingerprint: "mcp-v1", + dynamicToolsFingerprint: "[]", + }); + const controller = new AbortController(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + controller.abort("test abort"); + return threadStartResult("thread-uncommitted"); + } + if (method === "thread/delete") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const client = { + getInstanceId: () => "client-abort", + request, + addNotificationHandler: () => () => undefined, + addRequestHandler: () => () => undefined, + addCloseHandler: () => () => undefined, + } as never; + ensureCodexAppServerClientRuntime(client, { agentDir: workspaceDir }); + const releasePredecessor = vi.fn(async () => undefined); + await retainCodexAppServerLiveThread(client, "thread-legacy", releasePredecessor); + + await expect( + startOrResumeThread({ + client, + params: createParams(sessionFile, workspaceDir), + cwd: workspaceDir, + dynamicTools: [], + appServer: createAppServerOptions(), + configuredMcpOwnershipVersion: 1, + mcpServersFingerprintEvaluated: true, + nativeCodeModeEnabled: false, + userMcpServersEnabled: false, + signal: controller.signal, + }), + ).rejects.toThrow(); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/delete"]); + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + threadId: "thread-legacy", + }); + expect(releasePredecessor).not.toHaveBeenCalled(); + const predecessor = await consumeCodexAppServerLiveThread(client, "thread-legacy"); + expect(predecessor).toBeDefined(); + await predecessor?.release("thread-legacy"); + }); +}); diff --git a/extensions/codex/src/app-server/thread-lifecycle.test-fixtures.ts b/extensions/codex/src/app-server/thread-lifecycle.test-fixtures.ts new file mode 100644 index 000000000000..475896e62b2e --- /dev/null +++ b/extensions/codex/src/app-server/thread-lifecycle.test-fixtures.ts @@ -0,0 +1,91 @@ +import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { CodexAppServerRuntimeOptions } from "./config.js"; +import { testCodexAppServerBindingStore } from "./session-binding.test-helpers.js"; +import { startOrResumeThread as startOrResumeThreadImpl } from "./thread-lifecycle.js"; + +export function startOrResumeThread( + params: Omit[0], "bindingStore">, +) { + return startOrResumeThreadImpl({ ...params, bindingStore: testCodexAppServerBindingStore }); +} + +export function threadStartResult(threadId = "thread-1"): Record { + return { + thread: { + id: threadId, + sessionId: "session-1", + forkedFromId: null, + preview: "", + ephemeral: false, + modelProvider: "openai", + createdAt: 1, + updatedAt: 1, + status: { type: "idle" }, + path: null, + cwd: "/tmp", + cliVersion: "0.147.0", + source: "unknown", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }, + model: "gpt-5.4-codex", + modelProvider: "openai", + serviceTier: null, + cwd: "/tmp", + instructionSources: [], + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: { type: "dangerFullAccess" }, + permissionProfile: null, + reasoningEffort: null, + }; +} + +export function threadResumeResult(threadId = "thread-existing"): Record { + return threadStartResult(threadId); +} + +export function createAppServerOptions(): CodexAppServerRuntimeOptions { + return { + start: { + transport: "stdio", + command: "codex", + args: ["app-server"], + headers: {}, + }, + codeModeOnly: false, + loopDetectionPreToolUseRelay: true, + requestTimeoutMs: 60_000, + turnCompletionIdleTimeoutMs: 60_000, + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: "workspace-write", + } as unknown as CodexAppServerRuntimeOptions; +} + +export function createParams( + sessionFile: string, + workspaceDir: string, + configOverrides?: EmbeddedRunAttemptParams["config"], +): EmbeddedRunAttemptParams { + return { + prompt: "hello", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir, + runId: "run-1", + provider: "codex", + modelId: "gpt-5.4-codex", + thinkLevel: "medium", + disableTools: true, + timeoutMs: 5_000, + authStorage: {} as never, + authProfileStore: { version: 1, profiles: {} }, + modelRegistry: {} as never, + config: configOverrides, + } as unknown as EmbeddedRunAttemptParams; +} diff --git a/extensions/codex/src/app-server/thread-lifecycle.user-mcp-servers.test.ts b/extensions/codex/src/app-server/thread-lifecycle.user-mcp-servers.test.ts index f2f5de7cdc87..7b4674e06ad0 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.user-mcp-servers.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.user-mcp-servers.test.ts @@ -4,104 +4,21 @@ import os from "node:os"; import path from "node:path"; import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { CodexAppServerRuntimeOptions } from "./config.js"; import { hashCodexAppServerBindingFingerprint, readCodexAppServerBinding, registerCodexTestSessionIdentity, resetCodexTestBindingStore, seedCodexTestBinding, - testCodexAppServerBindingStore, writeCodexAppServerBinding, } from "./session-binding.test-helpers.js"; -import { startOrResumeThread as startOrResumeThreadImpl } from "./thread-lifecycle.js"; - -function startOrResumeThread( - params: Omit[0], "bindingStore">, -) { - return startOrResumeThreadImpl({ ...params, bindingStore: testCodexAppServerBindingStore }); -} - -function threadStartResult(threadId = "thread-1"): Record { - return { - thread: { - id: threadId, - sessionId: "session-1", - forkedFromId: null, - preview: "", - ephemeral: false, - modelProvider: "openai", - createdAt: 1, - updatedAt: 1, - status: { type: "idle" }, - path: null, - cwd: "/tmp", - cliVersion: "0.147.0", - source: "unknown", - agentNickname: null, - agentRole: null, - gitInfo: null, - name: null, - turns: [], - }, - model: "gpt-5.4-codex", - modelProvider: "openai", - serviceTier: null, - cwd: "/tmp", - instructionSources: [], - approvalPolicy: "never", - approvalsReviewer: "user", - sandbox: { type: "dangerFullAccess" }, - permissionProfile: null, - reasoningEffort: null, - }; -} - -function threadResumeResult(threadId = "thread-existing"): Record { - return threadStartResult(threadId); -} - -function createAppServerOptions(): CodexAppServerRuntimeOptions { - return { - start: { - transport: "stdio", - command: "codex", - args: ["app-server"], - headers: {}, - }, - codeModeOnly: false, - loopDetectionPreToolUseRelay: true, - requestTimeoutMs: 60_000, - turnCompletionIdleTimeoutMs: 60_000, - approvalPolicy: "never", - approvalsReviewer: "user", - sandbox: "workspace-write", - } as unknown as CodexAppServerRuntimeOptions; -} - -function createParams( - sessionFile: string, - workspaceDir: string, - configOverrides?: EmbeddedRunAttemptParams["config"], -): EmbeddedRunAttemptParams { - return { - prompt: "hello", - sessionId: "session-1", - sessionKey: "agent:main:session-1", - sessionFile, - workspaceDir, - runId: "run-1", - provider: "codex", - modelId: "gpt-5.4-codex", - thinkLevel: "medium", - disableTools: true, - timeoutMs: 5_000, - authStorage: {} as never, - authProfileStore: { version: 1, profiles: {} }, - modelRegistry: {} as never, - config: configOverrides, - } as unknown as EmbeddedRunAttemptParams; -} +import { + createAppServerOptions, + createParams, + startOrResumeThread, + threadResumeResult, + threadStartResult, +} from "./thread-lifecycle.test-fixtures.js"; describe("startOrResumeThread — user mcp.servers projection (regression: #80814)", () => { let tempDir = ""; diff --git a/src/agents/agent-bundle-mcp-harness.test.ts b/src/agents/agent-bundle-mcp-harness.test.ts index 69c9e55cd585..6f16b0269c26 100644 --- a/src/agents/agent-bundle-mcp-harness.test.ts +++ b/src/agents/agent-bundle-mcp-harness.test.ts @@ -1,6 +1,11 @@ /** Behavior tests for harness-facing requester-scoped MCP materialization. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { executeMcpAppOperation } from "../gateway/mcp-app-operations.js"; import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; +import { getMcpAppViewLease } from "./mcp-ui-resource.js"; +import { testing as mcpUiResourceTesting } from "./mcp-ui-resource.test-support.js"; + +const MCP_APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; const mocks = vi.hoisted(() => { type Runtime = SessionMcpRuntime; @@ -42,6 +47,7 @@ const mocks = vi.hoisted(() => { return undefined; }, ), + getOrCreateSessionMcpRuntime: vi.fn(), rememberAdvertisedScopedMcpCatalog: vi.fn( (sessionId: string, catalog: typeof advertised extends Map ? V : never) => { advertised.set(sessionId, catalog); @@ -61,12 +67,16 @@ vi.mock("./agent-bundle-mcp-runtime.js", async (importOriginal) => { return { ...actual, getOrCreateRequesterScopedMcpRuntime: mocks.getOrCreateRequesterScopedMcpRuntime, + getOrCreateSessionMcpRuntime: mocks.getOrCreateSessionMcpRuntime, rememberAdvertisedScopedMcpCatalog: mocks.rememberAdvertisedScopedMcpCatalog, getAdvertisedScopedMcpCatalog: mocks.getAdvertisedScopedMcpCatalog, }; }); -import { materializeRequesterScopedMcpToolsForHarnessRun } from "./agent-bundle-mcp-harness.js"; +import { + materializeRequesterScopedMcpToolsForHarnessRun, + materializeStaticMcpToolsForScheduledHarnessRun, +} from "./agent-bundle-mcp-harness.js"; function makeRuntime(params: { sessionId: string; requesterSenderId: string }): SessionMcpRuntime { const serverName = "user-mail"; @@ -137,12 +147,375 @@ function makeRuntime(params: { sessionId: string; requesterSenderId: string }): beforeEach(() => { mocks.reset(); mocks.getOrCreateRequesterScopedMcpRuntime.mockClear(); + mocks.getOrCreateSessionMcpRuntime.mockReset(); mocks.rememberAdvertisedScopedMcpCatalog.mockClear(); mocks.getAdvertisedScopedMcpCatalog.mockClear(); }); +describe("materializeStaticMcpToolsForScheduledHarnessRun", () => { + it("materializes static tools without carrying requester identity and applies the stored cap", async () => { + const runtime = makeRuntime({ sessionId: "scheduled", requesterSenderId: "unused" }); + delete runtime.requesterScope; + runtime.peekCatalog()!.servers["user-mail"]!.codexApprovalMode = "approve"; + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime); + + const result = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: "scheduled", + workspaceDir: "/workspace", + toolsAllow: ["user-mail__inbox"], + }); + + expect(mocks.getOrCreateSessionMcpRuntime).toHaveBeenCalledWith( + expect.not.objectContaining({ + requesterSenderId: expect.anything(), + agentAccountId: expect.anything(), + messageChannel: expect.anything(), + }), + ); + expect(result?.tools.map((tool) => tool.name)).toEqual(["user-mail__inbox"]); + await result?.dispose(); + }); + + it("never widens a finite scheduled cap", async () => { + const runtime = makeRuntime({ sessionId: "scheduled-denied", requesterSenderId: "unused" }); + delete runtime.requesterScope; + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime); + + const result = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: "scheduled-denied", + workspaceDir: "/workspace", + toolsAllow: ["read"], + }); + + expect(result?.tools).toEqual([]); + await result?.dispose(); + }); + + it("binds persistent app views to the same finite scheduled cap", async () => { + const runtime = makeRuntime({ sessionId: "scheduled-app", requesterSenderId: "unused" }); + delete runtime.requesterScope; + const catalog = runtime.peekCatalog()!; + catalog.servers["user-mail"]!.toolCount = 2; + catalog.servers["user-mail"]!.codexApprovalMode = "approve"; + catalog.tools = [ + { + serverName: "user-mail", + safeServerName: "user-mail", + toolName: "show", + inputSchema: { type: "object" }, + fallbackDescription: "show", + uiResourceUri: "ui://user-mail/app", + }, + { + serverName: "user-mail", + safeServerName: "user-mail", + toolName: "app-only", + inputSchema: { type: "object" }, + fallbackDescription: "app-only", + uiVisibility: ["app"], + }, + ]; + runtime.mcpAppsEnabled = true; + runtime.readResource = async () => ({ + contents: [ + { + uri: "ui://user-mail/app", + mimeType: MCP_APP_RESOURCE_MIME_TYPE, + text: "mail", + }, + ], + }); + const callTool = vi.spyOn(runtime, "callTool"); + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime); + + const result = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: "scheduled-app", + workspaceDir: "/workspace", + toolsAllow: ["user-mail__show", "user-mail__app-only"], + }); + const callResult = await result.tools[0]!.execute("call-app", {}); + const viewId = (callResult.details as { mcpAppPreview?: { mcpApp?: { viewId?: string } } }) + .mcpAppPreview?.mcpApp?.viewId; + + const view = getMcpAppViewLease(viewId!, runtime)!; + expect(view.allowedAppToolNames).toEqual(new Set(["app-only", "show"])); + await expect( + executeMcpAppOperation( + { runtime, view }, + { method: "tools/call", params: { name: "app-only", arguments: {} } }, + ), + ).resolves.toBeDefined(); + expect(callTool).toHaveBeenCalledTimes(2); + await result.dispose(); + }); + + it("excludes unsafe auto app tools while allowing read-only app calls", async () => { + const runtime = makeRuntime({ + sessionId: "scheduled-app-approval", + requesterSenderId: "unused", + }); + delete runtime.requesterScope; + const catalog = runtime.peekCatalog()!; + catalog.servers["user-mail"]!.toolCount = 3; + catalog.servers["user-mail"]!.codexApprovalMode = "auto"; + catalog.tools = [ + { + serverName: "user-mail", + safeServerName: "user-mail", + toolName: "show", + inputSchema: { type: "object" }, + fallbackDescription: "show", + uiResourceUri: "ui://user-mail/app", + codexAnnotations: { readOnlyHint: true }, + }, + { + serverName: "user-mail", + safeServerName: "user-mail", + toolName: "safe-app", + inputSchema: { type: "object" }, + fallbackDescription: "safe app", + uiVisibility: ["app"], + codexAnnotations: { readOnlyHint: true }, + }, + { + serverName: "user-mail", + safeServerName: "user-mail", + toolName: "unsafe-app", + inputSchema: { type: "object" }, + fallbackDescription: "unsafe app", + uiVisibility: ["app"], + }, + ]; + runtime.mcpAppsEnabled = true; + runtime.readResource = async () => ({ + contents: [ + { + uri: "ui://user-mail/app", + mimeType: MCP_APP_RESOURCE_MIME_TYPE, + text: "mail", + }, + ], + }); + const callTool = vi.spyOn(runtime, "callTool"); + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime); + + const result = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: "scheduled-app-approval", + workspaceDir: "/workspace", + toolsAllow: ["*"], + }); + const callResult = await result.tools[0]!.execute("call-app", {}); + const viewId = (callResult.details as { mcpAppPreview?: { mcpApp?: { viewId?: string } } }) + .mcpAppPreview?.mcpApp?.viewId; + const view = getMcpAppViewLease(viewId!, runtime)!; + expect(view.allowedAppToolNames).toEqual(new Set(["safe-app", "show"])); + + await expect( + executeMcpAppOperation( + { runtime, view }, + { method: "tools/call", params: { name: "unsafe-app", arguments: {} } }, + ), + ).rejects.toThrow('MCP tool "unsafe-app" is not app-callable'); + expect(callTool).toHaveBeenCalledTimes(1); + await expect( + executeMcpAppOperation( + { runtime, view }, + { method: "tools/call", params: { name: "safe-app", arguments: {} } }, + ), + ).resolves.toBeDefined(); + expect(callTool).toHaveBeenCalledTimes(2); + await result.dispose(); + }); + + it("allows prompt-mode app tools only under host-confirmed yolo", async () => { + const runtime = makeRuntime({ sessionId: "scheduled-app-yolo", requesterSenderId: "unused" }); + delete runtime.requesterScope; + const catalog = runtime.peekCatalog()!; + catalog.servers["user-mail"]!.toolCount = 2; + catalog.servers["user-mail"]!.codexApprovalMode = "prompt"; + catalog.tools = [ + { + serverName: "user-mail", + safeServerName: "user-mail", + toolName: "show", + inputSchema: { type: "object" }, + fallbackDescription: "show", + uiResourceUri: "ui://user-mail/app", + }, + { + serverName: "user-mail", + safeServerName: "user-mail", + toolName: "prompt-app", + inputSchema: { type: "object" }, + fallbackDescription: "prompt app", + uiVisibility: ["app"], + }, + ]; + runtime.mcpAppsEnabled = true; + runtime.readResource = async () => ({ + contents: [ + { + uri: "ui://user-mail/app", + mimeType: MCP_APP_RESOURCE_MIME_TYPE, + text: "mail", + }, + ], + }); + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime); + + const result = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: "scheduled-app-yolo", + workspaceDir: "/workspace", + toolsAllow: ["*"], + autoApproveCodexAppServerApprovals: true, + }); + const callResult = await result.tools[0]!.execute("call-app", {}); + const viewId = (callResult.details as { mcpAppPreview?: { mcpApp?: { viewId?: string } } }) + .mcpAppPreview?.mcpApp?.viewId; + expect(getMcpAppViewLease(viewId!, runtime)?.allowedAppToolNames).toEqual( + new Set(["prompt-app", "show"]), + ); + await result.dispose(); + }); + + it("retains prepared static ownership when discovery returns no catalog entries", async () => { + const runtime = makeRuntime({ sessionId: "scheduled-empty", requesterSenderId: "unused" }); + delete runtime.requesterScope; + const emptyCatalog = { version: 1, generatedAt: 0, servers: {}, tools: [] }; + runtime.peekCatalog = () => emptyCatalog; + runtime.getCatalog = async () => emptyCatalog; + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime); + + const result = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: "scheduled-empty", + workspaceDir: "/workspace", + toolsAllow: ["*"], + }); + + expect(result).toMatchObject({ tools: [] }); + await result?.dispose(); + }); + + it("returns a bounded operator-visible notice for failed configured MCP discovery", async () => { + const runtime = makeRuntime({ sessionId: "scheduled-diagnostic", requesterSenderId: "unused" }); + delete runtime.requesterScope; + const failedCatalog = { + version: 1, + generatedAt: 0, + servers: {}, + tools: [], + diagnostics: [ + { + serverName: "user-mail", + safeServerName: "user-mail", + launchSummary: "user-mail", + message: "authentication required", + }, + ], + }; + runtime.peekCatalog = () => failedCatalog; + runtime.getCatalog = async () => failedCatalog; + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime); + + const result = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: "scheduled-diagnostic", + workspaceDir: "/workspace", + toolsAllow: ["*"], + }); + + expect(result.diagnosticNotice).toContain("user-mail: authentication required"); + expect(result.diagnosticNotice).toContain("Do not claim MCP-backed work succeeded"); + await result.dispose(); + }); + + it("omits prompt-approved MCP tools from unattended execution", async () => { + const runtime = makeRuntime({ sessionId: "scheduled-prompt", requesterSenderId: "unused" }); + delete runtime.requesterScope; + const catalog = runtime.peekCatalog()!; + catalog.servers["user-mail"]!.codexApprovalMode = "prompt"; + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime); + const callTool = vi.spyOn(runtime, "callTool"); + + const result = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: "scheduled-prompt", + workspaceDir: "/workspace", + toolsAllow: ["user-mail__inbox"], + }); + + expect(result.tools).toEqual([]); + expect(result.diagnosticNotice).toContain("user-mail/inbox"); + expect(result.diagnosticNotice).toContain('defaultToolsApprovalMode="approve"'); + expect(callTool).not.toHaveBeenCalled(); + await result?.dispose(); + }); + + it("bypasses scheduled MCP prompting only for the host-confirmed yolo profile", async () => { + const runtime = makeRuntime({ sessionId: "scheduled-yolo", requesterSenderId: "unused" }); + delete runtime.requesterScope; + runtime.peekCatalog()!.servers["user-mail"]!.codexApprovalMode = "prompt"; + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime); + const callTool = vi.spyOn(runtime, "callTool"); + + const result = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: "scheduled-yolo", + workspaceDir: "/workspace", + toolsAllow: ["user-mail__inbox"], + autoApproveCodexAppServerApprovals: true, + }); + + await expect(result.tools[0]!.execute("call-1", {})).resolves.toBeDefined(); + expect(callTool).toHaveBeenCalledOnce(); + await result.dispose(); + }); + + it.each([ + { mode: "approve" as const, annotations: undefined }, + { mode: "auto" as const, annotations: { readOnlyHint: true } }, + ])("executes scheduled MCP tools admitted by $mode approval", async ({ mode, annotations }) => { + const runtime = makeRuntime({ sessionId: `scheduled-${mode}`, requesterSenderId: "unused" }); + delete runtime.requesterScope; + const catalog = runtime.peekCatalog()!; + catalog.servers["user-mail"]!.codexApprovalMode = mode; + if (annotations) { + catalog.tools[0]!.codexAnnotations = annotations; + } + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime); + const callTool = vi.spyOn(runtime, "callTool"); + + const result = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: `scheduled-${mode}`, + workspaceDir: "/workspace", + toolsAllow: ["user-mail__inbox"], + }); + + await expect(result!.tools[0]!.execute("call-1", {})).resolves.toBeDefined(); + expect(callTool).toHaveBeenCalledOnce(); + await result?.dispose(); + }); + + it("omits MCP tools when scheduled approval metadata is absent", async () => { + const runtime = makeRuntime({ sessionId: "scheduled-unknown", requesterSenderId: "unused" }); + delete runtime.requesterScope; + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime); + const callTool = vi.spyOn(runtime, "callTool"); + + const result = await materializeStaticMcpToolsForScheduledHarnessRun({ + sessionId: "scheduled-unknown", + workspaceDir: "/workspace", + toolsAllow: ["user-mail__inbox"], + }); + + expect(result.tools).toEqual([]); + expect(result.diagnosticNotice).toContain("user-mail/inbox"); + expect(result.diagnosticNotice).toContain('defaultToolsApprovalMode="approve"'); + expect(callTool).not.toHaveBeenCalled(); + await result?.dispose(); + }); +}); + afterEach(() => { mocks.reset(); + mcpUiResourceTesting.clearViewStore(); }); describe("materializeRequesterScopedMcpToolsForHarnessRun", () => { @@ -157,6 +530,23 @@ describe("materializeRequesterScopedMcpToolsForHarnessRun", () => { expect(mocks.rememberAdvertisedScopedMcpCatalog).not.toHaveBeenCalled(); }); + it("releases the live runtime when pre-return catalog publication fails", async () => { + const runtime = makeRuntime({ sessionId: "session-cleanup", requesterSenderId: "authed" }); + mocks.setResolveImpl(async () => runtime); + mocks.rememberAdvertisedScopedMcpCatalog.mockImplementationOnce(() => { + throw new Error("catalog publication failed"); + }); + + await expect( + materializeRequesterScopedMcpToolsForHarnessRun({ + sessionId: "session-cleanup", + workspaceDir: "/workspace", + requesterSenderId: "authed", + }), + ).rejects.toThrow("catalog publication failed"); + expect(runtime.activeLeases).toBe(0); + }); + it("keeps advertised specs stable and returns not-connected for unauthed senders", async () => { mocks.setResolveImpl(async (params) => { const senderId = params.requesterSenderId; diff --git a/src/agents/agent-bundle-mcp-harness.ts b/src/agents/agent-bundle-mcp-harness.ts index 27e018801a93..cc727c025bf1 100644 --- a/src/agents/agent-bundle-mcp-harness.ts +++ b/src/agents/agent-bundle-mcp-harness.ts @@ -1,7 +1,5 @@ -/** - * Harness-facing materialization of requester-scoped MCP tools. - * Static MCP stays harness-native; this path never opens static transports. - */ +/** Harness-facing materialization of configured MCP tools. */ +import type { SessionToolOverrides } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { getPluginToolMeta } from "../plugins/tools.js"; @@ -12,8 +10,11 @@ import { import { getAdvertisedScopedMcpCatalog, getOrCreateRequesterScopedMcpRuntime, + getOrCreateSessionMcpRuntime, rememberAdvertisedScopedMcpCatalog, + retireSessionMcpRuntime, } from "./agent-bundle-mcp-runtime.js"; +import type { McpToolCatalog } from "./agent-bundle-mcp-types.js"; import { resolveConversationCapabilityProfile, type ConversationCapabilityProfileParams, @@ -21,6 +22,7 @@ import { } from "./conversation-capability-profile.js"; import { applyFinalEffectiveToolPolicy } from "./embedded-agent-runner/effective-tool-policy.js"; import { applyEmbeddedAttemptToolsAllow } from "./embedded-agent-runner/run/attempt-tool-construction-plan.js"; +import { requiresMcpCodexToolApproval } from "./mcp-codex-tool-approval.js"; import type { AnyAgentTool } from "./tools/common.js"; type RequesterScopedHarnessMcpTools = { @@ -34,6 +36,54 @@ type RequesterScopedHarnessMcpTools = { dispose: () => Promise; }; +type ScheduledStaticHarnessMcpTools = { + /** Final executable static MCP tools for this scheduled turn. */ + tools: AnyAgentTool[]; + /** Bounded model/operator warning when configured servers or final policy were incomplete. */ + diagnosticNotice?: string; + dispose: () => Promise; +}; + +function formatScheduledMcpDiagnosticNotice(messages: readonly string[]): string | undefined { + const bounded = [...new Set(messages)] + .map((message) => message.replaceAll(/\s+/g, " ").trim().slice(0, 180)) + .filter(Boolean) + .slice(0, 4); + if (bounded.length === 0) { + return undefined; + } + return ( + `Configured MCP is incomplete for this scheduled run: ${bounded.join("; ")}. ` + + "Do not claim MCP-backed work succeeded; report this blocker to the operator." + ); +} + +function isScheduledCodexApprovalAllowed(tool: AnyAgentTool, autoApprove: boolean): boolean { + const mcp = getPluginToolMeta(tool)?.mcp; + return ( + mcp?.operation !== "tool" || + autoApprove || + (mcp.codexApproval !== undefined && !requiresMcpCodexToolApproval(mcp.codexApproval)) + ); +} + +function filterScheduledCodexApproval( + tools: readonly AnyAgentTool[], + autoApprove: boolean, + onOmitted?: (message: string) => void, +): AnyAgentTool[] { + return tools.filter((tool) => { + if (isScheduledCodexApprovalAllowed(tool, autoApprove)) { + return true; + } + const mcp = getPluginToolMeta(tool)?.mcp; + onOmitted?.( + `${mcp?.serverName ?? "configured MCP"}/${mcp?.toolName ?? tool.name}: requires interactive Codex approval (${mcp?.codexApproval?.mode ?? "auto"}); configure codex.defaultToolsApprovalMode="approve" or use the host-confirmed yolo profile`, + ); + return false; + }); +} + type MaterializeRequesterScopedMcpToolsForHarnessRunParams = { sessionId: string; sessionKey?: string; @@ -95,6 +145,108 @@ function applyHarnessToolPolicy( }); } +function buildCatalogTools( + catalog: McpToolCatalog, + params: MaterializeRequesterScopedMcpToolsForHarnessRunParams, +): AnyAgentTool[] { + return buildBundleMcpToolsFromCatalog({ + catalog, + reservedToolNames: params.reservedToolNames ? Array.from(params.reservedToolNames) : undefined, + createExecute: (tool) => async () => notConnectedToolResult(tool.serverName, tool.toolName), + }); +} + +/** + * Materialize only static configured MCP for an authenticated scheduled turn. + * No requester identity is accepted here, so requester resolvers stay unreachable. + */ +export async function materializeStaticMcpToolsForScheduledHarnessRun( + params: Omit< + MaterializeRequesterScopedMcpToolsForHarnessRunParams, + "requesterSenderId" | "agentAccountId" | "messageChannel" + > & { + toolOverrides?: Pick; + /** Exact established Codex yolo predicate; no other profile bypasses approval metadata. */ + autoApproveCodexAppServerApprovals?: boolean; + /** Mutation-only probes retire their isolated runtime after the snapshot. */ + retireSessionRuntimeAfterDispose?: boolean; + }, +): Promise { + const runtime = await getOrCreateSessionMcpRuntime({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + cfg: params.cfg, + manifestRegistry: params.manifestRegistry, + toolOverrides: params.toolOverrides, + }); + const retireSnapshotRuntime = params.retireSessionRuntimeAfterDispose + ? async () => { + await retireSessionMcpRuntime({ + sessionId: params.sessionId, + reason: "scheduled-authority-snapshot-complete", + }); + } + : undefined; + let liveRuntime: Awaited>; + try { + liveRuntime = await materializeBundleMcpToolsForRun({ + runtime, + reservedToolNames: params.reservedToolNames, + ...(retireSnapshotRuntime ? { disposeRuntime: retireSnapshotRuntime } : {}), + }); + } catch (error) { + await retireSnapshotRuntime?.(); + throw error; + } + try { + const policyWarnings: string[] = []; + const policyParams = { + ...params, + warn: (message: string) => { + policyWarnings.push(message); + params.warn?.(message); + }, + }; + const allowed = filterScheduledCodexApproval( + applyHarnessToolPolicy(liveRuntime.tools, policyParams), + params.autoApproveCodexAppServerApprovals === true, + (message) => policyWarnings.push(message), + ); + // App views outlive this attempt, so bind their callable surface to the + // same complete catalog and final policy before any model tool can mint one. + liveRuntime.restrictAppTools?.( + filterScheduledCodexApproval( + applyHarnessToolPolicy(liveRuntime.appTools ?? liveRuntime.tools, policyParams), + params.autoApproveCodexAppServerApprovals === true, + (message) => policyWarnings.push(message), + ), + ); + const diagnosticNotice = formatScheduledMcpDiagnosticNotice([ + ...(liveRuntime.diagnostics ?? []).map( + (diagnostic) => `${diagnostic.serverName}: ${diagnostic.message}`, + ), + ...policyWarnings, + ]); + let disposed = false; + return { + tools: allowed, + ...(diagnosticNotice ? { diagnosticNotice } : {}), + dispose: async () => { + if (disposed) { + return; + } + disposed = true; + await liveRuntime.dispose(); + }, + }; + } catch (error) { + await liveRuntime.dispose(); + throw error; + } +} + /** * Materialize requester-scoped MCP tools for a harness run (e.g. Codex dynamic tools). * Updates the session advertised-catalog cache when a requester resolves a catalog. @@ -116,49 +268,53 @@ export async function materializeRequesterScopedMcpToolsForHarnessRun( }); let liveRuntime: Awaited> | undefined; - if (scopedRuntime) { - liveRuntime = await materializeBundleMcpToolsForRun({ - runtime: scopedRuntime, - reservedToolNames: params.reservedToolNames, - }); - const catalog = scopedRuntime.peekCatalog() ?? (await scopedRuntime.getCatalog()); - rememberAdvertisedScopedMcpCatalog(params.sessionId, catalog); - } + try { + if (scopedRuntime) { + liveRuntime = await materializeBundleMcpToolsForRun({ + runtime: scopedRuntime, + reservedToolNames: params.reservedToolNames, + }); + const catalog = scopedRuntime.peekCatalog() ?? (await scopedRuntime.getCatalog()); + rememberAdvertisedScopedMcpCatalog(params.sessionId, catalog); + } - const advertisedCatalog = getAdvertisedScopedMcpCatalog(params.sessionId); - if (!advertisedCatalog || advertisedCatalog.tools.length === 0) { - await liveRuntime?.dispose(); - return undefined; - } - - const reservedToolNames = params.reservedToolNames - ? Array.from(params.reservedToolNames) - : undefined; - const advertisedTools = buildBundleMcpToolsFromCatalog({ - catalog: advertisedCatalog, - reservedToolNames, - createExecute: (tool) => async () => notConnectedToolResult(tool.serverName, tool.toolName), - }); - const liveByName = new Map((liveRuntime?.tools ?? []).map((tool) => [tool.name, tool])); - // Live tools supply execution; advertised catalog supplies the stable name/schema surface. - const tools = advertisedTools.map((tool) => liveByName.get(tool.name) ?? tool); - - const filteredTools = applyHarnessToolPolicy(tools, params); - const filteredAdvertised = applyHarnessToolPolicy(advertisedTools, params); - // Policy must keep both lists aligned by name for fingerprint stability. - const allowedNames = new Set(filteredAdvertised.map((tool) => tool.name)); - const executableTools = filteredTools.filter((tool) => allowedNames.has(tool.name)); - - let disposed = false; - return { - tools: executableTools, - advertisedTools: filteredAdvertised, - dispose: async () => { - if (disposed) { - return; - } - disposed = true; + const advertisedCatalog = getAdvertisedScopedMcpCatalog(params.sessionId); + if (!advertisedCatalog || advertisedCatalog.tools.length === 0) { await liveRuntime?.dispose(); - }, - }; + return undefined; + } + + const reservedToolNames = params.reservedToolNames + ? Array.from(params.reservedToolNames) + : undefined; + const advertisedTools = buildCatalogTools(advertisedCatalog, { + ...params, + reservedToolNames, + }); + const liveByName = new Map((liveRuntime?.tools ?? []).map((tool) => [tool.name, tool])); + // Live tools supply execution; advertised catalog supplies the stable name/schema surface. + const tools = advertisedTools.map((tool) => liveByName.get(tool.name) ?? tool); + + const filteredTools = applyHarnessToolPolicy(tools, params); + const filteredAdvertised = applyHarnessToolPolicy(advertisedTools, params); + // Policy must keep both lists aligned by name for fingerprint stability. + const allowedNames = new Set(filteredAdvertised.map((tool) => tool.name)); + const executableTools = filteredTools.filter((tool) => allowedNames.has(tool.name)); + + let disposed = false; + return { + tools: executableTools, + advertisedTools: filteredAdvertised, + dispose: async () => { + if (disposed) { + return; + } + disposed = true; + await liveRuntime?.dispose(); + }, + }; + } catch (error) { + await liveRuntime?.dispose(); + throw error; + } } diff --git a/src/agents/agent-bundle-mcp-materialize.ts b/src/agents/agent-bundle-mcp-materialize.ts index 50ba0743f813..235779b65072 100644 --- a/src/agents/agent-bundle-mcp-materialize.ts +++ b/src/agents/agent-bundle-mcp-materialize.ts @@ -57,6 +57,7 @@ function buildAppToolPolicyProjections(params: { return serverOrder || a.toolName.localeCompare(b.toolName); }); for (const tool of appOnlyTools) { + const server = params.catalog.servers[tool.serverName]; const name = buildSafeToolName({ serverName: tool.safeServerName, toolName: tool.toolName, @@ -80,6 +81,10 @@ function buildAppToolPolicyProjections(params: { safeServerName: tool.safeServerName, toolName: tool.toolName, operation: "tool", + codexApproval: { + mode: server?.codexApprovalMode ?? "auto", + ...(tool.codexAnnotations ? { annotations: tool.codexAnnotations } : {}), + }, }, }); tools.push(projection); @@ -344,6 +349,10 @@ export function buildBundleMcpToolsFromCatalog(params: { toolName: tool.toolName, operation: "tool", ...(tool.deniedBySession ? { deniedBySession: true } : {}), + codexApproval: { + mode: server?.codexApprovalMode ?? "auto", + ...(tool.codexAnnotations ? { annotations: tool.codexAnnotations } : {}), + }, }, }); tools.push(agentTool); diff --git a/src/agents/agent-bundle-mcp-runtime-config.ts b/src/agents/agent-bundle-mcp-runtime-config.ts index 61c5fb89e5aa..af4404a49b4a 100644 --- a/src/agents/agent-bundle-mcp-runtime-config.ts +++ b/src/agents/agent-bundle-mcp-runtime-config.ts @@ -331,3 +331,18 @@ export function resolveSessionMcpConfigSummary(params: { }); return { fingerprint: bareRuntimeFingerprint, serverNames }; } + +/** Reads the enabled static MCP server set without opening transports or listing tools. */ +export function resolveStaticSessionMcpServerNames(params: { + workspaceDir: string; + cfg?: OpenClawConfig; + manifestRegistry?: Pick; + toolOverrides?: Pick; +}): string[] { + const { loaded } = loadSessionMcpConfig({ + ...params, + logDiagnostics: false, + }); + const { staticServers } = partitionMcpServersByConnectionScope(loaded.mcpServers); + return Object.keys(staticServers).toSorted((left, right) => left.localeCompare(right)); +} diff --git a/src/agents/agent-bundle-mcp-runtime.test.ts b/src/agents/agent-bundle-mcp-runtime.test.ts index 747ef0df3df1..1c1f8647e97a 100644 --- a/src/agents/agent-bundle-mcp-runtime.test.ts +++ b/src/agents/agent-bundle-mcp-runtime.test.ts @@ -20,6 +20,7 @@ import { import { getOrCreateSessionMcpRuntime, materializeBundleMcpToolsForRun, + peekSessionMcpRuntime, retireSessionMcpRuntime, retireSessionMcpRuntimeForSessionKey, } from "./agent-bundle-mcp-tools.js"; @@ -2773,6 +2774,27 @@ process.on("SIGINT", shutdown);`, await expect(retireSessionMcpRuntime({ sessionId: " ", reason: "test" })).resolves.toBe(false); }); + it("keeps an ordinary session-key mapping when an unbound mutation probe retires", async () => { + const ordinary = await getOrCreateSessionMcpRuntime({ + sessionId: "session-ordinary", + sessionKey: "agent:test:ordinary", + workspaceDir: "/workspace", + cfg: { mcp: {} }, + }); + await getOrCreateSessionMcpRuntime({ + sessionId: "cron-authority:probe", + workspaceDir: "/workspace", + cfg: { mcp: {} }, + }); + + await retireSessionMcpRuntime({ + sessionId: "cron-authority:probe", + reason: "scheduled-authority-snapshot-complete", + }); + + expect(peekSessionMcpRuntime({ sessionKey: "agent:test:ordinary" })).toBe(ordinary); + }); + it("preserves a runtime while a bounded app view lease is active", async () => { const runtime = await getOrCreateSessionMcpRuntime({ sessionId: "session-view-lease", diff --git a/src/agents/agent-bundle-mcp-runtime.ts b/src/agents/agent-bundle-mcp-runtime.ts index 03c122b24382..7aeccf0cb052 100644 --- a/src/agents/agent-bundle-mcp-runtime.ts +++ b/src/agents/agent-bundle-mcp-runtime.ts @@ -54,6 +54,10 @@ import type { SessionMcpRuntime, SessionMcpRuntimeManager, } from "./agent-bundle-mcp-types.js"; +import { + normalizeMcpCodexToolAnnotations, + resolveMcpCodexToolApprovalMode, +} from "./mcp-codex-tool-approval.js"; import { isMcpConfigRecord } from "./mcp-config-shared.js"; import { applyMcpConnectionOverride, @@ -873,6 +877,7 @@ export function createSessionMcpRuntime(params: { ...(deniedToolNames.size > 0 ? { deniedToolNames: [...deniedToolNames].toSorted() } : {}), + codexApprovalMode: resolveMcpCodexToolApprovalMode(serverName, rawServer), }; const toolEntries: McpCatalogTool[] = []; for (const tool of policyEligibleTools) { @@ -902,6 +907,7 @@ export function createSessionMcpRuntime(params: { ...(uiResourceUri ? { uiResourceUri } : {}), ...(uiVisibility ? { uiVisibility } : {}), ...(deniedToolNames.has(toolName) ? { deniedBySession: true } : {}), + codexAnnotations: normalizeMcpCodexToolAnnotations(tool.annotations), }); } return { diff --git a/src/agents/agent-bundle-mcp-tools.materialize.test.ts b/src/agents/agent-bundle-mcp-tools.materialize.test.ts index d5204d963a76..5bfdaa6df2b1 100644 --- a/src/agents/agent-bundle-mcp-tools.materialize.test.ts +++ b/src/agents/agent-bundle-mcp-tools.materialize.test.ts @@ -133,6 +133,7 @@ describe("createBundleMcpToolRuntime", () => { "demo__hidden_tool", "demo__model_tool", ]); + expect(getPluginToolMeta(runtime.appTools![0]!)?.mcp?.codexApproval).toEqual({ mode: "auto" }); expect( applyEmbeddedAttemptToolsAllow(runtime.appTools ?? [], ["demo__model_tool"], { toolMeta: (tool) => getPluginToolMeta(tool), diff --git a/src/agents/agent-bundle-mcp-types.ts b/src/agents/agent-bundle-mcp-types.ts index eacb6e5d4715..3931b6f0c772 100644 --- a/src/agents/agent-bundle-mcp-types.ts +++ b/src/agents/agent-bundle-mcp-types.ts @@ -6,8 +6,10 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import type { TSchema } from "typebox"; import type { SessionToolOverrides } from "../config/sessions/types.js"; +import type { McpCodexToolApprovalMode } from "../config/types.mcp.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; +import type { McpCodexToolAnnotations } from "./mcp-codex-tool-approval.js"; import type { AnyAgentTool } from "./tools/common.js"; /** Materialized MCP tools plus diagnostics and cleanup handle for one run. */ @@ -43,6 +45,7 @@ export type McpServerCatalog = { exclude?: string[]; }; deniedToolNames?: string[]; + codexApprovalMode?: McpCodexToolApprovalMode; }; /** MCP tool entry after server-name sanitization and schema normalization. */ @@ -57,6 +60,7 @@ export type McpCatalogTool = { uiResourceUri?: string; uiVisibility?: Array<"app" | "model">; deniedBySession?: true; + codexAnnotations?: McpCodexToolAnnotations; }; /** Complete tool catalog for a session-scoped MCP runtime. */ diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index 7530d2588e3e..d1e3361c19b0 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -43,6 +43,10 @@ import { import { runWithAgentRingZeroTools } from "./agent-tools.ring-zero-context.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; import { resolveConversationCapabilityProfile } from "./conversation-capability-profile.js"; +import { + runWithCronCreatorAuthority, + runWithCronCreatorAuthorityResolver, +} from "./cron-creator-authority-context.js"; import * as openClawPluginTools from "./openclaw-plugin-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; import { expectReadWriteEditTools } from "./test-helpers/agent-tools-fs-helpers.js"; @@ -293,6 +297,59 @@ describe("createOpenClawCodingTools", () => { ); }); + it("binds configured MCP cron authority only to the exact admitted run", async () => { + const resolve = vi.fn().mockResolvedValue({ + tools: ["read", { name: "mcp_todoist_add_task", pluginId: "todoist" }], + provenance: { version: 1, source: "final-executable-surface" }, + }); + let releaseRun: (() => void) | undefined; + const holdRun = new Promise((resolveHold) => { + releaseRun = resolveHold; + }); + let retainedResolver: (() => Promise) | undefined; + + vi.mocked(createOpenClawTools).mockClear(); + runWithCronCreatorAuthorityResolver({ + runId: "forged-run", + resolve, + run: () => createOpenClawCodingTools({ runId: "forged-run" }), + }); + expect( + vi.mocked(createOpenClawTools).mock.lastCall?.[0]?.resolveCronCreatorToolAuthority, + ).toBeUndefined(); + + const activeRun = runWithCronCreatorAuthority("admitted-run", async () => { + runWithCronCreatorAuthorityResolver({ + runId: "other-run", + resolve, + run: () => createOpenClawCodingTools({ runId: "admitted-run" }), + }); + expect( + vi.mocked(createOpenClawTools).mock.lastCall?.[0]?.resolveCronCreatorToolAuthority, + ).toBeUndefined(); + + runWithCronCreatorAuthorityResolver({ + runId: "admitted-run", + resolve, + run: () => createOpenClawCodingTools({ runId: "admitted-run" }), + }); + retainedResolver = + vi.mocked(createOpenClawTools).mock.lastCall?.[0]?.resolveCronCreatorToolAuthority; + expect(retainedResolver).toEqual(expect.any(Function)); + await expect(retainedResolver!()).resolves.toMatchObject({ + provenance: { source: "final-executable-surface" }, + }); + await holdRun; + }); + + releaseRun?.(); + await activeRun; + await expect(retainedResolver!()).rejects.toThrow( + "Configured MCP cron authority is no longer active for this run", + ); + expect(resolve).toHaveBeenCalledTimes(1); + }); + it("re-wraps existing before_tool_call hooks once with the current context", async () => { const beforeToolCall = vi.fn(); initializeGlobalHookRunner( diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index d8ccd82b2f48..4fc89942eaf9 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -55,6 +55,7 @@ import { } from "./conversation-tool-policy-pipeline.js"; import { createCoreCodingTools } from "./core-coding-tools.js"; import type { OpenClawCodingToolConstructionPlan } from "./core-tool-factory-descriptors.js"; +import { bindActiveCronCreatorAuthorityResolver } from "./cron-creator-authority-context.js"; import { applyDelegationCapability, type DelegationCapability } from "./delegation-capability.js"; import { resolveImageSanitizationLimits } from "./image-sanitization.js"; import { resolveExecToolConfig } from "./lazy-exec-tool.js"; @@ -100,7 +101,9 @@ import { import { replaceWithEffectiveCronCreatorToolAllowlist, type CronCreatorToolAllowlistEntry, + type CronToolsAllowCaptureRef, } from "./tools/cron-tool.js"; +import type { CronToolOptions } from "./tools/cron-tool.types.js"; import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-context.js"; const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]); @@ -310,6 +313,10 @@ type OpenClawCodingToolsOptions = { inheritedToolAllowlistRef?: string[]; /** Mutable cron creator cap ref for callers that append final runtime tools later. */ cronCreatorToolAllowlistRef?: CronCreatorToolAllowlistEntry[]; + /** Mutable proof that the cron cap reached the final executable surface. */ + cronCreatorToolAllowlistCaptureRef?: CronToolsAllowCaptureRef; + /** Visible fail-closed reason for queued Codex configured-MCP cron mutations. */ + cronCreatorAuthorityUnavailableReason?: CronToolOptions["creatorAuthorityUnavailableReason"]; /** If true, the model has native vision capability */ modelHasVision?: boolean; /** Mutable model-context generation used to expire screenshot coordinate frames. */ @@ -648,6 +655,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) const shouldInheritEffectiveToolAllowlist = toolPolicyInheritanceSources.some(hasRestrictiveAllowPolicy); const cronCreatorToolAllowlist = options?.cronCreatorToolAllowlistRef ?? []; + const cronCreatorToolAllowlistCaptureRef = options?.cronCreatorToolAllowlistCaptureRef; const gatewayCallerAccountId = options?.scheduledToolPolicy?.ownerAccountId ?? options?.agentAccountId; // Plugin-only plans bypass createOpenClawTools, so the capability gate must @@ -776,6 +784,9 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) pluginToolAllowlist, pluginToolDenylist, cronCreatorToolAllowlist, + cronCreatorToolAllowlistCaptureRef, + resolveCronCreatorToolAuthority: bindActiveCronCreatorAuthorityResolver(options?.runId), + cronCreatorAuthorityUnavailableReason: options?.cronCreatorAuthorityUnavailableReason, currentChannelId: options?.currentChannelId, currentChatType: options?.chatType, currentMessagingTarget: options?.currentMessagingTarget, diff --git a/src/agents/cli-runner/bundle-mcp-codex.ts b/src/agents/cli-runner/bundle-mcp-codex.ts index 126aef41d488..ed4645698964 100644 --- a/src/agents/cli-runner/bundle-mcp-codex.ts +++ b/src/agents/cli-runner/bundle-mcp-codex.ts @@ -68,6 +68,27 @@ function isCodexMcpServerAllowedForAgent( return agentIds.includes(normalizeAgentId(options.agentId)); } +/** + * Applies Codex-only agent scoping before OpenClaw resolves credentials or opens transports. + * Session overrides may narrow this result, but cannot widen `codex.agents`. + */ +export function resolveCodexMcpToolOverridesForAgent( + cfg: OpenClawConfig | undefined, + options: Pick, +): Pick | undefined { + const deniedServerNames = Object.entries(normalizeConfiguredMcpServers(cfg?.mcp?.servers)) + .filter(([, server]) => !isCodexMcpServerAllowedForAgent(server, options)) + .map(([name]) => name); + if (deniedServerNames.length === 0) { + return options.toolOverrides; + } + const mcpServers = { ...options.toolOverrides?.mcpServers }; + for (const serverName of deniedServerNames) { + mcpServers[serverName] = false; + } + return { ...options.toolOverrides, mcpServers }; +} + function readSessionMcpServerOverride( options: CodexUserMcpServersProjectionOptions | undefined, name: string, diff --git a/src/agents/codex-mcp-config.test.ts b/src/agents/codex-mcp-config.test.ts index fd4e86d90d46..612b053d1f23 100644 --- a/src/agents/codex-mcp-config.test.ts +++ b/src/agents/codex-mcp-config.test.ts @@ -8,6 +8,7 @@ import { buildCodexMcpServersConfig, loadCodexBundleMcpThreadConfig } from "./co import { testing as resolverTesting } from "./mcp-connection-resolver.js"; const mocks = vi.hoisted(() => ({ + loadCalls: [] as Array>, bundleMcp: { config: { mcpServers: {}, @@ -18,10 +19,14 @@ const mocks = vi.hoisted(() => ({ const tempDirs: string[] = []; vi.mock("../plugins/bundle-mcp.js", () => ({ - loadEnabledBundleMcpConfig: () => mocks.bundleMcp, + loadEnabledBundleMcpConfig: (params: Record) => { + mocks.loadCalls.push(params); + return mocks.bundleMcp; + }, })); beforeEach(() => { + mocks.loadCalls.length = 0; mocks.bundleMcp = { config: { mcpServers: {}, @@ -90,6 +95,16 @@ describe("buildCodexMcpServersConfig", () => { }); describe("loadCodexBundleMcpThreadConfig", () => { + it("forwards a prepared manifest registry to bundle loading", () => { + const manifestRegistry = { plugins: [] }; + + loadCodexBundleMcpThreadConfig({ workspaceDir: "/workspace", manifestRegistry }); + + expect(mocks.loadCalls).toEqual([ + expect.objectContaining({ workspaceDir: "/workspace", manifestRegistry }), + ]); + }); + it("prepares Agent Plugins data dirs before projecting Codex thread config", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-agent-mcp-")); tempDirs.push(tempDir); @@ -158,6 +173,7 @@ describe("loadCodexBundleMcpThreadConfig", () => { }, }); expect(loaded.fingerprint).toMatch(/^[a-f0-9]{64}$/); + expect(loaded.staticServerNames).toEqual(["search"]); }); it("applies session server and tool denials to bundled Codex MCP config", () => { @@ -252,6 +268,8 @@ describe("loadCodexBundleMcpThreadConfig", () => { expect(loaded.configPatch).toBeUndefined(); expect(loaded.fingerprint).toBeUndefined(); expect(loaded.evaluated).toBe(true); + expect(loaded.staticServerNames).toEqual(["search"]); + expect(loaded.userStaticServerNames).toEqual(["search"]); }); it("returns an evaluated empty MCP config when no bundle MCP runtime is needed", () => { @@ -281,6 +299,7 @@ describe("loadCodexBundleMcpThreadConfig", () => { expect(loaded.configPatch).toBeUndefined(); expect(loaded.fingerprint).toBeUndefined(); expect(loaded.evaluated).toBe(true); + expect(loaded.staticServerNames).toEqual([]); } }); @@ -353,6 +372,7 @@ describe("loadCodexBundleMcpThreadConfig", () => { expect(JSON.stringify(loaded.configPatch)).not.toContain("user-mail"); expect(loaded.configPatch).toEqual(withoutScopedConfig.configPatch); expect(loaded.fingerprint).toBe(withoutScopedConfig.fingerprint); + expect(loaded.staticServerNames).toEqual(["search"]); }); it("keeps static projection byte-identical when no resolver exists", () => { diff --git a/src/agents/codex-mcp-config.ts b/src/agents/codex-mcp-config.ts index bd8656ff362d..11bfdca101c5 100644 --- a/src/agents/codex-mcp-config.ts +++ b/src/agents/codex-mcp-config.ts @@ -25,45 +25,9 @@ import type { LoadCodexBundleMcpThreadConfigParams, } from "./codex-mcp-config.types.js"; import { shouldCreateBundleMcpRuntimeForAttempt } from "./embedded-agent-runner/run/attempt-tool-construction-plan.js"; +import { resolveProjectedMcpCodexToolApprovalMode } from "./mcp-codex-tool-approval.js"; import { partitionMcpServersByConnectionScope } from "./mcp-connection-resolver.js"; -function isOpenClawLoopbackMcpServer(name: string, server: BundleMcpServerConfig): boolean { - return ( - name === "openclaw" && - typeof server.url === "string" && - /^https?:\/\/(?:127\.0\.0\.1|localhost):\d+\/mcp(?:[?#].*)?$/.test(server.url) - ); -} - -type CodexMcpToolApprovalMode = "auto" | "prompt" | "approve"; - -const CODEX_MCP_TOOL_APPROVAL_MODES = new Set([ - "auto", - "prompt", - "approve", -]); - -function readCodexProjectionConfig(server: BundleMcpServerConfig): Record { - return isRecord(server.codex) ? server.codex : {}; -} - -function normalizeCodexToolApprovalMode(value: unknown): CodexMcpToolApprovalMode | undefined { - return typeof value === "string" && - CODEX_MCP_TOOL_APPROVAL_MODES.has(value as CodexMcpToolApprovalMode) - ? (value as CodexMcpToolApprovalMode) - : undefined; -} - -function resolveCodexDefaultToolsApprovalMode( - server: BundleMcpServerConfig, -): CodexMcpToolApprovalMode | undefined { - const codex = readCodexProjectionConfig(server); - return ( - normalizeCodexToolApprovalMode(codex.defaultToolsApprovalMode) ?? - normalizeCodexToolApprovalMode(codex.default_tools_approval_mode) - ); -} - function normalizeToolFilterList(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -138,13 +102,9 @@ export function normalizeCodexMcpServerConfig( ): Record { const next = normalizeBundleMcpServerConfig(server); applyCodexToolFilter(next, name, server); - const defaultToolsApprovalMode = resolveCodexDefaultToolsApprovalMode(server); + const defaultToolsApprovalMode = resolveProjectedMcpCodexToolApprovalMode(name, server); if (defaultToolsApprovalMode) { next.default_tools_approval_mode = defaultToolsApprovalMode; - } else if (isOpenClawLoopbackMcpServer(name, server)) { - // OpenClaw's loopback MCP exposes local tools; Codex should ask for approval - // unless plugin metadata explicitly selected another approval mode. - next.default_tools_approval_mode = "approve"; } const httpHeaders = normalizeStringRecord(server.headers); if (httpHeaders) { @@ -222,11 +182,14 @@ export function loadCodexBundleMcpThreadConfig( return { diagnostics: [], evaluated: true, + staticServerNames: [], + userStaticServerNames: [], }; } const bundleMcp = loadEnabledBundleMcpConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, + manifestRegistry: params.manifestRegistry, }); const configuredMcp = normalizeConfiguredMcpServers(params.cfg?.mcp?.servers); const serverOverrides = params.toolOverrides?.mcpServers; @@ -248,6 +211,27 @@ export function loadCodexBundleMcpThreadConfig( ]), ), }; + const enabledConfiguredMcp = Object.fromEntries( + Object.entries(configuredMcp).filter(([name, server]) => { + const override = + serverOverrides && Object.hasOwn(serverOverrides, name) ? serverOverrides[name] : undefined; + return override !== false && (override === true || server.enabled !== false); + }), + ); + // The native thread projection has separate bundle and owner-config paths, + // but scheduled ownership covers their one merged static execution surface. + const { staticServers: configuredStaticServers } = partitionMcpServersByConnectionScope({ + ...effectiveConfig.mcpServers, + ...enabledConfiguredMcp, + }); + const { staticServers: userStaticServers } = + partitionMcpServersByConnectionScope(enabledConfiguredMcp); + const staticServerNames = Object.keys(configuredStaticServers).toSorted((left, right) => + left.localeCompare(right), + ); + const userStaticServerNames = Object.keys(userStaticServers).toSorted((left, right) => + left.localeCompare(right), + ); const preparedDataDirs = prepareOwnedBundleMcpDataDirs({ config: effectiveConfig, prepareDataDirsByServer: bundleMcp.prepareDataDirsByServer ?? {}, @@ -258,6 +242,8 @@ export function loadCodexBundleMcpThreadConfig( return { diagnostics, evaluated: true, + staticServerNames, + userStaticServerNames, }; } return { @@ -267,5 +253,7 @@ export function loadCodexBundleMcpThreadConfig( diagnostics, evaluated: true, fingerprint: fingerprintCodexMcpServersConfig(mcpServers), + staticServerNames, + userStaticServerNames, }; } diff --git a/src/agents/codex-mcp-config.types.ts b/src/agents/codex-mcp-config.types.ts index c2ee1d138842..31bfe7c55e81 100644 --- a/src/agents/codex-mcp-config.types.ts +++ b/src/agents/codex-mcp-config.types.ts @@ -4,6 +4,7 @@ import type { SessionToolOverrides } from "../config/sessions/types.js"; */ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { BundleMcpDiagnostic } from "../plugins/bundle-mcp.js"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; /** Codex app-server `mcp_servers` config map. */ export type CodexMcpServersConfig = Record>; @@ -16,6 +17,10 @@ export type CodexBundleMcpThreadConfig = { diagnostics: BundleMcpDiagnostic[]; evaluated: boolean; fingerprint?: string; + /** Enabled static servers across bundle defaults and owner config. */ + staticServerNames: string[]; + /** Enabled static servers originating from owner `mcp.servers` config. */ + userStaticServerNames: string[]; }; /** Inputs used to load a Codex bundle-MCP thread config patch. */ @@ -25,5 +30,6 @@ export type LoadCodexBundleMcpThreadConfigParams = { toolsEnabled?: boolean; disableTools?: boolean; toolsAllow?: string[]; + manifestRegistry?: Pick; toolOverrides?: Pick; }; diff --git a/src/agents/cron-creator-authority-context.ts b/src/agents/cron-creator-authority-context.ts new file mode 100644 index 000000000000..9818198d2077 --- /dev/null +++ b/src/agents/cron-creator-authority-context.ts @@ -0,0 +1,113 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { + createCronCreatorAuthorityRunScope, + mintCronCreatorAuthorityGrant, + revokeCronCreatorAuthorityRunScope, + type CronCreatorAuthorityRunScope, +} from "../gateway/cron-creator-authority-grant.js"; +import type { + CronCreatorToolAuthorityMaterialization, + CronToolOptions, +} from "./tools/cron-tool.types.js"; + +type CronCreatorAuthorityResolver = NonNullable; + +type CronCreatorAuthorityResolverScope = { + resolve: (options?: { signal?: AbortSignal }) => Promise; + runId: string; +}; + +const activeCronCreatorAuthority = new AsyncLocalStorage(); +const activeCronCreatorAuthorityResolver = + new AsyncLocalStorage(); + +function isPromiseLike(value: unknown): value is PromiseLike { + if ((typeof value !== "object" || value === null) && typeof value !== "function") { + return false; + } + return "then" in value && typeof value.then === "function"; +} + +/** Keeps fresh cron reauthorization within one admitted Gateway agent run. */ +export function runWithCronCreatorAuthority( + runId: string, + run: () => T, + signal?: AbortSignal, +): T { + const normalizedRunId = runId.trim(); + if (!normalizedRunId) { + return run(); + } + const scope = createCronCreatorAuthorityRunScope(normalizedRunId); + const revoke = () => revokeCronCreatorAuthorityRunScope(scope); + signal?.addEventListener("abort", revoke, { once: true }); + if (signal?.aborted) { + revoke(); + } + try { + const result = activeCronCreatorAuthority.run(scope, run); + if (isPromiseLike(result)) { + return Promise.resolve(result).finally(() => { + signal?.removeEventListener("abort", revoke); + revoke(); + }) as T; + } + signal?.removeEventListener("abort", revoke); + revoke(); + return result; + } catch (error) { + signal?.removeEventListener("abort", revoke); + revoke(); + throw error; + } +} + +/** Carries a bundled-Codex resolver through synchronous core tool construction. */ +export function runWithCronCreatorAuthorityResolver(params: { + runId: string; + resolve: (options?: { signal?: AbortSignal }) => Promise; + run: () => T; +}): T { + return activeCronCreatorAuthorityResolver.run( + { runId: params.runId.trim(), resolve: params.resolve }, + params.run, + ); +} + +/** Binds the resolver to the exact active run and revokes retained callbacks at settlement. */ +export function bindActiveCronCreatorAuthorityResolver( + runId: string | undefined, +): CronCreatorAuthorityResolver | undefined { + const authority = activeCronCreatorAuthority.getStore(); + const resolver = activeCronCreatorAuthorityResolver.getStore(); + const normalizedRunId = runId?.trim(); + if ( + !normalizedRunId || + authority?.active !== true || + authority.runId !== normalizedRunId || + resolver?.runId !== normalizedRunId + ) { + return undefined; + } + return async (options) => { + // Tool callbacks can run on async resources created outside the ALS scope, + // so retain the exact scope object and revoke it when the run settles. + const operationSignal = options?.signal; + authority.signal.throwIfAborted(); + operationSignal?.throwIfAborted(); + const signal = operationSignal + ? AbortSignal.any([authority.signal, operationSignal]) + : authority.signal; + const snapshot = await resolver.resolve({ signal }); + authority.signal.throwIfAborted(); + operationSignal?.throwIfAborted(); + if (!authority.active) { + authority.signal.throwIfAborted(); + } + return Object.freeze({ + tools: snapshot.tools, + provenance: snapshot.provenance, + grant: mintCronCreatorAuthorityGrant(authority, operationSignal), + }); + }; +} diff --git a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.test.ts b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.test.ts index 89edd332487b..df0ed2fd6d0a 100644 --- a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setPluginToolMeta } from "../../../plugins/tools.js"; import { attachToolAllowlistIntersection } from "../../tool-policy.js"; const mocks = vi.hoisted(() => ({ @@ -6,6 +7,7 @@ const mocks = vi.hoisted(() => ({ getOrCreateSessionMcpRuntime: vi.fn(), materializeBundleMcpToolsForRun: vi.fn(), applyFinalEffectiveToolPolicy: vi.fn(), + filterRuntimeCompatibleTools: vi.fn(), })); vi.mock("../../agent-bundle-lsp-runtime.js", () => ({ @@ -26,7 +28,7 @@ vi.mock("../../local-model-lean.js", () => ({ })); vi.mock("../../tool-schema-projection.js", () => ({ - filterRuntimeCompatibleTools: vi.fn((tools: unknown[]) => ({ tools, diagnostics: [] })), + filterRuntimeCompatibleTools: mocks.filterRuntimeCompatibleTools, })); vi.mock("../effective-tool-policy.js", () => ({ @@ -44,6 +46,9 @@ describe("prepareEmbeddedAttemptBundleTools", () => { mocks.applyFinalEffectiveToolPolicy .mockReset() .mockImplementation(({ bundledTools }: { bundledTools: unknown[] }) => bundledTools); + mocks.filterRuntimeCompatibleTools + .mockReset() + .mockImplementation((tools: unknown[]) => ({ tools, diagnostics: [] })); }); function createInput(inheritedToolAllowlist: string[], toolsRaw: unknown[]) { @@ -212,6 +217,39 @@ describe("prepareEmbeddedAttemptBundleTools", () => { expect(inheritedToolAllowlist).not.toContain("server__delete"); }); + it("captures the post-quarantine creator cap with plugin ownership", async () => { + const coreTool = { name: "automations" }; + const allowedMcpTool = { name: "mail__read" }; + const quarantinedMcpTool = { name: "mail__broken" }; + setPluginToolMeta(allowedMcpTool as never, { pluginId: "bundle-mcp", optional: false }); + setPluginToolMeta(quarantinedMcpTool as never, { + pluginId: "bundle-mcp", + optional: false, + }); + mocks.getOrCreateSessionMcpRuntime.mockResolvedValue({}); + mocks.materializeBundleMcpToolsForRun.mockResolvedValue({ + tools: [allowedMcpTool, quarantinedMcpTool], + }); + mocks.filterRuntimeCompatibleTools.mockImplementation((tools: Array<{ name: string }>) => ({ + tools: tools.filter((tool) => tool.name !== "mail__broken"), + diagnostics: [{ toolName: "mail__broken", violations: ["unsupported"] }], + })); + const input = createInput([], [coreTool]); + const captureRef: { value?: { version: 1; source: "final-executable-surface" } } = {}; + input.preparedToolBase.cronCreatorToolAllowlistCaptureRef = captureRef; + + await prepareEmbeddedAttemptBundleTools(input); + + expect(input.preparedToolBase.cronCreatorToolAllowlist).toEqual([ + { name: "automations" }, + { name: "mail__read", pluginId: "bundle-mcp" }, + ]); + expect(captureRef.value).toEqual({ + version: 1, + source: "final-executable-surface", + }); + }); + it("disposes prepared bundle runtimes when later policy setup fails", async () => { const disposeMcp = vi.fn(async () => {}); const disposeLsp = vi.fn(async () => {}); diff --git a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts index 092201f30867..f12a98cc5cf8 100644 --- a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts +++ b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts @@ -10,7 +10,7 @@ import { isRuntimeToolAllowed } from "../../tool-policy-match.js"; import { replaceWithEffectiveToolAllowlist } from "../../tool-policy.js"; import { filterRuntimeCompatibleTools } from "../../tool-schema-projection.js"; import { logRuntimeToolSchemaQuarantine } from "../../tool-schema-quarantine.js"; -import { replaceWithEffectiveCronCreatorToolAllowlist } from "../../tools/cron-tool.js"; +import { captureFinalEffectiveCronCreatorToolAllowlist } from "../../tools/cron-tool.js"; import { applyFinalEffectiveToolPolicy } from "../effective-tool-policy.js"; import { log } from "../logger.js"; import type { prepareEmbeddedAttemptSetup } from "./attempt-setup.js"; @@ -37,6 +37,7 @@ export async function prepareEmbeddedAttemptBundleTools(params: { }) { const { cronCreatorToolAllowlist, + cronCreatorToolAllowlistCaptureRef, effectiveToolsAllow, inheritedToolAllowlist, localModelLeanPreserveToolNames, @@ -205,15 +206,17 @@ export async function prepareEmbeddedAttemptBundleTools(params: { agentId: params.sessionAgentId, preserveToolNames: localModelLeanPreserveToolNames, }); - if (cronCreatorToolAllowlist.length > 0) { - // Cron is built before bundled tools; refresh its cap against the complete surface. - replaceWithEffectiveCronCreatorToolAllowlist( + const schemaProjection = filterRuntimeCompatibleTools(projectedTools); + if (cronCreatorToolAllowlistCaptureRef) { + // Cron is constructed before bundled tools; capture only the executable + // surface that survived provider normalization and schema quarantine. + captureFinalEffectiveCronCreatorToolAllowlist( cronCreatorToolAllowlist, - projectedTools, + cronCreatorToolAllowlistCaptureRef, + schemaProjection.tools, (tool) => getPluginToolMeta(tool), ); } - const schemaProjection = filterRuntimeCompatibleTools(projectedTools); if (inheritedToolAllowlist?.length) { // Spawn tools close over this ref before MCP/LSP materialize. Refresh it // only after final policy and schema projection so children inherit the diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts index 5b88cd4b662c..d625164299f3 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts @@ -22,7 +22,10 @@ import { } from "../../tool-search.js"; import { resolveAgentToolSurfacePlan } from "../../tool-surface-plan.js"; import type { ComputerContextEpoch } from "../../tools/computer-tool.js"; -import type { CronCreatorToolAllowlistEntry } from "../../tools/cron-tool.js"; +import type { + CronCreatorToolAllowlistEntry, + CronToolsAllowCaptureRef, +} from "../../tools/cron-tool.js"; import { log } from "../logger.js"; import { applyEmbeddedAttemptToolsAllow, @@ -110,6 +113,7 @@ export function prepareEmbeddedAttemptToolBase(params: { const toolSearchTargetTranscriptProjections: ToolSearchTargetTranscriptProjection[] = []; const codeModeSkills = attempt.toolsAllow?.length ? [] : params.codeModeSkills; const cronCreatorToolAllowlist: CronCreatorToolAllowlistEntry[] = []; + const cronCreatorToolAllowlistCaptureRef: CronToolsAllowCaptureRef = {}; const inheritedToolAllowlist: string[] = []; const spawnWorkspaceDir = params.effectiveCwd !== params.effectiveWorkspace @@ -301,6 +305,7 @@ export function prepareEmbeddedAttemptToolBase(params: { runtimeToolAllowlist: effectiveToolsAllow, inheritedToolAllowlistRef: inheritedToolAllowlist, cronCreatorToolAllowlistRef: cronCreatorToolAllowlist, + cronCreatorToolAllowlistCaptureRef, authProfileStore: attempt.authProfileStore, recordToolPrepStage: params.markCoreToolStage, onToolOutcome: attempt.onToolOutcome, @@ -333,6 +338,7 @@ export function prepareEmbeddedAttemptToolBase(params: { codeModeSkills, computerContextEpoch, cronCreatorToolAllowlist, + cronCreatorToolAllowlistCaptureRef, effectiveToolsAllow, forceDirectMessageTool, inheritedToolAllowlist, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 47e2a0fc1b88..9eebd6a55c11 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -260,6 +260,8 @@ export type RunEmbeddedAgentParams = { trustedInternalHandoff?: TrustedSubagentCompletionHandoff; /** Trusted server-stamped authority for an explicitly capped scheduled run. */ scheduledToolPolicy?: ScheduledToolPolicyContext; + /** Ephemeral reason fresh local-operator cron authority cannot survive this queued turn. */ + cronCreatorAuthorityUnavailableReason?: "queued-local-operator"; /** Seen bootstrap truncation warning signatures for this session (once mode dedupe). */ bootstrapPromptWarningSignaturesSeen?: string[]; /** Last shown bootstrap truncation warning signature for this session. */ diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index 75ad1ecb4cb2..a02fdd427719 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -386,6 +386,7 @@ export async function dispatchEmbeddedRunAttempt(input: { inputProvenance: params.inputProvenance, trustedInternalHandoff: params.trustedInternalHandoff, scheduledToolPolicy: params.scheduledToolPolicy, + cronCreatorAuthorityUnavailableReason: params.cronCreatorAuthorityUnavailableReason, streamParams: params.streamParams, modelRun: params.modelRun, disableTrajectory: params.disableTrajectory, diff --git a/src/agents/mcp-codex-tool-approval.test.ts b/src/agents/mcp-codex-tool-approval.test.ts new file mode 100644 index 000000000000..5fc3308a576f --- /dev/null +++ b/src/agents/mcp-codex-tool-approval.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeMcpCodexToolAnnotations, + requiresMcpCodexToolApproval, + resolveMcpCodexToolApprovalMode, + resolveProjectedMcpCodexToolApprovalMode, +} from "./mcp-codex-tool-approval.js"; + +describe("Codex MCP tool approval projection", () => { + it("keeps native projection optional while scheduled enforcement defaults to auto", () => { + const server = { command: "example-mcp" }; + expect(resolveProjectedMcpCodexToolApprovalMode("example", server)).toBeUndefined(); + expect(resolveMcpCodexToolApprovalMode("example", server)).toBe("auto"); + }); + + it("preserves explicit modes and the loopback OpenClaw approval exception", () => { + expect( + resolveMcpCodexToolApprovalMode("example", { + command: "example-mcp", + codex: { defaultToolsApprovalMode: "prompt" }, + }), + ).toBe("prompt"); + expect( + resolveProjectedMcpCodexToolApprovalMode("openclaw", { + url: "http://127.0.0.1:18789/mcp", + }), + ).toBe("approve"); + }); + + it.each([ + { mode: "approve" as const, annotations: {}, expected: false }, + { mode: "prompt" as const, annotations: { readOnlyHint: true }, expected: true }, + { mode: "auto" as const, annotations: { destructiveHint: true }, expected: true }, + { mode: "auto" as const, annotations: { readOnlyHint: true }, expected: false }, + { + mode: "auto" as const, + annotations: { destructiveHint: false, openWorldHint: false }, + expected: false, + }, + { mode: "auto" as const, annotations: { destructiveHint: false }, expected: true }, + { mode: "auto" as const, annotations: {}, expected: true }, + ])("resolves $mode with $annotations", ({ mode, annotations, expected }) => { + expect(requiresMcpCodexToolApproval({ mode, annotations })).toBe(expected); + }); + + it("copies only boolean MCP annotations", () => { + expect( + normalizeMcpCodexToolAnnotations({ + readOnlyHint: true, + destructiveHint: "false", + idempotentHint: false, + extra: true, + }), + ).toEqual({ readOnlyHint: true, idempotentHint: false }); + }); +}); diff --git a/src/agents/mcp-codex-tool-approval.ts b/src/agents/mcp-codex-tool-approval.ts new file mode 100644 index 000000000000..76547d951d0f --- /dev/null +++ b/src/agents/mcp-codex-tool-approval.ts @@ -0,0 +1,87 @@ +import type { McpCodexToolApprovalMode, McpServerConfig } from "../config/types.mcp.js"; + +export type McpCodexToolAnnotations = { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +}; + +const APPROVAL_MODES = new Set(["auto", "prompt", "approve"]); + +function normalizeApprovalMode(value: unknown): McpCodexToolApprovalMode | undefined { + return typeof value === "string" && APPROVAL_MODES.has(value as McpCodexToolApprovalMode) + ? (value as McpCodexToolApprovalMode) + : undefined; +} + +function isOpenClawLoopbackServer(name: string, server: McpServerConfig): boolean { + return ( + name === "openclaw" && + typeof server.url === "string" && + /^https?:\/\/(?:127\.0\.0\.1|localhost):\d+\/mcp(?:[?#].*)?$/.test(server.url) + ); +} + +/** Mirrors the approval default projected into Codex native MCP config. */ +export function resolveProjectedMcpCodexToolApprovalMode( + serverName: string, + server: McpServerConfig, +): McpCodexToolApprovalMode | undefined { + const codex = + server.codex && typeof server.codex === "object" && !Array.isArray(server.codex) + ? (server.codex as Record) + : {}; + return ( + normalizeApprovalMode(codex.defaultToolsApprovalMode) ?? + normalizeApprovalMode(codex.default_tools_approval_mode) ?? + (isOpenClawLoopbackServer(serverName, server) ? "approve" : undefined) + ); +} + +export function resolveMcpCodexToolApprovalMode( + serverName: string, + server: McpServerConfig, +): McpCodexToolApprovalMode { + return resolveProjectedMcpCodexToolApprovalMode(serverName, server) ?? "auto"; +} + +export function normalizeMcpCodexToolAnnotations(value: unknown): McpCodexToolAnnotations { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + const record = value as Record; + const result: McpCodexToolAnnotations = {}; + for (const key of [ + "readOnlyHint", + "destructiveHint", + "idempotentHint", + "openWorldHint", + ] as const) { + if (typeof record[key] === "boolean") { + result[key] = record[key]; + } + } + return result; +} + +/** Mirrors Codex `auto` approval semantics for unattended dynamic execution. */ +export function requiresMcpCodexToolApproval(params: { + mode: McpCodexToolApprovalMode; + annotations?: McpCodexToolAnnotations; +}): boolean { + if (params.mode === "approve") { + return false; + } + if (params.mode === "prompt") { + return true; + } + const annotations = params.annotations ?? {}; + if (annotations.destructiveHint === true) { + return true; + } + if (annotations.readOnlyHint === true) { + return false; + } + return annotations.destructiveHint !== false || annotations.openWorldHint !== false; +} diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 8a005152dbaa..a1d0b0fc7b0e 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -57,7 +57,8 @@ import { createConversationsSendTool, createConversationsTurnTool, } from "./tools/conversation-tools.js"; -import { createCronTool, type CronCreatorToolAllowlistEntry } from "./tools/cron-tool.js"; +import { createCronTool } from "./tools/cron-tool.js"; +import type { CronToolOptions } from "./tools/cron-tool.types.js"; import { createDashboardTool } from "./tools/dashboard-tool.js"; import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js"; import { createGatewayToolCallerWrapper } from "./tools/gateway-caller-context.js"; @@ -135,7 +136,10 @@ export function createOpenClawTools( pluginToolAllowlist?: string[]; pluginToolDenylist?: string[]; /** Effective caller tool surface to persist on isolated cron agentTurn jobs. */ - cronCreatorToolAllowlist?: CronCreatorToolAllowlistEntry[]; + cronCreatorToolAllowlist?: CronToolOptions["creatorToolAllowlist"]; + cronCreatorToolAllowlistCaptureRef?: CronToolOptions["creatorToolAllowlistCaptureRef"]; + resolveCronCreatorToolAuthority?: CronToolOptions["resolveCreatorToolAuthority"]; + cronCreatorAuthorityUnavailableReason?: CronToolOptions["creatorAuthorityUnavailableReason"]; /** Current channel ID for auto-threading. */ currentChannelId?: string; /** Trusted normalized conversation kind for the active inbound turn. */ @@ -452,7 +456,6 @@ export function createOpenClawTools( allowlist: explicitFactoryAllowlist, denylist: explicitFactoryDenylist, }); - const includeSubagentSpawnTool = !embedded || options?.allowGatewaySubagentBinding === true; const effectiveCallGateway = embedded ? createEmbeddedCallGateway() : callGateway; const includeUpdatePlanTool = shouldIncludeUpdatePlanToolForOpenClawTools({ config: resolvedConfig, @@ -485,9 +488,7 @@ export function createOpenClawTools( }), ]), createCronTool({ - // attempt-tool-base-prepare preserves the durable store key as runSessionKey. - // Cron bindings, wakes, and reminder history need that transcript owner; a - // policy-scoped DM key can be empty and cleanup-retired, leaving jobs dangling. + // Use the durable runSessionKey; cleanup-retired policy keys leave cron jobs dangling. agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey, agentAccountId: gatewayCallerAccountId, currentDeliveryContext: { @@ -497,10 +498,11 @@ export function createOpenClawTools( threadId: options?.currentThreadTs ?? options?.agentThreadId, }, creatorToolAllowlist: options?.cronCreatorToolAllowlist, + creatorToolAllowlistCaptureRef: options?.cronCreatorToolAllowlistCaptureRef, + resolveCreatorToolAuthority: options?.resolveCronCreatorToolAuthority, + creatorAuthorityUnavailableReason: options?.cronCreatorAuthorityUnavailableReason, runId: options?.runId, - ...(options?.cronSelfRemoveOnlyJobId - ? { selfRemoveOnlyJobId: options.cronSelfRemoveOnlyJobId } - : {}), + selfRemoveOnlyJobId: options?.cronSelfRemoveOnlyJobId, }), createSessionsTool({ agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey, @@ -654,7 +656,7 @@ export function createOpenClawTools( config: resolvedConfig, }), ]), - ...(includeSubagentSpawnTool + ...(!embedded || options?.allowGatewaySubagentBinding === true ? [ createSessionsSpawnTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/tools/cron-tool-caller-scope.ts b/src/agents/tools/cron-tool-caller-scope.ts new file mode 100644 index 000000000000..10f1cca9ed70 --- /dev/null +++ b/src/agents/tools/cron-tool-caller-scope.ts @@ -0,0 +1,69 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; +import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; +import { resolveSessionAgentId } from "../agent-scope.js"; +import type { CronToolCallerScope, CronToolOptions } from "./cron-tool.types.js"; + +export function resolveCronToolCallerScope( + opts: CronToolOptions | undefined, + cfg: OpenClawConfig, +): CronToolCallerScope | undefined { + const sessionKey = opts?.agentSessionKey?.trim(); + if (!sessionKey) { + return undefined; + } + return { + kind: "agentTool", + agentId: resolveSessionAgentId({ sessionKey, config: cfg }), + }; +} + +export function readCronToolAgentId(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? normalizeAgentId(value) : undefined; +} + +function readAgentIdFromCronToolSessionRef(value: unknown): string | undefined { + return typeof value === "string" && value.trim() + ? parseAgentSessionKey(value.trim())?.agentId + : undefined; +} + +function readAgentIdFromCronToolSessionTarget(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed.startsWith("session:")) { + return undefined; + } + return readAgentIdFromCronToolSessionRef(trimmed.slice("session:".length)); +} + +export function assertCronToolAgentFieldMatchesScope(params: { + value: unknown; + field: string; + callerScope: CronToolCallerScope; +}): void { + if (params.value === undefined) { + return; + } + const agentId = readCronToolAgentId(params.value); + if (agentId && agentId === params.callerScope.agentId) { + return; + } + throw new Error(`${params.field} must match the calling agent`); +} + +export function assertCronToolSessionRefsMatchScope( + value: Record, + callerScope: CronToolCallerScope, +): void { + const sessionAgentId = readAgentIdFromCronToolSessionRef(value.sessionKey); + if (sessionAgentId && normalizeAgentId(sessionAgentId) !== callerScope.agentId) { + throw new Error("automations sessionKey must match the calling agent"); + } + const sessionTargetAgentId = readAgentIdFromCronToolSessionTarget(value.sessionTarget); + if (sessionTargetAgentId && normalizeAgentId(sessionTargetAgentId) !== callerScope.agentId) { + throw new Error("automations sessionTarget must match the calling agent"); + } +} diff --git a/src/agents/tools/cron-tool-creator-cap.test.ts b/src/agents/tools/cron-tool-creator-cap.test.ts index d296a39408b1..619e821b873c 100644 --- a/src/agents/tools/cron-tool-creator-cap.test.ts +++ b/src/agents/tools/cron-tool-creator-cap.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { mergeCronPayload } from "../../cron/service/payload-merge.js"; +import type { CronPayloadPatch } from "../../cron/types.js"; import { capCronJobToolsAllowOnCreate, planCronJobUpdatePatch } from "./cron-tool-creator-cap.js"; type CronJobUpdatePatchPlan = ReturnType; @@ -72,14 +74,17 @@ describe("cron tool creator cap", () => { ).toEqual({ kind: "needs-current-job" }); }); - it("preserves explicit narrower caps and re-derives stored defaults", () => { + it("preserves explicit narrower and default caps through canonical payload merge", () => { + const storedNarrowerPayload = { + kind: "agentTurn" as const, + message: "work", + toolsAllow: ["read"], + }; const narrower = readReadyPatch( planCronJobUpdatePatch({ patch: { payload: { message: "updated" } }, creatorToolAllowlist: ["read", "exec", "cron"], - currentJob: { - payload: { kind: "agentTurn", message: "work", toolsAllow: ["read"] }, - }, + currentJob: { payload: storedNarrowerPayload }, }), ); const storedDefault = readReadyPatch( @@ -97,16 +102,28 @@ describe("cron tool creator cap", () => { }), ); - expect(narrower).toEqual({ - payload: { kind: "agentTurn", message: "updated", toolsAllow: ["read"] }, + expect(narrower).toEqual({ payload: { kind: "agentTurn", message: "updated" } }); + expect(mergeCronPayload(storedNarrowerPayload, narrower.payload as CronPayloadPatch)).toEqual({ + kind: "agentTurn", + message: "updated", + toolsAllow: ["read"], }); - expect(storedDefault).toEqual({ - payload: { - kind: "agentTurn", - message: "updated", - toolsAllow: ["read", "automations"], - toolsAllowIsDefault: true, - }, + expect(storedDefault).toEqual({ payload: { kind: "agentTurn", message: "updated" } }); + expect( + mergeCronPayload( + { + kind: "agentTurn", + message: "work", + toolsAllow: ["read"], + toolsAllowIsDefault: true, + }, + storedDefault.payload as CronPayloadPatch, + ), + ).toEqual({ + kind: "agentTurn", + message: "updated", + toolsAllow: ["read"], + toolsAllowIsDefault: true, }); }); diff --git a/src/agents/tools/cron-tool-creator-cap.ts b/src/agents/tools/cron-tool-creator-cap.ts index 818bd3d398ee..2b9154e03bc8 100644 --- a/src/agents/tools/cron-tool-creator-cap.ts +++ b/src/agents/tools/cron-tool-creator-cap.ts @@ -6,7 +6,7 @@ import { expandToolGroups, normalizeToolName, } from "../tool-policy.js"; -import type { CronCreatorToolAllowlistEntry } from "./cron-tool.types.js"; +import type { CronCreatorToolAllowlistEntry, CronToolsAllowCaptureRef } from "./cron-tool.types.js"; type NormalizedCronCreatorTool = { name: string; @@ -15,7 +15,61 @@ type NormalizedCronCreatorTool = { type CronJobUpdatePatchPlan = | { kind: "ready"; patch: Record } - | { kind: "needs-current-job" }; + | { kind: "needs-current-job" } + | { kind: "needs-creator-authority" }; + +export const CRON_CREATOR_AUTHORITY_RECOVERY_MESSAGE = + "Retry from a fresh authenticated direct-local operator turn, or create/edit via the CLI or Gateway with an explicit finite toolsAllow list containing only currently visible tools; no automation changes were saved."; +export const INCOMPLETE_CRON_CREATOR_AUTHORITY_MESSAGE = `Configured MCP authority is unavailable because this turn did not capture the complete model-callable tool surface. ${CRON_CREATOR_AUTHORITY_RECOVERY_MESSAGE}`; + +/** No capture marker means this runtime has no deferred configured-MCP surface. */ +export function isCronCreatorToolCaptureComplete( + captureRef: CronToolsAllowCaptureRef | undefined, +): boolean { + return captureRef === undefined || captureRef.value?.source === "final-executable-surface"; +} + +export function assertInheritedCronToolCaptureReady( + value: unknown, + captureRef: CronToolsAllowCaptureRef | undefined, +): void { + const payload = isRecord(value) && isRecord(value.payload) ? value.payload : undefined; + if (payload?.toolsAllowIsDefault !== true || isCronCreatorToolCaptureComplete(captureRef)) { + return; + } + throw new Error(INCOMPLETE_CRON_CREATOR_AUTHORITY_MESSAGE); +} + +export function replaceWithEffectiveCronCreatorToolAllowlist( + target: CronCreatorToolAllowlistEntry[], + tools: readonly T[], + toolMeta?: (tool: T) => { pluginId?: string } | undefined, +): void { + target.length = 0; + const seen = new Set(); + for (const tool of tools) { + const name = normalizeToolName(tool.name); + if (!name || seen.has(name)) { + continue; + } + seen.add(name); + const meta = toolMeta?.(tool); + const pluginId = + typeof meta?.pluginId === "string" ? normalizeToolName(meta.pluginId) : undefined; + target.push(pluginId ? { name, pluginId } : { name }); + } +} + +/** Records the creator cap only after every runtime policy and schema quarantine has run. */ +export function captureFinalEffectiveCronCreatorToolAllowlist( + target: CronCreatorToolAllowlistEntry[], + captureRef: CronToolsAllowCaptureRef, + tools: readonly T[], + toolMeta?: (tool: T) => { pluginId?: string } | undefined, +): void { + replaceWithEffectiveCronCreatorToolAllowlist(target, tools, toolMeta); + captureRef.value = { version: 1, source: "final-executable-surface" }; +} function normalizeCronToolsAllow(values: readonly string[]): string[] { const normalized: string[] = []; @@ -55,6 +109,70 @@ function hasCronTriggerScript(value: unknown): boolean { return isRecord(value) && typeof value.script === "string" && value.script.trim().length > 0; } +function classifyExplicitToolsAllow( + payload: Record | undefined, +): "absent" | "empty" | "finite" | "resolved" { + if (!payload || !Object.hasOwn(payload, "toolsAllow")) { + return "absent"; + } + if (!Array.isArray(payload.toolsAllow)) { + return "resolved"; + } + const values = payload.toolsAllow.filter((entry): entry is string => typeof entry === "string"); + if (values.length === 0) { + return "empty"; + } + return values.some((entry) => { + const normalized = normalizeToolName(entry); + return normalized === "*" || normalized.startsWith("group:"); + }) + ? "resolved" + : "finite"; +} + +function explicitFiniteToolsNeedResolution( + payload: Record | undefined, + creatorToolAllowlist: readonly CronCreatorToolAllowlistEntry[] | undefined, +): boolean { + if (classifyExplicitToolsAllow(payload) !== "finite") { + return false; + } + const toolsAllow = payload?.toolsAllow; + if (!Array.isArray(toolsAllow)) { + return false; + } + const creatorNames = new Set( + normalizeCronCreatorToolsAllow(creatorToolAllowlist ?? []).map((tool) => tool.name), + ); + return normalizeCronToolsAllow( + toolsAllow.filter((entry): entry is string => typeof entry === "string"), + ).some((name) => !creatorNames.has(name)); +} + +/** Whether an add needs the creator's complete authority rather than an explicit empty cap. */ +export function cronCreateRequiresCreatorAuthority( + value: unknown, + creatorToolAllowlist?: readonly CronCreatorToolAllowlistEntry[], +): boolean { + if (!isRecord(value)) { + return false; + } + const payload = isRecord(value.payload) ? value.payload : undefined; + const explicitToolsAllow = classifyExplicitToolsAllow(payload); + if (explicitToolsAllow === "empty") { + return false; + } + if (explicitToolsAllow === "finite") { + return explicitFiniteToolsNeedResolution(payload, creatorToolAllowlist); + } + return ( + hasCronTriggerScript(value.trigger) || + payload?.kind === "agentTurn" || + payload?.kind === "script" || + explicitToolsAllow === "resolved" + ); +} + function capCronJobToolsAllow(params: { payload: Record; trigger?: unknown; @@ -114,7 +232,10 @@ export function capCronJobToolsAllowOnCreate( value: unknown, creatorToolAllowlist: readonly CronCreatorToolAllowlistEntry[] | undefined, ): void { - if (!creatorToolAllowlist || !isRecord(value) || !isRecord(value.payload)) { + if (!isRecord(value) || !isRecord(value.payload)) { + return; + } + if (!creatorToolAllowlist) { return; } capCronJobToolsAllow({ @@ -133,55 +254,91 @@ export function planCronJobUpdatePatch(params: { patch: Record; creatorToolAllowlist: readonly CronCreatorToolAllowlistEntry[] | undefined; currentJob?: Record; + creatorAuthorityComplete?: boolean; }): CronJobUpdatePatchPlan { const patch = structuredClone(params.patch); const payload = isRecord(patch.payload) ? patch.payload : undefined; + const explicitPayloadKind = readCronPayloadKind(payload); + const explicitToolsAllow = classifyExplicitToolsAllow(payload); if (payload === undefined && !Object.hasOwn(patch, "trigger")) { // Schedule, delivery, naming, and enabled-state edits do not reauthorize // legacy jobs. Only tool-runtime changes may synthesize durable authority. return { kind: "ready", patch }; } - const explicitPayloadKind = readCronPayloadKind(payload); + if ( + explicitPayloadKind !== undefined && + explicitToolsAllow === "absent" && + params.creatorAuthorityComplete !== false && + !params.creatorToolAllowlist && + !Object.hasOwn(patch, "trigger") + ) { + return { kind: "ready", patch }; + } + if ( + params.creatorAuthorityComplete === false && + explicitFiniteToolsNeedResolution(payload, params.creatorToolAllowlist) + ) { + return { kind: "needs-creator-authority" }; + } if ( params.creatorToolAllowlist && - explicitPayloadKind !== undefined && - payload && - Object.hasOwn(payload, "toolsAllow") + (explicitToolsAllow === "empty" || explicitToolsAllow === "finite") && + explicitPayloadKind !== undefined ) { capCronJobToolsAllow({ - payload, + payload: payload!, trigger: patch.trigger, creatorToolAllowlist: params.creatorToolAllowlist, }); return { kind: "ready", patch }; } - - const needsStoredPayloadKind = payload !== undefined && explicitPayloadKind === undefined; - if (!needsStoredPayloadKind && !params.creatorToolAllowlist) { - return { kind: "ready", patch }; - } if (!params.currentJob) { return { kind: "needs-current-job" }; } const existingPayload = params.currentJob.payload; + const existingPayloadRecord = isRecord(existingPayload) ? existingPayload : undefined; + const existingPayloadKind = readCronPayloadKind(existingPayload); const payloadKind = explicitPayloadKind ?? readCronPayloadKind(existingPayload); if (payload && payloadKind !== undefined) { payload.kind = payloadKind; patch.payload = payload; } - if (!params.creatorToolAllowlist) { - return { kind: "ready", patch }; - } const trigger = Object.hasOwn(patch, "trigger") ? patch.trigger : params.currentJob.trigger; - const writesToolsAllow = payload !== undefined && Object.hasOwn(payload, "toolsAllow"); + const startsToolPayload = + explicitPayloadKind !== undefined && + explicitPayloadKind !== existingPayloadKind && + (payloadKind === "agentTurn" || payloadKind === "script"); + const startsToolTrigger = + Object.hasOwn(patch, "trigger") && + hasCronTriggerScript(trigger) && + !hasCronTriggerScript(params.currentJob.trigger); + const reusesDefaultAuthority = + explicitToolsAllow === "absent" && + (startsToolPayload || startsToolTrigger) && + (existingPayloadRecord?.toolsAllowIsDefault === true || + !Array.isArray(existingPayloadRecord?.toolsAllow)); + const needsResolvedAuthority = + explicitToolsAllow === "resolved" || + reusesDefaultAuthority || + explicitFiniteToolsNeedResolution(payload, params.creatorToolAllowlist); + if (needsResolvedAuthority && params.creatorAuthorityComplete === false) { + return { kind: "needs-creator-authority" }; + } if ( - payloadKind !== "agentTurn" && - payloadKind !== "script" && - !hasCronTriggerScript(trigger) && - !writesToolsAllow + !needsResolvedAuthority && + (explicitToolsAllow === "empty" || explicitToolsAllow === "finite") && + params.creatorToolAllowlist ) { + capCronJobToolsAllow({ + payload: payload!, + trigger, + creatorToolAllowlist: params.creatorToolAllowlist, + }); + return { kind: "ready", patch }; + } + if (!needsResolvedAuthority || !params.creatorToolAllowlist) { return { kind: "ready", patch }; } @@ -195,8 +352,8 @@ export function planCronJobUpdatePatch(params: { trigger, creatorToolAllowlist: params.creatorToolAllowlist, defaultToolsAllow: - isRecord(existingPayload) && existingPayload.toolsAllowIsDefault !== true - ? existingPayload.toolsAllow + existingPayloadRecord && existingPayloadRecord.toolsAllowIsDefault !== true + ? existingPayloadRecord.toolsAllow : undefined, }); return { kind: "ready", patch }; diff --git a/src/agents/tools/cron-tool-write.ts b/src/agents/tools/cron-tool-write.ts index a675de0cc00b..3c2fc0d71a70 100644 --- a/src/agents/tools/cron-tool-write.ts +++ b/src/agents/tools/cron-tool-write.ts @@ -1,8 +1,18 @@ // Agent cron-tool write safety and optimistic update orchestration. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { isRecord } from "../../utils.js"; -import { planCronJobUpdatePatch } from "./cron-tool-creator-cap.js"; -import type { CronCreatorToolAllowlistEntry, GatewayToolCaller } from "./cron-tool.types.js"; +import { + CRON_CREATOR_AUTHORITY_RECOVERY_MESSAGE, + INCOMPLETE_CRON_CREATOR_AUTHORITY_MESSAGE, + isCronCreatorToolCaptureComplete, + planCronJobUpdatePatch, +} from "./cron-tool-creator-cap.js"; +import type { + CronCreatorToolAllowlistEntry, + CronCreatorToolAuthoritySnapshot, + CronToolsAllowCaptureRef, + GatewayToolCaller, +} from "./cron-tool.types.js"; import type { GatewayCallOptions } from "./gateway.js"; export function assertNoCronShellExecution(value: unknown): void { @@ -25,22 +35,57 @@ export function assertNoCronShellExecution(value: unknown): void { // matching trigger-script trust rather than ordinary agent exec policy. } +export function assertCronCreatorAuthorityResolutionAvailable(params: { + required: boolean; + resolveCreatorToolAuthority?: unknown; + creatorToolAllowlistCaptureRef?: CronToolsAllowCaptureRef; + unavailableReason?: "queued-local-operator-configured-mcp"; +}): void { + if (!params.required || params.resolveCreatorToolAuthority) { + return; + } + if ( + params.unavailableReason === "queued-local-operator-configured-mcp" || + !isCronCreatorToolCaptureComplete(params.creatorToolAllowlistCaptureRef) + ) { + throw new Error( + params.unavailableReason === "queued-local-operator-configured-mcp" + ? `Configured MCP authority is unavailable because this local operator turn was queued. ${CRON_CREATOR_AUTHORITY_RECOVERY_MESSAGE}` + : INCOMPLETE_CRON_CREATOR_AUTHORITY_MESSAGE, + ); + } +} + async function prepareCronJobUpdateForGateway(params: { id: string; patch: Record; creatorToolAllowlist: readonly CronCreatorToolAllowlistEntry[] | undefined; + creatorToolAllowlistCaptureRef?: CronToolsAllowCaptureRef; + creatorAuthorityComplete: boolean; + resolveCreatorToolAuthority?: (options?: { + signal?: AbortSignal; + }) => Promise; + operationSignal?: AbortSignal; + creatorAuthorityUnavailableReason?: "queued-local-operator-configured-mcp"; gatewayOpts: GatewayCallOptions; callGateway: GatewayToolCaller; -}): Promise<{ patch: Record; expectedConfigRevision?: string }> { +}): Promise<{ + patch: Record; + expectedConfigRevision?: string; + resolvedAuthority?: CronCreatorToolAuthoritySnapshot; +}> { + params.operationSignal?.throwIfAborted(); const initialPlan = planCronJobUpdatePatch({ patch: params.patch, creatorToolAllowlist: params.creatorToolAllowlist, + creatorAuthorityComplete: params.creatorAuthorityComplete, }); if (initialPlan.kind === "ready") { return { patch: initialPlan.patch }; } const existing = await params.callGateway("cron.get", params.gatewayOpts, { id: params.id }); + params.operationSignal?.throwIfAborted(); const existingRecord = isRecord(existing) ? existing : undefined; const expectedConfigRevision = existingRecord?.configRevision; if (typeof expectedConfigRevision !== "string" || expectedConfigRevision.length === 0) { @@ -48,15 +93,38 @@ async function prepareCronJobUpdateForGateway(params: { "cron.get response is missing configRevision; restart the Gateway before retrying this update", ); } - const finalPlan = planCronJobUpdatePatch({ + let resolvedAuthority: CronCreatorToolAuthoritySnapshot | undefined; + let finalPlan = planCronJobUpdatePatch({ patch: params.patch, creatorToolAllowlist: params.creatorToolAllowlist, currentJob: existingRecord, + creatorAuthorityComplete: params.creatorAuthorityComplete, }); + if (finalPlan.kind === "needs-creator-authority") { + assertCronCreatorAuthorityResolutionAvailable({ + required: true, + resolveCreatorToolAuthority: params.resolveCreatorToolAuthority, + creatorToolAllowlistCaptureRef: params.creatorToolAllowlistCaptureRef, + unavailableReason: params.creatorAuthorityUnavailableReason, + }); + if (!params.resolveCreatorToolAuthority) { + throw new Error("cron update requires complete creator tool authority"); + } + resolvedAuthority = await params.resolveCreatorToolAuthority({ + signal: params.operationSignal, + }); + params.operationSignal?.throwIfAborted(); + finalPlan = planCronJobUpdatePatch({ + patch: params.patch, + creatorToolAllowlist: resolvedAuthority.tools, + currentJob: existingRecord, + creatorAuthorityComplete: true, + }); + } if (finalPlan.kind !== "ready") { throw new Error("cron update patch planning did not use the loaded job"); } - return { patch: finalPlan.patch, expectedConfigRevision }; + return { patch: finalPlan.patch, expectedConfigRevision, resolvedAuthority }; } function isCronJobConfigRevisionConflict(error: unknown): boolean { @@ -73,25 +141,71 @@ export async function updateCronJobFromAgentTool(params: { id: string; patch: Record; creatorToolAllowlist: readonly CronCreatorToolAllowlistEntry[] | undefined; + creatorToolAllowlistCaptureRef?: CronToolsAllowCaptureRef; + resolveCreatorToolAuthority?: (options?: { + signal?: AbortSignal; + }) => Promise; + withCreatorAuthorityProvenance?: ( + authority: CronCreatorToolAuthoritySnapshot, + run: () => Promise, + ) => Promise; gatewayOpts: GatewayCallOptions; callGateway: GatewayToolCaller; + operationSignal?: AbortSignal; + creatorAuthorityUnavailableReason?: "queued-local-operator-configured-mcp"; }): Promise { const callerIncludedPayloadPatch = isRecord(params.patch.payload); + let creatorAuthorityPromise: Promise | undefined; + const resolveCreatorToolAuthority = params.resolveCreatorToolAuthority + ? (options?: { signal?: AbortSignal }) => + (creatorAuthorityPromise ??= params.resolveCreatorToolAuthority!(options)) + : undefined; for (let attempt = 0; attempt < 2; attempt += 1) { - const prepared = await prepareCronJobUpdateForGateway(params); + params.operationSignal?.throwIfAborted(); + const prepared = await prepareCronJobUpdateForGateway({ + ...params, + creatorAuthorityComplete: + isCronCreatorToolCaptureComplete(params.creatorToolAllowlistCaptureRef) && + resolveCreatorToolAuthority === undefined && + params.creatorAuthorityUnavailableReason === undefined, + resolveCreatorToolAuthority, + operationSignal: params.operationSignal, + }); if (callerIncludedPayloadPatch) { // Kind-less caller payloads inherit the stored kind above. Recheck those // edits, but not a toolsAllow cap synthesized internally. assertNoCronShellExecution(prepared.patch); } + const payload = isRecord(prepared.patch.payload) ? prepared.patch.payload : undefined; + const captureSource = prepared.resolvedAuthority + ? prepared.resolvedAuthority.provenance.source + : params.creatorToolAllowlistCaptureRef?.value?.source; + if ( + payload?.toolsAllowIsDefault === true && + (prepared.resolvedAuthority || params.creatorToolAllowlistCaptureRef) && + captureSource !== "final-executable-surface" + ) { + throw new Error(INCOMPLETE_CRON_CREATOR_AUTHORITY_MESSAGE); + } + if (prepared.resolvedAuthority && !params.withCreatorAuthorityProvenance) { + throw new Error( + "fresh configured MCP cron authority requires an authenticated local agent run", + ); + } try { - return await params.callGateway("cron.update", params.gatewayOpts, { - id: params.id, - patch: prepared.patch, - ...(prepared.expectedConfigRevision - ? { expectedConfigRevision: prepared.expectedConfigRevision } - : {}), - }); + const write = async () => { + params.operationSignal?.throwIfAborted(); + return await params.callGateway("cron.update", params.gatewayOpts, { + id: params.id, + patch: prepared.patch, + ...(prepared.expectedConfigRevision + ? { expectedConfigRevision: prepared.expectedConfigRevision } + : {}), + }); + }; + return prepared.resolvedAuthority && params.withCreatorAuthorityProvenance + ? await params.withCreatorAuthorityProvenance(prepared.resolvedAuthority, write) + : await write(); } catch (error) { if (attempt === 0 && isCronJobConfigRevisionConflict(error)) { continue; diff --git a/src/agents/tools/cron-tool.flat-params.test.ts b/src/agents/tools/cron-tool.flat-params.test.ts index e615e3d4de4c..007317c08626 100644 --- a/src/agents/tools/cron-tool.flat-params.test.ts +++ b/src/agents/tools/cron-tool.flat-params.test.ts @@ -24,9 +24,13 @@ describe("cron tool flat-params", () => { }); function firstGatewayToolCall(): [string, unknown, TParams] { - const call = callGatewayToolMock.mock.calls[0]; + return gatewayToolCall(0); + } + + function gatewayToolCall(index: number): [string, unknown, TParams] { + const call = callGatewayToolMock.mock.calls[index]; if (!call) { - throw new Error("expected callGatewayTool to be called"); + throw new Error(`expected callGatewayTool call ${index + 1}`); } return call as [string, unknown, TParams]; } @@ -320,6 +324,14 @@ describe("cron tool flat-params", () => { }); it("recovers a flat trigger when updating a job", async () => { + callGatewayToolMock + .mockResolvedValueOnce({ + id: "job-trigger", + configRevision: "sha256:flat-trigger-update", + trigger: null, + payload: { kind: "systemEvent", text: "before" }, + }) + .mockResolvedValueOnce({ ok: true }); const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-flat-trigger-update", { @@ -328,18 +340,32 @@ describe("cron tool flat-params", () => { trigger: { script: "json({ fire: true })", once: false }, }); - const [method, _gatewayOpts, params] = firstGatewayToolCall<{ + const [getMethod, _getGatewayOpts, getParams] = firstGatewayToolCall<{ id?: string }>(); + expect(getMethod).toBe("cron.get"); + expect(getParams).toEqual({ id: "job-trigger" }); + + const [method, _gatewayOpts, params] = gatewayToolCall<{ id?: string; + expectedConfigRevision?: string; patch?: { trigger?: { script?: string; once?: boolean } }; - }>(); + }>(1); expect(method).toBe("cron.update"); expect(params).toEqual({ id: "job-trigger", + expectedConfigRevision: "sha256:flat-trigger-update", patch: { trigger: { script: "json({ fire: true })", once: false } }, }); }); it("recovers a flat trigger clear when updating a job", async () => { + callGatewayToolMock + .mockResolvedValueOnce({ + id: "job-trigger", + configRevision: "sha256:flat-trigger-clear", + trigger: { script: "json({ fire: false })", once: true }, + payload: { kind: "systemEvent", text: "before" }, + }) + .mockResolvedValueOnce({ ok: true }); const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); await tool.execute("call-flat-trigger-clear", { @@ -348,12 +374,21 @@ describe("cron tool flat-params", () => { trigger: null, }); - const [method, _gatewayOpts, params] = firstGatewayToolCall<{ + const [getMethod, _getGatewayOpts, getParams] = firstGatewayToolCall<{ id?: string }>(); + expect(getMethod).toBe("cron.get"); + expect(getParams).toEqual({ id: "job-trigger" }); + + const [method, _gatewayOpts, params] = gatewayToolCall<{ id?: string; + expectedConfigRevision?: string; patch?: { trigger?: null }; - }>(); + }>(1); expect(method).toBe("cron.update"); - expect(params).toEqual({ id: "job-trigger", patch: { trigger: null } }); + expect(params).toEqual({ + id: "job-trigger", + expectedConfigRevision: "sha256:flat-trigger-clear", + patch: { trigger: null }, + }); }); it("trims trailing whitespace from recognized job object keys (#95407)", async () => { diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index eb2f1463ad40..8c8ee20ba300 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -20,7 +20,19 @@ vi.mock("../../config/sessions/delivery-info.js", () => ({ })); import { GatewayClientRequestError } from "../../gateway/client.js"; +import { + consumeCronCreatorAuthorityGrant, + createCronCreatorAuthorityRunScope, + mintCronCreatorAuthorityGrant, + revokeCronCreatorAuthorityRunScope, + type CronCreatorAuthorityGrant, +} from "../../gateway/cron-creator-authority-grant.js"; import { buildAgentPeerSessionKey } from "../../routing/session-key.js"; +import { + bindActiveCronCreatorAuthorityResolver, + runWithCronCreatorAuthority, + runWithCronCreatorAuthorityResolver, +} from "../cron-creator-authority-context.js"; import { createCronTool } from "./cron-tool.js"; import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js"; @@ -61,6 +73,17 @@ describe("cron tool", () => { }); } + function resolvedCreatorAuthority( + tools: readonly (string | { name: string; pluginId?: string })[], + grant: CronCreatorAuthorityGrant = { runId: "run-test", token: "grant-test" }, + ) { + return { + tools, + provenance: { version: 1 as const, source: "final-executable-surface" as const }, + grant, + }; + } + function readGatewayCall(index = 0): { method?: string; params?: Record } { return ( (callGatewayMock.mock.calls[index]?.[0] as @@ -808,6 +831,9 @@ describe("cron tool", () => { const tool = createTestCronTool(); expect(tool.description).toContain("reminders, delayed self-wakeups, loops, recurring work"); expect(tool.description).toContain("Never exec sleep/poll as timer."); + expect(tool.description).toContain( + "Inherited configured MCP authority includes only model-callable tools; interactive app-view-only capabilities are excluded from headless jobs.", + ); }); it("documents the event-trigger authoring contract", () => { @@ -1417,6 +1443,365 @@ describe("cron tool", () => { expect(params?.payload?.toolsAllow).toEqual(["read", "automations"]); }); + it("lazily snapshots configured MCP authority for a default agentTurn add", async () => { + const identities: unknown[] = []; + callGatewayMock.mockImplementation(async () => { + identities.push(getGatewayToolCallerIdentity()); + return { ok: true }; + }); + const resolveCreatorToolAuthority = vi.fn(async () => + resolvedCreatorAuthority(["read", "cron", "configured__lookup"]), + ); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + creatorToolAllowlist: ["read", "cron"], + resolveCreatorToolAuthority, + }); + + await tool.execute("call-default-configured-mcp", { + action: "add", + job: buildReminderAgentTurnJob(), + }); + + expect(resolveCreatorToolAuthority).toHaveBeenCalledOnce(); + expect(readGatewayCall().params).toMatchObject({ + payload: { + toolsAllow: ["read", "automations", "configured__lookup"], + toolsAllowIsDefault: true, + }, + }); + expect(identities).toEqual([ + expect.objectContaining({ cronToolsAllowCapture: "final-executable-surface" }), + ]); + }); + + it("does not write when the admitted run aborts while lazy authority resolves", async () => { + let finishResolution!: () => void; + const resolution = new Promise((resolve) => { + finishResolution = resolve; + }); + const abortController = new AbortController(); + const run = runWithCronCreatorAuthority( + "run-timeout", + () => { + const resolveCreatorToolAuthority = runWithCronCreatorAuthorityResolver({ + runId: "run-timeout", + resolve: async () => { + await resolution; + return { + tools: ["read", "configured__lookup"], + provenance: { version: 1, source: "final-executable-surface" }, + }; + }, + run: () => bindActiveCronCreatorAuthorityResolver("run-timeout"), + }); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + resolveCreatorToolAuthority, + }); + return tool.execute("call-late-authority-timeout", { + action: "add", + job: buildReminderAgentTurnJob(), + }); + }, + abortController.signal, + ); + + abortController.abort(new Error("run timed out")); + finishResolution(); + await expect(run).rejects.toThrow(); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("does not mint or write when the exact cron tool call aborts during discovery", async () => { + let finishResolution!: () => void; + let discoverySignal: AbortSignal | undefined; + const resolution = new Promise((resolve) => { + finishResolution = resolve; + }); + const operation = new AbortController(); + const run = runWithCronCreatorAuthority("run-operation-timeout", () => { + const resolveCreatorToolAuthority = runWithCronCreatorAuthorityResolver({ + runId: "run-operation-timeout", + resolve: async (options) => { + discoverySignal = options?.signal; + await resolution; + return { + tools: ["read", "configured__lookup"], + provenance: { version: 1, source: "final-executable-surface" }, + }; + }, + run: () => bindActiveCronCreatorAuthorityResolver("run-operation-timeout"), + }); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + resolveCreatorToolAuthority, + }); + return tool.execute( + "call-operation-authority-timeout", + { action: "add", job: buildReminderAgentTurnJob() }, + operation.signal, + ); + }); + + operation.abort(new Error("cron tool call timed out")); + finishResolution(); + + await expect(run).rejects.toThrow("cron tool call timed out"); + expect(discoverySignal?.aborted).toBe(true); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("lets a later cron operation rematerialize after an earlier operation abort", async () => { + let finishFirstResolution!: () => void; + const firstResolution = new Promise((resolve) => { + finishFirstResolution = resolve; + }); + let materializations = 0; + callGatewayMock.mockImplementation(async () => { + const grant = getGatewayToolCallerIdentity()?.cronCreatorAuthorityGrant; + expect(grant).toBeDefined(); + consumeCronCreatorAuthorityGrant(grant!); + return { ok: true }; + }); + + await runWithCronCreatorAuthority("run-operation-retry", async () => { + const resolveCreatorToolAuthority = runWithCronCreatorAuthorityResolver({ + runId: "run-operation-retry", + resolve: async () => { + materializations += 1; + if (materializations === 1) { + await firstResolution; + } + return { + tools: ["read", "configured__lookup"], + provenance: { version: 1, source: "final-executable-surface" }, + }; + }, + run: () => bindActiveCronCreatorAuthorityResolver("run-operation-retry"), + }); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + resolveCreatorToolAuthority, + }); + const firstOperation = new AbortController(); + const firstWrite = tool.execute( + "call-operation-retry-first", + { action: "add", job: buildReminderAgentTurnJob() }, + firstOperation.signal, + ); + firstOperation.abort(new Error("first cron call timed out")); + finishFirstResolution(); + await expect(firstWrite).rejects.toThrow("first cron call timed out"); + expect(callGatewayMock).not.toHaveBeenCalled(); + + await tool.execute( + "call-operation-retry-second", + { action: "add", job: buildReminderAgentTurnJob() }, + new AbortController().signal, + ); + }); + + expect(materializations).toBe(2); + expect(callGatewayMock).toHaveBeenCalledOnce(); + }); + + it("does not commit when the exact cron tool call aborts after grant mint", async () => { + const operation = new AbortController(); + let committedWrites = 0; + callGatewayMock.mockImplementation(async () => { + const grant = getGatewayToolCallerIdentity()?.cronCreatorAuthorityGrant; + expect(grant).toBeDefined(); + operation.abort(new Error("cron tool call timed out before commit")); + consumeCronCreatorAuthorityGrant(grant!); + committedWrites += 1; + return { ok: true }; + }); + const run = runWithCronCreatorAuthority("run-abort-before-commit", () => { + const resolveCreatorToolAuthority = runWithCronCreatorAuthorityResolver({ + runId: "run-abort-before-commit", + resolve: async () => ({ + tools: ["read", "configured__lookup"], + provenance: { version: 1, source: "final-executable-surface" }, + }), + run: () => bindActiveCronCreatorAuthorityResolver("run-abort-before-commit"), + }); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + resolveCreatorToolAuthority, + }); + return tool.execute( + "call-abort-before-commit", + { action: "add", job: buildReminderAgentTurnJob() }, + operation.signal, + ); + }); + + await expect(run).rejects.toThrow("Configured MCP cron authority is no longer active"); + expect(committedWrites).toBe(0); + }); + + it("fails a queued configured-MCP default add visibly without writing", async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + creatorToolAllowlist: ["read", "cron"], + creatorAuthorityUnavailableReason: "queued-local-operator-configured-mcp", + }); + + await expect( + tool.execute("call-queued-configured-mcp-add", { + action: "add", + job: buildReminderAgentTurnJob(), + }), + ).rejects.toThrow("fresh authenticated direct-local operator turn"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it.each([ + ["finite", ["read"]], + ["empty", []], + ])("keeps an explicit %s add offline and exact", async (_label, toolsAllow) => { + const resolveCreatorToolAuthority = vi.fn(async () => { + throw new Error("must stay offline"); + }); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + creatorToolAllowlist: ["read", "cron"], + resolveCreatorToolAuthority, + }); + + await tool.execute("call-explicit-configured-mcp", { + action: "add", + job: { + ...buildReminderAgentTurnJob(), + payload: { kind: "agentTurn", message: "hello", toolsAllow }, + }, + }); + + expect(resolveCreatorToolAuthority).not.toHaveBeenCalled(); + expect(readGatewayCall().params).toMatchObject({ payload: { toolsAllow } }); + }); + + it("resolves an unknown finite add name and cannot pre-authorize a future tool", async () => { + const resolveCreatorToolAuthority = vi.fn(async () => resolvedCreatorAuthority(["read"])); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + creatorToolAllowlist: ["read"], + resolveCreatorToolAuthority, + }); + + await tool.execute("call-future-configured-mcp", { + action: "add", + job: { + ...buildReminderAgentTurnJob(), + payload: { kind: "agentTurn", message: "hello", toolsAllow: ["future__tool"] }, + }, + }); + + expect(resolveCreatorToolAuthority).toHaveBeenCalledOnce(); + expect(readGatewayCall().params).toMatchObject({ payload: { toolsAllow: [] } }); + }); + + it("keeps future-tool prevention for complete runtimes without a capture marker", async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + creatorToolAllowlist: ["read"], + }); + + await tool.execute("call-future-no-capture-marker", { + action: "add", + job: { + ...buildReminderAgentTurnJob(), + payload: { kind: "agentTurn", message: "hello", toolsAllow: ["future__tool"] }, + }, + }); + + expect(readGatewayCall().params).toMatchObject({ payload: { toolsAllow: [] } }); + }); + + it("resolves symbolic groups before persisting an add cap", async () => { + const resolveCreatorToolAuthority = vi.fn(async () => + resolvedCreatorAuthority(["read", { name: "configured__lookup", pluginId: "bundle-mcp" }]), + ); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + resolveCreatorToolAuthority, + }); + + await tool.execute("call-symbolic-configured-mcp", { + action: "add", + job: { + ...buildReminderAgentTurnJob(), + payload: { kind: "agentTurn", message: "hello", toolsAllow: ["group:plugins"] }, + }, + }); + + expect(resolveCreatorToolAuthority).toHaveBeenCalledOnce(); + expect(readGatewayCall().params).toMatchObject({ + payload: { toolsAllow: ["configured__lookup"] }, + }); + }); + + it("does not write a default add when configured MCP authentication fails", async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + resolveCreatorToolAuthority: async () => { + throw new Error("Sign in to configured MCP, then retry; no automation changes were saved."); + }, + }); + + await expect( + tool.execute("call-default-auth-failure", { + action: "add", + job: buildReminderAgentTurnJob(), + }), + ).rejects.toThrow("no automation changes were saved"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("fails incomplete inherited and unknown finite adds while preserving known finite tools", async () => { + const captureRef = {}; + const tool = createTestCronTool({ + agentSessionKey: "agent:main:telegram:group:restricted-room", + creatorToolAllowlist: ["read", "cron"], + creatorToolAllowlistCaptureRef: captureRef, + }); + + await expect( + tool.execute("call-default-capture-unavailable", { + action: "add", + job: buildReminderAgentTurnJob(), + }), + ).rejects.toThrow("fresh authenticated direct-local operator turn"); + expect(callGatewayMock).not.toHaveBeenCalled(); + + await expect( + tool.execute("call-unknown-finite-capture-unavailable", { + action: "add", + job: { + ...buildReminderAgentTurnJob(), + payload: { + kind: "agentTurn", + message: "hello", + toolsAllow: ["future__tool"], + }, + }, + }), + ).rejects.toThrow("CLI or Gateway with an explicit finite toolsAllow list"); + expect(callGatewayMock).not.toHaveBeenCalled(); + + await tool.execute("call-explicit-capture-unavailable", { + action: "add", + job: { + ...buildReminderAgentTurnJob(), + payload: { kind: "agentTurn", message: "hello", toolsAllow: ["read"] }, + }, + }); + expect(expectSingleGatewayCallMethod("cron.add")).toMatchObject({ + payload: { toolsAllow: ["read"] }, + }); + }); + it("caps trigger-script systemEvent adds to the creator tool surface", async () => { const tool = createTestCronTool({ agentSessionKey: "agent:main:telegram:group:restricted-room", @@ -2601,7 +2986,13 @@ describe("cron tool", () => { }); it("recovers flattened model-only payload patch params for update action", async () => { - callGatewayMock.mockResolvedValueOnce({ ok: true }); + callGatewayMock + .mockResolvedValueOnce({ + id: "job-5", + configRevision: "sha256:model-only", + payload: { kind: "agentTurn", message: "before" }, + }) + .mockResolvedValueOnce({ ok: true }); const tool = createTestCronTool(); await tool.execute("call-update-flat-model-only", { @@ -2612,7 +3003,7 @@ describe("cron tool", () => { toolsAllow: [" exec ", " read "], }); - const params = expectSingleGatewayCallMethod("cron.update") as + const params = readGatewayCall(1).params as | { id?: string; patch?: { @@ -3001,6 +3392,245 @@ describe("cron tool", () => { }); }); + it("keeps payload metadata updates offline and preserves the stored cap", async () => { + callGatewayMock + .mockResolvedValueOnce({ + id: "job-metadata", + configRevision: "sha256:metadata", + payload: { + kind: "agentTurn", + message: "before", + toolsAllow: ["read", "configured__lookup"], + toolsAllowIsDefault: true, + }, + }) + .mockResolvedValueOnce({ ok: true }); + const resolveCreatorToolAuthority = vi.fn(async () => { + throw new Error("metadata update must stay offline"); + }); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + resolveCreatorToolAuthority, + }); + + await tool.execute("call-update-metadata-offline", { + action: "update", + id: "job-metadata", + patch: { payload: { kind: "agentTurn", message: "after" } }, + }); + + expect(resolveCreatorToolAuthority).not.toHaveBeenCalled(); + expect(readGatewayCall(1)).toEqual({ + method: "cron.update", + params: { + id: "job-metadata", + expectedConfigRevision: "sha256:metadata", + patch: { payload: { kind: "agentTurn", message: "after" } }, + }, + }); + }); + + it("intersects a visible finite update offline without opening configured MCP", async () => { + const resolveCreatorToolAuthority = vi.fn(async () => { + throw new Error("visible finite update must stay offline"); + }); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + creatorToolAllowlist: ["read", "cron"], + resolveCreatorToolAuthority, + }); + + await tool.execute("call-update-finite-offline", { + action: "update", + id: "job-finite", + patch: { payload: { kind: "agentTurn", toolsAllow: ["read"] } }, + }); + + expect(resolveCreatorToolAuthority).not.toHaveBeenCalled(); + expect(readGatewayCall().params).toMatchObject({ + patch: { payload: { kind: "agentTurn", toolsAllow: ["read"] } }, + }); + }); + + it("reuses one resolved snapshot across a conflicting wildcard reauthorization", async () => { + const conflict = Object.assign(new Error("changed"), { + name: "GatewayClientRequestError", + details: { code: "CRON_JOB_CHANGED" }, + }); + const writeIdentities: unknown[] = []; + const authorityScope = createCronCreatorAuthorityRunScope("run-update-race"); + const operation = new AbortController(); + const authorityGrant = mintCronCreatorAuthorityGrant(authorityScope, operation.signal); + callGatewayMock + .mockResolvedValueOnce({ + id: "job-resolve-race", + configRevision: "sha256:first", + payload: { kind: "agentTurn", message: "before", toolsAllow: ["read"] }, + }) + .mockImplementationOnce(async () => { + writeIdentities.push(getGatewayToolCallerIdentity()); + throw conflict; + }) + .mockResolvedValueOnce({ + id: "job-resolve-race", + configRevision: "sha256:second", + payload: { kind: "agentTurn", message: "before", toolsAllow: [] }, + }) + .mockImplementationOnce(async () => { + const identity = getGatewayToolCallerIdentity(); + writeIdentities.push(identity); + consumeCronCreatorAuthorityGrant(identity!.cronCreatorAuthorityGrant!); + return { ok: true }; + }); + const resolveCreatorToolAuthority = vi.fn(async () => + resolvedCreatorAuthority(["read", "configured__lookup"], authorityGrant), + ); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + resolveCreatorToolAuthority, + }); + + await tool.execute( + "call-update-resolve-race", + { + action: "update", + id: "job-resolve-race", + patch: { payload: { toolsAllow: ["*"] } }, + }, + operation.signal, + ); + + expect(resolveCreatorToolAuthority).toHaveBeenCalledOnce(); + expect(readGatewayCall(1).params).toMatchObject({ + patch: { + payload: { + kind: "agentTurn", + toolsAllow: ["read", "configured__lookup"], + toolsAllowIsDefault: true, + }, + }, + }); + expect(readGatewayCall(3).params).toMatchObject({ + expectedConfigRevision: "sha256:second", + patch: { + payload: { + kind: "agentTurn", + toolsAllow: ["read", "configured__lookup"], + toolsAllowIsDefault: true, + }, + }, + }); + expect(writeIdentities).toEqual([ + expect.objectContaining({ + cronToolsAllowCapture: "final-executable-surface", + cronCreatorAuthorityGrant: authorityGrant, + }), + expect.objectContaining({ + cronToolsAllowCapture: "final-executable-surface", + cronCreatorAuthorityGrant: authorityGrant, + }), + ]); + expect(() => consumeCronCreatorAuthorityGrant(authorityGrant)).toThrow( + "Configured MCP cron authority is no longer active", + ); + revokeCronCreatorAuthorityRunScope(authorityScope); + }); + + it("does not write a freshly resolved update without authenticated grant transport", async () => { + callGatewayMock.mockResolvedValueOnce({ + id: "job-no-caller-identity", + configRevision: "sha256:no-caller-identity", + payload: { kind: "agentTurn", message: "before", toolsAllow: ["read"] }, + }); + const tool = createTestCronTool({ + resolveCreatorToolAuthority: async () => + resolvedCreatorAuthority(["read", "configured__lookup"]), + }); + + await expect( + tool.execute("call-update-no-caller-identity", { + action: "update", + id: "job-no-caller-identity", + patch: { payload: { toolsAllow: ["*"] } }, + }), + ).rejects.toThrow("requires an authenticated local agent run"); + expect(callGatewayMock).toHaveBeenCalledOnce(); + expect(readGatewayCall().method).toBe("cron.get"); + }); + + it("fails a queued configured-MCP wildcard update visibly without writing", async () => { + callGatewayMock.mockResolvedValueOnce({ + id: "job-queued-authority", + configRevision: "sha256:queued-authority", + payload: { kind: "agentTurn", message: "before", toolsAllow: ["read"] }, + }); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + creatorToolAllowlist: ["read", "cron"], + creatorAuthorityUnavailableReason: "queued-local-operator-configured-mcp", + }); + + await expect( + tool.execute("call-queued-configured-mcp-update", { + action: "update", + id: "job-queued-authority", + patch: { payload: { toolsAllow: ["*"] } }, + }), + ).rejects.toThrow("no automation changes were saved"); + expect(callGatewayMock).toHaveBeenCalledOnce(); + expect(readGatewayCall().method).toBe("cron.get"); + }); + + it("rejects an unknown finite update when configured-MCP capture is incomplete", async () => { + callGatewayMock.mockResolvedValueOnce({ + id: "job-incomplete-authority", + configRevision: "sha256:incomplete-authority", + payload: { kind: "agentTurn", message: "before", toolsAllow: ["read"] }, + }); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:telegram:group:restricted-room", + creatorToolAllowlist: ["read", "cron"], + creatorToolAllowlistCaptureRef: {}, + }); + + await expect( + tool.execute("call-incomplete-authority-update", { + action: "update", + id: "job-incomplete-authority", + patch: { payload: { kind: "agentTurn", toolsAllow: ["future__tool"] } }, + }), + ).rejects.toThrow("fresh authenticated direct-local operator turn"); + expect(callGatewayMock).toHaveBeenCalledOnce(); + expect(readGatewayCall()).toEqual({ + method: "cron.get", + params: { id: "job-incomplete-authority" }, + }); + }); + + it("does not write an update when configured MCP authentication fails", async () => { + callGatewayMock.mockResolvedValueOnce({ + id: "job-auth-failure", + configRevision: "sha256:auth-failure", + payload: { kind: "agentTurn", message: "before", toolsAllow: ["read"] }, + }); + const tool = createTestCronTool({ + agentSessionKey: "agent:main:main", + resolveCreatorToolAuthority: async () => { + throw new Error("Sign in to configured MCP, then retry; no automation changes were saved."); + }, + }); + + await expect( + tool.execute("call-update-auth-failure", { + action: "update", + id: "job-auth-failure", + patch: { payload: { toolsAllow: ["*"] } }, + }), + ).rejects.toThrow("no automation changes were saved"); + expect(callGatewayMock).toHaveBeenCalledOnce(); + expect(readGatewayCall().method).toBe("cron.get"); + }); + it("leaves a stored narrower cap untouched when updating without a policy patch", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); @@ -3065,7 +3695,7 @@ describe("cron tool", () => { params: { id: "job-race", expectedConfigRevision: "sha256:first", - patch: { payload: { kind: "agentTurn", message: "updated", toolsAllow: ["read"] } }, + patch: { payload: { kind: "agentTurn", message: "updated" } }, }, }); expect(readGatewayCall(3)).toEqual({ @@ -3073,7 +3703,7 @@ describe("cron tool", () => { params: { id: "job-race", expectedConfigRevision: "sha256:second", - patch: { payload: { kind: "agentTurn", message: "updated", toolsAllow: [] } }, + patch: { payload: { kind: "agentTurn", message: "updated" } }, }, }); }); @@ -3126,7 +3756,6 @@ describe("cron tool", () => { payload: { kind: "agentTurn", model: "openai/gpt-5.5", - toolsAllow: ["read"], }, }, }, diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 191d96d102d3..0a02be15bb3e 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -5,7 +5,7 @@ */ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { parseDurationMs } from "../../cli/parse-duration.js"; -import { getRuntimeConfig, type OpenClawConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { resolveCronCreationDelivery } from "../../cron/delivery-context.js"; import { assertCronDeliveryInputNonBlankFields } from "../../cron/delivery-target-validation.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; @@ -13,12 +13,10 @@ import type { CronDelivery } from "../../cron/types.js"; import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; import { GatewayClientRequestError } from "../../gateway/client.js"; import { recordCronNextCheckProposal } from "../../infra/agent-run-registry.js"; -import { normalizeAgentId } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { isRecord } from "../../utils.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { CRON_TOOL_DISPLAY_SUMMARY } from "../tool-description-presets.js"; -import { normalizeToolName } from "../tool-policy.js"; import { setToolTerminalPresentation } from "../tool-terminal-presentation.js"; import { AUTOMATIONS_TOOL_NAME } from "./automations-tool-name.js"; import { @@ -28,6 +26,12 @@ import { readPositiveIntegerParam, readStringParam, } from "./common.js"; +import { + assertCronToolAgentFieldMatchesScope, + assertCronToolSessionRefsMatchScope, + readCronToolAgentId, + resolveCronToolCallerScope, +} from "./cron-tool-caller-scope.js"; import { canonicalizeCronToolObject, hasCronCreateSignal, @@ -39,117 +43,40 @@ import { REMINDER_CONTEXT_MARKER, stripExistingContext, } from "./cron-tool-context.js"; -import { capCronJobToolsAllowOnCreate } from "./cron-tool-creator-cap.js"; +import { + assertInheritedCronToolCaptureReady, + capCronJobToolsAllowOnCreate, + cronCreateRequiresCreatorAuthority, +} from "./cron-tool-creator-cap.js"; import { assertCronPacingInput, createCronToolSchema, CRON_TOOL_LIST_MAX_LIMIT, } from "./cron-tool-schema.js"; -import { assertNoCronShellExecution, updateCronJobFromAgentTool } from "./cron-tool-write.js"; -import type { - CronCreatorToolAllowlistEntry, - CronToolCallerScope, - CronToolDeps, - CronToolOptions, -} from "./cron-tool.types.js"; +import { + assertCronCreatorAuthorityResolutionAvailable, + assertNoCronShellExecution, + updateCronJobFromAgentTool, +} from "./cron-tool-write.js"; +import type { CronToolDeps, CronToolOptions } from "./cron-tool.types.js"; import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js"; import { callGatewayTool, readGatewayCallOptions, type GatewayCallOptions } from "./gateway.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; -export type { CronCreatorToolAllowlistEntry } from "./cron-tool.types.js"; +export type { CronCreatorToolAllowlistEntry, CronToolsAllowCaptureRef } from "./cron-tool.types.js"; +export { + captureFinalEffectiveCronCreatorToolAllowlist, + replaceWithEffectiveCronCreatorToolAllowlist, +} from "./cron-tool-creator-cap.js"; function isMissingOrEmptyObject(value: unknown): boolean { return !value || (isRecord(value) && Object.keys(value).length === 0); } -export function replaceWithEffectiveCronCreatorToolAllowlist( - target: CronCreatorToolAllowlistEntry[], - tools: readonly T[], - toolMeta?: (tool: T) => { pluginId?: string } | undefined, -): void { - target.length = 0; - const seen = new Set(); - for (const tool of tools) { - const name = normalizeToolName(tool.name); - if (!name || seen.has(name)) { - continue; - } - seen.add(name); - const meta = toolMeta?.(tool); - const pluginId = - typeof meta?.pluginId === "string" ? normalizeToolName(meta.pluginId) : undefined; - target.push(pluginId ? { name, pluginId } : { name }); - } -} - function readCronJobIdParam(params: Record) { return readStringParam(params, "jobId") ?? readStringParam(params, "id"); } -function resolveCronToolCallerScope( - opts: CronToolOptions | undefined, - cfg: OpenClawConfig, -): CronToolCallerScope | undefined { - const sessionKey = opts?.agentSessionKey?.trim(); - if (!sessionKey) { - return undefined; - } - return { - kind: "agentTool", - agentId: resolveSessionAgentId({ sessionKey, config: cfg }), - }; -} - -function readCronToolAgentId(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? normalizeAgentId(value) : undefined; -} - -function readAgentIdFromCronToolSessionRef(value: unknown): string | undefined { - return typeof value === "string" && value.trim() - ? parseAgentSessionKey(value.trim())?.agentId - : undefined; -} - -function readAgentIdFromCronToolSessionTarget(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed.startsWith("session:")) { - return undefined; - } - return readAgentIdFromCronToolSessionRef(trimmed.slice("session:".length)); -} - -function assertCronToolAgentFieldMatchesScope(params: { - value: unknown; - field: string; - callerScope: CronToolCallerScope; -}): void { - if (params.value === undefined) { - return; - } - const agentId = readCronToolAgentId(params.value); - if (agentId && agentId === params.callerScope.agentId) { - return; - } - throw new Error(`${params.field} must match the calling agent`); -} - -function assertCronToolSessionRefsMatchScope( - value: Record, - callerScope: CronToolCallerScope, -): void { - const sessionAgentId = readAgentIdFromCronToolSessionRef(value.sessionKey); - if (sessionAgentId && normalizeAgentId(sessionAgentId) !== callerScope.agentId) { - throw new Error("automations sessionKey must match the calling agent"); - } - const sessionTargetAgentId = readAgentIdFromCronToolSessionTarget(value.sessionTarget); - if (sessionTargetAgentId && normalizeAgentId(sessionTargetAgentId) !== callerScope.agentId) { - throw new Error("automations sessionTarget must match the calling agent"); - } -} - const CRON_SELF_REMOVE_SCOPE_ERROR = "Automations tool is restricted to the current automation."; function readCronSelfRemoveOnlyJobId(opts: CronToolOptions | undefined) { @@ -306,6 +233,7 @@ TARGET+PAYLOAD: - "main" = heartbeat lane; payload {kind:"systemEvent",text} (systemEvent default target). - "session:" = named session. - agentTurn {kind:"agentTurn",message,model?,thinking?,timeoutSeconds?}; timeoutSeconds 0=none. +- Inherited configured MCP authority includes only model-callable tools; interactive app-view-only capabilities are excluded from headless jobs. - script {kind:"script",script,timeoutSeconds?,toolBudget?}: main|isolated only; needs cron.triggers.enabled. PACED LOOP: recurring job + pacing{min?,max?} durations ("15m","4h"; at least one). Inside its run, job calls next_check in:"" to set the next delay (clamped to bounds, measured from run end; failed runs keep normal backoff). Adaptive polling: tighten when active, back off when quiet. @@ -316,7 +244,8 @@ DELIVERY {mode:"none"|"announce"|"webhook",channel?,to?,threadId?,bestEffort?}: Job wakeMode (main jobs): "now"(default)|"next-heartbeat". Restricted automation-run sessions: self status/list/get/runs/remove + own next_check only. failureAlert {...}|false disables. jobId canonical (id=compat). contextMessages 0-10 embeds recent chat lines into reminder text.`, parameters: createCronToolSchema(), - execute: async (_toolCallId, args) => { + execute: async (_toolCallId, args, operationSignal) => { + operationSignal?.throwIfAborted(); const params = args as Record; const action = readStringParam(params, "action", { required: true }); assertCronSelfRemoveScope(opts, action, params); @@ -336,6 +265,10 @@ Job wakeMode (main jobs): "now"(default)|"next-heartbeat". Restricted automation ...(readCronSelfRemoveOnlyJobId(opts) ? { cronSelfManagementJobId: readCronSelfRemoveOnlyJobId(opts) } : {}), + ...(opts?.creatorToolAllowlistCaptureRef?.value?.version === 1 && + opts.creatorToolAllowlistCaptureRef.value.source === "final-executable-surface" + ? { cronToolsAllowCapture: "final-executable-surface" as const } + : {}), } : undefined; @@ -467,7 +400,27 @@ Job wakeMode (main jobs): "now"(default)|"next-heartbeat". Restricted automation ) { delete job.enabled; } - capCronJobToolsAllowOnCreate(job, opts?.creatorToolAllowlist); + const requiresCreatorAuthority = cronCreateRequiresCreatorAuthority( + job, + opts?.creatorToolAllowlist, + ); + assertCronCreatorAuthorityResolutionAvailable({ + required: requiresCreatorAuthority, + resolveCreatorToolAuthority: opts?.resolveCreatorToolAuthority, + creatorToolAllowlistCaptureRef: opts?.creatorToolAllowlistCaptureRef, + unavailableReason: opts?.creatorAuthorityUnavailableReason, + }); + const resolvedAuthority = + requiresCreatorAuthority && opts?.resolveCreatorToolAuthority + ? await opts.resolveCreatorToolAuthority({ signal: operationSignal }) + : undefined; + operationSignal?.throwIfAborted(); + const creatorToolAllowlist = resolvedAuthority?.tools ?? opts?.creatorToolAllowlist; + const creatorToolAllowlistCaptureRef = resolvedAuthority + ? { value: resolvedAuthority.provenance } + : opts?.creatorToolAllowlistCaptureRef; + capCronJobToolsAllowOnCreate(job, creatorToolAllowlist); + assertInheritedCronToolCaptureReady(job, creatorToolAllowlistCaptureRef); if (job && typeof job === "object") { const { mainKey, alias } = resolveMainSessionAlias(runtimeConfig); const resolvedSessionKey = opts?.agentSessionKey @@ -557,10 +510,30 @@ Job wakeMode (main jobs): "now"(default)|"next-heartbeat". Restricted automation } } } + const writeCallerIdentity = + resolvedAuthority && callerIdentity + ? { + ...callerIdentity, + cronToolsAllowCapture: "final-executable-surface" as const, + cronCreatorAuthorityGrant: resolvedAuthority.grant, + } + : callerIdentity; + if ( + resolvedAuthority && + (!writeCallerIdentity || !("cronCreatorAuthorityGrant" in writeCallerIdentity)) + ) { + throw new Error( + "fresh configured MCP cron authority requires an authenticated local agent run", + ); + } return jsonResult( - await callGateway("cron.add", gatewayOpts, { - ...job, - }), + await withGatewayToolCallerIdentity( + writeCallerIdentity, + async () => + await callGateway("cron.add", gatewayOpts, { + ...job, + }), + ), ); } case "update": { @@ -609,8 +582,23 @@ Job wakeMode (main jobs): "now"(default)|"next-heartbeat". Restricted automation id, patch, creatorToolAllowlist: opts?.creatorToolAllowlist, + creatorToolAllowlistCaptureRef: opts?.creatorToolAllowlistCaptureRef, + resolveCreatorToolAuthority: opts?.resolveCreatorToolAuthority, + withCreatorAuthorityProvenance: callerIdentity + ? async (authority, run) => + await withGatewayToolCallerIdentity( + { + ...callerIdentity, + cronToolsAllowCapture: "final-executable-surface", + cronCreatorAuthorityGrant: authority.grant, + }, + run, + ) + : undefined, gatewayOpts, callGateway, + operationSignal, + creatorAuthorityUnavailableReason: opts?.creatorAuthorityUnavailableReason, }), ); } diff --git a/src/agents/tools/cron-tool.types.ts b/src/agents/tools/cron-tool.types.ts index 647b540efa54..f2ad308e8038 100644 --- a/src/agents/tools/cron-tool.types.ts +++ b/src/agents/tools/cron-tool.types.ts @@ -1,3 +1,4 @@ +import type { CronCreatorAuthorityGrant } from "../../gateway/cron-creator-authority-grant.js"; // Cron tool type declarations shared with the cron tool implementation. import type { DeliveryContext } from "../../utils/delivery-context.shared.js"; import type { callGatewayTool } from "./gateway.js"; @@ -9,6 +10,25 @@ export type CronCreatorToolAllowlistEntry = pluginId?: string; }; +type CronToolsAllowCaptureProvenance = { + version: 1; + source: "final-executable-surface"; +}; + +export type CronToolsAllowCaptureRef = { + value?: CronToolsAllowCaptureProvenance; +}; + +export type CronCreatorToolAuthorityMaterialization = { + tools: readonly CronCreatorToolAllowlistEntry[]; + provenance: CronToolsAllowCaptureProvenance; +}; + +export type CronCreatorToolAuthoritySnapshot = CronCreatorToolAuthorityMaterialization & { + /** Gateway-process one-shot proof consumed only at the matching cron write. */ + grant: CronCreatorAuthorityGrant; +}; + export type CronToolOptions = { agentSessionKey?: string; /** Authenticated source account; authority must not be inferred from delivery. */ @@ -20,6 +40,14 @@ export type CronToolOptions = { * need this cap persisted before the original session policy is lost. */ creatorToolAllowlist?: CronCreatorToolAllowlistEntry[]; + /** Host-owned proof that creatorToolAllowlist reached the final executable surface. */ + creatorToolAllowlistCaptureRef?: CronToolsAllowCaptureRef; + /** Attempt-cached authority resolved only when a mutation changes its tool cap. */ + resolveCreatorToolAuthority?: (options?: { + signal?: AbortSignal; + }) => Promise; + /** Visible fail-closed reason when a queued local turn cannot retain fresh MCP authority. */ + creatorAuthorityUnavailableReason?: "queued-local-operator-configured-mcp"; selfRemoveOnlyJobId?: string; runId?: string; }; diff --git a/src/agents/tools/gateway-caller-context.ts b/src/agents/tools/gateway-caller-context.ts index 93bf0c629c01..184bc1aa473d 100644 --- a/src/agents/tools/gateway-caller-context.ts +++ b/src/agents/tools/gateway-caller-context.ts @@ -1,5 +1,6 @@ // Ambient trusted caller context for model-mediated Gateway tool calls. import { AsyncLocalStorage } from "node:async_hooks"; +import type { CronCreatorAuthorityGrant } from "../../gateway/cron-creator-authority-grant.js"; import { copyAgentToolMetadata } from "../agent-tool-metadata.js"; import { attachInternalToolExecutionPreparer, @@ -12,6 +13,9 @@ type GatewayToolCallerIdentity = { sessionKey: string; /** Host-signed capability for the scheduled run's existing self-management surface. */ cronSelfManagementJobId?: string; + cronToolsAllowCapture?: "final-executable-surface"; + /** One-shot Gateway-owned proof for a freshly resolved configured-MCP cap. */ + cronCreatorAuthorityGrant?: CronCreatorAuthorityGrant; // Trusted run context, carried separately from model-authored tool arguments. turnSourceChannel?: string; turnSourceTo?: string; @@ -50,6 +54,12 @@ export async function withGatewayToolCallerIdentity( ...(identity.cronSelfManagementJobId?.trim() ? { cronSelfManagementJobId: identity.cronSelfManagementJobId.trim() } : {}), + ...(identity.cronToolsAllowCapture === "final-executable-surface" + ? { cronToolsAllowCapture: identity.cronToolsAllowCapture } + : {}), + ...(identity.cronCreatorAuthorityGrant + ? { cronCreatorAuthorityGrant: identity.cronCreatorAuthorityGrant } + : {}), ...(identity.turnSourceChannel?.trim() ? { turnSourceChannel: identity.turnSourceChannel.trim() } : {}), diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index 8665bf9335a9..afa8d030b98b 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -78,6 +78,8 @@ export type TurnAdoptionLifecycle = { /** Stable cancellation owner for collect-mode batches. */ ownerKey?: string; abortSignal?: AbortSignal; + /** Ephemeral fact: a direct local operator turn lost fresh cron authority when queued. */ + cronCreatorAuthorityUnavailable?: "queued-local-operator"; }; /** Partial assistant payload emitted during streaming or replacement updates. */ diff --git a/src/auto-reply/reply/agent-runner-embedded-candidate.ts b/src/auto-reply/reply/agent-runner-embedded-candidate.ts index 931494b33121..df47e347be36 100644 --- a/src/auto-reply/reply/agent-runner-embedded-candidate.ts +++ b/src/auto-reply/reply/agent-runner-embedded-candidate.ts @@ -200,6 +200,8 @@ export async function runEmbeddedFallbackCandidate(params: { lifecycleGeneration: params.getLifecycleGeneration(), allowGatewaySubagentBinding: true, trigger: turn.isHeartbeat ? "heartbeat" : "user", + cronCreatorAuthorityUnavailableReason: + turn.opts?.turnAdoptionLifecycle?.cronCreatorAuthorityUnavailable, groupId: resolveGroupSessionKey(turn.sessionCtx)?.id, groupChannel: normalizeOptionalString(turn.sessionCtx.GroupChannel) ?? diff --git a/src/auto-reply/reply/queue.collect.test.ts b/src/auto-reply/reply/queue.collect.test.ts index 7103e710af91..7faa24bc3c45 100644 --- a/src/auto-reply/reply/queue.collect.test.ts +++ b/src/auto-reply/reply/queue.collect.test.ts @@ -59,6 +59,54 @@ function createDrainRecorder(expectedCalls = 1) { } describe("followup queue collect routing", () => { + it("carries queued local cron-authority unavailability through a followup drain", async () => { + const key = `test-followup-cron-authority-${Date.now()}`; + const { calls, done, runFollowup } = createDrainRecorder(); + const run = createRun({ prompt: "queued local operator turn" }); + run.turnAdoptionLifecycle = { + admission: "cancel-only", + ownerKey: "gateway:local", + cronCreatorAuthorityUnavailable: "queued-local-operator", + onAdopted: async () => {}, + }; + enqueueFollowupRun(key, run, { ...createQueueSettings(), mode: "followup" }); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + + expect(calls[0]?.turnAdoptionLifecycle?.cronCreatorAuthorityUnavailable).toBe( + "queued-local-operator", + ); + }); + + it("carries queued local cron-authority unavailability through a collect batch", async () => { + const key = `test-collect-cron-authority-${Date.now()}`; + const { calls, done, runFollowup } = createDrainRecorder(); + const first = createRun({ prompt: "first queued turn" }); + first.turnAdoptionLifecycle = { + admission: "cancel-only", + ownerKey: "gateway:local", + cronCreatorAuthorityUnavailable: "queued-local-operator", + onAdopted: async () => {}, + }; + const second = createRun({ prompt: "second queued turn" }); + second.turnAdoptionLifecycle = { + admission: "cancel-only", + ownerKey: "gateway:local", + onAdopted: async () => {}, + }; + const settings = createQueueSettings(); + enqueueFollowupRun(key, first, settings); + enqueueFollowupRun(key, second, settings); + + scheduleFollowupDrain(key, runFollowup); + await done.promise; + + expect(calls[0]?.turnAdoptionLifecycle?.cronCreatorAuthorityUnavailable).toBe( + "queued-local-operator", + ); + }); + it("marks exclusive admission without onAbandoned and isolates collect identity", () => { // Failure window: cancel-only used to be inferred from missing onAbandoned, // so exclusive admission without onAbandoned shared collect identity. diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index 1e8557ea53e9..8881816747d9 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -572,6 +572,17 @@ function collectRuntimeMetadata( }; } +function resolveQueuedCronCreatorAuthorityUnavailable( + items: readonly FollowupRun[], +): "queued-local-operator" | undefined { + return items.some( + (item) => + item.turnAdoptionLifecycle?.cronCreatorAuthorityUnavailable === "queued-local-operator", + ) + ? "queued-local-operator" + : undefined; +} + type FollowupQueueSummaryState = { cap: number; droppedCount: number; @@ -983,6 +994,9 @@ async function runSyntheticOverflowSummary(params: { turnAdoptionLifecycle: { // Synthetic aggregate owner — not a durable exclusive ingress identity. admission: "cancel-only" as const, + ...(resolveQueuedCronCreatorAuthorityUnavailable(params.sources) + ? { cronCreatorAuthorityUnavailable: "queued-local-operator" as const } + : {}), onAdopted: async () => { await params.onAdmitted?.(); admitted = true; @@ -1320,6 +1334,9 @@ export function scheduleFollowupDrain( turnAdoptionLifecycle: { // Synthetic aggregate owner — sources keep their own admission. admission: "cancel-only" as const, + ...(resolveQueuedCronCreatorAuthorityUnavailable(activeGroupItems) + ? { cronCreatorAuthorityUnavailable: "queued-local-operator" as const } + : {}), onAdopted: admitGroupSources, onSettled: () => { if (admitted) { diff --git a/src/commands/doctor/cron/index.test.ts b/src/commands/doctor/cron/index.test.ts index d220dafaf2a0..26a3541611e6 100644 --- a/src/commands/doctor/cron/index.test.ts +++ b/src/commands/doctor/cron/index.test.ts @@ -339,6 +339,68 @@ describe("collectLegacyCronStoreHealthFindings", () => { }); describe("maybeRepairLegacyCronStore", () => { + it("keeps shared-workspace legacy MCP warnings scoped to each job agent", async () => { + const storePath = await makeTempStorePath(); + const sharedWorkspace = path.join(path.dirname(storePath), "shared-workspace"); + await writeCurrentCronStore(storePath, [ + createCurrentCronJob({ + id: "research-job", + name: "Research legacy cap", + agentId: "research", + payload: { + kind: "agentTurn", + message: "research", + toolsAllow: ["read"], + toolsAllowIsDefault: true, + }, + }), + createCurrentCronJob({ + id: "support-job", + name: "Support legacy cap", + agentId: "support", + payload: { + kind: "agentTurn", + message: "support", + toolsAllow: ["read"], + toolsAllowIsDefault: true, + }, + }), + ]); + const cfg = { + cron: { store: storePath }, + agents: { + list: [ + { id: "research", workspace: sharedWorkspace }, + { id: "support", workspace: sharedWorkspace }, + ], + }, + mcp: { + servers: { + notes: { + transport: "stdio", + command: "notes-mcp", + codex: { agents: ["research"] }, + }, + }, + }, + } as OpenClawConfig; + + await maybeRepairLegacyCronStore({ + cfg, + options: {}, + prompter: makePrompter(true), + }); + + const advisory = noteMock.mock.calls.find( + ([message, title]) => + title === "Cron" && + typeof message === "string" && + message.includes("inherited default tool cap"), + )?.[0]; + expect(advisory).toContain("Research legacy cap"); + expect(advisory).not.toContain("Support legacy cap"); + }); + it("reports quarantined cron rows even when the active store is already sanitized", async () => { const storePath = await makeTempStorePath(); await writeCurrentCronStore(storePath, []); diff --git a/src/commands/doctor/cron/index.ts b/src/commands/doctor/cron/index.ts index 3e34ba5ab74a..953b12e2690d 100644 --- a/src/commands/doctor/cron/index.ts +++ b/src/commands/doctor/cron/index.ts @@ -1,6 +1,9 @@ // Doctor cron repair orchestration for legacy stores, run logs, payloads, and warnings. import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { note } from "../../../../packages/terminal-core/src/note.js"; +import { resolveStaticSessionMcpServerNames } from "../../../agents/agent-bundle-mcp-runtime-config.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js"; +import { resolveCodexMcpToolOverridesForAgent } from "../../../agents/cli-runner/bundle-mcp-codex.js"; import { formatCliCommand } from "../../../cli/command-format.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { loadCronQuarantinedJobs, resolveCronJobsStorePath } from "../../../cron/store.js"; @@ -18,6 +21,7 @@ import { } from "./legacy-repair.js"; import { formatLegacyIssuePreview, + formatIncompleteInheritedAuthorityAdvisory, formatScheduledToolPolicyAdvisory, formatUnresolvedCommandPromptAdvisory, formatUnresolvedShellPromptAdvisory, @@ -485,6 +489,52 @@ export async function maybeRepairLegacyCronStore(params: { if (scheduledToolPolicyAdvisory) { note(scheduledToolPolicyAdvisory, "Cron"); } + const staticMcpByAgentWorkspace = new Map(); + const incompleteInheritedAuthorityAdvisory = formatIncompleteInheritedAuthorityAdvisory( + rawJobs + .filter((job) => { + const payload = isRecord(job.payload) ? job.payload : undefined; + const provenance = isRecord(job.toolsAllowProvenance) + ? job.toolsAllowProvenance + : undefined; + if ( + payload?.toolsAllowIsDefault !== true || + (provenance?.version === 1 && provenance.source === "final-executable-surface") + ) { + return false; + } + const agentId = + typeof job.agentId === "string" && job.agentId.trim() + ? job.agentId.trim() + : resolveDefaultAgentId(params.cfg); + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId); + const cacheKey = `${agentId}\0${workspaceDir}`; + let hasStaticMcp = staticMcpByAgentWorkspace.get(cacheKey); + if (hasStaticMcp === undefined) { + hasStaticMcp = + resolveStaticSessionMcpServerNames({ + workspaceDir, + cfg: params.cfg, + toolOverrides: resolveCodexMcpToolOverridesForAgent(params.cfg, { + agentId, + toolOverrides: undefined, + }), + }).length > 0; + staticMcpByAgentWorkspace.set(cacheKey, hasStaticMcp); + } + return hasStaticMcp; + }) + .map((job) => + typeof job.name === "string" && job.name.trim() + ? job.name.trim() + : typeof job.id === "string" + ? job.id + : "unknown automation", + ), + ); + if (incompleteInheritedAuthorityAdvisory) { + note(incompleteInheritedAuthorityAdvisory, "Cron"); + } const previewLines = formatLegacyIssuePreview(normalized.issues); if (legacyStoreDetected) { previewLines.unshift( diff --git a/src/commands/doctor/cron/repair-plan.ts b/src/commands/doctor/cron/repair-plan.ts index 83c1155c2b53..555324fa07ba 100644 --- a/src/commands/doctor/cron/repair-plan.ts +++ b/src/commands/doctor/cron/repair-plan.ts @@ -74,11 +74,23 @@ export function formatScheduledToolPolicyAdvisory(params: { } lines.push( "- These jobs continue through restrictive sender-policy resolution; doctor will not infer authority from delivery or current configuration.", - "- Reauthorize with `openclaw cron edit --tools `, or use `--clear-tools` to adopt the current default cap.", + "- Reauthorize with an exact explicit cap: `openclaw cron edit --tools `.", ); return lines.join("\n"); } +/** Advisory for legacy default caps that were captured before configured MCP was final. */ +export function formatIncompleteInheritedAuthorityAdvisory(names: string[]): string | null { + if (names.length === 0) { + return null; + } + return [ + `${pluralize(names.length, "automation")} ${names.length === 1 ? "has" : "have"} an inherited default tool cap captured before final configured-MCP provenance was recorded${formatJobNameList(names)}.`, + "- The stored finite cap remains unchanged; doctor will not silently widen or rewrite it.", + "- If the job uses Codex configured MCP, reauthorize in place with an exact explicit list: `openclaw automations edit --tools `.", + ].join("\n"); +} + /** Convert legacy cron issue counts into doctor preview lines. */ export function formatLegacyIssuePreview(issues: CronLegacyIssueCounts): string[] { const lines: string[] = []; diff --git a/src/cron/isolated-agent/run-delivery-trace.tools-allow.test.ts b/src/cron/isolated-agent/run-delivery-trace.tools-allow.test.ts new file mode 100644 index 000000000000..62a3808204a8 --- /dev/null +++ b/src/cron/isolated-agent/run-delivery-trace.tools-allow.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { createCronToolsAllowPreflightDiagnostics } from "./run-delivery-trace.js"; + +const cfg = { + mcp: { + servers: { + notes: { transport: "stdio", command: "notes-mcp" }, + }, + }, +} as OpenClawConfig; + +describe("configured MCP inherited-cap diagnostics", () => { + it("persists an actionable warning for legacy Codex default caps", async () => { + const diagnostics = await createCronToolsAllowPreflightDiagnostics({ + cfg, + jobId: "job-1", + provider: "openai", + model: "gpt-5.4-codex", + workspaceDir: "/workspace", + agentRuntime: "codex", + agentPayload: { + kind: "agentTurn", + message: "run", + toolsAllow: ["read"], + toolsAllowIsDefault: true, + }, + }); + + expect(diagnostics?.entries[0]).toMatchObject({ + source: "cron-preflight", + severity: "warn", + }); + expect(diagnostics?.summary).toContain("openclaw automations edit job-1 --tools "); + }); + + it("does not warn after final executable-surface capture", async () => { + await expect( + createCronToolsAllowPreflightDiagnostics({ + cfg, + jobId: "job-1", + provider: "openai", + model: "gpt-5.4-codex", + workspaceDir: "/workspace", + agentRuntime: "codex", + toolsAllowProvenance: { version: 1, source: "final-executable-surface" }, + agentPayload: { + kind: "agentTurn", + message: "run", + toolsAllow: ["notes__read"], + toolsAllowIsDefault: true, + }, + }), + ).resolves.toBeUndefined(); + }); + + it("does not warn for a configured MCP server excluded from the run agent", async () => { + const agentScopedCfg = { + mcp: { + servers: { + notes: { + transport: "stdio", + command: "notes-mcp", + codex: { agents: ["research"] }, + }, + }, + }, + } as OpenClawConfig; + const base = { + cfg: agentScopedCfg, + jobId: "job-agent-scope", + provider: "openai", + model: "gpt-5.4-codex", + workspaceDir: "/workspace", + agentRuntime: "codex", + agentPayload: { + kind: "agentTurn" as const, + message: "run", + toolsAllow: ["read"], + toolsAllowIsDefault: true as const, + }, + }; + + await expect( + createCronToolsAllowPreflightDiagnostics({ ...base, agentId: "support" }), + ).resolves.toBeUndefined(); + await expect( + createCronToolsAllowPreflightDiagnostics({ ...base, agentId: "research" }), + ).resolves.toMatchObject({ entries: [expect.objectContaining({ severity: "warn" })] }); + }); +}); diff --git a/src/cron/isolated-agent/run-delivery-trace.ts b/src/cron/isolated-agent/run-delivery-trace.ts index 50de63dd79a9..4f3c8b9b4b19 100644 --- a/src/cron/isolated-agent/run-delivery-trace.ts +++ b/src/cron/isolated-agent/run-delivery-trace.ts @@ -1,3 +1,5 @@ +import { resolveStaticSessionMcpServerNames } from "../../agents/agent-bundle-mcp-runtime-config.js"; +import { resolveCodexMcpToolOverridesForAgent } from "../../agents/cli-runner/bundle-mcp-codex.js"; /** Delivery planning, prompt policy, and delivery trace construction for cron runs. */ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { @@ -11,6 +13,7 @@ import { type CronDeliveryPlan, } from "../delivery-plan.js"; import { + createCronRunDiagnosticsFromError, createCronRunDiagnosticsFromMissingWebSearchProvider, toolsAllowRequestsWebSearch, } from "../run-diagnostics.js"; @@ -21,6 +24,7 @@ import type { CronDeliveryTraceTarget, CronJob, CronRunDiagnostics, + CronToolsAllowProvenance, } from "../types.js"; import { logWarn } from "./run.runtime.js"; import { resolveCronSourceDeliveryPlan } from "./source-delivery-plan.js"; @@ -161,14 +165,37 @@ export async function createCronToolsAllowPreflightDiagnostics(params: { modelApi?: string; agentId?: string; agentDir?: string; + workspaceDir: string; sessionKey?: string; agentPayload: Extract | null; + agentRuntime?: string; + toolsAllowProvenance?: CronToolsAllowProvenance; }): Promise { const toolsAllow = params.agentPayload?.toolsAllow; - if ( - params.agentPayload?.toolsAllowIsDefault === true || - !toolsAllowRequestsWebSearch(toolsAllow) - ) { + if (params.agentPayload?.toolsAllowIsDefault === true) { + const hasEnabledStaticMcp = + resolveStaticSessionMcpServerNames({ + workspaceDir: params.workspaceDir, + cfg: params.cfg, + toolOverrides: resolveCodexMcpToolOverridesForAgent(params.cfg, { + agentId: params.agentId, + toolOverrides: undefined, + }), + }).length > 0; + if ( + params.agentRuntime === "codex" && + hasEnabledStaticMcp && + params.toolsAllowProvenance?.source !== "final-executable-surface" + ) { + return createCronRunDiagnosticsFromError( + "cron-preflight", + `This automation's inherited tool cap predates final configured-MCP capture, so it continues with its stored finite tools and may omit MCP capabilities. Reauthorize in place with an exact explicit cap: openclaw automations edit ${params.jobId} --tools .`, + { severity: "warn" }, + ); + } + return undefined; + } + if (!toolsAllowRequestsWebSearch(toolsAllow)) { return undefined; } try { diff --git a/src/cron/isolated-agent/run-prepare-runtime.ts b/src/cron/isolated-agent/run-prepare-runtime.ts index 5d6ebd279940..0d8ebe66170e 100644 --- a/src/cron/isolated-agent/run-prepare-runtime.ts +++ b/src/cron/isolated-agent/run-prepare-runtime.ts @@ -9,6 +9,7 @@ import type { CronAgentExecutionPhaseUpdate, CronAgentExecutionStarted, CronJob, + CronStoredJob, } from "../types.js"; import type { MutableCronSession } from "./run-session-state.js"; import { logWarn } from "./run.runtime.js"; @@ -17,7 +18,7 @@ import type { RunCronAgentTurnResult } from "./run.types.js"; export type RunCronAgentTurnParams = { cfg: OpenClawConfig; deps: CliDeps; - job: CronJob; + job: CronStoredJob; message: string; abortSignal?: AbortSignal; signal?: AbortSignal; diff --git a/src/cron/isolated-agent/run-prepare.ts b/src/cron/isolated-agent/run-prepare.ts index 27c49e9a522e..9c7d932eb22e 100644 --- a/src/cron/isolated-agent/run-prepare.ts +++ b/src/cron/isolated-agent/run-prepare.ts @@ -510,8 +510,11 @@ export async function prepareCronRunContext(params: { modelApi, agentId: modelOwner.agentId, agentDir: modelOwner.agentDir, + workspaceDir, sessionKey: agentSessionKey, agentPayload, + agentRuntime: effectiveAgentRuntime, + toolsAllowProvenance: input.job.toolsAllowProvenance, }); const { deliveryPlan, deliveryRequested, resolvedDelivery, sourceDelivery } = await resolveCronDeliveryContext({ diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index ef8345a52526..d850d04cb070 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -395,6 +395,22 @@ export function normalizeCronJobInput( } } + if ("toolsAllowProvenance" in base) { + const provenance = base.toolsAllowProvenance; + if ( + isRecord(provenance) && + provenance.version === 1 && + provenance.source === "final-executable-surface" + ) { + next.toolsAllowProvenance = { + version: 1, + source: "final-executable-surface", + }; + } else { + delete next.toolsAllowProvenance; + } + } + if ("agentId" in base) { const agentId = base.agentId; if (agentId === null) { diff --git a/src/cron/public-job.test.ts b/src/cron/public-job.test.ts index 41e16e197624..217a1e6b3dee 100644 --- a/src/cron/public-job.test.ts +++ b/src/cron/public-job.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { makeCronJob } from "./delivery.test-helpers.js"; import { toPublicCronJob } from "./public-job.js"; +import type { CronStoredJob } from "./types.js"; describe("toPublicCronJob", () => { it("strips scheduler-only pacing slots without mutating stored state", () => { @@ -42,4 +43,17 @@ describe("toPublicCronJob", () => { state: { triggerState: { revision: 1 } }, }); }); + + it("strips private tool-cap provenance without mutating the stored job", () => { + const job: CronStoredJob = { + ...makeCronJob({}), + toolsAllowProvenance: { version: 1, source: "final-executable-surface" }, + }; + + expect(toPublicCronJob(job)).not.toHaveProperty("toolsAllowProvenance"); + expect(job.toolsAllowProvenance).toEqual({ + version: 1, + source: "final-executable-surface", + }); + }); }); diff --git a/src/cron/public-job.ts b/src/cron/public-job.ts index 3bef460f41e5..0730341da651 100644 --- a/src/cron/public-job.ts +++ b/src/cron/public-job.ts @@ -1,11 +1,12 @@ -import type { CronJob } from "./types.js"; +import type { CronJob, CronStoredJob } from "./types.js"; /** Remove scheduler-only state before a cron job crosses a public API boundary. */ -export function toPublicCronJob(job: CronJob): CronJob { +export function toPublicCronJob(job: CronStoredJob): CronJob { + const { toolsAllowProvenance: _toolsAllowProvenance, ...publicJob } = job; const state = { ...job.state }; delete state.queuedAtMs; delete state.startupCatchupAtMs; delete state.pacedNextRunAtMs; delete state.forcePreservedNextRunAtMs; - return { ...job, state }; + return { ...publicJob, state }; } diff --git a/src/cron/service/jobs-tool-policy.ts b/src/cron/service/jobs-tool-policy.ts new file mode 100644 index 000000000000..75c0d03deee6 --- /dev/null +++ b/src/cron/service/jobs-tool-policy.ts @@ -0,0 +1,71 @@ +import { + createTrustedCronScheduledToolPolicy, + resolveCronScheduledToolPolicy, + type CronScheduledToolPolicy, +} from "../scheduled-tool-policy.js"; +import { cronJobUsesToolRuntime } from "../tools-allow.js"; +import type { CronStoredJob, CronToolsAllowProvenance } from "../types.js"; + +export function stampScheduledToolPolicy( + job: CronStoredJob, + scheduledToolPolicy: CronScheduledToolPolicy | undefined, +): void { + if (!cronJobUsesToolRuntime(job) || job.payload.toolsAllow === undefined) { + delete job.scheduledToolPolicy; + return; + } + const policy = scheduledToolPolicy ?? createTrustedCronScheduledToolPolicy(); + if ( + policy.mode === "account" && + (job.owner?.sessionKey !== policy.ownerSessionKey || + job.owner?.accountId !== policy.ownerAccountId) + ) { + throw new Error("scheduled account policy must match the persisted job owner"); + } + job.scheduledToolPolicy = structuredClone(policy); +} + +export function reconcileScheduledToolPolicy(params: { + job: CronStoredJob; + previouslyUsedToolRuntime: boolean; + explicitlyMutatesToolsAllow: boolean; + scheduledToolPolicy?: CronScheduledToolPolicy; +}): void { + const { job } = params; + if (!cronJobUsesToolRuntime(job) || job.payload.toolsAllow === undefined) { + delete job.scheduledToolPolicy; + return; + } + const current = resolveCronScheduledToolPolicy({ + toolsAllow: job.payload.toolsAllow, + scheduledToolPolicy: job.scheduledToolPolicy, + owner: job.owner, + }); + if (current) { + job.scheduledToolPolicy = current; + return; + } + delete job.scheduledToolPolicy; + if (params.explicitlyMutatesToolsAllow || !params.previouslyUsedToolRuntime) { + stampScheduledToolPolicy(job, params.scheduledToolPolicy); + } +} + +export function reconcileToolsAllowProvenance(params: { + job: CronStoredJob; + explicitlyMutatesToolsAllow: boolean; + toolsAllowProvenance?: CronToolsAllowProvenance; +}): void { + if (!params.explicitlyMutatesToolsAllow) { + return; + } + if ( + params.job.payload.toolsAllowIsDefault === true && + params.toolsAllowProvenance?.version === 1 && + params.toolsAllowProvenance.source === "final-executable-surface" + ) { + params.job.toolsAllowProvenance = structuredClone(params.toolsAllowProvenance); + return; + } + delete params.job.toolsAllowProvenance; +} diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index ff1a08e8dc9d..43bebc2546a1 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -7,11 +7,7 @@ import { import type { CronConfig } from "../../config/types.cron.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import { resolveCronDeliveryPlan } from "../delivery-plan.js"; -import { - createTrustedCronScheduledToolPolicy, - resolveCronScheduledToolPolicy, - type CronScheduledToolPolicy, -} from "../scheduled-tool-policy.js"; +import type { CronScheduledToolPolicy } from "../scheduled-tool-policy.js"; import { normalizeCronScriptPayload } from "../script-payload.js"; import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "../stagger.js"; import { createCronStreamSourceIdentity } from "../stream-schedule.js"; @@ -21,10 +17,11 @@ import type { CronDeliveryPatch, CronFailureAlert, CronFailureAlertPatch, - CronJob, CronJobCreate, CronJobPatch, CronJobState, + CronStoredJob, + CronToolsAllowProvenance, } from "../types.js"; import { resolveInitialCronDelivery } from "./initial-delivery.js"; import { @@ -32,6 +29,11 @@ import { normalizeStreamScheduleBounds, resolveEveryAnchorMs, } from "./jobs-scheduling.js"; +import { + reconcileScheduledToolPolicy, + reconcileToolsAllowProvenance, + stampScheduledToolPolicy, +} from "./jobs-tool-policy.js"; import { assertAnnounceDeliveryChannelSupport, assertCronExpressionSatisfiable, @@ -72,57 +74,15 @@ export { isJobDue, resolveJobPayloadTextForMain, } from "./jobs-scheduling.js"; -function stampScheduledToolPolicy( - job: CronJob, - scheduledToolPolicy: CronScheduledToolPolicy | undefined, -): void { - if (!cronJobUsesToolRuntime(job) || job.payload.toolsAllow === undefined) { - delete job.scheduledToolPolicy; - return; - } - const policy = scheduledToolPolicy ?? createTrustedCronScheduledToolPolicy(); - if ( - policy.mode === "account" && - (job.owner?.sessionKey !== policy.ownerSessionKey || - job.owner?.accountId !== policy.ownerAccountId) - ) { - throw new Error("scheduled account policy must match the persisted job owner"); - } - job.scheduledToolPolicy = structuredClone(policy); -} - -function reconcileScheduledToolPolicy(params: { - job: CronJob; - previouslyUsedToolRuntime: boolean; - explicitlyMutatesToolsAllow: boolean; - scheduledToolPolicy?: CronScheduledToolPolicy; -}): void { - const { job } = params; - if (!cronJobUsesToolRuntime(job) || job.payload.toolsAllow === undefined) { - delete job.scheduledToolPolicy; - return; - } - const current = resolveCronScheduledToolPolicy({ - toolsAllow: job.payload.toolsAllow, - scheduledToolPolicy: job.scheduledToolPolicy, - owner: job.owner, - }); - if (current) { - job.scheduledToolPolicy = current; - return; - } - delete job.scheduledToolPolicy; - if (params.explicitlyMutatesToolsAllow || !params.previouslyUsedToolRuntime) { - stampScheduledToolPolicy(job, params.scheduledToolPolicy); - } -} - /** Creates a normalized cron job row from public add input and computes its initial schedule. */ export function createJob( state: CronServiceState, input: CronJobCreate, - opts?: DeliveryValidationOptions & { scheduledToolPolicy?: CronScheduledToolPolicy }, -): CronJob { + opts?: DeliveryValidationOptions & { + scheduledToolPolicy?: CronScheduledToolPolicy; + toolsAllowProvenance?: CronToolsAllowProvenance; + }, +): CronStoredJob { const now = state.deps.nowMs(); const id = normalizeOptionalString(input.id) ?? crypto.randomUUID(); const schedule = @@ -178,7 +138,7 @@ export function createJob( // Schedule activation is stamped only by committed scheduling mutations. // Accepting caller state here would let imports spoof restart catch-up ownership. delete initialState.scheduleActivatedAtMs; - const job: CronJob = { + const job: CronStoredJob = { id, ...(declarationKey ? { declarationKey } : {}), ...(displayName ? { displayName } : {}), @@ -221,6 +181,11 @@ export function createJob( // required to arrive with a creator cap before the service can apply this default. applyDefaultCronToolsAllow(job); stampScheduledToolPolicy(job, opts?.scheduledToolPolicy); + reconcileToolsAllowProvenance({ + job, + explicitlyMutatesToolsAllow: true, + toolsAllowProvenance: opts?.toolsAllowProvenance, + }); assertSupportedJobSpec(job); assertPacingSupport(job); assertTriggerSupport(job, { @@ -246,13 +211,14 @@ export function createJob( /** Applies a public cron patch in-place, preserving omitted nested fields and validating the result. */ export function applyJobPatch( - job: CronJob, + job: CronStoredJob, patch: CronJobPatch, opts?: { defaultAgentId?: string; scheduleValidationNowMs?: number; cronConfig?: CronConfig; scheduledToolPolicy?: CronScheduledToolPolicy; + toolsAllowProvenance?: CronToolsAllowProvenance; } & DeliveryValidationOptions, ) { const previouslyUsedToolRuntime = cronJobUsesToolRuntime(job); @@ -357,6 +323,12 @@ export function applyJobPatch( patch.payload !== undefined && Object.hasOwn(patch.payload, "toolsAllow"), scheduledToolPolicy: opts?.scheduledToolPolicy, }); + reconcileToolsAllowProvenance({ + job, + explicitlyMutatesToolsAllow: + patch.payload !== undefined && Object.hasOwn(patch.payload, "toolsAllow"), + toolsAllowProvenance: opts?.toolsAllowProvenance, + }); if (patch.delivery) { const implicitMode = resolveCronDeliveryPlan(job).mode; job.delivery = mergeCronDelivery(job.delivery, patch.delivery, implicitMode); @@ -449,7 +421,7 @@ export function applyJobPatch( /** Converges the declared schedule, payload, delivery, and display label only. */ export function applyDeclarativeJobSpec( - job: CronJob, + job: CronStoredJob, input: CronJobCreate, opts: { defaultAgentId?: string; @@ -457,6 +429,7 @@ export function applyDeclarativeJobSpec( nowMs: number; cronConfig?: CronConfig; scheduledToolPolicy?: CronScheduledToolPolicy; + toolsAllowProvenance?: CronToolsAllowProvenance; } & DeliveryValidationOptions, ) { const previouslyUsedToolRuntime = cronJobUsesToolRuntime(job); @@ -536,6 +509,11 @@ export function applyDeclarativeJobSpec( explicitlyMutatesToolsAllow: explicitlyDeclaresToolsAllow, scheduledToolPolicy: opts.scheduledToolPolicy, }); + reconcileToolsAllowProvenance({ + job, + explicitlyMutatesToolsAllow: explicitlyDeclaresToolsAllow, + toolsAllowProvenance: opts.toolsAllowProvenance, + }); const delivery = resolveInitialCronDelivery(input); if (delivery) { job.delivery = structuredClone(delivery); diff --git a/src/cron/service/ops-mutations.ts b/src/cron/service/ops-mutations.ts index 8298af540406..07f232f2e54b 100644 --- a/src/cron/service/ops-mutations.ts +++ b/src/cron/service/ops-mutations.ts @@ -15,7 +15,7 @@ import { deleteCronJobScratch } from "../scratch-store.js"; import { removeStaleCronJobFamilyRows } from "../store.js"; import { createCronStreamSourceIdentity, cronStreamScheduleKey } from "../stream-schedule.js"; import { normalizeCronTaskRunJobId } from "../task-run-history.js"; -import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js"; +import type { CronJob, CronJobCreate, CronJobPatch, CronStoredJob } from "../types.js"; import { cronPatchTouchesDeliveryResolution } from "./jobs-validation.js"; import { applyJobPatch, @@ -201,13 +201,14 @@ async function persistUpdatedJob(params: { }); } -function declarativeFields(job: CronJob, includeEnabled: boolean) { +function declarativeFields(job: CronStoredJob, includeEnabled: boolean) { return { schedule: job.schedule, pacing: job.pacing, trigger: job.trigger, payload: job.payload, scheduledToolPolicy: job.scheduledToolPolicy, + toolsAllowProvenance: job.toolsAllowProvenance, delivery: job.delivery, displayName: job.displayName, ...(includeEnabled ? { enabled: job.enabled } : {}), @@ -265,6 +266,7 @@ export async function add(state: CronServiceState, input: CronJobCreate, opts?: nowMs: now, cronConfig: state.deps.cronConfig, scheduledToolPolicy: opts?.scheduledToolPolicy, + toolsAllowProvenance: opts?.toolsAllowProvenance, configuredChannels, }); const includeEnabled = opts?.enabledExplicit === true; @@ -274,6 +276,7 @@ export async function add(state: CronServiceState, input: CronJobCreate, opts?: declarativeFields(nextJob, includeEnabled), ) ) { + opts?.commitGuard?.(); return { ...existing, created: false, updated: false, job: existing }; } const snapshot = snapshotStoreForRollback(state); @@ -284,6 +287,7 @@ export async function add(state: CronServiceState, input: CronJobCreate, opts?: schedulingInputsRequested: true, scheduleChanged: !isDeepStrictEqual(existing.schedule, nextJob.schedule), }); + opts?.commitGuard?.(); await persistUpdatedJob({ state, snapshot, previousJob: existing, nextJob }); return { ...nextJob, created: false, updated: true, job: nextJob }; } @@ -294,8 +298,10 @@ export async function add(state: CronServiceState, input: CronJobCreate, opts?: const snapshot = snapshotStoreForRollback(state); const job = createJob(state, normalizedInput, { scheduledToolPolicy: opts?.scheduledToolPolicy, + toolsAllowProvenance: opts?.toolsAllowProvenance, configuredChannels, }); + opts?.commitGuard?.(); state.store?.jobs.push(job); // Mutation notifications describe durable state, so publish them only @@ -371,16 +377,17 @@ export async function updateLoadedJob(params: { ); } const now = state.deps.nowMs(); - await precondition?.(structuredClone(job), now); - const nextJob = structuredClone(job); const configuredChannels = cronPatchTouchesDeliveryResolution(patch) ? await resolveConfiguredChannelsForValidation(state) : undefined; + await precondition?.(structuredClone(job), now); + const nextJob = structuredClone(job); applyJobPatch(nextJob, patch, { defaultAgentId: state.deps.defaultAgentId, scheduleValidationNowMs: now, cronConfig: state.deps.cronConfig, scheduledToolPolicy: opts?.scheduledToolPolicy, + toolsAllowProvenance: opts?.toolsAllowProvenance, configuredChannels, }); if (patch.agentId !== undefined) { @@ -400,6 +407,7 @@ export async function updateLoadedJob(params: { "pacing" in patch, scheduleChanged: patch.schedule !== undefined, }); + opts?.commitGuard?.(); await persistUpdatedJob({ state, snapshot, previousJob: job, nextJob }); return nextJob; } diff --git a/src/cron/service/ops.test.ts b/src/cron/service/ops.test.ts index 070ddce094e9..661dc0ee1636 100644 --- a/src/cron/service/ops.test.ts +++ b/src/cron/service/ops.test.ts @@ -23,6 +23,7 @@ import { removeAgentJobsTransactional, removeStaleJobFamily, update, + updateWithPrecondition, } from "./ops-mutations.js"; import { list } from "./ops-read.js"; import { inspectManualRunDisposition } from "./ops-run-preparation.js"; @@ -36,6 +37,127 @@ const { logger, makeStorePath } = setupCronServiceSuite({ }); describe("scheduled tool policy provenance", () => { + it("consumes add authority only after candidate validation and immediately before mutation", async () => { + const { storePath } = await makeStorePath(); + const state = createOkIsolatedCronState({ storePath, now: Date.now() }); + const commitGuard = vi.fn(); + const invalid = { + name: "invalid", + enabled: true, + schedule: { kind: "cron" as const, expr: "0 0 30 2 *" }, + sessionTarget: "isolated" as const, + wakeMode: "now" as const, + payload: { kind: "agentTurn" as const, message: "run" }, + }; + + await expect(add(state, invalid, { commitGuard })).rejects.toThrow(/no upcoming run time/); + expect(commitGuard).not.toHaveBeenCalled(); + expect(state.store?.jobs).toEqual([]); + + const valid = { ...invalid, schedule: { kind: "cron" as const, expr: "0 0 * * *" } }; + commitGuard.mockImplementation(() => { + expect(state.store?.jobs).toEqual([]); + }); + await add(state, valid, { commitGuard }); + expect(commitGuard).toHaveBeenCalledOnce(); + expect(state.store?.jobs).toHaveLength(1); + if (state.timer) { + clearTimeout(state.timer); + } + }); + + it("preserves update authority across a failed precondition and consumes at mutation", async () => { + const { storePath } = await makeStorePath(); + const state = createOkIsolatedCronState({ storePath, now: Date.now() }); + const job = await add(state, { + name: "original", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "run" }, + }); + const commitGuard = vi.fn(() => { + expect(state.store?.jobs[0]?.name).toBe("original"); + }); + + await expect( + updateWithPrecondition( + state, + job.id, + { name: "updated" }, + () => { + throw new Error("revision conflict"); + }, + { commitGuard }, + ), + ).rejects.toThrow("revision conflict"); + expect(commitGuard).not.toHaveBeenCalled(); + expect(state.store?.jobs[0]?.name).toBe("original"); + + await updateWithPrecondition(state, job.id, { name: "updated" }, () => undefined, { + commitGuard, + }); + expect(commitGuard).toHaveBeenCalledOnce(); + expect(state.store?.jobs[0]?.name).toBe("updated"); + if (state.timer) { + clearTimeout(state.timer); + } + }); + + it("stores final-surface provenance privately and never synthesizes it from the default marker", async () => { + const { storePath } = await makeStorePath(); + const state = createOkIsolatedCronState({ storePath, now: Date.now() }); + const base = { + enabled: true, + schedule: { kind: "every" as const, everyMs: 60_000 }, + sessionTarget: "isolated" as const, + wakeMode: "now" as const, + }; + const proven = await add( + state, + { + ...base, + name: "proven", + payload: { + kind: "agentTurn" as const, + message: "run", + toolsAllow: ["notes__read"], + toolsAllowIsDefault: true, + }, + }, + { + toolsAllowProvenance: { version: 1, source: "final-executable-surface" }, + }, + ); + expect(proven.toolsAllowProvenance).toEqual({ + version: 1, + source: "final-executable-surface", + }); + + const legacy = await add(state, { + ...base, + name: "legacy-default", + payload: { + kind: "agentTurn", + message: "run", + toolsAllow: ["notes__read"], + toolsAllowIsDefault: true, + }, + }); + expect(legacy.toolsAllowProvenance).toBeUndefined(); + + const routine = await update(state, proven.id, { description: "keep" }); + expect(routine.toolsAllowProvenance).toEqual(proven.toolsAllowProvenance); + const explicit = await update(state, proven.id, { + payload: { kind: "agentTurn", toolsAllow: ["read"] }, + }); + expect(explicit.toolsAllowProvenance).toBeUndefined(); + if (state.timer) { + clearTimeout(state.timer); + } + }); + it("stamps trusted and authenticated-account creates", async () => { const { storePath } = await makeStorePath(); const now = Date.parse("2026-07-23T12:00:00.000Z"); diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 16406d03ab7e..bddd3c5cd1f1 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -24,6 +24,7 @@ import type { CronRunStatus, CronRunTelemetry, CronStoreFile, + CronToolsAllowProvenance, } from "../types.js"; /** Event payload emitted for cron lifecycle changes and completed runs. */ @@ -385,12 +386,19 @@ export type CronAddOptions = { systemOwned?: boolean; /** Authenticated caller provenance stamped by the service, never public input. */ scheduledToolPolicy?: CronScheduledToolPolicy; + /** Private proof from an authenticated agent-runtime caller. */ + toolsAllowProvenance?: CronToolsAllowProvenance; + /** Synchronous Gateway-owned guard consumed immediately before mutation. */ + commitGuard?: () => void; }; /** Normalized patch input accepted by cron service updates. */ export type CronUpdateInput = CronJobPatch; /** Authenticated caller provenance used only when a tool policy is explicitly adopted. */ export type CronUpdateOptions = { scheduledToolPolicy?: CronScheduledToolPolicy; + toolsAllowProvenance?: CronToolsAllowProvenance; + /** Synchronous Gateway-owned guard consumed immediately before mutation. */ + commitGuard?: () => void; }; /** Cron-store-locked guard evaluated against the current job before an update applies. */ export type CronUpdatePrecondition = (job: CronJob, nowMs: number) => void | Promise; diff --git a/src/cron/store/row-codec.ts b/src/cron/store/row-codec.ts index b3d7207effb1..0bef8aef8d32 100644 --- a/src/cron/store/row-codec.ts +++ b/src/cron/store/row-codec.ts @@ -9,7 +9,13 @@ import { normalizeCronJobInput } from "../normalize.js"; import { getInvalidPersistedCronJobReason } from "../persisted-shape.js"; import { tryCronScheduleIdentity } from "../schedule-identity.js"; import { normalizeCronScheduledToolPolicy } from "../scheduled-tool-policy.js"; -import type { CronJob, CronJobState, CronPacing, CronSchedule, CronStoreFile } from "../types.js"; +import type { + CronJobState, + CronPacing, + CronSchedule, + CronStoredJob, + CronStoreFile, +} from "../types.js"; import { bindDeliveryColumns, deliveryFromRow } from "./delivery-codec.js"; import { bindFailureAlertColumns, failureAlertFromRow } from "./failure-alert-codec.js"; import { bindPayloadColumns, payloadFromRow } from "./payload-codec.js"; @@ -96,7 +102,7 @@ function stripJobRuntimeFields(job: CronStoreFile["jobs"][number]): Record, - projectedJob: CronJob | null, + projectedJob: CronStoredJob | null, ): Record { const failureDestination = projectedJob?.delivery?.failureDestination; if (!failureDestination) { @@ -148,7 +154,7 @@ function mergeFailureDestinationProjection( }; } -function bindCronJobRow(storeKey: string, job: CronJob, sortOrder: number): CronJobInsert { +function bindCronJobRow(storeKey: string, job: CronStoredJob, sortOrder: number): CronJobInsert { return { store_key: storeKey, job_id: job.id, @@ -180,7 +186,7 @@ function bindCronJobRow(storeKey: string, job: CronJob, sortOrder: number): Cron }; } -function normalizeCronJobForSqlite(job: CronStoreFile["jobs"][number]): CronJob | null { +function normalizeCronJobForSqlite(job: CronStoreFile["jobs"][number]): CronStoredJob | null { const raw = structuredClone(job) as unknown as Record; const hadDeleteAfterRun = Object.hasOwn(raw, "deleteAfterRun"); normalizeCronJobIdentityFields(raw); @@ -206,7 +212,7 @@ function normalizeCronJobForSqlite(job: CronStoreFile["jobs"][number]): CronJob createdAtMs, updatedAtMs, state: isRecord(normalized.state) ? (normalized.state as CronJobState) : {}, - } as CronJob; + } as CronStoredJob; } function countUnpersistableCronJobs(store: CronStoreFile): number { @@ -268,7 +274,7 @@ function pacingFromRow(row: CronJobRow): CronPacing | undefined { }; } -function rowToCronJob(row: CronJobRow): CronJob | null { +function rowToCronJob(row: CronJobRow): CronStoredJob | null { const jobJson = asOptionalObjectRecord(safeParseJson(row.job_json)) ?? {}; const jsonOwner = isRecord(jobJson.owner) ? jobJson.owner : undefined; const ownerAccountId = normalizeOptionalAccountId( @@ -281,6 +287,12 @@ function rowToCronJob(row: CronJobRow): CronJob | null { const trigger = triggerFromRow(row); const pacing = pacingFromRow(row); const scheduledToolPolicy = normalizeCronScheduledToolPolicy(jobJson.scheduledToolPolicy); + const toolsAllowProvenance = + isRecord(jobJson.toolsAllowProvenance) && + jobJson.toolsAllowProvenance.version === 1 && + jobJson.toolsAllowProvenance.source === "final-executable-surface" + ? ({ version: 1, source: "final-executable-surface" } as const) + : undefined; if (!schedule || !payload) { return null; } @@ -299,6 +311,7 @@ function rowToCronJob(row: CronJobRow): CronJob | null { } : {}), ...(scheduledToolPolicy ? { scheduledToolPolicy } : {}), + ...(toolsAllowProvenance ? { toolsAllowProvenance } : {}), name: row.name, ...(row.description ? { description: row.description } : {}), enabled: row.enabled !== 0, @@ -312,8 +325,8 @@ function rowToCronJob(row: CronJobRow): CronJob | null { ...(row.session_key ? { sessionKey: row.session_key } : {}), schedule, ...(pacing !== undefined ? { pacing } : {}), - sessionTarget: row.session_target as CronJob["sessionTarget"], - wakeMode: row.wake_mode as CronJob["wakeMode"], + sessionTarget: row.session_target as CronStoredJob["sessionTarget"], + wakeMode: row.wake_mode as CronStoredJob["wakeMode"], ...(trigger ? { trigger } : {}), payload, ...(delivery ? { delivery } : {}), @@ -323,7 +336,7 @@ function rowToCronJob(row: CronJobRow): CronJob | null { } /** Projects a live job through the same normalization/codecs used by SQLite persistence. */ -export function projectCronJobThroughStorageCodec(job: CronJob): CronJob { +export function projectCronJobThroughStorageCodec(job: CronStoredJob): CronStoredJob { const normalized = normalizeCronJobForSqlite(job); if (!normalized) { throw new Error(`cannot project invalid cron job ${job.id}`); @@ -425,7 +438,7 @@ export function replaceCronRows(db: DatabaseSync, storeKey: string, store: CronS export function upsertCronJobRow( db: DatabaseSync, storeKey: string, - job: CronJob, + job: CronStoredJob, sortOrder: number, ): void { const normalized = normalizeCronJobForSqlite(job); @@ -467,7 +480,7 @@ export function updateCronRuntimeRows( /** Reconstructs loaded cron store data and config-runtime sidecars from SQLite rows. */ export function loadedCronStoreFromRows(rows: CronJobRow[]): LoadedCronStore { - const jobs: CronJob[] = []; + const jobs: CronStoredJob[] = []; const configJobs: LoadedCronStore["configJobs"] = []; const configJobIndexes: number[] = []; const configJobRuntimeEntries: LoadedCronStore["configJobRuntimeEntries"] = []; diff --git a/src/cron/types.ts b/src/cron/types.ts index e45e9f449f2e..b6f6af627f19 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -463,7 +463,7 @@ export type CronTriggerEvaluationResult = | { kind: "busy" } | { kind: "error"; code: CronTriggerFailureCode; error: string }; -/** Fully persisted cron job with spec fields and mutable run state. */ +/** Public cron job contract with spec fields and mutable run state. */ export type CronJob = CronJobBase< CronSchedule, CronSessionTarget, @@ -486,10 +486,21 @@ export type CronJob = CronJobBase< state: CronJobState; }; +/** Store-only proof omitted from public Gateway results and the CronJob wire/type contract. */ +export type CronToolsAllowProvenance = { + version: 1; + source: "final-executable-surface"; +}; + +/** Persisted row shape; public Gateway and wire contracts use CronJob. */ +export type CronStoredJob = CronJob & { + toolsAllowProvenance?: CronToolsAllowProvenance; +}; + /** Versioned cron store file shape. */ export type CronStoreFile = { version: 1; - jobs: CronJob[]; + jobs: CronStoredJob[]; }; type CronJobStateInput = Partial< diff --git a/src/gateway/agent-runtime-identity-token.test.ts b/src/gateway/agent-runtime-identity-token.test.ts index cf16db533fb3..5c9d46a6cd2f 100644 --- a/src/gateway/agent-runtime-identity-token.test.ts +++ b/src/gateway/agent-runtime-identity-token.test.ts @@ -137,6 +137,47 @@ describe("agent runtime identity token", () => { nowSpy.mockRestore(); }); + it("round-trips final cron-cap capture provenance", async () => { + useTempHome(); + const runtimeToken = await importRuntimeTokenModule(); + const token = await runtimeToken.mintAgentRuntimeIdentityToken({ + agentId: "main", + sessionKey: "agent:main:main", + cronToolsAllowCapture: "final-executable-surface", + }); + + await expect(runtimeToken.verifyAgentRuntimeIdentityToken(token)).resolves.toEqual({ + kind: "agentRuntime", + agentId: "main", + sessionKey: "agent:main:main", + cronToolsAllowCapture: "final-executable-surface", + }); + }); + + it("round-trips a signed private cron creator grant only with final provenance", async () => { + useTempHome(); + const runtimeToken = await importRuntimeTokenModule(); + const cronCreatorAuthorityGrant = { runId: "run-1", token: "opaque-grant" }; + const token = await runtimeToken.mintAgentRuntimeIdentityToken({ + agentId: "main", + sessionKey: "agent:main:main", + cronToolsAllowCapture: "final-executable-surface", + cronCreatorAuthorityGrant, + }); + + await expect(runtimeToken.verifyAgentRuntimeIdentityToken(token)).resolves.toMatchObject({ + cronToolsAllowCapture: "final-executable-surface", + cronCreatorAuthorityGrant, + }); + await expect( + runtimeToken.mintAgentRuntimeIdentityToken({ + agentId: "main", + sessionKey: "agent:main:main", + cronCreatorAuthorityGrant, + }), + ).rejects.toThrow("require final tool-surface provenance"); + }); + it("does not mint local credentials while rejecting invalid presented tokens", async () => { useTempHome(); const runtimeToken = await importRuntimeTokenModule(); diff --git a/src/gateway/agent-runtime-identity-token.ts b/src/gateway/agent-runtime-identity-token.ts index 68fbbde8fa9b..b8ab7b370a7b 100644 --- a/src/gateway/agent-runtime-identity-token.ts +++ b/src/gateway/agent-runtime-identity-token.ts @@ -9,6 +9,7 @@ import { ensureExecApprovalsSnapshot, loadExecApprovalsAsync } from "../infra/ex import { normalizeOptionalAccountId } from "../routing/account-id.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { safeEqualSecret } from "../security/secret-equal.js"; +import type { CronCreatorAuthorityGrant } from "./cron-creator-authority-grant.js"; import type { AgentRuntimeMessageActionContext } from "./message-action-turn-capability.js"; const AGENT_RUNTIME_IDENTITY_TOKEN_CONTEXT = "openclaw:gateway-agent-runtime-identity-token:v1"; @@ -28,6 +29,8 @@ export type AgentRuntimeIdentity = { turnSourceAccountId?: string; messageActionContext?: AgentRuntimeMessageActionContext; cronSelfManagementContext?: AgentRuntimeCronSelfManagementContext; + cronToolsAllowCapture?: "final-executable-surface"; + cronCreatorAuthorityGrant?: CronCreatorAuthorityGrant; sessionSpawnContext?: AgentRuntimeSessionSpawnContext; }; @@ -47,6 +50,8 @@ type AgentRuntimeIdentityTokenPayload = { turnSourceAccountId?: string; messageActionContext?: AgentRuntimeMessageActionContext; cronSelfManagementContext?: AgentRuntimeCronSelfManagementContext; + cronToolsAllowCapture?: "final-executable-surface"; + cronCreatorAuthorityGrant?: CronCreatorAuthorityGrant; sessionSpawnContext?: AgentRuntimeSessionSpawnContext; }; @@ -77,6 +82,15 @@ function decodeSessionSpawnContext(value: unknown): AgentRuntimeSessionSpawnCont }; } +function decodeCronCreatorAuthorityGrant(value: unknown): CronCreatorAuthorityGrant | undefined { + if (!isRecord(value)) { + return undefined; + } + const runId = normalizeOptionalString(value.runId); + const token = normalizeOptionalString(value.token); + return runId && token ? { runId, token } : undefined; +} + async function readSharedAgentRuntimeIdentitySecret(): Promise { return (await loadExecApprovalsAsync()).socket?.token?.trim() || null; } @@ -214,6 +228,8 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP messageActionContext?: unknown; cronSelfManagementContext?: unknown; sessionSpawnContext?: unknown; + cronToolsAllowCapture?: unknown; + cronCreatorAuthorityGrant?: unknown; }; if ( raw.kind !== AGENT_RUNTIME_IDENTITY_TOKEN_KIND || @@ -265,6 +281,23 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP if (raw.sessionSpawnContext !== undefined && !sessionSpawnContext) { return undefined; } + const cronToolsAllowCapture = + raw.cronToolsAllowCapture === "final-executable-surface" + ? raw.cronToolsAllowCapture + : undefined; + if (raw.cronToolsAllowCapture !== undefined && !cronToolsAllowCapture) { + return undefined; + } + const cronCreatorAuthorityGrant = + raw.cronCreatorAuthorityGrant === undefined + ? undefined + : decodeCronCreatorAuthorityGrant(raw.cronCreatorAuthorityGrant); + if (raw.cronCreatorAuthorityGrant !== undefined && !cronCreatorAuthorityGrant) { + return undefined; + } + if (cronCreatorAuthorityGrant && !cronToolsAllowCapture) { + return undefined; + } return { kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND, agentId, @@ -273,6 +306,8 @@ function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenP ...(messageActionContext ? { messageActionContext } : {}), ...(cronSelfManagementContext ? { cronSelfManagementContext } : {}), ...(sessionSpawnContext ? { sessionSpawnContext } : {}), + ...(cronToolsAllowCapture ? { cronToolsAllowCapture } : {}), + ...(cronCreatorAuthorityGrant ? { cronCreatorAuthorityGrant } : {}), }; } catch { return undefined; @@ -286,8 +321,16 @@ export async function mintAgentRuntimeIdentityToken(params: { turnSourceAccountId?: string; messageActionContext?: AgentRuntimeMessageActionContext; cronSelfManagementJobId?: string; + cronToolsAllowCapture?: "final-executable-surface"; + cronCreatorAuthorityGrant?: CronCreatorAuthorityGrant; sessionSpawnContext?: AgentRuntimeSessionSpawnContext; }): Promise { + if ( + params.cronCreatorAuthorityGrant && + params.cronToolsAllowCapture !== "final-executable-surface" + ) { + throw new Error("cron creator authority grants require final tool-surface provenance"); + } if ( params.messageActionContext?.sourceReplyFinal === true && !normalizeOptionalString(params.messageActionContext.sourceReplyToolCallId) @@ -320,6 +363,12 @@ export async function mintAgentRuntimeIdentityToken(params: { ...(turnSourceAccountId ? { turnSourceAccountId } : {}), ...(messageActionContext ? { messageActionContext } : {}), ...(cronSelfManagementContext ? { cronSelfManagementContext } : {}), + ...(params.cronToolsAllowCapture === "final-executable-surface" + ? { cronToolsAllowCapture: params.cronToolsAllowCapture } + : {}), + ...(params.cronCreatorAuthorityGrant + ? { cronCreatorAuthorityGrant: params.cronCreatorAuthorityGrant } + : {}), ...(params.sessionSpawnContext ? { sessionSpawnContext: params.sessionSpawnContext } : {}), }); const signature = signPayload(await requireSharedAgentRuntimeIdentitySecret(), payload); @@ -356,6 +405,12 @@ export async function verifyAgentRuntimeIdentityToken( ...(payload.cronSelfManagementContext ? { cronSelfManagementContext: payload.cronSelfManagementContext } : {}), + ...(payload.cronToolsAllowCapture + ? { cronToolsAllowCapture: payload.cronToolsAllowCapture } + : {}), + ...(payload.cronCreatorAuthorityGrant + ? { cronCreatorAuthorityGrant: payload.cronCreatorAuthorityGrant } + : {}), ...(payload.sessionSpawnContext ? { sessionSpawnContext: payload.sessionSpawnContext } : {}), }; } diff --git a/src/gateway/cron-creator-authority-grant.test.ts b/src/gateway/cron-creator-authority-grant.test.ts new file mode 100644 index 000000000000..672495c78654 --- /dev/null +++ b/src/gateway/cron-creator-authority-grant.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { + consumeCronCreatorAuthorityGrant, + createCronCreatorAuthorityRunScope, + mintCronCreatorAuthorityGrant, + revokeCronCreatorAuthorityRunScope, +} from "./cron-creator-authority-grant.js"; + +describe("cron creator authority grants", () => { + it("consumes an exact live grant only once", () => { + const scope = createCronCreatorAuthorityRunScope("run-1"); + const grant = mintCronCreatorAuthorityGrant(scope); + + expect(() => consumeCronCreatorAuthorityGrant(grant)).not.toThrow(); + expect(() => consumeCronCreatorAuthorityGrant(grant)).toThrow( + "Configured MCP cron authority is no longer active", + ); + revokeCronCreatorAuthorityRunScope(scope); + }); + + it("rejects a runId mismatch without consuming the exact grant", () => { + const scope = createCronCreatorAuthorityRunScope("run-1"); + const grant = mintCronCreatorAuthorityGrant(scope); + + expect(() => consumeCronCreatorAuthorityGrant({ ...grant, runId: "run-other" })).toThrow( + "Configured MCP cron authority is no longer active", + ); + expect(() => consumeCronCreatorAuthorityGrant(grant)).not.toThrow(); + revokeCronCreatorAuthorityRunScope(scope); + }); + + it("rejects grants revoked by run settlement or abort", () => { + const scope = createCronCreatorAuthorityRunScope("run-1"); + const grant = mintCronCreatorAuthorityGrant(scope); + revokeCronCreatorAuthorityRunScope(scope); + + expect(scope.signal.aborted).toBe(true); + expect(() => consumeCronCreatorAuthorityGrant(grant)).toThrow( + "Configured MCP cron authority is no longer active", + ); + }); + + it("rejects a grant when its exact tool operation aborts", () => { + const scope = createCronCreatorAuthorityRunScope("run-1"); + const operation = new AbortController(); + const grant = mintCronCreatorAuthorityGrant(scope, operation.signal); + + operation.abort(new Error("tool call timed out")); + + expect(() => consumeCronCreatorAuthorityGrant(grant)).toThrow( + "Configured MCP cron authority is no longer active", + ); + revokeCronCreatorAuthorityRunScope(scope); + }); + + it("cleans operation abort listeners after consume and run revocation", () => { + const consumedScope = createCronCreatorAuthorityRunScope("run-consume"); + const consumedOperation = new AbortController(); + const consumedRemove = vi.spyOn(consumedOperation.signal, "removeEventListener"); + const consumedGrant = mintCronCreatorAuthorityGrant(consumedScope, consumedOperation.signal); + + consumeCronCreatorAuthorityGrant(consumedGrant); + expect(consumedRemove).toHaveBeenCalledWith("abort", expect.any(Function)); + revokeCronCreatorAuthorityRunScope(consumedScope); + + const revokedScope = createCronCreatorAuthorityRunScope("run-revoke"); + const revokedOperation = new AbortController(); + const revokedRemove = vi.spyOn(revokedOperation.signal, "removeEventListener"); + mintCronCreatorAuthorityGrant(revokedScope, revokedOperation.signal); + + revokeCronCreatorAuthorityRunScope(revokedScope); + expect(revokedRemove).toHaveBeenCalledWith("abort", expect.any(Function)); + }); +}); diff --git a/src/gateway/cron-creator-authority-grant.ts b/src/gateway/cron-creator-authority-grant.ts new file mode 100644 index 000000000000..8fc5a3c9c02c --- /dev/null +++ b/src/gateway/cron-creator-authority-grant.ts @@ -0,0 +1,108 @@ +import { randomBytes } from "node:crypto"; + +export type CronCreatorAuthorityGrant = Readonly<{ + runId: string; + token: string; +}>; + +export type CronCreatorAuthorityRunScope = { + readonly runId: string; + readonly signal: AbortSignal; + readonly grantTokens: Set; + active: boolean; + abort: () => void; +}; + +type CronCreatorAuthorityGrantEntry = { + scope: CronCreatorAuthorityRunScope; + operationSignal?: AbortSignal; + onOperationAbort?: () => void; +}; + +const grantsByToken = new Map(); + +function expiredAuthorityError(): Error & { status: number } { + return Object.assign( + new TypeError( + "Configured MCP cron authority is no longer active for this run. Retry the automation mutation from the active local operator turn.", + ), + { name: "CronCreatorAuthorityExpiredError", status: 403 }, + ); +} + +export function createCronCreatorAuthorityRunScope(runId: string): CronCreatorAuthorityRunScope { + const abortController = new AbortController(); + return { + runId, + signal: abortController.signal, + grantTokens: new Set(), + active: true, + abort: () => abortController.abort(expiredAuthorityError()), + }; +} + +export function mintCronCreatorAuthorityGrant( + scope: CronCreatorAuthorityRunScope, + operationSignal?: AbortSignal, +): CronCreatorAuthorityGrant { + if (!scope.active || scope.signal.aborted || operationSignal?.aborted) { + throw expiredAuthorityError(); + } + const token = randomBytes(32).toString("base64url"); + const entry: CronCreatorAuthorityGrantEntry = { scope, operationSignal }; + if (operationSignal) { + entry.onOperationAbort = () => revokeCronCreatorAuthorityGrant(token); + } + grantsByToken.set(token, entry); + scope.grantTokens.add(token); + if (operationSignal && entry.onOperationAbort) { + operationSignal.addEventListener("abort", entry.onOperationAbort, { once: true }); + } + return Object.freeze({ runId: scope.runId, token }); +} + +function revokeCronCreatorAuthorityGrant(token: string): void { + const entry = grantsByToken.get(token); + if (!entry) { + return; + } + grantsByToken.delete(token); + entry.scope.grantTokens.delete(token); + if (entry.operationSignal && entry.onOperationAbort) { + entry.operationSignal.removeEventListener("abort", entry.onOperationAbort); + } +} + +export function revokeCronCreatorAuthorityRunScope(scope: CronCreatorAuthorityRunScope): void { + if (!scope.active) { + return; + } + scope.active = false; + scope.abort(); + for (const token of scope.grantTokens) { + revokeCronCreatorAuthorityGrant(token); + } +} + +/** Consumes one live exact-run grant synchronously at the cron commit boundary. */ +export function consumeCronCreatorAuthorityGrant(grant: CronCreatorAuthorityGrant): void { + const runId = grant.runId.trim(); + const token = grant.token.trim(); + const entry = token ? grantsByToken.get(token) : undefined; + if (!entry) { + throw expiredAuthorityError(); + } + const scope = entry.scope; + if ( + !scope.active || + scope.signal.aborted || + entry.operationSignal?.aborted || + scope.runId !== runId + ) { + if (!scope.active || scope.signal.aborted || entry.operationSignal?.aborted) { + revokeCronCreatorAuthorityGrant(token); + } + throw expiredAuthorityError(); + } + revokeCronCreatorAuthorityGrant(token); +} diff --git a/src/gateway/server-methods/agent-run-admission-phase.ts b/src/gateway/server-methods/agent-run-admission-phase.ts index 79ac7a7b436a..4f443045736b 100644 --- a/src/gateway/server-methods/agent-run-admission-phase.ts +++ b/src/gateway/server-methods/agent-run-admission-phase.ts @@ -40,11 +40,16 @@ import { resolveGatewayAgentTaskTrackingMode, type GatewayAgentTaskTrackingMode, } from "./agent-task-tracking.js"; +import { + resolveGatewayCronCreatorAuthorityAdmission, + type GatewayCronCreatorAuthorityAdmission, +} from "./cron-creator-authority-admission.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; export type PreparedAgentRunDispatch = { activeGatewayWorkAdmission: SessionWorkAdmissionLease; activeRunAbort: ReturnType; + cronCreatorAuthority?: GatewayCronCreatorAuthorityAdmission; effectiveProviderOverride?: string; effectiveModelOverride?: string; effectiveThinking?: string; @@ -431,9 +436,21 @@ export async function prepareAgentRunDispatch(params: { }, }); params.respond(true, accepted, undefined, { runId: params.runId }); + const cronCreatorAuthority = resolveGatewayCronCreatorAuthorityAdmission({ + runId: params.runId, + resolvedSessionKey: params.resolvedSessionKey, + spawnedBy: params.sessionEntry?.spawnedBy, + client: params.client, + request: params.request, + inputProvenance: params.inputProvenance, + hasRestoredCronContinuation: params.restoredCronContinuation !== undefined, + isOneShotModelRun: params.isOneShotModelRun, + isRestartRecoveryResumeRun: params.isRestartRecoveryResumeRun, + }); return { activeGatewayWorkAdmission, activeRunAbort, + ...(cronCreatorAuthority ? { cronCreatorAuthority } : {}), effectiveProviderOverride, effectiveModelOverride, effectiveThinking, diff --git a/src/gateway/server-methods/agent-run-dispatch.ts b/src/gateway/server-methods/agent-run-dispatch.ts index 5ebe0ea17db0..83e6dce01262 100644 --- a/src/gateway/server-methods/agent-run-dispatch.ts +++ b/src/gateway/server-methods/agent-run-dispatch.ts @@ -4,6 +4,7 @@ import { classifyAgentRunTerminalOutcome, type AgentRunTerminalOutcome, } from "../../agents/agent-run-terminal-outcome.js"; +import { runWithCronCreatorAuthority } from "../../agents/cron-creator-authority-context.js"; import { isTimeoutError } from "../../agents/failover-error.js"; import type { MainSessionRecoveryPendingTarget } from "../../agents/main-session-recovery-store.js"; import { isAgentRunRestartAbortReason } from "../../agents/run-termination.js"; @@ -23,6 +24,7 @@ import { tryFinalizeTrackedAgentTask, type GatewayAgentTaskTrackingMode, } from "./agent-task-tracking.js"; +import type { GatewayCronCreatorAuthorityAdmission } from "./cron-creator-authority-admission.js"; import type { GatewayRequestContext, GatewayRequestHandlerOptions } from "./types.js"; function resolveResolvedAgentTimeoutStopReason( @@ -100,6 +102,7 @@ export function deleteGatewayDedupeEntries(params: { export function dispatchAgentRunFromGateway(params: { ingressOpts: Parameters[0]; runId: string; + cronCreatorAuthority?: GatewayCronCreatorAuthorityAdmission; dedupeKeys: readonly string[]; /** * Controller whose signal is wired into `ingressOpts.abortSignal`. Used on @@ -161,9 +164,18 @@ export function dispatchAgentRunFromGateway(params: { return false; } }; - void agentCommandFromGatewayIngress(params.ingressOpts, defaultRuntime, params.context.deps, { - restoreAdmittedRecovery: params.restoreAdmittedRecovery, - }) + const runAgent = () => + agentCommandFromGatewayIngress(params.ingressOpts, defaultRuntime, params.context.deps, { + restoreAdmittedRecovery: params.restoreAdmittedRecovery, + }); + const agentRun = params.cronCreatorAuthority + ? runWithCronCreatorAuthority( + params.cronCreatorAuthority.runId, + runAgent, + params.abortController.signal, + ) + : runAgent(); + void agentRun .then(async (result) => { const signalStopReason = resolveResolvedAgentTimeoutStopReason( result?.meta, diff --git a/src/gateway/server-methods/agent-run-execution-phase.ts b/src/gateway/server-methods/agent-run-execution-phase.ts index d6bf0b94f9f0..b0038a7d06c6 100644 --- a/src/gateway/server-methods/agent-run-execution-phase.ts +++ b/src/gateway/server-methods/agent-run-execution-phase.ts @@ -364,6 +364,7 @@ export function startAgentRunExecution(params: { ); dispatchAgentRunFromGateway({ + cronCreatorAuthority: prepared.cronCreatorAuthority, ingressOpts: { message, images: params.images, diff --git a/src/gateway/server-methods/agent-run-local-operator-authority.test.ts b/src/gateway/server-methods/agent-run-local-operator-authority.test.ts new file mode 100644 index 000000000000..2b0a8bb6d935 --- /dev/null +++ b/src/gateway/server-methods/agent-run-local-operator-authority.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; +import type { InputProvenance } from "../../sessions/input-provenance.js"; +import type { AgentRunRequest } from "./agent-request-types.js"; +import { + resolveGatewayChatCronCreatorAuthorityAdmission, + resolveGatewayCronCreatorAuthorityAdmission, + type GatewayCronCreatorAuthorityAdmission, +} from "./cron-creator-authority-admission.js"; +import type { GatewayClient } from "./shared-types.js"; + +function createClient(overrides: Partial> = {}) { + return { + connect: { scopes: ["operator.admin"] }, + internal: { isLocalClient: true, ...overrides }, + } as unknown as GatewayClient; +} + +function createParams( + overrides: { + client?: GatewayClient | null; + request?: Partial; + inputProvenance?: InputProvenance; + hasRestoredCronContinuation?: boolean; + isOneShotModelRun?: boolean; + isRestartRecoveryResumeRun?: boolean; + resolvedSessionKey?: string; + spawnedBy?: string; + } = {}, +): Parameters[0] { + return { + runId: "run-local-operator", + resolvedSessionKey: "agent:main:main", + client: createClient(), + request: { + message: "create an automation", + idempotencyKey: "run-local-operator", + ...overrides.request, + }, + hasRestoredCronContinuation: false, + isOneShotModelRun: false, + isRestartRecoveryResumeRun: false, + ...(overrides.client !== undefined ? { client: overrides.client } : {}), + ...(overrides.inputProvenance ? { inputProvenance: overrides.inputProvenance } : {}), + ...(overrides.hasRestoredCronContinuation !== undefined + ? { hasRestoredCronContinuation: overrides.hasRestoredCronContinuation } + : {}), + ...(overrides.isOneShotModelRun !== undefined + ? { isOneShotModelRun: overrides.isOneShotModelRun } + : {}), + ...(overrides.isRestartRecoveryResumeRun !== undefined + ? { isRestartRecoveryResumeRun: overrides.isRestartRecoveryResumeRun } + : {}), + ...(overrides.resolvedSessionKey !== undefined + ? { resolvedSessionKey: overrides.resolvedSessionKey } + : {}), + ...(overrides.spawnedBy !== undefined ? { spawnedBy: overrides.spawnedBy } : {}), + }; +} + +describe("resolveGatewayCronCreatorAuthorityAdmission", () => { + it("mints only for the admitted direct local admin turn", () => { + expect(resolveGatewayCronCreatorAuthorityAdmission(createParams())).toEqual({ + runId: "run-local-operator", + } satisfies GatewayCronCreatorAuthorityAdmission); + }); + + it.each([ + ["missing Gateway client", { client: null }], + ["non-local client", { client: createClient({ isLocalClient: undefined }) }], + [ + "non-admin client", + { + client: { + ...createClient(), + connect: { scopes: ["operator.write"] }, + } as unknown as GatewayClient, + }, + ], + ["ephemeral run", { resolvedSessionKey: "" }], + ["spawned run", { spawnedBy: "agent:main:parent" }], + ["external provenance", { inputProvenance: { kind: "external_user" } }], + ["cron continuation", { hasRestoredCronContinuation: true }], + ["restart continuation", { isRestartRecoveryResumeRun: true }], + ["model run", { isOneShotModelRun: true }], + ["internal handoff", { request: { internalRuntimeHandoffId: "handoff-1" } }], + ["model-run request", { request: { modelRun: true } }], + ["identity retry", { request: { internalExecutionIdentityRetry: true } }], + ["exec approval followup", { request: { execApprovalFollowupExpectedSessionId: "session-1" } }], + ["internal session effects", { request: { sessionEffects: "internal" } }], + ["suppressed prompt persistence", { request: { suppressPromptPersistence: true } }], + ["swarm collector", { request: { swarmCollector: true } }], + ["completion event", { request: { internalEvents: [{ type: "task_completion" }] } }], + ["ACP spawn", { request: { acpTurnSource: "manual_spawn" } }], + ["subagent lane", { request: { lane: "subagent" } }], + ["plugin run", { client: createClient({ pluginRuntimeOwnerId: "memory-core" }) }], + ["synthetic run", { client: createClient({ syntheticClient: true }) }], + ["delegated run", { client: createClient({ delegatedToolPolicyHandoffId: "handoff-1" }) }], + ["approval runtime", { client: createClient({ approvalRuntime: true }) }], + ["sender attribution", { client: createClient({ senderAttribution: { id: "sender-1" } }) }], + ["tracked agent run", { client: createClient({ agentRunTracking: "plugin_subagent" }) }], + [ + "plugin subagent requester", + { client: createClient({ pluginSubagentRequester: {} as never }) }, + ], + ["runtime plugin grant", { client: createClient({ runtimePluginToolGrant: {} as never }) }], + [ + "worker runtime", + { + client: createClient({ + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "main", + sessionKey: "agent:main:worker", + }, + }), + }, + ], + ] as const)("rejects %s", (_label, override) => { + expect( + resolveGatewayCronCreatorAuthorityAdmission( + createParams(override as Parameters[0]), + ), + ).toBeUndefined(); + }); +}); + +function createChatParams( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + runId: "run-local-chat", + resolvedSessionKey: "agent:main:main", + client: createClient(), + hasExplicitOrigin: false, + hasRestoredCronContinuation: false, + isIncognito: false, + isReconnectResume: false, + isSystemGenerated: false, + turnKind: "main", + isDirectExternalUser: true, + ...overrides, + }; +} + +describe("resolveGatewayChatCronCreatorAuthorityAdmission", () => { + it("mints only for a direct external local-admin user turn", () => { + expect(resolveGatewayChatCronCreatorAuthorityAdmission(createChatParams())).toEqual({ + runId: "run-local-chat", + }); + }); + + it.each([ + ["internal re-entry", { isDirectExternalUser: false }], + ["explicit origin", { hasExplicitOrigin: true }], + ["reconnect", { isReconnectResume: true }], + ["system-generated", { isSystemGenerated: true }], + ["BTW turn", { turnKind: "btw" }], + ["incognito", { isIncognito: true }], + ["persisted cron continuation", { hasRestoredCronContinuation: true }], + ["spawned lineage", { spawnedBy: "agent:main:parent" }], + ["input provenance", { inputProvenance: { kind: "external_user" } }], + ["remote client", { client: createClient({ isLocalClient: undefined }) }], + [ + "non-admin client", + { client: { ...createClient(), connect: { scopes: ["operator.write"] } } }, + ], + ["synthetic client", { client: createClient({ syntheticClient: true }) }], + ["sender attribution", { client: createClient({ senderAttribution: { id: "sender-1" } }) }], + ["approval runtime", { client: createClient({ approvalRuntime: true }) }], + ["cron continuation client", { client: createClient({ cronRunContinuation: true }) }], + [ + "worker runtime", + { + client: createClient({ + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "main", + sessionKey: "agent:main:worker", + }, + }), + }, + ], + ["plugin runtime", { client: createClient({ pluginRuntimeOwnerId: "memory-core" }) }], + ["tracked agent run", { client: createClient({ agentRunTracking: "plugin_subagent" }) }], + [ + "plugin subagent requester", + { client: createClient({ pluginSubagentRequester: {} as never }) }, + ], + ["runtime plugin grant", { client: createClient({ runtimePluginToolGrant: {} as never }) }], + ["delegated handoff", { client: createClient({ delegatedToolPolicyHandoffId: "handoff" }) }], + ] as const)("rejects %s", (_label, overrides) => { + expect( + resolveGatewayChatCronCreatorAuthorityAdmission( + createChatParams( + overrides as Partial< + Parameters[0] + >, + ), + ), + ).toBeUndefined(); + }); +}); diff --git a/src/gateway/server-methods/chat-send-external-authority-contract.ts b/src/gateway/server-methods/chat-send-external-authority-contract.ts new file mode 100644 index 000000000000..17812f978fb9 --- /dev/null +++ b/src/gateway/server-methods/chat-send-external-authority-contract.ts @@ -0,0 +1,19 @@ +import type { InputProvenance } from "../../sessions/input-provenance.js"; +import type { GatewayRequestHandlerOptions } from "./types.js"; + +export type ChatSendExternalAuthorityAdmission = { + resolve(params: { + runId: string; + sessionKey: string; + spawnedBy?: string; + client: GatewayRequestHandlerOptions["client"]; + inputProvenance?: InputProvenance; + hasExplicitOrigin: boolean; + hasRestoredCronContinuation: boolean; + isIncognitoEntry: boolean; + isReconnectResume: boolean; + isSystemGenerated: boolean; + turnKind: "btw" | "main"; + }): Readonly<{ runId: string }> | undefined; + run(authority: Readonly<{ runId: string }>, run: () => T, signal?: AbortSignal): T; +}; diff --git a/src/gateway/server-methods/chat-send-external-entry.ts b/src/gateway/server-methods/chat-send-external-entry.ts new file mode 100644 index 000000000000..a2473151bd6e --- /dev/null +++ b/src/gateway/server-methods/chat-send-external-entry.ts @@ -0,0 +1,33 @@ +import { runWithCronCreatorAuthority } from "../../agents/cron-creator-authority-context.js"; +import { isIncognitoSessionKey } from "../../routing/session-key.js"; +import type { ChatSendExternalAuthorityAdmission } from "./chat-send-external-authority-contract.js"; +import { handleChatSend } from "./chat-send-handler.js"; +import { resolveGatewayChatCronCreatorAuthorityAdmission } from "./cron-creator-authority-admission.js"; +import type { GatewayRequestHandlerOptions } from "./types.js"; + +const externalAuthorityAdmission: ChatSendExternalAuthorityAdmission = { + resolve: (params) => + resolveGatewayChatCronCreatorAuthorityAdmission({ + runId: params.runId, + resolvedSessionKey: params.sessionKey, + spawnedBy: params.spawnedBy, + client: params.client, + inputProvenance: params.inputProvenance, + hasExplicitOrigin: params.hasExplicitOrigin, + hasRestoredCronContinuation: params.hasRestoredCronContinuation, + isIncognito: params.isIncognitoEntry || isIncognitoSessionKey(params.sessionKey), + isReconnectResume: params.isReconnectResume, + isSystemGenerated: params.isSystemGenerated, + turnKind: params.turnKind, + isDirectExternalUser: true, + }), + run: (authority, run, signal) => runWithCronCreatorAuthority(authority.runId, run, signal), +}; + +/** Authenticated external chat entry; internal re-entry must call handleChatSend directly. */ +export function handleDirectExternalChatSend( + options: GatewayRequestHandlerOptions, + onAdmissionOwned?: () => Promise, +): Promise { + return handleChatSend(options, onAdmissionOwned, externalAuthorityAdmission); +} diff --git a/src/gateway/server-methods/chat-send-handler.ts b/src/gateway/server-methods/chat-send-handler.ts index cd17e41dd188..7bd0138a3bc1 100644 --- a/src/gateway/server-methods/chat-send-handler.ts +++ b/src/gateway/server-methods/chat-send-handler.ts @@ -17,11 +17,6 @@ import { import { retainGatewayRootWorkAdmissionContinuation } from "../../process/gateway-work-admission.js"; import { isOperatorUiClient } from "../../utils/message-channel.js"; import { updateChatRunProvider } from "../chat-abort.js"; -import { - completeQueuedChatTurn, - registerQueuedChatTurn, - retireQueuedChatTurnCancellation, -} from "../chat-queued-turns.js"; import type { ChatRunTiming } from "../server-chat-state.js"; import { setGatewayDedupeEntry } from "./agent-job.js"; import { broadcastChatError, broadcastChatFinal } from "./chat-broadcast.js"; @@ -36,6 +31,7 @@ import { createChatSendDispatchErrorLifecycle, handleChatSendSetupError, } from "./chat-send-dispatch-errors.js"; +import type { ChatSendExternalAuthorityAdmission } from "./chat-send-external-authority-contract.js"; import { beginChatSendMessageInjection, finalizeAcceptedChatSendMessageInjection, @@ -49,6 +45,7 @@ import { import { createChatSendReplyDispatch } from "./chat-send-reply-dispatch.js"; import { prepareAndAdmitChatSend } from "./chat-send-setup.js"; import { finalizeChatSendSourceReplies } from "./chat-send-source-finalization.js"; +import { createChatSendTurnAdoptionLifecycle } from "./chat-send-turn-adoption.js"; import { applyChatSendManagedMedia, prepareChatSendUserTurn } from "./chat-send-user-turn.js"; import { chatSendAckServerTimingAttributes, @@ -57,7 +54,6 @@ import { shouldIncludeChatSendAckServerTiming, type ChatSendServerTimingPhase, } from "./chat-server-timing.js"; -import { normalizeOptionalChatText as normalizeOptionalText } from "./chat-text-normalization.js"; import { createGatewayChatUserTurnController } from "./chat-user-turn-recorder.js"; import { gatewayClientSenderFields } from "./gateway-client-identity.js"; import { emitSessionsChanged } from "./session-change-event.js"; @@ -66,6 +62,7 @@ import type { GatewayRequestHandlerOptions } from "./types.js"; export async function handleChatSend( { params, respond, context, client }: GatewayRequestHandlerOptions, onAdmissionOwned?: () => Promise, + externalAuthorityAdmission?: ChatSendExternalAuthorityAdmission, ): Promise { const setup = await prepareAndAdmitChatSend( { params, respond, context, client }, @@ -136,6 +133,21 @@ export async function handleChatSend( return; } const { imageOrder, prepareAttachmentsMs } = preparedAttachments.value; + const cronCreatorAuthority = externalAuthorityAdmission?.resolve({ + runId: clientRunId, + sessionKey, + spawnedBy: entry?.spawnedBy, + client, + inputProvenance: systemInputProvenance, + hasExplicitOrigin: normalizedRequest.value.explicitOrigin !== undefined, + hasRestoredCronContinuation: entry?.cronRunContinuation !== undefined, + isIncognitoEntry: entry?.incognito === true, + isReconnectResume: reconnectResumeRequested, + isSystemGenerated: + normalizedRequest.value.suppressCommandInterpretation || + normalizedRequest.value.systemProvenanceReceipt !== undefined, + turnKind: normalizedRequest.value.turnKind, + }); const admissionStartedAt = Date.now(); const terminalizeRestartSafeAdmission = async (terminalState: { @@ -337,12 +349,24 @@ export async function handleChatSend( session: preparedSession.value, userTurnRecorder, }); - let queuedFollowupEnqueued = false; - let releaseQueuedFollowupWorkAdmission: (() => void) | undefined; + const queuedFollowup = createChatSendTurnAdoptionLifecycle({ + chatQueuedTurns: context.chatQueuedTurns, + runId: clientRunId, + controller: activeRunAbort.controller, + sessionId: backingSessionId ?? clientRunId, + sessionKey, + agentId: selectedAgent.agentId, + ownerConnId: client?.connId, + ownerDeviceId: client?.connect?.device?.id, + ownerKey: queuedFollowupOwnerKey, + ...(expectedLeafEntryId !== undefined ? { originatingLeafEntryId: expectedLeafEntryId } : {}), + hasCronCreatorAuthority: cronCreatorAuthority !== undefined, + retainWorkAdmission: retainGatewayWorkAdmission, + }); const dispatchErrorLifecycle = createChatSendDispatchErrorLifecycle({ admission: admitted.value, context, - isQueuedFollowupEnqueued: () => queuedFollowupEnqueued, + isQueuedFollowupEnqueued: queuedFollowup.isEnqueued, persistUserTurnTranscript: persistGatewayUserTurnTranscript, session: preparedSession.value, terminalizeRestartSafeAdmission, @@ -419,171 +443,137 @@ export async function handleChatSend( } } applyChatSendManagedMedia(ctx, await pluginBoundMediaPromise); - const dispatchResult = await dispatchInboundMessageWithProjectedDispatcher({ - ctx, - cfg, - dispatcherOptions: replyDispatch.dispatcherOptions, - onSessionMetadataChanges: (changes) => { - for (const change of changes) { - emitSessionsChanged(context, change); - } - }, - replyOptions: { - runId: clientRunId, - ...(isOperatorUiClient(clientInfo) - ? { - promptCacheKey: resolveWebchatPromptCacheKey({ - agentId, - provider: resolvedSessionModel.provider, - model: resolvedSessionModel.model, - sessionKey: activeRunScopeKey, - }), - } - : {}), - ...(supportsTaskSuggestions - ? { taskSuggestionDeliveryMode: "gateway" as const } - : {}), - requestedSessionId, - ...(restartSafeAdmission - ? { - expectedExistingSessionId: admittedSessionId, - pinExpectedExistingSession: true, - } - : entry?.sessionId - ? { expectedExistingSessionId: entry.sessionId } - : {}), - resumeRequestedSession: reconnectResumeRequested, - onSessionPrepared: (binding) => { - if (binding.sessionKey === sessionKey) { - userTurn.setAcceptedSessionId(binding.sessionId); + const dispatchInbound = () => + dispatchInboundMessageWithProjectedDispatcher({ + ctx, + cfg, + dispatcherOptions: replyDispatch.dispatcherOptions, + onSessionMetadataChanges: (changes) => { + for (const change of changes) { + emitSessionsChanged(context, change); } }, - abortSignal: activeRunAbort.controller.signal, - // Keep a Gateway-owned cancel identity after this chat.send - // terminalizes while the prompt waits in followup/collect queue. - onFollowupQueueDisposition: (reason) => { - context.logGateway.info("chat queue turn intentionally skipped", { - runId: clientRunId, - sessionKey, - outcome: "skipped", - reason, - }); - }, - turnAdoptionLifecycle: { - // Gateway cancel identity only — share collect key via ownerKey. - admission: "cancel-only", - ...(expectedLeafEntryId !== undefined - ? { originatingLeafEntryId: expectedLeafEntryId } + replyOptions: { + runId: clientRunId, + ...(isOperatorUiClient(clientInfo) + ? { + promptCacheKey: resolveWebchatPromptCacheKey({ + agentId, + provider: resolvedSessionModel.provider, + model: resolvedSessionModel.model, + sessionKey: activeRunScopeKey, + }), + } : {}), - ownerKey: queuedFollowupOwnerKey, - onAdopted: async () => {}, - onDeferred: () => { - queuedFollowupEnqueued = registerQueuedChatTurn({ - chatQueuedTurns: context.chatQueuedTurns, - runId: clientRunId, - controller: activeRunAbort.controller, - sessionId: backingSessionId ?? clientRunId, - sessionKey, - agentId: selectedAgent.agentId, - ownerConnId: normalizeOptionalText(client?.connId), - ownerDeviceId: normalizeOptionalText(client?.connect?.device?.id), - }); - if (queuedFollowupEnqueued && !releaseQueuedFollowupWorkAdmission) { - // The detached dispatch can finish before this queued turn is - // adopted. Retain the session fence across that ownership gap. - releaseQueuedFollowupWorkAdmission = retainGatewayWorkAdmission(); + ...(supportsTaskSuggestions + ? { taskSuggestionDeliveryMode: "gateway" as const } + : {}), + requestedSessionId, + ...(restartSafeAdmission + ? { + expectedExistingSessionId: admittedSessionId, + pinExpectedExistingSession: true, + } + : entry?.sessionId + ? { expectedExistingSessionId: entry.sessionId } + : {}), + resumeRequestedSession: reconnectResumeRequested, + onSessionPrepared: (binding) => { + if (binding.sessionKey === sessionKey) { + userTurn.setAcceptedSessionId(binding.sessionId); } - return queuedFollowupEnqueued; }, - onCancellationRetired: () => { - retireQueuedChatTurnCancellation( - context.chatQueuedTurns, - clientRunId, - activeRunAbort.controller, + abortSignal: activeRunAbort.controller.signal, + // Keep a Gateway-owned cancel identity after this chat.send + // terminalizes while the prompt waits in followup/collect queue. + onFollowupQueueDisposition: (reason) => { + context.logGateway.info("chat queue turn intentionally skipped", { + runId: clientRunId, + sessionKey, + outcome: "skipped", + reason, + }); + }, + turnAdoptionLifecycle: queuedFollowup.lifecycle, + images: replyOptionImages, + imageOrder: imageOrder.length > 0 ? imageOrder : undefined, + media: replyOptionMedia, + thinkingLevelOverride: p.thinking, + fastModeOverride: p.fastMode, + queueModeOverride: p.queueMode, + userTurnTranscriptRecorder: userTurnRecorder, + ...(messageInjectionTarget && !isInternalTextSlashCommandTurn + ? { messageInjectionAttempted: true as const } + : {}), + ...(restartSafeAdmission ? { suppressNextUserMessagePersistence: true } : {}), + fastModeAutoOnSecondsOverride: p.fastAutoOnSeconds, + onAgentRunStart: (runId) => { + agentRunStarted = replyDispatch.captureAgentTranscriptStart(); + emitServerTiming( + "agent-run-started", + runId !== clientRunId ? { agentRunId: runId } : undefined, + dispatchStartedAtMs, ); - }, - onSettled: () => { - completeQueuedChatTurn( - context.chatQueuedTurns, - clientRunId, - activeRunAbort.controller, + const connId = typeof client?.connId === "string" ? client.connId : undefined; + const wantsToolEvents = hasGatewayClientCap( + client?.connect?.caps, + GATEWAY_CLIENT_CAPS.TOOL_EVENTS, ); - releaseQueuedFollowupWorkAdmission?.(); - releaseQueuedFollowupWorkAdmission = undefined; - }, - }, - images: replyOptionImages, - imageOrder: imageOrder.length > 0 ? imageOrder : undefined, - media: replyOptionMedia, - thinkingLevelOverride: p.thinking, - fastModeOverride: p.fastMode, - queueModeOverride: p.queueMode, - userTurnTranscriptRecorder: userTurnRecorder, - ...(messageInjectionTarget && !isInternalTextSlashCommandTurn - ? { messageInjectionAttempted: true as const } - : {}), - ...(restartSafeAdmission ? { suppressNextUserMessagePersistence: true } : {}), - fastModeAutoOnSecondsOverride: p.fastAutoOnSeconds, - onAgentRunStart: (runId) => { - agentRunStarted = replyDispatch.captureAgentTranscriptStart(); - emitServerTiming( - "agent-run-started", - runId !== clientRunId ? { agentRunId: runId } : undefined, - dispatchStartedAtMs, - ); - const connId = typeof client?.connId === "string" ? client.connId : undefined; - const wantsToolEvents = hasGatewayClientCap( - client?.connect?.caps, - GATEWAY_CLIENT_CAPS.TOOL_EVENTS, - ); - if (connId && wantsToolEvents) { - context.registerToolEventRecipient(runId, connId); - // Register for any other active runs *in the same session* so - // late-joining clients (e.g. page refresh mid-response) receive - // in-progress tool events without leaking cross-session data. - const defaultAgentId = resolveDefaultAgentId(cfg); - const selectedGlobalAgentId = - sessionKey === "global" - ? (selectedAgent.agentId ?? defaultAgentId) - : undefined; - for (const [activeRunId, active] of context.chatAbortControllers) { - const activeGlobalAgentId = - active.sessionKey === "global" - ? (active.agentId ?? defaultAgentId) + if (connId && wantsToolEvents) { + context.registerToolEventRecipient(runId, connId); + // Register for any other active runs *in the same session* so + // late-joining clients (e.g. page refresh mid-response) receive + // in-progress tool events without leaking cross-session data. + const defaultAgentId = resolveDefaultAgentId(cfg); + const selectedGlobalAgentId = + sessionKey === "global" + ? (selectedAgent.agentId ?? defaultAgentId) : undefined; - const sameSelectedGlobalAgent = - sessionKey === "global" && - selectedGlobalAgentId !== undefined && - activeGlobalAgentId === selectedGlobalAgentId; - const sameSession = - active.sessionKey === sessionKey && - (sessionKey !== "global" || sameSelectedGlobalAgent); - if (activeRunId !== runId && sameSession) { - context.registerToolEventRecipient(activeRunId, connId); + for (const [activeRunId, active] of context.chatAbortControllers) { + const activeGlobalAgentId = + active.sessionKey === "global" + ? (active.agentId ?? defaultAgentId) + : undefined; + const sameSelectedGlobalAgent = + sessionKey === "global" && + selectedGlobalAgentId !== undefined && + activeGlobalAgentId === selectedGlobalAgentId; + const sameSession = + active.sessionKey === sessionKey && + (sessionKey !== "global" || sameSelectedGlobalAgent); + if (activeRunId !== runId && sameSession) { + context.registerToolEventRecipient(activeRunId, connId); + } } } - } + }, + onModelSelected: (modelSelection) => { + updateChatRunProvider(context.chatAbortControllers, { + runId: clientRunId, + providerId: modelSelection.provider, + authProviderId: resolveProviderIdForAuth(modelSelection.provider, { + config: cfg, + }), + }); + replyDispatch.onModelSelected(modelSelection); + emitServerTiming( + "model-selected", + { + provider: modelSelection.provider, + model: modelSelection.model, + }, + dispatchStartedAtMs, + ); + }, }, - onModelSelected: (modelSelection) => { - updateChatRunProvider(context.chatAbortControllers, { - runId: clientRunId, - providerId: modelSelection.provider, - authProviderId: resolveProviderIdForAuth(modelSelection.provider, { - config: cfg, - }), - }); - replyDispatch.onModelSelected(modelSelection); - emitServerTiming( - "model-selected", - { - provider: modelSelection.provider, - model: modelSelection.model, - }, - dispatchStartedAtMs, - ); - }, - }, - }); + }); + const dispatchResult = await (cronCreatorAuthority && externalAuthorityAdmission + ? externalAuthorityAdmission.run( + cronCreatorAuthority, + dispatchInbound, + activeRunAbort.controller.signal, + ) + : dispatchInbound()); if (dispatchResult.beforeAgentRunBlocked === true) { userTurnRecorder.markBlocked(); } @@ -639,7 +629,7 @@ export async function handleChatSend( // duplicate normal embedded-agent assistant turns. The non-agent branch below has no // runtime-owned assistant turn, so it appends a gateway-injected assistant entry before // broadcasting the final UI event. - if (!agentRunStarted && !queuedFollowupEnqueued) { + if (!agentRunStarted && !queuedFollowup.isEnqueued()) { await finalizeChatSendNonAgentReplies({ accountId, context, @@ -709,7 +699,7 @@ export async function handleChatSend( }, dispatchStartedAtMs, ); - if (queuedFollowupEnqueued && !context.chatRunState.hasAbortMarker(clientRunId)) { + if (queuedFollowup.isEnqueued() && !context.chatRunState.hasAbortMarker(clientRunId)) { // Successful queue admission ends this client run. The later // aggregate/followup owns its own run id. broadcastChatFinal({ diff --git a/src/gateway/server-methods/chat-send-turn-adoption.ts b/src/gateway/server-methods/chat-send-turn-adoption.ts new file mode 100644 index 000000000000..8eee5e9ea0ea --- /dev/null +++ b/src/gateway/server-methods/chat-send-turn-adoption.ts @@ -0,0 +1,64 @@ +import type { TurnAdoptionLifecycle } from "../../auto-reply/get-reply-options.types.js"; +import { + completeQueuedChatTurn, + registerQueuedChatTurn, + retireQueuedChatTurnCancellation, + type QueuedChatTurnMap, +} from "../chat-queued-turns.js"; +import { normalizeOptionalChatText } from "./chat-text-normalization.js"; + +export function createChatSendTurnAdoptionLifecycle(params: { + chatQueuedTurns: QueuedChatTurnMap; + runId: string; + controller: AbortController; + sessionId: string; + sessionKey: string; + agentId?: string; + ownerConnId?: string; + ownerDeviceId?: string; + ownerKey?: string; + originatingLeafEntryId?: string | null; + hasCronCreatorAuthority: boolean; + retainWorkAdmission: () => () => void; +}): { lifecycle: TurnAdoptionLifecycle; isEnqueued: () => boolean } { + let enqueued = false; + let releaseWorkAdmission: (() => void) | undefined; + const lifecycle: TurnAdoptionLifecycle = { + // Gateway cancel identity only — share collect key via ownerKey. + admission: "cancel-only", + ...(params.originatingLeafEntryId !== undefined + ? { originatingLeafEntryId: params.originatingLeafEntryId } + : {}), + ownerKey: params.ownerKey, + onAdopted: async () => {}, + onDeferred: () => { + if (params.hasCronCreatorAuthority) { + lifecycle.cronCreatorAuthorityUnavailable = "queued-local-operator"; + } + enqueued = registerQueuedChatTurn({ + chatQueuedTurns: params.chatQueuedTurns, + runId: params.runId, + controller: params.controller, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + agentId: params.agentId, + ownerConnId: normalizeOptionalChatText(params.ownerConnId), + ownerDeviceId: normalizeOptionalChatText(params.ownerDeviceId), + }); + if (enqueued && !releaseWorkAdmission) { + // Retain the session fence until this detached queued turn is adopted. + releaseWorkAdmission = params.retainWorkAdmission(); + } + return enqueued; + }, + onCancellationRetired: () => { + retireQueuedChatTurnCancellation(params.chatQueuedTurns, params.runId, params.controller); + }, + onSettled: () => { + completeQueuedChatTurn(params.chatQueuedTurns, params.runId, params.controller); + releaseWorkAdmission?.(); + releaseWorkAdmission = undefined; + }, + }; + return { lifecycle, isEnqueued: () => enqueued }; +} diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 01f7e57e0c0d..90e9f051d46b 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -14,6 +14,10 @@ import { } from "../../../packages/gateway-protocol/src/client-info.js"; import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js"; import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-protocol/src/schema.js"; +import { + bindActiveCronCreatorAuthorityResolver, + runWithCronCreatorAuthorityResolver, +} from "../../agents/cron-creator-authority-context.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import { onTrustedMessageAuditEvent } from "../../audit/message-audit-events.js"; import { setReplyPayloadMetadata } from "../../auto-reply/reply-payload.js"; @@ -48,6 +52,7 @@ import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.pa import { createDeferred } from "../../test-utils/deferred.js"; import { withEnvAsync } from "../../test-utils/env.js"; import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; +import { consumeCronCreatorAuthorityGrant } from "../cron-creator-authority-grant.js"; import { createChatRunState } from "../server-chat-state.js"; import { handleChatSend } from "./chat-send-handler.js"; import type { GatewayRequestContext } from "./types.js"; @@ -157,6 +162,9 @@ const mockState = vi.hoisted(() => ({ disposedTranscriptWriteAttempts: 0, runtimeAssistantContentBeforeDelivery: null as Array> | null, runtimeAssistantTextsBeforeDelivery: [] as string[], + cronAuthorityProbe: undefined as + | ((runId: string | undefined) => Promise | void) + | undefined, // `unstagedSources` lets tests simulate partial staging failure: absolute // source paths listed here are excluded from the returned `staged` map even // though ctx still carries their rewritten paths. This mirrors how the real @@ -324,6 +332,7 @@ dispatchInboundMessageMock.mockImplementation( }>, ) => void; replyOptions?: { + runId?: string; onAgentRunStart?: (runId: string) => void; userTurnTranscriptRecorder?: { message?: unknown; @@ -347,6 +356,7 @@ dispatchInboundMessageMock.mockImplementation( mockState.lastDispatchOriginatingLeafEntryId = params.replyOptions?.turnAdoptionLifecycle?.originatingLeafEntryId; mockState.lastTaskSuggestionDeliveryMode = params.replyOptions?.taskSuggestionDeliveryMode; + await mockState.cronAuthorityProbe?.(params.replyOptions?.runId); const recorder = params.replyOptions?.userTurnTranscriptRecorder; mockState.lastDispatchUserTurnInput = recorder?.resolveMessage ? await recorder.resolveMessage() @@ -1094,6 +1104,7 @@ async function runNonStreamingChatSend(params: { client?: unknown; expectBroadcast?: boolean; requestParams?: Record; + directExternal?: boolean; waitForCompletion?: boolean; waitForDedupe?: boolean; waitFor?: NonStreamingChatSendWaitFor; @@ -1111,10 +1122,11 @@ async function runNonStreamingChatSend(params: { if (typeof params.deliver === "boolean") { sendParams.deliver = params.deliver; } - await expectDefined( - chatHandlers["chat.send"], - 'chatHandlers["chat.send"] test invariant', - )({ + const handler = + params.directExternal === false + ? handleChatSend + : expectDefined(chatHandlers["chat.send"], 'chatHandlers["chat.send"] test invariant'); + await handler({ params: { ...sendParams, ...params.requestParams, @@ -1293,6 +1305,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.disposedTranscriptWriteAttempts = 0; mockState.runtimeAssistantContentBeforeDelivery = null; mockState.runtimeAssistantTextsBeforeDelivery = []; + mockState.cronAuthorityProbe = undefined; }); it.each([ @@ -7124,6 +7137,187 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); describe("chat.send operator UI client sender context", () => { + it.each([ + [GATEWAY_CLIENT_NAMES.CONTROL_UI, GATEWAY_CLIENT_MODES.WEBCHAT, "web"], + [GATEWAY_CLIENT_NAMES.MACOS_APP, GATEWAY_CLIENT_MODES.UI, "darwin"], + ] as const)( + "binds lazy configured-MCP cron authority to an admitted local %s turn", + async (clientId, mode, platform) => { + await createGatewayUserTurnSqliteFixture(`openclaw-chat-send-cron-authority-${clientId}-`); + const { send } = createChatRequestFixture(); + let retainedResolver: ReturnType; + let resolvedGrant: { runId: string; token: string } | undefined; + mockState.cronAuthorityProbe = async (runId) => { + await runWithCronCreatorAuthorityResolver({ + runId: runId ?? "", + resolve: async () => ({ + tools: ["read", { name: "configured__lookup", pluginId: "bundle-mcp" }], + provenance: { version: 1, source: "final-executable-surface" }, + }), + run: async () => { + retainedResolver = bindActiveCronCreatorAuthorityResolver(runId); + const snapshot = await retainedResolver!(); + resolvedGrant = snapshot.grant; + expect(snapshot.tools).toEqual([ + "read", + { name: "configured__lookup", pluginId: "bundle-mcp" }, + ]); + }, + }); + }; + + await send({ + idempotencyKey: `idem-cron-authority-${clientId}`, + client: { + connect: { + client: { id: clientId, mode, version: "dev", platform }, + scopes: ["operator.admin"], + }, + internal: { isLocalClient: true }, + }, + expectBroadcast: false, + }); + + expect(resolvedGrant).toMatchObject({ runId: `idem-cron-authority-${clientId}` }); + await expect(retainedResolver!()).rejects.toThrow( + "Configured MCP cron authority is no longer active", + ); + expect(() => consumeCronCreatorAuthorityGrant(resolvedGrant!)).toThrow( + "Configured MCP cron authority is no longer active", + ); + }, + ); + + it("denies otherwise-eligible internal chat.send re-entry, including Talk consults", async () => { + await createGatewayUserTurnSqliteFixture("openclaw-chat-send-cron-authority-internal-reentry-"); + let boundResolver: ReturnType; + mockState.cronAuthorityProbe = async (runId) => { + runWithCronCreatorAuthorityResolver({ + runId: runId ?? "", + resolve: async () => ({ + tools: ["read", "configured__lookup"], + provenance: { version: 1, source: "final-executable-surface" }, + }), + run: () => { + boundResolver = bindActiveCronCreatorAuthorityResolver(runId); + }, + }); + }; + const { send } = createChatRequestFixture(); + + await send({ + idempotencyKey: "idem-cron-authority-internal-reentry", + message: "Talk realtime agent consult prompt", + directExternal: false, + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + version: "dev", + platform: "web", + }, + scopes: ["operator.admin"], + }, + internal: { isLocalClient: true }, + }, + expectBroadcast: false, + }); + + expect(boundResolver!).toBeUndefined(); + }); + + it.each([ + { + name: "remote client", + client: { internal: {}, scopes: ["operator.admin"] }, + }, + { + name: "non-admin client", + client: { internal: { isLocalClient: true }, scopes: ["operator.write"] }, + }, + { + name: "incognito session", + client: { internal: { isLocalClient: true }, scopes: ["operator.admin"] }, + sessionEntry: { incognito: true }, + }, + { + name: "synthetic client", + client: { + internal: { isLocalClient: true, syntheticClient: true }, + scopes: ["operator.admin"], + }, + }, + { + name: "input provenance", + client: { internal: { isLocalClient: true }, scopes: ["operator.admin"] }, + requestParams: { systemInputProvenance: { kind: "external_user" } }, + }, + { + name: "delegated handoff", + client: { + internal: { isLocalClient: true, delegatedToolPolicyHandoffId: "handoff-1" }, + scopes: ["operator.admin"], + }, + }, + { + name: "spawned lineage", + client: { internal: { isLocalClient: true }, scopes: ["operator.admin"] }, + sessionEntry: { spawnedBy: "agent:main:parent" }, + }, + { + name: "synthetic cron continuation", + client: { + internal: { isLocalClient: true, cronRunContinuation: true }, + scopes: ["operator.admin"], + }, + }, + { + name: "persisted cron continuation", + client: { internal: { isLocalClient: true }, scopes: ["operator.admin"] }, + sessionEntry: { + cronRunContinuation: { lifecycleRevision: "revision-1", phase: "running" }, + }, + }, + ])("does not mint configured-MCP cron authority for $name", async (testCase) => { + await createGatewayUserTurnSqliteFixture("openclaw-chat-send-cron-authority-negative-"); + mockState.sessionEntry = testCase.sessionEntry ?? {}; + let boundResolver: ReturnType; + mockState.cronAuthorityProbe = async (runId) => { + runWithCronCreatorAuthorityResolver({ + runId: runId ?? "", + resolve: async () => ({ + tools: ["read"], + provenance: { version: 1, source: "final-executable-surface" }, + }), + run: () => { + boundResolver = bindActiveCronCreatorAuthorityResolver(runId); + }, + }); + }; + const { send } = createChatRequestFixture(); + + await send({ + idempotencyKey: `idem-cron-authority-negative-${testCase.name.replaceAll(" ", "-")}`, + client: { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + version: "dev", + platform: "web", + }, + scopes: testCase.client.scopes, + }, + internal: testCase.client.internal, + }, + requestParams: testCase.requestParams, + expectBroadcast: false, + }); + + expect(boundResolver!).toBeUndefined(); + }); + it("does not inject sender identity fields for Control UI clients", async () => { await createGatewayUserTurnSqliteFixture("openclaw-chat-send-control-ui-sender-"); const { send } = createChatRequestFixture(); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 2703b12b3419..48595251f48b 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -24,7 +24,7 @@ import { sendGlobalAwareNodeChatPayload } from "./chat-broadcast.js"; import { chatHistoryHandlers } from "./chat-history-handler.js"; import { chatMessageGetHandlers } from "./chat-message-get-handler.js"; import { resolveRequestedChatAgentId, validateChatSelectedAgent } from "./chat-origin-routing.js"; -import { handleChatSend } from "./chat-send-handler.js"; +import { handleDirectExternalChatSend } from "./chat-send-external-entry.js"; import { normalizeOptionalChatText as normalizeOptionalText } from "./chat-text-normalization.js"; import { appendAssistantTranscriptMessage } from "./chat-transcript-persistence.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -101,7 +101,7 @@ export const chatHandlers: GatewayRequestHandlers = { respond(true, { titles }); }, "chat.abort": handleChatAbortRequest, - "chat.send": handleChatSend, + "chat.send": handleDirectExternalChatSend, "chat.inject": async ({ params, respond, context }) => { if (!assertValidParams(params, validateChatInjectParams, "chat.inject", respond)) { return; diff --git a/src/gateway/server-methods/cron-caller-scope.ts b/src/gateway/server-methods/cron-caller-scope.ts index ed7fe5272625..861b58dbd66b 100644 --- a/src/gateway/server-methods/cron-caller-scope.ts +++ b/src/gateway/server-methods/cron-caller-scope.ts @@ -4,10 +4,16 @@ import { createTrustedCronScheduledToolPolicy, type CronScheduledToolPolicy, } from "../../cron/scheduled-tool-policy.js"; -import type { CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js"; +import type { + CronJob, + CronJobCreate, + CronJobPatch, + CronToolsAllowProvenance, +} from "../../cron/types.js"; import { normalizeAccountId } from "../../routing/account-id.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; +import type { CronCreatorAuthorityGrant } from "../cron-creator-authority-grant.js"; import type { GatewayClient } from "./types.js"; export type CronCallerScope = { @@ -16,6 +22,8 @@ export type CronCallerScope = { sessionKey?: string; accountId: string; currentJobId?: string; + toolsAllowProvenance?: CronToolsAllowProvenance; + cronCreatorAuthorityGrant?: CronCreatorAuthorityGrant; }; export function readCronCallerScope( @@ -36,6 +44,17 @@ export function readCronCallerScope( sessionKey: identity.sessionKey?.trim() || undefined, accountId: normalizeAccountId(identity.turnSourceAccountId), currentJobId, + ...(identity.cronToolsAllowCapture === "final-executable-surface" + ? { + toolsAllowProvenance: { + version: 1 as const, + source: "final-executable-surface" as const, + }, + } + : {}), + ...(identity.cronCreatorAuthorityGrant + ? { cronCreatorAuthorityGrant: identity.cronCreatorAuthorityGrant } + : {}), }; } diff --git a/src/gateway/server-methods/cron-creator-authority-admission.ts b/src/gateway/server-methods/cron-creator-authority-admission.ts new file mode 100644 index 000000000000..ff741e8d8b21 --- /dev/null +++ b/src/gateway/server-methods/cron-creator-authority-admission.ts @@ -0,0 +1,109 @@ +import type { InputProvenance } from "../../sessions/input-provenance.js"; +import { clientHasAdminScope } from "./agent-handler-helpers.js"; +import type { AgentRunRequest } from "./agent-request-types.js"; +import type { GatewayClient } from "./shared-types.js"; + +export type GatewayCronCreatorAuthorityAdmission = Readonly<{ runId: string }>; + +type DirectLocalOperatorAuthorityParams = { + runId: string; + resolvedSessionKey?: string; + spawnedBy?: string; + client?: GatewayClient | null; + inputProvenance?: InputProvenance; + disallowed: boolean; +}; + +function resolveDirectLocalOperatorAuthority( + params: DirectLocalOperatorAuthorityParams, +): GatewayCronCreatorAuthorityAdmission | undefined { + const internal = params.client?.internal; + const runId = params.runId.trim(); + const isDirectLocalOperator = + runId.length > 0 && + clientHasAdminScope(params.client ?? null) && + internal?.isLocalClient === true && + Boolean(params.resolvedSessionKey?.trim()) && + !params.spawnedBy?.trim() && + params.inputProvenance === undefined && + !params.disallowed && + internal.syntheticClient !== true && + internal.senderAttribution === undefined && + internal.approvalRuntime !== true && + internal.cronRunContinuation !== true && + internal.agentRuntimeIdentity === undefined && + internal.pluginRuntimeOwnerId === undefined && + internal.agentRunTracking === undefined && + internal.pluginSubagentRequester === undefined && + internal.runtimePluginToolGrant === undefined && + internal.delegatedToolPolicyHandoffId === undefined; + return isDirectLocalOperator ? Object.freeze({ runId }) : undefined; +} + +/** Mints fresh cron authority only for an admitted direct local agent RPC turn. */ +export function resolveGatewayCronCreatorAuthorityAdmission(params: { + runId: string; + resolvedSessionKey?: string; + spawnedBy?: string; + client?: GatewayClient | null; + request: AgentRunRequest; + inputProvenance?: InputProvenance; + hasRestoredCronContinuation: boolean; + isOneShotModelRun: boolean; + isRestartRecoveryResumeRun: boolean; +}): GatewayCronCreatorAuthorityAdmission | undefined { + const request = params.request; + return resolveDirectLocalOperatorAuthority({ + runId: params.runId, + resolvedSessionKey: params.resolvedSessionKey, + spawnedBy: params.spawnedBy, + client: params.client, + inputProvenance: params.inputProvenance, + disallowed: + params.hasRestoredCronContinuation || + params.isOneShotModelRun || + params.isRestartRecoveryResumeRun || + request.modelRun === true || + request.acpTurnSource !== undefined || + request.internalRuntimeHandoffId !== undefined || + request.internalExecutionIdentityRetry === true || + request.execApprovalFollowupExpectedSessionId !== undefined || + request.internalEvents !== undefined || + request.sessionEffects === "internal" || + request.suppressPromptPersistence === true || + request.swarmCollector === true || + request.lane === "subagent", + }); +} + +/** Mints the same authority for an admitted ordinary local chat.send turn. */ +export function resolveGatewayChatCronCreatorAuthorityAdmission(params: { + runId: string; + resolvedSessionKey?: string; + spawnedBy?: string; + client?: GatewayClient | null; + inputProvenance?: InputProvenance; + hasExplicitOrigin: boolean; + hasRestoredCronContinuation: boolean; + isIncognito: boolean; + isReconnectResume: boolean; + isSystemGenerated: boolean; + turnKind: "btw" | "main"; + isDirectExternalUser: boolean; +}): GatewayCronCreatorAuthorityAdmission | undefined { + return resolveDirectLocalOperatorAuthority({ + runId: params.runId, + resolvedSessionKey: params.resolvedSessionKey, + spawnedBy: params.spawnedBy, + client: params.client, + inputProvenance: params.inputProvenance, + disallowed: + !params.isDirectExternalUser || + params.hasExplicitOrigin || + params.hasRestoredCronContinuation || + params.isIncognito || + params.isReconnectResume || + params.isSystemGenerated || + params.turnKind !== "main", + }); +} diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 2a8bdfa2a8c9..e2efd637c1f8 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -51,6 +51,7 @@ import { resolveAgentHarnessSessionStoreEntryError, } from "../../sessions/agent-harness-session-key.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; +import { consumeCronCreatorAuthorityGrant } from "../cron-creator-authority-grant.js"; import { getGatewayProcessInstanceId } from "../process-instance.js"; import { loadSessionEntryReadOnly } from "../session-utils.js"; import { @@ -71,6 +72,19 @@ import { assertValidParams } from "./validation.js"; type CronJobIdParams = { id?: string; jobId?: string }; +function resolveCronCreatorAuthorityCommitGuard( + callerScope: CronCallerScope | undefined, +): (() => void) | undefined { + const grant = callerScope?.cronCreatorAuthorityGrant; + if (!grant) { + return undefined; + } + if (!callerScope.toolsAllowProvenance) { + throw new TypeError("cron creator authority grant is missing tool-surface provenance"); + } + return () => consumeCronCreatorAuthorityGrant(grant); +} + type CronRunsRequestParams = CronJobIdParams & { agentId?: string; scope?: "job" | "all"; @@ -658,6 +672,13 @@ export const cronHandlers: GatewayRequestHandlers = { return; } const callerScope = readCronCallerScope(client); + let cronCreatorAuthorityCommitGuard: (() => void) | undefined; + try { + cronCreatorAuthorityCommitGuard = resolveCronCreatorAuthorityCommitGuard(callerScope); + } catch (err) { + respondInvalidCronParams(respond, "cron.add", formatErrorMessage(err)); + return; + } const jobCreate = applyCronCreateCallerScopeDefault(candidate as CronJobCreate, callerScope); const cfg = context.getRuntimeConfig(); try { @@ -718,7 +739,15 @@ export const cronHandlers: GatewayRequestHandlers = { defaultAgentId: context.cron.getDefaultAgentId(), }), ...(cronJobUsesToolRuntime(jobCreate) - ? { scheduledToolPolicy: resolveCronScheduledToolPolicyForCaller(callerScope) } + ? { + scheduledToolPolicy: resolveCronScheduledToolPolicyForCaller(callerScope), + ...(callerScope?.toolsAllowProvenance + ? { toolsAllowProvenance: callerScope.toolsAllowProvenance } + : {}), + ...(cronCreatorAuthorityCommitGuard + ? { commitGuard: cronCreatorAuthorityCommitGuard } + : {}), + } : {}), }); } catch (err) { @@ -799,6 +828,13 @@ export const cronHandlers: GatewayRequestHandlers = { expectedConfigRevision?: string; }; const callerScope = readCronCallerScope(client); + let cronCreatorAuthorityCommitGuard: (() => void) | undefined; + try { + cronCreatorAuthorityCommitGuard = resolveCronCreatorAuthorityCommitGuard(callerScope); + } catch (err) { + respondInvalidCronParams(respond, "cron.update", formatErrorMessage(err)); + return; + } const jobId = resolveCronJobId(p); if (!jobId) { respond( @@ -905,7 +941,15 @@ export const cronHandlers: GatewayRequestHandlers = { } }, cronPatchTouchesToolRuntime(patch) - ? { scheduledToolPolicy: resolveCronScheduledToolPolicyForCaller(callerScope) } + ? { + scheduledToolPolicy: resolveCronScheduledToolPolicyForCaller(callerScope), + ...(callerScope?.toolsAllowProvenance + ? { toolsAllowProvenance: callerScope.toolsAllowProvenance } + : {}), + ...(cronCreatorAuthorityCommitGuard + ? { commitGuard: cronCreatorAuthorityCommitGuard } + : {}), + } : undefined, ); } catch (err) { diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index a7029b021eb3..876936f13a57 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -12,6 +12,12 @@ import { createChannelTestPluginBase, createTestRegistry, } from "../../test-utils/channel-plugins.js"; +import { + createCronCreatorAuthorityRunScope, + mintCronCreatorAuthorityGrant, + revokeCronCreatorAuthorityRunScope, + type CronCreatorAuthorityGrant, +} from "../cron-creator-authority-grant.js"; import { getGatewayProcessInstanceId } from "../process-instance.js"; import type { GatewayClient } from "./types.js"; @@ -124,31 +130,39 @@ function setCronValidationTestRegistry(): void { function createCronContext(currentJobs?: CronJob | CronJob[]) { const jobs = currentJobs ? (Array.isArray(currentJobs) ? currentJobs : [currentJobs]) : []; - const update = vi.fn(async (id: string, patch: Partial) => - createCronJob({ + const committedAdds: Partial[] = []; + const committedUpdates: Array<{ id: string; patch: Partial }> = []; + const update = vi.fn(async (id: string, patch: Partial) => { + committedUpdates.push({ id, patch }); + return createCronJob({ ...jobs.find((job) => job.id === id), ...patch, id, - }), - ); + }); + }); return { + committedAdds, + committedUpdates, cron: { - add: vi.fn(async (input: Partial, _opts?: unknown) => - createCronJob({ ...input, id: "cron-1" }), - ), + add: vi.fn(async (input: Partial, opts?: { commitGuard?: () => void }) => { + opts?.commitGuard?.(); + committedAdds.push(input); + return createCronJob({ ...input, id: "cron-1" }); + }), update, updateWithPrecondition: vi.fn( async ( id: string, patch: Partial, precondition: (job: CronJob, nowMs: number) => void | Promise, - _opts?: unknown, + opts?: { commitGuard?: () => void }, ) => { const job = jobs.find((candidate) => candidate.id === id); if (!job) { throw new Error(`unknown automation id: ${id}`); } await precondition(job, Date.now()); + opts?.commitGuard?.(); return await update(id, patch); }, ), @@ -313,6 +327,13 @@ function callerClient( }; } +function callerClientWithCronCreatorAuthority(grant: CronCreatorAuthorityGrant): GatewayClient { + const client = callerClient("ops"); + client.internal!.agentRuntimeIdentity!.cronToolsAllowCapture = "final-executable-surface"; + client.internal!.agentRuntimeIdentity!.cronCreatorAuthorityGrant = grant; + return client; +} + function telegramDeliveryWithSlackFailure(overrides: Partial = {}): CronDelivery { return { mode: "announce", @@ -1228,6 +1249,136 @@ describe("cron method validation", () => { expectCronSuccess(respond); }); + it("consumes an exact live configured-MCP grant once at cron.add commit", async () => { + const scope = createCronCreatorAuthorityRunScope("run-add"); + const grant = mintCronCreatorAuthorityGrant(scope); + const context = createCronContext(); + const client = callerClientWithCronCreatorAuthority(grant); + + const first = await invokeCron("cron.add", agentTurnCronParams(), { context, client }); + expectCronSuccess(first.respond); + expect(context.committedAdds).toHaveLength(1); + + const replay = await invokeCron("cron.add", agentTurnCronParams(), { context, client }); + expectResponseError(replay.respond, { + code: "INVALID_REQUEST", + messageIncludes: "Configured MCP cron authority is no longer active", + }); + expect(context.committedAdds).toHaveLength(1); + revokeCronCreatorAuthorityRunScope(scope); + }); + + it("rejects a mismatched cron.add runId without consuming the exact grant", async () => { + const scope = createCronCreatorAuthorityRunScope("run-add"); + const grant = mintCronCreatorAuthorityGrant(scope); + const context = createCronContext(); + + const mismatch = await invokeCron("cron.add", agentTurnCronParams(), { + context, + client: callerClientWithCronCreatorAuthority({ ...grant, runId: "run-other" }), + }); + expectResponseError(mismatch.respond, { + code: "INVALID_REQUEST", + messageIncludes: "Configured MCP cron authority is no longer active", + }); + expect(context.committedAdds).toHaveLength(0); + + const exact = await invokeCron("cron.add", agentTurnCronParams(), { + context, + client: callerClientWithCronCreatorAuthority(grant), + }); + expectCronSuccess(exact.respond); + expect(context.committedAdds).toHaveLength(1); + revokeCronCreatorAuthorityRunScope(scope); + }); + + it("keeps cron.add mutation at zero after the admitted run revokes its grant", async () => { + const scope = createCronCreatorAuthorityRunScope("run-add-revoked"); + const grant = mintCronCreatorAuthorityGrant(scope); + revokeCronCreatorAuthorityRunScope(scope); + const context = createCronContext(); + + const result = await invokeCron("cron.add", agentTurnCronParams(), { + context, + client: callerClientWithCronCreatorAuthority(grant), + }); + + expectResponseError(result.respond, { + code: "INVALID_REQUEST", + messageIncludes: "Configured MCP cron authority is no longer active", + }); + expect(context.committedAdds).toHaveLength(0); + }); + + it("keeps cron.update mutation at zero after resolution outlives its run", async () => { + const scope = createCronCreatorAuthorityRunScope("run-update-revoked"); + const grant = mintCronCreatorAuthorityGrant(scope); + revokeCronCreatorAuthorityRunScope(scope); + const currentJob = createCronJob({ + agentId: "ops", + owner: { agentId: "ops", sessionKey: "agent:ops:main", accountId: "default" }, + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: "agent:ops:main", + ownerAccountId: "default", + }, + }); + const context = createCronContext(currentJob); + + const result = await invokeCron( + "cron.update", + { + jobId: currentJob.id, + patch: { + payload: { kind: "agentTurn", message: "updated", toolsAllow: ["read"] }, + }, + }, + { context, client: callerClientWithCronCreatorAuthority(grant) }, + ); + + expectResponseError(result.respond, { + code: "INVALID_REQUEST", + messageIncludes: "Configured MCP cron authority is no longer active", + }); + expect(context.committedUpdates).toHaveLength(0); + }); + + it("consumes an exact live configured-MCP grant once at cron.update commit", async () => { + const scope = createCronCreatorAuthorityRunScope("run-update"); + const grant = mintCronCreatorAuthorityGrant(scope); + const currentJob = createCronJob({ + agentId: "ops", + owner: { agentId: "ops", sessionKey: "agent:ops:main", accountId: "default" }, + scheduledToolPolicy: { + version: 1, + mode: "account", + ownerSessionKey: "agent:ops:main", + ownerAccountId: "default", + }, + }); + const context = createCronContext(currentJob); + const client = callerClientWithCronCreatorAuthority(grant); + const params = { + jobId: currentJob.id, + patch: { + payload: { kind: "agentTurn", message: "updated", toolsAllow: ["read"] }, + }, + }; + + const first = await invokeCron("cron.update", params, { context, client }); + expectCronSuccess(first.respond); + expect(context.committedUpdates).toHaveLength(1); + + const replay = await invokeCron("cron.update", params, { context, client }); + expectResponseError(replay.respond, { + code: "INVALID_REQUEST", + messageIncludes: "Configured MCP cron authority is no longer active", + }); + expect(context.committedUpdates).toHaveLength(1); + revokeCronCreatorAuthorityRunScope(scope); + }); + it("keeps scoped read access with the stamped owner after operator retargeting", async () => { const job = createCronJob({ agentId: "worker", diff --git a/src/gateway/server-methods/sessions-messaging.ts b/src/gateway/server-methods/sessions-messaging.ts index c41756b898a6..8c9fd5295ad6 100644 --- a/src/gateway/server-methods/sessions-messaging.ts +++ b/src/gateway/server-methods/sessions-messaging.ts @@ -28,7 +28,7 @@ import { import { asWorkerInferenceControl } from "../worker-environments/inference-control.js"; import { formatForLog } from "../ws-log.js"; import { handleChatAbortRequestWithLifecycle } from "./chat-abort-handler.js"; -import { handleChatSend } from "./chat-send-handler.js"; +import { handleDirectExternalChatSend } from "./chat-send-external-entry.js"; import { chatHandlers } from "./chat.js"; import { resolveGatewayInflightRequest, type GatewayInflightResult } from "./inflight.js"; import { hasTrackedActiveSessionRun } from "./session-active-runs.js"; @@ -396,7 +396,7 @@ async function handleSessionSend(params: { isWebchatConnect: params.isWebchatConnect, }; if (onAdmissionOwned) { - await handleChatSend(options, onAdmissionOwned); + await handleDirectExternalChatSend(options, onAdmissionOwned); return; } await expectDefined(chatHandlers["chat.send"], "chat.send handler")(options); diff --git a/src/gateway/server-methods/sessions.send-followup-status.test.ts b/src/gateway/server-methods/sessions.send-followup-status.test.ts index 82cbf63a43cb..152c1cf2972a 100644 --- a/src/gateway/server-methods/sessions.send-followup-status.test.ts +++ b/src/gateway/server-methods/sessions.send-followup-status.test.ts @@ -86,8 +86,8 @@ vi.mock("./chat.js", () => ({ }, })); -vi.mock("./chat-send-handler.js", () => ({ - handleChatSend: (...args: unknown[]) => chatSendWithAdmissionOwnedMock(...args), +vi.mock("./chat-send-external-entry.js", () => ({ + handleDirectExternalChatSend: (...args: unknown[]) => chatSendWithAdmissionOwnedMock(...args), })); vi.mock("./chat-abort-handler.js", () => ({ diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 18b170215cfe..1840b7010180 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -91,6 +91,8 @@ export type GatewayClient = { /** Signed shared-auth session admitted only to approve its own upgrade pairing. */ isControlUiDeviceAuthMigration?: boolean; internal?: { + /** Handshake-attested direct-local transport; never accepted from wire params. */ + isLocalClient?: true; /** Marks the server-constructed client used by trusted in-process dispatch. */ syntheticClient?: true; /** Overrides persisted sender attribution without changing the authorizing client identity. */ diff --git a/src/gateway/server-methods/skills.proposals.test.ts b/src/gateway/server-methods/skills.proposals.test.ts index 43460059dad4..693515a92694 100644 --- a/src/gateway/server-methods/skills.proposals.test.ts +++ b/src/gateway/server-methods/skills.proposals.test.ts @@ -86,6 +86,10 @@ vi.mock("./chat.js", () => ({ }, })); +vi.mock("./chat-send-handler.js", () => ({ + handleChatSend: mocks.chatSend, +})); + const { skillsHandlers } = await import("./skills.js"); function callHandler(method: string, params: Record) { diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 3bbfa0a79ffd..c26f0e087c2a 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -155,11 +155,7 @@ async function forwardSkillWorkshopRevisionToChatSend( targetAgentId?: string; }, ): Promise { - const { chatHandlers } = await import("./chat.js"); - const chatSend = chatHandlers["chat.send"]; - if (!chatSend) { - throw new Error("chat.send handler is unavailable"); - } + const { handleChatSend } = await import("./chat-send-handler.js"); const chatParams = { sessionKey: params.sessionKey, agentId: params.targetAgentId ?? params.agentId, @@ -173,7 +169,7 @@ async function forwardSkillWorkshopRevisionToChatSend( suppressCommandInterpretation: true, idempotencyKey: params.idempotencyKey, }; - await chatSend({ + await handleChatSend({ ...opts, req: { ...opts.req, method: "chat.send", params: chatParams }, params: chatParams, diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index 8dc4fb23e02f..f2ae1bc9a9f6 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -178,10 +178,8 @@ vi.mock("../../talk/client-voice-session.js", async (importOriginal) => { }; }); -vi.mock("./chat.js", () => ({ - chatHandlers: { - "chat.send": mocks.chatSend, - }, +vi.mock("./chat-send-handler.js", () => ({ + handleChatSend: mocks.chatSend, })); vi.mock("../sessions-resolve.js", () => ({ diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index bcb9fbb9f26e..183f72972ad5 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -294,8 +294,9 @@ export async function attachAuthenticatedGatewayConnect( } } const internal = - isTrustedApprovalRuntime || trustedAgentRuntimeIdentity + isLocalClient || isTrustedApprovalRuntime || trustedAgentRuntimeIdentity ? { + ...(isLocalClient ? { isLocalClient: true as const } : {}), ...(isTrustedApprovalRuntime ? { approvalRuntime: true } : {}), ...(trustedAgentRuntimeIdentity ? { agentRuntimeIdentity: trustedAgentRuntimeIdentity } diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index 90b001c84819..fe6d5f27bc62 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -21,6 +21,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js"; import type { HealthSummary } from "../../health/types.js"; import { getOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js"; import { handleGatewayRequest } from "../../server-methods.js"; +import { resolveGatewayCronCreatorAuthorityAdmission } from "../../server-methods/cron-creator-authority-admission.js"; import type { GatewayRequestContext } from "../../server-methods/types.js"; import { GatewayNodeLifecycleDispatchTracker } from "./node-lifecycle-dispatch.js"; @@ -1092,6 +1093,106 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { expect(connectedClient?.internal?.approvalRuntime).not.toBe(true); }); + it("retains handshake-attested locality for direct operator admission", async () => { + const refreshHealthSnapshot = vi.fn(async () => + createHealthSummary(), + ); + const harness = attachGatewayHarness({ + connId: "conn-local-operator-authority", + connectNonce: "nonce-local-operator-authority", + refreshHealthSnapshot, + }); + + harness.sendConnect("connect-local-operator-authority", { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "gateway-client", + version: "dev", + platform: "test", + mode: "backend", + }, + role: "operator", + scopes: ["operator.admin"], + caps: [], + }); + + await waitForFast(() => { + expect(harness.socketSend).toHaveBeenCalled(); + }); + const connectedClient = harness.client as { + clientIp?: string; + internal?: { isLocalClient?: true }; + } | null; + expect(connectedClient?.clientIp).toBeUndefined(); + expect(connectedClient?.internal?.isLocalClient).toBe(true); + + const admission = resolveGatewayCronCreatorAuthorityAdmission({ + runId: "local-operator-run", + resolvedSessionKey: "agent:main:main", + client: harness.client as never, + request: { message: "hello", idempotencyKey: "local-operator-run" }, + hasRestoredCronContinuation: false, + isOneShotModelRun: false, + isRestartRecoveryResumeRun: false, + }); + expect(admission).toEqual({ runId: "local-operator-run" }); + }); + + it("does not carry local operator authority for an authenticated remote client", async () => { + const refreshHealthSnapshot = vi.fn(async () => + createHealthSummary(), + ); + const harness = attachGatewayHarness({ + connId: "conn-remote-operator-authority", + connectNonce: "nonce-remote-operator-authority", + requestHost: "gateway.example.com:18789", + remoteAddr: "203.0.113.50", + resolvedAuth: { + mode: "token", + token: "gateway-token", + allowTailscale: false, + }, + refreshHealthSnapshot, + }); + + harness.sendConnect("connect-remote-operator-authority", { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "gateway-client", + version: "dev", + platform: "test", + mode: "backend", + }, + role: "operator", + scopes: ["operator.admin"], + caps: [], + auth: { token: "gateway-token" }, + }); + + await waitForFast(() => { + expect(harness.socketSend).toHaveBeenCalled(); + }); + const connectedClient = harness.client as { + clientIp?: string; + internal?: { isLocalClient?: true }; + } | null; + expect(connectedClient?.clientIp).toBe("203.0.113.50"); + expect(connectedClient?.internal?.isLocalClient).toBeUndefined(); + + const admission = resolveGatewayCronCreatorAuthorityAdmission({ + runId: "remote-operator-run", + resolvedSessionKey: "agent:main:main", + client: harness.client as never, + request: { message: "hello", idempotencyKey: "remote-operator-run" }, + hasRestoredCronContinuation: false, + isOneShotModelRun: false, + isRestartRecoveryResumeRun: false, + }); + expect(admission).toBeUndefined(); + }); + it("marks operator approval clients with the server runtime token", async () => { const refreshHealthSnapshot = vi.fn(async () => createHealthSummary(), diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index 8ecd9e98658f..d8c5a9aae6f1 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -47,6 +47,8 @@ export type GatewayWsClient = PluginNodeCapabilityClient & { }; clientIp?: string; internal?: { + /** Handshake-attested direct-local transport; never accepted from wire params. */ + isLocalClient?: true; approvalRuntime?: boolean; agentRuntimeIdentity?: AgentRuntimeIdentity; }; diff --git a/src/gateway/talk-agent-consult.ts b/src/gateway/talk-agent-consult.ts index eb7c6831de30..d41e5a2a1f89 100644 --- a/src/gateway/talk-agent-consult.ts +++ b/src/gateway/talk-agent-consult.ts @@ -1,7 +1,6 @@ // Gateway Talk realtime agent-consult bridge. // Starts chat.send runs that answer realtime Talk tool calls. import { randomUUID } from "node:crypto"; -import { expectDefined } from "@openclaw/normalization-core"; import { ErrorCodes, errorShape, @@ -11,11 +10,11 @@ import { import { normalizeTalkSection } from "../config/talk.js"; import { buildRealtimeVoiceAgentConsultChatMessage } from "../talk/agent-consult-tool.js"; import { abortChatRunById } from "./chat-abort.js"; -import { chatHandlers } from "./server-methods/chat.js"; +import { handleChatSend } from "./server-methods/chat-send-handler.js"; import type { GatewayClient, GatewayRequestContext, - GatewayRequestHandlers, + GatewayRequestHandlerOptions, } from "./server-methods/shared-types.js"; import { registerTalkRealtimeRelayAgentRun } from "./talk-realtime-relay.js"; import { formatForLog } from "./ws-log.js"; @@ -84,10 +83,7 @@ export async function startTalkRealtimeAgentConsult(params: { { ok: true; result: unknown } | { ok: false; error: ErrorShape } | undefined >((resolve) => { let acknowledged = false; - const chatSendResult = expectDefined( - chatHandlers["chat.send"], - "chat.send handler", - )({ + const chatSendResult = handleChatSend({ req: { type: "req", id: `${params.requestId}:talk-tool-call`, @@ -150,7 +146,7 @@ export async function startTalkRealtimeAgentConsult(params: { }, ); }, - } as Parameters[0]); + } as GatewayRequestHandlerOptions); void Promise.resolve(chatSendResult).then( () => { if (!acknowledged) { @@ -186,6 +182,11 @@ export async function startTalkRealtimeAgentConsult(params: { if (terminalAckError) { return { ok: false, error: terminalAckError }; } - const runId = expectDefined(acknowledgedRunId, "talk agent run id"); - return { ok: true, runId: expectDefined(runId, "talk agent run id"), idempotencyKey }; + if (!acknowledgedRunId) { + return { + ok: false, + error: errorShape(ErrorCodes.UNAVAILABLE, "chat.send did not acknowledge an active run"), + }; + } + return { ok: true, runId: acknowledgedRunId, idempotencyKey }; } diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index fae279bef6fe..cc8b6a184484 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -407,6 +407,7 @@ export async function materializeRequesterScopedMcpToolsForHarnessRun( await import("../agents/agent-bundle-mcp-harness.js"); return materialize(params); } + export { resolveSandboxContext } from "../agents/sandbox.js"; export type { SandboxContext, SandboxWorkspaceAccess } from "../agents/sandbox.js"; export { diff --git a/src/plugin-sdk/codex-mcp-projection.ts b/src/plugin-sdk/codex-mcp-projection.ts index 23b90b5f7277..feb5900cabcb 100644 --- a/src/plugin-sdk/codex-mcp-projection.ts +++ b/src/plugin-sdk/codex-mcp-projection.ts @@ -2,8 +2,37 @@ // runtime's user-mcp-server projection so the bundled Codex app-server harness // can attach the same user `mcp.servers` entries to its thread config without // deep-importing core helpers. +import type { AnyAgentTool } from "../agents/tools/common.js"; +import type { + CronCreatorToolAllowlistEntry, + CronToolsAllowCaptureRef, +} from "../agents/tools/cron-tool.types.js"; export { buildCodexUserMcpServersThreadConfigPatch, buildCodexUserMcpServersThreadConfigPatchForRuntime, + resolveCodexMcpToolOverridesForAgent, } from "../agents/cli-runner/bundle-mcp-codex.js"; +export { runWithCronCreatorAuthorityResolver } from "../agents/cron-creator-authority-context.js"; + +/** Materialize static configured MCP under a scheduled Codex authority envelope. */ +export async function materializeStaticMcpToolsForScheduledHarnessRun( + params: Parameters< + typeof import("../agents/agent-bundle-mcp-harness.js").materializeStaticMcpToolsForScheduledHarnessRun + >[0], +) { + const { materializeStaticMcpToolsForScheduledHarnessRun: materialize } = + await import("../agents/agent-bundle-mcp-harness.js"); + return materialize(params); +} + +/** Capture the final Codex dynamic-tool surface for cron creator authority. */ +export async function captureFinalCodexCronCreatorToolAllowlist( + target: CronCreatorToolAllowlistEntry[], + captureRef: CronToolsAllowCaptureRef, + tools: readonly AnyAgentTool[], +) { + const [{ captureFinalEffectiveCronCreatorToolAllowlist: capture }, { getPluginToolMeta }] = + await Promise.all([import("../agents/tools/cron-tool.js"), import("../plugins/tools.js")]); + return capture(target, captureRef, tools, (tool) => getPluginToolMeta(tool)); +} diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index ce788864dc78..7e95e264eb07 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -5,9 +5,11 @@ import { uniqueStrings, } from "@openclaw/normalization-core/string-normalization"; import { compileGlobPatterns, matchesAnyGlobPattern } from "../agents/glob-pattern.js"; +import type { McpCodexToolAnnotations } from "../agents/mcp-codex-tool-approval.js"; import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, normalizeToolName } from "../agents/tool-policy.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { normalizeConversationReadInvocationOrigin } from "../channels/plugins/conversation-read-origin.js"; +import type { McpCodexToolApprovalMode } from "../config/types.mcp.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js"; import { @@ -54,6 +56,10 @@ export type PluginToolMcpMeta = { toolName: string; operation: "tool" | "resources_list" | "resources_read" | "prompts_list" | "prompts_get"; deniedBySession?: true; + codexApproval?: { + mode: McpCodexToolApprovalMode; + annotations?: McpCodexToolAnnotations; + }; node?: { id: string; displayName?: string; diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index 545b97183bcf..9bc47b5d7a72 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -261,7 +261,7 @@ "tools": [ { "deferLoading": true, - "description": "Gateway scheduler: reminders, delayed self-wakeups, loops, recurring work, event watchers. Never exec sleep/poll as timer.\n\nACTIONS: status | list [includeDisabled,limit?,offset?] (use nextOffset for the next page) | get jobId | add job | update jobId patch | remove jobId | run jobId (runMode \"force\"=now) | runs jobId = history | next_check in:\"30m\" (own paced run only) | wake text mode?:\"now\"|\"next-heartbeat\"(default) nudges a caller-owned lane (sessionKey/agentId to pick another).\n\nADD: {name?,schedule,payload,sessionTarget?,pacing?,trigger?,delivery?,enabled?}. Required: schedule+payload.\n\nSCHEDULE:\n- {kind:\"at\",at:\"ISO-8601\"} one-shot; no tz=UTC; auto-deletes after run.\n- {kind:\"every\",everyMs}.\n- {kind:\"cron\",expr,tz?:\"IANA\"}: expr is wall time in tz; never pre-convert to UTC; no tz=gateway host local. 18:00 Shanghai => {expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n- {kind:\"stream\",command:[argv],mode?:\"line\"|\"match\",match?}: fires on supervised process output; needs cron.triggers.enabled.\n\nTARGET+PAYLOAD:\n- \"current\" (agentTurn default) = this conversation: run carries this chat's context, result lands here. Self-wakeup/\"continue later\"/loop = at|every + agentTurn + current.\n- \"isolated\" = fresh detached session (shows in `openclaw tasks`); standalone background work.\n- \"main\" = heartbeat lane; payload {kind:\"systemEvent\",text} (systemEvent default target).\n- \"session:\" = named session.\n- agentTurn {kind:\"agentTurn\",message,model?,thinking?,timeoutSeconds?}; timeoutSeconds 0=none.\n- script {kind:\"script\",script,timeoutSeconds?,toolBudget?}: main|isolated only; needs cron.triggers.enabled.\n\nPACED LOOP: recurring job + pacing{min?,max?} durations (\"15m\",\"4h\"; at least one). Inside its run, job calls next_check in:\"\" to set the next delay (clamped to bounds, measured from run end; failed runs keep normal backoff). Adaptive polling: tighten when active, back off when quiet.\n\nTRIGGER (condition watcher on every/cron): {script,once?}; needs cron.triggers.enabled — if off, say so; never model-poll instead. Quiet headless check, no model; 30s/5 tool calls/16KB state. Read frozen trigger.state, return json({fire,message?,state?}) with NEW state; dedupe via state, never memory. fire:false saves state only. fire:true runs payload; message is that run's entire context — self-contained. Fire on failures/timeouts too; success-only watchers look healthy when broken. Script stays read-only; actions belong in payload. once:true disables after first fire. Code Mode: await tools.call(\"exec\",{command:\"...\"}).\n\nDELIVERY {mode:\"none\"|\"announce\"|\"webhook\",channel?,to?,threadId?,bestEffort?}: where detached run output goes. Omitted=announce (current=>this chat; isolated=>last route; set channel/to for a specific chat — no messaging tool inside the run). Silent watcher=>mode:\"none\". webhook posts finished-run event to URL in `to`.\n\nJob wakeMode (main jobs): \"now\"(default)|\"next-heartbeat\". Restricted automation-run sessions: self status/list/get/runs/remove + own next_check only. failureAlert {...}|false disables. jobId canonical (id=compat). contextMessages 0-10 embeds recent chat lines into reminder text.", + "description": "Gateway scheduler: reminders, delayed self-wakeups, loops, recurring work, event watchers. Never exec sleep/poll as timer.\n\nACTIONS: status | list [includeDisabled,limit?,offset?] (use nextOffset for the next page) | get jobId | add job | update jobId patch | remove jobId | run jobId (runMode \"force\"=now) | runs jobId = history | next_check in:\"30m\" (own paced run only) | wake text mode?:\"now\"|\"next-heartbeat\"(default) nudges a caller-owned lane (sessionKey/agentId to pick another).\n\nADD: {name?,schedule,payload,sessionTarget?,pacing?,trigger?,delivery?,enabled?}. Required: schedule+payload.\n\nSCHEDULE:\n- {kind:\"at\",at:\"ISO-8601\"} one-shot; no tz=UTC; auto-deletes after run.\n- {kind:\"every\",everyMs}.\n- {kind:\"cron\",expr,tz?:\"IANA\"}: expr is wall time in tz; never pre-convert to UTC; no tz=gateway host local. 18:00 Shanghai => {expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n- {kind:\"stream\",command:[argv],mode?:\"line\"|\"match\",match?}: fires on supervised process output; needs cron.triggers.enabled.\n\nTARGET+PAYLOAD:\n- \"current\" (agentTurn default) = this conversation: run carries this chat's context, result lands here. Self-wakeup/\"continue later\"/loop = at|every + agentTurn + current.\n- \"isolated\" = fresh detached session (shows in `openclaw tasks`); standalone background work.\n- \"main\" = heartbeat lane; payload {kind:\"systemEvent\",text} (systemEvent default target).\n- \"session:\" = named session.\n- agentTurn {kind:\"agentTurn\",message,model?,thinking?,timeoutSeconds?}; timeoutSeconds 0=none.\n- Inherited configured MCP authority includes only model-callable tools; interactive app-view-only capabilities are excluded from headless jobs.\n- script {kind:\"script\",script,timeoutSeconds?,toolBudget?}: main|isolated only; needs cron.triggers.enabled.\n\nPACED LOOP: recurring job + pacing{min?,max?} durations (\"15m\",\"4h\"; at least one). Inside its run, job calls next_check in:\"\" to set the next delay (clamped to bounds, measured from run end; failed runs keep normal backoff). Adaptive polling: tighten when active, back off when quiet.\n\nTRIGGER (condition watcher on every/cron): {script,once?}; needs cron.triggers.enabled — if off, say so; never model-poll instead. Quiet headless check, no model; 30s/5 tool calls/16KB state. Read frozen trigger.state, return json({fire,message?,state?}) with NEW state; dedupe via state, never memory. fire:false saves state only. fire:true runs payload; message is that run's entire context — self-contained. Fire on failures/timeouts too; success-only watchers look healthy when broken. Script stays read-only; actions belong in payload. once:true disables after first fire. Code Mode: await tools.call(\"exec\",{command:\"...\"}).\n\nDELIVERY {mode:\"none\"|\"announce\"|\"webhook\",channel?,to?,threadId?,bestEffort?}: where detached run output goes. Omitted=announce (current=>this chat; isolated=>last route; set channel/to for a specific chat — no messaging tool inside the run). Silent watcher=>mode:\"none\". webhook posts finished-run event to URL in `to`.\n\nJob wakeMode (main jobs): \"now\"(default)|\"next-heartbeat\". Restricted automation-run sessions: self status/list/get/runs/remove + own next_check only. failureAlert {...}|false disables. jobId canonical (id=compat). contextMessages 0-10 embeds recent chat lines into reminder text.", "inputSchema": { "additionalProperties": true, "properties": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index f250717243cc..58e6337dae24 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 62730, - "roughTokens": 15683 + "chars": 62876, + "roughTokens": 15719 }, "openClawDeveloperInstructions": { "chars": 4519, @@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 7226 }, "totalWithDynamicToolsJson": { - "chars": 91634, - "roughTokens": 22909 + "chars": 91780, + "roughTokens": 22945 }, "userInputText": { "chars": 1300, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 371159ffcf54..551ae85b77e8 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 62422, - "roughTokens": 15606 + "chars": 62568, + "roughTokens": 15642 }, "openClawDeveloperInstructions": { "chars": 3410, @@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6856 }, "totalWithDynamicToolsJson": { - "chars": 89846, - "roughTokens": 22462 + "chars": 89992, + "roughTokens": 22498 }, "userInputText": { "chars": 929, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index e158a4ac856d..1c76775881b3 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -222,8 +222,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 63956, - "roughTokens": 15989 + "chars": 64102, + "roughTokens": 16026 }, "openClawDeveloperInstructions": { "chars": 3410, @@ -234,8 +234,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6960 }, "totalWithDynamicToolsJson": { - "chars": 91796, - "roughTokens": 22949 + "chars": 91942, + "roughTokens": 22986 }, "userInputText": { "chars": 1271, diff --git a/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts b/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts index d01dcaebea62..e2eabbf9b55a 100644 --- a/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts +++ b/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts @@ -8,10 +8,12 @@ function createExtensionCodexAppServerAttemptExtraVitestConfig( [ "extensions/codex/src/app-server/run-attempt-lifecycle-controller.test.ts", "extensions/codex/src/app-server/run-attempt-thread-cleanup.test.ts", + "extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts", "extensions/codex/src/app-server/run-attempt.context-engine.test.ts", "extensions/codex/src/app-server/run-attempt.dynamic-tools.test.ts", "extensions/codex/src/app-server/run-attempt.hooks.test.ts", "extensions/codex/src/app-server/run-attempt.native-hook-relay.test.ts", + "extensions/codex/src/app-server/run-attempt-runtime.authority.test.ts", "extensions/codex/src/app-server/run-attempt.steering.test.ts", "extensions/codex/src/app-server/run-attempt.turn-watches.test.ts", "extensions/codex/src/app-server/run-attempt.usage-limits.test.ts",