mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(codex): preserve configured MCP tools in scheduled turns (#120366)
This commit is contained in:
@@ -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 = "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" })] });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user