@gotcos/glasses-server 6.88.0 → 6.89.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/.env.example CHANGED
@@ -53,6 +53,13 @@ BIND_HOST=0.0.0.0
53
53
  # server after Wi-Fi/Tailscale changes and process restarts.
54
54
  # COS_SERVER_INSTANCE_ID_PATH=/path/to/server-instance-id
55
55
 
56
+ # 6.89.0 Tailscale self-heal. After a restart, a wake from sleep, a Tailscale
57
+ # restart or a network change, or when a recently active phone goes quiet while
58
+ # Tailscale says it is online, the server runs a bounded `tailscale ping` at it
59
+ # (at most one a minute per phone, 30 an hour). On by default; 0 turns it off.
60
+ # See docs/tailscale-self-heal.md.
61
+ # COS_TAILSCALE_NUDGE=0
62
+
56
63
  # Build 204+ uses server-owned durable query jobs by default. The server fsyncs an
57
64
  # accepted prompt before returning 202, then keeps provider work running if the
58
65
  # phone backgrounds, reloads, or changes networks. Reopening COS reattaches to
package/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## 6.89.0 - LAN first removed; the Mac nudges a stale Tailscale tunnel back to life
2
+
3
+ Miles, 2026-10-23 19:57: no LAN-only code, because he uses COS away from home; and checks around the nudge so the
4
+ Tailscale connection always comes back. After the 10/22 Mac restart the phone's Tailscale extension kept a stale tunnel:
5
+ Tailscale's own ping went through, no COS request did, and only toggling Tailscale on the phone fixed it. Contract and
6
+ field canary: `docs/tailscale-self-heal.md`.
7
+
8
+ - **LAN first is gone.** Removed: public `GET /api/route-proof`, `routes` on `/api/models`, `capabilities.routeProof` and
9
+ `capabilities.lanRoutes`, `COS_LAN_FIRST`, `server/lib/lan-first.ts`, `server/routes/route-proof.ts`,
10
+ `docs/lan-first.md`, the SECURITY.md note, their tests and gate (reverts c593b91). `/api/models` is the 6.87.0 shape
11
+ path for path, and `/api/health` is 6.87.0 plus `tailscaleNudge`, both checked against goldens captured from
12
+ 601d807. `/api/route-proof` is an unknown path behind the token again. `COS_LAN_FIRST` is read by nothing.
13
+ - **Kept:** 6.88.0's transfer integrity against the phone's final index and the origin-wipe recovery save that waits
14
+ for it (fc52f49), and the older opt-in "Allow pairing on this Wi-Fi" pairing switch (6.67.0).
15
+ - **The nudge** (`server/lib/tailscale-nudge.ts`). `tailscale ping -c 3 --timeout 5s <peer>` (disco: both ends redo
16
+ path discovery), then, only after a pong, `tailscale ping --icmp -c 1 --timeout 5s <peer>` (one packet through
17
+ WireGuard, which starts a fresh handshake on a stale session). `execFile` from a fixed path, never a shell.
18
+ - **Triggers:** 30 s after boot; a wake from sleep (the wall clock outran the monotonic clock by 20 s); a change in
19
+ this Mac's IPv4 interfaces; a change in Tailscale's own state (BackendState, this Mac's Tailscale IPs, or peer byte
20
+ counters going backwards, meaning tailscaled restarted); and a phone that made an authenticated request in the last
21
+ 5 minutes, or sent meeting chunks in the last 15 minutes while a meeting is open, silent for 45 s. Every one needs
22
+ the peer `Online` in a status read at most 15 s old and Tailscale `Running`: absence of requests alone never nudges.
23
+ - **Caps:** one nudge per peer per 60 s, doubling per unanswered nudge to 15 minutes, reset by any request; 30 an hour
24
+ in all; one pass at a time; hard timeouts (5, 20 and 10 s, SIGKILL). `COS_TAILSCALE_NUDGE=0` turns it off, read
25
+ every tick, forwarded by the managed runtime contract. No LLM, no disk write, no new route.
26
+ - **What it says.** `[tailscale-nudge]` log lines with trigger, outcome, timing, handshake age and byte movement, never
27
+ an address; `tailscaleNudge { enabled, available, lastAt, lastTrigger, lastOutcome, lastDataPath, lastRecoveredAt,
28
+ nudgesLastHour }` on public `/api/health`.
29
+ - **Phone contract for glasses 6.10.653** (not built here): retry with fresh `no-store` requests on a 2, 4, 8, 15, 30 s
30
+ ladder, show 6.10.652's stuck-Tailscale message only after 90 s, read `tailscaleNudge`, and the iOS VPN On Demand note.
31
+
1
32
  ## 6.88.0 - LAN first: a pre-token route proof and the Mac's addresses for the phone
