thinkpool-pair 0.7.157 → 0.7.158
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/bridge.mjs +88 -1
- package/package.json +2 -1
- package/serve-consent.mjs +95 -0
package/bridge.mjs
CHANGED
|
@@ -37,6 +37,7 @@ 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
41
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
41
42
|
import { z } from 'zod'
|
|
42
43
|
import { startClaudeSession } from './claude-session.mjs'
|
|
@@ -569,7 +570,49 @@ async function refreshOwnerPlan() {
|
|
|
569
570
|
if (rows?.[0]?.plan) ownerPlan = rows[0].plan === 'plus' ? 'plus' : 'free'
|
|
570
571
|
} catch { /* DNS-flaky / offline — keep the safe default; the next token refresh retries */ }
|
|
571
572
|
}
|
|
572
|
-
|
|
573
|
+
// ── Room-serve owner consent (Contract C-CODE-3) ──────────────────────────────
|
|
574
|
+
// A room is served by its OWNER's bridge; a non-owner may serve only with the
|
|
575
|
+
// owner's explicit grant (code_sessions.serve_grant_uid). Enforced HERE — before the
|
|
576
|
+
// realtime channel is even built — so a refused bridge never dials the room. Owner
|
|
577
|
+
// reclaim (a grantee yields when the owner's bridge reappears) + grant revocation are
|
|
578
|
+
// wired below on the EXISTING room channel / REST row — no new topic
|
|
579
|
+
// (feedback_no_duplicate_realtime_channel, H41). LIMITATION: enforcement is in-bridge;
|
|
580
|
+
// an OLDER bridge bypasses it (no wire-level lock). Spec: docs/specs/2026-07-04-room-serve-owner-consent.md.
|
|
581
|
+
let roomOwnerId = null // code_sessions.owner_id, learned at the gate
|
|
582
|
+
let myServeUid = null // this bridge's authed uid (null = anon)
|
|
583
|
+
let iServeAsGrantee = false // serving by grant, not as owner → the only bridge that yields
|
|
584
|
+
let yielding = false // set once when standing down, so reclaim + revoke don't double-fire
|
|
585
|
+
{
|
|
586
|
+
// Standalone room mode ran anon unless TP_ACCESS_TOKEN was in env; load the stored
|
|
587
|
+
// login so `npx thinkpool-pair <CODE>` authenticates AS the signed-in user (the
|
|
588
|
+
// identity the consent check needs). Account children already carry the env token.
|
|
589
|
+
if (!codeAuthToken) {
|
|
590
|
+
try { const { loadAuth } = await import('./auth-store.mjs'); codeAuthToken = loadAuth()?.access_token || null } catch { /* no store — stays anon */ }
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
if (codeAuthToken) {
|
|
594
|
+
const { data } = await supabase.auth.getUser(codeAuthToken)
|
|
595
|
+
myServeUid = data?.user?.id || null
|
|
596
|
+
}
|
|
597
|
+
const { ownerId, grantUid, name } = await fetchServeRow({ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, room })
|
|
598
|
+
roomOwnerId = ownerId
|
|
599
|
+
const decision = serveDecision({ ownerId, grantUid, myUid: myServeUid })
|
|
600
|
+
if (!decision.serve) {
|
|
601
|
+
process.stderr.write(refusalMessage({ room, name, reason: decision.reason }))
|
|
602
|
+
// exit 0 — an intentional stand-down, not a crash, so a supervisor does not
|
|
603
|
+
// thrash-restart it (matches the machine-lock stand-down at the top of boot).
|
|
604
|
+
process.exit(0)
|
|
605
|
+
}
|
|
606
|
+
iServeAsGrantee = amGrantee({ ownerId, grantUid, myUid: myServeUid })
|
|
607
|
+
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
|
+
} catch (e) {
|
|
609
|
+
// Fail OPEN on a network/transport error ONLY (spec: never strand a user on a
|
|
610
|
+
// flaky fetch). A clean "no row / not granted" is a decision above, not a throw.
|
|
611
|
+
process.stderr.write(`\n ◇ serve-consent check degraded (${e.message}); proceeding without it.\n`)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
refreshOwnerPlan() // fire-and-forget on boot (codeAuthToken now set from env OR the stored login)
|
|
573
616
|
|
|
574
617
|
// ── Pre-flight budget caps — spec docs/specs/2026-07-01-budget-caps.md (nexos steal #1).
|
|
575
618
|
// The bridge calls the durable ledger RPCs as the OWNER (codeAuthToken); is_session_member
|
|
@@ -800,6 +843,12 @@ const announce = () =>
|
|
|
800
843
|
// Additive top-level field; consumed by src/pages/code/room.jsx onAnnounce in
|
|
801
844
|
// a later lane. Older clients ignore it.
|
|
802
845
|
host,
|
|
846
|
+
// uid: the authed user id whose bridge is serving this room (owner or grantee).
|
|
847
|
+
// Additive field (older clients ignore it). A grantee bridge watches peer `bridge`
|
|
848
|
+
// announces for uid === owner_id to know the owner's bridge reclaimed the room and
|
|
849
|
+
// it must stand down (Contract C-CODE-3 invariant 3). No new topic — rides the
|
|
850
|
+
// existing announce (feedback_no_duplicate_realtime_channel).
|
|
851
|
+
uid: myServeUid || undefined,
|
|
803
852
|
// provider: the LLM endpoint this bridge drives Claude Code through —
|
|
804
853
|
// 'anthropic' (the default, the host's regular Claude login) or 'custom'
|
|
805
854
|
// (any Anthropic-compatible base url: GLM/Z.ai, OpenRouter, a proxy). Shown
|
|
@@ -841,6 +890,21 @@ const announce = () =>
|
|
|
841
890
|
],
|
|
842
891
|
})
|
|
843
892
|
|
|
893
|
+
// Owner reclaim (Contract C-CODE-3 invariant 3): a grantee-served bridge that sees
|
|
894
|
+
// the OWNER's bridge announce on this same room channel stands down — the owner's
|
|
895
|
+
// bridge always wins. Reuses the existing `bridge` announce (its additive `uid`) — no
|
|
896
|
+
// new topic (feedback_no_duplicate_realtime_channel). The owner's own bridge never
|
|
897
|
+
// yields (standDownOnPeerAnnounce returns false for it). Clean shutdown(0): shuttingDown
|
|
898
|
+
// suppresses the term-exit broadcast, so no structured terminal row is reaped
|
|
899
|
+
// (docs/specs/2026-07-04-structured-terminal-durability.md — a handover must not delete rows).
|
|
900
|
+
function onPeerBridgeAnnounce (payload) {
|
|
901
|
+
if (yielding) return
|
|
902
|
+
if (!standDownOnPeerAnnounce({ myUid: myServeUid, ownerId: roomOwnerId, peerUid: payload?.uid })) return
|
|
903
|
+
yielding = true
|
|
904
|
+
process.stderr.write(`\n ◆ owner's bridge reconnected — yielding room ${room}.\n`)
|
|
905
|
+
shutdown(0)
|
|
906
|
+
}
|
|
907
|
+
|
|
844
908
|
// ── Thinkpool Ensemble — cross-ROOM reach (Tier 1: same-machine pair peek) ────
|
|
845
909
|
// The in-room Ensemble (read_terminal over sibling terminals) expanded ACROSS the
|
|
846
910
|
// account's OTHER rooms. The account supervisor (account.mjs) is the router: this
|
|
@@ -2478,6 +2542,9 @@ channel
|
|
|
2478
2542
|
announce()
|
|
2479
2543
|
})
|
|
2480
2544
|
.on('broadcast', { event: 'who' }, announce)
|
|
2545
|
+
// Peer bridge announces (owner reclaim) — a grantee yields when the owner's bridge
|
|
2546
|
+
// reappears. broadcast self:false, so this only fires for OTHER bridges on the topic.
|
|
2547
|
+
.on('broadcast', { event: 'bridge' }, ({ payload }) => onPeerBridgeAnnounce(payload))
|
|
2481
2548
|
.subscribe(status => {
|
|
2482
2549
|
if (status === 'SUBSCRIBED') {
|
|
2483
2550
|
realtimeHealthy = true; brokenSince = 0
|
|
@@ -2844,6 +2911,26 @@ setInterval(() => {
|
|
|
2844
2911
|
}
|
|
2845
2912
|
}, 15000).unref()
|
|
2846
2913
|
|
|
2914
|
+
// Grant revocation mid-serve (Contract C-CODE-3 invariant 3): a grantee re-checks the
|
|
2915
|
+
// row on a slow cadence — a cheap poll on the EXISTING REST endpoint, no new realtime
|
|
2916
|
+
// subscription (the owner-reclaim announce path above is the fast one). If the grant no
|
|
2917
|
+
// longer names this bridge (cleared or reassigned), stand down. Owner bridges never poll
|
|
2918
|
+
// (iServeAsGrantee is false for them). A flaky fetch never yields — next tick re-checks.
|
|
2919
|
+
if (iServeAsGrantee) {
|
|
2920
|
+
const RECHECK_MS = Math.max(15000, parseInt(process.env.TP_SERVE_RECHECK_MS, 10) || 30000)
|
|
2921
|
+
setInterval(async () => {
|
|
2922
|
+
if (yielding) return
|
|
2923
|
+
try {
|
|
2924
|
+
const { grantUid } = await fetchServeRow({ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, room })
|
|
2925
|
+
if (standDownOnGrantChange({ myUid: myServeUid, ownerId: roomOwnerId, grantUid })) {
|
|
2926
|
+
yielding = true
|
|
2927
|
+
process.stderr.write(`\n ◆ serve grant revoked — yielding room ${room}.\n`)
|
|
2928
|
+
shutdown(0)
|
|
2929
|
+
}
|
|
2930
|
+
} catch { /* transient — never yield on a flaky fetch; next tick re-checks */ }
|
|
2931
|
+
}, RECHECK_MS).unref()
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2847
2934
|
// ── auto-update (OS-service tier only) ─────────────────────────────────
|
|
2848
2935
|
// launchd (KeepAlive) and systemd (Restart=always) re-run
|
|
2849
2936
|
// `npx -y thinkpool-pair@latest` on every (re)start, so a clean exit here makes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.158",
|
|
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": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"flow-redispatch.mjs",
|
|
32
32
|
"flow-skill-registry.mjs",
|
|
33
33
|
"serve-dir.mjs",
|
|
34
|
+
"serve-consent.mjs",
|
|
34
35
|
"service.mjs",
|
|
35
36
|
"presence.mjs",
|
|
36
37
|
"account.mjs",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Room-serve owner consent — decision core (pure) + row fetch.
|
|
2
|
+
//
|
|
3
|
+
// Spec: docs/specs/2026-07-04-room-serve-owner-consent.md (Contract C-CODE-3).
|
|
4
|
+
// A room is served by its OWNER's bridge; a non-owner bridge may serve only with
|
|
5
|
+
// the owner's explicit grant (`code_sessions.serve_grant_uid`); the owner's bridge
|
|
6
|
+
// always wins reclaim, the grantee stands down.
|
|
7
|
+
//
|
|
8
|
+
// This module holds ONLY pure, order-independent decisions (unit-tested in
|
|
9
|
+
// bridge/test-serve-consent.mjs) plus a single network helper. The wiring —
|
|
10
|
+
// where the gate runs, when a grantee yields — lives in bridge.mjs. Keeping the
|
|
11
|
+
// logic pure is the feedback_code_seed_order_independent discipline: no decision
|
|
12
|
+
// depends on live channel/session state, so every branch is testable off-wire.
|
|
13
|
+
//
|
|
14
|
+
// LIMITATION (documented, spec Contract C-CODE-3 invariant 1): enforcement is
|
|
15
|
+
// in-bridge. A bridge OLDER than this version never reads the column and bypasses
|
|
16
|
+
// the check — there is no server-side wire-level lock. The grant is a coordination
|
|
17
|
+
// signal honored by compliant bridges, not a serve token. Out of scope here.
|
|
18
|
+
|
|
19
|
+
// serveDecision — may THIS bridge serve THIS room?
|
|
20
|
+
// ownerId : code_sessions.owner_id (uuid | null)
|
|
21
|
+
// grantUid : code_sessions.serve_grant_uid (uuid | null) — the one granted non-owner
|
|
22
|
+
// myUid : this bridge's authed user id (uuid | null) — null when the bridge is anon
|
|
23
|
+
// Returns { serve, reason }:
|
|
24
|
+
// reason 'owner' — myUid owns the room (always serves)
|
|
25
|
+
// reason 'grant' — myUid is the granted non-owner
|
|
26
|
+
// reason 'no-identity' — bridge has no authed uid, so it can be neither owner nor
|
|
27
|
+
// grantee → refuse (the new model has no anon serve path)
|
|
28
|
+
// reason 'not-granted' — authed, but neither owner nor the current grantee → refuse
|
|
29
|
+
// reason 'unknown-room'— no owner_id resolved (row missing / RLS-hidden) → refuse
|
|
30
|
+
export function serveDecision ({ ownerId, grantUid, myUid }) {
|
|
31
|
+
if (!myUid) return { serve: false, reason: 'no-identity' }
|
|
32
|
+
if (!ownerId) return { serve: false, reason: 'unknown-room' }
|
|
33
|
+
if (myUid === ownerId) return { serve: true, reason: 'owner' }
|
|
34
|
+
if (grantUid && myUid === grantUid) return { serve: true, reason: 'grant' }
|
|
35
|
+
return { serve: false, reason: 'not-granted' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// amGrantee — this bridge serves by grant (not as owner). Grantees are the only
|
|
39
|
+
// bridges that ever stand down; the owner's bridge never yields.
|
|
40
|
+
export function amGrantee ({ ownerId, grantUid, myUid }) {
|
|
41
|
+
return !!(myUid && myUid !== ownerId && grantUid && myUid === grantUid)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// standDownOnPeerAnnounce — a grantee-served bridge saw ANOTHER bridge announce on
|
|
45
|
+
// the room channel. Yield iff that peer is the OWNER's bridge (peerUid === ownerId)
|
|
46
|
+
// and I am not the owner. The owner's own bridge (myUid === ownerId) never yields —
|
|
47
|
+
// serveDecision already refused any second non-owner, so a peer that isn't the owner
|
|
48
|
+
// is either a stale duplicate of me or nothing to cede to.
|
|
49
|
+
export function standDownOnPeerAnnounce ({ myUid, ownerId, peerUid }) {
|
|
50
|
+
if (!myUid || !ownerId) return false
|
|
51
|
+
if (myUid === ownerId) return false // owner never stands down
|
|
52
|
+
return !!peerUid && peerUid === ownerId // owner's bridge reclaimed → yield
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// standDownOnGrantChange — a grantee re-checked the row and the grant no longer
|
|
56
|
+
// names it (revoked, or reassigned to a third party). Yield. The owner never yields
|
|
57
|
+
// on a grant change (the grant does not govern the owner).
|
|
58
|
+
export function standDownOnGrantChange ({ myUid, ownerId, grantUid }) {
|
|
59
|
+
if (!myUid || myUid === ownerId) return false
|
|
60
|
+
return grantUid !== myUid
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// fetchServeRow — read the room's consent columns with whatever identity the bridge
|
|
64
|
+
// holds (authed token → RLS returns the row if the user is a member; anon → empty).
|
|
65
|
+
// THROWS on a network/transport error so the caller can fail OPEN (never strand a
|
|
66
|
+
// user on a flaky fetch). A clean HTTP response that simply yields no row is NOT a
|
|
67
|
+
// throw — it returns { ownerId: null } and the caller refuses (unknown room).
|
|
68
|
+
export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
|
|
69
|
+
const headers = { apikey: anonKey }
|
|
70
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
71
|
+
const r = await fetch(
|
|
72
|
+
`${supabaseUrl}/rest/v1/code_sessions?select=owner_id,serve_grant_uid,name&code=eq.${encodeURIComponent(room)}`,
|
|
73
|
+
{ headers },
|
|
74
|
+
)
|
|
75
|
+
if (!r.ok) throw new Error(`code_sessions fetch ${r.status}`)
|
|
76
|
+
const rows = await r.json()
|
|
77
|
+
const row = Array.isArray(rows) ? rows[0] : null
|
|
78
|
+
return {
|
|
79
|
+
ownerId: row?.owner_id ?? null,
|
|
80
|
+
grantUid: row?.serve_grant_uid ?? null,
|
|
81
|
+
name: row?.name ?? null,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// refusalMessage — the human-facing stderr line when a bridge refuses to serve.
|
|
86
|
+
export function refusalMessage ({ room, name, reason }) {
|
|
87
|
+
const label = name ? `"${name}"` : '(unnamed)'
|
|
88
|
+
if (reason === 'no-identity') {
|
|
89
|
+
return `\n ◇ Refusing to serve room ${room} — this bridge is not signed in.\n` +
|
|
90
|
+
` A room is served by its owner's bridge. Sign in first: npx thinkpool-pair login\n`
|
|
91
|
+
}
|
|
92
|
+
return `\n ◇ Refusing to serve room ${room} ${label} — it is owned by another person; their bridge serves it.\n` +
|
|
93
|
+
` Ask them to grant your machine hosting rights in the room (bridge status card),\n` +
|
|
94
|
+
` then re-run. The grant is the only way in — there is no override flag.\n`
|
|
95
|
+
}
|