@gotcos/glasses-server 7.0.7 → 7.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +19 -0
- package/CHANGELOG.md +305 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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": "a4dbdb42ba6c9de5dc02235985fde499854693c41fca54ad3f3395587ab9834a",
|
|
6
|
+
"/app.js": "9a26933a5bd72724b620c9c61d11508d895f63ddea620df684f62bb585d761de",
|
|
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": "736f56bbeec449f2204b815594d74042924622ef3004227202a0374df8bc82b5",
|
|
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": "654e0509ab2b699b9ea92b6d9e2f7522146df1f0b6355915bef03caaf63addc4",
|
|
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": "57ae8b378b1061060ae2a3f61d227d440d7806538085c1d5b310239c036e4bdc",
|
|
38
|
+
"/panes/work.css": "da50355dac690f712d1f6331f70ee5eadbf5982d37508648ae7786afc99ac7b1",
|
|
39
|
+
"/panes/work.js": "1ef872b8c7e1a57bd7bd314e11eee7ac5fa9668871da7e644a1fbf5fd9c0d9e8",
|
|
40
|
+
"/shell.js": "f1cc6a52c78e01ddba89ff27c6bd0e8f8bd8f29bdfcbb5150ee4ed66a8128d9d",
|
|
41
|
+
"/theme.js": "360df6013cd638524543728f8fa3d35f5641d4d230da7c290de40c79216670ee",
|
|
42
|
+
"/vendor/qrcode-encoder.js": "61d87cc7445cc66b862cff913de4571634746ea62bcd50d0f592be14f6d1ce46"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -106,6 +106,15 @@ a { color: var(--accent); }
|
|
|
106
106
|
svg.icon { width: 22px; height: 22px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
|
107
107
|
|
|
108
108
|
/* ── Sign in ─────────────────────────────────────────────── */
|
|
109
|
+
/* 7.0.19 first paint: the mark on the page's background until the sign-in card or the app shows (index.html #first-paint).
|
|
110
|
+
Fixed, so it is out of the layout and nothing shifts when it goes. Only while theme.js has run (html.fp-wait), so it
|
|
111
|
+
never shows with JavaScript off. The failsafe hides it after 25 s, longer than the 20 s sign-in check, so it ends the
|
|
112
|
+
wait only when the scripts never ran; reduced motion shortens durations, not this delay. */
|
|
113
|
+
.first-paint { display: none; }
|
|
114
|
+
html.fp-wait .first-paint { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; background: var(--page); color: var(--ink); pointer-events: none; animation: fp-failsafe 1ms linear 25s forwards; }
|
|
115
|
+
.first-paint .mark { width: 44px; height: 44px; }
|
|
116
|
+
@keyframes fp-failsafe { to { visibility: hidden; } }
|
|
117
|
+
.noscript-note { margin: 0; min-height: 100dvh; display: grid; place-items: center; padding: 24px var(--gutter); font-size: 17px; color: var(--ink); text-align: center; }
|
|
109
118
|
.auth-gate { min-height: 100dvh; display: grid; place-items: center; padding: calc(var(--safe-top) + 24px) var(--gutter) calc(var(--safe-bottom) + 24px); }
|
|
110
119
|
.auth-card { width: 100%; max-width: 420px; }
|
|
111
120
|
.auth-card .mark { width: 44px; height: 44px; margin-bottom: 22px; }
|
|
@@ -254,6 +263,8 @@ select.field {
|
|
|
254
263
|
.story { font: 400 24px/1.28 var(--display); letter-spacing: -0.005em; margin: 26px 2px; text-wrap: pretty; }
|
|
255
264
|
/* The Today summary's quiet age line (app.js briefWhen). */
|
|
256
265
|
.story-when { display: block; margin-top: 10px; font: 400 14px/1.35 var(--body); letter-spacing: 0; color: var(--sub); }
|
|
266
|
+
/* Today round 2: "Ask about this" under the summary, the COS chat composer (panes/cos-chat.css) laid flat in the page. */
|
|
267
|
+
.today-ask.cc-composer { position: static; background: transparent; border-top: 0; padding: 0 0 14px; }
|
|
257
268
|
.story-skel { display: grid; gap: 12px; margin: 30px 0; }
|
|
258
269
|
.story-skel .skeleton { height: 22px; }
|
|
259
270
|
|
|
@@ -298,6 +309,7 @@ select.field {
|
|
|
298
309
|
.next-later .later-when { color: var(--sub); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 4.5em; }
|
|
299
310
|
.next-later .later-what { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
300
311
|
.next-later .later-asof { color: var(--sub); margin-top: 4px; }
|
|
312
|
+
.next-later .later-more { margin-top: 4px; padding: 3px 0; }
|
|
301
313
|
|
|
302
314
|
/* ── Lists (sessions, meetings, messages) ────────────────── */
|
|
303
315
|
.list { background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow); }
|
|
@@ -415,6 +427,11 @@ select.field {
|
|
|
415
427
|
.menu-host { display: flex; align-items: center; gap: 8px; color: var(--sub); font-size: 14px; margin: 6px 4px 12px; }
|
|
416
428
|
.sheet-head { display: flex; gap: 10px; align-items: center; }
|
|
417
429
|
.sheet-head .field { flex: 1; }
|
|
430
|
+
/* W1 (Miles 2026-11-09 21:24, closed Duo): the Ask field sat beside the camera, the clip and Cancel and its placeholder
|
|
431
|
+
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. */
|
|
432
|
+
#ask-form { flex-wrap: wrap; }
|
|
433
|
+
#ask-form > .field { flex: 1 1 15rem; min-width: 0; }
|
|
434
|
+
#ask-form > .ask-clip { margin-left: auto; }
|
|
418
435
|
@keyframes fade { from { opacity: 0; } }
|
|
419
436
|
@keyframes drop { from { transform: translateY(-24px); opacity: 0.5; } }
|
|
420
437
|
|
|
@@ -582,7 +599,15 @@ select.field {
|
|
|
582
599
|
@media (any-pointer: coarse) {
|
|
583
600
|
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
601
|
select, textarea { font-size: 16px !important; }
|
|
602
|
+
/* 7.0.17 (Miles 2026-11-10, Duo web app zooming): the same floor for any element that types without being a field.
|
|
603
|
+
None exists in Control Web today (work.js only names the selector); this keeps a future editor from zooming. */
|
|
604
|
+
[contenteditable]:not([contenteditable='false']) { font-size: 16px !important; }
|
|
585
605
|
}
|
|
606
|
+
/* 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
|
|
607
|
+
showed vvs=2.12 with the keyboard down, a double-tap zoom fitting a column). manipulation keeps pan and pinch and
|
|
608
|
+
turns off double-tap zoom. On the root only: touch-action is the intersection down the tree, so the splitter's
|
|
609
|
+
none and the charts' pan-y stay exactly as strict as they were. */
|
|
610
|
+
html { touch-action: manipulation; }
|
|
586
611
|
/* 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
612
|
minimum only, so it grows the compact ones and leaves the taller ones; the per-pane phone heights that fought it are
|
|
588
613
|
gone. */
|
|
@@ -608,3 +633,23 @@ select.field {
|
|
|
608
633
|
/* 6.99.30 (QA): an element with the hidden attribute is hidden, whatever display its classes give it ("Retry image"
|
|
609
634
|
showed under loaded images: .link-btn's inline-flex beat [hidden]). */
|
|
610
635
|
[hidden] { display: none !important; }
|
|
636
|
+
|
|
637
|
+
/* W1: the Mac not answering, and the sign-in ending soon (app.js "Mac reachability and sign-in lifetime"). */
|
|
638
|
+
.conn-notice { display: grid; gap: 8px; margin: 0 0 12px; }
|
|
639
|
+
.conn-notice[hidden] { display: none; }
|
|
640
|
+
.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; }
|
|
641
|
+
.conn-line strong { font-weight: 600; }
|
|
642
|
+
.conn-line .conn-sub { color: var(--sub); flex: 1 1 220px; min-width: 0; }
|
|
643
|
+
.conn-line .btn { margin: 0; }
|
|
644
|
+
.auth-offline p { color: var(--sub); }
|
|
645
|
+
.meaning-consent .row-actions { padding: 0 16px 14px; }
|
|
646
|
+
/* W1 A5: a question waiting on the Mac's own prompt, at the top of the session reader. */
|
|
647
|
+
.sx-mac-q { margin: 8px 0 12px; padding: 12px 14px; border-radius: var(--radius-sm); border: 1px solid var(--panel-border); background: var(--panel); }
|
|
648
|
+
.sx-mac-q[hidden] { display: none; }
|
|
649
|
+
.sx-mac-q .sx-mac-q-title { margin: 0 0 6px; }
|
|
650
|
+
/* 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,
|
|
651
|
+
each label at most 40 characters), so the closed Duo stacks them and the open Duo keeps them side by side. Labels wrap
|
|
652
|
+
inside a button rather than cut. */
|
|
653
|
+
.sx-te-choices, .ta-te-choices { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 6px; }
|
|
654
|
+
.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; }
|
|
655
|
+
.sx-mac-q .sx-note { margin: 6px 0 0; }
|