Skip to content

Commit 1d7aabd

Browse files
committed
fix(codex): keep node exec policy private
1 parent c9cb49b commit 1d7aabd

8 files changed

Lines changed: 286 additions & 124 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveCodexNativeExecutionPolicy } from "./native-execution-policy.js";
3+
4+
describe("resolveCodexNativeExecutionPolicy", () => {
5+
it("allows Codex native execution for gateway exec hosts", () => {
6+
expect(
7+
resolveCodexNativeExecutionPolicy({
8+
config: { tools: { exec: { host: "gateway" } } },
9+
sessionKey: "session-1",
10+
}),
11+
).toMatchObject({
12+
nativeToolSurfaceAllowed: true,
13+
requestedExecHost: "gateway",
14+
effectiveExecHost: "gateway",
15+
});
16+
});
17+
18+
it("resolves auto to gateway when no sandbox is active", () => {
19+
expect(
20+
resolveCodexNativeExecutionPolicy({
21+
config: { tools: { exec: { host: "auto" } } },
22+
sessionKey: "session-1",
23+
sandboxAvailable: false,
24+
}),
25+
).toMatchObject({
26+
nativeToolSurfaceAllowed: true,
27+
requestedExecHost: "auto",
28+
effectiveExecHost: "gateway",
29+
});
30+
});
31+
32+
it("resolves auto to sandbox when a sandbox is active", () => {
33+
expect(
34+
resolveCodexNativeExecutionPolicy({
35+
config: { tools: { exec: { host: "auto" } } },
36+
sessionKey: "session-1",
37+
sandboxAvailable: true,
38+
}),
39+
).toMatchObject({
40+
nativeToolSurfaceAllowed: true,
41+
requestedExecHost: "auto",
42+
effectiveExecHost: "sandbox",
43+
});
44+
});
45+
46+
it("disables Codex native execution when exec host resolves to node", () => {
47+
expect(
48+
resolveCodexNativeExecutionPolicy({
49+
config: { tools: { exec: { host: "node", node: "worker-1" } } },
50+
sessionKey: "session-1",
51+
}),
52+
).toMatchObject({
53+
nativeToolSurfaceAllowed: false,
54+
requestedExecHost: "node",
55+
effectiveExecHost: "node",
56+
node: "worker-1",
57+
});
58+
});
59+
60+
it("honors per-attempt node exec overrides before config defaults", () => {
61+
expect(
62+
resolveCodexNativeExecutionPolicy({
63+
config: { tools: { exec: { host: "gateway" } } },
64+
sessionKey: "session-1",
65+
execOverrides: { host: "node", node: "worker-2" },
66+
}),
67+
).toMatchObject({
68+
nativeToolSurfaceAllowed: false,
69+
requestedExecHost: "node",
70+
effectiveExecHost: "node",
71+
node: "worker-2",
72+
});
73+
});
74+
75+
it("honors persisted session node exec hosts before config defaults", () => {
76+
expect(
77+
resolveCodexNativeExecutionPolicy({
78+
config: { tools: { exec: { host: "gateway" } } },
79+
sessionKey: "session-1",
80+
sessionEntry: { execHost: "node", execNode: "worker-3" } as never,
81+
}),
82+
).toMatchObject({
83+
nativeToolSurfaceAllowed: false,
84+
requestedExecHost: "node",
85+
effectiveExecHost: "node",
86+
node: "worker-3",
87+
});
88+
});
89+
90+
it("honors agent exec config before global exec config", () => {
91+
expect(
92+
resolveCodexNativeExecutionPolicy({
93+
config: {
94+
tools: { exec: { host: "gateway" } },
95+
agents: { list: [{ id: "main", tools: { exec: { host: "node", node: "worker-4" } } }] },
96+
},
97+
sessionKey: "agent:main:session-1",
98+
}),
99+
).toMatchObject({
100+
nativeToolSurfaceAllowed: false,
101+
requestedExecHost: "node",
102+
effectiveExecHost: "node",
103+
node: "worker-4",
104+
});
105+
});
106+
});
Lines changed: 132 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
2-
import { resolveRuntimeExecDefaults } from "openclaw/plugin-sdk/agent-runtime";
31
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
42
import { resolveSandboxRuntimeStatus } from "openclaw/plugin-sdk/sandbox";
3+
import { getSessionEntry, type SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
54

65
type ExecHost = "sandbox" | "gateway" | "node";
76
type ExecTarget = "auto" | ExecHost;
8-
type ExecHostOverride = Pick<
9-
NonNullable<EmbeddedRunAttemptParams["execOverrides"]>,
10-
"host" | "node"
11-
>;
7+
8+
type ExecHostOverride = {
9+
host?: string;
10+
node?: string;
11+
};
12+
13+
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
14+
15+
const DEFAULT_AGENT_ID = "main";
16+
const VALID_AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
17+
const INVALID_AGENT_ID_CHARS_PATTERN = /[^a-z0-9_-]+/g;
18+
const LEADING_DASH_PATTERN = /^-+/;
19+
const TRAILING_DASH_PATTERN = /-+$/;
1220

1321
export type CodexNativeExecutionPolicy = {
1422
nativeToolSurfaceAllowed: boolean;
@@ -20,34 +28,44 @@ export type CodexNativeExecutionPolicy = {
2028

2129
export function resolveCodexNativeExecutionPolicy(params: {
2230
config?: OpenClawConfig;
31+
sessionEntry?: SessionEntry;
2332
sessionKey?: string;
2433
sessionId?: string;
2534
agentId?: string;
2635
execOverrides?: ExecHostOverride;
2736
sandboxAvailable?: boolean;
2837
readRuntimeSessionEntry?: boolean;
2938
}): CodexNativeExecutionPolicy {
30-
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim();
31-
const sandboxAvailable = resolveSandboxAvailable({
32-
config: params.config,
33-
sessionKey,
34-
sandboxAvailable: params.sandboxAvailable,
35-
});
36-
const defaults = resolveRuntimeExecDefaults({
37-
cfg: params.config,
38-
agentId: params.agentId,
39-
sessionKey,
40-
sandboxAvailable,
41-
readRuntimeSessionEntry: params.readRuntimeSessionEntry,
42-
});
43-
const requestedExecHost = params.execOverrides?.host ?? defaults.host;
44-
const effectiveExecHost = resolveEffectiveHost({
39+
const config = params.config ?? {};
40+
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim() || undefined;
41+
const sessionEntry =
42+
params.sessionEntry ??
43+
(params.readRuntimeSessionEntry && sessionKey
44+
? readRuntimeSessionEntryBestEffort(sessionKey)
45+
: undefined);
46+
const sandboxAvailable =
47+
params.sandboxAvailable ??
48+
(sessionKey
49+
? resolveSandboxRuntimeStatus({
50+
cfg: config,
51+
sessionKey,
52+
}).sandboxed
53+
: false);
54+
const agentId = resolvePolicyAgentId({ config, sessionKey, agentId: params.agentId });
55+
const agentExec = resolvePolicyAgentExec({ config, agentId });
56+
const globalExec = config.tools?.exec;
57+
const requestedExecHost =
58+
normalizeExecTarget(params.execOverrides?.host) ??
59+
normalizeExecTarget(sessionEntry?.execHost) ??
60+
normalizeExecTarget(agentExec?.host) ??
61+
normalizeExecTarget(globalExec?.host) ??
62+
"auto";
63+
const effectiveExecHost = resolveEffectiveExecHost({
4564
requestedExecHost,
46-
defaultExecHost: defaults.host,
47-
defaultEffectiveHost: defaults.effectiveHost,
4865
sandboxAvailable,
4966
});
50-
const node = params.execOverrides?.node ?? defaults.node;
67+
const node =
68+
params.execOverrides?.node ?? sessionEntry?.execNode ?? agentExec?.node ?? globalExec?.node;
5169
if (effectiveExecHost !== "node") {
5270
return {
5371
nativeToolSurfaceAllowed: true,
@@ -78,34 +96,101 @@ export function formatCodexNativeNodeExecBlock(params: {
7896
].join(" ");
7997
}
8098

81-
function resolveEffectiveHost(params: {
99+
function resolvePolicyAgentId(params: {
100+
config: OpenClawConfig;
101+
sessionKey?: string;
102+
agentId?: string;
103+
}): string {
104+
const explicitAgentId = normalizeAgentIdOrDefault(params.agentId);
105+
if (explicitAgentId) {
106+
return explicitAgentId;
107+
}
108+
const sessionAgentId = parseAgentIdFromSessionKey(params.sessionKey);
109+
if (sessionAgentId) {
110+
return sessionAgentId;
111+
}
112+
const agents = listAgentEntries(params.config);
113+
const defaultEntry = agents.find((entry) => entry?.default) ?? agents[0];
114+
return normalizeAgentId(defaultEntry?.id);
115+
}
116+
117+
function resolvePolicyAgentExec(params: {
118+
config: OpenClawConfig;
119+
agentId: string;
120+
}): ExecHostOverride | undefined {
121+
return listAgentEntries(params.config).find(
122+
(entry) => normalizeAgentId(entry?.id) === params.agentId,
123+
)?.tools?.exec;
124+
}
125+
126+
function listAgentEntries(config: OpenClawConfig): AgentEntry[] {
127+
return (config.agents?.list ?? []).filter(
128+
(entry): entry is AgentEntry => entry !== null && typeof entry === "object",
129+
);
130+
}
131+
132+
function parseAgentIdFromSessionKey(sessionKey?: string): string | undefined {
133+
const raw = sessionKey?.trim();
134+
if (!raw) {
135+
return undefined;
136+
}
137+
const parts = raw.toLowerCase().split(":").filter(Boolean);
138+
if (parts.length < 3 || parts[0] !== "agent" || !parts[2]) {
139+
return undefined;
140+
}
141+
return normalizeAgentIdOrDefault(parts[1]);
142+
}
143+
144+
function normalizeAgentIdOrDefault(value?: string | null): string | undefined {
145+
const normalized = normalizeAgentId(value);
146+
return normalized === DEFAULT_AGENT_ID && !(value ?? "").trim() ? undefined : normalized;
147+
}
148+
149+
function normalizeAgentId(value?: string | null): string {
150+
const trimmed = (value ?? "").trim();
151+
if (!trimmed) {
152+
return DEFAULT_AGENT_ID;
153+
}
154+
const normalized = trimmed.toLowerCase();
155+
if (VALID_AGENT_ID_PATTERN.test(trimmed)) {
156+
return normalized;
157+
}
158+
return (
159+
normalized
160+
.replace(INVALID_AGENT_ID_CHARS_PATTERN, "-")
161+
.replace(LEADING_DASH_PATTERN, "")
162+
.replace(TRAILING_DASH_PATTERN, "")
163+
.slice(0, 64) || DEFAULT_AGENT_ID
164+
);
165+
}
166+
167+
function normalizeExecTarget(value?: string | null): ExecTarget | undefined {
168+
const normalized = value?.trim().toLowerCase();
169+
if (
170+
normalized === "auto" ||
171+
normalized === "sandbox" ||
172+
normalized === "gateway" ||
173+
normalized === "node"
174+
) {
175+
return normalized;
176+
}
177+
return undefined;
178+
}
179+
180+
function resolveEffectiveExecHost(params: {
82181
requestedExecHost: ExecTarget;
83-
defaultExecHost: ExecTarget;
84-
defaultEffectiveHost: ExecHost;
85182
sandboxAvailable: boolean;
86183
}): ExecHost {
87-
if (params.requestedExecHost !== "auto") {
88-
return params.requestedExecHost;
89-
}
90-
if (params.defaultExecHost === "auto") {
91-
return params.defaultEffectiveHost;
184+
if (params.requestedExecHost === "auto") {
185+
return params.sandboxAvailable ? "sandbox" : "gateway";
92186
}
93-
return params.sandboxAvailable ? "sandbox" : "gateway";
187+
return params.requestedExecHost;
94188
}
95189

96-
function resolveSandboxAvailable(params: {
97-
config?: OpenClawConfig;
98-
sessionKey?: string;
99-
sandboxAvailable?: boolean;
100-
}): boolean {
101-
if (params.sandboxAvailable !== undefined) {
102-
return params.sandboxAvailable;
103-
}
104-
if (!params.sessionKey) {
105-
return false;
190+
function readRuntimeSessionEntryBestEffort(sessionKey: string): SessionEntry | undefined {
191+
try {
192+
return getSessionEntry({ sessionKey });
193+
} catch {
194+
return undefined;
106195
}
107-
return resolveSandboxRuntimeStatus({
108-
cfg: params.config,
109-
sessionKey: params.sessionKey,
110-
}).sandboxed;
111196
}

extensions/codex/src/app-server/request.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ describe("requestCodexAppServerJson sandbox guard", () => {
3030
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
3131
});
3232

33+
it("fails closed before raw app-server bypass methods when exec host=node is active", async () => {
34+
await expect(
35+
requestCodexAppServerJson({
36+
method: "command/exec",
37+
requestParams: { command: ["sh", "-lc", "id"] },
38+
config: { tools: { exec: { host: "node", node: "worker-1" } } },
39+
sessionKey: "node-session",
40+
}),
41+
).rejects.toThrow(
42+
"Codex-native app-server method `command/exec` is unavailable because OpenClaw exec host=node is active for this session.",
43+
);
44+
45+
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
46+
});
47+
3348
it("allows metadata methods in sandboxed sessions", async () => {
3449
const request = vi.fn(async () => ({ ok: true }));
3550
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
@@ -124,4 +139,27 @@ describe("requestCodexAppServerJson sandbox guard", () => {
124139

125140
expect(request).toHaveBeenCalledWith("thread/start", params, { timeoutMs: 60_000 });
126141
});
142+
143+
it("blocks thread starts with sandbox environments when exec host=node is active", async () => {
144+
const params = {
145+
cwd: "/workspace",
146+
environments: [{ environmentId: "openclaw-sandbox-abc123", cwd: "/workspace" }],
147+
};
148+
149+
await expect(
150+
requestCodexAppServerJson({
151+
method: "thread/start",
152+
requestParams: params,
153+
config: {
154+
agents: { defaults: { sandbox: { mode: "all" } } },
155+
tools: { exec: { host: "node", node: "worker-1" } },
156+
},
157+
sessionKey: "node-session",
158+
}),
159+
).rejects.toThrow(
160+
"Codex-native app-server method `thread/start` is unavailable because OpenClaw exec host=node is active for this session.",
161+
);
162+
163+
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
164+
});
127165
});

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -948,12 +948,17 @@ export async function runCodexAppServerAttempt(
948948
: resolveCodexAppServerEnvApiKeyCacheKey({
949949
startOptions: appServer.start,
950950
});
951+
const nodeExecBlocksNativeExecution = isCodexNativeExecutionBlockedByNodeExecHost(params, {
952+
agentId: sessionAgentId,
953+
runtimeSessionKey: sandboxSessionKey,
954+
sandbox,
955+
});
951956
const bundleMcpThreadConfig = await loadCodexBundleMcpThreadConfig({
952957
workspaceDir: effectiveWorkspace,
953958
cfg: params.config,
954959
toolsEnabled: supportsModelTools(params.model),
955960
disableTools: params.disableTools,
956-
toolsAllow: params.toolsAllow,
961+
toolsAllow: nodeExecBlocksNativeExecution ? [] : params.toolsAllow,
957962
});
958963
const sandboxExecServerEnabled = isCodexSandboxExecServerEnabled(pluginConfig);
959964
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params, sandbox, {

0 commit comments

Comments
 (0)