Files
AgentHub/server/__tests__/server-s3-reads.test.js
T
nntrivi2001 84e2a9f0ac 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>
2026-10-09 14:57:10 +07:00

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();
}
});
});