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 +51 -1
- package/bridge.mjs +18 -8
- package/package.json +2 -1
- package/providers.mjs +232 -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,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
|
-
|
|
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.
|
|
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
|
+
}
|