thinkpool-pair 0.7.354 → 0.7.357

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.
@@ -0,0 +1,356 @@
1
+ /*
2
+ * Provider resilience policy primitives.
3
+ *
4
+ * This module never makes a network call or reads provider credentials. Phase 1
5
+ * adds an injected same-target controller, but transport remains owned by the
6
+ * Claude session and fallback remains unavailable.
7
+ */
8
+ import { isSecretFreeRoomPayload } from './runtime-contract.mjs'
9
+
10
+ export const RESILIENCE_VERSION = 1
11
+ export const MAX_NETWORK_ATTEMPTS = 3
12
+ export const MAX_SAME_TARGET_ATTEMPTS = 2
13
+ export const TRANSIENT_FAILURES = new Set(['timeout', 'network', 'rate_limit', 'overload', 'upstream_5xx'])
14
+ const RETRYABLE = [...TRANSIENT_FAILURES]
15
+ const TRACE_FIELDS = new Set(['traceId', 'turnRev', 'attempt', 'targetProviderId', 'targetProviderName', 'requestedModel', 'actualConfiguredModel', 'phase', 'outcome', 'failureClass', 'elapsedMs', 'retryAfterMs', 'fallbackFrom'])
16
+ const OUTCOMES = new Set(['started', 'retrying', 'failed', 'fallback', 'succeeded', 'circuit_open', 'cap_blocked'])
17
+ const TRACE_TEXT_FIELDS = new Set(['traceId', 'targetProviderId', 'targetProviderName', 'requestedModel', 'actualConfiguredModel', 'phase', 'failureClass', 'fallbackFrom'])
18
+ const TRACE_URL_OR_PATH = /(?:[a-z][a-z0-9+.-]*:\/\/|(?:^|[\s"'(])\/(?:Users|home|private|tmp)\/|[a-z]:\\|\\\\)/i
19
+
20
+ export const DEFAULT_RESILIENCE_POLICY = Object.freeze({
21
+ v: RESILIENCE_VERSION,
22
+ enabled: false,
23
+ timeoutMs: 90000,
24
+ maxAttempts: 2,
25
+ retryOn: RETRYABLE,
26
+ circuit: Object.freeze({ failureThreshold: 3, windowMs: 60000, cooldownMs: 30000 }),
27
+ fallback: Object.freeze([]),
28
+ })
29
+
30
+ const plainObject = (x) => !!x && typeof x === 'object' && !Array.isArray(x)
31
+ const positiveInt = (x) => Number.isInteger(x) && x > 0
32
+ const text = (x) => typeof x === 'string' && x.trim() ? x.trim() : null
33
+ const safeTraceText = (value) => {
34
+ const normalized = text(value)
35
+ if (!normalized || normalized.length > 160 || TRACE_URL_OR_PATH.test(normalized)) return null
36
+ return isSecretFreeRoomPayload({ value: normalized }, 512) ? normalized : null
37
+ }
38
+
39
+ /**
40
+ * Validate and canonicalize the non-secret provider policy. Missing policy is
41
+ * the explicit observe-only default. Extra fields are discarded rather than
42
+ * copied into a room-facing projection.
43
+ */
44
+ export function validateResiliencePolicy(input, { providers = [], primaryProviderId = null } = {}) {
45
+ if (input == null) return { ok: true, value: { ...DEFAULT_RESILIENCE_POLICY, retryOn: [...RETRYABLE], circuit: { ...DEFAULT_RESILIENCE_POLICY.circuit }, fallback: [] } }
46
+ if (!plainObject(input) || input.v !== RESILIENCE_VERSION) return { ok: false, error: 'unsupported resilience policy version' }
47
+ if (typeof input.enabled !== 'boolean') return { ok: false, error: 'enabled must be boolean' }
48
+ if (!Number.isInteger(input.timeoutMs) || input.timeoutMs < 30000 || input.timeoutMs > 120000) return { ok: false, error: 'timeoutMs must be 30000..120000' }
49
+ if (!positiveInt(input.maxAttempts)) return { ok: false, error: 'maxAttempts must be a positive integer' }
50
+ if (!Array.isArray(input.retryOn) || input.retryOn.some((x) => !TRANSIENT_FAILURES.has(x))) return { ok: false, error: 'retryOn contains an unsupported failure class' }
51
+ if (!plainObject(input.circuit) || !positiveInt(input.circuit.failureThreshold) || !positiveInt(input.circuit.windowMs) || !positiveInt(input.circuit.cooldownMs)) return { ok: false, error: 'invalid circuit policy' }
52
+ if (!Array.isArray(input.fallback)) return { ok: false, error: 'fallback must be an array' }
53
+ const fallback = []
54
+ for (const candidate of input.fallback) {
55
+ if (!plainObject(candidate) || !text(candidate.providerId) || !text(candidate.model) || candidate.allow !== true) return { ok: false, error: 'invalid fallback target' }
56
+ // Phase 0 stores only the conservative v1 declarations. A future UI may add
57
+ // explicit elevated consent, but it must not be smuggled in via this shape.
58
+ if (candidate.privacyClass !== 'same' || !['same', 'same-or-lower'].includes(candidate.costClass)) return { ok: false, error: 'fallback privacy/cost class is not eligible' }
59
+ if (primaryProviderId && candidate.providerId === primaryProviderId) return { ok: false, error: 'fallback cannot target itself' }
60
+ if (providers.length) {
61
+ const row = providers.find((provider) => provider?.id === candidate.providerId)
62
+ if (!row || row.id === 'anthropic' || row.model !== candidate.model) return { ok: false, error: 'fallback must name a registered exact provider/model target' }
63
+ }
64
+ fallback.push({ providerId: text(candidate.providerId), model: text(candidate.model), allow: true, privacyClass: candidate.privacyClass, costClass: candidate.costClass })
65
+ }
66
+ return {
67
+ ok: true,
68
+ value: {
69
+ v: RESILIENCE_VERSION,
70
+ enabled: input.enabled,
71
+ timeoutMs: input.timeoutMs,
72
+ // A malformed high value may never increase live network work.
73
+ maxAttempts: Math.min(input.maxAttempts, MAX_NETWORK_ATTEMPTS),
74
+ retryOn: [...new Set(input.retryOn)],
75
+ circuit: { failureThreshold: input.circuit.failureThreshold, windowMs: input.circuit.windowMs, cooldownMs: input.circuit.cooldownMs },
76
+ fallback,
77
+ },
78
+ }
79
+ }
80
+
81
+ /** Safe allowlist projection for local diagnostics / room-safe metadata. */
82
+ export function resilienceProjection(input) {
83
+ const validated = validateResiliencePolicy(input)
84
+ if (!validated.ok) return null
85
+ const p = validated.value
86
+ return { v: p.v, enabled: p.enabled, timeoutMs: p.timeoutMs, maxAttempts: p.maxAttempts, retryOn: [...p.retryOn], circuit: { ...p.circuit }, fallback: p.fallback.map((x) => ({ ...x })) }
87
+ }
88
+
89
+ /** Coarse classification only. It intentionally does not return raw errors. */
90
+ export function classifyProviderFailure(input = {}) {
91
+ const status = Number(input.status || input.statusCode || 0)
92
+ const code = String(input.code || '').toLowerCase()
93
+ const message = String(input.message || input.error?.message || '').toLowerCase()
94
+ if (input.aborted || /\babort(ed|error)?\b|cancelled|canceled/.test(`${code} ${message}`)) return 'user_abort'
95
+ if (input.permissionDenied || /permission denied|forbidden by policy|guardrail/.test(`${code} ${message}`)) return 'permission_denied'
96
+ if (status === 401 || status === 403 || /unauthori[sz]ed|invalid (api )?key|credential/.test(`${code} ${message}`)) return 'auth'
97
+ if (status === 429 || /rate.?limit/.test(`${code} ${message}`)) return 'rate_limit'
98
+ if (status === 529 || /overload|overloaded/.test(`${code} ${message}`)) return 'overload'
99
+ if (status >= 500 && status <= 599) return 'upstream_5xx'
100
+ if (/timeout|timed out|deadline exceeded|etimedout/.test(`${code} ${message}`)) return 'timeout'
101
+ if (/econnreset|enotfound|eai_again|socket|connection (closed|reset)|fetch failed|network error/.test(`${code} ${message}`)) return 'network'
102
+ if (status === 400 || /invalid model|unknown model/.test(`${code} ${message}`)) return 'invalid_model'
103
+ if (/context length|request too large|payload too large|too many tokens/.test(`${code} ${message}`)) return 'request_too_large'
104
+ if (status >= 400 && status < 500) return 'invalid_request'
105
+ return 'unknown'
106
+ }
107
+
108
+ export const isReplaySafe = ({ outputVisible = false, toolOutputVisible = false, aborted = false, permissionDenied = false, nonIdempotent = false } = {}) => !(outputVisible || toolOutputVisible || aborted || permissionDenied || nonIdempotent)
109
+
110
+ /** Resolve only registered custom Claude targets with their exact configured model. */
111
+ export function resolveAttemptTarget({ runtime = 'claude', providers = [], providerId, model } = {}) {
112
+ if (runtime !== 'claude' || !text(providerId) || !text(model)) return null
113
+ const provider = providers.find((p) => p && p.id === providerId && p.id !== 'anthropic' && p.model === model)
114
+ if (!provider) return null
115
+ return { providerId: provider.id, targetProviderName: text(provider.name) || provider.id, actualConfiguredModel: provider.model }
116
+ }
117
+
118
+ /** All eight Phase-2 eligibility gates in a pure, fail-closed form. */
119
+ export function assessFallbackEligibility({ runtime = 'claude', providers = [], primary, candidate, policy, outputVisible = false, toolOutputVisible = false, aborted = false, permissionDenied = false, capAllowed = true, circuit = 'closed' } = {}) {
120
+ if (!isReplaySafe({ outputVisible, toolOutputVisible, aborted, permissionDenied })) return { ok: false, reason: 'replay_unsafe' }
121
+ if (circuit !== 'closed') return { ok: false, reason: 'circuit_open' }
122
+ if (!capAllowed) return { ok: false, reason: 'cap_blocked' }
123
+ const checked = validateResiliencePolicy(policy)
124
+ if (!checked.ok || !checked.value.enabled || runtime !== 'claude') return { ok: false, reason: 'policy_disabled' }
125
+ const target = resolveAttemptTarget({ runtime, providers, providerId: candidate?.providerId, model: candidate?.model })
126
+ if (!target) return { ok: false, reason: 'unknown_target' }
127
+ const configured = checked.value.fallback.find((x) => x.providerId === target.providerId && x.model === target.actualConfiguredModel)
128
+ if (!configured?.allow) return { ok: false, reason: 'not_explicitly_allowed' }
129
+ if (configured.privacyClass !== 'same' || !['same', 'same-or-lower'].includes(configured.costClass)) return { ok: false, reason: 'boundary_not_consented' }
130
+ if (primary?.providerId === target.providerId && primary?.actualConfiguredModel === target.actualConfiguredModel) return { ok: false, reason: 'same_target' }
131
+ return { ok: true, target, fallback: configured }
132
+ }
133
+
134
+ /** Local-only total-attempt accounting. It is intentionally not a retry engine. */
135
+ export function createAttemptLedger({ maxAttempts = MAX_NETWORK_ATTEMPTS, traceId = 'local', turnRev = null, onRecord = null } = {}) {
136
+ const ceiling = Math.max(1, Math.min(Number.isInteger(maxAttempts) ? maxAttempts : 1, MAX_NETWORK_ATTEMPTS))
137
+ const records = []
138
+ const record = (entry = {}) => {
139
+ if (records.length >= ceiling) return null
140
+ const next = safeTraceRecord({ traceId, turnRev, attempt: Math.min(records.length + 1, ceiling), ...entry })
141
+ records.push(next)
142
+ try { onRecord?.(next) } catch { /* observation must never affect the session */ }
143
+ return next
144
+ }
145
+ return {
146
+ get ceiling() { return ceiling },
147
+ get attempts() { return records.length },
148
+ get records() { return records.map((x) => ({ ...x })) },
149
+ canSubmit() { return records.length < ceiling },
150
+ record,
151
+ }
152
+ }
153
+
154
+ /** Memory-only circuit breaker keyed by bridge/provider/exact-model. */
155
+ export function createMemoryCircuit({ now = () => Date.now() } = {}) {
156
+ const states = new Map()
157
+ const keyOf = ({ bridgeHostId, providerId, model } = {}) => `${bridgeHostId || ''}\u0000${providerId || ''}\u0000${model || ''}`
158
+ const stateFor = (target) => states.get(keyOf(target)) || { failures: [], openUntil: 0, probing: false }
159
+ const put = (target, state) => states.set(keyOf(target), state)
160
+ return {
161
+ preflight(target) {
162
+ const state = stateFor(target); const t = now()
163
+ if (state.openUntil > t) return { ok: false, state: 'open' }
164
+ if (state.openUntil && state.openUntil <= t) {
165
+ if (state.probing) return { ok: false, state: 'half_open_busy' }
166
+ state.probing = true; put(target, state); return { ok: true, state: 'half_open' }
167
+ }
168
+ return { ok: true, state: 'closed' }
169
+ },
170
+ success(target) { states.delete(keyOf(target)) },
171
+ failure(target, policy, failureClass) {
172
+ if (!TRANSIENT_FAILURES.has(failureClass)) return { state: this.preflight(target).state, counted: false }
173
+ const state = stateFor(target); const t = now(); const circuit = policy?.circuit || DEFAULT_RESILIENCE_POLICY.circuit
174
+ if (state.probing) { state.probing = false; state.failures = []; state.openUntil = t + circuit.cooldownMs; put(target, state); return { state: 'open', counted: true } }
175
+ state.failures = state.failures.filter((at) => at > t - circuit.windowMs)
176
+ state.failures.push(t)
177
+ if (state.failures.length >= circuit.failureThreshold) state.openUntil = t + circuit.cooldownMs
178
+ put(target, state)
179
+ return { state: state.openUntil > t ? 'open' : 'closed', counted: true }
180
+ },
181
+ // Test/diagnostic-only metadata. Never returns credentials, URLs, or prompts.
182
+ snapshot(target) { const s = stateFor(target); return { failures: s.failures.length, openUntil: s.openUntil, probing: s.probing } },
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Apply the only authoritative provider-work cap currently available at the
188
+ * bridge boundary. Ordinary/uncapped lanes remain admissible, but the result
189
+ * names that absence explicitly so the host can record `cap_not_configured`
190
+ * rather than pretending an approval occurred.
191
+ */
192
+ export function providerResilienceCapAdmission(budget) {
193
+ if (!budget || budget.capTokens == null) {
194
+ return { allowed: true, configured: false, reason: 'cap_not_configured' }
195
+ }
196
+ const capTokens = Number(budget.capTokens)
197
+ const spentTokens = Number(budget.spentTokens)
198
+ const allowed = Number.isFinite(capTokens)
199
+ && capTokens >= 0
200
+ && Number.isFinite(spentTokens)
201
+ && spentTokens >= 0
202
+ && budget.killed !== true
203
+ && spentTokens < capTokens
204
+ return { allowed, configured: true, reason: allowed ? 'within_cap' : 'cap_blocked' }
205
+ }
206
+
207
+ /**
208
+ * Pure, injected Phase-1 controller for a single exact custom Claude target.
209
+ *
210
+ * The bridge/session integration owns transport and invokes this controller at
211
+ * turn start, before each submission, after visible output, and at terminal
212
+ * success/failure. It intentionally has no fallback path and cannot resolve a
213
+ * built-in Anthropic target. A caller must inject both the authoritative cap
214
+ * admission gate and the bridge-scoped memory circuit; missing or broken gates
215
+ * deny the submission rather than guessing that it is safe.
216
+ */
217
+ export function createSameTargetResilienceController({
218
+ runtime = 'claude', providers = [], providerId, model, requestedModel = model,
219
+ policy, bridgeHostId = null, circuit, capGate, traceId = 'local', turnRev = null,
220
+ onRecord = null,
221
+ } = {}) {
222
+ let checked
223
+ try { checked = validateResiliencePolicy(policy, { providers, primaryProviderId: providerId }) } catch { checked = { ok: false } }
224
+ let target = null
225
+ try { target = resolveAttemptTarget({ runtime, providers, providerId, model }) } catch { /* malformed registry is not retryable */ }
226
+ const configured = checked?.ok && checked.value.enabled && target && runtime === 'claude'
227
+ const circuitReady = !!circuit && typeof circuit.preflight === 'function' && typeof circuit.failure === 'function' && typeof circuit.success === 'function'
228
+ const ceiling = configured ? Math.min(checked.value.maxAttempts, MAX_SAME_TARGET_ATTEMPTS) : 0
229
+ const targetKey = target && { bridgeHostId, providerId: target.providerId, model: target.actualConfiguredModel }
230
+ const base = target && {
231
+ traceId,
232
+ turnRev,
233
+ targetProviderId: target.providerId,
234
+ targetProviderName: target.targetProviderName,
235
+ requestedModel,
236
+ actualConfiguredModel: target.actualConfiguredModel,
237
+ phase: 'same_target',
238
+ }
239
+ let started = false
240
+ let submissions = 0
241
+ let awaitingOutcome = false
242
+ let retryEligible = false
243
+ let outputVisible = false
244
+ let toolOutputVisible = false
245
+ let aborted = false
246
+ let permissionDenied = false
247
+ let finished = false
248
+
249
+ const makeRecord = (outcome, extra = {}, attempt = submissions) => safeTraceRecord({
250
+ ...base,
251
+ attempt: Math.max(1, attempt),
252
+ outcome,
253
+ ...extra,
254
+ })
255
+ const emit = (outcome, extra) => {
256
+ const record = makeRecord(outcome, extra)
257
+ try { onRecord?.(record) } catch { /* trace observers are never control flow */ }
258
+ return record
259
+ }
260
+ const blocked = (outcome = 'failed', extra) => ({ admitted: false, record: emit(outcome, extra) })
261
+ const replaySafe = () => isReplaySafe({ outputVisible, toolOutputVisible, aborted, permissionDenied })
262
+
263
+ return {
264
+ /** Mark the controller as belonging to this turn; no provider work occurs here. */
265
+ start() { started = true },
266
+
267
+ /** Once any assistant/tool output is visible, automatic replay is permanently disabled. */
268
+ visibleOutput({ tool = false } = {}) {
269
+ outputVisible = true
270
+ if (tool) toolOutputVisible = true
271
+ retryEligible = false
272
+ },
273
+
274
+ /**
275
+ * Admit exactly one upcoming submission. The cap gate is awaited before
276
+ * transport, and any false/unknown/throwing result denies the attempt.
277
+ */
278
+ async preflight() {
279
+ if (!started) started = true
280
+ if (!configured || !circuitReady || finished || awaitingOutcome || submissions >= ceiling) return blocked()
281
+ if (submissions > 0 && (!retryEligible || !replaySafe())) return blocked()
282
+ const pending = makeRecord(submissions === 0 ? 'started' : 'retrying', {}, submissions + 1)
283
+ let admitted = false
284
+ try {
285
+ const result = await capGate?.(pending)
286
+ admitted = result === true || result?.allowed === true
287
+ } catch { admitted = false }
288
+ if (!admitted) return blocked('cap_blocked')
289
+ let circuitState
290
+ try { circuitState = circuit.preflight(targetKey) } catch { return blocked() }
291
+ if (!circuitState?.ok) return blocked('circuit_open')
292
+ submissions += 1
293
+ awaitingOutcome = true
294
+ retryEligible = false
295
+ return { admitted: true, record: emit(submissions === 1 ? 'started' : 'retrying') }
296
+ },
297
+
298
+ /** Classify a raw transport failure locally and expose only a coarse trace. */
299
+ failure(input = {}) {
300
+ if (!awaitingOutcome || finished) return null
301
+ awaitingOutcome = false
302
+ const failureClass = classifyProviderFailure(input)
303
+ if (failureClass === 'user_abort') aborted = true
304
+ if (failureClass === 'permission_denied') permissionDenied = true
305
+ let circuitUsable = true
306
+ if (TRANSIENT_FAILURES.has(failureClass)) {
307
+ try {
308
+ const state = circuit.failure(targetKey, checked.value, failureClass)
309
+ if (state?.state === 'open') circuitUsable = false
310
+ } catch { circuitUsable = false }
311
+ }
312
+ retryEligible = circuitUsable && checked.value.retryOn.includes(failureClass) && replaySafe() && submissions < ceiling
313
+ return emit('failed', { failureClass })
314
+ },
315
+
316
+ /** Close/reset the injected circuit after a completed provider response. */
317
+ success() {
318
+ if (!awaitingOutcome || finished) return null
319
+ awaitingOutcome = false
320
+ finished = true
321
+ try { circuit.success(targetKey) } catch { /* a completed response is never replayed */ }
322
+ return emit('succeeded')
323
+ },
324
+ }
325
+ }
326
+
327
+ export function safeTraceRecord(input = {}) {
328
+ const out = {}
329
+ for (const key of TRACE_FIELDS) {
330
+ if (input[key] === undefined || input[key] === null) continue
331
+ if (TRACE_TEXT_FIELDS.has(key)) {
332
+ const value = safeTraceText(input[key])
333
+ if (value) out[key] = value
334
+ } else if (key === 'turnRev') {
335
+ if (Number.isSafeInteger(input[key]) && input[key] >= 0) out[key] = input[key]
336
+ } else out[key] = input[key]
337
+ }
338
+ out.attempt = Math.max(1, Math.min(Number(out.attempt) || 1, MAX_NETWORK_ATTEMPTS))
339
+ if (!OUTCOMES.has(out.outcome)) out.outcome = 'failed'
340
+ if (out.failureClass && !/^[a-z0-9_]+$/.test(String(out.failureClass))) delete out.failureClass
341
+ for (const key of ['elapsedMs', 'retryAfterMs']) if (out[key] != null) out[key] = Math.max(0, Math.floor(Number(out[key]) || 0))
342
+ return out
343
+ }
344
+
345
+ /** A room-safe line assembled exclusively from the allowlisted coarse record. */
346
+ export function formatResilienceTrace(input) {
347
+ const r = safeTraceRecord(input)
348
+ const target = [r.targetProviderName || r.targetProviderId, r.actualConfiguredModel].filter(Boolean).join(' / ') || 'configured provider'
349
+ const failure = r.failureClass ? ` (${r.failureClass.replaceAll('_', ' ')})` : ''
350
+ if (r.outcome === 'retrying') return `${target} unavailable${failure} — retrying once before output (attempt ${r.attempt} of ${MAX_SAME_TARGET_ATTEMPTS}).`
351
+ if (r.outcome === 'circuit_open') return `${target} is temporarily unavailable; its local circuit is open.`
352
+ if (r.outcome === 'cap_blocked') return `Additional provider work was blocked by the applicable cap.`
353
+ if (r.outcome === 'succeeded') return `${target} completed.`
354
+ if (r.outcome === 'fallback') return `Configured fallback selected: ${target}.`
355
+ return `${target} failed${failure}.`
356
+ }
package/providers.mjs CHANGED
@@ -43,6 +43,12 @@ import fs from 'node:fs'
43
43
  import path from 'node:path'
44
44
  import crypto from 'node:crypto'
45
45
  import { claudeish } from './event-id.mjs'
46
+ import {
47
+ DEFAULT_RESILIENCE_POLICY,
48
+ resilienceProjection,
49
+ resolveAttemptTarget,
50
+ validateResiliencePolicy,
51
+ } from './provider-resilience.mjs'
46
52
 
47
53
  const DIR = path.join(os.homedir(), '.thinkpool-pair')
48
54
  const REG_FILE = path.join(DIR, 'providers.json')
@@ -54,7 +60,7 @@ export const BUILTIN_ID = 'anthropic'
54
60
  function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true, mode: 0o700 }) } catch { /* noop */ } }
55
61
 
56
62
  // ── registry load / save ───────────────────────────────────────────────
57
- /** @returns {Array<{id:string,name:string,baseUrl:string,model?:string,key:string,addedAt:number}>} */
63
+ /** @returns {Array<{id:string,name:string,baseUrl:string,model?:string,key:string,addedAt:number,resilience?:object}>} */
58
64
  export function loadProviders() {
59
65
  try {
60
66
  const arr = JSON.parse(fs.readFileSync(REG_FILE, 'utf8'))
@@ -244,6 +250,9 @@ export function addProviderModel({ id, model, name } = {}) {
244
250
  baseUrl: src.baseUrl, // copied host-side
245
251
  model: nextModel,
246
252
  key: src.key, // copied host-side — never re-sent by the client
253
+ // A cloned credential is not consent to retry it. Each row needs its own
254
+ // explicit Phase-1 opt-in, even when both rows share the same endpoint/key.
255
+ resilience: { ...DEFAULT_RESILIENCE_POLICY, retryOn: [...DEFAULT_RESILIENCE_POLICY.retryOn], circuit: { ...DEFAULT_RESILIENCE_POLICY.circuit }, fallback: [] },
247
256
  addedAt: Date.now(),
248
257
  })
249
258
  saveProviders(arr)
@@ -322,6 +331,55 @@ export function providerModel(id) {
322
331
  return loadProviders().find((x) => x.id === id)?.model || null
323
332
  }
324
333
 
334
+ /**
335
+ * Persist one custom provider row's Phase-1 same-target policy. This is a
336
+ * host-local primitive: account RPC/UI wiring deliberately lives elsewhere.
337
+ *
338
+ * `policy` may be a complete v1 policy, or a boolean for the conservative
339
+ * Phase-1 preset. Fallback declarations are rejected here: alternate targets
340
+ * require the later explicit-consent flow, so this API can never turn one on.
341
+ */
342
+ export function setProviderResiliencePolicy(id, policy) {
343
+ if (!id || id === BUILTIN_ID) return { ok: false, error: 'the built-in Anthropic provider cannot use resilience policy' }
344
+ const arr = loadProviders()
345
+ const provider = arr.find((p) => p.id === id)
346
+ if (!provider) return { ok: false, error: 'no such provider' }
347
+
348
+ const input = typeof policy === 'boolean'
349
+ ? { ...DEFAULT_RESILIENCE_POLICY, enabled: policy, retryOn: [...DEFAULT_RESILIENCE_POLICY.retryOn], circuit: { ...DEFAULT_RESILIENCE_POLICY.circuit }, fallback: [] }
350
+ : policy
351
+ const checked = validateResiliencePolicy(input, { providers: arr, primaryProviderId: id })
352
+ if (!checked.ok) return { ok: false, error: checked.error }
353
+ if (checked.value.fallback.length) return { ok: false, error: 'fallback is not available in Phase 1' }
354
+ // An enabled controller must have a configured exact model for its safe
355
+ // target record. A disabled policy remains valid for legacy/no-model rows.
356
+ if (checked.value.enabled && !resolveAttemptTarget({ providers: arr, providerId: id, model: provider.model })) {
357
+ return { ok: false, error: 'an enabled resilience policy requires an exact configured model' }
358
+ }
359
+
360
+ const next = arr.map((p) => p.id === id ? { ...p, resilience: checked.value } : p)
361
+ saveProviders(next)
362
+ return { ok: true, resilience: resilienceProjection(checked.value) }
363
+ }
364
+
365
+ /**
366
+ * Resolve a custom provider's persisted policy for bridge wiring without ever
367
+ * returning host credentials or endpoint data. Missing policy is safely
368
+ * disabled; malformed stored policy, built-in, unknown, and model-less targets
369
+ * fail closed with null.
370
+ */
371
+ export function resolveProviderResiliencePolicy(id) {
372
+ if (!id || id === BUILTIN_ID) return null
373
+ const providers = loadProviders()
374
+ const provider = providers.find((p) => p.id === id)
375
+ if (!provider) return null
376
+ const checked = validateResiliencePolicy(provider.resilience, { providers, primaryProviderId: id })
377
+ if (!checked.ok) return null
378
+ const target = resolveAttemptTarget({ providers, providerId: id, model: provider.model })
379
+ if (!target) return null
380
+ return { policy: resilienceProjection(checked.value), target }
381
+ }
382
+
325
383
  // ── read-only projections (never expose the raw key) ────────────────────
326
384
  /** Masked list for the dashboard: keyHint = last 4 chars only. Built-in first. */
327
385
  export function listProviders() {
@@ -331,8 +389,9 @@ export function listProviders() {
331
389
  model: p.model || null,
332
390
  group: providerGroup(p),
333
391
  keyHint: keyHint(p.key),
392
+ resilience: resilienceProjection(p.resilience),
334
393
  }))
335
- return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, group: BUILTIN_ID, keyHint: null }, ...custom]
394
+ return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, group: BUILTIN_ID, keyHint: null, resilience: null }, ...custom]
336
395
  }
