feat(server-s3): one Linux user per account (S3) — experimental

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>
This commit is contained in:
2026-10-09 14:57:10 +07:00
parent b7f3735b29
commit 84e2a9f0ac
123 changed files with 12051 additions and 605 deletions
@@ -0,0 +1,246 @@
/**
* @file server-s3-provision.test.js
* @description S3 plan, P6, without root: (1) `agenthub-provision create`
* seeds the member's `~/.claude.json` (onboarding done, home trusted, merged,
* a non-JSON file left alone) — the real script, run with stub `id`,
* `useradd`, `chown` and `runuser` first on PATH and its two fixed system
* paths pointed at a temp dir; (2) the opt-in plugin baseline runs
* `claude plugin marketplace add|install` AS the member through sudo (played
* by fixtures/fake-sudo.js), skips entries that could be read as options, and
* starts by itself after a user is created.
* @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 { spawnSync } = require("node:child_process");
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-provision-")));
const USERS = path.join(TMP, "users");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "etc", "server.conf");
process.env.AGENTHUB_TOOLS_ROOT = path.join(TMP, "tools");
const linux = require("../lib/linux-accounts");
const memberClaude = require("../lib/member-claude");
const auth = require("../lib/auth");
const { fakeSudo } = require("./fixtures/fake-sudo");
const SCRIPT = path.join(__dirname, "..", "..", "scripts", "server", "agenthub-provision.sh");
const BASELINE = path.join(TMP, "etc", "plugin-baseline.json");
const CLAUDE_LOG = path.join(TMP, "claude.log");
before(() => {
fs.mkdirSync(path.join(TMP, "etc"), { recursive: true });
fs.mkdirSync(USERS, { recursive: true });
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
);
linux.__resetConfCache();
});
after(() => {
delete process.env.AGENTHUB_MODE;
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("agenthub-provision create seeds ~/.claude.json", () => {
const BIN = path.join(TMP, "bin");
const STATE = path.join(TMP, "accounts");
const script = path.join(TMP, "agenthub-provision");
const home = path.join(USERS, "mia");
const file = path.join(home, ".claude.json");
const stub = (name, body) =>
fs.writeFileSync(path.join(BIN, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 });
const create = () =>
spawnSync("bash", [script, "create", "mia"], {
encoding: "utf8",
env: { ...process.env, PATH: `${BIN}:${process.env.PATH}`, STATE },
});
before(() => {
fs.mkdirSync(BIN, { recursive: true });
fs.mkdirSync(STATE, { recursive: true });
// Root, the account database and the switch of user are stubs; mkdir,
// chmod and the seed itself are real.
stub(
"id",
`case "$1" in
-u) echo 0 ;;
-nG) [ -f "$STATE/$2" ] && echo "$2 agenthub-members" || exit 1 ;;
*) [ -f "$STATE/$1" ] || exit 1 ;;
esac`
);
stub(
"useradd",
`while [ $# -gt 1 ]; do [ "$1" = --home-dir ] && h=$2; shift; done
mkdir -p "$h" && touch "$STATE/$1"`
);
stub("chown", "exit 0");
stub("runuser", `shift 2; [ "$1" = -- ] && shift; exec "$@"`);
fs.symlinkSync(process.execPath, path.join(TMP, "node"));
const text = fs
.readFileSync(SCRIPT, "utf8")
.replace("CONF=/etc/agenthub/server.conf", `CONF=${process.env.AGENTHUB_SERVER_CONF}`)
.replaceAll("/opt/agenthub/tools/bin/node", path.join(TMP, "node"));
fs.writeFileSync(script, text);
});
it("marks onboarding done and trusts the home, 0600", () => {
const r = create();
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout.trim(), `created ah-mia ${home}`);
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), {
hasCompletedOnboarding: true,
projects: { [home]: { hasTrustDialogAccepted: true } },
});
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
assert.equal(fs.statSync(path.join(home, ".agenthub", "handoff")).mode & 0o777, 0o700);
});
it("merges into what the member already has", () => {
const mine = {
theme: "dark",
projects: { "/x": { a: 1 }, [home]: { allowedTools: ["Bash"] } },
};
fs.writeFileSync(file, JSON.stringify(mine));
assert.equal(create().status, 0);
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), {
theme: "dark",
hasCompletedOnboarding: true,
projects: {
"/x": { a: 1 },
[home]: { allowedTools: ["Bash"], hasTrustDialogAccepted: true },
},
});
});
it("leaves a file that is not JSON alone", () => {
fs.writeFileSync(file, "{ not json");
const r = create();
assert.equal(r.status, 0, r.stderr);
assert.equal(fs.readFileSync(file, "utf8"), "{ not json");
});
});
describe("the plugin baseline (opt-in)", () => {
const sudo = fakeSudo();
const home = path.join(USERS, "rex");
const claude = path.join(process.env.AGENTHUB_TOOLS_ROOT, "bin", "claude");
before(() => {
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(path.dirname(claude), { recursive: true });
fs.writeFileSync(
claude,
`#!/bin/sh
echo "$PWD|$*" >> '${CLAUDE_LOG}'
case "$*" in *broken*) echo "boom: $*" >&2; exit 1 ;; esac
`,
{ mode: 0o755 }
);
memberClaude.__setExecImpl(async (cmd, argv, opts) => sudo.exec(cmd, argv, opts));
linux.__setRunImpl(() => "ok");
});
beforeEach(() => {
process.env.AGENTHUB_MODE = "server";
sudo.calls.length = 0;
fs.rmSync(CLAUDE_LOG, { force: true });
fs.rmSync(BASELINE, { force: true });
});
it("does nothing without the baseline file, or outside S3", async () => {
assert.equal(await memberClaude.applyPluginBaseline("rex"), null);
fs.writeFileSync(BASELINE, JSON.stringify({ plugins: ["a@b"] }));
delete process.env.AGENTHUB_MODE;
assert.equal(await memberClaude.applyPluginBaseline("rex"), null);
assert.deepEqual(sudo.calls, []);
});
it("adds each marketplace, then installs each plugin, AS the member", async () => {
fs.writeFileSync(
BASELINE,
JSON.stringify({
marketplaces: ["acme/market", "--claudeai x"],
plugins: ["good@market", "broken@market", "-y@market"],
})
);
const results = await memberClaude.applyPluginBaseline("rex");
assert.deepEqual(
results.map((r) => [r.item, r.ok]),
[
["acme/market", true],
["--claudeai x", false],
["good@market", true],
["broken@market", false],
["-y@market", false],
]
);
assert.match(results[3].error, /boom/);
assert.equal(results[1].error, "not a valid entry");
const env = ["HOME=" + home, "DISABLE_AUTOUPDATER=1"];
assert.deepEqual(sudo.calls, [
["sudo", "-n", "-u", "ah-rex", ...env, claude, "plugin", "marketplace", "add", "acme/market"],
[
"sudo",
"-n",
"-u",
"ah-rex",
...env,
claude,
"plugin",
"install",
"--scope",
"user",
"good@market",
],
[
"sudo",
"-n",
"-u",
"ah-rex",
...env,
claude,
"plugin",
"install",
"--scope",
"user",
"broken@market",
],
]);
// Run from the member's home, never the dashboard's cwd.
assert.ok(
fs
.readFileSync(CLAUDE_LOG, "utf8")
.split("\n")
.filter(Boolean)
.every((l) => l.startsWith(`${home}|`))
);
});
it("refuses when claude is not in the shared tools", async () => {
fs.writeFileSync(BASELINE, JSON.stringify({ marketplaces: ["acme/market"] }));
process.env.AGENTHUB_TOOLS_ROOT = path.join(TMP, "no-tools");
try {
await assert.rejects(memberClaude.applyPluginBaseline("rex"), { code: "ENOTSHARED" });
} finally {
process.env.AGENTHUB_TOOLS_ROOT = path.join(TMP, "tools");
}
assert.deepEqual(sudo.calls, []);
});
it("starts by itself once a user is created", async () => {
fs.writeFileSync(BASELINE, JSON.stringify({ plugins: ["good@market"] }));
fs.mkdirSync(path.join(USERS, "zed"), { recursive: true });
await auth.createUser("zed", "member-pass-1", "developer");
for (let i = 0; i < 100 && !sudo.calls.length; i++) await new Promise((r) => setTimeout(r, 20));
assert.equal(sudo.calls.length, 1);
assert.equal(sudo.calls[0][3], "ah-zed");
assert.equal(sudo.calls[0].at(-1), "good@market");
});
});