fix(codex): restore shell for restricted turns (#92294)

* fix(codex): keep OpenClaw exec when native surface has no environment (#92238)

* chore: keep release note in PR

* fix(codex): keep shell tool type internal

Co-authored-by: yetval <yetvald@gmail.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Yuval Dinodia
2026-07-15 03:37:04 -04:00
committed by GitHub
parent 7856a99aa5
commit 7974def3fd
7 changed files with 257 additions and 111 deletions

View File

@@ -467,9 +467,9 @@ and `HOME` stays inherited so subprocesses can use normal user-home state.
## Dynamic tools
Codex dynamic tools default to `searchable` loading, exposed under the
`openclaw` namespace with `deferLoading: true`. OpenClaw does not expose
dynamic tools that duplicate Codex-native workspace operations or Codex's own
tool-search surface:
`openclaw` namespace with `deferLoading: true`. OpenClaw normally does not
expose dynamic tools that duplicate Codex-native workspace operations or
Codex's own tool-search surface:
- `read`
- `write`
@@ -483,6 +483,11 @@ tool-search surface:
- `tool_search`
- `tool_search_code`
When a finite runtime allowlist disables native Code Mode, OpenClaw sends an
empty execution-environment selection. In that direct, unsandboxed case,
OpenClaw keeps its policy-filtered `exec` and `process` tools as the shell
fallback. Runtime allowlists and `codexDynamicToolsExclude` still apply.
Most remaining OpenClaw integration tools, such as messaging, media, cron,
browser, nodes, gateway, `heartbeat_respond`, and `web_search`, are available
through Codex tool search under that namespace. This keeps the initial model

View File

@@ -39,7 +39,10 @@ With the default `tools.exec.host: "auto"` and no active OpenClaw sandbox,
Codex also receives `node_exec` and `node_process` tools for commands on paired
nodes. Native shell remains on the Codex app-server host and workspace
(Gateway-local for the default stdio deployment); `node_exec` selects a node by
name or id and keeps OpenClaw's node approval policy in force.
name or id and keeps OpenClaw's node approval policy in force. If a finite
runtime allowlist disables native Code Mode and leaves the turn without an
execution environment, OpenClaw keeps its policy-filtered `exec` and `process`
tools available instead for direct, unsandboxed execution.
This Codex-native feature is separate from
[OpenClaw code mode](/reference/code-mode), an opt-in QuickJS-WASI runtime
@@ -623,14 +626,16 @@ normal user-home state.
### Dynamic tools and web search
Codex dynamic tools default to `searchable` loading. OpenClaw does not
expose dynamic tools that duplicate Codex-native workspace operations:
Codex dynamic tools default to `searchable` loading. OpenClaw normally does
not expose dynamic tools that duplicate Codex-native workspace operations:
`read`, `write`, `edit`, `apply_patch`, `exec`, `process`, `update_plan`,
`tool_call`, `tool_describe`, `tool_search`, and `tool_search_code`. Most
remaining OpenClaw integration tools, such as messaging, media, cron,
browser, nodes, gateway, and `heartbeat_respond`, are available through
Codex tool search under the `openclaw` namespace, keeping the initial model
context smaller.
context smaller. The restricted-turn shell fallback is the exception for
`exec` and `process` when a finite allowlist disables native Code Mode;
runtime allowlists and `codexDynamicToolsExclude` still apply.
Tools marked `catalogMode: "direct-only"`, including the OpenClaw `computer`
tool, use the `openclaw_direct` namespace instead. Codex treats that namespace

View File

@@ -981,6 +981,73 @@ describe("Codex app-server dynamic tool build", () => {
nativeToolSurfaceEnabled: true,
});
expect(gatewayTools.map((tool) => tool.name)).toEqual(["message"]);
const allowlistedParams = {
...gatewayParams,
toolsAllow: ["message"],
} as EmbeddedRunAttemptParams;
const allowlistedTools = await buildDynamicToolsForTest(allowlistedParams, workspaceDir, {
nativeToolSurfaceEnabled: true,
});
expect(allowlistedTools.map((tool) => tool.name)).toEqual(["message"]);
});
it("restores the policy-filtered OpenClaw shell when a finite allowlist disables native Code Mode", async () => {
const execTool = createRuntimeDynamicTool("exec");
const processTool = createRuntimeDynamicTool("process");
const messageTool = createRuntimeDynamicTool("message");
setOpenClawCodingToolsFactoryForTests(() => [execTool, processTool, messageTool]);
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "restricted-session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.toolsAllow = ["exec", "process", "message"];
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params);
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
nativeToolSurfaceEnabled,
});
expect(nativeToolSurfaceEnabled).toBe(false);
expect(tools.map((tool) => tool.name)).toEqual([
"exec",
"process",
"message",
"node_exec",
"node_process",
]);
const bridge = createCodexDynamicToolBridge({
tools,
signal: new AbortController().signal,
loading: "direct",
});
await bridge.handleToolCall({
threadId: "restricted-thread",
turnId: "restricted-turn",
tool: "exec",
callId: "restricted-exec",
arguments: { command: "echo restored" },
});
expect(execTool.execute).toHaveBeenCalledWith(
"restricted-exec",
{ command: "echo restored" },
expect.any(AbortSignal),
undefined,
);
const excludedTools = await buildDynamicToolsForTest(params, workspaceDir, {
nativeToolSurfaceEnabled,
pluginConfig: { codexDynamicToolsExclude: ["exec", "process"] },
});
expect(excludedTools.map((tool) => tool.name)).toEqual(["message"]);
const messageOnlyTools = await buildDynamicToolsForTest(
{ ...params, toolsAllow: ["message"] },
workspaceDir,
{ nativeToolSurfaceEnabled: false },
);
expect(messageOnlyTools.map((tool) => tool.name)).toEqual(["message"]);
});
it("exposes Docker sandbox shell tools when native Code Mode cannot honor sandbox paths", async () => {
@@ -1024,9 +1091,12 @@ describe("Codex app-server dynamic tool build", () => {
const disabledSandboxTools = await buildDynamicToolsForTest(params, workspaceDir, {
sandbox: { enabled: false, backendId: "ssh" } as never,
nativeToolSurfaceEnabled: false,
});
expect(disabledSandboxTools.map((tool) => tool.name)).toEqual([
"exec",
"process",
"message",
"node_exec",
"node_process",
@@ -1046,6 +1116,7 @@ describe("Codex app-server dynamic tool build", () => {
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
sandbox: { enabled: true, backendId: "ssh" } as never,
nativeToolSurfaceEnabled: false,
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
@@ -1066,6 +1137,7 @@ describe("Codex app-server dynamic tool build", () => {
for (const excludedToolName of ["sandbox_exec", "process"]) {
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
sandbox: { enabled: true, backendId: "ssh" } as never,
nativeToolSurfaceEnabled: false,
pluginConfig: { codexDynamicToolsExclude: [excludedToolName] },
});

View File

@@ -24,6 +24,7 @@ import { readCodexPluginConfig, type CodexPluginConfig } from "./config.js";
import { dynamicToolBuildState } from "./dynamic-tool-build-state.js";
import {
filterCodexDynamicTools,
filterCodexDynamicToolsWithOpenClawShell,
isSystemAgentOnlyCodexDynamicToolAllowlist,
isForcedPrivateQaCodexRuntime,
normalizeCodexDynamicToolName,
@@ -36,6 +37,13 @@ import {
} from "./native-execution-policy.js";
import type { CodexSandboxPolicy, CodexTurnEnvironmentParams } from "./protocol.js";
import type { CodexSandboxExecEnvironment } from "./sandbox-exec-server.js";
import {
CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME,
CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME,
createNodeExecDynamicTool,
createNodeProcessDynamicTool,
isCodexDynamicToolExcluded,
} from "./shell-dynamic-tools.js";
import { filterToolsForVisionInputs } from "./vision-tools.js";
import { resolveCodexWebSearchPlan, type CodexNativeWebSearchSupport } from "./web-search.js";
@@ -60,9 +68,6 @@ const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [
"apply_patch",
] as const;
const CODEX_MEMORY_FLUSH_DYNAMIC_TOOL_ALLOW = new Set(["read", "write"]);
const CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME = "node_exec";
const CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME = "node_process";
const CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES = new Set(["host", "security", "ask"]);
function preserveRingZeroSystemAgentTool<T extends { name: string; catalogMode?: string }>(
allTools: T[],
filteredTools: T[],
@@ -338,7 +343,9 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
nativeProviderWebSearchSupport: input.nativeProviderWebSearchSupport,
});
const readableAllTools = [...readableAllToolProjection.tools];
const normallyProfiledTools = filterCodexDynamicTools(readableAllTools, input.pluginConfig);
const normallyProfiledTools = shouldKeepOpenClawShellDynamicTools(input, nativeExecutionPolicy)
? filterCodexDynamicToolsWithOpenClawShell(readableAllTools, input.pluginConfig)
: filterCodexDynamicTools(readableAllTools, input.pluginConfig);
const hostSystemAgentActive =
input.isHostScopedToolActive?.("openclaw") ?? isHostScopedAgentToolActive("openclaw");
const profileFilteredTools =
@@ -736,13 +743,6 @@ function shouldExposeSandboxExecDynamicTool(input: DynamicToolBuildParams): bool
const backendId = input.sandbox?.enabled ? input.sandbox.backendId.trim().toLowerCase() : "";
return Boolean(backendId && input.nativeToolSurfaceEnabled === false);
}
function isCodexDynamicToolExcluded(config: CodexPluginConfig, names: string[]): boolean {
const normalizedNames = new Set(names.map((name) => normalizeCodexDynamicToolName(name)));
return (config.codexDynamicToolsExclude ?? []).some((name) => {
const normalized = normalizeCodexDynamicToolName(name);
return normalizedNames.has(normalized);
});
}
function isSandboxShellDynamicToolExcluded(config: CodexPluginConfig): boolean {
return isCodexDynamicToolExcluded(config, ["exec", "sandbox_exec", "process", "sandbox_process"]);
}
@@ -790,97 +790,18 @@ function addNodeShellDynamicToolsIfNeeded(
}
return toolsToAppend.length > 0 ? [...filteredTools, ...toolsToAppend] : filteredTools;
}
function createNodeExecDynamicTool(
execTool: OpenClawDynamicTool,
configuredNode: string | undefined,
): OpenClawDynamicTool {
const pinnedNode = configuredNode?.trim();
return {
...execTool,
name: CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME,
description: pinnedNode
? "Run a shell command on the OpenClaw configured remote node for this session. This tool always uses OpenClaw host=node internally and follows the existing node exec approval and allowlist policy. Use node_process for follow-up on backgrounded node_exec sessions. Use Codex's native shell for local app-server work."
: "Run a shell command on an OpenClaw remote node. Select the node by name or id when multiple nodes are available. This tool always uses OpenClaw host=node internally and follows the existing node exec approval and allowlist policy. Use node_process for follow-up on backgrounded node_exec sessions. Use Codex's native shell for local app-server work.",
parameters: hideNodeExecDynamicToolParameters(execTool.parameters, {
hideNode: Boolean(pinnedNode),
}),
execute: async (toolCallId, args, signal, onUpdate) => {
const result = await execTool.execute(
toolCallId,
pinNodeExecDynamicToolArgs(args, pinnedNode),
signal,
onUpdate,
);
return {
...result,
content: result.content.map((item) =>
item.type === "text"
? Object.assign({}, item, {
text: item.text.replace(
"Use process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.",
"Use node_process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.",
),
})
: item,
),
};
},
};
}
function createNodeProcessDynamicTool(processTool: OpenClawDynamicTool): OpenClawDynamicTool {
return {
...processTool,
name: CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME,
description:
"Manage node_exec sessions that were started on OpenClaw remote nodes: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use only for node_exec follow-up; use Codex's native shell session handling for local app-server work.",
};
}
function pinNodeExecDynamicToolArgs(args: unknown, configuredNode: string | undefined): unknown {
const source =
args && typeof args === "object" && !Array.isArray(args)
? (args as Record<string, unknown>)
: {};
const { host: _host, security: _security, ask: _ask, node: requestedNode, ...rest } = source;
const node = configuredNode ?? (typeof requestedNode === "string" ? requestedNode.trim() : "");
return {
...rest,
host: "node",
...(node ? { node } : {}),
};
}
function hideNodeExecDynamicToolParameters(
parameters: OpenClawDynamicTool["parameters"],
options: { hideNode: 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 nextProperties = Object.fromEntries(
Object.entries(rawProperties).filter(
([name]) =>
!CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) &&
!(options.hideNode && normalizeCodexDynamicToolName(name) === "node"),
),
function shouldKeepOpenClawShellDynamicTools(
input: DynamicToolBuildParams,
nodePolicy: CodexNativeExecutionPolicy,
): boolean {
return (
!isCodexMemoryFlushRun(input.params) &&
// Disabled native Code Mode sends `environments: []`, so Codex cannot
// advertise a shell. Preserve OpenClaw's policy-filtered direct shell.
input.nativeToolSurfaceEnabled === false &&
input.sandbox?.enabled !== true &&
nodePolicy.effectiveExecHost !== "node"
);
const rawRequired = schema.required;
const nextRequired = Array.isArray(rawRequired)
? rawRequired.filter(
(name) =>
typeof name !== "string" ||
(!CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) &&
!(options.hideNode && normalizeCodexDynamicToolName(name) === "node")),
)
: rawRequired;
return {
...schema,
properties: nextProperties,
...(Array.isArray(rawRequired) ? { required: nextRequired } : {}),
};
}
function resolveCodexNativeExecutionPolicyForDynamicTools(
input: DynamicToolBuildParams,

View File

@@ -21,6 +21,7 @@ const CODEX_APP_SERVER_OWNED_DYNAMIC_TOOL_EXCLUDES = [
"tool_search",
"tool_search_code",
] as const;
const CODEX_APP_SERVER_OWNED_SHELL_TOOL_EXCLUDES = new Set(["exec", "process"]);
const DYNAMIC_TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
@@ -113,10 +114,35 @@ export function filterCodexDynamicTools<T extends { name: string }>(
tools: T[],
config: Pick<CodexPluginConfig, "codexDynamicToolsExclude">,
env: CodexDynamicToolProfileEnv = process.env,
): T[] {
return filterCodexDynamicToolsWithOptions(tools, config, env, {
preserveOpenClawShell: false,
});
}
/** Keeps exec/process only when Codex cannot advertise an environment-backed native shell. */
export function filterCodexDynamicToolsWithOpenClawShell<T extends { name: string }>(
tools: T[],
config: Pick<CodexPluginConfig, "codexDynamicToolsExclude">,
env: CodexDynamicToolProfileEnv = process.env,
): T[] {
return filterCodexDynamicToolsWithOptions(tools, config, env, {
preserveOpenClawShell: true,
});
}
function filterCodexDynamicToolsWithOptions<T extends { name: string }>(
tools: T[],
config: Pick<CodexPluginConfig, "codexDynamicToolsExclude">,
env: CodexDynamicToolProfileEnv,
options: { preserveOpenClawShell: boolean },
): T[] {
const excludes = new Set<string>();
if (!isForcedPrivateQaCodexRuntime(env)) {
for (const name of CODEX_APP_SERVER_OWNED_DYNAMIC_TOOL_EXCLUDES) {
if (options.preserveOpenClawShell && CODEX_APP_SERVER_OWNED_SHELL_TOOL_EXCLUDES.has(name)) {
continue;
}
excludes.add(name);
}
}

View File

@@ -0,0 +1,119 @@
import type { CodexPluginConfig } from "./config.js";
import { normalizeCodexDynamicToolName } from "./dynamic-tool-profile.js";
type OpenClawCodingToolsFactory =
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
type OpenClawDynamicTool = ReturnType<OpenClawCodingToolsFactory>[number];
export const CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME = "node_exec";
export const CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME = "node_process";
const CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES = new Set(["host", "security", "ask"]);
/** Returns true when plugin config explicitly removes any named dynamic tool. */
export function isCodexDynamicToolExcluded(
config: Pick<CodexPluginConfig, "codexDynamicToolsExclude">,
names: readonly string[],
): boolean {
const normalizedNames = new Set(names.map((name) => normalizeCodexDynamicToolName(name)));
return (config.codexDynamicToolsExclude ?? []).some((name) =>
normalizedNames.has(normalizeCodexDynamicToolName(name)),
);
}
export function createNodeExecDynamicTool(
execTool: OpenClawDynamicTool,
configuredNode: string | undefined,
): OpenClawDynamicTool {
const pinnedNode = configuredNode?.trim();
return {
...execTool,
name: CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME,
description: pinnedNode
? "Run a shell command on the OpenClaw configured remote node for this session. This tool always uses OpenClaw host=node internally and follows the existing node exec approval and allowlist policy. Use node_process for follow-up on backgrounded node_exec sessions. Use Codex's native shell for local app-server work."
: "Run a shell command on an OpenClaw remote node. Select the node by name or id when multiple nodes are available. This tool always uses OpenClaw host=node internally and follows the existing node exec approval and allowlist policy. Use node_process for follow-up on backgrounded node_exec sessions. Use Codex's native shell for local app-server work.",
parameters: hideNodeExecDynamicToolParameters(execTool.parameters, {
hideNode: Boolean(pinnedNode),
}),
execute: async (toolCallId, args, signal, onUpdate) => {
const result = await execTool.execute(
toolCallId,
pinNodeExecDynamicToolArgs(args, pinnedNode),
signal,
onUpdate,
);
return {
...result,
content: result.content.map((item) =>
item.type === "text"
? Object.assign({}, item, {
text: item.text.replace(
"Use process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.",
"Use node_process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.",
),
})
: item,
),
};
},
};
}
export function createNodeProcessDynamicTool(
processTool: OpenClawDynamicTool,
): OpenClawDynamicTool {
return {
...processTool,
name: CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME,
description:
"Manage node_exec sessions that were started on OpenClaw remote nodes: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use only for node_exec follow-up; use Codex's native shell session handling for local app-server work.",
};
}
function pinNodeExecDynamicToolArgs(args: unknown, configuredNode: string | undefined): unknown {
const source =
args && typeof args === "object" && !Array.isArray(args)
? (args as Record<string, unknown>)
: {};
const { host: _host, security: _security, ask: _ask, node: requestedNode, ...rest } = source;
const node = configuredNode ?? (typeof requestedNode === "string" ? requestedNode.trim() : "");
return {
...rest,
host: "node",
...(node ? { node } : {}),
};
}
function hideNodeExecDynamicToolParameters(
parameters: OpenClawDynamicTool["parameters"],
options: { hideNode: 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 nextProperties = Object.fromEntries(
Object.entries(rawProperties).filter(
([name]) =>
!CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) &&
!(options.hideNode && normalizeCodexDynamicToolName(name) === "node"),
),
);
const rawRequired = schema.required;
const nextRequired = Array.isArray(rawRequired)
? rawRequired.filter(
(name) =>
typeof name !== "string" ||
(!CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) &&
!(options.hideNode && normalizeCodexDynamicToolName(name) === "node")),
)
: rawRequired;
return {
...schema,
properties: nextProperties,
...(Array.isArray(rawRequired) ? { required: nextRequired } : {}),
};
}

View File

@@ -1,4 +1,3 @@
// Codex plugin module implements side question behavior.
import { randomUUID } from "node:crypto";
import {
buildAgentHookContextChannelFields,
@@ -253,8 +252,7 @@ export async function runCodexAppServerSideQuestion(
const appServer = connection.appServer;
const cwd = binding.cwd || params.workspaceDir || process.cwd();
const runId = params.opts?.runId ?? randomUUID();
// A supervised side run inherits capability facts from the private binding.
// Outer model metadata may describe another provider or disable tools entirely.
// Side runs inherit private-binding capabilities, not outer model metadata.
const effectiveParams: AgentHarnessSideQuestionParams = supervisionModelSelection
? {
...params,