thinkpool-pair 0.7.164 → 0.7.166

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.
package/bridge.mjs CHANGED
@@ -43,6 +43,7 @@ import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
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
45
  import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
46
+ import { validateProviderSwitch, BUILTIN_PROVIDER } from './switch-provider.mjs'
46
47
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
47
48
  import { z } from 'zod'
48
49
  import { startClaudeSession } from './claude-session.mjs'
@@ -597,6 +598,30 @@ async function refreshOwnerPlan() {
597
598
  function reapTerminalRow(id) {
598
599
  return _reapTerminalRow({ id, token: codeAuthToken, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, shuttingDown })
599
600
  }
601
+ // Auth: is the sender a PARTICIPANT of THIS room (owner OR granted OR joined)? Room mode
602
+ // serves the room, so RLS returns participants; we confirm the jwt's user is one of them.
603
+ // Pattern (per #218/#219): getUser + uid → REST existence check on code_sessions
604
+ // (code=room, participants cs.{uid}) — NOT byte comparison of the jwt against codeAuthToken
605
+ // (that bug already happened once tonight: a freshly-refreshed jwt has a new signature/iat/jti
606
+ // per the grant cycle, so a byte compare rejects the legitimate sender). Hoisted to module
607
+ // scope so provider-switch + providers-list-req share the ONE implementation. Returns false
608
+ // for anon / missing / invalid jwt without throwing. Fails closed on any fetch error.
609
+ const isRoomParticipant = async (jwt) => {
610
+ if (!jwt || typeof jwt !== 'string') return false
611
+ try {
612
+ const { data } = await supabase.auth.getUser(jwt)
613
+ if (!data?.user) return false
614
+ const uid = data.user.id
615
+ // Query with the CALLER's jwt (not the bridge host's codeAuthToken) so RLS scopes
616
+ // the read to what THIS user may see, and require an actual matching row — `r.ok`
617
+ // alone is true for an empty [] result, which let ANY authenticated user pass.
618
+ // participants is a uuid[]; PostgREST array-contains is cs.{uid} (braces required).
619
+ const r = await fetch(`${SUPABASE_URL}/rest/v1/code_sessions?code=eq.${encodeURIComponent(room)}&participants=cs.%7B${encodeURIComponent(uid)}%7D&select=code`, { headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${jwt}` } })
620
+ if (!r.ok) return false
621
+ const rows = await r.json().catch(() => [])
622
+ return Array.isArray(rows) && rows.length > 0
623
+ } catch { return false }
624
+ }
600
625
  // ── Room-serve owner consent (Contract C-CODE-3) ──────────────────────────────
601
626
  // A room is served by its OWNER's bridge; a non-owner may serve only with the
602
627
  // owner's explicit grant (code_sessions.serve_grant_uid). Enforced HERE — before the
@@ -2248,6 +2273,38 @@ function acceptEditsPending(s) {
2248
2273
  }
2249
2274
  }
2250
2275
 
2276
+ // Respawn a LIVE structured lane under a DIFFERENT LLM provider, keeping its
2277
+ // terminal id + visible transcript/history. Provider env is process-level, so a
2278
+ // switch = end the SDK session + reopen under the SAME id with the new provider's
2279
+ // env (resolveProviderEnv spreads into startClaudeSession's child env). The lane is
2280
+ // NOT closing — this mirrors how a BRIDGE RESTART preserves the row (#205/#208
2281
+ // durability): NO `term-exit` broadcast, NO reapTerminalRow, NO deleteSession. The
2282
+ // agent's conversational memory resets (cross-backend, no SDK resume across
2283
+ // providers) — accepted UX, the UI warns; the visible transcript persists via the
2284
+ // restored `log`. A no-op switch (same effective provider) is rejected by the
2285
+ // caller's validateProviderSwitch, so this fn always changes the env.
2286
+ //
2287
+ // Returns true if the lane was live + respawned; false if the id had no live
2288
+ // structured session (caller replies ok:false "no such live lane").
2289
+ function respawnStructured(id, provider) {
2290
+ const s = sessions.get(id)
2291
+ if (!s) return false
2292
+ // Capture the lane's identity for re-open (NOT resume — fresh SDK session).
2293
+ const { model, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
2294
+ // Teardown WITHOUT the close side effects. drainPending denies any in-flight
2295
+ // permission cards (the switch aborts the current turn, same as /model mid-turn).
2296
+ drainPending(s)
2297
+ try { s.session?.end() } catch { /* noop */ }
2298
+ try { s.mockupWatcher?.close() } catch { /* noop */ }
2299
+ sessions.delete(id) // openStructured early-returns if the id is still mapped
2300
+ // Re-open under the SAME id with the new provider env. openStructured persists
2301
+ // sessionData() (provider included) synchronously on open, so a bridge restart
2302
+ // restores the lane on its CURRENT provider, not the original — and its next
2303
+ // announce carries the new provider badge (additive {id,name} projection).
2304
+ openStructured({ id, model, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt })
2305
+ return true
2306
+ }
2307
+
2251
2308
  function endStructured(id) {
2252
2309
  if (!id) return
2253
2310
  const s = sessions.get(id)
@@ -2680,25 +2737,48 @@ channel
2680
2737
  channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: r.ok, error: r.ok ? undefined : r.error } })
2681
2738
  })
2682
2739
  // providers-list-req {nonce, jwt} → the masked list (keyHint = last 4 chars only).
2740
+ // Auth via the shared isRoomParticipant (hoisted; same pattern as provider-switch).
2683
2741
  .on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
2684
2742
  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: [] } })
2743
+ if (!(await isRoomParticipant(payload?.jwt))) return channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: false, error: 'unauthorized', providers: [] } })
2700
2744
  channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: true, providers: listProviders() } })
2701
2745
  })
2746
+ // ── Per-lane provider SWITCH (multi-BYOK slice 2). Switch a LIVE structured lane to a
2747
+ // different LLM provider by restarting that lane's agent process under the SAME terminal
2748
+ // id with the new provider's env. Visible transcript persists; the agent's conversational
2749
+ // memory resets (cross-backend, no SDK resume) — accepted UX the UI warns about. Same-
2750
+ // provider model changes keep using the EXISTING /model path (in-process setModel); the UI
2751
+ // only sends provider-switch when the provider ACTUALLY changes.
2752
+ // provider-switch {term, provider, nonce, jwt} → bridge validates + respawns
2753
+ // provider-switch-res {nonce, ok, error?} ← reply (NEVER key material)
2754
+ // Auth: participant check via supabase.auth.getUser (isRoomParticipant) — NOT byte
2755
+ // comparison of the jwt (a refreshed jwt has a new signature/iat/jti). Anon refused.
2756
+ // After the respawn the lane's next announce reflects its new provider (additive {id,name}
2757
+ // projection the client already renders from #219). Spec: docs/specs/2026-07-05-provider-switch.md.
2758
+ .on('broadcast', { event: 'provider-switch' }, async ({ payload }) => {
2759
+ const nonce = payload?.nonce
2760
+ const reply = (ok, error) => channel.send({ type: 'broadcast', event: 'provider-switch-res', payload: { nonce, ok, ...(error ? { error } : {}) } })
2761
+ // AUTH first — anon / non-participant / bad jwt → ok:false 'unauthorized'. No lane
2762
+ // state is touched for a rejected sender (fail closed before any lookup).
2763
+ if (!(await isRoomParticipant(payload?.jwt))) return reply(false, 'unauthorized')
2764
+ const term = payload?.term
2765
+ const s = term ? sessions.get(term) : null
2766
+ // The target must be a LIVE structured session this bridge serves.
2767
+ if (!s) return reply(false, 'no such live lane')
2768
+ // VALIDATE — pure: missing / unknown provider, or a no-op same-provider switch.
2769
+ const v = validateProviderSwitch({
2770
+ provider: payload?.provider,
2771
+ currentProvider: s.provider,
2772
+ registeredIds: listProviders().map((p) => p.id),
2773
+ })
2774
+ if (!v.ok) return reply(false, v.error)
2775
+ if (v.noop) return reply(true) // nothing to do; idempotent ok (UI guarantees a change)
2776
+ const target = payload?.provider === BUILTIN_PROVIDER ? null : payload?.provider
2777
+ const done = respawnStructured(term, target)
2778
+ if (!done) return reply(false, 'no such live lane') // raced closed between the lookup + respawn
2779
+ process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
2780
+ reply(true)
2781
+ })
2702
2782
  .subscribe(status => {
2703
2783
  if (status === 'SUBSCRIBED') {
2704
2784
  realtimeHealthy = true; brokenSince = 0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.164",
4
- "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
3
+ "version": "0.7.166",
4
+ "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, \u2026) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "thinkpool-pair": "bridge.mjs"
@@ -33,6 +33,7 @@
33
33
  "serve-dir.mjs",
34
34
  "serve-consent.mjs",
35
35
  "reap-terminal.mjs",
36
+ "switch-provider.mjs",
36
37
  "service.mjs",
37
38
  "presence.mjs",
38
39
  "account.mjs",
@@ -0,0 +1,57 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ switch-provider.mjs — validation for the per-lane provider-switch
3
+ wire request (`provider-switch` on the room channel).
4
+
5
+ The bridge handler (bridge.mjs) owns AUTH (supabase.getUser + room
6
+ participation) and the actual RESPAWN (teardown-without-reap +
7
+ reopen under the same terminal id with the new provider env). This
8
+ module owns the decidable part — pure, no bridge state, no supabase —
9
+ so the validation branches are unit-testable in isolation:
10
+
11
+ V1 missing provider id → ok:false error:'missing provider'
12
+ V2 unknown id (not registered) → ok:false error:'unknown provider'
13
+ V3 valid + actually changes → ok:true noop:false
14
+ V4 valid but same as current → ok:true noop:true (UI guarantees a
15
+ change, but we stay correct + idempotent
16
+ if it ever sends a no-op)
17
+
18
+ The built-in Anthropic path is internally `provider: null` (omitted on
19
+ the announce), but the wire sends the literal `'anthropic'`. Both mean
20
+ the default Claude env, so `null` ↔ `'anthropic'` is a SAME-provider
21
+ (no-op) switch. resolveProviderEnv (providers.mjs) treats them the same.
22
+
23
+ Spec: docs/specs/2026-07-05-provider-switch.md (wire contract LOCKED;
24
+ this module must NOT change the request/reply field names or shapes).
25
+ ───────────────────────────────────────────────────────────── */
26
+
27
+ export const BUILTIN_PROVIDER = 'anthropic'
28
+
29
+ /**
30
+ * Normalize a provider id so the built-in default (`null`, undefined, or the
31
+ * literal 'anthropic') collapses to a single canonical value. Used by the
32
+ * no-op check: switching a lane whose current provider is null (the default
33
+ * path) to 'anthropic' is NOT a real change.
34
+ * @param {string|null|undefined} p
35
+ * @returns {string}
36
+ */
37
+ export function effectiveProvider(p) {
38
+ return (!p || p === BUILTIN_PROVIDER) ? BUILTIN_PROVIDER : String(p)
39
+ }
40
+
41
+ /**
42
+ * Validate a provider-switch request's provider field.
43
+ *
44
+ * @param {{provider: string, currentProvider?: string|null, registeredIds?: string[]}} input
45
+ * @returns {{ok: true, noop: boolean} | {ok: false, error: string}}
46
+ */
47
+ export function validateProviderSwitch({ provider, currentProvider, registeredIds }) {
48
+ const id = String(provider ?? '').trim()
49
+ if (!id) return { ok: false, error: 'missing provider' }
50
+
51
+ const known = id === BUILTIN_PROVIDER ||
52
+ (Array.isArray(registeredIds) && registeredIds.includes(id))
53
+ if (!known) return { ok: false, error: 'unknown provider' }
54
+
55
+ const noop = effectiveProvider(id) === effectiveProvider(currentProvider)
56
+ return { ok: true, noop }
57
+ }