c5aba094bd
- AI Tools moves from a Settings section into a modal behind a button at the Settings tab bar's right edge (AiToolsModal, `actions` on PageTabs, /settings#ai-tools); rows get an enable switch, Default badge, and an Update button for every tool whose catalog names an updater (no version lookup on open); not-installed tools fold behind a disclosure, a finished job's log folds to one line; shared AiToolIcon - codex updates through its own `codex update`, openclaw/hermes gain `update --yes`; update-all runs every installed updater and skips an npm one only when npm does not manage the binary - Add-lane tool picker is one button showing the chosen tool that opens the enabled tools as buttons - Claude Code / Codex AgentHub sub-tab folded into Overview (home editor, hooks, pricing); the path list drops CLAUDE_HOME/CODEX_HOME; HomeDirSetting shows the saved value only while the field differs - Preview/Raw toggle for markdown files in the config FileViewer and the Changes panel's whole-file view (MarkdownPreview: react-markdown + remark-gfm, raw HTML never run, frontmatter as a code block) - docs and the CLAUDE.md marker updated
390 lines
14 KiB
JavaScript
390 lines
14 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 <prefix>/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);
|
|
});
|
|
});
|