Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b0ddf3d
feat(agents): add the agent install catalog
devswha Oct 29, 2026
9be8b94
feat(agents): answer GET /api/agent-catalog with what this PC has ins…
devswha Oct 29, 2026
1691415
docs(agents): how the install catalog is kept current and tested
devswha Oct 29, 2026
08f4d88
feat(agents): record the Windows clean-PC runs in the install catalog
devswha Oct 29, 2026
142e6b7
fix(agents): no relative PATH lookups, kill a version flag's process …
devswha Oct 29, 2026
1ea5d65
fix(agents): accept only the catalog's own commands, byte for byte
devswha Oct 29, 2026
459e585
test(agents): the approved commands equal the catalog's, both ways
devswha Oct 29, 2026
2b5b38d
fix(agents): tell a replaced executable by its inode and ctime
devswha Oct 29, 2026
8c630e7
feat(agents): start an agent's install or sign-in in a visible termin…
devswha Oct 29, 2026
2cb630d
feat(agents): a setup card that installs, re-detects and signs in an …
devswha Oct 29, 2026
bf3e6cf
fix(agents): say it plainly on the setup card
devswha Oct 29, 2026
6848e25
fix(agents): refuse extra fields, an unknown shell and a second install
devswha Oct 29, 2026
b88ef5a
fix(agents): hold an install for its installer's lifetime
devswha Oct 29, 2026
0900f5c
docs(changelog): the install catalog now also installs and signs in
devswha Oct 29, 2026
86596d6
Merge remote-tracking branch 'origin/main' into feat/agent-install-run
devswha Oct 29, 2026
108b839
fix(agents): run a Windows .cmd agent shim through cmd.exe for its ve…
devswha Oct 29, 2026
1917df3
test(agents): drive the shell deadline test with a virtual clock
devswha Oct 29, 2026
5dc15cb
test(usage): start the sign-in-again check from no shared account order
devswha Oct 29, 2026
cba848d
Merge remote-tracking branch 'origin/main' into feat/agent-install-run
devswha Oct 29, 2026
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
6 changes: 6 additions & 0 deletions changelog.d/agent-install-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Added
- An agent can be installed and signed in to from Ovis: the server opens a terminal tab on that PC
and types the vendor's own install command for it, or the agent's own sign-in, so every line
shows and Ctrl+C stops it (`POST /api/agent-catalog/install` and `/sign-in`, also for remote
PCs). A setup card follows the install, finds the agent once it is in place, and offers its
sign-in.
33 changes: 32 additions & 1 deletion server/agent-catalog-detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";

import { AGENT_CATALOG, type AgentCatalogEntry } from "../shared/agent-catalog.ts";
import { AgentDetector, catalogLocator, LOGIN_PATH_KEPT_MS, runVersion, TIMEOUT_KEPT_MS } from "./agent-catalog-detect.ts";
import { AgentDetector, catalogLocator, LOGIN_PATH_KEPT_MS, runVersion, TIMEOUT_KEPT_MS, versionSpawn } from "./agent-catalog-detect.ts";

const entry = (id: string): AgentCatalogEntry => AGENT_CATALOG.find((candidate) => candidate.id === id)!;

Expand Down Expand Up @@ -198,3 +198,34 @@ describe.skipIf(process.platform === "win32")("AgentDetector", () => {
expect(run.output.startsWith("1.2.3")).toBe(true);
});
});

describe("versionSpawn", () => {
it("runs a Windows batch file (an npm agent's .cmd shim) through cmd.exe, with its line as cmd reads it", () => {
const comspec = "C:\\Windows\\system32\\cmd.exe";
expect(versionSpawn("C:\\Users\\Jo Doe\\AppData\\Roaming\\npm\\codex.cmd", ["--version"], "win32", comspec)).toEqual({
cmd: [comspec, "/d", "/s", "/c", '""C:\\Users\\Jo Doe\\AppData\\Roaming\\npm\\codex.cmd" "--version""'],
windowsVerbatimArguments: true,
});
expect(versionSpawn("C:\\tools\\AGENT.BAT", ["--version"], "win32", "").cmd.slice(0, 4)).toEqual(["cmd.exe", "/d", "/s", "/c"]);
});

it("runs anything else as itself", () => {
expect(versionSpawn("C:\\Users\\u\\.local\\bin\\claude.exe", ["--version"], "win32", "cmd.exe")).toEqual({ cmd: ["C:\\Users\\u\\.local\\bin\\claude.exe", "--version"], windowsVerbatimArguments: false });
expect(versionSpawn("/opt/tools/agent.cmd", ["--version"], "linux", undefined)).toEqual({ cmd: ["/opt/tools/agent.cmd", "--version"], windowsVerbatimArguments: false });
});
});

