Files
AgentHub/server/openapi-extra/remote.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

316 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 };