@gotcos/glasses-server 6.99.33 → 7.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
package/README.md CHANGED
@@ -473,7 +473,9 @@ settled on evidence, never sent twice), `COS_PIN_BOOT_REAP` (default on: a threa
473
473
  run held frees 31 minutes after the restart, once its child is gone), `COS_QUEUE_RESEND`
474
474
  (default on: Send again on a refused or expired queued turn), `COS_MESSAGES_STEER` (default
475
475
  off: steer a running Claude Ask from the glasses), `COS_SESSION_STEER` (default off: steer a
476
- busy Claude desk session through its peer inbox) and `COS_QUERY_JOB_DELTA_JOURNAL` (default
476
+ busy Claude desk session through its peer inbox when a client asks), `COS_STEER_MIDTURN` (default
477
+ on: a message parked at a busy Claude desk session lands inside the running turn at its next tool
478
+ step, through the same peer inbox; `0` makes it wait for the turn to end) and `COS_QUERY_JOB_DELTA_JOURNAL` (default
477
479
  off until the release after 6.78.0: the compact Ask journal writer),
478
480
  `COS_PERMISSION_BROKER` (6.52.0: `0` answers every permission request with
479
481
  the Mac's own dialog, `questions` holds questions but not tool approvals; unset holds
@@ -0,0 +1,378 @@
1
+ > Current integration: Codex phone-approvals lane, based on server 7.0.4. No live enrollment or authority activation is implied. `COS_WEB_KEY_AUTHORITY` remains off by default. Historical 6.99.31 labels below describe the reusable WIP, not a shipped qualification. Fresh focused, final independent and on-device acceptance are required.
2
+
3
+ # Web key verifier contract (server 6.99.31, flag `COS_WEB_KEY_AUTHORITY`)
4
+
5
+ What `verify_g2_turn.py` (MU-Chief-Staff `operations/scripts/`) reads for a Control Web turn signed by
6
+ an enrolled phone key. The server half lives in `shared/web-turn-envelope.ts`,
7
+ `server/lib/web-key-*.ts`, `server/routes/web-key.ts` and `server/routes/g2-turns.ts`. When this file
8
+ and the code disagree, the code is wrong: fix one of them and add a line to the change log at the end.
9
+
10
+ Flag unset: none of this exists. `GET /api/g2-turns/:id` answers a web record exactly as 6.99.29
11
+ (`reason: "web_cookie_derivable_from_token"`, no web-key fields), and every `/api/web-key/*` route is
12
+ a 404 fall-through.
13
+
14
+ ## 0. Encodings
15
+
16
+ | Name | Format |
17
+ | --- | --- |
18
+ | b64url | RFC 4648 base64url, no padding, alphabet `[A-Za-z0-9_-]`. Length mod 4 never 1. |
19
+ | hex64 | 64 lower-case hex characters (a sha256 digest). |
20
+ | JCS | RFC 8785. Keys sorted by UTF-16 code unit (every key in this protocol is ASCII, so code-point sort agrees), no whitespace, strings escaped as ES `JSON.stringify`, numbers are safe integers only (no floats, no exponents). Lone surrogates are refused, in keys and values. Booleans are never integers: Python must reject `True`/`False` wherever an integer is required (`kver`, `epoch`, `iat`, `exp`, `bytes`, `wordsLen`, `createdAt`, `setAt`). Python mirror: `json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)` plus the surrogate and boolean checks. |
21
+ | P1363 | ECDSA P-256 / SHA-256 signature as `r||s`, exactly 64 bytes, carried b64url. Used by the phone key and the anchor. |
22
+ | DER sig | ECDSA signature as ASN.1 DER. Used by WebAuthn (ES256) only. |
23
+ | SPKI | P-256 SubjectPublicKeyInfo DER, carried b64url. Must re-export to identical bytes (no trailing data). |
24
+ | fingerprint | First 16 hex of sha256(SPKI DER), upper case, as `XXXX-XXXX-XXXX-XXXX`. For people. Machines compare the full `spkiSha256` (hex64). |
25
+ | kid | `b64url(sha256(SPKI DER))[0:22]`. |
26
+
27
+ Every signed object is signed over its JCS bytes. The server refuses bytes that are not already
28
+ canonical, so one message has exactly one signable form.
29
+
30
+ ## 1. Origins and identity
31
+
32
+ - **Signer origin:** `https://<selfDnsName>:3144` (`WEB_KEY_SIGNER_PORT = 3144`,
33
+ `webKeySignerOrigin()` in `server/lib/web-key-origin.ts`). The ONLY origin a phone key or passkey
34
+ signs from. `envelope.origin`, the request `Origin` header, the passkey `clientDataJSON.origin`
35
+ and the cookie gate's extra port all use this one function. 3143 (the big app) never signs.
36
+ - **What 3144 serves (Phase 3, `lib/web-key-signer-app.ts`):** the signer page only (`server/web-signer/`:
37
+ `index.html`, `signer.js`, `signer-proof.js`, `signer-store.js`, `signer.css`), from bytes that matched
38
+ `server/web-signer/.manifest.json` (written at pack time like the Control Web manifest), plus an /api
39
+ allowlist: `GET web-key/status`, `GET web-key/keys`, `POST web-key/nonce`, `POST web-key/enroll/start`,
40
+ `POST web-key/enroll/prove`, `GET web-key/enroll/status`, `POST web-key/revoke`, `POST web-key/handoff/claim`,
41
+ `POST agent-sessions/bindings/:id/turns`, `GET agent-sessions/bindings/:id/turns/:turnId`. Everything else
42
+ is 404 there. An /api request naming another `Origin` is refused (403 `origin_refused`), and a write must
43
+ name the signer origin. Every answer carries `Content-Security-Policy` with `default-src 'none'`,
44
+ `script-src 'self'`, `style-src 'self'`, `frame-ancestors 'none'` (no inline script or style), plus
45
+ `X-Frame-Options: DENY`, COOP and CORP `same-origin`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store`.
46
+ - **Signer-only steps:** `enroll/start`, `enroll/prove`, `nonce` and `handoff/claim` answer 403
47
+ `signer_origin_required` unless the request reached the 3144 listener (socket local port). A page on 3143
48
+ can neither make a key nor fetch a handoff.
49
+ - **rpId:** `selfDnsName`, the full lower-case MagicDNS host, no trailing dot (for example
50
+ `ukaoma-mac-studio.tail38457f.ts.net`; `ts.net` is a public suffix). The verifier derives it as
51
+ the host of `envelope.origin`.
52
+ - **aud:** `hex(sha256(anchor SPKI DER || utf8(lower(rpId))))`. The verifier computes it from ITS
53
+ pinned anchor, never from a key in the answer.
54
+
55
+ ## 2. The anchor (B1)
56
+
57
+ The anchor is COS Control's Secure Enclave P-256 key. It reaches the server only through:
58
+
59
+ `POST /api/web-key/anchor` (flag on, loopback socket only, `X-Cos-Token` required, no proxy headers)
60
+
61
+ 7.0.9: the same route also answers with `COS_WEB_KEY_ANCHOR_BOOTSTRAP` on and `COS_WEB_KEY_AUTHORITY` off,
62
+ with identical gates and the identical record. In that mode `GET /api/web-key/status` (token only)
63
+ answers `{ ok, enabled: false, mode: "bootstrap", anchorRegistered, anchorMature, anchorSetAt, aud,
64
+ origin, epoch }` and no other web-key route, listener or vouch exists. Nothing in this contract's
65
+ verdicts changes: a bootstrap-only server never vouches a web turn.
66
+
67
+ ```json
68
+ { "anchorPub": "<SPKI b64url>", "statement": "<b64url of JCS bytes>", "signature": "<P1363 b64url>" }
69
+ ```
70
+
71
+ Statement (the anchor signs it with itself):
72
+
73
+ ```json
74
+ { "v": "cos-web-anchor/1", "spkiSha256": "<hex64 of anchorPub DER>", "fingerprint": "XXXX-XXXX-XXXX-XXXX", "createdAt": <ms> }
75
+ ```
76
+
77
+ `createdAt` must be within 10 minutes of the server clock. The server stores
78
+ `anchorRegistration: { statement, signature, setAt }` next to `anchorPub` in `web-keys.json`, where
79
+ `setAt` is the server clock at registration. Registering a different anchor revokes every key and
80
+ bumps the epoch.
81
+
82
+ - An `anchorPub` in the file WITHOUT a registration record whose self-signature verifies is refused
83
+ (`anchor_unregistered`): no enrollment, no vouch.
84
+ - `WEB_ANCHOR_MATURITY_MS = 86_400_000` (24 h). An anchor younger than that vouches nothing
85
+ (`anchor_too_new`).
86
+ - `setAt` is file-backed, so a same-user process can backdate it. The 24 h rule binds the server's
87
+ own route; **the verifier's committed pin is the real check.** The verifier must refuse when the
88
+ pin file is missing, uncommitted, or its `spkiSha256` differs from `anchor.spkiSha256` in the answer.
89
+
90
+ ## 3. Enrollment (anchor-signed approval)
91
+
92
+ Approval object, `cos-web-enroll-approve/2`. Every value Control displays is inside these bytes
93
+ (W4); Control decodes `approvalJcs` and derives the SAS, fingerprint and label from it, never from a
94
+ separate field. `GET /api/web-key/enroll/pending` returns only `{ enrollId, startedAt, exp, approval, approvalJcs }`.
95
+
96
+ ```json
97
+ {
98
+ "v": "cos-web-enroll-approve/2",
99
+ "enrollId": "<b64url 16 bytes>",
100
+ "kid": "<kid>",
101
+ "spki": "<phone key SPKI b64url>",
102
+ "spki_sha256": "<hex64>",
103
+ "fingerprint": "XXXX-XXXX-XXXX-XXXX",
104
+ "label": "<1..64 chars, attacker-supplied text>",
105
+ "userAgent": "<0..200 chars, attacker-supplied text>",
106
+ "remote": "<enrolling socket address, normalized, no ::ffff: prefix>",
107
+ "challenge": "<b64url 32 bytes, server-chosen>",
108
+ "sasCommit": "<hex64 = sha256(phoneNonce bytes)>",
109
+ "phoneNonce": "<b64url 32 bytes, revealed at prove>",
110
+ "sas": "<6 digits>",
111
+ "aud": "<hex64>",
112
+ "origin": "https://<rpId>:3144",
113
+ "kver": 1,
114
+ "epoch": <int>,
115
+ "rotates": "<kid>" | null,
116
+ "passkey_credential_id": "<b64url>",
117
+ "passkey_spki_sha256": "<hex64 of passkey SPKI DER>",
118
+ "enrolledAt": <ms>,
119
+ "exp": <ms>
120
+ }
121
+ ```
122
+
123
+ - **SAS (W3, commit-reveal):** the phone sends `sasCommit = sha256(r)` at start, BEFORE the server
124
+ picks `challenge`; it reveals `r` (`phoneNonce`) at prove.
125
+ `sas = uint32_be(sha256(challenge || spkiDer || r)[0:4]) mod 1_000_000`, zero-padded to 6.
126
+ The phone computes it locally. The server cannot grind `challenge` to match a chosen SAS because
127
+ `r` is unknown to it when it picks.
128
+ - **Proof of possession:** the phone key signs (P1363) the JCS of
129
+ `{ "v": "cos-web-enroll/2", "enrollId", "challenge", "aud", "origin", "kid", "phoneNonce", "passkey_credential_id", "passkey_spki_sha256" }`,
130
+ and the passkey asserts (section 5) with challenge `sha256(those JCS bytes)`.
131
+ - **Device allowlist (W6):** enrollment start is refused (`device_not_allowed`) unless the socket
132
+ address is in `COS_WEB_KEY_DEVICES` (comma-separated addresses). Empty list: every start refused.
133
+ - **Registry entry** (what the turn answer returns as `registryEntry`):
134
+ `{ kid, spki, passkeySpki, kver, label, approval, anchorSig, revokedAt? }`. `passkeySpki` is the
135
+ passkey's P-256 SPKI b64url (converted from COSE at enrollment); its sha256 must equal
136
+ `approval.passkey_spki_sha256`.
137
+ - An entry counts only when: `anchorSig` (P1363) verifies over JCS(approval) with the anchor,
138
+ `approval.kid == kid == kid(spki)`, `approval.spki == spki`, `approval.spki_sha256 == sha256(spki)`,
139
+ `approval.aud == aud`, `approval.kver == kver`, `approval.epoch == current epoch` (W5,
140
+ `key_epoch_stale`), `sha256(passkeySpki) == approval.passkey_spki_sha256`, no `revokedAt`.
141
+ `approval.rotates` (W8) names the one key the approval revoked; nothing else is revoked by it.
142
+
143
+ ## 4. The envelope, `cos-web-turn/1`
144
+
145
+ Header `x-cos-web-envelope`: b64url of the JCS bytes. Header `x-cos-web-sig`: P1363 b64url by the
146
+ phone key over those exact bytes. Header `x-cos-web-passkey`: section 5.
147
+
148
+ ```json
149
+ {
150
+ "v": "cos-web-turn/1", "aud": "<hex64>", "origin": "https://<rpId>:3144",
151
+ "kid": "<kid>", "kver": 1, "epoch": <int>,
152
+ "turnId": "^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$", "nonce": "^[A-Za-z0-9_-]{22,64}$",
153
+ "iat": <s>, "exp": <s>,
154
+ "provider": "claude" | "codex" | "cursor", "sessionId": "<1..128>", "bindingId": "<1..128>" | null,
155
+ "fork": false,
156
+ "wordsSha256": "<hex64 = sha256(utf8(words with CRLF replaced by LF, nothing else))>", "wordsLen": <utf8 byte length>,
157
+ "attachments": [ { "id": "<1..128>", "sha256": "<hex64>", "bytes": <int>, "name": "<0..255>" } ],
158
+ "scope": { "kind": "message" } | { "kind": "approve", ... }
159
+ }
160
+ ```
161
+
162
+ Exact key sets everywhere; an unknown key is refused. `exp - iat <= 900`, `iat <= now + 60`, `now <= exp`.
163
+
164
+ **Words never end in whitespace.** The footer step trims trailing ASCII whitespace
165
+ (`[\t\n\v\f\r ]`) before it appends the `[COS-WEB turn ...]` line, so the signer trims trailing
166
+ ASCII whitespace BEFORE it hashes, and the server refuses signed words that end in it
167
+ (`words_trailing_whitespace`). Inner whitespace is exact. The verifier hashes the words before the
168
+ footer as received (CRLF to LF only) and needs no trimming rule of its own.
169
+
170
+ ### Scope (B4)
171
+
172
+ - `message`: **conversation only.** `MESSAGE_SCOPE_MEANING = "conversation"`. It never authorizes
173
+ install, publish, push (main or branch), a Hub file, or anything on the never-list, whatever the
174
+ words say.
175
+ - `approve`: `{ "kind": "approve", "action", "artifact", "version", "digest", "channel", "packetSha256" }`
176
+
177
+ | Field | Pattern |
178
+ | --- | --- |
179
+ | action | `install` \| `publish` \| `push-main` |
180
+ | artifact | `^[a-z0-9@][a-z0-9@._/-]{0,127}$` |
181
+ | version | `^[0-9]{1,5}\.[0-9]{1,5}\.[0-9]{1,6}(-[0-9A-Za-z.-]{1,32})?$` |
182
+ | digest | `^sha256:[0-9a-f]{64}$` or `^sha512:[0-9a-f]{128}$` or `^sha512-[A-Za-z0-9+/]{86}==$` (npm SRI) |
183
+ | channel | from the table below only |
184
+ | packetSha256 | hex64 of the JCS bytes of the packet |
185
+
186
+ | action | allowed channels |
187
+ | --- | --- |
188
+ | install | `mac-local` |
189
+ | publish | `npm-next`, `npm-latest`, `control-appcast`, `hub-alias`, `gotcos-site`, `control-downloads`, `release-docs` |
190
+ | push-main | `git-main` |
191
+
192
+ In this candidate `install` and `publish` are accepted only through exact scoped proof (`WEB_KEY_ENABLED_APPROVE_ACTIONS`). `push-main` remains refused with `approve_action_not_enabled`. A message scope never authorizes either action.
193
+
194
+ ### Handoff from Control Web (3143) to the signer (3144), Phase 3
195
+
196
+ The composer never signs and never frames the signer. It posts the turn it wants approved, then navigates
197
+ TOP LEVEL to the signer. The words are fetched by the signer; they are never in a URL.
198
+
199
+ 1. `POST /api/web-key/handoff` (web path, flag on) `{ bindingId, epoch, targetKey, clientTurnId, prompt,
200
+ acknowledgedRevision?, boundTo?, packetSha256? }`. The destination (`provider`, `sessionId`) comes from
201
+ the binding, never the body; a moved binding is `409 binding_changed`. Refused: `no_phone_key` (no
202
+ active key), `attachments_not_signable` (any `attachmentIds`), `words_too_long` (over 8000 chars),
203
+ `packet_unknown`, `packet_session_mismatch` (a packet is signable only for the session that created it).
204
+ Answer `201 { ok, handoffId, exp, signerUrl: "https://<rpId>:3144/#h=<handoffId>" }`. The composer
205
+ navigates only to that exact shape (same host, port 3144, path `/`, no query, fragment id only).
206
+ 2. `POST /api/web-key/handoff/claim { handoffId }` (web path, 3144 listener, `Origin` = signer origin).
207
+ Once only (`409 handoff_claimed`), within 120 s (`410 handoff_expired`). Answer
208
+ `{ ok, handoff: { handoffId, exp, words, provider, sessionId, bindingId, turn: { bindingId, epoch, targetKey,
209
+ clientTurnId, acknowledgedRevision?, boundTo? }, scope, packet: <full packet preimage or null>, attachments: [] } }`. `scope` is `message`, or the
210
+ packet's `approve` scope when `packetSha256` was given.
211
+ 3. The signer trims trailing ASCII whitespace from `words`, shows the exact result with provider, session
212
+ and scope, and signs only after a real tap (`isTrusted`). For an approval, it first validates the full canonical packet preimage and SHA-256, exact fields and receiver, and expiry. It displays that packet with its digest and expiry before asking for a user-verified passkey. Cancel or denied verification sends nothing. Envelope: `origin` = the page's own
213
+ `location.origin`; `turnId` = `turn.clientTurnId`; `aud`, `nonce`, `epoch` from `POST nonce`;
214
+ `iat` = now - 30 s; `exp` = `iat` + 600; `fork: false`; `attachments: []`; `kver: 1`. It signs the JCS
215
+ bytes with the device key, then calls `navigator.credentials.get` with challenge = sha256(those bytes)
216
+ and `userVerification: "required"`, then posts `{ prompt: <the shown words>, epoch, targetKey,
217
+ clientTurnId, acknowledgedRevision?, boundTo? }` to `POST /api/agent-sessions/bindings/:bindingId/turns`
218
+ with the three headers.
219
+
220
+ A handoff is not authority (any cookie holder can create one). Authority is the tap, the device key and
221
+ a user-verified phone passkey over the envelope the signer built and showed. WebAuthn proves user verification, not a specific biometric method; platform passkey fallback is not a claim of Face ID-only enforcement.
222
+
223
+ ### Approval packets
224
+
225
+ `POST /api/web-key/packets` (token). The creating session comes from the request context headers
226
+ `x-cos-provider` and `x-cos-session-id`, never the body (a body `provider`/`sessionId` is refused,
227
+ `session_from_context_only`). Packet object, `cos-web-packet/1`:
228
+ `{ v, packetId, action, artifact, version, digest, channel, provider, sessionId, createdAt, exp }`.
229
+ An `approve` envelope whose `provider`/`sessionId` differ from the packet's creator is refused
230
+ (`packet_session_mismatch`). The receiver MUST provide its locally reviewed full canonical packet through `--expect-packet-file`, plus `--expect-packet` and `--expect-digest`, and validate every exact scope/receiver field and expiry before one-shot consume. It recomputes the canonical preimage hash; hash plus digest alone is insufficient. Per-session identity is not separately authenticated (every agent holds the token). The packet is an untrusted request until the phone signs it.
231
+
232
+ Packet id is canonical base64url of 12 random bytes (`^[A-Za-z0-9_-]{16}$`). `createdAt` and `exp` are safe integer Unix milliseconds; `0 < exp-createdAt <= 1800000`. Shared exports are `WEB_APPROVAL_PACKET_MAX_LIFETIME_MS`, `WEB_APPROVAL_PACKET_ID_RE`, `parseWebApprovalPacket`. Golden canonical bytes and SHA-256 are in `shared/web-approval-packets.golden.json`.
233
+
234
+ `GET /api/web-key/packets?provider=<provider>&sessionId=<id>` uses the existing authenticated web/token gate and is absent when authority is off. It lists only enabled actions and the requested receiver. Sessions and Today's session sheet use one shared explicit picker. Default is message only; choosing a packet passes its exact hash to handoff. A stale selection refuses, without falling back to a message or a broader approval. The separate 3144 signer cannot list packets: its allowlist only permits claiming the one chosen handoff.
235
+
236
+ New publish channels have disjoint, mandatory artifact names and SHA-256 digests:
237
+
238
+ | Channel | Required artifact | Digest covers / consumer responsibility |
239
+ | --- | --- | --- |
240
+ | `gotcos-site` | `gotcos-site-manifest` | Exact canonical deployment manifest: source commit, build output digests and allowlisted gotcos.com deployment target. No mutable URL or wildcard. |
241
+ | `control-downloads` | `cos-control-downloads-manifest` | Exact ZIP/sidecar digests, immutable asset names, latest aliases and allowed release repository. |
242
+ | `release-docs` | `cos-release-docs-manifest` | Exact documentation file/path set and separate deployment target. If docs are part of the same website deployment, one site manifest lists those bytes; a separate docs approval does not approve the site. |
243
+
244
+ The server validates channel/artifact/digest grammar and packet binding. The deploying consumer MUST additionally validate the locally reviewed deployment manifest bytes and target allowlists before consume; this generic server does not inspect deployment manifest contents. No channel grants a blanket matched-release approval. npm's own passkey authentication remains separate from COS authorization.
245
+
246
+ ## 5. Passkey assertion (B2), required for EVERY vouched turn
247
+
248
+ Header `x-cos-web-passkey`: b64url of the JCS of
249
+
250
+ ```json
251
+ { "v": "cos-web-passkey/1", "credentialId": "<b64url>", "authenticatorData": "<b64url>", "clientDataJSON": "<b64url>", "signature": "<DER sig b64url>" }
252
+ ```
253
+
254
+ Checks (server and verifier, identically):
255
+ 1. `credentialId == approval.passkey_credential_id`.
256
+ 2. `clientDataJSON` parses as JSON; `type == "webauthn.get"`; `challenge == b64url(sha256(envelope JCS bytes))` exactly; `origin == envelope.origin` exactly (port included); `crossOrigin` absent or false.
257
+ 3. `authenticatorData` at least 37 bytes; `authenticatorData[0:32] == sha256(utf8(rpId))`.
258
+ 4. flags byte `authenticatorData[32]`: UP (`0x01`) AND UV (`0x04`) both set.
259
+ 5. ECDSA P-256 / SHA-256 DER signature over `authenticatorData || sha256(clientDataJSON bytes)` verifies with `registryEntry.passkeySpki`, whose sha256 equals the anchored `approval.passkey_spki_sha256`.
260
+
261
+ No assertion, no vouch (`passkey_missing`, `passkey_invalid`, `passkey_origin_mismatch`, ...).
262
+
263
+ **The passkey is THE approval path (Miles, 2026-11-07).** The phone key plus a passkey with Face ID
264
+ on the 3144 signer page is the complete approval path for every user, including people with only a
265
+ phone and a Mac and no Even app. A confirmation from the COS glasses companion is optional, never
266
+ required, and not built. The seam stays: the server verifies the second factor in one module
267
+ (`server/lib/web-key-passkey.ts`), its proof travels as its own field beside the envelope (`passkey`
268
+ in the answer), and the phone assembles the proof in one module (`server/web-signer/signer-proof.js`
269
+ `assembleTurnProof`, whose signer is an interface). An optional companion signer would add a sibling
270
+ without changing the envelope, the anchor or this check. Nothing assumes one exists.
271
+
272
+ ## 6. Turn answer: `GET /api/g2-turns/:turnId` for a web-key record (flag on)
273
+
274
+ Existing fields (`found, turnId, createdAt, originJudgedAt, ageMs, textSha256, origin, originKind,
275
+ verifiedOrigin, reason, sealed, target, via`) are unchanged. A web-key record adds:
276
+
277
+ ```json
278
+ {
279
+ "authority": "web-key",
280
+ "kid": "<kid>",
281
+ "aud": "<hex64>",
282
+ "scope": { ... },
283
+ "scopeMeaning": "conversation" | "approve",
284
+ "notAuthorized": [ "<item>", ... ],
285
+ "envelope": "<b64url JCS>", "signature": "<P1363 b64url>",
286
+ "passkey": { "credentialId", "authenticatorData", "clientDataJSON", "signature" },
287
+ "authId": "<hex64 = sha256(phone signature bytes)>",
288
+ "wordsSha256": "<hex64, copied from the parsed envelope>",
289
+ "registryEntry": { "kid", "spki", "passkeySpki", "kver", "label", "approval", "anchorSig", "revokedAt?" },
290
+ "anchor": { "spkiSha256": "<hex64>", "fingerprint": "XXXX-...", "setAt": <ms>, "ageMs": <ms>, "mature": true },
291
+ "epoch": <int>,
292
+ "revoked": [ { "kid", "revokedAt" } ],
293
+ "revocationDigest": "<hex64 = sha256(JCS(revoked sorted by kid))>",
294
+ "integrity": { "enabled": true, "lastCheckOk": true, "checkedAt": <ms>, "manifestSha256": "<hex64>" | null },
295
+ "integrityAtAccept": { same shape, frozen when the turn was accepted },
296
+ "consumeKeys": [ "turn:<turnId>", "web:<aud>:<authId>" ]
297
+ }
298
+ ```
299
+
300
+ `verifiedOrigin: true` only when, besides signature and registry state: the anchor is mature, the
301
+ integrity check is enabled AND its last check passed now and at accept, the passkey verifies, and
302
+ the record matches the envelope (B3): `record.turnId == envelope.turnId`,
303
+ `record.target.provider == envelope.provider`, `record.target.sessionId == envelope.sessionId`,
304
+ and every copied proof field (`kid, kver, epoch, iat, exp, provider, sessionId, wordsSha256, scope,
305
+ authId`) equals the envelope. Any difference: `reason: "record_envelope_mismatch"`.
306
+
307
+ **`textSha256` covers the delivered prompt (words plus files block), normalized.** For a web-key
308
+ turn the verifier must NOT use it: compare `sha256(signedWordsText(words))` of the words it received
309
+ (before the files block and footer) with `wordsSha256` decoded from the envelope bytes, and session,
310
+ provider and turn id with the ENVELOPE, never the record.
311
+
312
+ **Integrity (W1):** with the signer (Phase 3), `lastCheckOk` is true only when BOTH the Control Web tree
313
+ (`web-control/.manifest.json`) and the signer tree (`web-signer/.manifest.json`) match; `manifestSha256`
314
+ is still the Control Web manifest's (shape unchanged). `/api/web-key/status` reports the signer's own
315
+ check under `integrity.signer`. The verifier refuses unless `integrity.enabled`, `integrity.lastCheckOk`,
316
+ `integrityAtAccept.enabled` and `integrityAtAccept.lastCheckOk` are all `true`.
317
+
318
+ **Epoch and revocation (W5):** the server reads them from `web-keys.json`, which a same-user process
319
+ can roll back (remove a `revokedAt`, restore an older file). The server alone cannot see that. The
320
+ verifier compares `epoch` and `revocationDigest` with a Control-held source (Control's keychain
321
+ mirror) and refuses on difference; `approval.epoch` and `envelope.epoch` must equal Control's epoch.
322
+
323
+ **notAuthorized** (always includes the never-list):
324
+ `credentials`, `payment-details`, `moving-money`, `deleting-data`, `sending-messages`,
325
+ `security-or-permission-settings`, `harness-permission-prompts`, `authority-rule-changes`.
326
+ For `message` scope it also includes `install`, `publish`, `push-main`, `push-branch`, `hub-file`.
327
+ For `approve` scope it also includes every action other than the signed one, and
328
+ `other-artifact-version-digest-or-channel`.
329
+
330
+ **Attachments:** until `shared/turn-files-block.ts` exists, the verifier refuses any non-empty
331
+ `envelope.attachments`.
332
+
333
+ ## 7. Consume
334
+
335
+ `POST /api/g2-turns/:turnId/consume` `{ "session", "provider" }` (token, flag on). Consumes
336
+ `consumeKeys` (`turn:<turnId>` and `web:<aud>:<authId>`) in ONE atomic write of
337
+ `web-key-nonces.json`; refuses `already_consumed` if either key exists. The verifier does not call
338
+ this route. Its own `.g2_turns_consumed.json` spends, in one locked write, the BARE turn id (the
339
+ same key the G2 path spends, so G2 and web share one namespace) plus `web:<aud>:<authId>`, and
340
+ requires `consumeKeys` to equal exactly `["turn:<turnId>", "web:<aud>:<authId>"]`. The `turn:`
341
+ prefix exists only in the server's store.
342
+
343
+ ## 8. Reasons the server can give (web-key)
344
+
345
+ `web_key_authority_off, signature_missing, envelope_malformed, envelope_version_unknown,
346
+ scope_not_allowed, envelope_not_canonical, anchor_not_registered, anchor_unregistered,
347
+ anchor_too_new, aud_unavailable, aud_mismatch, origin_unavailable, origin_mismatch, key_unknown,
348
+ key_unanchored, key_revoked, key_epoch_stale, key_version_stale, epoch_stale, signature_invalid,
349
+ lifetime_invalid, iat_in_future, signature_expired, fork_destination_unbound, destination_unbound,
350
+ destination_mismatch, turn_id_mismatch, words_changed, words_trailing_whitespace, attachments_unsealable, attachments_changed,
351
+ packet_unknown, packet_mismatch, packet_session_mismatch, approve_action_not_enabled,
352
+ passkey_missing, passkey_malformed, passkey_credential_mismatch, passkey_type_invalid,
353
+ passkey_challenge_mismatch, passkey_origin_mismatch, passkey_rpid_mismatch, passkey_not_user_verified,
354
+ passkey_invalid, integrity_off, integrity_failed, record_envelope_mismatch, nonce_unknown,
355
+ nonce_spent, nonce_expired, nonce_store_unavailable, web_key_check_failed`.
356
+
357
+ Route refusals added in Phase 3 (HTTP answers, never turn reasons): `signer_origin_required`,
358
+ `origin_refused` (3144 listener), `handoff_unavailable`, `no_phone_key`, `unknown_binding`,
359
+ `binding_changed`, `attachments_not_signable`, `words_too_long`, `handoff_cap_reached`,
360
+ `handoff_unknown`, `handoff_expired`, `handoff_claimed`.
361
+
362
+ ## Change log
363
+
364
+ - 2026-11-07 17:05 CST: first version (B1 anchor bootstrap, B2 signer origin 3144 and passkey per
365
+ turn, B3 record-envelope match, B4 scope patterns and packet binding, W1 integrity, W3 SAS
366
+ commit-reveal, W4 display inside approval bytes, W5 approval epoch and revocation digest, W6
367
+ device allowlist and remote, W8 rotates). Field names `passkey_credential_id`,
368
+ `passkey_spki_sha256`, `passkeySpki`, `integrity.lastCheckOk` follow the verifier lane's request.
369
+ - 2026-11-07 18:05 CST: words never end in whitespace (`words_trailing_whitespace`, verifier lane
370
+ point 1, option a). Consume: the verifier spends the bare turn id plus `web:<aud>:<authId>`; the
371
+ server's own store keeps the `turn:` prefix (verifier lane point 2). Added the second-signer note
372
+ in section 5 (B2 step 2 is a later, separate plan). Drain re-check now also compares the parked
373
+ file manifest with the signed one (`attachments_changed`).
374
+ - 2026-11-07 18:50 CST: Phase 3 signer. 3144 serves only the signer page (its own manifest) and an /api
375
+ allowlist; the phone's key steps and the handoff claim answer only there (`signer_origin_required`).
376
+ Added the 3143 to 3144 handoff (section 4). Integrity `lastCheckOk` now covers the signer tree too
377
+ (shape unchanged). Section 5: the passkey with Face ID is the complete approval path for every user;
378
+ the companion confirm is optional and never required (Miles). No envelope, passkey or answer field changed.
@@ -82,6 +82,10 @@
82
82
  "COS_PERMISSION_BROKER_TIMEOUT_S",
83
83
  "COS_PERMISSION_BROKER_AWAY_HOLD_S",
84
84
  "COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S",
85
+ "COS_QUESTION_FULL_HOLD",
86
+ "COS_APPROVAL_FULL_HOLD",
87
+ "COS_WAITING_QUESTION_WORDS",
88
+ "COS_TURN_END_ASKS",
85
89
  "COS_LENS_GIST_ENGINE",
86
90
  "COS_LENS_GIST_MODEL",
87
91
  "COS_LENS_GIST_DAILY_CAP",
@@ -92,7 +96,10 @@
92
96
  "COS_PIN_BOOT_REAP",
93
97
  "COS_QUEUE_RESEND",
94
98
  "COS_SESSION_STEER",
99
+ "COS_STEER_MIDTURN",
95
100
  "COS_KEY_LANES",
101
+ "COS_WEB_SESSION_FILES",
102
+ "COS_WEB_LIVE_EVENTS",
96
103
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY",
97
104
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_MESSAGE",
98
105
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_MEETING",
@@ -112,7 +119,11 @@
112
119
  "COS_PROMPT_EDIT_DAILY_CAP",
113
120
  "COS_PROMPT_EDIT_MODEL",
114
121
  "COS_PROMPT_EDIT_EFFORT",
115
- "COS_PROMPT_EDIT_TIMEOUT_MS"
122
+ "COS_PROMPT_EDIT_TIMEOUT_MS",
123
+ "COS_WEB_KEY_AUTHORITY",
124
+ "COS_WEB_CONTROL_INTEGRITY",
125
+ "COS_WEB_KEY_DEVICES",
126
+ "COS_WEB_KEY_ANCHOR_BOOTSTRAP"
116
127
  ],
117
128
  "maintenance": {
118
129
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.99.33",
3
+ "version": "7.0.18",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,8 @@
15
15
  "test": "vitest run --maxWorkers=1",
16
16
  "typecheck": "tsc --noEmit",
17
17
  "mutation:gate": "node server/scripts/mutation-gate.mjs",
18
- "control2:lab": "node bin/control2-foundation.cjs"
18
+ "control2:lab": "node bin/control2-foundation.cjs",
19
+ "prepack": "node server/scripts/write-web-control-manifest.mjs && node server/scripts/write-web-control-manifest.mjs server/web-signer"
19
20
  },
20
21
  "keywords": [
21
22
  "even-g2",
@@ -148,7 +149,13 @@
148
149
  "docs/test-plan-6.71.8.md",
149
150
  "docs/test-plan-6.71.9.md",
150
151
  "docs/test-plan-6.71.10.md",
151
- "docs/test-plan-6.71.11.md"
152
+ "docs/test-plan-6.71.11.md",
153
+ "!server/scripts/web-key-mutation-gate.mjs",
154
+ "!server/scripts/web-key-signer-mutation-gate.mjs",
155
+ "!server/scripts/compact-session-log.ts",
156
+ "!server/scripts/full-hold-mutation-gate.mjs",
157
+ "!server/scripts/turn-end-ask-mutation-gate.mjs",
158
+ "docs/web-key-verifier-contract.md"
152
159
  ],
153
160
  "author": "COS Contributors",
154
161
  "license": "MIT",
@@ -2,6 +2,8 @@
2
2
  // slot used by the installed LaunchAgent before mutable routes or stores load.
3
3
  import './env.js'
4
4
  import { acquireServerInstanceLock, ServerInstanceActiveError } from './lib/server-instance-lock.js'
5
+ import { diskIoMeterRequested, installDiskIoMeter } from './lib/disk-io-meter.js'
6
+ import { startEventLoopStallMonitor } from './lib/event-loop-stall.js'
5
7
 
6
8
  try {
7
9
  const instanceLock = acquireServerInstanceLock()
@@ -14,3 +16,11 @@ try {
14
16
  console.error('[COS API] Startup refused: single-instance lock failed.', error)
15
17
  process.exit(74)
16
18
  }
19
+
20
+ // S1 (2026-11-09): opt-in per-path disk I/O meter (COS_DISK_IO_METER=1, default off). Installed
21
+ // here, before index.ts loads its stores, so module-load writes are counted too.
22
+ if (diskIoMeterRequested()) installDiskIoMeter()
23
+
24
+ // S1 (2026-11-09): log when the event loop was blocked over 2 s (a line only on a stall). Settles
25
+ // whether a client timeout (Mac dictation's 5 s capabilities check) was the server not running JS.
26
+ startEventLoopStallMonitor()