@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.
- package/.env.example +19 -0
- package/CHANGELOG.md +170 -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 +126 -23
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +3 -3
- 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/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/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- 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-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- 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/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +11 -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/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +16 -9
- 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 +36 -0
- package/server/web-control/app.js +953 -57
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +14 -1
- 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/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +75 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +178 -35
- 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
package/.env.example
CHANGED
|
@@ -209,6 +209,25 @@ BIND_HOST=0.0.0.0
|
|
|
209
209
|
# Per-hook rollback without reinstall: echo 0 > ~/.cos-glasses/hook-ask-hold
|
|
210
210
|
# COS_PERMISSION_BROKER_DESK_IDLE_S=90 # never below 30
|
|
211
211
|
# COS_PERMISSION_BROKER_TIMEOUT_S=110 # clamped to 5..120 (under the hook's 125 s)
|
|
212
|
+
# 7.0.16: a waiting session question's first 80 characters show on Today and the session rows
|
|
213
|
+
# (glasses, phone, COS Control, Control Web). Question text only, never options or answers. It is
|
|
214
|
+
# also written to the session-hook ledger on disk. Default ON; 0 restores 7.0.13's empty line.
|
|
215
|
+
# COS_WAITING_QUESTION_WORDS=0
|
|
216
|
+
# 7.0.16, both OFF by default: hold a session question (QUESTION) or a tool approval (APPROVAL) for
|
|
217
|
+
# the whole hook wait, about an hour, so the glasses, the phone or Control Web can answer it even if
|
|
218
|
+
# none was connected when it was asked. Only on engines proven to keep their own dialog up meanwhile
|
|
219
|
+
# (Claude Code CLI 2.1.321+ in a terminal, Claude Desktop 2.1.317+); the Mac's dialog still shows at
|
|
220
|
+
# once and the first answer wins. To turn on: set it here, restart the server, then COS Control >
|
|
221
|
+
# Settings > Reinstall hooks. To turn off: remove it and restart (every held item goes back to the Mac).
|
|
222
|
+
# Keep APPROVAL off for now: a signed-in Control Web browser could allow a tool for about an hour.
|
|
223
|
+
# COS_QUESTION_FULL_HOLD=1
|
|
224
|
+
# COS_APPROVAL_FULL_HOLD=1
|
|
225
|
+
# 7.0.18, default ON (named by Miles 11/10): a Claude desktop session that ends its turn with a
|
|
226
|
+
# question and reply buttons shows as waiting on the glasses, phone, Control Web and COS Control once
|
|
227
|
+
# the desk has been idle 90 s. Only an ask with reply buttons (or marked blocked); gone on any reply,
|
|
228
|
+
# a new turn, the session ending, or after 8 hours. Never print runs, COS-started runs or sub-agents.
|
|
229
|
+
# Words follow COS_WAITING_QUESTION_WORDS. 0 turns it off (restart the server); nothing to reinstall.
|
|
230
|
+
# COS_TURN_END_ASKS=0
|
|
212
231
|
|
|
213
232
|
# ── THE LLM (chat) ──────────────────────────────────────────────────────
|
|
214
233
|
# Chat runs through your LOCAL agent CLI — NOT an API key:
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,173 @@
|
|
|
1
|
+
## 7.0.18 - A question a session ends its turn with reaches the glasses, Control Web and the desktop
|
|
2
|
+
|
|
3
|
+
2026-11-10. 7.0.17 (7313e50) plus X1's server half (claude/server-7018-turn-end-ask) and W1's Control Web card (claude/server-7018-web-turn-end), on Miles's go (07:14, "fold this in to the latest update so I can respond from glasses, web app and desktop"). Pairs with COS Control 0.5.389. Private on Miles's Mac first.
|
|
4
|
+
|
|
5
|
+
- **The problem (Miles, 07:02):** a Claude session in the desktop app that ended its turn with a question and reply buttons read as idle. It never asked through AskUserQuestion, so nothing on the glasses or the web said a session was waiting on him.
|
|
6
|
+
- **The server now sees it:** the session's last next-step call (`mcp__ccd_turn__wrap_up`) carries the question and its reply buttons. Once that turn has stopped, the session's row reads as waiting on a question (`waiting_source: 'turn_end'`), with the question (80 characters) and up to two reply labels (40 each). Only a question with at least one reply button, or one marked blocked, counts. App buttons such as commit or create PR are never offered as choices.
|
|
7
|
+
- **Quiet while you are at the Mac:** the row shows only after the Mac has been idle for the same threshold as held questions (never under 30 seconds). Once shown, it stays shown until it clears. At the Mac, the Claude app's own box is the answer.
|
|
8
|
+
- **It clears itself** on any reply, a new turn, the session ending, or after 8 hours.
|
|
9
|
+
- **The words** follow the existing switch for question words (`COS_WAITING_QUESTION_WORDS`). With it off, the row still says a question waits, with no words and no choices.
|
|
10
|
+
- **Control Web:** Today's row reads "<session> asked: <question>". The session reader and Today's sheet show the question, a button per choice, and one line: "A choice or a reply here reaches the session as a message from COS, not typed in the session, and the session may ask you to confirm." The reply box stays for your own answer.
|
|
11
|
+
- **What an answer sends:** a tap, or a typed reply, goes through the session's existing Continue as "Answer sent from COS Control Web through COS, not typed in this session: <your choice>". It never reads as typed at the desk, so the session may ask you to confirm. If the question was answered on another screen first, nothing is sent and it says so.
|
|
12
|
+
- **Logs and counts:** the session ledger adds only the question, its labels and the blocked mark on that one tool's rows; never the session's recap. The authenticated question status adds counts (armed, shown, cleared, aged out), never words.
|
|
13
|
+
- **Not proven off the device:** tests and WebKit show each rule; only the glasses, the phone and the desktop app can show the full loop.
|
|
14
|
+
|
|
15
|
+
**Default-on for every install:** yes: turn-end questions (`COS_TURN_END_ASKS`, on unless set to 0 and restarted), their words where question words are already on, and the Control Web card.
|
|
16
|
+
|
|
17
|
+
### If you roll back
|
|
18
|
+
|
|
19
|
+
- To 7.0.17: sessions that end their turn with a question read as idle again. The extra ledger key (`turn_ask`) is carried in the row but 7.0.17 never reads it. Nothing else is stored.
|
|
20
|
+
|
|
21
|
+
## 7.0.17 - Control Web stops zooming in and out on the phone
|
|
22
|
+
|
|
23
|
+
2026-11-10. 7.0.16 (d2de7ee) plus one change to Control Web, on Miles's go (06:25, "Yes, start it"). Private on Miles's Mac first.
|
|
24
|
+
|
|
25
|
+
- **The problem (Miles, 06:17):** in the Control Web app on the Duo, the page zoomed in and out around the keyboard and the layout stayed wrong. His phone's own layout report showed the page at 2.12x with the keyboard down on the unfolded Duo: most likely a double-tap zoom fitting a column (a pinch reads the same in that report). Every field was already 16 px, so it was not the focus zoom.
|
|
26
|
+
- **Double-tap zoom is off:** the page root says `touch-action: manipulation`. Scrolling still works. The Sessions splitter and the charts keep their stricter touch rules.
|
|
27
|
+
- **On iPhone and iPad only,** the page's viewport is capped at its normal scale (`maximum-scale=1`), once, as it loads. Never while the page is already zoomed, so the zoom never changes under your fingers. Android, the Mac and other browsers keep the viewport as it was. The cost: in the Control Web app added to the Home Screen, pinch to zoom no longer works at all; in a Safari tab it still does (Apple keeps pinch there). Elsewhere pinch is unchanged.
|
|
28
|
+
- **The 16 px floor** on touch screens also covers any element that types without being a field (none exists today).
|
|
29
|
+
- **A layout report** when the zoom crosses 1.01 either way: the scale, the pane, the focused element's kind (never its text) and how long since the last touch, inside the existing report limits, so the next one is diagnosed from numbers.
|
|
30
|
+
- **Not proven off the device:** the tests and WebKit show each rule is in place; only the phone can show the zoom has stopped.
|
|
31
|
+
|
|
32
|
+
**Default-on for every install:** yes: double-tap zoom off in Control Web everywhere, the iOS viewport cap, and the zoom layout report.
|
|
33
|
+
|
|
34
|
+
### If you roll back
|
|
35
|
+
|
|
36
|
+
- To 7.0.16: the page zooms as before. Nothing is stored by this change.
|
|
37
|
+
|
|
38
|
+
## 7.0.16 - Questions held for the whole wait (off by default), question words on rows, and a Today summary that holds still
|
|
39
|
+
|
|
40
|
+
2026-11-09. 7.0.15 (ffcb6f9) plus X1's full hold (claude/server-question-hold 053d01c), T1's Today cadence (claude/server-today-stable 18b4031) and W1's web follow-ups (claude/server-web-hardening ed32418). Each reviewed by Q1. Not installed: this build changes the permission path, and its private install waits for Miles's own go. Does not contain 7.0.14 (the tailnet fix) or f4f309f.
|
|
41
|
+
|
|
42
|
+
### Named default-on changes
|
|
43
|
+
|
|
44
|
+
- **Question words on rows (Miles 22:10, "Yes, show by default").** A session waiting on a question now says the first 80 characters of its first question, everywhere a waiting row shows: the glasses, the phone app, COS Control and any signed-in Control Web browser. Only the first question's text: never an option, a description, a header, a second question or an answer. Control and format characters are replaced, whitespace collapsed, cut at 80 with an ellipsis. Stored in the session-hook ledger on disk (`session-hook-events.jsonl`, rotated once at 10 MB; the last 6 hours replay at boot) and in `waiting_detail` until the question settles. Never in the broker's logs. `COS_WAITING_QUESTION_WORDS=0` restores 7.0.15 exactly.
|
|
45
|
+
- **The Today summary is rewritten less (T1).** A material change (a session opened or closed, a message typed into one, Needs you, Work cards, meetings, the read state, a recording) rewrites after the existing 10 minutes; anything else (a reply, a state flip, sub-agents, durations) only once the shown summary is 30 minutes old. The count sentence before the paragraph changes only after a new count holds 5 minutes. An ask that could not read the session list never replaces today's summary. Replay over two busy hours: 12 model calls down to 4, 44 shown-text changes down to 4. Model, daily cap, switch, breaker, forced Refresh and the reconnect exception are unchanged.
|
|
46
|
+
|
|
47
|
+
### Package
|
|
48
|
+
|
|
49
|
+
- The npm package no longer carries `server/scripts/compact-session-log.ts` (the compaction tool, not ready) or `server/scripts/full-hold-mutation-gate.mjs` (a test gate).
|
|
50
|
+
|
|
51
|
+
### Off by default (X1)
|
|
52
|
+
|
|
53
|
+
- **`COS_QUESTION_FULL_HOLD`:** a session question is held for the whole hook wait (3590 s after Reinstall hooks), whether or not a COS device is live when it is asked, on an engine proven to keep its own dialog up while the hook waits (CLI 2.1.321 and later in a terminal, not a print run; the desktop app 2.1.317 and later). The Mac's dialog is up at once; the first answer wins, from the Mac, the glasses, the phone app, COS Control or Control Web. A question shows on devices once the desk has been idle 90 s; before that it is not answerable by id (409 `not_shown`).
|
|
54
|
+
- **`COS_APPROVAL_FULL_HOLD`:** the same for a tool approval, only while the desk stays idle, never ExitPlanMode. A web answer needs a touch within 60 s and a server-issued confirm for that item and decision. Keep it off until the phone key lands.
|
|
55
|
+
- The three switches are listed in `.env.example` and the managed-runtime contract, and work on a COS Control install through `~/.cos-glasses/.env` (final review S6).
|
|
56
|
+
- With both switches off, admission, deadlines, hook output, the questions response (key for key), `~/.claude/settings.json` and the hook block equal 7.0.15; `/confirm` answers 404. Turning a switch on: `~/.cos-glasses/.env`, restart, then Reinstall hooks in Control. There is no Control switch for them.
|
|
57
|
+
|
|
58
|
+
### Control Web (W1)
|
|
59
|
+
|
|
60
|
+
- **Open streams end with the sign-in (final review S1).** A live stream a browser opened (a session's view, the live events, a COS answer) now ends at its next heartbeat once that browser's sign-in no longer verifies: Sign out every browser, the cookie's expiry, or the 30-day cap. The browser reconnects with the cookie it holds now. Streams the API token opened (the glasses, the phone app, COS Control) are unchanged.
|
|
61
|
+
- **Sign out every browser also ends sign-in links and codes (final review V7).** A sign-in link or code made before the sign-out no longer signs a browser in after it; one made after it works as before.
|
|
62
|
+
- **Display-stream tickets only for the token door (final review S8).** `/api/models` hands a display-stream ticket only to a caller the API token let in (the glasses, the phone app, COS Control), never to a browser signed in by cookie. Control Web never used it.
|
|
63
|
+
- **Meaning search** says where the Jev and SubQ keys are now: search Settings in COS Control for Jev key or SubQ key.
|
|
64
|
+
- **Today's summary holds still:** it stays through a pending or failed session read (the age line names what could not be read; the Ask box stays), keeps the tallest height it has had and shrinks only on a new day, a width change, Refresh or the switch toggled; unchanged words are not written again.
|
|
65
|
+
- **Question block on X1's final contract:** reads `webAnswer`; with `confirmApprovals` an approval is confirmed first and answered once; a 409 `not_shown` keeps the card with "Not sent: this question is not on your devices yet. Answer it at the Mac, or try again in a minute." With both hold switches off the server sends neither, and nothing changes.
|
|
66
|
+
- **Question refusals in words:** when an answer is refused, the card now says what happened: "Already answered on another device.", "Already answered at the Mac, or the session moved on.", "Left for the Mac. Answer it there.", "This question timed out here. Answer it at the Mac." or "This question is no longer waiting." Before, a refusal that carried a reason (answered at the Mac, a drain, an invalid answer id) kept the card and showed a raw code with a retry. Present since 6.99.x, including 7.0.15.
|
|
67
|
+
|
|
68
|
+
**Default-on for every install:** yes: the question words on rows, the Today summary cadence, on the web the Today summary hold, browser streams ending when their sign-in does, Sign out every browser also ending sign-in links and codes, and no display-stream ticket for a signed-in browser. The full hold and its web rules are off unless a hold switch is on.
|
|
69
|
+
|
|
70
|
+
### If you roll back
|
|
71
|
+
|
|
72
|
+
- A browser whose sign-in was renewed by 7.0.15 or later holds the four-part cookie, and a server rolled back to 7.0.13 or earlier, including 7.0.7, makes that browser sign in again. Nothing is lost.
|
|
73
|
+
- Back on 7.0.7, questions older than 7 days count as waiting again (7.0.9 had stopped counting them).
|
|
74
|
+
- The inbox: the first start of 7.0.9 or later marks every unread inbox item read, once. 7.0.7 drops that marker when it writes, so after a rollback to 7.0.7 and a later update, everything that arrived unread under 7.0.7 is marked read again.
|
|
75
|
+
|
|
76
|
+
## 7.0.15 - Control Web for the phone, and server hardening from runtime evidence
|
|
77
|
+
|
|
78
|
+
2026-11-09. 7.0.13 (97402b4) plus the production hardening pass, web lane (W1) and server lane (S1). Private on Miles's Mac first. Does not contain 7.0.14 (the tailnet fix), X1's question hold or T1's Today work; those ride a later build.
|
|
79
|
+
|
|
80
|
+
### Control Web (W1)
|
|
81
|
+
|
|
82
|
+
- **Off Tailscale:** a browser that reaches the server from outside the tailnet gets a readable help page, "Open COS over Tailscale" (status still 403).
|
|
83
|
+
- **Sign-in that lasts while in use:** a signed-in browser is renewed in the last 2 days of its 7, up to 30 days from first sign-in. Renewed cookies are a new 4-part shape; status and login add `renewUntil`, renewed answers carry `X-COS-Web-Session`. A notice shows in the last 24 hours, and the sign-in card says why a sign-in lapsed.
|
|
84
|
+
- **Sign-in links:** a refused link on a browser that is still signed in opens the app with an "old link" note. The server log names why a link failed (used, expired, evicted or unknown; the link is never logged). A failed link no longer counts toward the 8-failure lock on that device; wrong codes and tokens still do.
|
|
85
|
+
- **When the Mac does not answer:** every pane says so, with the time and both likely causes; while the page is visible it checks `/api/health` every 30 s. A cold open with the Mac unreachable shows that card instead of the sign-in form, and retries every 30 s.
|
|
86
|
+
- **Today:** Running shows the desktop's COS job rows (no Stop on the web). Start work defaults to Claude Opus from the Mac's model catalog; a saved destination wins. A Work card with no session of its own preselects Start a new session.
|
|
87
|
+
- **Meaning search** asks consent per browser before anything is sent (off until Turn on).
|
|
88
|
+
- **Sort** no longer shows Keep, Drop or Undo (no server routes behind them).
|
|
89
|
+
- **A question waiting at the Mac's prompt:** Today's card and the session reader say it once, with the words when the server has them and an honest "COS cannot read this question's words from here" line when it does not, and offer nothing that pretends to answer. A Claude session waiting on a question reads "Waiting for your answer".
|
|
90
|
+
- **Question block (X1 contract v1):** the web is ready for a later server's question list (`waiting`); 7.0.15's server sends none, so nothing changes with this server. With a server that sends it, the web draws held, at the Mac (words and options, one card per question) and answered (2 minutes). Answers send how recently the page was touched; an approval the server wants confirmed is confirmed and sent once more; the three new refusals keep the card with a retry line. The cards sit above the reader's Send box, which reads "Message this session (not an answer)" while a question is open. With no `waiting` key nothing changes.
|
|
91
|
+
- **Duo layout:** every composer keeps its placeholder on one line at any width. The session reply box, COS chat, Today ask and a thread reply put their buttons on the row below when the words have less than 19rem; the Ask sheet's buttons wrap under its field; the COS chat header's links wrap under its description. Two placeholders are shorter: "Ask COS or drop a thought" (Drop it on) and "Ask about this or steer a session" (Today). Measured in WebKit at 382, 440, 520 and 951 px wide and at the narrowest chat column; not yet checked on the phone.
|
|
92
|
+
- **Copy:** the web names the Mac's Settings page "Glasses, phone and web" (Control 0.5.387).
|
|
93
|
+
|
|
94
|
+
### Server (S1)
|
|
95
|
+
|
|
96
|
+
- **Session ledger:** an unchanged session is not appended again (adds `data/session-log-state.json`, tied to the ledger's inode); one re-append per session on the first boot after the update. The ledger reader streams, opens with `O_NONBLOCK` and reads only a regular file. `/api/sessions/lookup` answers from history again on a large ledger (old range rule); its first call per boot streams the whole ledger. The compaction tool is not ready and is not offered.
|
|
97
|
+
- **whisper-server and the preview sidecar** are stopped with SIGKILL plus a bounded wait on shutdown, and the preview sidecar also when it restarts, ending the crash reports at restart.
|
|
98
|
+
- **Work reviews** are saved only when something besides `updatedAt` changed.
|
|
99
|
+
- **Instrumentation:** an event-loop stall monitor (a line only on a stall over 2 s, at most 1 per 10 s); `[cors] blocked origin=...` once per origin per hour; the stop log names what was running; extended HQ timeout lines.
|
|
100
|
+
- **No ledger configured:** one "said once" line per process instead of per-session lines; the session-cache warning is said once.
|
|
101
|
+
- **Secret guard** refuses previews of `key-secrets/` and `provider-keys.json`.
|
|
102
|
+
- **Opt-in, off by default:** progressive HQ no longer retries a timed-out checkpoint (`COS_MEETING_PROGRESSIVE_HQ=1`); a per-path disk I/O meter (`COS_DISK_IO_METER=1`).
|
|
103
|
+
- **Tests:** two flaky tests made deterministic, six bounded.
|
|
104
|
+
|
|
105
|
+
**Default-on for every install:** yes. Web: the off-Tailscale page, sign-in renewal and its notices, the not-answering card and its 30 s health check, Today re-reading `/api/query-jobs/active` every 15 s while COS jobs run, the at-Mac question copy, Running rows, Start work defaults, meaning-search consent, the Sort cuts, the Duo layout and the two placeholders. Server: ledger dedupe and its state file, the lookup rule, SIGKILL on shutdown, the work-review save rule, the stall monitor and the new log lines, the secret-guard paths. The question block draws nothing new with this server.
|
|
106
|
+
|
|
107
|
+
### If you roll back
|
|
108
|
+
|
|
109
|
+
- A browser whose sign-in was renewed by 7.0.15 or later holds the four-part cookie, and a server rolled back to 7.0.13 or earlier, including 7.0.7, makes that browser sign in again. Nothing is lost.
|
|
110
|
+
- Back on 7.0.7, questions older than 7 days count as waiting again (7.0.9 had stopped counting them).
|
|
111
|
+
- The inbox: the first start of 7.0.9 or later marks every unread inbox item read, once. 7.0.7 drops that marker when it writes, so after a rollback to 7.0.7 and a later update, everything that arrived unread under 7.0.7 is marked read again.
|
|
112
|
+
|
|
113
|
+
## 7.0.13 - Work chat Plan no longer fails on a short session id
|
|
114
|
+
|
|
115
|
+
2026-11-09. 7.0.12 (2d54a4a) plus one fix. Private on Miles's Mac first.
|
|
116
|
+
|
|
117
|
+
- **The bug.** Before every Work chat plan, COS Control pushes a snapshot of the sessions it sees. A Claude CLI session the server knows only by its short 8 character id reached that snapshot as `claude:<8 hex>`, and the server rejected the WHOLE snapshot (400 `invalid_snapshot`, `invalid_live_row`). While that one session ran, Plan failed on every card with "could not build this task's session list".
|
|
118
|
+
- **The fix.** A live row with an invalid id, or text past its bounds, is skipped and counted instead. `/api/work/orchestrator/input` and `/snapshot` report `droppedLiveRows` (only when not zero) and log it. Links and receipts stay strict: a bad one is still a 400.
|
|
119
|
+
- **Default-on for every install:** yes (a parse that used to refuse now accepts and skips). A clean snapshot answers exactly as before.
|
|
120
|
+
|
|
121
|
+
## 7.0.12 - Prep any meeting on Today, with your own context
|
|
122
|
+
|
|
123
|
+
2026-11-09. 7.0.11 (1beefbd) plus one change. Private on Miles's Mac first.
|
|
124
|
+
|
|
125
|
+
- **Prep on every Next up row (Control Web).** Each meeting row has a quiet Prep; the first row keeps Prep me and Open meeting and gains Add context. Prep opens a panel under that row: a box for goals, people and what you want out of it, Add files, Cancel and Start prep. One panel at a time; the words survive the page's refresh; Escape keeps them, Cancel drops them.
|
|
126
|
+
- **Start prep** uses that meeting's own Prep draft (the same one Prep me opens): typed context is added after any notes already there, files upload first, and a file that does not go in stops the run with one line saying so. With Prep off but Ask on, it asks "Prep me for <title> at <time>" with the context as quoted data. With neither, no Prep is offered.
|
|
127
|
+
- **Holds get no Prep.** New `CALENDAR_HOLD_WORDS` / `isCalendarHold` (Blocked, Busy, Hold, Focus, Travel, Personal, OOO, Lunch and similar, whole word at the start, unless the title says "with"). `/api/calendar/today` rows and welcome-context `nextEvent` carry `hold` (additive).
|
|
128
|
+
- **Default-on for every install:** yes, the Prep actions on Today (web). Desktop follows in Control 0.5.385.
|
|
129
|
+
|
|
130
|
+
## 7.0.11 - Today knows your calendar, shows more of it, and takes questions
|
|
131
|
+
|
|
132
|
+
2026-11-09. 7.0.10 (004df50) plus Today round 2. Private on Miles's Mac first.
|
|
133
|
+
|
|
134
|
+
- **The summary sees the calendar.** The meeting in progress, the next 4 with how long until each, and meetings that ended in the last 2 hours. An upcoming meeting is worked into an existing sentence ("...before your 10:00 forecast call"), never a sentence of its own; still at most three sentences. A session whose recent words name a meeting that already ended is marked so its prep reads as past.
|
|
135
|
+
- **No more "can wait until tomorrow".** The prompt no longer asks for it; the opener already says what can wait.
|
|
136
|
+
- **Calendar API: `/api/calendar/today`.** `next` holds up to 5 meetings (was 3). Duplicate invites (same title and start) and canceled events are dropped before `next`, `remainingCount` and the new `endedRecently` are counted.
|
|
137
|
+
- **Control Web Next up.** The current or next meeting plus up to 4 more, then "+N more today", which opens Meetings.
|
|
138
|
+
- **Ask about this.** A box under the Today summary asks COS about it or tells a session what to do. It sends the typed words plus the summary and one line per session (title, provider, state, last activity, last ask) as quoted data, and COS can relay an instruction to a Claude session by title. Hidden without durable Ask jobs, offline, or before a model summary exists.
|
|
139
|
+
- **The brief view** (GET and POST `/api/today/brief`) gains an optional `sessions` list (at most 12, scrubbed) stored with each summary.
|
|
140
|
+
- **Default-on for every install:** yes: the summary inputs and prompt, Next up rows, and the Ask box. Model, interval, cap, switch and breaker unchanged.
|
|
141
|
+
|
|
142
|
+
## 7.0.10 - Today summary describes what is happening now
|
|
143
|
+
|
|
144
|
+
2026-11-09. 7.0.9 (1b2a8f2) plus one change. Private on Miles's Mac first.
|
|
145
|
+
|
|
146
|
+
- **Today summary, recent activity first.** The summary used to see each session through one 180 character line made of its FIRST prompt and the latest reply, so a session's opening ask from weeks ago led the summary. It now reads, on the server, each session's last 1 to 3 messages from the past 24 hours (with how long ago), the start of the latest reply, and when the session last moved. Titles are treated as history.
|
|
147
|
+
- **Sub-agents:** only running ones, or ones finished within 2 hours, with their task. Older finished ones become a count. A session with a running sub-agent counts as working.
|
|
148
|
+
- **Collapsing:** sessions quiet for over a day become one count line (a session waiting on the person always shows). Work cards that have not moved in 48 hours become one line.
|
|
149
|
+
- **Messages relayed in from another screen or session** are labelled as such and never attributed to the person. Agent hand-backs and background-task notices are not counted as asks.
|
|
150
|
+
- **Privacy:** more of each session's recent text goes to the same model through the same isolated runner. Every field is scrubbed for secrets, long opaque tokens and short commit ids before it is sent; only the summary is cached.
|
|
151
|
+
- **Default-on for every install:** yes, this changes the Today summary's inputs and prompt for everyone. Model, interval, daily cap, switch and breaker are unchanged (Sonnet).
|
|
152
|
+
|
|
153
|
+
## 7.0.9 - 7.0.8 plus phone approvals (dormant) and the anchor bootstrap switch
|
|
154
|
+
|
|
155
|
+
2026-11-08. 7.0.8 (d8bc2a7) merged with the phone approvals server (6ed40cd), plus one new switch. Both switches below are OFF by default, so with neither set this build behaves as 7.0.8 for every install. Not device-qualified; private on Miles's Mac first.
|
|
156
|
+
|
|
157
|
+
- **Messages on Control Web (Miles, 2026-11-08).** The rail badge counts only new messages you have not seen, cleared on any device once seen (on the first start of this server every unread inbox item is marked read once, seen or not, and never counts again; corrected in 7.0.16, final review S2). The pinned COS row says how many are new and how many wait on your reply; Work's your move no longer counts there. A finished Ask never shows "COS is answering": it fetches its words, or says COS finished without a written answer. A running Ask shows its step trail (the same steps the glasses show, the newest six, then an "N steps" disclosure when done). An Ask that relayed to another session says which session, links to it, and shows that session's reply when it comes. A question in the COS chat that has waited 7 days since it was asked stops counting and moves to older questions above the chat, where you can still read and answer it.
|
|
158
|
+
- **Phone approvals, dormant.** The enrolled phone key server (the `/api/web-key/*` routes, the signer page on port 3144, served-asset integrity for it, and verified-turn authority for a phone-signed Control Web turn) is in the package behind `COS_WEB_KEY_AUTHORITY`, default off. Off, none of those routes exists (each answers 404 as on 7.0.8), port 3144 is never opened, `/api/health` has no `webKeySigner`, and a web turn is recorded exactly as on 7.0.8: never vouched. `verify_g2_turn.py` keeps refusing web authority either way.
|
|
159
|
+
- **New: `COS_WEB_KEY_ANCHOR_BOOTSTRAP`, default off.** On, with `COS_WEB_KEY_AUTHORITY` off, the server answers exactly two routes so COS Control can register this Mac's Touch ID key and start its 24 hour clock: `POST /api/web-key/anchor` (loopback only, the API token and the key's own signature, as before) and `GET /api/web-key/status` (API token only). The status reply in this mode is `{ ok: true, enabled: false, mode: "bootstrap", anchorRegistered, anchorMature, anchorSetAt, aud, origin, epoch }`; `enabled` stays false so an older COS Control hides phone approvals. Everything else stays off exactly as with both switches off: phone enrollment, the key list, revoke, nonces, approval packets, the handoff, the consume route, port 3144, the signer integrity check, the extra cookie port, `webKeySigner` in health, and verified-turn authority. Read like every switch: `1`, `true` or `on` is on, `0`, `false` or `off` is off, anything else the default. Listed in `managed-runtime-contract.json`.
|
|
160
|
+
- **With `COS_WEB_KEY_AUTHORITY` on, nothing changes** and the bootstrap switch does nothing: the status reply is byte for byte the 6.99.31 one (no `mode` field). The anchor registered in bootstrap mode is the same record the authority reads, so turning the authority on later needs no second registration and keeps its `setAt`.
|
|
161
|
+
- **Known, unchanged: registering the same key again restarts its 24 hour clock.** The server sets `setAt` to now on every registration, the same key included (no keys are revoked and the epoch stays). Do not re-register to check that the key persisted.
|
|
162
|
+
- Default-on for every install: none.
|
|
163
|
+
|
|
164
|
+
## 7.0.8 - Control Web home screen icon
|
|
165
|
+
|
|
166
|
+
2026-11-08. 7.0.7 (689a41b, the public candidate) plus one change. Control Web only. Private on Miles's Mac first.
|
|
167
|
+
|
|
168
|
+
- **Control Web has a home screen icon.** Saving Control Web to an iPhone home screen showed a grey letter, because iOS ignores the SVG favicon. The page now names a 180 px PNG of the COS mark on its cream background (`apple-touch-icon.png`). Remove an old shortcut and add it again from Safari's Share menu to pick it up.
|
|
169
|
+
- No other change from 7.0.7. Default-on for every install: this icon only.
|
|
170
|
+
|
|
1
171
|
## 7.0.7 - Answer lists never change what Markdown draws
|
|
2
172
|
|
|
3
173
|
2026-11-08. 7.0.6 (f98dbbd, installed privately on Miles's Mac) plus four final QA fixes. Server and Control Web. This is the public candidate, paired with COS Control 0.5.380.
|
|
@@ -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",
|
|
@@ -115,7 +119,11 @@
|
|
|
115
119
|
"COS_PROMPT_EDIT_DAILY_CAP",
|
|
116
120
|
"COS_PROMPT_EDIT_MODEL",
|
|
117
121
|
"COS_PROMPT_EDIT_EFFORT",
|
|
118
|
-
"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"
|
|
119
127
|
],
|
|
120
128
|
"maintenance": {
|
|
121
129
|
"scope": "cross_boot",
|