# Remote clients — work runs on each user's machine, the hub only manages 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. ## 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. ## Decisions (user, 2026-10-09) 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. ## 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 │ └───────────────────────────────────┘ └──────────────────────────────────┘ ``` 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 the hub can see (per tool, checked 2026-10-09) 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. | 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: ` (same id in `metadata.user_id`), and `--session-id ` 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). 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=` 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=`, 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. 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). ## Phases Each one 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=` 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. **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. **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 `/remote///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:`); 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. **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`). **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 R3 works for Claude: ChatGPT-account auth differs from an API key, and its session-id header still has to be checked. ## 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.