@gotcos/glasses-server 6.99.33 → 7.0.18

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 (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,320 @@
1
+ // 7.0.18 (X1, Miles 2026-11-10 07:14, "fold this in ... so I can respond from glasses, web app and desktop"):
2
+ // a session whose turn ENDS by asking a question in the Claude desktop app's next-step box.
3
+ //
4
+ // WHAT HAPPENS: the session calls `mcp__ccd_turn__wrap_up` with `ask` and reply buttons (`actions[].label`),
5
+ // then its turn stops. There is no AskUserQuestion and no PermissionRequest, so the permission broker never
6
+ // sees it, and the row read idle: the glasses said nothing was waiting (Miles, 07:02).
7
+ //
8
+ // WHAT COS RECEIVES (canaries C15 on the CLI with a stand-in ccd_turn server, C17 in a real Claude desktop
9
+ // Code tab, 2026-11-10): the COS hook spools PostToolUse for every tool, and that payload carries
10
+ // `tool_input.ask` and `tool_input.actions` verbatim, plus `prompt_id`; the Stop follows (about 5 s in the
11
+ // canary; the PostToolUse hook is async, so either order). PreToolUse is not spooled for this tool.
12
+ //
13
+ // WHAT THIS FILE DOES: keeps, per session, the newest qualifying ask (only an ask with at least one reply
14
+ // button, or `blocked`), marks it asked once the turn has stopped, shows it only after the Mac's desk has
15
+ // been idle for the broker's threshold (then latched, so the frozen glasses stay quiet while Miles sits at
16
+ // the Mac), and drops it on any reply, a new turn, the session ending, or 8 hours. The words (the ask, 80
17
+ // characters; reply labels, 40 each, at most 2) follow COS_WAITING_QUESTION_WORDS; with that at 0 the row
18
+ // still says a question waits, with no words. App-action buttons (commit, create PR) are never choices.
19
+ //
20
+ // THE SWITCH: `COS_TURN_END_ASKS`, read once (the first read, at startup), default in TURN_END_ASKS_DEFAULT.
21
+ // Off: nothing here runs, nothing is projected into the ledger, and every row is 7.0.17's.
22
+ //
23
+ // THE ANSWER is the existing Continue (contract v1, COORD 07:18), with text that never reads as typed at the
24
+ // desk. Memory and counters only; nothing here logs a question's words.
25
+
26
+ import { readSwitch } from './env-switch.js'
27
+ import { questionRowText } from './full-hold.js'
28
+ import type { HookEnvelope } from './session-hook-events.js'
29
+
30
+ /** The default when COS_TURN_END_ASKS is unset. One constant, per the lead (Miles chooses the value). */
31
+ export const TURN_END_ASKS_DEFAULT = true
32
+
33
+ /** The Claude desktop app's next-step tool (its `ccd_turn` MCP server). */
34
+ export const WRAP_UP_TOOL = 'mcp__ccd_turn__wrap_up'
35
+ export const TURN_ASK_MAX_CHARS = 80
36
+ export const TURN_ASK_CHOICE_MAX_CHARS = 40
37
+ export const TURN_ASK_MAX_CHOICES = 2
38
+ /** A question nobody answered in 8 hours is not news the next morning; it stays in the transcript. */
39
+ export const TURN_ASK_MAX_AGE_MS = 8 * 60 * 60_000
40
+ /** The shortest gap between two desk reads while an ask waits for the latch. */
41
+ export const TURN_ASK_DESK_POLL_MS = 3_000
42
+ /** When the desk cannot be read, the next try. */
43
+ export const TURN_ASK_UNREADABLE_RETRY_MS = 60_000
44
+ /** The latch never fires for a desk idle less than this, whatever the setting says (the broker's floor). */
45
+ export const TURN_ASK_DESK_IDLE_MIN_S = 30
46
+ /** Sessions tracked at once; past it the oldest go. */
47
+ export const MAX_TURN_ASKS = 128
48
+ /** The registry saying busy this long after the stop means the session runs again. */
49
+ export const TURN_ASK_BUSY_GRACE_MS = 5_000
50
+
51
+ let enabledAtStartup: boolean | null = null
52
+
53
+ /** `COS_TURN_END_ASKS`, read on the first call (startup) and kept: a change needs a restart. */
54
+ export function turnEndAsksEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
55
+ if (enabledAtStartup === null) enabledAtStartup = readSwitch(env.COS_TURN_END_ASKS, TURN_END_ASKS_DEFAULT)
56
+ return enabledAtStartup
57
+ }
58
+
59
+ /** Tests only: set the switch, or null to read it again on the next call. */
60
+ export function __setTurnEndAsksForTests(value: boolean | null): void {
61
+ enabledAtStartup = value
62
+ }
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return !!value && typeof value === 'object' && !Array.isArray(value)
66
+ }
67
+
68
+ function rowText(value: unknown, max: number): string {
69
+ const text = questionRowText(value).replace(/\s+/g, ' ').trim()
70
+ return text.length > max ? text.slice(0, max - 1) + '…' : text
71
+ }
72
+
73
+ /** What is kept of one ask: already cleaned and bounded; empty words when the words switch is off. */
74
+ export interface TurnAskCapture { ask: string; choices: string[]; blocked: boolean }
75
+
76
+ /**
77
+ * A `wrap_up` input read strictly. Null unless it carries a non-blank `ask` AND (at least one reply button
78
+ * or `blocked: true`): an ask with no buttons is the session's suggestion, not a decision it stopped on. A
79
+ * reply button has a `label` and no `action`; a button with an `action` (commit, create PR) is the app's own
80
+ * and never a choice. `words` false keeps the fact and drops every word.
81
+ */
82
+ export function captureTurnAsk(toolInput: unknown, words: boolean): TurnAskCapture | null {
83
+ if (!isRecord(toolInput)) return null
84
+ const ask = rowText(toolInput.ask, TURN_ASK_MAX_CHARS)
85
+ if (!ask) return null
86
+ const labels: string[] = []
87
+ if (Array.isArray(toolInput.actions)) {
88
+ for (const action of toolInput.actions) {
89
+ if (!isRecord(action)) continue
90
+ if (action.action !== undefined && action.action !== null && action.action !== '') continue
91
+ const label = rowText(action.label, TURN_ASK_CHOICE_MAX_CHARS)
92
+ if (label) labels.push(label)
93
+ }
94
+ }
95
+ const blocked = toolInput.blocked === true
96
+ if (labels.length === 0 && !blocked) return null
97
+ return { ask: words ? ask : '', choices: words ? labels.slice(0, TURN_ASK_MAX_CHOICES) : [], blocked }
98
+ }
99
+
100
+ /** The ledger's copy (`projectHookPayload`): the capture as stored, so a restart replays it. */
101
+ export function projectedTurnAsk(value: unknown): TurnAskCapture | null {
102
+ if (!isRecord(value) || typeof value.ask !== 'string' || !Array.isArray(value.choices)) return null
103
+ const choices = value.choices.filter((c): c is string => typeof c === 'string').slice(0, TURN_ASK_MAX_CHOICES).map(c => rowText(c, TURN_ASK_CHOICE_MAX_CHARS)).filter(Boolean)
104
+ return { ask: rowText(value.ask, TURN_ASK_MAX_CHARS), choices, blocked: value.blocked === true }
105
+ }
106
+
107
+ /** What a row shows (`deriveSessionState` input). */
108
+ export interface VisibleTurnAsk { ask: string; choices: string[]; stoppedAt: number }
109
+
110
+ interface AskState {
111
+ sessionId: string
112
+ promptId: string | null
113
+ capture: TurnAskCapture
114
+ armedAt: number
115
+ stoppedAt: number | null
116
+ visibleAt: number | null
117
+ }
118
+
119
+ export interface TurnEndAskTrackerDeps {
120
+ now(): number
121
+ enabled(): boolean
122
+ /** COS_WAITING_QUESTION_WORDS (default on): the words, or the fact alone. */
123
+ words(): boolean
124
+ readDeskIdleSeconds(): Promise<number | null>
125
+ deskIdleSeconds(): number
126
+ pollMs?: number
127
+ }
128
+
129
+ export type TurnEndAskCounters = { armed: number; shown: number; clearedByReply: number; clearedByTurn: number; agedOut: number }
130
+
131
+ /** Events that end a session's present: whatever it asked is moot. */
132
+ const SESSION_RESETS: ReadonlySet<string> = new Set(['SessionEnd', 'Interrupt', 'StopFailure'])
133
+
134
+ function str(value: unknown): string | null {
135
+ return typeof value === 'string' && value.length > 0 ? value : null
136
+ }
137
+
138
+ export class TurnEndAskTracker {
139
+ private readonly deps: TurnEndAskTrackerDeps
140
+ private readonly asks = new Map<string, AskState>()
141
+ /** The newest Stop per session, for a wrap_up whose (async) PostToolUse lands after it. */
142
+ private readonly lastStop = new Map<string, { at: number; promptId: string | null }>()
143
+ private poll: ReturnType<typeof setTimeout> | null = null
144
+ private pollInFlight = false
145
+ private stopped = false
146
+ readonly counters: TurnEndAskCounters = { armed: 0, shown: 0, clearedByReply: 0, clearedByTurn: 0, agedOut: 0 }
147
+
148
+ constructor(deps: TurnEndAskTrackerDeps) {
149
+ this.deps = deps
150
+ }
151
+
152
+ /**
153
+ * One applied hook event (the signal store's subscriber: the raw envelope live, the ledger's projection
154
+ * on replay). `signal` is the store's signal after the event; only its registry `entrypoint` is read.
155
+ */
156
+ observe(env: HookEnvelope, child: boolean, signal?: { entrypoint: string | null }): void {
157
+ if (!this.deps.enabled()) return
158
+ const p = env.payload
159
+ // A sub-agent's events are never the session asking you, and never its reply.
160
+ if (str(p.agent_id)) return
161
+ const sessionId = env.sessionId.toLowerCase()
162
+ const current = this.asks.get(sessionId)
163
+ // Any prompt, from the desk or a COS-spawned Continue child, is a reply: the question is answered.
164
+ if (env.event === 'UserPromptSubmit') {
165
+ if (current) { this.asks.delete(sessionId); this.counters.clearedByReply++ }
166
+ return
167
+ }
168
+ // A COS-spawned child's other events are not the session's own turn.
169
+ if (child) return
170
+ if (SESSION_RESETS.has(env.event) || (env.event === 'SessionStart' && p.source !== 'compact')) {
171
+ if (current) { this.asks.delete(sessionId); this.counters.clearedByTurn++ }
172
+ this.lastStop.delete(sessionId)
173
+ return
174
+ }
175
+ if (env.event === 'Stop') {
176
+ const promptId = str(p.prompt_id)
177
+ this.rememberStop(sessionId, env.ts, promptId)
178
+ if (current && current.stoppedAt === null && env.ts >= current.armedAt && (current.promptId === null || promptId === null || current.promptId === promptId)) {
179
+ current.stoppedAt = env.ts
180
+ this.ensurePolling()
181
+ }
182
+ return
183
+ }
184
+ const toolName = typeof p.tool_name === 'string' ? p.tool_name : ''
185
+ if (env.event === 'PostToolUse' && toolName === WRAP_UP_TOOL) {
186
+ // A print run never asks anyone (the broker's own rule: registry entrypoint sdk-cli).
187
+ if (signal?.entrypoint === 'sdk-cli') return
188
+ const capture = p.turn_ask !== undefined ? projectedTurnAsk(p.turn_ask) : captureTurnAsk(p.tool_input, this.deps.words())
189
+ const promptId = str(p.prompt_id)
190
+ if (!capture) {
191
+ // A later wrap_up of the same turn without a qualifying ask disarms the earlier one.
192
+ if (current && current.stoppedAt === null) this.asks.delete(sessionId)
193
+ return
194
+ }
195
+ const stop = this.lastStop.get(sessionId)
196
+ const stoppedAlready = !!stop && env.ts <= stop.at && (stop.promptId === null || promptId === null || stop.promptId === promptId)
197
+ this.asks.set(sessionId, { sessionId, promptId, capture, armedAt: env.ts, stoppedAt: stoppedAlready ? stop!.at : null, visibleAt: null })
198
+ this.counters.armed++
199
+ this.prune()
200
+ if (stoppedAlready) this.ensurePolling()
201
+ return
202
+ }
203
+ // Any other tool step after the stop, from another prompt, is a new turn running: the ask is moot.
204
+ if (current && current.stoppedAt !== null && env.ts > current.stoppedAt && (env.event === 'PreToolUse' || env.event === 'PostToolUse' || env.event === 'PostToolUseFailure' || env.event === 'PermissionRequest')) {
205
+ const promptId = str(p.prompt_id)
206
+ if (promptId === null || current.promptId === null || promptId !== current.promptId) {
207
+ this.asks.delete(sessionId)
208
+ this.counters.clearedByTurn++
209
+ }
210
+ }
211
+ }
212
+
213
+ private rememberStop(sessionId: string, at: number, promptId: string | null): void {
214
+ this.lastStop.delete(sessionId)
215
+ this.lastStop.set(sessionId, { at, promptId })
216
+ if (this.lastStop.size > MAX_TURN_ASKS) {
217
+ const oldest = this.lastStop.keys().next().value
218
+ if (oldest !== undefined) this.lastStop.delete(oldest)
219
+ }
220
+ }
221
+
222
+ /** The ask a row shows now: stopped, latched visible, not older than the age-out. */
223
+ visibleAsk(sessionId: string, now = this.deps.now()): VisibleTurnAsk | null {
224
+ if (!this.deps.enabled()) return null
225
+ const key = sessionId.toLowerCase()
226
+ const state = this.asks.get(key) ?? (key.length < 36 ? [...this.asks.values()].find(s => s.sessionId.startsWith(key)) : undefined)
227
+ if (!state || state.stoppedAt === null) return null
228
+ if (now - state.stoppedAt > TURN_ASK_MAX_AGE_MS) {
229
+ this.asks.delete(state.sessionId)
230
+ this.counters.agedOut++
231
+ return null
232
+ }
233
+ if (state.visibleAt === null) return null
234
+ return { ask: state.capture.ask, choices: [...state.capture.choices], stoppedAt: state.stoppedAt }
235
+ }
236
+
237
+ /** The session's row ended: nothing it asked can still be answered. */
238
+ sessionRowEnded(sessionId: string): void {
239
+ const key = sessionId.toLowerCase()
240
+ for (const state of [...this.asks.values()]) {
241
+ if (state.sessionId === key || (key.length < 36 && state.sessionId.startsWith(key))) { this.asks.delete(state.sessionId); this.counters.clearedByTurn++ }
242
+ }
243
+ }
244
+
245
+ private prune(): void {
246
+ const now = this.deps.now()
247
+ for (const state of [...this.asks.values()]) {
248
+ if (state.stoppedAt !== null && now - state.stoppedAt > TURN_ASK_MAX_AGE_MS) { this.asks.delete(state.sessionId); this.counters.agedOut++ }
249
+ }
250
+ while (this.asks.size > MAX_TURN_ASKS) {
251
+ const oldest = [...this.asks.values()].sort((a, b) => a.armedAt - b.armedAt)[0]
252
+ if (!oldest) break
253
+ this.asks.delete(oldest.sessionId)
254
+ }
255
+ }
256
+
257
+ private waitingForLatch(): boolean {
258
+ for (const state of this.asks.values()) if (state.stoppedAt !== null && state.visibleAt === null) return true
259
+ return false
260
+ }
261
+
262
+ private ensurePolling(): void {
263
+ if (this.poll || this.stopped || !this.waitingForLatch()) return
264
+ this.schedule(this.deps.pollMs ?? TURN_ASK_DESK_POLL_MS)
265
+ }
266
+
267
+ private schedule(delayMs: number): void {
268
+ this.stopPolling()
269
+ if (this.stopped) return
270
+ this.poll = setTimeout(() => {
271
+ this.poll = null
272
+ void this.tick().then(next => { if (next !== null) this.schedule(next) })
273
+ }, delayMs)
274
+ this.poll.unref?.()
275
+ }
276
+
277
+ private stopPolling(): void {
278
+ if (this.poll) clearTimeout(this.poll)
279
+ this.poll = null
280
+ }
281
+
282
+ private thresholdS(): number {
283
+ const configured = Number(this.deps.deskIdleSeconds())
284
+ return Math.max(TURN_ASK_DESK_IDLE_MIN_S, Number.isFinite(configured) ? configured : TURN_ASK_DESK_IDLE_MIN_S)
285
+ }
286
+
287
+ /**
288
+ * One desk read while an ask waits: idle at or past the threshold latches every stopped ask visible.
289
+ * Returns when to read again (ms), or null when nothing waits. The desk cannot reach the threshold
290
+ * sooner than (threshold - idle) seconds, so a busy desk is read about once per threshold, not every
291
+ * poll: an unanswered ask on a Mac in use costs one `ioreg` a minute or so, never one every 3 s.
292
+ */
293
+ async tick(): Promise<number | null> {
294
+ this.prune()
295
+ if (!this.waitingForLatch()) return null
296
+ const pollMs = this.deps.pollMs ?? TURN_ASK_DESK_POLL_MS
297
+ if (this.pollInFlight) return pollMs
298
+ this.pollInFlight = true
299
+ let idle: number | null = null
300
+ try { idle = await this.deps.readDeskIdleSeconds() } catch { idle = null } finally { this.pollInFlight = false }
301
+ // Unreadable: nothing is shown (it never guesses the desk is empty); read again later.
302
+ if (idle === null || !Number.isFinite(idle)) return Math.max(pollMs, TURN_ASK_UNREADABLE_RETRY_MS)
303
+ const threshold = this.thresholdS()
304
+ if (idle < threshold) return Math.max(pollMs, Math.ceil((threshold - idle) * 1000))
305
+ const now = this.deps.now()
306
+ for (const state of this.asks.values()) {
307
+ if (state.stoppedAt !== null && state.visibleAt === null) { state.visibleAt = now; this.counters.shown++ }
308
+ }
309
+ return null
310
+ }
311
+
312
+ stats(): TurnEndAskCounters & { tracked: number } {
313
+ return { ...this.counters, tracked: this.asks.size }
314
+ }
315
+
316
+ stop(): void {
317
+ this.stopped = true
318
+ this.stopPolling()
319
+ }
320
+ }
@@ -11,9 +11,9 @@
11
11
  // message that landed in the wrong session does not verify there. An unknown provider keeps the
