From f5fedad88f1c1dcccd02bba020a14c21572819ce Mon Sep 17 00:00:00 2001 From: qcq01083097 Date: Mon, 19 Oct 2026 10:09:02 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=20Windows?= =?UTF-8?q?=20=E6=89=93=E5=BC=80=E7=99=BB=E5=BD=95=20URL=20=E6=97=B6=20&?= =?UTF-8?q?=20=E8=A2=AB=E6=88=AA=E6=96=AD=EF=BC=8C=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E7=BC=BA=E5=B0=91=20API=20Key=20=E7=9A=84=E4=B8=AD?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 buildOpenInvocation,在 Windows 上正确打开带 & 的 URL。 - runConsoleLogin 在只保存了控制台会话、没有 API Key 时给出本地化提示。 - makeAuthUiBridge 及相关函数支持本地化。 - 为新的 URL 处理和无 API Key 提示补充测试。 --- docs/agents/auth-change.md | 1 + packages/bailian-kb-dsh/src/console-login.ts | 53 +++++++-- .../tests/console-login.test.ts | 12 ++ .../commands/src/commands/auth/console-ui.ts | 13 ++- .../src/commands/auth/login-console.ts | 20 ++++ packages/commands/src/commands/auth/login.ts | 2 +- packages/commands/src/commands/auth/status.ts | 10 +- .../src/commands/shared/local-server.ts | 64 +++++++++-- packages/commands/tests/e2e/auth.e2e.test.ts | 41 +++++++ .../commands/tests/local-server-open.test.ts | 66 +++++++++++ packages/commands/tests/login-console.test.ts | 108 ++++++++++++++++++ 11 files changed, 367 insertions(+), 23 deletions(-) create mode 100644 packages/commands/tests/local-server-open.test.ts create mode 100644 packages/commands/tests/login-console.test.ts diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index 8be0b17ae..190a606ed 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -175,3 +175,4 @@ pnpm -F bailian-cli exec tsx src/main.ts usage stats --dry-run --output json --w - ✗ token mask 显示完整 token,日志泄漏 - ✗ `auth: "console"` 命令误用 `apiKey` 域,config 只有 API key 时会把 `sk-...` 发到网关 - ✗ 新增 core resolver hint 时写死产品命令,导致 `kscli` 等入口提示错误 +- ✗ Windows 上用 `cmd /c start` 打开带 `&` 的 URL 时没把整段目标括起来。`auth login --console` 的 `needapikey=true` 会被 cmd 当成下一条命令,浏览器只拿到控制台会话。打开参数走 `packages/commands/src/commands/shared/local-server.ts` 的 `buildOpenInvocation`(dsh 插件里有一份同步实现) diff --git a/packages/bailian-kb-dsh/src/console-login.ts b/packages/bailian-kb-dsh/src/console-login.ts index 62b28f547..62c4cdc57 100644 --- a/packages/bailian-kb-dsh/src/console-login.ts +++ b/packages/bailian-kb-dsh/src/console-login.ts @@ -160,16 +160,53 @@ async function extractCredentials( return pickCallbackCredentials(Object.fromEntries(url.searchParams), body); } -/** Open a URL with the OS default handler; never routed through a shell. */ +/** + * Quote a value for `cmd.exe`. `%` is doubled because cmd expands variables even + * inside quotes; embedded `"` becomes `""`. + * Keep in sync with `buildOpenInvocation` in + * `packages/commands/src/commands/shared/local-server.ts`. + */ +function quoteForCmd(value: string): string { + return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; +} + +/** + * Command that opens `target` with the OS default handler. + * On Windows, `cmd.exe` splits an unquoted `&`, so `needapikey=true` never + * reaches the browser. See `buildOpenInvocation` in the commands package. + */ +export function buildOpenInvocation( + target: string, + platform = process.platform, +): { command: string; args: string[]; windowsVerbatimArguments?: boolean } { + if (platform === "win32") { + const inner = `start "" ${quoteForCmd(target)}`; + return { + command: "cmd", + args: ["/d", "/v:off", "/s", "/c", `"${inner}"`], + windowsVerbatimArguments: true, + }; + } + if (platform === "darwin") return { command: "open", args: [target] }; + return { command: "xdg-open", args: [target] }; +} + +/** Open a URL with the OS default handler. */ function openInBrowser(url: string): Promise { - const cmd = - process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; - const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + const invocation = buildOpenInvocation(url); return new Promise((resolve, reject) => { - execFile(cmd, args, { windowsHide: true }, (err) => { - if (err) reject(err); - else resolve(); - }); + execFile( + invocation.command, + invocation.args, + { + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }, + (err) => { + if (err) reject(err); + else resolve(); + }, + ); }); } diff --git a/packages/bailian-kb-dsh/tests/console-login.test.ts b/packages/bailian-kb-dsh/tests/console-login.test.ts index 4637177d2..ea8769205 100644 --- a/packages/bailian-kb-dsh/tests/console-login.test.ts +++ b/packages/bailian-kb-dsh/tests/console-login.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { + buildOpenInvocation, consoleLoginState, parseCallbackBody, pickCallbackCredentials, @@ -68,3 +69,14 @@ describe("consoleLoginState", () => { expect(consoleLoginState()).toEqual({ phase: "idle" }); }); }); + +describe("buildOpenInvocation", () => { + it("quotes a Windows start target so & stays inside the URL", () => { + const url = + "https://bailian.console.aliyun.com/console-login?notice=127.0.0.1:1?state=abc&needapikey=true"; + const invocation = buildOpenInvocation(url, "win32"); + const command = invocation.args.at(-1) ?? ""; + expect(invocation.windowsVerbatimArguments).toBe(true); + expect(command.slice(1, -1)).toBe(`start "" "${url}"`); + }); +}); diff --git a/packages/commands/src/commands/auth/console-ui.ts b/packages/commands/src/commands/auth/console-ui.ts index dd5a498cc..7acb48745 100644 --- a/packages/commands/src/commands/auth/console-ui.ts +++ b/packages/commands/src/commands/auth/console-ui.ts @@ -1,4 +1,10 @@ -import { maskToken, type AuthStore, type Identity, type Settings } from "bailian-cli-core"; +import { + maskToken, + type AuthStore, + type Identity, + type LocalizedText, + type Settings, +} from "bailian-cli-core"; import { runConsoleLogin, resolveConsoleOrigin } from "./login-console.ts"; /** Read-only auth snapshot the config UI account widget renders. bl stores no @@ -32,8 +38,9 @@ export function makeAuthUiBridge(ctx: { identity: Identity; settings: Settings; authStore: AuthStore; + localize?: (text: LocalizedText) => string; }): AuthUiBridge { - const { identity, settings, authStore } = ctx; + const { identity, settings, authStore, localize } = ctx; return { status() { const a = authStore.describe(); @@ -63,7 +70,7 @@ export function makeAuthUiBridge(ctx: { // endpoint to detect completion. Errors are logged, not surfaced. void runConsoleLogin( origin, - { identity, settings, authStore }, + { identity, settings, authStore, localize }, { needApiKey: !hasApiKey, }, diff --git a/packages/commands/src/commands/auth/login-console.ts b/packages/commands/src/commands/auth/login-console.ts index c37d3d8ab..5c4313970 100644 --- a/packages/commands/src/commands/auth/login-console.ts +++ b/packages/commands/src/commands/auth/login-console.ts @@ -8,6 +8,7 @@ import { type AuthStore, type ConfigFile, type Identity, + type LocalizedText, type Settings, } from "bailian-cli-core"; import { listenLocalServer, openInBrowser } from "../shared/local-server.ts"; @@ -18,6 +19,13 @@ export interface LoginDeps { identity: Identity; settings: Settings; authStore: AuthStore; + /** 缺省时用户可见新文案回退到 en-US。 */ + localize?: (text: LocalizedText) => string; +} + +function localizeLogin(deps: LoginDeps, text: LocalizedText): string { + if (typeof text === "string") return text; + return deps.localize ? deps.localize(text) : text["en-US"]; } const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000; @@ -371,6 +379,7 @@ export async function runConsoleLogin( ): Promise { const state = randomBytes(16).toString("hex"); let callbackError: unknown; + let savedSessionWithoutApiKey = false; const server = http.createServer(async (req, res) => { try { if (req.method === "OPTIONS") { @@ -423,6 +432,7 @@ export async function runConsoleLogin( ...callbackPatch, base_url: baseUrl || undefined, }); + savedSessionWithoutApiKey = true; process.stderr.write(`Config saved to ${deps.authStore.path}\n`); } } catch (err: unknown) { @@ -506,4 +516,14 @@ export async function runConsoleLogin( if (callbackError) { throw callbackError; } + + if (opts?.needApiKey && savedSessionWithoutApiKey) { + const binName = deps.identity.binName; + process.stderr.write( + `${localizeLogin(deps, { + "en-US": `Saved the console session only. Model commands still need an API key: ${binName} auth login --console, or ${binName} auth login --api-key `, + "zh-CN": `仅保存了控制台会话。模型命令仍需要 API Key:${binName} auth login --console,或 ${binName} auth login --api-key `, + })}\n`, + ); + } } diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index b4c9554c7..805a5deb7 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -119,7 +119,7 @@ export default defineCommand({ async run(ctx) { const { identity, settings, flags } = ctx; const store = ctx.authStore; - const deps = { identity, settings, authStore: store }; + const deps = { identity, settings, authStore: store, localize: ctx.localize }; const key = flags.apiKey; const baseUrl = flags.baseUrl ? normalizeModelBaseUrl(flags.baseUrl) : undefined; diff --git a/packages/commands/src/commands/auth/status.ts b/packages/commands/src/commands/auth/status.ts index 893d6554e..bce37cfa8 100644 --- a/packages/commands/src/commands/auth/status.ts +++ b/packages/commands/src/commands/auth/status.ts @@ -10,7 +10,7 @@ export default defineCommand({ auth: "none", exampleArgs: ["", "--output json"], async run(ctx) { - const { identity, settings } = ctx; + const { identity, settings, localize } = ctx; const format = detectOutputFormat(settings.output); const auth = ctx.authStore.describe(); @@ -40,6 +40,12 @@ export default defineCommand({ const authenticated = !!(apiKey || consoleCred || openapi); const configName = settings.configName ?? "default"; const configFile = ctx.authStore.path; + const modelKeyHint = apiKey + ? undefined + : localize({ + "en-US": `No model API key is configured. Model commands need: ${identity.binName} auth login --console, or ${identity.binName} auth login --api-key `, + "zh-CN": `尚未配置模型 API Key。模型命令需要:${identity.binName} auth login --console,或 ${identity.binName} auth login --api-key `, + }); if (!authenticated) { emitResult( @@ -69,6 +75,7 @@ export default defineCommand({ api_key: apiKey, console: consoleCred, openapi, + ...(modelKeyHint ? { hint: modelKeyHint } : {}), }, format, ); @@ -82,6 +89,7 @@ export default defineCommand({ emitBare(` API key (model): ${apiKey.source} ${apiKey.masked}`); } else { emitBare(" API key (model): not configured"); + if (modelKeyHint) emitBare(` ${modelKeyHint}`); } if (consoleCred) { emitBare( diff --git a/packages/commands/src/commands/shared/local-server.ts b/packages/commands/src/commands/shared/local-server.ts index 102247d53..9046b19f6 100644 --- a/packages/commands/src/commands/shared/local-server.ts +++ b/packages/commands/src/commands/shared/local-server.ts @@ -22,21 +22,65 @@ export function listenLocalServer(server: http.Server, port = 0): Promise"`. + */ +export function buildOpenInvocation(target: string, platform = process.platform): OpenInvocation { + if (platform === "win32") { + const inner = `start "" ${quoteForCmd(target)}`; + return { + command: "cmd", + args: ["/d", "/v:off", "/s", "/c", `"${inner}"`], + windowsVerbatimArguments: true, + }; + } + if (platform === "darwin") { + return { command: "open", args: [target] }; + } + return { command: "xdg-open", args: [target] }; +} + /** * Open a local file, directory, or URL with the OS default handler - * (best-effort, cross-platform). Arguments are passed to `execFile` as an array - * so the target is never interpreted by a shell. + * (best-effort, cross-platform). */ export function openPath(target: string): Promise { - const platform = process.platform; - const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; - const args = platform === "win32" ? ["/c", "start", "", target] : [target]; - + const invocation = buildOpenInvocation(target); return new Promise((resolve, reject) => { - execFile(cmd, args, { windowsHide: true }, (err) => { - if (err) reject(err); - else resolve(); - }); + execFile( + invocation.command, + invocation.args, + { + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }, + (err) => { + if (err) reject(err); + else resolve(); + }, + ); }); } diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index b9f6dadb9..2e1aec80c 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -681,6 +681,47 @@ describe("e2e: auth", () => { expect(stderr).not.toContain("Cleared api_key"); }); + test("auth status 在只有控制台会话时标明模型 API Key 仍未配置", async () => { + const configDir = makeE2eOutputDir("auth-status-console-only"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ + language: "zh-CN", + access_token: "console-token-placeholder", + console_region: "cn-beijing", + console_site: "domestic", + }), + ); + const env = { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: undefined, + DASHSCOPE_BASE_URL: undefined, + ALIBABA_CLOUD_ACCESS_KEY_ID: undefined, + ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined, + ALIBABA_CLOUD_SECURITY_TOKEN: undefined, + }; + + const json = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env); + expect(json.exitCode, json.stderr).toBe(0); + const data = parseStdoutJson<{ + authenticated?: boolean; + api_key?: unknown; + console?: { region?: string }; + hint?: string; + }>(json.stdout); + expect(data.authenticated).toBe(true); + expect(data.api_key).toBeUndefined(); + expect(data.console?.region).toBe("cn-beijing"); + expect(data.hint).toContain("尚未配置模型 API Key"); + expect(data.hint).toContain("bl auth login --api-key "); + expect(json.stdout).not.toContain("console-token-placeholder"); + + const text = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "text"], env); + expect(text.exitCode, text.stderr).toBe(0); + expect(text.stdout).toContain("API key (model): not configured"); + expect(text.stdout).toContain("尚未配置模型 API Key"); + }); + test("auth logout 清除当前 Config 的全部凭证和 Base URL,保留普通配置", async () => { const configDir = makeE2eOutputDir("auth-logout-all"); writeFileSync( diff --git a/packages/commands/tests/local-server-open.test.ts b/packages/commands/tests/local-server-open.test.ts new file mode 100644 index 000000000..f2a8d480b --- /dev/null +++ b/packages/commands/tests/local-server-open.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "vite-plus/test"; +import { buildOpenInvocation } from "../src/commands/shared/local-server.ts"; + +const LOGIN_URL = + "https://bailian.console.aliyun.com/console-login?notice=127.0.0.1:54675?state=abc&needapikey=true"; + +/** Command line `cmd.exe` runs after `/s` strips one outer quote pair. */ +function commandExecutedByCmd(args: string[]): string { + const switchIndex = args.lastIndexOf("/c"); + const remainder = args[switchIndex + 1]; + if (!remainder || switchIndex < 0) throw new Error("missing /c command"); + expect(remainder.startsWith('"')).toBe(true); + expect(remainder.endsWith('"')).toBe(true); + return remainder.slice(1, -1); +} + +/** Split on `&` that is not inside double quotes, the way `cmd.exe` does. */ +function splitUnquotedAmpersand(command: string): string[] { + const parts: string[] = []; + let current = ""; + let quoted = false; + for (const character of command) { + if (character === '"') { + quoted = !quoted; + current += character; + continue; + } + if (character === "&" && !quoted) { + parts.push(current); + current = ""; + continue; + } + current += character; + } + parts.push(current); + return parts; +} + +test("win32 打开带 & 的登录 URL 时,needapikey 留在同一条 start 命令里", () => { + const invocation = buildOpenInvocation(LOGIN_URL, "win32"); + expect(invocation.command).toBe("cmd"); + expect(invocation.windowsVerbatimArguments).toBe(true); + expect(invocation.args.slice(0, 4)).toEqual(["/d", "/v:off", "/s", "/c"]); + + const executed = commandExecutedByCmd(invocation.args); + expect(executed).toBe(`start "" "${LOGIN_URL}"`); + expect(splitUnquotedAmpersand(executed)).toEqual([executed]); + expect(executed).toContain("&needapikey=true"); +}); + +test("win32 转义 cmd 会展开的 % 和引号", () => { + const invocation = buildOpenInvocation('https://example.com/a%20b?"q"', "win32"); + const executed = commandExecutedByCmd(invocation.args); + expect(executed).toBe('start "" "https://example.com/a%%20b?""q"""'); +}); + +test("darwin / linux 直接把目标交给系统打开命令", () => { + expect(buildOpenInvocation(LOGIN_URL, "darwin")).toEqual({ + command: "open", + args: [LOGIN_URL], + }); + expect(buildOpenInvocation(LOGIN_URL, "linux")).toEqual({ + command: "xdg-open", + args: [LOGIN_URL], + }); +}); diff --git a/packages/commands/tests/login-console.test.ts b/packages/commands/tests/login-console.test.ts new file mode 100644 index 000000000..0726dcb03 --- /dev/null +++ b/packages/commands/tests/login-console.test.ts @@ -0,0 +1,108 @@ +import http from "node:http"; +import { expect, test, vi } from "vite-plus/test"; +import type { AuthStore, Identity, Settings } from "bailian-cli-core"; + +const openInBrowser = vi.hoisted(() => vi.fn<(url: string) => Promise>()); + +vi.mock("../src/commands/shared/local-server.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openInBrowser: (url: string) => openInBrowser(url), + }; +}); + +import { runConsoleLogin } from "../src/commands/auth/login-console.ts"; + +function callbackTarget(loginUrl: string): { port: number; state: string } { + const notice = new URL(loginUrl).searchParams.get("notice"); + if (!notice) throw new Error("login URL is missing notice"); + const callback = new URL(`http://${notice}`); + const state = callback.searchParams.get("state"); + if (!callback.port || !state) throw new Error("login URL is missing callback state"); + return { port: Number(callback.port), state }; +} + +function postCallback(port: number, state: string, body: Record): Promise { + const payload = JSON.stringify(body); + return new Promise((resolve, reject) => { + const request = http.request( + { + host: "127.0.0.1", + port, + path: `/?state=${encodeURIComponent(state)}`, + method: "POST", + agent: false, + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + connection: "close", + }, + }, + (response) => { + response.resume(); + response.on("end", () => resolve(response.statusCode ?? 0)); + }, + ); + request.on("error", reject); + request.end(payload); + }); +} + +test("控制台回调只有会话、没有 API Key 时提示模型命令仍不可用", async () => { + const stderr: string[] = []; + const write = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + const saved: unknown[] = []; + let loginUrl = ""; + openInBrowser.mockReset(); + openInBrowser.mockImplementation(async (url) => { + loginUrl = url; + }); + + const pending = runConsoleLogin( + "https://bailian.console.aliyun.com", + { + identity: { binName: "bl" } as Identity, + settings: {} as Settings, + authStore: { + path: "/tmp/bailian/config.json", + login: async (patch: unknown) => { + saved.push(patch); + }, + } as unknown as AuthStore, + localize: (text) => (typeof text === "string" ? text : text["zh-CN"]), + }, + { needApiKey: true }, + ); + + try { + await vi.waitFor(() => { + if (!loginUrl) throw new Error("waiting for login URL"); + }); + expect(loginUrl).toContain("&needapikey=true"); + const target = callbackTarget(loginUrl); + const status = await postCallback(target.port, target.state, { + access_token: "console-token", + console_region: "cn-beijing", + console_site: "domestic", + }); + expect(status).toBe(200); + await pending; + } finally { + write.mockRestore(); + } + + expect(saved).toEqual([ + expect.objectContaining({ + access_token: "console-token", + console_region: "cn-beijing", + console_site: "domestic", + }), + ]); + expect(stderr.join("")).toContain("仅保存了控制台会话"); + expect(stderr.join("")).toContain("bl auth login --api-key "); + expect(stderr.join("")).not.toContain("console-token"); +}); From 6449c601be50be1378ee39e7405a746b1e7ec89c Mon Sep 17 00:00:00 2001 From: qcq01083097 Date: Mon, 19 Oct 2026 13:57:25 +0800 Subject: [PATCH 2/2] fix(cmd): update quoting behavior for cmd /c to preserve percent-encoded characters --- README.zh.md | 2 +- packages/bailian-kb-dsh/src/console-login.ts | 7 +++--- .../tests/console-login.test.ts | 7 ++++++ .../src/commands/shared/local-server.ts | 10 +++++--- .../commands/tests/local-server-open.test.ts | 24 +++++++++++++++---- 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/README.zh.md b/README.zh.md index 4cdebf186..28e431b65 100644 --- a/README.zh.md +++ b/README.zh.md @@ -36,7 +36,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_

- 点击播放演示视频 + 点击播放演示视频

diff --git a/packages/bailian-kb-dsh/src/console-login.ts b/packages/bailian-kb-dsh/src/console-login.ts index 62c4cdc57..89b7e4c97 100644 --- a/packages/bailian-kb-dsh/src/console-login.ts +++ b/packages/bailian-kb-dsh/src/console-login.ts @@ -161,13 +161,14 @@ async function extractCredentials( } /** - * Quote a value for `cmd.exe`. `%` is doubled because cmd expands variables even - * inside quotes; embedded `"` becomes `""`. + * Quote a value for `cmd /c`. An embedded `"` is written as `""`. + * Do not double `%`: that substitution only happens inside batch files, and + * `cmd /c` would otherwise open a target that still contains `%%`. * Keep in sync with `buildOpenInvocation` in * `packages/commands/src/commands/shared/local-server.ts`. */ function quoteForCmd(value: string): string { - return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; + return `"${value.replaceAll('"', '""')}"`; } /** diff --git a/packages/bailian-kb-dsh/tests/console-login.test.ts b/packages/bailian-kb-dsh/tests/console-login.test.ts index ea8769205..b6ec18e26 100644 --- a/packages/bailian-kb-dsh/tests/console-login.test.ts +++ b/packages/bailian-kb-dsh/tests/console-login.test.ts @@ -79,4 +79,11 @@ describe("buildOpenInvocation", () => { expect(invocation.windowsVerbatimArguments).toBe(true); expect(command.slice(1, -1)).toBe(`start "" "${url}"`); }); + + it("leaves percent-encoded characters unchanged for cmd /c", () => { + const url = "https://example.com/a%20b"; + const command = buildOpenInvocation(url, "win32").args.at(-1) ?? ""; + expect(command).toContain("%20"); + expect(command).not.toContain("%%"); + }); }); diff --git a/packages/commands/src/commands/shared/local-server.ts b/packages/commands/src/commands/shared/local-server.ts index 9046b19f6..f2c6fa0c1 100644 --- a/packages/commands/src/commands/shared/local-server.ts +++ b/packages/commands/src/commands/shared/local-server.ts @@ -30,11 +30,15 @@ export interface OpenInvocation { } /** - * Quote a value for `cmd.exe`. `%` is doubled because cmd expands variables even - * inside quotes; embedded `"` becomes `""`. + * Quote a value for `cmd /c`. An embedded `"` is written as `""`, which cmd + * reads as one literal quote. + * + * Do not double `%`. `%%` → `%` is a batch-file rule and does not apply when + * Node starts `cmd /c` directly. Doubling would make `start` open a different + * target (`%20` would stay `%20` with two percent signs). */ function quoteForCmd(value: string): string { - return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; + return `"${value.replaceAll('"', '""')}"`; } /** diff --git a/packages/commands/tests/local-server-open.test.ts b/packages/commands/tests/local-server-open.test.ts index f2a8d480b..f43bc694d 100644 --- a/packages/commands/tests/local-server-open.test.ts +++ b/packages/commands/tests/local-server-open.test.ts @@ -14,6 +14,18 @@ function commandExecutedByCmd(args: string[]): string { return remainder.slice(1, -1); } +/** + * Target `start` receives after cmd quote parsing: one outer quote pair is + * removed, and each `""` inside it is one literal `"`. `%` is not rewritten. + */ +function targetReceivedByStart(executed: string): string { + const prefix = 'start "" '; + if (!executed.startsWith(prefix)) throw new Error("not a start command"); + const quoted = executed.slice(prefix.length); + if (!quoted.startsWith('"') || !quoted.endsWith('"')) throw new Error("target not quoted"); + return quoted.slice(1, -1).replaceAll('""', '"'); +} + /** Split on `&` that is not inside double quotes, the way `cmd.exe` does. */ function splitUnquotedAmpersand(command: string): string[] { const parts: string[] = []; @@ -45,13 +57,15 @@ test("win32 打开带 & 的登录 URL 时,needapikey 留在同一条 start 命 const executed = commandExecutedByCmd(invocation.args); expect(executed).toBe(`start "" "${LOGIN_URL}"`); expect(splitUnquotedAmpersand(executed)).toEqual([executed]); - expect(executed).toContain("&needapikey=true"); + expect(targetReceivedByStart(executed)).toBe(LOGIN_URL); }); -test("win32 转义 cmd 会展开的 % 和引号", () => { - const invocation = buildOpenInvocation('https://example.com/a%20b?"q"', "win32"); - const executed = commandExecutedByCmd(invocation.args); - expect(executed).toBe('start "" "https://example.com/a%%20b?""q"""'); +test("win32 传给 start 的目标保持原样,% 不会被写成 %%", () => { + const target = 'https://example.com/a%20b?"q"&x=1'; + const executed = commandExecutedByCmd(buildOpenInvocation(target, "win32").args); + expect(executed).not.toContain("%%"); + expect(targetReceivedByStart(executed)).toBe(target); + expect(splitUnquotedAmpersand(executed)).toEqual([executed]); }); test("darwin / linux 直接把目标交给系统打开命令", () => {