c946b73d11
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.
135 lines
4.5 KiB
JavaScript
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,
|
|
};
|