@gotcos/glasses-server 6.40.2 → 6.42.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 +21 -3
- package/CHANGELOG.md +101 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/server/lib/api-auth.ts +18 -2
- package/server/lib/claude-bridge.ts +0 -1
- package/server/lib/claude-permissions.ts +9 -3
- package/server/lib/claude-tool-access.ts +14 -4
- package/server/lib/display-ticket.ts +99 -0
- package/server/lib/recovery-activity.ts +42 -1
- package/server/routes/display.ts +209 -6
- package/server/routes/health.ts +39 -0
- package/server/routes/query.ts +7 -3
package/.env.example
CHANGED
|
@@ -14,9 +14,27 @@ BIND_HOST=0.0.0.0
|
|
|
14
14
|
# PORT=3141
|
|
15
15
|
|
|
16
16
|
# ── AUTH ────────────────────────────────────────────────────────────────
|
|
17
|
-
# A token is
|
|
18
|
-
#
|
|
19
|
-
#
|
|
17
|
+
# A token is required on every /api call EXCEPT six paths. Four are public because
|
|
18
|
+
# a client needs them BEFORE it holds a usable token; two are capability URLs
|
|
19
|
+
# because the browser API that fetches them cannot send a header at all:
|
|
20
|
+
#
|
|
21
|
+
# GET /api/health server availability and capability advertisement
|
|
22
|
+
# GET /api/diag/health recovery diagnostics
|
|
23
|
+
# POST /api/diag/client client-side crash/diagnostic upload
|
|
24
|
+
# GET /api/display-stream the glasses display bus. EventSource cannot set
|
|
25
|
+
# headers, so this returns 200 to anyone — but a
|
|
26
|
+
# subscriber without a token gets a live transport and
|
|
27
|
+
# lifecycle markers ONLY, never transcripts or answers.
|
|
28
|
+
# GET /api/display-stream/<expiry>.<hmac> short-lived capability URLs, each
|
|
29
|
+
# GET /api/tts/play/<uuid> minted by an AUTHENTICATED call, for
|
|
30
|
+
# EventSource and for native audio players respectively
|
|
31
|
+
# — neither can send a header. On the display stream the
|
|
32
|
+
# X-Cos-Token header is accepted as an equivalent, so any
|
|
33
|
+
# caller that CAN send one should; the TTS UUID also names
|
|
34
|
+
# which audio to play, so it is not interchangeable.
|
|
35
|
+
#
|
|
36
|
+
# Leave UNSET and the server prints an auto-generated token at boot — paste it into
|
|
37
|
+
# the phone app. Set a fixed value here for a stable token across restarts.
|
|
20
38
|
# COS_API_TOKEN=pick-any-long-random-string
|
|
21
39
|
|
|
22
40
|
# Optional durable image-store location. Defaults to
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,104 @@
|
|
|
1
|
+
## 6.42.0
|
|
2
|
+
|
|
3
|
+
The display stream stops broadcasting your meetings to the local network.
|
|
4
|
+
|
|
5
|
+
`GET /api/display-stream` has been public since it was written, because
|
|
6
|
+
EventSource cannot attach an `X-Cos-Token` header. That is still true, so the
|
|
7
|
+
route is still public and still returns 200 to everyone — but what it SENDS is
|
|
8
|
+
now decided per event. A subscriber that proves it holds the pairing token gets
|
|
9
|
+
the whole bus. A subscriber that does not gets a live transport, the handshake,
|
|
10
|
+
the keepalive, replay-gap notices, and one projected lifecycle marker. It never
|
|
11
|
+
receives a transcript, an answer, a coaching cue, a tool status or an error.
|
|
12
|
+
|
|
13
|
+
**COS Glasses app 6.8.441 is required to restore full content delivery.** Older
|
|
14
|
+
builds keep working — that is the entire reason the connection is not rejected —
|
|
15
|
+
but they connect without a capability, so they will see the content-suppressed
|
|
16
|
+
stream: no live transcript on the lens and no streamed answers, while meeting
|
|
17
|
+
capture, saving, and offline sync continue normally. 6.8.441 is the first app
|
|
18
|
+
build that fetches a ticket and reconnects on `contentAuthorized: false`. Nothing
|
|
19
|
+
else states this, so state it here: update the app with the server.
|
|
20
|
+
|
|
21
|
+
Two ways to prove you hold the token:
|
|
22
|
+
|
|
23
|
+
- `X-Cos-Token` on the request, for every fetch-based consumer (COS Control, the
|
|
24
|
+
companion's own connection probe, curl). Preferred wherever it is possible.
|
|
25
|
+
- `GET /api/display-stream/<expiry>.<hmac>` for EventSource, which cannot send a
|
|
26
|
+
header. The ticket is an HMAC over its own expiry keyed on the pairing token,
|
|
27
|
+
so it is stateless, unforgeable without the key, and invalidated the instant
|
|
28
|
+
the token rotates. `GET /api/models` mints one; `GET /api/health` advertises the
|
|
29
|
+
capability so a client can detect an older server by its absence.
|
|
30
|
+
|
|
31
|
+
An invalid or expired ticket is never rejected. It degrades to the ticketless
|
|
32
|
+
stream, so a client whose ticket died mid-reconnect keeps its transport instead
|
|
33
|
+
of entering a retry loop — and the `ready` frame now carries
|
|
34
|
+
`contentAuthorized: <boolean>` so it can tell the difference and re-mint. Without
|
|
35
|
+
that field a degraded stream is indistinguishable from a healthy one.
|
|
36
|
+
|
|
37
|
+
The ticket TTL is 15 minutes, chosen by the platform rather than by taste: a
|
|
38
|
+
backgrounded Even Hub WebView is suspended and cannot re-mint, and the Even Hub
|
|
39
|
+
review loop locks the phone for five minutes. A 2-minute TTL was guaranteed to
|
|
40
|
+
be dead on the reviewer's exact path. Minting requires the pairing token, so the
|
|
41
|
+
TTL only bounds the value of a ticket that leaks out of a URL.
|
|
42
|
+
|
|
43
|
+
Also in this release:
|
|
44
|
+
|
|
45
|
+
- `recording_stop` reaches ticketless subscribers as a PROJECTION carrying only
|
|
46
|
+
`sessionId`. The production payload also carries `filename`, which embeds the
|
|
47
|
+
transcript-derived meeting title (`..._Q3_Budget_Cuts_Layoffs_....md`), plus
|
|
48
|
+
duration and business domain. An allowlist of event TYPES passed the whole
|
|
49
|
+
event through and would have broadcast the title of every meeting.
|
|
50
|
+
- `replay_gap` reaches ticketless subscribers too. It carries only a reason, the
|
|
51
|
+
cursor the client itself sent, the watermark already in `ready`, and a buffer
|
|
52
|
+
boundary, and withholding it strands a client on a dead cursor after a server
|
|
53
|
+
restart. The buffer it describes is still never served without a ticket.
|
|
54
|
+
- The ticketed path is exempt from the recovery lease classifier. It matched by
|
|
55
|
+
exact string, so the new path segment classified as a leased request — and an
|
|
56
|
+
SSE lease never settles, so a connected lens would have held the recovery gate
|
|
57
|
+
open and 409'd every COS Control server restart. (In this build the admission
|
|
58
|
+
middleware is exported but not mounted, so that was latent, not live; the
|
|
59
|
+
classification is a contract a build that mounts it would inherit.) Lease kinds
|
|
60
|
+
are also redacted now, so `GET /api/recovery/status` can never republish a live
|
|
61
|
+
display ticket or TTS capability as plain text.
|
|
62
|
+
- `mintDisplayTicket` throws on an empty token instead of returning a ticket that
|
|
63
|
+
can never verify, and `/api/models` omits the field rather than advertising a
|
|
64
|
+
dead capability.
|
|
65
|
+
- Ticketless connects are summarized once a minute with a count instead of logged
|
|
66
|
+
per connect. `retry: 3000` means one stale client would otherwise write ~1,200
|
|
67
|
+
lines an hour into the launchd log.
|
|
68
|
+
- `.env.example` said a token is required on every `/api` call. It is not, and it
|
|
69
|
+
never was: four paths are public and two are capability URLs. That file ships in
|
|
70
|
+
the npm tarball.
|
|
71
|
+
|
|
72
|
+
## 6.41.0
|
|
73
|
+
|
|
74
|
+
Allowlist mode can finally read the workspace it was pointed at.
|
|
75
|
+
|
|
76
|
+
A public user set `COS_CLAUDE_TRUST_MODE=allowlist` (the security-conscious
|
|
77
|
+
choice, tried first) and got "I don't have access to your workspace files."
|
|
78
|
+
That was the code working as written: in allowlist mode the per-query tool
|
|
79
|
+
list is the entire tool universe, and it was built as WebSearch + WebFetch,
|
|
80
|
+
with Read added only when the query carried a photo. The CLI's working
|
|
81
|
+
directory correctly pointed at the user's workspace; nothing in the list
|
|
82
|
+
could open a file in it.
|
|
83
|
+
|
|
84
|
+
Three changes, one release:
|
|
85
|
+
|
|
86
|
+
- The base tool list now always includes `Read`, `Glob`, and `Grep` — the
|
|
87
|
+
read-only exploration trio. Allowlist mode becomes "web plus read-only
|
|
88
|
+
workspace": shell, edits, and writes remain impossible by construction,
|
|
89
|
+
and a new test pins that Bash/Edit/Write can never appear in this list.
|
|
90
|
+
In trusted mode (the default) the list is only an auto-approve hint, so
|
|
91
|
+
behavior there is unchanged.
|
|
92
|
+
- The allowlist capability prompt now affirms workspace readability — but
|
|
93
|
+
only when the list actually grants it, so the prompt can never over-claim.
|
|
94
|
+
Without the affirmation, a model told it is "genuinely limited" tends to
|
|
95
|
+
refuse reads it has.
|
|
96
|
+
- The one listless spawn (prewarm) no longer passes an empty `--tools` in
|
|
97
|
+
allowlist mode. On the current CLI that flag triggers a spurious context
|
|
98
|
+
compaction and a synthetic 400 (the same pathology isolated by the fork
|
|
99
|
+
bisection on 2026-08-26); denial now rides `--permission-mode dontAsk`
|
|
100
|
+
plus an empty `--allowedTools` alone.
|
|
101
|
+
|
|
1
102
|
## 6.40.2
|
|
2
103
|
|
|
3
104
|
A finished answer could leave the stream open forever.
|
package/README.md
CHANGED
|
@@ -87,8 +87,12 @@ without silently losing completed replies.
|
|
|
87
87
|
> has those tools, so you will see it use them more readily than before.
|
|
88
88
|
> Set `COS_CLAUDE_TRUST_MODE=allowlist` to remove Claude's permission bypass
|
|
89
89
|
> and restrict it to COS's explicit per-query tool allowlist; undeclared tools
|
|
90
|
-
> then fail closed without prompting.
|
|
91
|
-
>
|
|
90
|
+
> then fail closed without prompting. In allowlist mode the query keeps web
|
|
91
|
+
> search/fetch and **read-only workspace access** (Read, Glob, Grep) — no
|
|
92
|
+
> shell, no edits, no writes. Only the exact value `allowlist` restricts
|
|
93
|
+
> anything — any other value logs a warning and stays trusted. Servers before
|
|
94
|
+
> 6.41.0 denied ALL workspace reads in allowlist mode; if a hardened install
|
|
95
|
+
> answers "I don't have access to your workspace files", update the server.
|
|
92
96
|
|
|
93
97
|
## Connect your phone (the one gotcha)
|
|
94
98
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.42.0",
|
|
4
4
|
"description": "COS Glasses \u2014 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": {
|
package/server/lib/api-auth.ts
CHANGED
|
@@ -16,10 +16,26 @@ const PUBLIC_API_PATHS = new Set([
|
|
|
16
16
|
// path segment, and no query-token fallback that could leak into URL logs.
|
|
17
17
|
const TTS_PLAYBACK_CAPABILITY_PATH = /^\/tts\/play\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
18
18
|
|
|
19
|
+
// EventSource has the identical constraint, so the display stream reuses the same
|
|
20
|
+
// shape: `/display-stream/<expUnixSeconds>.<hex hmac>`, GET/HEAD only, path segment,
|
|
21
|
+
// no query-token fallback. Admission here is SHAPE ONLY — the signature is verified
|
|
22
|
+
// in the route, which is the only place that holds the API token.
|
|
23
|
+
//
|
|
24
|
+
// `/display-stream` itself STAYS PUBLIC, deliberately. Removing it would make a
|
|
25
|
+
// ticketless connect fall through to the token check and 401, and neither EventSource
|
|
26
|
+
// in the client can send a header — every installed build would enter a permanent
|
|
27
|
+
// reconnect loop and, because `displayBusConnected` would never turn true, would also
|
|
28
|
+
// stop syncing already-recorded offline meetings. Confidentiality is enforced by
|
|
29
|
+
// withholding CONTENT in the route, not by rejecting the connection. The route also
|
|
30
|
+
// accepts a valid X-Cos-Token header as equivalent authorization, for the fetch-based
|
|
31
|
+
// callers that can send one; the ticket exists only for the ones that cannot.
|
|
32
|
+
const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64}$/
|
|
33
|
+
|
|
19
34
|
export function isPublicApiRequest(method: string, path: string): boolean {
|
|
20
35
|
if (PUBLIC_API_PATHS.has(path)) return true
|
|
21
|
-
|
|
22
|
-
|
|
36
|
+
if (method !== 'GET' && method !== 'HEAD') return false
|
|
37
|
+
return TTS_PLAYBACK_CAPABILITY_PATH.test(path)
|
|
38
|
+
|| DISPLAY_STREAM_CAPABILITY_PATH.test(path)
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
/** Global /api authentication boundary. Mount before all body parsers. */
|
|
@@ -45,10 +45,16 @@ export function claudePermissionArgs(
|
|
|
45
45
|
: ['--dangerously-skip-permissions', '--allowedTools', allowedTools]
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// No per-query list (the prewarm spawn): deny everything via dontAsk plus an
|
|
49
|
+
// empty allowedTools, but never pass an empty --tools — on the current CLI
|
|
50
|
+
// an empty --tools triggers a spurious context compaction and a synthetic
|
|
51
|
+
// 400 (single-flag bisection 2026-08-26, documented in fork-thread.ts).
|
|
52
|
+
if (allowedTools === null) {
|
|
53
|
+
return ['--permission-mode', 'dontAsk', '--allowedTools', '']
|
|
54
|
+
}
|
|
49
55
|
return [
|
|
50
56
|
'--permission-mode', 'dontAsk',
|
|
51
|
-
'--tools',
|
|
52
|
-
'--allowedTools',
|
|
57
|
+
'--tools', allowedTools,
|
|
58
|
+
'--allowedTools', allowedTools,
|
|
53
59
|
]
|
|
54
60
|
}
|
|
@@ -89,12 +89,15 @@ export function reportClaudeExtraToolConfiguration(
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
export function buildClaudeToolList(input: {
|
|
92
|
-
includeRead?: boolean
|
|
93
92
|
publisherTool?: string
|
|
94
93
|
env?: NodeJS.ProcessEnv
|
|
95
94
|
} = {}): string[] {
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
// Read-only workspace tools are unconditional: in allowlist mode this list
|
|
96
|
+
// is the entire tool universe, and without them a hardened install cannot
|
|
97
|
+
// read the workspace the glasses are pointed at (field report 2026-08-27).
|
|
98
|
+
// Shell/Edit/Write stay excluded, so allowlist keeps its no-side-effects
|
|
99
|
+
// property. In trusted mode the list is only an auto-approve hint.
|
|
100
|
+
const tools = ['WebSearch', 'WebFetch', 'Read', 'Glob', 'Grep']
|
|
98
101
|
tools.push(...configuredClaudeExtraTools(input.env))
|
|
99
102
|
if (input.publisherTool) tools.push(input.publisherTool)
|
|
100
103
|
return [...new Set(tools)]
|
|
@@ -186,8 +189,15 @@ export function claudeToolCapabilityPrompt(
|
|
|
186
189
|
const honesty = TOOL_HONESTY_CLAUSE
|
|
187
190
|
|
|
188
191
|
if (mode === 'allowlist') {
|
|
192
|
+
// Affirm workspace readability ONLY when the list actually grants it —
|
|
193
|
+
// this prompt is something the session trusts, and promising reads a
|
|
194
|
+
// caller did not include would recreate the 2026-07-28 class of header
|
|
195
|
+
// mismatch in the opposite direction.
|
|
196
|
+
const workspaceLine = tools.includes('Read')
|
|
197
|
+
? `\nYour working directory is the user's COS workspace: Read, Glob, and Grep are in the list so you can search and read its files. The restriction here is on shell, writes, and undeclared tools — not on reading the workspace. Never refuse a workspace read in this mode.`
|
|
198
|
+
: ''
|
|
189
199
|
return `TOOL CAPABILITY CONTRACT:
|
|
190
|
-
This request runs in RESTRICTED allowlist mode and is genuinely limited to these tool selectors: ${list}. Undeclared tools are denied without prompting, so a call outside this list will fail
|
|
200
|
+
This request runs in RESTRICTED allowlist mode and is genuinely limited to these tool selectors: ${list}. Undeclared tools are denied without prompting, so a call outside this list will fail.${workspaceLine}
|
|
191
201
|
Selectors are permissions, not proof that a connector is online. Use a tool only when it is actually present in this session. If the user asks for a tool or connector that is absent, or a tool call fails, say that it is unavailable. ${honesty}
|
|
192
202
|
${UNTRUSTED_CONTENT_CLAUSE}`
|
|
193
203
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto'
|
|
2
|
+
import { timingSafeTokenEqual } from './token-auth.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Short-lived capability for GET /api/display-stream.
|
|
6
|
+
*
|
|
7
|
+
* WHY A CAPABILITY AT ALL. `EventSource` cannot attach `X-Cos-Token` — the same
|
|
8
|
+
* constraint native HTML audio has, which is why `api-auth.ts` already carries a
|
|
9
|
+
* TTS playback capability. This reuses that reviewed shape rather than inventing
|
|
10
|
+
* a second one: **a path segment, GET/HEAD only, and no query-token fallback that
|
|
11
|
+
* could leak into URL logs.**
|
|
12
|
+
*
|
|
13
|
+
* WHY STATELESS. An HMAC over the expiry needs no store, so there is no eviction
|
|
14
|
+
* policy to get wrong, no unbounded Map, and no timer to leak. It also invalidates
|
|
15
|
+
* every outstanding ticket the moment the pairing token rotates, which a UUID store
|
|
16
|
+
* would not. The TTS capability needs a store because its UUID carries no claims;
|
|
17
|
+
* this one carries its own expiry.
|
|
18
|
+
*
|
|
19
|
+
* WHY REPLAY WITHIN THE TTL IS FINE. Minting requires the pairing token, so an
|
|
20
|
+
* attacker who could mint already has full API access. The TTL bounds the value of
|
|
21
|
+
* a ticket that leaks out of a URL, which is the actual threat. Single-use would be
|
|
22
|
+
* strictly worse: the server sends `retry: 3000`, so a browser-initiated reconnect
|
|
23
|
+
* replays the same URL, and a consumed ticket would turn every transport blip into a
|
|
24
|
+
* hard failure.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 15 minutes, set by the PLATFORM, not by taste.
|
|
29
|
+
*
|
|
30
|
+
* The first draft used 120s, which is exactly the Even Hub reviewer's boundary: the
|
|
31
|
+
* review rubric checks a two-minute idle, and the pre-submission loop locks the phone
|
|
32
|
+
* for five. A backgrounded Even Hub WebView is SUSPENDED — no timer, no fetch, no
|
|
33
|
+
* re-mint — so a 120s ticket is guaranteed to be dead on the exact path a reviewer
|
|
34
|
+
* exercises, and the app would come back to a content-suppressed stream.
|
|
35
|
+
*
|
|
36
|
+
* The TTL is not what protects the stream. MINTING requires the pairing token, so
|
|
37
|
+
* anyone who can mint already has full API access; the TTL only bounds the value of
|
|
38
|
+
* a ticket that leaks out of a URL (proxy log, screen share, shoulder surf). 15
|
|
39
|
+
* minutes keeps that window small while surviving every suspension a phone actually
|
|
40
|
+
* imposes.
|
|
41
|
+
*/
|
|
42
|
+
export const DISPLAY_TICKET_TTL_SECONDS = 900
|
|
43
|
+
|
|
44
|
+
/** Domain separation. Without a purpose string, any future feature that HMACs an
|
|
45
|
+
* integer under the same key would mint cross-usable display tickets. */
|
|
46
|
+
const TICKET_PURPOSE = 'display-stream'
|
|
47
|
+
|
|
48
|
+
function signature(apiToken: string, expSeconds: number): string {
|
|
49
|
+
return createHmac('sha256', apiToken)
|
|
50
|
+
.update(`${TICKET_PURPOSE}:${expSeconds}`)
|
|
51
|
+
.digest('hex')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `<expUnixSeconds>.<hex sha256 hmac>` — safe in a path segment, no encoding needed.
|
|
56
|
+
*
|
|
57
|
+
* THROWS on an empty token rather than returning a ticket. An HMAC keyed on '' is a
|
|
58
|
+
* well-formed string that `verifyDisplayTicket` rejects unconditionally (it fails
|
|
59
|
+
* closed on `!apiToken`), so a silent mint would publish a capability that can never
|
|
60
|
+
* be redeemed — the caller would advertise `ticketSupported` and hand out a value
|
|
61
|
+
* that is dead on arrival. Callers must decide what to do without a token; they may
|
|
62
|
+
* not be handed a placeholder.
|
|
63
|
+
*/
|
|
64
|
+
export function mintDisplayTicket(
|
|
65
|
+
apiToken: string,
|
|
66
|
+
nowMs: number = Date.now(),
|
|
67
|
+
): string {
|
|
68
|
+
if (!apiToken) {
|
|
69
|
+
throw new Error('mintDisplayTicket requires a non-empty API token — a ticket minted without one can never verify')
|
|
70
|
+
}
|
|
71
|
+
const exp = Math.floor(nowMs / 1000) + DISPLAY_TICKET_TTL_SECONDS
|
|
72
|
+
return `${exp}.${signature(apiToken, exp)}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* True only for a well-formed, unexpired, correctly-signed ticket.
|
|
77
|
+
*
|
|
78
|
+
* Fails CLOSED on every malformed input. The expiry is parsed from the ticket
|
|
79
|
+
* itself, so a forged expiry changes the signed message and fails the HMAC — the
|
|
80
|
+
* claim cannot be edited without the key.
|
|
81
|
+
*/
|
|
82
|
+
export function verifyDisplayTicket(
|
|
83
|
+
apiToken: string,
|
|
84
|
+
ticket: unknown,
|
|
85
|
+
nowMs: number = Date.now(),
|
|
86
|
+
): boolean {
|
|
87
|
+
if (typeof ticket !== 'string' || !apiToken) return false
|
|
88
|
+
const separator = ticket.indexOf('.')
|
|
89
|
+
if (separator <= 0) return false
|
|
90
|
+
const expRaw = ticket.slice(0, separator)
|
|
91
|
+
const provided = ticket.slice(separator + 1)
|
|
92
|
+
if (!/^\d{1,15}$/.test(expRaw) || !/^[0-9a-f]{64}$/.test(provided)) return false
|
|
93
|
+
const exp = Number(expRaw)
|
|
94
|
+
if (!Number.isSafeInteger(exp)) return false
|
|
95
|
+
// Expiry is checked BEFORE the compare so a stale ticket cannot be probed for
|
|
96
|
+
// signature validity, and so the common rejection costs no hashing.
|
|
97
|
+
if (Math.floor(nowMs / 1000) >= exp) return false
|
|
98
|
+
return timingSafeTokenEqual(provided, signature(apiToken, exp))
|
|
99
|
+
}
|
|
@@ -29,6 +29,40 @@ const EXEMPT_EXACT = new Set([
|
|
|
29
29
|
'POST /api/recovery/server/restart',
|
|
30
30
|
])
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Exempt by PREFIX, because the display stream grew a path segment.
|
|
34
|
+
*
|
|
35
|
+
* EXEMPT_EXACT matches whole strings, so `GET /api/display-stream/<exp>.<hmac>`
|
|
36
|
+
* (6.42.0's ticketed form) fell through to 'request' and would take a lease. An SSE
|
|
37
|
+
* connection has no `finish`, and `close` only converts the lease to a 120s grace
|
|
38
|
+
* window that a live socket never reaches — so one connected lens would hold the
|
|
39
|
+
* recovery gate open indefinitely and 409 every COS Control server restart. A
|
|
40
|
+
* long-lived stream is the exact shape that must never take a lease.
|
|
41
|
+
*
|
|
42
|
+
* SCOPE, stated honestly: in THIS build `recoveryAdmissionMiddleware` is exported
|
|
43
|
+
* but never mounted (`grep -rn "recovery-activity" server` shows routes/recovery.ts
|
|
44
|
+
* importing only acquireMaintenance and getRecoveryActivityStatus), so no lease is
|
|
45
|
+
* taken for any route and the 409 above is latent rather than live. The
|
|
46
|
+
* classification is still a published contract that a build mounting the middleware
|
|
47
|
+
* would inherit, so it is fixed here rather than left as a trap.
|
|
48
|
+
*/
|
|
49
|
+
const EXEMPT_GET_PREFIXES = ['/api/display-stream/']
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A capability URL is a live bearer credential. GET /api/recovery/status publishes
|
|
53
|
+
* every lease `kind` verbatim, and that route is reachable by anyone who can already
|
|
54
|
+
* read status — so a kind built from the raw path would republish a working display
|
|
55
|
+
* ticket (or a TTS playback capability) as plain text in a diagnostic response.
|
|
56
|
+
*
|
|
57
|
+
* Redaction happens where the kind is BUILT, not where it is read, so no future
|
|
58
|
+
* reader of `active` can reintroduce the leak.
|
|
59
|
+
*/
|
|
60
|
+
export function redactCapabilityPath(path: string): string {
|
|
61
|
+
return path
|
|
62
|
+
.replace(/^(\/api\/display-stream)\/[^/]+$/, '$1/<ticket>')
|
|
63
|
+
.replace(/^(\/api\/tts\/play)\/[^/]+$/, '$1/<capability>')
|
|
64
|
+
}
|
|
65
|
+
|
|
32
66
|
const OPERATION_GET_PREFIXES = [
|
|
33
67
|
'/api/models',
|
|
34
68
|
'/v1/models',
|
|
@@ -53,6 +87,10 @@ export function classifyRecoveryRoute(method: string, path: string): RecoveryRou
|
|
|
53
87
|
const verb = method.toUpperCase()
|
|
54
88
|
const normalized = path.split('?')[0]
|
|
55
89
|
if (EXEMPT_EXACT.has(`${verb} ${normalized}`)) return 'exempt'
|
|
90
|
+
if ((verb === 'GET' || verb === 'HEAD')
|
|
91
|
+
&& EXEMPT_GET_PREFIXES.some(prefix => normalized.startsWith(prefix))) {
|
|
92
|
+
return 'exempt'
|
|
93
|
+
}
|
|
56
94
|
if (!normalized.startsWith('/api/') && !normalized.startsWith('/v1/')) return 'exempt'
|
|
57
95
|
if (verb !== 'GET' && verb !== 'HEAD') return 'operation'
|
|
58
96
|
if (OPERATION_GET_PREFIXES.some(prefix => normalized === prefix || normalized.startsWith(prefix))) {
|
|
@@ -144,7 +182,10 @@ export function recoveryAdmissionMiddleware(req: Request, res: Response, next: N
|
|
|
144
182
|
}
|
|
145
183
|
|
|
146
184
|
const id = `${routeClass}:${++sequence}`
|
|
147
|
-
const release = createLease(
|
|
185
|
+
const release = createLease(
|
|
186
|
+
`${req.method} ${redactCapabilityPath((req.originalUrl || req.path).split('?')[0])}`,
|
|
187
|
+
id,
|
|
188
|
+
)
|
|
148
189
|
res.once('finish', release)
|
|
149
190
|
res.once('close', () => {
|
|
150
191
|
const lease = active.get(id)
|
package/server/routes/display.ts
CHANGED
|
@@ -2,30 +2,181 @@
|
|
|
2
2
|
// Any connected glasses client receives real-time query responses
|
|
3
3
|
// regardless of which interface submitted the query
|
|
4
4
|
|
|
5
|
-
import { Router, type Response } from 'express'
|
|
5
|
+
import { Router, type Request, type Response } from 'express'
|
|
6
6
|
import {
|
|
7
7
|
emitDisplay,
|
|
8
8
|
getDisplayWatermark,
|
|
9
9
|
onDisplay,
|
|
10
10
|
replayDisplayEvents,
|
|
11
|
+
type DisplayEvent,
|
|
11
12
|
type PublishedDisplayEvent,
|
|
12
13
|
} from '../lib/display-bus.js'
|
|
14
|
+
import { verifyDisplayTicket } from '../lib/display-ticket.js'
|
|
15
|
+
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
13
16
|
|
|
14
17
|
export const displayRouter = Router()
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* What a TICKETLESS subscriber may receive, as a per-type PROJECTION.
|
|
21
|
+
*
|
|
22
|
+
* ALLOWLIST, NEVER A DENYLIST. A new member of the DisplayEvent union is withheld
|
|
23
|
+
* by default, so adding an event type can never silently widen the unauthenticated
|
|
24
|
+
* surface.
|
|
25
|
+
*
|
|
26
|
+
* A projection, not a pass-through, because an allowlisted TYPE can still carry
|
|
27
|
+
* content in its payload — which is exactly the bug the first cut of this shipped.
|
|
28
|
+
* The projection is the contract: whatever the emitter grows later, only the fields
|
|
29
|
+
* named here can ever leave.
|
|
30
|
+
*
|
|
31
|
+
* The other ten members of the union all carry user content: `transcript_chunk` and
|
|
32
|
+
* `prompt_transcript` carry meeting speech with speaker labels, `chunk`/`done` carry
|
|
33
|
+
* answer text, `session_restore` carries conversation state, `coaching_nudge` carries
|
|
34
|
+
* derived guidance, `start` carries session metadata, `tool_status` carries a message,
|
|
35
|
+
* `error` carries error text, and `recording_start` is covered by its own note below.
|
|
36
|
+
*
|
|
37
|
+
* NOT ROUTED THROUGH HERE: `keepalive` is an SSE comment (`: keepalive`), not an
|
|
38
|
+
* event. `ready` and `replay_gap` are written directly by the handler below — both
|
|
39
|
+
* are transport metadata with no user content, and `replay_gap` is deliberately
|
|
40
|
+
* ticketless-visible (see the handler).
|
|
41
|
+
*/
|
|
42
|
+
const TICKETLESS_PROJECTIONS: {
|
|
43
|
+
readonly [K in DisplayEvent['type']]?: (data: Record<string, unknown>) => Record<string, unknown>
|
|
44
|
+
} = {
|
|
45
|
+
/**
|
|
46
|
+
* `recording_stop` is a lifecycle marker: the lens uses it to clear a stale
|
|
47
|
+
* "recording" indicator that would otherwise persist forever.
|
|
48
|
+
*
|
|
49
|
+
* BUT THE PRODUCTION PAYLOAD IS NOT BARE. Both emitters (routes/meeting.ts, the
|
|
50
|
+
* durable save path and the orphan-recovery path) send
|
|
51
|
+
* `{ sessionId, filename, durationMin, domain }`, and `filename` is built by
|
|
52
|
+
* meeting-store.ts `filenameStem()` from the transcript-derived meeting TITLE —
|
|
53
|
+
* `2026-08-31_Q3_Budget_Cuts_Layoffs_1a2b3c4d.md`. Passing the event through whole
|
|
54
|
+
* broadcast the meeting title, its duration and its business domain to every
|
|
55
|
+
* unauthenticated listener on the LAN, which is precisely what a ticket exists to
|
|
56
|
+
* withhold.
|
|
57
|
+
*
|
|
58
|
+
* Only `sessionId` survives. It is the id the CLIENT supplied when it started the
|
|
59
|
+
* capture (meeting-store `normalizeSessionId` constrains it to
|
|
60
|
+
* `[A-Za-z0-9:_-]{3,96}` and the server never derives it from the transcript), so
|
|
61
|
+
* it is the one field the subscriber already holds and the one the lens needs to
|
|
62
|
+
* match the marker to its own indicator.
|
|
63
|
+
*/
|
|
64
|
+
// `durationMin` rides along because the lens renders `Meeting saved — ${n}m`
|
|
65
|
+
// straight from this frame (glasses-entry.ts). Projecting it away left a valid
|
|
66
|
+
// JSON payload — so the client's catch never fired — and painted
|
|
67
|
+
// "Meeting saved — undefinedm". A duration is a scalar with no transcript in it;
|
|
68
|
+
// `filename` stays stripped precisely because it embeds the meeting TITLE.
|
|
69
|
+
recording_stop: data => ({ sessionId: data.sessionId, durationMin: data.durationMin }),
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `recording_start` is NOT here, as a DECISION rather than an omission.
|
|
73
|
+
*
|
|
74
|
+
* Three reasons, in order of weight:
|
|
75
|
+
* 1. The failure modes are asymmetric. A missing `stop` leaves the lens asserting
|
|
76
|
+
* something FALSE — a recording indicator burning with no recording behind it.
|
|
77
|
+
* A missing `start` leaves it showing nothing, which is exactly what the
|
|
78
|
+
* ticketless contract promises anyway. Only the wrong state needs repairing.
|
|
79
|
+
* 2. `start` is a live presence signal. "A meeting is beginning on this machine,
|
|
80
|
+
* right now" is occupancy intelligence for any listener on the network; `stop`
|
|
81
|
+
* is the erasure of a signal already shown.
|
|
82
|
+
* 3. Nothing in this server emits it. `grep -rn "type: 'recording_start'" server`
|
|
83
|
+
* returns no emitter as of 6.42.0 — it exists in the union for client-side
|
|
84
|
+
* use — so allowlisting it would advertise a path that never runs.
|
|
85
|
+
*
|
|
86
|
+
* If a server-side emitter is ever added, revisit this WITH a projection; do not
|
|
87
|
+
* simply add the key.
|
|
88
|
+
*/
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The event a ticketless subscriber may receive, or null when it may receive none.
|
|
93
|
+
* Never returns the input event unchanged — the projection is always applied.
|
|
94
|
+
*/
|
|
95
|
+
function projectForTicketless(event: PublishedDisplayEvent): PublishedDisplayEvent | null {
|
|
96
|
+
const project = TICKETLESS_PROJECTIONS[event.type]
|
|
97
|
+
if (!project) return null
|
|
98
|
+
return { ...event, data: project(event.data) }
|
|
99
|
+
}
|
|
100
|
+
|
|
16
101
|
function writeEvent(res: Response, event: PublishedDisplayEvent): void {
|
|
17
102
|
const data = JSON.stringify({
|
|
18
103
|
...event.data,
|
|
104
|
+
// NESTED, and it must stay nested. Every shipped client reads
|
|
105
|
+
// `parsed._cosDisplayCursor` and RETURNS EARLY when it is absent
|
|
106
|
+
// (Main.ts, identically in 6.8.353 and 6.8.441), so flattening these three
|
|
107
|
+
// fields to the top level silently freezes the client cursor at its
|
|
108
|
+
// connect-time watermark: `rememberCursor` never fires, and every reconnect
|
|
109
|
+
// then re-replays and re-processes everything since the connect (duplicate
|
|
110
|
+
// `done` renders), or trips a spurious buffer_overflow replay_gap past 200
|
|
111
|
+
// events. 6.42.0 briefly shipped the flattened shape in development; it was
|
|
112
|
+
// caught pre-release. A test that only asserts `"eventId":1` cannot see the
|
|
113
|
+
// difference, because that substring is present in BOTH shapes.
|
|
19
114
|
_cosDisplayCursor: {
|
|
20
115
|
bootId: event.bootId,
|
|
21
116
|
eventId: event.eventId,
|
|
22
117
|
publishedAt: event.publishedAt,
|
|
23
118
|
},
|
|
24
119
|
})
|
|
120
|
+
// SSE `id:` is the reconnect cursor EventSource echoes back as Last-Event-ID.
|
|
121
|
+
// Namespaced by bootId so a restarted server cannot look like a resumable gap.
|
|
25
122
|
res.write(`id: ${event.bootId}:${event.eventId}\nevent: ${event.type}\ndata: ${data}\n\n`)
|
|
26
123
|
}
|
|
27
124
|
|
|
28
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Ticketless-connect accounting, summarized rather than logged per connect.
|
|
127
|
+
*
|
|
128
|
+
* The server sends `retry: 3000`, so ONE client that cannot mint reconnects every
|
|
129
|
+
* three seconds — 1,200 lines an hour into the launchd log, forever, from a single
|
|
130
|
+
* stale install. The counter is the point (adoption is measured, not guessed); the
|
|
131
|
+
* per-line volume is not.
|
|
132
|
+
*/
|
|
133
|
+
const TICKETLESS_LOG_INTERVAL_MS = 60_000
|
|
134
|
+
let ticketlessConnects = 0
|
|
135
|
+
let ticketlessRejectedTickets = 0
|
|
136
|
+
let ticketlessLoggedAt = 0
|
|
137
|
+
|
|
138
|
+
function noteTicketlessConnect(rejectedTicket: boolean): void {
|
|
139
|
+
ticketlessConnects++
|
|
140
|
+
if (rejectedTicket) ticketlessRejectedTickets++
|
|
141
|
+
const now = Date.now()
|
|
142
|
+
if (ticketlessLoggedAt !== 0 && now - ticketlessLoggedAt < TICKETLESS_LOG_INTERVAL_MS) return
|
|
143
|
+
ticketlessLoggedAt = now
|
|
144
|
+
console.warn(
|
|
145
|
+
`[display-bus] ${ticketlessConnects} ticketless subscriber(s)`
|
|
146
|
+
+ ` (${ticketlessRejectedTickets} with a rejected ticket) — content withheld, lifecycle only`,
|
|
147
|
+
)
|
|
148
|
+
ticketlessConnects = 0
|
|
149
|
+
ticketlessRejectedTickets = 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function __resetDisplayStreamLogForTests(): void {
|
|
153
|
+
ticketlessConnects = 0
|
|
154
|
+
ticketlessRejectedTickets = 0
|
|
155
|
+
ticketlessLoggedAt = 0
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A valid `X-Cos-Token` header is equivalent authorization to a ticket.
|
|
160
|
+
*
|
|
161
|
+
* The ticket exists only because EventSource cannot set headers. Every fetch-based
|
|
162
|
+
* consumer can — including the client's own `probeConnectionTarget`, COS Control, and
|
|
163
|
+
* curl — so requiring those to mint first would suppress content for callers that are
|
|
164
|
+
* already fully authenticated, and would permanently pollute the ticketless adoption
|
|
165
|
+
* counter with connections that were never the problem.
|
|
166
|
+
*/
|
|
167
|
+
function headerAuthorized(req: Request): boolean {
|
|
168
|
+
return timingSafeTokenEqual(req.headers['x-cos-token'], process.env.COS_API_TOKEN ?? '')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Shared handler for both registrations.
|
|
173
|
+
*
|
|
174
|
+
* `authorized` decides CONTENT, never admission. A ticketless subscriber still gets
|
|
175
|
+
* 200, `ready`, the keepalive, lifecycle markers, and replay-gap notices, so every
|
|
176
|
+
* already-installed client keeps a live transport, keeps `displayBusConnected` true,
|
|
177
|
+
* and keeps syncing offline meetings. It simply never receives transcripts or answers.
|
|
178
|
+
*/
|
|
179
|
+
function serveDisplayStream(req: Request, res: Response, authorized: boolean): void {
|
|
29
180
|
res.writeHead(200, {
|
|
30
181
|
'Content-Type': 'text/event-stream',
|
|
31
182
|
'Cache-Control': 'no-cache',
|
|
@@ -38,26 +189,48 @@ displayRouter.get('/display-stream', (req, res) => {
|
|
|
38
189
|
// Tell EventSource to retry quickly on disconnect (3s instead of browser default ~5-10s)
|
|
39
190
|
res.write('retry: 3000\n\n')
|
|
40
191
|
|
|
192
|
+
// DO NOT move this inside an `if (authorized)`. It looks unused on the ticketless
|
|
193
|
+
// path and is not: a ticketless subscriber never receives buffered events, but it
|
|
194
|
+
// MUST still be told when its cursor is unresumable (see replay_gap below), and
|
|
195
|
+
// that verdict is a function of the cursor. Hiding the cursor behind the
|
|
196
|
+
// authorization check would silently delete ticketless gap reporting.
|
|
41
197
|
const headerCursor = String(req.headers['last-event-id'] ?? '')
|
|
42
198
|
const [headerBootId, headerEventId] = headerCursor.includes(':')
|
|
43
199
|
? headerCursor.split(':', 2)
|
|
44
200
|
: ['', headerCursor]
|
|
45
201
|
const cursorBootId = String(req.query.bootId ?? headerBootId ?? '') || null
|
|
46
202
|
const cursorEventId = Number(req.query.eventId ?? headerEventId ?? 0)
|
|
47
|
-
const replay = replayDisplayEvents(cursorBootId, Number.isFinite(cursorEventId) ? cursorEventId : 0)
|
|
48
203
|
|
|
49
204
|
// Ready is a transport handshake, not proof that replay was consumed. It
|
|
50
205
|
// must precede application events so build 188 can finish admission first.
|
|
206
|
+
//
|
|
207
|
+
// `contentAuthorized` is what makes a degraded stream DETECTABLE. Without it the
|
|
208
|
+
// two handshakes are byte-identical, so a client whose ticket expired sets
|
|
209
|
+
// `displayBusConnected = true`, sees a healthy socket, and never re-mints — the
|
|
210
|
+
// stream stays silent forever with nothing anywhere reporting a fault. Old clients
|
|
211
|
+
// JSON.parse this frame and read only bootId/eventId, so the extra key is inert
|
|
212
|
+
// for them.
|
|
51
213
|
const watermark = getDisplayWatermark()
|
|
52
|
-
res.write(`event: ready\ndata: ${JSON.stringify(watermark)}\n\n`)
|
|
214
|
+
res.write(`event: ready\ndata: ${JSON.stringify({ ...watermark, contentAuthorized: authorized })}\n\n`)
|
|
215
|
+
|
|
216
|
+
const replay = replayDisplayEvents(cursorBootId, Number.isFinite(cursorEventId) ? cursorEventId : 0)
|
|
53
217
|
if (replay.gap) {
|
|
218
|
+
// Ticketless-VISIBLE on purpose. The payload is transport metadata only —
|
|
219
|
+
// reason, the cursor the client itself sent, the watermark already in `ready`,
|
|
220
|
+
// and a buffer boundary — so it discloses nothing. Withholding it silently
|
|
221
|
+
// breaks the client's replay-reconciliation branch after a server restart: the
|
|
222
|
+
// client would sit on a dead cursor waiting for a resume that cannot come.
|
|
54
223
|
res.write(`event: replay_gap\ndata: ${JSON.stringify({
|
|
55
224
|
reason: replay.reason,
|
|
56
225
|
requested: { bootId: cursorBootId, eventId: cursorEventId },
|
|
57
226
|
watermark,
|
|
58
227
|
oldestEventId: replay.oldestEventId,
|
|
59
228
|
})}\n\n`)
|
|
60
|
-
} else {
|
|
229
|
+
} else if (authorized) {
|
|
230
|
+
// The replay buffer holds up to REPLAY_BUFFER_SIZE past events, so serving it
|
|
231
|
+
// to a ticketless subscriber would be a retroactive transcript dump — a larger
|
|
232
|
+
// disclosure than the live subscription. Skipped entirely rather than filtered,
|
|
233
|
+
// so no future event type can leak through a per-item test here.
|
|
61
234
|
for (const event of replay.events) writeEvent(res, event)
|
|
62
235
|
if (replay.events.length > 0) {
|
|
63
236
|
console.log(`[display-bus] Replayed ${replay.events.length} publish-owned events after ${cursorEventId}`)
|
|
@@ -70,13 +243,43 @@ displayRouter.get('/display-stream', (req, res) => {
|
|
|
70
243
|
}, 15_000)
|
|
71
244
|
|
|
72
245
|
const unsub = onDisplay((event) => {
|
|
73
|
-
|
|
246
|
+
// The projection runs INSIDE the try, not above it. This is a synchronous
|
|
247
|
+
// EventEmitter listener, so anything that throws here propagates back out
|
|
248
|
+
// through `bus.emit()` into whoever called `emitDisplay` — and one of those
|
|
249
|
+
// callers is the meeting-save path. A malformed event must never be able to
|
|
250
|
+
// reach a recording. 6.41.0 guarded the whole listener body; keep that.
|
|
251
|
+
try {
|
|
252
|
+
const outgoing = authorized ? event : projectForTicketless(event)
|
|
253
|
+
if (!outgoing) return
|
|
254
|
+
writeEvent(res, outgoing)
|
|
255
|
+
} catch { /* client gone, or an event this subscriber simply cannot render */ }
|
|
74
256
|
})
|
|
75
257
|
|
|
76
258
|
req.on('close', () => {
|
|
77
259
|
clearInterval(ping)
|
|
78
260
|
unsub()
|
|
79
261
|
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Ticketless path. Stays public so installed clients keep a live transport; content
|
|
265
|
+
// is withheld above unless the caller sent a valid token header.
|
|
266
|
+
displayRouter.get('/display-stream', (req, res) => {
|
|
267
|
+
const authorized = headerAuthorized(req)
|
|
268
|
+
if (!authorized) noteTicketlessConnect(false)
|
|
269
|
+
serveDisplayStream(req, res, authorized)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
// Ticketed path. `api-auth` admits it on SHAPE alone; the signature is verified
|
|
273
|
+
// here, where the API token lives. A bad or expired ticket is not rejected — it
|
|
274
|
+
// degrades to exactly the ticketless stream, so a client whose ticket expired
|
|
275
|
+
// mid-reconnect keeps its transport instead of entering a retry loop. It learns it
|
|
276
|
+
// is degraded from `contentAuthorized:false` in the `ready` frame, and re-mints.
|
|
277
|
+
displayRouter.get('/display-stream/:ticket', (req, res) => {
|
|
278
|
+
const apiToken = process.env.COS_API_TOKEN ?? ''
|
|
279
|
+
const ticketOk = verifyDisplayTicket(apiToken, req.params.ticket)
|
|
280
|
+
const authorized = ticketOk || headerAuthorized(req)
|
|
281
|
+
if (!authorized) noteTicketlessConnect(true)
|
|
282
|
+
serveDisplayStream(req, res, authorized)
|
|
80
283
|
})
|
|
81
284
|
|
|
82
285
|
// POST /api/display-session — broadcast session restore to glasses (cross-surface sync)
|
package/server/routes/health.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contra
|
|
|
10
10
|
import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
11
11
|
import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
|
|
12
12
|
import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
|
|
13
|
+
import { mintDisplayTicket, DISPLAY_TICKET_TTL_SECONDS } from '../lib/display-ticket.js'
|
|
13
14
|
import { correctionStoreStats } from '../lib/meeting-corrections.js'
|
|
14
15
|
import { meetingAudioStats } from '../lib/meeting-audio-archive.js'
|
|
15
16
|
import { adaptivePlaybackStatus } from '../lib/adaptive-playback-audio.js'
|
|
@@ -353,6 +354,14 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
353
354
|
review_audio: reviewAudio,
|
|
354
355
|
...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
|
|
355
356
|
capabilities: {
|
|
357
|
+
// Advertised on the PUBLIC health route on purpose: a client deciding whether
|
|
358
|
+
// to request a display ticket may not hold a usable token yet, and an old
|
|
359
|
+
// server simply omits this key, which is how a new client detects it.
|
|
360
|
+
displayStream: {
|
|
361
|
+
ticketSupported: true,
|
|
362
|
+
ticketTtlSeconds: DISPLAY_TICKET_TTL_SECONDS,
|
|
363
|
+
contentRequiresTicket: true,
|
|
364
|
+
},
|
|
356
365
|
transcription: {
|
|
357
366
|
...transcription,
|
|
358
367
|
live: transcriptionLive,
|
|
@@ -429,6 +438,9 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
429
438
|
// THIS surface and Main.ts states outright that /api/health alone is not used,
|
|
430
439
|
// so a capability published only there is invisible to the phone.
|
|
431
440
|
const threadAttach = threadAttachCapability()
|
|
441
|
+
// mintDisplayTicket refuses an empty key, because a ticket signed with '' can
|
|
442
|
+
// never verify. Omit the field rather than publish a dead capability.
|
|
443
|
+
const apiToken = process.env.COS_API_TOKEN ?? ''
|
|
432
444
|
res.json({
|
|
433
445
|
...catalog,
|
|
434
446
|
...threadAttachHealthFields(threadAttach),
|
|
@@ -447,6 +459,33 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
447
459
|
},
|
|
448
460
|
ollamaReady: isOllamaProviderReady(),
|
|
449
461
|
serverInstanceId: getServerInstanceId(),
|
|
462
|
+
// Minted here rather than on a route of its own: this response is already
|
|
463
|
+
// authenticated, and the mint is stateless, so it costs nothing and stores
|
|
464
|
+
// nothing.
|
|
465
|
+
//
|
|
466
|
+
// TWO THINGS THIS ROUTE IS NOT, both of which the client must plan around:
|
|
467
|
+
//
|
|
468
|
+
// 1. It IS fetched before every connect in the 6.8.441 client — every retry
|
|
469
|
+
// calls connectDisplayBus() with no argument, which re-runs
|
|
470
|
+
// probeConnectionTarget, which fetches this route. Do not assume that of
|
|
471
|
+
// any OTHER client: nothing in the protocol requires it, and a client that
|
|
472
|
+
// minted once at pairing would go content-free one TTL later.
|
|
473
|
+
// 2. Its drain behaviour is NOT what classifyRecoveryRoute implies. That
|
|
474
|
+
// classifier puts /api/models in OPERATION_GET_PREFIXES, but its only
|
|
475
|
+
// consumer — recoveryAdmissionMiddleware — is never mounted in this build,
|
|
476
|
+
// and the gate that IS mounted (server/index.ts) returns next() for every
|
|
477
|
+
// GET/HEAD/OPTIONS on its first line. So this route answers 200 during a
|
|
478
|
+
// COS Control drain today. Treat the classifier's verdict as the contract a
|
|
479
|
+
// build that mounts that middleware would enforce, not as current behaviour.
|
|
480
|
+
// (The ticketed display-stream path is exempt either way, so an already-held
|
|
481
|
+
// ticket keeps working through a drain — which is still why the TTL has to
|
|
482
|
+
// outlive one.)
|
|
483
|
+
//
|
|
484
|
+
// So the CLIENT carries the re-mint obligation: fetch /api/models and reconnect
|
|
485
|
+
// when a display-stream `ready` frame reports `contentAuthorized: false`, or
|
|
486
|
+
// when DISPLAY_TICKET_TTL_SECONDS has elapsed since the last mint — retrying if
|
|
487
|
+
// the fetch 503s. Nothing on the server can re-mint on the client's behalf.
|
|
488
|
+
...(apiToken ? { displayStreamTicket: mintDisplayTicket(apiToken) } : {}),
|
|
450
489
|
capabilities: {
|
|
451
490
|
durableQueryJobs: {
|
|
452
491
|
enabled: durableJobs.enabled,
|
package/server/routes/query.ts
CHANGED
|
@@ -161,9 +161,13 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
161
161
|
emitDisplay({ type: 'tool_status', data: { message } })
|
|
162
162
|
}
|
|
163
163
|
},
|
|
164
|
-
// Activity lines stay on this authenticated request stream. The
|
|
165
|
-
//
|
|
166
|
-
//
|
|
164
|
+
// Activity lines stay on this authenticated request stream. The guard is
|
|
165
|
+
// unchanged; the reason has moved. Since 6.42.0 the global display stream is
|
|
166
|
+
// not "unauthenticated" — it ADMITS ticketless subscribers (200 for everyone,
|
|
167
|
+
// so an installed client keeps a live transport) and withholds CONTENT per
|
|
168
|
+
// event instead. Broadcasting observable command/output text onto that bus
|
|
169
|
+
// would leave it one allowlist entry away from an unauthenticated listener,
|
|
170
|
+
// so it is never emitted there at all.
|
|
167
171
|
...(activityToolMode === 'preview' ? {
|
|
168
172
|
onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => {
|
|
169
173
|
if (!done) {
|