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

135 lines
4.5 KiB
JavaScript

/**
* @file devices.js
* @description The hub's enrolled remote-client machines (server mode only;
* docs/plans/remote-clients/README.md). A user enrolls a machine running
* AgentHub LOCAL with their hub password (`POST /api/remote/device/login`,
* throttled like a login — `auth.checkLogin`) and gets ONE device token,
* `ahdev_<32 random bytes>`, shown once; the hub keeps only its SHA-256.
*
* A token is good for `TOKEN_DAYS` (90) and until it is revoked — by its own
* machine (logout), by its user, or by an admin (both behind the step-up
* password, written to `audit_log`) — or its user is disabled. It is the only
* thing a client holds: it opens the device routes and the gateway, never a
* cookie route, and never carries a pool credential.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const sql = (q) => require("../db").db.prepare(q);
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
const TOKEN_DAYS = Number(process.env.AGENTHUB_DEVICE_TOKEN_DAYS) || 90;
const TOKEN_RE = /^ahdev_[A-Za-z0-9_-]{43}$/;
const MAX_NAME = 80;
/** last_seen_at is rewritten at most this often per device (every gateway call reads it). */
const SEEN_EVERY_MS = 60_000;
/** A device name as shown: printable, trimmed, capped; "" → "device". */
function cleanName(name) {
const s = String(name || "")
.replace(/[\u0000-\u001f\u007f]/g, "")
.trim()
.slice(0, MAX_NAME);
return s || "device";
}
/** Mint a device for `userId`: `{token, device}` — the token exists only in this answer. */
function createDevice(userId, name) {
const token = `ahdev_${crypto.randomBytes(32).toString("base64url")}`;
const expires = new Date(Date.now() + TOKEN_DAYS * 864e5).toISOString();
const r = sql(
"INSERT INTO devices (user_id, name, token_hash, expires_at) VALUES (?, ?, ?, ?)"
).run(userId, cleanName(name), sha256(token), expires);
return { token, device: getDevice(Number(r.lastInsertRowid)) };
}
/** A device row as the API shows it — never the hash. */
function shape(row) {
if (!row) return null;
const now = Date.now();
return {
id: row.id,
userId: row.user_id,
name: row.name,
createdAt: row.created_at,
expiresAt: row.expires_at,
lastSeenAt: row.last_seen_at,
lastIp: row.last_ip,
revokedAt: row.revoked_at,
active: !row.revoked_at && Date.parse(row.expires_at) > now,
};
}
function getDevice(id) {
return shape(sql("SELECT * FROM devices WHERE id = ?").get(Number(id)));
}
/** Every device of `userId` (null = every user's), newest first. */
function listDevices(userId = null) {
const rows =
userId == null
? sql("SELECT * FROM devices ORDER BY id DESC").all()
: sql("SELECT * FROM devices WHERE user_id = ? ORDER BY id DESC").all(Number(userId));
return rows.map(shape);
}
/** Revoke one device (idempotent); false when there is no such device. */
function revokeDevice(id) {
const r = sql(
"UPDATE devices SET revoked_at = COALESCE(revoked_at, strftime('%Y-%m-%dT%H:%M:%fZ','now')) WHERE id = ?"
).run(Number(id));
return r.changes > 0;
}
/** The bearer token of a request (Authorization: Bearer, else x-api-key). */
function bearerOf(req) {
const h = String(req.headers.authorization || "");
const m = /^Bearer\s+(\S+)$/i.exec(h);
if (m) return m[1];
const k = req.headers["x-api-key"];
return typeof k === "string" ? k : "";
}
/**
* `{device, user}` for a request carrying a live device token of an enabled
* user, else null. A malformed token is not even hashed against the table.
*/
function deviceFromRequest(req) {
const token = bearerOf(req);
if (!TOKEN_RE.test(token)) return null;
const row = sql(
`SELECT d.*, u.username, u.role, u.disabled
FROM devices d JOIN users u ON u.id = d.user_id
WHERE d.token_hash = ?`
).get(sha256(token));
if (!row || row.revoked_at || row.disabled) return null;
if (!(Date.parse(row.expires_at) > Date.now())) return null;
const seen = row.last_seen_at ? Date.parse(row.last_seen_at) : 0;
const ip = req.socket?.remoteAddress || null;
if (Date.now() - seen > SEEN_EVERY_MS || ip !== row.last_ip) {
sql("UPDATE devices SET last_seen_at = ?, last_ip = ? WHERE id = ?").run(
new Date().toISOString(),
ip,
row.id
);
}
return {
device: shape(row),
user: { id: row.user_id, username: row.username, role: row.role },
};
}
module.exports = {
TOKEN_DAYS,
TOKEN_RE,
cleanName,
createDevice,
getDevice,
listDevices,
revokeDevice,
deviceFromRequest,
bearerOf,
__sha256: sha256,
};