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.
This commit is contained in:
2026-11-04 15:16:49 +07:00
parent 3daf7ed4cb
commit c946b73d11
95 changed files with 7918 additions and 340 deletions
+315
View File
@@ -0,0 +1,315 @@
/**
* @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 };