fix(codex): preserve configured MCP tools in scheduled turns (#120366)

This commit is contained in:
Josh Avant
2026-08-08 19:25:39 -05:00
committed by GitHub
parent b4cedfd40e
commit a345ede685
112 changed files with 6708 additions and 935 deletions
@@ -88,6 +88,8 @@ const bundleMcpThreadConfig = {
diagnostics: [],
evaluated: false,
fingerprint: undefined,
staticServerNames: [],
userStaticServerNames: [],
} satisfies CodexBundleMcpThreadConfig;
const HARNESS_REQUEST_TIMEOUT_MS = 15_000;
@@ -155,6 +155,8 @@ export async function startCodexAttemptThread(params: {
buildFinalConfigPatch?: Parameters<typeof startOrResumeThread>[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,
@@ -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<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>();
return {
...actual,
normalizeAgentRuntimeTools: (...args: Parameters<typeof actual.normalizeAgentRuntimeTools>) => {
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");
@@ -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),
});
@@ -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 () => {
@@ -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<typeof wrapToolWithBeforeToolCallHook>[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<CodexDynamicToolHookContext, "config">;
@@ -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,
@@ -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<CodexAppServerClient, "request">;
threadId: string;
mcpServerNames: readonly string[];
toolOverrides?: AgentHarnessMcpCatalogParams["toolOverrides"];
}): Promise<McpToolCatalog> {
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();
}
@@ -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 = {
@@ -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);
@@ -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,
);
});
});
@@ -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,
@@ -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,
});
@@ -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<string | { name: string; pluginId?: string }> = [];
const cronCreatorToolAllowlistCaptureRef: {
value?: { version: 1; source: "final-executable-surface" };
} = {};
let toolBridge: ReturnType<typeof createCodexDynamicToolBridge> | 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<ReturnType<typeof materializeRequesterScopedMcpToolsForHarnessRun>> =
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<typeof materializeStaticMcpToolsForScheduledHarnessRun>
>;
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<string | { name: string; pluginId?: string }> = [];
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<ReturnType<typeof prepareCodexAttemptTools>>;
@@ -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<Record<string, unknown>>,
staticDiagnosticNotice: undefined as string | undefined,
staticFailure: undefined as Error | undefined,
staticFailureGate: undefined as Promise<void> | undefined,
staticCalls: [] as Array<Record<string, unknown>>,
staticToolExecutes: [] as ReturnType<typeof vi.fn>[],
threadConfigCalls: [] as Array<Record<string, unknown>>,
}));
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>();
return {
...actual,
materializeRequesterScopedMcpToolsForHarnessRun: async (
...args: Parameters<typeof actual.materializeRequesterScopedMcpToolsForHarnessRun>
) => {
mcpMocks.requesterCalls += 1;
mcpMocks.requesterParams.push(args[0] as Record<string, unknown>);
return undefined;
},
loadCodexBundleMcpThreadConfig: async (
...args: Parameters<typeof actual.loadCodexBundleMcpThreadConfig>
) => {
const params = args[0] as Record<string, unknown>;
mcpMocks.threadConfigCalls.push(params);
mcpMocks.threadConfigFacade(params);
const cfg = params.cfg as
| { mcp?: { servers?: Record<string, Record<string, unknown>> } }
| 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<typeof import("openclaw/plugin-sdk/codex-mcp-projection")>();
return {
...actual,
runWithCronCreatorAuthorityResolver: <T>(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<string, unknown>) => {
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<typeof actual.captureFinalCodexCronCreatorToolAllowlist>
) => {
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<typeof createParams>): 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<string, unknown>; 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<string, unknown>; 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<void>((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();
});
});
@@ -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
);
}
@@ -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);
@@ -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<Omit<CodexAppServerThreadBinding, "threadId">>;
}
| {
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({
@@ -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,
@@ -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;
});
}
@@ -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;
@@ -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<typeof import("./shared-client.js")>();
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");
});
});
@@ -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<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore">,
) {
return startOrResumeThreadImpl({ ...params, bindingStore: testCodexAppServerBindingStore });
}
export function threadStartResult(threadId = "thread-1"): Record<string, unknown> {
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<string, unknown> {
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;
}
@@ -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<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore">,
) {
return startOrResumeThreadImpl({ ...params, bindingStore: testCodexAppServerBindingStore });
}
function threadStartResult(threadId = "thread-1"): Record<string, unknown> {
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<string, unknown> {
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 = "";
+391 -1
View File
@@ -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<string, infer V> ? 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: "<html>mail</html>",
},
],
});
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: "<html>mail</html>",
},
],
});
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: "<html>mail</html>",
},
],
});
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;
+203 -47
View File
@@ -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<void>;
};
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<void>;
};
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<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
/** 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<ScheduledStaticHarnessMcpTools> {
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<ReturnType<typeof materializeBundleMcpToolsForRun>>;
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<ReturnType<typeof materializeBundleMcpToolsForRun>> | 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;
}
}
@@ -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);
@@ -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<PluginManifestRegistry, "plugins">;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}): string[] {
const { loaded } = loadSessionMcpConfig({
...params,
logDiagnostics: false,
});
const { staticServers } = partitionMcpServersByConnectionScope(loaded.mcpServers);
return Object.keys(staticServers).toSorted((left, right) => left.localeCompare(right));
}
@@ -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",
+6
View File
@@ -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 {
@@ -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),
+4
View File
@@ -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. */
@@ -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<void>((resolveHold) => {
releaseRun = resolveHold;
});
let retainedResolver: (() => Promise<unknown>) | 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(
+11
View File
@@ -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,
+21
View File
@@ -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<CodexUserMcpServersProjectionOptions, "agentId" | "toolOverrides">,
): Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny"> | 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,
+21 -1
View File
@@ -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<Record<string, unknown>>,
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<string, unknown>) => {
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", () => {
+30 -42
View File
@@ -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<CodexMcpToolApprovalMode>([
"auto",
"prompt",
"approve",
]);
function readCodexProjectionConfig(server: BundleMcpServerConfig): Record<string, unknown> {
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<string, unknown> {
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,
};
}
+6
View File
@@ -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<string, Record<string, unknown>>;
@@ -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<PluginManifestRegistry, "plugins">;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
};
@@ -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<CronToolOptions["resolveCreatorToolAuthority"]>;
type CronCreatorAuthorityResolverScope = {
resolve: (options?: { signal?: AbortSignal }) => Promise<CronCreatorToolAuthorityMaterialization>;
runId: string;
};
const activeCronCreatorAuthority = new AsyncLocalStorage<CronCreatorAuthorityRunScope>();
const activeCronCreatorAuthorityResolver =
new AsyncLocalStorage<CronCreatorAuthorityResolverScope>();
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
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<T>(
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<T>(params: {
runId: string;
resolve: (options?: { signal?: AbortSignal }) => Promise<CronCreatorToolAuthorityMaterialization>;
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),
});
};
}
@@ -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 () => {});
@@ -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
@@ -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,
@@ -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. */
@@ -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,
@@ -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 });
});
});
+87
View File
@@ -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<McpCodexToolApprovalMode>(["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<string, unknown>)
: {};
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<string, unknown>;
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;
}
+12 -10
View File
@@ -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,
@@ -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<string, unknown>,
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");
}
}
+30 -13
View File
@@ -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<typeof planCronJobUpdatePatch>;
@@ -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,
});
});
+180 -23
View File
@@ -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<string, unknown> }
| { 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<T extends { name: string }>(
target: CronCreatorToolAllowlistEntry[],
tools: readonly T[],
toolMeta?: (tool: T) => { pluginId?: string } | undefined,
): void {
target.length = 0;
const seen = new Set<string>();
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<T extends { name: string }>(
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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
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<string, unknown>;
creatorToolAllowlist: readonly CronCreatorToolAllowlistEntry[] | undefined;
currentJob?: Record<string, unknown>;
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 };
+127 -13
View File
@@ -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<string, unknown>;
creatorToolAllowlist: readonly CronCreatorToolAllowlistEntry[] | undefined;
creatorToolAllowlistCaptureRef?: CronToolsAllowCaptureRef;
creatorAuthorityComplete: boolean;
resolveCreatorToolAuthority?: (options?: {
signal?: AbortSignal;
}) => Promise<CronCreatorToolAuthoritySnapshot>;
operationSignal?: AbortSignal;
creatorAuthorityUnavailableReason?: "queued-local-operator-configured-mcp";
gatewayOpts: GatewayCallOptions;
callGateway: GatewayToolCaller;
}): Promise<{ patch: Record<string, unknown>; expectedConfigRevision?: string }> {
}): Promise<{
patch: Record<string, unknown>;
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<string, unknown>;
creatorToolAllowlist: readonly CronCreatorToolAllowlistEntry[] | undefined;
creatorToolAllowlistCaptureRef?: CronToolsAllowCaptureRef;
resolveCreatorToolAuthority?: (options?: {
signal?: AbortSignal;
}) => Promise<CronCreatorToolAuthoritySnapshot>;
withCreatorAuthorityProvenance?: <T>(
authority: CronCreatorToolAuthoritySnapshot,
run: () => Promise<T>,
) => Promise<T>;
gatewayOpts: GatewayCallOptions;
callGateway: GatewayToolCaller;
operationSignal?: AbortSignal;
creatorAuthorityUnavailableReason?: "queued-local-operator-configured-mcp";
}): Promise<unknown> {
const callerIncludedPayloadPatch = isRecord(params.patch.payload);
let creatorAuthorityPromise: Promise<CronCreatorToolAuthoritySnapshot> | 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;
+42 -7
View File
@@ -24,9 +24,13 @@ describe("cron tool flat-params", () => {
});
function firstGatewayToolCall<TParams>(): [string, unknown, TParams] {
const call = callGatewayToolMock.mock.calls[0];
return gatewayToolCall<TParams>(0);
}
function gatewayToolCall<TParams>(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 () => {
+634 -5
View File
@@ -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<string, unknown> } {
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<void>((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<void>((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<void>((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"],
},
},
},
+89 -101
View File
@@ -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<T extends { name: string }>(
target: CronCreatorToolAllowlistEntry[],
tools: readonly T[],
toolMeta?: (tool: T) => { pluginId?: string } | undefined,
): void {
target.length = 0;
const seen = new Set<string>();
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<string, unknown>) {
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<string, unknown>,
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:<key>" = 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:"<dur>" 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<string, unknown>;
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,
}),
);
}
+28
View File
@@ -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<CronCreatorToolAuthoritySnapshot>;
/** Visible fail-closed reason when a queued local turn cannot retain fresh MCP authority. */
creatorAuthorityUnavailableReason?: "queued-local-operator-configured-mcp";
selfRemoveOnlyJobId?: string;
runId?: string;
};
@@ -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<T>(
...(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() }
: {}),
@@ -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. */
@@ -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) ??
@@ -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.
+17
View File
@@ -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) {
+62
View File
@@ -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, []);
+50
View File
@@ -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<string, boolean>();
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(
+13 -1
View File
@@ -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 <id> --tools <tool,...>`, or use `--clear-tools` to adopt the current default cap.",
"- Reauthorize with an exact explicit cap: `openclaw cron edit <id> --tools <tool,...>`.",
);
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 <id> --tools <tool,...>`.",
].join("\n");
}
/** Convert legacy cron issue counts into doctor preview lines. */
export function formatLegacyIssuePreview(issues: CronLegacyIssueCounts): string[] {
const lines: string[] = [];
@@ -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 <tool,...>");
});
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" })] });
});
});
+31 -4
View File
@@ -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<CronJob["payload"], { kind: "agentTurn" }> | null;
agentRuntime?: string;
toolsAllowProvenance?: CronToolsAllowProvenance;
}): Promise<CronRunDiagnostics | undefined> {
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 <tool,...>.`,
{ severity: "warn" },
);
}
return undefined;
}
if (!toolsAllowRequestsWebSearch(toolsAllow)) {
return undefined;
}
try {
@@ -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;
+3
View File
@@ -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({
+16
View File
@@ -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) {
+14
View File
@@ -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",
});
});
});
+4 -3
View File
@@ -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 };
}
+71
View File
@@ -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;
}
+34 -56
View File
@@ -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);
+12 -4
View File
@@ -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;
}
+122
View File
@@ -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");
+8
View File
@@ -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<void>;
+24 -11
View File
@@ -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<strin
function mergeFailureDestinationProjection(
configJob: Record<string, unknown>,
projectedJob: CronJob | null,
projectedJob: CronStoredJob | null,
): Record<string, unknown> {
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<string, unknown>;
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"] = [];
+13 -2
View File
@@ -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<
@@ -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();
@@ -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<string | null> {
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<string> {
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 } : {}),
};
}
@@ -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));
});
});
+108
View File
@@ -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<string>;
active: boolean;
abort: () => void;
};
type CronCreatorAuthorityGrantEntry = {
scope: CronCreatorAuthorityRunScope;
operationSignal?: AbortSignal;
onOperationAbort?: () => void;
};
const grantsByToken = new Map<string, CronCreatorAuthorityGrantEntry>();
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);
}
@@ -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<typeof registerChatAbortController>;
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,
@@ -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<typeof agentCommandFromGatewayIngress>[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,
@@ -364,6 +364,7 @@ export function startAgentRunExecution(params: {
);
dispatchAgentRunFromGateway({
cronCreatorAuthority: prepared.cronCreatorAuthority,
ingressOpts: {
message,
images: params.images,
@@ -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<NonNullable<GatewayClient["internal"]>> = {}) {
return {
connect: { scopes: ["operator.admin"] },
internal: { isLocalClient: true, ...overrides },
} as unknown as GatewayClient;
}
function createParams(
overrides: {
client?: GatewayClient | null;
request?: Partial<AgentRunRequest>;
inputProvenance?: InputProvenance;
hasRestoredCronContinuation?: boolean;
isOneShotModelRun?: boolean;
isRestartRecoveryResumeRun?: boolean;
resolvedSessionKey?: string;
spawnedBy?: string;
} = {},
): Parameters<typeof resolveGatewayCronCreatorAuthorityAdmission>[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<typeof createParams>[0]),
),
).toBeUndefined();
});
});
function createChatParams(
overrides: Partial<Parameters<typeof resolveGatewayChatCronCreatorAuthorityAdmission>[0]> = {},
): Parameters<typeof resolveGatewayChatCronCreatorAuthorityAdmission>[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<typeof resolveGatewayChatCronCreatorAuthorityAdmission>[0]
>,
),
),
).toBeUndefined();
});
});
@@ -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<T>(authority: Readonly<{ runId: string }>, run: () => T, signal?: AbortSignal): T;
};
@@ -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<boolean>,
): Promise<void> {
return handleChatSend(options, onAdmissionOwned, externalAuthorityAdmission);
}
+155 -165
View File
@@ -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<boolean>,
externalAuthorityAdmission?: ChatSendExternalAuthorityAdmission,
): Promise<void> {
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({
@@ -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 };
}
@@ -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<Record<string, unknown>> | null,
runtimeAssistantTextsBeforeDelivery: [] as string[],
cronAuthorityProbe: undefined as
| ((runId: string | undefined) => Promise<void> | 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<string, unknown>;
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<typeof bindActiveCronCreatorAuthorityResolver>;
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<typeof bindActiveCronCreatorAuthorityResolver>;
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<typeof bindActiveCronCreatorAuthorityResolver>;
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();
+2 -2
View File
@@ -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;
@@ -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 }
: {}),
};
}
@@ -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",
});
}
+46 -2
View File
@@ -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) {
@@ -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<CronJob>) =>
createCronJob({
const committedAdds: Partial<CronJob>[] = [];
const committedUpdates: Array<{ id: string; patch: Partial<CronJob> }> = [];
const update = vi.fn(async (id: string, patch: Partial<CronJob>) => {
committedUpdates.push({ id, patch });
return createCronJob({
...jobs.find((job) => job.id === id),
...patch,
id,
}),
);
});
});
return {
committedAdds,
committedUpdates,
cron: {
add: vi.fn(async (input: Partial<CronJob>, _opts?: unknown) =>
createCronJob({ ...input, id: "cron-1" }),
),
add: vi.fn(async (input: Partial<CronJob>, opts?: { commitGuard?: () => void }) => {
opts?.commitGuard?.();
committedAdds.push(input);
return createCronJob({ ...input, id: "cron-1" });
}),
update,
updateWithPrecondition: vi.fn(
async (
id: string,
patch: Partial<CronJob>,
precondition: (job: CronJob, nowMs: number) => void | Promise<void>,
_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> = {}): 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",
@@ -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);
@@ -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", () => ({
@@ -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. */
@@ -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<string, unknown>) {
+2 -6
View File
@@ -155,11 +155,7 @@ async function forwardSkillWorkshopRevisionToChatSend(
targetAgentId?: string;
},
): Promise<void> {
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,
+2 -4
View File
@@ -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", () => ({

Some files were not shown because too many files have changed in this diff Show More