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:
+13
-6
@@ -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
@@ -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
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user