Files
AgentHub/server/__tests__/nine-router-update.test.js
T
nntrivi2001 569b68ec98 feat(ai-proxy): Update 9router button — shut down, npm update, relaunch hidden
Settings → AI Proxy gains an admin-only Update 9router button beside
Config 9router. One in-memory job (server/lib/nine-router-update.js) stops
9router (its user unit, else this account's own 9router processes), runs
npm install -g --prefix <its prefix> 9router@latest (sudo -n only when
sudoers already allows it, probed before anything stops), then relaunches
it hidden only if it was running. A failed update still relaunches the old
version. Nothing in the request names a command, path or package.
2026-10-26 17:00:53 +07:00

263 lines
9.6 KiB
JavaScript

/**
* @file nine-router-update.test.js
* @description The **Update 9router** job (`server/lib/nine-router-update.js`)
* and its routes, against a FAKE 9router: a throwaway npm prefix whose `cli.js`
* listens on a free port and keeps a renamed child, and a fake `npm` that bumps
* its version. PATH is cut down so the machine's real 9router can never be
* found, let alone stopped — every test first checks the located install is
* the fake one. No systemd: the unit path is a seam turned off here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "nine-router-update-"));
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
const PREFIX = path.join(TMP, "prefix");
const PKG_DIR = path.join(PREFIX, "lib", "node_modules", "9router");
const BIN_DIR = path.join(PREFIX, "bin");
const NPM_DIR = path.join(TMP, "npmbin");
const ORIGINAL_PATH = process.env.PATH;
const update = require("../lib/nine-router-update");
const express = require("express");
const router = require("../routes/ai-proxies");
const CLI = `#!/usr/bin/env node
const net = require("net");
const { spawn } = require("child_process");
const i = process.argv.indexOf("-p");
const port = Number(process.argv[i + 1]);
// A child that renames itself, like 9router's next-server.
const child = spawn(process.execPath, ["-e", "process.title='fake-next-server'; setInterval(()=>{},1e6)"], { stdio: "ignore" });
const srv = net.createServer((s) => s.end()).listen(port, "127.0.0.1");
process.on("SIGTERM", () => { try { child.kill("SIGKILL"); } catch {} srv.close(); process.exit(0); });
setInterval(() => {}, 1e6);
`;
const NPM = `#!/bin/sh
# fake npm: --version, or install -g --prefix P ... 9router@latest
if [ "$1" = "--version" ]; then echo 10.0.0; exit 0; fi
while [ $# -gt 0 ]; do
if [ "$1" = "--prefix" ]; then P="$2"; shift; fi
shift
done
[ -f "${TMP}/npm-fail" ] && { echo "boom" >&2; exit 1; }
echo '{"name":"9router","version":"9.9.9"}' > "$P/lib/node_modules/9router/package.json"
echo installed
`;
function freePort() {
return new Promise((resolve) => {
const s = net.createServer().listen(0, "127.0.0.1", () => {
const { port } = s.address();
s.close(() => resolve(port));
});
});
}
const portOpen = (port) =>
new Promise((resolve) => {
const s = net.connect({ host: "127.0.0.1", port });
s.once("connect", () => (s.destroy(), resolve(true)));
s.once("error", () => resolve(false));
});
async function until(fn, ms = 20000) {
const end = Date.now() + ms;
while (Date.now() < end) {
if (await fn()) return true;
await new Promise((r) => setTimeout(r, 100));
}
return false;
}
/** pids whose argv names the fake install — what a test must clean up. */
function fakePids() {
const out = [];
for (const d of fs.readdirSync("/proc")) {
if (!/^\d+$/.test(d)) continue;
try {
const argv = fs.readFileSync(`/proc/${d}/cmdline`, "utf8");
if (argv.includes(TMP) || argv.includes("fake-next-server")) out.push(Number(d));
} catch {
/* gone */
}
}
return out.filter((p) => p !== process.pid);
}
function killFakes() {
for (const pid of fakePids()) {
try {
process.kill(pid, "SIGKILL");
} catch {
/* gone */
}
}
}
function startFake(port) {
const child = spawn(process.execPath, [path.join(PKG_DIR, "cli.js"), "--tray", "-p", String(port)], {
stdio: "ignore",
detached: true,
});
child.unref();
return child;
}
async function finished() {
await until(() => update.current()?.status !== "running", 60000);
return update.current();
}
let server;
let BASE;
before(async () => {
fs.mkdirSync(PKG_DIR, { recursive: true });
fs.mkdirSync(BIN_DIR, { recursive: true });
fs.mkdirSync(NPM_DIR, { recursive: true });
fs.writeFileSync(path.join(PKG_DIR, "cli.js"), CLI, { mode: 0o755 });
fs.symlinkSync("../lib/node_modules/9router/cli.js", path.join(BIN_DIR, "9router"));
fs.writeFileSync(path.join(NPM_DIR, "npm"), NPM, { mode: 0o755 });
// Only the fake bins, node's own dir and the basics — never /usr/local/bin.
process.env.PATH = [BIN_DIR, NPM_DIR, path.dirname(process.execPath), "/bin", "/usr/bin"].join(":");
const app = express();
app.use(express.json());
app.use("/api/ai-proxies", router);
await new Promise((resolve) => {
server = app.listen(0, "127.0.0.1", resolve);
});
BASE = `http://127.0.0.1:${server.address().port}`;
});
beforeEach(() => {
killFakes();
update.__reset();
fs.writeFileSync(path.join(PKG_DIR, "package.json"), JSON.stringify({ name: "9router", version: "1.0.0" }));
fs.rmSync(path.join(TMP, "npm-fail"), { force: true });
update.__setSeams({
useSystemd: false,
fetchLatest: async () => "2.0.0",
timing: { termGraceMs: 2000, portFreeMs: 5000, portUpMs: 15000 },
});
const loc = update.locate();
assert.ok(loc && loc.pkgDir.startsWith(TMP), `refusing to run against ${loc?.pkgDir}`);
});
after(() => {
killFakes();
process.env.PATH = ORIGINAL_PATH;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("nine-router-update", () => {
it("reports the install, the latest version and that nothing runs", async () => {
const s = await update.status();
assert.equal(s.installed, true);
assert.equal(s.version, "1.0.0");
assert.equal(s.latest, "2.0.0");
assert.equal(s.updateAvailable, true);
assert.equal(s.running, false);
assert.equal(s.blocker, null);
});
it("refuses when already on the latest version", async () => {
update.__setSeams({ fetchLatest: async () => "1.0.0" });
const s = await update.status();
assert.equal(s.blocker.code, "EUPTODATE");
await assert.rejects(update.start(), { code: "EUPTODATE" });
});
it("refuses with a manual command when the prefix is not writable and sudo asks", async () => {
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o555);
update.__setSeams({
exec: async (file, args) =>
file === "sudo" ? { code: 1, stdout: "", stderr: "a password is required" } : { code: 0, stdout: "", stderr: "" },
});
try {
if (process.getuid?.() === 0) return; // root writes anyway
await assert.rejects(update.start(), (e) => e.code === "ENOPERM" && /sudo npm install -g 9router@latest/.test(e.command));
assert.equal(update.current(), null, "no job, so 9router was never stopped");
} finally {
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o755);
}
});
it("stops a running 9router (child included), updates it, and starts it again hidden", async () => {
const port = await freePort();
startFake(port);
assert.ok(await until(() => portOpen(port)), "fake 9router came up");
assert.ok(await until(() => fakePids().length >= 2), "with its child");
const before = fakePids();
const job = await update.start();
assert.equal(job.status, "running");
assert.equal(job.managedBy, "process");
const done = await finished();
assert.equal(done.status, "done", done.log + JSON.stringify(done.steps));
assert.deepEqual(done.steps.map((s) => s.status), ["done", "done", "done"]);
assert.equal(done.from, "1.0.0");
assert.equal(done.to, "9.9.9");
for (const pid of before) assert.ok(!fakePids().includes(pid), `old pid ${pid} is gone`);
assert.ok(await portOpen(port), "relaunched on the same port");
// Relaunched hidden: tray mode, no browser, no self-update.
const argv = fakePids()
.map((p) => fs.readFileSync(`/proc/${p}/cmdline`, "utf8").split("\0"))
.find((a) => a.includes(path.join(PKG_DIR, "cli.js")));
for (const flag of ["--tray", "--no-browser", "--skip-update"]) assert.ok(argv.includes(flag), flag);
});
it("leaves 9router off when it was not running", async () => {
await update.start();
const done = await finished();
assert.equal(done.status, "done");
assert.deepEqual(done.steps.map((s) => s.status), ["skipped", "done", "skipped"]);
assert.equal(fakePids().length, 0);
});
it("still relaunches the old version when npm fails", async () => {
fs.writeFileSync(path.join(TMP, "npm-fail"), "");
const port = await freePort();
startFake(port);
assert.ok(await until(() => portOpen(port)));
await update.start();
const done = await finished();
assert.equal(done.status, "failed");
assert.deepEqual(done.steps.map((s) => s.status), ["done", "failed", "done"]);
assert.match(done.log, /boom/);
assert.ok(await portOpen(port));
});
it("refuses a second start while one runs", async () => {
const port = await freePort();
startFake(port);
assert.ok(await until(() => portOpen(port)));
await update.start();
await assert.rejects(update.start(), { code: "EBUSY" });
await finished();
});
});
describe("routes /api/ai-proxies/9router/update", () => {
it("GET reports status, POST starts and answers 202, a refusal is a 409", async () => {
const s = await (await fetch(`${BASE}/api/ai-proxies/9router/update`)).json();
assert.equal(s.version, "1.0.0");
const r = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" });
assert.equal(r.status, 202);
await finished();
update.__setSeams({ fetchLatest: async () => "9.9.9" });
const again = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" });
assert.equal(again.status, 409);
assert.equal((await again.json()).error.code, "EUPTODATE");
});
});