/** * @file Tests for server/lib/ai-tool-jobs.js and ai-tool-versions.js — the * argv each install/update runs (npm from the login shell's PATH first), the * script download guards, one job at a time, the log cap, timeout, EACCES * hint, enable-after-install, update-all, and npm-managed detection by realpath. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, beforeEach, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { EventEmitter } = require("node:events"); const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-jobs-")); const cat = path.join(root, "cat"); const empty = path.join(root, "empty"); const shellBin = path.join(root, "shellbin"); // the login shell's npm const serverBin = path.join(root, "serverbin"); // the pinned Node's npm const prefix = path.join(root, "prefix"); const standalone = path.join(root, "standalone"); const data = path.join(root, "data"); for (const d of [cat, empty, shellBin, serverBin, prefix, standalone, data]) { fs.mkdirSync(d, { recursive: true }); } const saved = { PATH: process.env.PATH, DATA: process.env.DASHBOARD_DATA_DIR, SP: process.env.AGENTHUB_SHELL_PATH, }; process.env.DASHBOARD_DATA_DIR = data; const aiTools = require("../lib/ai-tools"); const shell = require("../lib/shell-path"); const versions = require("../lib/ai-tool-versions"); const jobs = require("../lib/ai-tool-jobs"); const exe = (p) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, "#!/bin/sh\n"); fs.chmodSync(p, 0o755); return p; }; const write = (name, doc) => fs.writeFileSync(path.join(cat, name), JSON.stringify(doc)); const tool = (id, extra) => ({ id, label: id, detect: [id], prompt: { mode: "argv" }, homepageUrl: "https://docs.example.com/cli", ...extra, }); /** A fake spawn: records argv/env, prints `out`, exits with `code` (or never, for "hang"). */ let spawned = []; function fakeSpawn({ code = 0, out = "", hang = false } = {}) { return (file, args, opts) => { spawned.push({ argv: [file, ...args], opts }); const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.kill = () => setImmediate(() => child.emit("close", null)); if (!hang) { setImmediate(() => { if (out) child.stdout.emit("data", Buffer.from(out)); child.emit("close", code); }); } return child; }; } async function settle() { for (let i = 0; i < 400 && jobs.current()?.status === "running"; i++) { await new Promise((r) => setTimeout(r, 5)); } return jobs.current(); } let origFetch; before(async () => { origFetch = globalThis.fetch; write( "npmtool.json", tool("npmtool", { install: { kind: "npm", package: "npm-tool" }, update: { kind: "npm" } }) ); write( "solo.json", tool("solo", { install: { kind: "npm", package: "solo-pkg" }, update: { kind: "npm" } }) ); write( "fresh.json", tool("fresh", { install: { kind: "npm", package: "fresh-pkg" }, update: { kind: "npm" } }) ); write( "scripted.json", tool("scripted", { install: { kind: "script", url: "https://example.com/install" } }) ); write("selfy.json", tool("selfy", { update: { kind: "self", args: ["update", "--yes"] } })); write("plain.json", tool("plain")); exe(path.join(shellBin, "npm")); exe(path.join(serverBin, "npm")); // npmtool: npm-managed (bin symlink into /lib/node_modules) exe(path.join(prefix, "lib", "node_modules", "npm-tool", "cli.js")); fs.mkdirSync(path.join(prefix, "bin")); fs.symlinkSync( path.join(prefix, "lib", "node_modules", "npm-tool", "cli.js"), path.join(prefix, "bin", "npmtool") ); // solo: installed outside npm exe(path.join(standalone, "solo")); fs.symlinkSync(path.join(standalone, "solo"), path.join(serverBin, "solo")); exe(path.join(serverBin, "selfy")); process.env.PATH = [serverBin, path.join(prefix, "bin")].join(path.delimiter); delete process.env.AGENTHUB_SHELL_PATH; // The login shell prints its own PATH: its npm first. await shell.hydrateShellPath({ spawn: (_c, _a, _o, cb) => { setImmediate(() => cb(null, `__AGENTHUB_PATH__${shellBin}__AGENTHUB_PATH__`)); return { stdin: { end() {} } }; }, }); // Later hydrations (at job end) must not spawn a real shell. process.env.AGENTHUB_SHELL_PATH = "0"; aiTools.__setCatalogDirs([cat, empty]); }); beforeEach(() => { spawned = []; jobs.__reset(); jobs.__setSpawn(fakeSpawn()); versions.__setExec(async (file, args) => { if (args[0] === "prefix") return { stdout: `${prefix}\n` }; if (args[0] === "view") return { stdout: args[1] === "npm-tool" ? "2.0.0\n" : "1.0.0\n" }; if (args[0] === "--version") return { stdout: "npmtool v1.4.2 (build x)\n" }; throw new Error("unexpected " + file); }); fs.rmSync(path.join(data, "ai-tools.json"), { force: true }); aiTools.refresh(); globalThis.fetch = origFetch; }); after(() => { globalThis.fetch = origFetch; process.env.PATH = saved.PATH; for (const [k, v] of [ ["DASHBOARD_DATA_DIR", saved.DATA], ["AGENTHUB_SHELL_PATH", saved.SP], ]) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } jobs.__setSpawn(null); versions.__setExec(null); aiTools.__setCatalogDirs(null); fs.rmSync(root, { recursive: true, force: true }); }); describe("stepsFor", () => { it("installs with the login shell's npm, not the server's", async () => { assert.deepEqual(await jobs.stepsFor("fresh", "install"), [ { argv: [path.join(shellBin, "npm"), "install", "-g", "fresh-pkg@latest"] }, ]); }); it("runs a self updater by its detected path", async () => { assert.deepEqual(await jobs.stepsFor("selfy", "update"), [ { argv: [path.join(serverBin, "selfy"), "update", "--yes"] }, ]); }); it("refuses what the catalog or the machine does not allow", async () => { await assert.rejects(jobs.stepsFor("nope", "install"), { code: "ENOTOOL" }); await assert.rejects(jobs.stepsFor("plain", "install"), { code: "ENOINSTALL" }); await assert.rejects(jobs.stepsFor("npmtool", "install"), { code: "EINSTALLED" }); await assert.rejects(jobs.stepsFor("plain", "update"), { code: "ENOUPDATE" }); await assert.rejects(jobs.stepsFor("fresh", "update"), { code: "ENOTINSTALLED" }); await assert.rejects(jobs.stepsFor("solo", "update"), { code: "ENOTNPM" }); }); }); describe("versions", () => { it("marks npm-managed by realpath and badges a newer version", async () => { const v = await versions.versions({ fresh: true }); assert.deepEqual(v.npmtool, { npmManaged: true, installed: "1.4.2", latest: "2.0.0", updateAvailable: true, }); assert.equal(v.solo.npmManaged, false); assert.equal(v.solo.updateAvailable, false); assert.equal(v.fresh, undefined); // not installed }); it("shows no badge when npm view fails (offline)", async () => { versions.__setExec(async (_f, args) => { if (args[0] === "prefix") return { stdout: prefix }; if (args[0] === "--version") return { stdout: "1.4.2" }; throw new Error("ENOTFOUND"); }); const v = await versions.versions({ fresh: true }); assert.equal(v.npmtool.latest, null); assert.equal(v.npmtool.updateAvailable, false); }); }); describe("jobs", () => { it("runs the argv with stdin closed and the shell PATH first, then enables the tool", async () => { aiTools.setEnabled("fresh", false); const started = await jobs.start("fresh", "install"); assert.equal(started.status, "running"); assert.equal(started.child, undefined); // The fake install does not create a binary; pretend it did before the end. exe(path.join(serverBin, "fresh")); const done = await settle(); assert.equal(done.status, "ok"); assert.equal(spawned[0].opts.stdio[0], "ignore"); assert.ok(spawned[0].opts.env.PATH.startsWith(shellBin + path.delimiter)); assert.equal(aiTools.isEnabled("fresh"), true); fs.rmSync(path.join(serverBin, "fresh")); }); it("allows one job at a time", async () => { jobs.__setSpawn(fakeSpawn({ hang: true })); jobs.__reset({ timeoutMs: 50 }); await jobs.start("fresh", "install"); await assert.rejects(jobs.start("selfy", "update"), { code: "EBUSY" }); const done = await settle(); assert.equal(done.status, "failed"); assert.match(done.log, /ETIMEDOUT/); }); it("caps the log, keeping the tail", async () => { jobs.__setSpawn(fakeSpawn({ out: "x".repeat(300 * 1024) + "END" })); await jobs.start("fresh", "install"); const done = await settle(); assert.ok(done.log.length <= 256 * 1024); assert.ok(done.log.endsWith("END")); }); it("adds a hint when npm fails with EACCES", async () => { jobs.__setSpawn(fakeSpawn({ code: 243, out: "npm error code EACCES\n" })); await jobs.start("fresh", "install"); const done = await settle(); assert.equal(done.status, "failed"); assert.equal(done.exitCode, 243); assert.match(done.log, /user-level Node/); }); it("downloads a script to a temp file, runs it with bash, then deletes it", async () => { globalThis.fetch = async (url) => ({ ok: true, status: 200, url: "https://downloads.example.com/install.sh", arrayBuffer: async () => Buffer.from("echo hi\n"), _url: url, }); await jobs.start("scripted", "install"); const done = await settle(); assert.equal(done.status, "ok"); const [bash, file] = spawned[0].argv; assert.equal(bash, "bash"); assert.equal(fs.existsSync(file), false); }); it("refuses a script that redirects off its site or is too big", async () => { globalThis.fetch = async () => ({ ok: true, status: 200, url: "https://evil.example.net/x.sh", arrayBuffer: async () => Buffer.from("x"), }); await jobs.start("scripted", "install"); let done = await settle(); assert.equal(done.status, "failed"); assert.match(done.log, /redirected off example\.com/); assert.equal(spawned.length, 0); globalThis.fetch = async () => ({ ok: true, status: 200, url: "https://example.com/install", arrayBuffer: async () => Buffer.alloc(1024 * 1024 + 1), }); await jobs.start("scripted", "install"); done = await settle(); assert.match(done.log, /larger than 1 MB/); }); it("reports the end only after re-detecting, so the first poll sees the installed tool", async () => { // A slow login-shell read at the end: the job must not look finished before it. jobs.__setHydrate(() => new Promise((r) => setTimeout(() => r(false), 40))); jobs.__setSpawn((file, args) => { spawned.push({ argv: [file, ...args] }); const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); setImmediate(() => { exe(path.join(serverBin, "fresh")); // what npm install -g would leave behind child.emit("close", 0); }); return child; }); aiTools.setEnabled("fresh", false); try { await jobs.start("fresh", "install"); const done = await settle(); assert.equal(done.status, "ok"); assert.equal(aiTools.detect("fresh").installed, true); assert.equal(aiTools.isEnabled("fresh"), true); } finally { jobs.__setHydrate(null); fs.rmSync(path.join(serverBin, "fresh"), { force: true }); } }); it("ends a timed-out job even when a grandchild keeps the pipes open", async () => { const signals = []; jobs.__setSpawn((file, args) => { spawned.push({ argv: [file, ...args] }); const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); // The direct child dies, but 'close' never comes: the pipes are still held. child.kill = (sig) => { signals.push(sig); setImmediate(() => child.emit("exit", null, sig)); }; return child; }); jobs.__reset({ timeoutMs: 30 }); await jobs.start("fresh", "install"); const done = await settle(); assert.equal(done.status, "failed"); assert.match(done.log, /ETIMEDOUT/); assert.equal(signals[0], "SIGTERM"); await assert.doesNotReject(jobs.start("selfy", "update"), "the slot is free again"); await settle(); }); it("survives a failure while finishing and still frees the slot", async (t) => { const unhandled = []; const onUnhandled = (e) => unhandled.push(e); process.on("unhandledRejection", onUnhandled); t.mock.method(aiTools, "setEnabled", () => { throw new Error("EROFS: read-only data dir"); }); try { jobs.__setSpawn(() => { const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); setImmediate(() => { exe(path.join(serverBin, "fresh")); child.emit("close", 0); }); return child; }); await jobs.start("fresh", "install"); const done = await settle(); await new Promise((r) => setTimeout(r, 20)); assert.deepEqual(unhandled, []); assert.notEqual(done.status, "running"); assert.match(done.log, /EROFS/); } finally { process.off("unhandledRejection", onUnhandled); fs.rmSync(path.join(serverBin, "fresh"), { force: true }); } }); it("update-all runs every npm-managed tool and every self updater, asking npm no version", async () => { let viewed = 0; versions.__setExec(async (_f, args) => { if (args[0] === "view") viewed++; return { stdout: args[0] === "prefix" ? `${prefix}\n` : "1.0.0\n" }; }); await jobs.startUpdateAll(); const done = await settle(); assert.equal(done.status, "ok"); assert.deepEqual( spawned.map((s) => s.argv.slice(1).join(" ")), ["install -g npm-tool@latest", "update --yes"] ); assert.equal(viewed, 0); }); });