@gotcos/glasses-server 7.0.7 → 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 (87) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +170 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +126 -23
  8. package/server/lib/agent-session-recent.ts +321 -0
  9. package/server/lib/agent-session-store.ts +3 -3
  10. package/server/lib/api-auth.ts +37 -1
  11. package/server/lib/calendar-meetings.ts +70 -0
  12. package/server/lib/calendar-source.ts +54 -9
  13. package/server/lib/claude-hooks-installer.ts +111 -12
  14. package/server/lib/conversation.ts +56 -28
  15. package/server/lib/cors-reject-log.ts +51 -0
  16. package/server/lib/disk-io-meter.ts +202 -0
  17. package/server/lib/drop-service.ts +47 -12
  18. package/server/lib/drop-store.ts +21 -1
  19. package/server/lib/event-loop-stall.ts +62 -0
  20. package/server/lib/full-hold-engine.ts +114 -0
  21. package/server/lib/full-hold.ts +124 -0
  22. package/server/lib/g2-turn-provenance.ts +128 -9
  23. package/server/lib/listener-startup.ts +18 -0
  24. package/server/lib/meeting-batch-transcribe.ts +32 -5
  25. package/server/lib/permission-broker.ts +443 -11
  26. package/server/lib/secret-path-guard.ts +4 -1
  27. package/server/lib/session-cache-writer.ts +8 -1
  28. package/server/lib/session-hook-events.ts +23 -1
  29. package/server/lib/session-hooks-runtime.ts +34 -0
  30. package/server/lib/session-log.ts +313 -4
  31. package/server/lib/session-state-derive.ts +36 -0
  32. package/server/lib/session-stream-events.ts +25 -1
  33. package/server/lib/today-brief.ts +522 -58
  34. package/server/lib/turn-end-ask.ts +320 -0
  35. package/server/lib/turn-footer.ts +3 -3
  36. package/server/lib/web-control-integrity.ts +160 -0
  37. package/server/lib/web-control-offnet-page.ts +58 -0
  38. package/server/lib/web-control-static.ts +53 -9
  39. package/server/lib/web-key-authority.ts +372 -0
  40. package/server/lib/web-key-envelope.ts +98 -0
  41. package/server/lib/web-key-handoff.ts +135 -0
  42. package/server/lib/web-key-origin.ts +21 -0
  43. package/server/lib/web-key-passkey.ts +155 -0
  44. package/server/lib/web-key-registry.ts +825 -0
  45. package/server/lib/web-key-signer-app.ts +152 -0
  46. package/server/lib/web-session-auth.ts +133 -15
  47. package/server/lib/whisper-local.ts +93 -7
  48. package/server/lib/whisper-preview.ts +5 -3
  49. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  50. package/server/lib/work-review-runtime.ts +17 -4
  51. package/server/routes/agent-session-bindings.ts +16 -1
  52. package/server/routes/agent-session-stream.ts +7 -0
  53. package/server/routes/drops.ts +7 -2
  54. package/server/routes/g2-turns.ts +35 -0
  55. package/server/routes/health.ts +11 -1
  56. package/server/routes/live-events.ts +6 -1
  57. package/server/routes/permission-broker.ts +26 -0
  58. package/server/routes/query-jobs.ts +3 -0
  59. package/server/routes/sessions.ts +15 -24
  60. package/server/routes/thread-turn-queue.ts +16 -9
  61. package/server/routes/today-brief.ts +91 -36
  62. package/server/routes/web-control.ts +37 -4
  63. package/server/routes/web-key.ts +284 -0
  64. package/server/routes/welcome-context.ts +6 -2
  65. package/server/routes/work-orchestrator.ts +12 -7
  66. package/server/scripts/write-web-control-manifest.mjs +43 -0
  67. package/server/web-control/.manifest.json +44 -0
  68. package/server/web-control/app.css +36 -0
  69. package/server/web-control/app.js +953 -57
  70. package/server/web-control/apple-touch-icon.png +0 -0
  71. package/server/web-control/index.html +14 -1
  72. package/server/web-control/panes/cos-chat.css +52 -6
  73. package/server/web-control/panes/cos-chat.js +364 -32
  74. package/server/web-control/panes/questions.js +135 -14
  75. package/server/web-control/panes/settings.js +1 -1
  76. package/server/web-control/panes/today-actions.css +35 -0
  77. package/server/web-control/panes/today-actions.js +75 -6
  78. package/server/web-control/panes/work.css +3 -0
  79. package/server/web-control/panes/work.js +178 -35
  80. package/server/web-signer/.manifest.json +11 -0
  81. package/server/web-signer/index.html +74 -0
  82. package/server/web-signer/signer-proof.js +348 -0
  83. package/server/web-signer/signer-store.js +39 -0
  84. package/server/web-signer/signer.css +23 -0
  85. package/server/web-signer/signer.js +341 -0
  86. package/shared/web-approval-packets.golden.json +124 -0
  87. package/shared/web-turn-envelope.ts +271 -0
@@ -24,6 +24,10 @@ import {
24
24
  type WebLoginFailure,
25
25
  WebGlassesCodeProbeBudget,
26
26
  WEB_SESSION_TTL_MS,
27
+ WEB_SESSION_COOKIE_NAME,
28
+ WEB_SESSION_MAX_LIFE_MS,
29
+ readWebSessionCookie,
30
+ webSessionCookieFrom,
27
31
  WebHandoffStore,
28
32
  WebLoginLimiter,
29
33
  WebDeviceCodeStore,
@@ -199,6 +203,18 @@ export function webControlCsp(_requestPath: string): string {
199
203
  ].join('; ')
200
204
  }
201
205
 
206
+ /**
207
+ * The expiry (ISO) of the cos_web_session cookie on a request, read from the SAME value the verifier reads (Q1 S1:
208
+ * `parseCookies` keeps the last of several; another app on this host name can plant an earlier one). Read only after
209
+ * verification. Shapes: `<exp>.<epoch>.<sig>`, or a renewed `<exp>.<epoch>.<orig>.<sig>`.
210
+ */
211
+ export function webSessionCookieExpiry(cookieHeader: unknown): string | null {
212
+ const value = webSessionCookieFrom(cookieHeader)
213
+ const exp = typeof value === 'string' ? /^(\d{1,15})\./.exec(value) : null
214
+ const ms = exp ? Number(exp[1]) * 1000 : NaN
215
+ return Number.isSafeInteger(ms) && ms > 0 ? new Date(ms).toISOString() : null
216
+ }
217
+
202
218
  export function createWebControlRouter(deps: WebControlRouterDeps): Router {
203
219
  const router = Router()
204
220
  const handoffStore = deps.handoffStore ?? new WebHandoffStore()
@@ -287,11 +303,20 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
287
303
  ? { authenticated: false, mode: 'none' }
288
304
  : { authenticated: false, mode: 'none', network: 'tailscale_only' })
