Skip to content

Commit 03125c8

Browse files
TurboTheTurtleclawsweeper[bot]Takhoffman
authored
Validate Codex app-server command overrides (#84417)
Summary: - The PR rejects Codex app-server command overrides that embed Node/package-manager inline arguments, adds matching doctor diagnostics, regression tests, and a changelog entry. - Reproducibility: yes. for the scoped malformed override path: current main passes the combined command strin ... ix resolver/doctor live output. I did not establish a live Windows npm-global managed-startup reproduction. Automerge notes: - PR branch already contained follow-up commit before automerge: Validate Codex app-server command overrides Validation: - ClawSweeper review passed for head 966bcd6. - Required merge gates passed before the squash merge. Prepared head SHA: 966bcd6 Review: #84417 (comment) Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent 62a330e commit 03125c8

8 files changed

Lines changed: 221 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424

2525
### Fixes
2626

27+
- Codex app-server: reject command overrides that embed Node or package-manager arguments and point users to `appServer.args`, so Windows startup avoids shell parsing failures. (#84417) Thanks @TurboTheTurtle.
2728
- Agents/Copilot: drop unsafe GitHub Copilot Responses reasoning replay items before send so Telegram direct sessions no longer fail on overlong replay IDs. Fixes #85197. (#85198) Thanks @galiniliev.
2829
- UI: add accessible tooltips to the topbar color-mode buttons so System, Light, and Dark choices are labeled on hover and focus. (#85227) Thanks @amknight.
2930
- fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,38 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
619619
);
620620
});
621621