2
33
 
3
34
  The server half of the glasses "LAN first" reconnect plan (plan of 2026-10-22, Miles's decisions of 2026-10-23: "Go
package/SECURITY.md CHANGED
@@ -43,16 +43,11 @@ reproduction you have. Do not include real transcripts.
43
43
  turns itself off after 10 minutes. Over Tailscale the traffic is encrypted.
44
44
  - Behind `tailscale serve` or `funnel`, requests arrive from a loopback proxy, so
45
45
  every claim is refused (`not_allowed_network`): pairing fails closed there.
46
- - `GET /api/route-proof` (6.88.0, `docs/lan-first.md`) is public by design. It returns
47
- the server instance id and HMAC-SHA256(token, "cos-route-proof:v1:" + nonce + ":" +
48
- instance id), never the token, so the phone can check which Mac answers at a home
49
- Wi-Fi address before sending the token there. The caller picks only a 22 to 64
50
- character base64url nonce. It is served only for a token of 32 characters or more,
51
- because a proof lets anyone who reaches the port test guesses offline. Limits: 20 a
52
- minute per connection address, 240 a minute in all. It does not stop an active
53
- attacker on the same Wi-Fi who relays the request to the real Mac: the LAN path is
54
- plain HTTP, as above. `COS_LAN_FIRST=0` turns it off. The Mac's addresses are
55
- published only on authenticated `/api/models`, never on `/api/health`.
46
+ - 6.89.0 Tailscale self-heal (`docs/tailscale-self-heal.md`): the server runs `tailscale status --json`
47
+ and `tailscale ping` at a peer that Tailscale reports online, with `execFile` from a fixed path and a
48
+ fixed argument list (the only variable is a 100.64.0.0/10 address read from Tailscale's own status).
49
+ Never a shell. Public `/api/health` shows when it last ran and the outcome, never an address.
50
+ `COS_TAILSCALE_NUDGE=0` turns it off.
56
51
  - `GET /api/display-stream` is public by design and returns lifecycle events
57
52
  only. Content requires a ticket or the token header. See
58
53
  `server/routes/display.ts` for the allowlist.
@@ -0,0 +1,159 @@
1
+ # Tailscale self-heal (server 6.89.0)
2
+
3
+ COS is used away from home, over Tailscale. The problem this solves: after the Mac restarts, wakes
4
+ from sleep, restarts Tailscale or changes network, the phone can stay on a stale Tailscale tunnel.
5
+ Tailscale says the phone is online and its own ping gets through, yet no COS request arrives, and
6
+ the only fix used to be toggling Tailscale on the phone. 6.89.0 adds a bounded Mac-side "nudge" and
7
+ a contract for the phone side. Code: `server/lib/tailscale-nudge.ts`.
8
+
9
+ 6.89.0 also removes 6.88.0's LAN-first pieces (`GET /api/route-proof`, `routes` on `/api/models`,
10
+ `capabilities.routeProof` and `capabilities.lanRoutes`, `COS_LAN_FIRST`). Tailscale already takes
11
+ the direct path over the home Wi-Fi when both ends are home, so a separate LAN route added nothing
12
+ for the away-from-home case. `COS_LAN_FIRST` is now read by nothing.
13
+
14
+ ## What the Mac can see (read-only probes, 2026-10-23, Tailscale 1.102.2 on macOS)
15
+
16
+ `tailscale status --json`, the phone's peer entry while COS was working normally:
17
+
18
+ | Field | Value seen | What it tells us |
19
+ |---|---|---|
20
+ | `Online` | `true` | Control-plane presence. It stays true while the iOS extension runs, stale tunnel or not, so on its own it proves nothing about the data path. |
21
+ | `Active` | `true` | Recent traffic in the engine. |
22
+ | `CurAddr` | the phone's home Wi-Fi address and port | A direct path. Away from home it is a public address, or empty when relayed. |
23
+ | `Relay` | the home DERP region | The fallback relay. |
24
+ | `LastHandshake` | 60 s old, refreshing about every 2 minutes | The WireGuard session is being renewed. A handshake much older than about 3 minutes while `Active` is true is suspicious. |
25
+ | `LastSeen` | zero time | Only set while the peer is offline. |
26
+ | `RxBytes` / `TxBytes` | grew by about 9 KB and 18 KB in 12 s | Per-peer counters for the life of tailscaled. They reset when tailscaled restarts, which is how the server detects a Mac-side Tailscale restart. |
27
+
28
+ `tailscale ping -c 3 --timeout 5s <phone>` answered once, `via <home address>:41641 in 91ms`, and
29
+ stopped: it stops at the first direct pong. `tailscale ping --icmp -c 1 --timeout 5s <phone>`
30
+ answered `via ICMP in 27ms`. The CLI's own help says the plain (disco) ping does not go through the
31
+ tunnel; the `--icmp` ping does go through WireGuard.
32
+
33
+ The local signal for "the peer looks online but no COS request has arrived": the server's own last
34
+ request time from that Tailscale address (any `/api` request, public routes included, because any
35
+ request that arrives proves the tunnel carries COS traffic), together with `Online: true` from a
36
+ status read no more than 15 s old. Each nudge log line also records the peer's handshake age and its
37
+ `RxBytes` movement between the last two status reads, so the field canary can show which of them
38
+ tells a stuck tunnel apart. The stuck state itself was not reproduced here.
39
+
40
+ ## The nudge
41
+
42
+ A nudge is two bounded commands at one peer, run with `execFile` from a fixed path (never a shell,
43
+ never a PATH lookup):
44
+
45
+ 1. `tailscale ping -c 3 --timeout 5s <peer>`: a disco ping. Both ends redo path discovery.
46
+ 2. Only after a pong: `tailscale ping --icmp -c 1 --timeout 5s <peer>`: one packet through
47
+ WireGuard, which makes WireGuard start a fresh handshake when its session is stale. On 10/22 the
48
+ disco ping got through while COS did not, so the disco ping alone is not enough.
49
+
50
+ The CLI is looked for at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`,
51
+ `/opt/homebrew/bin/tailscale`, `/usr/local/bin/tailscale` and `/usr/bin/tailscale`, in that order.
52
+ Without one the loop does nothing and health says `available: false`.
53
+
54
+ Whether a Mac-side nudge un-sticks the iOS extension is not proven by tests. The field canary below
55
+ is the proof.
56
+
57
+ ### Triggers
58
+
59
+ Every trigger needs the positive signal: the peer is `Online` in a status read at most 15 s old, and
60
+ Tailscale's `BackendState` is `Running`. Absence of requests alone never nudges.
61
+
62
+ | Trigger | When | Who |
63
+ |---|---|---|
64
+ | `boot` | 30 s after the server starts | Event peers (below) |
65
+ | `wake` | The wall clock moved 20 s or more past the monotonic clock between two 10 s ticks, which only happens across a sleep. Runs 15 s later. | Event peers |
66
+ | `network` | This Mac's external IPv4 interfaces changed, Tailscale's tunnel interface included. Runs 10 s later. | Event peers |
67
+ | `tailscale` | Tailscale's own state changed between status reads: `BackendState`, this Mac's Tailscale IPs, or the peer byte counters went backwards (tailscaled restarted). Runs 10 s later. | Event peers |
68
+ | `silent` | A Tailscale address that made an authenticated request in the last 5 minutes, or sent meeting chunks in the last 15 minutes while a meeting is open, has made no request at all for 45 s. | That address |
69
+
70
+ Event peers: Online peers this server has seen authenticate in the last 12 hours, plus Online iOS
71
+ and Android peers (after a restart the server remembers no one), at most 4, known ones first. A peer
72
+ that has already made a request since the event is skipped. An event that cannot run because
73
+ Tailscale is not `Running` waits, and is dropped (with one log line) after 2 minutes.
74
+
75
+ ### Caps
76
+
77
+ - One nudge per peer per 60 s. Each nudge that brings no request doubles the gap: 60, 120, 240,
78
+ 480 s, then 900 s. Any request from that peer resets it.
79
+ - 30 nudges an hour in all, across peers.
80
+ - One pass at a time; each command has a hard timeout (status 5 s, disco 20 s, ICMP 10 s, killed
81
+ with SIGKILL).
82
+ - A quiet phone outside a meeting is nudged at most 3 times (at about 45 s, 1:50 and 3:50) before its
83
+ 5-minute window closes. In a meeting, at most 4 in 15 minutes.
84
+ - Status is read once a minute while the loop is on, and otherwise only when a nudge could run.
85
+ - `COS_TAILSCALE_NUDGE=0` (or `false`, `off`) stops all of it, read every tick. On by default.
86
+ Forwarded by the managed runtime contract.
87
+ - No LLM, no disk write, no new route.
88
+
89
+ ### What it says
90
+
91
+ Log lines, outcome and timing only, never an address:
92
+
93
+ ```
94
+ [tailscale-nudge] trigger=silent outcome=pong_direct dataPath=ok pingMs=91 icmpMs=27 silentS=50 handshakeAgeS=61 rxDelta=9040/60s inRow=1 lastHour=1
95
+ [tailscale-nudge] peer answered 4.2s after the silent nudge
96
+ [tailscale-nudge] trigger=wake dropped: Tailscale not running for 121s
97
+ ```
98
+
99
+ `outcome` is `pong_direct`, `pong_relay`, `no_pong`, `timeout`, `error` or `unavailable`.
100
+ `dataPath` is `ok`, `failed` or `skipped` (no ICMP ping without a disco pong). A failed ping also
101
+ logs `code`, `signal` and `killed`.
102
+
103
+ Public `/api/health` carries:
104
+
105
+ ```json
106
+ "tailscaleNudge": {
107
+ "enabled": true,
108
+ "available": true,
109
+ "lastAt": "2026-10-23T20:05:10.000Z",
110
+ "lastTrigger": "silent",
111
+ "lastOutcome": "pong_direct",
112
+ "lastDataPath": "ok",
113
+ "lastRecoveredAt": "2026-10-23T20:05:14.200Z",
114
+ "nudgesLastHour": 1
115
+ }
116
+ ```
117
+
118
+ `available` is `null` until the first look for the CLI. `lastRecoveredAt` is when a nudged peer next
119
+ made a request, if that was within 90 s of the nudge. No address or device name is published.
120
+
121
+ ## The phone side (contract for glasses 6.10.653; not built in this release)
122
+
123
+ - **Retry with a fresh request.** On a reconnect failure, retry `/api/health` and then the
124
+ authenticated `/api/models` with `cache: 'no-store'` and a cache-busting query, on a fresh
125
+ connection where the WebView allows it (do not reuse a stalled keep-alive socket; a new
126
+ `EventSource` for the display stream). Backoff ladder: 2, 4, 8, 15, then every 30 s while the app
127
+ is in the foreground. Never a tight loop.
128
+ - **Wait out the Mac-side window before blaming Tailscale.** The Mac's nudge fires 45 s into a
129
+ silence (or 30 s after a Mac boot, 15 s after a wake) and its pings take up to about 30 s. Show
130
+ 6.10.652's stuck-Tailscale message only after 90 s of failed fresh requests while the phone has
131
+ network (the gotcos.com reachability check). Before that, show the ordinary reconnecting state.
132
+ - **Read the outcome.** `/api/health` is public, so once the phone reaches it, `tailscaleNudge`
133
+ tells it whether the Mac nudged and when. If health is reachable but authenticated calls are not,
134
+ the tunnel is fine and the problem is elsewhere: do not show the Tailscale message.
135
+ - **Keep working offline.** Local-first capture and backfill are unchanged: record locally, upload
136
+ when the Mac answers.
137
+ - **What to tell Miles about VPN On Demand.** In the iOS Tailscale app (Settings, VPN On Demand),
138
+ turning it on makes iOS bring the tunnel back by itself after it drops, for example after a network
139
+ change. It is a phone setting he can choose; COS cannot change it. It does not replace the nudge: a
140
+ tunnel iOS considers "up" but stale is not a drop, which is the case the Mac-side nudge targets.
141
+
142
+ ## Field canary (Miles)
143
+
144
+ 1. Note the time. Phone on cellular or away from home, COS open on the phone, Tailscale on.
145
+ 2. On the Mac, either quit and reopen Tailscale (menu bar, Quit, then open it), or restart the Mac.
146
+ 3. Do not touch Tailscale on the phone. Use COS on the phone within about two minutes.
147
+
148
+ Success:
149
+
150
+ - **Log** (the server log; with COS Control, `~/Library/Logs/COS Glasses/server.log`): a `[tailscale-nudge] trigger=tailscale` (Tailscale restart) or
151
+ `trigger=boot` (Mac restart) line with `outcome=pong_direct` or `pong_relay` and `dataPath=ok`,
152
+ then `peer answered ...s after the ... nudge`.
153
+ - **Health:** `curl -s http://127.0.0.1:3141/api/health` shows `tailscaleNudge.lastTrigger` matching,
154
+ `lastOutcome` a pong, and `lastRecoveredAt` set a few seconds after `lastAt`.
155
+ - **Phone:** COS reconnects by itself, without a Tailscale toggle, and a meeting started before the
156
+ restart keeps its chunks (transfer integrity 100%).
157
+
158
+ If the phone still needs a toggle: record the nudge log line (its `handshakeAgeS` and `rxDelta` are
159
+ the evidence for the next step) and the time of the toggle.
@@ -36,6 +36,7 @@
36
36
  "COS_LIVE_CUES_V2_MAX_PER_MEETING",
37
37
  "COS_LIVE_CUES_V2_DAILY_WRITER_CALLS",
38
38
  "COS_LIVE_CUES_V2_MAC",
39
+ "COS_TAILSCALE_NUDGE",
39
40
  "COS_LIVE_CUES_JEV_DAILY_TOKENS",
40
41
  "COS_LIVE_CUES_SUBQ_DAILY_TOKENS",
41
42
  "COS_DECISION_PROVIDER_CUES",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.88.0",
3
+ "version": "6.89.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": {
@@ -136,9 +136,9 @@
136
136
  "!server/scripts/question-hold-mutation-gate.mjs",
137
137
  "!server/scripts/live-meeting-6860-mutation-gate.mjs",
138
138
  "!server/scripts/live-meeting-6870-mutation-gate.mjs",
139
- "!server/scripts/lan-first-mutation-gate.mjs",
140
139
  "!server/scripts/integrity-6880-mutation-gate.mjs",
141
- "docs/lan-first.md",
140
+ "!server/scripts/tailscale-nudge-6890-mutation-gate.mjs",
141
+ "docs/tailscale-self-heal.md",
142
142
  "docs/test-plan-6.71.8.md",
143
143
  "docs/test-plan-6.71.9.md",
144
144
  "docs/test-plan-6.71.10.md",
package/server/index.ts CHANGED
@@ -14,7 +14,8 @@ import { execSync } from 'node:child_process'
14
14
  import { randomBytes, randomUUID } from 'node:crypto'
15
15
  import { healthRouter } from './routes/health.js'
16
16
  import { diagRouter } from './routes/diag.js'
17
- import { createClientInstanceRouter } from './routes/client-instance.js'
17
+ import { createClientInstanceRouter, isMeetingChunkPost } from './routes/client-instance.js'
18
+ import { installTailscaleNudger } from './lib/tailscale-nudge.js'
18
19
  import { queryRouter } from './routes/query.js'
19
20
  import { providerProofRouter } from './routes/provider-proof.js'
20
21
  import { transcribeRouter } from './routes/transcribe.js'
@@ -147,7 +148,7 @@ import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversati
147
148
  import { getMediaStore } from './lib/media-store.js'
148
149
  import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
149
150
  import { serverMetrics } from './lib/server-metrics.js'
150
- import { getServerInstanceId, initializeServerInstanceId } from './lib/server-instance-id.js'
151
+ import { initializeServerInstanceId } from './lib/server-instance-id.js'
151
152
  import { appendPrivateEnvBlock, UnsafeUserConfigPathError } from './lib/secure-user-config.js'
152
153
  import { getTranscriptionProfileStatus } from './lib/profile.js'
153
154
  import { createQueryJobsRouter } from './routes/query-jobs.js'
@@ -188,10 +189,9 @@ import {
188
189
  isLoopbackSocket,
189
190
  reachableIpv4Addresses,
190
191
  } from './lib/network-policy.js'
191
- import { requireApiToken } from './lib/api-auth.js'
192
+ import { isPublicApiRequest, requireApiToken } from './lib/api-auth.js'
192
193
  import { PairingState, pairingHostsFrom } from './lib/glasses-pairing.js'
193
194
  import { createPairingRouter } from './routes/pairing.js'
194
- import { createRouteProofRouter } from './routes/route-proof.js'
195
195
  import { isManagedRuntime } from './lib/managed-runtime.js'
196
196
  import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
197
197
  import {
@@ -356,9 +356,23 @@ const pairingState = new PairingState({
356
356
  serverName: () => hostname().replace(/\.local$/i, ''),
357
357
  hosts: lanArmed => pairingHostsFrom(reachableIpv4Addresses(networkInterfaces()), PORT, lanArmed),
358
358
  })
359
+ // 6.89.0 Tailscale self-heal (lib/tailscale-nudge.ts, docs/tailscale-self-heal.md). Every /api
360
+ // request from a Tailscale address is noted BEFORE the token check, public routes included: any
361
+ // request that arrives proves the tunnel carries COS traffic. Authenticated ones also mark the
362
+ // address as a paired phone. Notes only, never answers; noteRequest never throws.
363
+ const tailscaleNudger = installTailscaleNudger({ activeMeetings: () => getActiveTranscriptionSessionCount() })
364
+ app.use('/api', (req, _res, next) => {
365
+ tailscaleNudger.noteRequest(req.socket?.remoteAddress, { meetingChunk: isMeetingChunkPost(req.method, req.path) })
366
+ next()
367
+ })
359
368
  app.use('/api', requireApiToken(API_TOKEN, {
360
369
  onAuthenticated: req => pairingState.noteAuthenticated(req.socket?.remoteAddress),
361
370
  }))
371
+ // Past the token gate, a request on a non-public path carried the token: mark a paired phone.
372
+ app.use('/api', (req, _res, next) => {
373
+ if (!isPublicApiRequest(req.method, req.path)) tailscaleNudger.noteRequest(req.socket?.remoteAddress, { authenticated: true })
374
+ next()
375
+ })
362
376
 
363
377
  // Pairing routes sit HERE on purpose: after the token gate (mint, status and decision
364
378
  // need it; the claim and its poll are exact public doors in api-auth.ts), and BEFORE the
@@ -369,14 +383,6 @@ app.use('/api', createPairingRouter({
369
383
  state: () => pairingState,
370
384
  isDraining: () => gracefulShutdownStarted || !maintenanceAdmissionsOpen(),
371
385
  }))
372
- // 6.88.0 LAN first (docs/lan-first.md): the public pre-token identity proof. Writes nothing,
373
- // so it sits beside pairing, ahead of the lease catch-all and the global parser, and is
374
- // served during a drain. The instance id is read per request: it is minted only after
375
- // every listener binds, and until then the route answers 503 `not_ready`.
376
- app.use('/api', createRouteProofRouter({
377
- token: () => API_TOKEN,
378
- serverInstanceId: getServerInstanceId,
379
- }))
380
386
 
381
387
  // Fail-closed catch-all for mutation routes that do not own a more specific
382
388
  // lifecycle lease below. This closes the admission/drain race for secondary
@@ -1283,6 +1289,7 @@ async function gracefulShutdown(): Promise<void> {
1283
1289
  await meetingAskRuntime.shutdown()
1284
1290
  stopMorningBriefScheduler()
1285
1291
  stopRoutineScheduler()
1292
+ tailscaleNudger.stop()
1286
1293
  await workReviewRuntime?.close()
1287
1294
  workIntakeStore?.close()
1288
1295
  workHandoffRequestStore?.close()
@@ -1485,6 +1492,8 @@ listenRequiredServers(listeners).then(() => {
1485
1492
  // Hash the pinned FluidAudio CLI once at boot so the first Settings GET already knows `installable`.
1486
1493
  void findPinnedCli().catch(() => null)
1487
1494
  startPreviewWarmLoop()
1495
+ // 6.89.0: the Tailscale nudge loop. Its boot trigger is due 30 s from here. COS_TAILSCALE_NUDGE=0 stops it.
1496
+ tailscaleNudger.start()
1488
1497
  }
1489
1498
 
1490
1499
  // Durable recovery starts with the admitted runtime by default. A literal
@@ -43,17 +43,10 @@ const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64
43
43
  // Minting, status and decisions stay behind the token AND a loopback socket.
44
44
  const PAIRING_POLL_PATH = new RegExp(`^/pairing/claim/${PAIRING_NONCE_BODY}$`)
45
45
 
46
- // 6.88.0 LAN first (docs/lan-first.md): GET /route-proof is public because it exists to
47
- // prove which Mac answers at an address BEFORE the phone sends its token there. Exact path,
48
- // GET only (HEAD and every other verb stay behind the token). It returns an HMAC keyed by
49
- // the token over a fixed, domain-separated message, never the token (routes/route-proof.ts).
50
- const ROUTE_PROOF_PATH = '/route-proof'
51
-
52
46
  export function isPublicApiRequest(method: string, path: string): boolean {
53
47
  if (PUBLIC_API_PATHS.has(path)) return true
54
48
  if (method === 'POST' && path === '/pairing/claim') return true
55
49
  if (method === 'GET' && PAIRING_POLL_PATH.test(path)) return true
56
- if (method === 'GET' && path === ROUTE_PROOF_PATH) return true
57
50
  if (method !== 'GET' && method !== 'HEAD') return false
58
51
  return TTS_PLAYBACK_CAPABILITY_PATH.test(path)
59
52
  || DISPLAY_STREAM_CAPABILITY_PATH.test(path)