thinkpool-pair 0.7.160 → 0.7.161

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,10 @@ 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
+ // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
42
+ // registered custom provider, or null for the built-in/unknown (leave the default env intact).
43
+ // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
44
+ import { resolveProviderEnv } from './providers.mjs'
41
45
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
42
46
  import { z } from 'zod'
43
47
  import { startClaudeSession } from './claude-session.mjs'
@@ -1412,12 +1416,12 @@ function worktreeSnapshot(cwd) {
1412
1416
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1413
1417
  // persist to the host file; tool calls round-trip through the perm card; the
1414
1418
  // 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 }) {
1419
+ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider }) {
1416
1420
  if (sessions.has(id)) return
1417
1421
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1418
1422
  // session store so the Ensemble flag survives a bridge restart (else a respin
1419
1423
  // 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,
1424
+ 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
1425
  // Stable creation order — persisted so a bridge restart restores tabs in the SAME
1422
1426
  // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
1423
1427
  // first transcript event ts, so even the first post-fix restart is ordered right.
@@ -1595,7 +1599,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1595
1599
  // restart. Without this, sessionData omitted it → on restart the resumed session
1596
1600
  // re-launched on the host default (Opus) regardless of the last switch, and the
1597
1601
  // 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 })
1602
+ 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
1603
  const persist = () => saveSession(room, id, sessionData())
1600
1604
  // Synchronous flush of this session's record. Used on open (so a brand-new session
1601
1605
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -1761,6 +1765,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1761
1765
  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
1766
  task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
1763
1767
  model: z.string().optional().describe('optional model for the lane, e.g. opus / sonnet / haiku'),
1768
+ 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
1769
  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
1770
  },
1766
1771
  async (args) => {
@@ -1791,7 +1796,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1791
1796
  // plan-mode lane would stall on ExitPlanMode cards. An explicit args.mode still wins;
1792
1797
  // otherwise inherit, but fall plan → default (an autonomous worker doesn't plan-gate).
1793
1798
  const childMode = args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode)
1794
- openStructured({ id: newId, model: args?.model, mode: childMode })
1799
+ openStructured({ id: newId, model: args?.model, provider: args?.provider, mode: childMode })
1795
1800
  const ne = sessions.get(newId)
1796
1801
  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
1802
  ne.spawnedBy = id // ownership: only the spawner may close_terminal it
@@ -1975,7 +1980,12 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1975
1980
  // Tier 3 — the SENDER-side gate for post_to_session (the receipt card lives in
1976
1981
  // the target room). roomHop/crossRoomPostCount reset on a real human turn (code-turn).
1977
1982
  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 },
1983
+ // Per-lane provider (multi-BYOK slice 1): a lane opened with a registered `provider`
1984
+ // id runs on THAT Anthropic-compatible endpoint. resolveProviderEnv → {ANTHROPIC_BASE_URL,
1985
+ // ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL}, spread AFTER process.env so it overrides the
1986
+ // per-machine default (provider.mjs/applyProviderEnv). null for built-in/unknown → the
1987
+ // default Claude env is left exactly as-is (unchanged path).
1988
+ env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), ...(resolveProviderEnv(provider) || {}), TP_MOCKUP_OUTBOX: mockupOutbox },
1979
1989
  onEvent: (evt) => {
1980
1990
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
1981
1991
  // keeping the transcript (scrollback survives; live context is gone).
@@ -2320,7 +2330,7 @@ channel
2320
2330
  // previous terminal's model (and the first uses the provider default). Falls
2321
2331
  // through to the SDK default when absent. Stamped on the entry so the announce
2322
2332
  // carries it at open (chip shows the model immediately, not the branch).
2323
- openStructured({ id: payload.id, mode: payload.mode, model: payload.model || undefined })
2333
+ openStructured({ id: payload.id, mode: payload.mode, model: payload.model || undefined, provider: payload.provider || undefined })
2324
2334
  process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
2325
2335
  return
2326
2336
  }
@@ -2428,7 +2438,7 @@ channel
2428
2438
  // ── structured-session control (Phase 2) ──
2429
2439
  .on('broadcast', { event: 'code-open' }, ({ payload }) => {
2430
2440
  if (payload?.host && payload.host !== name) return
2431
- openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume, mode: payload?.mode })
2441
+ openStructured({ id: payload?.id || randomUUID(), model: payload?.model, provider: payload?.provider, resume: payload?.resume, mode: payload?.mode })
2432
2442
  })
2433
2443
  .on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
2434
2444
  const s = payload?.term && sessions.get(payload.term)
@@ -2611,7 +2621,7 @@ channel
2611
2621
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
2612
2622
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
2613
2623
  // 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,
2624
+ 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
2625
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
2616
2626
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
2617
2627
  // (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.161",
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": {
@@ -37,6 +37,7 @@
37
37
  "account.mjs",
38
38
  "auth-store.mjs",
39
39
  "provider.mjs",
40
+ "providers.mjs",
40
41
  "README.md"
41
42
  ],
42
43
  "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
+ }