Files
AgentHub/server/lib/session-account.js
T
nntrivi2001 c946b73d11 chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool):
- server/hub/ holds the hub-only code (devices, remote sessions, upload ingest,
  LAN gateway), required only in server mode; hub-boundary.test.js keeps local
  code from importing it.
- A local install enrolls with `agenthub remote login` or Settings → AI tools →
  Company hub; the "Company account" AI Proxy (reserved name agenthub.company)
  sends a session through the hub's gateway, which adds a pool account's token —
  no credential reaches the machine. Only those sessions are uploaded, hourly
  and at their end; secret-shaped strings are redacted on the client.
- Gateway refuses: unknown device (401), a message without a session id, an
  unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota
  (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30.
- New alert rule type remote_unmonitored: a company-account session its device
  never registered; a late registration clears the flag instead.
- The hub imports a session once per upload round (chunks carry `more`),
  not once per chunk.

Artifacts:
- Share an artifact with chosen people (artifact_shares, PATCH sharedWith,
  GET /api/artifacts/people) besides everyone; a "Shared with you" tab.
  Only the owner or an admin sees or edits who it is shared with; audited.
- Folders: each account's own arrangement (agenthub.artifacts.folders pref).
- A claude.ai page keeps a copy viewable without the publishing account: its
  HTML file (and supporting files) snapshotted at publish, or the copy Claude
  saves for a Doc / Slides / Design in artifacts-export/ (a synchronous
  PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch),
  attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or
  pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF).

Fixes:
- tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's
  tmux (stop the last pane, start again within 10 s failed with ENOENT).
- shelves: a shelf restores beside changes in other files.
- chat: a finished reply ends the turn even with no hook at all.
- Settings → AI tools: profile list display tweaks.
2026-11-04 15:16:49 +07:00

116 lines
4.8 KiB
JavaScript

/**
* @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, nor owned outright (`owner_id`, a remote client's upload) —
// exactly the complement of scope.js's ownership.
const UNOWNED = `s.owner_id IS NULL
AND (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,
};