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.
223 lines
7.1 KiB
JavaScript
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 };
|