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.
316 lines
11 KiB
JavaScript
316 lines
11 KiB
JavaScript
/**
|
||
* @file OpenAPI fragments for the remote-clients architecture
|
||
* (docs/plans/remote-clients/README.md): the HUB's routes (server mode only,
|
||
* server/hub/ — a device's enrollment, session registration and hourly upload
|
||
* under `/api/remote/device`, and the signed-in views under `/api/remote`) and
|
||
* a LOCAL AgentHub's `/api/remote-client` (server/routes/remote-client.js).
|
||
* The gateway `/api/remote/gw/v1/*` is a raw relay of Anthropic's Messages API
|
||
* and is described in the tag, not as operations. Exports `{ tags, schemas,
|
||
* paths }` for server/openapi-extra.js.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
const tags = [
|
||
{
|
||
name: "Remote clients",
|
||
description:
|
||
"Company hub ↔ local AgentHub. On the hub (server mode): a machine enrolls with its user's password and gets ONE device token (shown once, 90 days, revocable); it registers each Company-account session at start (id + folder, no content) and uploads the new lines of its transcripts hourly. Its Claude Code reaches Anthropic only through `/api/remote/gw/v1/*`, which checks the device token and the registered session id (`x-claude-code-session-id`), swaps in a pool account's OAuth token and relays the answer — the pool credential never leaves the hub. On a local install, `/api/remote-client` shows and manages this machine's enrollment; the device token is never returned.",
|
||
},
|
||
];
|
||
|
||
const err = { $ref: "#/components/schemas/ErrorResponse" };
|
||
const json = (schema) => ({ "application/json": { schema } });
|
||
const ok = (description, schema = { type: "object" }) => ({ description, content: json(schema) });
|
||
const fail = (description) => ({ description, content: json(err) });
|
||
|
||
const schemas = {
|
||
RemoteDevice: {
|
||
type: "object",
|
||
description: "An enrolled machine. Never carries its token or token hash.",
|
||
properties: {
|
||
id: { type: "integer" },
|
||
userId: { type: "integer" },
|
||
name: { type: "string" },
|
||
createdAt: { type: "string", format: "date-time" },
|
||
expiresAt: { type: "string", format: "date-time" },
|
||
lastSeenAt: { type: "string", format: "date-time", nullable: true },
|
||
lastIp: { type: "string", nullable: true },
|
||
revokedAt: { type: "string", format: "date-time", nullable: true },
|
||
active: { type: "boolean" },
|
||
},
|
||
},
|
||
RemoteUploadFile: {
|
||
type: "object",
|
||
required: ["path", "from", "to", "data"],
|
||
properties: {
|
||
path: {
|
||
type: "string",
|
||
description:
|
||
"`<sessionId>.jsonl` or `<sessionId>/subagents/agent-<id>.jsonl` — nothing else.",
|
||
},
|
||
from: {
|
||
type: "integer",
|
||
description: "Local byte offset this chunk starts at; must equal the hub's `consumed`.",
|
||
},
|
||
to: { type: "integer", description: "Local byte offset after the chunk." },
|
||
data: {
|
||
type: "string",
|
||
description: "Whole JSONL lines, secret-shaped strings already replaced.",
|
||
},
|
||
},
|
||
},
|
||
RemoteClientStatus: {
|
||
type: "object",
|
||
properties: {
|
||
enrolled: { type: "boolean" },
|
||
expired: { type: "boolean" },
|
||
hub: { type: "string" },
|
||
user: { type: "object", nullable: true },
|
||
device: { $ref: "#/components/schemas/RemoteDevice" },
|
||
enrolledAt: { type: "string", nullable: true },
|
||
retainDays: { type: "integer", nullable: true },
|
||
uploads: { type: "array", items: { type: "object" } },
|
||
},
|
||
},
|
||
};
|
||
|
||
const T = ["Remote clients"];
|
||
const sid = { name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } };
|
||
const did = { name: "id", in: "path", required: true, schema: { type: "integer" } };
|
||
|
||
const paths = {
|
||
"/api/remote/device/login": {
|
||
post: {
|
||
tags: T,
|
||
operationId: "remoteDeviceLogin",
|
||
summary: "Enroll a machine (hub, server mode)",
|
||
description:
|
||
"The user's hub password, throttled like a login. Answers the device token ONCE.",
|
||
requestBody: {
|
||
required: true,
|
||
content: json({
|
||
type: "object",
|
||
required: ["username", "password"],
|
||
properties: {
|
||
username: { type: "string" },
|
||
password: { type: "string" },
|
||
deviceName: { type: "string" },
|
||
},
|
||
}),
|
||
},
|
||
responses: {
|
||
200: ok("Enrolled", {
|
||
type: "object",
|
||
properties: {
|
||
token: { type: "string", example: "ahdev_…" },
|
||
device: { $ref: "#/components/schemas/RemoteDevice" },
|
||
user: { type: "object" },
|
||
retainDays: { type: "integer" },
|
||
},
|
||
}),
|
||
401: fail("Wrong username or password"),
|
||
429: fail("Too many failed logins"),
|
||
},
|
||
},
|
||
},
|
||
"/api/remote/device/me": {
|
||
get: {
|
||
tags: T,
|
||
operationId: "remoteDeviceMe",
|
||
summary: "This device and its user (device token)",
|
||
|
||
responses: { 200: ok("The device"), 401: fail("Token missing, expired or revoked") },
|
||
},
|
||
},
|
||
"/api/remote/device/logout": {
|
||
post: {
|
||
tags: T,
|
||
operationId: "remoteDeviceLogout",
|
||
summary: "Revoke this device's own token",
|
||
|
||
responses: { 200: ok("Revoked"), 401: fail("Token missing, expired or revoked") },
|
||
},
|
||
},
|
||
"/api/remote/device/sessions": {
|
||
post: {
|
||
tags: T,
|
||
operationId: "remoteDeviceRegisterSession",
|
||
summary: "Register a Company-account session at its start (id + folder, no content)",
|
||
|
||
requestBody: {
|
||
required: true,
|
||
content: json({
|
||
type: "object",
|
||
required: ["sessionId", "cwd"],
|
||
properties: {
|
||
sessionId: { type: "string", format: "uuid" },
|
||
cwd: { type: "string" },
|
||
tool: { type: "string", enum: ["claude"] },
|
||
},
|
||
}),
|
||
},
|
||
responses: {
|
||
200: ok("Registered (again, on a resume)"),
|
||
400: fail("Not a UUID, not an absolute folder, not Claude"),
|
||
409: fail("EFOREIGN — the session belongs to another device or is already known"),
|
||
},
|
||
},
|
||
},
|
||
"/api/remote/device/sessions/{id}/end": {
|
||
post: {
|
||
tags: T,
|
||
operationId: "remoteDeviceEndSession",
|
||
summary: "Mark a registered session ended",
|
||
|
||
parameters: [sid],
|
||
responses: { 200: ok("Done") },
|
||
},
|
||
},
|
||
"/api/remote/device/sessions/{id}/files": {
|
||
get: {
|
||
tags: T,
|
||
operationId: "remoteDeviceSessionFiles",
|
||
summary: "How far the hub holds each file of a registered session",
|
||
|
||
parameters: [sid],
|
||
responses: { 200: ok("Files and offsets"), 404: fail("Not this device's session") },
|
||
},
|
||
},
|
||
"/api/remote/device/upload": {
|
||
post: {
|
||
tags: T,
|
||
operationId: "remoteDeviceUpload",
|
||
summary: "Upload new transcript lines of a registered session (hourly)",
|
||
description:
|
||
"Each chunk must start where the hub stopped; the hub then imports its copy with the local-history importer and stamps the session as this device's user's.",
|
||
|
||
requestBody: {
|
||
required: true,
|
||
content: json({
|
||
type: "object",
|
||
required: ["sessionId"],
|
||
properties: {
|
||
sessionId: { type: "string", format: "uuid" },
|
||
files: { type: "array", items: { $ref: "#/components/schemas/RemoteUploadFile" } },
|
||
ended: { type: "boolean" },
|
||
},
|
||
}),
|
||
},
|
||
responses: {
|
||
200: ok("Taken"),
|
||
400: fail("A path outside the two file shapes, a bad range, half a line"),
|
||
404: fail("Not this device's session"),
|
||
409: fail("EOFFSET — `error.expected.consumed` says where to resume"),
|
||
410: fail("EPURGED — the conversation passed its retention"),
|
||
},
|
||
},
|
||
},
|
||
"/api/remote/devices": {
|
||
get: {
|
||
tags: T,
|
||
operationId: "remoteListDevices",
|
||
summary: "Enrolled machines — your own; staff may pass ?user=",
|
||
parameters: [{ name: "user", in: "query", schema: { type: "integer" } }],
|
||
responses: {
|
||
200: ok("Devices", {
|
||
type: "object",
|
||
properties: {
|
||
devices: { type: "array", items: { $ref: "#/components/schemas/RemoteDevice" } },
|
||
},
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
"/api/remote/devices/{id}/revoke": {
|
||
post: {
|
||
tags: T,
|
||
operationId: "remoteRevokeDevice",
|
||
summary: "Revoke a device — its owner or an admin, with their own password again (audited)",
|
||
parameters: [did],
|
||
requestBody: {
|
||
required: true,
|
||
content: json({
|
||
type: "object",
|
||
required: ["password"],
|
||
properties: { password: { type: "string" } },
|
||
}),
|
||
},
|
||
responses: {
|
||
200: ok("Revoked"),
|
||
403: fail("Wrong password"),
|
||
404: fail("No such device of yours"),
|
||
},
|
||
},
|
||
},
|
||
"/api/remote/sessions": {
|
||
get: {
|
||
tags: T,
|
||
operationId: "remoteListSessions",
|
||
summary:
|
||
"Registered Company-account sessions (device › folder) — your own; staff may pass ?user=",
|
||
parameters: [{ name: "user", in: "query", schema: { type: "integer" } }],
|
||
responses: { 200: ok("Sessions") },
|
||
},
|
||
},
|
||
"/api/remote/audit": {
|
||
get: {
|
||
tags: T,
|
||
operationId: "remoteMyAudit",
|
||
summary: "Who (admin/analyst) opened your sessions",
|
||
responses: { 200: ok("Audit entries") },
|
||
},
|
||
},
|
||
"/api/remote-client": {
|
||
get: {
|
||
tags: T,
|
||
operationId: "remoteClientStatus",
|
||
summary: "This local machine's enrollment and what it sent (local AgentHub)",
|
||
responses: { 200: ok("Status", { $ref: "#/components/schemas/RemoteClientStatus" }) },
|
||
},
|
||
},
|
||
"/api/remote-client/login": {
|
||
post: {
|
||
tags: T,
|
||
operationId: "remoteClientLogin",
|
||
summary: "Enroll this machine with a hub — the password goes to the hub once and is not kept",
|
||
requestBody: {
|
||
required: true,
|
||
content: json({
|
||
type: "object",
|
||
required: ["hub", "username", "password"],
|
||
properties: {
|
||
hub: { type: "string", example: "http://10.1.13.7:4820" },
|
||
username: { type: "string" },
|
||
password: { type: "string" },
|
||
deviceName: { type: "string" },
|
||
},
|
||
}),
|
||
},
|
||
responses: {
|
||
200: ok("Enrolled", { $ref: "#/components/schemas/RemoteClientStatus" }),
|
||
400: fail("Bad hub address"),
|
||
401: fail("Wrong username or password"),
|
||
503: fail("EHUBDOWN — the hub cannot be reached"),
|
||
},
|
||
},
|
||
},
|
||
"/api/remote-client/logout": {
|
||
post: {
|
||
tags: T,
|
||
operationId: "remoteClientLogout",
|
||
summary: "Revoke this machine on the hub (best effort) and forget its token",
|
||
responses: { 200: ok("Logged out") },
|
||
},
|
||
},
|
||
"/api/remote-client/upload": {
|
||
post: {
|
||
tags: T,
|
||
operationId: "remoteClientUpload",
|
||
summary: "Send the Company-account sessions' new lines now",
|
||
responses: { 200: ok("Round result"), 400: fail("ENOHUB — not enrolled") },
|
||
},
|
||
},
|
||
};
|
||
|
||
module.exports = { tags, schemas, paths };
|