@gotcos/glasses-server 6.99.25 → 6.99.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +166 -0
- package/package.json +1 -1
- package/server/index.ts +46 -4
- package/server/lib/agent-session-output-images.ts +280 -0
- package/server/lib/agent-session-store.ts +3 -1
- package/server/lib/agent-session-turns.ts +18 -7
- package/server/lib/client-liveness.ts +38 -3
- package/server/lib/codex-subagent-activity.ts +36 -0
- package/server/lib/g2-turn-provenance.ts +679 -0
- package/server/lib/media-store.ts +41 -2
- package/server/lib/permission-broker.ts +94 -17
- package/server/lib/queue-turn-evidence.ts +19 -2
- package/server/lib/session-halt.ts +15 -1
- package/server/lib/session-stream-events.ts +4 -0
- package/server/lib/session-subagents.ts +155 -0
- package/server/lib/session-turn-attachments.ts +245 -0
- package/server/lib/thread-turn-queue-deliver.ts +17 -2
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +731 -0
- package/server/lib/turn-file-store.ts +202 -0
- package/server/lib/turn-footer.ts +81 -0
- package/server/routes/agent-session-bindings.ts +164 -16
- package/server/routes/agent-sessions.ts +77 -8
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +30 -1
- package/server/routes/g2-turns.ts +46 -0
- package/server/routes/health.ts +9 -0
- package/server/routes/media.ts +32 -8
- package/server/routes/permission-broker.ts +47 -4
- package/server/routes/thread-turn-queue.ts +148 -8
- package/server/routes/today-brief.ts +148 -0
- package/server/web-control/app.css +2 -0
- package/server/web-control/app.js +738 -102
- package/server/web-control/index.html +5 -1
- package/server/web-control/panes/cos-chat.css +18 -0
- package/server/web-control/panes/cos-chat.js +239 -45
- package/server/web-control/panes/meetings.js +20 -9
- package/server/web-control/panes/questions.css +39 -1
- package/server/web-control/panes/questions.js +368 -51
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +2 -0
- package/server/web-control/panes/settings.js +34 -2
- package/server/web-control/panes/today-actions.css +62 -4
- package/server/web-control/panes/today-actions.js +384 -53
|
@@ -83,6 +83,8 @@ import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
|
|
|
83
83
|
import { sessionActFactsFor, sessionActFields } from '../lib/session-act-fields.js'
|
|
84
84
|
import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
|
|
85
85
|
|
|
86
|
+
import { agentSessionOutputImages } from '../lib/agent-session-output-images.js'
|
|
87
|
+
|
|
86
88
|
export const agentSessionsRouter = Router()
|
|
87
89
|
|
|
88
90
|
function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
|
|
@@ -236,7 +238,7 @@ export function withRunning<T extends { session_id: string }>(entry: T, scan: Oc
|
|
|
236
238
|
// clears on its own when the work stops. Still a display hint, never a gate.
|
|
237
239
|
// 6.62.0: or the engine's own hooks (or a Codex rollout) say a turn is open and the
|
|
238
240
|
// last event is inside HOOK_ACTIVE_CAP_MS (plan 1.1-1.3).
|
|
239
|
-
running_active: decides ? hint!.active : occ?.activeRecently === true || hint?.active === true,
|
|
241
|
+
running_active: decides || hint?.activeDecides === true ? hint!.active : occ?.activeRecently === true || hint?.active === true,
|
|
240
242
|
}
|
|
241
243
|
}
|
|
242
244
|
|
|
@@ -254,14 +256,22 @@ export function withRunning<T extends { session_id: string }>(entry: T, scan: Oc
|
|
|
254
256
|
* and an Esc with no Stop looks the same, QA W8);
|
|
255
257
|
* Claude and Codex: no hint (occupancy answers)
|
|
256
258
|
* hook waiting running, not active
|
|
259
|
+
* hook or registry idle (Claude, Codex) not active even if the file was just written
|
|
260
|
+
* (a flush after Stop), unless the transcript's
|
|
261
|
+
* newest record is more than
|
|
262
|
+
* TRANSCRIPT_AFTER_IDLE_GRACE_MS past the idle
|
|
263
|
+
* signal: then no hint (occupancy freshness
|
|
264
|
+
* answers, a hookless restarted turn)
|
|
265
|
+
* hook or registry waiting, ended, failed not active, ownership kept (Cursor: decided)
|
|
257
266
|
* Codex rollout turn open inside the cap running, active (no Codex hooks yet)
|
|
258
267
|
* Cursor, any other hook state not running, not active (`decides`)
|
|
259
268
|
*
|
|
260
269
|
* A hook-derived `running` only exists inside the deriver's 30-minute open-turn ceiling, so an
|
|
261
270
|
* open-quiet Cursor row ends there, or at its Stop or SessionEnd.
|
|
262
271
|
*
|
|
263
|
-
* For Claude and Codex
|
|
264
|
-
*
|
|
272
|
+
* For Claude and Codex, authoritative idle/waiting states suppress recent-write activity
|
|
273
|
+
* for display while leaving ownership intact. Other hints add to occupancy. For Cursor,
|
|
274
|
+
* whose list has no occupancy at all, a hook signal decides, and
|
|
265
275
|
* a running Cursor turn is the user's own (an IDE composer or their terminal; COS's own child
|
|
266
276
|
* is kept out of the phase), so it is foreign.
|
|
267
277
|
*/
|
|
@@ -271,15 +281,42 @@ export interface RunningHint {
|
|
|
271
281
|
foreign: boolean
|
|
272
282
|
/** Cursor with hook evidence: this hint IS the answer, occupancy and file time are not read. */
|
|
273
283
|
decides: boolean
|
|
284
|
+
/** Authoritative non-working state suppresses file-write activity without changing occupancy. */
|
|
285
|
+
activeDecides?: boolean
|
|
274
286
|
}
|
|
275
287
|
|
|
276
|
-
|
|
288
|
+
/**
|
|
289
|
+
* A transcript write this long after the idle signal is new work, not the flush that
|
|
290
|
+
* trails a Stop. Claude writes its last records around the Stop hook; a turn that wakes
|
|
291
|
+
* later (a background-task notification restarts a text-only turn, with no prompt and no
|
|
292
|
+
* tool call, so no hook fires until its Stop) writes well past this.
|
|
293
|
+
*/
|
|
294
|
+
export const TRANSCRIPT_AFTER_IDLE_GRACE_MS = 5_000
|
|
295
|
+
|
|
296
|
+
export function runningHintFor(provider: AgentProvider, derived: DerivedSessionState | undefined, lastEventAt: number | null, now: number, transcriptAt: number | null = null): RunningHint | null {
|
|
277
297
|
const cursor = provider === 'cursor'
|
|
278
298
|
const quiet: RunningHint | null = cursor ? { running: false, active: false, foreign: false, decides: true } : null
|
|
279
299
|
if (!derived) return null
|
|
300
|
+
// The state deriver already arbitrates fresh work, waits and completed turns.
|
|
301
|
+
// A transcript flush after Stop must not turn an idle/waiting badge back to
|
|
302
|
+
// working. Keep ownership/open-window evidence intact: this is display only.
|
|
303
|
+
if ((derived.state_source === 'hook' || derived.state_source === 'registry') && derived.agent_state !== 'running') {
|
|
304
|
+
// An idle signal OLDER than the transcript's newest real record is not the latest
|
|
305
|
+
// word: a turn restarted with no hook (see TRANSCRIPT_AFTER_IDLE_GRACE_MS) is writing.
|
|
306
|
+
// Fall back to occupancy freshness. Idle only: waiting keeps `running: true` and its
|
|
307
|
+
// question; ended and failed stay settled. Cursor's transcript has no clock.
|
|
308
|
+
if (!cursor && derived.agent_state === 'idle' && transcriptAt !== null) {
|
|
309
|
+
const since = Date.parse(derived.state_since)
|
|
310
|
+
const signalAt = Math.max(Number.isFinite(since) ? since : 0, derived.state_source === 'hook' ? lastEventAt ?? 0 : 0)
|
|
311
|
+
if (transcriptAt > signalAt + TRANSCRIPT_AFTER_IDLE_GRACE_MS) return null
|
|
312
|
+
}
|
|
313
|
+
if (cursor) return derived.agent_state === 'waiting'
|
|
314
|
+
? { running: true, active: false, foreign: true, decides: true }
|
|
315
|
+
: quiet
|
|
316
|
+
return { running: derived.agent_state === 'waiting', active: false, foreign: false, decides: false, activeDecides: true }
|
|
317
|
+
}
|
|
280
318
|
const inCap = lastEventAt !== null && now - lastEventAt <= HOOK_ACTIVE_CAP_MS && lastEventAt <= now + 5_000
|
|
281
319
|
if (derived.state_source === 'hook') {
|
|
282
|
-
if (derived.agent_state === 'waiting') return { running: true, active: false, foreign: cursor, decides: cursor }
|
|
283
320
|
if (derived.agent_state === 'running' && inCap) return { running: true, active: true, foreign: cursor, decides: cursor }
|
|
284
321
|
if (derived.agent_state === 'running' && cursor) return { running: true, active: false, foreign: true, decides: true }
|
|
285
322
|
return quiet
|
|
@@ -535,7 +572,7 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
535
572
|
const derived = derivedById.get(row.session_id)
|
|
536
573
|
const signal = signalFor(row.session_id)
|
|
537
574
|
const facts = rolloutById.get(row.session_id) ?? null
|
|
538
|
-
const hint = runningHintFor(row.provider, derived, hintClock(signal, derived, facts), now)
|
|
575
|
+
const hint = runningHintFor(row.provider, derived, hintClock(signal, derived, facts), now, clocks.get(row.session_id) ?? null)
|
|
539
576
|
return {
|
|
540
577
|
...withRunning(toEntry(row, activity[index], derived, queuedOf(row.provider, row.session_id)), running, hint),
|
|
541
578
|
...cursorActivityFields(row.provider, signal),
|
|
@@ -604,6 +641,37 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
|
604
641
|
}
|
|
605
642
|
})
|
|
606
643
|
|
|
644
|
+
// Authenticated by index.ts's shared /api gate. Neither the request nor the response
|
|
645
|
+
// carries a filesystem path; the resolver reauthorizes exact transcript membership.
|
|
646
|
+
agentSessionsRouter.get('/agent-sessions/:provider/:sessionId/output-images/:imageId/content', async (req, res) => {
|
|
647
|
+
res.set('Cache-Control', 'private, no-store')
|
|
648
|
+
res.set('X-Content-Type-Options', 'nosniff')
|
|
649
|
+
const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
|
|
650
|
+
if (!provider || (req.query.variant !== undefined && req.query.variant !== 'thumb' && req.query.variant !== 'phone')) {
|
|
651
|
+
res.status(404).json({ error: 'image_not_found' }); return
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
const content = await agentSessionOutputImages.content(provider, String(req.params.sessionId), String(req.params.imageId), req.query.variant === 'thumb' ? 'thumb' : 'phone')
|
|
655
|
+
if (content.status !== 'ok') {
|
|
656
|
+
res.status(content.status === 'not_found' ? 404 : 503).json({ error: content.status === 'not_found' ? 'image_not_found' : 'image_unavailable' }); return
|
|
657
|
+
}
|
|
658
|
+
res.type('image/jpeg')
|
|
659
|
+
// `send` defaults to `dotfiles: 'ignore'` and applies it to the WHOLE absolute path
|
|
660
|
+
// when no `root` is given, so a media root under `~/.cos-glasses/data` 404'd on
|
|
661
|
+
// every default install (see the header of lib/send-audio.ts). 'allow' is safe: the
|
|
662
|
+
// dot component is our data home, and the resolver above returned an exact,
|
|
663
|
+
// containment-checked path with no caller-supplied segments.
|
|
664
|
+
res.sendFile(content.path, { dotfiles: 'allow' }, err => {
|
|
665
|
+
if (!err) return
|
|
666
|
+
if (!res.headersSent) res.status(503).json({ error: 'image_unavailable' })
|
|
667
|
+
else res.destroy()
|
|
668
|
+
})
|
|
669
|
+
} catch {
|
|
670
|
+
console.warn('[agent-sessions] output image unavailable')
|
|
671
|
+
res.status(503).json({ error: 'image_unavailable' })
|
|
672
|
+
}
|
|
673
|
+
})
|
|
674
|
+
|
|
607
675
|
agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res) => {
|
|
608
676
|
res.set('Cache-Control', 'private, no-store')
|
|
609
677
|
const provider = asProvider(String(req.params.provider ?? '').toLowerCase())
|
|
@@ -647,7 +715,8 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
647
715
|
let recentTurns: RecentTurnsRead | null = null
|
|
648
716
|
if (turnsWanted !== null) {
|
|
649
717
|
try {
|
|
650
|
-
|
|
718
|
+
const images = await agentSessionOutputImages.context(provider, sessionId, found)
|
|
719
|
+
recentTurns = await readRecentSessionTurns(provider, found, turnsWanted, undefined, images.project)
|
|
651
720
|
} catch (error) {
|
|
652
721
|
console.warn(`[agent-sessions] recent turns failed provider=${provider}: ${error instanceof Error ? error.message : error}`)
|
|
653
722
|
}
|
|
@@ -684,7 +753,7 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
684
753
|
derived = deriveCodexRow(parsed.session_id, running.occupied.has(parsed.session_id), rollout, now, false)
|
|
685
754
|
}
|
|
686
755
|
const signal = signalFor(parsed.session_id)
|
|
687
|
-
const hint = runningHintFor(provider, derived, hintClock(signal, derived, rollout), now)
|
|
756
|
+
const hint = runningHintFor(provider, derived, hintClock(signal, derived, rollout), now, activityClockMs(st.mtimeMs, activity))
|
|
688
757
|
res.json({
|
|
689
758
|
...withRunning({ session_id: parsed.session_id }, running, hint),
|
|
690
759
|
...derivedRowFields(derived),
|
|
@@ -32,6 +32,17 @@ export const CLIENT_INSTANCE_MAX_SEEN = 64
|
|
|
32
32
|
|
|
33
33
|
export interface ClientInstanceRouterDeps {
|
|
34
34
|
now?: () => number
|
|
35
|
+
/**
|
|
36
|
+
* G2 authority Tier 1 (6.99.29): the owner-per-device map, shared so the turn provenance
|
|
37
|
+
* can ask which app copy holds the ring for a request's address. Absent: private, as before.
|
|
38
|
+
*/
|
|
39
|
+
owners?: Map<string, ClientInstanceOwner>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The ring owner's id for this device address, when it claimed within `freshMs`; else null. */
|
|
43
|
+
export function pairedInstanceFrom(owners: ReadonlyMap<string, ClientInstanceOwner>, device: string, now: number, freshMs: number): string | null {
|
|
44
|
+
const owner = owners.get(deviceKey(device))
|
|
45
|
+
return owner && now - owner.seenAt <= freshMs ? owner.id : null
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
/** A POST that carries a meeting chunk: a live one, or an iPhone offline chunk replayed. The preview is not one. */
|
|
@@ -49,7 +60,7 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
|
|
|
49
60
|
const opts: ClientInstanceRouterDeps = typeof deps === 'function' ? { now: deps } : deps
|
|
50
61
|
const now = opts.now ?? Date.now
|
|
51
62
|
const router = Router()
|
|
52
|
-
const owners = new Map<string, ClientInstanceOwner>()
|
|
63
|
+
const owners = opts.owners ?? new Map<string, ClientInstanceOwner>()
|
|
53
64
|
const chunks = new ClientChunkLedger()
|
|
54
65
|
// 6.52.3: when each copy last claimed, so an older boot proves it was awake before it
|
|
55
66
|
// may take a quiet ring back. Bounded like the chunk ledger.
|
|
@@ -14,12 +14,15 @@ import express, { Router, type NextFunction, type Request, type Response } from
|
|
|
14
14
|
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
15
15
|
import { claimNextCursorTurn, cursorStopAcceptsFollowup, cursorStopIsFresh, parseCursorStopEnvelope } from '../lib/cursor-stop-followup.js'
|
|
16
16
|
import { MAX_QUEUED_PER_THREAD, type QueuedThreadTurn } from '../lib/thread-turn-queue.js'
|
|
17
|
+
import { hasFooterShapedLine, type G2TurnProvenance } from '../lib/g2-turn-provenance.js'
|
|
17
18
|
|
|
18
19
|
export interface CursorStopFollowupDeps {
|
|
19
20
|
/** The per-install hook token, or null when none has been minted (then nothing is answered). */
|
|
20
21
|
hookToken: () => string | null
|
|
21
22
|
readQueue: (provider: string, threadId: string, now: number) => QueuedThreadTurn[]
|
|
22
23
|
writeQueue: (provider: string, threadId: string, queue: QueuedThreadTurn[]) => void
|
|
24
|
+
/** G2 authority Tier 1 (6.99.29), optional: ledger the hand-off and foot a glasses or phone turn. */
|
|
25
|
+
provenance?: G2TurnProvenance
|
|
23
26
|
now: () => number
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -60,6 +63,20 @@ export function createCursorStopFollowupRouter(deps: CursorStopFollowupDeps): Ro
|
|
|
60
63
|
}
|
|
61
64
|
claimed = claimNextCursorTurn(deps.readQueue('cursor', facts.conversationId, now), now)
|
|
62
65
|
if (!claimed) return res.json({})
|
|
66
|
+
// G2 authority Tier 1 (Codex re-audit of 910eb90): the queue file is mutable, and this is the
|
|
67
|
+
// last hand-off before the words reach the composer. Words carrying a footer-shaped line are
|
|
68
|
+
// refused here exactly as at every other route that hands words to a session: the row is
|
|
69
|
+
// settled refused (never handed over, never retried) and its parked origin forgotten.
|
|
70
|
+
if (deps.provenance && hasFooterShapedLine(claimed.turn.prompt)) {
|
|
71
|
+
const refusedTurn = claimed.turn
|
|
72
|
+
const settled = claimed.queue.map(t => t.clientTurnId === refusedTurn.clientTurnId
|
|
73
|
+
? { ...t, status: 'refused' as const, reason: 'footer_line_refused', settledAt: now }
|
|
74
|
+
: t)
|
|
75
|
+
deps.writeQueue('cursor', facts.conversationId, settled)
|
|
76
|
+
deps.provenance.forgetQueued('cursor', facts.conversationId, refusedTurn.clientTurnId)
|
|
77
|
+
console.warn(`[cursor-stop-followup] refused a queued turn with a footer-shaped line clientTurnId=${refusedTurn.clientTurnId}`)
|
|
78
|
+
return res.json({})
|
|
79
|
+
}
|
|
63
80
|
// Written BEFORE the text leaves: the row must never read `waiting` after the
|
|
64
81
|
// composer has been handed it, or a later Stop would hand it over again.
|
|
65
82
|
deps.writeQueue('cursor', facts.conversationId, claimed.queue)
|
|
@@ -68,7 +85,19 @@ export function createCursorStopFollowupRouter(deps: CursorStopFollowupDeps): Ro
|
|
|
68
85
|
return res.json({})
|
|
69
86
|
}
|
|
70
87
|
console.log(`[cursor-stop-followup] handed a queued turn to Cursor clientTurnId=${claimed.turn.clientTurnId} chars=${claimed.turn.prompt.length} loop=${facts.loopCount ?? 'null'} cursor=${facts.cursorVersion}`)
|
|
71
|
-
|
|
88
|
+
let followup = claimed.turn.prompt
|
|
89
|
+
if (deps.provenance) {
|
|
90
|
+
// The hook's own socket is local; the origin is the one captured when the turn was
|
|
91
|
+
// parked, and only while the queued text is still the text it vouched for.
|
|
92
|
+
const turn = claimed.turn
|
|
93
|
+
const verdict = deps.provenance.queuedVerdict('cursor', facts.conversationId, turn.clientTurnId, turn.prompt) ?? deps.provenance.originOf(req)
|
|
94
|
+
followup = deps.provenance.footed(turn.prompt, turn.clientTurnId, verdict, 'cursor')
|
|
95
|
+
// The queue's own hand-off of its parked turn: it continues the record parked under this id.
|
|
96
|
+
deps.provenance.record({ turnId: turn.clientTurnId, prompt: turn.prompt, verdict, provider: 'cursor', sessionId: facts.conversationId, via: 'cursor-stop-followup', continues: true })
|
|
97
|
+
// Settled (handed over): the parked origin is forgotten (QA 6.99.29).
|
|
98
|
+
deps.provenance.forgetQueued('cursor', facts.conversationId, turn.clientTurnId)
|
|
99
|
+
}
|
|
100
|
+
return res.json({ followup_message: followup })
|
|
72
101
|
})
|
|
73
102
|
return router
|
|
74
103
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// GET /api/g2-turns/:turnId (and its 6.99.29 alias GET /api/turn-provenance/:turnId): read-only provenance for one turn this server delivered or queued
|
|
2
|
+
// into an agent session (G2 authority Tier 1, see lib/g2-turn-provenance.ts). Behind the global
|
|
3
|
+
// X-Cos-Token gate like every /api route. 400 on a malformed id, 404 `{ found: false }` when no
|
|
4
|
+
// record names it. Answers what was recorded, and vouches (`verifiedOrigin: true`) only for a
|
|
5
|
+
// glasses or phone record this process wrote and still holds, written within 24 hours. A web record
|
|
6
|
+
// is reported with `reason: web_cookie_derivable_from_token` and never vouched (6.99.29).
|
|
7
|
+
|
|
8
|
+
import { Router } from 'express'
|
|
9
|
+
import { timingSafeTokenEqual } from '../lib/token-auth.js'
|
|
10
|
+
import { describeG2Turn, G2_TURN_ID_RE, type G2TurnProvenance } from '../lib/g2-turn-provenance.js'
|
|
11
|
+
|
|
12
|
+
export function createG2TurnsRouter(deps: {
|
|
13
|
+
provenance: G2TurnProvenance
|
|
14
|
+
now?: () => number
|
|
15
|
+
/**
|
|
16
|
+
* 6.99.29: the pairing token. With it, only a request that presents X-Cos-Token is answered: the
|
|
17
|
+
* /api gate also admits a COS Control Web browser by its cookie, and the verifier runs on this Mac
|
|
18
|
+
* with the token. Absent: whatever the gate admitted (the 6.66 tests mount the gate alone).
|
|
19
|
+
*/
|
|
20
|
+
apiToken?: () => string
|
|
21
|
+
}): Router {
|
|
22
|
+
const router = Router()
|
|
23
|
+
const now = deps.now ?? Date.now
|
|
24
|
+
// 6.99.29: one handler, two paths. `/turn-provenance` names what it is now that web turns are
|
|
25
|
+
// recorded too (reported, never vouched: lib/g2-turn-provenance.ts); `/g2-turns` stays for
|
|
26
|
+
// verify_g2_turn.py and every caller of the 6.66 design.
|
|
27
|
+
router.get(['/g2-turns/:turnId', '/turn-provenance/:turnId'], async (req, res) => {
|
|
28
|
+
res.set('Cache-Control', 'private, no-store')
|
|
29
|
+
if (deps.apiToken) {
|
|
30
|
+
let expected = ''
|
|
31
|
+
try { expected = deps.apiToken() } catch { expected = '' }
|
|
32
|
+
if (!expected || !timingSafeTokenEqual(req.headers['x-cos-token'], expected)) return void res.status(401).json({ found: false, error: 'token_required' })
|
|
33
|
+
}
|
|
34
|
+
const turnId = String(req.params.turnId ?? '')
|
|
35
|
+
if (!G2_TURN_ID_RE.test(turnId)) return void res.status(400).json({ found: false, error: 'invalid_turn_id' })
|
|
36
|
+
try {
|
|
37
|
+
const answer = await describeG2Turn(deps.provenance, turnId, now())
|
|
38
|
+
if (!answer) return void res.status(404).json({ found: false, turnId })
|
|
39
|
+
res.json(answer)
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`[g2-turns] lookup failed: ${error instanceof Error ? error.message : error}`)
|
|
42
|
+
res.status(500).json({ found: false, error: 'lookup_failed' })
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
return router
|
|
46
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { MAX_ATTACHMENTS_PER_PROMPT } from '../../shared/media-attachment.js'
|
|
2
|
+
import { TURN_FILE_MAX_BYTES } from '../lib/turn-file-store.js'
|
|
1
3
|
import { dropItCapability } from '../lib/drop-settings.js'
|
|
2
4
|
import { PREP_CAPABILITY } from '../../shared/prep.js'
|
|
3
5
|
import { phoneMicrophoneMeetingsCapability } from '../lib/phone-recording.js'
|
|
@@ -446,6 +448,13 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
446
448
|
...checks,
|
|
447
449
|
server_version: managedServerVersion(),
|
|
448
450
|
...threadAttachHealthFields(threadAttach),
|
|
451
|
+
// 11/07 (Control Web attach): a session turn takes up to this many media-store ids as `attachmentIds`, and the turns
|
|
452
|
+
// route writes each file's path on this Mac under the words. 0 with attach off; absent on an older server. Either
|
|
453
|
+
// way a client sends words only.
|
|
454
|
+
threadTurnAttachments: threadAttach.enabled ? MAX_ATTACHMENTS_PER_PROMPT : 0,
|
|
455
|
+
// 6.99.27: a session turn also takes ANY file up to this size (lib/turn-file-store.ts), sent to /api/media/file with
|
|
456
|
+
// X-COS-Purpose: session-turn. 0 with attach off; absent on an older server, which takes only what the media store reads.
|
|
457
|
+
threadTurnFileBytes: threadAttach.enabled ? TURN_FILE_MAX_BYTES : 0,
|
|
449
458
|
...sessionHooksHealthFields(),
|
|
450
459
|
// 6.52.0: counts, the mode and two timestamps; never an id, a question, a command, or
|
|
451
460
|
// how many are held (that is on the authenticated questions route).
|
package/server/routes/media.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// /api/media/file STREAMS to disk rather than buffering: see
|
|
23
23
|
// createMediaBinaryBodyParser below.
|
|
24
24
|
|
|
25
|
+
import { getTurnFileStore } from '../lib/turn-file-store.js'
|
|
25
26
|
import { Router, json, type NextFunction, type Request, type Response } from 'express'
|
|
26
27
|
import { createReadStream, createWriteStream, openSync, statSync } from 'node:fs'
|
|
27
28
|
import {
|
|
@@ -554,6 +555,15 @@ mediaRouter.post('/media', async (req: Request, res: Response) => {
|
|
|
554
555
|
* bounded requests, but composer admission still owns the shared five-item
|
|
555
556
|
* prompt cap. Filename and MIME are hints only; the store sniffs and validates
|
|
556
557
|
* the actual staged bytes. */
|
|
558
|
+
/**
|
|
559
|
+
* 6.99.27: does a refused upload become a turn file? Only for a session turn, and only when the media store refused the
|
|
560
|
+
* FORMAT (it cannot read the type, or the text is not UTF-8). Too large, too long, or the Mac unable to process media
|
|
561
|
+
* right now are said as they always were.
|
|
562
|
+
*/
|
|
563
|
+
export function keepsAsTurnFile(purpose: string | undefined, code: unknown): boolean {
|
|
564
|
+
return purpose === 'session-turn' && (code === 'unsupported_attachment_format' || code === 'corrupt_attachment')
|
|
565
|
+
}
|
|
566
|
+
|
|
557
567
|
mediaRouter.post('/media/file', async (req: Request, res: Response) => {
|
|
558
568
|
const staged = takeStagedUploadBody(req)
|
|
559
569
|
try {
|
|
@@ -566,14 +576,28 @@ mediaRouter.post('/media/file', async (req: Request, res: Response) => {
|
|
|
566
576
|
if (rawLabel) {
|
|
567
577
|
try { label = decodeURIComponent(rawLabel).slice(0, 120) } catch { label = rawLabel.slice(0, 120) }
|
|
568
578
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
579
|
+
let attachment: unknown
|
|
580
|
+
try {
|
|
581
|
+
attachment = await getMediaStore().ingestRichMediaFromFile({
|
|
582
|
+
sourcePath: staged.path,
|
|
583
|
+
byteLength: staged.bytes,
|
|
584
|
+
label,
|
|
585
|
+
declaredMime: safeString(req.header('content-type'), 120),
|
|
586
|
+
capturedAt: safeString(req.header('x-cos-captured-at'), 40),
|
|
587
|
+
sessionId: safeString(req.header('x-cos-session-id'), 64),
|
|
588
|
+
})
|
|
589
|
+
} catch (err) {
|
|
590
|
+
// 6.99.27: a file for a Claude or Codex session turn, in a format the media store does not read (a .zip, a .docx,
|
|
591
|
+
// a log in another encoding). The session gets its path and opens it itself, so it is kept as a turn file, which
|
|
592
|
+
// only the turn and fork routes can resolve (lib/turn-file-store.ts). Anything else refuses as before.
|
|
593
|
+
if (!keepsAsTurnFile(req.header('x-cos-purpose'), (err as { code?: unknown } | null)?.code)) throw err
|
|
594
|
+
attachment = getTurnFileStore().ingestFromFile({
|
|
595
|
+
sourcePath: staged.path,
|
|
596
|
+
byteLength: staged.bytes,
|
|
597
|
+
label,
|
|
598
|
+
declaredMime: safeString(req.header('content-type'), 120),
|
|
599
|
+
})
|
|
600
|
+
}
|
|
577
601
|
res.json({ attachment })
|
|
578
602
|
} catch (err) {
|
|
579
603
|
sendMediaError(res, err)
|
|
@@ -35,8 +35,14 @@
|
|
|
35
35
|
// The hook token still opens nothing here. A browser poll counts as a live answering client
|
|
36
36
|
// only with `?client=web`, which the page sends only while it shows the cards (a visible tab);
|
|
37
37
|
// the same 30 s window as the phone. A cookie poll naming `glasses` or `phone`, or no client,
|
|
38
|
-
// never counts, and a token poll naming `web` never counts either.
|
|
39
|
-
//
|
|
38
|
+
// never counts, and a token poll naming `web` never counts either.
|
|
39
|
+
// 6.99.26 (Miles 11/07, "Away rule, like phone"): a browser poll `?client=web&hold=1&presenceAgeMs=<int>`
|
|
40
|
+
// (the ms since a touch or a key while the page was in front) records COS Control Web's presence for the
|
|
41
|
+
// away hold ONLY (lib/client-liveness noteWebPresence): never a live client, so at the desk the Mac's own
|
|
42
|
+
// pop-up still comes at once (6.99.13). Without `hold=1` or a valid `presenceAgeMs` it records nothing. A
|
|
43
|
+
// token poll naming `web`, and a browser poll naming anything else, still record nothing. QA round 1: neither
|
|
44
|
+
// does a poll from the Mac itself (loopback, or one of its own addresses, with no proxy header): Control Web
|
|
45
|
+
// open on the Mac is the desk, never away.
|
|
40
46
|
// 6.60.0: a poll with `&hold=1&presenceAgeMs=<int>` (COS Glasses 6.9.563 and later) records
|
|
41
47
|
// wearer presence for the away hold instead of the 6.59.0 liveness; a `hold=1` poll without
|
|
42
48
|
// a valid `presenceAgeMs` makes no client live and enables no hold. The response adds
|
|
@@ -53,7 +59,9 @@ import {
|
|
|
53
59
|
type HookReplyChannel,
|
|
54
60
|
type PermissionBroker,
|
|
55
61
|
} from '../lib/permission-broker.js'
|
|
56
|
-
import { noteQuestionsPoll, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
|
|
62
|
+
import { noteQuestionsPoll, noteWebPresence, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
|
|
63
|
+
import { hasProxyForwardedHeaders, isLoopbackAddress, normalizeRemoteIp, reachableIpv4Addresses } from '../lib/network-policy.js'
|
|
64
|
+
import { networkInterfaces } from 'node:os'
|
|
57
65
|
|
|
58
66
|
export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
|
|
59
67
|
/** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
|
|
@@ -155,6 +163,10 @@ export interface SessionQuestionsRouterDeps {
|
|
|
155
163
|
webSessionAuthorized?: (req: Request) => boolean
|
|
156
164
|
/** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
|
|
157
165
|
notePoll?: (at: number, poll: QuestionsPollFacts) => void
|
|
166
|
+
/** 6.99.26: where COS Control Web's presence is recorded; `noteWebPresence` in production. */
|
|
167
|
+
noteWebPresence?: (at: number, presenceAgeMs: number) => void
|
|
168
|
+
/** 6.99.26 (QA round 1): whether a request comes from this Mac itself; `requestFromThisMac` in production. */
|
|
169
|
+
fromThisMac?: (req: Request) => boolean
|
|
158
170
|
now?: () => number
|
|
159
171
|
/** 6.60.0: when this server process started (ISO); defaults to the process's own start. */
|
|
160
172
|
serverStartedAt?: string
|
|
@@ -176,9 +188,30 @@ export function questionsPollFacts(query: Record<string, unknown>): QuestionsPol
|
|
|
176
188
|
return { hold, presenceAgeMs: hold && validPresenceAgeMs(parsed) ? parsed : null }
|
|
177
189
|
}
|
|
178
190
|
|
|
191
|
+
/**
|
|
192
|
+
* 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
|
+
* tailnet address first), and no proxy header says it was forwarded for someone else (a `tailscale serve` hop arrives on
|
|
194
|
+
* loopback for a remote peer). Control Web open on the Mac is the desk: its touch never makes the Mac away.
|
|
195
|
+
*/
|
|
196
|
+
export function requestFromThisMac(
|
|
197
|
+
req: { socket?: { remoteAddress?: string | undefined } | null; headers?: Record<string, unknown> | null },
|
|
198
|
+
selfAddresses: () => readonly string[] = () => reachableIpv4Addresses(networkInterfaces()).map(a => a.address),
|
|
199
|
+
): boolean {
|
|
200
|
+
if (hasProxyForwardedHeaders(req.headers)) return false
|
|
201
|
+
const remote = req.socket?.remoteAddress
|
|
202
|
+
if (isLoopbackAddress(remote)) return true
|
|
203
|
+
const ip = normalizeRemoteIp(remote ?? '')
|
|
204
|
+
if (!ip) return false
|
|
205
|
+
let own: readonly string[] = []
|
|
206
|
+
try { own = selfAddresses() } catch { own = [] }
|
|
207
|
+
return own.includes(ip)
|
|
208
|
+
}
|
|
209
|
+
|
|
179
210
|
export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
|
|
180
211
|
const router = Router()
|
|
181
212
|
const notePoll = deps.notePoll ?? noteQuestionsPoll
|
|
213
|
+
const noteWeb = deps.noteWebPresence ?? ((at: number, ageMs: number) => noteWebPresence(at, ageMs))
|
|
214
|
+
const fromThisMac = deps.fromThisMac ?? ((req: Request) => requestFromThisMac(req))
|
|
182
215
|
const now = deps.now ?? Date.now
|
|
183
216
|
router.get('/session-questions', (req: Request, res: Response) => {
|
|
184
217
|
res.set('Cache-Control', 'private, no-store')
|
|
@@ -199,6 +232,11 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
199
232
|
if (byToken && ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
|
|
200
233
|
// 6.99.13 (Miles, option B): a browser poll never counts as live, so an open web tab never holds a question
|
|
201
234
|
// back from the Mac's own pop-up. The web still lists and answers what the glasses or phone are holding.
|
|
235
|
+
// 6.99.26: its touch counts for the away hold only, like the phone's (Miles 11/07, "Away rule, like phone").
|
|
236
|
+
if (byWebSession && client === WEB_ANSWERING_CLIENT) {
|
|
237
|
+
const facts = questionsPollFacts(req.query as Record<string, unknown>)
|
|
238
|
+
if (facts.presenceAgeMs !== null && facts.presenceAgeMs !== undefined && !fromThisMac(req)) noteWeb(now(), facts.presenceAgeMs)
|
|
239
|
+
}
|
|
202
240
|
const health = deps.broker.health()
|
|
203
241
|
// Listed first: a deadline the timer has not caught yet is settled by the listing.
|
|
204
242
|
const items = deps.broker.list()
|
|
@@ -227,7 +265,12 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
|
|
|
227
265
|
})
|
|
228
266
|
router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
|
|
229
267
|
res.set('Cache-Control', 'private, no-store')
|
|
230
|
-
|
|
268
|
+
// 6.99.27: label the answer by its door (the /api gate already let it in): the token is the Even app, anything else
|
|
269
|
+
// is COS Control Web's browser session.
|
|
270
|
+
let expected = ''
|
|
271
|
+
try { expected = deps.apiToken() } catch { expected = '' }
|
|
272
|
+
const by = timingSafeTokenEqual(req.headers['x-cos-token'], expected) ? 'app' : 'web'
|
|
273
|
+
const result = deps.broker.answer(String(req.params.id ?? ''), req.body, by)
|
|
231
274
|
res.status(result.status).json(result.body)
|
|
232
275
|
})
|
|
233
276
|
return router
|