289
305
  }
306
+ // W1 (audit 02 F2): a signed-in browser learns when ITS OWN cookie ends, so the page can say so before it does.
307
+ // Only after the cookie verified (its signature covers the expiry), only for cookie mode, and nothing a caller without
308
+ // that cookie can read. The 7-day lifetime itself is unchanged.
309
+ const expiresAt = cookieOk && !headerOk ? webSessionCookieExpiry(req.headers.cookie) : null
310
+ // Renewal (Miles 2026-11-09): the latest this sign-in can last while in use, 30 days from its first sign-in.
311
+ const read = cookieOk && !headerOk ? readWebSessionCookie(webSessionCookieFrom(req.headers.cookie), token) : null
312
+ const renewUntil = read ? new Date((read.origUnixSec + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000)) * 1000).toISOString() : null
290
313
  res.json({
291
314
  authenticated: true,
292
315
  mode: headerOk ? 'header' : 'cookie',
293
316
  serverName: hostname().replace(/\.local$/i, ''),
294
317
  ownerName: getOwnerName(),
318
+ ...(expiresAt ? { expiresAt } : {}),
319
+ ...(renewUntil ? { renewUntil } : {}),
295
320
  })
296
321
  })
297
322
 
@@ -322,10 +347,13 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
322
347
  const token = deps.apiToken()
323
348
  const result = classifyWebLogin(body, token, handoffStore, deviceCodes, glassesCheck)
