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
+319
View File
@@ -0,0 +1,319 @@
/**
* @file codex-pool.test.js
* @description The Codex account pool (server/lib/codex-pool.js), fake
* accounts only: key/auth.json validation and file modes, the balancer
* (full quota last — read from the account's own rollouts —, then fewest live
* Codex panes, then spend), an API-key account reaching a pane only through
* tmux `-e` with a `-c` provider naming the variable, a ChatGPT account's
* `auth.json` written AS the member into their CODEX_HOME (never a host pane's,
* never over the member's own login, sticky while a pane of theirs runs on
* one), the newest `auth.json` copied back into the pool, and an empty pool
* leaving every spawn as it was.
* @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 TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "codex-pool-test-")));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "claude");
process.env.CODEX_HOME = path.join(TMP, "codex");
const { db } = require("../db");
const pool = require("../lib/codex-pool");
const tmux = require("../lib/tmux");
const runAs = require("../lib/run-as");
const pty = require("../lib/pty-run");
const { fakeSudo } = require("./fixtures/fake-sudo");
const KEY = (c) => `sk-proj-${c.repeat(40)}`;
const jwt = (claims) =>
`e30.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig-${Math.random()}`;
const AUTH = (accountId, lastRefresh = "2026-10-01T00:00:00Z", rt = `rt-${accountId}`) => ({
auth_mode: "chatgpt",
OPENAI_API_KEY: null,
tokens: {
id_token: jwt({ email: `${accountId}@example.com` }),
access_token: jwt({ exp: 2e9 }),
refresh_token: rt,
account_id: accountId,
},
last_refresh: lastRefresh,
});
const MIA = { name: "mia", login: "ah-mia", home: path.join(TMP, "users", "mia"), uid: 5001 };
const MIA_AUTH = path.join(MIA.home, ".codex", "auth.json");
const realAccountForLane = runAs.accountForLane;
const realAccountForPath = runAs.accountForPath;
const realMemberHomes = runAs.memberHomes;
let sudo;
let tmuxArgv; // the last new-session argv
function run(
id,
account,
{ status = "completed", session = null, tool = "codex", lane = null } = {}
) {
db.prepare(
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, account, tool, lane_id)
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?, ?, ?)`
).run(id, session, status, new Date().toISOString(), account, tool, lane);
}
function rollout(sessionId, usedPercent, home = process.env.CODEX_HOME) {
const dir = path.join(home, "sessions", "2026", "10", "09");
fs.mkdirSync(dir, { recursive: true });
const line = {
type: "event_msg",
payload: {
type: "token_count",
rate_limits: {
primary: { used_percent: usedPercent, window_minutes: 300, resets_at: 4e9 },
secondary: { used_percent: 5, window_minutes: 10080, resets_at: 4e9 },
},
},
};
const file = path.join(dir, `rollout-2026-10-09T00-00-00-${sessionId}.jsonl`);
fs.writeFileSync(
file,
`${JSON.stringify({ type: "session_meta", payload: { id: sessionId } })}\n${JSON.stringify(line)}\n`
);
return file;
}
/** Record that AgentHub placed pool account `name` in mia's home (what place() does). */
function placedWithMia(name) {
const file = path.join(pool.poolDir(), `${name}.json`);
fs.writeFileSync(file, JSON.stringify({ ...pool.__read(name), placedIn: ["ah-mia"] }), {
mode: 0o600,
});
}
const envOf = (argv, name) =>
argv
.slice(0, argv.indexOf("--"))
.find((x) => String(x).startsWith(`${name}=`))
?.slice(name.length + 1);
before(() => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") tmuxArgv = args;
return "";
});
});
after(() => {
tmux.__setExecImpl(null);
runAs.__setExecImpl(null);
runAs.accountForLane = realAccountForLane;
runAs.accountForPath = realAccountForPath;
runAs.memberHomes = realMemberHomes;
fs.rmSync(TMP, { recursive: true, force: true });
});
beforeEach(() => {
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
fs.rmSync(path.join(TMP, "users"), { recursive: true, force: true });
fs.rmSync(path.join(process.env.CODEX_HOME, "sessions"), { recursive: true, force: true });
db.prepare("DELETE FROM dashboard_runs").run();
pool.resetQuota();
tmuxArgv = null;
sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
runAs.accountForLane = (laneId) => (laneId >= 50 ? MIA : null);
// ...and her folder is hers (a pane's cwd must be its workspace's account's).
runAs.accountForPath = (p) => (String(p).startsWith(MIA.home) ? MIA : realAccountForPath(p));
runAs.memberHomes = () => [MIA.home];
fs.mkdirSync(path.join(MIA.home, ".agenthub", "handoff"), { recursive: true });
});
describe("codex pool: accounts", () => {
it("is empty until an admin adds one, and stores it 0600 in a 0700 dir", () => {
assert.equal(pool.pickAccount(null), null);
assert.throws(() => pool.addAccount("a", { apiKey: "nope" }), { code: "EBADTOKEN" });
assert.throws(() => pool.addAccount("../x", { apiKey: KEY("a") }), { code: "EBADNAME" });
assert.throws(() => pool.addAccount("c", { auth: { tokens: {} } }), { code: "EBADTOKEN" });
assert.equal(pool.addAccount("a", { apiKey: KEY("a") }).kind, "apikey");
assert.equal(pool.addAccount("c", { auth: AUTH("acct-c") }).kind, "chatgpt");
// An auth.json holding only an API key is an API-key account.
assert.equal(pool.addAccount("k", { auth: { OPENAI_API_KEY: KEY("k") } }).kind, "apikey");
assert.throws(() => pool.addAccount("a", { apiKey: KEY("b") }), { code: "EEXISTS" });
assert.equal(fs.statSync(path.join(pool.poolDir(), "a.json")).mode & 0o777, 0o600);
assert.equal(fs.statSync(pool.poolDir()).mode & 0o777, 0o700);
const shown = JSON.stringify(pool.poolStatus());
assert.ok(
!shown.includes(KEY("a")) && !shown.includes("rt-acct-c"),
"status never shows a secret"
);
pool.removeAccount("a");
assert.throws(() => pool.removeAccount("a"), { code: "ENOACCOUNT" });
});
it("ranks by Codex live panes (a Claude run of the same name does not count), then quota", () => {
for (const n of ["a", "b"]) pool.addAccount(n, { apiKey: KEY(n) });
run("c1", "a", { status: "running", tool: "claude" });
assert.equal(pool.pickAccount(null).name, "a");
run("x1", "a", { status: "running" });
assert.equal(pool.pickAccount(null).name, "b");
// b's own rollout says its 5-hour window is 95% used: b goes last.
run("x2", "b", { session: "11111111-2222-3333-4444-555555555555" });
rollout("11111111-2222-3333-4444-555555555555", 95);
pool.resetQuota();
assert.equal(pool.pickAccount(null).name, "a");
const b = pool.poolStatus().find((x) => x.name === "b");
assert.equal(b.full, true);
assert.equal(b.quota.windows[0].usedPercent, 95);
});
it("reads a run's quota only from its own account's CODEX_HOME, never dated in the future", () => {
pool.addAccount("b", { apiKey: KEY("b") });
const MIA_CODEX = path.join(MIA.home, ".codex");
// A host run's session id, forged in mia's home: not the host's reading.
run("h1", "b", { session: "11111111-2222-3333-4444-000000000001" });
rollout("11111111-2222-3333-4444-000000000001", 95, MIA_CODEX);
pool.resetQuota();
assert.equal(pool.poolStatus().find((x) => x.name === "b").quota, null);
// Mia's own run, but her rollout is dated 2030 to stay the newest forever.
run("m1", "b", { session: "11111111-2222-3333-4444-000000000002", lane: 50 });
const future = rollout("11111111-2222-3333-4444-000000000002", 0, MIA_CODEX);
fs.utimesSync(future, new Date("2030-01-01"), new Date("2030-01-01"));
pool.resetQuota();
assert.equal(pool.poolStatus().find((x) => x.name === "b").quota, null);
// Her own current reading of her own run still counts.
fs.utimesSync(future, new Date(), new Date());
pool.resetQuota();
assert.equal(pool.poolStatus().find((x) => x.name === "b").quotaPercent, 5);
});
it("never gives a host pane a ChatGPT account (it would replace the host's login)", () => {
pool.addAccount("c", { auth: AUTH("acct-c") });
assert.equal(pool.pickAccount(null), null);
assert.equal(pool.pickAccount(MIA).name, "c");
});
});
describe("codex pool: spawn", () => {
it("an empty pool leaves a Codex spawn exactly as before", () => {
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "codex" });
const plain = tmuxArgv;
assert.ok(!plain.some((x) => /AGENTHUB_POOL_KEY|agenthub_pool/.test(String(x))));
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
assert.equal(row.account, null);
});
it("an API-key account reaches a host pane through tmux -e only", () => {
pool.addAccount("a", { apiKey: KEY("a") });
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "codex", initialPrompt: "hi" });
assert.equal(envOf(tmuxArgv, "AGENTHUB_POOL_KEY"), KEY("a"));
const after = tmuxArgv.slice(tmuxArgv.indexOf("--"));
assert.ok(!after.some((x) => String(x).includes(KEY("a"))), "never in the pane's argv");
assert.ok(after.includes('model_provider="agenthub_pool"'));
assert.ok(after.some((x) => String(x).includes('env_key="AGENTHUB_POOL_KEY"')));
assert.ok(after.indexOf('model_provider="agenthub_pool"') < after.indexOf("hi"));
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
assert.equal(row.account, "a");
// Claude panes and Codex panes on an AI Proxy keep their own way in.
tmuxArgv = null;
pty.spawnRun({ laneId: 8, cwd: "/tmp", tool: "claude" });
assert.ok(!tmuxArgv.some((x) => String(x).includes("AGENTHUB_POOL_KEY")));
});
it("writes a ChatGPT account's auth.json AS the member, 0600, nothing secret over sudo", () => {
pool.addAccount("c", { auth: AUTH("acct-c") });
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
const written = JSON.parse(fs.readFileSync(MIA_AUTH, "utf8"));
assert.equal(written.tokens.refresh_token, "rt-acct-c");
assert.equal(fs.statSync(MIA_AUTH).mode & 0o777, 0o600);
assert.ok(
sudo.calls.some((a) => a.includes(MIA_AUTH)),
"written through write-as"
);
assert.ok(!JSON.stringify(sudo.calls).includes("rt-acct-c"));
assert.ok(!tmuxArgv.some((x) => String(x).includes("rt-acct-c")));
const row = db
.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-50'")
.get();
assert.equal(row.account, "c");
});
it("never replaces a member's own login, and stays on the account their live pane uses", () => {
pool.addAccount("c", { auth: AUTH("acct-c") });
pool.addAccount("d", { auth: AUTH("acct-d") });
fs.mkdirSync(path.dirname(MIA_AUTH), { recursive: true });
fs.writeFileSync(MIA_AUTH, JSON.stringify(AUTH("mias-own")));
assert.equal(pool.pickAccount(MIA), null);
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.account_id, "mias-own");
// A pool login in place, its pane live on d: the next pane is d too, though
// d already has more live panes than c.
fs.writeFileSync(MIA_AUTH, JSON.stringify(AUTH("acct-d")));
placedWithMia("d");
run("agenthub-lane-51", "d", { status: "running", lane: 51 });
assert.equal(pool.pickAccount(MIA).name, "d");
db.prepare("DELETE FROM dashboard_runs").run();
assert.equal(pool.pickAccount(MIA).name, "c");
});
it("a member's own seat in a pool account's ChatGPT Team stays theirs", () => {
// Codex's account_id is the WORKSPACE: every seat of a Team shares it.
pool.addAccount("c", { auth: AUTH("team-x") });
placedWithMia("c"); // even in a home AgentHub once placed c in
const own = AUTH("team-x", "2026-10-02T00:00:00Z", "rt-mias-seat");
own.tokens.id_token = jwt({ email: "mia@example.com" });
fs.mkdirSync(path.dirname(MIA_AUTH), { recursive: true });
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
assert.equal(pool.pickAccount(MIA), null);
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token, "rt-mias-seat");
});
it("copies the newest auth.json back into the pool", () => {
pool.addAccount("c", { auth: AUTH("acct-c", "2026-10-01T00:00:00Z") });
// A refresh comes back only from a home AgentHub placed the account in.
fs.mkdirSync(path.dirname(MIA_AUTH), { recursive: true });
fs.writeFileSync(MIA_AUTH, JSON.stringify(AUTH("acct-c", "2026-10-05T00:00:00Z", "rt-new")));
pool.syncBack();
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-acct-c", "never placed there");
fs.rmSync(MIA_AUTH);
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
assert.deepEqual(pool.__read("c").placedIn, ["ah-mia"]);
fs.writeFileSync(MIA_AUTH, JSON.stringify(AUTH("acct-c", "2026-09-01T00:00:00Z", "rt-old")));
pool.syncBack();
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-acct-c", "older: kept ours");
fs.writeFileSync(MIA_AUTH, JSON.stringify(AUTH("acct-c", "2026-10-05T00:00:00Z", "rt-new")));
pool.syncBack();
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-new");
assert.equal(fs.statSync(path.join(pool.poolDir(), "c.json")).mode & 0o777, 0o600);
// A new pane starts from the newest copy.
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token, "rt-new");
});
it("never takes back a forged auth.json: a future date, or another account's id_token", () => {
pool.addAccount("c", { auth: AUTH("acct-c", "2026-10-01T00:00:00Z") });
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
const forged = AUTH("acct-c", "9999-12-31T00:00:00Z", "rt-forged");
fs.writeFileSync(MIA_AUTH, JSON.stringify(forged));
pool.syncBack();
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-acct-c", "future last_refresh");
// mia's own ChatGPT tokens relabelled with the pool account's id.
const own = AUTH("mias-own", "2026-10-05T00:00:00Z", "rt-mia");
own.tokens.account_id = "acct-c";
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
pool.syncBack();
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-acct-c", "another identity");
// A garbage id_token proves nothing either.
own.tokens.id_token = "x";
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
pool.syncBack();
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-acct-c", "no identity");
});
});
+37
View File
@@ -77,6 +77,43 @@ describe("cursor usage", () => {
assert.equal(cursor.cwdFromSlug(""), null);
});
it("bounds the walk: no symlink is followed, and it gives up after a budget", () => {
const base = path.join(fs.realpathSync(ROOT), "maze");
// A symlink loop: every `a` and `a-a` is the same directory again.
const loop = path.join(base, "loop");
fs.mkdirSync(loop, { recursive: true });
fs.symlinkSync(".", path.join(loop, "a"));
fs.symlinkSync(".", path.join(loop, "a-a"));
// A real tree with ~1000 directories that all prefix the slug.
const tree = path.join(base, "tree");
const grow = (dir, t) => {
if (t <= 0) return;
fs.mkdirSync(path.join(dir, "a"), { recursive: true });
grow(path.join(dir, "a"), t - 1);
if (t < 2) return;
fs.mkdirSync(path.join(dir, "a-a"), { recursive: true });
grow(path.join(dir, "a-a"), t - 2);
};
grow(tree, 13);
const real = fs.readdirSync;
let calls = 0;
fs.readdirSync = (...a) => {
if (++calls > 5000) throw new Error("unbounded walk");
return real(...a);
};
try {
assert.equal(cursor.cwdFromSlug(`${slugOf(loop)}${"-a".repeat(30)}-z`), null);
assert.ok(calls < 20, `followed a symlink loop: ${calls} reads`);
calls = 0;
assert.equal(cursor.cwdFromSlug(`${slugOf(tree)}${"-a".repeat(13)}-z`), null);
assert.ok(calls <= 501, `walked ${calls} directories`);
assert.equal(cursor.cwdFromSlug(slugOf(PROJECT)), PROJECT);
} finally {
fs.readdirSync = real;
fs.rmSync(base, { recursive: true, force: true });
}
});
it("creates the session with its name and cwd, and no tokens", async () => {
transcript(ID);
assert.deepEqual(await cursor.ingestCursorUsage(), { files: 1, sessions: 1 });
+78
View File
@@ -0,0 +1,78 @@
/**
* @file drop-caps.test.js
* @description S3 review fix F5: with ambient capabilities (faked here), every
* child goes through setpriv and still behaves the same — argv, shell
* commands, sync and async forms — and a browser terminal's `tmux attach`,
* which node-pty forks natively (outside child_process), goes through
* setpriv too. Runs in a child node process so the patch never touches this
* test runner's child_process.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const MOD = path.join(__dirname, "..", "lib", "drop-caps.js");
describe("drop-caps", () => {
it("reads CapAmb", () => {
const { hasAmbientCaps } = require(MOD);
assert.equal(hasAmbientCaps("CapAmb:\t0000000000000004\n"), true);
assert.equal(hasAmbientCaps("CapAmb:\t0000000000000000\n"), false);
});
it(
"wraps every form and keeps what each one does",
{ skip: process.platform !== "linux" },
() => {
const script = `
const cp = require("node:child_process");
const ok = require(${JSON.stringify(MOD)}).install(cp, "CapAmb:\\t0000000000000004\\n");
const out = [ok];
out.push(cp.execFileSync("printf", ["%s|", "a b", "$HOME"]).toString());
out.push(cp.execSync("echo $((1+2)) | tr 3 x").toString().trim());
out.push(cp.spawnSync("printf", ["%s", "z"]).stdout.toString());
out.push(cp.execFileSync("printf", ["%s", "s"], { shell: true }).toString());
cp.execFile("printf", ["%s", "async"], (e, so) => {
out.push(so);
cp.exec("echo shell | tr a-z A-Z", (e2, so2) => {
out.push(so2.trim());
process.stdout.write(JSON.stringify(out));
});
});`;
const got = JSON.parse(execFileSync(process.execPath, ["-e", script]).toString());
assert.deepEqual(got, [true, "a b|$HOME|", "x", "z", "s", "async", "SHELL"]);
}
);
it(
"starts a browser terminal's tmux attach through setpriv (node-pty bypasses child_process)",
{ skip: process.platform !== "linux" },
() => {
const ATTACH = path.join(__dirname, "..", "lib", "pty-attach.js");
const TMUX = path.join(__dirname, "..", "lib", "tmux.js");
const script = `
const dc = require(${JSON.stringify(MOD)});
const before = dc.ptyArgv("tmux", ["a"]);
dc.install(require("node:child_process"), "CapAmb:\\t0000000000000004\\n");
const tmux = require(${JSON.stringify(TMUX)});
tmux.__setExecImpl(() => "");
tmux.__setAccountImpl(() => null);
const pa = require(${JSON.stringify(ATTACH)});
let got;
pa.__setSpawnImpl((file, args) => {
got = [file, args];
return { onData() {}, onExit() {}, write() {}, resize() {}, kill() {} };
});
pa.attach({ on() {}, send() {} }, "agenthub-lane-7", { cols: 80, rows: 24 });
process.stdout.write(JSON.stringify({ before, got }));`;
const { before, got } = JSON.parse(execFileSync(process.execPath, ["-e", script]).toString());
assert.deepEqual(before, ["tmux", ["a"]]);
assert.equal(got[0], "/usr/bin/setpriv");
assert.deepEqual(got[1].slice(0, 3), ["--inh-caps=-all", "--ambient-caps=-all", "--"]);
assert.deepEqual(got[1].slice(-3), ["attach-session", "-t", "=agenthub-lane-7"]);
}
);
});
+32
View File
@@ -0,0 +1,32 @@
/**
* @file fake-sudo.js
* @description Test helper for the S3 suites: an `execImpl` for
* `run-as.__setExecImpl` that plays `sudo -n -u <login> [VAR=v…] <bin> …`
* without root — it records the argv (so a test can assert nothing secret
* crossed sudo) and runs the real `scripts/server/write-as.sh` for
* `/opt/agenthub/libexec/write-as`, as this account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const WRITE_AS = path.join(__dirname, "..", "..", "..", "scripts", "server", "write-as.sh");
/** `{ exec, calls }`: pass `exec` to `runAs.__setExecImpl`; `calls` = every sudo argv. */
function fakeSudo() {
const calls = [];
const exec = (cmd, argv, opts = {}) => {
calls.push([cmd, ...argv]);
if (cmd !== "sudo") return execFileSync(cmd, argv, opts);
let i = argv.indexOf("-u") + 2; // past the login
while (/^[A-Z_][A-Z0-9_]*=/.test(argv[i])) i++;
const [bin, ...rest] = argv.slice(i);
if (bin === "/opt/agenthub/libexec/write-as")
return execFileSync("sh", [WRITE_AS, ...rest], opts);
return execFileSync(bin, rest, opts);
};
return { exec, calls };
}
module.exports = { fakeSudo };
+136
View File
@@ -0,0 +1,136 @@
/**
* @file me-prefs.test.js
* @description `/api/me` (docs/plans/onboarding): an account's own UI
* preferences and first-run flag — local mode is the one account `local`;
* in server mode each account reads and writes only its own row, only known
* keys and short strings are kept, `null` deletes one; an S3 member's
* dark/light mode becomes their Claude Code theme, written as them.
* @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(), "me-prefs-")));
const USERS = path.join(TMP, "users");
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.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const { fakeSudo } = require("./fixtures/fake-sudo");
let server;
let BASE;
const call = (method, p, { body, cookie } = {}) =>
fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
async function login(username, password) {
const r = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return r.headers.get("set-cookie").split(";")[0];
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("local mode: one account", () => {
it("starts empty, keeps known keys, deletes on null, refuses the rest", async () => {
assert.deepEqual((await call("GET", "/api/me/prefs")).body, { prefs: {}, onboardedAt: null });
let r = await call("PUT", "/api/me/prefs", {
body: { prefs: { theme: "light", "agenthub.theme.light": "canvas" } },
});
assert.equal(r.status, 200);
r = await call("PUT", "/api/me/prefs", { body: { prefs: { theme: null } } });
assert.deepEqual(r.body.prefs, { "agenthub.theme.light": "canvas" });
for (const prefs of [{ evil: "x" }, { theme: 1 }, { theme: "x".repeat(9000) }, []]) {
r = await call("PUT", "/api/me/prefs", { body: { prefs } });
assert.equal(r.status, 400, JSON.stringify(prefs));
assert.equal(r.body.error.code, "EBADPREFS");
}
});
it("marks the first-run setup done, once", async () => {
const r = await call("POST", "/api/me/onboarded");
assert.match(r.body.onboardedAt, /^\d{4}-/);
const again = await call("POST", "/api/me/onboarded");
assert.equal(again.body.onboardedAt, r.body.onboardedAt);
});
});
describe("server mode: per account", () => {
let mia;
let max;
before(async () => {
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");
process.env.AGENTHUB_MODE = "server";
await auth.createUser("mia", "member-pass-1", "developer");
await auth.createUser("max", "member-pass-2", "developer");
fs.mkdirSync(path.join(USERS, "mia"), { recursive: true });
mia = await login("mia", "member-pass-1");
max = await login("max", "member-pass-2");
});
it("needs a login", async () => {
assert.equal((await call("GET", "/api/me/prefs")).status, 401);
});
it("keeps each account's own, apart from local mode's and each other's", async () => {
await call("PUT", "/api/me/prefs", { cookie: mia, body: { prefs: { i18nextLng: "vi" } } });
await call("PUT", "/api/me/prefs", { cookie: max, body: { prefs: { i18nextLng: "en" } } });
assert.deepEqual((await call("GET", "/api/me/prefs", { cookie: mia })).body.prefs, {
i18nextLng: "vi",
});
const m = (await call("GET", "/api/me/prefs", { cookie: max })).body;
assert.deepEqual(m, { prefs: { i18nextLng: "en" }, onboardedAt: null });
});
it("puts an S3 member's mode into their Claude Code theme, as them", async () => {
const sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
const settings = path.join(USERS, "mia", ".claude", "settings.json");
fs.mkdirSync(path.dirname(settings), { recursive: true });
fs.writeFileSync(settings, JSON.stringify({ model: "opus" }));
await call("PUT", "/api/me/prefs", { cookie: mia, body: { prefs: { theme: "light" } } });
assert.deepEqual(JSON.parse(fs.readFileSync(settings, "utf8")), {
model: "opus",
theme: "light",
});
assert.ok(sudo.calls.some((c) => c.includes("ah-mia") && c.includes(settings)));
// "system" is the browser's to resolve: Claude's file is left as it is.
await call("PUT", "/api/me/prefs", { cookie: mia, body: { prefs: { theme: "system" } } });
assert.equal(JSON.parse(fs.readFileSync(settings, "utf8")).theme, "light");
});
});
+5 -5
View File
@@ -14,6 +14,7 @@ const http = require("node:http");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "run-route-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp } = require("../index");
const tmux = require("../lib/tmux");
@@ -226,7 +227,9 @@ describe("POST /api/run/drop-image", () => {
});
assert.equal(r.status, 200);
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}\.png$/);
assert.ok(r.body.path.startsWith(os.tmpdir()));
// The host's own data dir, never a predictable name in the shared tmp.
assert.equal(path.dirname(r.body.path), path.join(TMP, "data", "drops"));
assert.equal(fs.statSync(path.dirname(r.body.path)).mode & 0o777, 0o700);
assert.deepEqual(fs.readFileSync(r.body.path), PNG);
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
fs.unlinkSync(r.body.path);
@@ -256,10 +259,7 @@ describe("POST /api/run/drop-image", () => {
raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
});
assert.equal(r.status, 200);
assert.equal(
path.dirname(r.body.path),
path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`)
);
assert.equal(path.dirname(r.body.path), path.join(TMP, "data", "drops"));
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}-b_o_c_o_Q3\.pdf$/);
assert.equal(fs.readFileSync(r.body.path, "utf8"), "%PDF");
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
+150
View File
@@ -0,0 +1,150 @@
/**
* @file server-mode-leaks.test.js
* @description Server mode (S3 plan, P0): a member cannot read the host's AI
* tool config files through `/api/ai-tools/:id/config[/:index]`, and the
* member-readable `/api/cc-config/{skills,commands}` list project items only
* from the member's own workspaces — never another user's folder, nor the
* dashboard's own working directory when no `cwd` is given.
* @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-mode-leaks-")));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.LANES_ROOT = path.join(TMP, "lanes");
// The user-scope skills come from CLAUDE_HOME — keep it out of the real one.
process.env.CLAUDE_HOME = path.join(TMP, "claude-home");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
let server;
let BASE;
const MIA_DIR = path.join(TMP, "w", "mia");
const MAX_DIR = path.join(TMP, "w", "max");
function writeSkill(dir, name) {
const d = path.join(dir, ".claude", "skills", name);
fs.mkdirSync(d, { recursive: true });
fs.writeFileSync(path.join(d, "SKILL.md"), `---\nname: ${name}\n---\nbody\n`);
}
function writeCommand(dir, name) {
const d = path.join(dir, ".claude", "commands");
fs.mkdirSync(d, { recursive: true });
fs.writeFileSync(path.join(d, `${name}.md`), "do it\n");
}
async function call(p, cookie) {
const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} });
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
const names = (r) => (r.body?.items || []).map((i) => i.name);
before(async () => {
writeSkill(MIA_DIR, "mia-skill");
writeSkill(MAX_DIR, "max-skill");
writeCommand(MAX_DIR, "max-cmd");
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "developer");
const max = await auth.createUser("max", "member-pass-2", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
lane.run(MIA_DIR, mia.id);
lane.run(MAX_DIR, max.id);
process.env.AGENTHUB_MODE = "server";
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("server mode: AI tool config files are staff-only", () => {
for (const p of [
"/api/ai-tools/claude/config",
"/api/ai-tools/claude/config/0",
"/api/AI-Tools/claude/Config/0",
"/api/ai-tools/claude/%63onfig/0",
"/api/ai-tools//claude/config/0",
]) {
it(`refuses a member ${p}`, async () => {
const r = await call(p, await login("mia", "member-pass-1"));
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "EADMIN");
});
}
it("still lets an admin reach it", async () => {
const r = await call("/api/ai-tools/claude/config", await login("boss", "admin-pass-1"));
assert.notEqual(r.status, 403);
});
it("keeps the rest of /api/ai-tools open to a member", async () => {
const r = await call("/api/ai-tools", await login("mia", "member-pass-1"));
assert.equal(r.status, 200);
});
});
describe("server mode: a member's skills/commands come from their own workspaces", () => {
it("lists the project skills of the member's own workspace", async () => {
const mia = await login("mia", "member-pass-1");
const r = await call(`/api/cc-config/skills?cwd=${encodeURIComponent(MIA_DIR)}`, mia);
assert.equal(r.status, 200);
assert.ok(names(r).includes("mia-skill"));
});
it("refuses another user's folder (skills and commands)", async () => {
const mia = await login("mia", "member-pass-1");
for (const kind of ["skills", "commands"]) {
const r = await call(`/api/cc-config/${kind}?cwd=${encodeURIComponent(MAX_DIR)}`, mia);
assert.equal(r.status, 403, kind);
assert.equal(r.body.error.code, "EFOLDER");
assert.ok(!JSON.stringify(r.body).includes("max-"));
}
});
it("refuses a path that walks out of the member's folder", async () => {
const mia = await login("mia", "member-pass-1");
const sneaky = path.join(MIA_DIR, "..", "max");
const r = await call(`/api/cc-config/skills?cwd=${encodeURIComponent(sneaky)}`, mia);
assert.equal(r.status, 403);
});
it("lists no project items without a cwd (never the dashboard's own folder)", async () => {
const mia = await login("mia", "member-pass-1");
const r = await call("/api/cc-config/skills?scope=project", mia);
assert.equal(r.status, 200);
assert.deepEqual(r.body.items, []);
const all = await call("/api/cc-config/commands", mia);
assert.equal(all.status, 200);
assert.ok(all.body.items.every((i) => i.scope === "user"));
});
it("leaves an admin free to name any folder", async () => {
const boss = await login("boss", "admin-pass-1");
const r = await call(`/api/cc-config/skills?cwd=${encodeURIComponent(MAX_DIR)}`, boss);
assert.equal(r.status, 200);
assert.ok(names(r).includes("max-skill"));
});
});
@@ -0,0 +1,257 @@
/**
* @file server-mode-proxies.test.js
* @description Personal AI Proxy profiles (S3 plan, P1): each user keeps
* their own beside the admin's shared ones. A member lists, edits, deletes and
* tests only their own; nobody else sees, edits or starts a session on them;
* only an admin writes a shared one; local mode is unchanged (shared only).
* Also `resolveProxy`/`resumeProxy`: a personal profile resolves only for a
* workspace its owner owns.
* @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.mkdtempSync(path.join(os.tmpdir(), "server-mode-proxies-"));
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, ".claude");
process.env.CODEX_HOME = path.join(TMP, ".codex");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const proxies = require("../lib/ai-proxies");
const { resolveProxy, resumeProxy } = require("../lib/proxy-apply");
const { db } = require("../db");
let server;
let BASE;
let mia;
let max;
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
const profile = (label, extra = {}) => ({
label,
baseUrl: "http://127.0.0.1:9/v1",
apiKey: "sk-secret-" + label.replace(/\W/g, ""),
model: "m1",
...extra,
});
const namesOf = (r) => r.body.items.map((i) => `${i.scope}:${i.name}`);
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
mia = await auth.createUser("mia", "member-pass-1", "developer");
max = await auth.createUser("max", "member-pass-2", "developer");
await auth.createUser("boss", "admin-pass-1", "admin");
await auth.createUser("ana", "analyst-pass-1", "analyst");
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("local mode keeps one shared store", () => {
it("creates a shared profile with the plain slug name", async () => {
delete process.env.AGENTHUB_MODE;
const r = await call("POST", "/api/ai-proxies", { body: profile("Team Router") });
assert.equal(r.status, 201);
assert.equal(r.body.name, "team-router");
assert.equal(r.body.scope, "shared");
assert.equal(r.body.hasKey, true);
assert.equal(r.body.apiKey, undefined);
});
});
describe("server mode: personal profiles", () => {
before(() => {
process.env.AGENTHUB_MODE = "server";
});
it("gives a member's new profile a personal name and keeps it out of others' lists", async () => {
const cm = await login("mia", "member-pass-1");
const r = await call("POST", "/api/ai-proxies", { cookie: cm, body: profile("Mia Proxy") });
assert.equal(r.status, 201);
assert.equal(r.body.name, `u${mia.id}_mia-proxy`);
assert.equal(r.body.scope, "personal");
assert.equal(r.body.apiKey, undefined);
const mine = await call("GET", "/api/ai-proxies", { cookie: cm });
assert.deepEqual(namesOf(mine), [
"shared:native",
"shared:team-router",
`personal:u${mia.id}_mia-proxy`,
]);
assert.ok(!JSON.stringify(mine.body).includes("sk-secret"));
const cx = await login("max", "member-pass-2");
const theirs = await call("GET", "/api/ai-proxies", { cookie: cx });
assert.ok(!namesOf(theirs).some((n) => n.includes("mia")));
const boss = await login("boss", "admin-pass-1");
const admins = await call("GET", "/api/ai-proxies", { cookie: boss });
assert.ok(!namesOf(admins).some((n) => n.includes("mia")), "not even an admin lists it");
});
it("refuses a member a shared profile, and anyone else's personal one", async () => {
const cm = await login("mia", "member-pass-1");
const shared = await call("POST", "/api/ai-proxies", {
cookie: cm,
body: profile("Sneaky", { scope: "shared" }),
});
assert.equal(shared.status, 403);
assert.equal(shared.body.error.code, "EADMIN");
const edit = await call("PATCH", "/api/ai-proxies/team-router", {
cookie: cm,
body: { model: "m2" },
});
assert.equal(edit.status, 403);
const del = await call("DELETE", "/api/ai-proxies/team-router", { cookie: cm });
assert.equal(del.status, 403);
const cx = await login("max", "member-pass-2");
const name = `u${mia.id}_mia-proxy`;
const e2 = await call("PATCH", `/api/ai-proxies/${name}`, { cookie: cx, body: { model: "x" } });
assert.equal(e2.status, 404);
const d2 = await call("DELETE", `/api/ai-proxies/${name}`, { cookie: cx });
assert.equal(d2.status, 404);
const boss = await login("boss", "admin-pass-1");
const d3 = await call("DELETE", `/api/ai-proxies/${name}`, { cookie: boss });
assert.equal(d3.status, 404, "an admin does not manage someone's personal profile");
assert.ok(proxies.getProxy(name), "still there");
});
it("lets the owner edit and delete their own", async () => {
const cm = await login("mia", "member-pass-1");
const made = await call("POST", "/api/ai-proxies", { cookie: cm, body: profile("Temp One") });
const name = made.body.name;
const e = await call("PATCH", `/api/ai-proxies/${name}`, { cookie: cm, body: { model: "m9" } });
assert.equal(e.status, 200);
assert.equal(e.body.model, "m9");
const d = await call("DELETE", `/api/ai-proxies/${name}`, { cookie: cm });
assert.equal(d.status, 200);
});
it("never uses another user's stored key in a test", async () => {
const cx = await login("max", "member-pass-2");
const r = await call("POST", "/api/ai-proxies/test", {
cookie: cx,
body: { name: `u${mia.id}_mia-proxy`, baseUrl: "http://127.0.0.1:9", model: "m1" },
});
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADKEY");
});
it("lets an admin write shared profiles, and keep a personal one of their own", async () => {
const boss = await login("boss", "admin-pass-1");
const s = await call("POST", "/api/ai-proxies", { cookie: boss, body: profile("Ops Gateway") });
assert.equal(s.status, 201);
assert.equal(s.body.scope, "shared");
const p = await call("POST", "/api/ai-proxies", {
cookie: boss,
body: profile("Boss Own", { scope: "personal" }),
});
assert.equal(p.status, 201);
assert.equal(p.body.scope, "personal");
assert.match(p.body.name, /^u\d+_boss-own$/);
const cm = await login("mia", "member-pass-1");
const list = await call("GET", "/api/ai-proxies", { cookie: cm });
assert.ok(namesOf(list).includes("shared:ops-gateway"));
assert.ok(!namesOf(list).some((n) => n.includes("boss-own")));
});
it("lets an analyst keep a personal profile, never a shared one", async () => {
const ca = await login("ana", "analyst-pass-1");
const r = await call("POST", "/api/ai-proxies", { cookie: ca, body: profile("Ana Proxy") });
assert.equal(r.status, 201);
assert.equal(r.body.scope, "personal");
const s = await call("POST", "/api/ai-proxies", {
cookie: ca,
body: { ...profile("Ana Shared"), scope: "shared" },
});
assert.equal(s.status, 403);
assert.equal(s.body.error.code, "EADMIN");
});
});
describe("resolveProxy / resumeProxy: a personal profile only for its owner's workspace", () => {
it("resolves for the owner, refuses anyone else and an ownerless workspace", () => {
const name = `u${mia.id}_mia-proxy`;
assert.equal(resolveProxy("claude", name, mia.id).name, name);
assert.throws(() => resolveProxy("claude", name, max.id), { code: "EBADPROXY" });
assert.throws(() => resolveProxy("claude", name, null), { code: "EBADPROXY" });
assert.equal(resolveProxy("claude", "team-router", max.id).name, "team-router");
assert.equal(resolveProxy("claude", "team-router", null).name, "team-router");
});
it("resumes on a personal profile only for its owner, else on native", () => {
const name = `u${mia.id}_mia-proxy`;
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/p', 'claude', ?)").run(mia.id);
const laneId = db.prepare("SELECT id FROM lanes WHERE cwd = '/w/p'").get().id;
db.prepare(
"INSERT INTO dashboard_runs (id, lane_id, cwd, status, session_id, proxy) VALUES ('agenthub-lane-p', ?, '/w/p', 'exited', 'sess-proxy-1', ?)"
).run(laneId, name);
assert.equal(resumeProxy("sess-proxy-1", mia.id), name);
assert.equal(resumeProxy("sess-proxy-1", max.id), null);
});
});
describe("an edited profile reaches a live gateway pane (review fix)", () => {
it("rewrites a running pane's own settings file and deletes a dead one", () => {
process.env.AGENTHUB_MODE = "server";
const crypto = require("node:crypto");
const { proxyForSpawn, refreshProfileFiles } = require("../lib/proxy-apply");
const { name } = proxies.createProxy(profile("Refresh me"));
const live = "ahpane_live_token";
const fileLive = proxyForSpawn(
"claude",
proxies.getProxy(name),
live,
null,
"agenthub-lane-91"
).argv[0].slice("--settings=".length);
const fileDead = proxyForSpawn(
"claude",
proxies.getProxy(name),
"ahpane_dead",
null,
"agenthub-lane-92"
).argv[0].slice("--settings=".length);
const hash = crypto.createHash("sha256").update(live).digest("hex");
db.prepare(
"INSERT INTO dashboard_runs (id, cwd, mode, status, pane_token_hash) VALUES ('agenthub-lane-91', ?, 'interactive', 'running', ?)"
).run(TMP, hash);
proxies.updateProxy(name, { model: "m2" });
refreshProfileFiles(name);
const env = JSON.parse(fs.readFileSync(fileLive, "utf8")).env;
assert.equal(env.ANTHROPIC_MODEL, "m2");
assert.equal(env.ANTHROPIC_AUTH_TOKEN, live, "the pane keeps its own token");
assert.equal(fs.existsSync(fileDead), false, "a file whose pane is gone holds a dead token");
});
});
@@ -0,0 +1,248 @@
/**
* @file server-mode-proxy-gateway.test.js
* @description The AI Proxy key gateway (S3 plan, P1b). In server mode a pane
* never gets a proxy's key: its CLI talks to `/api/proxy-gw/<name>/v1/…` on
* loopback with its own pane token, and the gateway forwards to the profile's
* endpoint with the real key, streaming both ways. Refused: local mode, a
* non-loopback request, an unknown/dead pane token, a profile the pane was not
* started on, a path outside `/v1/`. Also what a spawn writes in server mode:
* no key in any file, the gateway as the endpoint, the pane token as the key.
* @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 crypto = require("node:crypto");
const http = require("node:http");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const { execFileSync } = require("node:child_process");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-proxy-gw-"));
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, ".claude");
process.env.CODEX_HOME = path.join(TMP, ".codex");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const proxies = require("../lib/ai-proxies");
const { proxyForSpawn, resolveProxy } = require("../lib/proxy-apply");
const { db } = require("../db");
const KEY = "sk-upstream-real-key";
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
let server;
let BASE;
let upstream;
let seen = []; // what the fake upstream received
const LIVE_TOKEN = "ahpane_live-token-for-tests-0001";
const DEAD_TOKEN = "ahpane_dead-token-for-tests-0002";
const LIVE_RUN = "agenthub-lane-900";
const DEAD_RUN = "agenthub-lane-901";
let profileName;
let otherName;
function gw(p, { token = LIVE_TOKEN, headers = {}, body, method = "POST" } = {}) {
return fetch(`${BASE}/api/proxy-gw/${p}`, {
method,
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
...headers,
},
body,
});
}
before(async () => {
upstream = http.createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
seen.push({ url: req.url, headers: req.headers, body: raw });
if (req.url.endsWith("/stream")) {
res.writeHead(200, { "content-type": "text/event-stream" });
res.write("event: a\ndata: 1\n\n");
setTimeout(() => res.end("event: b\ndata: 2\n\n"), 30);
return;
}
res.writeHead(201, { "content-type": "application/json", "x-upstream": "yes" });
res.end(JSON.stringify({ ok: true }));
});
});
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
const upBase = `http://127.0.0.1:${upstream.address().port}/root`;
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "developer");
profileName = proxies.createProxy({
label: "Team GW",
baseUrl: upBase,
apiKey: KEY,
model: "m",
}).name;
otherName = proxies.createProxy({
label: "Other GW",
baseUrl: upBase,
apiKey: KEY,
model: "m",
}).name;
const laneId = db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/gw', 'claude', ?)")
.run(mia.id).lastInsertRowid;
const run = db.prepare(
`INSERT INTO dashboard_runs (id, lane_id, cwd, mode, status, proxy, pane_token_hash)
VALUES (?, ?, '/w/gw', 'interactive', 'running', ?, ?)`
);
run.run(LIVE_RUN, laneId, profileName, sha256(LIVE_TOKEN));
run.run(DEAD_RUN, laneId, profileName, sha256(DEAD_TOKEN));
// A live pane for LIVE_RUN only, on the suite's private tmux socket.
execFileSync("tmux", ["new-session", "-d", "-s", LIVE_RUN, "sleep 300"]);
process.env.AGENTHUB_MODE = "server";
});
after(() => {
delete process.env.AGENTHUB_MODE;
try {
execFileSync("tmux", ["kill-session", "-t", LIVE_RUN]);
} catch {
/* already gone */
}
server?.close();
upstream?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("the proxy gateway", () => {
it("forwards with the real key, never the pane token, body and status intact", async () => {
seen = [];
const res = await gw(`${profileName}/v1/messages?beta=true`, {
headers: { "anthropic-version": "2023-06-01", "x-api-key": LIVE_TOKEN },
body: JSON.stringify({ hello: "world" }),
});
assert.equal(res.status, 201);
assert.equal(res.headers.get("x-upstream"), "yes");
assert.deepEqual(await res.json(), { ok: true });
assert.equal(seen.length, 1);
const got = seen[0];
assert.equal(got.url, "/root/v1/messages?beta=true");
assert.equal(got.headers.authorization, `Bearer ${KEY}`);
assert.equal(got.headers["x-api-key"], KEY);
assert.equal(got.headers["anthropic-version"], "2023-06-01");
assert.ok(!JSON.stringify(got.headers).includes("ahpane_"));
assert.equal(got.body, JSON.stringify({ hello: "world" }));
});
it("streams an SSE answer through", async () => {
const res = await gw(`${profileName}/v1/stream`, { body: "{}" });
assert.equal(res.status, 200);
assert.equal(await res.text(), "event: a\ndata: 1\n\nevent: b\ndata: 2\n\n");
});
it("refuses a missing, unknown or dead pane token", async () => {
for (const token of [null, "ahpane_nope", DEAD_TOKEN]) {
seen = [];
const res = await gw(`${profileName}/v1/messages`, { token, body: "{}" });
assert.equal(res.status, 401, String(token));
assert.equal(seen.length, 0);
}
});
it("refuses a profile the pane was not started on", async () => {
seen = [];
const res = await gw(`${otherName}/v1/messages`, { body: "{}" });
assert.equal(res.status, 403);
assert.equal(seen.length, 0);
});
it("refuses a path that leaves /v1/", async () => {
seen = [];
for (const p of [`${profileName}/v1/../../admin`, `${profileName}/v2/x`, `${profileName}`]) {
const res = await gw(p, { body: "{}" });
assert.equal(res.status, 400, p);
}
assert.equal(seen.length, 0);
});
it("refuses a raw `..` path the client did not normalise", async () => {
seen = [];
for (const raw of [`${profileName}/v1/../../admin`, `${profileName}/v1/%2e%2e/%2e%2e/admin`]) {
const status = await new Promise((resolve, reject) => {
const req = http.request(
`${BASE}/api/proxy-gw/x`,
{
method: "POST",
path: `/api/proxy-gw/${raw}`,
headers: { authorization: `Bearer ${LIVE_TOKEN}`, "content-type": "application/json" },
},
(res) => {
res.resume();
resolve(res.statusCode);
}
);
req.on("error", reject);
req.end("{}");
});
assert.equal(status, 400, raw);
}
assert.equal(seen.length, 0);
});
it("refuses a request that came through a reverse proxy", async () => {
const res = await gw(`${profileName}/v1/messages`, {
headers: { "x-forwarded-for": "10.0.0.9" },
body: "{}",
});
assert.equal(res.status, 403);
});
it("is not there in local mode", async () => {
delete process.env.AGENTHUB_MODE;
try {
const res = await gw(`${profileName}/v1/messages`, { body: "{}" });
assert.equal(res.status, 404);
} finally {
process.env.AGENTHUB_MODE = "server";
}
});
});
describe("a spawn on a proxy in server mode", () => {
it("puts no key in Claude's settings file — the pane's own, with its pane token", () => {
const spawn = proxyForSpawn(
"claude",
resolveProxy("claude", profileName),
LIVE_TOKEN,
null,
"agenthub-lane-3"
);
const file = spawn.argv[0].replace(/^--settings=/, "");
assert.equal(path.basename(file), `${profileName}--agenthub-lane-3.json`);
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
const text = fs.readFileSync(file, "utf8");
assert.ok(!text.includes(KEY));
const env = JSON.parse(text).env;
assert.match(env.ANTHROPIC_BASE_URL, new RegExp(`/api/proxy-gw/${profileName}$`));
// The command-line layer: a user's or project's env.ANTHROPIC_* cannot override it.
assert.equal(env.ANTHROPIC_AUTH_TOKEN, LIVE_TOKEN);
assert.deepEqual(spawn.env, { ANTHROPIC_AUTH_TOKEN: LIVE_TOKEN });
});
it("points Codex's layer at the gateway, keyed by the pane token", () => {
const spawn = proxyForSpawn("codex", resolveProxy("codex", profileName), LIVE_TOKEN);
const layer = fs.readFileSync(
path.join(process.env.CODEX_HOME, `${profileName}.config.toml`),
"utf8"
);
assert.ok(!layer.includes(KEY));
assert.match(layer, new RegExp(`base_url = ".*/api/proxy-gw/${profileName}/v1"`));
assert.match(layer, /env_key = "AGENTHUB_PROXY_KEY"/);
assert.deepEqual(spawn.env, { AGENTHUB_PROXY_KEY: LIVE_TOKEN });
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* @file server-s3-git.test.js
* @description S3 plan, P3b: in a member's folder the dashboard runs git, gh
* and file writes AS the member (sudo), and only non-secret variables cross
* sudo (it logs its command line). A fake `sudo` on PATH records the argv; a
* fake passwd and server.conf make `mia` a provisioned member. Outside the
* users root nothing changes. The credential store is never handed to a
* member's git, and a typed password is refused.
* @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-git-")));
const USERS = path.join(TMP, "users");
const HOME = path.join(USERS, "mia");
const HANDOFF = path.join(HOME, ".agenthub", "handoff");
const BIN = path.join(TMP, "bin");
const LOG = path.join(TMP, "sudo.log");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const { git } = require("../lib/worktree");
const { gitConfigFor } = require("../lib/git-credentials");
const sudoCalls = () =>
fs.existsSync(LOG)
? fs
.readFileSync(LOG, "utf8")
.trim()
.split("\n")
.map((l) => JSON.parse(l))
: [];
before(async () => {
fs.mkdirSync(HANDOFF, { recursive: true });
fs.mkdirSync(BIN, { recursive: true });
// A fake sudo: records its argv, then answers like the command would.
fs.writeFileSync(
path.join(BIN, "sudo"),
`#!/usr/bin/env node\nrequire("fs").appendFileSync(${JSON.stringify(LOG)}, JSON.stringify(process.argv.slice(2)) + "\\n");\nprocess.stdout.write("fake-git-out\\n");\n`,
{ mode: 0o755 }
);
process.env.PATH = `${BIN}:${process.env.PATH}`;
fs.writeFileSync(process.env.AGENTHUB_PASSWD, "ah-mia:x:5001:5001::/:/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");
process.env.AGENTHUB_MODE = "server";
await auth.createUser("mia", "member-pass-1", "developer");
});
after(() => {
delete process.env.AGENTHUB_MODE;
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("git and files in a member's folder run as the member", () => {
it("maps a path under the users root to its member", () => {
assert.deepEqual(runAs.accountForPath(path.join(HOME, "proj")), {
name: "mia",
login: "ah-mia",
home: HOME,
uid: 5001,
});
assert.equal(runAs.accountForPath(TMP), null);
});
it("runs git through sudo -u with only plain variables", async () => {
fs.rmSync(LOG, { force: true });
const out = await git(HOME, ["status", "--porcelain"], {
literal: true,
env: { GIT_INDEX_FILE: "/x/index" },
});
assert.equal(out.stdout, "fake-git-out\n");
assert.deepEqual(sudoCalls(), [
[
"-n",
"-u",
"ah-mia",
"GIT_TERMINAL_PROMPT=0",
`HOME=${HOME}`,
"GIT_INDEX_FILE=/x/index",
"GIT_LITERAL_PATHSPECS=1",
"git",
"status",
"--porcelain",
],
]);
});
it("leaves git outside the users root alone", async () => {
fs.rmSync(LOG, { force: true });
await git(TMP, ["--version"]);
assert.deepEqual(sudoCalls(), []);
});
it("writes a member's file through write-as, with the content on stdin", () => {
const seen = [];
runAs.__setExecImpl((cmd, argv, opts) => seen.push({ cmd, argv, input: opts.input }));
runAs.writeFileAs(path.join(HOME, "a.txt"), "hello");
runAs.writeFileAs(path.join(HOME, ".gitignore"), "/x\n", { append: true });
assert.deepEqual(seen, [
{
cmd: "sudo",
argv: ["-n", "-u", "ah-mia", "/opt/agenthub/libexec/write-as", path.join(HOME, "a.txt")],
input: "hello",
},
{
cmd: "sudo",
argv: [
"-n",
"-u",
"ah-mia",
"/opt/agenthub/libexec/write-as",
path.join(HOME, ".gitignore"),
"append",
],
input: "/x\n",
},
]);
const mine = path.join(TMP, "host.txt");
runAs.writeFileAs(mine, "direct");
assert.equal(fs.readFileSync(mine, "utf8"), "direct");
assert.equal(seen.length, 2);
});
it("names a scratch index the member's git creates, and removes it as the member", () => {
const seen = [];
runAs.__setExecImpl((cmd, argv) => seen.push([cmd, ...argv]));
const s = runAs.scratchFile(path.join(HOME, "proj"), "agenthub-index-");
assert.equal(path.dirname(s.file), HANDOFF);
assert.ok(!fs.existsSync(s.file), "the dashboard writes nothing in a member's home");
s.remove();
assert.deepEqual(
seen.map((c) => c.slice(-2)),
[
[s.file, "remove"],
[`${s.file}.lock`, "remove"],
]
);
assert.ok(seen.every((c) => c[0] === "sudo" && c.includes("ah-mia")));
const host = runAs.scratchFile(TMP, "agenthub-index-");
assert.equal(path.dirname(path.dirname(host.file)), os.tmpdir());
host.remove();
});
it("never hands a member's git the credential store, nor a typed password", async () => {
assert.deepEqual(await gitConfigFor(HOME), { args: [], env: {} });
await assert.rejects(
gitConfigFor(HOME, { protocol: "https", host: "github.com", username: "u", password: "p" }),
{ code: "EMEMBERCRED" }
);
});
});
+166
View File
@@ -0,0 +1,166 @@
/**
* @file server-s3-handoff.test.js
* @description S3 plan, P3c: what a member's pane must read lives in the
* member's handoff dir, never the dashboard's data dir (which the member's
* account cannot reach): the workspace prompt and context files, the AI Proxy
* settings for Claude (no key — the gateway), Codex's provider as `-c`
* overrides (no file in the member's CODEX_HOME), and files dropped into the
* prompt box. Every one is written AS the member (`write-as`, played by
* fixtures/fake-sudo.js), never by the dashboard itself. A fake passwd 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-handoff-")));
const USERS = path.join(TMP, "users");
const HOME = path.join(USERS, "mia");
const HANDOFF = path.join(HOME, ".agenthub", "handoff");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.CODEX_HOME = path.join(TMP, ".codex");
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.LANES_ROOT = path.join(TMP, "lanes");
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 proxies = require("../lib/ai-proxies");
const { proxyForSpawn, resolveProxy } = require("../lib/proxy-apply");
const { launchExtras } = require("../lib/lane-sources");
const { fakeSudo } = require("./fixtures/fake-sudo");
const sudo = fakeSudo();
let server;
let BASE;
let laneId;
let acct;
let profile;
before(async () => {
fs.mkdirSync(path.join(HOME, "proj"), { recursive: true });
fs.mkdirSync(HANDOFF, { recursive: true });
fs.writeFileSync(process.env.AGENTHUB_PASSWD, "ah-mia:x:5001:5001::/:/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");
runAs.__setExecImpl(sudo.exec);
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
const mia = await auth.createUser("mia", "member-pass-1", "developer");
laneId = Number(
db
.prepare(
"INSERT INTO lanes (cwd, tool, owner_id, instructions) VALUES (?, 'claude', ?, 'Be brief.')"
)
.run(path.join(HOME, "proj"), mia.id).lastInsertRowid
);
const ctxFile = path.join(TMP, "data", "workspace-context", String(laneId), "spec.md");
fs.mkdirSync(path.dirname(ctxFile), { recursive: true });
fs.writeFileSync(ctxFile, "# spec\n");
db.prepare(
"INSERT INTO lane_sources (lane_id, kind, path, name, added_at) VALUES (?, 'file', ?, 'spec.md', 'now')"
).run(laneId, ctxFile);
acct = runAs.accountForLane(laneId);
profile = proxies.createProxy({
label: "Team GW",
baseUrl: "http://127.0.0.1:9",
apiKey: "sk-real-key",
model: "m1",
});
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
const mode = (f) => fs.statSync(f).mode & 0o777;
describe("a member pane's files live in the member's handoff dir", () => {
it("resolves the workspace to its member", () => {
assert.equal(acct?.login, "ah-mia");
});
it("copies the context files and writes the prompt there, readable", () => {
const x = launchExtras(laneId, "claude");
const ctx = path.join(HANDOFF, `context-${laneId}`);
assert.deepEqual(x.addDirs, [ctx]);
assert.equal(fs.readFileSync(path.join(ctx, "spec.md"), "utf8"), "# spec\n");
assert.equal(mode(path.join(ctx, "spec.md")), 0o644);
assert.equal(x.systemPromptFile, path.join(HANDOFF, `prompt-${laneId}.md`));
assert.equal(mode(x.systemPromptFile), 0o644);
const text = fs.readFileSync(x.systemPromptFile, "utf8");
assert.match(text, /Be brief\./);
assert.ok(text.includes(path.join(ctx, "spec.md")), "the prompt names the copy");
assert.ok(!text.includes(path.join(TMP, "data")), "never the data dir");
const writes = sudo.calls.filter((c) => c.includes("/opt/agenthub/libexec/write-as"));
assert.ok(writes.length >= 3, "copies and prompt written as the member");
assert.ok(writes.every((c) => c.includes("ah-mia")));
assert.ok(!writes.flat().some((a) => a.includes("Be brief")), "content never in argv");
});
it("gives Claude a keyless settings file of the pane's own in the handoff dir", () => {
const spawn = proxyForSpawn(
"claude",
resolveProxy("claude", profile.name),
"ahpane_t",
acct,
"agenthub-lane-1"
);
const file = spawn.argv[0].replace(/^--settings=/, "");
assert.equal(file, path.join(HANDOFF, `proxy-${profile.name}--agenthub-lane-1.json`));
assert.equal(mode(file), 0o600);
const text = fs.readFileSync(file, "utf8");
assert.ok(!text.includes("sk-real-key"));
assert.match(JSON.parse(text).env.ANTHROPIC_BASE_URL, /\/api\/proxy-gw\//);
assert.equal(JSON.parse(text).env.ANTHROPIC_AUTH_TOKEN, "ahpane_t");
assert.deepEqual(spawn.env, { ANTHROPIC_AUTH_TOKEN: "ahpane_t" });
});
it("gives Codex the provider as -c overrides, never a file in CODEX_HOME", () => {
const spawn = proxyForSpawn("codex", resolveProxy("codex", profile.name), "ahpane_t", acct);
assert.ok(!spawn.argv.includes("-p"));
const joined = spawn.argv.join(" ");
assert.match(joined, new RegExp(`model_providers\\.${profile.name}=\\{`));
assert.match(joined, /base_url="http:\/\/127\.0\.0\.1:\d+\/api\/proxy-gw\/[^"]+\/v1"/);
assert.match(joined, /env_key="AGENTHUB_PROXY_KEY"/);
assert.ok(!joined.includes("sk-real-key"));
assert.ok(!fs.existsSync(path.join(process.env.CODEX_HOME, `${profile.name}.config.toml`)));
assert.deepEqual(spawn.env, { AGENTHUB_PROXY_KEY: "ahpane_t" });
});
it("saves a member's dropped file in their handoff dir", async () => {
const login = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "mia", password: "member-pass-1" }),
});
const cookie = login.headers.get("set-cookie").split(";")[0];
const res = await fetch(`${BASE}/api/run/drop-image`, {
method: "POST",
headers: { "Content-Type": "image/png", cookie },
body: Buffer.from("89504e47", "hex"),
});
assert.equal(res.status, 200);
const { path: saved } = await res.json();
assert.equal(path.dirname(saved), path.join(HANDOFF, "drops"));
assert.equal(mode(saved), 0o644);
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* @file server-s3-hooks.test.js
* @description S3 plan, P3d: a hook is authenticated by the uid behind its
* loopback connection (`/proc/net/tcp*`, faked here). A member may report only
* work in their own home — not another member's folder, session or pane, and
* no transcript outside it; an account that is neither the host nor a member
* is refused; the host is unchanged. A member's session stays alive while its
* pane lives (the dashboard cannot read another uid's /proc cwd).
* @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 TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-hooks-")));
const USERS = path.join(TMP, "users");
const MIA = path.join(USERS, "mia");
const MAX = path.join(USERS, "max");
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.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
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 hookAuth = require("../lib/hook-auth");
const tmux = require("../lib/tmux");
const hooksRouter = require("../routes/hooks");
let server;
let BASE;
let peer = 5001; // mia
const post = (data, extra = {}) =>
fetch(`${BASE}/api/hooks/event`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hook_type: "UserPromptSubmit", data, ...extra }),
});
before(async () => {
for (const d of [MIA, MAX]) fs.mkdirSync(path.join(d, "proj"), { recursive: true });
fs.writeFileSync(
process.env.AGENTHUB_PASSWD,
"ah-mia:x:5001:5001::/:/bin/sh\nah-max:x:5002:5002::/:/bin/sh\nother:x:5003:5003::/:/bin/sh\n"
);
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=${process.getuid()}\nUSERS_ROOT=${USERS}\nPORT=4820\n`
);
linux.__resetConfCache();
runAs.__resetCache();
linux.__setRunImpl(() => "ok");
hookAuth.__setPeerUid(() => peer);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
process.env.AGENTHUB_MODE = "server";
await auth.createUser("mia", "member-pass-1", "developer");
const max = await auth.createUser("max", "member-pass-2", "developer");
const lane = db
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)")
.run(path.join(MAX, "proj"), max.id).lastInsertRowid;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd) VALUES ('s-max', 'max', 'active', ?)"
).run(path.join(MAX, "proj"));
db.prepare(
"INSERT INTO dashboard_runs (id, lane_id, cwd, mode, status) VALUES ('agenthub-lane-777', ?, ?, 'interactive', 'running')"
).run(lane, path.join(MAX, "proj"));
});
beforeEach(() => {
peer = 5001;
});
after(() => {
hookAuth.__setPeerUid(null);
tmux.__reset();
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("peerUid reads the client end of the connection from /proc/net/tcp", () => {
it("matches the client's port, our port and ESTABLISHED", () => {
hookAuth.__setTables(() => [
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid\n" +
" 0: 0100007F:12D4 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001\n" +
" 1: 0100007F:C7F2 0100007F:12D4 01 00000000:00000000 00:00000000 00000000 5001\n" +
" 2: 0100007F:12D4 0100007F:C7F2 01 00000000:00000000 00:00000000 00000000 1001\n",
"",
]);
assert.equal(hookAuth.peerUid({ remotePort: 0xc7f2, localPort: 0x12d4 }), 5001);
assert.equal(hookAuth.peerUid({ remotePort: 0xc7f3, localPort: 0x12d4 }), null);
});
});
describe("a member's hooks report only their own work", () => {
it("ingests a hook from a member's own folder", async () => {
const res = await post({ session_id: "s-mia-1", cwd: path.join(MIA, "proj"), prompt: "hi" });
assert.equal(res.status, 200);
assert.ok(db.prepare("SELECT 1 FROM sessions WHERE id = 's-mia-1'").get());
});
it("refuses a hook claiming another member's folder", async () => {
const res = await post({ session_id: "s-mia-2", cwd: path.join(MAX, "proj"), prompt: "x" });
assert.equal(res.status, 403);
assert.equal((await res.json()).error.code, "EFOREIGN");
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = 's-mia-2'").get(), undefined);
});
it("refuses a hook naming another member's session", async () => {
const res = await post({ session_id: "s-max", cwd: path.join(MIA, "proj"), prompt: "x" });
assert.equal(res.status, 403);
});
it("drops a transcript path outside the member's home", async () => {
const res = await post({
session_id: "s-mia-3",
cwd: path.join(MIA, "proj"),
transcript_path: path.join(MAX, ".claude", "projects", "x", "s.jsonl"),
prompt: "x",
});
assert.equal(res.status, 200);
const row = db.prepare("SELECT transcript_path FROM sessions WHERE id = 's-mia-3'").get();
assert.ok(!row?.transcript_path, "never another account's file");
});
it("never binds a member's session to another member's pane", async () => {
const res = await post(
{ session_id: "s-mia-4", cwd: path.join(MIA, "proj"), prompt: "x" },
{ run_id: "agenthub-lane-777" }
);
assert.equal(res.status, 200);
const run = db.prepare("SELECT session_id FROM dashboard_runs WHERE id = 'agenthub-lane-777'");
assert.equal(run.get().session_id, null);
});
it("refuses an account that is neither the host nor a member", async () => {
peer = 5003;
const res = await post({ session_id: "s-x", cwd: path.join(MIA, "proj"), prompt: "x" });
assert.equal(res.status, 403);
assert.equal((await res.json()).error.code, "EPEER");
});
it("leaves the host's own hooks as they were", async () => {
peer = process.getuid();
const res = await post({ session_id: "s-host", cwd: TMP, prompt: "x" });
assert.equal(res.status, 200);
});
});
describe("a member's session is alive while its pane lives", () => {
it("keeps it with a live pane, settles it once the pane is gone", () => {
const cwd = path.join(MIA, "proj");
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, tool) VALUES ('s-live', 'l', 'active', ?, 'claude')"
).run(cwd);
db.prepare(
"INSERT INTO dashboard_runs (id, cwd, mode, status, session_id) VALUES ('agenthub-lane-778', ?, 'interactive', 'running', 's-live')"
).run(cwd);
let panes = "agenthub-lane-778\n";
tmux.__setExecImpl((args) => (args.includes("list-sessions") ? panes : ""));
const status = () => db.prepare("SELECT status FROM sessions WHERE id = 's-live'").get().status;
const probe = { available: true, cwds: new Set() };
hooksRouter.livenessReap({ ignoreIdleGate: true, probe });
assert.equal(status(), "active");
panes = "";
tmux.__setExecImpl((args) => (args.includes("list-sessions") ? panes : ""));
hooksRouter.livenessReap({ ignoreIdleGate: true, probe });
assert.equal(status(), "completed");
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* @file server-s3-lanes.test.js
* @description S3 plan, P3e: a workspace in a member's folder gets neither the
* managed-worktree mode nor the runtime layer (they run as the dashboard's own
* account and write into LANES_ROOT) — 403 EMEMBERLANE; a host workspace is
* untouched. A host plugin reload never types into a member's pane. An analyst
* WRITES like a developer: their workspace only in their own folder, run as
* their own Linux 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 { execFileSync } = require("node:child_process");
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-lanes-")));
const USERS = path.join(TMP, "users");
const PROJ = path.join(USERS, "mia", "proj");
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.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
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");
let server;
let BASE;
let memberLane;
let hostLane;
let cookie;
const call = (method, p, body) =>
fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", cookie },
body: body ? JSON.stringify(body) : undefined,
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
before(async () => {
fs.mkdirSync(PROJ, { recursive: true });
execFileSync("git", ["init", "-q", PROJ]);
execFileSync("git", [
"-C",
PROJ,
"-c",
"user.email=a@b",
"-c",
"user.name=a",
"commit",
"-q",
"--allow-empty",
"-m",
"x",
]);
fs.writeFileSync(
process.env.AGENTHUB_PASSWD,
"ah-mia:x:5001:5001::/:/bin/sh\nah-ana:x:5002:5002::/:/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");
await auth.createUser("ana", "analyst-pass-1", "analyst");
const boss = db.prepare("SELECT id FROM users WHERE username = 'boss'").get().id;
const insert = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
memberLane = Number(insert.run(PROJ, boss).lastInsertRowid);
hostLane = Number(insert.run(path.join(TMP, "hostproj"), boss).lastInsertRowid);
const login = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "boss", password: "admin-pass-1" }),
});
cookie = login.headers.get("set-cookie").split(";")[0];
});
after(() => {
tmux.__reset();
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("no runtime layer and no managed worktree in a member's folder", () => {
for (const [method, p] of [
["POST", "up"],
["POST", "down"],
["POST", "hook/bootstrap"],
["POST", "sync-base"],
["POST", "profile/init"],
["POST", "mcp/sync"],
]) {
it(`refuses ${method} /:id/${p}`, async () => {
const r = await call(method, `/api/lanes/${memberLane}/${p}`, {});
assert.equal(r.status, 403, JSON.stringify(r.body));
assert.equal(r.body.error.code, "EMEMBERLANE");
});
}
it("refuses a managed worktree of a member's repository", async () => {
const r = await call("POST", "/api/lanes/worktree", { sourceRepo: PROJ, slug: "feat" });
assert.equal(r.status, 403);
assert.equal(r.body.error.code, "EMEMBERLANE");
});
it("leaves a host workspace's runtime routes as they were", async () => {
const r = await call("POST", `/api/lanes/${hostLane}/up`, {});
assert.notEqual(r.body?.error?.code, "EMEMBERLANE");
});
});
describe("a host plugin reload", () => {
it("never types into a member's pane", () => {
const { reloadableLanes } = require("../lib/cc-reload");
const runs = require("../lib/pty-run");
const orig = runs.listRuns;
db.prepare(
"INSERT INTO dashboard_runs (id, lane_id, cwd, mode, status, tool) VALUES (?, ?, ?, 'interactive', 'running', 'claude')"
).run(`agenthub-lane-${memberLane}`, memberLane, PROJ);
tmux.__setExecImpl((args) =>
args.includes("list-sessions") ? `agenthub-lane-${memberLane}\n` : "claude\n"
);
try {
assert.ok(!reloadableLanes().some((l) => l.id === `agenthub-lane-${memberLane}`));
} finally {
runs.listRuns = orig;
}
});
});
describe("an analyst's workspace is theirs, like a developer's", () => {
it("is created only in their own folder and runs as their Linux account", async () => {
const own = path.join(USERS, "ana", "proj");
fs.mkdirSync(own, { recursive: true });
process.env.AGENTHUB_USERS_ROOT = USERS;
try {
const login = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "ana", password: "analyst-pass-1" }),
});
const ca = login.headers.get("set-cookie").split(";")[0];
const post = (cwd) =>
fetch(`${BASE}/api/lanes`, {
method: "POST",
headers: { "Content-Type": "application/json", cookie: ca },
body: JSON.stringify({ cwd }),
}).then(async (r) => ({ status: r.status, body: await r.json() }));
const theirs = await post(path.join(USERS, "mia"));
assert.equal(theirs.status, 403);
assert.equal(theirs.body.error.code, "EFOLDER");
const mine = await post(own);
assert.equal(mine.status, 201, JSON.stringify(mine.body));
assert.equal(runAs.accountForLane(mine.body.lane.id).login, "ah-ana");
} finally {
delete process.env.AGENTHUB_USERS_ROOT;
}
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* @file server-s3-panes.test.js
* @description S3 plan, P3a + review fix F1, with tmux and sudo faked: a
* member's session lives on the member's own tmux server, and EVERY call on
* it runs AS the member (sudo) — never as a host client on a socket the
* member controls. A session's env and argv travel in a launch file written
* as the member, a paste on stdin: nothing secret or private in sudo's argv.
* A member's server is believed only about the member's own sessions. The
* host's own sessions are untouched. A member pane's environment is the
* member's (home, shared tools on PATH, no host desktop). The run-as path rule.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-panes-")));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
const tmux = require("../lib/tmux");
const runAs = require("../lib/run-as");
const linux = require("../lib/linux-accounts");
const auth = require("../lib/auth");
const { fakeSudo } = require("./fixtures/fake-sudo");
const HOME = path.join(TMP, "users", "mia");
const ACCT = { name: "mia", login: "ah-mia", home: HOME, uid: 5001 };
const SOCK = path.join(HOME, ".agenthub", "tmux", "sock");
const HANDOFF = path.join(HOME, ".agenthub", "handoff");
const MEMBER = "agenthub-lane-7";
const HOST = "agenthub-lane-8";
fs.mkdirSync(HANDOFF, { recursive: true });
let calls; // what the host's own tmux client ran
let asMember; // [login, ...tmux args, stdin] run as the member (sudo)
let sudo;
beforeEach(() => {
calls = [];
asMember = [];
sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
tmux.__setExecImpl((args) => {
calls.push(args);
if (args.includes("list-sessions")) return `${HOST}\nother\n`;
return "";
});
tmux.__setAccountImpl(
(name) => (name === MEMBER ? ACCT : null),
(acct, args, input) => {
asMember.push([acct.login, ...args, input]);
// A server the member runs could claim another workspace's session.
if (args.includes("list-sessions")) return `${MEMBER}\nagenthub-lane-9\n`;
return "";
}
);
});
after(() => {
tmux.__reset();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("a member's panes on the member's own tmux server", () => {
it("creates the session AS the member, env and argv only in the launch file", () => {
tmux.newSession({
name: MEMBER,
cwd: path.join(HOME, "p"),
argv: ["claude", "--", "a private prompt"],
env: { AGENTHUB_PANE_TOKEN: "ahpane_secret", CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat-secret" },
});
assert.deepEqual(calls, [], "no host client ever touches a member's server");
const create = asMember.find((a) => a.includes("new-session"));
const launch = create.slice(-3, -1);
assert.deepEqual(create.slice(0, 9), [
"ah-mia",
"new-session",
"-d",
"-s",
MEMBER,
"-c",
path.join(HOME, "p"),
"--",
"/opt/agenthub/libexec/pane-launch",
]);
assert.equal(path.dirname(launch[1]), HANDOFF);
const body = fs.readFileSync(launch[1], "utf8").split("\0");
assert.deepEqual(body.slice(0, 6), [
"2",
"AGENTHUB_PANE_TOKEN=ahpane_secret",
"CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat-secret",
"claude",
"--",
"a private prompt",
]);
assert.equal(fs.statSync(launch[1]).mode & 0o777, 0o600);
const logged = JSON.stringify([asMember, sudo.calls]);
assert.ok(!/secret|private prompt/.test(logged), "sudo logs its argv");
assert.ok(asMember.some((a) => a.includes("mouse")));
});
it("runs every per-session call as the member; a paste goes in on stdin", () => {
tmux.hasSession(MEMBER);
tmux.sendText(MEMBER, "/reload-plugins");
tmux.killSession(MEMBER);
tmux.paneCommand(MEMBER);
tmux.sendCommand(MEMBER, ["codex"], { K: "v-secret" }, path.join(HOME, "p"));
tmux.pasteText(MEMBER, "a private\nprompt");
assert.deepEqual(calls, []);
const load = asMember.find((a) => a.includes("load-buffer"));
assert.equal(load.at(-2), "-");
assert.equal(load.at(-1), "a private\nprompt");
const respawn = asMember.find((a) => a.includes("respawn-pane"));
assert.ok(respawn.includes("/opt/agenthub/libexec/pane-launch"));
assert.ok(!JSON.stringify(asMember.map((a) => a.slice(0, -1))).includes("secret"));
});
it("attaches a terminal as the member, a host session with our own binary", () => {
assert.deepEqual(tmux.memberAttach(MEMBER), [
"sudo",
["-n", "-u", "ah-mia", "tmux", "-S", SOCK, "attach-session", "-t", `=${MEMBER}`],
]);
assert.equal(tmux.memberAttach(HOST), null);
});
it("leaves the host's own sessions on the default server", () => {
tmux.newSession({ name: HOST, cwd: "/tmp", argv: ["bash"], env: {} });
tmux.killSession(HOST);
assert.deepEqual(asMember, []);
assert.ok(calls.every((a) => a[0] !== "-S"));
});
it("lists sessions across every server, believing a member's only about their own", () => {
const orig = runAs.membersWithTmux;
runAs.membersWithTmux = () => [ACCT];
try {
assert.deepEqual([...tmux.liveSessions()].sort(), [MEMBER, HOST, "other"].sort());
assert.deepEqual(tmux.listSessions("agenthub-lane-").sort(), [HOST, MEMBER].sort());
} finally {
runAs.membersWithTmux = orig;
}
});
it("never falls back to the host's server for a member with no account", () => {
tmux.__setAccountImpl(() => {
throw Object.assign(new Error("gone"), { code: "EMEMBERGONE" });
});
assert.throws(() => tmux.newSession({ name: MEMBER, cwd: HOME, argv: ["bash"], env: {} }), {
code: "EMEMBERGONE",
});
assert.deepEqual(calls, []);
});
});
describe("a member pane's environment", () => {
it("is the member's home and identity, the shared tools, no host desktop", () => {
const env = runAs.memberPaneEnv(
ACCT,
{
AGENTHUB_RUN_ID: MEMBER,
PATH: "/home/host/.local/bin:/usr/bin",
DISPLAY: ":0",
XDG_RUNTIME_DIR: "/run/user/1001",
CODEX_HOME: "/home/host/.codex",
ANTHROPIC_AUTH_TOKEN: "ahpane_x",
},
{ codex: true }
);
assert.equal(env.HOME, HOME);
assert.equal(env.USER, "ah-mia");
assert.equal(env.PATH, "/opt/agenthub/tools/bin:/usr/local/bin:/usr/bin:/bin");
assert.equal(env.CODEX_HOME, path.join(HOME, ".codex"));
assert.equal(env.DISABLE_AUTOUPDATER, "1");
assert.match(env.CLAUDE_DASHBOARD_PORT, /^\d+$/);
assert.equal(env.DISPLAY, undefined);
assert.equal(env.XDG_RUNTIME_DIR, undefined);
assert.equal(env.AGENTHUB_RUN_ID, MEMBER);
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "ahpane_x");
});
});
describe("run-as: a path under the users root belongs to that user", () => {
it("maps only enabled, provisioned users; null outside S3 or the root; refuses a gone one", async () => {
process.env.AGENTHUB_MODE = "server";
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nPORT=4820\n`
);
linux.__resetConfCache();
linux.__setRunImpl(() => "ok");
try {
await auth.createUser("root", "member-pass-1", "developer"); // a name, not uid 0
// `ah-root` is not in /etc/passwd on a test box: not provisioned — a
// folder of theirs is an error, never "the host runs it".
const gone = { code: "EMEMBERGONE" };
assert.throws(() => runAs.accountForPath(`${TMP}/users/root/p`), gone);
assert.throws(() => runAs.accountForPath(`${TMP}/users/../users/x`), gone);
assert.equal(runAs.accountForPath("/etc"), null);
assert.equal(runAs.accountForPath(`${TMP}/users`), null);
delete process.env.AGENTHUB_MODE;
assert.equal(runAs.accountForPath(`${TMP}/users/root/p`), null);
assert.equal(runAs.accountForSession(MEMBER), null);
} finally {
delete process.env.AGENTHUB_MODE;
}
});
it("names a member by uid only for ah-<name> accounts", () => {
assert.equal(runAs.memberNameForUid(0), null); // root
assert.equal(runAs.memberNameForUid(process.getuid()), null); // the host
});
it("builds sudo argv with env assignments before the command", () => {
assert.deepEqual(runAs.sudoArgv(ACCT, "git", ["status"], { GIT_TERMINAL_PROMPT: "0" }), [
"sudo",
["-n", "-u", "ah-mia", "GIT_TERMINAL_PROMPT=0", "git", "status"],
]);
assert.throws(() => runAs.sudoArgv(ACCT, "git", [], { "-E": "x" }));
assert.throws(() => runAs.sudoArgv(ACCT, "git", [], { "A=B": "x" }));
});
it("names the binary by the absolute path server.conf records (what sudoers lists)", () => {
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nGIT_BIN=/usr/bin/git\nTMUX_BIN=/opt/x/tmux\n`
);
linux.__resetConfCache();
assert.equal(runAs.sudoArgv(ACCT, "git", ["status"])[1][3], "/usr/bin/git");
assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], "/opt/x/tmux");
assert.equal(
runAs.sudoArgv(ACCT, "/opt/agenthub/libexec/write-as", [])[1][3],
"/opt/agenthub/libexec/write-as"
);
});
});
@@ -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");
});
});
+247
View File
@@ -0,0 +1,247 @@
/**
* @file server-s3-quota.test.js
* @description S3 plan, P4: plan quota per member home. `quotas({home})`
* reads Claude, Codex, Cursor, Grok, Kimi Code and OpenCode Go from that
* Linux account's home only — never the host's logins, never agy — and only
* files the member owns (review fix F3: a FIFO or another account's file is
* not read). `GET /api/pricing/quota` gives a developer or an analyst their
* OWN account's quota, an admin the host's. A fake passwd (mia, bob = this
* test's uid) and server.conf make them provisioned members.
* @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-quota-")));
const USERS = path.join(TMP, "users");
const MIA = path.join(USERS, "mia");
const BOB = path.join(USERS, "bob");
const HOST = path.join(TMP, "host");
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.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
process.env.AGENTHUB_AGY_USAGE = "0";
// The host's own logins, every one a different token than a member's.
process.env.CLAUDE_HOME = path.join(HOST, ".claude");
process.env.CODEX_HOME = path.join(HOST, ".codex");
process.env.XDG_CONFIG_HOME = path.join(HOST, ".config");
process.env.AGENTHUB_CURSOR_DIR = path.join(HOST, ".cursor");
process.env.GROK_HOME = path.join(HOST, ".grok");
process.env.KIMI_CODE_HOME = path.join(HOST, ".kimi-code");
process.env.XDG_DATA_HOME = path.join(HOST, ".local", "share");
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const { quotas, __resetCache } = require("../lib/quota");
const { Database } = require("../db");
const LATER = Date.now() + 3600_000;
const write = (file, body) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, typeof body === "string" ? body : JSON.stringify(body));
};
const jwt = (claims) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
/** Every login a tool keeps under `home`, each token named `<who>-<tool>`. */
function seedHome(home, who, { codexPercent }) {
write(path.join(home, ".claude", ".credentials.json"), {
claudeAiOauth: { accessToken: `${who}-claude`, expiresAt: LATER, subscriptionType: "pro" },
});
write(path.join(home, ".claude.json"), { oauthAccount: { emailAddress: `${who}@x.y` } });
write(path.join(home, ".codex", "auth.json"), {
tokens: {
id_token: jwt({ email: `${who}@codex` }),
access_token: jwt({ exp: Math.floor(LATER / 1000) }),
},
});
write(
path.join(home, ".codex", "sessions", "2026", "10", "09", `rollout-${who}.jsonl`),
`${JSON.stringify({
payload: {
rate_limits: {
primary: { used_percent: codexPercent, window_minutes: 300, resets_at: 4102444800 },
},
},
})}\n`
);
write(path.join(home, ".config", "cursor", "auth.json"), { accessToken: `${who}-cursor` });
write(path.join(home, ".cursor", "cli-config.json"), {
authInfo: { email: `${who}@cursor`, userId: "u1" },
});
write(path.join(home, ".grok", "auth.json"), {
"https://auth.x.ai": { key: `${who}-grok`, expires_at: new Date(LATER).toISOString() },
});
write(path.join(home, ".kimi-code", "credentials", "kimi-code.json"), {
access_token: `${who}-kimi`,
expires_at: Math.floor(LATER / 1000),
});
}
/** OpenCode 2 keeps its key in `opencode.db` (WAL), not `auth.json`. */
function seedOpenCodeDb(home, key) {
const dir = path.join(home, ".local", "share", "opencode");
fs.mkdirSync(dir, { recursive: true });
const db = new Database(path.join(dir, "opencode.db"));
db.pragma?.("journal_mode = WAL");
db.exec(
"CREATE TABLE credential (integration_id TEXT, value TEXT, active INT, time_created INT)"
);
db.prepare("INSERT INTO credential VALUES ('opencode-go', ?, 1, 1)").run(JSON.stringify({ key }));
db.close();
}
let asked = [];
const realFetch = global.fetch;
global.fetch = async (url, opts = {}) => {
if (String(url).startsWith("http://127.0.0.1")) return realFetch(url, opts);
const h = opts.headers || {};
asked.push(h.Authorization || h.Cookie || "");
const u = String(url);
const body = u.includes("anthropic.com")
? u.endsWith("/profile")
? { account: { email: "owner@x.y" } }
: { five_hour: { utilization: 10, resets_at: null } }
: {};
return { ok: true, status: 200, json: async () => body };
};
let server;
let BASE;
const setUid = (uid) => {
fs.writeFileSync(
process.env.AGENTHUB_PASSWD,
`ah-mia:x:${uid}:${uid}::/:/bin/sh\nah-bob:x:${uid}:${uid}::/:/bin/sh\n`
);
runAs.__resetCache();
};
before(async () => {
seedHome(HOST, "host", { codexPercent: 77 });
write(path.join(HOST, ".local", "share", "opencode", "auth.json"), {
"opencode-go": { type: "api", key: "host-opencode" },
});
seedHome(MIA, "mia", { codexPercent: 21 });
seedOpenCodeDb(MIA, "mia-opencode");
setUid(process.getuid());
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
);
fs.mkdirSync(BOB, { recursive: true });
linux.__resetConfCache();
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");
await auth.createUser("bob", "analyst-pass-1", "analyst");
await auth.createUser("carl", "member-pass-2", "developer"); // never provisioned
});
after(() => {
delete process.env.AGENTHUB_MODE;
global.fetch = realFetch;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("quotas({home}) reads a member's own logins", () => {
it("reads every tool from that home, never the host's, never agy", async () => {
__resetCache();
asked = [];
const { tools } = await quotas({ home: MIA });
assert.deepEqual(tools.map((t) => t.id).sort(), [
"claude",
"codex",
"cursor",
"grok",
"kimi",
"opencode",
]);
const codex = tools.find((t) => t.id === "codex");
assert.equal(codex.account, "mia@codex");
assert.equal(codex.windows[0].usedPercent, 21);
assert.equal(tools.find((t) => t.id === "cursor").account, "mia@cursor");
assert.ok(asked.length >= 6, asked.join(","));
assert.ok(
asked.every((a) => a.includes("mia-")),
`only mia's tokens leave: ${asked.join(",")}`
);
assert.ok(
asked.some((a) => a.includes("mia-opencode")),
"OpenCode 2's db is read"
);
assert.ok(tools.every((t) => t.label === undefined || t.label === null));
});
it("leaves the host's own reading unchanged", async () => {
__resetCache();
asked = [];
const { tools } = await quotas();
assert.equal(tools.find((t) => t.id === "codex").windows[0].usedPercent, 77);
assert.ok(
asked.every((a) => a.includes("host-")),
asked.join(",")
);
});
it("reads no file the member does not own, and never blocks on a FIFO", async () => {
fs.mkdirSync(path.join(BOB, ".grok"), { recursive: true });
require("node:child_process").execFileSync("mkfifo", [path.join(BOB, ".grok", "auth.json")]);
__resetCache();
assert.deepEqual((await quotas({ home: BOB })).tools, []);
// mia's files now belong to another uid: none is read.
setUid(5001);
try {
__resetCache();
asked = [];
assert.deepEqual((await quotas({ home: MIA })).tools, []);
assert.deepEqual(asked, []);
} finally {
setUid(process.getuid());
}
});
});
describe("GET /api/pricing/quota is the caller's own", () => {
const as = async (username, password) => {
const login = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const cookie = login.headers.get("set-cookie").split(";")[0];
__resetCache();
const res = await fetch(`${BASE}/api/pricing/quota`, { headers: { cookie } });
assert.equal(res.status, 200);
return (await res.json()).tools;
};
const codexPct = (tools) => tools.find((t) => t.id === "codex")?.windows[0].usedPercent;
it("a developer gets their own home's, with their own account shown", async () => {
const tools = await as("mia", "member-pass-1");
assert.equal(codexPct(tools), 21);
assert.equal(tools.find((t) => t.id === "codex").account, "mia@codex");
});
it("an analyst gets their own, never the host's", async () => {
assert.deepEqual(await as("bob", "analyst-pass-1"), []);
});
it("a member with no provisioned account gets nothing", async () => {
assert.deepEqual(await as("carl", "member-pass-2"), []);
});
it("an admin gets the host's", async () => {
assert.equal(codexPct(await as("boss", "admin-pass-1")), 77);
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* @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();
}
});
});
+332
View File
@@ -0,0 +1,332 @@
/**
* @file server-s3-review2.test.js
* @description S3 plan, second review: a member cannot point the dashboard's
* own rights at another account's files. A workspace folder swapped for a
* link is refused for file reads and saves (EFOREIGN); a member's git dir
* that names a path outside their home is never read or written by the host,
* and a FIFO there cannot hang it; a developer browses and lists branches
* only in their own folder; a transcript scan by id looks only in the
* session owner's home; a Codex journal flips only its own account's lanes,
* capped per home; nobody reads the dot folders at the top of a member's home
* (logins, pane tokens), and the home itself is no workspace; a disabled
* member's session neither throws in a read nor stops the liveness reaper.
* A fake passwd (every member = this test's uid) 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 { execFileSync } = require("node:child_process");
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-review2-")));
const USERS = path.join(TMP, "users");
const MIA = path.join(USERS, "mia");
const BOB = path.join(USERS, "bob");
const DAN = path.join(USERS, "dan");
const HOSTDIR = path.join(TMP, "hostdir");
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 { 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 claudeHome = require("../lib/claude-home");
const { fakeSudo } = require("./fixtures/fake-sudo");
let server;
let BASE;
const cookies = {};
const lanes = {};
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 gitInit = (dir) => {
fs.mkdirSync(dir, { recursive: true });
execFileSync("git", ["init", "-q", dir]);
execFileSync("git", [
"-C",
dir,
"-c",
"user.email=a@b",
"-c",
"user.name=a",
"commit",
"-q",
"--allow-empty",
"-m",
"x",
]);
};
const addLane = (cwd, owner, tool = "claude") =>
Number(
db
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, ?, ?, 'idle')")
.run(cwd, tool, db.prepare("SELECT id FROM users WHERE username = ?").get(owner).id)
.lastInsertRowid
);
before(async () => {
gitInit(path.join(MIA, "proj"));
gitInit(path.join(BOB, "proj"));
fs.writeFileSync(path.join(BOB, "proj", "bob.txt"), "bob's file\n");
fs.mkdirSync(HOSTDIR, { recursive: true });
fs.writeFileSync(path.join(HOSTDIR, "host.txt"), "host's file\n");
// mia swaps folders in her own home for links: to bob's, and to the host's.
fs.symlinkSync(path.join(BOB, "proj"), path.join(MIA, "tobob"));
fs.symlinkSync(HOSTDIR, path.join(MIA, "tohost"));
// bob's logins and a pane's handoff file, at the top of his home.
fs.mkdirSync(path.join(BOB, ".claude"), { recursive: true });
fs.writeFileSync(path.join(BOB, ".claude", ".credentials.json"), '{"token":"secret"}\n');
fs.mkdirSync(path.join(BOB, ".agenthub", "handoff"), { recursive: true });
fs.writeFileSync(path.join(BOB, ".agenthub", "handoff", "proxy-x--agenthub-lane-1.json"), "{}");
fs.writeFileSync(path.join(BOB, "notes.txt"), "plain\n");
fs.mkdirSync(path.join(DAN, "proj"), { recursive: true });
const uid = process.getuid();
fs.writeFileSync(
process.env.AGENTHUB_PASSWD,
["mia", "bob", "ana", "dan"].map((n) => `ah-${n}:x:${uid}:${uid}::/:/bin/sh`).join("\n") + "\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");
runAs.__setExecImpl(fakeSudo().exec);
tmux.__setExecImpl(() => "");
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
process.env.AGENTHUB_MODE = "server";
process.env.AGENTHUB_USERS_ROOT = USERS;
for (const [n, role] of [
["boss", "admin"],
["mia", "developer"],
["bob", "developer"],
["ana", "analyst"],
["dan", "developer"],
]) {
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];
}
lanes.tobob = addLane(path.join(MIA, "tobob"), "mia");
lanes.tohost = addLane(path.join(MIA, "tohost"), "mia");
lanes.bobHome = addLane(BOB, "bob");
});
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 folder swapped for a link", () => {
it("is refused for a file read, into another member's folder or the host's", async () => {
const a = await call("mia", "GET", `/api/lanes/${lanes.tobob}/git/file?path=bob.txt`);
assert.equal(a.status, 403, JSON.stringify(a.body));
assert.equal(a.body.error.code, "EFOREIGN");
const b = await call("mia", "GET", `/api/lanes/${lanes.tohost}/git/file?path=host.txt`);
assert.equal(b.status, 403, JSON.stringify(b.body));
});
it("is refused for a save", async () => {
const { writeLaneFile } = require("../lib/lane-file-write");
const sha = require("../lib/git-diff").contentSha(
fs.readFileSync(path.join(HOSTDIR, "host.txt"))
);
await assert.rejects(writeLaneFile(path.join(MIA, "tohost"), "host.txt", "pwned\n", sha), {
code: "EFOREIGN",
});
assert.equal(fs.readFileSync(path.join(HOSTDIR, "host.txt"), "utf8"), "host's file\n");
});
});
describe("a member's git dir that names a path outside their home", () => {
it("is neither read nor written by the host", () => {
const { excludeFromGit } = require("../lib/git-exclude");
// mia's repo: `.git` is a file pointing at a git dir she made in /tmp,
// whose info/exclude is a link to a host-owned file.
const evil = path.join(TMP, "evil");
gitInit(evil);
const target = path.join(HOSTDIR, "settings.json");
fs.writeFileSync(target, '{"a":1}\n');
fs.rmSync(path.join(evil, ".git", "info", "exclude"), { force: true });
fs.symlinkSync(target, path.join(evil, ".git", "info", "exclude"));
const repo = path.join(MIA, "redirect");
fs.mkdirSync(repo, { recursive: true });
fs.writeFileSync(path.join(repo, ".git"), `gitdir: ${path.join(evil, ".git")}\n`);
excludeFromGit(repo, [".claude/worktrees/"]);
assert.equal(fs.readFileSync(target, "utf8"), '{"a":1}\n');
});
it("cannot hang it on a FIFO", () => {
const { excludeFromGit } = require("../lib/git-exclude");
const repo = path.join(MIA, "fifo");
gitInit(repo);
const ex = path.join(repo, ".git", "info", "exclude");
fs.rmSync(ex, { force: true });
execFileSync("mkfifo", [ex]);
const t0 = Date.now();
excludeFromGit(repo, [".mcp.json"]);
assert.ok(Date.now() - t0 < 5000);
});
it("still gets its exclude line in its own repo", () => {
const { excludeFromGit } = require("../lib/git-exclude");
const repo = path.join(MIA, "proj");
excludeFromGit(repo, [".mcp.json"]);
const text = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
assert.match(text, /^\/\.mcp\.json$/m);
});
});
describe("a developer browses and lists branches only in their own folder", () => {
it("browse", async () => {
const other = await call("mia", "GET", `/api/lanes/browse?path=${encodeURIComponent(BOB)}`);
assert.equal(other.status, 403);
assert.equal(other.body.error.code, "EFOLDER");
const viaLink = await call(
"mia",
"GET",
`/api/lanes/browse?path=${encodeURIComponent(path.join(MIA, "tohost"))}`
);
assert.equal(viaLink.status, 403);
const own = await call("mia", "GET", "/api/lanes/browse");
assert.equal(own.status, 200, JSON.stringify(own.body));
assert.equal(own.body.path, MIA);
assert.equal(own.body.parent, null);
assert.ok(own.body.entries.some((e) => e.name === "proj"));
const admin = await call("boss", "GET", `/api/lanes/browse?path=${encodeURIComponent(BOB)}`);
assert.equal(admin.status, 200);
});
it("branches", async () => {
const other = await call(
"mia",
"GET",
`/api/lanes/branches?repo=${encodeURIComponent(path.join(BOB, "proj"))}`
);
assert.equal(other.status, 403);
const own = await call(
"mia",
"GET",
`/api/lanes/branches?repo=${encodeURIComponent(path.join(MIA, "proj"))}`
);
// Not refused (the real sudo it would run is not this test's).
assert.notEqual(own.status, 403, JSON.stringify(own.body));
});
});
describe("a transcript scan by id looks only in the session owner's home", () => {
it("never serves a file another member planted under that session id", () => {
const sid = "aaaaaaaa-1111-4222-8333-444444444444";
const cwd = path.join(MIA, "proj");
const planted = path.join(BOB, ".claude", "projects", "x");
fs.mkdirSync(path.join(planted, sid, "subagents"), { recursive: true });
fs.writeFileSync(path.join(planted, `${sid}.jsonl`), "{}\n");
fs.writeFileSync(path.join(planted, sid, "subagents", "agent-1.jsonl"), "{}\n");
assert.equal(claudeHome.getTranscriptPath(sid, cwd), null);
assert.equal(claudeHome.findTranscriptPath(sid, cwd), null);
assert.equal(claudeHome.findTranscriptPath(sid, null), null, "no folder: the host's dir only");
assert.equal(claudeHome.getSubagentTranscriptPath(sid, cwd, "1"), null);
assert.deepEqual(claudeHome.sessionProjectsDirs(cwd), [path.join(MIA, ".claude", "projects")]);
});
});
describe("a Codex journal speaks only for its own account", () => {
const journal = (home, name, cwd, mtime) => {
const day = path.join(home, ".codex", "sessions", "2026", "10", "09");
fs.mkdirSync(day, { recursive: true });
const f = path.join(day, `rollout-2026-10-09T00-00-00-${name}.jsonl`);
fs.writeFileSync(f, JSON.stringify({ type: "session_meta", payload: { cwd } }) + "\n");
fs.utimesSync(f, mtime / 1000, mtime / 1000);
return f;
};
it("never flips another member's lane, and one home cannot crowd the others out", () => {
const ingest = require("../lib/codex-ingest");
const bobLane = addLane(path.join(BOB, "proj"), "bob", "codex");
const now = Date.now();
// mia writes bob's folder into her own fresh journal…
journal(MIA, "forged", path.join(BOB, "proj"), now);
ingest.__resetCache();
ingest.ingestCodexOnce();
assert.equal(db.prepare("SELECT status FROM lanes WHERE id = ?").get(bobLane).status, "idle");
// …and keeps 40 fresher journals of her own; bob's real one must still count.
for (let i = 0; i < 41; i++) journal(MIA, `own${i}`, path.join(MIA, "proj"), now);
journal(BOB, "real", path.join(BOB, "proj"), now - 5000);
ingest.__resetCache();
ingest.ingestCodexOnce();
assert.equal(
db.prepare("SELECT status FROM lanes WHERE id = ?").get(bobLane).status,
"running"
);
});
});
describe("the top of a member's home", () => {
it("is not readable through a workspace rooted at it, by anyone", async () => {
for (const p of [
".claude/.credentials.json",
".agenthub/handoff/proxy-x--agenthub-lane-1.json",
]) {
const r = await call("ana", "GET", `/api/lanes/${lanes.bobHome}/git/file?path=${p}`);
assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`);
assert.equal(r.body.error.code, "ESECRET");
}
const ok = await call("ana", "GET", `/api/lanes/${lanes.bobHome}/git/file?path=notes.txt`);
assert.equal(ok.status, 200, JSON.stringify(ok.body));
});
it("is no workspace (S3)", async () => {
const r = await call("mia", "POST", "/api/lanes", { cwd: MIA });
assert.equal(r.status, 403, JSON.stringify(r.body));
assert.equal(r.body.error.code, "EFOLDER");
});
});
describe("a disabled member's session", () => {
it("is still read from their home, and does not stop the liveness reaper", () => {
db.prepare("UPDATE users SET disabled = 1 WHERE username = 'dan'").run();
const danCwd = path.join(DAN, "proj");
assert.equal(claudeHome.projectsDirFor(danCwd), path.join(DAN, ".claude", "projects"));
assert.doesNotThrow(() => claudeHome.getTranscriptPath("dan-sess", danCwd));
const ins = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, tool, updated_at) VALUES (?, ?, 'active', ?, 'claude', '2020-01-01T00:00:00.000Z')"
);
ins.run("dan-sess", "dan", danCwd);
ins.run("host-sess", "host", HOSTDIR);
const { livenessReap } = require("../routes/hooks");
assert.doesNotThrow(() =>
livenessReap({ ignoreIdleGate: true, probe: { available: true, cwds: new Set() } })
);
const st = db.prepare("SELECT status FROM sessions WHERE id = 'host-sess'").get().status;
assert.notEqual(st, "active");
});
});
+379
View File
@@ -0,0 +1,379 @@
/**
* @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');
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}`));
});
});
+576
View File
@@ -0,0 +1,576 @@
/**
* @file server-s3-review5.test.js
* @description S3 plan, fifth review: a member's Antigravity conversation is
* read from a private copy (a FIFO beside it blocks nothing); a developer's
* extra workspace folder is refused outside their home before it is probed;
* the config reader never serves the data dir (pane tokens, push keys, the
* database); a stopped or flooding member tmux server and a hung sudo never
* block the dashboard nor blind it to other members' panes; a one-line 512 MB
* file, a huge member file and a sparse transcript neither crash the server
* nor fill the host's disk; an async route that throws answers 500; a
* non-admin owns no host-folder workspace and a pane's cwd is its
* workspace's account's; a pane token works only from its pane's uid; an
* analyst's GET path stays in their own folder; a drop goes to the pane's
* account. A fake passwd (every member = this test's uid) and server.conf
* make the members provisioned; fake sudo runs their commands as this
* account — and a fake `sudo` on PATH that never returns stands in for a
* stalled member, in a child process.
* @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-review5-")));
const USERS = path.join(TMP, "users");
const MIA = path.join(USERS, "mia");
const BOB = path.join(USERS, "bob");
// The real layout: the data dir (and its database) inside CLAUDE_HOME.
const DATA = path.join(TMP, "host-claude", "agenthub");
process.env.DASHBOARD_DATA_DIR = DATA;
process.env.DASHBOARD_DB_PATH = path.join(DATA, "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");
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 { fakeSudo } = require("./fixtures/fake-sudo");
let server;
let BASE;
const cookies = {};
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) =>
Number(
db
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'idle')")
.run(cwd, idOf(owner)).lastInsertRowid
);
const UID = process.getuid();
let tmuxCalls = [];
let liveRuns = new Set();
const fakeTmux = (args) => {
tmuxCalls.push(args);
if (args[0] === "-V") return "tmux 3.4";
if (args.includes("has-session")) {
if (liveRuns.has(String(args.at(-1)).replace(/^=/, ""))) return "";
throw Object.assign(new Error("no"), { status: 1 });
}
return "";
};
before(async () => {
for (const d of [path.join(MIA, "proj"), path.join(MIA, "extra"), path.join(BOB, "private")])
fs.mkdirSync(d, { recursive: true });
fs.writeFileSync(
process.env.AGENTHUB_PASSWD,
["mia", "bob", "ana", "boss"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`).join("\n") + "\n"
);
runAs.__resetCache();
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(fakeTmux);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
process.env.AGENTHUB_MODE = "server";
for (const [n, role] of [
["mia", "developer"],
["bob", "developer"],
["ana", "analyst"],
["boss", "admin"],
]) {
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 member's Antigravity conversation", () => {
it("is read from a private copy: a FIFO beside it blocks nothing", () => {
const Database = require("better-sqlite3");
const dir = path.join(MIA, ".gemini", "antigravity-cli", "conversations");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "7481e37e-9157-4145-b985-2ea9a2117f52.db");
const c = new Database(file);
c.exec(`CREATE TABLE gen_metadata (idx integer, data blob, PRIMARY KEY (idx));
CREATE TABLE trajectory_metadata_blob (id text, data blob, PRIMARY KEY (id));`);
c.close();
// A "hot journal" SQLite would open by name — and block on, as a FIFO.
assert.equal(spawnSync("mkfifo", [`${file}-journal`]).status, 0);
// In a child: the old open blocked its whole thread, which no test timeout interrupts.
const child = spawnSync(
process.execPath,
[
"-e",
`const r = require(${JSON.stringify(path.join(__dirname, "..", "lib", "antigravity-usage"))}).readConversation(${JSON.stringify(file)});
process.stdout.write(JSON.stringify(r));`,
],
{ env: { ...process.env, AGENTHUB_MODE: "server" }, timeout: 15_000, encoding: "utf8" }
);
assert.equal(child.signal, null, "the read never blocked");
assert.equal(child.status, 0, child.stderr);
assert.deepEqual(JSON.parse(child.stdout), { cwd: null, usage: {}, model: null });
assert.ok(fs.statSync(`${file}-journal`).isFIFO(), "the member's files are left alone");
});
});
describe("a developer's extra workspace folder", () => {
it("is refused outside their own home before anything probes it", async () => {
const lane = addLane(path.join(MIA, "proj"), "mia");
const add = (p) => call("mia", "POST", `/api/lanes/${lane}/sources/folder`, { path: p });
// Bob's folder answers exactly like a folder that does not exist.
for (const p of [path.join(BOB, "private"), path.join(BOB, "nope")]) {
const r = await add(p);
assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`);
assert.equal(r.body.error.code, "EFOLDER");
}
// A link in her own home that points out is refused too.
fs.symlinkSync(path.join(BOB, "private"), path.join(MIA, "to-bob"));
const linked = await add(path.join(MIA, "to-bob"));
assert.equal(linked.status, 403, JSON.stringify(linked.body));
// Her own folder is fine.
const mine = await add(path.join(MIA, "extra"));
assert.equal(mine.status, 201, JSON.stringify(mine.body));
assert.deepEqual(
mine.body.sources.map((s) => s.path),
[path.join(MIA, "extra")]
);
});
});
describe("the Claude config file reader", () => {
it("never serves AgentHub's data dir: pane tokens, push keys, the database", async () => {
fs.mkdirSync(path.join(DATA, "proxy-settings"), { recursive: true });
const token = path.join(DATA, "proxy-settings", "gw--agenthub-lane-4.json");
fs.writeFileSync(token, '{"env":{"ANTHROPIC_AUTH_TOKEN":"ahpane_SECRET"}}');
fs.writeFileSync(path.join(DATA, "vapid-keys.json"), '{"privateKey":"VAPID-SECRET"}');
// Through a link elsewhere in CLAUDE_HOME too.
fs.symlinkSync(token, path.join(TMP, "host-claude", "innocent.json"));
for (const who of ["ana", "boss"]) {
for (const f of [
token,
path.join(DATA, "vapid-keys.json"),
path.join(DATA, "dashboard.db"),
path.join(TMP, "host-claude", "innocent.json"),
]) {
const r = await call(who, "GET", `/api/cc-config/file?path=${encodeURIComponent(f)}`);
assert.equal(r.status, 400, `${who} ${f}: ${JSON.stringify(r.body).slice(0, 200)}`);
assert.ok(!/SECRET|SQLite/.test(JSON.stringify(r.body)));
}
}
fs.writeFileSync(path.join(TMP, "host-claude", "settings.json"), '{"ok":true}');
const ok = await call(
"ana",
"GET",
`/api/cc-config/file?path=${encodeURIComponent(path.join(TMP, "host-claude", "settings.json"))}`
);
assert.equal(ok.status, 200);
});
});
// ── Robustness: what a member can do to the dashboard's one thread ─────────
const LIB = path.join(__dirname, "..", "lib");
/** Run `code` in a child (the old code hung or crashed the whole process). */
const inChild = (code, env = {}) =>
spawnSync(process.execPath, ["-e", code], {
env: { ...process.env, AGENTHUB_MODE: "server", ...env },
timeout: 40_000,
encoding: "utf8",
});
/** A sparse file of `size` bytes (costs no disk), optionally with text at both ends. */
function sparse(file, size, { head = "", tail = "" } = {}) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, head);
fs.truncateSync(file, size - Buffer.byteLength(tail));
if (tail) fs.appendFileSync(file, tail);
}
describe("a member's stopped tmux server or planted FIFO", () => {
it("never blocks the dashboard: every sudo into a member is bounded", () => {
const lane = addLane(path.join(MIA, "tm"), "mia");
const net = require("node:net");
const sock = path.join(MIA, ".agenthub", "tmux", "sock");
fs.mkdirSync(path.dirname(sock), { recursive: true });
fs.rmSync(sock, { force: true });
const srv = net.createServer().listen(sock);
// sudo that never returns: a tmux client talking to a SIGSTOPped server.
const bin = path.join(TMP, "fakebin");
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(path.join(bin, "sudo"), "#!/bin/sh\nexec sleep 60\n", { mode: 0o755 });
try {
const child = inChild(
`const runAs = require(${JSON.stringify(`${LIB}/run-as`)});
const tmux = require(${JSON.stringify(`${LIB}/tmux`)});
const proj = ${JSON.stringify(path.join(MIA, "proj"))};
const time = (f) => { const t = Date.now(); try { f(); return { ms: Date.now() - t }; }
catch (e) { return { code: e.code, ms: Date.now() - t }; } };
let live;
const first = time(() => (live = tmux.liveSessions()));
const again = time(() => tmux.liveSessions());
process.stdout.write(JSON.stringify({
isSet: live instanceof Set,
keeps: live.has("agenthub-lane-${lane}"),
other: live.has("agenthub-lane-999999"),
first, again,
git: time(() => runAs.execIn(proj, "git", ["status"])),
write: time(() => runAs.writeAs(runAs.accountForPath(proj), proj + "/f.txt", "", "x")),
}));`,
{ PATH: `${bin}:${process.env.PATH}` }
);
assert.equal(child.signal, null, "nothing hung");
assert.equal(child.status, 0, child.stderr);
const r = JSON.parse(child.stdout);
// Her panes are UNKNOWN, not dead: nothing of hers is ended for a stall.
assert.equal(r.isSet, true, "the host's answer still counts");
assert.equal(r.keeps, true);
assert.equal(r.other, false);
assert.ok(r.first.ms < 10_000, `first list: ${r.first.ms} ms`);
assert.ok(r.again.ms < 500, `backed off: ${r.again.ms} ms`);
assert.equal(r.git.code, "ETIMEDOUT");
assert.equal(r.write.code, "ETIMEDOUT");
} finally {
srv.close();
}
});
it("one member's failing server never blinds the dashboard to everyone else's", () => {
const net = require("node:net");
const sock = path.join(MIA, ".agenthub", "tmux", "sock");
fs.mkdirSync(path.dirname(sock), { recursive: true });
fs.rmSync(sock, { force: true });
const srv = net.createServer().listen(sock);
const lane = addLane(path.join(MIA, "proj2"), "mia");
let memberCalls = 0;
let hostFails = false;
tmux.__reset();
tmux.__setExecImpl(() => {
if (hostFails) throw Object.assign(new Error("fork"), { code: "EAGAIN" });
return "agenthub-lane-1\n";
});
tmux.__setAccountImpl(
(name) => runAs.accountForSession(name),
() => {
memberCalls += 1;
throw Object.assign(new Error("spawnSync sudo ENOBUFS"), { code: "ENOBUFS" });
}
);
try {
const live = tmux.liveSessions();
assert.ok(live, "not null for everyone");
assert.equal(live.has("agenthub-lane-1"), true);
assert.equal(live.has(`agenthub-lane-${lane}`), true, "hers: unknown, kept");
assert.equal(live.has("agenthub-lane-999999"), false);
tmux.__setExecImpl(() => "agenthub-lane-1\n");
tmux.liveSessions();
assert.equal(memberCalls, 1, "a flooding server is not asked again at once");
hostFails = true;
tmux.__setExecImpl(() => {
throw Object.assign(new Error("fork"), { code: "EAGAIN" });
});
assert.equal(tmux.liveSessions(), null, "our own failure still proves nothing");
} finally {
srv.close();
tmux.__reset();
tmux.__setExecImpl(fakeTmux);
}
});
});
describe("a member's huge or sparse files", () => {
it("a 600 MB line without a newline never crashes the transcript reader", () => {
const big = path.join(TMP, "one-line.jsonl");
sparse(big, 600 * 1024 * 1024, { head: '{"type":"user"' });
try {
const child = inChild(
`require(${JSON.stringify(path.join(__dirname, "..", "..", "scripts", "import-history"))})
.parseSessionFile(${JSON.stringify(big)})
.then(() => process.stdout.write("survived"));`
);
assert.equal(child.status, 0, child.stderr.slice(-400));
assert.equal(child.stdout, "survived");
} finally {
fs.rmSync(big, { force: true });
}
});
it("readLines keeps line numbers and never builds an over-long line", async () => {
const f = path.join(TMP, "lines.jsonl");
fs.writeFileSync(f, `a\r\n\n${"x".repeat(3 << 20)}\nlast`);
const out = [];
for await (const l of runAs.readLines(f, { maxLine: 1 << 20 })) out.push(l);
assert.deepEqual(out, ["a", "", "", "last"]);
fs.rmSync(f);
});
it("a member's file is read only up to its cap", async () => {
const f = path.join(MIA, ".codex", "auth.json");
sparse(f, 70 * 1024 * 1024);
assert.throws(() => runAs.readFileSafe(f, "utf8"), { code: "ETOOBIG" });
await assert.rejects(runAs.readFileSafeAsync(f), { code: "ETOOBIG" });
fs.writeFileSync(f, `{"pad":"${"x".repeat(2 << 20)}"}`);
assert.throws(() => runAs.readFileSafe(f, "utf8", { max: 1 << 20 }), { code: "ETOOBIG" });
fs.writeFileSync(f, '{"ok":1}');
assert.equal(runAs.readFileSafe(f, "utf8"), '{"ok":1}');
// A member transcript past the cap is not read on the event loop.
const t = path.join(MIA, ".claude", "projects", "p", "big.jsonl");
sparse(t, 300 * 1024 * 1024);
const real = fs.readSync;
let reads = 0;
fs.readSync = (...a) => {
reads += 1;
return real(...a);
};
try {
const TranscriptCache = require("../lib/transcript-cache");
new TranscriptCache()._streamRange(t, 0, fs.statSync(t).size);
} finally {
fs.readSync = real;
}
assert.equal(reads, 0);
fs.rmSync(t);
});
it("the transcript snapshot never fills the host's disk from a sparse file", () => {
const { snapshotTranscript } = require("../../scripts/import-history");
const { getTranscriptSnapshotDir } = require("../lib/claude-home");
const proj = path.join(MIA, ".claude", "projects", "-p");
const huge = "11111111-2222-4333-8444-555555555555";
sparse(path.join(proj, `${huge}.jsonl`), 300 * 1024 * 1024);
snapshotTranscript(path.join(proj, `${huge}.jsonl`), huge);
assert.equal(fs.existsSync(path.join(getTranscriptSnapshotDir(), `${huge}.jsonl`)), false);
// Under the cap: copied, and its hole stays a hole.
const holey = "11111111-2222-4333-8444-666666666666";
sparse(path.join(proj, `${holey}.jsonl`), 32 * 1024 * 1024, {
head: '{"a":1}\n',
tail: '{"z":1}\n',
});
snapshotTranscript(path.join(proj, `${holey}.jsonl`), holey);
const copy = path.join(getTranscriptSnapshotDir(), `${holey}.jsonl`);
const st = fs.statSync(copy);
assert.equal(st.size, 32 * 1024 * 1024);
assert.ok(st.blocks * 512 < 4 * 1024 * 1024, `copy allocated ${st.blocks * 512} bytes`);
const text = fs.readFileSync(copy);
assert.equal(text.subarray(0, 8).toString(), '{"a":1}\n');
assert.equal(text.subarray(-8).toString(), '{"z":1}\n');
});
});
describe("a request that makes an async route throw", () => {
it("answers an error instead of taking the server down", async () => {
const r = await call("mia", "POST", "/api/lanes/worktree", {
sourceRepo: path.join(USERS, "ghost", "x"),
branch: "b",
});
assert.equal(r.status, 403, JSON.stringify(r.body));
assert.equal(r.body.error.code, "EMEMBERLANE");
// Any async handler that rejects: a 500, not an exit.
const express = require("express");
const app = express();
app.get("/boom", async () => {
await null;
throw new Error("boom");
});
const s = await new Promise((ok) => {
const h = app.listen(0, () => ok(h));
});
try {
const res = await fetch(`http://127.0.0.1:${s.address().port}/boom`);
assert.equal(res.status, 500);
} finally {
s.close();
}
assert.equal((await call("mia", "GET", "/api/lanes")).status, 200, "still serving");
});
});
describe("who a pane runs as", () => {
const pty = require("../lib/pty-run");
const HOST = path.join(TMP, "hostdir");
before(() => fs.mkdirSync(HOST, { recursive: true }));
it("a non-admin owns no workspace outside their own home: no host pane for them", () => {
const lane = addLane(HOST, "boss");
assert.throws(() => auth.assignWorkspace(lane, "mia"), { code: "EOWNERFOLDER" });
assert.throws(() => auth.assignWorkspace(lane, "ana"), { code: "EOWNERFOLDER" });
// One that got there anyway (assigned before this rule, or a demoted admin's).
const theirs = addLane(path.join(HOST, "x"), "mia");
assert.throws(() => pty.spawnRun({ laneId: theirs, cwd: path.join(HOST, "x"), tool: "claude" }), {
code: "EOWNERFOLDER",
});
assert.throws(() => pty.spawnShell({ laneId: theirs, cwd: path.join(HOST, "x") }), {
code: "EOWNERFOLDER",
});
fs.mkdirSync(path.join(HOST, "y"), { recursive: true });
const viaRoute = addLane(path.join(HOST, "y"), "mia");
return call("mia", "POST", "/api/run", { laneId: viaRoute, cwd: path.join(HOST, "y") }).then(
(r) => {
assert.equal(r.status, 409, JSON.stringify(r.body));
assert.equal(r.body.error.code, "EOWNERFOLDER");
}
);
});
it("demoting or disabling a user ends their panes that run as the host", () => {
const lane = addLane(path.join(HOST, "z"), "boss");
const run = pty.runIdForLane(lane, 0);
liveRuns = new Set([run]);
tmuxCalls = [];
try {
auth.setRole("boss", "developer");
assert.ok(
tmuxCalls.some((a) => a[0] === "kill-session" && String(a.at(-1)).includes(run)),
"the host pane was killed"
);
} finally {
auth.setRole("boss", "admin");
liveRuns = new Set();
}
});
it("a pane's cwd must be its workspace's account's (POST /api/run names the cwd)", async () => {
const mine = addLane(path.join(MIA, "own"), "mia");
assert.throws(
() => pty.spawnRun({ laneId: mine, cwd: path.join(BOB, "private"), tool: "claude" }),
{ code: "ECWDACCOUNT" }
);
const host = addLane(path.join(HOST, "h"), "boss");
const r = await call("boss", "POST", "/api/run", { laneId: host, cwd: path.join(MIA, "proj") });
assert.equal(r.status, 409, JSON.stringify(r.body));
assert.equal(r.body.error.code, "ECWDACCOUNT");
});
});
describe("a pane token", () => {
const hookAuth = require("../lib/hook-auth");
const crypto = require("node:crypto");
it("is accepted only from the Linux account its pane runs as", async () => {
const lane = addLane(path.join(MIA, "pt"), "mia");
const run = `agenthub-lane-${lane}`;
const token = "ahpane_review5-token-value-0123456789";
db.prepare(
`INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash)
VALUES (?, 'interactive', ?, 'running', ?, ?)`
).run(run, path.join(MIA, "pt"), lane, crypto.createHash("sha256").update(token).digest("hex"));
tmux.__setAccountImpl(
(name) => runAs.accountForSession(name),
(acct, args) => fakeTmux(args)
);
liveRuns = new Set([run]);
const asPane = () =>
fetch(`${BASE}/api/sessions`, { headers: { "x-agenthub-pane-token": token } }).then(
(r) => r.status
);
try {
hookAuth.__setPeerUid(() => UID); // mia's uid in this test's passwd
assert.equal(await asPane(), 200);
hookAuth.__setPeerUid(() => UID + 7); // another local account
assert.equal(await asPane(), 401);
hookAuth.__setPeerUid(() => null); // a peer the kernel table does not show
assert.equal(await asPane(), 401);
// The AI Proxy gateway takes the same token: refused from another uid.
hookAuth.__setPeerUid(() => UID + 7);
const gw = await fetch(`${BASE}/api/proxy-gw/x/v1/messages`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(gw.status, 401);
} finally {
hookAuth.__setPeerUid(null);
liveRuns = new Set();
db.prepare("DELETE FROM dashboard_runs WHERE id = ?").run(run);
tmux.__reset();
tmux.__setExecImpl(fakeTmux);
}
});
});
describe("an analyst's GET that names a path", () => {
it("stays in their own folder or workspaces, like any non-admin's request", async () => {
fs.mkdirSync(path.join(USERS, "ana", "work"), { recursive: true });
const q = encodeURIComponent(path.join(BOB, "private"));
for (const p of [
`/api/lanes/browse?path=${q}`,
`/api/lanes/branches?repo=${q}`,
`/api/run/files?cwd=${q}`,
]) {
const r = await call("ana", "GET", p);
assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`);
assert.equal(r.body.error.code, "EFOLDER");
}
const own = await call(
"ana",
"GET",
`/api/lanes/browse?path=${encodeURIComponent(path.join(USERS, "ana"))}`
);
assert.equal(own.status, 200, JSON.stringify(own.body));
assert.equal((await call("boss", "GET", `/api/lanes/browse?path=${q}`)).status, 200);
});
});
describe("a dropped file", () => {
const drop = (who, laneId) =>
fetch(`${BASE}/api/run/drop-file?name=a.txt${laneId ? `&laneId=${laneId}` : ""}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream", cookie: cookies[who] },
body: Buffer.from("hello"),
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
it("goes where the workspace's panes can read it, not to the requester's account", async () => {
fs.mkdirSync(path.join(MIA, ".agenthub", "handoff"), { recursive: true });
const miaLane = addLane(path.join(MIA, "drop"), "mia");
const byAdmin = await drop("boss", miaLane);
assert.equal(byAdmin.status, 200, JSON.stringify(byAdmin.body));
assert.ok(
byAdmin.body.path.startsWith(path.join(MIA, ".agenthub", "handoff", "drops") + path.sep),
byAdmin.body.path
);
// A host workspace's pane runs as the host: the host's own 0700 drops dir.
const hostLane = addLane(path.join(TMP, "hostdir", "d"), "boss");
const host = await drop("boss", hostLane);
assert.equal(host.status, 200, JSON.stringify(host.body));
assert.equal(path.dirname(host.body.path), path.join(DATA, "drops"));
// Another member's workspace is not one to name.
assert.equal((await drop("bob", miaLane)).status, 404);
});
});
+281
View File
@@ -0,0 +1,281 @@
/**
* @file server-s3-system.test.js
* @description S3 plan, P2 (one Linux user per account), with every sudo and
* systemctl call faked: the system-unit service manager (host-owned env file,
* sudo for start/stop/restart, pure-fs staleness), service-manager picking it,
* Quit stopping it through sudo, the Linux-account layer (server.conf, name
* rule, the exact provision argv), user create provisioning FIRST, disable
* ending the logins first (and locking only an account that exists), and a
* member's bootstrap doing nothing.
* @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 TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-system-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.AGENTHUB_SYSTEM_UNIT_DIR = path.join(TMP, "units");
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
// `lin` has a Linux account; `pre` (made before S3) and `Old.Name` do not.
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
fs.writeFileSync(process.env.AGENTHUB_PASSWD, "ah-lin:x:5101:5101::/:/bin/sh\n");
const sys = require("../../scripts/systemd-system-service");
const linux = require("../lib/linux-accounts");
const auth = require("../lib/auth");
const { db } = require("../db");
const UNIT = path.join(process.env.AGENTHUB_SYSTEM_UNIT_DIR, "agenthub.service");
const writeUnit = () => {
fs.mkdirSync(path.dirname(UNIT), { recursive: true });
fs.writeFileSync(UNIT, `[Unit]\n${sys.MARKER}\n`);
};
const writeConf = (uid = 4242) => {
linux.__resetConfCache();
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=host\nHOST_UID=${uid}\nUSERS_ROOT=/srv/agenthub/users\nPORT=4820\n`
);
};
after(() => {
sys.__reset();
delete process.env.AGENTHUB_MODE;
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("the system-unit service manager", () => {
let calls;
beforeEach(() => {
calls = [];
fs.rmSync(path.dirname(UNIT), { recursive: true, force: true });
fs.rmSync(sys.envPath(), { force: true });
sys.__setExecImpl(
(args) => {
calls.push(["systemctl", ...args]);
return { status: 0, stdout: args[0] === "is-active" ? "active\n" : "enabled\n" };
},
(args) => {
calls.push(["sudo", "-n", "systemctl", ...args]);
return { status: 0 };
}
);
});
it("is unavailable, and changes nothing, without its unit", () => {
assert.equal(sys.unitIsOurs("agenthub"), false);
assert.equal(sys.systemctlAvailable(), false);
assert.equal(sys.writeUserService({ argv: ["/n", "/r/server/index.js"] }), "unavailable");
assert.equal(sys.restartUserService("agenthub"), "unavailable");
assert.deepEqual(calls, []);
});
it("repoints by rewriting the host-owned env file, never the unit", () => {
writeUnit();
const unitBefore = fs.readFileSync(UNIT, "utf8");
const unit = {
argv: ["/usr/bin/node", "/plugins/v2/server/index.js"],
env: { NODE_ENV: "production" },
};
assert.equal(sys.writeUserService(unit), "written");
assert.equal(sys.writeUserService(unit), "unchanged");
const env = fs.readFileSync(sys.envPath(), "utf8");
assert.match(env, /^AGENTHUB_NODE=\/usr\/bin\/node$/m);
assert.match(env, /^AGENTHUB_ROOT=\/plugins\/v2$/m);
assert.match(env, /^NODE_ENV=production$/m);
assert.equal(fs.statSync(sys.envPath()).mode & 0o777, 0o600);
assert.equal(fs.readFileSync(UNIT, "utf8"), unitBefore);
assert.equal(sys.unitRoot("agenthub"), "/plugins/v2");
assert.equal(sys.unitIsStale("agenthub", "/plugins/v2"), false);
assert.equal(sys.unitIsStale("agenthub", "/plugins/v3"), true);
assert.ok(!calls.some((c) => c[0] === "sudo"), "no root needed to repoint");
});
it("restarts, starts and enables only through sudo, restart without blocking", () => {
writeUnit();
assert.equal(sys.restartUserService("agenthub"), "restarted");
assert.equal(sys.setUnitEnabled("agenthub", false), "ok");
sys.installUserService({ name: "agenthub", argv: ["/n", "/r/server/index.js"] });
const sudo = calls.filter((c) => c[0] === "sudo").map((c) => c.slice(3).join(" "));
assert.deepEqual(sudo, [
"restart --no-block agenthub.service",
"disable agenthub.service",
"start --no-block agenthub.service",
]);
assert.deepEqual(sys.stopArgv("agenthub"), [
"sudo",
["-n", "systemctl", "stop", "--no-block", "agenthub.service"],
]);
});
it("is what service-manager picks while the unit exists", () => {
const load = () => {
delete require.cache[require.resolve("../../scripts/service-manager")];
return require("../../scripts/service-manager");
};
if (process.platform !== "linux") return;
fs.rmSync(UNIT, { force: true });
assert.notEqual(load().kind, "systemd-system");
writeUnit();
assert.equal(load().kind, "systemd-system");
});
it("lets Quit stop it through sudo, never by SIGTERM", () => {
const { stopServer } = require("../lib/quit");
const spawned = [];
let killed = false;
stopServer({
svc: {
...sys,
systemctlAvailable: () => true,
unitIsOurs: () => true,
unitIsActive: () => true,
},
spawn: (cmd, args) => {
spawned.push([cmd, ...args]);
return { unref() {} };
},
kill: () => (killed = true),
});
assert.deepEqual(spawned, [
["sudo", "-n", "systemctl", "stop", "--no-block", "agenthub.service"],
]);
assert.equal(killed, false);
});
});
describe("Linux accounts", () => {
let argvs;
let failNext;
before(() => {
linux.__setRunImpl((argv) => {
argvs.push(argv);
if (failNext) {
failNext = false;
throw Object.assign(new Error("exit 4"), { stderr: "agenthub-provision: ah-x exists\n" });
}
return `ok ${argv[1]} ${argv[2]}\n`;
});
});
beforeEach(() => {
argvs = [];
failNext = false;
writeConf();
process.env.AGENTHUB_MODE = "server";
});
it("is off outside server mode or without server.conf", () => {
delete process.env.AGENTHUB_MODE;
assert.equal(linux.s3Enabled(), false);
assert.equal(linux.provision("create", "anyone"), null);
process.env.AGENTHUB_MODE = "server";
fs.rmSync(process.env.AGENTHUB_SERVER_CONF);
linux.__resetConfCache();
assert.equal(linux.s3Enabled(), false);
assert.equal(linux.accountFor("mia"), null);
assert.deepEqual(argvs, []);
});
it("reads server.conf and maps a user to ah-<name> under USERS_ROOT", () => {
assert.deepEqual(linux.serverConf(), {
hostUser: "host",
hostUid: 4242,
usersRoot: "/srv/agenthub/users",
port: 4820,
bins: {},
});
assert.deepEqual(linux.accountFor("mia"), {
login: "ah-mia",
home: "/srv/agenthub/users/mia",
});
});
it("refuses a name Linux cannot take, before any sudo", () => {
for (const bad of ["Mia", "1mia", "mi.a", "-x", "a".repeat(30), "root;id", "../x"]) {
assert.throws(() => linux.provision("create", bad), { code: "EBADUSER" }, bad);
}
assert.throws(() => linux.provision("delete", "mia"), /unknown provision action/);
assert.deepEqual(argvs, []);
});
it("calls exactly the provision helper with an action and a name", () => {
linux.provision("create", "mia");
linux.provision("disable", "mia");
assert.deepEqual(argvs, [
["/opt/agenthub/sbin/agenthub-provision", "create", "mia"],
["/opt/agenthub/sbin/agenthub-provision", "disable", "mia"],
]);
});
it("creates the Linux account BEFORE the user, and no user when that fails", async () => {
const made = await auth.createUser("lin", "member-pass-1", "developer");
assert.equal(made.username, "lin");
assert.deepEqual(argvs[0], ["/opt/agenthub/sbin/agenthub-provision", "create", "lin"]);
failNext = true;
await assert.rejects(auth.createUser("lux", "member-pass-1", "developer"), {
code: "EPROVISION",
message: /ah-x exists/,
});
assert.equal(db.prepare("SELECT 1 FROM users WHERE username = 'lux'").get(), undefined);
await assert.rejects(auth.createUser("Lux", "member-pass-1", "developer"), {
code: "EBADUSER",
});
});
it("ends the logins first on disable, then locks the Linux account", () => {
argvs = [];
failNext = true;
// A failed lock is still reported — but the dashboard access is gone.
assert.throws(() => auth.setDisabled("lin", true), { code: "EPROVISION" });
assert.equal(db.prepare("SELECT disabled FROM users WHERE username = 'lin'").get().disabled, 1);
auth.setDisabled("lin", false);
assert.equal(db.prepare("SELECT disabled FROM users WHERE username = 'lin'").get().disabled, 0);
auth.setDisabled("lin", true);
assert.equal(db.prepare("SELECT disabled FROM users WHERE username = 'lin'").get().disabled, 1);
assert.deepEqual(
argvs.map((a) => a[1]),
["disable", "enable", "disable"]
);
});
it("disables and enables a user who has no Linux account, with no sudo", () => {
argvs = [];
for (const n of ["pre", "Old.Name"]) {
db.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(n);
const id = db.prepare("SELECT id FROM users WHERE username = ?").get(n).id;
db.prepare(
"INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES (?, ?, '2999-01-01')"
).run(`h-${n}`, id);
auth.setDisabled(n, true);
const row = db.prepare("SELECT disabled FROM users WHERE id = ?").get(id);
assert.equal(row.disabled, 1, n);
assert.equal(
db.prepare("SELECT 1 FROM auth_sessions WHERE user_id = ?").get(id),
undefined,
`${n}'s logins end`
);
auth.setDisabled(n, false);
assert.equal(db.prepare("SELECT disabled FROM users WHERE id = ?").get(id).disabled, 0, n);
}
assert.deepEqual(argvs, []);
});
});
describe("a member's plugin bootstrap", () => {
it("does nothing under another uid than the host's", () => {
const { notTheHost, bootstrap } = require("../../scripts/plugin-bootstrap");
writeConf(process.getuid() + 1);
assert.equal(notTheHost(), true);
assert.equal(bootstrap(path.join(TMP, "member-rt")), "not-host");
assert.equal(fs.existsSync(path.join(TMP, "member-rt")), false);
writeConf(process.getuid());
assert.equal(notTheHost(), false);
fs.rmSync(process.env.AGENTHUB_SERVER_CONF);
assert.equal(notTheHost(), false);
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* @file server-s3-tools.test.js
* @description S3/S4 plan, P5: what a publish copies under the shared tools
* root — a native executable alone, an npm package's whole folder, a script's
* own version folder — and never a shared `bin` folder; the `bin/<command>`
* link; an unchanged tool is not copied twice; everything readable by every
* account; the app scripts and the `agenthub` launcher for members' panes.
* @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-tools-")));
process.env.AGENTHUB_TOOLS_ROOT = path.join(TMP, "opt", "tools");
process.env.AGENTHUB_APP_ROOT = path.join(TMP, "opt", "app");
const shared = require("../lib/shared-tools");
const write = (p, body, mode = 0o755) => {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body, { mode });
return p;
};
const ELF = Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.alloc(64)]);
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
describe("what a publish copies", () => {
it("a native executable: that file alone", () => {
const f = write(path.join(TMP, "home", ".local", "share", "x", "versions", "1.0"), ELF);
assert.deepEqual(shared.planFor(f), { src: f, entry: "" });
});
it("a file in an npm package: the package folder (scoped too)", () => {
const plain = write(
path.join(TMP, "npm", "lib", "node_modules", "opencode-ai", "bin", "oc.js"),
"#!/usr/bin/env node\n"
);
assert.deepEqual(shared.planFor(plain), {
src: path.join(TMP, "npm", "lib", "node_modules", "opencode-ai"),
entry: path.join("bin", "oc.js"),
});
const scoped = write(
path.join(TMP, "npm", "lib", "node_modules", "@github", "copilot", "index.js"),
"x"
);
assert.equal(
shared.planFor(scoped).src,
path.join(TMP, "npm", "lib", "node_modules", "@github", "copilot")
);
});
it("a script in a version folder: that folder; in a shared bin: refused", () => {
const s = write(
path.join(TMP, "home", ".local", "share", "cursor-agent", "versions", "v1", "cursor-agent"),
"#!/bin/sh\n"
);
assert.deepEqual(shared.planFor(s), { src: path.dirname(s), entry: "cursor-agent" });
const b = write(path.join(TMP, "home", ".local", "bin", "tool.sh"), "#!/bin/sh\n");
assert.throws(() => shared.planFor(b), { code: "ESHAREDLAYOUT" });
// ~/.tool/tool: its login usually sits beside it — never the whole folder.
const realHome = process.env.HOME;
process.env.HOME = path.join(TMP, "home");
try {
const dot = write(path.join(TMP, "home", ".tool", "tool"), "#!/bin/sh\n");
assert.throws(() => shared.planFor(dot), { code: "ESHAREDLAYOUT" });
} finally {
process.env.HOME = realHome;
}
});
});
describe("publishing an installed tool", () => {
let real;
before(() => {
real = write(
path.join(TMP, "home", ".local", "share", "claude", "versions", "2.1.0"),
ELF,
0o700
);
write(path.join(TMP, "home", ".local", "bin", "claude"), ""); // replaced by a link below
fs.rmSync(path.join(TMP, "home", ".local", "bin", "claude"));
fs.symlinkSync(real, path.join(TMP, "home", ".local", "bin", "claude"));
const aiTools = require("../lib/ai-tools");
aiTools.detect = () => ({
installed: true,
path: path.join(TMP, "home", ".local", "bin", "claude"),
});
aiTools.get = (id) => (id === "claude" ? { id: "claude", detect: ["claude"] } : null);
});
it("copies it once, links bin/<command>, readable by everyone", async () => {
const r = await shared.publish("claude");
assert.equal(r.copied, true);
const link = path.join(process.env.AGENTHUB_TOOLS_ROOT, "bin", "claude");
assert.equal(fs.realpathSync(link), r.path);
assert.equal(shared.sharedPath("claude"), link);
assert.equal(fs.statSync(r.path).mode & 0o777, 0o755);
const again = await shared.publish("claude");
assert.equal(again.copied, false);
assert.equal(again.path, r.path);
});
it("never copies a file whose name says it holds a login", async () => {
const dir = path.join(TMP, "home", ".local", "share", "cur", "versions", "v1");
const exe = write(path.join(dir, "cur"), "#!/bin/sh\n");
write(path.join(dir, "lib.js"), "x", 0o644);
write(path.join(dir, "auth.json"), "{}", 0o600);
const aiTools = require("../lib/ai-tools");
const [get, detect] = [aiTools.get, aiTools.detect];
aiTools.get = (id) => (id === "cur" ? { id: "cur", detect: ["cur"] } : get(id));
aiTools.detect = (id, o) => (id === "cur" ? { installed: true, path: exe } : detect(id, o));
try {
const r = await shared.publish("cur");
const copy = path.dirname(r.path);
assert.ok(fs.existsSync(path.join(copy, "lib.js")));
assert.ok(!fs.existsSync(path.join(copy, "auth.json")));
} finally {
[aiTools.get, aiTools.detect] = [get, detect];
}
});
it("never names a command outside a plain file name", () => {
assert.equal(shared.sharedPath("../etc/passwd"), null);
assert.equal(shared.sharedPath("nope"), null);
});
});
describe("the app scripts members run", () => {
it("are copied with an agenthub launcher", async () => {
const root = path.join(TMP, "install");
write(path.join(root, "scripts", "hook-handler.js"), "// hook\n", 0o600);
write(path.join(root, "bin", "agenthub.js"), "// cli\n", 0o700);
await shared.publishApp(root);
const hook = path.join(process.env.AGENTHUB_APP_ROOT, "scripts", "hook-handler.js");
assert.equal(fs.readFileSync(hook, "utf8"), "// hook\n");
assert.equal(fs.statSync(hook).mode & 0o777, 0o644);
const launcher = fs.readFileSync(
path.join(process.env.AGENTHUB_TOOLS_ROOT, "bin", "agenthub"),
"utf8"
);
assert.match(launcher, /bin\/node .*\/bin\/agenthub\.js "\$@"/);
});
});