Files
AgentHub/server/hub/remote-sessions.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

221 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file remote-sessions.js
* @description The company-account sessions remote clients registered with the
* hub (server mode only). A client registers a session ONCE, when it starts it
* on the "Company account" (`POST /api/remote/device/sessions`), with its id,
* folder and tool — no content. The gateway (remote-gateway.js) relays a
* request only for a session THAT device registered; everything a session
* says arrives later in the hourly upload (remote-ingest.js).
*
* A session id is claimed by its first device: another device (or a session id
* the hub already holds from anywhere else) is refused with EFOREIGN, so one
* client can never write into another user's session. The pool account the
* gateway picks is kept on the row for the session's whole life: the prompt
* cache belongs to one account, and switching would re-write it all.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const sql = (q) => require("../db").db.prepare(q);
const err = (code, message, status) => Object.assign(new Error(message), { code, status });
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const TOOLS = new Set(["claude"]);
const MAX_CWD = 1024;
/** A client-sent folder, as text only: absolute-looking, printable, capped. */
function cleanCwd(cwd) {
const s = String(cwd || "").replace(/[\u0000-\u001f\u007f]/g, "");
if (!s || s.length > MAX_CWD) throw err("EBADCWD", "cwd must be a folder path", 400);
if (!s.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(s)) {
throw err("EBADCWD", "cwd must be absolute", 400);
}
return s;
}
/**
* Register (or re-register, on a resume) `sessionId` for `device`. Refuses a
* session another device registered, and one the hub already knows that did
* not come from this device (a local session, another user's upload).
*/
function registerSession(device, { sessionId, cwd, tool = "claude" } = {}) {
if (!UUID_RE.test(String(sessionId || ""))) {
throw err("EBADSESSION", "sessionId must be a UUID", 400);
}
if (!TOOLS.has(tool)) throw err("EBADTOOL", "only claude sessions use the company account", 400);
const folder = cleanCwd(cwd);
const id = String(sessionId).toLowerCase();
let row = sql(
"SELECT device_id, user_id, cwd, purged_at FROM remote_sessions WHERE session_id = ?"
).get(id);
if (row?.purged_at) {
throw err(
"EPURGED",
"this session's conversation was removed after the retention period; start a new session",
410
);
}
if (row && row.device_id !== device.id) {
// The same user on a new device (enrolled again, a new machine) resumes
// their own session: it moves to that device. Anyone else's never does.
if (row.user_id !== device.userId) {
throw err("EFOREIGN", "this session belongs to another device", 409);
}
require("./remote-ingest").moveSession(id, row.device_id, device.id, device.userId);
row = { ...row, device_id: device.id, user_id: device.userId };
}
if (!row) {
const known = sql("SELECT source FROM sessions WHERE id = ?").get(id);
if (known && known.source !== `device:${device.id}`) {
throw err("EFOREIGN", "this session id is already taken", 409);
}
sql(
"INSERT INTO remote_sessions (session_id, device_id, user_id, cwd, tool) VALUES (?, ?, ?, ?, ?)"
).run(id, device.id, device.userId, folder, tool);
} else {
// The folder is kept from the first registration: the hub's copy lives
// under it, and a later one (a resume, a hook) would split that copy.
// A registration that lost the race to its first request (the gateway
// waited REGISTER_GRACE_MS and recorded it unmonitored) clears the flag:
// its device did register it, which is all "monitored" means.
sql(
"UPDATE remote_sessions SET ended_at = NULL, unmonitored = 0, cwd = COALESCE(NULLIF(cwd, ''), ?) WHERE session_id = ?"
).run(folder, id);
}
return getSession(id);
}
function getSession(sessionId) {
return (
sql("SELECT * FROM remote_sessions WHERE session_id = ?").get(
String(sessionId || "").toLowerCase()
) || null
);
}
/** The registered session `sessionId` of `deviceId`, or null. */
function sessionOfDevice(sessionId, deviceId) {
const row = getSession(sessionId);
return row && row.device_id === deviceId ? row : null;
}
function markEnded(sessionId, deviceId) {
return (
sql(
"UPDATE remote_sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE session_id = ? AND device_id = ?"
).run(String(sessionId).toLowerCase(), deviceId).changes > 0
);
}
/**
* A session the gateway relayed for although its device never registered it
* (remote_gate = warn): kept as UNMONITORED so an admin sees it. A session id
* already held by another device stays refused (null).
*/
function recordUnmonitored(device, sessionId) {
const id = String(sessionId || "").toLowerCase();
if (!UUID_RE.test(id)) return null;
const row = getSession(id);
if (row) return row.device_id === device.id ? row : null;
// Never for a session the hub already knows from elsewhere (a hub pane's,
// another device's upload): that would hand it to this device's user.
const known = sql("SELECT source FROM sessions WHERE id = ?").get(id);
if (known && known.source !== `device:${device.id}`) return null;
sql(
"INSERT INTO remote_sessions (session_id, device_id, user_id, unmonitored) VALUES (?, ?, ?, 1)"
).run(id, device.id, device.userId);
scheduleUnmonitoredAlert(id);
return getSession(id);
}
/** How long a session may stay unregistered before the alert fires. */
const ALERT_AFTER_MS = () => Number(process.env.AGENTHUB_REMOTE_UNMONITORED_ALERT_MS) || 60_000;
/**
* Fire the `remote_unmonitored` alert rules for `sessionId` once it has stayed
* unregistered for ALERT_AFTER_MS — a registration that was only late (the
* hub was briefly unreachable) clears the flag first and alerts nobody.
*/
function scheduleUnmonitoredAlert(sessionId) {
setTimeout(() => {
try {
const row = sql(
`SELECT r.*, d.name AS device_name, u.username
FROM remote_sessions r JOIN devices d ON d.id = r.device_id
LEFT JOIN users u ON u.id = r.user_id
WHERE r.session_id = ?`
).get(sessionId);
if (!row || !row.unmonitored) return;
require("../lib/alerts").fireRemoteUnmonitored({
sessionId,
username: row.username || `#${row.user_id}`,
device: row.device_name,
deviceId: row.device_id,
userId: row.user_id,
});
} catch (e) {
console.warn(`[remote] unmonitored alert failed: ${e.message}`);
}
}, ALERT_AFTER_MS()).unref();
}
/** Keep the pool account the gateway picked for this session. */
function setAccount(sessionId, account) {
sql("UPDATE remote_sessions SET account = ? WHERE session_id = ?").run(account, sessionId);
}
/** Count one relayed request and the usage its answer reported. */
function countRequest(sessionId, usage = {}) {
const n = (v) => (Number.isFinite(v) && v > 0 ? Math.round(v) : 0);
sql(
`UPDATE remote_sessions SET requests = requests + 1,
last_request_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
gw_input_tokens = gw_input_tokens + ?, gw_output_tokens = gw_output_tokens + ?,
gw_cache_read_tokens = gw_cache_read_tokens + ?,
gw_cache_write_tokens = gw_cache_write_tokens + ?
WHERE session_id = ?`
).run(
n(usage.input_tokens),
n(usage.output_tokens),
n(usage.cache_read_input_tokens),
n(usage.cache_creation_input_tokens),
sessionId
);
}
/**
* The registered sessions a user (or every user, `userId` null) has, newest
* first, with their device's name — the "device › folder" view's rows.
*/
function listSessions(userId = null, limit = 200) {
const where = userId == null ? "" : "WHERE r.user_id = ?";
const args = userId == null ? [] : [Number(userId)];
return sql(
`SELECT r.session_id AS sessionId, r.device_id AS deviceId, d.name AS deviceName,
r.user_id AS userId, r.cwd, r.tool, r.account, r.registered_at AS registeredAt,
r.ended_at AS endedAt, r.last_request_at AS lastRequestAt, r.requests,
r.gw_input_tokens AS inputTokens, r.gw_output_tokens AS outputTokens,
r.gw_cache_read_tokens AS cacheReadTokens, r.gw_cache_write_tokens AS cacheWriteTokens,
r.last_upload_at AS lastUploadAt, r.purged_at AS purgedAt,
r.unmonitored,
s.name AS title
FROM remote_sessions r
JOIN devices d ON d.id = r.device_id
LEFT JOIN sessions s ON s.id = r.session_id
${where}
ORDER BY COALESCE(r.last_request_at, r.registered_at) DESC
LIMIT ?`
).all(...args, limit);
}
module.exports = {
UUID_RE,
registerSession,
getSession,
sessionOfDevice,
markEnded,
recordUnmonitored,
setAccount,
countRequest,
listSessions,
};