@gotcos/glasses-server 6.99.33 → 7.0.18
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 +37 -0
- package/CHANGELOG.md +587 -0
- package/README.md +3 -1
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +12 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +155 -30
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +27 -14
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +43 -1
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +536 -0
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +31 -5
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +83 -0
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +640 -85
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +29 -1
- package/server/routes/agent-session-stream.ts +23 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +46 -1
- package/server/routes/live-events.ts +334 -0
- package/server/routes/permission-broker.ts +95 -5
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +223 -12
- package/server/routes/today-brief.ts +134 -44
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +28 -4
- package/server/routes/work-board.ts +9 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +76 -1
- package/server/web-control/app.js +2247 -98
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +21 -3
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +94 -9
- package/server/web-control/panes/cos-chat.js +628 -44
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +162 -21
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +97 -6
- package/server/web-control/panes/today-actions.css +46 -0
- package/server/web-control/panes/today-actions.js +119 -17
- package/server/web-control/panes/work.css +13 -0
- package/server/web-control/panes/work.js +188 -37
- package/server/web-control/shell.js +17 -14
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// GET /api/events (7.0.1 live state). The contract is docs/live-events-contract.md; the hub is lib/live-events.ts.
|
|
2
|
+
//
|
|
3
|
+
// ONE stream per open Control Web page. It tells the page when the server's own session state changes (a session
|
|
4
|
+
// opens, works, waits, idles or ends; a reply lands; a question opens or closes; the queue moves), so the pet, the
|
|
5
|
+
// session rows, Today's counts and the running clocks stop drifting between polls.
|
|
6
|
+
//
|
|
7
|
+
// TRANSPORT, copied from the per-session stream that already works through Tailscale Serve and the https listener
|
|
8
|
+
// (agent-session-stream.ts): the same four headers, `flushHeaders()`, `retry:` first, one JSON object per `data:`
|
|
9
|
+
// line with no named events and no comment keepalives, a heartbeat every 15 s, and every refusal BEFORE the headers
|
|
10
|
+
// as ordinary JSON so a client sees a failed request, never a socket that never speaks.
|
|
11
|
+
//
|
|
12
|
+
// AUTH is the global /api gate (token header or the Control Web cookie), exactly as for `/stream`. Nothing here
|
|
13
|
+
// re-checks it, so nothing here can drift from it.
|
|
14
|
+
//
|
|
15
|
+
// SWITCH: COS_WEB_LIVE_EVENTS. Absent means on. `0` registers no route at all (404), which is also what a client
|
|
16
|
+
// sees from a server older than 7.0.1, so both take the same polling path.
|
|
17
|
+
|
|
18
|
+
import { streamSignInHolds } from '../lib/api-auth.js'
|
|
19
|
+
import { Router, type Request, type Response } from 'express'
|
|
20
|
+
import { readSwitch } from '../lib/env-switch.js'
|
|
21
|
+
import {
|
|
22
|
+
LIVE_ENDED_LINGER_MS,
|
|
23
|
+
LiveEventsHub,
|
|
24
|
+
parseLiveCursor,
|
|
25
|
+
type LiveFrame,
|
|
26
|
+
type LiveProvider,
|
|
27
|
+
type LiveQuestionReading,
|
|
28
|
+
type LiveQueueReading,
|
|
29
|
+
type LiveSessionReading,
|
|
30
|
+
type LiveSources,
|
|
31
|
+
type LiveState,
|
|
32
|
+
} from '../lib/live-events.js'
|
|
33
|
+
import { claudeSessionsDir, claudeSessionsEnabled, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
|
|
34
|
+
import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
|
|
35
|
+
import { deriveForRow, sessionHooksEnabled, sessionSignalStore, signalFor } from '../lib/session-hooks-runtime.js'
|
|
36
|
+
import type { SessionSignal } from '../lib/session-signal-store.js'
|
|
37
|
+
import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
|
|
38
|
+
import { runningHintFor } from './agent-sessions.js'
|
|
39
|
+
import { queuedThreadKeys, readQueue } from '../lib/thread-turn-queue-store.js'
|
|
40
|
+
import type { PermissionBroker } from '../lib/permission-broker.js'
|
|
41
|
+
|
|
42
|
+
/** Inside a 30 s client deadline with room for one lost write; the same cadence as `/stream`. */
|
|
43
|
+
export const LIVE_EVENTS_HEARTBEAT_MS = 15_000
|
|
44
|
+
/**
|
|
45
|
+
* Open streams per server. A phone, a laptop and a few tabs each hold one; 16 is room plus a ceiling. At the ceiling the
|
|
46
|
+
* OLDEST stream is closed to make room (ghost review W5): a peer that vanished behind Tailscale holds its slot until TCP
|
|
47
|
+
* gives up, and the newest request is usually the person who just opened the page. A closed live client reconnects
|
|
48
|
+
* with its last id.
|
|
49
|
+
*/
|
|
50
|
+
export const LIVE_EVENTS_MAX_STREAMS = 16
|
|
51
|
+
/** Unsent bytes past this close the stream: a slow client is dropped, never buffered without limit. */
|
|
52
|
+
export const LIVE_EVENTS_MAX_BUFFER_BYTES = 256 * 1024
|
|
53
|
+
/** A hook-only session with no event for this long (and no open turn or wait) is not open. The deriver's open-turn ceiling. */
|
|
54
|
+
export const LIVE_SIGNAL_OPEN_WINDOW_MS = 30 * 60_000
|
|
55
|
+
|
|
56
|
+
/** Absent means on; `0` (or false/off) means no route. Read at server start. */
|
|
57
|
+
export function liveEventsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
58
|
+
return readSwitch(env.COS_WEB_LIVE_EVENTS, true)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function newestOf(...values: Array<number | null | undefined>): number | null {
|
|
62
|
+
let out: number | null = null
|
|
63
|
+
for (const v of values) if (typeof v === 'number' && Number.isFinite(v) && v > 0 && (out === null || v > out)) out = v
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One session's reading, from the same derivation the list and the per-session stream use (deriveForRow, then
|
|
69
|
+
* runningHintFor), so `working` here is the list's `running_active` and the pet never disagrees with the rows.
|
|
70
|
+
*
|
|
71
|
+
* Exported for tests. Pure apart from the deriver's own reads of the signal store (remember: false, so it never
|
|
72
|
+
* touches the row readers' two-scan memory).
|
|
73
|
+
*/
|
|
74
|
+
export function readingFor(provider: LiveProvider, sessionId: string, peer: ClaudePeerRecord | undefined, signal: SessionSignal | undefined, now: number): LiveSessionReading {
|
|
75
|
+
const id = sessionId.toLowerCase()
|
|
76
|
+
const derived = deriveForRow({
|
|
77
|
+
sessionId: id,
|
|
78
|
+
registry: peer ? registryFacts(peer) : undefined,
|
|
79
|
+
now,
|
|
80
|
+
remember: false,
|
|
81
|
+
attachedTurn: isAttachedTurnActive(sessionStreamKey(provider, id)),
|
|
82
|
+
})
|
|
83
|
+
const hookClock = signal ? newestOf(signal.lastEventAt, signal.lastThought?.at) : null
|
|
84
|
+
let state: LiveState
|
|
85
|
+
let source: 'hook' | 'registry' = derived?.state_source === 'hook' ? 'hook' : 'registry'
|
|
86
|
+
if (!derived) {
|
|
87
|
+
// Hooks off: the registry's own status is all there is.
|
|
88
|
+
source = 'registry'
|
|
89
|
+
state = peer?.status === 'busy' ? 'working' : peer?.status === 'waiting' ? 'waiting' : 'idle'
|
|
90
|
+
} else if (derived.agent_state === 'ended') {
|
|
91
|
+
state = 'ended'
|
|
92
|
+
} else {
|
|
93
|
+
const hint = runningHintFor(provider, derived, derived.state_source === 'hook' ? hookClock : null, now, null)
|
|
94
|
+
if (hint?.active) state = 'working'
|
|
95
|
+
// A registry `busy` with no hooks: the list reads its transcript clock here; the registry's word is the closest
|
|
96
|
+
// thing this stream has without a file read, and a busy tab writes its transcript continuously.
|
|
97
|
+
else if (!hint && derived.agent_state === 'running' && derived.state_source !== 'hook') state = 'working'
|
|
98
|
+
else if (derived.agent_state === 'waiting') state = 'waiting'
|
|
99
|
+
else state = 'idle'
|
|
100
|
+
}
|
|
101
|
+
const registryTurn = peer && (peer.status === 'busy' || peer.status === 'waiting') ? peer.statusUpdatedAt : null
|
|
102
|
+
return {
|
|
103
|
+
provider,
|
|
104
|
+
sessionId: id,
|
|
105
|
+
state,
|
|
106
|
+
startedAt: newestOf(peer?.startedAt) ?? newestOf(signal?.firstSeenAt),
|
|
107
|
+
turnStartedAt: signal?.turnOpen && signal.turnStartedAt ? signal.turnStartedAt : registryTurn,
|
|
108
|
+
updatedAt: newestOf(hookClock, peer?.statusUpdatedAt),
|
|
109
|
+
source,
|
|
110
|
+
failed: derived?.agent_state === 'failed',
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Which sessions are open, from the two things the server holds without walking transcripts: the Claude registry
|
|
116
|
+
* (alive records) and the hook signal store. Exported for tests with injected inputs.
|
|
117
|
+
*/
|
|
118
|
+
export function liveReadings(input: { peers: ClaudePeerRecord[] | null; signals: SessionSignal[]; now: number; signalFor: (id: string) => SessionSignal | undefined; reading?: typeof readingFor }): LiveSessionReading[] {
|
|
119
|
+
const reading = input.reading ?? readingFor
|
|
120
|
+
const out: LiveSessionReading[] = []
|
|
121
|
+
const included = new Set<string>()
|
|
122
|
+
const registryKnown = input.peers !== null
|
|
123
|
+
for (const peer of input.peers ?? []) {
|
|
124
|
+
if (!peer.alive) continue
|
|
125
|
+
const id = peer.sessionId.toLowerCase()
|
|
126
|
+
const key = `claude:${id}`
|
|
127
|
+
if (included.has(key)) continue
|
|
128
|
+
included.add(key)
|
|
129
|
+
out.push(reading('claude', id, peer, input.signalFor(id), input.now))
|
|
130
|
+
}
|
|
131
|
+
for (const signal of input.signals) {
|
|
132
|
+
if (signal.keepWarm) continue
|
|
133
|
+
const provider: LiveProvider = signal.provider === 'codex' || signal.provider === 'cursor' ? signal.provider : 'claude'
|
|
134
|
+
const id = signal.sessionId.toLowerCase()
|
|
135
|
+
const key = `${provider}:${id}`
|
|
136
|
+
if (included.has(key)) continue
|
|
137
|
+
const endedRecently = !!signal.ended && input.now - signal.ended.at <= LIVE_ENDED_LINGER_MS
|
|
138
|
+
const open = !signal.ended && (signal.turnOpen || !!signal.waiting || input.now - signal.lastEventAt <= LIVE_SIGNAL_OPEN_WINDOW_MS)
|
|
139
|
+
// A Claude tab is open while its registry record is alive. With the registry readable and no alive record, a
|
|
140
|
+
// hook record that never said SessionEnd is a tab that died (or a COS child that finished): not open.
|
|
141
|
+
if (provider === 'claude' && registryKnown && !endedRecently) continue
|
|
142
|
+
if (!open && !endedRecently) continue
|
|
143
|
+
included.add(key)
|
|
144
|
+
const r = reading(provider, id, undefined, signal, input.now)
|
|
145
|
+
out.push(endedRecently && r.state !== 'ended' ? { ...r, state: 'ended', turnStartedAt: null } : r)
|
|
146
|
+
}
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The server's own sources. `broker` is read lazily so the route can mount before the broker exists. `readPeers` is a
|
|
152
|
+
* test seam for the registry read.
|
|
153
|
+
*/
|
|
154
|
+
export function realLiveSources(deps: { broker: () => PermissionBroker | null; readPeers?: () => Promise<ClaudePeerRecord[]> }): LiveSources {
|
|
155
|
+
const readPeers = deps.readPeers ?? (() => readClaudePeerRecords(claudeSessionsDir(), undefined, undefined, true))
|
|
156
|
+
// The registry as last read: a hook push rebuilds against it instead of reading the folder (ghost review W4).
|
|
157
|
+
let last: { peers: ClaudePeerRecord[] | null; degraded: boolean } | null = null
|
|
158
|
+
return {
|
|
159
|
+
async sessions(now, opts) {
|
|
160
|
+
let peers: ClaudePeerRecord[] | null = null
|
|
161
|
+
let degraded = false
|
|
162
|
+
if (opts?.hooksOnly && last) {
|
|
163
|
+
peers = last.peers
|
|
164
|
+
degraded = last.degraded
|
|
165
|
+
} else if (claudeSessionsEnabled()) {
|
|
166
|
+
// A folder that cannot be read throws: degraded, and the hub keeps its last good model rather than marking
|
|
167
|
+
// every tab ended (ghost review note 8).
|
|
168
|
+
try { peers = await readPeers() } catch { peers = null; degraded = true }
|
|
169
|
+
last = { peers, degraded }
|
|
170
|
+
} else {
|
|
171
|
+
last = { peers: null, degraded: false }
|
|
172
|
+
}
|
|
173
|
+
const signals = sessionHooksEnabled() ? sessionSignalStore.snapshot() : []
|
|
174
|
+
return { sessions: liveReadings({ peers, signals, now, signalFor }), degraded }
|
|
175
|
+
},
|
|
176
|
+
questions() {
|
|
177
|
+
const broker = deps.broker()
|
|
178
|
+
if (!broker) return []
|
|
179
|
+
return broker.list().map((q): LiveQuestionReading => ({ provider: q.provider, sessionId: q.sessionId, kind: q.kind === 'approval' ? 'approval' : 'question' }))
|
|
180
|
+
},
|
|
181
|
+
queue(now) {
|
|
182
|
+
const rows: LiveQueueReading[] = []
|
|
183
|
+
for (const { provider, threadId } of queuedThreadKeys()) {
|
|
184
|
+
if (provider !== 'claude' && provider !== 'codex' && provider !== 'cursor') continue
|
|
185
|
+
const count = readQueue(provider, threadId, now).filter(t => t.status === 'waiting').length
|
|
186
|
+
if (count > 0) rows.push({ provider, sessionId: threadId, count })
|
|
187
|
+
}
|
|
188
|
+
return rows
|
|
189
|
+
},
|
|
190
|
+
subscribe(listener) {
|
|
191
|
+
if (!sessionHooksEnabled()) return () => {}
|
|
192
|
+
return sessionSignalStore.subscribe((signal, env, child) => {
|
|
193
|
+
listener({ sessionId: signal.sessionId, turnEnded: !child && (env.event === 'Stop' || env.event === 'StopFailure') })
|
|
194
|
+
})
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface LiveEventsRouterDeps {
|
|
200
|
+
hub: LiveEventsHub
|
|
201
|
+
enabled?: boolean
|
|
202
|
+
maxStreams?: number
|
|
203
|
+
heartbeatMs?: number
|
|
204
|
+
maxBufferBytes?: number
|
|
205
|
+
log?: (line: string) => void
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The stream frame on the wire: an `id:` line on every frame a resume can start after (the state frames, and the
|
|
210
|
+
* snapshot, whose seq is the state it describes), then one `data:` line. Heartbeat and resume carry none: they repeat
|
|
211
|
+
* the newest seq, and an id on them would only restate the last one.
|
|
212
|
+
*
|
|
213
|
+
* The snapshot's id means the browser's OWN EventSource reconnect resumes from the snapshot instead of asking for a
|
|
214
|
+
* second whole snapshot when nothing changed in between.
|
|
215
|
+
*/
|
|
216
|
+
export function frameText(frame: LiveFrame): string {
|
|
217
|
+
const remembered = frame.kind === 'snapshot' || frame.kind === 'session' || frame.kind === 'session.removed' || frame.kind === 'questions' || frame.kind === 'queue'
|
|
218
|
+
return `${remembered ? `id: ${frame.epoch}.${frame.seq}\n` : ''}data: ${JSON.stringify(frame)}\n\n`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function createLiveEventsRouter(deps: LiveEventsRouterDeps): Router {
|
|
222
|
+
const router = Router()
|
|
223
|
+
if (deps.enabled === false) return router
|
|
224
|
+
router.get('/events', liveEventsHandler(deps))
|
|
225
|
+
return router
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The handler, exported so the backpressure and heartbeat guards can be driven with a fake response. */
|
|
229
|
+
export function liveEventsHandler(deps: LiveEventsRouterDeps): (req: Request, res: Response) => Promise<void> {
|
|
230
|
+
const maxStreams = deps.maxStreams ?? LIVE_EVENTS_MAX_STREAMS
|
|
231
|
+
const heartbeatMs = deps.heartbeatMs ?? LIVE_EVENTS_HEARTBEAT_MS
|
|
232
|
+
const maxBuffer = deps.maxBufferBytes ?? LIVE_EVENTS_MAX_BUFFER_BYTES
|
|
233
|
+
const log = deps.log ?? (line => console.log(line))
|
|
234
|
+
let open = 0
|
|
235
|
+
/** Each open stream's teardown, oldest first (a Map keeps insertion order). */
|
|
236
|
+
const streams = new Map<number, (why: string) => void>()
|
|
237
|
+
let nextStream = 0
|
|
238
|
+
|
|
239
|
+
return async (req: Request, res: Response) => {
|
|
240
|
+
res.set('Cache-Control', 'private, no-store')
|
|
241
|
+
if (open >= maxStreams) {
|
|
242
|
+
const oldest = streams.entries().next().value as [number, (why: string) => void] | undefined
|
|
243
|
+
if (oldest) {
|
|
244
|
+
log(`[live-events] stream cap ${maxStreams} reached: closing the oldest stream to admit a new one (open ${open})`)
|
|
245
|
+
oldest[1]('evicted')
|
|
246
|
+
}
|
|
247
|
+
if (open >= maxStreams) {
|
|
248
|
+
log(`[live-events] stream cap ${maxStreams} reached: refused a new stream, none could be closed (open ${open})`)
|
|
249
|
+
res.status(503).json({ error: 'too many live streams', reason: 'live_events_capacity' })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
open++
|
|
254
|
+
const streamId = nextStream++
|
|
255
|
+
let closed = false
|
|
256
|
+
let detach: (() => void) | null = null
|
|
257
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null
|
|
258
|
+
// Frames that arrive while the snapshot is being prepared, written after it in order. Bounded like the buffer.
|
|
259
|
+
const pending: LiveFrame[] = []
|
|
260
|
+
let deliver: (frame: LiveFrame) => void = frame => {
|
|
261
|
+
pending.push(frame)
|
|
262
|
+
if (pending.length > 512) pending.shift()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const teardown = (why = 'closed'): void => {
|
|
266
|
+
if (closed) return
|
|
267
|
+
closed = true
|
|
268
|
+
open--
|
|
269
|
+
streams.delete(streamId)
|
|
270
|
+
if (heartbeat !== null) clearInterval(heartbeat)
|
|
271
|
+
detach?.()
|
|
272
|
+
if (why === 'slow') log(`[live-events] dropped a stream that stopped reading (${res.writableLength} bytes unsent, open ${open})`)
|
|
273
|
+
try { res.end() } catch { /* already gone */ }
|
|
274
|
+
}
|
|
275
|
+
streams.set(streamId, teardown)
|
|
276
|
+
const write = (frame: LiveFrame): void => {
|
|
277
|
+
if (closed) return
|
|
278
|
+
try {
|
|
279
|
+
res.write(frameText(frame))
|
|
280
|
+
// BACKPRESSURE: a client that is not reading is dropped. It reconnects with its last id and resumes or
|
|
281
|
+
// snapshots; holding its frames in memory without limit is how one stuck phone grows the server.
|
|
282
|
+
if (res.writableLength > maxBuffer) teardown('slow')
|
|
283
|
+
} catch {
|
|
284
|
+
teardown()
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
req.on('close', () => teardown())
|
|
289
|
+
res.on('error', () => teardown())
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
detach = await deps.hub.attach(frame => deliver(frame))
|
|
293
|
+
} catch {
|
|
294
|
+
if (!closed) { closed = true; open--; streams.delete(streamId); res.status(503).json({ error: 'live events unavailable', reason: 'live_events_unavailable' }) }
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
if (closed) { detach(); return }
|
|
298
|
+
|
|
299
|
+
res.writeHead(200, {
|
|
300
|
+
'Content-Type': 'text/event-stream',
|
|
301
|
+
'Cache-Control': 'no-cache',
|
|
302
|
+
'Connection': 'keep-alive',
|
|
303
|
+
'X-Accel-Buffering': 'no',
|
|
304
|
+
})
|
|
305
|
+
res.flushHeaders()
|
|
306
|
+
res.write('retry: 3000\n\n')
|
|
307
|
+
|
|
308
|
+
// The query wins over the header: a manual reconnect sets `?after=`, the browser's own reconnect sends the header.
|
|
309
|
+
const cursor = parseLiveCursor(typeof req.query.after === 'string' ? req.query.after : undefined) ?? parseLiveCursor(req.get('last-event-id'))
|
|
310
|
+
const replay = deps.hub.replayAfter(cursor)
|
|
311
|
+
if (replay === null) {
|
|
312
|
+
write(deps.hub.snapshotFrame())
|
|
313
|
+
// Anything queued while attaching is already in the snapshot when its seq is not newer.
|
|
314
|
+
const snapSeq = deps.hub.newestSeq
|
|
315
|
+
for (const frame of pending.splice(0)) if (frame.seq > snapSeq) write(frame)
|
|
316
|
+
} else {
|
|
317
|
+
for (const frame of replay) write(frame)
|
|
318
|
+
const last = replay.length ? replay[replay.length - 1]!.seq : cursor!.seq
|
|
319
|
+
for (const frame of pending.splice(0)) if (frame.seq > last) write(frame)
|
|
320
|
+
write(deps.hub.resumeFrame(replay.length))
|
|
321
|
+
}
|
|
322
|
+
deliver = write
|
|
323
|
+
// A write during the snapshot or replay can tear the stream down (a throw, or the buffer cap): no heartbeat then,
|
|
324
|
+
// or its interval would outlive the stream (review N1).
|
|
325
|
+
if (closed) return
|
|
326
|
+
|
|
327
|
+
// Final review S1: each heartbeat first asks whether the browser sign-in that opened the stream still verifies.
|
|
328
|
+
heartbeat = setInterval(() => {
|
|
329
|
+
if (!streamSignInHolds(res)) { teardown('signed_out'); return }
|
|
330
|
+
write(deps.hub.heartbeatFrame())
|
|
331
|
+
}, heartbeatMs)
|
|
332
|
+
;(heartbeat as { unref?: () => void }).unref?.()
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
|
|
51
51
|
import express, { Router, type NextFunction, type Request, type Response } from 'express'
|
|
52
52
|
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
53
|
+
import { turnEndAskStats } from '../lib/session-hooks-runtime.js'
|
|
53
54
|
import {
|
|
54
55
|
ANSWERING_CLIENTS,
|
|
55
56
|
CLIENT_LIVE_WINDOW_MS,
|
|
@@ -60,8 +61,9 @@ import {
|
|
|
60
61
|
type PermissionBroker,
|
|
61
62
|
} from '../lib/permission-broker.js'
|
|
62
63
|
import { noteQuestionsPoll, noteWebPresence, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
|
|
63
|
-
import { hasProxyForwardedHeaders, isLoopbackAddress, normalizeRemoteIp, reachableIpv4Addresses } from '../lib/network-policy.js'
|
|
64
|
+
import { hasProxyForwardedHeaders, isLoopbackAddress, isTailscaleIpv4, isTailscaleIpv6, normalizeRemoteIp, reachableIpv4Addresses } from '../lib/network-policy.js'
|
|
64
65
|
import { networkInterfaces } from 'node:os'
|
|
66
|
+
import { isIP } from 'node:net'
|
|
65
67
|
|
|
66
68
|
export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
|
|
67
69
|
/** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
|
|
@@ -161,6 +163,8 @@ export interface SessionQuestionsRouterDeps {
|
|
|
161
163
|
* rules: `isWebSessionAuthorized` in production). Absent: only the token opens the list, as before.
|
|
162
164
|
*/
|
|
163
165
|
webSessionAuthorized?: (req: Request) => boolean
|
|
166
|
+
/** 7.0.18 (X1): the turn-end ask counters (lib/turn-end-ask.ts), null while COS_TURN_END_ASKS is off. Defaults to the runtime's. */
|
|
167
|
+
turnEndAskStats?: () => Record<string, number> | null
|
|
164
168
|
/** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
|
|
165
169
|
notePoll?: (at: number, poll: QuestionsPollFacts) => void
|
|
166
170
|
/** 6.99.26: where COS Control Web's presence is recorded; `noteWebPresence` in production. */
|
|
@@ -188,23 +192,92 @@ export function questionsPollFacts(query: Record<string, unknown>): QuestionsPol
|
|
|
188
192
|
return { hold, presenceAgeMs: hold && validPresenceAgeMs(parsed) ? parsed : null }
|
|
189
193
|
}
|
|
190
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The Mac's own addresses: every external IPv4 (its tailnet address first) and its Tailscale IPv6 (fd7a:115c:a1e0::/48).
|
|
197
|
+
* Safari on the Mac often reaches its own MagicDNS name over IPv6, so the IPv6 tailnet address is the Mac too.
|
|
198
|
+
*/
|
|
199
|
+
export function selfAddressesFromInterfaces(
|
|
200
|
+
nets: ReturnType<typeof networkInterfaces> = networkInterfaces(),
|
|
201
|
+
): string[] {
|
|
202
|
+
const out = reachableIpv4Addresses(nets).map(a => a.address)
|
|
203
|
+
for (const infos of Object.values(nets)) {
|
|
204
|
+
for (const info of infos ?? []) {
|
|
205
|
+
// Node 18.0-18.3 reported the family as a number.
|
|
206
|
+
const v6 = String(info.family) === 'IPv6' || String(info.family) === '6'
|
|
207
|
+
if (v6 && isTailscaleIpv6(info.address)) out.push(info.address.toLowerCase())
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Headers `tailscale serve` adds to a hop it forwards (ipn/ipnlocal/serve.go). Anything else proxy-shaped is not Serve. */
|
|
214
|
+
const SERVE_HOP_HEADERS = new Set([
|
|
215
|
+
'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto',
|
|
216
|
+
'tailscale-user-login', 'tailscale-user-name', 'tailscale-user-profile-pic', 'tailscale-headers-info',
|
|
217
|
+
])
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* W12 (QA week of 11/01-11/08): a `tailscale serve` hop from the Mac to itself. Control Web opened at the Mac's own
|
|
221
|
+
* https://<mac>.ts.net is the desk, but Serve forwards it over loopback with proxy headers, so 6.99.26 stored its touch
|
|
222
|
+
* as away and a web-only hold waited up to 10 minutes. It is the Mac only when ALL of these hold:
|
|
223
|
+
* - the socket is loopback: the local Serve proxy connected (a peer reaching 3141 directly has its own address);
|
|
224
|
+
* - no `Forwarded` or `X-Real-IP` (Serve never sets either);
|
|
225
|
+
* - exactly one `X-Forwarded-For` address, no list. Serve SETS that header to the hop's real source (Header.Set, and
|
|
226
|
+
* Go's ReverseProxy drops the incoming X-Forwarded-* before its Rewrite runs), so a remote device's spoofed value
|
|
227
|
+
* never survives; a proxy that appended instead would make a list, which is refused;
|
|
228
|
+
* - that address is loopback or one of the Mac's own TAILSCALE addresses (Serve listens on the tailnet only).
|
|
229
|
+
*/
|
|
230
|
+
export function serveHopFromThisMac(
|
|
231
|
+
req: { socket?: { remoteAddress?: string | undefined } | null; headers?: Record<string, unknown> | null },
|
|
232
|
+
selfAddresses: () => readonly string[],
|
|
233
|
+
): boolean {
|
|
234
|
+
const headers = req.headers
|
|
235
|
+
if (!headers || typeof headers !== 'object') return false
|
|
236
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)) return false
|
|
237
|
+
for (const name of ['forwarded', 'x-real-ip']) {
|
|
238
|
+
const v = headers[name]
|
|
239
|
+
if (v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === '')) return false
|
|
240
|
+
}
|
|
241
|
+
// Only Serve's own headers: an unknown proxy-shaped header means a hop this check does not understand.
|
|
242
|
+
for (const name of Object.keys(headers)) {
|
|
243
|
+
if (/^(x-forwarded-|tailscale-)/i.test(name) && !SERVE_HOP_HEADERS.has(name.toLowerCase())) return false
|
|
244
|
+
}
|
|
245
|
+
const xff = headers['x-forwarded-for']
|
|
246
|
+
if (typeof xff !== 'string') return false
|
|
247
|
+
// One address and nothing else: a list ("a, b"), a padded pair or a name is not an IP, so it is refused here.
|
|
248
|
+
const ip = normalizeRemoteIp(xff.trim()).toLowerCase()
|
|
249
|
+
if (isIP(ip) === 0) return false
|
|
250
|
+
if (isLoopbackAddress(ip)) return true
|
|
251
|
+
if (!isTailscaleIpv4(ip) && !isTailscaleIpv6(ip)) return false
|
|
252
|
+
let own: readonly string[] = []
|
|
253
|
+
try { own = selfAddresses() } catch { own = [] }
|
|
254
|
+
return own.some(a => normalizeRemoteIp(String(a)).toLowerCase() === ip)
|
|
255
|
+
}
|
|
256
|
+
|
|
191
257
|
/**
|
|
192
258
|
* 6.99.26 (QA round 1): a request from this Mac itself: its socket is loopback or one of the Mac's own addresses (its
|
|
193
259
|
* tailnet address first), and no proxy header says it was forwarded for someone else (a `tailscale serve` hop arrives on
|
|
194
260
|
* loopback for a remote peer). Control Web open on the Mac is the desk: its touch never makes the Mac away.
|
|
261
|
+
* W12: a proxied request is the Mac only as a Serve hop from the Mac to itself (serveHopFromThisMac).
|
|
195
262
|
*/
|
|
196
263
|
export function requestFromThisMac(
|
|
197
264
|
req: { socket?: { remoteAddress?: string | undefined } | null; headers?: Record<string, unknown> | null },
|
|
198
|
-
selfAddresses: () => readonly string[] = () =>
|
|
265
|
+
selfAddresses: () => readonly string[] = () => selfAddressesFromInterfaces(),
|
|
199
266
|
): boolean {
|
|
200
|
-
if (hasProxyForwardedHeaders(req.headers)) return
|
|
267
|
+
if (hasProxyForwardedHeaders(req.headers)) return serveHopFromThisMac(req, selfAddresses)
|
|
201
268
|
const remote = req.socket?.remoteAddress
|
|
202
269
|
if (isLoopbackAddress(remote)) return true
|
|
203
|
-
const ip = normalizeRemoteIp(remote ?? '')
|
|
270
|
+
const ip = normalizeRemoteIp(remote ?? '').toLowerCase()
|
|
204
271
|
if (!ip) return false
|
|
205
272
|
let own: readonly string[] = []
|
|
206
273
|
try { own = selfAddresses() } catch { own = [] }
|
|
207
|
-
return own.
|
|
274
|
+
return own.some(a => normalizeRemoteIp(String(a)).toLowerCase() === ip)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function turnEndAskCounters(deps: SessionQuestionsRouterDeps): { turnEndAsks?: Record<string, number> } {
|
|
278
|
+
let counters: Record<string, number> | null = null
|
|
279
|
+
try { counters = (deps.turnEndAskStats ?? turnEndAskStats)() } catch { counters = null }
|
|
280
|
+
return counters ? { turnEndAsks: counters } : {}
|
|
208
281
|
}
|
|
209
282
|
|
|
210
283
|
export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
|
|
@@ -261,6 +334,13 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
261
334
|
// forgotten (the server started then, or dropped it while it still waited). Missing from
|
|
262
335
|
// `settled` and settled after it: the session moved past it.
|
|
263
336
|
waitingAtMacForgottenBefore: new Date(deps.broker.waitingAtMacForgottenBeforeMs()).toISOString(),
|
|
337
|
+
// X1, additive and only while a full-hold switch is on: every question a live session waits on (held, at the
|
|
338
|
+
// Mac, or just answered), with its words while it waits, and what a web page must send with an answer right now.
|
|
339
|
+
// With both off the response is 7.0.13's, key for key.
|
|
340
|
+
...(deps.broker.extendedQuestionsView() ? { waiting: deps.broker.waitingList(), webAnswer: deps.broker.webAnswerRules() } : {}),
|
|
341
|
+
// 7.0.18 (X1), additive and only while COS_TURN_END_ASKS is on: how many turn-end asks armed, were shown,
|
|
342
|
+
// were answered, were overtaken by a new turn, or aged out. Counts only, never words.
|
|
343
|
+
...turnEndAskCounters(deps),
|
|
264
344
|
})
|
|
265
345
|
})
|
|
266
346
|
router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
|
|
@@ -273,5 +353,15 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
273
353
|
const result = deps.broker.answer(String(req.params.id ?? ''), req.body, by)
|
|
274
354
|
res.status(result.status).json(result.body)
|
|
275
355
|
})
|
|
356
|
+
// X1: the web door's confirm step for an approval (lib/permission-broker.ts `confirm`). The /api gate let the
|
|
357
|
+
// request in; the door is labelled exactly as for an answer, and only the web door gets a confirm id.
|
|
358
|
+
router.post('/session-questions/:id/confirm', (req: Request, res: Response) => {
|
|
359
|
+
res.set('Cache-Control', 'private, no-store')
|
|
360
|
+
let expected = ''
|
|
361
|
+
try { expected = deps.apiToken() } catch { expected = '' }
|
|
362
|
+
const by = timingSafeTokenEqual(req.headers['x-cos-token'], expected) ? 'app' : 'web'
|
|
363
|
+
const result = deps.broker.confirm(String(req.params.id ?? ''), req.body, by)
|
|
364
|
+
res.status(result.status).json(result.body)
|
|
365
|
+
})
|
|
276
366
|
return router
|
|
277
367
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { streamSignInHolds } from '../lib/api-auth.js'
|
|
1
2
|
import { Router, type Request, type Response } from 'express'
|
|
2
3
|
import { durableQueryJobsEnabled, messagesSteerEnabled } from '../lib/query-job-feature.js'
|
|
3
4
|
import { ACTIVE_QUERY_JOBS_LIST_MAX, ACTIVE_QUERY_JOBS_PROTOCOL } from '../lib/query-job-active.js'
|
|
@@ -429,6 +430,8 @@ export function createQueryJobsRouter(
|
|
|
429
430
|
if (closed) return
|
|
430
431
|
|
|
431
432
|
heartbeat = setInterval(() => {
|
|
433
|
+
// Final review S1: a stream opened by a browser sign-in ends once that sign-in no longer verifies.
|
|
434
|
+
if (!streamSignInHolds(res)) { finish(); return }
|
|
432
435
|
if (!closed && !res.writableEnded) res.write(': keepalive\n\n')
|
|
433
436
|
}, heartbeatMs)
|
|
434
437
|
heartbeat.unref?.()
|