c946b73d11
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.
222 lines
14 KiB
Markdown
222 lines
14 KiB
Markdown
# Remote clients — work runs on each user's machine, the hub only records company spend
|
||
|
||
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
|
||
the record of what the COMPANY's accounts were used for.
|
||
|
||
## Decisions (user, 2026-11-04 — replaces the 10-09 list)
|
||
|
||
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 — 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) │ │
|
||
└──────────────────────────────────────┘ └──────────────────────────────────┘
|
||
```
|
||
|
||
## What "conversation, not source" can and cannot mean
|
||
|
||
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.
|
||
|
||
## Gateway checks (verified 2026-10-09, Claude Code 2.1.295)
|
||
|
||
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 that SAME device registered at its
|
||
start. The registration, not the hourly upload, is what this check reads.
|
||
|
||
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 / 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 ships alone and changes nothing for a local-only install.
|
||
|
||
**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.
|
||
|
||
**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`).
|
||
|
||
**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.
|
||
|
||
**B4 — shared skills/plugins.** No AgentHub code: company marketplace (git) +
|
||
a `managed-settings.json` template for user machines.
|
||
|
||
Codex comes after B2 works for Claude (ChatGPT-login auth differs from an API
|
||
key; its session-id header is unchecked).
|
||
|
||
## 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 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.
|