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.
- package/bridge.mjs +91 -10
- package/package.json +1 -1
- 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
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
} catch {
|
|
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
|
-
|
|
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
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)
|