@gotcos/glasses-server 6.99.33 → 7.0.7

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.
Files changed (76) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +417 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +40 -1
  56. package/server/web-control/app.js +1309 -56
  57. package/server/web-control/control.css +25 -0
  58. package/server/web-control/index.html +7 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +42 -3
  61. package/server/web-control/panes/cos-chat.js +267 -15
  62. package/server/web-control/panes/meetings.css +18 -2
  63. package/server/web-control/panes/meetings.js +24 -8
  64. package/server/web-control/panes/memories.css +17 -2
  65. package/server/web-control/panes/memories.js +127 -42
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.js +28 -8
  69. package/server/web-control/panes/reader-media.js +409 -23
  70. package/server/web-control/panes/sessions.css +32 -3
  71. package/server/web-control/panes/settings.js +96 -5
  72. package/server/web-control/panes/today-actions.css +11 -0
  73. package/server/web-control/panes/today-actions.js +45 -12
  74. package/server/web-control/panes/work.css +10 -0
  75. package/server/web-control/panes/work.js +10 -2
  76. package/server/web-control/shell.js +17 -14
@@ -0,0 +1,329 @@
1
+ // GET /api/events (7.0.1 live state). The contract is docs/live-events-contract.md; the hub is lib/live-events.ts.
2
+ //
3
+ // ONE stream per open Control Web page. It tells the page when the server's own session state changes (a session
4
+ // opens, works, waits, idles or ends; a reply lands; a question opens or closes; the queue moves), so the pet, the
5
+ // session rows, Today's counts and the running clocks stop drifting between polls.
6
+ //
7
+ // TRANSPORT, copied from the per-session stream that already works through Tailscale Serve and the https listener
8
+ // (agent-session-stream.ts): the same four headers, `flushHeaders()`, `retry:` first, one JSON object per `data:`
9
+ // line with no named events and no comment keepalives, a heartbeat every 15 s, and every refusal BEFORE the headers
10
+ // as ordinary JSON so a client sees a failed request, never a socket that never speaks.
11
+ //
12
+ // AUTH is the global /api gate (token header or the Control Web cookie), exactly as for `/stream`. Nothing here
13
+ // re-checks it, so nothing here can drift from it.
14
+ //
15
+ // SWITCH: COS_WEB_LIVE_EVENTS. Absent means on. `0` registers no route at all (404), which is also what a client
16
+ // sees from a server older than 7.0.1, so both take the same polling path.
17
+
18
+ import { Router, type Request, type Response } from 'express'
19
+ import { readSwitch } from '../lib/env-switch.js'
20
+ import {
21
+ LIVE_ENDED_LINGER_MS,
22
+ LiveEventsHub,
23
+ parseLiveCursor,
24
+ type LiveFrame,
25
+ type LiveProvider,
26
+ type LiveQuestionReading,
27
+ type LiveQueueReading,
28
+ type LiveSessionReading,
29
+ type LiveSources,
30
+ type LiveState,
31
+ } from '../lib/live-events.js'
32
+ import { claudeSessionsDir, claudeSessionsEnabled, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
33
+ import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
34
+ import { deriveForRow, sessionHooksEnabled, sessionSignalStore, signalFor } from '../lib/session-hooks-runtime.js'
35
+ import type { SessionSignal } from '../lib/session-signal-store.js'
36
+ import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
37
+ import { runningHintFor } from './agent-sessions.js'
38
+ import { queuedThreadKeys, readQueue } from '../lib/thread-turn-queue-store.js'
39
+ import type { PermissionBroker } from '../lib/permission-broker.js'
40
+
41
+ /** Inside a 30 s client deadline with room for one lost write; the same cadence as `/stream`. */
42
+ export const LIVE_EVENTS_HEARTBEAT_MS = 15_000
43
+ /**
44
+ * Open streams per server. A phone, a laptop and a few tabs each hold one; 16 is room plus a ceiling. At the ceiling the
45
+ * OLDEST stream is closed to make room (ghost review W5): a peer that vanished behind Tailscale holds its slot until TCP
46
+ * gives up, and the newest request is usually the person who just opened the page. A closed live client reconnects
47
+ * with its last id.
48
+ */
49
+ export const LIVE_EVENTS_MAX_STREAMS = 16
50
+ /** Unsent bytes past this close the stream: a slow client is dropped, never buffered without limit. */
51
+ export const LIVE_EVENTS_MAX_BUFFER_BYTES = 256 * 1024
52
+ /** A hook-only session with no event for this long (and no open turn or wait) is not open. The deriver's open-turn ceiling. */
53
+ export const LIVE_SIGNAL_OPEN_WINDOW_MS = 30 * 60_000
54
+
55
+ /** Absent means on; `0` (or false/off) means no route. Read at server start. */
56
+ export function liveEventsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
57
+ return readSwitch(env.COS_WEB_LIVE_EVENTS, true)
58
+ }
59
+
60
+ function newestOf(...values: Array<number | null | undefined>): number | null {
61
+ let out: number | null = null
62
+ for (const v of values) if (typeof v === 'number' && Number.isFinite(v) && v > 0 && (out === null || v > out)) out = v
63
+ return out
64
+ }
65
+
66
+ /**
67
+ * One session's reading, from the same derivation the list and the per-session stream use (deriveForRow, then
68
+ * runningHintFor), so `working` here is the list's `running_active` and the pet never disagrees with the rows.
69
+ *
70
+ * Exported for tests. Pure apart from the deriver's own reads of the signal store (remember: false, so it never
71
+ * touches the row readers' two-scan memory).
72
+ */
73
+ export function readingFor(provider: LiveProvider, sessionId: string, peer: ClaudePeerRecord | undefined, signal: SessionSignal | undefined, now: number): LiveSessionReading {
74
+ const id = sessionId.toLowerCase()
75
+ const derived = deriveForRow({
76
+ sessionId: id,
77
+ registry: peer ? registryFacts(peer) : undefined,
78
+ now,
79
+ remember: false,
80
+ attachedTurn: isAttachedTurnActive(sessionStreamKey(provider, id)),
81
+ })
82
+ const hookClock = signal ? newestOf(signal.lastEventAt, signal.lastThought?.at) : null
83
+ let state: LiveState
84
+ let source: 'hook' | 'registry' = derived?.state_source === 'hook' ? 'hook' : 'registry'
85
+ if (!derived) {
86
+ // Hooks off: the registry's own status is all there is.
87
+ source = 'registry'
88
+ state = peer?.status === 'busy' ? 'working' : peer?.status === 'waiting' ? 'waiting' : 'idle'
89
+ } else if (derived.agent_state === 'ended') {
90
+ state = 'ended'
91
+ } else {
92
+ const hint = runningHintFor(provider, derived, derived.state_source === 'hook' ? hookClock : null, now, null)
93
+ if (hint?.active) state = 'working'
94
+ // A registry `busy` with no hooks: the list reads its transcript clock here; the registry's word is the closest
95
+ // thing this stream has without a file read, and a busy tab writes its transcript continuously.
96
+ else if (!hint && derived.agent_state === 'running' && derived.state_source !== 'hook') state = 'working'
97
+ else if (derived.agent_state === 'waiting') state = 'waiting'
98
+ else state = 'idle'
99
+ }
100
+ const registryTurn = peer && (peer.status === 'busy' || peer.status === 'waiting') ? peer.statusUpdatedAt : null
101
+ return {
102
+ provider,
103
+ sessionId: id,
104
+ state,
105
+ startedAt: newestOf(peer?.startedAt) ?? newestOf(signal?.firstSeenAt),
106
+ turnStartedAt: signal?.turnOpen && signal.turnStartedAt ? signal.turnStartedAt : registryTurn,
107
+ updatedAt: newestOf(hookClock, peer?.statusUpdatedAt),
108
+ source,
109
+ failed: derived?.agent_state === 'failed',
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Which sessions are open, from the two things the server holds without walking transcripts: the Claude registry
115
+ * (alive records) and the hook signal store. Exported for tests with injected inputs.
116
+ */
117
+ export function liveReadings(input: { peers: ClaudePeerRecord[] | null; signals: SessionSignal[]; now: number; signalFor: (id: string) => SessionSignal | undefined; reading?: typeof readingFor }): LiveSessionReading[] {
118
+ const reading = input.reading ?? readingFor
119
+ const out: LiveSessionReading[] = []
120
+ const included = new Set<string>()
121
+ const registryKnown = input.peers !== null
122
+ for (const peer of input.peers ?? []) {
123
+ if (!peer.alive) continue
124
+ const id = peer.sessionId.toLowerCase()
125
+ const key = `claude:${id}`
126
+ if (included.has(key)) continue
127
+ included.add(key)
128
+ out.push(reading('claude', id, peer, input.signalFor(id), input.now))
129
+ }
130
+ for (const signal of input.signals) {
131
+ if (signal.keepWarm) continue
132
+ const provider: LiveProvider = signal.provider === 'codex' || signal.provider === 'cursor' ? signal.provider : 'claude'
133
+ const id = signal.sessionId.toLowerCase()
134
+ const key = `${provider}:${id}`
135
+ if (included.has(key)) continue
136
+ const endedRecently = !!signal.ended && input.now - signal.ended.at <= LIVE_ENDED_LINGER_MS
137
+ const open = !signal.ended && (signal.turnOpen || !!signal.waiting || input.now - signal.lastEventAt <= LIVE_SIGNAL_OPEN_WINDOW_MS)
138
+ // A Claude tab is open while its registry record is alive. With the registry readable and no alive record, a
139
+ // hook record that never said SessionEnd is a tab that died (or a COS child that finished): not open.
140
+ if (provider === 'claude' && registryKnown && !endedRecently) continue
141
+ if (!open && !endedRecently) continue
142
+ included.add(key)
143
+ const r = reading(provider, id, undefined, signal, input.now)
144
+ out.push(endedRecently && r.state !== 'ended' ? { ...r, state: 'ended', turnStartedAt: null } : r)
145
+ }
146
+ return out
147
+ }
148
+
149
+ /**
150
+ * The server's own sources. `broker` is read lazily so the route can mount before the broker exists. `readPeers` is a
151
+ * test seam for the registry read.
152
+ */
153
+ export function realLiveSources(deps: { broker: () => PermissionBroker | null; readPeers?: () => Promise<ClaudePeerRecord[]> }): LiveSources {
154
+ const readPeers = deps.readPeers ?? (() => readClaudePeerRecords(claudeSessionsDir(), undefined, undefined, true))
155
+ // The registry as last read: a hook push rebuilds against it instead of reading the folder (ghost review W4).
156
+ let last: { peers: ClaudePeerRecord[] | null; degraded: boolean } | null = null
157
+ return {
158
+ async sessions(now, opts) {
159
+ let peers: ClaudePeerRecord[] | null = null
160
+ let degraded = false
161
+ if (opts?.hooksOnly && last) {
162
+ peers = last.peers
163
+ degraded = last.degraded
164
+ } else if (claudeSessionsEnabled()) {
165
+ // A folder that cannot be read throws: degraded, and the hub keeps its last good model rather than marking
166
+ // every tab ended (ghost review note 8).
167
+ try { peers = await readPeers() } catch { peers = null; degraded = true }
168
+ last = { peers, degraded }
169
+ } else {
170
+ last = { peers: null, degraded: false }
171
+ }
172
+ const signals = sessionHooksEnabled() ? sessionSignalStore.snapshot() : []
173
+ return { sessions: liveReadings({ peers, signals, now, signalFor }), degraded }
174
+ },
175
+ questions() {
176
+ const broker = deps.broker()
177
+ if (!broker) return []
178
+ return broker.list().map((q): LiveQuestionReading => ({ provider: q.provider, sessionId: q.sessionId, kind: q.kind === 'approval' ? 'approval' : 'question' }))
179
+ },
180
+ queue(now) {
181
+ const rows: LiveQueueReading[] = []
182
+ for (const { provider, threadId } of queuedThreadKeys()) {
183
+ if (provider !== 'claude' && provider !== 'codex' && provider !== 'cursor') continue
184
+ const count = readQueue(provider, threadId, now).filter(t => t.status === 'waiting').length
185
+ if (count > 0) rows.push({ provider, sessionId: threadId, count })
186
+ }
187
+ return rows
188
+ },
189
+ subscribe(listener) {
190
+ if (!sessionHooksEnabled()) return () => {}
191
+ return sessionSignalStore.subscribe((signal, env, child) => {
192
+ listener({ sessionId: signal.sessionId, turnEnded: !child && (env.event === 'Stop' || env.event === 'StopFailure') })
193
+ })
194
+ },
195
+ }
196
+ }
197
+
198
+ export interface LiveEventsRouterDeps {
199
+ hub: LiveEventsHub
200
+ enabled?: boolean
201
+ maxStreams?: number
202
+ heartbeatMs?: number
203
+ maxBufferBytes?: number
204
+ log?: (line: string) => void
205
+ }
206
+
207
+ /**
208
+ * The stream frame on the wire: an `id:` line on every frame a resume can start after (the state frames, and the
209
+ * snapshot, whose seq is the state it describes), then one `data:` line. Heartbeat and resume carry none: they repeat
210
+ * the newest seq, and an id on them would only restate the last one.
211
+ *
212
+ * The snapshot's id means the browser's OWN EventSource reconnect resumes from the snapshot instead of asking for a
213
+ * second whole snapshot when nothing changed in between.
214
+ */
215
+ export function frameText(frame: LiveFrame): string {
216
+ const remembered = frame.kind === 'snapshot' || frame.kind === 'session' || frame.kind === 'session.removed' || frame.kind === 'questions' || frame.kind === 'queue'
217
+ return `${remembered ? `id: ${frame.epoch}.${frame.seq}\n` : ''}data: ${JSON.stringify(frame)}\n\n`
218
+ }
219
+
220
+ export function createLiveEventsRouter(deps: LiveEventsRouterDeps): Router {
221
+ const router = Router()
222
+ if (deps.enabled === false) return router
223
+ router.get('/events', liveEventsHandler(deps))
224
+ return router
225
+ }
226
+
227
+ /** The handler, exported so the backpressure and heartbeat guards can be driven with a fake response. */
228
+ export function liveEventsHandler(deps: LiveEventsRouterDeps): (req: Request, res: Response) => Promise<void> {
229
+ const maxStreams = deps.maxStreams ?? LIVE_EVENTS_MAX_STREAMS
230
+ const heartbeatMs = deps.heartbeatMs ?? LIVE_EVENTS_HEARTBEAT_MS
231
+ const maxBuffer = deps.maxBufferBytes ?? LIVE_EVENTS_MAX_BUFFER_BYTES
232
+ const log = deps.log ?? (line => console.log(line))
233
+ let open = 0
234
+ /** Each open stream's teardown, oldest first (a Map keeps insertion order). */
235
+ const streams = new Map<number, (why: string) => void>()
236
+ let nextStream = 0
237
+
238
+ return async (req: Request, res: Response) => {
239
+ res.set('Cache-Control', 'private, no-store')
240
+ if (open >= maxStreams) {
241
+ const oldest = streams.entries().next().value as [number, (why: string) => void] | undefined
242
+ if (oldest) {
243
+ log(`[live-events] stream cap ${maxStreams} reached: closing the oldest stream to admit a new one (open ${open})`)
244
+ oldest[1]('evicted')
245
+ }
246
+ if (open >= maxStreams) {
247
+ log(`[live-events] stream cap ${maxStreams} reached: refused a new stream, none could be closed (open ${open})`)
248
+ res.status(503).json({ error: 'too many live streams', reason: 'live_events_capacity' })
249
+ return
250
+ }
251
+ }
252
+ open++
253
+ const streamId = nextStream++
254
+ let closed = false
255
+ let detach: (() => void) | null = null
256
+ let heartbeat: ReturnType<typeof setInterval> | null = null
257
+ // Frames that arrive while the snapshot is being prepared, written after it in order. Bounded like the buffer.
258
+ const pending: LiveFrame[] = []
259
+ let deliver: (frame: LiveFrame) => void = frame => {
260
+ pending.push(frame)
261
+ if (pending.length > 512) pending.shift()
262
+ }
263
+
264
+ const teardown = (why = 'closed'): void => {
265
+ if (closed) return
266
+ closed = true
267
+ open--
268
+ streams.delete(streamId)
269
+ if (heartbeat !== null) clearInterval(heartbeat)
270
+ detach?.()
271
+ if (why === 'slow') log(`[live-events] dropped a stream that stopped reading (${res.writableLength} bytes unsent, open ${open})`)
272
+ try { res.end() } catch { /* already gone */ }
273
+ }
274
+ streams.set(streamId, teardown)
275
+ const write = (frame: LiveFrame): void => {
276
+ if (closed) return
277
+ try {
278
+ res.write(frameText(frame))
279
+ // BACKPRESSURE: a client that is not reading is dropped. It reconnects with its last id and resumes or
280
+ // snapshots; holding its frames in memory without limit is how one stuck phone grows the server.
281
+ if (res.writableLength > maxBuffer) teardown('slow')
282
+ } catch {
283
+ teardown()
284
+ }
285
+ }
286
+
287
+ req.on('close', () => teardown())
288
+ res.on('error', () => teardown())
289
+
290
+ try {
291
+ detach = await deps.hub.attach(frame => deliver(frame))
292
+ } catch {
293
+ if (!closed) { closed = true; open--; streams.delete(streamId); res.status(503).json({ error: 'live events unavailable', reason: 'live_events_unavailable' }) }
294
+ return
295
+ }
296
+ if (closed) { detach(); return }
297
+
298
+ res.writeHead(200, {
299
+ 'Content-Type': 'text/event-stream',
300
+ 'Cache-Control': 'no-cache',
301
+ 'Connection': 'keep-alive',
302
+ 'X-Accel-Buffering': 'no',
303
+ })
304
+ res.flushHeaders()
305
+ res.write('retry: 3000\n\n')
306
+
307
+ // The query wins over the header: a manual reconnect sets `?after=`, the browser's own reconnect sends the header.
308
+ const cursor = parseLiveCursor(typeof req.query.after === 'string' ? req.query.after : undefined) ?? parseLiveCursor(req.get('last-event-id'))
309
+ const replay = deps.hub.replayAfter(cursor)
310
+ if (replay === null) {
311
+ write(deps.hub.snapshotFrame())
312
+ // Anything queued while attaching is already in the snapshot when its seq is not newer.
313
+ const snapSeq = deps.hub.newestSeq
314
+ for (const frame of pending.splice(0)) if (frame.seq > snapSeq) write(frame)
315
+ } else {
316
+ for (const frame of replay) write(frame)
317
+ const last = replay.length ? replay[replay.length - 1]!.seq : cursor!.seq
318
+ for (const frame of pending.splice(0)) if (frame.seq > last) write(frame)
319
+ write(deps.hub.resumeFrame(replay.length))
320
+ }
321
+ deliver = write
322
+ // A write during the snapshot or replay can tear the stream down (a throw, or the buffer cap): no heartbeat then,
323
+ // or its interval would outlive the stream (review N1).
324
+ if (closed) return
325
+
326
+ heartbeat = setInterval(() => write(deps.hub.heartbeatFrame()), heartbeatMs)
327
+ ;(heartbeat as { unref?: () => void }).unref?.()
328
+ }
329
+ }
@@ -60,8 +60,9 @@ import {
60
60
  type PermissionBroker,
61
61
  } from '../lib/permission-broker.js'
62
62
  import { noteQuestionsPoll, noteWebPresence, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
63
- import { hasProxyForwardedHeaders, isLoopbackAddress, normalizeRemoteIp, reachableIpv4Addresses } from '../lib/network-policy.js'
63
+ import { hasProxyForwardedHeaders, isLoopbackAddress, isTailscaleIpv4, isTailscaleIpv6, normalizeRemoteIp, reachableIpv4Addresses } from '../lib/network-policy.js'
64
64
  import { networkInterfaces } from 'node:os'
65
+ import { isIP } from 'node:net'
65
66
 
66
67
  export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
67
68
  /** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
@@ -188,23 +189,86 @@ export function questionsPollFacts(query: Record<string, unknown>): QuestionsPol
188
189
  return { hold, presenceAgeMs: hold && validPresenceAgeMs(parsed) ? parsed : null }
189
190
  }
190
191
 
192
+ /**
193
+ * The Mac's own addresses: every external IPv4 (its tailnet address first) and its Tailscale IPv6 (fd7a:115c:a1e0::/48).
194
+ * Safari on the Mac often reaches its own MagicDNS name over IPv6, so the IPv6 tailnet address is the Mac too.
195
+ */
196
+ export function selfAddressesFromInterfaces(
197
+ nets: ReturnType<typeof networkInterfaces> = networkInterfaces(),
198
+ ): string[] {
199
+ const out = reachableIpv4Addresses(nets).map(a => a.address)
200
+ for (const infos of Object.values(nets)) {
201
+ for (const info of infos ?? []) {
202
+ // Node 18.0-18.3 reported the family as a number.
203
+ const v6 = String(info.family) === 'IPv6' || String(info.family) === '6'
204
+ if (v6 && isTailscaleIpv6(info.address)) out.push(info.address.toLowerCase())
205
+ }
206
+ }
207
+ return out
208
+ }
209
+
210
+ /** Headers `tailscale serve` adds to a hop it forwards (ipn/ipnlocal/serve.go). Anything else proxy-shaped is not Serve. */
211
+ const SERVE_HOP_HEADERS = new Set([
212
+ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto',
213
+ 'tailscale-user-login', 'tailscale-user-name', 'tailscale-user-profile-pic', 'tailscale-headers-info',
214
+ ])
215
+
216
+ /**
217
+ * W12 (QA week of 11/01-11/08): a `tailscale serve` hop from the Mac to itself. Control Web opened at the Mac's own
218
+ * https://<mac>.ts.net is the desk, but Serve forwards it over loopback with proxy headers, so 6.99.26 stored its touch
219
+ * as away and a web-only hold waited up to 10 minutes. It is the Mac only when ALL of these hold:
220
+ * - the socket is loopback: the local Serve proxy connected (a peer reaching 3141 directly has its own address);
221
+ * - no `Forwarded` or `X-Real-IP` (Serve never sets either);
222
+ * - exactly one `X-Forwarded-For` address, no list. Serve SETS that header to the hop's real source (Header.Set, and
223
+ * Go's ReverseProxy drops the incoming X-Forwarded-* before its Rewrite runs), so a remote device's spoofed value
224
+ * never survives; a proxy that appended instead would make a list, which is refused;
225
+ * - that address is loopback or one of the Mac's own TAILSCALE addresses (Serve listens on the tailnet only).
226
+ */
227
+ export function serveHopFromThisMac(
228
+ req: { socket?: { remoteAddress?: string | undefined } | null; headers?: Record<string, unknown> | null },
229
+ selfAddresses: () => readonly string[],
230
+ ): boolean {
231
+ const headers = req.headers
232
+ if (!headers || typeof headers !== 'object') return false
233
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) return false
234
+ for (const name of ['forwarded', 'x-real-ip']) {
235
+ const v = headers[name]
236
+ if (v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === '')) return false
237
+ }
238
+ // Only Serve's own headers: an unknown proxy-shaped header means a hop this check does not understand.
239
+ for (const name of Object.keys(headers)) {
240
+ if (/^(x-forwarded-|tailscale-)/i.test(name) && !SERVE_HOP_HEADERS.has(name.toLowerCase())) return false
241
+ }
242
+ const xff = headers['x-forwarded-for']
243
+ if (typeof xff !== 'string') return false
244
+ // One address and nothing else: a list ("a, b"), a padded pair or a name is not an IP, so it is refused here.
245
+ const ip = normalizeRemoteIp(xff.trim()).toLowerCase()
246
+ if (isIP(ip) === 0) return false
247
+ if (isLoopbackAddress(ip)) return true
248
+ if (!isTailscaleIpv4(ip) && !isTailscaleIpv6(ip)) return false
249
+ let own: readonly string[] = []
250
+ try { own = selfAddresses() } catch { own = [] }
251
+ return own.some(a => normalizeRemoteIp(String(a)).toLowerCase() === ip)
252
+ }
253
+
191
254
  /**
192
255
  * 6.99.26 (QA round 1): a request from this Mac itself: its socket is loopback or one of the Mac's own addresses (its
193
256
  * tailnet address first), and no proxy header says it was forwarded for someone else (a `tailscale serve` hop arrives on
194
257
  * loopback for a remote peer). Control Web open on the Mac is the desk: its touch never makes the Mac away.
258
+ * W12: a proxied request is the Mac only as a Serve hop from the Mac to itself (serveHopFromThisMac).
195
259
  */
196
260
  export function requestFromThisMac(
197
261
  req: { socket?: { remoteAddress?: string | undefined } | null; headers?: Record<string, unknown> | null },
198
- selfAddresses: () => readonly string[] = () => reachableIpv4Addresses(networkInterfaces()).map(a => a.address),
262
+ selfAddresses: () => readonly string[] = () => selfAddressesFromInterfaces(),
199
263
  ): boolean {
200
- if (hasProxyForwardedHeaders(req.headers)) return false
264
+ if (hasProxyForwardedHeaders(req.headers)) return serveHopFromThisMac(req, selfAddresses)
201
265
  const remote = req.socket?.remoteAddress
202
266
  if (isLoopbackAddress(remote)) return true
203
- const ip = normalizeRemoteIp(remote ?? '')
267
+ const ip = normalizeRemoteIp(remote ?? '').toLowerCase()
204
268
  if (!ip) return false
205
269
  let own: readonly string[] = []
206
270
  try { own = selfAddresses() } catch { own = [] }
207
- return own.includes(ip)
271
+ return own.some(a => normalizeRemoteIp(String(a)).toLowerCase() === ip)
208
272
  }
209
273
 
210
274
  export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The preview contract's routes (preview engine phase 1, PLAN 4c), mounted inside agentSessionsRouter, so behind the
3
+ * shared /api gate. Neither a request nor a response carries a filesystem path: a card or file is named by an opaque
4
+ * id that the resolver re-authorizes on every call (agent-session-output-images.ts `previewFile`).
5
+ *
6
+ * GET /api/preview/agent-sessions/:provider/:sessionId/:id/meta
7
+ * GET /api/preview/agent-sessions/:provider/:sessionId/:id/content?part=raw|inline|text|table|html|html-run|thumb
8
+ * GET|POST /api/preview-folders/settings the Settings extra folders (lib/preview-folders-setting.ts)
9
+ *
10
+ * "Show session files, screenshots and images" (7.0.4 name) off answers 403 previews_off before any transcript or file is read.
11
+ * A denied secret name answers 403 secret_file_denied (as the 7.0.4 output routes do). A folder card has no content (phase 2 lists folders).
12
+ * Every refusal or failure but a missing id logs one line (`logPreviewRefusal`): reason code, provider, part, kind.
13
+ */
14
+ import { Router, type Request, type Response } from 'express'
15
+ import { agentSessionOutputImages } from '../lib/agent-session-output-images.js'
16
+ import { sessionFilesSetting } from '../lib/session-files-setting.js'
17
+ import { previewFoldersSetting, folderDisplay, type PreviewFoldersStore } from '../lib/preview-folders-setting.js'
18
+ import { previewThumbnailer } from '../lib/preview-thumbs.js'
19
+ import {
20
+ HTML_PREVIEW_MAX, looksBinary, PREVIEW_PARTS, previewHeaders, tablePage, TEXT_WINDOW_MAX, textWindow, withCspMeta, type PreviewPart,
21
+ } from '../lib/preview-content.js'
22
+ import type { PreviewKind } from '../lib/session-links.js'
23
+ import { previewErrorCode } from '../lib/preview-error-code.js'
24
+ import { warnPreviewThrottled } from '../lib/preview-log-throttle.js'
25
+ /** The 7.0.4 code (routes/agent-sessions.ts PREVIEWS_BUSY), repeated here so this router needs nothing from that one. */
26
+ const PREVIEWS_BUSY = 'previews_busy'
27
+
28
+ type Previews = Pick<typeof agentSessionOutputImages, 'previewFile' | 'previewImage'>
29
+ type Thumbs = Pick<typeof previewThumbnailer, 'thumbnail'>
30
+ export interface SessionPreviewDeps {
31
+ previews?: Previews
32
+ enabled?: () => boolean
33
+ thumbs?: Thumbs
34
+ folders?: PreviewFoldersStore
35
+ home?: () => string
36
+ }
37
+
38
+ const PROVIDERS = new Set(['claude', 'codex', 'cursor'])
39
+ const TEXT_KINDS = new Set<PreviewKind>(['markdown', 'text', 'code', 'html', 'table'])
40
+ const THUMB_KINDS = new Set<PreviewKind>(['pdf', 'document', 'slides', 'sheet', 'html'])
41
+ const NORMALIZED = /\.(png|jpe?g|webp|hei[cf]|avif)$/i
42
+
43
+ /** Which parts a kind has. The web reads this from `meta` and never asks for another. */
44
+ export function partsFor(kind: PreviewKind, name: string): PreviewPart[] {
45
+ const parts: PreviewPart[] = []
46
+ if (kind === 'folder') return parts
47
+ parts.push('raw')
48
+ if (kind === 'pdf') parts.push('inline')
49
+ if (kind === 'image' && NORMALIZED.test(name)) parts.push('inline')
50
+ if (TEXT_KINDS.has(kind)) parts.push('text')
51
+ if (kind === 'table') parts.push('table')
52
+ if (kind === 'html') parts.push('html', 'html-run')
53
+ if (THUMB_KINDS.has(kind)) parts.push('thumb')
54
+ if (kind === 'image' && NORMALIZED.test(name)) parts.push('thumb')
55
+ return parts
56
+ }
57
+
58
+ /** What a refusal or failure is logged with. Only fixed words: never a path, a file name, an id, a token or content. */
59
+ interface LogAt { route: 'meta' | 'content'; provider: string; part?: string; kind?: PreviewKind; detail?: string }
60
+ const SAFE_WORD = /^[a-z0-9_-]{1,40}$/i
61
+ /**
62
+ * QA 7.0.5 (the 7.0.4 promise, for the preview engine's routes): one line per refusal or failure, with a stable reason
63
+ * code and the provider, part and file kind. A missing id (404 preview_not_found) is ordinary and is not logged, as in
64
+ * the 7.0.4 output routes. 7.0.7 (final QA N1): a burst of the same line (a page of thumbnails while another client is
65
+ * busy) logs once per 10 seconds with the count it held back, through the 7.0.4 routes' own limiter.
66
+ */
67
+ export function logPreviewRefusal(at: LogAt, error: string, now = Date.now()): void {
68
+ if (error === 'preview_not_found') return
69
+ const words = [`reason=${error}`, `provider=${PROVIDERS.has(at.provider) ? at.provider : 'unknown'}`]
70
+ if (at.part && SAFE_WORD.test(at.part)) words.push(`part=${at.part}`)
71
+ if (at.kind && SAFE_WORD.test(at.kind)) words.push(`kind=${at.kind}`)
72
+ if (at.detail && SAFE_WORD.test(at.detail)) words.push(`detail=${at.detail}`)
73
+ const line = `[preview] ${at.route} refused ${words.join(' ')}`
74
+ warnPreviewThrottled(line, line, now)
75
+ }
76
+
77
+ function refuse(res: Response, status: string, at: LogAt) {
78
+ const map: Record<string, [number, string]> = {
79
+ not_found: [404, 'preview_not_found'], unavailable: [503, 'preview_unavailable'], too_large: [413, 'preview_too_large'],
80
+ secret: [403, 'secret_file_denied'], folder: [404, 'preview_folder'], busy: [503, PREVIEWS_BUSY],
81
+ }
82
+ const [code, error] = map[status] ?? [503, 'preview_unavailable']
83
+ logPreviewRefusal(at, error)
84
+ if (status === 'busy') res.set('Retry-After', '2')
85
+ res.status(code).json({ error })
86
+ }
87
+ /** A refusal the route answers itself (not through `refuse`), logged the same way. */
88
+ function answer(res: Response, code: number, error: string, at: LogAt) {
89
+ logPreviewRefusal(at, error)
90
+ res.status(code).json({ error })
91
+ }
92
+
93
+ export function createSessionPreviewRouter(deps: SessionPreviewDeps = {}): Router {
94
+ const router = Router()
95
+ const previews = deps.previews ?? agentSessionOutputImages
96
+ const enabled = deps.enabled ?? (() => sessionFilesSetting.read().enabled)
97
+ const thumbs = deps.thumbs ?? previewThumbnailer
98
+ const folders = deps.folders ?? previewFoldersSetting
99
+
100
+ const gate = (req: Request, res: Response, route: LogAt['route']): { provider: 'claude' | 'codex' | 'cursor'; sessionId: string; id: string } | null => {
101
+ res.set('Cache-Control', 'private, no-store')
102
+ res.set('X-Content-Type-Options', 'nosniff')
103
+ const provider = String(req.params.provider ?? '').toLowerCase()
104
+ // The switch decides before anything is read (no transcript, no file, no stat).
105
+ if (!enabled()) { answer(res, 403, 'previews_off', { route, provider }); return null }
106
+ if (!PROVIDERS.has(provider)) { res.status(404).json({ error: 'preview_not_found' }); return null }
107
+ return { provider: provider as 'claude' | 'codex' | 'cursor', sessionId: String(req.params.sessionId ?? ''), id: String(req.params.id ?? '') }
108
+ }
109
+
110
+ router.get('/preview/agent-sessions/:provider/:sessionId/:id/meta', async (req, res) => {
111
+ const g = gate(req, res, 'meta'); if (!g) return
112
+ const at: LogAt = { route: 'meta', provider: g.provider }
113
+ try {
114
+ const found = await previews.previewFile(g.provider, g.sessionId, g.id, true)
115
+ if (found.status === 'folder') { res.json({ id: g.id, name: '', kind: 'folder', type: 'Folder', state: 'available', parts: [] }); return }
116
+ if (found.status !== 'ok') { refuse(res, found.status, { ...at, detail: found.reason }); return }
117
+ res.json({ id: g.id, name: found.name, kind: found.kind, type: found.type, size: found.identity.size, ...(found.line ? { line: found.line } : {}), state: 'available', parts: partsFor(found.kind, found.name) })
118
+ } catch (error) { refuse(res, 'unavailable', { ...at, detail: 'error_' + previewErrorCode(error) }) }
119
+ })
120
+
121
+ router.get('/preview/agent-sessions/:provider/:sessionId/:id/content', async (req, res) => {
122
+ const g = gate(req, res, 'content'); if (!g) return
123
+ const part = String(req.query.part ?? 'raw') as PreviewPart
124
+ const at: LogAt = { route: 'content', provider: g.provider }
125
+ if (!PREVIEW_PARTS.includes(part)) { answer(res, 400, 'preview_part', at); return }
126
+ at.part = part
127
+ try {
128
+ const found = await previews.previewFile(g.provider, g.sessionId, g.id)
129
+ if (found.status !== 'ok') { refuse(res, found.status, { ...at, detail: found.reason }); return }
130
+ at.kind = found.kind
131
+ if (!partsFor(found.kind, found.name).includes(part)) { answer(res, 400, 'preview_part', at); return }
132
+ // A picture goes through the output-image normalizer, as a JPEG, never as the bytes a file claims to be.
133
+ if (found.kind === 'image' && part !== 'raw') {
134
+ const img = await previews.previewImage(g.provider, g.sessionId, g.id, part === 'thumb' ? 'thumb' : 'phone')
135
+ if (img.status !== 'ok') { refuse(res, img.status, { ...at, detail: 'reason' in img ? img.reason : undefined }); return }
136
+ if (!('path' in img)) { refuse(res, 'unavailable', at); return }
137
+ res.set({ 'Content-Type': 'image/jpeg', 'Content-Disposition': 'inline', 'Content-Security-Policy': "sandbox; default-src 'none'", 'Cross-Origin-Resource-Policy': 'same-origin' })
138
+ res.sendFile(img.path, { dotfiles: 'allow', headers: { 'Content-Type': 'image/jpeg' } }, err => {
139
+ if (!err) return
140
+ if (!res.headersSent) refuse(res, 'unavailable', { ...at, detail: 'send_' + previewErrorCode(err) }); else { logPreviewRefusal({ ...at, detail: 'send_' + previewErrorCode(err) }, 'preview_cut'); res.destroy() }
141
+ })
142
+ return
143
+ }
144
+ const bytes = found.bytes
145
+ if (part === 'raw' || part === 'inline') { res.set(previewHeaders(part, found.name)); res.status(200).end(bytes); return }
146
+ if (part === 'thumb') {
147
+ const t = await thumbs.thumbnail(bytes, found.name, found.identity)
148
+ if (t.status !== 'ok') {
149
+ if (t.status === 'busy') res.set('Retry-After', '2')
150
+ answer(res, 503, t.status === 'busy' ? PREVIEWS_BUSY : 'thumb_unavailable', { ...at, detail: t.status === 'unavailable' ? t.reason : undefined })
151
+ return
152
+ }
153
+ res.set(previewHeaders('thumb', found.name))
154
+ res.sendFile(t.path, { dotfiles: 'allow', headers: { 'Content-Type': 'image/png' } }, err => {
155
+ if (!err) return
156
+ if (!res.headersSent) answer(res, 503, 'thumb_unavailable', { ...at, detail: 'send_' + previewErrorCode(err) }); else { logPreviewRefusal({ ...at, detail: 'send_' + previewErrorCode(err) }, 'preview_cut'); res.destroy() }
157
+ })
158
+ return
159
+ }
160
+ if (looksBinary(bytes)) { answer(res, 415, 'preview_binary', at); return }
161
+ if (part === 'html' || part === 'html-run') {
162
+ if (bytes.length > HTML_PREVIEW_MAX) { refuse(res, 'too_large', at); return }
163
+ // PLAN 6: the policy is in the page itself too, before anything in it can load.
164
+ res.set(previewHeaders(part, found.name)); res.status(200).end(withCspMeta(bytes, part)); return
165
+ }
166
+ if (part === 'table') {
167
+ res.set(previewHeaders('table', found.name))
168
+ res.json(tablePage(bytes, found.name, Number(req.query.offset ?? 0)))
169
+ return
170
+ }
171
+ // Every text window, an HTML page's source included, is 512 KiB a call with "load more".
172
+ res.set(previewHeaders('text', found.name))
173
+ res.json({ ...textWindow(bytes, Number(req.query.offset ?? 0), Number(req.query.limit ?? TEXT_WINDOW_MAX), TEXT_WINDOW_MAX), ...(found.line ? { line: found.line } : {}) })
174
+ } catch (error) {
175
+ refuse(res, 'unavailable', { ...at, detail: 'error_' + previewErrorCode(error) })
176
+ }
177
+ })
178
+
179
+ // Settings, Privacy: the named extra folders. Shown as ~/x inside home. A refusal names each folder's problem.
180
+ router.get('/preview-folders/settings', (_req, res) => {
181
+ res.set('Cache-Control', 'private, no-store')
182
+ try {
183
+ const s = folders.read()
184
+ res.json({ folders: s.folders.map(f => folderDisplay(f, deps.home?.())), source: s.source })
185
+ } catch { res.status(500).json({ error: 'preview_folders_unavailable' }) }
186
+ })
187
+ router.post('/preview-folders/settings', (req, res) => {
188
+ res.set('Cache-Control', 'private, no-store')
189
+ const body = req.body
190
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'folders') || !Array.isArray(body.folders)) {
191
+ res.status(400).json({ error: { code: 'invalid_preview_folders', message: 'Send { folders: ["~/cos-worktrees"] }.' } }); return
192
+ }
193
+ try {
194
+ const r = folders.set(body.folders)
195
+ if (!r.ok) { res.status(400).json({ error: { code: 'preview_folder_refused', message: r.errors[0]?.message ?? 'That folder cannot be used.' }, errors: r.errors }); return }
196
+ res.json({ folders: r.setting.folders.map(f => folderDisplay(f, deps.home?.())), source: r.setting.source })
197
+ } catch { res.status(500).json({ error: 'preview_folders_unavailable' }) }
198
+ })
199
+ return router
200
+ }