fix(ai-tools): detect AI CLIs on Windows through PATHEXT

which() looked for the bare name only, so on Windows every tool (claude.exe,
codex.cmd, ...) read installed: false. It now tries each PATHEXT extension
(default .EXE;.CMD;.BAT), the bare name only when it already carries one -
npm's extensionless shim beside codex.cmd is a sh script Windows cannot run.

spawnTarget() gives a .cmd/.bat shell: true and a quoted path, used by the
install/update job runner and the npm/version probes, which Node otherwise
refuses with EINVAL (CVE-2024-27980). Catalog argv only.
This commit is contained in:
2026-10-05 22:05:33 +07:00
parent 094d9c4418
commit b0cf2132d1
7 changed files with 112 additions and 12 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -54,6 +54,9 @@ sudo pacman -S git tmux # Arch
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
On a native Windows install, workspace terminals are unavailable, but Settings →
Agents still detects each AI CLI through `PATHEXT` (`claude.exe`, `codex.cmd`)
and runs a `.cmd` updater or npm through `cmd.exe`.
A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older
versions work, just without it, and `/agenthub-doctor` warns. Homebrew ships the
+1 -1
View File
@@ -406,7 +406,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a session with a prompt starts from the workspace home's prompt box. There is no **New run** button: when a pane's process ends (a double Ctrl+C out of `claude`, `exit`, a crash) its pane closes; the last one shows the next open tab, or the home when none is left. A pane with nothing to show offers **New session**, which opens the home too.
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=2`, `enableMouse` in `server/lib/tmux.js`; tmux 3.6+). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane is started with `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` (`spawnRun` in `server/lib/pty-run.js`) and renders inline even when `~/.claude/settings.json` says `"tui": "fullscreen"`. That setting still applies to `claude` in your own terminals; a pane started before this change keeps fullscreen until it is restarted.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=2`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane is started with `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` (`spawnRun` in `server/lib/pty-run.js`) and renders inline even when `~/.claude/settings.json` says `"tui": "fullscreen"`. That setting still applies to `claude` in your own terminals; a pane started before this change keeps fullscreen until it is restarted.
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`), one tab per live tmux session of the workspace — at most four, its slots 0–3: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–3 are `agenthub-lane-<id>-<slot>`. A tab shows its AI tool's icon and name (or **Terminal**); the active tab is the focused pane's, which is the URL's `?run=`, so the Changes panel and Analyst follow it, and a tab another pane shows carries a grey underline. A click shows a tab in the focused pane (or focuses the pane already showing it), **Ctrl/Cmd+click** — on a tab or on a `+` item — opens it in a split, and a tab drags onto the panes. Every tab with a live run stays mounted and those no pane shows are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops its session (`DELETE /api/run/<runId>`) and closes its pane; with no pane left it moves to the next tab, or the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With four tabs open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): a binary tree of splits, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes, one per live session, a session never shown twice. **Ctrl/Cmd+click** a tab, a `+` item or a sidebar session row opens it to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** closes the pane only (its session stays a tab). Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The layout is a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
+62
View File
@@ -330,3 +330,65 @@ describe("install/update fields and trust tiers", () => {
});
});
});
describe("which() on Windows", () => {
const realPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const realPathext = process.env.PATHEXT;
const winDir = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tools-win-"));
// Executable bits only for the Linux host running the suite: Windows'
// X_OK check is plain existence.
for (const f of ["claude.exe", "codex.cmd"])
fs.writeFileSync(path.join(winDir, f), "", { mode: 0o755 });
const setPlatform = (p) => Object.defineProperty(process, "platform", { value: p });
after(() => {
Object.defineProperty(process, "platform", realPlatform);
if (realPathext === undefined) delete process.env.PATHEXT;
else process.env.PATHEXT = realPathext;
});
it("tries each PATHEXT extension after the bare name", () => {
setPlatform("win32");
process.env.PATHEXT = ".EXE;.CMD";
assert.equal(aiTools.which("claude", winDir), path.join(winDir, "claude.exe"));
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
});
it("skips npm's extensionless sh shim, which Windows cannot run", () => {
setPlatform("win32");
process.env.PATHEXT = ".EXE;.CMD";
fs.writeFileSync(path.join(winDir, "codex"), "#!/bin/sh\n", { mode: 0o755 });
try {
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
assert.equal(aiTools.which("claude.exe", winDir), path.join(winDir, "claude.exe"));
} finally {
fs.unlinkSync(path.join(winDir, "codex"));
}
});
it("falls back to .EXE;.CMD;.BAT without PATHEXT", () => {
setPlatform("win32");
delete process.env.PATHEXT;
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
});
it("runs a .cmd/.bat through cmd.exe, its path quoted for Program Files", () => {
setPlatform("win32");
const npm = "C:\\Program Files\\nodejs\\npm.cmd";
assert.deepEqual(aiTools.spawnTarget(npm), { file: `"${npm}"`, shell: true });
assert.deepEqual(aiTools.spawnTarget("C:\\x\\claude.exe"), {
file: "C:\\x\\claude.exe",
shell: false,
});
setPlatform("linux");
assert.deepEqual(aiTools.spawnTarget("/usr/bin/npm.cmd"), {
file: "/usr/bin/npm.cmd",
shell: false,
});
});
it("adds no extension anywhere else", () => {
setPlatform("linux");
process.env.PATHEXT = ".EXE;.CMD";
assert.equal(aiTools.which("claude", winDir), null);
});
});
+3 -1
View File
@@ -125,7 +125,9 @@ function runArgv(j, argv) {
resolve(code == null ? 1 : code);
};
try {
child = spawnImpl(argv[0], argv.slice(1), {
const { file, shell } = aiTools.spawnTarget(argv[0]);
child = spawnImpl(file, argv.slice(1), {
shell,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, PATH: versions.jobPath() },
detached: true, // its own process group, so a timeout reaches grandchildren
+4 -2
View File
@@ -37,8 +37,10 @@ function npmBin() {
return (sp && aiTools.which("npm", sp)) || aiTools.which("npm");
}
const run = (file, args) =>
exec(file, args, { timeout: TIMEOUT_MS, env: { ...process.env, PATH: jobPath() } });
const run = (bin, args) => {
const { file, shell } = aiTools.spawnTarget(bin);
return exec(file, args, { shell, timeout: TIMEOUT_MS, env: { ...process.env, PATH: jobPath() } });
};
const parseVersion = (out) => (String(out || "").match(/\d+\.\d+\.\d+/) || [null])[0];
+32 -1
View File
@@ -260,7 +260,8 @@ const has = (id) => typeof id === "string" && cat().has(id);
function which(name, pathStr = process.env.PATH || "") {
for (const dir of pathStr.split(path.delimiter)) {
if (!dir) continue;
const p = path.join(dir, name);
for (const candidate of executableNames(name)) {
const p = path.join(dir, candidate);
try {
if (fs.statSync(p).isFile()) {
fs.accessSync(p, fs.constants.X_OK);
@@ -270,9 +271,38 @@ function which(name, pathStr = process.env.PATH || "") {
/* not here */
}
}
}
return null;
}
/**
* The file names `name` can run as. On Windows only a `PATHEXT` extension
* makes a file runnable (`claude.exe`, `codex.cmd`): the bare name is tried
* only when it already carries one, because npm's extensionless `codex` next
* to `codex.cmd` is a POSIX sh shim Windows cannot start.
*/
function executableNames(name) {
if (process.platform !== "win32") return [name];
const exts = (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";").filter(Boolean);
const lower = name.toLowerCase();
if (exts.some((e) => lower.endsWith(e.toLowerCase()))) return [name];
return exts.map((e) => name + e.toLowerCase());
}
/**
* How to start `file` with spawn/execFile: on Windows a `.cmd`/`.bat` (npm,
* an npm-installed CLI) runs only through cmd.exe — Node refuses it otherwise
* (EINVAL, CVE-2024-27980) — so it gets `shell: true` and a quoted path, as
* `C:\Program Files\nodejs\npm.cmd` holds a space. Only for argv built from
* release catalog files, never from a request: cmd.exe re-parses the args.
*/
function spawnTarget(file) {
if (process.platform === "win32" && /\.(cmd|bat)$/i.test(file)) {
return { file: `"${file}"`, shell: true };
}
return { file, shell: false };
}
/** `{installed, path, command}` for one tool, cached until refresh() unless `fresh`. */
function detect(id, { fresh = false } = {}) {
if (!fresh && detected.has(id)) return detected.get(id);
@@ -426,6 +456,7 @@ module.exports = {
view,
validate,
which,
spawnTarget,
siteOf,
ROUTER_CAPABLE,
CONFIG_SECRET_RE,