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>
240 lines
9.3 KiB
JavaScript
240 lines
9.3 KiB
JavaScript
/**
|
|
* @file server-s3-panes.test.js
|
|
* @description S3 plan, P3a + review fix F1, with tmux and sudo faked: a
|
|
* member's session lives on the member's own tmux server, and EVERY call on
|
|
* it runs AS the member (sudo) — never as a host client on a socket the
|
|
* member controls. A session's env and argv travel in a launch file written
|
|
* as the member, a paste on stdin: nothing secret or private in sudo's argv.
|
|
* A member's server is believed only about the member's own sessions. The
|
|
* host's own sessions are untouched. A member pane's environment is the
|
|
* member's (home, shared tools on PATH, no host desktop). The run-as path rule.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, beforeEach, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
|
|
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-panes-")));
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
|
|
|
const tmux = require("../lib/tmux");
|
|
const runAs = require("../lib/run-as");
|
|
const linux = require("../lib/linux-accounts");
|
|
const auth = require("../lib/auth");
|
|
const { fakeSudo } = require("./fixtures/fake-sudo");
|
|
|
|
const HOME = path.join(TMP, "users", "mia");
|
|
const ACCT = { name: "mia", login: "ah-mia", home: HOME, uid: 5001 };
|
|
const SOCK = path.join(HOME, ".agenthub", "tmux", "sock");
|
|
const HANDOFF = path.join(HOME, ".agenthub", "handoff");
|
|
const MEMBER = "agenthub-lane-7";
|
|
const HOST = "agenthub-lane-8";
|
|
fs.mkdirSync(HANDOFF, { recursive: true });
|
|
|
|
let calls; // what the host's own tmux client ran
|
|
let asMember; // [login, ...tmux args, stdin] run as the member (sudo)
|
|
let sudo;
|
|
|
|
beforeEach(() => {
|
|
calls = [];
|
|
asMember = [];
|
|
sudo = fakeSudo();
|
|
runAs.__setExecImpl(sudo.exec);
|
|
tmux.__setExecImpl((args) => {
|
|
calls.push(args);
|
|
if (args.includes("list-sessions")) return `${HOST}\nother\n`;
|
|
return "";
|
|
});
|
|
tmux.__setAccountImpl(
|
|
(name) => (name === MEMBER ? ACCT : null),
|
|
(acct, args, input) => {
|
|
asMember.push([acct.login, ...args, input]);
|
|
// A server the member runs could claim another workspace's session.
|
|
if (args.includes("list-sessions")) return `${MEMBER}\nagenthub-lane-9\n`;
|
|
return "";
|
|
}
|
|
);
|
|
});
|
|
|
|
after(() => {
|
|
tmux.__reset();
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("a member's panes on the member's own tmux server", () => {
|
|
it("creates the session AS the member, env and argv only in the launch file", () => {
|
|
tmux.newSession({
|
|
name: MEMBER,
|
|
cwd: path.join(HOME, "p"),
|
|
argv: ["claude", "--", "a private prompt"],
|
|
env: { AGENTHUB_PANE_TOKEN: "ahpane_secret", CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat-secret" },
|
|
});
|
|
assert.deepEqual(calls, [], "no host client ever touches a member's server");
|
|
const create = asMember.find((a) => a.includes("new-session"));
|
|
const launch = create.slice(-3, -1);
|
|
assert.deepEqual(create.slice(0, 9), [
|
|
"ah-mia",
|
|
"new-session",
|
|
"-d",
|
|
"-s",
|
|
MEMBER,
|
|
"-c",
|
|
path.join(HOME, "p"),
|
|
"--",
|
|
"/opt/agenthub/libexec/pane-launch",
|
|
]);
|
|
assert.equal(path.dirname(launch[1]), HANDOFF);
|
|
const body = fs.readFileSync(launch[1], "utf8").split("\0");
|
|
assert.deepEqual(body.slice(0, 6), [
|
|
"2",
|
|
"AGENTHUB_PANE_TOKEN=ahpane_secret",
|
|
"CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat-secret",
|
|
"claude",
|
|
"--",
|
|
"a private prompt",
|
|
]);
|
|
assert.equal(fs.statSync(launch[1]).mode & 0o777, 0o600);
|
|
const logged = JSON.stringify([asMember, sudo.calls]);
|
|
assert.ok(!/secret|private prompt/.test(logged), "sudo logs its argv");
|
|
assert.ok(asMember.some((a) => a.includes("mouse")));
|
|
});
|
|
|
|
it("runs every per-session call as the member; a paste goes in on stdin", () => {
|
|
tmux.hasSession(MEMBER);
|
|
tmux.sendText(MEMBER, "/reload-plugins");
|
|
tmux.killSession(MEMBER);
|
|
tmux.paneCommand(MEMBER);
|
|
tmux.sendCommand(MEMBER, ["codex"], { K: "v-secret" }, path.join(HOME, "p"));
|
|
tmux.pasteText(MEMBER, "a private\nprompt");
|
|
assert.deepEqual(calls, []);
|
|
const load = asMember.find((a) => a.includes("load-buffer"));
|
|
assert.equal(load.at(-2), "-");
|
|
assert.equal(load.at(-1), "a private\nprompt");
|
|
const respawn = asMember.find((a) => a.includes("respawn-pane"));
|
|
assert.ok(respawn.includes("/opt/agenthub/libexec/pane-launch"));
|
|
assert.ok(!JSON.stringify(asMember.map((a) => a.slice(0, -1))).includes("secret"));
|
|
});
|
|
|
|
it("attaches a terminal as the member, a host session with our own binary", () => {
|
|
assert.deepEqual(tmux.memberAttach(MEMBER), [
|
|
"sudo",
|
|
["-n", "-u", "ah-mia", "tmux", "-S", SOCK, "attach-session", "-t", `=${MEMBER}`],
|
|
]);
|
|
assert.equal(tmux.memberAttach(HOST), null);
|
|
});
|
|
|
|
it("leaves the host's own sessions on the default server", () => {
|
|
tmux.newSession({ name: HOST, cwd: "/tmp", argv: ["bash"], env: {} });
|
|
tmux.killSession(HOST);
|
|
assert.deepEqual(asMember, []);
|
|
assert.ok(calls.every((a) => a[0] !== "-S"));
|
|
});
|
|
|
|
it("lists sessions across every server, believing a member's only about their own", () => {
|
|
const orig = runAs.membersWithTmux;
|
|
runAs.membersWithTmux = () => [ACCT];
|
|
try {
|
|
assert.deepEqual([...tmux.liveSessions()].sort(), [MEMBER, HOST, "other"].sort());
|
|
assert.deepEqual(tmux.listSessions("agenthub-lane-").sort(), [HOST, MEMBER].sort());
|
|
} finally {
|
|
runAs.membersWithTmux = orig;
|
|
}
|
|
});
|
|
|
|
it("never falls back to the host's server for a member with no account", () => {
|
|
tmux.__setAccountImpl(() => {
|
|
throw Object.assign(new Error("gone"), { code: "EMEMBERGONE" });
|
|
});
|
|
assert.throws(() => tmux.newSession({ name: MEMBER, cwd: HOME, argv: ["bash"], env: {} }), {
|
|
code: "EMEMBERGONE",
|
|
});
|
|
assert.deepEqual(calls, []);
|
|
});
|
|
});
|
|
|
|
describe("a member pane's environment", () => {
|
|
it("is the member's home and identity, the shared tools, no host desktop", () => {
|
|
const env = runAs.memberPaneEnv(
|
|
ACCT,
|
|
{
|
|
AGENTHUB_RUN_ID: MEMBER,
|
|
PATH: "/home/host/.local/bin:/usr/bin",
|
|
DISPLAY: ":0",
|
|
XDG_RUNTIME_DIR: "/run/user/1001",
|
|
CODEX_HOME: "/home/host/.codex",
|
|
ANTHROPIC_AUTH_TOKEN: "ahpane_x",
|
|
},
|
|
{ codex: true }
|
|
);
|
|
assert.equal(env.HOME, HOME);
|
|
assert.equal(env.USER, "ah-mia");
|
|
assert.equal(env.PATH, "/opt/agenthub/tools/bin:/usr/local/bin:/usr/bin:/bin");
|
|
assert.equal(env.CODEX_HOME, path.join(HOME, ".codex"));
|
|
assert.equal(env.DISABLE_AUTOUPDATER, "1");
|
|
assert.match(env.CLAUDE_DASHBOARD_PORT, /^\d+$/);
|
|
assert.equal(env.DISPLAY, undefined);
|
|
assert.equal(env.XDG_RUNTIME_DIR, undefined);
|
|
assert.equal(env.AGENTHUB_RUN_ID, MEMBER);
|
|
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "ahpane_x");
|
|
});
|
|
});
|
|
|
|
describe("run-as: a path under the users root belongs to that user", () => {
|
|
it("maps only enabled, provisioned users; null outside S3 or the root; refuses a gone one", async () => {
|
|
process.env.AGENTHUB_MODE = "server";
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_SERVER_CONF,
|
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nPORT=4820\n`
|
|
);
|
|
linux.__resetConfCache();
|
|
linux.__setRunImpl(() => "ok");
|
|
try {
|
|
await auth.createUser("root", "member-pass-1", "developer"); // a name, not uid 0
|
|
// `ah-root` is not in /etc/passwd on a test box: not provisioned — a
|
|
// folder of theirs is an error, never "the host runs it".
|
|
const gone = { code: "EMEMBERGONE" };
|
|
assert.throws(() => runAs.accountForPath(`${TMP}/users/root/p`), gone);
|
|
assert.throws(() => runAs.accountForPath(`${TMP}/users/../users/x`), gone);
|
|
assert.equal(runAs.accountForPath("/etc"), null);
|
|
assert.equal(runAs.accountForPath(`${TMP}/users`), null);
|
|
delete process.env.AGENTHUB_MODE;
|
|
assert.equal(runAs.accountForPath(`${TMP}/users/root/p`), null);
|
|
assert.equal(runAs.accountForSession(MEMBER), null);
|
|
} finally {
|
|
delete process.env.AGENTHUB_MODE;
|
|
}
|
|
});
|
|
|
|
it("names a member by uid only for ah-<name> accounts", () => {
|
|
assert.equal(runAs.memberNameForUid(0), null); // root
|
|
assert.equal(runAs.memberNameForUid(process.getuid()), null); // the host
|
|
});
|
|
|
|
it("builds sudo argv with env assignments before the command", () => {
|
|
assert.deepEqual(runAs.sudoArgv(ACCT, "git", ["status"], { GIT_TERMINAL_PROMPT: "0" }), [
|
|
"sudo",
|
|
["-n", "-u", "ah-mia", "GIT_TERMINAL_PROMPT=0", "git", "status"],
|
|
]);
|
|
assert.throws(() => runAs.sudoArgv(ACCT, "git", [], { "-E": "x" }));
|
|
assert.throws(() => runAs.sudoArgv(ACCT, "git", [], { "A=B": "x" }));
|
|
});
|
|
|
|
it("names the binary by the absolute path server.conf records (what sudoers lists)", () => {
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_SERVER_CONF,
|
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nGIT_BIN=/usr/bin/git\nTMUX_BIN=/opt/x/tmux\n`
|
|
);
|
|
linux.__resetConfCache();
|
|
assert.equal(runAs.sudoArgv(ACCT, "git", ["status"])[1][3], "/usr/bin/git");
|
|
assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], "/opt/x/tmux");
|
|
assert.equal(
|
|
runAs.sudoArgv(ACCT, "/opt/agenthub/libexec/write-as", [])[1][3],
|
|
"/opt/agenthub/libexec/write-as"
|
|
);
|
|
});
|
|
});
|