337
396
 
338
397
  /** Name-only projection for the announce/presence payload — NO key, NO baseUrl. */
package/recap.mjs CHANGED
@@ -26,11 +26,19 @@ export const RECAP_CAP = 20000
26
26
  // unmistakable authority boundary: the recap is memory, never a second task.
27
27
  export const CURRENT_PERSON_REQUEST_MARKER = '--- CURRENT PERSON REQUEST (authoritative; overrides all carried context above) ---'
28
28
 
29
+ // Recaps are an outbound context source. Keep the same privacy boundary as the
30
+ // bridge manifest without importing Node-only hashing into the browser bundle.
31
+ const UNSAFE_CONTEXT = /(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\|sk[_-][a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning)/i
32
+ const safeRecapText = (value) => {
33
+ const text = String(value || '').trim()
34
+ return text && !UNSAFE_CONTEXT.test(text) ? text : ''
35
+ }
36
+
29
37
  export function appendCurrentPersonRequest(carried, text) {
30
38
  const contexts = (Array.isArray(carried) ? carried : [carried])
31
- .map((value) => String(value || '').trim())
39
+ .map(safeRecapText)
32
40
  .filter(Boolean)
33
- const request = String(text || '')
41
+ const request = safeRecapText(text)
34
42
  if (!contexts.length) return request
35
43
  return `${contexts.join('\n\n')}\n\n${CURRENT_PERSON_REQUEST_MARKER}\n${request}`
36
44
  }
@@ -90,12 +98,12 @@ export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } =
90
98
  for (const e of log) {
91
99
  if (!e || typeof e !== 'object') continue
92
100
  if (e.kind === 'you') {
93
- const t = String(e.text || '').trim()
101
+ const t = safeRecapText(e.text)
94
102
  if (t) turns.push({ who: 'PERSON', text: t })
95
103
  } else if (e.kind === 'assistant') {
96
104
  const t = (Array.isArray(e.blocks) ? e.blocks : [])
97
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim())
98
- .map((b) => b.text.trim())
105
+ .filter((b) => b && b.type === 'text' && safeRecapText(b.text))
106
+ .map((b) => safeRecapText(b.text))
99
107
  .join('\n')
100
108
  if (t) turns.push({ who: 'YOU', text: t })
101
109
  }
