thinkpool-pair 0.7.162 → 0.7.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/bridge.mjs +91 -10
  2. package/package.json +1 -1
  3. package/providers.mjs +14 -0
package/bridge.mjs CHANGED
@@ -42,7 +42,7 @@ import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
42
42
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
43
43
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
44
44
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
45
- import { resolveProviderEnv } from './providers.mjs'
45
+ import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
46
46
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
47
47
  import { z } from 'zod'
48
48
  import { startClaudeSession } from './claude-session.mjs'
@@ -635,11 +635,15 @@ let yielding = false // set once when standing down, so reclaim + revoke
635
635
  const { authedClient } = await import('./account.mjs')
636
636
  const refreshed = await authedClient(SUPABASE_URL, SUPABASE_ANON)
637
637
  if (refreshed?.session?.access_token) codeAuthToken = refreshed.session.access_token
638
- // If the refresh FAILS (dead refresh token / offline), keep the stale token: an
639
- // offline refresh must not strand a legit owner. A dead refresh token then yields a
640
- // 401 at fetchServeRow → refuse (correct: the login is genuinely gone); a transient
641
- // offline refresh yields a fetch reject → fail-open (correct: availability).
642
- } catch { /* account module / refresh failed — proceed with the stored token */ }
638
+ // If refresh fails, DO NOT clear the token yet. Let the serve-consent gate below
639
+ // (lines 648-676) validate the token via getUser and handle auth-shaped errors
640
+ // vs network errors correctly. This fixes GAP 2 where a transient network error
641
+ // refused serving when the existing token might still be valid.
642
+ } catch {
643
+ // Refresh failed — but keep the existing token. Let serve-consent gate validate it.
644
+ // If it's an auth error, the gate will refuse (exit 0). If it's a network error,
645
+ // the gate will fail open and we can serve with the existing token.
646
+ }
643
647
  }
644
648
  try {
645
649
  if (codeAuthToken) {
@@ -885,8 +889,12 @@ const termNames = loadNames(room)
885
889
  // a hardcoded shorthand set on other devices / after reload). Max 2026-07-02.
886
890
  let roomModels = null
887
891
 
888
- const announce = () =>
889
- bcast('bridge', {
892
+ const announce = () => {
893
+ // Per-lane provider projection: resolve the registry id→NAME once per announce
894
+ // (name-only, NEVER key/baseUrl) so a cross-device viewer can badge a lane opened
895
+ // on a custom provider without the owner's account-channel registry.
896
+ const provNames = providerNameMap()
897
+ return bcast('bridge', {
890
898
  v: 2, name, repo: repoLabel, branch: readBranch(),
891
899
  // sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
892
900
  // surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
@@ -916,6 +924,13 @@ const announce = () =>
916
924
  // host (e.g. openrouter.ai). Older web clients ignore unknown fields.
917
925
  provider: PROVIDER_INFO.provider,
918
926
  providerBaseUrl: PROVIDER_INFO.baseUrl || undefined,
927
+ // providerPubKey: the bridge's RSA-OAEP public key (SPKI base64), used by the
928
+ // dashboard to seal a provider's API key so it reaches the bridge without ever
929
+ // crossing the wire in the clear. Computed once (the keypair is stable).
930
+ // providers: the name-only registry (id+name+model, NEVER the key or baseUrl)
931
+ // so the dashboard can list what's available. Both additive; older clients ignore.
932
+ providerPubKey: publicKeyB64(),
933
+ providers: announceProviders(),
919
934
  // pendingUpdate: a newer thinkpool-pair is out — drives the room's "update ready"
920
935
  // chip; re-sent per announce so reloads/late-joiners see it (Slice 3).
921
936
  pendingUpdate,
@@ -945,9 +960,15 @@ const announce = () =>
945
960
  // turnActive). Authoritative — the bridge owns the turn — so the /code
946
961
  // dashboard reads "N agents working" off the roster instead of sniffing
947
962
  // the live code-event stream (which a passive dashboard channel misses).
948
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true, busy: s.session?.turnActive ?? false, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}) })),
963
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true, busy: s.session?.turnActive ?? false, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
964
+ // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
965
+ // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
966
+ // built-in/default Claude path (no badge). Makes the lane's provider badge +
967
+ // /model honesty cross-device (was device-local via localStorage) and survives a
968
+ // bridge restart (entry.provider is persisted with the session).
969
+ ...(s.provider && s.provider !== 'anthropic' ? { provider: { id: s.provider, name: provNames[s.provider] || s.provider } } : {}) })),
949
970
  ],
950
- })
971
+ }) }
951
972
 
952
973
  // Owner reclaim (Contract C-CODE-3 invariant 3): a grantee-served bridge that sees
953
974
  // the OWNER's bridge announce on this same room channel stands down — the owner's
@@ -2618,6 +2639,66 @@ channel
2618
2639
  // Peer bridge announces (owner reclaim) — a grantee yields when the owner's bridge
