thinkpool-pair 0.7.314 → 0.7.315

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 CHANGED
@@ -2362,6 +2362,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2362
2362
  // It remains scoped to this lane's cwd and private mockup outbox.
2363
2363
  entry.viewport = new ViewportManager({
2364
2364
  workspaceRoot: cwd || process.cwd(), ownerId: id, outbox: mockupOutbox,
2365
+ authContext: () => ({ accessToken: codeAuthToken, supabaseUrl: SUPABASE_URL }),
2365
2366
  designContext: () => {
2366
2367
  const active = designActive.get(id)
2367
2368
  if (!active || active.record.sourceKind !== 'preview') return null
package/flow-worktree.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  import { execFileSync } from 'node:child_process'
12
12
  import path from 'node:path'
13
13
  import fs from 'node:fs'
14
+ import { linkLocalEnvIntoWorktree } from './lane-worktree.mjs'
14
15
 
15
16
  const ROOT = process.cwd() // the bridge's checkout (the shared main repo root)
16
17
 
@@ -33,10 +34,13 @@ export function worktreeSpec ({ flowId, taskKey, root = ROOT }) {
33
34
  // fresh `git init` checkout), and the dispatch loop swallowed the throw → NO lane ever
34
35
  // spawned. Resolve the first ref that actually exists: caller's base → origin/main →
35
36
  // origin/HEAD → main → master → HEAD.
36
- export function createFlowWorktree ({ flowId, taskKey, base = null, root = ROOT, git = runGit }) {
37
+ export function createFlowWorktree ({ flowId, taskKey, base = null, root = ROOT, git = runGit, fsImpl = fs }) {
37
38
  const { branch, dir, wtRoot } = worktreeSpec({ flowId, taskKey, root })
38
- if (fs.existsSync(path.join(dir, '.git'))) return { dir, branch, created: false }
39
- fs.mkdirSync(wtRoot, { recursive: true })
39
+ if (fsImpl.existsSync(path.join(dir, '.git'))) {
40
+ linkLocalEnvIntoWorktree({ root, dir, fsImpl })
41
+ return { dir, branch, created: false }
42
+ }
43
+ fsImpl.mkdirSync(wtRoot, { recursive: true })
40
44
  let ref = base
41
45
  if (!ref) {
42
46
  for (const cand of ['origin/main', 'origin/HEAD', 'main', 'master', 'HEAD']) {
@@ -50,6 +54,7 @@ export function createFlowWorktree ({ flowId, taskKey, base = null, root = ROOT,
50
54
  // Branch already exists (a prior dispatch of this task) — check it out instead.
51
55
  git(['worktree', 'add', dir, branch], root)
52
56
  }
57
+ linkLocalEnvIntoWorktree({ root, dir, fsImpl })
53
58
  return { dir, branch, created: true }
54
59
  }
55
60
 
package/lane-worktree.mjs CHANGED
@@ -4,6 +4,27 @@ import { execFileSync } from 'node:child_process'
4
4
  import path from 'node:path'
5
5
  import fs from 'node:fs'
6
6
 
7
+ const LOCAL_ENV_FILES = ['.env.local', '.env.development.local']
8
+
9
+ // Git deliberately omits local env files from worktrees. That is correct for
10
+ // version control but wrong for an isolated lane that must build/preview the
11
+ // same app as the main checkout: Vite otherwise compiles `undefined` public
12
+ // client config and the preview dies before React mounts. Link, never copy, the
13
+ // main checkout's existing ignored env files. Missing files are a clean no-op.
14
+ export function linkLocalEnvIntoWorktree({ root, dir, fsImpl = fs } = {}) {
15
+ const linked = []
16
+ for (const name of LOCAL_ENV_FILES) {
17
+ const source = path.join(root, name)
18
+ const target = path.join(dir, name)
19
+ try {
20
+ if (!fsImpl.existsSync?.(source) || fsImpl.existsSync?.(target)) continue
21
+ fsImpl.symlinkSync?.(source, target)
22
+ linked.push(name)
23
+ } catch { /* local env is optional; worktree creation must still succeed */ }
24
+ }
25
+ return linked
26
+ }
27
+
7
28
  export function createManagedLaneWorktree({ terminalId, cwd = process.cwd(), git = runGit, fsImpl = fs } = {}) {
8
29
  const id = String(terminalId || '')
9
30
  const short = id.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8)
@@ -15,6 +36,7 @@ export function createManagedLaneWorktree({ terminalId, cwd = process.cwd(), git
15
36
  const base = resolveLaneBase({ root, git })
16
37
  fsImpl.mkdirSync(path.dirname(dir), { recursive: true })
17
38
  git(['worktree', 'add', '-b', branch, dir, base], root)
39
+ linkLocalEnvIntoWorktree({ root, dir, fsImpl })
18
40
  return { terminalId: id, root, dir, branch }
19
41
  }
20
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.314",
3
+ "version": "0.7.315",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 12,
3
+ "bundleVersion": 13,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  {
43
43
  "id": "work-routing",
44
- "version": 4,
44
+ "version": 5,
45
45
  "providerRoutingContract": "A Claude lane may select a connected Anthropic-compatible provider by durable id, unique display name, or unique configured model. Unknown or ambiguous references fail closed and must never fall through to built-in Claude.",
46
46
  "openerParityContract": "Every top-level terminal may use spawn_terminal or open_main_terminal to open every supported structured runtime: Claude, Codex, or Hermes. Runtime-specific provider/model validation remains authoritative and occurs before inference.",
47
47
  "routes": [
@@ -94,7 +94,7 @@
94
94
  "id": "visual-proof",
95
95
  "tools": ["preview_start", "preview_capture", "preview_inspect", "preview_stop"],
96
96
  "trigger": "\\b(ui|ux|visual|design|frontend|html|css|page|route|mockup|screenshot|responsive|desktop|mobile|preview)\\b",
97
- "prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. preview_capture uses a fresh isolated browser and is inline verification evidence by default; it must not create a transcript card. Set card=true only when the person asked for a mockup/Design artifact or the visual itself is an intentional deliverable that should remain editable in the transcript. A card requires settled meaningful content at both desktop and mobile, rejects loading/public-auth fallback shells, and is delivered after the final agent response. Authenticated app routes require an authenticated visual harness. Surface PNG evidence only when no interactive source-backed Design card is displayed."
97
+ "prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. preview_capture uses a fresh isolated browser and is inline verification evidence by default; it must not create a transcript card. Set card=true only when the person asked for a mockup/Design artifact or the visual itself is an intentional deliverable that should remain editable in the transcript. A card requires settled meaningful content at both desktop and mobile, rejects loading/public-auth fallback shells, and is delivered after the final agent response. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, ordinary room broadcasts, and refresh-token access are blocked, while non-mutating roster/transcript snapshot requests are allowed. Surface PNG evidence only when no interactive source-backed Design card is displayed."
98
98
  }
99
99
  ],
100
100
  "impact": [
package/viewport.mjs CHANGED
@@ -25,6 +25,9 @@ export const DEFAULT_VIEWPORTS = Object.freeze({
25
25
 
26
26
  const MAX_CAPTURE_HEIGHT = 20000
27
27
  const MAX_SETTLE_MS = 5000
28
+ const AUTH_READY_TIMEOUT_MS = 15000
29
+ const AUTH_FRESH_MARGIN_SEC = 30
30
+ const PREVIEW_LOCAL_EXPIRY_EXTENSION_SEC = 3600
28
31
  const CDP_TIMEOUT_MS = 12000
29
32
  const MAX_PORTABLE_SNAPSHOT_BYTES = 1_900_000
30
33
 
@@ -88,12 +91,62 @@ export function previewArtifactIssue(route, capture) {
88
91
  if (actualRoute && actualRoute !== expectedRoute) return `Preview navigated from ${expectedRoute} to ${actualRoute}.`
89
92
  if (state.signedOutInvite) return 'Preview resolved to the signed-out room invitation instead of the authenticated Code room.'
90
93
  if (state.visibleBoot) return 'Preview was still showing the application loading shell.'
94
+ if (state.visibleLoading) return 'Preview was still showing an application loading state.'
91
95
  if (protectedRoute && state.prerenderGeo) return 'Preview resolved to the public marketing shell instead of the authenticated Code surface.'
92
96
  if (!state.hasBody || (state.textLength < 2 && state.meaningfulVisualCount < 1)) return 'Preview rendered an empty page.'
93
97
  }
94
98
  return null
95
99
  }
96
100
 
101
+ export function routeNeedsPreviewAuth(route) {
102
+ const parsed = new URL(normalizeRoute(route), 'http://127.0.0.1')
103
+ return !!parsed.searchParams.get('r') || ['/settings', '/contacts', '/code/link'].includes(parsed.pathname)
104
+ }
105
+
106
+ function decodeJwtPayload(token) {
107
+ try {
108
+ const payload = String(token || '').split('.')[1]
109
+ return payload ? JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) : null
110
+ } catch { return null }
111
+ }
112
+
113
+ // Build the minimum Supabase local-storage session needed for a short-lived,
114
+ // read-only visual capture. Never expose the bridge refresh token to Chrome.
115
+ export function previewAuthBootstrap({ accessToken, supabaseUrl, nowSec = Math.floor(Date.now() / 1000), minTtlSec = 0 } = {}) {
116
+ const claims = decodeJwtPayload(accessToken)
117
+ let endpoint
118
+ try { endpoint = new URL(supabaseUrl) } catch { return null }
119
+ if (!accessToken || endpoint.protocol !== 'https:' || !claims?.sub || !claims?.exp || claims.exp - nowSec <= minTtlSec) return null
120
+ const projectRef = endpoint.hostname.split('.')[0]
121
+ if (!projectRef) return null
122
+ return {
123
+ storageKey: `sb-${projectRef}-auth-token`,
124
+ allowedReadOrigins: [endpoint.origin],
125
+ allowedSocketOrigins: [`wss://${endpoint.host}`],
126
+ session: {
127
+ access_token: accessToken,
128
+ refresh_token: '',
129
+ // auth-js refreshes 90s early. Extend only its browser-local marker; the
130
+ // signed JWT keeps its real expiry and is still server-validated per read.
131
+ expires_in: Math.max(1, claims.exp - nowSec) + PREVIEW_LOCAL_EXPIRY_EXTENSION_SEC,
132
+ expires_at: claims.exp + PREVIEW_LOCAL_EXPIRY_EXTENSION_SEC,
133
+ token_type: 'bearer',
134
+ user: {
135
+ id: claims.sub,
136
+ aud: claims.aud || 'authenticated',
137
+ role: claims.role || 'authenticated',
138
+ email: claims.email || null,
139
+ phone: claims.phone || '',
140
+ app_metadata: claims.app_metadata || {},
141
+ user_metadata: claims.user_metadata || {},
142
+ identities: [],
143
+ created_at: claims.created_at || new Date(nowSec * 1000).toISOString(),
144
+ updated_at: new Date(nowSec * 1000).toISOString(),
145
+ },
146
+ },
147
+ }
148
+ }
149
+
97
150
  export function findBrowserExecutable({ env = process.env, platform = process.platform, exists = fs.existsSync } = {}) {
98
151
  const candidates = [
99
152
  env.TP_BROWSER_PATH,
@@ -241,22 +294,51 @@ export class CdpBrowser {
241
294
  }
242
295
  }
243
296
 
244
- async withPage({ url, viewport, waitMs = 300 }, fn) {
297
+ async withPage({ url, viewport, waitMs = 300, auth = null }, fn) {
245
298
  await this.ensureStarted()
246
299
  const { targetId } = await this.pipe.send('Target.createTarget', { url: 'about:blank' })
247
300
  const { sessionId } = await this.pipe.send('Target.attachToTarget', { targetId, flatten: true })
248
301
  let unsubscribeRequests = null
302
+ let unsubscribeResponses = null
303
+ let unsubscribeExceptions = null
304
+ const authResponses = []
305
+ const localResponses = []
306
+ const runtimeExceptions = []
249
307
  try {
250
308
  await this.pipe.send('Page.enable', {}, sessionId)
251
309
  await this.pipe.send('Runtime.enable', {}, sessionId)
310
+ await this.pipe.send('Network.enable', {}, sessionId).catch(() => {})
311
+ if (auth?.storageKey && auth?.session) {
312
+ const source = `(() => { try { localStorage.setItem(${JSON.stringify(auth.storageKey)}, ${JSON.stringify(JSON.stringify(auth.session))}); window.__TP_READ_ONLY_PREVIEW__ = true; } catch {} })()`
313
+ await this.pipe.send('Page.addScriptToEvaluateOnNewDocument', { source }, sessionId)
314
+ }
252
315
  // The page is untrusted build output. Keep its network authority narrower
253
- // than the lane sandbox: same preview origin only. This blocks internet,
254
- // cloud metadata, and other localhost services while still allowing all
255
- // JS/CSS/assets served from the selected build directory.
316
+ // than the lane sandbox: same preview origin plus read-only requests to
317
+ // the exact Supabase origin provided by the bridge auth context.
256
318
  const allowedOrigin = new URL(url).origin
319
+ const allowedReadOrigins = new Set(auth?.allowedReadOrigins || [])
320
+ const allowedSocketOrigins = new Set(auth?.allowedSocketOrigins || [])
321
+ unsubscribeResponses = this.pipe.subscribe('Network.responseReceived', sessionId, (params) => {
322
+ try {
323
+ const responseUrl = new URL(params.response?.url || '')
324
+ if (allowedReadOrigins.has(responseUrl.origin) && authResponses.length < 20) authResponses.push(`${params.response?.status || 0}:${responseUrl.pathname}`)
325
+ if (responseUrl.origin === allowedOrigin && localResponses.length < 20) localResponses.push(`${params.response?.status || 0}:${responseUrl.pathname}`)
326
+ } catch { /* diagnostics only */ }
327
+ })
328
+ unsubscribeExceptions = this.pipe.subscribe('Runtime.exceptionThrown', sessionId, (params) => {
329
+ if (runtimeExceptions.length >= 5) return
330
+ const detail = params.exceptionDetails?.exception?.description || params.exceptionDetails?.text || 'runtime error'
331
+ runtimeExceptions.push(String(detail).split('\n')[0].slice(0, 180))
332
+ })
257
333
  unsubscribeRequests = this.pipe.subscribe('Fetch.requestPaused', sessionId, (params) => {
258
334
  let allowed = false
259
- try { allowed = new URL(params.request?.url || '').origin === allowedOrigin } catch { allowed = false }
335
+ try {
336
+ const requestUrl = new URL(params.request?.url || '')
337
+ const requestMethod = String(params.request?.method || 'GET').toUpperCase()
338
+ allowed = requestUrl.origin === allowedOrigin ||
339
+ (allowedReadOrigins.has(requestUrl.origin) && ['GET', 'HEAD', 'OPTIONS'].includes(requestMethod)) ||
340
+ (allowedSocketOrigins.has(requestUrl.origin) && requestMethod === 'GET')
341
+ } catch { allowed = false }
260
342
  const method = allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest'
261
343
  const request = allowed
262
344
  ? { requestId: params.requestId }
@@ -276,11 +358,57 @@ export class CdpBrowser {
276
358
  await this.pipe.send('Runtime.evaluate', {
277
359
  expression: 'document.fonts && document.fonts.ready', awaitPromise: true, returnByValue: true,
278
360
  }, sessionId).catch(() => {})
279
- const settle = Math.min(MAX_SETTLE_MS, Math.max(0, Number(waitMs) || 0))
361
+ if (auth) {
362
+ const deadline = Date.now() + AUTH_READY_TIMEOUT_MS
363
+ let ready = false
364
+ let readySince = 0
365
+ while (Date.now() < deadline) {
366
+ const state = await this.pipe.send('Runtime.evaluate', {
367
+ expression: `(() => {
368
+ if (document.querySelector('#invitation-room-title')) return true;
369
+ if (document.querySelector('#boot, .boot-loader')) return false;
370
+ const loading = [...document.querySelectorAll('span')].some((node) => /^(loading session|restoring terminals…|connecting(?:…|\.\.\.))$/i.test((node.textContent || '').trim()));
371
+ return !loading && (document.body?.innerText || '').trim().length > 1;
372
+ })()`,
373
+ returnByValue: true,
374
+ }, sessionId)
375
+ if (state.result?.value === true) {
376
+ if (!readySince) readySince = Date.now()
377
+ if (Date.now() - readySince >= 800) { ready = true; break }
378
+ } else readySince = 0
379
+ await new Promise((resolve) => setTimeout(resolve, 100))
380
+ }
381
+ if (!ready) {
382
+ const diagnostic = await this.pipe.send('Runtime.evaluate', {
383
+ expression: `(async () => {
384
+ let locks = { held: [], pending: [] };
385
+ try { locks = await navigator.locks.query(); } catch {}
386
+ return {
387
+ sessionPresent: !!localStorage.getItem(${JSON.stringify(auth.storageKey || '')}),
388
+ inlineBoot: !!document.querySelector('#boot, .boot-loader'),
389
+ loadingSession: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim() === 'Loading session'),
390
+ connecting: [...document.querySelectorAll('span')].some((node) => (node.textContent || '').trim().toLowerCase() === 'connecting…'),
391
+ invitation: !!document.querySelector('#invitation-room-title'),
392
+ heldLocks: locks.held?.length || 0,
393
+ pendingLocks: locks.pending?.length || 0,
394
+ };
395
+ })()`,
396
+ awaitPromise: true,
397
+ returnByValue: true,
398
+ }, sessionId).catch(() => ({ result: { value: {} } }))
399
+ const d = diagnostic.result?.value || {}
400
+ throw new Error(`Authenticated preview did not leave its loading state within 15 seconds (session=${d.sessionPresent ? 'present' : 'missing'}, inlineBoot=${d.inlineBoot ? 'yes' : 'no'}, loadingSession=${d.loadingSession ? 'yes' : 'no'}, connecting=${d.connecting ? 'yes' : 'no'}, authLocks=${d.heldLocks || 0}/${d.pendingLocks || 0}, invitation=${d.invitation ? 'yes' : 'no'}, reads=${authResponses.join(',') || 'none'}, local=${localResponses.join(',') || 'none'}, runtime=${runtimeExceptions.join(' | ') || 'none'}); no screenshot was created.`)
401
+ }
402
+ }
403
+ const settle = auth
404
+ ? Math.min(300, Math.max(0, Number(waitMs) || 0))
405
+ : Math.min(MAX_SETTLE_MS, Math.max(0, Number(waitMs) || 0))
280
406
  if (settle) await new Promise((resolve) => setTimeout(resolve, settle))
281
407
  return await fn({ pipe: this.pipe, sessionId })
282
408
  } finally {
283
409
  unsubscribeRequests?.()
410
+ unsubscribeResponses?.()
411
+ unsubscribeExceptions?.()
284
412
  await this.pipe.send('Target.closeTarget', { targetId }).catch(() => {})
285
413
  }
286
414
  }
@@ -302,6 +430,10 @@ export class CdpBrowser {
302
430
  textLength: (document.body?.innerText || '').trim().length,
303
431
  meaningfulVisualCount: meaningful.length,
304
432
  visibleBoot: visible(boot),
433
+ visibleLoading: [...document.querySelectorAll('span')].some((node) => {
434
+ const text = (node.textContent || '').trim();
435
+ return visible(node) && /^(loading session|restoring terminals…|connecting(?:…|\.\.\.))$/i.test(text);
436
+ }),
305
437
  signedOutInvite: !!document.querySelector('#invitation-room-title'),
306
438
  prerenderGeo: !!document.querySelector('[data-prerender="geo"]'),
307
439
  };
@@ -382,8 +514,8 @@ export class CdpBrowser {
382
514
  return html
383
515
  }
384
516
 
385
- async capture({ url, viewport, fullPage = true, waitMs = 300, includeSnapshot = false }) {
386
- return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
517
+ async capture({ url, viewport, fullPage = true, waitMs = 300, includeSnapshot = false, auth = null }) {
518
+ return this.withPage({ url, viewport, waitMs, auth }, async ({ pipe, sessionId }) => {
387
519
  const beforeState = await this.renderedState(pipe, sessionId)
388
520
  const metrics = await pipe.send('Page.getLayoutMetrics', {}, sessionId)
389
521
  const contentHeight = Math.ceil(metrics.cssContentSize?.height || viewport.height)
@@ -398,12 +530,12 @@ export class CdpBrowser {
398
530
  })
399
531
  }
400
532
 
401
- async snapshot({ url, viewport = DEFAULT_VIEWPORTS.desktop, waitMs = 300 }) {
402
- return this.withPage({ url, viewport, waitMs }, ({ pipe, sessionId }) => this.freezeCurrentPage(pipe, sessionId))
533
+ async snapshot({ url, viewport = DEFAULT_VIEWPORTS.desktop, waitMs = 300, auth = null }) {
534
+ return this.withPage({ url, viewport, waitMs, auth }, ({ pipe, sessionId }) => this.freezeCurrentPage(pipe, sessionId))
403
535
  }
404
536
 
405
- async inspect({ url, viewport = DEFAULT_VIEWPORTS.mobile, selector, waitMs = 300 }) {
406
- return this.withPage({ url, viewport, waitMs }, async ({ pipe, sessionId }) => {
537
+ async inspect({ url, viewport = DEFAULT_VIEWPORTS.mobile, selector, waitMs = 300, auth = null }) {
538
+ return this.withPage({ url, viewport, waitMs, auth }, async ({ pipe, sessionId }) => {
407
539
  const expression = `(() => {
408
540
  const selector = ${JSON.stringify(selector || null)};
409
541
  const el = selector ? document.querySelector(selector) : null;
@@ -437,13 +569,14 @@ export class CdpBrowser {
437
569
  export const sharedViewportBrowser = new CdpBrowser()
438
570
 
439
571
  export class ViewportManager {
440
- constructor({ workspaceRoot, ownerId, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview, designContext = null } = {}) {
572
+ constructor({ workspaceRoot, ownerId, outbox, browser = sharedViewportBrowser, startPreviewImpl = startPreview, designContext = null, authContext = null } = {}) {
441
573
  this.workspaceRoot = path.resolve(workspaceRoot || process.cwd())
442
574
  this.ownerId = ownerId || randomUUID()
443
575
  this.outbox = outbox || path.join(os.tmpdir(), 'thinkpool-viewport-captures', this.ownerId)
444
576
  this.browser = browser
445
577
  this.startPreviewImpl = startPreviewImpl
446
578
  this.designContext = typeof designContext === 'function' ? designContext : () => null
579
+ this.authContext = typeof authContext === 'function' ? authContext : () => null
447
580
  this.previewId = `viewport:${this.ownerId}`
448
581
  this.preview = null
449
582
  this.root = null
@@ -479,6 +612,20 @@ export class ViewportManager {
479
612
  return new URL(normalizeRoute(route), preview.url).href
480
613
  }
481
614
 
615
+ async previewAuth(route) {
616
+ if (!routeNeedsPreviewAuth(route)) return null
617
+ let context = this.authContext() || {}
618
+ if (!context.accessToken) return null
619
+ const deadline = Date.now() + AUTH_READY_TIMEOUT_MS
620
+ for (;;) {
621
+ const auth = previewAuthBootstrap({ ...context, minTtlSec: AUTH_FRESH_MARGIN_SEC })
622
+ if (auth) return auth
623
+ if (Date.now() >= deadline) throw new Error('The bridge account token did not refresh in time for an authenticated preview; no screenshot was created.')
624
+ await new Promise((resolve) => setTimeout(resolve, 250))
625
+ context = this.authContext() || {}
626
+ }
627
+ }
628
+
482
629
  async capture({ route = '/', viewports = 'both', title = 'Viewport capture', fullPage = true, waitMs = 300, card = false } = {}) {
483
630
  const normalizedRoute = normalizeRoute(route)
484
631
  const url = this.pageUrl(normalizedRoute)
@@ -500,6 +647,7 @@ export class ViewportManager {
500
647
  captures[name] = await this.browser.capture({
501
648
  url, viewport: DEFAULT_VIEWPORTS[name], fullPage, waitMs,
502
649
  includeSnapshot: cardRequested && name === (names.includes('desktop') ? 'desktop' : names[0]),
650
+ auth: await this.previewAuth(normalizedRoute),
503
651
  })
504
652
  }
505
653
  // The screenshot and readiness state must come from the SAME page. The old
@@ -507,10 +655,10 @@ export class ViewportManager {
507
655
  // production proved the preflight could see marketing content while the
508
656
  // screenshot caught only the boot spinner.
509
657
  const cardReady = cardRequested && !!(captures.desktop && captures.mobile)
510
- if (cardReady) {
658
+ if (cardReady || routeNeedsPreviewAuth(normalizedRoute)) {
511
659
  for (const [name, capture] of Object.entries(captures)) {
512
660
  const issue = previewArtifactIssue(normalizedRoute, capture)
513
- if (issue) throw new Error(`${name} deliverable rejected: ${issue} No mockup card was created.`)
661
+ if (issue) throw new Error(`${name} preview rejected: ${issue} No screenshot or mockup card was created.`)
514
662
  }
515
663
  }
516
664
  const snapshot = captures.desktop?.snapshot || captures.mobile?.snapshot || null
@@ -541,9 +689,13 @@ export class ViewportManager {
541
689
  return { url, slug, manifestPath, captures, snapshotPath, cardReady }
542
690
  }
543
691
 
544
- inspect({ route = '/', viewport = 'mobile', selector, waitMs = 300 } = {}) {
692
+ async inspect({ route = '/', viewport = 'mobile', selector, waitMs = 300 } = {}) {
545
693
  if (!DEFAULT_VIEWPORTS[viewport]) throw new Error('viewport must be "desktop" or "mobile".')
546
- return this.browser.inspect({ url: this.pageUrl(route), viewport: DEFAULT_VIEWPORTS[viewport], selector, waitMs })
694
+ const normalizedRoute = normalizeRoute(route)
695
+ return this.browser.inspect({
696
+ url: this.pageUrl(normalizedRoute), viewport: DEFAULT_VIEWPORTS[viewport], selector, waitMs,
697
+ auth: await this.previewAuth(normalizedRoute),
698
+ })
547
699
  }
548
700
 
549
701
  async stop() {
@@ -572,7 +724,7 @@ export function createViewportTools({ tool, z, manager }) {
572
724
  ),
573
725
  tool(
574
726
  'preview_capture',
575
- 'Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page. Captures are inline verification evidence by default. Set card=true only for a user-requested mockup or visual deliverable that should remain editable in the transcript; complete desktop+mobile settled content is then required and the card arrives after the final response. Authenticated app routes require an authenticated visual harness.',
727
+ 'Capture this lane preview in a fresh isolated browser at exact desktop (1440×900) and mobile (390×844) CSS viewports. Defaults to both and full-page. Captures are inline verification evidence by default. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, and room broadcasts are blocked, apart from non-mutating roster/transcript snapshot requests. Set card=true only for a user-requested mockup or visual deliverable that should remain editable in the transcript; complete desktop+mobile settled content is then required and the card arrives after the final response.',
576
728
  {
577
729
  path: z.string().max(500).optional().describe('route within the preview, e.g. / or /settings; never a full URL'),
578
730
  viewports: z.enum(['both', 'desktop', 'mobile']).optional(),