622+
it("rejects Codex app-server command overrides that include inline arguments", () => {
623+
expect(() =>
624+
resolveRuntimeForTest({
625+
pluginConfig: {
626+
appServer: {
627+
command: "node C:\\Users\\me\\.openclaw\\npm\\node_modules\\@openai\\codex\\bin\\codex.js",
628+
},
629+
},
630+
}),
631+
).toThrow(
632+
"plugins.entries.codex.config.appServer.command must be only the Codex app-server executable path",
633+
);
634+
expect(() =>
635+
resolveRuntimeForTest({
636+
pluginConfig: {},
637+
env: {
638+
OPENCLAW_CODEX_APP_SERVER_BIN:
639+
"node C:\\Users\\me\\.openclaw\\npm\\node_modules\\@openai\\codex\\bin\\codex.js",
640+
},
641+
}),
642+
).toThrow("OPENCLAW_CODEX_APP_SERVER_BIN must be only the Codex app-server executable path");
643+
});
644+
645+
it("preserves executable paths that contain spaces", () => {
646+
const runtime = resolveRuntimeForTest({
647+
pluginConfig: { appServer: { command: "C:\\Program Files\\OpenAI Codex\\codex.exe" } },
648+
env: {},
649+
});
650+
651+
expect(runtime.start.command).toBe("C:\\Program Files\\OpenAI Codex\\codex.exe");
652+
});
653+
622654
it("resolves Computer Use setup from plugin config and environment fallbacks", () => {
623655
expect(
624656
resolveCodexComputerUseConfig({

extensions/codex/src/app-server/config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createHmac, randomBytes } from "node:crypto";
22
import { readFileSync } from "node:fs";
33
import { hostname as readHostName } from "node:os";
4+
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
45
import { z } from "zod";
56
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
67

@@ -305,6 +306,27 @@ export function isCodexSandboxExecServerEnabled(pluginConfig?: unknown): boolean
305306
return readCodexPluginConfig(pluginConfig).appServer?.experimental?.sandboxExecServer === true;
306307
}
307308

309+
function assertCodexAppServerCommandHasNoInlineArgs(params: {
310+
command: string;
311+
source: CodexAppServerCommandSource;
312+
}): void {
313+
const inlineArgs = detectWindowsSpawnCommandInlineArgs(params.command);
314+
if (!inlineArgs) {
315+
return;
316+
}
317+
const sourceLabel =
318+
params.source === "env"
319+
? "OPENCLAW_CODEX_APP_SERVER_BIN"
320+
: "plugins.entries.codex.config.appServer.command";
321+
const argsLabel =
322+
params.source === "env"
323+
? "OPENCLAW_CODEX_APP_SERVER_ARGS"
324+
: "plugins.entries.codex.config.appServer.args";
325+
throw new Error(
326+
`${sourceLabel} must be only the Codex app-server executable path; "${inlineArgs.executable}" was configured with inline arguments "${inlineArgs.arguments}". Move those arguments to ${argsLabel}, or remove the override to use the managed Codex startup path.`,
327+
);
328+
}
329+
308330
export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodexPluginsPolicy {
309331
const config = readCodexPluginConfig(pluginConfig).codexPlugins;
310332
const configured = config !== undefined;
@@ -356,6 +378,9 @@ export function resolveCodexAppServerRuntimeOptions(
356378
: envCommand
357379
? "env"
358380
: "managed";
381+
if (commandSource === "config" || commandSource === "env") {
382+
assertCodexAppServerCommandHasNoInlineArgs({ command, source: commandSource });
383+
}
359384
const args = resolveArgs(config.args, env.OPENCLAW_CODEX_APP_SERVER_ARGS);
360385
const headers = normalizeHeaders(config.headers);
361386
const clearEnv = normalizeStringList(config.clearEnv);

extensions/codex/src/app-server/transport-stdio.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,19 @@ describe("resolveCodexAppServerSpawnInvocation", () => {
8888
windowsHide: true,
8989
});
9090
});
91+
92+
it("rejects Windows Codex app-server commands that include inline script arguments", () => {
93+
expect(() =>
94+
resolveCodexAppServerSpawnInvocation(
95+
startOptions("node C:\\Users\\me\\.openclaw\\npm\\node_modules\\@openai\\codex\\bin\\codex.js"),
96+
{
97+
platform: "win32",
98+
env: {},
99+
execPath: "C:\\node\\node.exe",
100+
},
101+
),
102+
).toThrow("Windows spawn command must be an executable path only");
103+
});
91104
});
92105

93106
describe("resolveCodexAppServerSpawnEnv", () => {

src/commands/doctor/shared/codex-route-warnings.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,34 @@ describe("collectCodexRouteWarnings", () => {
134134
expect(warnings).toStrictEqual([]);
135135
});
136136

137+
it("warns when Codex app-server command includes inline arguments", () => {
138+
const warnings = collectCodexRouteWarnings({
139+
cfg: {
140+
plugins: {
141+
entries: {
142+
codex: {
143+
enabled: true,
144+
config: {
145+
appServer: {
146+
command:
147+
"node C:\\Users\\me\\.openclaw\\npm\\node_modules\\@openai\\codex\\bin\\codex.js",
148+
},
149+
},
150+
},
151+
},
152+
},
153+
} as unknown as OpenClawConfig,
154+
});
155+
156+
expect(warnings).toStrictEqual([
157+
[
158+
"- Codex app-server command override includes inline arguments.",
159+
'- plugins.entries.codex.config.appServer.command: "node C:\\Users\\me\\.openclaw\\npm\\node_modules\\@openai\\codex\\bin\\codex.js" starts with "node" and embeds "C:\\Users\\me\\.openclaw\\npm\\node_modules\\@openai\\codex\\bin\\codex.js". The command field must be only the executable path.',
160+
"- Remove the override to use managed Codex startup, or move script/options to plugins.entries.codex.config.appServer.args.",
161+
].join("\n"),
162+
]);
163+
});
164+
137165
it("warns when Codex runtime routes are configured while the Codex plugin is disabled", () => {
138166
const warnings = collectCodexRouteWarnings({
139167
cfg: {

src/commands/doctor/shared/codex-route-warnings.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { resolveAllAgentSessionStoreTargetsSync } from "../../../config/sessions
1313
import type { SessionEntry } from "../../../config/sessions/types.js";
1414
import type { AgentRuntimePolicyConfig } from "../../../config/types.agents-shared.js";
1515
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
16+
import { detectWindowsSpawnCommandInlineArgs } from "../../../plugin-sdk/windows-spawn.js";
1617
import { normalizeAgentId } from "../../../routing/session-key.js";
1718

1819
type CodexRouteHit = {
@@ -2427,6 +2428,29 @@ function formatDisabledCodexPluginWarning(params: {
24272428
].join("\n");
24282429
}
24292430

2431+
function collectCodexAppServerCommandWarnings(cfg: OpenClawConfig): string[] {
2432+
const plugins = asMutableRecord(cfg.plugins);
2433+
const entries = asMutableRecord(plugins?.entries);
2434+
const codex = asMutableRecord(entries?.codex);
2435+
const config = asMutableRecord(codex?.config);
2436+
const appServer = asMutableRecord(config?.appServer);
2437+
const command = typeof appServer?.command === "string" ? appServer.command.trim() : "";
2438+
if (!command) {
2439+
return [];
2440+
}
2441+
const inlineArgs = detectWindowsSpawnCommandInlineArgs(command);
2442+
if (!inlineArgs) {
2443+
return [];
2444+
}
2445+
return [
2446+
[
2447+
"- Codex app-server command override includes inline arguments.",
2448+
`- plugins.entries.codex.config.appServer.command: "${command}" starts with "${inlineArgs.executable}" and embeds "${inlineArgs.arguments}". The command field must be only the executable path.`,
2449+
"- Remove the override to use managed Codex startup, or move script/options to plugins.entries.codex.config.appServer.args.",
2450+
].join("\n"),
2451+
];
2452+
}
2453+
24302454
export function collectCodexRouteWarnings(params: {
24312455
cfg: OpenClawConfig;
24322456
env?: NodeJS.ProcessEnv;
@@ -2458,6 +2482,7 @@ export function collectCodexRouteWarnings(params: {
24582482
ignoreLegacyAgentRuntimePins,
24592483
});
24602484
const warnings: string[] = [];
2485+
warnings.push(...collectCodexAppServerCommandWarnings(params.cfg));
24612486
if (hits.length > 0) {
24622487
warnings.push(
24632488
[

src/plugin-sdk/windows-spawn.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,33 @@ const { createTempDir } = createPluginSdkTestHarness({
1212
});
1313

1414
describe("resolveWindowsSpawnProgram", () => {
15+
it("rejects node command strings that include inline entrypoint arguments on Windows", () => {
16+
expect(() =>
17+
resolveWindowsSpawnProgram({
18+
command: "node C:\\Users\\me\\.openclaw\\npm\\node_modules\\@openai\\codex\\bin\\codex.js",
19+
platform: "win32",
20+
env: {},
21+
execPath: "C:\\node\\node.exe",
22+
}),
23+
).toThrow("Windows spawn command must be an executable path only");
24+
});
25+
26+
it("allows executable paths with spaces on Windows", () => {
27+
const resolved = resolveWindowsSpawnProgram({
28+
command: "C:\\Program Files\\OpenAI Codex\\codex.exe",
29+
platform: "win32",
30+
env: {},
31+
execPath: "C:\\node\\node.exe",
32+
});
33+
34+
expect(resolved).toEqual({
35+
command: "C:\\Program Files\\OpenAI Codex\\codex.exe",
36+
leadingArgv: [],
37+
resolution: "direct",
38+
windowsHide: undefined,
39+
});
40+
});
41+
1542
it("fails closed by default for unresolved windows wrappers", async () => {
1643
const dir = await createTempDir("openclaw-windows-spawn-test-");
1744
const shimPath = path.join(dir, "wrapper.cmd");

src/plugin-sdk/windows-spawn.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,27 @@ export type ResolveWindowsSpawnProgramCandidateParams = Omit<
4848
ResolveWindowsSpawnProgramParams,
4949
"allowShellFallback"
5050
>;
51+
export type WindowsSpawnCommandInlineArgs = {
52+
executable: string;
53+
arguments: string;
54+
};
55+
56+
const INLINE_ARGUMENT_EXECUTABLES = new Set([
57+
"node",
58+
"node.exe",
59+
"npm",
60+
"npm.cmd",
61+
"npm.exe",
62+
"npx",
63+
"npx.cmd",
64+
"npx.exe",
65+
"pnpm",
66+
"pnpm.cmd",
67+
"pnpm.exe",
68+
"yarn",
69+
"yarn.cmd",
70+
"yarn.exe",
71+
]);
5172

5273
function isFilePath(candidate: string): boolean {
5374
try {
@@ -57,6 +78,49 @@ function isFilePath(candidate: string): boolean {
5778
}
5879
}
5980

81+
function readCommandToken(command: string): { token: string; rest: string } | null {
82+
const trimmed = command.trim();
83+
if (!trimmed) {
84+
return null;
85+
}
86+
if (trimmed.startsWith('"')) {
87+
const closeIndex = trimmed.indexOf('"', 1);
88+
if (closeIndex <= 0) {
89+
return null;
90+
}
91+
return {
92+
token: trimmed.slice(1, closeIndex),
93+
rest: trimmed.slice(closeIndex + 1).trim(),
94+
};
95+
}
96+
const match = trimmed.match(/^(\S+)\s+(.+)$/);
97+
if (!match) {
98+
return null;
99+
}
100+
return {
101+
token: match[1] ?? "",
102+
rest: (match[2] ?? "").trim(),
103+
};
104+
}
105+
106+
export function detectWindowsSpawnCommandInlineArgs(
107+
command: string,
108+
): WindowsSpawnCommandInlineArgs | null {
109+
const parsed = readCommandToken(command);
110+
if (!parsed?.rest) {
111+
return null;
112+
}
113+
const normalizedToken = parsed.token.replace(/\\/g, "/");
114+
const executable = normalizeLowercaseStringOrEmpty(path.posix.basename(normalizedToken));
115+
if (!INLINE_ARGUMENT_EXECUTABLES.has(executable)) {
116+
return null;
117+
}
118+
return {
119+
executable: parsed.token,
120+
arguments: parsed.rest,
121+
};
122+
}
123+
60124
/** Resolve a Windows command name through PATH and PATHEXT so wrapper inspection sees the real file. */
61125
export function resolveWindowsExecutablePath(command: string, env: NodeJS.ProcessEnv): string {
62126
if (command.includes("/") || command.includes("\\") || path.isAbsolute(command)) {
@@ -214,6 +278,12 @@ export function resolveWindowsSpawnProgramCandidate(
214278
resolution: "direct",
215279
};
216280
}
281+
const inlineArgs = detectWindowsSpawnCommandInlineArgs(params.command);
282+
if (inlineArgs) {
283+
throw new Error(
284+
`Windows spawn command must be an executable path only; "${inlineArgs.executable}" was configured with inline arguments "${inlineArgs.arguments}". Put arguments in the caller's args array instead.`,
285+
);
286+
}
217287

218288
const resolvedCommand = resolveWindowsExecutablePath(params.command, env);
219289
const ext = normalizeLowercaseStringOrEmpty(path.extname(resolvedCommand));

0 commit comments

Comments
 (0)