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.
This commit is contained in:
2026-10-26 17:12:02 +07:00
parent 272a3e7d6e
commit df15789982
3 changed files with 133 additions and 29 deletions
+57 -12
View File
@@ -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));
});