Skip to content

Commit be34ffe

Browse files
authored
fix(agents): preserve npm shims and parse v-prefixed versions (#295)
1 parent 15b069c commit be34ffe

4 files changed

Lines changed: 95 additions & 6 deletions

File tree

src/supervisor/agents/base.detect-version.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ describe("detectAgentInstall version probe", () => {
8282
expect.anything(),
8383
);
8484
});
85+
86+
it("extracts the full semver from a v-prefixed version string", async () => {
87+
// Regression: `\b\d+\.\d+…` could not match right after a leading `v`
88+
// (no word boundary between two word characters), so "v24.14.0" was
89+
// mis-extracted as "14.0" — surfacing a phantom newer version in the UI.
90+
execFileAsyncMock.mockImplementation(async (_cmd: unknown, args: unknown) => {
91+
const joined = (Array.isArray(args) ? args : []).join(" ");
92+
if (joined.includes("command -v")) return { stdout: "/opt/tools/grok\n", stderr: "" };
93+
return { stdout: "v24.14.0\n", stderr: "" };
94+
});
95+
96+
const status = await detectAgentInstall(undefined, spec);
97+
98+
expect(status.version).toBe("24.14.0");
99+
});
85100
});
86101

87102
describe("detectAgentInstall WSL interop guard", () => {

src/supervisor/agents/base.windows-path.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ vi.mock("node:child_process", async () => {
2222

2323
import {
2424
clearExecutablePathCache,
25+
extractWindowsCmdShimScript,
2526
getRefreshedWindowsPath,
2627
invalidateExecutablePathCache,
2728
resolveExecutablePath,
@@ -186,6 +187,42 @@ describe.skipIf(process.platform !== "win32")("Windows executable path fallback"
186187
expect(resolveExecutablePath("command-code")).toBe(cmdPath);
187188
});
188189

190+
it("keeps the .cmd path for npm shims with an extensionless bin script (e.g. grok.cmd)", () => {
191+
// Regression: @xai-official/grok's npm bin entry has no .js extension
192+
// (`"%_prog%" "%dp0%\node_modules\@xai-official\grok\bin\grok" %*`), so
193+
// the .js-only shim guard missed it and the exe substitution matched the
194+
// shim's `IF EXIST "%dp0%\node.exe"` line — resolving `grok` to node.exe.
195+
// Detection then read node's version and the ACP probe spawned
196+
// `node.exe agent stdio`, breaking version, models, and account info.
197+
const root = mkdtempSync(join(tmpdir(), "poracode-grok-shim-"));
198+
tempDirs.push(root);
199+
const cmdPath = join(root, "grok.cmd");
200+
const nodeExePath = join(root, "node.exe");
201+
writeFileSync(nodeExePath, "");
202+
writeFileSync(
203+
cmdPath,
204+
[
205+
"@ECHO off",
206+
"SETLOCAL",
207+
'IF EXIST "%dp0%\\node.exe" (',
208+
' SET "_prog=%dp0%\\node.exe"',
209+
") ELSE (",
210+
' SET "_prog=node"',
211+
")",
212+
'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\@xai-official\\grok\\bin\\grok" %*',
213+
"",
214+
].join("\r\n"),
215+
);
216+
spawnSyncMock.mockReturnValueOnce({
217+
error: undefined,
218+
status: 0,
219+
stdout: [join(root, "grok"), cmdPath].join("\r\n"),
220+
stderr: "",
221+
});
222+
223+
expect(resolveExecutablePath("grok")).toBe(cmdPath);
224+
});
225+
189226
it("applies the same fallback to async resolution", async () => {
190227
execFileAsyncMock.mockRejectedValueOnce(new Error("not found")).mockResolvedValueOnce({
191228
stdout: "C:\\Users\\demo\\scoop\\shims\\opencode.exe\r\n",
@@ -275,3 +312,20 @@ describe.skipIf(process.platform !== "win32")("Windows executable path fallback"
275312
expect(getRefreshedWindowsPath()).toContain("C:\\Users\\demo\\.local\\bin");
276313
});
277314
});
315+
316+
describe("extractWindowsCmdShimScript", () => {
317+
it("extracts .js/.mjs script entries", () => {
318+
const body = '"%dp0%\\node.exe" "%dp0%\\node_modules\\command-code\\dist\\index.mjs" %*';
319+
expect(extractWindowsCmdShimScript(body)).toBe("node_modules\\command-code\\dist\\index.mjs");
320+
});
321+
322+
it("extracts extensionless %_prog% bin scripts", () => {
323+
const body = '"%_prog%" "%dp0%\\node_modules\\@xai-official\\grok\\bin\\grok" %*';
324+
expect(extractWindowsCmdShimScript(body)).toBe("node_modules\\@xai-official\\grok\\bin\\grok");
325+
});
326+
327+
it("returns undefined for exe-wrapping shims", () => {
328+
const body = '"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*';
329+
expect(extractWindowsCmdShimScript(body)).toBeUndefined();
330+
});
331+
});

src/supervisor/agents/base/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getPrimedPosixEnv,
1616
getProjectShellEnv,
1717
getWindowsPathOverrideEnv,
18+
extractWindowsCmdShimScript,
1819
isWslInteropBinaryPath,
1920
readCommandOutputAsync,
2021
readWslLoginShellCommandOutputAsync,
@@ -209,8 +210,7 @@ function resolveWindowsNodeCmdShim(commandPath: string):
209210
return undefined;
210211
}
211212

212-
const match = /["']?%dp0%\\([^"']+?\.[cm]?js)["']?\s+%\*/i.exec(content);
213-
const relScript = match?.[1];
213+
const relScript = extractWindowsCmdShimScript(content);
214214
if (!relScript) return undefined;
215215

216216
const baseDir = dirname(effectivePath);
@@ -454,8 +454,11 @@ async function resolveDetectedBinary(
454454

455455
function extractSemverFromVersionOutput(raw: string | undefined): string | undefined {
456456
if (!raw) return undefined;
457-
const match = raw.match(/\b\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?\b/);
458-
return match ? match[0] : raw.trim() || undefined;
457+
// Allow a leading `v` ("v24.14.0"): without it, `\b` cannot match between
458+
// `v` and the first digit, so the regex would skip past the major segment
459+
// and latch onto "14.0" mid-string.
460+
const match = raw.match(/\bv?(\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?)\b/i);
461+
return match ? match[1] : raw.trim() || undefined;
459462
}
460463

461464
async function readDetectedVersion(

src/supervisor/agents/base/processRuntime.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,17 +287,34 @@ function parseWindowsExecutablePath(stdout: string): string | undefined {
287287
return resolveWindowsCmdExeTarget(resolved) ?? resolved;
288288
}
289289

290+
/**
291+
* Extract the `%dp0%`-relative Node script entry from an npm-style `.cmd`
292+
* shim body. Matches explicit `.js/.cjs/.mjs` entries as well as the
293+
* extensionless bin scripts npm's cmd-shim writes for packages whose bin file
294+
* has no extension (e.g. @xai-official/grok:
295+
* `"%_prog%" "%dp0%\node_modules\@xai-official\grok\bin\grok" %*`).
296+
* Returns undefined for exe-wrapping shims.
297+
*/
298+
export function extractWindowsCmdShimScript(body: string): string | undefined {
299+
const js = /["']?%dp0%\\([^"']+?\.[cm]?js)["']?\s+%\*/i.exec(body)?.[1];
300+
if (js) return js;
301+
const prog = /"%_prog%"\s+["']?%dp0%\\([^"']+?)["']?\s+%\*/i.exec(body)?.[1];
302+
if (prog && !/\.(?:exe|cmd|bat|com|ps1)$/i.test(prog)) return prog;
303+
return undefined;
304+
}
305+
290306
function resolveWindowsCmdExeTarget(path: string | undefined): string | undefined {
291307
if (!path || !/\.cmd$/i.test(path)) return undefined;
292308
try {
293309
const body = readFileSync(path, "utf8");
294-
// npm's standard Node-script shim wraps `"%dp0%\node.exe" "%dp0%\…\entry.mjs" %*`.
310+
// npm's standard Node-script shim wraps `"%dp0%\node.exe" "%dp0%\…\entry.mjs" %*`
311+
// (or `"%_prog%" "%dp0%\node_modules\…\bin\<name>" %*` for extensionless bins).
295312
// Leave those alone so the downstream resolveWindowsNodeCmdShim (in base/index.ts)
296313
// can extract the script entry and invoke node with it directly. Substituting to
297314
// node.exe here would strip the script arg and pass agent flags straight to
298315
// node, which rejects them ("bad option: --model", etc.) and exits — breaking
299316
// every npm-installed agent (codex, commandcode, gemini, …) on Windows.
300-
if (/["']?%dp0%\\[^"']+?\.[cm]?js["']?\s+%\*/i.test(body)) return undefined;
317+
if (extractWindowsCmdShimScript(body) !== undefined) return undefined;
301318
const match = /"%dp0%\\([^"]+?\.exe)"/i.exec(body);
302319
if (!match?.[1]) return undefined;
303320
const target = join(dirname(path), match[1]);

0 commit comments

Comments
 (0)