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

223 lines
7.1 KiB
JavaScript

/**
* @file server/hub/index.js
* @description The hub half of the remote-clients architecture
* (docs/plans/remote-clients/README.md), loaded ONLY in server mode:
* `server/index.js` requires this folder inside its `isServerMode()` branch,
* and nothing outside `server/hub/` may require it at load time (the boundary
* test in server/__tests__/hub-boundary.test.js). A local install never loads
* a line of it.
*
* - `mountEarly(app)` — before the body parsers and the cookie gate: the
* gateway (`/api/remote/gw`, a raw stream) and the device-token routes
* (`/api/remote/device/*`, their own JSON parser with room for an upload).
* - `mountRoutes(app)` — with the other routers, behind authGuard/scopeGuard:
* the signed-in views (devices, remote sessions, "who opened my sessions").
* - `startTimers()` — the retention sweep.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const express = require("express");
const devices = require("./devices");
const sessions = require("./remote-sessions");
const ingest = require("./remote-ingest");
const { remoteGateway } = require("./remote-gateway");
/** An upload chunk is at most 4 MB of text; JSON escaping can grow it. */
const UPLOAD_LIMIT = "12mb";
const send = (res, e) => {
const status = e.status || (e.code === "EBADLOGIN" ? 401 : e.code === "ETOOMANY" ? 429 : 500);
if (status === 500) console.error("[remote]", e);
res.status(status).json({
error: {
code: e.code || "EINTERNAL",
message: e.message,
...(e.expected ? { expected: e.expected } : {}),
},
});
};
/** Device-token middleware: sets `req.device` / `req.deviceUser`, else 401. */
function requireDevice(req, res, next) {
const who = devices.deviceFromRequest(req);
if (!who) {
return res
.status(401)
.json({ error: { code: "EDEVICE", message: "device token missing, expired or revoked" } });
}
req.device = { id: who.device.id, userId: who.user.id, name: who.device.name };
req.deviceInfo = who;
next();
}
function deviceRouter() {
const r = express.Router();
r.use(express.json({ limit: UPLOAD_LIMIT }));
// Enroll this machine: the user's own hub password, throttled like a login.
r.post("/login", async (req, res) => {
try {
const { username, password, deviceName } = req.body || {};
const u = await require("../lib/auth").checkLogin(req, username, password);
if (u.must_change_password) {
return res.status(403).json({
error: {
code: "EPWCHANGE",
message: "choose your own password on the hub first, then enroll this machine",
},
});
}
const { token, device } = devices.createDevice(u.id, deviceName);
require("../lib/audit").recordAccountChange(
u.id,
u.id,
"POST",
`remote device enrolled: #${device.id} ${device.name}`
);
res.json({
token,
device,
user: { id: u.id, username: u.username, role: u.role },
retainDays: ingest.RETAIN_DAYS,
});
} catch (e) {
send(res, e);
}
});
r.use(requireDevice);
r.get("/me", (req, res) => {
res.json({ ...req.deviceInfo, retainDays: ingest.RETAIN_DAYS });
});
// This machine logs itself out: its token stops working at once.
r.post("/logout", (req, res) => {
devices.revokeDevice(req.device.id);
res.json({ ok: true });
});
r.post("/sessions", (req, res) => {
try {
const row = sessions.registerSession(req.device, req.body || {});
res.json({ ok: true, sessionId: row.session_id, account: row.account });
} catch (e) {
send(res, e);
}
});
r.post("/sessions/:id/end", (req, res) => {
res.json({ ok: sessions.markEnded(req.params.id, req.device.id) });
});
r.get("/sessions/:id/files", (req, res) => {
try {
res.json({ files: ingest.fileState(req.device, req.params.id) });
} catch (e) {
send(res, e);
}
});
r.post("/upload", async (req, res) => {
try {
res.json(await ingest.takeUpload(req.device, req.body || {}));
} catch (e) {
send(res, e);
}
});
return r;
}
const isStaff = (u) => u && (u.role === "admin" || u.role === "analyst");
/** Whose rows a signed-in request asks for: their own, or (staff) `?user=`, or all. */
function targetUser(req) {
if (!isStaff(req.user)) return req.user.id;
if (req.query.user == null || req.query.user === "") return null;
const n = Number(req.query.user);
return Number.isInteger(n) && n > 0 ? n : -1;
}
function signedInRouter() {
const r = express.Router();
r.use((req, res, next) =>
req.user ? next() : res.status(401).json({ error: { code: "EAUTH", message: "log in first" } })
);
r.get("/devices", (req, res) => {
res.json({ devices: devices.listDevices(targetUser(req)) });
});
// Revoke a device: its owner or an admin, with their own password again.
r.post("/devices/:id/revoke", async (req, res) => {
try {
const d = devices.getDevice(req.params.id);
const mine = d && d.userId === req.user.id;
if (!d || (!mine && req.user.role !== "admin")) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such device" } });
}
await require("../lib/auth").confirmPassword(req.user, req.body?.password);
devices.revokeDevice(d.id);
require("../lib/audit").recordAccountChange(
req.user.id,
d.userId,
"POST",
`remote device revoked: #${d.id} ${d.name}`
);
res.json({ ok: true, device: devices.getDevice(d.id) });
} catch (e) {
send(res, e.code === "EREAUTH" ? Object.assign(e, { status: 403 }) : e);
}
});
r.get("/sessions", (req, res) => {
res.json({ sessions: sessions.listSessions(targetUser(req)) });
});
// Who opened MY sessions (admin/analyst reads, audit.js) — for every user.
r.get("/audit", (req, res) => {
const rows = require("../db")
.db.prepare(
`SELECT a.id, a.at, a.method, a.path, a.session_id AS sessionId,
r.username AS reader, r.display_name AS readerDisplay
FROM audit_log a LEFT JOIN users r ON r.id = a.user_id
WHERE a.owner_id = ? AND a.user_id != ? AND a.session_id IS NOT NULL
ORDER BY a.id DESC LIMIT 100`
)
.all(req.user.id, req.user.id);
res.json({ entries: rows });
});
return r;
}
/** Before the body parsers and the cookie gate. */
function mountEarly(app) {
app.use("/api/remote/gw", remoteGateway);
app.use("/api/remote/device", deviceRouter());
}
/** With the other routers, behind authGuard/scopeGuard/auditGuard. */
function mountRoutes(app) {
app.use("/api/remote", signedInRouter());
}
let timer = null;
function startTimers() {
if (timer) return;
const every = Number(process.env.AGENTHUB_REMOTE_PURGE_MS) || 60 * 60 * 1000;
const run = () => {
try {
const n = ingest.purgeOld();
if (n) console.log(`[remote] removed ${n} conversation(s) past ${ingest.RETAIN_DAYS} days`);
} catch (e) {
console.warn(`[remote] purge failed: ${e.message}`);
}
};
setTimeout(run, 30_000).unref();
timer = setInterval(run, every);
timer.unref();
}
module.exports = { mountEarly, mountRoutes, startTimers };