@gotcos/glasses-server 6.99.25 → 6.99.33

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +224 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +46 -4
  4. package/server/lib/agent-session-output-images.ts +280 -0
  5. package/server/lib/agent-session-store.ts +3 -1
  6. package/server/lib/agent-session-turns.ts +18 -7
  7. package/server/lib/client-liveness.ts +38 -3
  8. package/server/lib/codex-subagent-activity.ts +36 -0
  9. package/server/lib/g2-turn-provenance.ts +679 -0
  10. package/server/lib/media-store.ts +41 -2
  11. package/server/lib/permission-broker.ts +94 -17
  12. package/server/lib/queue-turn-evidence.ts +19 -2
  13. package/server/lib/session-halt.ts +15 -1
  14. package/server/lib/session-stream-events.ts +4 -0
  15. package/server/lib/session-subagents.ts +155 -0
  16. package/server/lib/session-turn-attachments.ts +245 -0
  17. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  18. package/server/lib/thread-turn-queue.ts +10 -0
  19. package/server/lib/today-brief.ts +731 -0
  20. package/server/lib/turn-file-store.ts +202 -0
  21. package/server/lib/turn-footer.ts +81 -0
  22. package/server/routes/agent-session-bindings.ts +164 -16
  23. package/server/routes/agent-sessions.ts +77 -8
  24. package/server/routes/client-instance.ts +12 -1
  25. package/server/routes/cursor-stop-followup.ts +30 -1
  26. package/server/routes/g2-turns.ts +46 -0
  27. package/server/routes/health.ts +9 -0
  28. package/server/routes/media.ts +32 -8
  29. package/server/routes/permission-broker.ts +47 -4
  30. package/server/routes/thread-turn-queue.ts +148 -8
  31. package/server/routes/today-brief.ts +148 -0
  32. package/server/web-control/app.css +42 -9
  33. package/server/web-control/app.js +975 -114
  34. package/server/web-control/control.css +12 -6
  35. package/server/web-control/index.html +6 -1
  36. package/server/web-control/panes/cos-chat.css +27 -3
  37. package/server/web-control/panes/cos-chat.js +240 -46
  38. package/server/web-control/panes/meetings.css +5 -2
  39. package/server/web-control/panes/meetings.js +21 -10
  40. package/server/web-control/panes/memories.css +1 -1
  41. package/server/web-control/panes/memories.js +3 -8
  42. package/server/web-control/panes/questions.css +56 -2
  43. package/server/web-control/panes/questions.js +454 -56
  44. package/server/web-control/panes/reader-media.js +154 -0
  45. package/server/web-control/panes/sessions.css +20 -7
  46. package/server/web-control/panes/settings.js +34 -2
  47. package/server/web-control/panes/speakers.css +3 -5
  48. package/server/web-control/panes/speakers.js +7 -4
  49. package/server/web-control/panes/threads.js +3 -2
  50. package/server/web-control/panes/today-actions.css +81 -3
  51. package/server/web-control/panes/today-actions.js +527 -62
  52. package/server/web-control/panes/work.css +1 -1
  53. package/server/web-control/shell.js +3 -0
@@ -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 the hint only ADDS to occupancy (a held, freshly written thread still
264
- * reads as it did). For Cursor, whose list has no occupancy at all, a hook signal decides, and
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
- export function runningHintFor(provider: AgentProvider, derived: DerivedSessionState | undefined, lastEventAt: number | null, now: number): RunningHint | null {
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
- recentTurns = await readRecentSessionTurns(provider, found, turnsWanted)
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
- return res.json({ followup_message: claimed.turn.prompt })
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
+ }
@@ -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).
@@ -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
- const attachment = await getMediaStore().ingestRichMediaFromFile({
570
- sourcePath: staged.path,
571
- byteLength: staged.bytes,
572
- label,
573
- declaredMime: safeString(req.header('content-type'), 120),
574
- capturedAt: safeString(req.header('x-cos-captured-at'), 40),
575
- sessionId: safeString(req.header('x-cos-session-id'), 64),
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. A browser never reports
39
- // wearer presence, so it never enables the away hold.
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
- const result = deps.broker.answer(String(req.params.id ?? ''), req.body)
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