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 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
- const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', ts: Date.now() })
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
- env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), TP_MOCKUP_OUTBOX: mockupOutbox },
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.160",
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
+ }