@gotcos/glasses-server 6.66.0 → 6.67.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## 6.67.0 — 2026-10-09 — glasses pairing
2
+
3
+ - Pair COS Glasses with your Mac by scanning a code instead of pasting the pairing token. COS Control shows a QR and a short code; the phone claims it, and nothing happens until you press Allow on the Mac. Pairs with COS Glasses 6.10.621 and the COS Control pairing release. Contract: `docs/pairing-contract.md`.
4
+ - `GET /api/health` adds `capabilities.pairing = { version: 1 }`. An older server leaves it out, which is how the phone knows to say "Update COS on your Mac".
5
+ - `POST /api/pairing/code` (token, and only from this Mac) makes an 8-character code, good for 5 minutes and one use, plus the QR text `COS1/MAC/<CODE>/<host>:<port>[+…]/<EXP>`, which uses only characters a QR can pack tightly. Tailscale addresses come first. A Wi-Fi address is offered only when "Allow pairing on this Wi-Fi" is on, and that lasts 10 minutes. A new code cancels the old one and any claim waiting for Allow.
6
+ - `POST /api/pairing/claim` and `GET /api/pairing/claim/<nonce>` need no token. A claim is accepted over Tailscale, or over the home Wi-Fi only while that is allowed. It waits up to 60 seconds for Allow. The phone then reads the pairing token once; a second read says `delivered` without it. The poll must come from the same address as the claim, and keeps answering during an update so an allowed token is never lost.
7
+ - `GET /api/pairing/status` and `POST /api/pairing/decision` (token, and only from this Mac) are how Control shows "wants to pair: Allow / Deny" and turns green, which happens only when the new phone makes its first signed-in request.
8
+ - Limits: 5 claims a minute per address, 20 tries per code (then it locks until a new code). The limits use the real connection address, never `X-Forwarded-For`.
9
+ - Everything is kept in memory. A restart forgets every code (`unknown_code`, a new `bootId`). The pairing routes write no files, so they never hold up an update; during one they answer `draining`.
10
+ - Logs carry the address, the result and a 2-character hash of the code, never the code, the nonce, the token or the QR text.
11
+ - A rejected token now says "Paste the pairing token from COS Control, or scan its code with COS Glasses 6.10.621 or newer." The `reason` stays `pairing_token_rejected`.
12
+ - The pairing token is still the only standing credential. See SECURITY.md.
13
+ - Compatibility: nothing changes for existing users. Pasting the pairing token still works, and older COS Control and COS Glasses builds keep working with this server. Scanning needs COS Glasses 6.10.621 and the COS Control release with the Glasses setup section. The QR needs Tailscale on both the Mac and the iPhone, or the 10-minute "Allow pairing on this Wi-Fi" switch at home.
14
+ - `docs/pairing-fixture.json` is a parity fixture the glasses app copies: the alphabet, real QR text, the reasons and the limits, checked against the server's code by its tests.
15
+
1
16
  ## 6.66.0 — 2026-10-08 — Managed voice setup and Haiku polish
2
17
 
3
18
  - Advertise Haiku, Sonnet and GPT-5.6 Luna Fast. Default omitted-model polish to Haiku for lower cost, while honoring explicit model choices and retaining the transcript on failure.
package/SECURITY.md CHANGED
@@ -27,6 +27,22 @@ reproduction you have. Do not include real transcripts.
27
27
 
28
28
  - The pairing token (`X-Cos-Token`) is the only credential. Rotating it
29
29
  invalidates every display-stream ticket; there is no server-side ticket store.
30
+ - Pairing codes (6.67.0, `docs/pairing-contract.md`) are not credentials. A code
31
+ lives 5 minutes in memory, works once, is made only over loopback with the token,
32
+ and releases the pairing token only after someone presses Allow on the Mac, and
33
+ then only once, to the address that claimed it. Claims are accepted over Tailscale
34
+ (100.64.0.0/10), or a private Wi-Fi address only while the user has allowed it for
35
+ 10 minutes. Limits: 5 claims a minute per connection address, 20 tries per code.
36
+ Codes, nonces, tokens and QR text are never logged. The pairing token is still the
37
+ only standing credential.
38
+ - A device on your tailnet can lock a live pairing code by guessing at it 20 times.
39
+ This is accepted: COS Control says why and offers a new code, and a code already
40
+ claimed is never locked by later guesses.
41
+ - While "Allow pairing on this Wi-Fi" is on, the pairing token reaches the phone over
42
+ plain HTTP on that Wi-Fi, as the pasted token always has. Use it only at home; it
43
+ turns itself off after 10 minutes. Over Tailscale the traffic is encrypted.
44
+ - Behind `tailscale serve` or `funnel`, requests arrive from a loopback proxy, so
45
+ every claim is refused (`not_allowed_network`): pairing fails closed there.
30
46
  - `GET /api/display-stream` is public by design and returns lifecycle events
31
47
  only. Content requires a ticket or the token header. See
32
48
  `server/routes/display.ts` for the allowlist.
