@gotcos/glasses-server 6.51.0 → 6.52.3
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 +16 -0
- package/CHANGELOG.md +86 -0
- package/README.md +56 -3
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/index.ts +58 -9
- package/server/lib/activity-preview.ts +45 -2
- package/server/lib/agent-session-search.ts +3 -1
- package/server/lib/agent-session-store.ts +35 -5
- package/server/lib/agent-session-turns.ts +4 -1
- package/server/lib/claude-bridge.ts +9 -0
- package/server/lib/client-instance-claim.ts +22 -1
- package/server/lib/client-liveness.ts +50 -0
- package/server/lib/codex-bridge.ts +6 -0
- package/server/lib/cursor-bridge.ts +6 -0
- package/server/lib/job-trail.ts +758 -0
- package/server/lib/ollama-bridge.ts +17 -0
- package/server/lib/permission-broker.ts +1156 -0
- package/server/lib/query-job-coordinator.ts +17 -1
- package/server/lib/query-job-feature.ts +10 -0
- package/server/lib/query-job-runtime.ts +9 -1
- package/server/lib/query-job-store.ts +214 -5
- package/server/lib/query-job-types.ts +36 -0
- package/server/lib/session-signal-store.ts +42 -10
- package/server/lib/session-state-derive.ts +14 -4
- package/server/lib/session-stream-events.ts +629 -18
- package/server/lib/session-stream-producer.ts +25 -6
- package/server/lib/session-transcript-watcher.ts +120 -3
- package/server/routes/agent-sessions.ts +12 -1
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +14 -7
- package/server/routes/health.ts +24 -1
- package/server/routes/permission-broker.ts +176 -0
- package/server/routes/query-jobs.ts +84 -13
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type SessionStreamProvider,
|
|
23
23
|
} from './session-stream-events.js'
|
|
24
24
|
import { beginAttachedTurn, publishSessionStream, sessionStreamKey } from './session-stream-bus.js'
|
|
25
|
+
import { releaseAfterTranscriptCatchUp } from './session-transcript-watcher.js'
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Ceiling on the incomplete trailing line held between chunks.
|
|
@@ -68,8 +69,15 @@ export function createLineAssembler(maxCarry: number = MAX_LINE_CARRY_CHARS): Li
|
|
|
68
69
|
export interface AttachedTurnStream {
|
|
69
70
|
/** Wire this as the adapter's `observeStdout`. Never throws. */
|
|
70
71
|
observeStdout(chunk: string): void
|
|
71
|
-
/**
|
|
72
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Call exactly once when the turn settles, whatever its outcome. Never throws.
|
|
74
|
+
*
|
|
75
|
+
* The terminal status is published before this returns. The hold-off lifts once the
|
|
76
|
+
* session's transcript watcher is at end of file (6.52.0 QA round 2): at once when no
|
|
77
|
+
* watcher tails the session, within `CATCH_UP_TIMEOUT_MS` otherwise. The promise
|
|
78
|
+
* settles then and never rejects; callers need not await it.
|
|
79
|
+
*/
|
|
80
|
+
finish(outcome: 'done' | 'idle'): Promise<void>
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
export interface AttachedTurnStreamOptions {
|
|
@@ -96,6 +104,7 @@ export function createAttachedTurnStream(options: AttachedTurnStreamOptions): At
|
|
|
96
104
|
const assembler = createLineAssembler()
|
|
97
105
|
const endTurn = beginAttachedTurn(key)
|
|
98
106
|
let finished = false
|
|
107
|
+
let released: Promise<void> = Promise.resolve()
|
|
99
108
|
|
|
100
109
|
const emit = (draft: SessionStreamDraft) => {
|
|
101
110
|
try {
|
|
@@ -117,8 +126,8 @@ export function createAttachedTurnStream(options: AttachedTurnStreamOptions): At
|
|
|
117
126
|
/* a malformed chunk costs its own events and nothing else */
|
|
118
127
|
}
|
|
119
128
|
},
|
|
120
|
-
finish(outcome: 'done' | 'idle'): void {
|
|
121
|
-
if (finished) return
|
|
129
|
+
finish(outcome: 'done' | 'idle'): Promise<void> {
|
|
130
|
+
if (finished) return released
|
|
122
131
|
finished = true
|
|
123
132
|
try {
|
|
124
133
|
for (const line of assembler.flush()) {
|
|
@@ -128,11 +137,21 @@ export function createAttachedTurnStream(options: AttachedTurnStreamOptions): At
|
|
|
128
137
|
/* fall through: the terminal status and the gate release matter more */
|
|
129
138
|
}
|
|
130
139
|
emit({ kind: 'status', state: outcome })
|
|
140
|
+
// NOT released here. The CLI wrote this turn's records to the transcript as well,
|
|
141
|
+
// and the watcher reads the hold-off only when it reads: released now, its next
|
|
142
|
+
// check would show the last lines again and put `working` after this `done`
|
|
143
|
+
// (6.52.0 QA round 2). The watcher module brings every watcher on this session to
|
|
144
|
+
// end of file while the hold still applies, then releases, within a bound.
|
|
131
145
|
try {
|
|
132
|
-
endTurn
|
|
146
|
+
released = releaseAfterTranscriptCatchUp(key, endTurn)
|
|
133
147
|
} catch {
|
|
134
|
-
|
|
148
|
+
try {
|
|
149
|
+
endTurn()
|
|
150
|
+
} catch {
|
|
151
|
+
/* the gate is a Map delete; there is no failure mode to report */
|
|
152
|
+
}
|
|
135
153
|
}
|
|
154
|
+
return released
|
|
136
155
|
},
|
|
137
156
|
}
|
|
138
157
|
}
|
|
@@ -141,6 +141,12 @@ export interface TranscriptTailer {
|
|
|
141
141
|
state(): 'working' | 'idle' | 'done' | null
|
|
142
142
|
/** True once this tailer has handed off to the poll. Terminal; never clears. */
|
|
143
143
|
degraded(): boolean
|
|
144
|
+
/**
|
|
145
|
+
* One HELD pass (6.52.0 QA round 2): move the cursor to end of file and emit nothing.
|
|
146
|
+
* Only `releaseAfterTranscriptCatchUp` calls it, while a Continue turn still holds the
|
|
147
|
+
* session. Resolves; never rejects.
|
|
148
|
+
*/
|
|
149
|
+
skipToEnd(): Promise<void>
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
/**
|
|
@@ -319,6 +325,18 @@ export function createTranscriptTailer(options: TranscriptTailerOptions): Transc
|
|
|
319
325
|
/* a tick that throws is a tick that produced nothing; the next one retries */
|
|
320
326
|
}
|
|
321
327
|
},
|
|
328
|
+
async skipToEnd(): Promise<void> {
|
|
329
|
+
if (degraded) return
|
|
330
|
+
try {
|
|
331
|
+
const st = await stat(options.path)
|
|
332
|
+
if (!st.isFile()) return
|
|
333
|
+
// The RAW end, not the last newline: see `releaseAfterTranscriptCatchUp`. Moving
|
|
334
|
+
// backward on a truncated file is what `tick` does too.
|
|
335
|
+
cursor = { offset: st.size }
|
|
336
|
+
} catch {
|
|
337
|
+
/* unreadable right now: the cursor stays, and the caller's bound still releases */
|
|
338
|
+
}
|
|
339
|
+
},
|
|
322
340
|
}
|
|
323
341
|
}
|
|
324
342
|
|
|
@@ -330,6 +348,8 @@ interface WatcherEntry {
|
|
|
330
348
|
refs: number
|
|
331
349
|
timer: ReturnType<typeof setInterval>
|
|
332
350
|
ticking: boolean
|
|
351
|
+
/** The pass `ticking` guards, so a catch-up can wait for it. Null when none runs. */
|
|
352
|
+
inflight: Promise<void> | null
|
|
333
353
|
tailer: TranscriptTailer
|
|
334
354
|
}
|
|
335
355
|
|
|
@@ -342,6 +362,8 @@ export interface AcquireWatcherOptions {
|
|
|
342
362
|
/** Where to start. Production passes the file's current size. */
|
|
343
363
|
offset: number
|
|
344
364
|
intervalMs?: number
|
|
365
|
+
/** Injected for tests: the tail this entry drives. Production builds it from the fields above. */
|
|
366
|
+
tailer?: TranscriptTailer
|
|
345
367
|
}
|
|
346
368
|
|
|
347
369
|
/**
|
|
@@ -358,7 +380,7 @@ export function acquireTranscriptWatcher(options: AcquireWatcherOptions): () =>
|
|
|
358
380
|
return releaseOnce(options.key)
|
|
359
381
|
}
|
|
360
382
|
|
|
361
|
-
const tailer = createTranscriptTailer(options)
|
|
383
|
+
const tailer = options.tailer ?? createTranscriptTailer(options)
|
|
362
384
|
const timer = setInterval(() => {
|
|
363
385
|
const entry = watchers.get(options.key)
|
|
364
386
|
if (!entry) return
|
|
@@ -367,12 +389,16 @@ export function acquireTranscriptWatcher(options: AcquireWatcherOptions): () =>
|
|
|
367
389
|
// next tick sees everything the skipped one would have.
|
|
368
390
|
if (entry.ticking) return
|
|
369
391
|
entry.ticking = true
|
|
370
|
-
|
|
392
|
+
// Kept on the entry rather than voided, so a catch-up can wait for it (below).
|
|
393
|
+
entry.inflight = tailer.tick().catch(() => {}).finally(() => {
|
|
394
|
+
entry.ticking = false
|
|
395
|
+
entry.inflight = null
|
|
396
|
+
})
|
|
371
397
|
}, options.intervalMs ?? POLL_INTERVAL_MS)
|
|
372
398
|
// Never hold the process open. A tail is a view, not work.
|
|
373
399
|
if (typeof (timer as any).unref === 'function') (timer as any).unref()
|
|
374
400
|
|
|
375
|
-
watchers.set(options.key, { refs: 1, timer, ticking: false, tailer })
|
|
401
|
+
watchers.set(options.key, { refs: 1, timer, ticking: false, inflight: null, tailer })
|
|
376
402
|
return releaseOnce(options.key)
|
|
377
403
|
}
|
|
378
404
|
|
|
@@ -410,6 +436,97 @@ export function transcriptWatcherDegraded(key: string): boolean {
|
|
|
410
436
|
return watchers.get(key)?.tailer.degraded() ?? false
|
|
411
437
|
}
|
|
412
438
|
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Catch-up before a Continue turn's hold-off lifts (6.52.0 QA round 2)
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// The hold-off is read when a tick READS, and a tick runs once a second. The CLI
|
|
443
|
+
// (`claude -p`, `codex exec resume`) writes each record to the transcript tailed here
|
|
444
|
+
// AND prints it on stdout, which the producer maps. Records written after the watcher's
|
|
445
|
+
// last check of the turn were therefore read by its first check AFTER the release, when
|
|
446
|
+
// nothing held them off: the final reply appeared twice on the lens, and the watcher's
|
|
447
|
+
// resume put `working` after `done`. QA reproduced it with the real tailer and producer
|
|
448
|
+
// on the real codex exec sample.
|
|
449
|
+
//
|
|
450
|
+
// So the producer hands its release to `releaseAfterTranscriptCatchUp`, which, while the
|
|
451
|
+
// hold still applies:
|
|
452
|
+
// 1. waits for a tick already in flight, so it reads the hold-off while it holds;
|
|
453
|
+
// 2. runs one held pass that moves the cursor to END OF FILE and emits nothing;
|
|
454
|
+
// 3. only then releases.
|
|
455
|
+
//
|
|
456
|
+
// RAW END OF FILE, not the last newline. On an ordinary exit the child has closed, so
|
|
457
|
+
// every byte there is the turn's. A record cut mid-write left BEHIND the cursor would
|
|
458
|
+
// join the next real record into one unparseable line and lose it; a fragment AHEAD of
|
|
459
|
+
// it is the tail of a record, which never parses (the outer record's closing brace
|
|
460
|
+
// follows it), so the grammar drops it.
|
|
461
|
+
//
|
|
462
|
+
// BOUNDED. The pass is one stat, but a wedged volume or a tick that never settles must
|
|
463
|
+
// not keep the hold-off: that would silence the session for good, which the producer
|
|
464
|
+
// already names as worse than a duplicated line. After `CATCH_UP_TIMEOUT_MS` the release
|
|
465
|
+
// happens anyway and is counted, as is a pass that throws.
|
|
466
|
+
|
|
467
|
+
/** Ceiling on the catch-up. The pass is one stat; this is for a wedged disk, not an expected wait. */
|
|
468
|
+
export const CATCH_UP_TIMEOUT_MS = 500
|
|
469
|
+
|
|
470
|
+
const catchUpCounts = { timedOut: 0, failed: 0 }
|
|
471
|
+
|
|
472
|
+
/** Catch-ups that released on the bound, or on a pass that threw, since this server started. */
|
|
473
|
+
export function transcriptCatchUpCounts(): { timedOut: number; failed: number } {
|
|
474
|
+
return { ...catchUpCounts }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function catchUpToEnd(entry: WatcherEntry): Promise<void> {
|
|
478
|
+
// 1. A check already reading: let it read the hold-off while it still holds.
|
|
479
|
+
while (entry.ticking && entry.inflight) await entry.inflight
|
|
480
|
+
// 2. One held pass, guarded like a tick so the timer cannot run another beside it.
|
|
481
|
+
entry.ticking = true
|
|
482
|
+
const pass = Promise.resolve().then(() => entry.tailer.skipToEnd())
|
|
483
|
+
entry.inflight = pass.catch(() => {}).finally(() => {
|
|
484
|
+
entry.ticking = false
|
|
485
|
+
entry.inflight = null
|
|
486
|
+
})
|
|
487
|
+
await pass
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Bring every watcher on this session to end of file, THEN call `release`.
|
|
492
|
+
*
|
|
493
|
+
* `release` runs exactly once: at once when nothing tails the session, otherwise after
|
|
494
|
+
* the catch-up or after `timeoutMs`, whichever is first. The promise settles when it has
|
|
495
|
+
* run and never rejects.
|
|
496
|
+
*/
|
|
497
|
+
export function releaseAfterTranscriptCatchUp(
|
|
498
|
+
key: string,
|
|
499
|
+
release: () => void,
|
|
500
|
+
timeoutMs: number = CATCH_UP_TIMEOUT_MS,
|
|
501
|
+
): Promise<void> {
|
|
502
|
+
const releaseSafely = () => {
|
|
503
|
+
try {
|
|
504
|
+
release()
|
|
505
|
+
} catch {
|
|
506
|
+
/* the release is a Map delete; there is no failure mode to report */
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const entry = watchers.get(key)
|
|
510
|
+
if (!entry) {
|
|
511
|
+
// Nothing tails this session, so nothing can read the turn's records late.
|
|
512
|
+
releaseSafely()
|
|
513
|
+
return Promise.resolve()
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
517
|
+
const bound = new Promise<'timed_out'>(resolve => {
|
|
518
|
+
timer = setTimeout(() => resolve('timed_out'), Math.max(0, timeoutMs))
|
|
519
|
+
if (typeof (timer as any).unref === 'function') (timer as any).unref()
|
|
520
|
+
})
|
|
521
|
+
const work = catchUpToEnd(entry).then(() => 'caught_up' as const, () => 'failed' as const)
|
|
522
|
+
return Promise.race([work, bound]).then(outcome => {
|
|
523
|
+
clearTimeout(timer)
|
|
524
|
+
if (outcome === 'timed_out') catchUpCounts.timedOut++
|
|
525
|
+
else if (outcome === 'failed') catchUpCounts.failed++
|
|
526
|
+
releaseSafely()
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
|
|
413
530
|
export function __resetTranscriptWatchersForTests(): void {
|
|
414
531
|
for (const entry of watchers.values()) clearInterval(entry.timer)
|
|
415
532
|
watchers.clear()
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
emptySessionListDropped,
|
|
30
30
|
loadClaudeDesktopAliases,
|
|
31
31
|
loadCursorComposerNames,
|
|
32
|
+
loadCodexThreadNames,
|
|
33
|
+
idFromCodexFilename,
|
|
32
34
|
parseAgentSession,
|
|
33
35
|
type AgentProvider,
|
|
34
36
|
type AgentSessionRoots,
|
|
@@ -482,7 +484,16 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
482
484
|
// `parsed.truncated` says so out loud, and the counts are then counts of what was
|
|
483
485
|
// READ. Presenting a partial count as the session total would be the same
|
|
484
486
|
// dishonesty as a silent cap, so the digest omits the number entirely instead.
|
|
485
|
-
|
|
487
|
+
// 6.52.0: a Codex thread is titled by its own name, as its list row already is. Keyed
|
|
488
|
+
// by the rollout's FILENAME id first: a cloned thread's `session_meta` carries its
|
|
489
|
+
// parent's id (see `listCodexSessions`), and the parent's name would be the wrong one.
|
|
490
|
+
let codexThreadName: string | undefined
|
|
491
|
+
if (provider === 'codex') {
|
|
492
|
+
const names = await loadCodexThreadNames(agentSessionRoots().codexSessions)
|
|
493
|
+
const fileId = idFromCodexFilename(found.split('/').pop() || '')
|
|
494
|
+
codexThreadName = (fileId ? names.get(fileId) : undefined) || names.get(sessionId) || undefined
|
|
495
|
+
}
|
|
496
|
+
const parsed = await parseAgentSession(provider, found, codexThreadName ? { codexThreadName } : {})
|
|
486
497
|
// 6.50.0 `?turns=N`: the recent conversation, for the lens's scroll-up history. Only
|
|
487
498
|
// when asked, so the detail poll and every older client get the same bytes as before.
|
|
488
499
|
// A failure here costs the history, never the detail page.
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
|
|
28
28
|
/** Owners kept at once; the least recently seen device is dropped past this. */
|
|
29
29
|
export const CLIENT_INSTANCE_MAX_DEVICES = 16
|
|
30
|
+
/** Copies whose last claim time is remembered (6.52.3); the least recent is dropped past this. */
|
|
31
|
+
export const CLIENT_INSTANCE_MAX_SEEN = 64
|
|
30
32
|
|
|
31
33
|
export interface ClientInstanceRouterDeps {
|
|
32
34
|
now?: () => number
|
|
@@ -49,6 +51,13 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
|
|
|
49
51
|
const router = Router()
|
|
50
52
|
const owners = new Map<string, ClientInstanceOwner>()
|
|
51
53
|
const chunks = new ClientChunkLedger()
|
|
54
|
+
// 6.52.3: when each copy last claimed, so an older boot proves it was awake before it
|
|
55
|
+
// may take a quiet ring back. Bounded like the chunk ledger.
|
|
56
|
+
const lastSeen = new Map<string, number>()
|
|
57
|
+
const noteSeen = (id: string, at: number) => {
|
|
58
|
+
lastSeen.delete(id); lastSeen.set(id, at)
|
|
59
|
+
while (lastSeen.size > CLIENT_INSTANCE_MAX_SEEN) lastSeen.delete(lastSeen.keys().next().value as string)
|
|
60
|
+
}
|
|
52
61
|
router.use((req, _res, next) => {
|
|
53
62
|
if (isMeetingChunkPost(req.method, req.path)) chunks.note(deviceKey(req.socket?.remoteAddress ?? req.ip), parseInstanceTag(req.query?.clientInstance), now())
|
|
54
63
|
next()
|
|
@@ -61,7 +70,9 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
|
|
|
61
70
|
const previous = owners.get(device) ?? null
|
|
62
71
|
const evidence = previous ? chunks.evidence(device, previous.id, claim.id, at) : NO_CAPTURE
|
|
63
72
|
const captureLive = previous ? ownerIsRecording(previous, evidence, at) : false
|
|
64
|
-
const
|
|
73
|
+
const claimantLastSeenAt = lastSeen.get(claim.id)
|
|
74
|
+
noteSeen(claim.id, at)
|
|
75
|
+
const result = arbitrateClientInstance(previous, claim, at, evidence, claimantLastSeenAt)
|
|
65
76
|
if (req.body?.check === true) {
|
|
66
77
|
const shown = previous ?? result.owner
|
|
67
78
|
return void res.json({ verdict: result.verdict, owner: { id: shown.id, bootAt: shown.bootAt, version: shown.version, seenAt: shown.seenAt }, check: true, captureLive })
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
// NOT under /api, on purpose. The hook holds the per-install HOOK token
|
|
5
5
|
// (`~/.cos-glasses/hook-token`, 0600), never the pairing token the /api gate checks, and
|
|
6
6
|
// widening that gate for one caller would be the larger change. This route checks the
|
|
7
|
-
// hook token itself, in constant time,
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// hook token itself, in constant time, BEFORE its body is read or parsed (6.52.0: it is
|
|
8
|
+
// mounted ahead of the global 10 MB parser, and its own 2 MB parser runs only after the
|
|
9
|
+
// token check). It answers only `{}` or `{"followup_message": ...}` so the hook can print
|
|
10
|
+
// it verbatim, and it never answers with anything a Claude Stop could act on: a body that
|
|
11
|
+
// is not a Cursor Stop is `{}`.
|
|
10
12
|
|
|
11
|
-
import express, { Router, type Request, type Response } from 'express'
|
|
13
|
+
import express, { Router, type NextFunction, type Request, type Response } from 'express'
|
|
12
14
|
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
13
15
|
import { claimNextCursorTurn, cursorStopAcceptsFollowup, cursorStopIsFresh, parseCursorStopEnvelope } from '../lib/cursor-stop-followup.js'
|
|
14
16
|
import { MAX_QUEUED_PER_THREAD, type QueuedThreadTurn } from '../lib/thread-turn-queue.js'
|
|
@@ -25,14 +27,19 @@ export const CURSOR_STOP_FOLLOWUP_PATH = '/hooks/cursor/stop-followup'
|
|
|
25
27
|
|
|
26
28
|
export function createCursorStopFollowupRouter(deps: CursorStopFollowupDeps): Router {
|
|
27
29
|
const router = Router()
|
|
28
|
-
// The hook posts its spool envelope, capped at 1 MiB by
|
|
29
|
-
|
|
30
|
+
// The token first, then the body: the hook posts its spool envelope, capped at 1 MiB by
|
|
31
|
+
// the script itself.
|
|
32
|
+
const requireHookToken = (req: Request, res: Response, next: NextFunction): void => {
|
|
30
33
|
res.set('Cache-Control', 'private, no-store')
|
|
31
34
|
let expected: string | null = null
|
|
32
35
|
try { expected = deps.hookToken() } catch { expected = null }
|
|
33
36
|
if (!expected || !timingSafeTokenEqual(req.headers['x-cos-hook-token'], expected)) {
|
|
34
|
-
|
|
37
|
+
res.status(401).json({})
|
|
38
|
+
return
|
|
35
39
|
}
|
|
40
|
+
next()
|
|
41
|
+
}
|
|
42
|
+
router.post(CURSOR_STOP_FOLLOWUP_PATH, requireHookToken, express.json({ limit: '2mb' }), (req: Request, res: Response) => {
|
|
36
43
|
const facts = parseCursorStopEnvelope(req.body)
|
|
37
44
|
if (!facts || !cursorStopAcceptsFollowup(facts, MAX_QUEUED_PER_THREAD)) return res.json({})
|
|
38
45
|
// The hook waits three seconds and then moves on. A claim made after it stopped
|
package/server/routes/health.ts
CHANGED
|
@@ -49,7 +49,8 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
|
|
|
49
49
|
// The flag lives beside the endpoints it describes, so it cannot drift from
|
|
50
50
|
// whether they are actually registered.
|
|
51
51
|
import { MEDIA_CHUNKED_UPLOAD_ENABLED } from './media.js'
|
|
52
|
-
import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
|
|
52
|
+
import { durableQueryJobsCapability, messagesTrailEnabled } from '../lib/query-job-feature.js'
|
|
53
|
+
import { jobTrailStats } from '../lib/job-trail.js'
|
|
53
54
|
import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
|
|
54
55
|
import { getMorningBriefScheduler } from '../lib/morning-brief-runtime.js'
|
|
55
56
|
import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
|
|
@@ -80,6 +81,7 @@ import {
|
|
|
80
81
|
threadAttachHealthFields,
|
|
81
82
|
} from '../lib/thread-attach-capability.js'
|
|
82
83
|
import { sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
|
|
84
|
+
import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib/permission-broker.js'
|
|
83
85
|
import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
|
|
84
86
|
import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
|
|
85
87
|
import { codexLiveQueueEnabled } from '../lib/codex-live-queue.js'
|
|
@@ -388,6 +390,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
388
390
|
server_version: managedServerVersion(),
|
|
389
391
|
...threadAttachHealthFields(threadAttach),
|
|
390
392
|
...sessionHooksHealthFields(),
|
|
393
|
+
// 6.52.0: counts, the mode and two timestamps; never an id, a question, a command, or
|
|
394
|
+
// how many are held (that is on the authenticated questions route).
|
|
395
|
+
...permissionBrokerHealthFields(),
|
|
391
396
|
...continueLiveHealthFields(),
|
|
392
397
|
server_instance_id: getServerInstanceId(),
|
|
393
398
|
boot_id: serverMetrics.bootId,
|
|
@@ -479,6 +484,12 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
479
484
|
protocolVersion: durableJobs.protocolVersion,
|
|
480
485
|
state: durableJobs.store.state,
|
|
481
486
|
},
|
|
487
|
+
// 6.52.0: the Messages trail's readers, process-wide since boot. Counts and a
|
|
488
|
+
// timestamp only: stdout lines read, entries emitted, failures swallowed.
|
|
489
|
+
messages_trail: {
|
|
490
|
+
enabled: durableJobs.enabled && messagesTrailEnabled(),
|
|
491
|
+
...jobTrailStats(),
|
|
492
|
+
},
|
|
482
493
|
})
|
|
483
494
|
})
|
|
484
495
|
|
|
@@ -568,7 +579,19 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
568
579
|
durableQueryJobs: {
|
|
569
580
|
enabled: durableJobs.enabled,
|
|
570
581
|
protocolVersion: durableJobs.protocolVersion,
|
|
582
|
+
// 6.52.0: jobs journal `trail` events and carry `snapshot.trail`. The app reads
|
|
583
|
+
// THIS surface (not /api/health) and falls back to the activity log without it.
|
|
584
|
+
// False whenever jobs themselves are off or COS_MESSAGES_TRAIL=0.
|
|
585
|
+
trail: durableJobs.enabled && messagesTrailEnabled(),
|
|
571
586
|
},
|
|
587
|
+
// 6.52.0: the session questions API and its client contract. A client polls
|
|
588
|
+
// `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs`; one
|
|
589
|
+
// quiet for `liveWindowMs` hands every held request back to the Mac.
|
|
590
|
+
sessionQuestions: sessionQuestionsCapability(),
|
|
591
|
+
// 6.52.1: Sessions prompt events carry additive `turn_started_at` (ISO), the
|
|
592
|
+
// real user-record time. Transport `at` stays the seed/live stamp. Absent on
|
|
593
|
+
// 6.52.0, the client falls back to a local pin.
|
|
594
|
+
runningTurnMeta: { protocolVersion: 1 },
|
|
572
595
|
transcription: {
|
|
573
596
|
...transcription,
|
|
574
597
|
live: transcriptionLive,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// The permission broker's two doors (6.52.0). See `lib/permission-broker.ts` for the why.
|
|
2
|
+
//
|
|
3
|
+
// POST /api/permission-requests/ask -- the COS session hook, on a PermissionRequest at a
|
|
4
|
+
// desk idle for 90 s. THIS IS THE URL THE 6.51.0 SCRIPT ALREADY CALLS, so no Mac needs
|
|
5
|
+
// `--hooks install` again (QA round 1: a new URL meant a new script, and every Mac that did
|
|
6
|
+
// not reinstall would have shown Control's "hooks outdated" banner and kept the old door).
|
|
7
|
+
// The hook holds the per-install HOOK token (`~/.cos-glasses/hook-token`, 0600), never the
|
|
8
|
+
// pairing token the /api gate checks, so this one route is registered BEFORE that gate and
|
|
9
|
+
// before the global body parser, as a deliberate exemption for exactly this method and
|
|
10
|
+
// path: it checks the hook token itself, in constant time, BEFORE it reads a byte of the
|
|
11
|
+
// body, and only then parses up to 2 MB. Any other method on the path falls through to the
|
|
12
|
+
// /api gate like every other /api route. It answers only `{}` or a
|
|
13
|
+
// `{"hookSpecificOutput": ...}` the hook prints verbatim, and is mounted unconditionally
|
|
14
|
+
// (not behind COS_THREAD_ATTACH_ENABLED): the switch is `COS_PERMISSION_BROKER`, read per
|
|
15
|
+
// request. It holds no maintenance lease; the broker refuses to park during a drain itself.
|
|
16
|
+
//
|
|
17
|
+
// Mixed versions fail safe. Against a 6.51 server the same script gets 401 from the /api
|
|
18
|
+
// gate, prints nothing, and the native dialog shows, as it always did there.
|
|
19
|
+
//
|
|
20
|
+
// GET /api/session-questions and POST /api/session-questions/:id/answer -- the lens and the
|
|
21
|
+
// phone, behind the API token like every other /api route. Question and command text only
|
|
22
|
+
// ever travels here, never on the display bus. The GET is ALSO the broker's liveness
|
|
23
|
+
// signal (lib/client-liveness.ts), and only when it names the client: `?client=glasses` or
|
|
24
|
+
// `?client=phone`. Anything else (missing, `control`, a typo) is answered but never counts,
|
|
25
|
+
// so a surface that only shows a count can poll without making the Mac hold questions it
|
|
26
|
+
// cannot answer. It checks X-Cos-Token itself as well, in constant time, and a request
|
|
27
|
+
// without it is refused and never counted, whatever sits in front of this router.
|
|
28
|
+
|
|
29
|
+
import express, { Router, type NextFunction, type Request, type Response } from 'express'
|
|
30
|
+
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
31
|
+
import {
|
|
32
|
+
ANSWERING_CLIENTS,
|
|
33
|
+
CLIENT_LIVE_WINDOW_MS,
|
|
34
|
+
QUESTIONS_POLL_INTERVAL_MS,
|
|
35
|
+
SESSION_QUESTIONS_PROTOCOL_VERSION,
|
|
36
|
+
type BrokerAdmission,
|
|
37
|
+
type HookReplyChannel,
|
|
38
|
+
type PermissionBroker,
|
|
39
|
+
} from '../lib/permission-broker.js'
|
|
40
|
+
import { noteQuestionsPoll } from '../lib/client-liveness.js'
|
|
41
|
+
|
|
42
|
+
export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
|
|
43
|
+
/** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
|
|
44
|
+
export const PERMISSION_BROKER_BODY_LIMIT = '2mb'
|
|
45
|
+
|
|
46
|
+
export interface PermissionBrokerHookRouterDeps {
|
|
47
|
+
/** The per-install hook token, or null when none has been minted (then nothing is answered). */
|
|
48
|
+
hookToken: () => string | null
|
|
49
|
+
broker: PermissionBroker
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The hook-token check, as middleware that runs BEFORE any body parser: a request without
|
|
54
|
+
* the token is answered 401 without a byte of its body being read or parsed.
|
|
55
|
+
*/
|
|
56
|
+
export function requireHookToken(hookToken: () => string | null) {
|
|
57
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
58
|
+
res.set('Cache-Control', 'private, no-store')
|
|
59
|
+
let expected: string | null = null
|
|
60
|
+
try { expected = hookToken() } catch { expected = null }
|
|
61
|
+
if (!expected || !timingSafeTokenEqual(req.headers['x-cos-hook-token'], expected)) {
|
|
62
|
+
res.status(401).json({})
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
next()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The held response, as the broker sees it. */
|
|
70
|
+
export function replyChannelFor(res: Response): HookReplyChannel {
|
|
71
|
+
const writable = (): boolean => {
|
|
72
|
+
if (res.headersSent || res.writableEnded || res.destroyed) return false
|
|
73
|
+
const socket = res.socket
|
|
74
|
+
return !!socket && !socket.destroyed && socket.writable !== false
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
writable,
|
|
78
|
+
reply(body) {
|
|
79
|
+
if (!writable()) return false
|
|
80
|
+
try {
|
|
81
|
+
res.status(200).json(body)
|
|
82
|
+
return true
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createPermissionBrokerHookRouter(deps: PermissionBrokerHookRouterDeps): Router {
|
|
91
|
+
const router = Router()
|
|
92
|
+
router.post(
|
|
93
|
+
PERMISSION_BROKER_HOOK_PATH,
|
|
94
|
+
requireHookToken(deps.hookToken),
|
|
95
|
+
express.json({ limit: PERMISSION_BROKER_BODY_LIMIT }),
|
|
96
|
+
async (req: Request, res: Response) => {
|
|
97
|
+
let admission: BrokerAdmission
|
|
98
|
+
try {
|
|
99
|
+
admission = await deps.broker.admit(req.body)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error(`[permission-broker] admit failed: ${error instanceof Error ? error.message : error}`)
|
|
102
|
+
res.json({})
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (!admission.ok) {
|
|
106
|
+
res.json({})
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const channel = replyChannelFor(res)
|
|
110
|
+
// The hook gave up during the desk read: nothing to hold.
|
|
111
|
+
if (!channel.writable()) {
|
|
112
|
+
deps.broker.noteFastPath('hook_gone_early')
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const id = deps.broker.park(admission.request, channel)
|
|
116
|
+
// `res` close, not `req` close: since Node 16 the request emits `close` once its body
|
|
117
|
+
// is read, which is not the hook leaving. A close after the reply is a no-op.
|
|
118
|
+
res.on('close', () => { deps.broker.hookClosed(id) })
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
// A body the parser refused (too large, not JSON): `{}` and nothing else, never a stack.
|
|
122
|
+
router.use(PERMISSION_BROKER_HOOK_PATH, (error: unknown, _req: Request, res: Response, next: NextFunction) => {
|
|
123
|
+
if (res.headersSent) { next(error); return }
|
|
124
|
+
deps.broker.noteFastPath('malformed')
|
|
125
|
+
const status = Number((error as { status?: unknown })?.status)
|
|
126
|
+
res.status(Number.isInteger(status) && status >= 400 && status < 500 ? status : 400).json({})
|
|
127
|
+
})
|
|
128
|
+
return router
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface SessionQuestionsRouterDeps {
|
|
132
|
+
broker: PermissionBroker
|
|
133
|
+
/** The pairing token (X-Cos-Token). A poll counts as a live client only with it. */
|
|
134
|
+
apiToken: () => string
|
|
135
|
+
/** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
|
|
136
|
+
notePoll?: (at: number) => void
|
|
137
|
+
now?: () => number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
|
|
141
|
+
const router = Router()
|
|
142
|
+
const notePoll = deps.notePoll ?? noteQuestionsPoll
|
|
143
|
+
const now = deps.now ?? Date.now
|
|
144
|
+
router.get('/session-questions', (req: Request, res: Response) => {
|
|
145
|
+
res.set('Cache-Control', 'private, no-store')
|
|
146
|
+
let expected = ''
|
|
147
|
+
try { expected = deps.apiToken() } catch { expected = '' }
|
|
148
|
+
// A hook token, or nothing: refused, and never a live client.
|
|
149
|
+
if (!timingSafeTokenEqual(req.headers['x-cos-token'], expected)) {
|
|
150
|
+
res.status(401).json({ error: 'unauthorized' })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
// Only a client that says it can answer (and shows the cards) counts as live.
|
|
154
|
+
const client = typeof req.query.client === 'string' ? req.query.client : ''
|
|
155
|
+
if (ANSWERING_CLIENTS.has(client)) notePoll(now())
|
|
156
|
+
const health = deps.broker.health()
|
|
157
|
+
// Listed first: a deadline the timer has not caught yet is settled by the listing.
|
|
158
|
+
const items = deps.broker.list()
|
|
159
|
+
res.json({
|
|
160
|
+
enabled: health.enabled,
|
|
161
|
+
mode: health.mode,
|
|
162
|
+
protocolVersion: SESSION_QUESTIONS_PROTOCOL_VERSION,
|
|
163
|
+
pollIntervalMs: QUESTIONS_POLL_INTERVAL_MS,
|
|
164
|
+
liveWindowMs: CLIENT_LIVE_WINDOW_MS,
|
|
165
|
+
pending: deps.broker.pendingCount(),
|
|
166
|
+
stats: deps.broker.stats(),
|
|
167
|
+
items,
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
|
|
171
|
+
res.set('Cache-Control', 'private, no-store')
|
|
172
|
+
const result = deps.broker.answer(String(req.params.id ?? ''), req.body)
|
|
173
|
+
res.status(result.status).json(result.body)
|
|
174
|
+
})
|
|
175
|
+
return router
|
|
176
|
+
}
|