12
12
  // `<claude|codex|cursor>` placeholder rather than naming a wrong one.
13
13
  //
14
- // The COS-WEB tag is reserved for a future web origin backed by a device key the API token cannot
15
- // produce. In 6.99.29 no turn is footed COS-WEB: a web turn is recorded, never vouched. The readers
16
- // still take either tag off.
14
+ // The COS-WEB tag is for a web turn signed by an enrolled phone key the API token cannot produce
15
+ // (6.99.31, behind COS_WEB_KEY_AUTHORITY, default off: lib/web-key-authority.ts). Off, as in 6.99.29,
16
+ // no turn is footed COS-WEB: a web turn is recorded, never vouched. The readers take either tag off.
17
17
  //
18
18
  // The verifier (MU-Chief-Staff operations/scripts/verify_g2_turn.py) mirrors G2_TURN_FOOTER_RE.
19
19
  //
@@ -0,0 +1,160 @@
1
+ // Served-asset integrity for COS Control Web (6.99.31, plan §4.8 / Phase 6). OFF by default:
2
+ // on with COS_WEB_CONTROL_INTEGRITY=1, and by default whenever COS_WEB_KEY_AUTHORITY=1 (signing
3
+ // is worthless if the page that holds the key can be rewritten on disk); COS_WEB_CONTROL_INTEGRITY=0
4
+ // turns it off even then. Read once at boot.
5
+ //
6
+ // WHY. The managed generation is user-writable (plan canary C7), and the static layer re-read the
7
+ // tree every second (C8), so any process running as Miles could rewrite app.js and have the phone
8
+ // run it within a second. With integrity on:
9
+ // - the pack step writes `web-control/.manifest.json` (sha256 of every shipped file; the script is
10
+ // server/scripts/write-web-control-manifest.mjs, mirrored by computeWebControlManifest below)
11
+ // - at boot the manifest is read ONCE and kept in memory; every file is read once, hashed and
12
+ // compared; only bytes that matched are ever served, from memory (the 1 s disk re-read is off)
13
+ // - nothing outside the manifest is served (express.static is not mounted behind it)
14
+ // - the tree is re-checked every `recheckMs` (60 s); a mismatch flips /control to a plain
15
+ // integrity-error page (503) and logs which files; a later passing check serves again
16
+ //
17
+ // WHAT IT CANNOT DO. The manifest sits in the same writable directory: an edit of both an asset
18
+ // and the manifest BEFORE a boot passes here. Only COS Control (signed, App Management protected)
19
+ // holding the manifest hash itself catches that (plan §4.8 Control audit, Phase 2). `manifestSha256`
20
+ // is reported for that audit. A process that patches the server code and restarts it is out of reach.
21
+
22
+ import { createHash } from 'node:crypto'
23
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+ import { readSwitch } from './env-switch.js'
26
+
27
+ export const WEB_CONTROL_MANIFEST_FILE = '.manifest.json'
28
+ export const WEB_CONTROL_INTEGRITY_RECHECK_MS = 60_000
29
+
30
+ export function webControlIntegrityEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
31
+ return readSwitch(env.COS_WEB_CONTROL_INTEGRITY, readSwitch(env.COS_WEB_KEY_AUTHORITY, false))
32
+ }
33
+
34
+ export interface WebControlManifest { v: 1; algorithm: 'sha256'; files: Record<string, string> }
35
+
36
+ /** The files the manifest covers: every regular, non-dot file, except tests (never shipped). */
37
+ export function manifestFiles(dir: string, base = ''): Array<{ urlPath: string; file: string }> {
38
+ const out: Array<{ urlPath: string; file: string }> = []
39
+ let names: string[]
40
+ try { names = readdirSync(dir) } catch { return out }
41
+ for (const name of names.sort()) {
42
+ if (name.startsWith('.')) continue
43
+ const file = join(dir, name)
44
+ let st
45
+ try { st = statSync(file) } catch { continue }
46
+ const urlPath = `${base}/${name}`
47
+ if (st.isDirectory()) out.push(...manifestFiles(file, urlPath))
48
+ else if (st.isFile() && !name.endsWith('.test.ts')) out.push({ urlPath, file })
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** What the pack step writes (and the .mjs script must produce byte for byte). */
54
+ export function computeWebControlManifest(dir: string): WebControlManifest {
55
+ const files: Record<string, string> = {}
56
+ for (const f of manifestFiles(dir)) files[f.urlPath] = createHash('sha256').update(readFileSync(f.file)).digest('hex')
57
+ return { v: 1, algorithm: 'sha256', files }
58
+ }
59
+
60
+ export type IntegrityMismatch = { path: string; problem: 'changed' | 'missing' | 'unexpected' | 'unreadable' }
61
+
62
+ export interface IntegrityState {
63
+ ok: boolean
64
+ /** Why it failed, when it did: a manifest problem or the files that differ. */
65
+ error: 'manifest_missing' | 'manifest_invalid' | 'files_mismatch' | null
66
+ mismatches: IntegrityMismatch[]
67
+ checkedAt: number
68
+ }
69
+
70
+ export class WebControlIntegrity {
71
+ private manifest: WebControlManifest | null = null
72
+ private manifestError: 'manifest_missing' | 'manifest_invalid' | null = null
73
+ readonly manifestSha256: string | null
74
+ private snapshotBytes: Map<string, Buffer> | null = null
75
+ /** Bumps each time a passing check follows a failed one (or the first pass): the static layer rebuilds then. */
76
+ generation = 0
77
+ state: IntegrityState
78
+
79
+ constructor(readonly dir: string, private readonly opts: { now?: () => number; log?: (line: string) => void } = {}) {
80
+ let raw: Buffer | null = null
81
+ try { raw = readFileSync(join(dir, WEB_CONTROL_MANIFEST_FILE)) } catch { this.manifestError = 'manifest_missing' }
82
+ this.manifestSha256 = raw ? createHash('sha256').update(raw).digest('hex') : null
83
+ if (raw) {
84
+ try {
85
+ const parsed = JSON.parse(raw.toString('utf8')) as WebControlManifest
86
+ const files = parsed?.files
87
+ if (parsed?.v === 1 && parsed.algorithm === 'sha256' && files && typeof files === 'object'
88
+ && Object.entries(files).every(([k, v]) => k.startsWith('/') && !k.includes('..') && typeof v === 'string' && /^[0-9a-f]{64}$/.test(v))) {
89
+ this.manifest = { v: 1, algorithm: 'sha256', files: { ...files } }
90
+ } else this.manifestError = 'manifest_invalid'
91
+ } catch { this.manifestError = 'manifest_invalid' }
92
+ }
93
+ this.state = { ok: false, error: this.manifestError ?? 'files_mismatch', mismatches: [], checkedAt: 0 }
94
+ this.check()
95
+ }
96
+
97
+ private now(): number { return (this.opts.now ?? Date.now)() }
98
+ private log(line: string): void { (this.opts.log ?? ((l: string) => console.error(l)))(line) }
99
+
100
+ /** The verified bytes (urlPath to bytes), or null while failing. Never re-read from disk by a server. */
101
+ snapshot(): Map<string, Buffer> | null {
102
+ return this.state.ok ? this.snapshotBytes : null
103
+ }
104
+
105
+ /**
106
+ * Read every file once, hash it, compare with the boot manifest. The snapshot is built from the
107
+ * very bytes that were hashed, so nothing read after the comparison is ever served.
108
+ */
109
+ check(): IntegrityState {
110
+ const wasOk = this.state.ok
111
+ const first = this.state.checkedAt === 0
112
+ const at = this.now()
113
+ if (!this.manifest) {
114
+ this.state = { ok: false, error: this.manifestError ?? 'manifest_missing', mismatches: [], checkedAt: at }
115
+ if (first) this.log(`[web-control-integrity] ${this.state.error}: /control is refused`)
116
+ this.snapshotBytes = null
117
+ return this.state
118
+ }
119
+ const expected = this.manifest.files
120
+ const seen = new Set<string>()
121
+ const bytes = new Map<string, Buffer>()
122
+ const mismatches: IntegrityMismatch[] = []
123
+ for (const f of manifestFiles(this.dir)) {
124
+ seen.add(f.urlPath)
125
+ const want = expected[f.urlPath]
126
+ if (!want) { mismatches.push({ path: f.urlPath, problem: 'unexpected' }); continue }
127
+ let body: Buffer
128
+ try { body = readFileSync(f.file) } catch { mismatches.push({ path: f.urlPath, problem: 'unreadable' }); continue }
129
+ if (createHash('sha256').update(body).digest('hex') !== want) { mismatches.push({ path: f.urlPath, problem: 'changed' }); continue }
130
+ bytes.set(f.urlPath, body)
131
+ }
132
+ for (const p of Object.keys(expected)) if (!seen.has(p)) mismatches.push({ path: p, problem: 'missing' })
133
+ const ok = mismatches.length === 0
134
+ this.state = { ok, error: ok ? null : 'files_mismatch', mismatches, checkedAt: at }
135
+ if (ok) {
136
+ if (!wasOk) { this.snapshotBytes = bytes; this.generation += 1 }
137
+ } else {
138
+ this.snapshotBytes = null
139
+ const list = mismatches.slice(0, 20).map(m => `${m.problem}:${m.path}`).join(', ')
140
+ this.log(`[web-control-integrity] ${mismatches.length} file(s) do not match the manifest; /control is refused: ${list}`)
141
+ }
142
+ if (ok && !wasOk && this.generation > 1) this.log('[web-control-integrity] files match the manifest again; /control is served')
143
+ return this.state
144
+ }
145
+
146
+ status(): Record<string, unknown> {
147
+ return {
148
+ enabled: true,
149
+ ok: this.state.ok,
150
+ error: this.state.error,
151
+ mismatches: this.state.mismatches.slice(0, 50),
152
+ checkedAt: this.state.checkedAt ? new Date(this.state.checkedAt).toISOString() : null,
153
+ manifestSha256: this.manifestSha256,
154
+ files: this.manifest ? Object.keys(this.manifest.files).length : 0,
155
+ }
156
+ }
157
+ }
158
+
159
+ /** The page served instead of /control while integrity fails. Plain, no script, no references. */
160
+ export const INTEGRITY_ERROR_PAGE = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>COS Control Web: integrity check failed</title></head><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem;line-height:1.5"><h1 style="font-size:1.25rem">COS Control Web is paused</h1><p>The files this Mac would send your phone do not match the files that shipped with this version, so they are not being served. Nothing was signed.</p><p>Open COS Control on the Mac to repair or reinstall the server.</p></body></html>'
@@ -0,0 +1,58 @@
1
+ /**
2
+ * W1 (audit 02 F1): what a browser sees when it opens /control from outside Tailscale (home Wi-Fi, a coffee shop).
3
+ *
4
+ * The answer stays 403. Only the body changes: a page that says why and what to do, in place of one line of plain text.
5
+ * It is fixed text with no script, no link, no name, no address and no version, so it tells a stranger on the same network
6
+ * nothing the old text did not ("available on this Mac and over Tailscale"). It never offers a LAN way in: COS Control Web
7
+ * is Tailscale only unless the owner sets COS_WEB_CONTROL_LAN=1 on the Mac (lib/web-session-auth.ts isWebControlSocketAllowed).
8
+ *
9
+ * A request that does not ask for HTML (an asset, a fetch) keeps the plain text line, so nothing that parses it changes.
10
+ */
11
+ export const WEB_CONTROL_OFFNET_TEXT = 'COS Control Web is available on this Mac and over Tailscale.'
12
+
13
+ /** Its own policy: no script, no outside request, inline style only. Replaces nothing for pages that pass the socket check. */
14
+ export const WEB_CONTROL_OFFNET_CSP = "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
15
+
16
+ export const WEB_CONTROL_OFFNET_HTML = `<!doctype html>
17
+ <html lang="en">
18
+ <head>
19
+ <meta charset="utf-8">
20
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
21
+ <meta name="referrer" content="no-referrer">
22
+ <meta name="robots" content="noindex">
23
+ <title>Open COS over Tailscale</title>
24
+ <style>
25
+ :root { color-scheme: light dark; --bg: #f6f5f2; --ink: #1d1c1a; --sub: #5d5a54; --card: #ffffff; --line: #e3e0da; }
26
+ @media (prefers-color-scheme: dark) { :root { --bg: #151514; --ink: #f1efea; --sub: #a9a59d; --card: #1f1e1c; --line: #34322e; } }
27
+ * { box-sizing: border-box; }
28
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
29
+ main { max-width: 520px; margin: 0 auto; padding: max(28px, env(safe-area-inset-top)) 16px 40px; }
30
+ h1 { font-size: 24px; line-height: 1.25; margin: 0 0 12px; }
31
+ p { margin: 0 0 14px; color: var(--sub); }
32
+ ol { margin: 0 0 18px; padding: 16px 16px 16px 36px; background: var(--card); border: 1px solid var(--line); border-radius: 14px; }
33
+ li { margin: 0 0 10px; }
34
+ li:last-child { margin-bottom: 0; }
35
+ strong { color: var(--ink); }
36
+ </style>
37
+ </head>
38
+ <body>
39
+ <main>
40
+ <h1>Open COS over Tailscale</h1>
41
+ <p>This is COS Control Web. It only opens over Tailscale, so it stays hidden from everyone else on this network.</p>
42
+ <ol>
43
+ <li>Install <strong>Tailscale</strong> on this phone or computer, and sign in with the same account your Mac uses.</li>
44
+ <li>Turn Tailscale on.</li>
45
+ <li>Open the COS link again. To get a new one, open COS Control Settings on your Mac and choose <strong>Open COS on your phone</strong>.</li>
46
+ </ol>
47
+ <p>Nothing was sent and you are not signed in.</p>
48
+ </main>
49
+ </body>
50
+ </html>
51
+ `
52
+
53
+ /** The 403 body for a request outside Tailscale: the page for a browser, the old line for anything else. */
54
+ export function webControlOffnetResponse(acceptsHtml: boolean): { type: 'html' | 'text'; body: string; csp: string | null } {
55
+ return acceptsHtml
56
+ ? { type: 'html', body: WEB_CONTROL_OFFNET_HTML, csp: WEB_CONTROL_OFFNET_CSP }
57
+ : { type: 'text', body: WEB_CONTROL_OFFNET_TEXT, csp: null }
58
+ }
@@ -25,6 +25,7 @@ import fs from 'fs'
25
25
  import path from 'path'
26
26
  import zlib from 'zlib'
27
27
  import type { NextFunction, Request, RequestHandler, Response } from 'express'
28
+ import { INTEGRITY_ERROR_PAGE, WEB_CONTROL_INTEGRITY_RECHECK_MS, type WebControlIntegrity } from './web-control-integrity.js'
28
29
 
29
30
  /** A versioned URL's lifetime. Safe because the hash changes whenever the bytes do. */
30
31
  export const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
@@ -114,11 +115,18 @@ export function rewriteReferences(text: string, mount: string, hashes: Map<strin
114
115
  * them and hashed, then HTML is rewritten against everything. So a font change gives its CSS a
115
116
  * new hash, and that gives index.html new references.
116
117
  */
117
- export function buildWebAssetTable(dir: string, mount = '/control'): WebAssetTable {
118
- const files = walk(dir)
118
+ export function buildWebAssetTable(dir: string, mount = '/control', source?: ReadonlyMap<string, Buffer>): WebAssetTable {
119
+ // 6.99.31: with `source` (integrity mode), the table is built ONLY from those verified bytes; the
120
+ // disk is not read at all.
121
+ const files = source
122
+ ? [...source.keys()].sort().filter(urlPath => TYPES[path.extname(urlPath).toLowerCase()] && !urlPath.split('/').some(part => part.startsWith('.'))).map(urlPath => ({ urlPath, file: urlPath }))
123
+ : walk(dir)
119
124
  const hashes = new Map<string, string>()
120
125
  const table: WebAssetTable = new Map()
121
- const read = (file: string): Buffer | null => { try { return fs.readFileSync(file) } catch { return null } }
126
+ const read = (file: string): Buffer | null => {
127
+ if (source) return source.get(file) ?? null
128
+ try { return fs.readFileSync(file) } catch { return null }
129
+ }
122
130
  const add = (urlPath: string, ext: string, body: Buffer): void => {
123
131
  const hash = sha(body)
124
132
  hashes.set(urlPath, hash)
@@ -168,20 +176,45 @@ function compressed(asset: WebAsset, encoding: 'br' | 'gzip'): Promise<Buffer> {
168
176
 
169
177
  export interface WebControlStaticOptions {
170
178
  mount?: string
171
- /** How often the tree is re-checked for edits. 0 checks on every request. */
179
+ /** How often the tree is re-checked for edits. 0 checks on every request. Ignored in integrity mode. */
172
180
  recheckMs?: number
173
181
  now?: () => number
182
+ /**
183
+ * 6.99.31 (Phase 6): serve only bytes that matched the pack-time manifest, from memory. The disk is
184
+ * never re-read per request; the integrity check re-runs every `integrityRecheckMs` (default 60 s,
185
+ * 0: never on a timer, tests call `integrity.check()`). While it fails, every GET or HEAD under the
186
+ * mount answers the plain integrity-error page (503), and nothing falls through to a disk server.
187
+ */
188
+ integrity?: WebControlIntegrity
189
+ integrityRecheckMs?: number
174
190
  }
175
191
 
176
- export function createWebControlStatic(dir: string, opts: WebControlStaticOptions = {}): RequestHandler & { table: () => WebAssetTable } {
192
+ export function createWebControlStatic(dir: string, opts: WebControlStaticOptions = {}): RequestHandler & { table: () => WebAssetTable; stop: () => void } {
177
193
  const mount = opts.mount ?? '/control'
178
194
  const recheckMs = opts.recheckMs ?? 1000
179
195
  const now = opts.now ?? Date.now
180
- let signature = treeSignature(dir)
181
- let table = buildWebAssetTable(dir, mount)
196
+ const integrity = opts.integrity ?? null
197
+ let signature = integrity ? '' : treeSignature(dir)
198
+ let table: WebAssetTable = integrity ? new Map() : buildWebAssetTable(dir, mount)
199
+ let builtFrom = -1
182
200
  let checkedAt = now()
201
+ let timer: ReturnType<typeof setInterval> | null = null
202
+ if (integrity) {
203
+ const every = opts.integrityRecheckMs ?? WEB_CONTROL_INTEGRITY_RECHECK_MS
204
+ if (every > 0) { timer = setInterval(() => { try { integrity.check() } catch { /* the next tick retries */ } }, every); timer.unref?.() }
205
+ }
206
+
207
+ /** Integrity mode: the table of verified bytes, or null while the check fails. */
208
+ const verified = (): WebAssetTable | null => {
209
+ if (!integrity) return null
210
+ const snap = integrity.snapshot()
211
+ if (!snap) return null
212
+ if (integrity.generation !== builtFrom) { table = buildWebAssetTable(dir, mount, snap); builtFrom = integrity.generation }
213
+ return table
214
+ }
183
215
 
184
216
  const current = (): WebAssetTable => {
217
+ if (integrity) return verified() ?? new Map()
185
218
  if (now() - checkedAt >= recheckMs) {
186
219
  checkedAt = now()
187
220
  const next = treeSignature(dir)
@@ -194,8 +227,19 @@ export function createWebControlStatic(dir: string, opts: WebControlStaticOption
194
227
  if (req.method !== 'GET' && req.method !== 'HEAD') { next(); return }
195
228
  let urlPath = req.path || '/'
196
229
  if (urlPath.endsWith('/')) urlPath += 'index.html'
230
+ if (integrity && verified() === null) {
231
+ res.status(503)
232
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
233
+ res.setHeader('Cache-Control', 'no-store')
234
+ if (req.method === 'HEAD') res.end(); else res.end(INTEGRITY_ERROR_PAGE)
235
+ return
236
+ }
197
237
  const asset = current().get(urlPath)
198
- if (!asset) { next(); return }
238
+ if (!asset) {
239
+ // Integrity mode serves nothing it did not verify: no fall-through to a disk server.
240
+ if (integrity) { res.status(404).type('text').send('Not found'); return }
241
+ next(); return
242
+ }
199
243
 
200
244
  const versioned = VERSIONABLE.has(asset.ext) && req.query.v === asset.hash
201
245
  const encoding = COMPRESSIBLE.has(asset.ext) ? pickEncoding(req.headers['accept-encoding'] as string | undefined) : null
@@ -222,5 +266,5 @@ export function createWebControlStatic(dir: string, opts: WebControlStaticOption
222
266
  if (req.method === 'HEAD') res.end(); else res.end(asset.body)
223
267
  })
224
268
  }
225
- return Object.assign(handler, { table: current })
269
+ return Object.assign(handler, { table: current, stop: () => { if (timer) clearInterval(timer); timer = null } })
226
270
  }