324
349
  if (!result.ok) {
325
- // Every failure counts, the glasses-code one included, so that check cannot be used to
326
- // probe codes faster than the limiter allows.
327
- loginLimiter.fail(ip, result.reason)
328
- log(`[web-login] fail kind=${result.reason} len=${result.length} ip=${ip}`)
350
+ // Every failure counts toward the per-IP limit, the glasses-code one included, so that check cannot be used to
351
+ // probe codes faster than the limiter allows. Except a failed sign-in LINK (Miles 2026-11-09 20:00, "Yes, stop
352
+ // counting stale links"): a ticket is 192 random bits, so guessing one is not a risk, and a person reopening an old
353
+ // QR or link should not lock themselves out of typing a code. It still fails with the same message.
354
+ if (result.reason !== 'handoff_expired') loginLimiter.fail(ip, result.reason)
355
+ // S1 item 7: why a handoff link failed (expired, used, evicted, unknown). The log only; the message is unchanged.
356
+ log(`[web-login] fail kind=${result.reason}${result.handoff ? ` sub=${result.handoff}` : ''} len=${result.length} ip=${ip}`)
329
357
  return res.status(401).json({
330
358
  ok: false,
331
359
  error: 'invalid_credentials',
@@ -340,6 +368,8 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
340
368
  return res.json({
341
369
  ok: true,
342
370
  expiresAt: new Date(minted.expiresAtMs).toISOString(),
371
+ // Renewal (Miles 2026-11-09): while in use this sign-in renews up to 30 days from now (lib/web-session-auth.ts).
372
+ renewUntil: new Date((Math.floor(minted.expiresAtMs / 1000) - Math.floor(WEB_SESSION_TTL_MS / 1000) + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000)) * 1000).toISOString(),
343
373
  serverName: hostname().replace(/\.local$/i, ''),
344
374
  ownerName: getOwnerName(),
345
375
  })
@@ -357,6 +387,9 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
357
387
  router.post('/web-session/revoke-all', (req: Request, res: Response) => {
358
388
  res.set('Cache-Control', 'private, no-store')
359
389
  const epoch = bumpWebSessionEpoch()
390
+ // Final review V7: a sign-in link or code made before the sign-out no longer signs a browser in after it.
391
+ handoffStore.revokeAll()
392
+ deviceCodes.revokeAll()
360
393
  res.setHeader('Set-Cookie', formatClearWebSessionCookie(req.secure))
361
394
  res.json({ ok: true, epoch })
362
395
  })
@@ -0,0 +1,284 @@
1
+ // /api/web-key/*: the enrolled phone key registry and enrollment ceremony (6.99.31, plan §4.3,
2
+ // §4.7). Behind COS_WEB_KEY_AUTHORITY (default off). Off, none of these is a route (each falls
3
+ // through to next(), exactly as on 6.99.29).
4
+ //
5
+ // WHO MAY CALL WHAT:
6
+ // - The phone's steps (enroll start, prove, status, nonce) need the WEB path: the signed cookie,
7
+ // no X-Cos-Token header, and a socket that is not this Mac (the same judgment as a web turn).
8
+ // A token-bearing, loopback or own-address request is refused: an agent on this Mac cannot
9
+ // start an enrollment for a key it made.
10
+ // - COS Control's steps (pending, approve) need X-Cos-Token, and approve ALSO needs the anchor's
11
+ // Secure Enclave signature over the frozen approval (Touch ID). The token alone approves nothing.
12
+ // - Revoke, revoke-all and the key list take either the token or the web path. Revocation never
13
+ // needs the anchor, so a lost phone can always be revoked (by a token holder too: denial of
14
+ // service only, logged in web-key-audit.jsonl).
15
+ // - Anchor registration (review B1) is COS Control's bootstrap: loopback socket only, no proxy
16
+ // headers, X-Cos-Token, and the anchor's own signature over its `cos-web-anchor/1` statement. A
17
+ // new anchor vouches nothing for 24 hours; the turn answer exposes its fingerprint and age so
18
+ // the verifier compares it with Miles's committed pin.
19
+ // - Approval packets name their creating session from the request context (`x-cos-provider`,
20
+ // `x-cos-session-id`), never from body text (review B4).
21
+ // - Phase 3: the phone's key steps (enroll start and prove, nonce) answer only on the 3144 signer
22
+ // listener when `isSignerRequest` is wired (index.ts), so a page on 3143 cannot make a key.
23
+ // - Phase 3 handoff: the composer creates one on the web path (the destination comes from the
24
+ // binding, never the body); only the signer page claims it, once, on 3144, naming its origin.
25
+ // - 7.0.9 anchor bootstrap (COS_WEB_KEY_ANCHOR_BOOTSTRAP on, COS_WEB_KEY_AUTHORITY off): ONLY
26
+ // GET status (X-Cos-Token only, reply `enabled: false, mode: 'bootstrap'`) and POST anchor (as
27
+ // above) answer. Every other route falls through exactly as with both switches off.
28
+
29
+ import { Router, type Request, type Response, type NextFunction } from 'express'
30
+ import { timingSafeTokenEqual } from '../lib/token-auth.js'
31
+ import { hasProxyForwardedHeaders, isLoopbackAddress, normalizeRemoteIp } from '../lib/network-policy.js'
32
+ import { WEB_KEY_ENABLED_APPROVE_ACTIONS, type WebKeyAuthority } from '../lib/web-key-authority.js'
33
+ import { WEB_ANCHOR_MATURITY_MS, type WebKeyEnrollments } from '../lib/web-key-registry.js'
34
+ import { parseHandoffRequest, type WebKeySignHandoffs } from '../lib/web-key-handoff.js'
35
+ import type { WebApprovalPacket, WebTurnScope } from '../../shared/web-turn-envelope.js'
36
+
37
+ export const ANCHOR_REGISTRATION_LOOPBACK_COPY = 'The COS Control anchor key can only be registered by COS Control on this Mac.'
38
+
39
+ export interface WebKeyRouterDeps {
40
+ authority: WebKeyAuthority
41
+ enrollments: WebKeyEnrollments
42
+ /** The web path in full: cookie, no token, a socket that is not this Mac (G2TurnProvenance.originOf). */
43
+ isWebRequest: (req: Request) => boolean
44
+ apiToken: () => string
45
+ selfDnsName: () => string | null
46
+ expectedOrigin: () => string | null
47
+ /** Served-asset integrity (Phase 6) for Control's audit; absent: not reported. */
48
+ integrityStatus?: () => Record<string, unknown>
49
+ /**
50
+ * Phase 3: whether this request reached the 3144 signer listener (index.ts: the socket's local
51
+ * port). Given, the phone's key steps (enroll start and prove, nonce) and the handoff claim answer
52
+ * only there, so a page on 3143 can neither make a key nor fetch a handoff. Absent (tests that
53
+ * predate the signer): not checked, exactly as before.
54
+ */
55
+ isSignerRequest?: (req: Request) => boolean
56
+ /** Phase 3: the 3143 to 3144 handoff store. Absent: the handoff routes answer 404 `handoff_unavailable`. */
57
+ handoffs?: WebKeySignHandoffs
58
+ /** Phase 3: the binding's real destination, as the turn route will resolve it. Null: no such binding. */
59
+ resolveBinding?: (bindingId: string) => { provider: string; sessionId: string; epoch: number; targetKey: string } | null
60
+ }
61
+
62
+ export const SIGNER_ORIGIN_REQUIRED = 'signer_origin_required'
63
+
64
+ export function createWebKeyRouter(deps: WebKeyRouterDeps): Router {
65
+ const router = Router()
66
+
67
+ const on = (req: Request, res: Response, next: NextFunction): void => {
68
+ if (!deps.authority.enabled()) { next('router'); return }
69
+ res.set('Cache-Control', 'private, no-store')
70
+ next()
71
+ }
72
+ const hasToken = (req: Request): boolean => {
73
+ let expected = ''
74
+ try { expected = deps.apiToken() } catch { expected = '' }
75
+ return expected.length > 0 && timingSafeTokenEqual(req.headers['x-cos-token'], expected)
76
+ }
77
+ const web = (req: Request): boolean => {
78
+ try { return deps.isWebRequest(req) === true } catch { return false }
79
+ }
80
+ const refuse = (res: Response, status: number, error: string, extra: Record<string, unknown> = {}): void => {
81
+ res.status(status).json({ ok: false, error, ...extra })
82
+ }
83
+ const by = (req: Request): string => (hasToken(req) ? 'token' : 'web')
84
+ const body = (req: Request): Record<string, unknown> => (req.body && typeof req.body === 'object' ? req.body as Record<string, unknown> : {})
85
+ /** On the 3144 listener, when the deployment can tell (Phase 3). Never throws. */
86
+ const onSigner = (req: Request): boolean => {
87
+ if (!deps.isSignerRequest) return true
88
+ try { return deps.isSignerRequest(req) === true } catch { return false }
89
+ }
90
+
91
+ // 7.0.9: status and anchor registration answer in either mode; with both switches off they fall
92
+ // through like every other route. They sit BEFORE `on`, which still gates everything else on the
93
+ // authority alone.
94
+ const anchorRoutes = (req: Request, res: Response, next: NextFunction): void => {
95
+ if (deps.authority.mode() === 'off') { next('router'); return }
96
+ res.set('Cache-Control', 'private, no-store')
97
+ next()
98
+ }
99
+
100
+ router.get('/web-key/status', anchorRoutes, (req, res) => {
101
+ const registry = deps.authority.registry
102
+ const selfDns = deps.selfDnsName()
103
+ if (deps.authority.mode() !== 'authority') {
104
+ // Bootstrap only: COS Control's anchor state, nothing that suggests approvals. Token only:
105
+ // no web page needs it while no phone can enroll. `enabled: false` keeps older Controls hidden.
106
+ if (!hasToken(req)) return refuse(res, 401, 'unauthorized')
107
+ const anchor = registry.anchor()
108
+ return void res.json({
109
+ ok: true,
110
+ enabled: false,
111
+ mode: 'bootstrap',
112
+ anchorRegistered: anchor !== null,
113
+ anchorMature: registry.anchorMature(),
114
+ anchorSetAt: anchor ? anchor.setAt : null,
115
+ aud: registry.aud(selfDns),
116
+ origin: deps.expectedOrigin(),
117
+ epoch: registry.epoch(),
118
+ })
119
+ }
120
+ if (!hasToken(req) && !web(req)) return refuse(res, 401, 'unauthorized')
121
+ res.json({
122
+ ok: true,
123
+ enabled: true,
124
+ anchorRegistered: registry.anchor() !== null,
125
+ anchorRefusal: registry.anchorRefusal(),
126
+ anchorMature: registry.anchorMature(),
127
+ registryReadable: !registry.unreadable,
128
+ nonceStoreReadable: !deps.authority.nonces.unavailable,
129
+ aud: registry.aud(selfDns),
130
+ origin: deps.expectedOrigin(),
131
+ epoch: registry.epoch(),
132
+ activeKeys: registry.activeCount(selfDns),
133
+ ...(deps.integrityStatus ? { integrity: deps.integrityStatus() } : {}),
134
+ })
135
+ })
136
+
137
+ router.post('/web-key/anchor', anchorRoutes, (req, res) => {
138
+ // Loopback socket, no forwarding headers (a local proxy makes every caller loopback), and the token.
139
+ const addr = normalizeRemoteIp(req.socket?.remoteAddress ?? '')
140
+ if (!addr || !isLoopbackAddress(addr) || hasProxyForwardedHeaders(req.headers)) return refuse(res, 403, 'loopback_required', { reasonCopy: ANCHOR_REGISTRATION_LOOPBACK_COPY })
141
+ if (!hasToken(req)) return refuse(res, 401, 'token_required')
142
+ const b = body(req)
143
+ const result = deps.authority.registry.registerAnchor({ anchorPub: b.anchorPub, statement: b.statement, signature: b.signature }, 'control')
144
+ if (!result.ok) return refuse(res, result.reason === 'registry_write_failed' || result.reason === 'registry_unreadable' ? 503 : result.reason === 'anchor_signature_invalid' ? 403 : 400, result.reason)
145
+ res.status(201).json({ ...result, maturesAt: result.setAt + WEB_ANCHOR_MATURITY_MS })
146
+ })
147
+
148
+ // Everything below: the authority only (bootstrap mode falls through here, as with both off).
149
+ router.use('/web-key', on)
150
+
151
+ router.post('/web-key/enroll/start', (req, res) => {
152
+ if (!web(req)) return refuse(res, 403, 'web_origin_required')
153
+ if (!onSigner(req)) return refuse(res, 403, SIGNER_ORIGIN_REQUIRED)
154
+ const b = body(req)
155
+ const result = deps.enrollments.start({
156
+ spki: b.spki,
157
+ label: b.label,
158
+ rotates: b.rotates,
159
+ sasCommit: b.sasCommit,
160
+ userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '',
161
+ remote: req.socket?.remoteAddress ?? 'unknown',
162
+ }, { selfDnsName: deps.selfDnsName(), origin: deps.expectedOrigin() })
163
+ if (!result.ok) return refuse(res, result.reason === 'device_not_allowed' ? 403 : result.reason === 'enrollment_pending' || result.reason === 'key_cap_reached' || result.reason === 'key_already_enrolled' ? 409 : 400, result.reason)
164
+ res.status(201).json(result)
165
+ })
166
+
167
+ router.post('/web-key/enroll/prove', (req, res) => {
168
+ if (!web(req)) return refuse(res, 403, 'web_origin_required')
169
+ if (!onSigner(req)) return refuse(res, 403, SIGNER_ORIGIN_REQUIRED)
170
+ const b = body(req)
171
+ const result = deps.enrollments.prove({ enrollId: b.enrollId, signature: b.signature, phoneNonce: b.phoneNonce, passkey: b.passkey })
172
+ if (!result.ok) return refuse(res, result.reason === 'enroll_unknown' ? 404 : 400, result.reason)
173
+ res.json(result)
174
+ })
175
+
176
+ router.get('/web-key/enroll/status', (req, res) => {
177
+ if (!web(req) && !hasToken(req)) return refuse(res, 401, 'unauthorized')
178
+ res.json({ ok: true, status: deps.enrollments.status(req.query.enrollId) })
179
+ })
180
+
181
+ router.get('/web-key/enroll/pending', (req, res) => {
182
+ if (!hasToken(req)) return refuse(res, 401, 'token_required')
183
+ res.json({ ok: true, pending: deps.enrollments.pendingView() })
184
+ })
185
+
186
+ router.post('/web-key/enroll/approve', (req, res) => {
187
+ if (!hasToken(req)) return refuse(res, 401, 'token_required')
188
+ const b = body(req)
189
+ const result = deps.enrollments.approve(b.enrollId, b.anchorSig, deps.selfDnsName())
190
+ if (!result.ok) return refuse(res, result.reason === 'enroll_unknown' ? 404 : result.reason === 'anchor_signature_invalid' ? 403 : 409, result.reason)
191
+ res.json(result)
192
+ })
193
+
194
+ router.get('/web-key/keys', (req, res) => {
195
+ if (!hasToken(req) && !web(req)) return refuse(res, 401, 'unauthorized')
196
+ res.json({ ok: true, epoch: deps.authority.registry.epoch(), keys: deps.authority.registry.list(deps.selfDnsName()) })
197
+ })
198
+
199
+ router.post('/web-key/revoke', (req, res) => {
200
+ if (!hasToken(req) && !web(req)) return refuse(res, 401, 'unauthorized')
201
+ const b = body(req)
202
+ if (typeof b.kid !== 'string') return refuse(res, 400, 'invalid_request')
203
+ const reason = typeof b.reason === 'string' && b.reason.trim() ? b.reason.trim() : 'revoked'
204
+ if (!deps.authority.registry.revoke(b.kid, reason, by(req))) return refuse(res, 404, 'key_unknown')
205
+ res.json({ ok: true, kid: b.kid, revoked: true })
206
+ })
207
+
208
+ router.post('/web-key/revoke-all', (req, res) => {
209
+ if (!hasToken(req) && !web(req)) return refuse(res, 401, 'unauthorized')
210
+ res.json({ ok: true, epoch: deps.authority.registry.revokeAll(by(req)) })
211
+ })
212
+
213
+ router.post('/web-key/nonce', (req, res) => {
214
+ if (!web(req)) return refuse(res, 403, 'web_origin_required')
215
+ if (!onSigner(req)) return refuse(res, 403, SIGNER_ORIGIN_REQUIRED)
216
+ const issued = deps.authority.nonces.issue()
217
+ if (!issued.ok) return refuse(res, 503, issued.reason)
218
+ res.status(201).json({ ok: true, nonce: issued.nonce, exp: issued.exp, aud: deps.authority.registry.aud(deps.selfDnsName()), epoch: deps.authority.registry.epoch() })
219
+ })
220
+
221
+ router.post('/web-key/packets', (req, res) => {
222
+ // A packet is only a request (a receiving session asks Miles to approve one exact action).
223
+ if (!hasToken(req)) return refuse(res, 401, 'token_required')
224
+ // The creating session comes from the request context, never the body (review B4).
225
+ const header = (name: string): string | null => { const v = req.headers[name]; return typeof v === 'string' && v.trim() ? v.trim() : null }
226
+ const created = deps.authority.packets.create(body(req), { provider: header('x-cos-provider'), sessionId: header('x-cos-session-id') })
227
+ if (!created.ok) return refuse(res, created.reason === 'packet_cap_reached' ? 409 : created.reason === 'creator_unknown' ? 401 : 400, created.reason)
228
+ res.status(201).json({ ok: true, packet: created.packet, packetSha256: created.packetSha256 })
229
+ })
230
+
231
+ router.get('/web-key/packets', (req, res) => {
232
+ if (!hasToken(req) && !web(req)) return refuse(res, 401, 'unauthorized')
233
+ const provider = req.query.provider, sessionId = req.query.sessionId
234
+ if ((provider !== undefined || sessionId !== undefined) && (typeof provider !== 'string' || !['claude', 'codex', 'cursor'].includes(provider) || typeof sessionId !== 'string' || !sessionId || sessionId.length > 128)) return refuse(res, 400, 'invalid_target')
235
+ const packets = deps.authority.packets.list().filter(row => WEB_KEY_ENABLED_APPROVE_ACTIONS.has(row.packet.action)
236
+ && (provider === undefined || (row.packet.provider === provider && row.packet.sessionId === sessionId)))
237
+ res.json({ ok: true, packets })
238
+ })
239
+
240
+ // ── Phase 3: the handoff from the Control Web composer (3143) to the signer page (3144) ──
241
+ // Create: the composer, on the web path. The destination is the binding's, never the body's.
242
+ router.post('/web-key/handoff', (req, res) => {
243
+ if (!web(req)) return refuse(res, 403, 'web_origin_required')
244
+ if (!deps.handoffs || !deps.resolveBinding) return refuse(res, 404, 'handoff_unavailable')
245
+ const signerOrigin = deps.expectedOrigin()
246
+ if (!signerOrigin) return refuse(res, 503, 'origin_unavailable')
247
+ if (deps.authority.registry.activeCount(deps.selfDnsName()) < 1) return refuse(res, 409, 'no_phone_key')
248
+ const parsed = parseHandoffRequest(req.body)
249
+ if (!parsed.ok) return refuse(res, 400, parsed.reason)
250
+ const { request } = parsed
251
+ let target: ReturnType<NonNullable<WebKeyRouterDeps['resolveBinding']>> = null
252
+ try { target = deps.resolveBinding(request.turn.bindingId) } catch { target = null }
253
+ if (!target) return refuse(res, 404, 'unknown_binding')
254
+ if (target.epoch !== request.turn.epoch || target.targetKey !== request.turn.targetKey) return refuse(res, 409, 'binding_changed')
255
+ let scope: WebTurnScope = { kind: 'message' }
256
+ let approvalPacket: WebApprovalPacket | null = null
257
+ if (request.packetSha256) {
258
+ const packet = deps.authority.packets.get(request.packetSha256)
259
+ if (!packet) return refuse(res, 404, 'packet_unknown')
260
+ if (!WEB_KEY_ENABLED_APPROVE_ACTIONS.has(packet.action)) return refuse(res, 400, 'approve_action_not_enabled')
261
+ // A packet is signable only for the session that asked for it (review B4).
262
+ if (packet.provider !== target.provider || packet.sessionId !== target.sessionId) return refuse(res, 409, 'packet_session_mismatch')
263
+ scope = { kind: 'approve', action: packet.action, artifact: packet.artifact, version: packet.version, digest: packet.digest, channel: packet.channel, packetSha256: request.packetSha256 }
264
+ approvalPacket = packet
265
+ }
266
+ const created = deps.handoffs.create(request, { provider: target.provider, sessionId: target.sessionId }, scope, approvalPacket)
267
+ if (!created.ok) return refuse(res, created.reason === 'handoff_cap_reached' ? 409 : 400, created.reason)
268
+ // The id rides in the fragment: never sent to a server, never in a Referer. The words are not in it.
269
+ res.status(201).json({ ok: true, handoffId: created.handoffId, exp: created.exp, signerUrl: `${signerOrigin}/#h=${created.handoffId}` })
270
+ })
271
+
272
+ // Claim: the signer page, once, on its own listener and naming its own origin.
273
+ router.post('/web-key/handoff/claim', (req, res) => {
274
+ if (!web(req)) return refuse(res, 403, 'web_origin_required')
275
+ if (!deps.handoffs) return refuse(res, 404, 'handoff_unavailable')
276
+ const signerOrigin = deps.expectedOrigin()
277
+ if (!deps.isSignerRequest || !onSigner(req) || !signerOrigin || req.headers.origin !== signerOrigin) return refuse(res, 403, SIGNER_ORIGIN_REQUIRED)
278
+ const claimed = deps.handoffs.claim(body(req).handoffId)
279
+ if (!claimed.ok) return refuse(res, claimed.reason === 'handoff_unknown' ? 404 : claimed.reason === 'handoff_expired' ? 410 : 409, claimed.reason)
280
+ res.json({ ok: true, handoff: claimed.handoff })
281
+ })
282
+
283
+ return router
284
+ }
@@ -5,6 +5,7 @@
5
5
  import { Router } from 'express'
6
6
  import { callPython, pythonBridgeAvailable } from '../lib/python-bridge.js'
7
7
  import { calendarToday, readCalendar } from '../lib/calendar-source.js'
8
+ import { isCalendarHold } from '../lib/calendar-meetings.js'
8
9
  import { parsePrepOccurrence } from '../lib/prep-store.js'
9
10
  import type { PrepOccurrence } from '../../shared/prep.js'
10
11
 
@@ -22,7 +23,8 @@ type WeatherPayload = { temp: string; desc: string; location: string }
22
23
  * provider, calendar id, event id or external id, and start. The bridge sends no event ids today, so it is absent.
23
24
  * It lets a client find a Prep draft for this exact event; it never binds one, and nothing is inferred from the title.
24
25
  */
25
- type NextEventPayload = { title: string; time: string; inMinutes?: number; occurrence?: PrepOccurrence }
26
+ // `hold` (11/09): present, true, only when the title is a hold or status block (calendar-meetings.ts isCalendarHold).
27
+ type NextEventPayload = { title: string; time: string; inMinutes?: number; occurrence?: PrepOccurrence; hold?: true }
26
28
 
27
29
  /** The bridge event's provider identity, or undefined. snake_case (calendar_context.py) or camelCase is accepted. */
28
30
  export function bridgeOccurrence(evt: Record<string, unknown> | null | undefined): PrepOccurrence | undefined {
@@ -364,6 +366,7 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
364
366
  time: meaningful.start_time || meaningful.start || '',
365
367
  ...(minsUntil != null ? { inMinutes: minsUntil } : {}),
366
368
  ...(occurrence ? { occurrence } : {}),
369
+ ...(isCalendarHold(meaningful.title) ? { hold: true as const } : {}),
367
370
  }
368
371
  } else if (cal?.next_event && !isGenericNoiseEvent(cal.next_event.title || '')) {
369
372
  const occurrence = bridgeOccurrence(cal.next_event as Record<string, unknown>)
@@ -372,6 +375,7 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
372
375
  time: cal.next_event.start_time || '',
373
376
  ...(cal.minutes_until_next != null ? { inMinutes: cal.minutes_until_next } : {}),
374
377
  ...(occurrence ? { occurrence } : {}),
378
+ ...(isCalendarHold(cal.next_event.title) ? { hold: true as const } : {}),
375
379
  }
376
380
  } else {
377
381
  cachedNextEvent = null
@@ -403,7 +407,7 @@ export function _resetWelcomeContextCachesForTests(): void {
403
407
 
404
408
  /**
405
409
  * PH plan P1: Today's calendar for Control Web and desktop. Behind the /api token gate. Selected calendars only, the
406
- * next 3 events. `connected: false` means "Calendar not connected", never an empty day.
410
+ * next 5 events (Today round 2, was 3), plus `endedRecently` (ended within 2 h). `connected: false` means "Calendar not connected", never an empty day.
407
411
  */
408
412
  welcomeContextRouter.get('/calendar/today', async (_req, res) => {
409
413
  try {
@@ -13,7 +13,7 @@
13
13
  * 10 minutes (`refresh=1`: 2 minutes). No timer reads usage unless COS_WORK_USAGE_POLL=1.
14
14
  */
15
15
  import { Router } from 'express'
16
- import { parseSnapshot, type ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
16
+ import { parseSnapshotReport, type ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
17
17
  import type { OrchestratorInput } from '../lib/work-orchestrator/input.js'
18
18
  import type { UsageMonitor, UsageView } from '../lib/work-orchestrator/usage.js'
19
19
  import { orchestratorInputFor, orchestratorSnapshots, orchestratorUsage } from '../lib/work-orchestrator/runtime.js'
@@ -67,8 +67,8 @@ export function createWorkOrchestratorRouter(given: Partial<WorkOrchestratorDeps
67
67
  return res.status(400).json({ error: 'invalid_snapshot_binding' })
68
68
  }
69
69
  if (Buffer.byteLength(JSON.stringify(body)) > ORCHESTRATOR_BODY_BYTES) return res.status(413).json({ error: 'snapshot_too_large' })
70
- let snapshot: ControlSnapshot
71
- try { snapshot = parseSnapshot(body.snapshot, deps.now()) }
70
+ let snapshot: ControlSnapshot, droppedLiveRows: number
71
+ try { ({ snapshot, droppedLiveRows } = parseSnapshotReport(body.snapshot, deps.now())) }
72
72
  catch { return res.status(400).json({ error: 'invalid_snapshot' }) }
73
73
  const binding = () => {
74
74
  const id = deps.serverInstanceId()
@@ -90,7 +90,9 @@ export function createWorkOrchestratorRouter(given: Partial<WorkOrchestratorDeps
90
90
  // This is the same paired-client evidence cache as /input, not execution authority. parseSnapshot
91
91
  // retains only its known fields; arbitrary automatic/forkable/capability fields never enter the cache.
92
92
  deps.putSnapshot(snapshot)
93
- return res.json({ protocolVersion: 1, accepted: true, workID: body.workID, revision: body.revision, serverInstanceId })
93
+ if (droppedLiveRows) console.warn(`[work-orchestrator] snapshot work=${body.workID} droppedLiveRows=${droppedLiveRows}`)
94
+ return res.json({ protocolVersion: 1, accepted: true, workID: body.workID, revision: body.revision, serverInstanceId,
95
+ ...(droppedLiveRows ? { droppedLiveRows } : {}) })
94
96
  } catch (error) {
95
97
  if (res.destroyed) return
96
98
  const known = error instanceof AskWorkPlanError
@@ -105,16 +107,19 @@ export function createWorkOrchestratorRouter(given: Partial<WorkOrchestratorDeps
105
107
  || !Array.isArray(meetings) || meetings.length > 20 || !meetings.every(m => typeof m === 'string' && MEETING_ID.test(m))) {
106
108
  return res.status(400).json({ error: 'invalid_orchestrator_input' })
107
109
  }
110
+ // 7.0.13: live rows the snapshot left out (a short or malformed session id, or text over a bound). Reported in the
111
+ // answer and the log line only when a snapshot came with this request and something was left out.
112
+ let droppedLiveRows = 0
108
113
  if ('snapshot' in body) {
109
114
  if (Buffer.byteLength(JSON.stringify(body.snapshot)) > ORCHESTRATOR_BODY_BYTES) return res.status(413).json({ error: 'snapshot_too_large' })
110
115
  let snapshot: ControlSnapshot
111
- try { snapshot = parseSnapshot(body.snapshot, deps.now()) } catch (e) { return res.status(400).json({ error: 'invalid_snapshot', reason: (e as Error).message }) }
116
+ try { ({ snapshot, droppedLiveRows } = parseSnapshotReport(body.snapshot, deps.now())) } catch (e) { return res.status(400).json({ error: 'invalid_snapshot', reason: (e as Error).message }) }
112
117
  deps.putSnapshot(snapshot)
113
118
  }
114
119
  try {
115
120
  const input = await deps.input(body.workID, meetings as string[])
116
- console.log(`[work-orchestrator] input work=${body.workID} rows=${input.rows.length} discovered=${input.discovered} leftOut=${input.leftOut.length} snapshotAge=${input.snapshotAgeSec ?? '-'}s liveAge=${input.liveListAgeSec ?? '-'}s scan=${input.logScanComplete ? 'complete' : 'partial'}`)
117
- return res.json(input)
121
+ console.log(`[work-orchestrator] input work=${body.workID} rows=${input.rows.length} discovered=${input.discovered} leftOut=${input.leftOut.length} snapshotAge=${input.snapshotAgeSec ?? '-'}s liveAge=${input.liveListAgeSec ?? '-'}s scan=${input.logScanComplete ? 'complete' : 'partial'}${droppedLiveRows ? ` droppedLiveRows=${droppedLiveRows}` : ''}`)
122
+ return res.json(droppedLiveRows ? { ...input, droppedLiveRows } : input)
118
123
  } catch (e) {
119
124
  console.warn(`[work-orchestrator] input failed for ${body.workID}: ${(e as Error).message}`)
120
125
  return res.status(503).json({ error: 'orchestrator_input_unavailable' })
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ // 6.99.31 (Phase 6): the pack step. Writes server/web-control/.manifest.json, the sha256 of every
3
+ // file COS Control Web ships (every regular non-dot file except *.test.ts, which never ship). With
4
+ // COS_WEB_CONTROL_INTEGRITY on, the server serves /control only while the files match it
5
+ // (server/lib/web-control-integrity.ts, whose computeWebControlManifest must agree with this byte
6
+ // for byte; web-control-integrity.test.ts pins that). Run by `npm pack` / `npm publish` (prepack).
7
+ // Writes one file, prints its path and the file count. Never reads anything outside web-control/.
8
+ import { createHash } from 'node:crypto'
9
+ import { readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
10
+ import { dirname, join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ export function manifestFor(dir) {
14
+ const files = {}
15
+ const walk = (d, base) => {
16
+ let names
17
+ try { names = readdirSync(d) } catch { return }
18
+ for (const name of names.sort()) {
19
+ if (name.startsWith('.')) continue
20
+ const file = join(d, name)
21
+ let st
22
+ try { st = statSync(file) } catch { continue }
23
+ const urlPath = `${base}/${name}`
24
+ if (st.isDirectory()) walk(file, urlPath)
25
+ else if (st.isFile() && !name.endsWith('.test.ts')) files[urlPath] = createHash('sha256').update(readFileSync(file)).digest('hex')
26
+ }
27
+ }
28
+ walk(dir, '')
29
+ return { v: 1, algorithm: 'sha256', files }
30
+ }
31
+
32
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
33
+ if (isMain) {
34
+ const dir = process.argv[2] ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'web-control')
35
+ const manifest = manifestFor(dir)
36
+ const out = join(dir, '.manifest.json')
37
+ const tmp = `${out}.tmp`
38
+ writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 })
39
+ renameSync(tmp, out)
40
+ // stderr, never stdout: it runs as `prepack`, and `npm pack --json` callers (the release packager, package-contents and
41
+ // launcher-contract tests) parse stdout as JSON (7.0.9 full suite).
42
+ console.error(`[web-control-manifest] ${out} (${Object.keys(manifest.files).length} files)`)
43
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "v": 1,
3
+ "algorithm": "sha256",
4
+ "files": {
5
+ "/app.css": "0d3ebbf2ca167bc9ef6ab8efef41618f514cc4f505ae588642d36935249697af",
6
+ "/app.js": "3c489b154588908178de5b70b18ee8184ec9256e99f774177b9e35ceb27eb362",
7
+ "/apple-touch-icon.png": "04ec7eca0bf6f53d89900d146ac32d4826759d6258bcdbd1ccdcc517f26f532c",
8
+ "/control.css": "e7b287aa4acc1b3895a9ce61512fe449c555b6335a7145684fd78108ae008c75",
9
+ "/fonts/DMSans.ttf": "8cd08d97e89c24d0aa92edd2f0f4c8ee6195eee9b7c9f154865a58b02f0c1c0d",
10
+ "/fonts/Fraunces.ttf": "177ff6c0f14e5550a3c624247cd1189611d4eb65d000b14944c63d967958abbb",
11
+ "/fonts/JetBrainsMono.ttf": "48715a42ec242c21e9f02692891e147d022299a52e48d5e413e1a942193ffeda",
12
+ "/index.html": "26ee9cd1f68829ae5f47dcc7a0c2e45a40d28c4ee65d0537f0677b13ea727267",
13
+ "/mark.svg": "8e380d33d393027df192eb1afa08f15a380c2070c72d68f27311b49d9e94ca18",
14
+ "/memories/d3.min.js": "f2094bbf6141b359722c4fe454eb6c4b0f0e42cc10cc7af921fc158fceb86539",
15
+ "/memories/graph-explorer.css": "ec128de59811f45c71b6bededd737989309205e903951eb8211ade7664f87486",
16
+ "/memories/graph-explorer.js": "98bde3df25f42022b098d047d2c8e7a22b972c1d817a18a24a0ab075d77871d3",
17
+ "/memories/memory-workspace.js": "013f6c8c72c3474768f8c374edccde0e3656fc243e56528e258c3c29acca64f5",
18
+ "/memory-review.js": "f27fe519f7ad7eccc3b44124fae724aa51e2eb3cb060d2f1795600e0e41b074f",
19
+ "/panes/cos-chat.css": "29a5d22d0f02d0707e65711d151ad7c8ac0ab4bfeb30ab04b1f9d9fcf7d457b8",
20
+ "/panes/cos-chat.js": "0005562525cab6353ef8fc002d7960b30a8bf9fc9a9a1059c0123c6cf343366e",
21
+ "/panes/dictation.js": "e9434b353a1a3ab3f21d4cef5b2d5f927a733f6af5f58285fda6a78472761f91",
22
+ "/panes/meetings.css": "3ad4d0c5d59bd9f21c5ac9e234437cc0c81b381bd98ca4634b147efba5105a53",
23
+ "/panes/meetings.js": "e3839cd7d52c98510ca232e41d3a336806d740b4a1381eab1a367d56a765f925",
24
+ "/panes/memories.css": "36731d6fd21ad1516dfeccf34fe5b2d3d267d21e40b47704055a0685334b579e",
25
+ "/panes/memories.js": "28461f953ad0bf9d13287039594f2ce7ea29a46366e249240fa50f4b0f3b8c31",
26
+ "/panes/messages.js": "be7a7b294f67a43fcb4cc518da987d2da2990484cb677ada810afa93af25a8a5",
27
+ "/panes/preview.js": "79f1aecb27f48d584ed4cf37c7f4f423f0f9b45dd6c2fb24a9d88c72625d8680",
28
+ "/panes/questions.css": "5c5a9b32ce32386b508ce326b17d40ecacbb0f2e049df7f4fe2a0009bd3b7908",
29
+ "/panes/questions.js": "228e8cd0a9651a290cbb380db487af677a2fc7e93a64b1e33c9411cd8b013478",
30
+ "/panes/reader-media.js": "4cf892a855aa2d0dce6978e69296cd49b8036d8fc62f12773b3fcadc210b2ef0",
31
+ "/panes/sessions.css": "fa2e714e7b03459e12407f7125ab0e1eb7d12a4cc9341a2442dc73900f5a3081",
32
+ "/panes/settings.js": "5c46c951fdcc2fc942486ec4b468b7e0bb0350a0a56a38d429948fc5147ab2f4",
33
+ "/panes/speakers.css": "49e70dae4266151b14b0057f2e43de169de225b8f3ac30343c81cf532735a487",
34
+ "/panes/speakers.js": "8132c9244fb3965de802680840d2985978571988b9b4462b347d079330251bc5",
35
+ "/panes/threads.js": "45fd7b56480be53225e91193cfa4c41a4ee7167890f10560e439bc7d2461747f",
36
+ "/panes/today-actions.css": "c82f95b2aaf09802b15d47b24eccdb5c974afcab38ff5f861b3a8d8630b60869",
37
+ "/panes/today-actions.js": "4fbc51dc624f9e4cdc85db89905f019a8a5961e4984e8733e0525c71cf0e8657",
38
+ "/panes/work.css": "da50355dac690f712d1f6331f70ee5eadbf5982d37508648ae7786afc99ac7b1",
39
+ "/panes/work.js": "0425f53fba98ca34496486d914c3b03f95de3c2a651dc0c88a2a20bc4f6407cd",
40
+ "/shell.js": "f1cc6a52c78e01ddba89ff27c6bd0e8f8bd8f29bdfcbb5150ee4ed66a8128d9d",
41
+ "/theme.js": "700453683539c9efcde014ef1eb088c39478dad54f1ef53fa5e2dee9b36f0a3f",
42
+ "/vendor/qrcode-encoder.js": "61d87cc7445cc66b862cff913de4571634746ea62bcd50d0f592be14f6d1ce46"
43
+ }
44
+ }
@@ -254,6 +254,8 @@ select.field {
254
254
  .story { font: 400 24px/1.28 var(--display); letter-spacing: -0.005em; margin: 26px 2px; text-wrap: pretty; }
255
255
  /* The Today summary's quiet age line (app.js briefWhen). */
256
256
  .story-when { display: block; margin-top: 10px; font: 400 14px/1.35 var(--body); letter-spacing: 0; color: var(--sub); }
257
+ /* Today round 2: "Ask about this" under the summary, the COS chat composer (panes/cos-chat.css) laid flat in the page. */
258
+ .today-ask.cc-composer { position: static; background: transparent; border-top: 0; padding: 0 0 14px; }
257
259
  .story-skel { display: grid; gap: 12px; margin: 30px 0; }
258
260
  .story-skel .skeleton { height: 22px; }
259
261
 
@@ -298,6 +300,7 @@ select.field {
298
300
  .next-later .later-when { color: var(--sub); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 4.5em; }
299
301
  .next-later .later-what { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
300
302
  .next-later .later-asof { color: var(--sub); margin-top: 4px; }
303
+ .next-later .later-more { margin-top: 4px; padding: 3px 0; }
301
304
 
302
305
  /* ── Lists (sessions, meetings, messages) ────────────────── */
303
306
  .list { background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow); }
@@ -415,6 +418,11 @@ select.field {
415
418
  .menu-host { display: flex; align-items: center; gap: 8px; color: var(--sub); font-size: 14px; margin: 6px 4px 12px; }
416
419
  .sheet-head { display: flex; gap: 10px; align-items: center; }
417
420
  .sheet-head .field { flex: 1; }
421
+ /* W1 (Miles 2026-11-09 21:24, closed Duo): the Ask field sat beside the camera, the clip and Cancel and its placeholder
422
+ was cut off. It needs at least 15rem; when the buttons do not fit beside that, they go to the row below, at its right. */
423
+ #ask-form { flex-wrap: wrap; }
424
+ #ask-form > .field { flex: 1 1 15rem; min-width: 0; }
425
+ #ask-form > .ask-clip { margin-left: auto; }
418
426
  @keyframes fade { from { opacity: 0; } }
419
427
  @keyframes drop { from { transform: translateY(-24px); opacity: 0.5; } }
420
428
 
@@ -582,7 +590,15 @@ select.field {
582
590
  @media (any-pointer: coarse) {
583
591
  input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='color']):not([type='file']):not([type='button']):not([type='submit']):not([type='reset']):not([type='image']):not([type='hidden']),
584
592
  select, textarea { font-size: 16px !important; }
593
+ /* 7.0.17 (Miles 2026-11-10, Duo web app zooming): the same floor for any element that types without being a field.
594
+ None exists in Control Web today (work.js only names the selector); this keeps a future editor from zooming. */
595
+ [contenteditable]:not([contenteditable='false']) { font-size: 16px !important; }
585
596
  }
597
+ /* 7.0.17 (Miles 2026-11-10: "a lot of zooming in and out as I'm opening and closing keyboard"; the Duo's own breadcrumb
598
+ showed vvs=2.12 with the keyboard down, a double-tap zoom fitting a column). manipulation keeps pan and pinch and
599
+ turns off double-tap zoom. On the root only: touch-action is the intersection down the tree, so the splitter's
600
+ none and the charts' pan-y stay exactly as strict as they were. */
601
+ html { touch-action: manipulation; }
586
602
  /* Folded phones: every button and field is at least 44 px tall (design QA: .cx-btn came in six heights from 30 px). A
587
603
  minimum only, so it grows the compact ones and leaves the taller ones; the per-pane phone heights that fought it are
588
604
  gone. */
@@ -608,3 +624,23 @@ select.field {
608
624
  /* 6.99.30 (QA): an element with the hidden attribute is hidden, whatever display its classes give it ("Retry image"
609
625
  showed under loaded images: .link-btn's inline-flex beat [hidden]). */
610
626
  [hidden] { display: none !important; }
627
+
628
+ /* W1: the Mac not answering, and the sign-in ending soon (app.js "Mac reachability and sign-in lifetime"). */
629
+ .conn-notice { display: grid; gap: 8px; margin: 0 0 12px; }
630
+ .conn-notice[hidden] { display: none; }
631
+ .conn-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 12px 14px; border-radius: var(--radius-sm); border: 1px solid var(--panel-border); background: var(--panel); color: var(--ink); font-size: 15px; line-height: 1.4; }
632
+ .conn-line strong { font-weight: 600; }
633
+ .conn-line .conn-sub { color: var(--sub); flex: 1 1 220px; min-width: 0; }
634
+ .conn-line .btn { margin: 0; }
635
+ .auth-offline p { color: var(--sub); }
636
+ .meaning-consent .row-actions { padding: 0 16px 14px; }
637
+ /* W1 A5: a question waiting on the Mac's own prompt, at the top of the session reader. */
638
+ .sx-mac-q { margin: 8px 0 12px; padding: 12px 14px; border-radius: var(--radius-sm); border: 1px solid var(--panel-border); background: var(--panel); }
639
+ .sx-mac-q[hidden] { display: none; }
640
+ .sx-mac-q .sx-mac-q-title { margin: 0 0 6px; }
641
+ /* 7.0.18: a turn that ended asking him: the choices as buttons in a row that wraps under its own width (at most two,
642
+ each label at most 40 characters), so the closed Duo stacks them and the open Duo keeps them side by side. Labels wrap
643
+ inside a button rather than cut. */
644
+ .sx-te-choices, .ta-te-choices { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 6px; }
645
+ .sx-te-choices .cx-btn, .ta-te-choices .cx-btn { flex: 1 1 12rem; min-height: 44px; white-space: normal; text-align: center; line-height: 1.25; }
646
+ .sx-mac-q .sx-note { margin: 6px 0 0; }