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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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" }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 "\$@"/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user