From df157899820fbc5195c7167210da25b43da112af Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 26 Oct 2026 17:12:02 +0700 Subject: [PATCH] fix(9router): a sudo update gives root's npm its own cache 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. --- docs/API.md | 2 +- server/__tests__/nine-router-update.test.js | 69 +++++++++++++--- server/lib/nine-router-update.js | 91 +++++++++++++++++---- 3 files changed, 133 insertions(+), 29 deletions(-) diff --git a/docs/API.md b/docs/API.md index 4cbf0740..785f5705 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3059,7 +3059,7 @@ A **proxy profile** is an endpoint that stands in front of Claude Code or Codex **Update 9router** (`server/lib/nine-router-update.js`; the AI Proxy page's button beside Config 9router, an admin's only — `/ai-proxies/9router` and `/ai-routers/9router` are in `ADMIN_WRITE_PREFIXES`). `GET /api/ai-proxies/9router/update` answers `{ installed, version, latest, updateAvailable, running, managedBy: "systemd"|"process"|null, blocker: {code, message, command?}|null, job }`. `POST` starts one job (202; `EBUSY` while one runs, else 409 with the blocker's code — checked BEFORE anything is stopped) of three steps, each `pending|running|done|failed|skipped`: 1. **shutdown** — a running `9router.service` user unit is `systemctl --user stop`ped; otherwise this account's processes whose argv names the install's `cli.js` get SIGTERM, then they and their descendants SIGKILL after 5 s; the step waits until the port is free. Skipped when nothing ran. -2. **update** — `npm install -g --prefix --prefer-online 9router@latest`; a prefix this account cannot write runs it under `sudo -n`, only when `sudo -n --version` succeeds (else `ENOPERM` with `command: "sudo npm install -g 9router@latest"`). +2. **update** — `npm install -g --prefix --prefer-online 9router@latest`; a prefix this account cannot write runs it under `sudo -n` with `--cache /var/cache/agenthub-9router-npm` (root's HOME is inaccessible inside the AgentHub unit; the sudoers rule must name that exact argv), only when `sudo -n --version` succeeds (else `ENOPERM` with `command: "sudo npm install -g 9router@latest"`). 3. **relaunch** — only when 9router was running before: `systemctl --user start 9router.service`, or `node cli.js --tray --no-browser --skip-update -p [-H ]` detached in its own `systemd-run --user --scope` (plain detached spawn without one); done once the port answers. Runs even after a failed update. **`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx. diff --git a/server/__tests__/nine-router-update.test.js b/server/__tests__/nine-router-update.test.js index 8fa5467b..308729ba 100644 --- a/server/__tests__/nine-router-update.test.js +++ b/server/__tests__/nine-router-update.test.js @@ -104,10 +104,14 @@ function killFakes() { } function startFake(port) { - const child = spawn(process.execPath, [path.join(PKG_DIR, "cli.js"), "--tray", "-p", String(port)], { - stdio: "ignore", - detached: true, - }); + const child = spawn( + process.execPath, + [path.join(PKG_DIR, "cli.js"), "--tray", "-p", String(port)], + { + stdio: "ignore", + detached: true, + } + ); child.unref(); return child; } @@ -128,7 +132,9 @@ before(async () => { 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(":"); + 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); @@ -141,7 +147,10 @@ before(async () => { beforeEach(() => { killFakes(); update.__reset(); - fs.writeFileSync(path.join(PKG_DIR, "package.json"), JSON.stringify({ name: "9router", version: "1.0.0" })); + 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, @@ -181,17 +190,43 @@ describe("nine-router-update", () => { 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: "" }, + 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)); + 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("a sudo update gives root's npm its own cache, never root's HOME", async () => { + if (process.getuid?.() === 0) return; // root writes the prefix directly + fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o555); + // A fake sudo first on PATH: drops `-n --` and runs the rest as us. + fs.writeFileSync(path.join(NPM_DIR, "sudo"), '#!/bin/sh\nshift; shift; exec "$@"\n', { + mode: 0o755, + }); + update.__setSeams({ exec: async () => ({ code: 0, stdout: "", stderr: "" }) }); + try { + await update.start(); + const done = await finished(); + assert.match( + done.log, + /\$ sudo -n -- \S+ install -g --prefix \S+ --prefer-online --cache \/var\/cache\/agenthub-9router-npm 9router@latest/ + ); + } finally { + fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o755); + fs.rmSync(path.join(NPM_DIR, "sudo"), { force: true }); + } + }); + it("stops a running 9router (child included), updates it, and starts it again hidden", async () => { const port = await freePort(); startFake(port); @@ -204,7 +239,10 @@ describe("nine-router-update", () => { 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.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`); @@ -213,14 +251,18 @@ describe("nine-router-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); + 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.deepEqual( + done.steps.map((s) => s.status), + ["skipped", "done", "skipped"] + ); assert.equal(fakePids().length, 0); }); @@ -232,7 +274,10 @@ describe("nine-router-update", () => { await update.start(); const done = await finished(); assert.equal(done.status, "failed"); - assert.deepEqual(done.steps.map((s) => s.status), ["done", "failed", "done"]); + assert.deepEqual( + done.steps.map((s) => s.status), + ["done", "failed", "done"] + ); assert.match(done.log, /boom/); assert.ok(await portOpen(port)); }); diff --git a/server/lib/nine-router-update.js b/server/lib/nine-router-update.js index 5f35fee9..d938a5af 100644 --- a/server/lib/nine-router-update.js +++ b/server/lib/nine-router-update.js @@ -56,8 +56,7 @@ const timing = { portUpMs: 45000, }; -const makeErr = (code, message, extra = {}) => - Object.assign(new Error(message), { code }, extra); +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 = {}) => @@ -137,11 +136,27 @@ function canWrite(dir) { } } -const npmArgs = (prefix) => ["install", "-g", "--prefix", prefix, "--prefer-online", `${PKG}@latest`]; +// 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"))) + 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. @@ -189,8 +204,9 @@ async function findProcesses(loc) { // 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; ) { + 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)) { @@ -243,7 +259,8 @@ const signal = (pids, sig) => { }; async function latestVersion({ fresh = false } = {}) { - if (!fresh && latestCache && Date.now() - latestCache.at < LATEST_CACHE_MS) return latestCache.version; + if (!fresh && latestCache && Date.now() - latestCache.at < LATEST_CACHE_MS) + return latestCache.version; let version = null; try { version = await fetchLatestImpl(); @@ -265,20 +282,37 @@ const view = (j) => (j ? JSON.parse(JSON.stringify(j)) : null); 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) }; + 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 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" }; + 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` }; + 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` } @@ -370,7 +404,15 @@ function runInstall(argv) { 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)]; + 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; @@ -381,7 +423,16 @@ async function launchHidden(loc, procs) { }; 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]); + 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); @@ -404,7 +455,10 @@ async function launchHidden(loc, procs) { 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 }); + 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); @@ -431,7 +485,7 @@ async function runJob(loc, npm, mode, unit, procs) { } if (!failed) { setStep("update", "running"); - const argv = [npm, ...npmArgs(loc.prefix)]; + 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; @@ -462,7 +516,12 @@ 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 } : {}); + 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);