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>
180 lines
7.0 KiB
JavaScript
180 lines
7.0 KiB
JavaScript
/**
|
|
* @file server-s3-reads.test.js
|
|
* @description S3 plan, P4: a member's tool data lives under their own home,
|
|
* and the dashboard reads it there — a session in a member's folder finds its
|
|
* transcript in `<home>/.claude/projects` (by cwd, and by id within that home
|
|
* when its name is unknown), the transcript route serves it, the session sync sweeps every
|
|
* member's projects dir, and Codex's rollouts are read from every member's
|
|
* CODEX_HOME. A member's file is read only when the OPEN file is a regular
|
|
* file that member owns (review fix F3): never a FIFO, never a link to
|
|
* another account's file. A fake passwd (mia = this test's uid) and
|
|
* server.conf make `mia` a provisioned member.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = 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.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-reads-")));
|
|
const USERS = path.join(TMP, "users");
|
|
const HOME = path.join(USERS, "mia");
|
|
const PROJ = path.join(HOME, "proj");
|
|
const SID = "11111111-2222-4333-8444-555555555555";
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
|
process.env.CLAUDE_HOME = path.join(TMP, "host-claude");
|
|
process.env.CODEX_HOME = path.join(TMP, "host-codex");
|
|
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
|
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const dbModule = require("../db");
|
|
const auth = require("../lib/auth");
|
|
const linux = require("../lib/linux-accounts");
|
|
const runAs = require("../lib/run-as");
|
|
const claudeHome = require("../lib/claude-home");
|
|
|
|
const TRANSCRIPT = path.join(
|
|
HOME,
|
|
".claude",
|
|
"projects",
|
|
claudeHome.encodeCwd(PROJ),
|
|
`${SID}.jsonl`
|
|
);
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
before(async () => {
|
|
fs.mkdirSync(path.dirname(TRANSCRIPT), { recursive: true });
|
|
const ts = new Date().toISOString();
|
|
fs.writeFileSync(
|
|
TRANSCRIPT,
|
|
[
|
|
{
|
|
type: "user",
|
|
sessionId: SID,
|
|
cwd: PROJ,
|
|
timestamp: ts,
|
|
uuid: "u1",
|
|
message: { role: "user", content: "say hi" },
|
|
},
|
|
{
|
|
type: "assistant",
|
|
sessionId: SID,
|
|
cwd: PROJ,
|
|
timestamp: ts,
|
|
uuid: "a1",
|
|
message: {
|
|
role: "assistant",
|
|
model: "claude-sonnet-4-5",
|
|
content: [{ type: "text", text: "pong" }],
|
|
usage: { input_tokens: 5, output_tokens: 1 },
|
|
},
|
|
},
|
|
]
|
|
.map((l) => JSON.stringify(l))
|
|
.join("\n") + "\n",
|
|
{ mode: 0o600 }
|
|
);
|
|
const uid = process.getuid();
|
|
fs.writeFileSync(process.env.AGENTHUB_PASSWD, `ah-mia:x:${uid}:${uid}::/:/bin/sh\n`);
|
|
fs.writeFileSync(
|
|
process.env.AGENTHUB_SERVER_CONF,
|
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
|
);
|
|
linux.__resetConfCache();
|
|
runAs.__resetCache();
|
|
linux.__setRunImpl(() => "ok");
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
process.env.AGENTHUB_MODE = "server";
|
|
await auth.createUser("boss", "admin-pass-1", "admin");
|
|
await auth.createUser("mia", "member-pass-1", "developer");
|
|
});
|
|
|
|
after(() => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
server?.close();
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("a member's Claude transcripts are found under their own home", () => {
|
|
it("resolves by cwd and by id", () => {
|
|
assert.equal(claudeHome.projectsDirFor(PROJ), path.join(HOME, ".claude", "projects"));
|
|
assert.equal(claudeHome.projectsDirFor(TMP), path.join(TMP, "host-claude", "projects"));
|
|
assert.equal(claudeHome.getTranscriptPath(SID, PROJ), TRANSCRIPT);
|
|
assert.equal(claudeHome.findTranscriptPath(SID, PROJ), TRANSCRIPT);
|
|
assert.ok(claudeHome.allProjectsDirs().includes(path.join(HOME, ".claude", "projects")));
|
|
});
|
|
|
|
it("is imported by the session sync's sweep of every projects dir", async () => {
|
|
const { syncDefaultProjects } = require("../../scripts/import-history");
|
|
const { changed } = await syncDefaultProjects(dbModule, {
|
|
projectsDir: path.join(HOME, ".claude", "projects"),
|
|
});
|
|
assert.ok(changed.some((c) => c.sessionId === SID));
|
|
const row = dbModule.db.prepare("SELECT cwd FROM sessions WHERE id = ?").get(SID);
|
|
assert.equal(row.cwd, PROJ);
|
|
});
|
|
|
|
it("is served by the transcript route", async () => {
|
|
const login = await fetch(`${BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: "boss", password: "admin-pass-1" }),
|
|
});
|
|
const cookie = login.headers.get("set-cookie").split(";")[0];
|
|
const res = await fetch(`${BASE}/api/sessions/${SID}/transcript`, { headers: { cookie } });
|
|
const body = await res.json();
|
|
assert.equal(res.status, 200);
|
|
assert.ok(body.total >= 2, JSON.stringify(body).slice(0, 200));
|
|
assert.ok(JSON.stringify(body.messages).includes("pong"));
|
|
});
|
|
});
|
|
|
|
describe("a member's Codex rollouts are read from their own CODEX_HOME", () => {
|
|
it("lists them beside this account's", () => {
|
|
const day = path.join(HOME, ".codex", "sessions", "2026", "10", "08");
|
|
fs.mkdirSync(day, { recursive: true });
|
|
const f = path.join(
|
|
day,
|
|
"rollout-2026-10-08T00-00-00-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl"
|
|
);
|
|
fs.writeFileSync(f, "{}\n");
|
|
const { allRolloutFiles } = require("../lib/codex-ingest");
|
|
const members = runAs.memberHomes().map((h) => path.join(h, ".codex"));
|
|
assert.deepEqual(members, [path.join(HOME, ".codex")]);
|
|
assert.ok(allRolloutFiles(members[0]).some((r) => r.path === f));
|
|
});
|
|
});
|
|
|
|
describe("a member's file is read only if it is theirs", () => {
|
|
it("refuses a FIFO without blocking, and a file another account owns", () => {
|
|
const fifo = path.join(HOME, "pipe.jsonl");
|
|
require("node:child_process").execFileSync("mkfifo", [fifo]);
|
|
assert.throws(() => runAs.readFileSafe(fifo, "utf8"), { code: "EFOREIGNFILE" });
|
|
assert.equal(runAs.readFileSafe(TRANSCRIPT, "utf8").includes("pong"), true);
|
|
// A link out of the home is read through, and must still be mia's file.
|
|
const outside = path.join(TMP, "host-secret.txt");
|
|
fs.writeFileSync(outside, "host only");
|
|
const link = path.join(HOME, "link.jsonl");
|
|
fs.symlinkSync(outside, link);
|
|
fs.writeFileSync(process.env.AGENTHUB_PASSWD, "ah-mia:x:5001:5001::/:/bin/sh\n");
|
|
runAs.__resetCache();
|
|
try {
|
|
assert.throws(() => runAs.readFileSafe(link, "utf8"), { code: "EFOREIGNFILE" });
|
|
assert.throws(() => runAs.readFileSafe(TRANSCRIPT, "utf8"), { code: "EFOREIGNFILE" });
|
|
assert.equal(runAs.readFileSafe(outside, "utf8"), "host only", "a host path reads as before");
|
|
} finally {
|
|
const uid = process.getuid();
|
|
fs.writeFileSync(process.env.AGENTHUB_PASSWD, `ah-mia:x:${uid}:${uid}::/:/bin/sh\n`);
|
|
runAs.__resetCache();
|
|
}
|
|
});
|
|
});
|