thinkpool-pair 0.7.163 → 0.7.165
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 +151 -6
- package/package.json +2 -1
- package/switch-provider.mjs +57 -0
package/bridge.mjs
CHANGED
|
@@ -42,7 +42,8 @@ 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, providerNameMap } from './providers.mjs'
|
|
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,24 @@ 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
|
+
const r = await fetch(`${SUPABASE_URL}/rest/v1/code_sessions?code=eq.${encodeURIComponent(room)}&participants=cs.${uid}`, { headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${codeAuthToken}` } })
|
|
616
|
+
return r.ok
|
|
617
|
+
} catch { return false }
|
|
618
|
+
}
|
|
600
619
|
// ── Room-serve owner consent (Contract C-CODE-3) ──────────────────────────────
|
|
601
620
|
// A room is served by its OWNER's bridge; a non-owner may serve only with the
|
|
602
621
|
// owner's explicit grant (code_sessions.serve_grant_uid). Enforced HERE — before the
|
|
@@ -635,11 +654,15 @@ let yielding = false // set once when standing down, so reclaim + revoke
|
|
|
635
654
|
const { authedClient } = await import('./account.mjs')
|
|
636
655
|
const refreshed = await authedClient(SUPABASE_URL, SUPABASE_ANON)
|
|
637
656
|
if (refreshed?.session?.access_token) codeAuthToken = refreshed.session.access_token
|
|
638
|
-
// If
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
} catch {
|
|
657
|
+
// If refresh fails, DO NOT clear the token yet. Let the serve-consent gate below
|
|
658
|
+
// (lines 648-676) validate the token via getUser and handle auth-shaped errors
|
|
659
|
+
// vs network errors correctly. This fixes GAP 2 where a transient network error
|
|
660
|
+
// refused serving when the existing token might still be valid.
|
|
661
|
+
} catch {
|
|
662
|
+
// Refresh failed — but keep the existing token. Let serve-consent gate validate it.
|
|
663
|
+
// If it's an auth error, the gate will refuse (exit 0). If it's a network error,
|
|
664
|
+
// the gate will fail open and we can serve with the existing token.
|
|
665
|
+
}
|
|
643
666
|
}
|
|
644
667
|
try {
|
|
645
668
|
if (codeAuthToken) {
|
|
@@ -920,6 +943,13 @@ const announce = () => {
|
|
|
920
943
|
// host (e.g. openrouter.ai). Older web clients ignore unknown fields.
|
|
921
944
|
provider: PROVIDER_INFO.provider,
|
|
922
945
|
providerBaseUrl: PROVIDER_INFO.baseUrl || undefined,
|
|
946
|
+
// providerPubKey: the bridge's RSA-OAEP public key (SPKI base64), used by the
|
|
947
|
+
// dashboard to seal a provider's API key so it reaches the bridge without ever
|
|
948
|
+
// crossing the wire in the clear. Computed once (the keypair is stable).
|
|
949
|
+
// providers: the name-only registry (id+name+model, NEVER the key or baseUrl)
|
|
950
|
+
// so the dashboard can list what's available. Both additive; older clients ignore.
|
|
951
|
+
providerPubKey: publicKeyB64(),
|
|
952
|
+
providers: announceProviders(),
|
|
923
953
|
// pendingUpdate: a newer thinkpool-pair is out — drives the room's "update ready"
|
|
924
954
|
// chip; re-sent per announce so reloads/late-joiners see it (Slice 3).
|
|
925
955
|
pendingUpdate,
|
|
@@ -2237,6 +2267,38 @@ function acceptEditsPending(s) {
|
|
|
2237
2267
|
}
|
|
2238
2268
|
}
|
|
2239
2269
|
|
|
2270
|
+
// Respawn a LIVE structured lane under a DIFFERENT LLM provider, keeping its
|
|
2271
|
+
// terminal id + visible transcript/history. Provider env is process-level, so a
|
|
2272
|
+
// switch = end the SDK session + reopen under the SAME id with the new provider's
|
|
2273
|
+
// env (resolveProviderEnv spreads into startClaudeSession's child env). The lane is
|
|
2274
|
+
// NOT closing — this mirrors how a BRIDGE RESTART preserves the row (#205/#208
|
|
2275
|
+
// durability): NO `term-exit` broadcast, NO reapTerminalRow, NO deleteSession. The
|
|
2276
|
+
// agent's conversational memory resets (cross-backend, no SDK resume across
|
|
2277
|
+
// providers) — accepted UX, the UI warns; the visible transcript persists via the
|
|
2278
|
+
// restored `log`. A no-op switch (same effective provider) is rejected by the
|
|
2279
|
+
// caller's validateProviderSwitch, so this fn always changes the env.
|
|
2280
|
+
//
|
|
2281
|
+
// Returns true if the lane was live + respawned; false if the id had no live
|
|
2282
|
+
// structured session (caller replies ok:false "no such live lane").
|
|
2283
|
+
function respawnStructured(id, provider) {
|
|
2284
|
+
const s = sessions.get(id)
|
|
2285
|
+
if (!s) return false
|
|
2286
|
+
// Capture the lane's identity for re-open (NOT resume — fresh SDK session).
|
|
2287
|
+
const { model, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
|
|
2288
|
+
// Teardown WITHOUT the close side effects. drainPending denies any in-flight
|
|
2289
|
+
// permission cards (the switch aborts the current turn, same as /model mid-turn).
|
|
2290
|
+
drainPending(s)
|
|
2291
|
+
try { s.session?.end() } catch { /* noop */ }
|
|
2292
|
+
try { s.mockupWatcher?.close() } catch { /* noop */ }
|
|
2293
|
+
sessions.delete(id) // openStructured early-returns if the id is still mapped
|
|
2294
|
+
// Re-open under the SAME id with the new provider env. openStructured persists
|
|
2295
|
+
// sessionData() (provider included) synchronously on open, so a bridge restart
|
|
2296
|
+
// restores the lane on its CURRENT provider, not the original — and its next
|
|
2297
|
+
// announce carries the new provider badge (additive {id,name} projection).
|
|
2298
|
+
openStructured({ id, model, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt })
|
|
2299
|
+
return true
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2240
2302
|
function endStructured(id) {
|
|
2241
2303
|
if (!id) return
|
|
2242
2304
|
const s = sessions.get(id)
|
|
@@ -2628,6 +2690,89 @@ channel
|
|
|
2628
2690
|
// Peer bridge announces (owner reclaim) — a grantee yields when the owner's bridge
|
|
2629
2691
|
// reappears. broadcast self:false, so this only fires for OTHER bridges on the topic.
|
|
2630
2692
|
.on('broadcast', { event: 'bridge' }, ({ payload }) => onPeerBridgeAnnounce(payload))
|
|
2693
|
+
// ── Provider registry — the multi-BYOK wire contract (room mode). ─────────────
|
|
2694
|
+
// AUTH: room mode serves a room the user may not own (post-#208 grant). We verify
|
|
2695
|
+
// the sender is a participant of THIS room before ANY operation. List is open to
|
|
2696
|
+
// any participant; add/remove mutate the HOST's registry, so gate to the BRIDGE
|
|
2697
|
+
// HOST's own uid (the authed login). Anon is refused for all three.
|
|
2698
|
+
.on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
|
|
2699
|
+
const nonce = payload?.nonce
|
|
2700
|
+
// Auth: must be the bridge host (the authed login running this bridge)
|
|
2701
|
+
// Use the same pattern as providers-list-req: getUser + uid comparison, not byte comparison
|
|
2702
|
+
try {
|
|
2703
|
+
const { data } = await supabase.auth.getUser(payload?.jwt)
|
|
2704
|
+
if (!data?.user || data.user.id !== myServeUid) {
|
|
2705
|
+
return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'unauthorized' } })
|
|
2706
|
+
}
|
|
2707
|
+
} catch {
|
|
2708
|
+
return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'unauthorized' } })
|
|
2709
|
+
}
|
|
2710
|
+
let fields
|
|
2711
|
+
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' } }) }
|
|
2712
|
+
const r = addProvider(fields)
|
|
2713
|
+
if (r.ok) { announce(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
|
|
2714
|
+
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 } })
|
|
2715
|
+
})
|
|
2716
|
+
// provider-remove {id, nonce, jwt} → remove, re-announce.
|
|
2717
|
+
.on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
|
|
2718
|
+
const nonce = payload?.nonce
|
|
2719
|
+
// Auth: must be the bridge host (the authed login running this bridge)
|
|
2720
|
+
// Use the same pattern as providers-list-req: getUser + uid comparison, not byte comparison
|
|
2721
|
+
try {
|
|
2722
|
+
const { data } = await supabase.auth.getUser(payload?.jwt)
|
|
2723
|
+
if (!data?.user || data.user.id !== myServeUid) {
|
|
2724
|
+
return channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: false, error: 'unauthorized' } })
|
|
2725
|
+
}
|
|
2726
|
+
} catch {
|
|
2727
|
+
return channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: false, error: 'unauthorized' } })
|
|
2728
|
+
}
|
|
2729
|
+
const r = removeProvider(payload?.id)
|
|
2730
|
+
if (r.ok) { announce(); process.stderr.write('\n ◆ provider removed — re-announced.\n') }
|
|
2731
|
+
channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: r.ok, error: r.ok ? undefined : r.error } })
|
|
2732
|
+
})
|
|
2733
|
+
// providers-list-req {nonce, jwt} → the masked list (keyHint = last 4 chars only).
|
|
2734
|
+
// Auth via the shared isRoomParticipant (hoisted; same pattern as provider-switch).
|
|
2735
|
+
.on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
|
|
2736
|
+
const nonce = payload?.nonce
|
|
2737
|
+
if (!(await isRoomParticipant(payload?.jwt))) return channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: false, error: 'unauthorized', providers: [] } })
|
|
2738
|
+
channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: true, providers: listProviders() } })
|
|
2739
|
+
})
|
|
2740
|
+
// ── Per-lane provider SWITCH (multi-BYOK slice 2). Switch a LIVE structured lane to a
|
|
2741
|
+
// different LLM provider by restarting that lane's agent process under the SAME terminal
|
|
2742
|
+
// id with the new provider's env. Visible transcript persists; the agent's conversational
|
|
2743
|
+
// memory resets (cross-backend, no SDK resume) — accepted UX the UI warns about. Same-
|
|
2744
|
+
// provider model changes keep using the EXISTING /model path (in-process setModel); the UI
|
|
2745
|
+
// only sends provider-switch when the provider ACTUALLY changes.
|
|
2746
|
+
// provider-switch {term, provider, nonce, jwt} → bridge validates + respawns
|
|
2747
|
+
// provider-switch-res {nonce, ok, error?} ← reply (NEVER key material)
|
|
2748
|
+
// Auth: participant check via supabase.auth.getUser (isRoomParticipant) — NOT byte
|
|
2749
|
+
// comparison of the jwt (a refreshed jwt has a new signature/iat/jti). Anon refused.
|
|
2750
|
+
// After the respawn the lane's next announce reflects its new provider (additive {id,name}
|
|
2751
|
+
// projection the client already renders from #219). Spec: docs/specs/2026-07-05-provider-switch.md.
|
|
2752
|
+
.on('broadcast', { event: 'provider-switch' }, async ({ payload }) => {
|
|
2753
|
+
const nonce = payload?.nonce
|
|
2754
|
+
const reply = (ok, error) => channel.send({ type: 'broadcast', event: 'provider-switch-res', payload: { nonce, ok, ...(error ? { error } : {}) } })
|
|
2755
|
+
// AUTH first — anon / non-participant / bad jwt → ok:false 'unauthorized'. No lane
|
|
2756
|
+
// state is touched for a rejected sender (fail closed before any lookup).
|
|
2757
|
+
if (!(await isRoomParticipant(payload?.jwt))) return reply(false, 'unauthorized')
|
|
2758
|
+
const term = payload?.term
|
|
2759
|
+
const s = term ? sessions.get(term) : null
|
|
2760
|
+
// The target must be a LIVE structured session this bridge serves.
|
|
2761
|
+
if (!s) return reply(false, 'no such live lane')
|
|
2762
|
+
// VALIDATE — pure: missing / unknown provider, or a no-op same-provider switch.
|
|
2763
|
+
const v = validateProviderSwitch({
|
|
2764
|
+
provider: payload?.provider,
|
|
2765
|
+
currentProvider: s.provider,
|
|
2766
|
+
registeredIds: listProviders().map((p) => p.id),
|
|
2767
|
+
})
|
|
2768
|
+
if (!v.ok) return reply(false, v.error)
|
|
2769
|
+
if (v.noop) return reply(true) // nothing to do; idempotent ok (UI guarantees a change)
|
|
2770
|
+
const target = payload?.provider === BUILTIN_PROVIDER ? null : payload?.provider
|
|
2771
|
+
const done = respawnStructured(term, target)
|
|
2772
|
+
if (!done) return reply(false, 'no such live lane') // raced closed between the lookup + respawn
|
|
2773
|
+
process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
|
|
2774
|
+
reply(true)
|
|
2775
|
+
})
|
|
2631
2776
|
.subscribe(status => {
|
|
2632
2777
|
if (status === 'SUBSCRIBED') {
|
|
2633
2778
|
realtimeHealthy = true; brokenSince = 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.165",
|
|
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": {
|
|
@@ -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
|
+
}
|