df15789982
The AgentHub unit makes /root inaccessible (InaccessiblePaths=), so root's npm failed at mkdir /root/.npm with ENOENT (exit 254). A sudo run now passes --cache /var/cache/agenthub-9router-npm; the sudoers rule names that argv.
567 lines
18 KiB
JavaScript
567 lines
18 KiB
JavaScript
/**
|
|
* @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;
|
|
}
|
|
}
|
|
|
|
// 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 };
|