fix(cost): bill a session outside every workspace to its Linux account's user
A script's claude -p in a temp folder (the SG Flow eval runner strips the pane's env) had no workspace, so its spend reached nobody's Today, the Users page or a spend quota — ~$57 of a $74 day was missing from the status bar. sessions.account records the Linux account a session ran as (the hook's peer uid, else its folder for poller-made and old rows); a session no workspace owns is owned by that account's user — a member's to them, the host's to the admin ticked 'Owns this server's host account' (or the only admin). Workspace ownership still wins.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file session-account.test.js
|
||||
* @description A session no workspace owns is billed to the user of the Linux
|
||||
* account it ran as (server/lib/session-account.js): a member's account to
|
||||
* that member, the host's to the admin marked as its owner (or the only
|
||||
* admin). A workspace's ownership always wins, a pushed session never gets an
|
||||
* account, and the hook peer is stamped once.
|
||||
* @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(), "session-account-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const { db } = require("../db");
|
||||
const sa = require("../lib/session-account");
|
||||
const scope = require("../lib/scope");
|
||||
const { OWNED_SESSIONS } = require("../routes/users");
|
||||
|
||||
const own = (id) => {
|
||||
const w = scope.ownSessionWhere({ user: { id } });
|
||||
return db
|
||||
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
|
||||
.all(...w.params)
|
||||
.map((r) => r.id);
|
||||
};
|
||||
|
||||
before(() => {
|
||||
const user = db.prepare(
|
||||
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
|
||||
);
|
||||
user.run(1, "boss", "admin");
|
||||
user.run(2, "dev", "developer");
|
||||
db.prepare("INSERT INTO lanes (id, cwd, tool, owner_id) VALUES (1, '/w/dev', 'claude', 2)").run();
|
||||
const s = db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, account, source) VALUES (?, ?, 'active', '/w', ?, ?, ?)"
|
||||
);
|
||||
s.run("host-free", "eval", null, "", "local");
|
||||
s.run("dev-free", "eval", null, "dev", "local");
|
||||
s.run("host-in-dev-lane", "lane", 1, "", "local");
|
||||
s.run("unknown", "old", null, null, "local");
|
||||
s.run("gone-member", "x", null, "ghost", "local");
|
||||
});
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("session-account", () => {
|
||||
it("bills a member's account to that member and the host's to the only admin", () => {
|
||||
assert.equal(sa.hostOwner(), 1);
|
||||
assert.deepEqual(own(1), ["host-free"]);
|
||||
assert.deepEqual(own(2), ["dev-free", "host-in-dev-lane"]);
|
||||
});
|
||||
|
||||
it("lets the workspace's owner win over the account", () => {
|
||||
assert.ok(!own(1).includes("host-in-dev-lane"));
|
||||
});
|
||||
|
||||
it("bills nobody for the host when two admins and none is marked, then the marked one", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, role, password_hash) VALUES (3, 'boss2', 'admin', 'x')"
|
||||
).run();
|
||||
assert.equal(sa.hostOwner(), null);
|
||||
assert.deepEqual(own(1), []);
|
||||
sa.setHostOwner(3);
|
||||
assert.equal(sa.hostOwner(), 3);
|
||||
assert.deepEqual(own(3), ["host-free"]);
|
||||
sa.setHostOwner(1);
|
||||
assert.deepEqual(own(3), []);
|
||||
assert.deepEqual(own(1), ["host-free"]);
|
||||
});
|
||||
|
||||
it("lists account-billed sessions in OWNED_SESSIONS (Users page, spend quota)", () => {
|
||||
const rows = db.prepare(OWNED_SESSIONS).all();
|
||||
const by = Object.fromEntries(rows.map((r) => [r.session_id, r.uid]));
|
||||
assert.equal(by["host-free"], 1);
|
||||
assert.equal(by["dev-free"], 2);
|
||||
assert.equal(by["host-in-dev-lane"], 2);
|
||||
assert.equal(by.unknown, undefined);
|
||||
assert.equal(by["gone-member"], undefined);
|
||||
});
|
||||
|
||||
it("stamps the hook peer once and never a pushed session", () => {
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('fresh', 'f', 'active', '/w')").run();
|
||||
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('pushed', 'p', 'active', '/w')").run();
|
||||
sa.stampFromPeer("fresh", { kind: "member", name: "dev", home: "/srv/u/dev" });
|
||||
sa.stampFromPeer("fresh", { kind: "host" });
|
||||
sa.stampFromPeer("pushed", { kind: "host" }, "push:laptop");
|
||||
sa.stampFromPeer("pushed", { kind: "refused" });
|
||||
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
||||
assert.equal(acct("fresh"), "dev");
|
||||
assert.equal(acct("pushed"), null);
|
||||
});
|
||||
|
||||
it("gives a folder's account to sessions no hook stamped, skipping pushed ones", () => {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, source) VALUES ('remote', 'r', 'active', '/w', 'push:laptop')"
|
||||
).run();
|
||||
sa.stampFromFolders();
|
||||
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
||||
// Outside S3 every folder is the host's.
|
||||
assert.equal(acct("unknown"), "");
|
||||
assert.equal(acct("pushed"), "");
|
||||
assert.equal(acct("remote"), null);
|
||||
});
|
||||
});
|
||||
@@ -1445,6 +1445,16 @@ try {
|
||||
db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run();
|
||||
}
|
||||
|
||||
// Migrate: add `account` to sessions — the Linux account a session ran as
|
||||
// (server/lib/session-account.js): "" = the host's, else the S3 member's name;
|
||||
// NULL = not known yet. A session no workspace owns is billed to the user of
|
||||
// that account (server/lib/scope.js), so a script's `claude -p` still counts.
|
||||
try {
|
||||
db.prepare("SELECT account FROM sessions LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE sessions ADD COLUMN account TEXT").run();
|
||||
}
|
||||
|
||||
// How far each Codex rollout journal has been read (server/lib/codex-usage.js).
|
||||
// In the DB, not a side file, so a session the user deleted stays deleted
|
||||
// until Codex writes to its journal again, while a wiped DB re-imports all.
|
||||
@@ -1952,6 +1962,9 @@ for (const col of ["display_name", "email", "avatar_at", "avatar_link"]) {
|
||||
for (const [col, type] of [
|
||||
["last_login_at", "TEXT"],
|
||||
["must_change_password", "INTEGER NOT NULL DEFAULT 0"],
|
||||
// The admin who owns the HOST's Linux account: its sessions outside every
|
||||
// workspace are billed to them (server/lib/session-account.js). At most one.
|
||||
["host_account", "INTEGER NOT NULL DEFAULT 0"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
||||
|
||||
+4
-1
@@ -873,7 +873,9 @@ function startBackgroundServices() {
|
||||
if (poolTimer.unref) poolTimer.unref();
|
||||
}
|
||||
// Server mode's spend quotas (user-quota.js): who is over theirs now, the
|
||||
// flag files their hooks read, the panes no hook can stop. Off with
|
||||
// flag files their hooks read, the panes no hook can stop — after giving
|
||||
// the sessions no hook made their Linux account (session-account.js), so a
|
||||
// poller's session is billed to someone too. Off with
|
||||
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
|
||||
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
|
||||
if (
|
||||
@@ -883,6 +885,7 @@ function startBackgroundServices() {
|
||||
) {
|
||||
const quotaSweep = () => {
|
||||
try {
|
||||
require("./lib/session-account").stampFromFolders();
|
||||
require("./lib/user-quota").sweep();
|
||||
} catch (err) {
|
||||
console.warn("user quota sweep failed:", err.message);
|
||||
|
||||
+7
-1
@@ -213,6 +213,9 @@ const publicUser = (u) =>
|
||||
createdAt: u.created_at ?? null,
|
||||
// An admin gave this password: the account must pick its own first.
|
||||
mustChangePassword: Boolean(u.must_change_password),
|
||||
// Owns the host's Linux account: its sessions outside every workspace
|
||||
// are billed to this admin (server/lib/session-account.js).
|
||||
hostAccount: Boolean(u.host_account),
|
||||
};
|
||||
|
||||
const MAX_DISPLAY_NAME = 100;
|
||||
@@ -415,7 +418,10 @@ function setRole(username, role) {
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
const u = requireUser(username);
|
||||
if (role !== "admin") assertKeepsAdmin(u, "demoted");
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
||||
// Only an admin owns the host account (session-account.js): a demotion drops the mark.
|
||||
sql(
|
||||
"UPDATE users SET role = ?, host_account = CASE WHEN ? = 'admin' THEN host_account ELSE 0 END WHERE id = ?"
|
||||
).run(role, role, u.id);
|
||||
if (role !== "admin") stopHostPanes(u.id);
|
||||
}
|
||||
|
||||
|
||||
+12
-6
@@ -5,7 +5,9 @@
|
||||
*
|
||||
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
|
||||
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
|
||||
* that workspace holding it (a session not stamped yet). Agents, events and
|
||||
* that workspace holding it (a session not stamped yet). A session no workspace owns
|
||||
* belongs to the user of the Linux account it ran as (`sessions.account`,
|
||||
* server/lib/session-account.js) — a script's `claude -p` is its runner's. Agents, events and
|
||||
* token rows belong to their session. A developer sees and changes only what
|
||||
* their own workspaces own; an analyst SEES everything but changes only their
|
||||
* own (every non-GET request is scoped like a developer's); an admin sees and
|
||||
@@ -17,6 +19,7 @@
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
const { ACCOUNT_OWNED } = require("./session-account");
|
||||
|
||||
/** The user whose rows this request is limited to, or null for "all". */
|
||||
function memberId(req) {
|
||||
@@ -53,8 +56,9 @@ function ownedSessionWhere(uid, col, laneCol) {
|
||||
return {
|
||||
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
|
||||
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE l.owner_id = ? AND r.session_id IS NOT NULL))`,
|
||||
params: [uid, uid],
|
||||
WHERE l.owner_id = ? AND r.session_id IS NOT NULL)
|
||||
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
|
||||
params: [uid, uid, uid],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,14 +268,16 @@ function ownerStillFits(laneId) {
|
||||
|
||||
const STAFF = { staffOnly: true };
|
||||
|
||||
/** Owner ids of the workspaces that own a session (stamped or held by a run). */
|
||||
/** Owner ids of a session: its workspace's (stamped or held by a run), else its account's user. */
|
||||
function sessionOwners(sessionId) {
|
||||
if (!sessionId) return new Set();
|
||||
const rows = sql(
|
||||
`SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ?
|
||||
UNION
|
||||
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?`
|
||||
).all(String(sessionId), String(sessionId));
|
||||
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?
|
||||
UNION
|
||||
SELECT uid FROM (${ACCOUNT_OWNED}) WHERE session_id = ?`
|
||||
).all(String(sessionId), String(sessionId), String(sessionId));
|
||||
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file session-account.js
|
||||
* @description Which Linux account a session ran as (`sessions.account`: ""
|
||||
* = the host's, else an S3 member's name), and whose bill a session no
|
||||
* workspace owns goes on. A workspace owns a session through `lane_id` or a
|
||||
* `dashboard_runs` row (server/lib/scope.js); a session started OUTSIDE every
|
||||
* workspace — a script's `claude -p`, a CLI typed in a plain terminal — has
|
||||
* neither, and without this its spend counted for nobody: not in anyone's
|
||||
* Today, the Users page or a spend quota.
|
||||
*
|
||||
* The account is the hook's PEER (server/lib/hook-auth.js: the uid of the
|
||||
* loopback connection, never the body) when a hook creates the session; a
|
||||
* session no hook made (the Codex/Antigravity/Cursor/omp pollers, history)
|
||||
* gets it from its folder, the S3 rule `accountForPath` already applies to
|
||||
* every pane. A pushed session (another machine's) never gets one. A member
|
||||
* account bills to the user of that name; the host account to the admin an
|
||||
* admin marked as its owner (`users.host_account`), or — with no mark — the
|
||||
* only enabled admin, if there is exactly one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
|
||||
// The user the host account's sessions bill to (null: nobody marked and not exactly one admin).
|
||||
const HOST_OWNER = `COALESCE(
|
||||
(SELECT MIN(id) FROM users WHERE host_account = 1 AND role = 'admin'),
|
||||
(SELECT MIN(id) FROM users WHERE role = 'admin' AND disabled = 0 HAVING COUNT(*) = 1))`;
|
||||
|
||||
// A session no workspace owns: neither stamped onto an owned lane nor held by
|
||||
// a run of one — exactly the complement of scope.js's ownership.
|
||||
const UNOWNED = `(s.lane_id IS NULL OR s.lane_id NOT IN (SELECT id FROM lanes WHERE owner_id IS NOT NULL))
|
||||
AND s.id NOT IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE r.session_id IS NOT NULL AND l.owner_id IS NOT NULL)`;
|
||||
|
||||
/** `session_id` → `uid` for every session billed by its account (no workspace owns it). */
|
||||
const ACCOUNT_OWNED = `
|
||||
SELECT s.id AS session_id, u.id AS uid
|
||||
FROM sessions s JOIN users u
|
||||
ON u.id = CASE WHEN s.account = '' THEN ${HOST_OWNER}
|
||||
ELSE (SELECT id FROM users WHERE username = s.account) END
|
||||
WHERE s.account IS NOT NULL AND ${UNOWNED}`;
|
||||
|
||||
/** The account a hook's peer names: "" for the host, a member's name, null otherwise. */
|
||||
function accountOfPeer(peer) {
|
||||
if (!peer || peer.kind === "host") return "";
|
||||
if (peer.kind === "member" && peer.name) return peer.name;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Record a hook-created session's account — first writer wins, a pushed hook never writes. */
|
||||
function stampFromPeer(sessionId, peer, pushSource = null) {
|
||||
if (pushSource || !sessionId) return;
|
||||
const account = accountOfPeer(peer);
|
||||
if (account == null) return;
|
||||
sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL").run(account, sessionId);
|
||||
}
|
||||
|
||||
/** The account a folder runs as: "" outside the users root, a member's name inside it. */
|
||||
function accountOfPath(cwd) {
|
||||
const linux = require("./linux-accounts");
|
||||
const conf = linux.s3Enabled() ? linux.serverConf() : null;
|
||||
const root = conf?.usersRoot;
|
||||
const path = require("path");
|
||||
if (!root || typeof cwd !== "string" || !path.isAbsolute(cwd)) return "";
|
||||
const rel = path.relative(root, path.resolve(cwd));
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) return "";
|
||||
// The users root itself is nobody's.
|
||||
return rel.split(path.sep)[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every local session still without an account the one its folder runs
|
||||
* as (sessions the pollers made, and history from before the column). Cheap
|
||||
* once caught up: only `account IS NULL` rows are read.
|
||||
*/
|
||||
function stampFromFolders() {
|
||||
const rows = sql(
|
||||
"SELECT id, cwd FROM sessions WHERE account IS NULL AND source NOT LIKE 'push:%' LIMIT 5000"
|
||||
).all();
|
||||
if (!rows.length) return 0;
|
||||
const set = sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL");
|
||||
let n = 0;
|
||||
require("../db").db.transaction(() => {
|
||||
for (const r of rows) {
|
||||
const account = accountOfPath(r.cwd);
|
||||
if (account != null) n += set.run(account, r.id).changes;
|
||||
}
|
||||
})();
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Mark `userId` (an admin) as the host account's owner, or clear the mark with null. */
|
||||
function setHostOwner(userId) {
|
||||
require("../db").db.transaction(() => {
|
||||
sql("UPDATE users SET host_account = 0 WHERE host_account = 1").run();
|
||||
if (userId != null) sql("UPDATE users SET host_account = 1 WHERE id = ?").run(userId);
|
||||
})();
|
||||
}
|
||||
|
||||
/** The user id the host account bills to right now, or null. */
|
||||
function hostOwner() {
|
||||
return sql(`SELECT ${HOST_OWNER} AS id`).get()?.id ?? null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACCOUNT_OWNED,
|
||||
accountOfPeer,
|
||||
stampFromPeer,
|
||||
accountOfPath,
|
||||
stampFromFolders,
|
||||
setHostOwner,
|
||||
hostOwner,
|
||||
};
|
||||
@@ -790,6 +790,11 @@ const paths = {
|
||||
addWorkspaces: { type: "array", items: { type: "integer" } },
|
||||
removeWorkspaces: { type: "array", items: { type: "integer" } },
|
||||
signOut: { type: "boolean", enum: [true] },
|
||||
hostAccount: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"An admin owns the host's Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)",
|
||||
},
|
||||
quota: { $ref: "#/components/schemas/UserQuotaSetting" },
|
||||
}),
|
||||
responses: {
|
||||
|
||||
@@ -1643,6 +1643,8 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
||||
} else {
|
||||
touchLaneFromHook(hook_type, data, tool, body.run_id, peer);
|
||||
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
|
||||
// The Linux account it ran as: whose bill it is when no workspace owns it.
|
||||
require("../lib/session-account").stampFromPeer(data.session_id, peer);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
+22
-3
@@ -54,8 +54,9 @@ const { listAudit, recordAccountChange } = require("../lib/audit");
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Which account each session belongs to — through its workspace, or a
|
||||
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once).
|
||||
// Which account each session belongs to — through its workspace, a dashboard
|
||||
// run on one, or (no workspace owns it) the Linux account it ran as
|
||||
// (server/lib/scope.js's sessionWhere, for every user at once).
|
||||
const OWNED_SESSIONS = `
|
||||
SELECT s.id AS session_id, l.owner_id AS uid
|
||||
FROM sessions s JOIN lanes l ON l.id = s.lane_id
|
||||
@@ -63,7 +64,9 @@ const OWNED_SESSIONS = `
|
||||
UNION
|
||||
SELECT s.id, l.owner_id
|
||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
|
||||
WHERE l.owner_id IS NOT NULL`;
|
||||
WHERE l.owner_id IS NOT NULL
|
||||
UNION
|
||||
${require("../lib/session-account").ACCOUNT_OWNED}`;
|
||||
|
||||
/**
|
||||
* Map(user id → {cost, tokens}) per window, each window a UTC hour
|
||||
@@ -580,6 +583,18 @@ router.patch(
|
||||
if (self && body.disabled) throw ESELF("you cannot disable yourself");
|
||||
}
|
||||
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
|
||||
if (body.hostAccount !== undefined) {
|
||||
if (typeof body.hostAccount !== "boolean") {
|
||||
throw Object.assign(new Error("hostAccount: true or false"), { code: "EBADFIELD" });
|
||||
}
|
||||
if (body.hostAccount && (body.role ?? u.role) !== "admin") {
|
||||
throw Object.assign(new Error("only an admin can own the host account"), {
|
||||
code: "EBADROLE",
|
||||
});
|
||||
}
|
||||
}
|
||||
const hostChange =
|
||||
body.hostAccount !== undefined && body.hostAccount !== Boolean(u.host_account);
|
||||
if (body.password !== undefined) {
|
||||
// Your own password: every rule. Someone else's: a starting one they replace.
|
||||
if (self) {
|
||||
@@ -619,6 +634,10 @@ router.patch(
|
||||
auth.setRole(u.username, body.role);
|
||||
done.push(`role ${body.role}`);
|
||||
}
|
||||
if (hostChange) {
|
||||
require("../lib/session-account").setHostOwner(body.hostAccount ? u.id : null);
|
||||
done.push(body.hostAccount ? "owns the host account" : "no longer owns the host account");
|
||||
}
|
||||
for (const id of add) auth.assignWorkspace(id, u.username);
|
||||
for (const id of remove) auth.unassignWorkspace(id, u.username);
|
||||
if (add.length) done.push(`workspace ${add.join(", ")}`);
|
||||
|
||||
Reference in New Issue
Block a user