mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
* fix(cli): escape claude args and stop aborting on exit in omniroute launch
Windows launches go through spawn(..., { shell: true }), which joins argv
with plain spaces and no escaping (Node DEP0190). Any argument containing a
space was split, so `omniroute launch -p "two words"` reached claude as
`-p two` plus stray positionals, and the prompt was silently truncated.
Escape each argument for cmd.exe instead: CRT argv rules first (double the
backslashes preceding a quote, escape embedded quotes, wrap in quotes), then
cmd metacharacters caret-escaped twice. The second pass is required because
claude.cmd is an npm shim that re-parses %* on the way to node; with a single
pass arguments still truncated at the first `&` or `|`.
The command action also called process.exit() on any non-zero exit. That tore
the loop down while the exited child's inherited stdio handles were still
closing and aborted the process with a libuv assertion
(!(handle->flags & UV_HANDLE_CLOSING), src/win/async.c:94, exit 0xC0000409)
instead of returning claude's exit code. Set process.exitCode and let the
loop drain.
Extracts resolveClaudeSpawn() alongside the existing resolveCodexSpawn()
precedent so both the platform choice and the escaping are unit-testable.
Tests: tests/unit/cli/launch-windows-spawn-args.test.ts (new, 7 tests).
Four pure-function tests plus golden strings pin the exact encoding on every
platform; a Windows-only test round-trips argv through a real npm-style .cmd
shim and asserts embedded quotes, `&`, `|`, `%PATH%`, `^`, `!`, a trailing
backslash and an empty string all arrive byte-identical.
Note: bin/cli/commands/launch-codex.mjs carries the identical defect (same
shell:true concatenation, same process.exit) and is left unchanged here.
* docs(changelog): add fragment for #8837
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
222 lines
8.5 KiB
JavaScript
222 lines
8.5 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { join } from "node:path";
|
|
import os from "node:os";
|
|
import { t } from "../i18n.mjs";
|
|
import { resolveActiveContext } from "../contexts.mjs";
|
|
|
|
function stripTrailingSlash(value) {
|
|
let s = String(value);
|
|
let end = s.length;
|
|
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
|
|
return end === s.length ? s : s.slice(0, end);
|
|
}
|
|
|
|
/**
|
|
* Build a clean child env for Claude Code pointed at OmniRoute.
|
|
*
|
|
* Strips inherited ANTHROPIC_* (avoids a stale shell token leaking through), then
|
|
* injects the base URL, gateway model discovery, and auto-compact window.
|
|
*
|
|
* @param {Record<string,string>} baseEnv
|
|
* @param {number|string} baseUrlOrPort a port (→ http://localhost:<port>) or a full base URL
|
|
* @param {string|undefined} authToken
|
|
* @param {{ configDir?:string, model?:string }} [opts]
|
|
* @returns {Record<string,string>}
|
|
*/
|
|
export function buildClaudeEnv(baseEnv, baseUrlOrPort, authToken, opts = {}) {
|
|
const env = { ...baseEnv };
|
|
for (const key of Object.keys(env)) {
|
|
if (key.startsWith("ANTHROPIC_")) delete env[key];
|
|
}
|
|
|
|
// Accept a bare port (number/numeric string → localhost) or a full base URL.
|
|
// Claude Code wants the ROOT URL (it appends /v1/messages itself) — no /v1 here.
|
|
let baseUrl;
|
|
if (typeof baseUrlOrPort === "number" || /^\d+$/.test(String(baseUrlOrPort))) {
|
|
baseUrl = `http://localhost:${Number(baseUrlOrPort) || 20128}`;
|
|
} else {
|
|
baseUrl = stripTrailingSlash(String(baseUrlOrPort)).replace(/\/v1$/, "");
|
|
}
|
|
|
|
env.ANTHROPIC_BASE_URL = baseUrl;
|
|
// Always set a token: when none is resolved, a sentinel keeps newer Claude Code
|
|
// from stopping at its local login gate before it ever contacts OmniRoute (an
|
|
// open backend ignores the value). Mirrors free-claude-code. ANTHROPIC_API_KEY
|
|
// stays stripped (above) so it can't shadow the Bearer token.
|
|
env.ANTHROPIC_AUTH_TOKEN = (authToken && String(authToken).trim()) || "omniroute-no-auth";
|
|
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
|
|
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000";
|
|
// Profile isolation (Claude Code has no native profiles — CLAUDE_CONFIG_DIR is
|
|
// the idiomatic mechanism: separate settings/credentials/history/cache per dir).
|
|
if (opts.configDir) env.CLAUDE_CONFIG_DIR = opts.configDir;
|
|
if (opts.model) env.ANTHROPIC_MODEL = opts.model;
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Resolve the OmniRoute base URL + auth for launch, honouring (in order):
|
|
* explicit flags → the active context (remote mode) → localhost:<port>.
|
|
* @param {{port?:string, remote?:string, baseUrl?:string, token?:string, apiKey?:string, context?:string}} opts
|
|
* @returns {{ baseUrl:string, authToken:string|undefined }}
|
|
*/
|
|
export function resolveLaunchTarget(opts = {}) {
|
|
const explicit = opts.remote ?? opts.baseUrl;
|
|
let baseUrl;
|
|
if (explicit) {
|
|
baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
|
|
} else {
|
|
let fromCtx;
|
|
try {
|
|
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
|
fromCtx = ctx?.baseUrl;
|
|
} catch {
|
|
/* no context */
|
|
}
|
|
baseUrl = fromCtx
|
|
? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
|
|
: `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
|
}
|
|
|
|
let authToken = opts.token ?? opts.apiKey ?? opts["api-key"];
|
|
if (!authToken) {
|
|
try {
|
|
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
|
authToken = ctx?.accessToken || ctx?.apiKey || undefined;
|
|
} catch {
|
|
/* no context auth */
|
|
}
|
|
}
|
|
if (!authToken) authToken = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.OMNIROUTE_API_KEY;
|
|
return { baseUrl, authToken };
|
|
}
|
|
|
|
/**
|
|
* #8246: on Windows, npm installs claude as a `.cmd` shim — spawn() without a
|
|
* shell cannot resolve PATHEXT shims (and Node refuses to exec `.cmd` directly
|
|
* since CVE-2024-27980), so the Windows path must go through cmd.exe.
|
|
*
|
|
* @param {NodeJS.Platform|string} platform
|
|
* @returns {{ command: string, shell: true|undefined }}
|
|
*/
|
|
export function resolveClaudeSpawn(platform) {
|
|
return platform === "win32"
|
|
? { command: "claude.cmd", shell: true }
|
|
: { command: "claude", shell: undefined };
|
|
}
|
|
|
|
/** cmd.exe metacharacters that stay live inside a quoted argument. */
|
|
const WIN_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
|
|
|
|
/**
|
|
* Escape one argument for a cmd.exe command line built by `shell: true`.
|
|
*
|
|
* Two layers, in order:
|
|
* 1. the CRT argv rules the target binary parses (double the backslashes that
|
|
* precede a quote, escape embedded quotes, wrap in quotes);
|
|
* 2. cmd.exe's metacharacters, caret-escaped — applied TWICE because the
|
|
* target is an npm `.cmd` shim that forwards `%*` to node, so the line is
|
|
* parsed by cmd a second time. Single-escaping truncated any argument at
|
|
* the first `&` or `|`. (Same rule as cross-spawn's doubleEscapeMetaChars.)
|
|
*
|
|
* @param {unknown} arg
|
|
* @returns {string}
|
|
*/
|
|
function escapeWindowsShellArg(arg) {
|
|
const s = String(arg);
|
|
if (s === "") return '""';
|
|
let out = s.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
|
|
out = `"${out}"`;
|
|
return out.replace(WIN_META_CHARS, "^$1").replace(WIN_META_CHARS, "^$1");
|
|
}
|
|
|
|
/**
|
|
* `shell: true` makes Node join argv with plain spaces and no escaping (the
|
|
* DEP0190 warning), so `-p "two words"` used to reach claude as `-p two` plus
|
|
* three stray positional arguments. Quote the args ourselves on that path.
|
|
* Off Windows there is no shell, so argv is passed through untouched.
|
|
*
|
|
* @param {string[]} args
|
|
* @param {NodeJS.Platform|string} platform
|
|
* @returns {string[]}
|
|
*/
|
|
export function quoteClaudeArgs(args, platform) {
|
|
const list = [...(args ?? [])];
|
|
return platform === "win32" ? list.map(escapeWindowsShellArg) : list;
|
|
}
|
|
|
|
/**
|
|
* @param {{port?:string, remote?:string, token?:string, apiKey?:string, profile?:string, claudeHome?:string}} opts
|
|
* @param {string[]} claudeArgs pass-through args for the claude binary
|
|
* @returns {Promise<number>} exit code
|
|
*/
|
|
export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
|
const { baseUrl, authToken } = resolveLaunchTarget(opts);
|
|
|
|
// Health check the (possibly remote) proxy before launching.
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
|
|
signal: AbortSignal.timeout(3000),
|
|
});
|
|
if (!res.ok) throw new Error(`status ${res.status}`);
|
|
} catch {
|
|
console.error(
|
|
(
|
|
t("launch.notRunning") ||
|
|
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
|
|
).replace("{port}", baseUrl)
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
const configDir = opts.profile
|
|
? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
|
|
: undefined;
|
|
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
|
|
|
|
return await new Promise((resolve) => {
|
|
const { command, shell } = resolveClaudeSpawn(process.platform);
|
|
const child = spawn(command, quoteClaudeArgs(claudeArgs, process.platform), {
|
|
env,
|
|
stdio: "inherit",
|
|
shell,
|
|
...(process.platform === "win32" ? { windowsHide: true } : {}),
|
|
});
|
|
child.on("error", (err) => {
|
|
if (err && err.code === "ENOENT") {
|
|
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
|
|
resolve(127);
|
|
} else {
|
|
console.error(String(err?.message || err));
|
|
resolve(1);
|
|
}
|
|
});
|
|
child.on("exit", (code) => resolve(code ?? 0));
|
|
});
|
|
}
|
|
|
|
export function registerLaunch(program) {
|
|
program
|
|
.command("launch")
|
|
.description(
|
|
t("launch.description") || "Launch Claude Code pointed at OmniRoute (local or remote)"
|
|
)
|
|
.option("--port <port>", t("serve.port") || "Proxy port", "20128")
|
|
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
|
|
.option(
|
|
"--profile <name>",
|
|
"Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)"
|
|
)
|
|
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
|
|
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.argument("[claudeArgs...]", "arguments passed through to the claude binary")
|
|
.action(async (claudeArgs, opts) => {
|
|
// process.exit() here aborted the process with a libuv assertion on
|
|
// Windows (`!(handle->flags & UV_HANDLE_CLOSING)`, async.c:94): it tears
|
|
// the loop down while the inherited stdio handles of the just-exited
|
|
// child are still closing. Setting exitCode lets the loop drain first.
|
|
process.exitCode = await runLaunchCommand(opts, claudeArgs ?? []);
|
|
});
|
|
}
|