diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 3923bbcfcdbf..4672a23f99cb 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 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 5aa154d29af2..d4d1fa984cec 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 @@ -226,6 +226,26 @@ function admitLocalOperatorCronAuthority(params: ReturnType } 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")); diff --git a/extensions/codex/src/app-server/shell-dynamic-tools.ts b/extensions/codex/src/app-server/shell-dynamic-tools.ts index 4964e5af62f2..2498cbbf59db 100644 --- a/extensions/codex/src/app-server/shell-dynamic-tools.ts +++ b/extensions/codex/src/app-server/shell-dynamic-tools.ts @@ -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 { - return args && typeof args === "object" && !Array.isArray(args) - ? (args as Record) - : {}; -} - -function hideExecDynamicToolParameters( - parameters: OpenClawDynamicTool["parameters"], - hideNode: boolean, - nodeOnly: boolean, -) { - if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { - return parameters; - } - const schema = parameters as Record; - 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 } : {}), - }; -} diff --git a/src/agents/exec-tool-target-pinning.ts b/src/agents/exec-tool-target-pinning.ts new file mode 100644 index 000000000000..3350685b4a16 --- /dev/null +++ b/src/agents/exec-tool-target-pinning.ts @@ -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 { + 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; + 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"]; +} diff --git a/src/cron/trigger-script.test.ts b/src/cron/trigger-script.test.ts index 275ad88fcfd6..464a20b8b3b5 100644 --- a/src/cron/trigger-script.test.ts +++ b/src/cron/trigger-script.test.ts @@ -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>[0]; type PrepareParams = Parameters>[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({ diff --git a/src/cron/trigger-script.ts b/src/cron/trigger-script.ts index 2ea8909859e6..c249c931abdc 100644 --- a/src/cron/trigger-script.ts +++ b/src/cron/trigger-script.ts @@ -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, diff --git a/src/plugin-sdk/codex-mcp-projection.ts b/src/plugin-sdk/codex-mcp-projection.ts index 79b02ad6bb69..8f5bc1aa996d 100644 --- a/src/plugin-sdk/codex-mcp-projection.ts +++ b/src/plugin-sdk/codex-mcp-projection.ts @@ -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,