mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(cron): preserve Codex trigger exec authority
This commit is contained in:
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user