/** * @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 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 --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 ` 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ĩ */ 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 * (`/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; } } // Root's npm cache for a sudo run. Never root's HOME: the AgentHub unit makes // `/root` inaccessible (`InaccessiblePaths=`), so `mkdir /root/.npm` fails with // ENOENT (npm exit 254). A root-owned path, never one in /tmp a user could plant. const SUDO_NPM_CACHE = "/var/cache/agenthub-9router-npm"; const npmArgs = (prefix, sudo = false) => [ "install", "-g", "--prefix", prefix, "--prefer-online", ...(sudo ? ["--cache", SUDO_NPM_CACHE] : []), `${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, mode === "sudo")]; 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 };