diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b8d8be05..081d18a96c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ### 🐛 Bug Fixes +- **fix(cli):** `omniroute launch-codex` now spawns `codex.cmd` through a shell on Windows (the npm `.cmd` shim is unresolvable by bare `spawn` → ENOENT), mirroring the qodercli Windows fix (#6263). Regression guard: `tests/unit/launch-codex-windows-spawn-6312.test.ts`. (thanks @swingtempo) - **fix(codex):** isolate the **Spark** quota from the shared Codex quota and stabilize the quota UI ordering / hydration so per-scope limits render consistently. Regression guards: `tests/unit/codex-quota-selection-hydration.test.ts`, `provider-limits-ui.test.ts` + 3 more. (thanks @xz-dev) - **feat(api):** add a `hidePaidModels` setting that filters paid-only models out of the `/v1/models` catalog. Regression guard: `tests/unit/models-catalog-hide-paid.test.ts`. (thanks @chirag127) - **fix(api-manager):** the fallback model picker now preserves combos instead of dropping them when a primary model is unavailable. Regression guard: `tests/unit/api-manager-page-static.test.ts`. (thanks @jmengit) diff --git a/bin/cli/commands/launch-codex.mjs b/bin/cli/commands/launch-codex.mjs index 064654a1ba..34db61d368 100644 --- a/bin/cli/commands/launch-codex.mjs +++ b/bin/cli/commands/launch-codex.mjs @@ -19,6 +19,16 @@ const STRIPPED_CODEX_ENV_KEYS = [ /** Placeholder so codex's `env_key` is always satisfied when the backend is open. */ const NO_AUTH_SENTINEL = "omniroute-no-auth"; +// On Windows the `codex` binary is an npm `.cmd` shim that `spawn` cannot resolve +// without a shell (bare "codex" → ENOENT). Mirror the qodercli Windows fix (#6263): +// spawn `codex.cmd` through a shell on win32, and the bare binary elsewhere. +export function resolveCodexSpawn(platform) { + if (platform === "win32") { + return { command: "codex.cmd", shell: true }; + } + return { command: "codex", shell: undefined }; +} + function stripTrailingSlash(value) { let s = String(value); let end = s.length; @@ -126,10 +136,10 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) { if (!(await healthCheck(baseUrl))) { console.error( - (t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace( - "{port}", - baseUrl - ) + ( + t("launch.notRunning") || + "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'." + ).replace("{port}", baseUrl) ); return 1; } @@ -142,7 +152,12 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) { const env = buildCodexEnv(process.env, authToken); return await new Promise((resolve) => { - const child = spawn("codex", extraArgs, { env, stdio: "inherit" }); + const { command: codexLaunch, shell: shellValue } = resolveCodexSpawn(process.platform); + const child = spawn(codexLaunch, extraArgs, { + env, + stdio: "inherit", + shell: shellValue, + }); child.on("error", (err) => { if (err?.code === "ENOENT") { console.error( @@ -165,10 +180,16 @@ export function registerLaunchCodex(program) { t("launchCodex.description") || "Launch Codex CLI pointed at OmniRoute (local or remote VPS)" ) .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") - .option("--remote ", "Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)") + .option( + "--remote ", + "Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)" + ) .option("--profile ", "Codex profile to activate (passed as --profile )") .option("-p, --p ", "Alias for --profile") - .option("--api-key ", "OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)") + .option( + "--api-key ", + "OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)" + ) .allowUnknownOption(true) .allowExcessArguments(true) .argument("[codexArgs...]", "arguments passed through to the codex binary") diff --git a/tests/unit/launch-codex-windows-spawn-6312.test.ts b/tests/unit/launch-codex-windows-spawn-6312.test.ts new file mode 100644 index 0000000000..a08019e40f --- /dev/null +++ b/tests/unit/launch-codex-windows-spawn-6312.test.ts @@ -0,0 +1,20 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { resolveCodexSpawn } from "../../bin/cli/commands/launch-codex.mjs"; + +// Regression guard for #6312: on Windows the `codex` binary is an npm `.cmd` +// shim that `spawn` cannot resolve without a shell (bare "codex" → ENOENT). +test("resolveCodexSpawn: win32 spawns codex.cmd through a shell", () => { + const { command, shell } = resolveCodexSpawn("win32"); + assert.equal(command, "codex.cmd"); + assert.equal(shell, true); +}); + +test("resolveCodexSpawn: non-Windows platforms spawn the bare binary without a shell", () => { + for (const platform of ["linux", "darwin", "freebsd"]) { + const { command, shell } = resolveCodexSpawn(platform); + assert.equal(command, "codex", `${platform} command`); + assert.equal(shell, undefined, `${platform} shell`); + } +});