2619
2640
  // reappears. broadcast self:false, so this only fires for OTHER bridges on the topic.
2620
2641
  .on('broadcast', { event: 'bridge' }, ({ payload }) => onPeerBridgeAnnounce(payload))
2642
+ // ── Provider registry — the multi-BYOK wire contract (room mode). ─────────────
2643
+ // AUTH: room mode serves a room the user may not own (post-#208 grant). We verify
2644
+ // the sender is a participant of THIS room before ANY operation. List is open to
2645
+ // any participant; add/remove mutate the HOST's registry, so gate to the BRIDGE
2646
+ // HOST's own uid (the authed login). Anon is refused for all three.
2647
+ .on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
2648
+ const nonce = payload?.nonce
2649
+ // Auth: must be the bridge host (the authed login running this bridge)
2650
+ // Use the same pattern as providers-list-req: getUser + uid comparison, not byte comparison
2651
+ try {
2652
+ const { data } = await supabase.auth.getUser(payload?.jwt)
2653
+ if (!data?.user || data.user.id !== myServeUid) {
2654
+ return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'unauthorized' } })
2655
+ }
2656
+ } catch {
2657
+ return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'unauthorized' } })
2658
+ }
2659
+ let fields
2660
+ try { fields = unseal(payload?.sealed) } catch { return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'could not decrypt the sealed payload' } }) }
2661
+ const r = addProvider(fields)
2662
+ if (r.ok) { announce(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
2663
+ channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined } })
2664
+ })
2665
+ // provider-remove {id, nonce, jwt} → remove, re-announce.
2666
+ .on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
2667
+ const nonce = payload?.nonce
2668
+ // Auth: must be the bridge host (the authed login running this bridge)
2669
+ // Use the same pattern as providers-list-req: getUser + uid comparison, not byte comparison
2670
+ try {
2671
+ const { data } = await supabase.auth.getUser(payload?.jwt)
2672
+ if (!data?.user || data.user.id !== myServeUid) {
2673
+ return channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: false, error: 'unauthorized' } })
2674
+ }
2675
+ } catch {
2676
+ return channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: false, error: 'unauthorized' } })
2677
+ }
2678
+ const r = removeProvider(payload?.id)
2679
+ if (r.ok) { announce(); process.stderr.write('\n ◆ provider removed — re-announced.\n') }
2680
+ channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: r.ok, error: r.ok ? undefined : r.error } })
2681
+ })
2682
+ // providers-list-req {nonce, jwt} → the masked list (keyHint = last 4 chars only).
2683
+ .on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
2684
+ const nonce = payload?.nonce
2685
+ const isParticipant = async (jwt) => {
2686
+ if (!jwt || typeof jwt !== 'string') return false
2687
+ try {
2688
+ const { data } = await supabase.auth.getUser(jwt)
2689
+ if (!data?.user) return false
2690
+ const uid = data.user.id
2691
+ // Verify the user is a participant of THIS room (owner OR granted OR joined).
2692
+ // Room mode serves the room, so RLS returns participants; we just need to confirm
2693
+ // the jwt's user is one of them. A simple existence check on code_sessions
2694
+ // with the room code + the user's id in the participants array is enough.
2695
+ const r = await fetch(`${SUPABASE_URL}/rest/v1/code_sessions?code=eq.${encodeURIComponent(room)}&participants=cs.${uid}`, { headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${codeAuthToken}` } })
2696
+ return r.ok
2697
+ } catch { return false }
2698
+ }
2699
+ if (!(await isParticipant(payload?.jwt))) return channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: false, error: 'unauthorized', providers: [] } })
2700
+ channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: true, providers: listProviders() } })
2701
+ })
2621
2702
  .subscribe(status => {
2622
2703
  if (status === 'SUBSCRIBED') {
2623
2704
  realtimeHealthy = true; brokenSince = 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.162",
3
+ "version": "0.7.164",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
package/providers.mjs CHANGED
@@ -211,6 +211,20 @@ export function announceProviders() {
211
211
  return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null }, ...custom]
212
212
  }
213
213
 
214
+ /**
215
+ * id → name map for the ROOM announce's per-lane provider projection (name-only,
216
+ * NEVER key or baseUrl). Built once per announce so a cross-device viewer resolves
217
+ * a lane's provider badge without the owner's account-channel registry. Built-in
218
+ * `anthropic` is omitted (the default path shows no badge). A stale/removed id has
219
+ * no entry; the caller falls back to the bare id (a random UUID — not key material).
220
+ * @returns {Record<string,string>}
221
+ */
222
+ export function providerNameMap() {
223
+ const m = {}
224
+ for (const p of loadProviders()) if (p?.id && p.id !== BUILTIN_ID) m[p.id] = p.name
225
+ return m
226
+ }
227
+
214
228
  function keyHint(key) {
215
229
  const k = String(key || '')
216
230
  return k.length <= 4 ? k : k.slice(-4)