From e46c7cd4943d1ca56364196a833793f05a6ab94f Mon Sep 17 00:00:00 2001 From: OrangerXu Date: Fri, 23 Oct 2026 13:55:42 +0800 Subject: [PATCH 1/2] fix(windows): prefer real executables over .cmd shims when resolving coding-agent CLIs A .cmd dispatcher forwards through cmd.exe (and often PowerShell), and an empty-string argument does not survive that hop: the CLI reads the next flag as the empty argument's value. For the qoder CN adapter that was fatal -- '--setting-sources ""' became '--setting-sources --max-turns' and every turn died with 'Invalid setting source: "--max-turns"' on the official installer layout, where qodercn resolves to the .cmd dispatcher. whichFromPath now scans the whole PATH for an .exe before falling back to any .cmd/.bat, and the Qoder CN profile tries the real qoderclicn executable before the qodercn dispatcher (they live in different directories, so PATH order alone cannot prefer the executable). Fixes #7518 --- src/adapters/coding-agent/profile.ts | 37 ++++++++--- src/adapters/qoder/profiles.ts | 6 +- .../coding-agent-binary-resolution.test.ts | 66 +++++++++++++++++++ tests/providers/qoder-adapter.test.ts | 4 +- 4 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 tests/adapters/coding-agent-binary-resolution.test.ts diff --git a/src/adapters/coding-agent/profile.ts b/src/adapters/coding-agent/profile.ts index d1f9d0bd33e..dfcc069f4d8 100644 --- a/src/adapters/coding-agent/profile.ts +++ b/src/adapters/coding-agent/profile.ts @@ -56,17 +56,36 @@ export function clearCodingAgentBinaryCache(): void { export function whichFromPath(candidate: string): string | undefined { const pathVar = process.env.PATH ?? ""; if (!pathVar) return undefined; - const extensions = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""]; - for (const dir of pathVar.split(delimiter)) { - if (!dir) continue; - for (const ext of extensions) { - const full = join(dir, `${candidate}${ext}`); - try { - if (existsSync(full)) return full; - } catch { - // An unreadable PATH entry must not abort discovery; skip it. + const dirs = pathVar.split(delimiter).filter(dir => dir !== ""); + if (process.platform === "win32") { + // Real executables first, across the whole PATH: a `.cmd` dispatcher + // forwards through cmd.exe (and often PowerShell), and an empty-string + // argument does not survive that hop — the CLI then reads the *next* + // flag as the empty argument's value (`--setting-sources ""` became + // `--setting-sources --max-turns`, #7518). Only fall back to a shim + // when no `.exe` with this name exists anywhere on PATH. + const passes: string[][] = [[".exe"], [".cmd", ".bat", ""]]; + for (const extensions of passes) { + for (const dir of dirs) { + for (const ext of extensions) { + const full = join(dir, `${candidate}${ext}`); + try { + if (existsSync(full)) return full; + } catch { + // An unreadable PATH entry must not abort discovery; skip it. + } + } } } + return undefined; + } + for (const dir of dirs) { + const full = join(dir, candidate); + try { + if (existsSync(full)) return full; + } catch { + // An unreadable PATH entry must not abort discovery; skip it. + } } return undefined; } diff --git a/src/adapters/qoder/profiles.ts b/src/adapters/qoder/profiles.ts index 9cb486832ff..794c9617fc8 100644 --- a/src/adapters/qoder/profiles.ts +++ b/src/adapters/qoder/profiles.ts @@ -25,7 +25,11 @@ export const QODER_CN_PROFILE: QoderProfile = { region: "cn", label: "Qoder CN", canonicalBaseUrl: "https://qoder.cn", - binaryCandidates: ["qodercn", "qoderclicn"], + // The real executable first: the CN installer's `qodercn` is a .cmd + // dispatcher (entry\qodercn.cmd -> PowerShell), and empty-string arguments + // like `--setting-sources ""` do not survive that hop -- every turn died + // with `Invalid setting source: "--max-turns"` (#7518). + binaryCandidates: ["qoderclicn", "qodercn"], tokenEnv: "QODERCN_PERSONAL_ACCESS_TOKEN", installHint: "npm install -g @qodercn-ai/qoderclicn", documentationUrl: "https://docs.qoder.cn/en/cli/authentication", diff --git a/tests/adapters/coding-agent-binary-resolution.test.ts b/tests/adapters/coding-agent-binary-resolution.test.ts new file mode 100644 index 00000000000..be17f3a6de9 --- /dev/null +++ b/tests/adapters/coding-agent-binary-resolution.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { whichFromPath } from "../../src/adapters/coding-agent/profile"; +import { QODER_CN_PROFILE } from "../../src/adapters/qoder/profiles"; + +// Windows resolution must prefer a real executable over a .cmd shim: +// a dispatcher forwards through cmd.exe (and often PowerShell), and an +// empty-string argument does not survive that hop, so the next flag is +// swallowed as its value (#7518). On other platforms whichFromPath is a +// plain first-match scan and these scenarios collapse. +const isWin = process.platform === "win32"; +const maybeTest = isWin ? test : test.skip; + +function dirWith(files: string[]): string { + const dir = mkdtempSync(join(tmpdir(), "ocx-which-")); + for (const file of files) writeFileSync(join(dir, file), ""); + return dir; +} + +function withPath(dirs: string[], run: () => void): void { + const saved = process.env.PATH; + process.env.PATH = dirs.join(delimiter); + try { + run(); + } finally { + process.env.PATH = saved; + } +} + +maybeTest("prefers the .exe over a same-directory .cmd shim", () => { + const dir = dirWith(["agent.cmd", "agent.exe"]); + withPath([dir], () => { + expect(whichFromPath("agent")).toBe(join(dir, "agent.exe")); + }); +}); + +maybeTest("prefers an .exe further down the PATH over an earlier .cmd", () => { + const cmdDir = dirWith(["agent.cmd"]); + const exeDir = dirWith(["agent.exe"]); + withPath([cmdDir, exeDir], () => { + expect(whichFromPath("agent")).toBe(join(exeDir, "agent.exe")); + }); +}); + +maybeTest("still resolves a .cmd when no .exe exists anywhere on the PATH", () => { + const dir = dirWith(["agent.cmd"]); + withPath([dir], () => { + expect(whichFromPath("agent")).toBe(join(dir, "agent.cmd")); + }); +}); + +maybeTest("falls back to a .bat when it is the only form present", () => { + const dir = dirWith(["agent.bat"]); + withPath([dir], () => { + expect(whichFromPath("agent")).toBe(join(dir, "agent.bat")); + }); +}); + +test("the Qoder CN profile names the real executable before the .cmd dispatcher", () => { + // The installer puts qodercn.cmd (a PowerShell dispatcher) and the real + // qoderclicn.exe in different directories, so PATH order alone cannot + // prefer the executable: the profile has to try it first (#7518). + expect(QODER_CN_PROFILE.binaryCandidates[0]).toBe("qoderclicn"); +}); diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index bba85c4ffff..0e04eb1c809 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -526,7 +526,9 @@ describe("qoder adapter", () => { test("keeps Global and CN profiles, executables, destinations, and PAT variables isolated", async () => { expect(resolveQoderProfile("https://qoder.com/")).toBe(QODER_GLOBAL_PROFILE); expect(resolveQoderProfile("https://qoder.cn/")).toBe(QODER_CN_PROFILE); - expect(QODER_CN_PROFILE.binaryCandidates).toEqual(["qodercn", "qoderclicn"]); + // The real executable is tried before the .cmd dispatcher: empty-string + // arguments do not survive the dispatcher's cmd.exe/PowerShell hop (#7518). + expect(QODER_CN_PROFILE.binaryCandidates).toEqual(["qoderclicn", "qodercn"]); const globalEnv = buildQoderChildEnv(QODER_GLOBAL_PROFILE, "global-pat"); const cnEnv = buildQoderChildEnv(QODER_CN_PROFILE, "cn-pat"); From 4db7da31402c1bf380ddb49fff85dbfd9c71cd66 Mon Sep 17 00:00:00 2001 From: OrangerXu Date: Fri, 23 Oct 2026 14:18:04 +0800 Subject: [PATCH 2/2] chore: retrigger checks on the dev base