@@ -0,0 +1,118 @@
1
+ <!-- Copy of operations/personal/wk41_2026/CONTRACT_glasses_pairing_2026-10-09.md in the COS repo, the single source of truth for server 6.67.0, glasses 6.10.621 and the Control pairing branch. Server notes on what the contract left open are at the end. -->
2
+
3
+ # Contract: glasses pairing (server 6.67.0, glasses 6.10.621, Control branch for Codex)
4
+
5
+ This is the single source of truth for the three builders. Design and reasons: `PLAN_glasses_onboarding_quick_connect_2026-10-08.md`, sections v2 and v4.
6
+
7
+ **Miles, 2026-10-09 07:23:** "We'll use the next version release as the canary. We'll start with Miles network, he should see the QR with the newer Activity setup view that contains the QR code for scanning."
8
+
9
+ **Scope of this release: pairing only.** Durability (Part C), approve-from-glasses (Part B) and the tour come in later releases.
10
+
11
+ ## Capability
12
+ - `GET /api/health` adds `capabilities.pairing = { version: 1 }`.
13
+ - Clients check it before any pairing call. When it is missing, the phone says "Update COS on your Mac (server 6.67 or newer)" and Control shows "Update the server".
14
+
15
+ ## Code and QR grammar (the server builds both, clients only parse)
16
+ - **Code:** 8 characters, Crockford base32 (`0123456789ABCDEFGHJKMNPQRSTVWXYZ`).
17
+ - Normalize input: uppercase it, map O→0, I and L→1, and strip spaces and dashes.
18
+ - Display it as `XXXX-XXXX`.
19
+ - **QR text:** `COS1/MAC/<CODE>/<host>:<port>[+<host>:<port>]/<EXP>`
20
+ - All uppercase alphanumeric plus `/ : + .`, so the QR stays at version 3 or 4. (Changed from `,` after QA round 1: a comma is not in the QR alphanumeric set. The server writes `+`; clients accept `+` and `,`.)
21
+ - `<EXP>` is the expiry, in unix seconds, written in base36 uppercase.
22
+ - Hosts are IPv4 only, http. Tailscale (100.64.0.0/10) comes first; a LAN address (RFC1918) is included only while LAN pairing is armed.
23
+ - `MAC` is the kind. `CLOUD` is reserved for later. Any other kind, or a version other than `COS1`, is refused with "Update COS to use this code".
24
+ - Clients refuse hosts outside 100.64/10 and RFC1918.
25
+
26
+ ## Server routes (6.67.0)
27
+ All under `/api/pairing`. Every response carries a `reason` string on failure. The reasons are:
28
+ `expired`, `used`, `locked`, `rate_limited`, `not_loopback`, `not_allowed_network`, `unknown_code`, `denied`, `draining`, `bad_request`.
29
+
30
+ | Route | Auth | Body or params | 200/202 response |
31
+ |---|---|---|---|
32
+ | `POST /pairing/code` | token **and** loopback socket (`req.socket.remoteAddress`, both forms; never `req.ip` or XFF) | `{ allowLan?: boolean }` (`allowLan` arms LAN claims for 10 minutes) | `{ code, display, qr, expiresAt, bootId, hosts:[{host,port,kind:'tailscale'|'lan'}], lanArmedUntil|null }`. A new code cancels the previous one and any pending claim. |
33
+ | `POST /pairing/claim` | **public**, an exact `POST` match in `isPublicApiRequest`, with its own JSON parser capped at 1 KB | `{ code, nonce }` (`nonce` is 16 to 64 characters from `[A-Za-z0-9_-]`, made by the phone) | 202 `{ pending: true }`. Idempotent for the same nonce and code. |
34
+ | `GET /pairing/claim/:nonce` | **public**, and the caller's socket IP must equal the claim's IP | | `{ state: 'pending'|'allowed'|'denied'|'expired'|'delivered', token?, serverName? }`. The token is returned **once** (the `allowed` state moves to `delivered`). A later read returns `delivered` with no token. |
35
+ | `GET /pairing/status` | token and loopback | | `{ code: {display, expiresAt}|null, pending: {nonce, ip, at}|null, lastClaim: {ip, at, allowed}|null, firstAuthAfterClaimAt|null, lanArmedUntil|null, bootId }` |
36
+ | `POST /pairing/decision` | token and loopback | `{ nonce, allow: boolean }` | `{ ok: true }` |
37
+
38
+ ### Rules
39
+ - **Network:** a claim is accepted from 100.64/10. It is accepted from RFC1918 only while LAN is armed. Anything else gets `not_allowed_network`. This check sits on top of the existing network allowlist.
40
+ - **Code lifetime:** 5 minutes, single use (the first successful claim moves it to `used`).
41
+ - **Pending claims:** one at a time. A pending claim waits up to 60 s for a decision, then becomes `expired`. A second claim while one is pending is refused with `locked`, and that does not cancel the first.
42
+ - **Limits:**
43
+ - 5 claim attempts per minute per socket IP, then `rate_limited`.
44
+ - 20 attempts per code, then the code is `locked` until a new code is made.
45
+ - The limiter keys on the socket IP. Trust-proxy is never enabled.
46
+ - **State:** in memory only. A restart loses codes, so a claim with an old code gets `unknown_code`, and `bootId` changes.
47
+ - **Drain:** the pairing routes are exempt from the mutation lease, since they write no files. When shutting down, `code`, `claim`, `status` and `decision` return 503 `draining`. The poll `GET /pairing/claim/:nonce` is still served (QA round 1), so a token already allowed reaches the phone even if the update gate closes before the next poll.
48
+ - **First authenticated call:** when a request passes `requireApiToken` and its socket IP equals an allowed `lastClaim.ip`, stamp `firstAuthAfterClaimAt` once.
49
+ - **Logging:** never the code, nonce, token or QR text. Log the IP, the result, and the first 2 characters of a sha256 of the code. A test must fail if any of those values appear in log output.
50
+ - **Docs:**
51
+ - SECURITY.md: the pairing token is still the only standing credential; pairing codes are short-lived and single use.
52
+ - `docs/pairing-contract.md`: a copy of this file.
53
+ - Change the 401 copy to "Paste the pairing token from COS Control, or scan its code with COS Glasses 6.10.621 or newer." (QA round 1: older glasses show it word for word.) The `reason` value stays `pairing_token_rejected`.
54
+
55
+ ## Glasses 6.10.621
56
+ - **Parser:** `src/lib/pair-code.ts` parses the QR grammar, normalizes codes and orders hosts. It is unit tested and exposed to the ES5 wizard as `window.__COS_PAIR`.
57
+ - **Scan entry points:**
58
+ - the wizard Start screen (first option, "Scan code on your Mac")
59
+ - `setup-step1` (the reauth path)
60
+ - the Settings overlay
61
+ - the local starter banner's Connect Mac
62
+ - **Ways in:**
63
+ - **Scan:** `window._evenBridge.captureImageFromCamera()`
64
+ - **Use a screenshot:** `pickImageFromAlbum()`
65
+ - **Type the code:** the code plus the Mac address
66
+ - Scan and screenshot are shown only when the bridge method is a function.
67
+ - **Decoding:** jsQR, decoding at the native width capped at 1920, then a second pass at 1280. A miss shows "Move closer so the code fills your screen".
68
+ - **Flow:**
69
+ 1. Check health on the first host that answers (try hosts in order, 3 s each).
70
+ 2. Make a nonce, then claim.
71
+ 3. Poll `GET /pairing/claim/:nonce` every 1.5 s for up to 70 s. The screen and the lens say "Waiting for Allow on your Mac".
72
+ 4. When the token arrives, write `cos_server_url = http://host:port` and `cos_api_token`, then run the existing `fetchAuthenticatedTarget`, then `finishConnectedSetup`, exactly as the IP form does.
73
+ 5. Keep the token in memory and retry if `/api/models` fails.
74
+ - **Re-pairing:** when a committed target already exists, ask "Replace <old host> with <host>?" first.
75
+ - **Lens lines:** "Pairing with your Mac…", "Waiting for Allow on your Mac", "Paired", "Code expired: show a new one", "Mac is updating". Each fits 6 lines and the width, using only drawable glyphs. isWearing is never used.
76
+ - **Manifest:** `camera` and `album` go in app.json **and** app-hub.json. Update `verify-timer-quarantine-build.mjs` and the `marketplace-hardening` pin. Strip URL-bearing comments from the jsQR bundle. Add a `THIRD_PARTY_NOTICES` entry.
77
+ - **Version touchpoints:** COS_VERSION, package.json, package-lock (both fields), CHANGELOG, the index.html changelog, app.json, app-hub.json, and the Hub receipt.
78
+
79
+ ## Control (branch off `origin/codex/whisper-runtime` 601a944, released by Codex)
80
+ - **New helper verbs:**
81
+ - `tailscale-status` runs the Tailscale bundle binary, `/Applications/Tailscale.app/Contents/MacOS/tailscale`, with **`SHLVL=1`** in the child's environment. It returns `{installed, running, selfIPv4, selfDNS, peers:[{os, dns, ipv4, online, sameUser}]}`, read from `status --json`. Use `Self.UserID` to work out `sameUser`; never use `HostName`.
82
+ - `tailscale-whois <ip>` returns `{node, os, user}`.
83
+ - `pairing-code [--allow-lan]`, `pairing-status` and `pairing-decision <nonce> allow|deny` call the server routes above over loopback with the token.
84
+ - **Setup guide, new "Glasses" section.** It is **not counted** in Finish setup, and it replaces `GlassesGuideRow`. It has three rows:
85
+ 1. **Tailscale on this Mac:**
86
+ - not installed: Get Tailscale (https://tailscale.com/download/mac)
87
+ - signed out: Open Tailscale
88
+ - running: green, with the address
89
+ 2. **Tailscale on your iPhone:**
90
+ - shows a QR for `https://apps.apple.com/app/tailscale/id1470499037`
91
+ - copy: "Sign in with the same account as this Mac"
92
+ - turns green when a same-user iOS peer is online, naming it ("iphone-15-pro-max is on your tailnet")
93
+ 3. **Connect your glasses:**
94
+ - Shown only when `capabilities.pairing` is present.
95
+ - The QR is drawn with CoreImage `CIQRCodeGenerator`, correction level Q, from the server's `qr` string, at least 220 pt. Show `display` and the first host as text, with the copy "Hold your phone so the code fills the screen".
96
+ - Re-mint only while the pane is visible: when the code expires or `bootId` changes, and on **Show a new code**.
97
+ - When `pending` appears, show the **Allow / Deny** card: "<whois node> (<user>) wants to pair". For a non-tailnet IP: "Device on Wi-Fi <ip>".
98
+ - It turns green on `firstAuthAfterClaimAt`, showing "Paired: <node>, <time>".
99
+ - "Allow pairing on this Wi-Fi (home only)" is a toggle (`--allow-lan`), off by default.
100
+ - **Plumbing:** add new Swift files to every `Tests/run.sh` source list. Every compile goes through `Tests/compile-guard.sh`, one at a time.
101
+ - **Reset pairing** (token rotation): **not in this release.** It ships with Part B.
102
+
103
+ ## Server 6.67.0: what the contract left open, as built
104
+
105
+ - **Times** in JSON (`expiresAt`, `lanArmedUntil`, `at`, `firstAuthAfterClaimAt`) are ISO 8601 strings, or null. Only the QR `<EXP>` is base36 unix seconds.
106
+ - **No host:** when the Mac has no Tailscale IPv4 and LAN is not armed, `POST /pairing/code` still mints, with `hosts: []` and `qr: null`, so Control can say why instead of drawing a QR no phone can use.
107
+ - **Hosts:** at most 3, Tailscale first. LAN addresses come only from RFC1918 on interfaces a phone can reach (never `utun` VPNs that are not Tailscale, `bridge`, `awdl`, `llw`, `vmnet`, `vboxnet`).
108
+ - **allowLan:** `true` arms LAN for 10 minutes; a mint without it disarms LAN, so Control's toggle is the source of truth.
109
+ - **Failures** are `{ reason, message }`: 400 `bad_request`, 403 `not_loopback` / `not_allowed_network` / `denied`, 404 `unknown_code`, 409 `used` / `locked`, 410 `expired`, 429 `rate_limited` (with `Retry-After`), 503 `draining`.
110
+ - **Poll:** an unknown nonce (for example after a restart) is 404 `unknown_code`; a poll from a different socket IP is 403 `not_allowed_network` and does not spend the token. `serverName` comes with the token and with `delivered`. Only a real GET may read the token: a HEAD (which needs the token header to get past the gate at all) is answered 405.
111
+ - **Status:** `code` is present while the current code is unexpired and carries an extra `state: 'active'|'used'|'locked'`.
112
+ - **Decision** on a nonce that is not pending: 404 `unknown_code`, 410 `expired`, or 409 `used` (already decided).
113
+ - **Per-code limit:** every claim attempt while a code is active counts against it, right code or wrong; the 21st attempt locks it. Once a code is `used`, later attempts neither count nor lock it: the right code answers `locked` while its claim waits for Allow and `used` after, a wrong one `unknown_code`. Codes are compared in constant time.
114
+ - **firstAuthAfterClaimAt** is stamped only after the allowed claim's token was delivered, on the first token-authenticated request from that claim's IP. A new Allow clears it.
115
+ - **Draining** means the server is shutting down or the maintenance gate is closed (an update in progress, including the startup gate before Control releases it). The poll is never refused for draining.
116
+ - **Loopback** accepts any 127.0.0.0/8, `::1` and `::ffff:127.x` socket at the pairing check, but the server's global network allowlist (unchanged) admits only `127.0.0.1` and `::1` exactly, so another 127.x address is refused before pairing sees it.
117
+ - **QR alphabet:** hosts are joined with `+`, which is in the QR alphanumeric set (`0-9 A-Z space $ % * + - . / :`), so every QR the server writes can be encoded in alphanumeric mode.
118
+ - **Parity fixture:** `docs/pairing-fixture.json` holds the alphabet, nonce pattern, QR pattern and charset, normalization cases, two real `buildPairingQr` outputs (one and two hosts), the reason list with statuses and messages, the poll states and the limits. `server/lib/glasses-pairing.test.ts` recomputes every value, so it fails if the fixture drifts. Clients copy it; nobody edits it by hand.
@@ -0,0 +1,94 @@
1
+ {
2
+ "$comment": "Server-generated parity fixture for glasses pairing (docs/pairing-contract.md). server/lib/glasses-pairing.test.ts fails if any value here stops matching what the server produces. Clients copy it; never hand-edit it.",
3
+ "protocolVersion": 1,
4
+ "capability": {
5
+ "pairing": {
6
+ "version": 1
7
+ }
8
+ },
9
+ "alphabet": "0123456789ABCDEFGHJKMNPQRSTVWXYZ",
10
+ "codeLength": 8,
11
+ "noncePattern": "^[A-Za-z0-9_-]{16,64}$",
12
+ "qrHostSeparator": "+",
13
+ "qrPattern": "^COS1\\/MAC\\/[0-9A-HJKMNP-TV-Z]{8}\\/(?:\\d{1,3}(?:\\.\\d{1,3}){3}:\\d{1,5})(?:\\+\\d{1,3}(?:\\.\\d{1,3}){3}:\\d{1,5})*\\/[0-9A-Z]+$",
14
+ "qrCharset": "^[0-9A-Z/:+.]+$",
15
+ "normalize": {
16
+ "abcd-efgh": "ABCDEFGH",
17
+ " oOil LI10 ": "00111110",
18
+ "7k3m 9pqr": "7K3M9PQR",
19
+ "ABCD-EFGU": null,
20
+ "ABCD-EFG": null
21
+ },
22
+ "examples": [
23
+ {
24
+ "code": "7K3M9PQR",
25
+ "display": "7K3M-9PQR",
26
+ "expiresAtMs": 1791547500000,
27
+ "hosts": [
28
+ {
29
+ "host": "100.100.10.20",
30
+ "port": 3141,
31
+ "kind": "tailscale"
32
+ }
33
+ ],
34
+ "qr": "COS1/MAC/7K3M9PQR/100.100.10.20:3141/TMN2WC"
35
+ },
36
+ {
37
+ "code": "7K3M9PQR",
38
+ "display": "7K3M-9PQR",
39
+ "expiresAtMs": 1791547500000,
40
+ "hosts": [
41
+ {
42
+ "host": "100.100.10.20",
43
+ "port": 3141,
44
+ "kind": "tailscale"
45
+ },
46
+ {
47
+ "host": "192.168.1.20",
48
+ "port": 3141,
49
+ "kind": "lan"
50
+ }
51
+ ],
52
+ "qr": "COS1/MAC/7K3M9PQR/100.100.10.20:3141+192.168.1.20:3141/TMN2WC"
53
+ }
54
+ ],
55
+ "reasons": {
56
+ "bad_request": 400,
57
+ "not_loopback": 403,
58
+ "not_allowed_network": 403,
59
+ "denied": 403,
60
+ "unknown_code": 404,
61
+ "used": 409,
62
+ "locked": 409,
63
+ "expired": 410,
64
+ "rate_limited": 429,
65
+ "draining": 503
66
+ },
67
+ "reasonMessages": {
68
+ "bad_request": "That pairing request was not understood. Scan the code again.",
69
+ "not_loopback": "Pairing codes are made only by COS Control on this Mac.",
70
+ "not_allowed_network": "Connect over Tailscale, or turn on \"Allow pairing on this Wi-Fi\" in COS Control.",
71
+ "denied": "Pairing was denied on the Mac.",
72
+ "unknown_code": "This code is not current. Show a new one in COS Control.",
73
+ "used": "This code was already used. Show a new one in COS Control.",
74
+ "locked": "Too many tries, or another device is waiting. Show a new code in COS Control.",
75
+ "expired": "Code expired: show a new one in COS Control.",
76
+ "rate_limited": "Too many tries. Wait a minute and try again.",
77
+ "draining": "Your Mac is updating. Try again in a minute."
78
+ },
79
+ "pollStates": [
80
+ "pending",
81
+ "allowed",
82
+ "denied",
83
+ "expired",
84
+ "delivered"
85
+ ],
86
+ "limits": {
87
+ "codeTtlMs": 300000,
88
+ "pendingTtlMs": 60000,
89
+ "lanArmMs": 600000,
90
+ "claimsPerIpPerMinute": 5,
91
+ "attemptsPerCode": 20,
92
+ "maxQrHosts": 3
93
+ }
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.66.0",
3
+ "version": "6.67.0",
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": {
@@ -47,6 +47,7 @@
47
47
  "!server/scripts/work-intake-mutation-gate.mjs",
48
48
  "!server/scripts/work-progress-mutation-gate.mjs",
49
49
  "!server/scripts/work-search-mutation-gate.mjs",
50
+ "!server/scripts/pairing-mutation-gate.mjs",
50
51
  "!server/scripts/away-hold-mutation-gate.mjs",
51
52
  "!server/scripts/nemotron-mutation-gate.mjs",
52
53
  "!server/scripts/merged-scribe-mutation-gate.mjs",
@@ -78,6 +79,8 @@
78
79
  "!server/scripts/build-work-task-runtime.py",
79
80
  "bin/hooks/cos-cursor-observer.cjs",
80
81
  "docs/provider-parity.md",
82
+ "docs/pairing-contract.md",
83
+ "docs/pairing-fixture.json",
81
84
  "bin/whisper-runtime.cjs",
82
85
  "bin/whisper-runtime.d.cts"
83
86
  ],
package/server/index.ts CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
8
8
  import { createServer as createHttpsServer } from 'node:https'
9
9
  import { createServer as createHttpServer } from 'node:http'
10
10
  import { readFileSync, existsSync } from 'node:fs'
11
- import { networkInterfaces, homedir } from 'node:os'
11
+ import { networkInterfaces, homedir, hostname } from 'node:os'
12
12
  import { join } from 'node:path'
13
13
  import { execSync } from 'node:child_process'
14
14
  import { randomBytes, randomUUID } from 'node:crypto'
@@ -162,9 +162,12 @@ import {
162
162
  import {
163
163
  isAllowedNetworkIp,
164
164
  isAllowedNetworkOrigin,
165
- isTailscaleIpv4,
165
+ isLoopbackSocket,
166
+ reachableIpv4Addresses,
166
167
  } from './lib/network-policy.js'
167
168
  import { requireApiToken } from './lib/api-auth.js'
169
+ import { PairingState, pairingHostsFrom } from './lib/glasses-pairing.js'
170
+ import { createPairingRouter } from './routes/pairing.js'
168
171
  import { isManagedRuntime } from './lib/managed-runtime.js'
169
172
  import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
170
173
  import {
@@ -317,7 +320,27 @@ if (threadAttachEnabled()) {
317
320
  // authenticated /tts/prepare for native audio players) and
318
321
  // /display-stream/<exp>.<hmac> GET/HEAD (minted on authenticated /api/models for
319
322
  // EventSource, which cannot set X-Cos-Token either). See api-auth.ts.
320
- app.use('/api', requireApiToken(API_TOKEN))
323
+ // 6.67.0 glasses pairing state: in memory only, so a restart loses every code and
324
+ // claim and `bootId` changes (docs/pairing-contract.md).
325
+ const pairingState = new PairingState({
326
+ token: () => API_TOKEN,
327
+ // The Mac's name as the phone shows it ("Paired with <name>").
328
+ serverName: () => hostname().replace(/\.local$/i, ''),
329
+ hosts: lanArmed => pairingHostsFrom(reachableIpv4Addresses(networkInterfaces()), PORT, lanArmed),
330
+ })
331
+ app.use('/api', requireApiToken(API_TOKEN, {
332
+ onAuthenticated: req => pairingState.noteAuthenticated(req.socket?.remoteAddress),
333
+ }))
334
+
335
+ // Pairing routes sit HERE on purpose: after the token gate (mint, status and decision
336
+ // need it; the claim and its poll are exact public doors in api-auth.ts), and BEFORE the
337
+ // mutation-lease catch-all and the global 10 MB parser. They write no files, so they
338
+ // never hold the lease, and each parses its own body at 1 KB. During a drain or
339
+ // shutdown they answer 503 `draining`.
340
+ app.use('/api', createPairingRouter({
341
+ state: () => pairingState,
342
+ isDraining: () => gracefulShutdownStarted || !maintenanceAdmissionsOpen(),
343
+ }))
321
344
 
322
345
  // Fail-closed catch-all for mutation routes that do not own a more specific
323
346
  // lifecycle lease below. This closes the admission/drain race for secondary
@@ -375,9 +398,7 @@ app.use('/api', (req, res, next) => {
375
398
  // still closed. Permit only its two bounded loopback proofs, and only with
376
399
  // the controller-held operation receipt. Normal phone/LAN admissions stay
377
400
  // closed throughout the update.
378
- const address = req.socket.remoteAddress ?? ''
379
- const loopback = address === '::1' || address === '127.0.0.1'
380
- || address.startsWith('127.') || address.startsWith('::ffff:127.')
401
+ const loopback = isLoopbackSocket(req)
381
402
  const controllerProofPath = req.path === '/diagnostics/provider-proof'
382
403
  || req.path === '/tts/prepare'
383
404
  const controllerProof = loopback && controllerProofPath
@@ -1225,17 +1246,11 @@ listenRequiredServers(listeners).then(() => {
1225
1246
  // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
1226
1247
  // not paste-able — enumerate real interfaces and label the Tailscale one.
1227
1248
  try {
1228
- const nets = networkInterfaces()
1229
- const addrs: Array<{ ip: string; label: string }> = []
1230
- for (const [name, infos] of Object.entries(nets)) {
1231
- for (const info of infos ?? []) {
1232
- if (info.family !== 'IPv4' || info.internal) continue
1233
- const isTailscale = isTailscaleIpv4(info.address) || name.startsWith('tailscale')
1234
- addrs.push({ ip: info.address, label: isTailscale ? 'Tailscale — works from anywhere' : `${name} — same Wi-Fi only` })
1235
- }
1236
- }
1249
+ const addrs = reachableIpv4Addresses(networkInterfaces()).map(entry => ({
1250
+ ip: entry.address,
1251
+ label: entry.kind === 'tailscale' ? 'Tailscale — works from anywhere' : `${entry.name} — same Wi-Fi only`,
1252
+ }))
1237
1253
  if (addrs.length > 0) {
1238
- addrs.sort((a, b) => Number(b.label.startsWith('Tailscale')) - Number(a.label.startsWith('Tailscale')))
1239
1254
  console.log('')
1240
1255
  console.log('[COS API] Server URL for the COS Glasses app:')
1241
1256
  for (const a of addrs) console.log(`[COS API] http://${a.ip}:${PORT} (${a.label})`)
@@ -1,5 +1,6 @@
1
1
  import type { RequestHandler } from 'express'
2
2
  import { timingSafeTokenEqual } from './token-auth.js'
3
+ import { PAIRING_NONCE_BODY } from './glasses-pairing.js'
3
4
 
4
5
  // Recovery/setup clients need these availability surfaces before they have a
5
6
  // usable token. Keep private provider state and every mutation route out.
@@ -34,24 +35,48 @@ const TTS_PLAYBACK_CAPABILITY_PATH = /^\/tts\/play\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-
34
35
  // callers that can send one; the ticket exists only for the ones that cannot.
35
36
  const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64}$/
36
37
 
38
+ // 6.67.0 glasses pairing (docs/pairing-contract.md). Exactly two public doors, each
39
+ // matched on METHOD and path, never through the method-agnostic set above:
40
+ // - POST /pairing/claim: a phone with no token yet presents the short-lived code.
41
+ // - GET /pairing/claim/<nonce>: the same phone polls for the decision. The route binds
42
+ // the poll to the claim's socket IP, and the token is released only after Allow.
43
+ // Minting, status and decisions stay behind the token AND a loopback socket.
44
+ const PAIRING_POLL_PATH = new RegExp(`^/pairing/claim/${PAIRING_NONCE_BODY}$`)
45
+
37
46
  export function isPublicApiRequest(method: string, path: string): boolean {
38
47
  if (PUBLIC_API_PATHS.has(path)) return true
48
+ if (method === 'POST' && path === '/pairing/claim') return true
49
+ if (method === 'GET' && PAIRING_POLL_PATH.test(path)) return true
39
50
  if (method !== 'GET' && method !== 'HEAD') return false
40
51
  return TTS_PLAYBACK_CAPABILITY_PATH.test(path)
41
52
  || DISPLAY_STREAM_CAPABILITY_PATH.test(path)
42
53
  }
43
54
 
55
+ export interface RequireApiTokenOptions {
56
+ /**
57
+ * Called once per request that passed the token check (never for a public path).
58
+ * Pairing uses it to stamp `firstAuthAfterClaimAt`. A throw here is swallowed: an
59
+ * observer must never turn an authenticated request into a failure.
60
+ */
61
+ onAuthenticated?: (req: Parameters<RequestHandler>[0]) => void
62
+ }
63
+
44
64
  /** Global /api authentication boundary. Mount before all body parsers. */
45
- export function requireApiToken(apiToken: string): RequestHandler {
65
+ export function requireApiToken(apiToken: string, options: RequireApiTokenOptions = {}): RequestHandler {
46
66
  return (req, res, next) => {
47
67
  if (isPublicApiRequest(req.method, req.path)) return next()
48
68
  if (!timingSafeTokenEqual(req.headers['x-cos-token'], apiToken)) {
49
69
  return res.status(401).json({
50
70
  error: 'unauthorized',
71
+ // Stable on purpose: clients key off this value, not the message.
51
72
  reason: 'pairing_token_rejected',
52
- message: 'In COS Control choose Copy Pairing Token, then paste the complete value into COS Glasses.',
73
+ // Older COS Glasses builds, which cannot scan, show this message word for word.
74
+ message: 'Paste the pairing token from COS Control, or scan its code with COS Glasses 6.10.621 or newer.',
53
75
  })
54
76
  }
77
+ if (options.onAuthenticated) {
78
+ try { options.onAuthenticated(req) } catch { /* observer only */ }
79
+ }
55
80
  next()
56
81
  }
57
82
  }
@@ -0,0 +1,444 @@
1
+ // Glasses pairing (6.67.0). Contract: docs/pairing-contract.md.
2
+ //
3
+ // COS Control mints a short-lived, single-use code over loopback and shows it as a QR.
4
+ // The phone claims it over Tailscale (or the home Wi-Fi while that is armed), the Mac
5
+ // user presses Allow, and only then does the phone's poll receive the pairing token,
6
+ // exactly once. Everything here is in memory: a restart loses every code and claim,
7
+ // and `bootId` changes so Control knows to show a new code.
8
+ //
9
+ // This module is pure. The clock, the random source, the token, the host list and
10
+ // the server name are injected so every rule in the contract is testable.
11
+
12
+ import { createHash, randomInt as cryptoRandomInt, randomUUID, timingSafeEqual } from 'node:crypto'
13
+ import { isRfc1918Ipv4, isTailscaleIpv4, normalizeRemoteIp } from './network-policy.js'
14
+
15
+ export const PAIRING_PROTOCOL_VERSION = 1
16
+ export const CROCKFORD_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
17
+ export const PAIRING_CODE_LENGTH = 8
18
+ export const PAIRING_CODE_TTL_MS = 5 * 60_000
19
+ export const PAIRING_PENDING_TTL_MS = 60_000
20
+ export const PAIRING_LAN_ARM_MS = 10 * 60_000
21
+ export const PAIRING_IP_LIMIT_PER_MINUTE = 5
22
+ export const PAIRING_IP_WINDOW_MS = 60_000
23
+ export const PAIRING_CODE_ATTEMPT_LIMIT = 20
24
+ /** Decided claims stay pollable this long, so a slow phone still sees `denied`. */
25
+ export const PAIRING_CLAIM_RETENTION_MS = 10 * 60_000
26
+ /** At most this many hosts in a QR, Tailscale first, so the QR stays small. */
27
+ export const PAIRING_MAX_QR_HOSTS = 3
28
+ /** The nonce body, unanchored. The single source for the route check and the public door in api-auth.ts. */
29
+ export const PAIRING_NONCE_BODY = '[A-Za-z0-9_-]{16,64}'
30
+ export const PAIRING_NONCE_PATTERN = new RegExp(`^${PAIRING_NONCE_BODY}$`)
31
+ /**
32
+ * Between hosts in the QR. `+` is in the QR alphanumeric set (`0-9 A-Z space $ % * + - . / :`);
33
+ * a comma is not, and would push a two-host code into byte mode. Clients accept `+` and `,`.
34
+ */
35
+ export const PAIRING_QR_HOST_SEPARATOR = '+'
36
+ /** The QR grammar. Clients parse it; the server is the only writer. */
37
+ export const PAIRING_QR_PATTERN = /^COS1\/MAC\/[0-9A-HJKMNP-TV-Z]{8}\/(?:\d{1,3}(?:\.\d{1,3}){3}:\d{1,5})(?:\+\d{1,3}(?:\.\d{1,3}){3}:\d{1,5})*\/[0-9A-Z]+$/
38
+ /** Every character the server ever writes into a QR: QR alphanumeric mode, so the code stays small. */
39
+ export const PAIRING_QR_CHARSET = /^[0-9A-Z/:+.]+$/
40
+
41
+ export type PairingReason =
42
+ | 'expired'
43
+ | 'used'
44
+ | 'locked'
45
+ | 'rate_limited'
46
+ | 'not_loopback'
47
+ | 'not_allowed_network'
48
+ | 'unknown_code'
49
+ | 'denied'
50
+ | 'draining'
51
+ | 'bad_request'
52
+
53
+ export const PAIRING_REASON_STATUS: Record<PairingReason, number> = {
54
+ bad_request: 400,
55
+ not_loopback: 403,
56
+ not_allowed_network: 403,
57
+ denied: 403,
58
+ unknown_code: 404,
59
+ used: 409,
60
+ locked: 409,
61
+ expired: 410,
62
+ rate_limited: 429,
63
+ draining: 503,
64
+ }
65
+
66
+ export const PAIRING_REASON_MESSAGE: Record<PairingReason, string> = {
67
+ bad_request: 'That pairing request was not understood. Scan the code again.',
68
+ not_loopback: 'Pairing codes are made only by COS Control on this Mac.',
69
+ not_allowed_network: 'Connect over Tailscale, or turn on "Allow pairing on this Wi-Fi" in COS Control.',
70
+ denied: 'Pairing was denied on the Mac.',
71
+ unknown_code: 'This code is not current. Show a new one in COS Control.',
72
+ used: 'This code was already used. Show a new one in COS Control.',
73
+ locked: 'Too many tries, or another device is waiting. Show a new code in COS Control.',
74
+ expired: 'Code expired: show a new one in COS Control.',
75
+ rate_limited: 'Too many tries. Wait a minute and try again.',
76
+ draining: 'Your Mac is updating. Try again in a minute.',
77
+ }
78
+
79
+ export type PairingHostKind = 'tailscale' | 'lan'
80
+ export interface PairingHost {
81
+ host: string
82
+ port: number
83
+ kind: PairingHostKind
84
+ }
85
+
86
+ export type ClaimState = 'pending' | 'allowed' | 'denied' | 'expired' | 'delivered'
87
+
88
+ export class PairingError extends Error {
89
+ readonly reason: PairingReason
90
+ readonly status: number
91
+ readonly retryAfterSeconds?: number
92
+ constructor(reason: PairingReason, retryAfterSeconds?: number) {
93
+ super(reason)
94
+ this.reason = reason
95
+ this.status = PAIRING_REASON_STATUS[reason]
96
+ this.retryAfterSeconds = retryAfterSeconds
97
+ }
98
+ }
99
+
100
+ // ── Code and QR grammar ─────────────────────────────────────────────────────
101
+
102
+ export function generatePairingCode(randomInt: (max: number) => number = max => cryptoRandomInt(max)): string {
103
+ let code = ''
104
+ for (let i = 0; i < PAIRING_CODE_LENGTH; i++) code += CROCKFORD_ALPHABET[randomInt(CROCKFORD_ALPHABET.length)]
105
+ return code
106
+ }
107
+
108
+ /** Uppercase, O→0, I/L→1, strip spaces and dashes. Null unless 8 Crockford characters remain. */
109
+ export function normalizePairingCode(input: unknown): string | null {
110
+ if (typeof input !== 'string' || input.length > 32) return null
111
+ const code = input.toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1')
112
+ if (code.length !== PAIRING_CODE_LENGTH) return null
113
+ for (const ch of code) if (!CROCKFORD_ALPHABET.includes(ch)) return null
114
+ return code
115
+ }
116
+
117
+ export function displayPairingCode(code: string): string {
118
+ return `${code.slice(0, 4)}-${code.slice(4)}`
119
+ }
120
+
121
+ /** `COS1/MAC/<CODE>/<host>:<port>[+<host>:<port>]/<EXP>`, EXP = unix seconds in base36 uppercase. */
122
+ export function buildPairingQr(code: string, hosts: readonly PairingHost[], expiresAtMs: number): string {
123
+ const hostList = hosts.map(h => `${h.host}:${h.port}`).join(PAIRING_QR_HOST_SEPARATOR)
124
+ const exp = Math.floor(expiresAtMs / 1000).toString(36).toUpperCase()
125
+ return `COS1/MAC/${code}/${hostList}/${exp}`
126
+ }
127
+
128
+ /** Constant-time compare of two normalized codes. */
129
+ export function pairingCodesEqual(a: string, b: string): boolean {
130
+ const left = Buffer.from(a, 'utf8')
131
+ const right = Buffer.from(b, 'utf8')
132
+ return left.length === right.length && timingSafeEqual(left, right)
133
+ }
134
+
135
+ /** First 2 hex characters of sha256(code): the only form of a code that is ever logged. */
136
+ export function pairingCodeTag(code: string): string {
137
+ return createHash('sha256').update(code).digest('hex').slice(0, 2)
138
+ }
139
+
140
+ /**
141
+ * Pairing hosts from the reachable-address list: Tailscale IPv4 first, then RFC1918
142
+ * only while LAN is armed. Never IPv6 or MagicDNS. Deduplicated, capped for QR size.
143
+ */
144
+ export function pairingHostsFrom(
145
+ addresses: ReadonlyArray<{ address: string; kind: 'tailscale' | 'lan' | 'other' }>,
146
+ port: number,
147
+ lanArmed: boolean,
148
+ ): PairingHost[] {
149
+ const seen = new Set<string>()
150
+ const tailscale: PairingHost[] = []
151
+ const lan: PairingHost[] = []
152
+ for (const entry of addresses) {
153
+ if (seen.has(entry.address)) continue
154
+ if (isTailscaleIpv4(entry.address)) {
155
+ seen.add(entry.address)
156
+ tailscale.push({ host: entry.address, port, kind: 'tailscale' })
157
+ } else if (lanArmed && entry.kind === 'lan' && isRfc1918Ipv4(entry.address)) {
158
+ seen.add(entry.address)
159
+ lan.push({ host: entry.address, port, kind: 'lan' })
160
+ }
161
+ }
162
+ return [...tailscale, ...lan].slice(0, PAIRING_MAX_QR_HOSTS)
163
+ }
164
+
165
+ // ── State machine ───────────────────────────────────────────────────────────
166
+
167
+ interface CodeRecord {
168
+ code: string
169
+ expiresAt: number
170
+ attempts: number
171
+ status: 'active' | 'used' | 'locked'
172
+ }
173
+
174
+ interface ClaimRecord {
175
+ nonce: string
176
+ code: string
177
+ ip: string
178
+ at: number
179
+ state: ClaimState
180
+ decidedAt: number | null
181
+ deliveredAt: number | null
182
+ }
183
+
184
+ export interface PairingStateOptions {
185
+ now?: () => number
186
+ randomInt?: (max: number) => number
187
+ bootId?: string
188
+ /** The standing pairing token, read at delivery time so a rotation is honoured. */
189
+ token: () => string
190
+ serverName: () => string
191
+ /** Hosts for the QR, given whether LAN is armed right now. */
192
+ hosts: (lanArmed: boolean) => PairingHost[]
193
+ log?: (line: string) => void
194
+ }
195
+
196
+ export interface MintResult {
197
+ code: string
198
+ display: string
199
+ qr: string | null
200
+ expiresAt: string
201
+ bootId: string
202
+ hosts: PairingHost[]
203
+ lanArmedUntil: string | null
204
+ }
205
+
206
+ export interface PollResult {
207
+ state: ClaimState
208
+ token?: string
209
+ serverName?: string
210
+ }
211
+
212
+ export interface PairingStatus {
213
+ code: { display: string; expiresAt: string; state: CodeRecord['status'] } | null
214
+ pending: { nonce: string; ip: string; at: string } | null
215
+ lastClaim: { ip: string; at: string; allowed: boolean } | null
216
+ firstAuthAfterClaimAt: string | null
217
+ lanArmedUntil: string | null
218
+ bootId: string
219
+ }
220
+
221
+ const iso = (ms: number) => new Date(ms).toISOString()
222
+
223
+ export class PairingState {
224
+ readonly bootId: string
225
+ private readonly now: () => number
226
+ private readonly randomInt?: (max: number) => number
227
+ private readonly opts: PairingStateOptions
228
+ private readonly log: (line: string) => void
229
+ private current: CodeRecord | null = null
230
+ private pendingNonce: string | null = null
231
+ private readonly claims = new Map<string, ClaimRecord>()
232
+ private lastClaimNonce: string | null = null
233
+ private firstAuthAfterClaimAt: number | null = null
234
+ private lanArmedUntil: number | null = null
235
+ private readonly ipAttempts = new Map<string, number[]>()
236
+
237
+ constructor(opts: PairingStateOptions) {
238
+ this.opts = opts
239
+ this.now = opts.now ?? (() => Date.now())
240
+ this.randomInt = opts.randomInt
241
+ this.bootId = opts.bootId ?? randomUUID()
242
+ this.log = opts.log ?? (line => console.log(line))
243
+ }
244
+
245
+ private lanArmed(at: number): boolean {
246
+ return this.lanArmedUntil != null && at < this.lanArmedUntil
247
+ }
248
+
249
+ /** Lazy timers: a pending claim older than 60 s expires; old decided claims are dropped. */
250
+ private sweep(at: number): void {
251
+ if (this.pendingNonce) {
252
+ const pending = this.claims.get(this.pendingNonce)
253
+ if (!pending || pending.state !== 'pending') {
254
+ this.pendingNonce = null
255
+ } else if (at - pending.at >= PAIRING_PENDING_TTL_MS) {
256
+ pending.state = 'expired'
257
+ pending.decidedAt = at
258
+ this.pendingNonce = null
259
+ this.log(`[pairing] claim expired ip=${pending.ip} code#=${pairingCodeTag(pending.code)}`)
260
+ }
261
+ }
262
+ for (const [nonce, claim] of this.claims) {
263
+ if (claim.state !== 'pending' && at - claim.at > PAIRING_CLAIM_RETENTION_MS && nonce !== this.lastClaimNonce) {
264
+ this.claims.delete(nonce)
265
+ }
266
+ }
267
+ if (this.lanArmedUntil != null && at >= this.lanArmedUntil) this.lanArmedUntil = null
268
+ }
269
+
270
+ /** POST /pairing/code. A new code cancels the previous code and any pending claim. */
271
+ mint(input: { allowLan?: boolean } = {}): MintResult {
272
+ const at = this.now()
273
+ this.sweep(at)
274
+ if (this.pendingNonce) {
275
+ const pending = this.claims.get(this.pendingNonce)
276
+ if (pending && pending.state === 'pending') {
277
+ pending.state = 'expired'
278
+ pending.decidedAt = at
279
+ }
280
+ this.pendingNonce = null
281
+ }
282
+ this.lanArmedUntil = input.allowLan === true ? at + PAIRING_LAN_ARM_MS : null
283
+ const code = generatePairingCode(this.randomInt)
284
+ const expiresAt = at + PAIRING_CODE_TTL_MS
285
+ this.current = { code, expiresAt, attempts: 0, status: 'active' }
286
+ const hosts = this.opts.hosts(this.lanArmed(at))
287
+ const qr = hosts.length > 0 ? buildPairingQr(code, hosts, expiresAt) : null
288
+ this.log(`[pairing] code minted hosts=${hosts.map(h => h.host).join(',') || 'none'} lan=${this.lanArmedUntil ? 'armed' : 'off'} code#=${pairingCodeTag(code)}`)
289
+ return {
290
+ code,
291
+ display: displayPairingCode(code),
292
+ qr,
293
+ expiresAt: iso(expiresAt),
294
+ bootId: this.bootId,
295
+ hosts,
296
+ lanArmedUntil: this.lanArmedUntil != null ? iso(this.lanArmedUntil) : null,
297
+ }
298
+ }
299
+
300
+ /** Throws PairingError unless `ip` may claim at all (network, then per-IP limit). */
301
+ private admitClaimer(ip: string, at: number): void {
302
+ if (!isTailscaleIpv4(ip) && !(isRfc1918Ipv4(ip) && this.lanArmed(at))) {
303
+ throw new PairingError('not_allowed_network')
304
+ }
305
+ const recent = (this.ipAttempts.get(ip) ?? []).filter(t => at - t < PAIRING_IP_WINDOW_MS)
306
+ if (recent.length >= PAIRING_IP_LIMIT_PER_MINUTE) {
307
+ this.ipAttempts.set(ip, recent)
308
+ const retry = Math.max(1, Math.ceil((PAIRING_IP_WINDOW_MS - (at - recent[0])) / 1000))
309
+ throw new PairingError('rate_limited', retry)
310
+ }
311
+ recent.push(at)
312
+ this.ipAttempts.set(ip, recent)
313
+ if (this.ipAttempts.size > 1000) {
314
+ for (const [key, times] of this.ipAttempts) {
315
+ if (times.every(t => at - t >= PAIRING_IP_WINDOW_MS)) this.ipAttempts.delete(key)
316
+ }
317
+ }
318
+ }
319
+
320
+ /** POST /pairing/claim. Returns normally for 202 `{ pending: true }`; throws PairingError otherwise. */
321
+ claim(input: { code: unknown; nonce: unknown }, socketIp: string): void {
322
+ const at = this.now()
323
+ this.sweep(at)
324
+ const ip = normalizeRemoteIp(socketIp)
325
+ this.admitClaimer(ip, at)
326
+ const nonce = typeof input.nonce === 'string' && PAIRING_NONCE_PATTERN.test(input.nonce) ? input.nonce : null
327
+ const code = normalizePairingCode(input.code)
328
+ if (!nonce || !code) {
329
+ this.log(`[pairing] claim ip=${ip} result=bad_request`)
330
+ throw new PairingError('bad_request')
331
+ }
332
+ const tag = pairingCodeTag(code)
333
+ const refuse = (reason: PairingReason): never => {
334
+ this.log(`[pairing] claim ip=${ip} result=${reason} code#=${tag}`)
335
+ throw new PairingError(reason)
336
+ }
337
+
338
+ // Idempotent for the same nonce and code from the same socket IP.
339
+ const existing = this.claims.get(nonce)
340
+ if (existing) {
341
+ if (!pairingCodesEqual(existing.code, code) || existing.ip !== ip) refuse('locked')
342
+ if (existing.state === 'pending') return
343
+ if (existing.state === 'denied') refuse('denied')
344
+ if (existing.state === 'expired') refuse('expired')
345
+ // allowed or delivered: the claim already succeeded; the poll carries the token.
346
+ return
347
+ }
348
+
349
+ const current = this.current
350
+ if (!current) refuse('unknown_code')
351
+ const record = current as CodeRecord
352
+ const matches = pairingCodesEqual(record.code, code)
353
+ if (at >= record.expiresAt) refuse(matches ? 'expired' : 'unknown_code')
354
+ if (record.status === 'locked') refuse('locked')
355
+ // A used code has nothing left to guess: further attempts neither count nor lock it,
356
+ // so a tailnet peer cannot turn "used" into "locked" under the claim that won.
357
+ if (record.status === 'used') {
358
+ if (!matches) refuse('unknown_code')
359
+ refuse(this.pendingNonce ? 'locked' : 'used')
360
+ }
361
+ // Every attempt while a code is live counts against it, right or wrong.
362
+ record.attempts++
363
+ if (record.attempts > PAIRING_CODE_ATTEMPT_LIMIT) {
364
+ record.status = 'locked'
365
+ refuse('locked')
366
+ }
367
+ if (!matches) refuse('unknown_code')
368
+
369
+ record.status = 'used'
370
+ this.claims.set(nonce, { nonce, code, ip, at, state: 'pending', decidedAt: null, deliveredAt: null })
371
+ this.pendingNonce = nonce
372
+ this.lastClaimNonce = nonce
373
+ this.log(`[pairing] claim ip=${ip} result=pending code#=${tag}`)
374
+ }
375
+
376
+ /** GET /pairing/claim/:nonce. The token is returned once; `allowed` then reads `delivered`. */
377
+ poll(nonce: string, socketIp: string): PollResult {
378
+ const at = this.now()
379
+ this.sweep(at)
380
+ const ip = normalizeRemoteIp(socketIp)
381
+ const claim = this.claims.get(nonce)
382
+ if (!claim) throw new PairingError('unknown_code')
383
+ if (claim.ip !== ip) {
384
+ this.log(`[pairing] poll ip=${ip} result=not_allowed_network code#=${pairingCodeTag(claim.code)}`)
385
+ throw new PairingError('not_allowed_network')
386
+ }
387
+ if (claim.state === 'allowed') {
388
+ claim.state = 'delivered'
389
+ claim.deliveredAt = at
390
+ this.log(`[pairing] token delivered ip=${ip} code#=${pairingCodeTag(claim.code)}`)
391
+ return { state: 'allowed', token: this.opts.token(), serverName: this.opts.serverName() }
392
+ }
393
+ if (claim.state === 'delivered') return { state: 'delivered', serverName: this.opts.serverName() }
394
+ return { state: claim.state }
395
+ }
396
+
397
+ /** POST /pairing/decision. */
398
+ decide(nonce: unknown, allow: unknown): void {
399
+ const at = this.now()
400
+ this.sweep(at)
401
+ if (typeof nonce !== 'string' || typeof allow !== 'boolean') throw new PairingError('bad_request')
402
+ const claim = this.claims.get(nonce)
403
+ if (!claim) throw new PairingError('unknown_code')
404
+ if (claim.state === 'expired') throw new PairingError('expired')
405
+ if (claim.state !== 'pending') throw new PairingError('used')
406
+ claim.state = allow ? 'allowed' : 'denied'
407
+ claim.decidedAt = at
408
+ this.pendingNonce = null
409
+ this.lastClaimNonce = nonce
410
+ if (allow) this.firstAuthAfterClaimAt = null
411
+ this.log(`[pairing] decision ip=${claim.ip} result=${allow ? 'allowed' : 'denied'} code#=${pairingCodeTag(claim.code)}`)
412
+ }
413
+
414
+ /**
415
+ * Called after a request passes requireApiToken. Stamps `firstAuthAfterClaimAt` once,
416
+ * for the first authenticated request from the allowed claim's IP after its token
417
+ * was delivered, so another phone's traffic can never turn Control green.
418
+ */
419
+ noteAuthenticated(socketIp: string | undefined): void {
420
+ if (this.firstAuthAfterClaimAt != null || !this.lastClaimNonce) return
421
+ const claim = this.claims.get(this.lastClaimNonce)
422
+ if (!claim || claim.state !== 'delivered') return
423
+ if (normalizeRemoteIp(socketIp ?? '') !== claim.ip) return
424
+ this.firstAuthAfterClaimAt = this.now()
425
+ this.log(`[pairing] first authenticated request ip=${claim.ip}`)
426
+ }
427
+
428
+ /** GET /pairing/status. */
429
+ status(): PairingStatus {
430
+ const at = this.now()
431
+ this.sweep(at)
432
+ const current = this.current && at < this.current.expiresAt ? this.current : null
433
+ const pending = this.pendingNonce ? this.claims.get(this.pendingNonce) ?? null : null
434
+ const last = this.lastClaimNonce ? this.claims.get(this.lastClaimNonce) ?? null : null
435
+ return {
436
+ code: current ? { display: displayPairingCode(current.code), expiresAt: iso(current.expiresAt), state: current.status } : null,
437
+ pending: pending ? { nonce: pending.nonce, ip: pending.ip, at: iso(pending.at) } : null,
438
+ lastClaim: last ? { ip: last.ip, at: iso(last.at), allowed: last.state === 'allowed' || last.state === 'delivered' } : null,
439
+ firstAuthAfterClaimAt: this.firstAuthAfterClaimAt != null ? iso(this.firstAuthAfterClaimAt) : null,
440
+ lanArmedUntil: this.lanArmedUntil != null ? iso(this.lanArmedUntil) : null,
441
+ bootId: this.bootId,
442
+ }
443
+ }
444
+ }
@@ -40,3 +40,67 @@ export function isAllowedNetworkOrigin(origin: string): boolean {
40
40
  return false
41
41
  }
42
42
  }
43
+
44
+ /** True for RFC1918 private IPv4 (10/8, 172.16/12, 192.168/16). */
45
+ export function isRfc1918Ipv4(value: string): boolean {
46
+ const octets = parseIpv4(normalizeRemoteIp(value))
47
+ if (!octets) return false
48
+ return octets[0] === 10 ||
49
+ (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
50
+ (octets[0] === 192 && octets[1] === 168)
51
+ }
52
+
53
+ /** Loopback in every form a Node socket reports it: 127.0.0.0/8, ::1, ::ffff:127.x. */
54
+ export function isLoopbackAddress(value: string | undefined | null): boolean {
55
+ const address = value ?? ''
56
+ return address === '::1' || address === '127.0.0.1'
57
+ || address.startsWith('127.') || address.startsWith('::ffff:127.')
58
+ }
59
+
60
+ /**
61
+ * Loopback check on the SOCKET, never `req.ip` or `X-Forwarded-For`. The server never
62
+ * enables trust-proxy, but reading the socket keeps this true even if someone does.
63
+ */
64
+ export function isLoopbackSocket(req: { socket?: { remoteAddress?: string | undefined } | null }): boolean {
65
+ return isLoopbackAddress(req.socket?.remoteAddress)
66
+ }
67
+
68
+ export type ReachableIpv4Kind = 'tailscale' | 'lan' | 'other'
69
+ export interface ReachableIpv4 {
70
+ name: string
71
+ address: string
72
+ kind: ReachableIpv4Kind
73
+ }
74
+ type InterfaceInfo = { address: string; family: string | number; internal: boolean }
75
+
76
+ // Interfaces a phone can never reach: macOS VPN tunnels that are not Tailscale's
77
+ // address, Internet Sharing / VM bridges, AirDrop (awdl) and low-latency WLAN (llw),
78
+ // and VM host-only networks.
79
+ const UNREACHABLE_INTERFACE = /^(utun|bridge|awdl|llw|vmnet|vboxnet)/
80
+
81
+ /**
82
+ * Every external IPv4 address on this machine, Tailscale first. `kind` is
83
+ * `tailscale` for 100.64/10 (or a Linux `tailscale*` interface), `lan` for RFC1918 on
84
+ * an interface a phone can reach, `other` for the rest. Shared by the startup print
85
+ * and the pairing host list.
86
+ */
87
+ export function reachableIpv4Addresses(
88
+ nets: Record<string, InterfaceInfo[] | undefined>,
89
+ ): ReachableIpv4[] {
90
+ const out: ReachableIpv4[] = []
91
+ for (const [name, infos] of Object.entries(nets)) {
92
+ for (const info of infos ?? []) {
93
+ const family = typeof info.family === 'number' ? (info.family === 4 ? 'IPv4' : 'IPv6') : info.family
94
+ if (family !== 'IPv4' || info.internal) continue
95
+ const kind: ReachableIpv4Kind = isTailscaleIpv4(info.address) || name.startsWith('tailscale')
96
+ ? 'tailscale'
97
+ : isRfc1918Ipv4(info.address) && !UNREACHABLE_INTERFACE.test(name) ? 'lan' : 'other'
98
+ out.push({ name, address: info.address, kind })
99
+ }
100
+ }
101
+ const rank = (kind: ReachableIpv4Kind) => (kind === 'tailscale' ? 0 : kind === 'lan' ? 1 : 2)
102
+ return out
103
+ .map((entry, index) => ({ entry, index }))
104
+ .sort((a, b) => rank(a.entry.kind) - rank(b.entry.kind) || a.index - b.index)
105
+ .map(({ entry }) => entry)
106
+ }
@@ -1,4 +1,5 @@
1
1
  import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
2
+ import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
2
3
  import { taskBridgeAvailable } from '../lib/task-bridge.js'
3
4
  import { Router } from 'express'
4
5
  import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
@@ -465,6 +466,10 @@ healthRouter.get('/health', async (_req, res) => {
465
466
  ...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
466
467
  capabilities: {
467
468
  dictationCleanup: { models: DICTATION_CLEAN_MODELS },
469
+ // 6.67.0: glasses pairing (docs/pairing-contract.md). Public on purpose: the phone
470
+ // reads it before it holds a token, and an older server omits it, which is how
471
+ // the phone knows to say "Update COS on your Mac (server 6.67 or newer)".
472
+ pairing: { version: PAIRING_PROTOCOL_VERSION },
468
473
  // Advertised on the PUBLIC health route on purpose: a client deciding whether
469
474
  // to request a display ticket may not hold a usable token yet, and an old
470
475
  // server simply omits this key, which is how a new client detects it.
@@ -0,0 +1,121 @@
1
+ // Glasses pairing routes (6.67.0). Contract: docs/pairing-contract.md.
2
+ //
3
+ // Mounted under /api AFTER requireApiToken and BEFORE the mutation-lease catch-all and
4
+ // the global 10 MB JSON parser:
5
+ // - POST /pairing/claim and GET /pairing/claim/:nonce are public (exact matches in
6
+ // isPublicApiRequest); everything else here needs the token AND a loopback socket.
7
+ // - The routes write no files, so they never take the mutation lease. During a drain or
8
+ // shutdown, minting, claiming, status and decisions answer 503 `draining`. The poll is
9
+ // still served: it writes nothing, and a token the user already allowed must still
10
+ // reach the phone if the update gate closes between Allow and the next poll.
11
+ // - Each route parses its own body with a 1 KB cap.
12
+ //
13
+ // Every address decision reads `req.socket.remoteAddress`, never `req.ip` or
14
+ // X-Forwarded-For. Codes, nonces, tokens and QR text are never logged (lib/glasses-pairing.ts
15
+ // logs the IP, the result and a 2-character hash of the code).
16
+
17
+ import express, { Router, type ErrorRequestHandler, type Request, type RequestHandler, type Response } from 'express'
18
+ import { isLoopbackSocket } from '../lib/network-policy.js'
19
+ import {
20
+ PAIRING_NONCE_PATTERN,
21
+ PAIRING_REASON_MESSAGE,
22
+ PAIRING_REASON_STATUS,
23
+ PairingError,
24
+ type PairingReason,
25
+ type PairingState,
26
+ } from '../lib/glasses-pairing.js'
27
+
28
+ export interface PairingRouterOptions {
29
+ /** Read per request so a restart created after mount is honoured in tests. */
30
+ state: () => PairingState
31
+ /** True while the server is shutting down or the maintenance gate is closed. */
32
+ isDraining: () => boolean
33
+ }
34
+
35
+ function sendReason(res: Response, reason: PairingReason, retryAfterSeconds?: number, status?: number): void {
36
+ if (retryAfterSeconds != null) res.setHeader('Retry-After', String(retryAfterSeconds))
37
+ res.status(status ?? PAIRING_REASON_STATUS[reason]).json({ reason, message: PAIRING_REASON_MESSAGE[reason] })
38
+ }
39
+
40
+ function sendError(res: Response, error: unknown): void {
41
+ if (error instanceof PairingError) return sendReason(res, error.reason, error.retryAfterSeconds)
42
+ console.error('[pairing] internal error:', error instanceof Error ? error.message : 'unknown')
43
+ res.status(500).json({ reason: 'internal', message: 'Pairing failed on the Mac. Try again.' })
44
+ }
45
+
46
+ function socketIp(req: Request): string {
47
+ return req.socket?.remoteAddress ?? ''
48
+ }
49
+
50
+ export function createPairingRouter(options: PairingRouterOptions): Router {
51
+ const router = Router()
52
+ const smallJson = express.json({ limit: '1kb' })
53
+
54
+ const notDraining: RequestHandler = (_req, res, next) => {
55
+ if (options.isDraining()) return sendReason(res, 'draining', 30)
56
+ next()
57
+ }
58
+ const loopbackOnly: RequestHandler = (req, res, next) => {
59
+ if (!isLoopbackSocket(req)) return sendReason(res, 'not_loopback')
60
+ next()
61
+ }
62
+ const body = (req: Request): Record<string, unknown> =>
63
+ req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body as Record<string, unknown> : {}
64
+
65
+ router.post('/pairing/code', notDraining, loopbackOnly, smallJson, (req, res) => {
66
+ const input = body(req)
67
+ if (input.allowLan !== undefined && typeof input.allowLan !== 'boolean') return sendReason(res, 'bad_request')
68
+ try {
69
+ res.json(options.state().mint({ allowLan: input.allowLan === true }))
70
+ } catch (error) { sendError(res, error) }
71
+ })
72
+
73
+ router.post('/pairing/claim', notDraining, smallJson, (req, res) => {
74
+ const input = body(req)
75
+ try {
76
+ options.state().claim({ code: input.code, nonce: input.nonce }, socketIp(req))
77
+ res.status(202).json({ pending: true })
78
+ } catch (error) { sendError(res, error) }
79
+ })
80
+
81
+ router.get('/pairing/claim/:nonce', (req, res) => {
82
+ // Express routes HEAD to a GET handler. Only a real GET may read (and so spend) the
83
+ // token; api-auth.ts already keeps HEAD behind the token, this holds it here too.
84
+ if (req.method !== 'GET') {
85
+ res.setHeader('Allow', 'GET')
86
+ return sendReason(res, 'bad_request', undefined, 405)
87
+ }
88
+ const nonce = req.params.nonce
89
+ if (typeof nonce !== 'string' || !PAIRING_NONCE_PATTERN.test(nonce)) return sendReason(res, 'bad_request')
90
+ try {
91
+ res.setHeader('Cache-Control', 'no-store')
92
+ res.json(options.state().poll(nonce, socketIp(req)))
93
+ } catch (error) { sendError(res, error) }
94
+ })
95
+
96
+ router.get('/pairing/status', notDraining, loopbackOnly, (_req, res) => {
97
+ try {
98
+ res.setHeader('Cache-Control', 'no-store')
99
+ res.json(options.state().status())
100
+ } catch (error) { sendError(res, error) }
101
+ })
102
+
103
+ router.post('/pairing/decision', notDraining, loopbackOnly, smallJson, (req, res) => {
104
+ const input = body(req)
105
+ try {
106
+ options.state().decide(input.nonce, input.allow)
107
+ res.json({ ok: true })
108
+ } catch (error) { sendError(res, error) }
109
+ })
110
+
111
+ // A body over 1 KB or one that is not JSON answers in the contract's shape, not
112
+ // Express's HTML error page. Scoped to this router's own parsers.
113
+ const parseError: ErrorRequestHandler = (error, req, res, next) => {
114
+ if (!req.path.startsWith('/pairing/')) return next(error)
115
+ if (res.headersSent) return next(error)
116
+ sendReason(res, 'bad_request')
117
+ }
118
+ router.use(parseError)
119
+
120
+ return router
121
+ }