1a365a45bd
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.
181 lines
7.1 KiB
JavaScript
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);
|
|
});
|
|
});
|