@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 +7 -0
- package/CHANGELOG.md +31 -0
- package/SECURITY.md +5 -10
- package/docs/tailscale-self-heal.md +159 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +3 -3
- package/server/index.ts +21 -12
- package/server/lib/api-auth.ts +0 -7
- package/server/lib/tailscale-nudge.ts +536 -0
- package/server/routes/health.ts +3 -15
- package/docs/lan-first.md +0 -158
- package/server/lib/lan-first.ts +0 -236
- package/server/routes/route-proof.ts +0 -83
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
|
-
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
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
|
-
"
|
|
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 {
|
|
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
|
package/server/lib/api-auth.ts
CHANGED
|
@@ -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)
|