package/repo-search.mjs CHANGED
@@ -27,6 +27,8 @@ const EXPANSIONS = Object.freeze({
27
27
  })
28
28
 
29
29
  const cleanQuery = (value) => String(value || '')
30
+ // Deliberately strip ASCII control characters from room-supplied queries.
31
+ // eslint-disable-next-line no-control-regex
30
32
  .replace(/[\u0000-\u001f\u007f]/g, ' ')
31
33
  .replace(/\s+/g, ' ')
32
34
  .trim()
@@ -0,0 +1,93 @@
1
+ // Bridge-owned semantic capabilities. Native runtimes keep their own tool
2
+ // transport; this module only admits the stable room-facing meaning.
3
+
4
+ const RUNTIMES = Object.freeze(['claude', 'codex', 'hermes'])
5
+ const SUPPORT = Object.freeze({ claude: 'native', codex: 'native', hermes: 'native' })
6
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/
7
+ const HOST_PATH = /(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\|(?:^|[\\/])\.\.(?:[\\/]|$))/i
8
+ const SECRET_KEY = /(secret|token|password|authorization|api.?key|private.?key)/i
9
+ const SECRET_VALUE = /(?:sk-[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{10,}|AKIA[0-9A-Z]{16}|sbp_[a-z0-9]{20,}|eyJ[a-z0-9_-]{16,}\.[a-z0-9_-]{16,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,})/i
10
+ const PROHIBITED_PROSE = /(?:raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning|system prompt|environment dump|provider key|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY)/i
11
+
12
+ const schema = (required = [], properties = {}) => Object.freeze({
13
+ type: 'object', additionalProperties: false, required: Object.freeze(required), properties: Object.freeze(properties),
14
+ })
15
+ const text = (maxLength = 500) => Object.freeze({ type: 'string', minLength: 1, maxLength })
16
+ const optionalText = (maxLength = 500) => Object.freeze({ type: 'string', maxLength })
17
+ const bool = Object.freeze({ type: 'boolean' })
18
+ const integer = (minimum, maximum) => Object.freeze({ type: 'integer', minimum, maximum })
19
+ const id = Object.freeze({ type: 'string', minLength: 1, maxLength: 160, pattern: '^[A-Za-z0-9][A-Za-z0-9._:@-]*$' })
20
+ const option = schema(['label', 'description'], { label: text(80), description: text(240) })
21
+ const question = schema(['id', 'header', 'question', 'options'], {
22
+ id: Object.freeze({ type: 'string', minLength: 1, maxLength: 80, pattern: '^[A-Za-z0-9][A-Za-z0-9_-]*$' }),
23
+ header: text(12), question: text(500), options: Object.freeze({ type: 'array', minItems: 2, maxItems: 3, items: option }), multiSelect: bool,
24
+ })
25
+ const evidenceRef = schema(['type', 'id'], { type: id, id })
26
+
27
+ export const RUNTIME_CAPABILITY_VERSION = 1
28
+ export const RUNTIME_CAPABILITIES = Object.freeze([
29
+ Object.freeze({ id: 'request_user_input', version: 1, runtimeSupport: SUPPORT, inputSchema: schema(['questions'], { questions: Object.freeze({ type: 'array', minItems: 1, maxItems: 3, items: question }), autoResolutionMs: integer(60_000, 240_000) }), output: 'accepted | deferred | rejected | failed', authority: 'pair-control', persistence: 'durable-safe-ref', continuation: 'resumable', secretFreeRoomPayload: true }),
30
+ Object.freeze({ id: 'request_permission', version: 1, runtimeSupport: SUPPORT, inputSchema: schema(['action'], { action: text(160), reason: optionalText(280) }), output: 'accepted | deferred | rejected | failed', authority: 'pair-control', persistence: 'durable-safe-ref', continuation: 'resumable', secretFreeRoomPayload: true }),
31
+ Object.freeze({ id: 'submit_flow_plan', version: 1, runtimeSupport: SUPPORT, inputSchema: schema(['plan'], { plan: text(32_768) }), output: 'accepted | rejected | failed', authority: 'pair-control', persistence: 'durable-safe-ref', continuation: 'resumable', secretFreeRoomPayload: true }),
32
+ Object.freeze({ id: 'mark_flow_done', version: 1, runtimeSupport: SUPPORT, inputSchema: schema([], { evidenceRef }), output: 'accepted | rejected | failed', authority: 'bridge', persistence: 'durable-safe-ref', continuation: 'none', secretFreeRoomPayload: true }),
33
+ Object.freeze({ id: 'submit_flow_review', version: 1, runtimeSupport: SUPPORT, inputSchema: schema(['verdict'], { verdict: text(16_384) }), output: 'accepted | rejected | failed', authority: 'bridge', persistence: 'durable-safe-ref', continuation: 'none', secretFreeRoomPayload: true }),
34
+ Object.freeze({ id: 'terminal_interrupt', version: 1, runtimeSupport: SUPPORT, inputSchema: schema([], { reason: optionalText(280) }), output: 'accepted | rejected | failed', authority: 'bridge', persistence: 'ephemeral', continuation: 'none', secretFreeRoomPayload: true }),
35
+ Object.freeze({ id: 'lane_continuation', version: 1, runtimeSupport: SUPPORT, inputSchema: schema(['state'], { state: Object.freeze({ type: 'string', enum: Object.freeze(['waiting_for_human', 'resumable', 'canceled', 'terminal']) }), resumeKind: Object.freeze({ type: 'string', enum: Object.freeze(['human_response', 'approval', 'redispatch', 'reconnect']) }) }), output: 'accepted | deferred | rejected | failed', authority: 'bridge', persistence: 'durable-safe-ref', continuation: 'resumable', secretFreeRoomPayload: true }),
36
+ ])
37
+
38
+ const CAPABILITIES = new Map(RUNTIME_CAPABILITIES.map((capability) => [capability.id, capability]))
39
+ export const runtimeCapability = (id) => CAPABILITIES.get(id) || null
40
+ export const runtimeSupportsCapability = (runtime, capabilityId) => RUNTIMES.includes(runtime) && runtimeCapability(capabilityId)?.runtimeSupport?.[runtime] === 'native'
41
+
42
+ const jsonBytes = (value) => {
43
+ try { return new TextEncoder().encode(JSON.stringify(value)).length } catch { return Infinity }
44
+ }
45
+
46
+ // This deliberately mirrors the pair-control redaction boundary without pulling
47
+ // browser source into the published bridge package.
48
+ export function isSecretFreeRoomPayload (value, maxBytes = 8192) {
49
+ if (value == null || jsonBytes(value) > maxBytes) return false
50
+ const visit = (node) => {
51
+ if (typeof node === 'string') return node.length <= 2048 && !HOST_PATH.test(node) && !SECRET_VALUE.test(node) && !PROHIBITED_PROSE.test(node)
52
+ if (node == null || typeof node === 'number' || typeof node === 'boolean') return true
53
+ if (Array.isArray(node)) return node.length <= 64 && node.every(visit)
54
+ if (typeof node !== 'object') return false
55
+ return Object.entries(node).every(([key, child]) => !SECRET_KEY.test(key) && visit(child))
56
+ }
57
+ return visit(value)
58
+ }
59
+
60
+ function validValue (value, definition) {
61
+ if (!definition) return true
62
+ if (definition.type === 'object') {
63
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
64
+ if (definition.additionalProperties === false && Object.keys(value).some((key) => !Object.hasOwn(definition.properties || {}, key))) return false
65
+ if ((definition.required || []).some((key) => !Object.hasOwn(value, key))) return false
66
+ return Object.entries(definition.properties || {}).every(([key, child]) => !Object.hasOwn(value, key) || validValue(value[key], child))
67
+ }
68
+ if (definition.type === 'array') return Array.isArray(value) && value.length >= (definition.minItems || 0) && value.length <= (definition.maxItems ?? Infinity) && (!definition.items || value.every((item) => validValue(item, definition.items)))
69
+ if (definition.type === 'string') return typeof value === 'string' && value.length >= (definition.minLength || 0) && value.length <= (definition.maxLength ?? Infinity) && (!definition.enum || definition.enum.includes(value)) && (!definition.pattern || new RegExp(definition.pattern).test(value))
70
+ if (definition.type === 'integer') return Number.isSafeInteger(value) && value >= (definition.minimum ?? -Infinity) && value <= (definition.maximum ?? Infinity)
71
+ if (definition.type === 'boolean') return typeof value === 'boolean'
72
+ return false
73
+ }
74
+
75
+ export function validateRuntimeCapabilityInput (capabilityId, input) {
76
+ const capability = runtimeCapability(capabilityId)
77
+ return !!capability && validValue(input, capability.inputSchema)
78
+ }
79
+
80
+ export function admitRuntimeCapability ({ runtime, capabilityId, input = {}, roomPayload = null } = {}) {
81
+ const capability = runtimeCapability(capabilityId)
82
+ if (!capability) return Object.freeze({ ok: false, code: 'unregistered_capability' })
83
+ if (!RUNTIMES.includes(runtime) || capability.runtimeSupport[runtime] !== 'native') return Object.freeze({ ok: false, code: 'runtime_capability_mismatch' })
84
+ if (!validateRuntimeCapabilityInput(capabilityId, input)) return Object.freeze({ ok: false, code: 'invalid_capability_input' })
85
+ if (capability.secretFreeRoomPayload && roomPayload != null && !isSecretFreeRoomPayload(roomPayload)) return Object.freeze({ ok: false, code: 'unsafe_room_payload' })
86
+ return Object.freeze({ ok: true, code: 'admitted', capability })
87
+ }
88
+
89
+ export function assertRuntimeCapability (request) {
90
+ const admitted = admitRuntimeCapability(request)
91
+ if (!admitted.ok) throw new TypeError(`Runtime capability rejected: ${admitted.code}`)
92
+ return admitted.capability
93
+ }
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path'
2
+ import { runtimeCapability, runtimeSupportsCapability } from './runtime-contract.mjs'
2
3
 
3
4
  const RUNTIMES = Object.freeze({
4
5
  claude: Object.freeze({
@@ -28,6 +29,11 @@ export const structuredRuntimeForCommand = (command) => {
28
29
  export const defaultStructuredMode = (runtime) => structuredRuntimeMetadata(runtime)?.defaultMode || 'default'
29
30
  export const structuredRuntimeSupportsMode = (runtime, mode) => structuredRuntimeMetadata(runtime)?.modes?.includes(mode) === true
30
31
  export const structuredRuntimeSupportsFlow = (runtime) => structuredRuntimeMetadata(runtime)?.flow === true
32
+ // This is deliberately semantic rather than a list of native tool names. The
33
+ // bridge may expose an MCP tool, an SDK hook, or ACP registration underneath.
34
+ export const structuredRuntimeCapability = (runtime, capabilityId) => (
35
+ runtimeSupportsCapability(runtime, capabilityId) ? runtimeCapability(capabilityId) : null
36
+ )
31
37
  export const structuredModeLocked = ({ flowRole, sliceType } = {}) => (
32
38
  flowRole === 'conductor' || flowRole === 'reviewer' || sliceType === 'review'
33
39
  )
@@ -1,6 +1,7 @@
1
1
  import { startClaudeSession } from './claude-session.mjs'
2
2
  import { startCodexSession } from './codex-session.mjs'
3
3
  import { startHermesSession } from './hermes-session.mjs'
4
+ import { assertRuntimeCapability } from './runtime-contract.mjs'
4
5
 
5
6
  const FACTORIES = Object.freeze({
6
7
  claude: startClaudeSession,
@@ -11,5 +12,9 @@ const FACTORIES = Object.freeze({
11
12
  export function startStructuredSession(runtime, options) {
12
13
  const factory = FACTORIES[runtime]
13
14
  if (!factory) throw new Error(`Unsupported structured runtime: ${runtime}`)
15
+ // Every live adapter has a native interrupt boundary. Assert the semantic
16
+ // contract here so an accidental registry/runtime divergence fails at launch,
17
+ // rather than silently accepting a provider-specific fallback later.
18
+ assertRuntimeCapability({ runtime, capabilityId: 'terminal_interrupt', input: {} })
14
19
  return factory(options)
15
20
  }