Skip to content
Closed
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
37 changes: 28 additions & 9 deletions src/adapters/coding-agent/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 5 additions & 1 deletion src/adapters/qoder/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
66 changes: 66 additions & 0 deletions tests/adapters/coding-agent-binary-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
4 changes: 3 additions & 1 deletion tests/providers/qoder-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading