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.
This commit is contained in:
2026-10-26 17:00:33 +07:00
parent 631ea96f19
commit 569b68ec98
13 changed files with 1318 additions and 2 deletions
+262
View File
@@ -0,0 +1,262 @@
/**
* @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");
});
});
+3
View File
@@ -80,6 +80,9 @@ const ADMIN_WRITE_PREFIXES = [
"/accounts",
// Alert rules and budgets fire for every user's sessions.
"/alerts/rules",
// Stopping, updating and restarting the host's 9router (both mounts).
"/ai-proxies/9router",
"/ai-routers/9router",
];
/**
+507
View File
@@ -0,0 +1,507 @@
/**
* @file nine-router-update.js
* @description The **Update 9router** button of Settings → AI Proxy: one
* in-memory job that shuts the host's 9router down, updates its npm package and
* starts it again hidden — in that order, because 9router's own updater relaunches
* through `npx` and kills by `ps aux | grep`, which neither respects a
* `9router.service` user unit (`Restart=always` would fight it) nor leaves a
* pane alone.
*
* Steps:
* 1. **shutdown** — disconnect every client by stopping 9router: a running
* `9router.service` user unit is `systemctl --user stop`ped; otherwise the
* 9router processes of THIS account (matched by the install's own path in
* their argv, never by name) get SIGTERM — its CLI kills its server and tray
* icon on that — then SIGKILL after a grace period, until the port is free.
* 2. **update** — `npm install -g --prefix <its own prefix> 9router@latest`, so
* the copy that is updated is the one that runs. A prefix this account
* cannot write runs the same argv under `sudo -n`, and only when sudoers
* already allows that npm without a password (probed BEFORE step 1 with
* `sudo -n <npm> --version`, so a refused update never leaves 9router
* down); otherwise
* the start is refused with the command to run by hand.
* 3. **relaunch** — only when 9router was running before step 1: the unit is
* started again, or `cli.js --tray --no-browser --skip-update -p <port>` is
* spawned detached (inside its own `systemd-run --user --scope` on Linux, so
* a dashboard restart never takes it along). A failed update still
* relaunches — the old version is better than none.
*
* Nothing here takes a command, path or package name from a request: the
* package is always `9router`, its location comes from PATH.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const net = require("node:net");
const path = require("node:path");
const { execFile, spawn } = require("node:child_process");
const aiTools = require("./ai-tools");
const versions = require("./ai-tool-versions");
const { compareVersions } = require("./update-check");
const { desktopEnv } = require("./desktop-env");
const PKG = "9router";
const UNIT = "9router.service";
const DEFAULT_PORT = 20128;
const LATEST_CACHE_MS = 10 * 60 * 1000;
const LOG_CAP = 128 * 1024;
const STEPS = ["shutdown", "update", "relaunch"];
const timing = {
installTimeoutMs: 10 * 60 * 1000,
termGraceMs: 5000,
portFreeMs: 15000,
portUpMs: 45000,
};
const makeErr = (code, message, extra = {}) =>
Object.assign(new Error(message), { code }, extra);
/** execFile as a promise that never rejects: `{code, stdout, stderr}`. */
const realExec = (file, args, opts = {}) =>
new Promise((resolve) => {
execFile(file, args, { encoding: "utf8", timeout: 10000, ...opts }, (err, stdout, stderr) =>
resolve({
code: err ? (typeof err.code === "number" ? err.code : 1) : 0,
stdout: String(stdout || ""),
stderr: String(stderr || err?.message || ""),
})
);
});
// Seams (tests): command runner, the systemd user manager, the latest-version
// lookup, and whether a relaunch may use systemd-run.
let exec = realExec;
let useSystemd = process.platform === "linux";
let fetchLatestImpl = async () => {
const r = await fetch(`https://registry.npmjs.org/${PKG}/latest`, {
signal: AbortSignal.timeout(5000),
});
if (!r.ok) return null;
const v = (await r.json())?.version;
return typeof v === "string" ? v : null;
};
let job = null;
let latestCache = null; // { at, version }
/** The env a `systemctl --user` needs; a server started outside a login lacks XDG_RUNTIME_DIR. */
function userEnv() {
const env = { ...process.env, PATH: versions.jobPath() };
if (!env.XDG_RUNTIME_DIR && typeof process.getuid === "function") {
const dir = `/run/user/${process.getuid()}`;
if (fs.existsSync(dir)) env.XDG_RUNTIME_DIR = dir;
}
return env;
}
/**
* Where 9router is installed: `{bin, cli, pkgDir, prefix, version}`, or null.
* `prefix` is null for a layout that is not an npm global install
* (`<prefix>/lib/node_modules/9router`), which this updater does not touch.
*/
function locate() {
const bin = aiTools.which(PKG, versions.jobPath());
if (!bin) return null;
let cli;
try {
cli = fs.realpathSync(bin);
} catch {
return null;
}
const pkgDir = path.dirname(cli);
let version = null;
try {
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
if (pkg.name !== PKG) return null;
version = typeof pkg.version === "string" ? pkg.version : null;
} catch {
return null;
}
const nm = path.dirname(pkgDir);
const lib = path.dirname(nm);
const prefix =
path.basename(nm) === "node_modules" && path.basename(lib) === "lib" ? path.dirname(lib) : null;
return { bin, cli, pkgDir, prefix, version };
}
/** Whether this account can write `dir`. */
function canWrite(dir) {
try {
fs.accessSync(dir, fs.constants.W_OK);
return true;
} catch {
return false;
}
}
const npmArgs = (prefix) => ["install", "-g", "--prefix", prefix, "--prefer-online", `${PKG}@latest`];
/** `direct` (we can write the prefix), `sudo` (passwordless sudo allows it), or null. */
async function installMode(loc, npm) {
if (canWrite(path.join(loc.prefix, "lib", "node_modules")) && canWrite(path.join(loc.prefix, "bin")))
return "direct";
// `sudo -l` would answer yes for a rule that still asks for a password, so
// the probe is a harmless real run of the same npm.
const r = await exec("sudo", ["-n", "--", npm, "--version"], { timeout: 10000 });
return r.code === 0 ? "sudo" : null;
}
/** Whether the `9router.service` user unit is running. */
async function unitActive() {
if (!useSystemd) return false;
const r = await exec("systemctl", ["--user", "is-active", "--quiet", UNIT], {
env: userEnv(),
timeout: 5000,
});
return r.code === 0;
}
/**
* This account's 9router processes — those whose argv names the install itself.
* `roots` are its CLI (argv carries cli.js or the bin link): SIGTERM lets each
* stop its own server and tray; `all` adds their descendants, for the SIGKILL.
*/
async function findProcesses(loc) {
const uid = typeof process.getuid === "function" ? String(process.getuid()) : null;
const r = await exec("ps", ["-ww", ...(uid ? ["-U", uid] : ["-e"]), "-o", "pid=,ppid=,args="], {
timeout: 5000,
});
const rows = [];
for (const line of r.stdout.split("\n")) {
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
if (m && Number(m[1]) !== process.pid)
rows.push({ pid: Number(m[1]), ppid: Number(m[2]), argv: m[3].split(/\s+/) });
}
const roots = [];
let port = null;
let host = null;
for (const { pid, argv } of rows) {
if (!argv.some((a) => a === loc.cli || a === loc.bin)) continue;
roots.push(pid);
const pi = argv.findIndex((a) => a === "-p" || a === "--port");
if (pi >= 0 && /^\d{2,5}$/.test(argv[pi + 1] || "")) port = Number(argv[pi + 1]);
const hi = argv.findIndex((a) => a === "-H" || a === "--host");
if (hi >= 0 && /^[\w.:-]+$/.test(argv[hi + 1] || "")) host = argv[hi + 1];
}
// Its server renames itself (`next-server`), so children are found by parent,
// plus anything still running from inside the install.
const all = new Set(roots);
for (const { pid, argv } of rows) if (argv.some((a) => a.startsWith(loc.pkgDir + path.sep))) all.add(pid);
for (let grew = true; grew; ) {
grew = false;
for (const { pid, ppid } of rows)
if (all.has(ppid) && !all.has(pid)) {
all.add(pid);
grew = true;
}
}
return { roots, all: [...all], port, host };
}
/** Whether something answers on 127.0.0.1:`port`. */
const portOpen = (port) =>
new Promise((resolve) => {
const s = net.connect({ host: "127.0.0.1", port });
const done = (v) => {
s.destroy();
resolve(v);
};
s.setTimeout(1000, () => done(false));
s.once("connect", () => done(true));
s.once("error", () => done(false));
});
async function waitFor(check, ms) {
const until = Date.now() + ms;
for (;;) {
if (await check()) return true;
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, 300));
}
}
const alive = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
const signal = (pids, sig) => {
for (const pid of pids) {
try {
process.kill(pid, sig);
} catch {
/* gone already */
}
}
};
async function latestVersion({ fresh = false } = {}) {
if (!fresh && latestCache && Date.now() - latestCache.at < LATEST_CACHE_MS) return latestCache.version;
let version = null;
try {
version = await fetchLatestImpl();
} catch {
version = null;
}
latestCache = { at: Date.now(), version };
return version;
}
/** The public view of the job (a copy). */
const view = (j) => (j ? JSON.parse(JSON.stringify(j)) : null);
/**
* What the page shows: `{installed, version, latest, updateAvailable, running,
* managedBy, blocker, job}`. `blocker` (`{code, message, command?}`) is why a
* start would be refused right now; `command` is the one to run by hand.
*/
async function status({ fresh = false } = {}) {
const loc = locate();
if (!loc) {
return { installed: false, version: null, latest: null, updateAvailable: false, running: false, managedBy: null, blocker: { code: "ENOTINSTALLED", message: "9router is not installed on this machine" }, job: view(job) };
}
const [latest, unit, procs] = await Promise.all([
latestVersion({ fresh }),
unitActive(),
findProcesses(loc),
]);
const updateAvailable = Boolean(latest && loc.version && compareVersions(latest, loc.version) > 0);
const manual = `sudo npm install -g ${PKG}@latest`;
let blocker = null;
if (process.platform === "win32") {
blocker = { code: "EUNSUPPORTED", message: "updating 9router from AgentHub is not supported on Windows" };
} else if (!loc.prefix) {
blocker = { code: "EUNSUPPORTED", message: `9router at ${loc.pkgDir} is not an npm global install` };
} else if (!updateAvailable) {
blocker = latest
? { code: "EUPTODATE", message: `9router ${loc.version} is the latest version` }
: { code: "ENOLATEST", message: "the npm registry could not be reached" };
} else {
const npm = versions.npmBin();
if (!npm) blocker = { code: "ENONPM", message: "npm was not found on the login shell's PATH" };
else if (!(await installMode(loc, npm)))
blocker = {
code: "ENOPERM",
message: `${loc.prefix} is not writable by this account and sudo needs a password`,
command: manual,
};
}
return {
installed: true,
version: loc.version,
latest,
updateAvailable,
running: unit || procs.roots.length > 0,
managedBy: unit ? "systemd" : procs.roots.length ? "process" : null,
blocker,
job: view(job),
};
}
function step(id) {
return job.steps.find((s) => s.id === id);
}
function log(text) {
job.log = (job.log + text).slice(-LOG_CAP);
}
function setStep(id, state, message) {
const s = step(id);
s.status = state;
if (message !== undefined) s.message = message;
}
async function doShutdown(loc, unit, procs) {
if (unit) {
const r = await exec("systemctl", ["--user", "stop", UNIT], { env: userEnv(), timeout: 30000 });
if (r.code !== 0) throw makeErr("ESTOP", `systemctl --user stop ${UNIT}: ${r.stderr.trim()}`);
} else {
signal(procs.roots, "SIGTERM");
await waitFor(async () => !procs.roots.some(alive), timing.termGraceMs);
// Whatever survived (and any child the CLI did not take down), by force.
const left = (await findProcesses(loc)).all;
signal([...new Set([...procs.all, ...left])].filter(alive), "SIGKILL");
}
const port = procs.port || DEFAULT_PORT;
if (!(await waitFor(async () => !(await portOpen(port)), timing.portFreeMs)))
throw makeErr("EPORTBUSY", `port ${port} is still in use after 9router stopped`);
}
/** Run npm, streaming its output into the job log. Resolves its exit code. */
function runInstall(argv) {
return new Promise((resolve) => {
log(`$ ${argv.join(" ")}\n`);
const child = spawn(argv[0], argv.slice(1), {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, PATH: versions.jobPath() },
detached: true,
});
const timer = setTimeout(() => {
log("\n[agenthub] npm timed out\n");
try {
process.kill(-child.pid, "SIGKILL");
} catch {
/* gone */
}
}, timing.installTimeoutMs);
child.stdout.on("data", (d) => log(String(d)));
child.stderr.on("data", (d) => log(String(d)));
child.on("error", (e) => {
clearTimeout(timer);
log(`\n${e.message}\n`);
resolve(1);
});
child.on("close", (code) => {
clearTimeout(timer);
resolve(code ?? 1);
});
});
}
/** Start 9router hidden, detached from this server. */
async function launchHidden(loc, procs) {
const node = aiTools.which("node", versions.jobPath()) || process.execPath;
const port = procs.port || DEFAULT_PORT;
const args = ["--dns-result-order=ipv4first", loc.cli, "--tray", "--no-browser", "--skip-update", "-p", String(port)];
if (procs.host) args.push("-H", procs.host);
const env = { ...userEnv(), ...desktopEnv() };
delete env.TRAY_MODE;
const spawnDetached = (file, argv) => {
const child = spawn(file, argv, { detached: true, stdio: "ignore", env, cwd: loc.pkgDir });
child.unref();
return child;
};
const systemdRun = useSystemd && env.XDG_RUNTIME_DIR && aiTools.which("systemd-run", env.PATH);
if (systemdRun) {
const child = spawnDetached(systemdRun, ["--user", "--scope", "--quiet", "--collect", "--description=9Router", "--", node, ...args]);
// A scope that cannot be created exits at once; anything else is 9router running.
const failed = await new Promise((resolve) => {
const t = setTimeout(() => resolve(false), 1500);
child.once("exit", (code) => {
clearTimeout(t);
resolve(code !== 0);
});
child.once("error", () => {
clearTimeout(t);
resolve(true);
});
});
if (!failed) return port;
log("[agenthub] systemd-run --user --scope failed; starting 9router directly\n");
}
spawnDetached(node, args);
return port;
}
async function doRelaunch(loc, unit, procs) {
let port = procs.port || DEFAULT_PORT;
if (unit) {
const r = await exec("systemctl", ["--user", "start", UNIT], { env: userEnv(), timeout: 30000 });
if (r.code !== 0) throw makeErr("ESTART", `systemctl --user start ${UNIT}: ${r.stderr.trim()}`);
} else {
port = await launchHidden(loc, procs);
}
if (!(await waitFor(() => portOpen(port), timing.portUpMs)))
throw makeErr("ENOTUP", `9router did not answer on port ${port}`);
}
async function runJob(loc, npm, mode, unit, procs) {
const wasRunning = unit || procs.roots.length > 0;
let failed = false;
if (!wasRunning) setStep("shutdown", "skipped", "9router was not running");
else {
setStep("shutdown", "running");
try {
await doShutdown(loc, unit, procs);
setStep("shutdown", "done");
} catch (e) {
// Still running (or unknown): updating underneath it is not safe.
setStep("shutdown", "failed", e.message);
setStep("update", "skipped");
failed = true;
}
}
if (!failed) {
setStep("update", "running");
const argv = [npm, ...npmArgs(loc.prefix)];
const code = await runInstall(mode === "sudo" ? ["sudo", "-n", "--", ...argv] : argv);
const after = locate();
job.to = after?.version || null;
if (code === 0) setStep("update", "done", job.to ? `${job.from} → ${job.to}` : undefined);
else {
setStep("update", "failed", `npm exited with code ${code}`);
failed = true;
}
}
if (!wasRunning) setStep("relaunch", "skipped", "9router was not running before the update");
else {
setStep("relaunch", "running");
try {
await doRelaunch(locate() || loc, unit, procs);
setStep("relaunch", "done");
} catch (e) {
setStep("relaunch", "failed", e.message);
failed = true;
}
}
job.status = failed ? "failed" : "done";
job.finishedAt = new Date().toISOString();
latestCache = null;
}
/** Start the update; refuses (with the status blocker's code) when it cannot run. */
async function start() {
if (job?.status === "running") throw makeErr("EBUSY", "a 9router update is already running");
const s = await status({ fresh: true });
if (job?.status === "running") throw makeErr("EBUSY", "a 9router update is already running");
if (s.blocker) throw makeErr(s.blocker.code, s.blocker.message, s.blocker.command ? { command: s.blocker.command } : {});
const loc = locate();
const npm = versions.npmBin();
const mode = await installMode(loc, npm);
if (!mode) throw makeErr("ENOPERM", "the npm prefix is not writable and sudo needs a password");
const unit = await unitActive();
const procs = await findProcesses(loc);
job = {
status: "running",
startedAt: new Date().toISOString(),
finishedAt: null,
from: loc.version,
to: null,
managedBy: unit ? "systemd" : procs.roots.length ? "process" : null,
install: mode,
steps: STEPS.map((id) => ({ id, status: "pending", message: null })),
log: "",
};
runJob(loc, npm, mode, unit, procs).catch((e) => {
job.status = "failed";
job.finishedAt = new Date().toISOString();
log(`\n${e.message}\n`);
});
return view(job);
}
const current = () => view(job);
function __setSeams({ exec: e, useSystemd: u, fetchLatest, timing: t } = {}) {
if (e) exec = e;
if (u !== undefined) useSystemd = u;
if (fetchLatest) fetchLatestImpl = fetchLatest;
if (t) Object.assign(timing, t);
latestCache = null;
}
function __reset() {
job = null;
latestCache = null;
exec = realExec;
}
module.exports = { status, start, current, locate, STEPS, __setSeams, __reset };
+24
View File
@@ -324,6 +324,30 @@ const paths = {
},
},
},
"/api/ai-proxies/9router/update": {
get: {
tags: ["AI Proxies"],
summary: "9router update status",
description:
"Installed vs latest npm version of the host's 9router, whether it runs (`managedBy`: the `9router.service` user unit or a plain process), `blocker` — why a start would be refused now (`ENOTINSTALLED`, `EUPTODATE`, `ENOLATEST`, `ENONPM`, `ENOPERM` with the `command` to run by hand, `EUNSUPPORTED`) — and the current/last job. `?fresh=1` re-asks the registry (cached 10 min).",
operationId: "getAiProxy9routerUpdate",
parameters: [{ name: "fresh", in: "query", schema: { type: "string", enum: ["1"] } }],
responses: { 200: json({ type: "object" }, "Status.") },
},
post: {
tags: ["AI Proxies"],
summary: "Update 9router",
description:
"Starts the one in-memory job: shut 9router down (stop the unit, or SIGTERM→SIGKILL its processes until the port is free), `npm install -g --prefix <its prefix> 9router@latest` (under `sudo -n` only when the prefix is not writable and sudo needs no password), then — only if it was running — start it again hidden (`--tray --no-browser --skip-update`). A failed update still relaunches. Admin only; same-origin guarded. Poll the GET for the steps.",
operationId: "updateAiProxy9router",
responses: {
202: json({ type: "object" }, "The job, running."),
403: errResp("`EADMIN`."),
409: errResp("`EBUSY` or the status `blocker` code."),
502: errResp("`ENOLATEST` — the npm registry did not answer."),
},
},
},
"/api/ai-proxies/test": {
post: {
tags: ["AI Proxies"],
+37 -1
View File
@@ -24,6 +24,9 @@
*
* `GET /9router` only says whether a `9router` binary is on the server's PATH,
* so the page can offer a link to its own config UI; it never runs it.
* `GET|POST /9router/update` reports on / starts the **Update 9router** job
* (`server/lib/nine-router-update.js`: shut down, `npm install -g`, start again
* hidden) — the host's own 9router, so starting one is an admin's only.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -36,6 +39,7 @@ const { refreshProfileFiles, removeProfileFiles } = require("../lib/proxy-apply"
const { sameOriginGuard } = require("../lib/same-origin");
const { which } = require("../lib/ai-tools");
const { shellPath } = require("../lib/shell-path");
const nineRouterUpdate = require("../lib/nine-router-update");
const router = express.Router();
@@ -49,6 +53,13 @@ const ERR_TO_STATUS = {
EBUILTIN: 409,
ENOTFOUND: 404,
EADMIN: 403,
EBUSY: 409,
ENOTINSTALLED: 409,
EUPTODATE: 409,
ENOLATEST: 502,
ENONPM: 409,
ENOPERM: 409,
EUNSUPPORTED: 409,
};
const notFound = (name) =>
@@ -68,7 +79,9 @@ function assertMayWrite(req, name) {
function fail(res, e) {
const status = ERR_TO_STATUS[e.code] || 500;
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
const error = { code: e.code || "EINTERNAL", message: e.message };
if (e.command) error.command = e.command;
return res.status(status).json({ error });
}
// GET /api/ai-proxies — every profile, built-in `native` first. Never a key.
@@ -87,6 +100,29 @@ router.get("/9router", (_req, res) => {
res.json({ installed: Boolean(which("9router", pathStr)) });
});
// GET /api/ai-proxies/9router/update — installed vs latest version, whether
// it runs (and under what), why an update would be refused now (`blocker`),
// and the last/current update job. `?fresh=1` re-asks the npm registry.
router.get("/9router/update", async (req, res) => {
try {
res.json(await nineRouterUpdate.status({ fresh: req.query.fresh === "1" }));
} catch (e) {
return fail(res, e);
}
});
// POST /api/ai-proxies/9router/update — start the job; 202 with it. Poll the
// GET above for its steps. Admin only (also in auth.js ADMIN_WRITE_PREFIXES).
router.post("/9router/update", sameOriginGuard, async (req, res) => {
try {
if (req.user && req.user.role !== "admin")
throw Object.assign(new Error("only an admin may update 9router"), { code: "EADMIN" });
res.status(202).json(await nineRouterUpdate.start());
} catch (e) {
return fail(res, e);
}
});
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?,
// behavesAs?, scope? }. `scope` is "shared" (an admin's default) or "personal"
// (a member's only choice); local mode has no user and always writes shared.