3ab750c765
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.
380 lines
16 KiB
JavaScript
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}`));
|
|
});
|
|
});
|