84e2a9f0ac
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
282 lines
10 KiB
JavaScript
282 lines
10 KiB
JavaScript
/**
|
|
* @file server-s3-system.test.js
|
|
* @description S3 plan, P2 (one Linux user per account), with every sudo and
|
|
* systemctl call faked: the system-unit service manager (host-owned env file,
|
|
* sudo for start/stop/restart, pure-fs staleness), service-manager picking it,
|
|
* Quit stopping it through sudo, the Linux-account layer (server.conf, name
|
|
* rule, the exact provision argv), user create provisioning FIRST, disable
|
|
* ending the logins first (and locking only an account that exists), and a
|
|
* member's bootstrap doing nothing.
|
|
* @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 path = require("node:path");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-system-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
|
process.env.AGENTHUB_SYSTEM_UNIT_DIR = path.join(TMP, "units");
|
|
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
|
// `lin` has a Linux account; `pre` (made before S3) and `Old.Name` do not.
|
|
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
|
fs.writeFileSync(process.env.AGENTHUB_PASSWD, "ah-lin:x:5101:5101::/:/bin/sh\n");
|
|
|
|
const sys = require("../../scripts/systemd-system-service");
|
|
const linux = require("../lib/linux-accounts");
|
|
const auth = require("../lib/auth");
|
|
const { db } = require("../db");
|
|
|
|
const UNIT = path.join(process.env.AGENTHUB_SYSTEM_UNIT_DIR, "agenthub.service");
|
|
const writeUnit = () => {
|
|
fs.mkdirSync(path.dirname(UNIT), { recursive: true });
|
|
fs.writeFileSync(UNIT, `[Unit]\n${sys.MARKER}\n`);
|
|
};
|
|
const writeConf = (uid = 4242) => {
|
|
linux.__resetConfCache();
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_SERVER_CONF,
|
|
`HOST_USER=host\nHOST_UID=${uid}\nUSERS_ROOT=/srv/agenthub/users\nPORT=4820\n`
|
|
);
|
|
};
|
|
|
|
after(() => {
|
|
sys.__reset();
|
|
delete process.env.AGENTHUB_MODE;
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("the system-unit service manager", () => {
|
|
let calls;
|
|
beforeEach(() => {
|
|
calls = [];
|
|
fs.rmSync(path.dirname(UNIT), { recursive: true, force: true });
|
|
fs.rmSync(sys.envPath(), { force: true });
|
|
sys.__setExecImpl(
|
|
(args) => {
|
|
calls.push(["systemctl", ...args]);
|
|
return { status: 0, stdout: args[0] === "is-active" ? "active\n" : "enabled\n" };
|
|
},
|
|
(args) => {
|
|
calls.push(["sudo", "-n", "systemctl", ...args]);
|
|
return { status: 0 };
|
|
}
|
|
);
|
|
});
|
|
|
|
it("is unavailable, and changes nothing, without its unit", () => {
|
|
assert.equal(sys.unitIsOurs("agenthub"), false);
|
|
assert.equal(sys.systemctlAvailable(), false);
|
|
assert.equal(sys.writeUserService({ argv: ["/n", "/r/server/index.js"] }), "unavailable");
|
|
assert.equal(sys.restartUserService("agenthub"), "unavailable");
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
|
|
it("repoints by rewriting the host-owned env file, never the unit", () => {
|
|
writeUnit();
|
|
const unitBefore = fs.readFileSync(UNIT, "utf8");
|
|
const unit = {
|
|
argv: ["/usr/bin/node", "/plugins/v2/server/index.js"],
|
|
env: { NODE_ENV: "production" },
|
|
};
|
|
assert.equal(sys.writeUserService(unit), "written");
|
|
assert.equal(sys.writeUserService(unit), "unchanged");
|
|
const env = fs.readFileSync(sys.envPath(), "utf8");
|
|
assert.match(env, /^AGENTHUB_NODE=\/usr\/bin\/node$/m);
|
|
assert.match(env, /^AGENTHUB_ROOT=\/plugins\/v2$/m);
|
|
assert.match(env, /^NODE_ENV=production$/m);
|
|
assert.equal(fs.statSync(sys.envPath()).mode & 0o777, 0o600);
|
|
assert.equal(fs.readFileSync(UNIT, "utf8"), unitBefore);
|
|
assert.equal(sys.unitRoot("agenthub"), "/plugins/v2");
|
|
assert.equal(sys.unitIsStale("agenthub", "/plugins/v2"), false);
|
|
assert.equal(sys.unitIsStale("agenthub", "/plugins/v3"), true);
|
|
assert.ok(!calls.some((c) => c[0] === "sudo"), "no root needed to repoint");
|
|
});
|
|
|
|
it("restarts, starts and enables only through sudo, restart without blocking", () => {
|
|
writeUnit();
|
|
assert.equal(sys.restartUserService("agenthub"), "restarted");
|
|
assert.equal(sys.setUnitEnabled("agenthub", false), "ok");
|
|
sys.installUserService({ name: "agenthub", argv: ["/n", "/r/server/index.js"] });
|
|
const sudo = calls.filter((c) => c[0] === "sudo").map((c) => c.slice(3).join(" "));
|
|
assert.deepEqual(sudo, [
|
|
"restart --no-block agenthub.service",
|
|
"disable agenthub.service",
|
|
"start --no-block agenthub.service",
|
|
]);
|
|
assert.deepEqual(sys.stopArgv("agenthub"), [
|
|
"sudo",
|
|
["-n", "systemctl", "stop", "--no-block", "agenthub.service"],
|
|
]);
|
|
});
|
|
|
|
it("is what service-manager picks while the unit exists", () => {
|
|
const load = () => {
|
|
delete require.cache[require.resolve("../../scripts/service-manager")];
|
|
return require("../../scripts/service-manager");
|
|
};
|
|
if (process.platform !== "linux") return;
|
|
fs.rmSync(UNIT, { force: true });
|
|
assert.notEqual(load().kind, "systemd-system");
|
|
writeUnit();
|
|
assert.equal(load().kind, "systemd-system");
|
|
});
|
|
|
|
it("lets Quit stop it through sudo, never by SIGTERM", () => {
|
|
const { stopServer } = require("../lib/quit");
|
|
const spawned = [];
|
|
let killed = false;
|
|
stopServer({
|
|
svc: {
|
|
...sys,
|
|
systemctlAvailable: () => true,
|
|
unitIsOurs: () => true,
|
|
unitIsActive: () => true,
|
|
},
|
|
spawn: (cmd, args) => {
|
|
spawned.push([cmd, ...args]);
|
|
return { unref() {} };
|
|
},
|
|
kill: () => (killed = true),
|
|
});
|
|
assert.deepEqual(spawned, [
|
|
["sudo", "-n", "systemctl", "stop", "--no-block", "agenthub.service"],
|
|
]);
|
|
assert.equal(killed, false);
|
|
});
|
|
});
|
|
|
|
describe("Linux accounts", () => {
|
|
let argvs;
|
|
let failNext;
|
|
before(() => {
|
|
linux.__setRunImpl((argv) => {
|
|
argvs.push(argv);
|
|
if (failNext) {
|
|
failNext = false;
|
|
throw Object.assign(new Error("exit 4"), { stderr: "agenthub-provision: ah-x exists\n" });
|
|
}
|
|
return `ok ${argv[1]} ${argv[2]}\n`;
|
|
});
|
|
});
|
|
beforeEach(() => {
|
|
argvs = [];
|
|
failNext = false;
|
|
writeConf();
|
|
process.env.AGENTHUB_MODE = "server";
|
|
});
|
|
|
|
it("is off outside server mode or without server.conf", () => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
assert.equal(linux.s3Enabled(), false);
|
|
assert.equal(linux.provision("create", "anyone"), null);
|
|
process.env.AGENTHUB_MODE = "server";
|
|
fs.rmSync(process.env.AGENTHUB_SERVER_CONF);
|
|
linux.__resetConfCache();
|
|
assert.equal(linux.s3Enabled(), false);
|
|
assert.equal(linux.accountFor("mia"), null);
|
|
assert.deepEqual(argvs, []);
|
|
});
|
|
|
|
it("reads server.conf and maps a user to ah-<name> under USERS_ROOT", () => {
|
|
assert.deepEqual(linux.serverConf(), {
|
|
hostUser: "host",
|
|
hostUid: 4242,
|
|
usersRoot: "/srv/agenthub/users",
|
|
port: 4820,
|
|
bins: {},
|
|
});
|
|
assert.deepEqual(linux.accountFor("mia"), {
|
|
login: "ah-mia",
|
|
home: "/srv/agenthub/users/mia",
|
|
});
|
|
});
|
|
|
|
it("refuses a name Linux cannot take, before any sudo", () => {
|
|
for (const bad of ["Mia", "1mia", "mi.a", "-x", "a".repeat(30), "root;id", "../x"]) {
|
|
assert.throws(() => linux.provision("create", bad), { code: "EBADUSER" }, bad);
|
|
}
|
|
assert.throws(() => linux.provision("delete", "mia"), /unknown provision action/);
|
|
assert.deepEqual(argvs, []);
|
|
});
|
|
|
|
it("calls exactly the provision helper with an action and a name", () => {
|
|
linux.provision("create", "mia");
|
|
linux.provision("disable", "mia");
|
|
assert.deepEqual(argvs, [
|
|
["/opt/agenthub/sbin/agenthub-provision", "create", "mia"],
|
|
["/opt/agenthub/sbin/agenthub-provision", "disable", "mia"],
|
|
]);
|
|
});
|
|
|
|
it("creates the Linux account BEFORE the user, and no user when that fails", async () => {
|
|
const made = await auth.createUser("lin", "member-pass-1", "developer");
|
|
assert.equal(made.username, "lin");
|
|
assert.deepEqual(argvs[0], ["/opt/agenthub/sbin/agenthub-provision", "create", "lin"]);
|
|
failNext = true;
|
|
await assert.rejects(auth.createUser("lux", "member-pass-1", "developer"), {
|
|
code: "EPROVISION",
|
|
message: /ah-x exists/,
|
|
});
|
|
assert.equal(db.prepare("SELECT 1 FROM users WHERE username = 'lux'").get(), undefined);
|
|
await assert.rejects(auth.createUser("Lux", "member-pass-1", "developer"), {
|
|
code: "EBADUSER",
|
|
});
|
|
});
|
|
|
|
it("ends the logins first on disable, then locks the Linux account", () => {
|
|
argvs = [];
|
|
failNext = true;
|
|
// A failed lock is still reported — but the dashboard access is gone.
|
|
assert.throws(() => auth.setDisabled("lin", true), { code: "EPROVISION" });
|
|
assert.equal(db.prepare("SELECT disabled FROM users WHERE username = 'lin'").get().disabled, 1);
|
|
auth.setDisabled("lin", false);
|
|
assert.equal(db.prepare("SELECT disabled FROM users WHERE username = 'lin'").get().disabled, 0);
|
|
auth.setDisabled("lin", true);
|
|
assert.equal(db.prepare("SELECT disabled FROM users WHERE username = 'lin'").get().disabled, 1);
|
|
assert.deepEqual(
|
|
argvs.map((a) => a[1]),
|
|
["disable", "enable", "disable"]
|
|
);
|
|
});
|
|
|
|
it("disables and enables a user who has no Linux account, with no sudo", () => {
|
|
argvs = [];
|
|
for (const n of ["pre", "Old.Name"]) {
|
|
db.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(n);
|
|
const id = db.prepare("SELECT id FROM users WHERE username = ?").get(n).id;
|
|
db.prepare(
|
|
"INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES (?, ?, '2999-01-01')"
|
|
).run(`h-${n}`, id);
|
|
auth.setDisabled(n, true);
|
|
const row = db.prepare("SELECT disabled FROM users WHERE id = ?").get(id);
|
|
assert.equal(row.disabled, 1, n);
|
|
assert.equal(
|
|
db.prepare("SELECT 1 FROM auth_sessions WHERE user_id = ?").get(id),
|
|
undefined,
|
|
`${n}'s logins end`
|
|
);
|
|
auth.setDisabled(n, false);
|
|
assert.equal(db.prepare("SELECT disabled FROM users WHERE id = ?").get(id).disabled, 0, n);
|
|
}
|
|
assert.deepEqual(argvs, []);
|
|
});
|
|
});
|
|
|
|
describe("a member's plugin bootstrap", () => {
|
|
it("does nothing under another uid than the host's", () => {
|
|
const { notTheHost, bootstrap } = require("../../scripts/plugin-bootstrap");
|
|
writeConf(process.getuid() + 1);
|
|
assert.equal(notTheHost(), true);
|
|
assert.equal(bootstrap(path.join(TMP, "member-rt")), "not-host");
|
|
assert.equal(fs.existsSync(path.join(TMP, "member-rt")), false);
|
|
writeConf(process.getuid());
|
|
assert.equal(notTheHost(), false);
|
|
fs.rmSync(process.env.AGENTHUB_SERVER_CONF);
|
|
assert.equal(notTheHost(), false);
|
|
});
|
|
});
|