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
+19 -5
View File
@@ -7,7 +7,9 @@
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
* that workspace holding it (a session not stamped yet). A session no workspace owns
* belongs to the user of the Linux account it ran as (`sessions.account`,
* server/lib/session-account.js) — a script's `claude -p` is its runner's. Agents, events and
* server/lib/session-account.js) — a script's `claude -p` is its runner's. A
* session a remote client uploaded is its device user's (`sessions.owner_id`,
* server/hub/remote-ingest.js). Agents, events and
* token rows belong to their session. A developer sees and changes only what
* their own workspaces own; an analyst SEES everything but changes only their
* own (every non-GET request is scoped like a developer's); an admin sees and
@@ -57,8 +59,9 @@ function ownedSessionWhere(uid, col, laneCol) {
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE l.owner_id = ? AND r.session_id IS NOT NULL)
OR ${col} IN (SELECT id FROM sessions WHERE owner_id = ?)
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
params: [uid, uid, uid],
params: [uid, uid, uid, uid],
};
}
@@ -276,8 +279,10 @@ function sessionOwners(sessionId) {
UNION
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?
UNION
SELECT owner_id FROM sessions WHERE id = ? AND owner_id IS NOT NULL
UNION
SELECT uid FROM (${ACCOUNT_OWNED}) WHERE session_id = ?`
).all(String(sessionId), String(sessionId), String(sessionId));
).all(String(sessionId), String(sessionId), String(sessionId), String(sessionId));
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
}
@@ -317,9 +322,18 @@ function broadcastAudience(type, data) {
case "lane_hook_result":
case "lane_runtime":
return { owners: laneOwners(d.laneId) };
// A shared artifact is everyone's news; a private one, its owner's.
// A shared artifact is everyone's news; a private one, its owner's and
// the people it is shared with by name.
case "artifact_changed":
return d.shared ? null : { owners: new Set(d.ownerId != null ? [d.ownerId] : []) };
return d.shared
? null
: {
owners: new Set(
[d.ownerId, ...(Array.isArray(d.recipients) ? d.recipients : [])].filter(
(x) => x != null
)
),
};
case "update_status":
// No payload: each client refetches its own `GET /api/run/interrupted`.
case "runs_interrupted":