thinkpool-pair 0.7.159 → 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 +77 -4
- package/bridge.mjs +61 -13
- package/package.json +2 -1
- package/providers.mjs +232 -0
- package/serve-consent.mjs +46 -4
package/account.mjs
CHANGED
|
@@ -16,7 +16,8 @@ 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 {
|
|
19
|
+
import { publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
|
|
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'
|
|
22
23
|
|
|
@@ -231,6 +232,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
231
232
|
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it: npx thinkpool-pair set-default-dir <dir> · one room: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
|
|
232
233
|
|
|
233
234
|
const children = new Map() // room -> child process
|
|
235
|
+
// grantCooldown: room -> wall-clock ms deadline. A grantee child that yielded to the
|
|
236
|
+
// owner's returning bridge (exit 75) parks its room here so discovery does NOT re-spawn a
|
|
237
|
+
// child that would only yield again — the owner-serves/grantee-stands-by coexistence state
|
|
238
|
+
// must not thrash-churn every 15s tick (DEFECT 2, 2026-07-05). Expires after TP_GRANT_COOLDOWN_MS;
|
|
239
|
+
// after that discovery retries once — serving normally if the owner has since gone.
|
|
240
|
+
const grantCooldown = new Map()
|
|
241
|
+
const GRANT_COOLDOWN_MS = Math.max(15_000, parseInt(process.env.TP_GRANT_COOLDOWN_MS, 10) || 120_000)
|
|
234
242
|
const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
|
|
235
243
|
const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
|
|
236
244
|
const childPeer = new Map() // room -> bool (a web client is watching this room)
|
|
@@ -362,7 +370,56 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
362
370
|
// service (always-on, survives reboot) vs a foreground `npx` run that dies with its
|
|
363
371
|
// terminal. THINKPOOL_PAIR_AUTOUPDATE=1 is set ONLY by the install-service plist/unit
|
|
364
372
|
// (service.mjs), so it's the reliable proxy for "running as the managed service".
|
|
365
|
-
|
|
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
|
+
|
|
366
423
|
// Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
|
|
367
424
|
// (network blip, or a token swap mid-flight) rejoins the channel and must
|
|
368
425
|
// re-announce, or the dashboard would read "no bridge" until the next restart.
|
|
@@ -554,6 +611,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
554
611
|
for (const r of rooms) {
|
|
555
612
|
const room = r.code
|
|
556
613
|
if (!room || children.has(room)) { if (room) refused.delete(room); continue } // served → not refused
|
|
614
|
+
// Grant-reclaim cooldown (DEFECT 2): this room's grantee child recently yielded to the
|
|
615
|
+
// owner's bridge (exit 75). Skip re-serving it until the window elapses so we don't
|
|
616
|
+
// thrash-respawn a child that will only yield again. refused already reads 'owner-serves'
|
|
617
|
+
// (set on that exit below), so presence stays honest while we stand by.
|
|
618
|
+
const cdUntil = grantCooldown.get(room)
|
|
619
|
+
if (roomInCooldown(cdUntil, Date.now())) continue
|
|
620
|
+
if (cdUntil) grantCooldown.delete(room) // window elapsed — allow one fresh attempt (owner may have gone)
|
|
557
621
|
// Defense-in-depth behind the .or above: serve iff we OWN the room or hold its grant.
|
|
558
622
|
if (!supervisorServes({ ownerId: r.owner_id, grantUid: r.serve_grant_uid, myUid: me })) continue
|
|
559
623
|
// Resolve the serve dir: explicit bind > last-served memory > launch dir. REFUSE
|
|
@@ -673,13 +737,22 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
673
737
|
try { w.reply(m) } catch { /* reply path gone */ }
|
|
674
738
|
}
|
|
675
739
|
})
|
|
676
|
-
const dropRoom = () => {
|
|
740
|
+
const dropRoom = (code) => {
|
|
677
741
|
children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room); roomPartner.delete(room) // re-served on the next tick
|
|
742
|
+
// Owner-reclaim SENTINEL (75): the grantee child stood down because the owner's bridge
|
|
743
|
+
// returned. Park the room on a cooldown so discovery doesn't respawn it into another
|
|
744
|
+
// immediate yield (DEFECT 2), and mark refused 'owner-serves' so presence is honest.
|
|
745
|
+
// A revoke-yield exits 0 (discovery drops a revoked room anyway); owner-owned rooms
|
|
746
|
+
// never yield, so neither cools down. `code` is undefined on the 'error' path — fine.
|
|
747
|
+
if (shouldCooldownOnExit(code)) {
|
|
748
|
+
grantCooldown.set(room, Date.now() + GRANT_COOLDOWN_MS)
|
|
749
|
+
refused.set(room, 'owner-serves')
|
|
750
|
+
}
|
|
678
751
|
// Fail any in-flight cross-room peeks/posts this dead room had ASKED (bus-origin
|
|
679
752
|
// entries have askerRoom null and just time out on their own).
|
|
680
753
|
for (const [reqId, w] of pairPending) { if (w?.askerRoom === room) pairPending.delete(reqId) }
|
|
681
754
|
}
|
|
682
|
-
child.on('exit', dropRoom)
|
|
755
|
+
child.on('exit', (code) => dropRoom(code))
|
|
683
756
|
// A spawn that never starts (execPath/cwd vanished between the existsSync check and
|
|
684
757
|
// spawn) emits 'error', NOT 'exit'. Without this handler the stale children-map entry
|
|
685
758
|
// would block re-serving forever AND an unhandled 'error' would crash the supervisor.
|
package/bridge.mjs
CHANGED
|
@@ -37,7 +37,11 @@ import { fileURLToPath } from 'node:url'
|
|
|
37
37
|
import { createRequire } from 'node:module'
|
|
38
38
|
import { randomUUID } from 'node:crypto'
|
|
39
39
|
import { createClient } from '@supabase/supabase-js'
|
|
40
|
-
import { serveDecision, amGrantee, standDownOnPeerAnnounce, standDownOnGrantChange, fetchServeRow, refusalMessage } from './serve-consent.mjs'
|
|
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'
|
|
@@ -586,8 +590,33 @@ let yielding = false // set once when standing down, so reclaim + revoke
|
|
|
586
590
|
// Standalone room mode ran anon unless TP_ACCESS_TOKEN was in env; load the stored
|
|
587
591
|
// login so `npx thinkpool-pair <CODE>` authenticates AS the signed-in user (the
|
|
588
592
|
// identity the consent check needs). Account children already carry the env token.
|
|
593
|
+
// Refresh the stored access token BEFORE the gate (DEFECT 1, 2026-07-05). A stored
|
|
594
|
+
// access token is minted at login and Supabase access tokens expire ~1h; a long-lived
|
|
595
|
+
// standalone bridge — or a service relaunched from a stale store — would otherwise hit
|
|
596
|
+
// the RLS-gated gate with a DEAD token: fetchServeRow → 401, which (old behavior) masked
|
|
597
|
+
// as a network error and FAILED OPEN → the consent gate was bypassed in the common case.
|
|
598
|
+
// authedClient exchanges the rotated refresh token for a live pair and persists it via
|
|
599
|
+
// saveAuth (refresh-token rotation: the used token is now dead, so the store must never be
|
|
600
|
+
// left on the stale pair). Only refresh when the token came from the STORE — an env token
|
|
601
|
+
// (TP_ACCESS_TOKEN, from a supervisor that already keeps it fresh) is left untouched.
|
|
602
|
+
let tokenFromStore = false
|
|
589
603
|
if (!codeAuthToken) {
|
|
590
|
-
try {
|
|
604
|
+
try {
|
|
605
|
+
const { loadAuth } = await import('./auth-store.mjs')
|
|
606
|
+
codeAuthToken = loadAuth()?.access_token || null
|
|
607
|
+
if (codeAuthToken) tokenFromStore = true
|
|
608
|
+
} catch { /* no store — stays anon */ }
|
|
609
|
+
}
|
|
610
|
+
if (tokenFromStore) {
|
|
611
|
+
try {
|
|
612
|
+
const { authedClient } = await import('./account.mjs')
|
|
613
|
+
const refreshed = await authedClient(SUPABASE_URL, SUPABASE_ANON)
|
|
614
|
+
if (refreshed?.session?.access_token) codeAuthToken = refreshed.session.access_token
|
|
615
|
+
// If the refresh FAILS (dead refresh token / offline), keep the stale token: an
|
|
616
|
+
// offline refresh must not strand a legit owner. A dead refresh token then yields a
|
|
617
|
+
// 401 at fetchServeRow → refuse (correct: the login is genuinely gone); a transient
|
|
618
|
+
// offline refresh yields a fetch reject → fail-open (correct: availability).
|
|
619
|
+
} catch { /* account module / refresh failed — proceed with the stored token */ }
|
|
591
620
|
}
|
|
592
621
|
try {
|
|
593
622
|
if (codeAuthToken) {
|
|
@@ -606,8 +635,15 @@ let yielding = false // set once when standing down, so reclaim + revoke
|
|
|
606
635
|
iServeAsGrantee = amGrantee({ ownerId, grantUid, myUid: myServeUid })
|
|
607
636
|
if (iServeAsGrantee) process.stderr.write(`\n ◆ serving room ${room} by the owner's grant — will yield if the owner's bridge returns or the grant is revoked.\n`)
|
|
608
637
|
} catch (e) {
|
|
609
|
-
//
|
|
610
|
-
//
|
|
638
|
+
// A THROWN fetchServeRow error is either a definitively-bad auth (401/403) or an
|
|
639
|
+
// availability failure (5xx / transport reject). Only the latter fails OPEN — a stale
|
|
640
|
+
// 401 must NEVER serve, or the gate is bypassed (DEFECT 1). gateFailAction classifies.
|
|
641
|
+
if (gateFailAction(e) === 'refuse') {
|
|
642
|
+
process.stderr.write(refusalMessage({ room, name: null, reason: 'no-identity' }))
|
|
643
|
+
process.stderr.write(`\n ◇ (serve-consent: auth rejected at the gate — ${e.message}; the stored login is expired or revoked. Re-run: npx thinkpool-pair login)\n`)
|
|
644
|
+
process.exit(0)
|
|
645
|
+
}
|
|
646
|
+
// Availability failure → fail OPEN (spec: never strand a user on a flaky fetch).
|
|
611
647
|
process.stderr.write(`\n ◇ serve-consent check degraded (${e.message}); proceeding without it.\n`)
|
|
612
648
|
}
|
|
613
649
|
}
|
|
@@ -902,7 +938,13 @@ function onPeerBridgeAnnounce (payload) {
|
|
|
902
938
|
if (!standDownOnPeerAnnounce({ myUid: myServeUid, ownerId: roomOwnerId, peerUid: payload?.uid })) return
|
|
903
939
|
yielding = true
|
|
904
940
|
process.stderr.write(`\n ◆ owner's bridge reconnected — yielding room ${room}.\n`)
|
|
905
|
-
|
|
941
|
+
// Exit with the grant-reclaim SENTINEL (not a clean 0). A supervisor that spawned this
|
|
942
|
+
// grantee child reads the code and puts the room on a cooldown, so it does NOT respawn a
|
|
943
|
+
// child that will only yield again — the owner-serves/grantee-stands-by coexistence state
|
|
944
|
+
// must not thrash-churn every discovery tick (DEFECT 2, 2026-07-05). shutdown() still sets
|
|
945
|
+
// shuttingDown FIRST, so the term-exit broadcast stays suppressed and structured terminal
|
|
946
|
+
// rows survive the stand-down (#205/#208 durability — a handover must not delete rows).
|
|
947
|
+
shutdown(GRANT_RECLAIM_EXIT)
|
|
906
948
|
}
|
|
907
949
|
|
|
908
950
|
// ── Thinkpool Ensemble — cross-ROOM reach (Tier 1: same-machine pair peek) ────
|
|
@@ -1374,12 +1416,12 @@ function worktreeSnapshot(cwd) {
|
|
|
1374
1416
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
1375
1417
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
1376
1418
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
1377
|
-
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 }) {
|
|
1378
1420
|
if (sessions.has(id)) return
|
|
1379
1421
|
// spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
|
|
1380
1422
|
// session store so the Ensemble flag survives a bridge restart (else a respin
|
|
1381
1423
|
// stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
|
|
1382
|
-
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,
|
|
1383
1425
|
// Stable creation order — persisted so a bridge restart restores tabs in the SAME
|
|
1384
1426
|
// order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
|
|
1385
1427
|
// first transcript event ts, so even the first post-fix restart is ordered right.
|
|
@@ -1557,7 +1599,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1557
1599
|
// restart. Without this, sessionData omitted it → on restart the resumed session
|
|
1558
1600
|
// re-launched on the host default (Opus) regardless of the last switch, and the
|
|
1559
1601
|
// switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
|
|
1560
|
-
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 })
|
|
1561
1603
|
const persist = () => saveSession(room, id, sessionData())
|
|
1562
1604
|
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
1563
1605
|
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
@@ -1723,6 +1765,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1723
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"'),
|
|
1724
1766
|
task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
|
|
1725
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'),
|
|
1726
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.'),
|
|
1727
1770
|
},
|
|
1728
1771
|
async (args) => {
|
|
@@ -1753,7 +1796,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1753
1796
|
// plan-mode lane would stall on ExitPlanMode cards. An explicit args.mode still wins;
|
|
1754
1797
|
// otherwise inherit, but fall plan → default (an autonomous worker doesn't plan-gate).
|
|
1755
1798
|
const childMode = args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode)
|
|
1756
|
-
openStructured({ id: newId, model: args?.model, mode: childMode })
|
|
1799
|
+
openStructured({ id: newId, model: args?.model, provider: args?.provider, mode: childMode })
|
|
1757
1800
|
const ne = sessions.get(newId)
|
|
1758
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.') }
|
|
1759
1802
|
ne.spawnedBy = id // ownership: only the spawner may close_terminal it
|
|
@@ -1937,7 +1980,12 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1937
1980
|
// Tier 3 — the SENDER-side gate for post_to_session (the receipt card lives in
|
|
1938
1981
|
// the target room). roomHop/crossRoomPostCount reset on a real human turn (code-turn).
|
|
1939
1982
|
crossRoomPostGate: () => crossRoomPostDecision({ roomHop: entry.roomHop || 0, postCount: entry.crossRoomPostCount || 0, disabled: process.env.TP_PAIRBUS_OFF === '1' }),
|
|
1940
|
-
|
|
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 },
|
|
1941
1989
|
onEvent: (evt) => {
|
|
1942
1990
|
// Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
|
|
1943
1991
|
// keeping the transcript (scrollback survives; live context is gone).
|
|
@@ -2282,7 +2330,7 @@ channel
|
|
|
2282
2330
|
// previous terminal's model (and the first uses the provider default). Falls
|
|
2283
2331
|
// through to the SDK default when absent. Stamped on the entry so the announce
|
|
2284
2332
|
// carries it at open (chip shows the model immediately, not the branch).
|
|
2285
|
-
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 })
|
|
2286
2334
|
process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
|
|
2287
2335
|
return
|
|
2288
2336
|
}
|
|
@@ -2390,7 +2438,7 @@ channel
|
|
|
2390
2438
|
// ── structured-session control (Phase 2) ──
|
|
2391
2439
|
.on('broadcast', { event: 'code-open' }, ({ payload }) => {
|
|
2392
2440
|
if (payload?.host && payload.host !== name) return
|
|
2393
|
-
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 })
|
|
2394
2442
|
})
|
|
2395
2443
|
.on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
|
|
2396
2444
|
const s = payload?.term && sessions.get(payload.term)
|
|
@@ -2573,7 +2621,7 @@ channel
|
|
|
2573
2621
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
2574
2622
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
2575
2623
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
2576
|
-
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,
|
|
2577
2625
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
2578
2626
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
2579
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
|
+
}
|
package/serve-consent.mjs
CHANGED
|
@@ -74,9 +74,16 @@ export function standDownOnGrantChange ({ myUid, ownerId, grantUid }) {
|
|
|
74
74
|
|
|
75
75
|
// fetchServeRow — read the room's consent columns with whatever identity the bridge
|
|
76
76
|
// holds (authed token → RLS returns the row if the user is a member; anon → empty).
|
|
77
|
-
// THROWS on a
|
|
78
|
-
//
|
|
79
|
-
//
|
|
77
|
+
// THROWS on a non-OK response or a transport error. The thrown error carries `.authHard`
|
|
78
|
+
// so the caller can tell the two apart (Contract C-CODE-3 invariant 2, hardened 2026-07-05):
|
|
79
|
+
// - 401/403 (authHard === true) → auth is DEFINITIVELY bad (dead/expired token). The
|
|
80
|
+
// caller REFUSES: a stale access token must never fail OPEN into serving, or the gate
|
|
81
|
+
// is bypassed in the common case (stored tokens expire ~1h). The caller refreshes the
|
|
82
|
+
// token BEFORE the gate, so a 401 here means the refresh also failed → genuinely unauthed.
|
|
83
|
+
// - 5xx / genuine fetch() reject (authHard === false) → availability failure. The caller
|
|
84
|
+
// fails OPEN (never strand a user on a flaky network / a Supabase blip).
|
|
85
|
+
// A clean HTTP response that simply yields no row is NOT a throw — it returns
|
|
86
|
+
// { ownerId: null } and the caller refuses (unknown room).
|
|
80
87
|
export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
|
|
81
88
|
const headers = { apikey: anonKey }
|
|
82
89
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
@@ -84,7 +91,12 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
|
|
|
84
91
|
`${supabaseUrl}/rest/v1/code_sessions?select=owner_id,serve_grant_uid,name&code=eq.${encodeURIComponent(room)}`,
|
|
85
92
|
{ headers },
|
|
86
93
|
)
|
|
87
|
-
if (!r.ok)
|
|
94
|
+
if (!r.ok) {
|
|
95
|
+
const err = new Error(`code_sessions fetch ${r.status}`)
|
|
96
|
+
err.status = r.status
|
|
97
|
+
err.authHard = r.status === 401 || r.status === 403
|
|
98
|
+
throw err
|
|
99
|
+
}
|
|
88
100
|
const rows = await r.json()
|
|
89
101
|
const row = Array.isArray(rows) ? rows[0] : null
|
|
90
102
|
return {
|
|
@@ -94,6 +106,36 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
|
|
|
94
106
|
}
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
// gateFailAction — pure classifier for a THROWN fetchServeRow error at the boot gate.
|
|
110
|
+
// 'refuse' — auth is definitively bad (401/403 after a token refresh); do NOT fail open.
|
|
111
|
+
// 'fail-open' — an availability error (5xx, offline, transport reject); serve rather than
|
|
112
|
+
// strand a user on a flaky fetch. This is the ONLY fail-open path.
|
|
113
|
+
export function gateFailAction (err) {
|
|
114
|
+
return err?.authHard ? 'refuse' : 'fail-open'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── supervisor grant-reclaim cooldown (DEFECT 2, 2026-07-05) ──────────────────
|
|
118
|
+
// A grantee child that yields to the owner's returning bridge exits with this SENTINEL
|
|
119
|
+
// code (distinct from a clean 0 / a crash 1). The supervisor reads the exit code and puts
|
|
120
|
+
// the room on a cooldown so it does NOT thrash-respawn a child that will only yield again —
|
|
121
|
+
// the feature's INTENDED coexistence state (owner serves, grantee stands by) must not churn.
|
|
122
|
+
// A revoke-yield keeps exit 0 (discovery drops a revoked room anyway); owner-owned rooms
|
|
123
|
+
// never yield, so they never cool down.
|
|
124
|
+
export const GRANT_RECLAIM_EXIT = 75
|
|
125
|
+
|
|
126
|
+
// shouldCooldownOnExit — did this child exit BECAUSE the owner reclaimed the room (75)?
|
|
127
|
+
// Only that exit arms the cooldown; a clean stop (0), a crash (1), or a signal (null) do not.
|
|
128
|
+
export function shouldCooldownOnExit (code) {
|
|
129
|
+
return code === GRANT_RECLAIM_EXIT
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// roomInCooldown — is a room still inside its grant-reclaim cooldown window? The supervisor
|
|
133
|
+
// discovery tick skips re-serving while this is true. `until` is a wall-clock ms deadline
|
|
134
|
+
// (Date.now() + window); a null/expired deadline means serve again (owner may have gone).
|
|
135
|
+
export function roomInCooldown (until, now) {
|
|
136
|
+
return !!(until && now < until)
|
|
137
|
+
}
|
|
138
|
+
|
|
97
139
|
// refusalMessage — the human-facing stderr line when a bridge refuses to serve.
|
|
98
140
|
export function refusalMessage ({ room, name, reason }) {
|
|
99
141
|
const label = name ? `"${name}"` : '(unnamed)'
|