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
+13 -6
View File
@@ -1366,8 +1366,10 @@ cannot collide on `index.lock`.
`shelf-drop` need a workspace at the repo root (`400 ESUBDIR`), and
`shelve`/`unshelve` are `409 EMERGING` during a merge, cherry-pick, revert
or rebase. `shelve` on a clean tree is `400 ENOCHANGES`; its `paths` go
through the same `git status` check as `discard`. `unshelve` refuses a
dirty tree (`400 EDIRTY`) so a restore never mixes with new work; a clean
through the same `git status` check as `discard`. `unshelve` refuses
anything staged and any change in one of the shelf's own files
(`400 EDIRTY`, those files in `paths`) so a restore never mixes with new
work — changes in other files (the ones a partial `shelve` left) stay put; a clean
apply drops the entry (`kept: false`), a conflicted one KEEPS it and
answers `conflicts: [...]`, `kept: true` — the files are resolved with
`resolve` (`ours` = the branch, `theirs` = the shelf; there is no merge to
@@ -3514,8 +3516,8 @@ A snapshot freezes a session as it is at that moment into one immutable `session
```http
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, exportsEnabled, canManage}
PUT /api/artifacts/settings {installsEnabled?: boolean, exportsEnabled?: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub (installs: server mode only)
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
GET /api/artifacts/skills?laneId= {items: [{name, from: "user"|"workspace"}]}
POST /api/artifacts/page {url, title?, description?}
@@ -3527,11 +3529,13 @@ POST /api/artifacts/skill/new {name, description, content, titl
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
GET /api/artifacts/:id {item}
PATCH /api/artifacts/:id {title?, description?, shared?, source?, market?, skill?: {name, description, content}} (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow)
GET /api/artifacts/people → {people: [{id, username, name}]} — server mode: every enabled account but the caller, names only (who an artifact can be shared with)
PATCH /api/artifacts/:id {title?, description?, shared?, sharedWith?: [userId], source?, market?, skill?: {name, description, content}} (sharedWith: server mode, replaces who it is shared with by name — up to 100 enabled users, the owner dropped; a row out of its owner's library shared with nobody is deleted, `removed`) (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow)
DELETE /api/artifacts/:id {ok, kept} — kept: a shared row of yours left your library but stays in the Hub
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
POST /api/artifacts/:id/copy → {item} — a page captured before pages kept a copy: snapshot the HTML file it was published from, if still in its workspace (once per row per server run; any viewer, who gets only the copy)
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
@@ -3554,7 +3558,10 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list.
- **a skill follows its source** — a skill row taken from a folder (its owner's `~/.claude/skills/<name>`, or the workspace's `.claude/skills/<name>`; the host's in local mode) is brought up to that folder whenever the Hub is listed (`syncSkillSources`, at most every 15 s; only a folder whose file names, sizes or mtimes changed is read again, through the same checks as a pick — no symlink, no secret, ≤ 5 MB — a member's files as theirs). A change is a NEW snapshot directory, then the old one dropped, and an `artifact_changed` broadcast. `artifacts.version` is a snapshot's content hash and `source_hash` the folder's when last taken: an upload or a Hub edit records the folder as it is then, so only a LATER change to the folder replaces it — the newer change wins. A list row's `updateAvailable` is true when the skill is `installed` and the viewer's `~/.claude/skills/<name>` (any account they run as) differs from `version`; the page then shows **Update**. `install` with `update: true` (a skill, `ENOTINSTALLED` when it is not installed) replaces those copies like `replace: true` — the old ones moved aside — and is not counted as another install. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared, recipients}`) reaches everyone for a shared row, else its owner, the people it is shared with by name (`recipients`) and staff.
- **Shared with chosen people** (`artifact_shares`, one row per person): `shared = 1` stays "everyone" (the Hub); a row may instead be shared with named users only. They see it (`canSee`), may install it, and find it under `?scope=withme` (**Shared with you**); `sharedWith` (the list) is shown only to the owner or an admin, `sharedWithMe` tells a recipient. Share again never widens a row shared by name to everyone. Changes are audited (`shared with ann, bob`).
- **A page's own copy** (a claude.ai page is private to the account that published it): a publish from an HTML file inside the session's workspace snapshots that file plus the supporting files the tool named (`files`), under the pinned-file guards (read as the folder's account, no secret names, 25 MB in all), and a republish of the same link replaces it. A Claude Docs doc or a typed artifact (Slides, Design…) has no such file: `scripts/artifact-export-hint.js` (a synchronous PostToolUse hook, AgentHub panes only, off when the pane carries `AGENTHUB_ARTIFACT_EXPORT=0` — the admin's `exportsEnabled` switch) asks Claude to save `artifacts-export/<artifact id>.(md|pdf|html)` in the workspace root, and the Stop capture attaches that file to the page's row (no second card) and git-excludes the folder. The page then shows **View**: an HTML copy runs in `HtmlFrame`'s sandbox (scripts, never the dashboard's origin), Markdown in `MarkdownPreview`, a PDF drawn by pdf.js (`client/src/components/PdfView.tsx`, legacy build, loaded only when a PDF is opened). The claude.ai link stays under ⋯ → Open.
- **Folders** are the viewer's own arrangement, not an artifact fact: the account preference `agenthub.artifacts.folders` (`/api/me/prefs`, 64 KB; `client/src/lib/artifactFolders.ts`), never seen by anyone else; deleting a folder only takes its artifacts out of it.
### Query Explorer
+3 -1
View File
@@ -552,7 +552,9 @@ you made in a terminal is never shown or touched. **Shelve (n)** beside
ones included) with an optional one-line note; **Shelve and switch** does the
same for every change before a switch, and puts the shelf straight back if the
switch then fails. The **Shelves (n)** list above the files shows each shelf's
branch, note, time and files, with **Restore** (on a clean tree only; a clean
branch, note, time and files, with **Restore** (refused while anything is staged or one of the shelf's own files
has changed since — the files a partial Shelve (n) left behind do not block
it; a clean
restore deletes the shelf, a conflicted one keeps it and lists the files under
**Merge conflicts** — *Keep mine* is the branch, *Take theirs* the shelf — and
you delete the shelf yourself once they are settled) and **Delete** (two
+1 -1
View File
@@ -82,7 +82,7 @@ checkout unnecessary.
| Component | Where it comes from |
|---|---|
| The eight event hooks | inline `hooks` in `.claude-plugin/plugin.json`, each running `scripts/hook-handler.js` |
| The eight event hooks | inline `hooks` in `.claude-plugin/plugin.json`, each running `scripts/hook-handler.js` — plus one synchronous `PostToolUse` entry (matcher `Artifact\|mcp__claude_ai_.*__batch`) running `scripts/artifact-export-hint.js`, which asks Claude for a viewable copy of a Doc / Slides / Design it just published (see docs/API.md § Artifacts hub) |
| The dashboard server | started detached by the bootstrap, from the plugin cache |
| The `agenthub` CLI | a launcher written to `~/.local/bin/agenthub` |
| The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts |
+45 -1
View File
@@ -534,6 +534,48 @@ How a new session picks its account (`server/lib/claude-pool.js`):
> launch file instead of tmux `-e`), but no other account's process or file.
> The AI Proxy gateway does not cover pool tokens.
## Users' own machines (Company account)
A user with their own computer can run **AgentHub local** there and still use
the Claude account pool. The pool's logins never leave this server: their
sessions go through the server's gateway, which adds the account. Only those
sessions are reported back here — their conversation and token usage, once an
hour and when the session ends. A session on the user's own login sends
nothing, and the server never reads or runs anything on their machine.
Design and limits: `docs/plans/remote-clients/README.md`.
On the user's machine (company LAN only, plain HTTP):
```bash
agenthub remote login http://10.1.13.7:4820 --user mia --name mia-laptop # hub password, asked hidden
agenthub remote status # hub, device, what was sent and when
agenthub remote upload # send now instead of waiting for the hour
agenthub remote logout # the token stops working on the server at once
```
or Settings → AI tools → **Company hub**. The machine then gets **Company
account** in the AI Proxy menu of a new Claude session; that choice is kept on
a resume. The device token lasts 90 days; after it expires or is revoked, log
in again and resume any session that was open (a running pane keeps the token
it started with).
On the server: Settings → AI tools → Company hub lists the user's own
machines (Revoke, their password again) and who opened their sessions; an
admin sees and revokes any user's machines on the **Users** page. Their
sessions are listed device › folder, with what the gateway counted; one used
with the device token outside AgentHub is flagged **unmonitored**, and an
alert rule of type **Company account outside AgentHub** (Settings → Alerts)
fires for it once it has stayed unregistered for a minute.
| Variable (server) | Default | Effect |
|---|---|---|
| `AGENTHUB_REMOTE_GATE` | `warn` | `block` refuses a session AgentHub did not register (403) |
| `AGENTHUB_REMOTE_RETAIN_DAYS` | `30` | conversations older than this are deleted; token rows stay |
| `AGENTHUB_DEVICE_TOKEN_DAYS` | `90` | device token lifetime |
| `AGENTHUB_REMOTE_UPSTREAM` | `https://api.anthropic.com` | where the gateway relays |
| `AGENTHUB_REMOTE_UNMONITORED_ALERT_MS` | `60000` | how long a session may stay unregistered before the alert fires |
| `AGENTHUB_REMOTE_FLUSH_MS` | `120000` | an upload round that stopped half-way is imported this long after its last chunk |
## Codex account pool
The same for Codex (`server/lib/codex-pool.js`), with two kinds of account:
@@ -1054,7 +1096,9 @@ a pane really is. Each case below is refused or bounded, with a test in
running host server uses that server's own binary (`/proc/<pid>/exe`, found
from its socket in `/proc`), so installing a newer tmux under a running
server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at
100% CPU.
100% CPU. That binary is cached for 10 s; once its server has exited (the
last pane stopped) the call falls back to PATH's `tmux` instead of failing
with `ENOENT`.
- **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes
through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs
the call with the binary of the member's running server (`/proc/<pid>/exe`,
+2 -2
View File
@@ -14,7 +14,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
- **Local mode:** one personal library, no sharing UI.
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it; a shared artifact appears in the **Hub** for every signed-in user. A shared skill or plugin can be **installed** into the viewer's own account.
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it with everyone — it appears in the **Hub** for every signed-in user — or with chosen people only (`artifact_shares`; they find it under **Shared with you**). A `page` keeps a viewable copy — its HTML file, or the copy Claude exports of a Doc / Slides / Design (`artifacts-export/`) — so the people it is shared with need no access to the claude.ai account that published it. A shared skill or plugin can be **installed** into the viewer's own account.
## Rules (binding, same spirit as CLAUDE.md)
@@ -22,7 +22,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a type that sandbox cannot show (Office files, zip, PDF — the sandbox blocks both a download and the PDF viewer) gets Download only, never a blank Preview. A `page` is only a link out, accepted only as `https://claude.ai/…`.
- **Installing is the viewer's explicit click, into the viewer's own account only.** For a skill, the hub writes the snapshot's files into `~/.claude/skills/<name>/` of the VIEWER (S3: as their member, through `write-as`). It never overwrites an existing skill without `conflict: replace`, and the old one goes to the trash first. For a plugin, the hub runs `claude plugin marketplace add <source>` + `claude plugin install <name>@<market> --scope user` as the viewer (the `applyPluginBaseline` path). The marketplace source passes an allowlist (`owner/repo` or a `validateRemoteUrl` URL), and no argv comes from the request beyond those two validated values.
- **Before installing, the dialog lists every file of the skill** (a skill may carry scripts that the viewer's agent will run). Every share, unshare and install is written to `audit_log`.
- **Visibility follows ownership:** a developer sees their own artifacts plus shared ones; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
- **Visibility follows ownership:** a developer sees their own artifacts plus those shared with everyone or with them by name; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
## Data
+186 -140
View File
@@ -1,175 +1,221 @@
# Remote clients — work runs on each user's machine, the hub only manages
# Remote clients — work runs on each user's machine, the hub only records company spend
Status: **PLAN, not built.** Decided 2026-10-09. Branch `server-s3` (one Linux
user per account on the hub) stays as it is until this plan is proven or dropped.
Status: **B1–B3 built 2026-11-04 for the Claude pool** (§ Built); B4 and
Codex not started. First decided 2026-10-09, re-scoped 2026-11-04 (this
version). Server mode with S3 stays and runs side by side: a user with a
weak machine keeps working on the hub, a user with their own machine runs
AgentHub local and enrolls it.
## Why
Everyone logging into one machine (server mode, S3) does not scale past ~10
concurrent users: every pane, build and test runs on the hub. Here the compute
moves to each user's machine; the hub keeps identity, the shared accounts, and
every workspace/session/conversation/token record.
the record of what the COMPANY's accounts were used for.
## Decisions (user, 2026-10-09)
## Decisions (user, 2026-11-04 — replaces the 10-09 list)
1. Each user installs **AgentHub local** on their own machine.
2. The **full conversation** is uploaded to the hub — only for sessions started
through AgentHub.
3. Shared accounts (the Claude pool, shared AI Proxies) are usable **only through
AgentHub**. The company has at least as many seats as people; a seat may be
used by more than one person.
4. Use without AgentHub is **detected and warned about**, and **blocked when an
admin turns blocking on**.
5. The hub is reached over the company LAN at `http://10.1.13.7:4820` — **no
HTTPS**.
6. An admin sees, in one click from the Users page, which machines a user has,
which workspaces they used, and what each session did.
7. `server-s3` is kept until this architecture is settled.
1. Each user may install **AgentHub local** on their own machine and enroll it
with the hub once. **S3 stays** alongside it.
2. **The account decides, not the workspace.** Only a session that runs on a
company account — the hub's pool or a shared AI Proxy, i.e. one that goes
through the hub's gateway — is reported. A session on the user's OWN login
or own proxy sends **nothing**: no conversation, no tokens, no heartbeat,
not even the workspace's name. Neither does anything run outside AgentHub.
3. What a reported session sends: its **conversation and token usage**, never
the repository. The hub **never reads the client's source tree, never runs
anything on the client, never drives it** (no terminal, no start/stop, no
file access). The client pushes; the hub never connects to a client.
4. Cadence: **one registration** when a company-account session starts
(session id + device, no content — what the gateway checks), then
conversation and tokens **once an hour** and once more when the session
ends. No per-minute heartbeat (user, 11-04). A queue on disk carries what
an offline machine could not send; it is sent back in the office.
The conversation is uploaded **whole** (tool results and code included —
user, 11-04: no stripping); only strings that look like keys, tokens or
passwords are redacted before sending.
The hub keeps a conversation **30 days**; token and cost rows are kept.
5. The company account's credential **never leaves the hub**. A client holds a
device token only; its Claude calls go through the hub's gateway, which adds
the real credential. Revoking the device token ends access at once.
6. Use of a device token outside AgentHub is **detected**; `remote_gate`
decides `warn` (default: forward, mark the session unmonitored, alert) or
`block` (403).
7. The hub is reached over the company LAN only (`10.1.13.7`); a device token
**expires after 90 days** (`agenthub remote login` again). At home a
company-account session simply cannot reach the hub — AgentHub local says
so plainly — while the user's own login works as usual. Working from home
later means a VPN, never exposing the hub. **No HTTPS** (user, 11-04): plain HTTP
on the company LAN, accepting that someone already inside the network
could sniff conversations, hub passwords and device tokens. Adding it
later is a reverse proxy in front of the hub (docs/SERVER-MODE.md § Put
HTTPS in front of it), no code change.
8. Company vs own is decided by the **account picked at session start**: an
enrolled machine's account menu gains "Company account", whose pane talks
to the hub's gateway; the default login and the user's own AI Proxies are
their own. It is recorded on the run (`dashboard_runs.proxy`), shown as a
"Company" badge, and a resume keeps it. A company account is usable only
through the gateway, so neither side can be mislabelled.
9. Each user can see, on their local AgentHub, what was sent for each
session and when, and on the hub who (admin/analyst) opened their
sessions (`audit_log`).
10. On the hub a client's sessions are shown grouped **device › folder** — no
lane rows are created for them.
## Shape
```
user machine — AgentHub LOCAL hub 10.1.13.7 — AgentHub SERVER mode
┌───────────────────────────────────┐ ┌──────────────────────────────────┐
│ pane: claude │─hooks +▶│ /api/remote/ingest (device token)│
│ ANTHROPIC_BASE_URL = hub gateway│ transcr.│ → sessions/events/tokens, owned │
│ ANTHROPIC_AUTH_TOKEN = ahdev_… │ │ by the device's user │
│ │─/v1/… ─▶│ /api/proxy-gw → pool token added │
└───────────────────────────────────┘ └──────────────────────────────────┘
user machine — AgentHub LOCAL hub 10.1.13.7 — SERVER mode
┌──────────────────────────────────────┐ ┌──────────────────────────────────┐
│ pane on a COMPANY account: │─/v1/… ─▶│ gateway: device token + session │
│ ANTHROPIC_BASE_URL = hub gateway │ │ check → pool credential added │
│ ANTHROPIC_AUTH_TOKEN = ahdev_… │ │ → Anthropic; usage counted now │
│ │─start ─▶│ session registered (id only) │
│ │─hourly▶│ conversation + tokens │
│ pane on the user's OWN login │ (none) │ │
└──────────────────────────────────────┘ └──────────────────────────────────┘
```
No pool token or proxy key ever reaches a user's machine; it holds one device
token. Plain HTTP means anyone sniffing the LAN can read that token and the
conversations — accepted for an internal network. Moving to HTTPS later is a
reverse proxy in front of the hub (Caddy `tls internal`), no code change.
## What "conversation, not source" can and cannot mean
## What the hub can see (per tool, checked 2026-10-09)
The hub stores no file of the repository. But a conversation is made of what
the agent read and wrote: a transcript's tool results hold the files Claude
`Read`, its `Edit`/`Write` inputs hold code, and every request through the
gateway carries the whole context (code included) on its way to Anthropic.
So "conversation only" still contains source excerpts. Decided (user,
11-04): they are NOT stripped — the transcript goes whole, only secret-shaped
strings are redacted.
Uploads cover every session the machine's AgentHub started, in ANY of its
workspaces — company repo or the user's own project alike. A session run
outside AgentHub on the user's own login is invisible; only pool use is gated.
## Gateway checks (verified 2026-10-09, Claude Code 2.1.295)
| Tool | Conversation | Tokens / cost | Pool / gate | Reader today |
|---|---|---|---|---|
| Claude Code | yes | yes | yes (R3) | hooks + transcript |
| Codex | yes | yes | not yet (Codex pool = later) | `codex-usage.js` rollouts |
| Antigravity | no — needs a renderer for `transcript_full.jsonl` | yes | no (own login) | `antigravity-usage.js` |
| Oh My Pi, other catalog tools | no | no | no | none: only the pane run (workspace, time) |
| DeepSeek CLI / not in catalog | no | no | no | not launchable as a tool; a shell pane records nothing |
R2 therefore uploads Claude transcripts, Codex rollouts and agy conversation
files (`remote-sync.js` today mirrors only the first two). Any tool beyond that
needs its own reader first — one per tool, on demand.
## Gating
Verified 2026-10-09 against Claude Code 2.1.295 with a capture server: every
`/v1/messages` call carries `x-claude-code-session-id: <id>` (same id in
`metadata.user_id`), and `--session-id <uuid>` sets it — `spawnRun` already
starts Claude panes that way.
The gateway checks each request:
Every `/v1/messages` call carries `x-claude-code-session-id: <id>`, and
`--session-id <uuid>` sets it — `spawnRun` already starts Claude panes that
way. The gateway checks each request:
- bearer = a device token that is not revoked, of a user who is not disabled —
else **always 401**;
- `x-claude-code-session-id` = a session the hub received uploads for, from
THAT device, within `REMOTE_LIVE_SEC` (300 s; 60 s grace for a new session).
- `x-claude-code-session-id` = a session that SAME device registered at its
start. The registration, not the hourly upload, is what this check reads.
A request failing the second check is a session AgentHub is not uploading
(token copied out of AgentHub, or uploads switched off). Setting
`remote_gate` decides: `warn` (default) forwards it and records the session
as **unmonitored** with its token count from the response's `usage`, and
fires an alert (existing `fireAlert`); `block` refuses it with 403. Ceiling:
someone who also fakes the upload stream passes — detection, not proof.
Relay spike (2026-10-09, Claude Code 2.1.295, script kept in
`~/.cache/agenthub-spike/relay.js`): a client with NO login and
`ANTHROPIC_AUTH_TOKEN=<fake>` worked through a relay that swapped in a team
subscription's OAuth token (+ `anthropic-beta: oauth-2025-04-20`). Checked:
answers, `--resume`, a local Bash tool call, prompt cache reads on every turn
(18–26k tokens), `anthropic-ratelimit-unified-*` headers passed back. Three
findings for R3:
- Through a base URL Claude Code writes **5-minute** cache entries; set
`ENABLE_PROMPT_CACHING_1H=1` in the pane (verified: 1h entries) to keep the
subscription's 1-hour TTL.
- The cache is per ACCOUNT (a fresh client read 18k cached tokens on its first
call), so the gateway must keep a session on ONE pool account — switching
accounts mid-session re-writes the whole cache.
- A repo's `.claude/settings*.json` `env` overrides the pane's
`ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (seen in this repo) — give the
gateway through `--settings=<file>`, the layer that outranks project settings,
as AI Proxy panes already do.
Not checked yet (interactive features that call claude.ai with the user's own
login, likely unavailable): `/usage` quota view, claude.ai MCP connectors,
Remote Control / web sessions, org plugins from claude.ai.
Relay spike (2026-10-09, script `~/.cache/agenthub-spike/relay.js`): a client
with no login and a fake `ANTHROPIC_AUTH_TOKEN` worked through a relay that
swapped in a team subscription's OAuth token (+ `anthropic-beta:
oauth-2025-04-20`): answers, `--resume`, local tools, prompt-cache reads,
rate-limit headers. Rules it gave:
- set `ENABLE_PROMPT_CACHING_1H=1` in the pane (a base URL drops to 5-minute
cache entries otherwise);
- keep a session on ONE pool account (the cache is per account);
- give the gateway through `--settings=<file>` (a repo's `.claude/settings*.json`
`env` overrides the pane env), as AI Proxy panes already do.
Not checked: `/usage`, claude.ai MCP connectors, Remote Control — likely lost
through a relay.
What is and is not guaranteed:
- **Guaranteed:** no hub account (or a disabled user / revoked device) → no
issued credential, because the credential never leaves the hub. This holds
only while the ADMIN alone holds each shared seat's login (setup-tokens
made by the admin). A seat whose login (email/SSO) an employee knows can
always be used directly at claude.ai or in a plain `claude` — AgentHub cannot
stop that.
- **Not guaranteed:** that the AgentHub APP was used. The device token sits on
a machine the user controls; `block` stops a plain `claude` using it, unless
the user also fakes the upload stream. Hard enforcement needs managed,
locked-down machines.
- **Closes the visibility gap (R3b):** the gateway sees every request body, so
it can record the conversation of every pool request ITSELF — new messages
per request by session id, plus the streamed answer and its `usage` — no
matter which client sent it. Then nothing that uses an issued credential is
invisible to the admin, AgentHub app or not.
- **Plain HTTP:** a LAN sniffer can take a device token and act as that user.
If that matters, HTTPS (reverse proxy + an internal CA every machine trusts,
`NODE_EXTRA_CA_CERTS` for Claude Code).
- **Guaranteed:** no hub account / disabled user / revoked device → no company
credential, because it never leaves the hub — as long as the admin alone
holds each shared seat's login.
- **Not guaranteed:** that the AgentHub app was used. A device token copied out
and used with a plain `claude` is caught by the registration check, unless
the user also fakes a registration. Hard enforcement needs managed machines.
- Quota and budget on company accounts stay **real-time** despite the hourly
upload: the gateway counts `usage` from each response it relays.
## What already exists (2026-11-04)
| Need | In the code | Missing |
|---|---|---|
| Hooks from another machine | `POST /api/hooks/ingest-batch` + `push-token.js` (`push:<source>`) | one hub-wide token, no user/device; transcript dropped |
| Credential swap | `proxy-gateway.js` (pane token → real key, SSE streamed) | loopback + pane token only |
| Shared accounts | pools, pool groups, pins, own choice, `refreshLogins` | a path for a remote caller |
| Spend per user | `pool-usage.js`, `user-quota.js`, `session-account.js`, Administration user detail | ownership of a session with no lane |
| Transcript import | `importFromDirectory`, `codex-usage.js` | an upload path |
## Phases
Each one ships alone and changes nothing for a local-only install.
Each ships alone and changes nothing for a local-only install.
**R0 — user activity view (hub, useful today).** Users page row → a user
page: their devices (once R1 exists), workspaces, and sessions by day with
tokens/cost; a session opens the existing session page (conversation, tool
calls). Backend: an `owner=<user id>` filter for staff on `GET /api/sessions`
and `GET /api/lanes`, reusing `sessionWhere`/`laneWhere` for that user — no
new query logic. Admin reads are audited as they already are.
Verify: server test for the filter (developer cannot use it); client snapshot.
**B1 — devices and ownership.** Table `devices` (id, user_id, name,
token_hash, created_at, last_seen_at, revoked_at); `agenthub remote login
http://10.1.13.7:4820` (hub password, throttled like `/api/auth/login`) stores
an `ahdev_` token 0600 in the runtime dir; an admin lists and revokes devices
on the user's page (step-up + `audit_log`). Column `sessions.owner_id`,
written from the device's user; `sessionWhere` honours it, so scoping needs no
lane. Verify: mint/hash/revoke tests; revoked → 401; a developer sees only
their own device sessions.
**R1 — devices (hub + CLI).** Table `devices` (id, user_id, name, token_hash,
created_at, last_seen_at, revoked_at). `agenthub remote login
http://10.1.13.7:4820` signs in with the user's hub password (throttled like
`/api/auth/login`), receives an `ahdev_` token once, stores it 0600 in the
runtime dir. The user page lists devices; an admin revokes one (re-auth +
`audit_log`).
Verify: mint/hash/revoke tests; a revoked token gets 401.
**B2 — gateway for the Claude pool over LAN.** `proxy-gateway.js` gains the
device-token path: heartbeat check, `pickAccount` + session-sticky account,
`usage` counted per response. The local `spawnRun`, on an enrolled machine and
only when the user picks a company account, points the pane at the hub through
`--settings=` and sets `ENABLE_PROMPT_CACHING_1H=1`. Spike first on one real
pool account. Verify: a pane answers "pong"; a curl with a device token and an
unknown session id is logged (`warn`) or 403 (`block`).
**R2 — upload (both sides).** An enrolled local AgentHub uploads each of ITS
sessions (`dashboard_runs`): hook events as they arrive, the session files of
the table above (Claude transcript, Codex rollout, agy conversation)
incrementally (byte offset, retry queue on disk, never blocking a hook). The
hub stores transcripts under `<data dir>/remote/<user id>/<device id>/projects/`
and imports them with the SAME `importFromDirectory` `remote-sync.js` uses, so
tokens and cost match local sessions. Sessions are owned by the device's user
(`source = device:<id>`); workspaces are mirrored as records (title, folder,
tool) — shown, not driven. The existing `ingest-batch` stays as it is.
Verify: two local instances → one hub; R0's page shows both users' work.
**B3 — registration and hourly upload.** For company-account sessions only:
one registration at start; conversation (Claude transcript, Codex rollout) and
tokens hourly and at session end, incremental by byte offset, disk queue,
never blocking a hook. The hub imports with `importFromDirectory` so cost
matches local sessions; shown device › folder on the user's page.
**R3 — remote gateway.** The local `spawnRun` points an enrolled machine's
Claude pane at the hub (`ANTHROPIC_BASE_URL`, device token as
`ANTHROPIC_AUTH_TOKEN`, through tmux `-e`). `proxy-gateway.js` (from
origin/main since 84e2a9f0) gains the remote path and checks above; for the pool it picks an
account with `pickAccount` and sends its OAuth token. **Spike first** on ONE
real pool account: which headers a setup-token needs through a relay.
Verify: a pane gets "pong"; a curl with the device token and a session id
nobody uploads is logged (`warn`) or 403 (`block`).
**B4 — shared skills/plugins.** No AgentHub code: company marketplace (git) +
a `managed-settings.json` template for user machines.
**R4 — shared skills/plugins.** No AgentHub code: the company marketplace (git)
plus a `managed-settings.json` template (`extraKnownMarketplaces`,
`enabledPlugins`) and an install script for user machines.
Codex comes after B2 works for Claude (ChatGPT-login auth differs from an API
key; its session-id header is unchecked).
Codex comes after R3 works for Claude: ChatGPT-account auth differs from an API
key, and its session-id header still has to be checked.
## Built (2026-11-04)
Hub side — `server/hub/`, loaded only in server mode (`server/index.js`
requires it inside `isServerMode()`; `server/__tests__/hub-boundary.test.js`
fails if anything outside the folder requires it):
| File | Does |
|---|---|
| `devices.js` | `ahdev_<32 bytes base64url>` tokens, only the SHA-256 kept; 90 days (`AGENTHUB_DEVICE_TOKEN_DAYS`); revoke |
| `remote-sessions.js` | registration (UUID, absolute cwd, `claude` only); one session = one device — another user's device gets `409 EFOREIGN`, the SAME user's new device (enrolled again) takes it over (`moveSession` moves its copy) |
| `remote-gateway.js` | `/api/remote/gw/v1/*`: device token → registered session (waits `AGENTHUB_REMOTE_REGISTER_GRACE_MS`, 5 s, for one in flight) → `claude-pool.forRemote` (sticky per session) → upstream `AGENTHUB_REMOTE_UPSTREAM`; usage read from a decompressed COPY of the answer (Anthropic sends it gzip/br) |
| `remote-ingest.js` | upload chunks at the hub's own offset (`409 EOFFSET` + `expected`), copy under `<data>/remote/u<uid>/d<did>/<sid>/`, `importFromDirectory` once per round (chunks carry `more: true`, the round's closing request imports; a round cut short is imported after `AGENTHUB_REMOTE_FLUSH_MS`, 2 min), then `sessions.source = device:<id>`, `owner_id`; `purgeOld` after `AGENTHUB_REMOTE_RETAIN_DAYS` (30) keeps token rows |
| `index.js` | `/api/remote/device/*` (device token: login, me, logout, sessions, end, files, upload), `/api/remote/*` (signed in: devices, revoke with password + audit, sessions, "who opened mine"), hourly purge |
Shared: `sessions.owner_id` (scope.js, session-account.js, users.js honour
it); `claude-pool.forRemote` and live remote sessions in the pool ranking;
`auth.checkLogin`.
Client side (local mode): `server/lib/remote-client.js` +
`/api/remote-client` + `agenthub remote login|status|upload|logout`, Settings →
AI tools → Company hub. The reserved AI Proxy name `agenthub.company` ("Company
account", Claude only) writes `<data>/proxy-settings/agenthub.company.json` with the
gateway URL, the device token and `ENABLE_PROMPT_CACHING_1H=1`. A company
session is registered at spawn, again on a new session id (SessionStart /
UserPromptSubmit hooks: `/clear`, a fork), uploaded hourly
(`DASHBOARD_REMOTE_UPLOAD_MS`) and soon after SessionEnd; every JSON line goes
through `privacy.redactValue` first.
Measured (2026-11-04): local mode loads none of `server/hub/` (216 server
modules vs 214 before: `remote-client.js` + its route); startup and RSS
unchanged within noise. E2E on one real pool account: answers, sticky account,
`/clear`, resume, hub totals = transcript totals, no device token or key in the
hub copy, revoke → 401, hub down → `503 EHUBDOWN` at start.
Known limits:
- Claude Code reads `ANTHROPIC_AUTH_TOKEN` once: a pane open across a
re-login (expired or revoked token) keeps failing with 401 until it is
resumed.
- An unmonitored session is flagged on the hub (`unmonitored`, a warning in
the log) and, once it has stayed unregistered for
`AGENTHUB_REMOTE_UNMONITORED_ALERT_MS` (60 s), fires every enabled
**Company account outside AgentHub** alert rule (`remote_unmonitored`). A
registration that was only late clears the flag first.
- `AGENTHUB_REMOTE_GATE=block` is covered by unit tests only.
- A shared AI Proxy as a company account, and Codex, are not built.
## Open questions
- Should an admin be able to mark a workspace "company account required"?
(Today's answer: no — a user's own login in any workspace is their business.)
## Open risks
- A hub outage stops every pool session; a machine can still use its own login.
- The hub stores every conversation of an AgentHub session; tell users.
- A hub outage stops every company-account session; the machine can still use
its own login.
- Every byte of a company-account session's context passes through the hub in
transit; tell users.