thinkpool-pair 0.7.160 → 0.7.162
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/account.mjs +51 -1
- package/bridge.mjs +43 -8
- package/package.json +3 -1
- package/providers.mjs +232 -0
- package/reap-terminal.mjs +47 -0
package/account.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import os from 'node:os'
|
|
|
16
16
|
import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
18
|
import { makeThrottledTrack } from './presence.mjs'
|
|
19
|
+
import { publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
|
|
19
20
|
import { supervisorServes, shouldCooldownOnExit, roomInCooldown } from './serve-consent.mjs'
|
|
20
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
21
22
|
import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
|
|
@@ -369,7 +370,56 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
369
370
|
// service (always-on, survives reboot) vs a foreground `npx` run that dies with its
|
|
370
371
|
// terminal. THINKPOOL_PAIR_AUTOUPDATE=1 is set ONLY by the install-service plist/unit
|
|
371
372
|
// (service.mjs), so it's the reliable proxy for "running as the managed service".
|
|
372
|
-
|
|
373
|
+
// providerPubKey: the bridge's RSA-OAEP public key (SPKI base64), generated +
|
|
374
|
+
// persisted 0600 on first read. The dashboard imports it to SEAL a provider's API
|
|
375
|
+
// key so it reaches the bridge without ever crossing the wire in the clear. Computed
|
|
376
|
+
// ONCE (the keypair is stable across restarts). `providers` is the name-only registry
|
|
377
|
+
// (id+name+model, NEVER the key or baseUrl) so the dashboard can list what's available
|
|
378
|
+
// to spawn against — re-read each announce so an add/remove reflects immediately.
|
|
379
|
+
// Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
|
|
380
|
+
const PROVIDER_PUBKEY = publicKeyB64()
|
|
381
|
+
const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), ts: Date.now() })
|
|
382
|
+
|
|
383
|
+
// ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
|
|
384
|
+
// AUTH: the account channel `tpacct:<uid>` is created WITHOUT config.private:true,
|
|
385
|
+
// so it is a PUBLIC realtime channel — NOT RLS-gated (the existing shutdown/restart
|
|
386
|
+
// handlers ride the same public channel; accepted for those, NOT for key management).
|
|
387
|
+
// Every provider event therefore carries the sender's `jwt` (session access token);
|
|
388
|
+
// we verify getUser(jwt).id === the owner uid before ANY registry mutation or read.
|
|
389
|
+
// Only the owner manages their own keys on their own machine — a partner is refused.
|
|
390
|
+
const OWNER_UID = session.user.id
|
|
391
|
+
const isOwner = async (jwt) => {
|
|
392
|
+
if (!jwt || typeof jwt !== 'string') return false
|
|
393
|
+
try { const { data } = await sb.auth.getUser(jwt); return !!data?.user && data.user.id === OWNER_UID } catch { return false }
|
|
394
|
+
}
|
|
395
|
+
const provReply = (event, payload) => { try { acct.send({ type: 'broadcast', event, payload }) } catch { /* channel down */ } }
|
|
396
|
+
// provider-add {sealed, nonce, jwt} → decrypt, validate, append, re-announce.
|
|
397
|
+
acct.on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
|
|
398
|
+
const nonce = payload?.nonce
|
|
399
|
+
if (!(await isOwner(payload?.jwt))) return provReply('provider-add-res', { nonce, ok: false, error: 'unauthorized' })
|
|
400
|
+
let fields
|
|
401
|
+
// Decrypt failures answer ok:false WITHOUT logging the ciphertext or plaintext
|
|
402
|
+
// (security invariant d) — a bare boolean, never the sealed blob or the key.
|
|
403
|
+
try { fields = unseal(payload?.sealed) } catch { return provReply('provider-add-res', { nonce, ok: false, error: 'could not decrypt the sealed payload' }) }
|
|
404
|
+
const r = addProvider(fields)
|
|
405
|
+
if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
|
|
406
|
+
provReply('provider-add-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
|
|
407
|
+
})
|
|
408
|
+
// provider-remove {id, nonce, jwt} → remove (built-in refuses), re-announce.
|
|
409
|
+
acct.on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
|
|
410
|
+
const nonce = payload?.nonce
|
|
411
|
+
if (!(await isOwner(payload?.jwt))) return provReply('provider-remove-res', { nonce, ok: false, error: 'unauthorized' })
|
|
412
|
+
const r = removeProvider(payload?.id)
|
|
413
|
+
if (r.ok) { pushPresence(); process.stderr.write('\n ◆ provider removed — re-announced.\n') }
|
|
414
|
+
provReply('provider-remove-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error })
|
|
415
|
+
})
|
|
416
|
+
// providers-list-req {nonce, jwt} → the masked list (keyHint = last 4 chars only).
|
|
417
|
+
acct.on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
|
|
418
|
+
const nonce = payload?.nonce
|
|
419
|
+
if (!(await isOwner(payload?.jwt))) return provReply('providers-list-res', { nonce, ok: false, error: 'unauthorized', providers: [] })
|
|
420
|
+
provReply('providers-list-res', { nonce, ok: true, providers: listProviders() })
|
|
421
|
+
})
|
|
422
|
+
|
|
373
423
|
// Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
|
|
374
424
|
// (network blip, or a token swap mid-flight) rejoins the channel and must
|
|
375
425
|
// re-announce, or the dashboard would read "no bridge" until the next restart.
|
package/bridge.mjs
CHANGED
|
@@ -38,6 +38,11 @@ import { createRequire } from 'node:module'
|
|
|
38
38
|
import { randomUUID } from 'node:crypto'
|
|
39
39
|
import { createClient } from '@supabase/supabase-js'
|
|
40
40
|
import { serveDecision, amGrantee, standDownOnPeerAnnounce, standDownOnGrantChange, fetchServeRow, refusalMessage, gateFailAction, GRANT_RECLAIM_EXIT } from './serve-consent.mjs'
|
|
41
|
+
import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
|
|
42
|
+
// resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
|
|
43
|
+
// registered custom provider, or null for the built-in/unknown (leave the default env intact).
|
|
44
|
+
// Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
|
|
45
|
+
import { resolveProviderEnv } from './providers.mjs'
|
|
41
46
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
42
47
|
import { z } from 'zod'
|
|
43
48
|
import { startClaudeSession } from './claude-session.mjs'
|
|
@@ -570,6 +575,28 @@ async function refreshOwnerPlan() {
|
|
|
570
575
|
if (rows?.[0]?.plan) ownerPlan = rows[0].plan === 'plus' ? 'plus' : 'free'
|
|
571
576
|
} catch { /* DNS-flaky / offline — keep the safe default; the next token refresh retries */ }
|
|
572
577
|
}
|
|
578
|
+
|
|
579
|
+
// ── Bridge-side row reap (Contract §Bridge-side reap, 2026-07-04) ───────────────
|
|
580
|
+
// A cleanly-closed structured lane's code_terminals row must be gone within seconds
|
|
581
|
+
// REGARDLESS of any client tab being open. The client-side reap (#205, room.jsx's
|
|
582
|
+
// `term-exit` handler) only runs in whichever browser tab happens to catch the transient
|
|
583
|
+
// broadcast — close a lane while no fresh tab is listening (stale/asleep) and the broadcast
|
|
584
|
+
// evaporates, leaving a permanent dormant ghost row (three hand-deleted 2026-07-04/05).
|
|
585
|
+
// The bridge is the ONE authoritative witness of a clean structured close, so it reaps the
|
|
586
|
+
// row itself. RLS: code_terminals_member_delete permits DELETE to any parent-session member;
|
|
587
|
+
// this bridge serves as the room owner or an authed grantee (codeAuthToken), so it qualifies.
|
|
588
|
+
// Anon bridges (no token) skip — the client reap covers them, as it does old bridges (#205
|
|
589
|
+
// is kept as-is). Idempotent: a fast client tab may reap first (204 / 0 rows is fine).
|
|
590
|
+
// Best-effort: any failure is swallowed and the client reap remains the fallback.
|
|
591
|
+
// Suppressed while shuttingDown — a late cleanupFlowLanes timer firing during teardown /
|
|
592
|
+
// stand-down / restart must NOT delete rows: dormant lanes across a restart are the feature.
|
|
593
|
+
// Thin wrapper: close over the bridge's LIVE state (codeAuthToken + shuttingDown are
|
|
594
|
+
// reassigned over the process life, so read them at call time) and delegate to the
|
|
595
|
+
// unit-tested reap-terminal.mjs. Fire-and-forget from endStructured — the module never
|
|
596
|
+
// throws (best-effort), so an unawaited promise can't reject.
|
|
597
|
+
function reapTerminalRow(id) {
|
|
598
|
+
return _reapTerminalRow({ id, token: codeAuthToken, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, shuttingDown })
|
|
599
|
+
}
|
|
573
600
|
// ── Room-serve owner consent (Contract C-CODE-3) ──────────────────────────────
|
|
574
601
|
// A room is served by its OWNER's bridge; a non-owner may serve only with the
|
|
575
602
|
// owner's explicit grant (code_sessions.serve_grant_uid). Enforced HERE — before the
|
|
@@ -1412,12 +1439,12 @@ function worktreeSnapshot(cwd) {
|
|
|
1412
1439
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
1413
1440
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
1414
1441
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
1415
|
-
function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer }) {
|
|
1442
|
+
function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider }) {
|
|
1416
1443
|
if (sessions.has(id)) return
|
|
1417
1444
|
// spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
|
|
1418
1445
|
// session store so the Ensemble flag survives a bridge restart (else a respin
|
|
1419
1446
|
// stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
|
|
1420
|
-
const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, model: model || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null,
|
|
1447
|
+
const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, model: model || null, provider: provider || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null,
|
|
1421
1448
|
// Stable creation order — persisted so a bridge restart restores tabs in the SAME
|
|
1422
1449
|
// order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
|
|
1423
1450
|
// first transcript event ts, so even the first post-fix restart is ordered right.
|
|
@@ -1595,7 +1622,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1595
1622
|
// restart. Without this, sessionData omitted it → on restart the resumed session
|
|
1596
1623
|
// re-launched on the host default (Opus) regardless of the last switch, and the
|
|
1597
1624
|
// switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
|
|
1598
|
-
const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, model: entry.model || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null })
|
|
1625
|
+
const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null })
|
|
1599
1626
|
const persist = () => saveSession(room, id, sessionData())
|
|
1600
1627
|
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
1601
1628
|
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
@@ -1761,6 +1788,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1761
1788
|
name: z.string().max(80).optional().describe('a short label for the new lane so the room (and you) can find it, e.g. "Research: topologies"'),
|
|
1762
1789
|
task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
|
|
1763
1790
|
model: z.string().optional().describe('optional model for the lane, e.g. opus / sonnet / haiku'),
|
|
1791
|
+
provider: z.string().optional().describe('optional registered LLM provider id to run this lane on (from the account\'s provider registry); omit for the default Claude/Anthropic path'),
|
|
1764
1792
|
mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('permission mode for the new lane; defaults to inheriting YOUR current mode. Raising a lane to bypassPermissions from a non-bypass lane asks the room to confirm once.'),
|
|
1765
1793
|
},
|
|
1766
1794
|
async (args) => {
|
|
@@ -1791,7 +1819,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1791
1819
|
// plan-mode lane would stall on ExitPlanMode cards. An explicit args.mode still wins;
|
|
1792
1820
|
// otherwise inherit, but fall plan → default (an autonomous worker doesn't plan-gate).
|
|
1793
1821
|
const childMode = args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode)
|
|
1794
|
-
openStructured({ id: newId, model: args?.model, mode: childMode })
|
|
1822
|
+
openStructured({ id: newId, model: args?.model, provider: args?.provider, mode: childMode })
|
|
1795
1823
|
const ne = sessions.get(newId)
|
|
1796
1824
|
if (!ne) { if (args?.name) { delete termNames[newId]; saveNames(room, termNames) } return okText('Could not open a new lane — the terminal cap may have just been reached. Close one and retry.') }
|
|
1797
1825
|
ne.spawnedBy = id // ownership: only the spawner may close_terminal it
|
|
@@ -1975,7 +2003,12 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1975
2003
|
// Tier 3 — the SENDER-side gate for post_to_session (the receipt card lives in
|
|
1976
2004
|
// the target room). roomHop/crossRoomPostCount reset on a real human turn (code-turn).
|
|
1977
2005
|
crossRoomPostGate: () => crossRoomPostDecision({ roomHop: entry.roomHop || 0, postCount: entry.crossRoomPostCount || 0, disabled: process.env.TP_PAIRBUS_OFF === '1' }),
|
|
1978
|
-
|
|
2006
|
+
// Per-lane provider (multi-BYOK slice 1): a lane opened with a registered `provider`
|
|
2007
|
+
// id runs on THAT Anthropic-compatible endpoint. resolveProviderEnv → {ANTHROPIC_BASE_URL,
|
|
2008
|
+
// ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL}, spread AFTER process.env so it overrides the
|
|
2009
|
+
// per-machine default (provider.mjs/applyProviderEnv). null for built-in/unknown → the
|
|
2010
|
+
// default Claude env is left exactly as-is (unchanged path).
|
|
2011
|
+
env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), ...(resolveProviderEnv(provider) || {}), TP_MOCKUP_OUTBOX: mockupOutbox },
|
|
1979
2012
|
onEvent: (evt) => {
|
|
1980
2013
|
// Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
|
|
1981
2014
|
// keeping the transcript (scrollback survives; live context is gone).
|
|
@@ -2203,6 +2236,8 @@ function endStructured(id) {
|
|
|
2203
2236
|
try { s.mockupWatcher?.close() } catch { /* noop */ }
|
|
2204
2237
|
sessions.delete(id)
|
|
2205
2238
|
bcast('term-exit', { id })
|
|
2239
|
+
reapTerminalRow(id) // bridge-side reap: delete the code_terminals row ourselves — the
|
|
2240
|
+
// client reap (#205) evaporates if no fresh tab caught the broadcast
|
|
2206
2241
|
fireImageCleanup(id) // drop this term's screenshots from Storage (COGS)
|
|
2207
2242
|
// EN-m3 — closing a spawner ORPHANS the lanes it spawned (only that spawner could
|
|
2208
2243
|
// close_terminal them; they can never be closed otherwise + keep occupying the caps).
|
|
@@ -2320,7 +2355,7 @@ channel
|
|
|
2320
2355
|
// previous terminal's model (and the first uses the provider default). Falls
|
|
2321
2356
|
// through to the SDK default when absent. Stamped on the entry so the announce
|
|
2322
2357
|
// carries it at open (chip shows the model immediately, not the branch).
|
|
2323
|
-
openStructured({ id: payload.id, mode: payload.mode, model: payload.model || undefined })
|
|
2358
|
+
openStructured({ id: payload.id, mode: payload.mode, model: payload.model || undefined, provider: payload.provider || undefined })
|
|
2324
2359
|
process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
|
|
2325
2360
|
return
|
|
2326
2361
|
}
|
|
@@ -2428,7 +2463,7 @@ channel
|
|
|
2428
2463
|
// ── structured-session control (Phase 2) ──
|
|
2429
2464
|
.on('broadcast', { event: 'code-open' }, ({ payload }) => {
|
|
2430
2465
|
if (payload?.host && payload.host !== name) return
|
|
2431
|
-
openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume, mode: payload?.mode })
|
|
2466
|
+
openStructured({ id: payload?.id || randomUUID(), model: payload?.model, provider: payload?.provider, resume: payload?.resume, mode: payload?.mode })
|
|
2432
2467
|
})
|
|
2433
2468
|
.on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
|
|
2434
2469
|
const s = payload?.term && sessions.get(payload.term)
|
|
@@ -2611,7 +2646,7 @@ channel
|
|
|
2611
2646
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
2612
2647
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
2613
2648
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
2614
|
-
openStructured({ id: rec.id, model: rec.model || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt,
|
|
2649
|
+
openStructured({ id: rec.id, model: rec.model || undefined, provider: rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt,
|
|
2615
2650
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
2616
2651
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
2617
2652
|
// (mid-turn needs auto-resume; flow needs its lane live).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.162",
|
|
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": {
|
|
@@ -32,11 +32,13 @@
|
|
|
32
32
|
"flow-skill-registry.mjs",
|
|
33
33
|
"serve-dir.mjs",
|
|
34
34
|
"serve-consent.mjs",
|
|
35
|
+
"reap-terminal.mjs",
|
|
35
36
|
"service.mjs",
|
|
36
37
|
"presence.mjs",
|
|
37
38
|
"account.mjs",
|
|
38
39
|
"auth-store.mjs",
|
|
39
40
|
"provider.mjs",
|
|
41
|
+
"providers.mjs",
|
|
40
42
|
"README.md"
|
|
41
43
|
],
|
|
42
44
|
"scripts": {
|
package/providers.mjs
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
providers.mjs — the bridge's LLM-provider REGISTRY (multi-BYOK).
|
|
3
|
+
|
|
4
|
+
Slice 1 of the multi-provider BYOK feature (spec:
|
|
5
|
+
docs/specs/2026-07-05-llm-provider-registry.md). Lets a user register
|
|
6
|
+
extra Anthropic-compatible providers (name + base url + model + API
|
|
7
|
+
key) on their OWN bridge; a Code lane can then spawn against any
|
|
8
|
+
registered provider by id. Keys live ONLY on the bridge host — they are
|
|
9
|
+
never broadcast, never written to the DB, never in an announce.
|
|
10
|
+
|
|
11
|
+
This module owns:
|
|
12
|
+
• providers.json — the registry ({id,name,baseUrl,model,key,addedAt}[]), 0600
|
|
13
|
+
• bridge-key.json — the bridge's RSA keypair, 0600 (host-only)
|
|
14
|
+
• seal/unseal — the hybrid envelope the dashboard uses to hand the
|
|
15
|
+
bridge a key WITHOUT it ever crossing the wire in the
|
|
16
|
+
clear (RSA-OAEP wraps an AES-256-GCM session key).
|
|
17
|
+
• env resolution — {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL}
|
|
18
|
+
for a chosen provider id, matching provider.mjs conventions.
|
|
19
|
+
|
|
20
|
+
The built-in `anthropic` provider (the host's regular Claude login) is
|
|
21
|
+
IMPLICIT: never stored, never removable, always present. A lane with no
|
|
22
|
+
provider id runs the default Claude path unchanged.
|
|
23
|
+
|
|
24
|
+
── Why HYBRID sealing, not raw RSA ──────────────────────────────────
|
|
25
|
+
RSA-OAEP-2048/SHA-256 caps plaintext at 190 bytes. A real payload —
|
|
26
|
+
{name,baseUrl,model,key} with e.g. an OpenAI `sk-proj-…` key (~164 chars)
|
|
27
|
+
plus JSON overhead — blows past that and RSA encrypt throws. So `sealed`
|
|
28
|
+
is a hybrid envelope: RSA-OAEP wraps a fresh 32-byte AES key, AES-256-GCM
|
|
29
|
+
encrypts the payload. Both ends are WebCrypto-native (browser dashboard
|
|
30
|
+
seals, Node bridge unseals). Envelope shape (base64 of UTF8 JSON):
|
|
31
|
+
{ v:1, ek:base64(RSA-OAEP(aesKeyRaw)), iv:base64(12B), ct:base64(gcmCiphertext||16B-tag) }
|
|
32
|
+
WebCrypto's subtle.encrypt returns ciphertext||tag concatenated; Node's
|
|
33
|
+
createDecipheriv wants the tag separately, so unseal splits the last 16.
|
|
34
|
+
|
|
35
|
+
── Auth (see account.mjs handlers) ─────────────────────────────────
|
|
36
|
+
The account channel `tpacct:<uid>` is a PUBLIC realtime channel (no
|
|
37
|
+
config.private:true), so it is NOT RLS-gated — the sender is verified at
|
|
38
|
+
the application layer (getUser(jwt).id === owner uid) before any registry
|
|
39
|
+
mutation. This module stays transport-agnostic; the caller does auth.
|
|
40
|
+
───────────────────────────────────────────────────────────── */
|
|
41
|
+
import os from 'node:os'
|
|
42
|
+
import fs from 'node:fs'
|
|
43
|
+
import path from 'node:path'
|
|
44
|
+
import crypto from 'node:crypto'
|
|
45
|
+
|
|
46
|
+
const DIR = path.join(os.homedir(), '.thinkpool-pair')
|
|
47
|
+
const REG_FILE = path.join(DIR, 'providers.json')
|
|
48
|
+
const KEY_FILE = path.join(DIR, 'bridge-key.json')
|
|
49
|
+
|
|
50
|
+
// The built-in provider id — always present, never stored, never removable.
|
|
51
|
+
export const BUILTIN_ID = 'anthropic'
|
|
52
|
+
|
|
53
|
+
function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true, mode: 0o700 }) } catch { /* noop */ } }
|
|
54
|
+
|
|
55
|
+
// ── registry load / save ───────────────────────────────────────────────
|
|
56
|
+
/** @returns {Array<{id:string,name:string,baseUrl:string,model?:string,key:string,addedAt:number}>} */
|
|
57
|
+
export function loadProviders() {
|
|
58
|
+
try {
|
|
59
|
+
const arr = JSON.parse(fs.readFileSync(REG_FILE, 'utf8'))
|
|
60
|
+
return Array.isArray(arr) ? arr.filter((p) => p && p.id && p.id !== BUILTIN_ID) : []
|
|
61
|
+
} catch { return [] }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function saveProviders(arr) {
|
|
65
|
+
ensureDir()
|
|
66
|
+
fs.writeFileSync(REG_FILE, JSON.stringify(arr, null, 2), { mode: 0o600 })
|
|
67
|
+
// Writing with { mode } only applies on CREATE; harden an existing file too.
|
|
68
|
+
try { fs.chmodSync(REG_FILE, 0o600) } catch { /* noop */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── keypair ─────────────────────────────────────────────────────────────
|
|
72
|
+
// Generate an RSA-OAEP-2048 keypair on first run, persist 0600, return the
|
|
73
|
+
// public key as SPKI base64 (what the dashboard imports) + the private key
|
|
74
|
+
// object (for unseal). Idempotent: a persisted key is reused.
|
|
75
|
+
let _keyCache = null
|
|
76
|
+
export function ensureKeypair() {
|
|
77
|
+
if (_keyCache) return _keyCache
|
|
78
|
+
ensureDir()
|
|
79
|
+
try {
|
|
80
|
+
const saved = JSON.parse(fs.readFileSync(KEY_FILE, 'utf8'))
|
|
81
|
+
if (saved?.publicKeyB64 && saved?.privateKeyPem) {
|
|
82
|
+
_keyCache = {
|
|
83
|
+
publicKeyB64: saved.publicKeyB64,
|
|
84
|
+
privateKey: crypto.createPrivateKey({ key: saved.privateKeyPem, format: 'pem' }),
|
|
85
|
+
}
|
|
86
|
+
return _keyCache
|
|
87
|
+
}
|
|
88
|
+
} catch { /* generate below */ }
|
|
89
|
+
|
|
90
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
91
|
+
const publicKeyB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64')
|
|
92
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' })
|
|
93
|
+
fs.writeFileSync(KEY_FILE, JSON.stringify({ publicKeyB64, privateKeyPem, createdAt: Date.now() }, null, 2), { mode: 0o600 })
|
|
94
|
+
try { fs.chmodSync(KEY_FILE, 0o600) } catch { /* noop */ }
|
|
95
|
+
_keyCache = { publicKeyB64, privateKey }
|
|
96
|
+
return _keyCache
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** SPKI base64 public key for the announce (`providerPubKey`). */
|
|
100
|
+
export function publicKeyB64() { return ensureKeypair().publicKeyB64 }
|
|
101
|
+
|
|
102
|
+
// ── seal / unseal (hybrid RSA-OAEP + AES-256-GCM) ───────────────────────
|
|
103
|
+
// seal() is here so the UNIT can drive both ends node-side; the real seal
|
|
104
|
+
// happens in the browser dashboard via WebCrypto against the same envelope.
|
|
105
|
+
/** @param {object} payload @param {string=} pubKeyB64 → base64(envelope JSON) */
|
|
106
|
+
export function seal(payload, pubKeyB64) {
|
|
107
|
+
const spkiB64 = pubKeyB64 || publicKeyB64()
|
|
108
|
+
const publicKey = crypto.createPublicKey({ key: Buffer.from(spkiB64, 'base64'), format: 'der', type: 'spki' })
|
|
109
|
+
const aesKey = crypto.randomBytes(32)
|
|
110
|
+
const iv = crypto.randomBytes(12)
|
|
111
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv)
|
|
112
|
+
const enc = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()])
|
|
113
|
+
const tag = cipher.getAuthTag()
|
|
114
|
+
const ek = crypto.publicEncrypt(
|
|
115
|
+
{ key: publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
|
|
116
|
+
aesKey,
|
|
117
|
+
)
|
|
118
|
+
const envelope = {
|
|
119
|
+
v: 1,
|
|
120
|
+
ek: ek.toString('base64'),
|
|
121
|
+
iv: iv.toString('base64'),
|
|
122
|
+
ct: Buffer.concat([enc, tag]).toString('base64'), // ct||tag, matches WebCrypto
|
|
123
|
+
}
|
|
124
|
+
return Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Decrypt a sealed envelope with the bridge private key. Returns the parsed
|
|
129
|
+
* payload object, or throws. Callers MUST catch and answer ok:false WITHOUT
|
|
130
|
+
* logging the ciphertext or plaintext (security invariant d).
|
|
131
|
+
* @param {string} sealedB64 base64(envelope JSON)
|
|
132
|
+
*/
|
|
133
|
+
export function unseal(sealedB64) {
|
|
134
|
+
const { privateKey } = ensureKeypair()
|
|
135
|
+
const env = JSON.parse(Buffer.from(String(sealedB64 || ''), 'base64').toString('utf8'))
|
|
136
|
+
if (!env || env.v !== 1 || !env.ek || !env.iv || !env.ct) throw new Error('bad envelope')
|
|
137
|
+
const aesKey = crypto.privateDecrypt(
|
|
138
|
+
{ key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
|
|
139
|
+
Buffer.from(env.ek, 'base64'),
|
|
140
|
+
)
|
|
141
|
+
const iv = Buffer.from(env.iv, 'base64')
|
|
142
|
+
const ctTag = Buffer.from(env.ct, 'base64')
|
|
143
|
+
if (ctTag.length < 16) throw new Error('bad ciphertext')
|
|
144
|
+
const tag = ctTag.subarray(ctTag.length - 16)
|
|
145
|
+
const ct = ctTag.subarray(0, ctTag.length - 16)
|
|
146
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, iv)
|
|
147
|
+
decipher.setAuthTag(tag)
|
|
148
|
+
const dec = Buffer.concat([decipher.update(ct), decipher.final()])
|
|
149
|
+
return JSON.parse(dec.toString('utf8'))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── CRUD ────────────────────────────────────────────────────────────────
|
|
153
|
+
function validate({ name, baseUrl, key }) {
|
|
154
|
+
if (!name || typeof name !== 'string' || !name.trim()) return 'name is required'
|
|
155
|
+
if (!key || typeof key !== 'string' || !key.trim()) return 'key is required'
|
|
156
|
+
let u
|
|
157
|
+
try { u = new URL(String(baseUrl)) } catch { return 'baseUrl must be a valid https url' }
|
|
158
|
+
if (u.protocol !== 'https:') return 'baseUrl must be https'
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Append a provider. `fields` = {name, baseUrl, model?, key} (already unsealed).
|
|
164
|
+
* @returns {{ok:true,id:string} | {ok:false,error:string}}
|
|
165
|
+
*/
|
|
166
|
+
export function addProvider(fields) {
|
|
167
|
+
const err = validate(fields || {})
|
|
168
|
+
if (err) return { ok: false, error: err }
|
|
169
|
+
const arr = loadProviders()
|
|
170
|
+
const id = crypto.randomUUID()
|
|
171
|
+
arr.push({
|
|
172
|
+
id,
|
|
173
|
+
name: String(fields.name).trim(),
|
|
174
|
+
baseUrl: String(fields.baseUrl).trim(),
|
|
175
|
+
model: fields.model ? String(fields.model).trim() : undefined,
|
|
176
|
+
key: String(fields.key).trim(),
|
|
177
|
+
addedAt: Date.now(),
|
|
178
|
+
})
|
|
179
|
+
saveProviders(arr)
|
|
180
|
+
return { ok: true, id }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Remove a provider by id. The built-in `anthropic` refuses.
|
|
185
|
+
* @returns {{ok:true} | {ok:false,error:string}}
|
|
186
|
+
*/
|
|
187
|
+
export function removeProvider(id) {
|
|
188
|
+
if (!id || id === BUILTIN_ID) return { ok: false, error: 'the built-in Anthropic provider cannot be removed' }
|
|
189
|
+
const arr = loadProviders()
|
|
190
|
+
const next = arr.filter((p) => p.id !== id)
|
|
191
|
+
if (next.length === arr.length) return { ok: false, error: 'no such provider' }
|
|
192
|
+
saveProviders(next)
|
|
193
|
+
return { ok: true }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── read-only projections (never expose the raw key) ────────────────────
|
|
197
|
+
/** Masked list for the dashboard: keyHint = last 4 chars only. Built-in first. */
|
|
198
|
+
export function listProviders() {
|
|
199
|
+
const custom = loadProviders().map((p) => ({
|
|
200
|
+
id: p.id,
|
|
201
|
+
name: p.name,
|
|
202
|
+
model: p.model || null,
|
|
203
|
+
keyHint: keyHint(p.key),
|
|
204
|
+
}))
|
|
205
|
+
return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, keyHint: null }, ...custom]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Name-only projection for the announce/presence payload — NO key, NO baseUrl. */
|
|
209
|
+
export function announceProviders() {
|
|
210
|
+
const custom = loadProviders().map((p) => ({ id: p.id, name: p.name, model: p.model || null }))
|
|
211
|
+
return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null }, ...custom]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function keyHint(key) {
|
|
215
|
+
const k = String(key || '')
|
|
216
|
+
return k.length <= 4 ? k : k.slice(-4)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the spawn env for a provider id, matching provider.mjs conventions
|
|
221
|
+
* exactly (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL).
|
|
222
|
+
* Built-in / unknown id → null (the caller leaves the default Claude env intact).
|
|
223
|
+
* @returns {{ANTHROPIC_BASE_URL:string,ANTHROPIC_AUTH_TOKEN:string,ANTHROPIC_MODEL?:string} | null}
|
|
224
|
+
*/
|
|
225
|
+
export function resolveProviderEnv(id) {
|
|
226
|
+
if (!id || id === BUILTIN_ID) return null
|
|
227
|
+
const p = loadProviders().find((x) => x.id === id)
|
|
228
|
+
if (!p) return null
|
|
229
|
+
const env = { ANTHROPIC_BASE_URL: p.baseUrl, ANTHROPIC_AUTH_TOKEN: p.key }
|
|
230
|
+
if (p.model) env.ANTHROPIC_MODEL = p.model
|
|
231
|
+
return env
|
|
232
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Bridge-side reap of a cleanly-closed structured lane's code_terminals row.
|
|
2
|
+
//
|
|
3
|
+
// Spec: docs/specs/2026-07-04-structured-terminal-durability.md §Bridge-side reap.
|
|
4
|
+
//
|
|
5
|
+
// WHY this exists: the row-reap on a clean structured close (endStructured / MCP
|
|
6
|
+
// close_terminal) used to be CLIENT-executed only — it ran in whichever browser tab
|
|
7
|
+
// caught the transient `term-exit` broadcast (room.jsx #205). Close a lane while no fresh
|
|
8
|
+
// tab is listening (stale / asleep) → the broadcast evaporates → a permanent dormant ghost
|
|
9
|
+
// row (three hand-deleted 2026-07-04/05). The bridge is the ONE authoritative witness of a
|
|
10
|
+
// clean structured close, so it reaps the row ITSELF — the client reap is kept as the
|
|
11
|
+
// fallback for old bridges and for the fast-tab case.
|
|
12
|
+
//
|
|
13
|
+
// Extracted from bridge.mjs (which has top-level side effects and can't be unit-imported)
|
|
14
|
+
// so the reap DECISION + REST shape are testable with an injected fetch — same pattern as
|
|
15
|
+
// serve-consent.mjs. bridge.mjs wraps this, closing over its live codeAuthToken /
|
|
16
|
+
// shuttingDown state.
|
|
17
|
+
//
|
|
18
|
+
// RLS: `code_terminals_member_delete` permits DELETE to any member of the parent code
|
|
19
|
+
// session; the bridge serves as the room owner or an authed grantee (codeAuthToken), so it
|
|
20
|
+
// qualifies. Anon bridges (no token) skip — the client reap covers them.
|
|
21
|
+
//
|
|
22
|
+
// Scope guard: NEVER reap while shuttingDown — a late cleanupFlowLanes timer firing during
|
|
23
|
+
// teardown / stand-down / restart must not delete rows. Dormant lanes across a restart are
|
|
24
|
+
// the feature, not the leak.
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @returns {Promise<{reaped: boolean, reason: 'ok'|'shutdown'|'anon'|'no-id'|'error', error?: string}>}
|
|
28
|
+
* Never throws — best-effort; the client reap (#205) is the fallback.
|
|
29
|
+
*/
|
|
30
|
+
export async function reapTerminalRow ({ id, token, supabaseUrl, anonKey, shuttingDown = false, fetchImpl = fetch }) {
|
|
31
|
+
if (shuttingDown) return { reaped: false, reason: 'shutdown' }
|
|
32
|
+
if (!token) return { reaped: false, reason: 'anon' }
|
|
33
|
+
if (!id) return { reaped: false, reason: 'no-id' }
|
|
34
|
+
try {
|
|
35
|
+
// Mirror of the client reap: DELETE code_terminals WHERE id = <terminal id>. The
|
|
36
|
+
// terminal id IS the code_terminals PK (client/bridge-minted uuid). RLS scopes the
|
|
37
|
+
// delete to this bridge's sessions, so id alone is sufficient + idempotent (a fast
|
|
38
|
+
// client tab may have reaped first — 204 / 0 rows is fine).
|
|
39
|
+
await fetchImpl(`${supabaseUrl}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}`, {
|
|
40
|
+
method: 'DELETE',
|
|
41
|
+
headers: { apikey: anonKey, Authorization: `Bearer ${token}` },
|
|
42
|
+
})
|
|
43
|
+
return { reaped: true, reason: 'ok' }
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return { reaped: false, reason: 'error', error: e?.message }
|
|
46
|
+
}
|
|
47
|
+
}
|