Files
AgentHub/server/__tests__/collab-runs.test.js
T
nntrivi2001 1a365a45bd feat(server-mode): collaborators run sessions on their own account (sharing phase 2)
A user a workspace is shared with may now start and resume AI sessions in
it. The run uses THEIR OWN account — a Claude pool setup-token, a Codex/Cursor
pool API key, or an AI Proxy of theirs — never the owner's login or a pool
login (409 ENOCOLLABACCOUNT otherwise). Their quota decides (EQUOTA, their own
flag file, the sweep goes by payer).

Money is per payer by UTC hour (session_payers, server/lib/session-payers.js):
a session nobody else ran is billed as before; one a collaborator started is
theirs; a resume changes hands from the next hour. Only Claude books by the
hour, so a cross-payer resume of any other tool and a collaborator's
resumeLast are refused (409 ECOLLABRESUME); a Terminal tab stays the owner's.
Users page, details, quotas, Today (mine=1) and developer budgets count by
payer. Docs: SERVER-MODE.md, API.md, CLAUDE.md, plan status.
2026-11-06 11:10:05 +07:00

181 lines
7.1 KiB
JavaScript

/**
* @file collab-runs.test.js
* @description A session started by a user a workspace is shared with
* (sharing phase 2, pty-run.js spawnRun `startedBy`): it runs on a pool
* setup-token of THEIR OWN through tmux `-e` (never the owner's pick, never a
* login), is refused with `ENOCOLLABACCOUNT` when they have none, records
* `started_by`, makes them the payer of the new session from its first hour
* (session-payers.js), points its hooks at their own quota flag and is
* refused while they are over their quota. The owner's own start is unchanged.
* @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(), "collab-runs-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "claude");
const { db } = require("../db");
const ccUsage = require("../lib/cc-usage");
const pool = require("../lib/claude-pool");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
const realFetchUsage = ccUsage.fetchUsage;
const realFetchOwner = ccUsage.fetchOwner;
let argv = null;
let alice;
let bob;
let lane;
const envOf = (name) => {
const hit = (argv || []).find((x) => String(x).startsWith(`${name}=`));
return hit ? String(hit).slice(name.length + 1) : undefined;
};
before(() => {
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
ccUsage.fetchOwner = async () => null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args;
return "";
});
const user = (name) =>
Number(
db
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', 'developer')")
.run(name).lastInsertRowid
);
alice = user("alice");
bob = user("bob");
lane = Number(
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(TMP, alice)
.lastInsertRowid
);
db.prepare("INSERT INTO lane_shares (lane_id, user_id) VALUES (?, ?)").run(lane, bob);
process.env.AGENTHUB_MODE = "server";
});
after(() => {
delete process.env.AGENTHUB_MODE;
ccUsage.fetchUsage = realFetchUsage;
ccUsage.fetchOwner = realFetchOwner;
tmux.__setExecImpl(null);
fs.rmSync(TMP, { recursive: true, force: true });
});
beforeEach(() => {
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
db.prepare("DELETE FROM dashboard_runs").run();
db.prepare("DELETE FROM session_payers").run();
db.prepare("DELETE FROM account_pins").run();
db.prepare("DELETE FROM user_quotas").run();
pool.__quotaCache.clear();
argv = null;
});
describe("a collaborator's own session", () => {
it("is refused without an account of their own, never run on the owner's", () => {
assert.throws(() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob }), {
code: "ENOCOLLABACCOUNT",
});
assert.equal(argv, null, "no pane was started");
// The owner's own start is as before.
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude" });
const row = db.prepare("SELECT started_by FROM dashboard_runs").get();
assert.equal(row.started_by, null);
});
it("runs on their own pool token, records them and makes them its payer", () => {
pool.addAccount("theirs", TOKEN("b"));
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob, slot: 1 });
assert.equal(envOf("CLAUDE_CODE_OAUTH_TOKEN"), TOKEN("b"));
assert.ok(argv.indexOf(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("b")}`) < argv.indexOf("--"));
// Their own quota flag, never the owner's.
assert.match(envOf("AGENTHUB_QUOTA_FILE"), new RegExp(`u${bob}\\.json$`));
const run = db
.prepare("SELECT session_id, started_by, account FROM dashboard_runs WHERE id = ?")
.get(`agenthub-lane-${lane}-1`);
assert.equal(run.started_by, bob);
assert.equal(run.account, "theirs");
assert.deepEqual(
db.prepare("SELECT session_id, from_hour, user_id FROM session_payers").all(),
[{ session_id: run.session_id, from_hour: "0000", user_id: bob }]
);
});
it("starts nothing while they are over their own quota — and the owner still starts", () => {
pool.addAccount("theirs", TOKEN("b"));
const MODEL = "claude-sonnet-4-5-20250929";
const hour = new Date().toISOString().slice(0, 13);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sBob', 'b', 'active', ?, ?, ?)"
).run(TMP, lane, new Date().toISOString());
db.prepare(
"INSERT INTO token_usage (session_id, model, output_tokens) VALUES ('sBob', ?, 10000000)"
).run(MODEL);
db.prepare(
"INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES ('sBob', ?, ?, 10000000)"
).run(hour, MODEL);
db.prepare("INSERT INTO session_payers VALUES ('sBob', '0000', ?)").run(bob);
const q = require("../lib/user-quota");
for (const id of [alice, bob]) {
q.setUserQuota(id, { mode: "custom", limit_usd: 1, period: "5h", tz_offset: 0 });
}
assert.throws(() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob }), {
code: "EQUOTA",
});
// Those hours are bob's money, not alice's.
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", slot: 2 });
assert.ok(argv, "the owner's pane started");
});
it("never resumes a non-Claude session across payers, nor the latest one for a collaborator", () => {
pool.addAccount("theirs", TOKEN("b"));
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('sCodexAAA', 'a', 'completed', ?, ?, 'codex')"
).run(TMP, lane);
assert.throws(
() =>
pty.spawnRun({
laneId: lane,
cwd: TMP,
tool: "codex",
resumeSessionId: "sCodexAAA",
startedBy: bob,
}),
{ code: "ECOLLABRESUME" }
);
// Nor the owner a session the collaborator pays for.
db.prepare("INSERT INTO session_payers VALUES ('sCodexBBB', '0000', ?)").run(bob);
assert.throws(
() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "codex", resumeSessionId: "sCodexBBB" }),
{ code: "ECOLLABRESUME" }
);
assert.throws(
() =>
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", resumeLast: true, startedBy: bob }),
{ code: "ECOLLABRESUME" }
);
});
it("keeps a Terminal tab's session the owner's money", () => {
const payers = require("../lib/session-payers");
db.prepare(
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, started_by)
VALUES ('agenthub-lane-shell', 'sShell', 'interactive', ?, 'running', ?, 'shell', ?)`
).run(TMP, lane, bob);
payers.noteRun("agenthub-lane-shell");
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM session_payers").get().n, 0);
});
});