describe.skipIf(process.platform !== "win32")("runVersion on Windows", () => {
it("reads the version a .cmd shim prints, from a folder with a space in its name", async () => {
const dir = mkdtempSync(join(tmpdir(), "ovis catalog cmd "));
try {
const shim = join(dir, "agent.cmd");
// %~1: the first argument as the shim got it, cmd's quotes taken off (an npm shim hands %* to node, which does the same)
writeFileSync(shim, "@echo off\r\necho agent 4.5.6 %~1\r\n");
const run = await runVersion(shim, ["--version"], 10_000);
expect([run.timedOut, run.exitCode, run.output.trim()]).toEqual([false, 0, "agent 4.5.6 --version"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
20 changes: 18 additions & 2 deletions server/agent-catalog-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { accessSync, constants, statSync } from "node:fs";
import { homedir } from "node:os";
import { delimiter, isAbsolute, join } from "node:path";
import { meetsMinVersion, parseVersion, type AgentCatalogEntry, type AgentInstallState, type CatalogPlatform } from "../shared/agent-catalog.ts";
import { loginPathProbe, loginShell, type LoginPathProbe } from "./shell-agent.ts";
import { loginPathProbe, loginShell, shellCommandLine, type LoginPathProbe } from "./shell-agent.ts";

/** How long one `--version` may run before it is killed and reported as timed out. */
export const VERSION_TIMEOUT_MS = 5_000;
Expand Down Expand Up @@ -116,13 +116,24 @@ function killTree(pid: number): void {
try { process.kill(-pid, "SIGKILL"); } catch { /* the group is gone already */ }
}

/**
* The process that runs `executable args`. On Windows a .cmd or .bat (the shim npm installs an agent
* as) is a batch file, which only cmd.exe runs: it goes through `cmd.exe /d /s /c "<line>"`, the line
* quoted as cmd reads it, with Bun's own argument quoting off so cmd gets it as written.
*/
export function versionSpawn(executable: string, args: readonly string[], platform: NodeJS.Platform = process.platform, comspec: string | undefined = process.env["ComSpec"]): { cmd: string[]; windowsVerbatimArguments: boolean } {
if (platform !== "win32" || !/\.(?:cmd|bat)$/i.test(executable)) return { cmd: [executable, ...args], windowsVerbatimArguments: false };
return { cmd: [comspec || "cmd.exe", "/d", "/s", "/c", `"${shellCommandLine("cmd", executable, args)}"`], windowsVerbatimArguments: true };
}

/**
* Runs `executable args` with no input and this server's environment, in a process group of its own.
* At `timeoutMs` the whole group is killed and its output no longer read: a version flag that starts
* a child holding stdout open (an npm shim, a launcher) would otherwise outlast the timeout.
*/
export const runVersion: RunVersion = async (executable, args, timeoutMs) => {
const child = Bun.spawn([executable, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", detached: process.platform !== "win32" });
const { cmd, windowsVerbatimArguments } = versionSpawn(executable, args);
const child = Bun.spawn(cmd, { stdin: "ignore", stdout: "pipe", stderr: "pipe", detached: process.platform !== "win32", windowsVerbatimArguments });
let timedOut = false;
let stopReading!: () => void;
const stop = new Promise<void>((resolve) => { stopReading = resolve; });
Expand Down Expand Up @@ -233,6 +244,11 @@ export class AgentDetector {
return read;
}

/** The executable `binary` would be started as on this PC: PATH first, then the installer's own folders; null where none is. */
async executable(binary: string, dirs: readonly string[], platform: CatalogPlatform | null): Promise<string | null> {
return (await this.#locate(binary)) ?? (platform === null ? null : this.#inInstallDirs(binary, dirs, platform));
}

/**
* Whether `entry` is on this PC: the first of its binaries found, and that one's version. Where
* another program has the binary's name (detect.unreliable_on), nothing is looked up or run.
Expand Down
Loading
Loading