Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci-windows-codex.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ jobs:
- name: Install codex CLI
shell: pwsh
run: npm install -g @openai/codex
# #2196 hard gate (deterministic, no model calls — unlike the soft-gated
# verify below): the Windows argv pass-through contract must hold on a real
# machine — full corpus round-trip with the child's RECEIVED argv asserted
# item-by-item, official-npm-layout detection against the real global
# install, and the wrapper's --help booting through plain node.
- name: Windows argv pass-through tests (#2196)
shell: pwsh
run: node --import tsx --test tests/win-cmd-argv.test.ts
# Non-obvious on purpose: do NOT remove continue-on-error until one clean
# run proves codex boots unauthenticated on windows-latest. The transcript
# artifact shows what happened so this can then become a hard gate.
Expand Down
40 changes: 40 additions & 0 deletions CLIENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,46 @@ The #321 budget `-c` args are kept verbatim (embedded mode honors them
identically). If you want the shared background server, run native `codex`
directly — no compression, but tools still work via `bili plugin install codex`.

### Windows launch path: user argv never re-enters cmd.exe (#2196)

On Windows the default npm install puts a `codex.cmd` shim on PATH, and its
`%*` forwarding re-parses every user argument through cmd.exe's LINE parser —
which has no escape mechanism: embedded double quotes split tokens
(`Please say "hello world" exactly` arrived as four arguments), `%VAR%`
expands, `&|<>^()` act as command operators, and empty arguments vanish.
Plain spaced prompts survived, which is why the #679 space-truncation fix did
not expose it. #2196 makes the launcher refuse to feed user argv into that
parser:

- **Resolution order (win32 only):** within each PATH directory the native
`codex.exe` wins over `codex.cmd`/`codex.bat` (earliest directory still wins
overall); a `.cmd`/`.bat` hit is upgraded to `node <official bin/codex.js>`
when it sits beside a trusted npm layout — `<dir>/node_modules/@openai/codex`
whose package.json is named `@openai/codex` with a resolvable `"codex"` bin
entry, AND shim text referencing that package (a hand-written `codex.cmd`
placed beside an unrelated tree must not hijack the launch). Running the
official wrapper under Node reproduces exactly what the shim does — vendor
binary lookup, env init, signal forwarding — while Node's own CreateProcess
argv encoding carries every argument verbatim. Unrecognized layouts
(yarn-classic `.bin` trees, pnpm store shims without the local link, …) keep
the legacy cmd path under the contract below.
- **Pass-through contract:** the remaining cmd-wrapped launches — any client's
`.cmd`/`.bat`/extensionless binary, plus dsh-channel spawns — accept only
argv the line parser can carry verbatim. Anything else (embedded quotes,
`%VAR%`, metacharacters, empty args, line breaks, odd trailing-backslash
runs) fails loudly with an actionable error *before* any process starts,
instead of arriving corrupted or executing unintended commands. Direct-spawn
`.exe` launches are unaffected: Node encodes their argv losslessly itself.
- **Workaround / power-user knob:** `BILI_CLIENT_BIN=<path>` still outranks
everything — point it at the real `codex.exe` (or at a script entry run
under node) to bypass the shim entirely.

Verified on windows-latest CI against the real global `@openai/codex` install
(`tests/win-cmd-argv.test.ts`, hard gate in `ci-windows-codex.yml`): the full
corpus — empty arg, plain spaces, embedded quotes, TOML `-c` values, JSON,
Unicode, trailing backslashes, `%COMSPEC%`, `!VAR!`, `&|<>^()` — arrives at the
child process item-by-item identical to the caller array.

## Gemini family (Gemini CLI / iFlow CLI / Qwen Code / Antigravity)

