fix(cron): preserve Codex trigger exec authority

This commit is contained in:
Josh Lehman
2026-08-19 17:18:36 -07:00
parent 5775cd3dfa
commit e51ad6845e
7 changed files with 196 additions and 86 deletions
-1
View File
@@ -236,7 +236,6 @@ extensions/codex/src/app-server/session-binding.ts 4
extensions/codex/src/app-server/session-history.ts 6
extensions/codex/src/app-server/settled-turn-context.ts 3
extensions/codex/src/app-server/shared-client.ts 5
extensions/codex/src/app-server/shell-dynamic-tools.ts 2
extensions/codex/src/app-server/side-question.ts 8
extensions/codex/src/app-server/startup-binding.ts 2
extensions/codex/src/app-server/thread-fingerprints.ts 2
@@ -226,6 +226,26 @@ function admitLocalOperatorCronAuthority(params: ReturnType<typeof createParams>
}
describe("runCodexAppServerAttempt configured MCP ownership", () => {
it("preserves host-pinned shell aliases in scheduler authority capture", async () => {
const sessionFile = path.join(tempDir, "session-cron-shell-aliases.jsonl");
const params = createParams(sessionFile, path.join(tempDir, "workspace-cron-shell-aliases"));
setCodexTestModelSupportsTools(params, true);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
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.captureCalls).toHaveLength(1);
expect(mcpMocks.captureCalls[0]?.storedNames).toEqual(mcpMocks.captureCalls[0]?.sourceNames);
expect(mcpMocks.captureCalls[0]?.storedNames).toContain("gateway_exec");
expect(mcpMocks.captureCalls[0]?.storedNames).toContain("gateway_process");
expect(mcpMocks.captureCalls[0]?.storedNames).not.toContain("exec");
});
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"));
@@ -1,3 +1,4 @@
import { pinExecToolTarget } from "openclaw/plugin-sdk/codex-mcp-projection";
import type { CodexPluginConfig } from "./config.js";
import { normalizeCodexDynamicToolName } from "./dynamic-tool-profile.js";
@@ -11,14 +12,6 @@ type ExecAliasParams =
export const CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME = "node_exec";
export const CODEX_GATEWAY_EXEC_DYNAMIC_TOOL_NAME = "gateway_exec";
export const CODEX_GATEWAY_PROCESS_DYNAMIC_TOOL_NAME = "gateway_process";
const CODEX_EXEC_POLICY_PARAMETER_NAMES = new Set(["host", "security", "ask"]);
const CODEX_NODE_EXEC_PARAMETER_NAMES = new Set([
"command",
"workdir",
"env",
"timeoutSeconds",
"node",
]);
const PROCESS_FOLLOWUP_TEXT =
"Use process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.";
@@ -51,22 +44,18 @@ export function createExecAliasDynamicTool(
: gatewayProcessAliasAvailable
? "Use gateway_process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up."
: "Background session follow-up is unavailable because gateway_process is not exposed. Rerun without background=true and set yieldMs high enough to wait for completion.";
const pinnedTool = pinExecToolTarget(
execTool,
nodeAlias
? { host: "node", ...(pinnedNode ? { node: pinnedNode } : {}) }
: { host: "gateway", ...(params.ask ? { ask: params.ask } : {}) },
);
return {
...execTool,
...pinnedTool,
name,
description,
parameters: hideExecDynamicToolParameters(
execTool.parameters,
!nodeAlias || Boolean(pinnedNode),
nodeAlias,
),
execute: async (toolCallId, args, signal, onUpdate) => {
const result = await execTool.execute(
toolCallId,
pinExecDynamicToolArgs(args, params, pinnedNode),
signal,
onUpdate,
);
const result = await pinnedTool.execute(toolCallId, args, signal, onUpdate);
return {
...result,
content: result.content.map((item) =>
@@ -92,61 +81,3 @@ export function createGatewayProcessAliasDynamicTool(
};
}
function pinExecDynamicToolArgs(
args: unknown,
params: ExecAliasParams,
configuredNode?: string,
): unknown {
const source = normalizeExecDynamicToolArgs(args);
const { host: _host, security: _security, ask: _ask, node: requestedNode, ...rest } = source;
if (params.host === "gateway") {
return { ...rest, host: params.host, ...(params.ask ? { ask: params.ask } : {}) };
}
const nodeArgs = Object.fromEntries(
Object.entries(rest).filter(([name]) => CODEX_NODE_EXEC_PARAMETER_NAMES.has(name)),
);
const node = configuredNode ?? (typeof requestedNode === "string" ? requestedNode.trim() : "");
return {
...nodeArgs,
host: params.host,
...(node ? { node } : {}),
};
}
function normalizeExecDynamicToolArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" && !Array.isArray(args)
? (args as Record<string, unknown>)
: {};
}
function hideExecDynamicToolParameters(
parameters: OpenClawDynamicTool["parameters"],
hideNode: boolean,
nodeOnly: boolean,
) {
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
return parameters;
}
const schema = parameters as Record<string, unknown>;
const rawProperties = schema.properties;
if (!rawProperties || typeof rawProperties !== "object" || Array.isArray(rawProperties)) {
return parameters;
}
const includeParameter = (name: string) =>
nodeOnly
? CODEX_NODE_EXEC_PARAMETER_NAMES.has(name) && !(hideNode && name === "node")
: !CODEX_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) &&
!(hideNode && normalizeCodexDynamicToolName(name) === "node");
const nextProperties = Object.fromEntries(
Object.entries(rawProperties).filter(([name]) => includeParameter(name)),
);
const rawRequired = schema.required;
const nextRequired = Array.isArray(rawRequired)
? rawRequired.filter((name) => typeof name !== "string" || includeParameter(name))
: rawRequired;
return {
...schema,
properties: nextProperties,
...(Array.isArray(rawRequired) ? { required: nextRequired } : {}),
};
}
+74
View File
@@ -0,0 +1,74 @@
import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce";
import type { AnyAgentTool } from "./tools/common.js";
const EXEC_POLICY_PARAMETER_NAMES = new Set(["host", "security", "ask"]);
const NODE_EXEC_PARAMETER_NAMES = new Set(["command", "workdir", "env", "timeoutSeconds", "node"]);
type PinnedExecToolTarget =
| { host: "gateway"; ask?: "always" }
| { host: "node"; node?: string };
/** 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;
return {
...tool,
parameters: restrictExecToolParameters(tool.parameters, target.host, Boolean(pinnedNode)),
execute: (toolCallId, args, signal, onUpdate) =>
tool.execute(toolCallId, pinExecToolArgs(args, target, pinnedNode), signal, onUpdate),
};
}
function pinExecToolArgs(
args: unknown,
target: PinnedExecToolTarget,
pinnedNode: string | undefined,
): Record<string, unknown> {
const source = asNonArrayRecord(args);
const { host: _host, security: _security, ask: _ask, node: requestedNode, ...rest } = source;
if (target.host === "gateway") {
return { ...rest, host: "gateway", ...(target.ask ? { ask: target.ask } : {}) };
}
const nodeArgs = Object.fromEntries(
Object.entries(rest).filter(([name]) => NODE_EXEC_PARAMETER_NAMES.has(name)),
);
const node = pinnedNode ?? (typeof requestedNode === "string" ? requestedNode.trim() : "");
return {
...nodeArgs,
host: "node",
...(node ? { node } : {}),
};
}
function restrictExecToolParameters(
parameters: AnyAgentTool["parameters"],
host: PinnedExecToolTarget["host"],
hasPinnedNode: boolean,
): AnyAgentTool["parameters"] {
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
return parameters;
}
// SAFETY: the guards above establish a non-array object schema before field inspection.
const schema = parameters as Record<string, unknown>;
const rawProperties = schema.properties;
if (!rawProperties || typeof rawProperties !== "object" || Array.isArray(rawProperties)) {
return parameters;
}
const includeParameter = (name: string) =>
host === "node"
? NODE_EXEC_PARAMETER_NAMES.has(name) && !(hasPinnedNode && name === "node")
: !EXEC_POLICY_PARAMETER_NAMES.has(name) && name !== "node";
const properties = Object.fromEntries(
Object.entries(rawProperties).filter(([name]) => includeParameter(name)),
);
const rawRequired = schema.required;
const required = Array.isArray(rawRequired)
? rawRequired.filter((name) => typeof name !== "string" || includeParameter(name))
: rawRequired;
return {
...schema,
properties,
...(Array.isArray(rawRequired) ? { required } : {}),
// SAFETY: this preserves the original schema shape and only removes properties and required names.
} as AnyAgentTool["parameters"];
}
+52 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { wrapToolWithBeforeToolCallHook } from "../agents/agent-tools.before-tool-call.js";
import { BEFORE_TOOL_CALL_HOOK_CONTEXT } from "../agents/before-tool-call-metadata.js";
import type { CodeModeHeadlessResult } from "../agents/code-mode.js";
@@ -11,6 +12,7 @@ type HeadlessParams = Parameters<NonNullable<EvaluatorDeps["runHeadless"]>>[0];
type PrepareParams = Parameters<NonNullable<EvaluatorDeps["prepareRuntime"]>>[0];
const beforeToolCallTesting = { BEFORE_TOOL_CALL_HOOK_CONTEXT };
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function completed(params: { value: unknown; output?: unknown[] }): CodeModeHeadlessResult {
return {
@@ -69,6 +71,55 @@ function createCronTriggerEvaluator(deps: EvaluatorDeps) {
}
describe("cron trigger script evaluator", () => {
it("executes a gateway-pinned creator alias through the documented exec name", async () => {
const workspaceDir = tempDirs.make("openclaw-cron-codex-alias-");
const config = {
agents: { defaults: { workspace: workspaceDir } },
tools: {
exec: {
host: "node",
node: "configured-node-must-not-run",
security: "full",
ask: "off",
},
},
} as OpenClawConfig;
const evaluate = createCronScriptRuntime({ config }).evaluateTrigger;
await expect(
evaluate({
jobId: "job-codex-exec-alias",
script: 'await exec({ command: "printf openclaw-cron-alias-ok" }); return { fire: false };',
state: null,
toolsAllow: ["gateway_exec", "gateway_process"],
scheduledToolPolicy: { version: 1, mode: "trusted" },
}),
).resolves.toEqual({ kind: "evaluated", fire: false });
});
it.each(["node_exec", "sandbox_exec"])(
"does not widen %s into generic exec authority",
async (creatorAlias) => {
const workspaceDir = tempDirs.make("openclaw-cron-codex-alias-denied-");
const config = {
agents: { defaults: { workspace: workspaceDir } },
tools: { exec: { host: "gateway", security: "full", ask: "off" } },
} as OpenClawConfig;
const evaluate = createCronScriptRuntime({ config }).evaluateTrigger;
const result = await evaluate({
jobId: `job-codex-${creatorAlias}`,
script: 'await exec({ command: "printf must-not-run" }); return { fire: false };',
state: null,
toolsAllow: [creatorAlias],
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("prefers a valid returned value and injects trigger state", async () => {
const runHeadless = vi.fn(async (_params: HeadlessParams) =>
completed({
+40 -6
View File
@@ -26,6 +26,7 @@ import {
applyEmbeddedAttemptToolsAllow,
resolveEmbeddedAttemptToolConstructionPlan,
} from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js";
import { pinExecToolTarget } from "../agents/exec-tool-target-pinning.js";
import { loadAgentRuntimePluginRegistryHandle } from "../agents/runtime-plugins.js";
import { resolveSandboxContext } from "../agents/sandbox.js";
import {
@@ -65,6 +66,8 @@ 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;
@@ -111,6 +114,24 @@ function resolveTriggerAgentId(config: OpenClawConfig, agentId?: string): string
return agentId?.trim() ? normalizeAgentId(agentId) : resolveDefaultAgentId(config);
}
function projectTriggerToolAuthority(toolsAllow: string[] | undefined): {
toolsAllow: string[] | undefined;
pinGatewayExec: boolean;
} {
if (!toolsAllow?.includes(GATEWAY_EXEC_CREATOR_ALIAS)) {
return { toolsAllow, pinGatewayExec: false };
}
const projected = new Set(
toolsAllow.map((name) => {
if (name === GATEWAY_EXEC_CREATOR_ALIAS) {
return "exec";
}
return name === GATEWAY_PROCESS_CREATOR_ALIAS ? "process" : name;
}),
);
return { toolsAllow: [...projected], pinGatewayExec: true };
}
async function prepareTriggerRuntime(params: {
runtimeConfig: OpenClawConfig;
jobId: string;
@@ -158,16 +179,20 @@ async function prepareTriggerRuntime(params: {
params.signal?.throwIfAborted();
const effectiveWorkspace =
sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? sandbox.workspaceDir : workspaceDir;
const projectedAuthority = projectTriggerToolAuthority(params.toolsAllow);
const toolPlan = resolveEmbeddedAttemptToolConstructionPlan({
toolsEnabled: true,
toolsAllow: params.toolsAllow,
toolsAllow: projectedAuthority.toolsAllow,
});
// Bundle MCP tools are source:"mcp", which the headless bridge excludes.
// LSP runtimes are session-scoped and intentionally outside trigger v1.
const allTools = toolPlan.constructTools
? createOpenClawCodingTools({
agentId,
exec: { config },
exec: {
config,
...(projectedAuthority.pinGatewayExec ? { host: "gateway" as const } : {}),
},
sandbox,
sessionKey,
trigger: "cron",
@@ -182,15 +207,24 @@ async function prepareTriggerRuntime(params: {
runtimeToolAllowlist: toolPlan.runtimeToolAllowlist,
inheritRuntimeToolAllowlist: Boolean(toolPlan.runtimeToolAllowlist),
scheduledToolPolicy: resolveScheduledToolPolicyContext({
toolsAllow: params.toolsAllow,
toolsAllow: projectedAuthority.toolsAllow,
scheduledToolPolicy: params.scheduledToolPolicy,
}),
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
})
: [];
const tools = applyEmbeddedAttemptToolsAllow(allTools, params.toolsAllow, {
toolMeta: (tool) => getPluginToolMeta(tool),
});
const authorityBoundTools = projectedAuthority.pinGatewayExec
? allTools.map((tool) =>
tool.name === "exec" ? pinExecToolTarget(tool, { host: "gateway" }) : tool,
)
: allTools;
const tools = applyEmbeddedAttemptToolsAllow(
authorityBoundTools,
projectedAuthority.toolsAllow,
{
toolMeta: (tool) => getPluginToolMeta(tool),
},
);
const hookContext: HookContext = {
agentId,
config,
+1
View File
@@ -8,6 +8,7 @@ import type {
CronToolsAllowCaptureRef,
} from "../agents/tools/cron-tool.types.js";
export { pinExecToolTarget } from "../agents/exec-tool-target-pinning.js";
export {
buildCodexUserMcpServersThreadConfigPatch,
buildCodexUserMcpServersThreadConfigPatchForRuntime,