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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user