Four launchers for the gemini-cli architecture family (#1043 tier 1). Three of
Expand Down
10 changes: 10 additions & 0 deletions CLIENTS.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ Codex 是唯一一个插件安装无法自给自足的客户端。接缝矩阵

自 ~0.156 起,Codex 可以附着到(或自动拉起)一台机器全局共享的后台 server,其模型流量用的是 **daemon 启动时**存在的环境变量,而不是会话启动时的。启动器的代理是会话级的(端口随进程消亡),所以长寿 daemon 没法安全地经它路由:若 codex 先于 bili env 启动,之后的 `bili codex` 会话会静默附着上去、**完全绕过压缩**;若 bili 先启动,幸存的 daemon 则一直指向死端口。因此启动器显式传 `--no-daemon` —— 先探测 `codex --help` 是否有该 flag(旧版本二进制原样启动),且仅当用户未自行钉死模式(`--no-daemon` 或 `--remote`)时。结果:确定性的 embedded 运行,无逐次启动的回退警告,无静默绕过。#321 的预算 `-c` 参数原样保留(embedded 模式同等生效)。想要共享后台 server 的话,直接跑原生 `codex` —— 没有压缩,但工具仍可通过 `bili plugin install codex` 使用。

### Windows 启动路径:用户 argv 不再重新进入 cmd.exe(#2196)

Windows 上默认 npm 安装把 `codex.cmd` shim 放上 PATH,它的 `%*` 转发会让每个用户参数再经 **cmd.exe 行解析器**解析一遍——该解析器没有任何转义机制:嵌入双引号拆词(`Please say "hello world" exactly` 到达时变成四个参数)、`%VAR%` 被展开、`&|<>^()` 成为命令操作符、空参数消失。普通带空格提示词恰好能幸存,所以 #679 的空格截断修复没有暴露它。#2196 让启动器拒绝把用户 argv 喂进这个解析器:

- **解析顺序(win32 专属):** 每个 PATH 目录内原生 `codex.exe` 优先于 `codex.cmd`/`codex.bat`(最早目录仍整体优先);`.cmd`/`.bat` 命中若位于可信 npm 布局旁——`<dir>/node_modules/@openai/codex`,package.json name 为 `@openai/codex`、有可解析的 `"codex"` bin 条目、且 shim 文本引用该包(放在无关树旁的手写 `codex.cmd` 不得劫持启动)——则升级为 `node <官方 bin/codex.js>`。Node 下运行官方 wrapper 与 shim 行为完全一致——vendor 二进制查找、env 初始化、信号转发——而 Node 自己的 CreateProcess argv 编码逐字无损携带每个参数。未识别布局(yarn-classic `.bin` 树、无本地链接的 pnpm store shim……)保留旧 cmd 路径并遵循下述契约。
- **透传契约:** 其余 cmd 包装启动——任何客户端的 `.cmd`/`.bat`/无扩展名二进制,加上 dsh-channel spawn——只接受行解析器能逐字携带的 argv;其余(嵌入引号、`%VAR%`、元字符、空参数、换行、奇数尾反斜杠)在**任何进程启动之前**以可操作的错误明确失败,而不是损坏到达或执行非预期命令。直接 spawn 的 `.exe` 不受影响:Node 自身无损编码其 argv。
- **绕行 / power-user 旋钮:** `BILI_CLIENT_BIN=<path>` 仍最高优先——指向真实 `codex.exe`(或经 node 运行的脚本入口)即可完全绕开 shim。

已在 windows-latest CI 上对真实全局 `@openai/codex` 安装验证(`tests/win-cmd-argv.test.ts`,`ci-windows-codex.yml` 硬门禁):完整语料——空参数、普通空格、嵌入引号、TOML `-c` 值、JSON、Unicode、尾反斜杠、`%COMSPEC%`、`!VAR!`、`&|<>^()`——逐项与调用方数组相等地到达子进程。

## Pi(pi.dev coding agent)

Pi 有完整原生模式(`bili plugin install pi`,README 快速上手方案 1);这一节只讲一行表格装不下的内容——**原生拦截实际覆盖哪些模型传输**。pi 是唯一把 WebSocket 模型流量带进环路的宿主。
Expand Down
12 changes: 12 additions & 0 deletions src/dsh-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import path from "node:path";
import { execFile, spawnSync } from "node:child_process";
import { promisify } from "node:util";
import { resolveDshHome } from "./client-config.js";
import { winCmdUnsafeToken, winCmdRefusalError } from "./win-cmd.js";

export const DSH_PACKAGE = "billion-context";
// The profile owned by dsh's own desktop app. Its billion-context copy is
Expand Down Expand Up @@ -262,6 +263,17 @@ export function planDshSpawn(
const base = command.slice(Math.max(command.lastIndexOf("/"), command.lastIndexOf("\\")) + 1);
const needsCmd = lower.endsWith(".cmd") || lower.endsWith(".bat") || !path.extname(base);
if (!needsCmd) return { command, args: [...args] };
// #2196: same safe-set contract as the launcher's comspec path — profile
// names and specs are user-chosen strings that cmd.exe would otherwise
// mangle or interpret silently.
[command, ...args].forEach((token, i) => {
const reason = winCmdUnsafeToken(token);
if (reason) throw winCmdRefusalError(
i === 0 ? "resolved dsh command" : `argument #${i - 1}`,
reason,
"Set BILI_DSH_BIN to the dsh executable, or reword the argument.",
);
});
const comspec = env.COMSPEC?.trim() || "cmd.exe";
const line = `"${[command, ...args].map(quoteWinToken).join(" ")}"`;
return { command: comspec, args: ["/d", "/s", "/c", line], windowsVerbatimArguments: true };
Expand Down
112 changes: 105 additions & 7 deletions src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
import { selfPackageRoot, isBiliPiEntry, ompPluginLoadedFrom, dshNativeInstalled, claudeNativeInstalled } from "./plugin-install.js";
import { applyOmpFirstEventTimeout } from "./agent/native-bootstrap.js";
import { log as teeLog } from "./logger.js";
import { winCmdUnsafeToken, winCmdRefusalError } from "./win-cmd.js";

/** Absolute path of a file inside our dist/, resolved via the package root
* (import.meta.url-based) so it survives global-installed symlink bins
Expand Down Expand Up @@ -4059,10 +4060,10 @@ async function abortLaunchOnBusyOverlay(handle: ProxyHandle, err: OverlayBusyErr

/** #679: quote one token for cmd.exe's line parser. Only whitespace-bearing
* tokens get wrapped in double quotes, so a space-free launch produces a
* byte-identical line to the old shell:true form. A token containing an
* embedded double quote stays bare: cmd.exe has no escape mechanism for
* quotes, so wrapping would only change how it is mangled (today's behavior
* preserved). */
* byte-identical line to the old shell:true form. Pure formatter: callers
* must pre-validate their tokens through the #2196 safe-set guard
* (winCmdUnsafeToken) — an embedded double quote stays bare here only
* because such tokens never reach this path anymore. */
export function quoteWinToken(token: string): string {
if (!/\s/.test(token) || token.includes('"')) return token;
return `"${token}"`;
Expand All @@ -4083,7 +4084,11 @@ export function buildWindowsCommandLine(cmd: string, args: readonly string[]): s
* extension) spawns directly and the OS quotes the executable and argv
* itself, spaces included. shell:true is never used anymore: no DEP0190, no
* cmd.exe re-splitting of spaced paths at their first space (which truncated
* both the command and its args). */
* both the command and its args).
* #2196: the comspec form re-feeds user argv into cmd.exe's LINE parser,
* which has no escape mechanism — so before building the line, every token
* (command included) must pass the safe-set guard; unsafe tokens throw with
* an actionable error instead of being mangled or interpreted silently. */
export function planClientSpawn(
cmd: string,
args: readonly string[],
Expand All @@ -4095,6 +4100,15 @@ export function planClientSpawn(
const base = cmd.slice(Math.max(cmd.lastIndexOf("/"), cmd.lastIndexOf("\\")) + 1);
const needsCmd = lower.endsWith(".cmd") || lower.endsWith(".bat") || !path.extname(base);
if (!needsCmd) return { command: cmd, args: [...args] };
const tokens = [cmd, ...args];
tokens.forEach((token, i) => {
const reason = winCmdUnsafeToken(token);
if (reason) throw winCmdRefusalError(
i === 0 ? "resolved command" : `argument #${i - 1}`,
reason,
"Set BILI_CLIENT_BIN to the native executable (e.g. the real codex.exe) or to a script entry run under node, or reword the argument.",
);
});
const comspec = nonEmpty(env.COMSPEC) ? env.COMSPEC : "cmd.exe";
return {
command: comspec,
Expand Down Expand Up @@ -4146,9 +4160,82 @@ export function isOnPath(name: string, env: NodeJS.ProcessEnv): boolean {
return resolveOnPath(name, env) !== undefined;
}

/** #2196: given a resolved codex .cmd/.bat shim, locate the official npm
* package's JS entry when this is a trusted npm install layout: the shim sits
* beside <dir>/node_modules/@openai/codex whose package.json is named
* "@openai/codex" and declares a resolvable "codex" bin entry, AND the shim
* text references that package (npm-generated shims always do; a hand-written
* codex.cmd placed beside an unrelated tree must not hijack the launch).
* Running that entry under Node reproduces exactly what the shim does — its
* vendor-binary lookup, env init, signal forwarding — while Node's own
* CreateProcess argv encoding carries user arguments verbatim instead of
* cmd.exe mangling them (#2196). Returns undefined for any other layout
* (yarn-classic .bin trees, pnpm store shims without the local node_modules
* link, ...): those keep the legacy cmd path under the safe-set guard. */
export function resolveCodexOfficialJsEntry(shimPath: string): string | undefined {
const pkgDir = path.join(path.dirname(shimPath), "node_modules", "@openai", "codex");
let pkgJson: unknown;
try {
pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
} catch {
return undefined;
}
if (!pkgJson || typeof pkgJson !== "object") return undefined;
const meta = pkgJson as { name?: unknown; bin?: unknown };
if (meta.name !== "@openai/codex") return undefined;
let rel: string | undefined;
if (typeof meta.bin === "string") rel = meta.bin;
else if (meta.bin && typeof meta.bin === "object" && !Array.isArray(meta.bin)) {
const v = (meta.bin as Record<string, unknown>)["codex"];
if (typeof v === "string") rel = v;
}
if (!rel) return undefined;
const entry = path.resolve(pkgDir, rel);
try {
if (!fs.statSync(entry).isFile()) return undefined;
} catch {
return undefined;
}
try {
const shimText = fs.readFileSync(shimPath, "utf8").slice(0, 65536);
if (!shimText.includes("@openai\\codex") && !shimText.includes("@openai/codex")) return undefined;
} catch {
return undefined;
}
return entry;
}

/** #2196: Windows-only codex resolution that never routes through cmd.exe's
* line parser when it can be avoided: per PATH directory the native
* codex.exe wins over codex.cmd/codex.bat (earliest directory still wins
* overall), and a .cmd/.bat hit is upgraded to `node <official bin/codex.js>`
* whenever the trusted npm layout is present. Returns undefined when no
* codex.{exe,cmd,bat} exists anywhere — the caller falls through to the
* generic resolution (which also covers the extensionless case). */
function resolveCodexWin32(env: NodeJS.ProcessEnv): { command: string; prefixArgs: string[] } | undefined {
const p = env.PATH;
if (!p) return undefined;
for (const dir of p.split(path.delimiter)) {
if (!dir) continue;
for (const ext of [".exe", ".cmd", ".bat"]) {
const f = path.join(dir, "codex" + ext);
try {
if (fs.existsSync(f) && fs.statSync(f).isFile()) {
if (ext === ".exe") return { command: f, prefixArgs: [] };
const entry = resolveCodexOfficialJsEntry(f);
if (entry) return { command: process.execPath, prefixArgs: [entry] };
return { command: f, prefixArgs: [] };
}
} catch {}
}
}
return undefined;
}

export function resolveClientCommand(
client: ClientName,
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform = process.platform,
): { command: string; prefixArgs: string[] } {
const binOverride = env.BILI_CLIENT_BIN?.trim();
if (binOverride) {
Expand Down Expand Up @@ -4223,6 +4310,17 @@ export function resolveClientCommand(
}
return { command: base, prefixArgs: [] };
}
if (client === "codex" && platform === "win32") {
// #2196: the default npm install is a codex.cmd shim whose %*
// forwarding re-parses every user argument through cmd.exe (embedded
// quotes split tokens, %VAR% expands, &|<>^() execute). Prefer forms
// that never enter that parser; a leftover .cmd/.bat only reaches
// cmd.exe when neither exists — where the safe-set guard in
// planClientSpawn then refuses argv it cannot carry verbatim instead
// of corrupting it silently.
const win32Hit = resolveCodexWin32(env);
if (win32Hit) return win32Hit;
}
const resolved = resolveOnPath(client, env);
return { command: resolved ?? client, prefixArgs: [] };
}
Expand Down Expand Up @@ -4756,7 +4854,7 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {}
env = buildCodexEnv(origin, codexCaPath, stripInheritedProxy(process.env));
clientArgs = buildCodexArgs(origin, routes.httpRewrites, routes.httpsRewrites, clientArgs);
if (!codexRunModePinned(params.clientArgs)) {
const { command: codexBin, prefixArgs: codexPrefix } = resolveClientCommand(base, process.env);
const { command: codexBin, prefixArgs: codexPrefix } = resolveClientCommand(base, process.env, deps.platform ?? process.platform);
if (codexSupportsNoDaemon(codexBin, codexPrefix, deps.platform ?? process.platform)) {
clientArgs = ["--no-daemon", ...clientArgs];
console.error("bili: codex pinned to embedded mode (--no-daemon) — the launcher proxy is session-scoped; a shared background server would outlive it and bypass compression.");
Expand Down Expand Up @@ -4867,7 +4965,7 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {}
}
}

const { command, prefixArgs } = resolveClientCommand(base, process.env);
const { command, prefixArgs } = resolveClientCommand(base, process.env, deps.platform ?? process.platform);
const effectiveClientArgs = piTestArgs(params.client, clientArgs);
let code = 0;
try {
Expand Down
Loading
Loading