From d3e30d1ab917c9fcde5e2bb620c12e4e3d25f6d3 Mon Sep 17 00:00:00 2001 From: Josh Lehman <550978+jalehman@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:26:35 -0700 Subject: [PATCH] fix(cron): bind projected exec authority to Codex --- .../src/app-server/run-attempt-tool-setup.ts | 45 ++++++++--- .../run-attempt.configured-mcp.test.ts | 39 +++++++--- .../src/app-server/shell-dynamic-tools.ts | 56 ++++++++------ src/agents/exec-tool-target-pinning.ts | 17 +++++ src/agents/tools/cron-tool-creator-cap.ts | 16 +++- src/agents/tools/cron-tool.types.ts | 6 +- src/cron/runtime-authority.test.ts | 38 ++++++++++ src/cron/runtime-authority.ts | 75 ++++++++++++++++++- src/cron/service/state.ts | 4 +- src/cron/store.test.ts | 7 ++ src/cron/trigger-script.test.ts | 35 +++++++++ src/cron/trigger-script.ts | 37 ++++++--- src/gateway/server-cron.ts | 2 + src/plugin-sdk/codex-mcp-projection.ts | 15 +++- 14 files changed, 333 insertions(+), 59 deletions(-) diff --git a/extensions/codex/src/app-server/run-attempt-tool-setup.ts b/extensions/codex/src/app-server/run-attempt-tool-setup.ts index d70974e2846d..59d0fbe02a35 100644 --- a/extensions/codex/src/app-server/run-attempt-tool-setup.ts +++ b/extensions/codex/src/app-server/run-attempt-tool-setup.ts @@ -33,10 +33,34 @@ import { resolveScheduledCodexAppCreatorCaptureDecision, } from "./scheduled-app-authority.js"; +type CodexCronCreatorTool = Parameters[0][number]; +type CodexCronRuntimeAuthority = NonNullable; + function isAuthorityResolutionOperationAbort(error: unknown, signal: AbortSignal | undefined) { return signal?.aborted === true && error === signal.reason; } +function bindCodexCronScheduledTools( + authority: CodexCronRuntimeAuthority | undefined, + tools: readonly CodexCronCreatorTool[], +): CodexCronRuntimeAuthority | undefined { + const toolBindings = tools.flatMap((tool) => + typeof tool === "string" || !tool.scheduledToolBinding ? [] : [tool.scheduledToolBinding], + ); + if (toolBindings.length === 0) { + return authority; + } + return { + ...(authority ?? { + version: 1, + runtimeId: "codex", + namespace: "scheduled-tools", + payload: { version: 1 }, + }), + toolBindings, + }; +} + export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { const { connection, @@ -140,7 +164,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { frameToolCallId?: string; frameImageIdentity?: string; } = { value: 0 }; - const cronCreatorToolAllowlist: Array = []; + const cronCreatorToolAllowlist: CodexCronCreatorTool[] = []; const cronCreatorToolAllowlistCaptureRef: { value?: { version: 1; source: "final-executable-surface" }; } = {}; @@ -174,20 +198,22 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { const canResolveScheduledCodexAppAuthority = appCreatorCapture.supported; const requiresScheduledCodexAppAuthority = appCreatorCapture.required; const canResolveAnyScheduledCreatorAuthority = - canResolveScheduledConfiguredMcpCreatorAuthority || requiresScheduledCodexAppAuthority; + canResolveScheduledConfiguredMcpCreatorAuthority || + requiresScheduledCodexAppAuthority || + (nativeToolSurfaceEnabled === true && sandbox?.enabled !== true); let toolBridge: ReturnType | undefined; let creatorAuthorityPromise: | Promise<{ - tools: readonly (string | { name: string; pluginId?: string })[]; + tools: readonly CodexCronCreatorTool[]; provenance: { version: 1; source: "final-executable-surface" }; - runtimeAuthority?: NonNullable; + runtimeAuthority?: CodexCronRuntimeAuthority; }> | undefined; let resolveCreatorAuthorityImpl: | ((options?: { signal?: AbortSignal }) => Promise<{ - tools: readonly (string | { name: string; pluginId?: string })[]; + tools: readonly CodexCronCreatorTool[]; provenance: { version: 1; source: "final-executable-surface" }; - runtimeAuthority?: NonNullable; + runtimeAuthority?: CodexCronRuntimeAuthority; }>) | undefined; const runtimeYieldCompletionClaim: { current?: () => boolean } = {}; @@ -224,7 +250,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { ? { resolveCronCreatorToolAuthority: (options?: { signal?: AbortSignal }) => { if (!resolveCreatorAuthorityImpl) { - throw new Error("configured MCP authority resolver was invoked before tool setup"); + throw new Error("cron creator authority resolver was invoked before tool setup"); } options?.signal?.throwIfAborted(); if (creatorAuthorityPromise) { @@ -449,7 +475,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { if (!toolBridge) { throw new Error("cron creator authority resolver lost the active tool bridge"); } - const authorityTools: Array = []; + const authorityTools: CodexCronCreatorTool[] = []; const captureRef: { value?: { version: 1; source: "final-executable-surface" }; } = {}; @@ -462,7 +488,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { throw new Error("cron creator authority snapshot did not produce provenance"); } const appSource = scheduledAppAuthoritySourceRef.current; - const runtimeAuthority = + const appRuntimeAuthority = canResolveScheduledCodexAppAuthority && preparedChatgptAuth ? appSource ? await captureScheduledCodexAppAuthority({ @@ -476,6 +502,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { ); })() : undefined; + const runtimeAuthority = bindCodexCronScheduledTools(appRuntimeAuthority, authorityTools); if (!canResolveScheduledConfiguredMcpCreatorAuthority) { options?.signal?.throwIfAborted(); return Object.freeze({ diff --git a/extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts b/extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts index d4d1fa984cec..d7db6a33c64f 100644 --- a/extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts +++ b/extensions/codex/src/app-server/run-attempt.configured-mcp.test.ts @@ -5,14 +5,19 @@ 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 })[]; + tools: readonly ( + | string + | { name: string; pluginId?: string; scheduledToolBinding?: unknown } + )[]; provenance: { version: 1; source: "final-executable-surface" }; + runtimeAuthority?: unknown; }> >, captureCalls: [] as Array<{ sourceNames: string[]; storedNames: string[]; provenance?: unknown; + storedBindings: unknown[]; }>, captureRefs: [] as Array<{ value?: { version: 1; source: "final-executable-surface" }; @@ -127,21 +132,18 @@ vi.mock("openclaw/plugin-sdk/codex-mcp-projection", async (importOriginal) => { 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" }; + await actual.captureFinalCodexCronCreatorToolAllowlist(target, captureRef, tools); mcpMocks.captureCalls.push({ sourceNames: tools.map((tool) => tool.name).toSorted(), storedNames: target .map((entry) => (typeof entry === "string" ? entry : entry.name)) .toSorted(), provenance: captureRef.value, + storedBindings: target.flatMap((entry) => + typeof entry === "string" || !entry.scheduledToolBinding + ? [] + : [entry.scheduledToolBinding], + ), }); }, }; @@ -232,6 +234,7 @@ describe("runCodexAppServerAttempt configured MCP ownership", () => { setCodexTestModelSupportsTools(params, true); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); + admitLocalOperatorCronAuthority(params); const harness = createStartedThreadHarness(); const run = runCodexAppServerAttempt(params); @@ -244,6 +247,22 @@ describe("runCodexAppServerAttempt configured MCP ownership", () => { expect(mcpMocks.captureCalls[0]?.storedNames).toContain("gateway_exec"); expect(mcpMocks.captureCalls[0]?.storedNames).toContain("gateway_process"); expect(mcpMocks.captureCalls[0]?.storedNames).not.toContain("exec"); + expect(mcpMocks.captureCalls[0]?.storedBindings).toEqual([ + { + sourceTool: "gateway_exec", + targetTool: "exec", + execTarget: { host: "gateway" }, + }, + { sourceTool: "gateway_process", targetTool: "process" }, + ]); + + const authority = await mcpMocks.authorityResolvers[0]!(); + expect(authority.runtimeAuthority).toMatchObject({ + version: 1, + runtimeId: "codex", + namespace: "scheduled-tools", + toolBindings: mcpMocks.captureCalls[0]?.storedBindings, + }); }); it("does not replace bundle discovery with partial prepared plugin metadata", async () => { diff --git a/extensions/codex/src/app-server/shell-dynamic-tools.ts b/extensions/codex/src/app-server/shell-dynamic-tools.ts index 2498cbbf59db..3600b101a07c 100644 --- a/extensions/codex/src/app-server/shell-dynamic-tools.ts +++ b/extensions/codex/src/app-server/shell-dynamic-tools.ts @@ -1,4 +1,4 @@ -import { pinExecToolTarget } from "openclaw/plugin-sdk/codex-mcp-projection"; +import { bindCronScheduledTool, pinExecToolTarget } from "openclaw/plugin-sdk/codex-mcp-projection"; import type { CodexPluginConfig } from "./config.js"; import { normalizeCodexDynamicToolName } from "./dynamic-tool-profile.js"; @@ -50,34 +50,48 @@ export function createExecAliasDynamicTool( ? { host: "node", ...(pinnedNode ? { node: pinnedNode } : {}) } : { host: "gateway", ...(params.ask ? { ask: params.ask } : {}) }, ); - return { + const execute: OpenClawDynamicTool["execute"] = async (toolCallId, args, signal, onUpdate) => { + const result = await pinnedTool.execute(toolCallId, args, signal, onUpdate); + return { + ...result, + content: result.content.map((item) => + item.type === "text" + ? Object.assign({}, item, { + text: item.text.replace(PROCESS_FOLLOWUP_TEXT, followupText), + }) + : item, + ), + }; + }; + const alias = { ...pinnedTool, name, description, - execute: async (toolCallId, args, signal, onUpdate) => { - const result = await pinnedTool.execute(toolCallId, args, signal, onUpdate); - return { - ...result, - content: result.content.map((item) => - item.type === "text" - ? Object.assign({}, item, { - text: item.text.replace(PROCESS_FOLLOWUP_TEXT, followupText), - }) - : item, - ), - }; - }, + execute, }; + return nodeAlias + ? alias + : bindCronScheduledTool(alias, { + sourceTool: CODEX_GATEWAY_EXEC_DYNAMIC_TOOL_NAME, + targetTool: "exec", + execTarget: { host: "gateway" }, + }); } export function createGatewayProcessAliasDynamicTool( processTool: OpenClawDynamicTool, ): OpenClawDynamicTool { - return { - ...processTool, - name: CODEX_GATEWAY_PROCESS_DYNAMIC_TOOL_NAME, - description: - "Manage background shell sessions in the existing per-session OpenClaw process scope: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use for gateway_exec follow-up; use native Codex shell session handling for ordinary local work.", - }; + return bindCronScheduledTool( + { + ...processTool, + name: CODEX_GATEWAY_PROCESS_DYNAMIC_TOOL_NAME, + description: + "Manage background shell sessions in the existing per-session OpenClaw process scope: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use for gateway_exec follow-up; use native Codex shell session handling for ordinary local work.", + }, + { + sourceTool: CODEX_GATEWAY_PROCESS_DYNAMIC_TOOL_NAME, + targetTool: "process", + }, + ); } diff --git a/src/agents/exec-tool-target-pinning.ts b/src/agents/exec-tool-target-pinning.ts index 3350685b4a16..a5d0d10061fe 100644 --- a/src/agents/exec-tool-target-pinning.ts +++ b/src/agents/exec-tool-target-pinning.ts @@ -1,6 +1,9 @@ import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce"; +import type { CronScheduledToolBinding } from "../cron/runtime-authority.js"; import type { AnyAgentTool } from "./tools/common.js"; +const scheduledToolBindings = new WeakMap(); + const EXEC_POLICY_PARAMETER_NAMES = new Set(["host", "security", "ask"]); const NODE_EXEC_PARAMETER_NAMES = new Set(["command", "workdir", "env", "timeoutSeconds", "node"]); @@ -8,6 +11,20 @@ type PinnedExecToolTarget = | { host: "gateway"; ask?: "always" } | { host: "node"; node?: string }; +export function bindCronScheduledTool( + tool: AnyAgentTool, + binding: CronScheduledToolBinding, +): AnyAgentTool { + scheduledToolBindings.set(tool, binding); + return tool; +} + +export function getCronScheduledToolBinding( + tool: AnyAgentTool, +): CronScheduledToolBinding | undefined { + return scheduledToolBindings.get(tool); +} + /** Restricts an exec tool to one host target even when callers submit broader arguments. */ export function pinExecToolTarget(tool: AnyAgentTool, target: PinnedExecToolTarget): AnyAgentTool { const pinnedNode = target.host === "node" ? target.node?.trim() : undefined; diff --git a/src/agents/tools/cron-tool-creator-cap.ts b/src/agents/tools/cron-tool-creator-cap.ts index 7f5d7d0cdb1f..48fa3a0d0464 100644 --- a/src/agents/tools/cron-tool-creator-cap.ts +++ b/src/agents/tools/cron-tool-creator-cap.ts @@ -1,3 +1,4 @@ +import type { CronScheduledToolBinding } from "../../cron/runtime-authority.js"; import { isRecord } from "../../utils.js"; import { isToolAllowedByPolicyName } from "../tool-policy-match.js"; import { @@ -43,7 +44,9 @@ export function assertInheritedCronToolCaptureReady( export function replaceWithEffectiveCronCreatorToolAllowlist( target: CronCreatorToolAllowlistEntry[], tools: readonly T[], - toolMeta?: (tool: T) => { pluginId?: string } | undefined, + toolMeta?: ( + tool: T, + ) => { pluginId?: string; scheduledToolBinding?: CronScheduledToolBinding } | undefined, ): void { target.length = 0; const seen = new Set(); @@ -56,7 +59,12 @@ export function replaceWithEffectiveCronCreatorToolAllowlist { pluginId?: string } | undefined, + toolMeta?: ( + tool: T, + ) => { pluginId?: string; scheduledToolBinding?: CronScheduledToolBinding } | undefined, ): void { replaceWithEffectiveCronCreatorToolAllowlist(target, tools, toolMeta); captureRef.value = { version: 1, source: "final-executable-surface" }; diff --git a/src/agents/tools/cron-tool.types.ts b/src/agents/tools/cron-tool.types.ts index 51022c6c6171..f46be9e50f47 100644 --- a/src/agents/tools/cron-tool.types.ts +++ b/src/agents/tools/cron-tool.types.ts @@ -1,6 +1,9 @@ // Cron tool type declarations shared with the cron tool implementation. import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import type { CronRuntimeAuthority } from "../../cron/runtime-authority.js"; +import type { + CronRuntimeAuthority, + CronScheduledToolBinding, +} from "../../cron/runtime-authority.js"; import type { CronCreatorAuthorityGrant } from "../../gateway/cron-creator-authority-grant.js"; import type { DeliveryContext } from "../../utils/delivery-context.shared.js"; import type { callGatewayTool } from "./gateway.js"; @@ -10,6 +13,7 @@ export type CronCreatorToolAllowlistEntry = | { name: string; pluginId?: string; + scheduledToolBinding?: CronScheduledToolBinding; }; type CronToolsAllowCaptureProvenance = { diff --git a/src/cron/runtime-authority.test.ts b/src/cron/runtime-authority.test.ts index ae438d37edb6..75de31326820 100644 --- a/src/cron/runtime-authority.test.ts +++ b/src/cron/runtime-authority.test.ts @@ -26,7 +26,45 @@ describe("normalizeCronRuntimeAuthority", () => { expect(Object.isFrozen((normalized.payload.apps as unknown[])[0])).toBe(true); }); + it("normalizes producer-bound scheduled tool bindings", () => { + const input = { + ...authority({ version: 1 }), + toolBindings: [ + { + sourceTool: "gateway_exec", + targetTool: "exec", + execTarget: { host: "gateway" }, + }, + { sourceTool: "gateway_process", targetTool: "process" }, + ], + }; + + const normalized = normalizeCronRuntimeAuthority(input); + + expect(normalized).toEqual(input); + expect(Object.isFrozen(normalized?.toolBindings)).toBe(true); + expect(Object.isFrozen(normalized?.toolBindings?.[0])).toBe(true); + }); + it.each([ + { + ...authority({}), + toolBindings: [ + { + sourceTool: "gateway_exec", + targetTool: "exec", + execTarget: { host: "node" }, + }, + ], + }, + { + ...authority({}), + toolBindings: [ + { sourceTool: "gateway_exec", targetTool: "process" }, + { sourceTool: "gateway_exec", targetTool: "process" }, + ], + }, + { ...authority({}), toolBindings: [{ sourceTool: "gateway_exec", targetTool: "write" }] }, authority({ value: Number.NaN }), authority({ value: Number.POSITIVE_INFINITY }), authority({ value: undefined }), diff --git a/src/cron/runtime-authority.ts b/src/cron/runtime-authority.ts index 4bf73903b710..52b27fe44bf1 100644 --- a/src/cron/runtime-authority.ts +++ b/src/cron/runtime-authority.ts @@ -5,11 +5,29 @@ const CRON_RUNTIME_AUTHORITY_MAX_BYTES = 64 * 1024; const CRON_RUNTIME_AUTHORITY_MAX_ID_LENGTH = 128; const CRON_RUNTIME_AUTHORITY_MAX_DEPTH = 16; const CRON_RUNTIME_AUTHORITY_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u; -const CRON_RUNTIME_AUTHORITY_KEYS = new Set(["version", "runtimeId", "namespace", "payload"]); +const CRON_RUNTIME_AUTHORITY_KEYS = new Set([ + "version", + "runtimeId", + "namespace", + "payload", + "toolBindings", +]); +const CRON_RUNTIME_AUTHORITY_MAX_TOOL_BINDINGS = 16; type JsonPrimitive = string | number | boolean | null; type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +export type CronScheduledToolBinding = + | Readonly<{ + sourceTool: string; + targetTool: "exec"; + execTarget: Readonly<{ host: "gateway" }>; + }> + | Readonly<{ + sourceTool: string; + targetTool: "process"; + }>; + export type CronRuntimeAuthority = Readonly<{ version: 1; /** Concrete harness runtime that alone may consume this opaque authority. */ @@ -17,6 +35,8 @@ export type CronRuntimeAuthority = Readonly<{ /** Runtime-owned payload discriminator; core never interprets its value. */ namespace: string; payload: Readonly>; + /** Host-validated projections from runtime-specific creator tools to scheduled tools. */ + toolBindings?: readonly CronScheduledToolBinding[]; }>; function normalizeAuthorityId(value: unknown): string | undefined { @@ -99,6 +119,55 @@ function deepFreezeJson(value: JsonValue): JsonValue { return value; } +function normalizeToolBindings(value: unknown): readonly CronScheduledToolBinding[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value) || value.length > CRON_RUNTIME_AUTHORITY_MAX_TOOL_BINDINGS) { + return undefined; + } + const bindings: CronScheduledToolBinding[] = []; + const seenSources = new Set(); + for (const candidate of value) { + if (!isRecord(candidate)) { + return undefined; + } + const sourceTool = normalizeAuthorityId(candidate.sourceTool); + if (!sourceTool || seenSources.has(sourceTool)) { + return undefined; + } + if ( + candidate.targetTool === "exec" && + Object.keys(candidate).every((key) => + ["sourceTool", "targetTool", "execTarget"].includes(key), + ) && + isRecord(candidate.execTarget) && + Object.keys(candidate.execTarget).length === 1 && + candidate.execTarget.host === "gateway" + ) { + seenSources.add(sourceTool); + bindings.push( + Object.freeze({ + sourceTool, + targetTool: "exec", + execTarget: Object.freeze({ host: "gateway" }), + }), + ); + continue; + } + if ( + candidate.targetTool === "process" && + Object.keys(candidate).every((key) => key === "sourceTool" || key === "targetTool") + ) { + seenSources.add(sourceTool); + bindings.push(Object.freeze({ sourceTool, targetTool: "process" })); + continue; + } + return undefined; + } + return Object.freeze(bindings); +} + /** Validates the private persisted transport without learning runtime-owned payload semantics. */ export function normalizeCronRuntimeAuthority(value: unknown): CronRuntimeAuthority | undefined { if ( @@ -114,7 +183,8 @@ export function normalizeCronRuntimeAuthority(value: unknown): CronRuntimeAuthor const runtimeId = normalizeAuthorityId(value.runtimeId); const namespace = normalizeAuthorityId(value.namespace); const payload = cloneJsonObject(value.payload); - if (!runtimeId || !namespace || !payload) { + const toolBindings = normalizeToolBindings(value.toolBindings); + if (!runtimeId || !namespace || !payload || (value.toolBindings !== undefined && !toolBindings)) { return undefined; } const normalized = { @@ -122,6 +192,7 @@ export function normalizeCronRuntimeAuthority(value: unknown): CronRuntimeAuthor runtimeId, namespace, payload: deepFreezeJson(payload) as Readonly>, + ...(toolBindings ? { toolBindings } : {}), } as const; if (Buffer.byteLength(JSON.stringify(normalized), "utf8") > CRON_RUNTIME_AUTHORITY_MAX_BYTES) { return undefined; diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 34b6e4631996..24338aad5372 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -101,7 +101,7 @@ export type CronServiceDeps = { /** List enabled, configured channel ids without exposing channel machinery to cron core. */ listConfiguredChannels?: () => readonly string[] | Promise; evaluateCronTrigger?: (params: { - job: CronJob; + job: CronStoredJob; script: string; state: unknown; streamBatch?: string; @@ -219,7 +219,7 @@ export type CronServiceDeps = { } & CronRunOutcome >; runScriptJob?: (params: { - job: CronJob; + job: CronStoredJob; streamBatch?: string; abortSignal?: AbortSignal; }) => Promise< diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 6489c8c3a984..82adee8915a3 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -99,6 +99,13 @@ function makeAuthorityStore(jobId: string): CronStoreFile { runtimeId: "codex", namespace: "codex.apps", payload: { apps: [{ id: "calendar" }] }, + toolBindings: [ + { + sourceTool: "gateway_exec", + targetTool: "exec", + execTarget: { host: "gateway" }, + }, + ], }; return store; } diff --git a/src/cron/trigger-script.test.ts b/src/cron/trigger-script.test.ts index 464a20b8b3b5..6d5c013a7b1c 100644 --- a/src/cron/trigger-script.test.ts +++ b/src/cron/trigger-script.test.ts @@ -92,11 +92,46 @@ describe("cron trigger script evaluator", () => { script: 'await exec({ command: "printf openclaw-cron-alias-ok" }); return { fire: false };', state: null, toolsAllow: ["gateway_exec", "gateway_process"], + runtimeAuthority: { + version: 1, + runtimeId: "codex", + namespace: "scheduled-tools", + payload: { version: 1 }, + toolBindings: [ + { + sourceTool: "gateway_exec", + targetTool: "exec", + execTarget: { host: "gateway" }, + }, + { sourceTool: "gateway_process", targetTool: "process" }, + ], + }, scheduledToolPolicy: { version: 1, mode: "trusted" }, }), ).resolves.toEqual({ kind: "evaluated", fire: false }); }); + it("does not project a colliding raw alias without producer-bound authority", async () => { + const workspaceDir = tempDirs.make("openclaw-cron-codex-alias-collision-"); + const evaluate = createCronScriptRuntime({ + config: { + agents: { defaults: { workspace: workspaceDir } }, + tools: { exec: { host: "gateway", security: "full", ask: "off" } }, + } as OpenClawConfig, + }).evaluateTrigger; + + const result = await evaluate({ + jobId: "job-colliding-gateway-exec", + script: 'await exec({ command: "printf must-not-run" }); return { fire: false };', + state: null, + toolsAllow: ["gateway_exec"], + scheduledToolPolicy: { version: 1, mode: "trusted" }, + }); + + expect(result).toMatchObject({ kind: "error", code: "internal_error" }); + expect(result.kind === "error" ? result.error : "").toContain("exec is not defined"); + }); + it.each(["node_exec", "sandbox_exec"])( "does not widen %s into generic exec authority", async (creatorAlias) => { diff --git a/src/cron/trigger-script.ts b/src/cron/trigger-script.ts index c249c931abdc..690a45cbff1b 100644 --- a/src/cron/trigger-script.ts +++ b/src/cron/trigger-script.ts @@ -53,6 +53,7 @@ import { resolveCronAgentConfig, } from "./isolated-agent/run-config.js"; import { resolveCronAgentSessionKey } from "./isolated-agent/session-key.js"; +import type { CronRuntimeAuthority } from "./runtime-authority.js"; import { DEFAULT_CRON_SCRIPT_TIMEOUT_SECONDS, DEFAULT_CRON_SCRIPT_TOOL_BUDGET, @@ -66,8 +67,6 @@ const MAX_TRIGGER_STATE_BYTES = 16 * 1024; const MAX_CACHED_TRIGGER_RUNTIMES = 128; const HEADLESS_TRIGGER_WALL_CLOCK_MS = 30_000; const HEADLESS_TRIGGER_TOOL_BUDGET = 5; -const GATEWAY_EXEC_CREATOR_ALIAS = "gateway_exec"; -const GATEWAY_PROCESS_CREATOR_ALIAS = "gateway_process"; let activeTriggerEvaluations = 0; @@ -93,6 +92,7 @@ type PrepareTriggerRuntime = (params: { jobId: string; agentId?: string; toolsAllow?: string[]; + runtimeAuthority?: CronRuntimeAuthority; scheduledToolPolicy?: ScheduledToolPolicyContext; signal?: AbortSignal; }) => Promise; @@ -114,22 +114,30 @@ function resolveTriggerAgentId(config: OpenClawConfig, agentId?: string): string return agentId?.trim() ? normalizeAgentId(agentId) : resolveDefaultAgentId(config); } -function projectTriggerToolAuthority(toolsAllow: string[] | undefined): { +function projectTriggerToolAuthority( + toolsAllow: string[] | undefined, + runtimeAuthority: CronRuntimeAuthority | undefined, +): { toolsAllow: string[] | undefined; pinGatewayExec: boolean; } { - if (!toolsAllow?.includes(GATEWAY_EXEC_CREATOR_ALIAS)) { + if (!toolsAllow || !runtimeAuthority?.toolBindings?.length) { return { toolsAllow, pinGatewayExec: false }; } + const bindingsBySource = new Map( + runtimeAuthority.toolBindings.map((binding) => [binding.sourceTool, binding]), + ); + let pinGatewayExec = false; const projected = new Set( toolsAllow.map((name) => { - if (name === GATEWAY_EXEC_CREATOR_ALIAS) { - return "exec"; + const binding = bindingsBySource.get(name); + if (binding?.targetTool === "exec") { + pinGatewayExec = binding.execTarget.host === "gateway"; } - return name === GATEWAY_PROCESS_CREATOR_ALIAS ? "process" : name; + return binding?.targetTool ?? name; }), ); - return { toolsAllow: [...projected], pinGatewayExec: true }; + return { toolsAllow: [...projected], pinGatewayExec }; } async function prepareTriggerRuntime(params: { @@ -137,6 +145,7 @@ async function prepareTriggerRuntime(params: { jobId: string; agentId?: string; toolsAllow?: string[]; + runtimeAuthority?: CronRuntimeAuthority; scheduledToolPolicy?: ScheduledToolPolicyContext; signal?: AbortSignal; }): Promise { @@ -179,7 +188,10 @@ async function prepareTriggerRuntime(params: { params.signal?.throwIfAborted(); const effectiveWorkspace = sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? sandbox.workspaceDir : workspaceDir; - const projectedAuthority = projectTriggerToolAuthority(params.toolsAllow); + const projectedAuthority = projectTriggerToolAuthority( + params.toolsAllow, + params.runtimeAuthority, + ); const toolPlan = resolveEmbeddedAttemptToolConstructionPlan({ toolsEnabled: true, toolsAllow: projectedAuthority.toolsAllow, @@ -381,6 +393,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { requestedAgentId?: string; agentId: string; toolsAllow?: string[]; + runtimeAuthority?: CronRuntimeAuthority; scheduledToolPolicy?: ScheduledToolPolicyContext; toolsAllowKey: string; signal: AbortSignal; @@ -416,6 +429,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { jobId: request.jobId, agentId: request.requestedAgentId, toolsAllow: request.toolsAllow, + runtimeAuthority: request.runtimeAuthority, scheduledToolPolicy: request.scheduledToolPolicy, signal: request.signal, }); @@ -442,6 +456,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { agentId?: string; script: string; toolsAllow?: string[]; + runtimeAuthority?: CronRuntimeAuthority; scheduledToolPolicy?: ScheduledToolPolicyContext; abortSignal?: AbortSignal; wallClockMs: number; @@ -462,6 +477,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { const agentId = resolveTriggerAgentId(runtimeConfig, params.agentId); const toolsAllowKey = JSON.stringify([ params.toolsAllow ?? null, + params.runtimeAuthority?.toolBindings ?? null, params.scheduledToolPolicy ?? null, ]); const runtime = await resolveCachedRuntime({ @@ -470,6 +486,7 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { requestedAgentId: params.agentId, agentId, toolsAllow: params.toolsAllow, + runtimeAuthority: params.runtimeAuthority, scheduledToolPolicy: params.scheduledToolPolicy, toolsAllowKey, signal: evaluationScope.signal, @@ -643,6 +660,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) { state: unknown; streamBatch?: string; toolsAllow?: string[]; + runtimeAuthority?: CronRuntimeAuthority; scheduledToolPolicy?: ScheduledToolPolicyContext; abortSignal?: AbortSignal; }): Promise => { @@ -670,6 +688,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) { state: unknown; streamBatch?: string; toolsAllow?: string[]; + runtimeAuthority?: CronRuntimeAuthority; scheduledToolPolicy?: ScheduledToolPolicyContext; timeoutSeconds?: number; toolBudget?: number; diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 6bba3afdc372..4ec22ca23a45 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -726,6 +726,7 @@ export function buildGatewayCronService(params: { state, streamBatch, toolsAllow: job.payload.toolsAllow, + runtimeAuthority: job.runtimeAuthority, scheduledToolPolicy: resolveCronScheduledToolPolicy({ toolsAllow: job.payload.toolsAllow, scheduledToolPolicy: job.scheduledToolPolicy, @@ -927,6 +928,7 @@ export function buildGatewayCronService(params: { state: job.state.triggerState, streamBatch, toolsAllow: job.payload.toolsAllow, + runtimeAuthority: job.runtimeAuthority, scheduledToolPolicy: resolveCronScheduledToolPolicy({ toolsAllow: job.payload.toolsAllow, scheduledToolPolicy: job.scheduledToolPolicy, diff --git a/src/plugin-sdk/codex-mcp-projection.ts b/src/plugin-sdk/codex-mcp-projection.ts index 8f5bc1aa996d..8d6636aef2d8 100644 --- a/src/plugin-sdk/codex-mcp-projection.ts +++ b/src/plugin-sdk/codex-mcp-projection.ts @@ -2,13 +2,18 @@ // runtime's user-mcp-server projection so the bundled Codex app-server harness // can attach the same user `mcp.servers` entries to its thread config without // deep-importing core helpers. +import { + bindCronScheduledTool, + getCronScheduledToolBinding, + pinExecToolTarget, +} from "../agents/exec-tool-target-pinning.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { CronCreatorToolAllowlistEntry, CronToolsAllowCaptureRef, } from "../agents/tools/cron-tool.types.js"; -export { pinExecToolTarget } from "../agents/exec-tool-target-pinning.js"; +export { bindCronScheduledTool, getCronScheduledToolBinding, pinExecToolTarget }; export { buildCodexUserMcpServersThreadConfigPatch, buildCodexUserMcpServersThreadConfigPatchForRuntime, @@ -38,5 +43,11 @@ export async function captureFinalCodexCronCreatorToolAllowlist( ) { const [{ captureFinalEffectiveCronCreatorToolAllowlist: capture }, { getPluginToolMeta }] = await Promise.all([import("../agents/tools/cron-tool.js"), import("../plugins/tools.js")]); - return capture(target, captureRef, tools, (tool) => getPluginToolMeta(tool)); + return capture(target, captureRef, tools, (tool) => { + const scheduledToolBinding = getCronScheduledToolBinding(tool); + return { + ...getPluginToolMeta(tool), + ...(scheduledToolBinding ? { scheduledToolBinding } : {}), + }; + }); }