Files
AgentHub/server/__tests__/server-s3-review3.test.js
nntrivi2001 3ab750c765 fix(server-s3): transcript snapshot copies asynchronously (F42 follow-up)
copyIfNewer read/wrote a member file of up to 256 MiB with readSync/writeSync,
blocking the event loop for every user. It now copies with fs.promises 1 MiB
at a time from the runAs.openSafeAsync handle (owner/type check kept), keeps
the 256 MiB member cap and zero-run holes, writes a temp file and renames it
into place, and joins a copy already in flight for the same destination.
A failed copy is logged and never rejects; snapshotTranscript is async and
all three import callers await it.
2026-10-09 15:17:55 +07:00

380 lines
16 KiB
JavaScript

/**
* @file server-s3-review3.test.js
* @description S3 plan, third review: a workspace in a member's home is owned
* by that member only (create, assign and every pane start refuse another
* owner, whose pane token the folder's member could read); a long first
* prompt in a member's pane travels in its launch file, never through the
* host's private run-prompts dir; the workspace memory view, the transcript
* snapshot and the startup AGENTS.md cleanup never follow a member's planted
* link with the dashboard's rights; a non-admin's `?cwd=` on the config
* readers names only their own workspaces, and no credential file is served
* by name; a member's quiet Codex session keeps the name from their own
* index; `agenthub users provision --all` goes on past a user it cannot
* provision. A fake passwd (every member = this test's uid, or another uid
* when a test needs the file to be someone else's) and server.conf make the
* members provisioned; fake sudo runs their commands as this account.
* @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 { spawnSync } = require("node:child_process");
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-review3-")));
const USERS = path.join(TMP, "users");
const MIA = path.join(USERS, "mia");
const BOB = path.join(USERS, "bob");
const ANA = path.join(USERS, "ana");
const HOSTDIR = path.join(TMP, "hostdir");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
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");
process.env.AGENTHUB_USERS_ROOT = USERS;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
const { fakeSudo } = require("./fixtures/fake-sudo");
let server;
let BASE;
const cookies = {};
let memberCalls = []; // tmux calls run as a member: [login, ...args, stdin]
const call = (who, method, p, body) =>
fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", cookie: cookies[who] },
body: body ? JSON.stringify(body) : undefined,
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
const idOf = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id;
const addLane = (cwd, owner, tool = "claude") =>
Number(
db
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, ?, ?, 'idle')")
.run(cwd, tool, owner ? idOf(owner) : null).lastInsertRowid
);
const UID = process.getuid();
const passwd = (miaUid = UID) => {
fs.writeFileSync(
process.env.AGENTHUB_PASSWD,
[
`ah-mia:x:${miaUid}:${miaUid}::/:/bin/sh`,
...["bob", "ana"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`),
].join("\n") + "\n"
);
runAs.__resetCache();
};
/** Run `fn` while mia is another uid: every file this test made is not hers. */
const asForeign = async (fn) => {
passwd(UID + 1);
try {
return await fn();
} finally {
passwd();
}
};
before(async () => {
fs.mkdirSync(path.join(MIA, ".agenthub", "handoff"), { recursive: true });
for (const d of [path.join(MIA, "proj"), path.join(BOB, "proj"), path.join(ANA, "proj")])
fs.mkdirSync(d, { recursive: true });
fs.mkdirSync(HOSTDIR, { recursive: true });
fs.writeFileSync(path.join(HOSTDIR, "secret.txt"), "HOST-SECRET\n");
fs.mkdirSync(path.join(BOB, ".claude"), { recursive: true });
fs.writeFileSync(path.join(BOB, ".claude", ".credentials.json"), '{"token":"BOB-SECRET"}\n');
passwd();
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
);
linux.__resetConfCache();
linux.__setRunImpl(() => "ok");
runAs.__setExecImpl(fakeSudo().exec);
tmux.__setExecImpl((args) => {
if (args[0] === "-V") return "tmux 3.4";
if (args.includes("has-session")) throw Object.assign(new Error("no"), { status: 1 });
return "";
});
tmux.__setAccountImpl(
(name) => runAs.accountForSession(name),
(acct, args, input) => {
memberCalls.push([acct.login, ...args, input]);
if (args.includes("has-session")) throw Object.assign(new Error("no"), { status: 1 });
return "";
}
);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
process.env.AGENTHUB_MODE = "server";
// A user made before S3 whose name fits no Linux login — first in id order.
db.prepare("INSERT INTO users (username, password_hash) VALUES ('Old.Name', 'x')").run();
for (const [n, role] of [
["boss", "admin"],
["mia", "developer"],
["bob", "developer"],
["ana", "analyst"],
]) {
await auth.createUser(n, `${n}-pass-123`, role);
const login = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: n, password: `${n}-pass-123` }),
});
cookies[n] = login.headers.get("set-cookie").split(";")[0];
}
});
after(() => {
tmux.__reset();
delete process.env.AGENTHUB_MODE;
delete process.env.AGENTHUB_USERS_ROOT;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("a workspace in a member's home is that member's only", () => {
it("refuses an admin creating one there, and lets the member", async () => {
const byBoss = await call("boss", "POST", "/api/lanes", { cwd: path.join(MIA, "proj") });
assert.equal(byBoss.status, 409, JSON.stringify(byBoss.body));
assert.equal(byBoss.body.error.code, "EOWNERFOLDER");
const ensured = await call("boss", "POST", "/api/lanes/ensure", {
cwd: path.join(MIA, "proj"),
});
assert.equal(ensured.status, 409, JSON.stringify(ensured.body));
const byMia = await call("mia", "POST", "/api/lanes", { cwd: path.join(MIA, "proj") });
assert.equal(byMia.status, 201, JSON.stringify(byMia.body));
// Nor can it be given to someone else afterwards.
assert.throws(() => auth.assignWorkspace(byMia.body.lane.id, "bob"), {
code: "EOWNERFOLDER",
});
auth.assignWorkspace(byMia.body.lane.id, "mia");
});
it("starts no pane carrying another owner's token there", () => {
const lane = addLane(path.join(MIA, "proj", "sub"), "boss");
memberCalls = [];
assert.throws(
() => pty.spawnRun({ laneId: lane, cwd: path.join(MIA, "proj"), tool: "claude" }),
{ code: "EOWNERFOLDER" }
);
assert.throws(() => pty.spawnShell({ laneId: lane, cwd: path.join(MIA, "proj") }), {
code: "EOWNERFOLDER",
});
assert.ok(!memberCalls.some((a) => a.includes("new-session")), "no pane started");
});
});
describe("a long first prompt in a member's pane", () => {
it("travels in the launch file, never the host's run-prompts dir", () => {
const lane = addLane(path.join(MIA, "long"), "mia");
fs.mkdirSync(path.join(MIA, "long"), { recursive: true });
memberCalls = [];
const prompt = `spec: ${"x".repeat(6000)}`;
pty.spawnRun({
laneId: lane,
cwd: path.join(MIA, "long"),
tool: "claude",
initialPrompt: prompt,
});
const create = memberCalls.find((a) => a.includes("new-session"));
assert.ok(create, "started on mia's server");
const launch = create[create.indexOf("/opt/agenthub/libexec/pane-launch") + 1];
const body = fs.readFileSync(launch, "utf8").split("\0");
assert.equal(body.at(-2), prompt, "the prompt is the CLI's last argument");
assert.ok(
!body.some((x) => x.includes("run-prompts") || x.includes('cat "$1"')),
"no sh reading a host file"
);
assert.equal(fs.existsSync(path.join(TMP, "data", "run-prompts")), false);
});
});
describe("a member's planted link is never followed with the dashboard's rights", () => {
it("the memory view reads only the member's own CLAUDE.md", async () => {
const dir = path.join(MIA, "mem");
fs.mkdirSync(dir, { recursive: true });
const lane = addLane(dir, "mia");
fs.writeFileSync(path.join(dir, "AGENTS.md"), "mia's own notes\n");
fs.symlinkSync(path.join(HOSTDIR, "secret.txt"), path.join(dir, "CLAUDE.md"));
const mine = await call("mia", "GET", `/api/lanes/${lane}/memory`);
assert.equal(mine.status, 200);
assert.ok(JSON.stringify(mine.body).includes("mia's own notes"));
await asForeign(async () => {
const r = await call("mia", "GET", `/api/lanes/${lane}/memory`);
assert.equal(r.status, 200);
assert.ok(!JSON.stringify(r.body).includes("HOST-SECRET"), JSON.stringify(r.body));
});
});
it("the transcript snapshot copies only the member's own files", async () => {
const { snapshotTranscript } = require("../../scripts/import-history");
const { getTranscriptSnapshotDir } = require("../lib/claude-home");
const proj = path.join(MIA, ".claude", "projects", "-p");
const sid = "aaaaaaaa-1111-4222-8333-444444444444";
fs.mkdirSync(path.join(proj, sid, "subagents"), { recursive: true });
fs.writeFileSync(path.join(proj, `${sid}.jsonl`), '{"own":1}\n');
fs.writeFileSync(path.join(proj, sid, "subagents", "agent-own.jsonl"), '{"own":2}\n');
await snapshotTranscript(path.join(proj, `${sid}.jsonl`), sid);
const snap = getTranscriptSnapshotDir();
assert.equal(fs.readFileSync(path.join(snap, `${sid}.jsonl`), "utf8"), '{"own":1}\n');
assert.ok(fs.existsSync(path.join(snap, sid, "subagents", "agent-own.jsonl")));
const sid2 = "bbbbbbbb-1111-4222-8333-444444444444";
fs.mkdirSync(path.join(proj, sid2, "subagents"), { recursive: true });
fs.writeFileSync(path.join(proj, `${sid2}.jsonl`), '{"own":3}\n');
fs.symlinkSync(
path.join(HOSTDIR, "secret.txt"),
path.join(proj, sid2, "subagents", "agent-x.jsonl")
);
await asForeign(() => snapshotTranscript(path.join(proj, `${sid2}.jsonl`), sid2));
assert.equal(fs.existsSync(path.join(snap, sid2, "subagents", "agent-x.jsonl")), false);
});
it("the startup AGENTS.md cleanup never touches a member's folder", () => {
const { removeStageDoc } = require("../lib/lane-agents");
const doc = "keep\n\n<!-- agenthub:stage:start -->\nx\n<!-- agenthub:stage:end -->\n";
const target = path.join(HOSTDIR, "victim.txt");
fs.writeFileSync(target, doc);
const dir = path.join(MIA, "cx");
fs.mkdirSync(dir, { recursive: true });
fs.symlinkSync(target, path.join(dir, "AGENTS.md"));
assert.equal(removeStageDoc(dir), false);
assert.equal(fs.readFileSync(target, "utf8"), doc);
// A host folder is still cleaned as before.
const host = path.join(HOSTDIR, "lane");
fs.mkdirSync(host);
fs.writeFileSync(path.join(host, "AGENTS.md"), doc);
assert.equal(removeStageDoc(host), true);
assert.equal(fs.readFileSync(path.join(host, "AGENTS.md"), "utf8"), "keep\n");
});
});
describe("the config readers", () => {
it("take a non-admin's ?cwd= only inside their own workspaces", async () => {
addLane(path.join(ANA, "proj"), "ana");
const q = (cwd, p) =>
`/api/cc-config/file?cwd=${encodeURIComponent(cwd)}&path=${encodeURIComponent(p)}`;
const bobs = await call("ana", "GET", q(BOB, path.join(BOB, ".claude", ".credentials.json")));
assert.equal(bobs.status, 403);
assert.equal(bobs.body.error.code, "EFOLDER");
// A link in a folder of the caller's own: `/tmp/a/.claude -> /`.
const out = path.join(TMP, "a");
fs.mkdirSync(out);
fs.symlinkSync("/", path.join(out, ".claude"));
const root = await call("ana", "GET", q(out, path.join(out, ".claude", "etc", "hostname")));
assert.equal(root.status, 403);
const codex = await call(
"ana",
"GET",
`/api/codex-config/memory?cwd=${encodeURIComponent(BOB)}`
);
assert.equal(codex.status, 403);
const own = await call(
"ana",
"GET",
`/api/cc-config/hooks?cwd=${encodeURIComponent(path.join(ANA, "proj"))}`
);
assert.equal(own.status, 200);
});
it("serve no credential file by name, to anyone", async () => {
const home = process.env.CLAUDE_HOME;
fs.mkdirSync(path.join(home, "agenthub-proxies"), { recursive: true });
fs.writeFileSync(path.join(home, ".credentials.json"), '{"t":"HOST-TOKEN"}');
fs.writeFileSync(path.join(home, "agenthub-proxies", "gw.json"), '{"apiKey":"sk-x"}');
fs.writeFileSync(path.join(home, "settings.json"), '{"ok":true}');
const get = (p) => call("boss", "GET", `/api/cc-config/file?path=${encodeURIComponent(p)}`);
for (const f of [".credentials.json", path.join("agenthub-proxies", "gw.json")]) {
const r = await get(path.join(home, f));
assert.equal(r.status, 400, f);
assert.ok(!JSON.stringify(r.body).includes("HOST-TOKEN"));
}
assert.equal((await get(path.join(home, "settings.json"))).status, 200);
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
fs.writeFileSync(path.join(process.env.CODEX_HOME, "auth.json"), '{"t":"CODEX-TOKEN"}');
const cx = await call(
"boss",
"GET",
`/api/codex-config/file?path=${encodeURIComponent(path.join(process.env.CODEX_HOME, "auth.json"))}`
);
assert.equal(cx.status, 400);
});
});
describe("a member's quiet Codex session", () => {
it("keeps the name from the member's own index", async () => {
const { ingestCodexUsage, settleActive } = require("../lib/codex-usage");
const tid = "cccccccc-1111-4222-8333-444444444444";
const codexHome = path.join(MIA, ".codex");
const day = path.join(codexHome, "sessions", "2026", "10", "09");
fs.mkdirSync(day, { recursive: true });
const file = path.join(day, `rollout-2026-10-09T00-00-00-${tid}.jsonl`);
const ts = new Date().toISOString();
fs.writeFileSync(
file,
[
{
timestamp: ts,
type: "session_meta",
payload: { id: tid, timestamp: ts, cwd: path.join(MIA, "proj") },
},
{ timestamp: ts, type: "event_msg", payload: { type: "user_message", message: "hello" } },
]
.map((l) => JSON.stringify(l))
.join("\n") + "\n"
);
fs.writeFileSync(
path.join(codexHome, "session_index.jsonl"),
`${JSON.stringify({ id: tid, thread_name: "Mia's named thread" })}\n`
);
const now = Date.now();
await ingestCodexUsage({ home: codexHome, now });
const row = () => db.prepare("SELECT name, status FROM sessions WHERE id = ?").get(tid);
assert.deepEqual(row(), { name: "Mia's named thread", status: "active" });
settleActive(() => {}, now + 60 * 60_000);
assert.deepEqual(row(), { name: "Mia's named thread", status: "completed" });
});
});
describe("agenthub users provision --all", () => {
it("goes on past a user it cannot provision", () => {
const bin = path.join(TMP, "fakebin");
fs.mkdirSync(bin);
// Stands in for `sudo -n /opt/agenthub/sbin/agenthub-provision create <name>`.
fs.writeFileSync(path.join(bin, "sudo"), '#!/bin/sh\necho "made $4"\n', { mode: 0o755 });
const r = spawnSync(
process.execPath,
[path.join(__dirname, "..", "..", "bin", "agenthub.js"), "users", "provision", "--all"],
{
encoding: "utf8",
// AGENTHUB_FILE_KEYS: what this process loaded from a settings file,
// which the CLI would take back out of ITS env (AGENTHUB_MODE among them).
env: {
...process.env,
AGENTHUB_FILE_KEYS: "",
PATH: `${bin}:${process.env.PATH}`,
NO_COLOR: "1",
},
}
);
assert.equal(r.status, 1, r.stderr);
assert.match(r.stderr, /Old\.Name/);
for (const n of ["boss", "mia", "bob", "ana"]) assert.match(r.stdout, new RegExp(`made ${n}`));
});
});