@gotcos/glasses-server 6.86.0 → 6.88.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 +90 -0
- package/SECURITY.md +10 -0
- package/docs/lan-first.md +158 -0
- package/package.json +5 -1
- package/server/index.ts +10 -1
- package/server/lib/api-auth.ts +7 -0
- package/server/lib/chunk-levels.ts +64 -0
- package/server/lib/lan-first.ts +236 -0
- package/server/lib/live-provisional.ts +4 -3
- package/server/lib/transfer-integrity.ts +59 -0
- package/server/routes/health.ts +15 -0
- package/server/routes/meeting.ts +20 -1
- package/server/routes/route-proof.ts +83 -0
- package/server/routes/transcribe-stream.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
|
+
## 6.88.0 - LAN first: a pre-token route proof and the Mac's addresses for the phone
|
|
2
|
+
|
|
3
|
+
The server half of the glasses "LAN first" reconnect plan (plan of 2026-10-22, Miles's decisions of 2026-10-23: "Go
|
|
4
|
+
with your picks on the reconnect plan. No regressions or loss of existing functionality."). After the 10/22 Mac restart
|
|
5
|
+
the phone stayed on a stale Tailscale tunnel while the Mac answered fine on the same Wi-Fi. These two additions let a
|
|
6
|
+
phone build reach the Mac over the home network first, without ever sending its token to the wrong machine. Contract
|
|
7
|
+
for the phone side: `docs/lan-first.md`.
|
|
8
|
+
|
|
9
|
+
- **`GET /api/route-proof?nonce=<22 to 64 base64url characters>`, public.** Answers `{ v: 1, serverInstanceId, proof }`,
|
|
10
|
+
where `proof` is lowercase hex HMAC-SHA256(token, "cos-route-proof:v1:" + nonce + ":" + serverInstanceId). The phone
|
|
11
|
+
computes the same HMAC with its stored token and sends `X-Cos-Token` to that address only on a match. The token is never
|
|
12
|
+
in the response, the nonce is never echoed or logged, and the caller chooses only the nonce, so the route is not a
|
|
13
|
+
general HMAC oracle; the fixed prefix also keeps a proof from ever being a display-stream ticket. Exact public door,
|
|
14
|
+
GET only (`server/lib/api-auth.ts`); every other verb and path stays behind the token. Errors: 400 `bad_nonce`,
|
|
15
|
+
404 `disabled`, 405 `method_not_allowed`, 429 `rate_limited` (with `Retry-After`), 503 `not_ready`.
|
|
16
|
+
- **Rate limited.** 20 a minute per socket address and 240 a minute in all, in memory; bad nonces spend the same budget.
|
|
17
|
+
- **Only for a strong token.** A proof lets anyone who reaches the port test guesses offline, so it is served (and
|
|
18
|
+
advertised) only when the token is 32 characters or more. Tokens made by COS Control (64) and by the server (44) qualify;
|
|
19
|
+
a short hand-set legacy token keeps today's behaviour.
|
|
20
|
+
- **`routes` on authenticated `/api/models` only.** `{ v: 1, serverInstanceId, learnedAt, lan: [{ip, port, scheme}],
|
|
21
|
+
tailscale: [{ip, port, scheme}] }`: RFC1918 IPv4 on real interfaces (tunnels, Internet Sharing and VM bridges, Docker,
|
|
22
|
+
AirDrop and link-local left out) and 100.64/10 on Tailscale's own interface, plain http on `PORT` (3141). Re-read at
|
|
23
|
+
most every 10 s, so a new DHCP lease shows up; a failed interface read omits the field and leaves the rest of the
|
|
24
|
+
response alone. Never on public `/api/health`. The MagicDNS name is left out (reading it needs a `tailscale`
|
|
25
|
+
process per request).
|
|
26
|
+
- **Capability flags.** `capabilities.routeProof: { protocol: 1 }` and `capabilities.lanRoutes: { protocol: 1 }` on
|
|
27
|
+
`/api/health` and `/api/models`, from one helper. An older server omits both, and a phone keeps today's single URL.
|
|
28
|
+
- **Switch.** `COS_LAN_FIRST=0` turns both pieces off, read per request; the responses are then the 6.87.0 shape exactly.
|
|
29
|
+
- **No regression.** Every existing field of `/api/health` and `/api/models` is unchanged, checked path for path against
|
|
30
|
+
a golden captured from the unmodified 6.87.0 base, and byte for byte against the same response with the switch off.
|
|
31
|
+
- **Not built:** Phase 3's loopback `/api/glasses/link` for COS Control's banner. It needs new tracking of the phone's
|
|
32
|
+
last path and check-in, so it is not a purely additive change.
|
|
33
|
+
- **No spend.** No LLM, no new caller, nothing written to disk.
|
|
34
|
+
- **Checks (6.88.0 branch).** Full suite, serial (`--maxWorkers=1`), HOME, COS_DATA_DIR and COS_PROFILE_PATH in a scratch folder,
|
|
35
|
+
COS_SCRIPTS_DIR unset. Unmodified 6.87.0 base (601d807): 584 files (583 passed, 1 skipped), 8815 tests, 8801 passed,
|
|
36
|
+
14 skipped, 0 failed. This branch: 588 files (587 passed, 1 skipped), 8911 tests, 8897 passed, 14 skipped, 0 failed.
|
|
37
|
+
The 96 new tests are the only change per file (`lan-first.test.ts` 58, `route-proof.test.ts` 18, `health-lan-first.test.ts`
|
|
38
|
+
9, `lan-first-wiring.test.ts` 2, `api-auth.test.ts` 40 to 49); no existing test was changed. Mutation gate
|
|
39
|
+
`lan-first-mutation-gate.mjs` against a green baseline (136 tests in 5 files): 56 of 56 killed, each by a named test,
|
|
40
|
+
every target landing exactly once and restored byte for byte. Its first run left two survivors (the IPv4-only filter and
|
|
41
|
+
the de-duplication), each now covered by its own test and killed. `tsc --noEmit` clean. Pack dry run 549 files
|
|
42
|
+
(6.87.0: 546; the three new files are `server/lib/lan-first.ts`, `server/routes/route-proof.ts` and `docs/lan-first.md`).
|
|
43
|
+
|
|
44
|
+
### Also in 6.88.0: transfer integrity counts what the phone captured, and recovery saves wait for it
|
|
45
|
+
|
|
46
|
+
For glasses 6.10.652 (meeting t2xs9u, 2026-10-23 07:59): a relaunched COS window saved a meeting from the Mac's 74 chunks
|
|
47
|
+
while the window still recording it had captured through index 120, and the file said "Transfer integrity 100%". One new
|
|
48
|
+
file (`server/lib/transfer-integrity.ts`) and two edits in `performSave` (`server/routes/meeting.ts`).
|
|
49
|
+
|
|
50
|
+
- **Integrity counts the phone's final index.** When the phone has said how far it captured (the Stop intent's
|
|
51
|
+
`finalChunkIndex`, or the save's own new optional `finalChunkIndex`), expected is that index plus one and every index past
|
|
52
|
+
the Mac's highest chunk is missing. 74 of 121 now reads 61.1% with 47 chunks not received, in the saved markdown, the
|
|
53
|
+
sidecar and the receipt. A final index more than 20,000 past the Mac's highest chunk is capped there; a malformed one is
|
|
54
|
+
ignored.
|
|
55
|
+
- **A recovery save waits.** A save with `recovery: 'origin_wipe'` (glasses 6.10.652) is refused with 409
|
|
56
|
+
`chunks_outstanding` (`missingCount`, `finalChunkIndex`) while the phone's final index says the Mac lacks chunks, and
|
|
57
|
+
nothing is saved or closed. `acceptMissing: true` (the phone waited its 10 minutes) saves what the Mac has, honestly counted.
|
|
58
|
+
- **Unchanged:** a save without `recovery` is never refused here; a save with no final index from anywhere writes exactly
|
|
59
|
+
what 6.87.0 wrote; an already saved meeting replays its receipt first; the autonomous save, the stop route and the status
|
|
60
|
+
route are untouched. No new log line, no LLM, no new disk write. Contract: `docs/meeting-autonomous-save.md`, section 6.88.0.
|
|
61
|
+
- **Checks (integrity, on top of c593b91).** Full suite, serial, the same scratch folder rules: 590 files, 8926 tests, 8912
|
|
62
|
+
passed, 14 skipped, 0 failed (the LAN-first record above is 8911 tests; the 15 new ones are `transfer-integrity.test.ts` 5
|
|
63
|
+
and `meeting-save-integrity.test.ts` 10; no existing test was changed). Mutation gate `integrity-6880-mutation-gate.mjs`
|
|
64
|
+
against a green baseline (15 tests in 2 files): 15 of 15 killed, each by a named test. `tsc --noEmit` clean. Pack dry run
|
|
65
|
+
551 files (the LAN-first 549 plus `server/lib/transfer-integrity.ts` and the gate script).
|
|
66
|
+
|
|
67
|
+
## 6.87.0 - A rough level per chunk for COS Control's glasses meeting strip
|
|
68
|
+
|
|
69
|
+
The server half of the level strip COS Control 0.5.339 draws in a glasses meeting's bottom bar (plan of 2026-10-23,
|
|
70
|
+
approved 09:25). The quiet gate already measured every live chunk (RMS, and Silero speech seconds) and only printed the
|
|
71
|
+
numbers on the `[perf]` line. Now each chunk keeps them.
|
|
72
|
+
|
|
73
|
+
- **Two numbers per chunk on the active-session record.** `chunkLevels[{i, level, speechSec}]` in
|
|
74
|
+
`active-sessions/<id>.json`: `level` is the chunk's RMS on a dBFS scale, 0 at -60 dBFS and under, 1 at -10 dBFS and
|
|
75
|
+
over, three decimals; `speechSec` is the Silero speech seconds (two decimals), or null when the gate fell back to
|
|
76
|
+
RMS. Every chunk the quiet gate sees gets one, quiet chunks included; a replayed index keeps its first answer, like
|
|
77
|
+
the chunk itself. Bounded to the newest 120 chunks (about 12 minutes of 6 s chunks) and restored, validated, after a
|
|
78
|
+
restart. It lives only as long as the record (deleted at save). `server/lib/chunk-levels.ts`.
|
|
79
|
+
- **Control reads it from the file it already reads.** The helper's `live-transcript` read opens this same file, so
|
|
80
|
+
there is no new route. `capabilities.liveMeeting.chunkLevels: true` on `/api/health` and `/api/models` tells Control
|
|
81
|
+
the record carries levels; an older server omits it and Control keeps its still glyph.
|
|
82
|
+
- **Not text, never logged.** Nothing new is logged, and no text is stored: the entries hold an index and two numbers.
|
|
83
|
+
`transcribe-stream-chunk-levels.test.ts` spies every console level and checks the record holds numbers only.
|
|
84
|
+
- **No spend.** No LLM, no new caller, no new disk write: the numbers ride on the durable write each chunk already makes.
|
|
85
|
+
- **Checks (6.87.0 branch).** Full suite, serial (`--maxWorkers=1`), HOME, COS_DATA_DIR and COS_PROFILE_PATH in a scratch folder,
|
|
86
|
+
COS_SCRIPTS_DIR unset: 584 files (583 passed, 1 skipped), 8815 tests, 8801 passed, 14 skipped, 0 failed. Mutation gate `live-meeting-6870-mutation-gate.mjs` against a green
|
|
87
|
+
baseline (32 tests in 4 files): 25 of 25 killed, each by a named
|
|
88
|
+
test, every target landing exactly once and restored byte for byte. `tsc --noEmit` clean. Pack dry run 546 files (6.86.0:
|
|
89
|
+
545; the one new file is `server/lib/chunk-levels.ts`).
|
|
90
|
+
|
|
1
91
|
## 6.86.0 - Decision, promise and follow-up cues, live cues on Mac recordings, and the grey provisional line
|
|
2
92
|
|
|
3
93
|
The server half of COS Control's live meeting view (plan of 2026-10-22, decisions of 15:21). Three lanes: three more cue
|
package/SECURITY.md
CHANGED
|
@@ -43,6 +43,16 @@ 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
56
|
- `GET /api/display-stream` is public by design and returns lifecycle events
|
|
47
57
|
only. Content requires a ticket or the token header. See
|
|
48
58
|
`server/routes/display.ts` for the allowlist.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# LAN first: the server contract (6.88.0)
|
|
2
|
+
|
|
3
|
+
The server half of the glasses "LAN first" reconnect plan (2026-10-22, decisions of 2026-10-23). The phone
|
|
4
|
+
reaches the Mac over the home network first and over Tailscale second. The server adds two things, both
|
|
5
|
+
additive and both capability gated:
|
|
6
|
+
|
|
7
|
+
1. `GET /api/route-proof`, a public identity proof, so the phone can check WHICH Mac answers at an address
|
|
8
|
+
before it sends `X-Cos-Token` there.
|
|
9
|
+
2. `routes` on authenticated `GET /api/models`: the addresses this Mac answers on, re-learned after a DHCP change.
|
|
10
|
+
|
|
11
|
+
An older server has neither capability flag. A phone must then keep today's single stored URL and change
|
|
12
|
+
nothing. `COS_LAN_FIRST=0` in the server environment turns both pieces off (read per request).
|
|
13
|
+
|
|
14
|
+
## Capability flags
|
|
15
|
+
|
|
16
|
+
Published on `GET /api/health` (public) and `GET /api/models` (token) under `capabilities`, from one helper,
|
|
17
|
+
so the two surfaces always agree:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
"routeProof": { "protocol": 1 },
|
|
21
|
+
"lanRoutes": { "protocol": 1 }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| Flag | Present when | Absent means |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `routeProof` | the switch is on, the server instance id is minted, and the token is at least 32 characters | do not call `/api/route-proof`; never send the token to an unproven address |
|
|
27
|
+
| `lanRoutes` | the switch is on | `/api/models` will not carry `routes` |
|
|
28
|
+
|
|
29
|
+
Public health carries only these flags. It never carries an address.
|
|
30
|
+
|
|
31
|
+
## `GET /api/route-proof?nonce=<nonce>`
|
|
32
|
+
|
|
33
|
+
Public: no `X-Cos-Token`. GET only.
|
|
34
|
+
|
|
35
|
+
### Request
|
|
36
|
+
|
|
37
|
+
| Part | Rule |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `nonce` | exactly one query parameter, 22 to 64 characters of base64url (`A-Z a-z 0-9 - _`), no padding. Fresh random per request; 16 random bytes encode to 22 characters. |
|
|
40
|
+
| Headers | none needed. Do not send `X-Cos-Token` to an address you have not proven. |
|
|
41
|
+
| Timeout | the plan's 1.5 s for a LAN proof. |
|
|
42
|
+
|
|
43
|
+
### Success: `200`
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{ "v": 1, "serverInstanceId": "<uuid v4>", "proof": "<64 lowercase hex>" }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`Cache-Control: no-store`.
|
|
50
|
+
|
|
51
|
+
### The HMAC
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
message = "cos-route-proof:v1:" + nonce + ":" + serverInstanceId (UTF-8)
|
|
55
|
+
proof = lowercase hex( HMAC-SHA256( key = token (UTF-8 bytes), message ) )
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`token` is the exact pairing token string the phone stores (the `X-Cos-Token` value). `serverInstanceId` is the
|
|
59
|
+
value in the response, which must also equal the phone's stored `active.serverInstanceId`.
|
|
60
|
+
|
|
61
|
+
Known-answer vector:
|
|
62
|
+
|
|
63
|
+
| Input | Value |
|
|
64
|
+
|---|---|
|
|
65
|
+
| token | `0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` |
|
|
66
|
+
| nonce | `AAAAAAAAAAAAAAAAAAAAAA` |
|
|
67
|
+
| serverInstanceId | `00000000-0000-4000-8000-000000000000` |
|
|
68
|
+
| message | `cos-route-proof:v1:AAAAAAAAAAAAAAAAAAAAAA:00000000-0000-4000-8000-000000000000` |
|
|
69
|
+
| proof | `445fc7d579849fa3fdfb8c2831cf0ace04c6db74e2817b75e87139cc9a3cd207` |
|
|
70
|
+
|
|
71
|
+
### Phone-side check (the whole point)
|
|
72
|
+
|
|
73
|
+
The route counts only if ALL hold, and only then may the phone send `X-Cos-Token` to that address:
|
|
74
|
+
|
|
75
|
+
1. Status 200 and `v === 1`.
|
|
76
|
+
2. `serverInstanceId` equals the stored `active.serverInstanceId`.
|
|
77
|
+
3. `proof` equals the phone's own HMAC over the nonce IT generated for this request and the STORED id.
|
|
78
|
+
|
|
79
|
+
Anything else (timeout, any other status, a body that does not parse, a mismatch) means "not my Mac": drop that
|
|
80
|
+
address for this cycle and never send the token there. Compare in constant time where the platform offers it;
|
|
81
|
+
the comparison happens on the phone, so there is no remote timing oracle either way.
|
|
82
|
+
|
|
83
|
+
### Errors
|
|
84
|
+
|
|
85
|
+
Every error body is `{ "v": 1, "reason": "<code>" }` with `Cache-Control: no-store`.
|
|
86
|
+
|
|
87
|
+
| Status | `reason` | When | Phone should |
|
|
88
|
+
|---|---|---|---|
|
|
89
|
+
| 400 | `bad_nonce` | nonce missing, repeated, wrong length or charset | fix the client; treat as not proven |
|
|
90
|
+
| 401 | (the standard `pairing_token_rejected` body) | any verb other than GET without a token, or any path other than exactly `/api/route-proof` | treat as not proven |
|
|
91
|
+
| 404 | `disabled` | `COS_LAN_FIRST=0` | stop LAN proofs until the capability returns |
|
|
92
|
+
| 404 | (no body contract) | a server older than 6.88.0 | stop LAN proofs; it has no `routeProof` flag anyway |
|
|
93
|
+
| 405 | `method_not_allowed` | a non-GET verb that carried a valid token. `Allow: GET` | fix the client |
|
|
94
|
+
| 429 | `rate_limited` | over the limit below. `Retry-After` in seconds | wait `Retry-After`, keep the current route |
|
|
95
|
+
| 503 | `not_ready` | the instance id is not minted yet (still binding after a restart), or the token is too short to expose. `Retry-After: 30` | keep the current route, retry later |
|
|
96
|
+
| 403 | (network filter) | the caller is not on loopback, Tailscale or a private network | treat as not proven |
|
|
97
|
+
|
|
98
|
+
### Rate limit
|
|
99
|
+
|
|
100
|
+
Per socket address (never `X-Forwarded-For`): 20 requests in any 60 s window. Global: 240 in any 60 s window
|
|
101
|
+
across all addresses. Bad-nonce requests spend the same budget. The plan's phone schedule (boot, foreground,
|
|
102
|
+
reconnect, then one every 60 s while on Tailscale) stays far below it.
|
|
103
|
+
|
|
104
|
+
## `routes` on `GET /api/models` (token)
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
"routes": {
|
|
108
|
+
"v": 1,
|
|
109
|
+
"serverInstanceId": "<uuid v4, same as top-level serverInstanceId>",
|
|
110
|
+
"learnedAt": "<ISO time the interfaces were read>",
|
|
111
|
+
"lan": [ { "ip": "192.168.1.204", "port": 3141, "scheme": "http" } ],
|
|
112
|
+
"tailscale": [ { "ip": "100.108.149.114", "port": 3141, "scheme": "http" } ]
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Field | Rule |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `lan` | RFC1918 IPv4 (10/8, 172.16/12, 192.168/16) on a non-internal interface that is not a tunnel, bridge or VM network (`utun`, `tun`, `tap`, `ipsec`, `ppp`, `bridge`, `awdl`, `llw`, `vmnet`, `vboxnet`, `docker`, `br-`, `veth`, `virbr`, `lxc`, `lxd`, `cni`, `flannel`, `podman`, `cali`, `vnic`, `tailscale` are left out). Link-local 169.254/16 and public addresses never appear. |
|
|
119
|
+
| `tailscale` | 100.64/10 on Tailscale's own interface (`utun*` on macOS, `tailscale*` elsewhere). A carrier-grade-NAT address on Wi-Fi is not Tailscale and is left out. |
|
|
120
|
+
| `port` | the plain-http port (`PORT`, default 3141) for both kinds. HTTPS 3143 is never advertised: its certificate does not cover these addresses. |
|
|
121
|
+
| `scheme` | always `http` in protocol 1. |
|
|
122
|
+
| MagicDNS name | not published. Reading it means running `tailscale status --json` per request, which is not cheap; a later protocol may add `name`. |
|
|
123
|
+
| order | interface order as the OS reports it; at most 8 per list; no duplicates. |
|
|
124
|
+
| freshness | read from the interfaces at most every 10 s, so a new DHCP lease shows up on the next read after that. |
|
|
125
|
+
| absent | the switch is off, the instance id is not minted, or the interface read failed. The rest of `/api/models` is unchanged in every case. |
|
|
126
|
+
|
|
127
|
+
Node does not expose interface up/down flags; an address the OS still lists can appear. That is why every
|
|
128
|
+
address must pass the route proof before the token goes there.
|
|
129
|
+
|
|
130
|
+
Replace the stored LAN list each time `/api/models` is read on either path. Never hardcode an address.
|
|
131
|
+
|
|
132
|
+
## What a stranger can learn
|
|
133
|
+
|
|
134
|
+
| Surface | Without the token |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `/api/route-proof` | the instance id (already public on `/api/health` as `server_instance_id`), an HMAC of a message it cannot choose, and whether the token is long enough to be proven (it gets `not_ready` otherwise) |
|
|
137
|
+
| `/api/health` | the two protocol flags |
|
|
138
|
+
| `/api/models` | the standard 401 |
|
|
139
|
+
|
|
140
|
+
## Security decisions
|
|
141
|
+
|
|
142
|
+
- **No token oracle.** The caller chooses only the nonce, its charset has no `:`, and the message always starts with
|
|
143
|
+
the fixed prefix and ends with the server's own id. The only other HMAC keyed by the token signs
|
|
144
|
+
`display-stream:<exp>` (display tickets), which can never start with the prefix, so a proof is never a ticket.
|
|
145
|
+
- **Offline guessing.** A proof lets anyone who can reach the port test token guesses offline. Server- and
|
|
146
|
+
Control-generated tokens (44 and 64 characters of random) make that hopeless; a token under 32 characters gets no
|
|
147
|
+
proof and no `routeProof` flag, so that install keeps today's behaviour.
|
|
148
|
+
- **What the proof does and does not stop.** It stops the token going to a DIFFERENT machine at a remembered
|
|
149
|
+
address (someone else's Wi-Fi, a reused DHCP lease). It does not stop an active attacker on the same Wi-Fi who relays
|
|
150
|
+
the proof request to the real Mac: the LAN path is plain HTTP, so the token crosses the home network unencrypted, as
|
|
151
|
+
a pasted token and "Allow pairing on this Wi-Fi" already do. Over Tailscale the traffic is encrypted.
|
|
152
|
+
- **Nothing logged.** The route never logs or echoes the nonce or the proof.
|
|
153
|
+
- **No state on disk.** The rate limiter is in memory; a restart clears it.
|
|
154
|
+
|
|
155
|
+
## Not in this release
|
|
156
|
+
|
|
157
|
+
Phase 3 of the plan (a loopback `GET /api/glasses/link` for COS Control's banner) is not built: it needs new
|
|
158
|
+
tracking of the client's last path and check-in time, so it is not a trivially additive change.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.88.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": {
|
|
@@ -135,6 +135,10 @@
|
|
|
135
135
|
"docs/test-plan-6.71.7.md",
|
|
136
136
|
"!server/scripts/question-hold-mutation-gate.mjs",
|
|
137
137
|
"!server/scripts/live-meeting-6860-mutation-gate.mjs",
|
|
138
|
+
"!server/scripts/live-meeting-6870-mutation-gate.mjs",
|
|
139
|
+
"!server/scripts/lan-first-mutation-gate.mjs",
|
|
140
|
+
"!server/scripts/integrity-6880-mutation-gate.mjs",
|
|
141
|
+
"docs/lan-first.md",
|
|
138
142
|
"docs/test-plan-6.71.8.md",
|
|
139
143
|
"docs/test-plan-6.71.9.md",
|
|
140
144
|
"docs/test-plan-6.71.10.md",
|
package/server/index.ts
CHANGED
|
@@ -147,7 +147,7 @@ import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversati
|
|
|
147
147
|
import { getMediaStore } from './lib/media-store.js'
|
|
148
148
|
import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
|
|
149
149
|
import { serverMetrics } from './lib/server-metrics.js'
|
|
150
|
-
import { initializeServerInstanceId } from './lib/server-instance-id.js'
|
|
150
|
+
import { getServerInstanceId, initializeServerInstanceId } from './lib/server-instance-id.js'
|
|
151
151
|
import { appendPrivateEnvBlock, UnsafeUserConfigPathError } from './lib/secure-user-config.js'
|
|
152
152
|
import { getTranscriptionProfileStatus } from './lib/profile.js'
|
|
153
153
|
import { createQueryJobsRouter } from './routes/query-jobs.js'
|
|
@@ -191,6 +191,7 @@ import {
|
|
|
191
191
|
import { requireApiToken } from './lib/api-auth.js'
|
|
192
192
|
import { PairingState, pairingHostsFrom } from './lib/glasses-pairing.js'
|
|
193
193
|
import { createPairingRouter } from './routes/pairing.js'
|
|
194
|
+
import { createRouteProofRouter } from './routes/route-proof.js'
|
|
194
195
|
import { isManagedRuntime } from './lib/managed-runtime.js'
|
|
195
196
|
import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
|
|
196
197
|
import {
|
|
@@ -368,6 +369,14 @@ app.use('/api', createPairingRouter({
|
|
|
368
369
|
state: () => pairingState,
|
|
369
370
|
isDraining: () => gracefulShutdownStarted || !maintenanceAdmissionsOpen(),
|
|
370
371
|
}))
|
|
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
|
+
}))
|
|
371
380
|
|
|
372
381
|
// Fail-closed catch-all for mutation routes that do not own a more specific
|
|
373
382
|
// lifecycle lease below. This closes the admission/drain race for secondary
|
package/server/lib/api-auth.ts
CHANGED
|
@@ -43,10 +43,17 @@ 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
|
+
|
|
46
52
|
export function isPublicApiRequest(method: string, path: string): boolean {
|
|
47
53
|
if (PUBLIC_API_PATHS.has(path)) return true
|
|
48
54
|
if (method === 'POST' && path === '/pairing/claim') return true
|
|
49
55
|
if (method === 'GET' && PAIRING_POLL_PATH.test(path)) return true
|
|
56
|
+
if (method === 'GET' && path === ROUTE_PROOF_PATH) return true
|
|
50
57
|
if (method !== 'GET' && method !== 'HEAD') return false
|
|
51
58
|
return TTS_PLAYBACK_CAPABILITY_PATH.test(path)
|
|
52
59
|
|| DISPLAY_STREAM_CAPABILITY_PATH.test(path)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// 6.87.0: a rough level per live chunk, for COS Control's level strip on glasses meetings.
|
|
2
|
+
//
|
|
3
|
+
// The quiet gate already measures every chunk (stt-live.ts decideQuiet: RMS and, with Silero, speech seconds). Until
|
|
4
|
+
// 6.87.0 those numbers only reached the [perf] line. Now each chunk keeps two numbers on the active session record:
|
|
5
|
+
// `level`, 0 to 1 from the chunk's RMS on a dBFS scale, and `speechSec`, the Silero speech seconds (null when the gate
|
|
6
|
+
// fell back to RMS). Control reads them from the same active-session file its live transcript reads, so there is no
|
|
7
|
+
// new route and no new log line.
|
|
8
|
+
//
|
|
9
|
+
// NOT TEXT. Two numbers per chunk, bounded to the newest CHUNK_LEVELS_LIMIT chunks, living only as long as the
|
|
10
|
+
// active-session record (the 4-hour crash-recovery file, deleted at save). Nothing here logs.
|
|
11
|
+
|
|
12
|
+
/** The newest chunks kept: about 12 minutes of 6 s chunks, far more than the strip draws. */
|
|
13
|
+
export const CHUNK_LEVELS_LIMIT = 120
|
|
14
|
+
/** The dBFS that reads as 0: under the quiet gate's RMS 150 (about -47 dBFS) and the room floor of a G2 mic. */
|
|
15
|
+
export const LEVEL_FLOOR_DBFS = -60
|
|
16
|
+
/** The dBFS that reads as 1: loud close speech. */
|
|
17
|
+
export const LEVEL_CEILING_DBFS = -10
|
|
18
|
+
|
|
19
|
+
export interface ChunkLevel {
|
|
20
|
+
/** The chunk's own index. */
|
|
21
|
+
i: number
|
|
22
|
+
/** 0 to 1, three decimals. */
|
|
23
|
+
level: number
|
|
24
|
+
/** Silero speech seconds, two decimals; null when the quiet gate used RMS. */
|
|
25
|
+
speechSec: number | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A chunk's RMS (PCM16 units) as 0 to 1: LEVEL_FLOOR_DBFS and under is 0, LEVEL_CEILING_DBFS and over is 1. */
|
|
29
|
+
export function normalizedLevel(rms: number): number {
|
|
30
|
+
if (!Number.isFinite(rms) || rms <= 0) return 0
|
|
31
|
+
const dbfs = 20 * Math.log10(rms / 32768)
|
|
32
|
+
const level = (dbfs - LEVEL_FLOOR_DBFS) / (LEVEL_CEILING_DBFS - LEVEL_FLOOR_DBFS)
|
|
33
|
+
return Math.round(Math.min(1, Math.max(0, level)) * 1000) / 1000
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The stored entry for one chunk's quiet decision. */
|
|
37
|
+
export function chunkLevel(chunkIndex: number, rms: number, speechSec: number | null): ChunkLevel {
|
|
38
|
+
const speech = speechSec !== null && Number.isFinite(speechSec) ? Math.round(Math.max(0, speechSec) * 100) / 100 : null
|
|
39
|
+
return { i: chunkIndex, level: normalizedLevel(rms), speechSec: speech }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `levels` with `entry` in index order (a second entry for an index replaces the first), the newest
|
|
43
|
+
* CHUNK_LEVELS_LIMIT kept. Never mutates its input. */
|
|
44
|
+
export function withChunkLevel(levels: readonly ChunkLevel[] | undefined, entry: ChunkLevel): ChunkLevel[] {
|
|
45
|
+
const next = (levels ?? []).filter(l => l.i !== entry.i)
|
|
46
|
+
next.push(entry)
|
|
47
|
+
next.sort((a, b) => a.i - b.i)
|
|
48
|
+
return next.length > CHUNK_LEVELS_LIMIT ? next.slice(next.length - CHUNK_LEVELS_LIMIT) : next
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The levels from a recovered session file: only well-formed entries, one per index, in order, bounded. */
|
|
52
|
+
export function restoreChunkLevels(value: unknown): ChunkLevel[] {
|
|
53
|
+
if (!Array.isArray(value)) return []
|
|
54
|
+
let levels: ChunkLevel[] = []
|
|
55
|
+
for (const raw of value) {
|
|
56
|
+
if (!raw || typeof raw !== 'object') continue
|
|
57
|
+
const { i, level, speechSec } = raw as Record<string, unknown>
|
|
58
|
+
if (!Number.isInteger(i) || (i as number) < 0) continue
|
|
59
|
+
if (typeof level !== 'number' || !Number.isFinite(level) || level < 0 || level > 1) continue
|
|
60
|
+
if (speechSec !== null && (typeof speechSec !== 'number' || !Number.isFinite(speechSec) || speechSec < 0)) continue
|
|
61
|
+
levels = withChunkLevel(levels, { i: i as number, level, speechSec: speechSec as number | null })
|
|
62
|
+
}
|
|
63
|
+
return levels
|
|
64
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// 6.88.0 LAN first (docs/lan-first.md). Two additive pieces for the phone:
|
|
2
|
+
//
|
|
3
|
+
// 1. A pre-token identity proof. `GET /api/route-proof?nonce=<phone nonce>` is public, so a
|
|
4
|
+
// phone can ask an address it has not used before "are you the Mac I paired with?"
|
|
5
|
+
// BEFORE it sends X-Cos-Token there. The answer is an HMAC keyed by the token over a
|
|
6
|
+
// fixed, domain-separated message; the phone computes the same HMAC with its stored
|
|
7
|
+
// token and sends the token only on a match. A machine without the token cannot make a
|
|
8
|
+
// matching proof, and the response never carries the token.
|
|
9
|
+
// 2. The addresses this Mac answers on, published ONLY on authenticated /api/models (never
|
|
10
|
+
// on public /api/health), so the phone can learn its LAN route after a DHCP change.
|
|
11
|
+
//
|
|
12
|
+
// This module is pure apart from the cached interface read. Clock, interfaces, token and
|
|
13
|
+
// instance id are injected so every rule is testable.
|
|
14
|
+
|
|
15
|
+
import { createHmac } from 'node:crypto'
|
|
16
|
+
import { networkInterfaces as osNetworkInterfaces } from 'node:os'
|
|
17
|
+
import { isRfc1918Ipv4, isTailscaleIpv4, normalizeRemoteIp } from './network-policy.js'
|
|
18
|
+
|
|
19
|
+
export const ROUTE_PROOF_PROTOCOL = 1
|
|
20
|
+
export const LAN_ROUTES_PROTOCOL = 1
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The fixed domain-separation prefix. The caller chooses only the nonce, never the whole
|
|
24
|
+
* message, and the nonce charset has no `:`, so the message always parses one way. The only
|
|
25
|
+
* other HMAC keyed by the token signs `display-stream:<exp>` (lib/display-ticket.ts), which
|
|
26
|
+
* can never begin with this prefix, so a proof is never a usable display ticket.
|
|
27
|
+
*/
|
|
28
|
+
export const ROUTE_PROOF_PREFIX = 'cos-route-proof:v1:'
|
|
29
|
+
/** base64url alphabet, 22 to 64 characters. 22 base64url characters carry 132 bits. */
|
|
30
|
+
export const ROUTE_PROOF_NONCE_PATTERN = /^[A-Za-z0-9_-]{22,64}$/
|
|
31
|
+
/**
|
|
32
|
+
* A proof lets anyone on the network check token guesses offline, so it is served only for
|
|
33
|
+
* a token long enough that guessing is hopeless. COS Control writes 64 hex characters and
|
|
34
|
+
* the server's own token is 44 base64url characters; a short hand-set legacy token gets no
|
|
35
|
+
* proof (the phone keeps today's single-route behaviour).
|
|
36
|
+
*/
|
|
37
|
+
export const ROUTE_PROOF_MIN_TOKEN_LENGTH = 32
|
|
38
|
+
export const ROUTE_PROOF_IP_LIMIT = 20
|
|
39
|
+
export const ROUTE_PROOF_GLOBAL_LIMIT = 240
|
|
40
|
+
export const ROUTE_PROOF_WINDOW_MS = 60_000
|
|
41
|
+
/** Past this many tracked addresses, idle ones are dropped. Live ones are bounded by the global ceiling. */
|
|
42
|
+
export const ROUTE_PROOF_MAX_TRACKED_IPS = 1024
|
|
43
|
+
|
|
44
|
+
export const LAN_ROUTES_CACHE_MS = 10_000
|
|
45
|
+
export const LAN_ROUTES_MAX_PER_KIND = 8
|
|
46
|
+
|
|
47
|
+
/** `COS_LAN_FIRST=0` turns off both pieces (read per request). Anything else leaves them on. */
|
|
48
|
+
export function lanFirstEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
49
|
+
return env.COS_LAN_FIRST !== '0'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The plain-http port the phone uses on both paths (server/index.ts reads the same `PORT`). */
|
|
53
|
+
export function lanFirstHttpPort(env: NodeJS.ProcessEnv = process.env): number {
|
|
54
|
+
const port = parseInt(env.PORT ?? '3141', 10)
|
|
55
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3141
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isValidRouteProofNonce(value: unknown): value is string {
|
|
59
|
+
return typeof value === 'string' && ROUTE_PROOF_NONCE_PATTERN.test(value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function routeProofTokenUsable(token: unknown): token is string {
|
|
63
|
+
return typeof token === 'string' && token.length >= ROUTE_PROOF_MIN_TOKEN_LENGTH
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function routeProofMessage(nonce: string, serverInstanceId: string): string {
|
|
67
|
+
return `${ROUTE_PROOF_PREFIX}${nonce}:${serverInstanceId}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Lowercase hex HMAC-SHA256(token, "cos-route-proof:v1:" + nonce + ":" + serverInstanceId). */
|
|
71
|
+
export function computeRouteProof(token: string, nonce: string, serverInstanceId: string): string {
|
|
72
|
+
return createHmac('sha256', token).update(routeProofMessage(nonce, serverInstanceId), 'utf8').digest('hex')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when the proof is servable right now: switch on, instance id minted, token strong. */
|
|
76
|
+
export function routeProofAvailable(input: { token: unknown; serverInstanceId: string | null; env?: NodeJS.ProcessEnv }): boolean {
|
|
77
|
+
return lanFirstEnabled(input.env) && !!input.serverInstanceId && routeProofTokenUsable(input.token)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `capabilities.routeProof` / `capabilities.lanRoutes`, or nothing. Absent means "do not try". */
|
|
81
|
+
export function lanFirstCapabilities(input: { token: unknown; serverInstanceId: string | null; env?: NodeJS.ProcessEnv }): {
|
|
82
|
+
routeProof?: { protocol: number }
|
|
83
|
+
lanRoutes?: { protocol: number }
|
|
84
|
+
} {
|
|
85
|
+
if (!lanFirstEnabled(input.env)) return {}
|
|
86
|
+
return {
|
|
87
|
+
...(routeProofAvailable(input) ? { routeProof: { protocol: ROUTE_PROOF_PROTOCOL } } : {}),
|
|
88
|
+
lanRoutes: { protocol: LAN_ROUTES_PROTOCOL },
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Rate limit ───────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export type RouteProofAdmission = { ok: true } | { ok: false; retryAfterSeconds: number }
|
|
95
|
+
|
|
96
|
+
/** Sliding one-minute window per remote address, plus a global ceiling. In memory only. */
|
|
97
|
+
export class RouteProofRateLimiter {
|
|
98
|
+
private readonly perIp = new Map<string, number[]>()
|
|
99
|
+
private global: number[] = []
|
|
100
|
+
|
|
101
|
+
constructor(private readonly now: () => number = () => Date.now()) {}
|
|
102
|
+
|
|
103
|
+
admit(remoteAddress: string | undefined | null): RouteProofAdmission {
|
|
104
|
+
const at = this.now()
|
|
105
|
+
const ip = normalizeRemoteIp(remoteAddress ?? '') || 'unknown'
|
|
106
|
+
const live = (times: number[]) => times.filter(t => at - t < ROUTE_PROOF_WINDOW_MS)
|
|
107
|
+
const retry = (oldest: number) => Math.max(1, Math.ceil((ROUTE_PROOF_WINDOW_MS - (at - oldest)) / 1000))
|
|
108
|
+
|
|
109
|
+
this.global = live(this.global)
|
|
110
|
+
const recent = live(this.perIp.get(ip) ?? [])
|
|
111
|
+
if (recent.length >= ROUTE_PROOF_IP_LIMIT) {
|
|
112
|
+
this.perIp.set(ip, recent)
|
|
113
|
+
return { ok: false, retryAfterSeconds: retry(recent[0]) }
|
|
114
|
+
}
|
|
115
|
+
if (this.global.length >= ROUTE_PROOF_GLOBAL_LIMIT) {
|
|
116
|
+
// Nothing is stored for a refused address, so a flood of new addresses grows nothing.
|
|
117
|
+
if (recent.length) this.perIp.set(ip, recent)
|
|
118
|
+
return { ok: false, retryAfterSeconds: retry(this.global[0]) }
|
|
119
|
+
}
|
|
120
|
+
recent.push(at)
|
|
121
|
+
this.global.push(at)
|
|
122
|
+
this.perIp.set(ip, recent)
|
|
123
|
+
// Live entries are bounded by the global ceiling, so dropping the idle ones is enough.
|
|
124
|
+
if (this.perIp.size > ROUTE_PROOF_MAX_TRACKED_IPS) {
|
|
125
|
+
for (const [key, times] of this.perIp) if (live(times).length === 0) this.perIp.delete(key)
|
|
126
|
+
}
|
|
127
|
+
return { ok: true }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** For tests: how many addresses the limiter is holding. */
|
|
131
|
+
trackedAddressCount(): number {
|
|
132
|
+
return this.perIp.size
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Routes ───────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export interface RouteEntry {
|
|
139
|
+
ip: string
|
|
140
|
+
port: number
|
|
141
|
+
scheme: 'http'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface LanRoutes {
|
|
145
|
+
v: number
|
|
146
|
+
serverInstanceId: string
|
|
147
|
+
learnedAt: string
|
|
148
|
+
lan: RouteEntry[]
|
|
149
|
+
tailscale: RouteEntry[]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type InterfaceInfo = { address: string; family: string | number; internal: boolean }
|
|
153
|
+
export type InterfaceTable = Record<string, InterfaceInfo[] | undefined>
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Interfaces a phone on the home Wi-Fi can never reach through: VPN tunnels (utun is checked
|
|
157
|
+
* separately for Tailscale), Internet Sharing and VM bridges, AirDrop (awdl), low-latency WLAN
|
|
158
|
+
* (llw), VM host-only networks, and Linux container bridges and veth pairs.
|
|
159
|
+
*/
|
|
160
|
+
const NOT_LAN_INTERFACE = /^(utun|tun|tap|ipsec|ppp|bridge|awdl|llw|vmnet|vboxnet|docker|br-|veth|virbr|lxc|lxd|cni|flannel|podman|cali|vnic|tailscale)/i
|
|
161
|
+
/** Tailscale's own interface: utunN on macOS, tailscale0 on Linux, "Tailscale" on Windows. */
|
|
162
|
+
const TAILSCALE_INTERFACE = /^(utun|tailscale)/i
|
|
163
|
+
|
|
164
|
+
function ipv4(info: InterfaceInfo): boolean {
|
|
165
|
+
const family = typeof info.family === 'number' ? (info.family === 4 ? 'IPv4' : 'IPv6') : info.family
|
|
166
|
+
return family === 'IPv4' && !info.internal
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Pure. LAN = RFC1918 IPv4 on a non-internal interface that is not a tunnel, bridge or VM
|
|
171
|
+
* network. Tailscale = 100.64/10 on Tailscale's own interface (a carrier-grade-NAT address
|
|
172
|
+
* on en0 is not Tailscale and is left out). Link-local 169.254/16 is neither. Each list keeps
|
|
173
|
+
* interface order, is de-duplicated, and is capped.
|
|
174
|
+
*/
|
|
175
|
+
export function buildRouteLists(nets: InterfaceTable, port: number): { lan: RouteEntry[]; tailscale: RouteEntry[] } {
|
|
176
|
+
const lan: RouteEntry[] = []
|
|
177
|
+
const tailscale: RouteEntry[] = []
|
|
178
|
+
const seen = new Set<string>()
|
|
179
|
+
for (const [name, infos] of Object.entries(nets)) {
|
|
180
|
+
for (const info of infos ?? []) {
|
|
181
|
+
if (!info || typeof info.address !== 'string' || !ipv4(info)) continue
|
|
182
|
+
const ip = info.address
|
|
183
|
+
if (seen.has(ip)) continue
|
|
184
|
+
if (isTailscaleIpv4(ip)) {
|
|
185
|
+
if (TAILSCALE_INTERFACE.test(name) && tailscale.length < LAN_ROUTES_MAX_PER_KIND) {
|
|
186
|
+
seen.add(ip)
|
|
187
|
+
tailscale.push({ ip, port, scheme: 'http' })
|
|
188
|
+
}
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if (isRfc1918Ipv4(ip) && !NOT_LAN_INTERFACE.test(name) && lan.length < LAN_ROUTES_MAX_PER_KIND) {
|
|
192
|
+
seen.add(ip)
|
|
193
|
+
lan.push({ ip, port, scheme: 'http' })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { lan, tailscale }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let readInterfaces: () => InterfaceTable = () => osNetworkInterfaces() as InterfaceTable
|
|
201
|
+
let cache: { at: number; port: number; lists: { lan: RouteEntry[]; tailscale: RouteEntry[] } } | null = null
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The `routes` field for authenticated /api/models, or null to omit it. Re-read from the
|
|
205
|
+
* interfaces at most every 10 s, so a DHCP change shows up within one cache period. Never
|
|
206
|
+
* throws: a failed interface read omits the field and leaves the rest of the response alone.
|
|
207
|
+
*/
|
|
208
|
+
export function lanRoutesField(input: {
|
|
209
|
+
port: number
|
|
210
|
+
serverInstanceId: string | null
|
|
211
|
+
env?: NodeJS.ProcessEnv
|
|
212
|
+
now?: () => number
|
|
213
|
+
}): LanRoutes | null {
|
|
214
|
+
try {
|
|
215
|
+
if (!lanFirstEnabled(input.env) || !input.serverInstanceId) return null
|
|
216
|
+
const at = (input.now ?? Date.now)()
|
|
217
|
+
if (!cache || cache.port !== input.port || at - cache.at >= LAN_ROUTES_CACHE_MS || at < cache.at) {
|
|
218
|
+
cache = { at, port: input.port, lists: buildRouteLists(readInterfaces(), input.port) }
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
v: LAN_ROUTES_PROTOCOL,
|
|
222
|
+
serverInstanceId: input.serverInstanceId,
|
|
223
|
+
learnedAt: new Date(cache.at).toISOString(),
|
|
224
|
+
lan: cache.lists.lan.map(r => ({ ...r })),
|
|
225
|
+
tailscale: cache.lists.tailscale.map(r => ({ ...r })),
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
cache = null
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function __setInterfaceReaderForTests(reader: (() => InterfaceTable) | null): void {
|
|
234
|
+
readInterfaces = reader ?? (() => osNetworkInterfaces() as InterfaceTable)
|
|
235
|
+
cache = null
|
|
236
|
+
}
|
|
@@ -80,7 +80,8 @@ export function liveProvisional(): LiveProvisionalStore { return (store ??= new
|
|
|
80
80
|
/** 6.86.0: what /api/health and /api/models publish as `capabilities.liveMeeting`, so COS Control gates the live meeting
|
|
81
81
|
* view by capability, never by a status code. Constants only (health is public). An older server omits the key.
|
|
82
82
|
* - macLiveCues: POST /api/mac-recording/start takes `liveCues: true` and answers `liveCues: {armed, ...}`.
|
|
83
|
-
* - provisional: GET <route>?sessionId= serves the newest preview of a live meeting.
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
* - provisional: GET <route>?sessionId= serves the newest preview of a live meeting.
|
|
84
|
+
* - chunkLevels (6.87.0): the active-session record keeps `chunkLevels[{i, level, speechSec}]` (lib/chunk-levels.ts). */
|
|
85
|
+
export function liveMeetingCapability(): { protocol: 1; macLiveCues: true; provisional: { route: string; maxChars: number; ttlMs: number }; chunkLevels: true } {
|
|
86
|
+
return { protocol: 1, macLiveCues: true, provisional: { route: '/api/transcribe-stream/provisional', maxChars: LIVE_PROVISIONAL_LIMITS.maxChars, ttlMs: LIVE_PROVISIONAL_LIMITS.ttlMs }, chunkLevels: true }
|
|
86
87
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// 6.88.0: transfer integrity counts what the phone captured, not only what the Mac heard of.
|
|
2
|
+
//
|
|
3
|
+
// WHAT HAPPENED. 2026-10-23 07:59, meeting t2xs9u (glasses 6.10.651). A relaunched COS window saved
|
|
4
|
+
// the meeting from the 74 chunks the Mac held while the window still recording it had captured
|
|
5
|
+
// through index 120. The saved file said "Transfer integrity 100%": the gap report only looks for
|
|
6
|
+
// holes in [0, highest index received], so 47 chunks the Mac never heard of did not count.
|
|
7
|
+
//
|
|
8
|
+
// THE RULE. When the phone has said how far it captured (the Stop's `finalChunkIndex`, or a save's
|
|
9
|
+
// own `finalChunkIndex`, glasses 6.10.652), the expected count is that index plus one, and every
|
|
10
|
+
// index from the Mac's highest received chunk to it is missing. 74 of 121 reads 61.1%.
|
|
11
|
+
//
|
|
12
|
+
// And a phone's ORIGIN-WIPE RECOVERY save (`recovery: 'origin_wipe'`, glasses 6.10.652) is refused
|
|
13
|
+
// with 409 `chunks_outstanding` while the phone has said it captured chunks the Mac does not hold,
|
|
14
|
+
// unless it says it waited them out (`acceptMissing: true`). The phone checks the same thing before
|
|
15
|
+
// it asks; this closes the moment between its status read and its save (a Stop landing in between).
|
|
16
|
+
// A save without `recovery` is never refused here: every existing caller behaves as before.
|
|
17
|
+
|
|
18
|
+
import type { TranscriptGapReport } from '../routes/transcribe-stream.js'
|
|
19
|
+
|
|
20
|
+
/** The stop route's own bound on a final index. */
|
|
21
|
+
export const PHONE_FINAL_INDEX_LIMIT = 1_000_000
|
|
22
|
+
/** A phone-said index this far past the Mac's highest chunk is capped (about 30 hours of 6 s chunks),
|
|
23
|
+
* so one bad number cannot write a huge missing list into the sidecar. */
|
|
24
|
+
export const PHONE_FINAL_INDEX_MAX_AHEAD = 20_000
|
|
25
|
+
|
|
26
|
+
/** A final index as the stop route accepts it (a whole number, -1 or more), else null. */
|
|
27
|
+
export function parsePhoneFinalChunkIndex(value: unknown): number | null {
|
|
28
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= -1 && value <= PHONE_FINAL_INDEX_LIMIT ? value : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The highest index any phone window said it captured, or null when none said. */
|
|
32
|
+
export function phoneFinalChunkIndex(...said: Array<number | null | undefined>): number | null {
|
|
33
|
+
const known = said.filter((v): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0)
|
|
34
|
+
return known.length > 0 ? Math.max(...known) : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The gap report with the phone's final index counted. Unchanged when the phone said nothing new. */
|
|
38
|
+
export function withPhoneFinalIndex(report: TranscriptGapReport | null, phoneFinal: number | null): TranscriptGapReport | null {
|
|
39
|
+
if (!report || phoneFinal === null || phoneFinal <= report.maxIndex) return report
|
|
40
|
+
const final = Math.min(phoneFinal, Math.max(report.maxIndex, 0) + PHONE_FINAL_INDEX_MAX_AHEAD)
|
|
41
|
+
const missingIndices = [...report.missingIndices]
|
|
42
|
+
for (let index = report.maxIndex + 1; index <= final; index++) missingIndices.push(index)
|
|
43
|
+
const expected = final + 1
|
|
44
|
+
return {
|
|
45
|
+
...report,
|
|
46
|
+
maxIndex: final,
|
|
47
|
+
expected,
|
|
48
|
+
missingIndices,
|
|
49
|
+
completeness: expected > 0 ? report.received / expected : 1,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Chunks in [0, phoneFinal] the Mac does not hold. */
|
|
54
|
+
export function chunksOutstanding(report: TranscriptGapReport | null, phoneFinal: number | null): number {
|
|
55
|
+
if (phoneFinal === null) return 0
|
|
56
|
+
const counted = withPhoneFinalIndex(report, phoneFinal)
|
|
57
|
+
if (!counted) return phoneFinal + 1
|
|
58
|
+
return counted.missingIndices.filter(index => index <= phoneFinal).length
|
|
59
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -75,6 +75,7 @@ import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
|
75
75
|
import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
|
|
76
76
|
import { liveMeetingCapability } from '../lib/live-provisional.js'
|
|
77
77
|
import { CUE_KINDS } from '../../shared/live-cues-v2.js'
|
|
78
|
+
import { lanFirstCapabilities, lanFirstHttpPort, lanRoutesField } from '../lib/lan-first.js'
|
|
78
79
|
|
|
79
80
|
/** 6.86.0: `capabilities.liveCues.kinds`, the cue kinds this server can write (additive; an older server omits it, which
|
|
80
81
|
* is how a client knows to expect only reinforce and challenge). Which kinds a given meeting gets is in its arm answer. */
|
|
@@ -547,6 +548,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
547
548
|
tasks: {
|
|
548
549
|
gate: taskBridgeAvailable() ? 'ready' : 'disabled',
|
|
549
550
|
},
|
|
551
|
+
// 6.88.0 LAN first (docs/lan-first.md): protocol flags only. This route is public, so
|
|
552
|
+
// the addresses themselves are published only on authenticated /api/models.
|
|
553
|
+
...lanFirstCapabilities({ token: process.env.COS_API_TOKEN ?? '', serverInstanceId: getServerInstanceId() }),
|
|
550
554
|
},
|
|
551
555
|
// /api/health is intentionally unauthenticated for setup diagnostics.
|
|
552
556
|
// Publish capability only; job counts, retention identities, subscriber
|
|
@@ -638,6 +642,10 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
638
642
|
// Key lanes (plan v4 3.1): `keyProfiles` only while a profile is active. Absent when off.
|
|
639
643
|
...keyProfilesModelsField(),
|
|
640
644
|
serverInstanceId: getServerInstanceId(),
|
|
645
|
+
// 6.88.0 LAN first (docs/lan-first.md): the addresses this Mac answers on, behind the
|
|
646
|
+
// token. Absent when the switch is off, the instance id is not minted yet, or the
|
|
647
|
+
// interface read failed; never a reason for this response to fail.
|
|
648
|
+
...lanRoutesModelsField(),
|
|
641
649
|
// Minted here rather than on a route of its own: this response is already
|
|
642
650
|
// authenticated, and the mint is stateless, so it costs nothing and stores
|
|
643
651
|
// nothing.
|
|
@@ -733,9 +741,16 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
733
741
|
tasks: {
|
|
734
742
|
gate: taskBridgeAvailable() ? 'ready' : 'disabled',
|
|
735
743
|
},
|
|
744
|
+
// 6.88.0 LAN first: the same helper as /api/health, so the two surfaces cannot disagree.
|
|
745
|
+
...lanFirstCapabilities({ token: apiToken, serverInstanceId: getServerInstanceId() }),
|
|
736
746
|
},
|
|
737
747
|
})
|
|
738
748
|
})
|
|
749
|
+
/** 6.88.0: `{ routes }` for /api/models, or nothing at all. lanRoutesField never throws. */
|
|
750
|
+
function lanRoutesModelsField(): { routes?: NonNullable<ReturnType<typeof lanRoutesField>> } {
|
|
751
|
+
const routes = lanRoutesField({ port: lanFirstHttpPort(), serverInstanceId: getServerInstanceId() })
|
|
752
|
+
return routes ? { routes } : {}
|
|
753
|
+
}
|
|
739
754
|
/** 6.78.0: `{ steer }` for /api/models `durableQueryJobs`, or nothing at all. */
|
|
740
755
|
export function steerCapabilityField(jobsEnabled: boolean): { steer?: { protocolVersion: 1; providers: ['claude'] } } {
|
|
741
756
|
const steer = messagesSteerCapability({ jobsEnabled, cliSupported: claudeSteerSupportKnown() })
|
package/server/routes/meeting.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { Router } from 'express'
|
|
|
12
12
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
13
13
|
import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
|
|
14
14
|
import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
|
|
15
|
+
import { chunksOutstanding, parsePhoneFinalChunkIndex, phoneFinalChunkIndex, withPhoneFinalIndex } from '../lib/transfer-integrity.js'
|
|
15
16
|
import {
|
|
16
17
|
AUTONOMOUS_FINALIZE_POLL_MS,
|
|
17
18
|
AUTONOMOUS_FINALIZE_SETTLE_MS,
|
|
@@ -1128,6 +1129,24 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1128
1129
|
if (MAC_CAPTURE_ID.test(sessionId)) {
|
|
1129
1130
|
requireMacRecordingForSave(sessionId, body?.expectedChunkCount)
|
|
1130
1131
|
}
|
|
1132
|
+
// 6.88.0 (lib/transfer-integrity.ts): how far a phone window said it captured, from its Stop or
|
|
1133
|
+
// from this save (glasses 6.10.652). The saved file's integrity counts against it.
|
|
1134
|
+
const phoneFinal = phoneFinalChunkIndex(stopIntents.get(sessionId)?.finalChunkIndex, parsePhoneFinalChunkIndex(body?.finalChunkIndex))
|
|
1135
|
+
// An origin-wipe recovery never finalizes a meeting a phone window still holds chunks for, unless
|
|
1136
|
+
// it says it waited them out. A save without `recovery` is never refused here.
|
|
1137
|
+
if (body?.recovery === 'origin_wipe' && body?.acceptMissing !== true) {
|
|
1138
|
+
const report = sessions.getIntegrity(sessionId)
|
|
1139
|
+
const outstanding = report ? chunksOutstanding(report, phoneFinal) : 0
|
|
1140
|
+
if (outstanding > 0) {
|
|
1141
|
+
respond(409, {
|
|
1142
|
+
error: 'A phone window captured chunks this Mac does not hold yet',
|
|
1143
|
+
reason: 'chunks_outstanding',
|
|
1144
|
+
missingCount: outstanding,
|
|
1145
|
+
finalChunkIndex: phoneFinal,
|
|
1146
|
+
})
|
|
1147
|
+
return null
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1131
1150
|
if (savingSessions.has(sessionId)) {
|
|
1132
1151
|
respond(409, { error: 'Meeting save already in progress', reason: 'save_in_progress' })
|
|
1133
1152
|
return null
|
|
@@ -1166,7 +1185,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1166
1185
|
const durationMs = durationFromTimeline > 0
|
|
1167
1186
|
? durationFromTimeline
|
|
1168
1187
|
: Math.max(0, Date.now() - startTime)
|
|
1169
|
-
const integrity = sessions.getIntegrity(sessionId)
|
|
1188
|
+
const integrity = withPhoneFinalIndex(sessions.getIntegrity(sessionId), phoneFinal)
|
|
1170
1189
|
const needsOperations = cosOpsPipelineConfigured() && !macRecording
|
|
1171
1190
|
// Standalone saves need a finalization pass too, for summary enrichment.
|
|
1172
1191
|
// Before 6.37 a standalone save with no audio nulled the job below and
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// 6.88.0 LAN first: the pre-token identity proof (docs/lan-first.md).
|
|
2
|
+
//
|
|
3
|
+
// GET /api/route-proof?nonce=<22-64 base64url chars> is PUBLIC (an exact GET door in
|
|
4
|
+
// api-auth.ts), because it exists to prove which Mac answers at an address BEFORE the phone
|
|
5
|
+
// sends its token there. Rules, each with its reason:
|
|
6
|
+
// - The nonce is validated strictly (charset and length) and never echoed or logged.
|
|
7
|
+
// - The message is fixed: "cos-route-proof:v1:" + nonce + ":" + serverInstanceId. The caller
|
|
8
|
+
// picks only the nonce, so this is never a general HMAC oracle, and the prefix keeps it
|
|
9
|
+
// apart from the display-stream ticket, the only other HMAC keyed by the token.
|
|
10
|
+
// - The response carries the instance id (already on public /api/health as
|
|
11
|
+
// server_instance_id) and the HMAC. Never the token, never an address, never a version.
|
|
12
|
+
// - Served only while lib/lan-first.ts `routeProofAvailable` holds: switch on, instance id
|
|
13
|
+
// minted, token at least 32 characters (a proof would let a stranger test guesses of a
|
|
14
|
+
// short token offline).
|
|
15
|
+
// - Rate limited per socket address (never req.ip or X-Forwarded-For) plus a global ceiling.
|
|
16
|
+
// - Writes nothing, so it takes no lease and is served during a drain.
|
|
17
|
+
|
|
18
|
+
import { Router, type Request, type Response } from 'express'
|
|
19
|
+
import {
|
|
20
|
+
ROUTE_PROOF_PROTOCOL,
|
|
21
|
+
RouteProofRateLimiter,
|
|
22
|
+
computeRouteProof,
|
|
23
|
+
isValidRouteProofNonce,
|
|
24
|
+
lanFirstEnabled,
|
|
25
|
+
routeProofTokenUsable,
|
|
26
|
+
} from '../lib/lan-first.js'
|
|
27
|
+
|
|
28
|
+
export type RouteProofReason = 'bad_nonce' | 'rate_limited' | 'not_ready' | 'disabled' | 'method_not_allowed'
|
|
29
|
+
|
|
30
|
+
export const ROUTE_PROOF_REASON_STATUS: Record<RouteProofReason, number> = {
|
|
31
|
+
bad_nonce: 400,
|
|
32
|
+
disabled: 404,
|
|
33
|
+
method_not_allowed: 405,
|
|
34
|
+
rate_limited: 429,
|
|
35
|
+
not_ready: 503,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RouteProofRouterOptions {
|
|
39
|
+
/** Read per request, so a token change is honoured without a remount. */
|
|
40
|
+
token: () => string
|
|
41
|
+
serverInstanceId: () => string | null
|
|
42
|
+
env?: () => NodeJS.ProcessEnv
|
|
43
|
+
limiter?: RouteProofRateLimiter
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function send(res: Response, reason: RouteProofReason, retryAfterSeconds?: number): void {
|
|
47
|
+
if (retryAfterSeconds != null) res.setHeader('Retry-After', String(retryAfterSeconds))
|
|
48
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
49
|
+
res.status(ROUTE_PROOF_REASON_STATUS[reason]).json({ v: ROUTE_PROOF_PROTOCOL, reason })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createRouteProofRouter(options: RouteProofRouterOptions): Router {
|
|
53
|
+
const router = Router()
|
|
54
|
+
const limiter = options.limiter ?? new RouteProofRateLimiter()
|
|
55
|
+
const env = options.env ?? (() => process.env)
|
|
56
|
+
|
|
57
|
+
router.all('/route-proof', (req: Request, res: Response) => {
|
|
58
|
+
// The public door in api-auth.ts is GET only, so any other method has already been
|
|
59
|
+
// refused without a token. With a token it is still refused here: one shape, one verb.
|
|
60
|
+
if (req.method !== 'GET') {
|
|
61
|
+
res.setHeader('Allow', 'GET')
|
|
62
|
+
return send(res, 'method_not_allowed')
|
|
63
|
+
}
|
|
64
|
+
if (!lanFirstEnabled(env())) return send(res, 'disabled')
|
|
65
|
+
// Counted before the nonce check, so malformed requests spend the same budget.
|
|
66
|
+
const admission = limiter.admit(req.socket?.remoteAddress)
|
|
67
|
+
if (!admission.ok) return send(res, 'rate_limited', admission.retryAfterSeconds)
|
|
68
|
+
const nonce = req.query.nonce
|
|
69
|
+
if (!isValidRouteProofNonce(nonce)) return send(res, 'bad_nonce')
|
|
70
|
+
let token = ''
|
|
71
|
+
try { token = options.token() } catch { token = '' }
|
|
72
|
+
const serverInstanceId = options.serverInstanceId()
|
|
73
|
+
if (!serverInstanceId || !routeProofTokenUsable(token)) return send(res, 'not_ready', 30)
|
|
74
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
75
|
+
res.json({
|
|
76
|
+
v: ROUTE_PROOF_PROTOCOL,
|
|
77
|
+
serverInstanceId,
|
|
78
|
+
proof: computeRouteProof(token, nonce, serverInstanceId),
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return router
|
|
83
|
+
}
|
|
@@ -117,6 +117,7 @@ import {
|
|
|
117
117
|
import { feedLiveCueTranscript } from '../lib/live-cues-engine.js'
|
|
118
118
|
import { armMacLiveCues, type MacLiveCuesAnswer } from '../lib/live-cues-v2.js'
|
|
119
119
|
import { liveProvisional } from '../lib/live-provisional.js'
|
|
120
|
+
import { chunkLevel, restoreChunkLevels, withChunkLevel, type ChunkLevel } from '../lib/chunk-levels.js'
|
|
120
121
|
import { preemptMetalBatchForLive, registerLiveActivityProbe } from '../lib/whisper-metal-gate.js'
|
|
121
122
|
import {
|
|
122
123
|
scheduleProgressiveHqCheckpoint,
|
|
@@ -348,6 +349,8 @@ interface TranscriptSession {
|
|
|
348
349
|
/** Exact replay records for silent/filtered ASR completions. */
|
|
349
350
|
emptyCompletions?: Record<string, EmptyTranscriptCompletion>
|
|
350
351
|
maxChunkIndex?: number
|
|
352
|
+
/** 6.87.0: two numbers per chunk (level 0 to 1, Silero speech seconds) for Control's level strip. Never text. */
|
|
353
|
+
chunkLevels?: ChunkLevel[]
|
|
351
354
|
/** Persisted idle-retention clock. Meeting date/duration still use startTime. */
|
|
352
355
|
lastActivityAt: number
|
|
353
356
|
// Count of consecutive vocab-echo (prompt-regurgitation) chunks. Reset to 0 by
|
|
@@ -713,6 +716,7 @@ function persistSessionRequired(sessionId: string): void {
|
|
|
713
716
|
emptyCompletions: session.emptyCompletions ?? {},
|
|
714
717
|
maxChunkIndex: session.maxChunkIndex ?? -1,
|
|
715
718
|
providerCandidates: session.providerCandidates ?? {},
|
|
719
|
+
chunkLevels: session.chunkLevels ?? [],
|
|
716
720
|
})
|
|
717
721
|
try {
|
|
718
722
|
durableAtomicWriteFileSync(filePath, data, { mode: 0o600 })
|
|
@@ -901,6 +905,7 @@ function recoverSessions(): void {
|
|
|
901
905
|
providerCandidates: data.providerCandidates && typeof data.providerCandidates === 'object'
|
|
902
906
|
? data.providerCandidates
|
|
903
907
|
: {},
|
|
908
|
+
chunkLevels: restoreChunkLevels(data.chunkLevels),
|
|
904
909
|
}
|
|
905
910
|
// Retroactively strip inline hallucinations from recovered chunks.
|
|
906
911
|
// Two-pass: (1) scan all chunks to build frequency/blocklist,
|
|
@@ -2439,6 +2444,8 @@ async function processStreamChunk(opts: {
|
|
|
2439
2444
|
const quiet = decideQuiet(audioBuffer, stt.quietGate)
|
|
2440
2445
|
const rms = quiet.rms
|
|
2441
2446
|
const isQuiet = quiet.isQuiet
|
|
2447
|
+
// 6.87.0: the level strip's two numbers, on the record every terminal path below persists. Not text, never logged.
|
|
2448
|
+
session.chunkLevels = withChunkLevel(session.chunkLevels, chunkLevel(chunkIndex, rms, quiet.speechSec))
|
|
2442
2449
|
const whisperAudio = isQuiet ? audioBuffer : await asrFrontEnd(audioBuffer, stt.frontEnd)
|
|
2443
2450
|
const whisperContext = recentWhisperContext(sessionId, session)
|
|
2444
2451
|
console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
|