mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(cron): bind projected exec authority to Codex
This commit is contained in:
@@ -33,10 +33,34 @@ import {
|
||||
resolveScheduledCodexAppCreatorCaptureDecision,
|
||||
} from "./scheduled-app-authority.js";
|
||||
|
||||
type CodexCronCreatorTool = Parameters<typeof captureFinalCodexCronCreatorToolAllowlist>[0][number];
|
||||
type CodexCronRuntimeAuthority = NonNullable<EmbeddedRunAttemptParams["scheduledRuntimeAuthority"]>;
|
||||
|
||||
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<string | { name: string; pluginId?: string }> = [];
|
||||
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<typeof createCodexDynamicToolBridge> | undefined;
|
||||
let creatorAuthorityPromise:
|
||||
| Promise<{
|
||||
tools: readonly (string | { name: string; pluginId?: string })[];
|
||||
tools: readonly CodexCronCreatorTool[];
|
||||
provenance: { version: 1; source: "final-executable-surface" };
|
||||
runtimeAuthority?: NonNullable<EmbeddedRunAttemptParams["scheduledRuntimeAuthority"]>;
|
||||
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<EmbeddedRunAttemptParams["scheduledRuntimeAuthority"]>;
|
||||
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<string | { name: string; pluginId?: string }> = [];
|
||||
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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AnyAgentTool, CronScheduledToolBinding>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<T extends { name: string }>(
|
||||
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<string>();
|
||||
@@ -56,7 +59,12 @@ export function replaceWithEffectiveCronCreatorToolAllowlist<T extends { name: s
|
||||
const meta = toolMeta?.(tool);
|
||||
const pluginId =
|
||||
typeof meta?.pluginId === "string" ? normalizeToolPolicyName(meta.pluginId) : undefined;
|
||||
target.push(pluginId ? { name, pluginId } : { name });
|
||||
const scheduledToolBinding = meta?.scheduledToolBinding;
|
||||
target.push({
|
||||
name,
|
||||
...(pluginId ? { pluginId } : {}),
|
||||
...(scheduledToolBinding ? { scheduledToolBinding } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +73,9 @@ export function captureFinalEffectiveCronCreatorToolAllowlist<T extends { name:
|
||||
target: CronCreatorToolAllowlistEntry[],
|
||||
captureRef: CronToolsAllowCaptureRef,
|
||||
tools: readonly T[],
|
||||
toolMeta?: (tool: T) => { pluginId?: string } | undefined,
|
||||
toolMeta?: (
|
||||
tool: T,
|
||||
) => { pluginId?: string; scheduledToolBinding?: CronScheduledToolBinding } | undefined,
|
||||
): void {
|
||||
replaceWithEffectiveCronCreatorToolAllowlist(target, tools, toolMeta);
|
||||
captureRef.value = { version: 1, source: "final-executable-surface" };
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
/** 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<string>();
|
||||
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<Record<string, unknown>>,
|
||||
...(toolBindings ? { toolBindings } : {}),
|
||||
} as const;
|
||||
if (Buffer.byteLength(JSON.stringify(normalized), "utf8") > CRON_RUNTIME_AUTHORITY_MAX_BYTES) {
|
||||
return undefined;
|
||||
|
||||
@@ -101,7 +101,7 @@ export type CronServiceDeps = {
|
||||
/** List enabled, configured channel ids without exposing channel machinery to cron core. */
|
||||
listConfiguredChannels?: () => readonly string[] | Promise<readonly string[]>;
|
||||
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<
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<PreparedTriggerRuntime>;
|
||||
@@ -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<PreparedTriggerRuntime> {
|
||||
@@ -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<CronTriggerEvaluationResult> => {
|
||||
@@ -670,6 +688,7 @@ export function createCronScriptRuntime(deps: CronTriggerEvaluatorDeps) {
|
||||
state: unknown;
|
||||
streamBatch?: string;
|
||||
toolsAllow?: string[];
|
||||
runtimeAuthority?: CronRuntimeAuthority;
|
||||
scheduledToolPolicy?: ScheduledToolPolicyContext;
|
||||
timeoutSeconds?: number;
|
||||
toolBudget?: number;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user