@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
@@ -0,0 +1,202 @@
1
+ // Any file, for a session turn only (Miles, 2026-11-07: "We still don't have the ability to take picture, or add photo
2
+ // or files from the web").
3
+ //
4
+ // The media store takes what a MODEL can read (photos, PDF, TXT, MD, CSV, JSON, XLSX, MP4, MOV): it extracts text and
5
+ // frames, and an Ask, a Drop or Prep hands those to a model. A Claude or Codex session on this Mac is different: it gets
6
+ // the file's path (lib/session-turn-attachments.ts) and opens it with its own tools, so a .zip, a .docx, a log or a
7
+ // HEIC is useful there. This keeps those, and only those, beside the media store:
8
+ //
9
+ // - Same upload: POST /api/media/file. Only when the upload says `X-COS-Purpose: session-turn` AND the media store
10
+ // refuses the file as a format it cannot read does the file land here instead (routes/media.ts).
11
+ // - Same ids (m_ + 24 hex), but never in the media index: an Ask, a Drop, Prep or GET /api/media/:id cannot find one
12
+ // (media_not_found), so a file no model can read never reaches a model. Only the turn and fork routes resolve them.
13
+ // - Same cap as the media store's own for a file that is not a video: 64 MB (MAX_OTHER_MEDIA_BYTES).
14
+ // - Kept 4 hours until a turn takes it, then 30 days. Swept on each new file, at boot and every 5 minutes after
15
+ // (startSweep, beside the media store's own GC; QA 6.99.27).
16
+ // - Each file is `<root>/<id>/file/<its own name, made safe>`, so the agent sees a real name and extension, and the
17
+ // record is `<root>/<id>/meta.json`, one folder up. No name a person gives a file can reach the record: on the
18
+ // default case-insensitive macOS disk a file named META.JSON beside the record WAS the record, and the record write
19
+ // replaced its bytes (QA, 6.99.27). Nothing here ever serves the bytes back over HTTP.
20
+
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, copyFileSync, statSync } from 'node:fs'
22
+ import { join, resolve, sep } from 'node:path'
23
+ import { randomBytes } from 'node:crypto'
24
+ import { DATA_DIR } from './data-dir.js'
25
+ import { atomicWriteFileSync } from './atomic-fs.js'
26
+ import { MAX_OTHER_MEDIA_BYTES, RichMediaSafetyError } from './rich-media-safety.js'
27
+ import { isValidMediaId, type MediaAttachmentRef } from '../../shared/media-attachment.js'
28
+ import { MediaStoreError, MEDIA_GC_INTERVAL_MS } from './media-store.js'
29
+
30
+ export const TURN_FILE_MAX_BYTES = MAX_OTHER_MEDIA_BYTES
31
+ export const TURN_FILE_STAGED_TTL_MS = 4 * 60 * 60_000
32
+ export const TURN_FILE_KEPT_TTL_MS = 30 * 24 * 60 * 60_000
33
+ const PAYLOAD_DIR = 'file'
34
+
35
+ interface TurnFileMeta {
36
+ id: string
37
+ name: string
38
+ label: string
39
+ mime: string
40
+ bytes: number
41
+ createdAtMs: number
42
+ associatedAtMs?: number
43
+ runId?: string
44
+ }
45
+
46
+ /** What the upload answers: an id and what it is. Never a path. */
47
+ export interface TurnFileRef {
48
+ id: string
49
+ kind: 'session_file'
50
+ mime: string
51
+ bytes: number
52
+ label: string
53
+ createdAt: string
54
+ }
55
+
56
+ /** A filename the agent can read, that cannot climb out of its folder or carry control characters. */
57
+ export function safeTurnFileName(label: string | undefined): string {
58
+ // eslint-disable-next-line no-control-regex
59
+ // QA 6.99.27: bidi controls go too, so the name on disk (and in the path the agent reads) shows as what it is.
60
+ let name = String(label ?? '').replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '')
61
+ .replace(/[\u0000-\u001f\u007f\u2028\u2029/\\:]+/g, ' ').replace(/\s+/g, ' ').trim()
62
+ name = name.replace(/^[.\s]+/, '').trimEnd()
63
+ if (!name) name = 'file'
64
+ if (name.length > 120) {
65
+ const dot = name.lastIndexOf('.')
66
+ const ext = dot > 0 && name.length - dot <= 12 ? name.slice(dot) : ''
67
+ name = name.slice(0, 120 - ext.length).trimEnd() + ext
68
+ }
69
+ return name
70
+ }
71
+
72
+ function safeMime(declared: string | undefined): string {
73
+ const m = String(declared ?? '').toLowerCase().split(';', 1)[0]!.trim()
74
+ return /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/.test(m) ? m : 'application/octet-stream'
75
+ }
76
+
77
+ export class TurnFileStore {
78
+ readonly root: string
79
+ private readonly now: () => number
80
+ constructor(root: string = join(DATA_DIR, 'turn-files'), now: () => number = Date.now) {
81
+ this.root = resolve(root)
82
+ this.now = now
83
+ }
84
+
85
+ private dirOf(id: string): string { return join(this.root, id) }
86
+ /** The file's own folder, apart from the record (see the header). */
87
+ private payloadDirOf(id: string): string { return join(this.root, id, PAYLOAD_DIR) }
88
+
89
+ private readMeta(id: string): TurnFileMeta | null {
90
+ if (!isValidMediaId(id)) return null
91
+ const path = join(this.dirOf(id), 'meta.json')
92
+ if (!existsSync(path)) return null
93
+ try {
94
+ const m = JSON.parse(readFileSync(path, 'utf8')) as TurnFileMeta
95
+ if (m.id !== id || typeof m.name !== 'string' || m.name !== safeTurnFileName(m.name) || typeof m.createdAtMs !== 'number') return null
96
+ return m
97
+ } catch { return null }
98
+ }
99
+
100
+ private expired(m: TurnFileMeta, now: number): boolean {
101
+ return typeof m.associatedAtMs === 'number' ? now - m.associatedAtMs > TURN_FILE_KEPT_TTL_MS : now - m.createdAtMs > TURN_FILE_STAGED_TTL_MS
102
+ }
103
+
104
+ /** Takes a staged upload (moved, else copied). Throws RichMediaSafetyError('attachment_too_large') past the cap. */
105
+ ingestFromFile(input: { sourcePath: string; byteLength: number; label?: string; declaredMime?: string }): TurnFileRef {
106
+ if (!Number.isSafeInteger(input.byteLength) || input.byteLength <= 0) throw new RichMediaSafetyError('corrupt_attachment', 'attachment is empty')
107
+ if (input.byteLength > TURN_FILE_MAX_BYTES) throw new RichMediaSafetyError('attachment_too_large', `attachment exceeds ${TURN_FILE_MAX_BYTES} byte limit`)
108
+ this.sweep()
109
+ const id = 'm_' + randomBytes(12).toString('hex')
110
+ const dir = this.dirOf(id)
111
+ const payloadDir = this.payloadDirOf(id)
112
+ mkdirSync(payloadDir, { recursive: true, mode: 0o700 })
113
+ const name = safeTurnFileName(input.label)
114
+ const target = join(payloadDir, name)
115
+ try {
116
+ renameSync(input.sourcePath, target)
117
+ } catch {
118
+ copyFileSync(input.sourcePath, target)
119
+ }
120
+ const now = this.now()
121
+ const meta: TurnFileMeta = { id, name, label: String(input.label ?? '').slice(0, 120) || name, mime: safeMime(input.declaredMime), bytes: input.byteLength, createdAtMs: now }
122
+ atomicWriteFileSync(join(dir, 'meta.json'), JSON.stringify(meta))
123
+ return { id, kind: 'session_file', mime: meta.mime, bytes: meta.bytes, label: meta.label, createdAt: new Date(now).toISOString() }
124
+ }
125
+
126
+ /** The file's ref (in the media ref's shape, for the turn's line) and path, or a media-store error. */
127
+ resolveUsable(id: string): { record: { ref: MediaAttachmentRef }; path: string } {
128
+ const m = this.readMeta(id)
129
+ if (!m) throw new MediaStoreError('media_not_found', `attachment ${id} not found`)
130
+ if (this.expired(m, this.now())) throw new MediaStoreError('media_expired', `attachment ${id} expired`)
131
+ const path = join(this.payloadDirOf(id), m.name)
132
+ if (!resolve(path).startsWith(this.payloadDirOf(id) + sep) || !existsSync(path)) throw new MediaStoreError('media_unavailable', `attachment ${id} content unavailable`)
133
+ // Shaped as a ref only for the line the agent reads (kind and mime are not in the media contract and never leave
134
+ // this server as one).
135
+ const ref = { id, kind: 'session_file', category: 'file', mime: m.mime, width: 0, height: 0, createdAt: new Date(m.createdAtMs).toISOString(), bytes: m.bytes, label: m.label } as unknown as MediaAttachmentRef
136
+ return { record: { ref }, path }
137
+ }
138
+
139
+ has(id: string): boolean { return this.readMeta(id) !== null }
140
+
141
+ /** Kept for 30 days from now: a turn took it. */
142
+ async associate(ids: string[], target: { runId?: string }): Promise<void> {
143
+ const now = this.now()
144
+ for (const id of ids) {
145
+ const m = this.readMeta(id)
146
+ if (!m) throw new MediaStoreError('media_not_found', `attachment ${id} not found`)
147
+ if (this.expired(m, now)) throw new MediaStoreError('media_expired', `attachment ${id} expired`)
148
+ m.associatedAtMs = now
149
+ if (target.runId) m.runId = target.runId.slice(0, 160)
150
+ atomicWriteFileSync(join(this.dirOf(id), 'meta.json'), JSON.stringify(m))
151
+ }
152
+ }
153
+
154
+ private sweepTimer: ReturnType<typeof setInterval> | null = null
155
+ /**
156
+ * QA 6.99.27 (Skeptic W2): a sweep at once and then every `intervalMs` (the media store's GC interval), so a file
157
+ * past its time goes even when no new file arrives. Before, only an upload swept. Idempotent; the timer never holds
158
+ * the process open.
159
+ */
160
+ startSweep(intervalMs: number = MEDIA_GC_INTERVAL_MS): void {
161
+ if (this.sweepTimer) return
162
+ this.sweep()
163
+ this.sweepTimer = setInterval(() => { this.sweep() }, intervalMs)
164
+ this.sweepTimer.unref?.()
165
+ }
166
+ stopSweep(): void {
167
+ if (this.sweepTimer) clearInterval(this.sweepTimer)
168
+ this.sweepTimer = null
169
+ }
170
+
171
+ /** Removes expired files and folders that are not ours. Never throws. */
172
+ sweep(): number {
173
+ let removed = 0
174
+ if (!existsSync(this.root)) return 0
175
+ const now = this.now()
176
+ let names: string[] = []
177
+ try { names = readdirSync(this.root) } catch { return 0 }
178
+ for (const name of names) {
179
+ const dir = join(this.root, name)
180
+ try {
181
+ if (!statSync(dir).isDirectory()) continue
182
+ const m = this.readMeta(name)
183
+ // A folder with no readable meta is a crashed upload once it is past the staged TTL.
184
+ const stale = m ? this.expired(m, now) : now - statSync(dir).mtimeMs > TURN_FILE_STAGED_TTL_MS
185
+ if (stale) { rmSync(dir, { recursive: true, force: true }); removed++ }
186
+ } catch { /* one bad folder never stops the sweep */ }
187
+ }
188
+ return removed
189
+ }
190
+ }
191
+
192
+ let defaultStore: TurnFileStore | null = null
193
+ export function getTurnFileStore(): TurnFileStore {
194
+ if (!defaultStore) defaultStore = new TurnFileStore()
195
+ return defaultStore
196
+ }
197
+ /** Test hook. */
198
+ export function _setTurnFileStoreForTests(store: TurnFileStore | null): TurnFileStore | null {
199
+ const prev = defaultStore
200
+ defaultStore = store
201
+ return prev
202
+ }
@@ -0,0 +1,81 @@
1
+ // The one trailing verify line a vouched glasses or phone turn carries into an agent session (G2
2
+ // authority Tier 1, 6.99.29). PURE: no I/O, no imports, so the stream grammar
3
+ // (session-stream-events.ts), the transcript readers and the queue evidence can take the line off
4
+ // without importing the ledger. lib/g2-turn-provenance.ts re-exports all of it.
5
+ //
6
+ // [COS-G2 turn <id> · verify: operations/scripts/cos_python operations/scripts/verify_g2_turn.py <id> --session <your session id> --provider claude]
7
+ //
8
+ // The provider is filled in (claude, codex or cursor): the server footing the turn knows which
9
+ // harness it delivers into (Codex re-audit of 910eb90). The session stays a placeholder ON PURPOSE:
10
+ // the receiving agent states its OWN session id and the verifier compares it with the record, so a
11
+ // message that landed in the wrong session does not verify there. An unknown provider keeps the
12
+ // `<claude|codex|cursor>` placeholder rather than naming a wrong one.
13
+ //
14
+ // The COS-WEB tag is reserved for a future web origin backed by a device key the API token cannot
15
+ // produce. In 6.99.29 no turn is footed COS-WEB: a web turn is recorded, never vouched. The readers
16
+ // still take either tag off.
17
+ //
18
+ // The verifier (MU-Chief-Staff operations/scripts/verify_g2_turn.py) mirrors G2_TURN_FOOTER_RE.
19
+ //
20
+ // FOOTER-SHAPED LINES IN TYPED TEXT ARE REFUSED (QA 6.99.29). Only the server writes a footer, so
21
+ // words that carry anything that looks like one are refused at every route that hands words to a
22
+ // session (turns, queue park and edit, steer, fork, Send again, native Codex queue edit).
23
+
24
+ /** The footer tag per origin: the glasses and the phone keep COS-G2, a web turn says COS-WEB. */
25
+ export function turnFooterTag(client: string): 'COS-G2' | 'COS-WEB' {
26
+ return client === 'web' ? 'COS-WEB' : 'COS-G2'
27
+ }
28
+
29
+ /** The harnesses the verifier accepts for --provider. */
30
+ export const FOOTER_PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor'])
31
+
32
+ /** The footer's --provider value: the receiving harness when it is one the verifier knows, else the placeholder. */
33
+ export function footerProvider(provider: string | null | undefined): string {
34
+ return typeof provider === 'string' && FOOTER_PROVIDERS.has(provider) ? provider : '<claude|codex|cursor>'
35
+ }
36
+
37
+ export function turnFooter(turnId: string, client: string = 'glasses', provider?: string | null): string {
38
+ return `[${turnFooterTag(client)} turn ${turnId} · verify: operations/scripts/cos_python operations/scripts/verify_g2_turn.py ${turnId} --session <your session id> --provider ${footerProvider(provider)}]`
39
+ }
40
+
41
+ /**
42
+ * Anything that looks like a footer, anywhere in the text: `[COS-G2 turn` or `[COS-WEB turn`, any
43
+ * case, with spaces or any dash between the parts. Lenient on purpose: refusing a lookalike costs a
44
+ * retyped line; accepting one could put a second footer in front of a reader.
45
+ */
46
+ export const TURN_FOOTER_SHAPED_RE = /\[\s*COS[-\u2010-\u2015\u2212\s]*(?:G2|WEB)\s+turn\b/i
47
+
48
+ /** Whether typed words carry a footer-shaped line (refused: only the server writes a footer). */
49
+ export function hasFooterShapedLine(text: string): boolean {
50
+ return typeof text === 'string' && TURN_FOOTER_SHAPED_RE.test(text)
51
+ }
52
+
53
+ /** The refusal copy for a footer-shaped line, shown where the words were typed. */
54
+ export const FOOTER_LINE_REFUSED_COPY = 'This message has a line that looks like a COS verify footer ("[COS-G2 turn" or "[COS-WEB turn"). Only COS writes those lines. Take that line out and send it again.'
55
+
56
+ /** The refusal copy for a turn id this server already used for a different turn. */
57
+ export const TURN_ID_IN_USE_COPY = 'That message id was already used for a different message. Send it again as a new message.'
58
+
59
+ /** The 6.66 name, kept: a glasses or phone footer. */
60
+ export function g2TurnFooter(turnId: string, provider?: string | null): string {
61
+ return turnFooter(turnId, 'glasses', provider)
62
+ }
63
+
64
+ /** The footer line as it ends a delivered prompt (either tag). Used by tests and mirrored by the verifier. */
65
+ export const G2_TURN_FOOTER_RE = /\n\[COS-(G2|WEB) turn ([A-Za-z0-9][A-Za-z0-9_-]{7,127}) · verify: [^\n\]]*\][\t\n\v\f\r ]*$/
66
+
67
+ /** The prompt with its footer stripped, and the turn id the footer named (null: no footer). */
68
+ export function stripG2TurnFooter(text: string): { text: string; turnId: string | null; tag: 'COS-G2' | 'COS-WEB' | null } {
69
+ const m = G2_TURN_FOOTER_RE.exec(text)
70
+ if (!m) return { text, turnId: null, tag: null }
71
+ return { text: text.slice(0, m.index), turnId: m[2], tag: m[1] === 'WEB' ? 'COS-WEB' : 'COS-G2' }
72
+ }
73
+
74
+ /**
75
+ * The words a transcript row shows with a trailing verify footer taken off, for the code that
76
+ * matches a transcript against the words it sent (queue-turn-evidence.ts). Only a footer at the
77
+ * very end, of either tag, is removed.
78
+ */
79
+ export function withoutTurnFooter(text: string): string {
80
+ return stripG2TurnFooter(text).text
81
+ }
@@ -98,7 +98,7 @@
98
98
  import type { FenceRecord } from '../lib/thread-fence-store.js'
99
99
  import { defaultPidStartProbe, fenceLiveness, type FenceLiveness, type FenceLivenessDeps } from '../lib/fence-liveness.js'
100
100
  import { Router, type Request, type Response } from 'express'
101
- import { createHash, randomUUID } from 'node:crypto'
101
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
102
102
  import {
103
103
  threadOccupancy,
104
104
  type Occupancy,
@@ -131,7 +131,9 @@ import { isValidNativeThreadId } from '../lib/native-thread-id.js'
131
131
  import { FORK_PROVIDER_FAILURES, type ForkProviderFailure } from '../lib/fork-thread.js'
132
132
  import { CLIENT_FORK_ID_RE, ForkJobLedger, type ForkJobRow } from '../lib/fork-job-ledger.js'
133
133
  import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
134
+ import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, QUEUED_TURN_TAMPERED_COPY, sealMatches, TURN_CARRY_HEADER, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict, type QueueSeal } from '../lib/g2-turn-provenance.js'
134
135
  import { KEYED_SESSION_REFUSAL } from '../lib/keyed-sessions.js'
136
+ import { associateTurnAttachments, composeTurnPrompt, parseTurnAttachmentIds, TURN_ATTACHMENT_COPY, turnAttachmentManifest, type TurnAttachmentStore } from '../lib/session-turn-attachments.js'
135
137
  import {
136
138
  CANCEL_QUEUE_HOLD_MS,
137
139
  CLIENT_CANCEL_ID_RE,
@@ -318,6 +320,13 @@ export type AttachedTurnResult =
318
320
  | { ok: boolean; delivery: 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' | 'cancelled' }
319
321
 
320
322
  export interface AgentSessionBindingsDeps {
323
+ /**
324
+ * G2 authority Tier 1, with web origin (6.99.29): where each turn came from. With it, every turn
325
+ * and fork this router hands to a session is ledgered, and a VERIFIED glasses or phone turn
326
+ * carries the one verify footer line (a Control Web turn is recorded, never vouched or footed).
327
+ * Footer-shaped words and reused turn ids are refused. Absent: the 6.99.28 behaviour, byte for byte.
328
+ */
329
+ provenance?: G2TurnProvenance
321
330
  /** Shared fence state. Omit and the router owns a private one, which is correct
322
331
  * for tests and wrong for the server -- see the wiring note at its use site. */
323
332
  guard?: TargetGuard
@@ -437,6 +446,11 @@ export interface AgentSessionBindingsDeps {
437
446
  * and before the claim; a hit is replayed, never sent again. Absent: 6.77 behaviour.
438
447
  */
439
448
  turnReceipt?: (provider: string, nativeThreadId: string, clientTurnId: string, prompt: string) => TurnReceipt | null
449
+ /**
450
+ * 11/07 (Control Web attach): the media store a turn's `attachmentIds` resolve against. Absent: the real store
451
+ * (lib/session-turn-attachments.ts). Tests pass a fake. Never read for a turn without files.
452
+ */
453
+ turnAttachmentStore?: TurnAttachmentStore
440
454
 
441
455
  /**
442
456
  * 6.78.0 (COS_SESSION_STEER): could a turn go INTO this thread's running turn? Added to an
@@ -2601,10 +2615,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2601
2615
  if (typeof cosSessionId !== 'string' || !COS_SESSION_ID_RE.test(cosSessionId)) {
2602
2616
  return refuseFork('invalid_request')
2603
2617
  }
2604
- const prompt = body.prompt
2605
- if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.length > maxPromptChars) {
2618
+ const words = body.prompt
2619
+ if (typeof words !== 'string' || words.trim().length === 0 || words.length > maxPromptChars) {
2606
2620
  return refuseFork('invalid_request')
2607
2621
  }
2622
+ // 6.99.27: a fork's first turn takes files as a turn does (the message written with them, sent to the copy).
2623
+ const forkFiles = parseTurnAttachmentIds(body.attachmentIds)
2624
+ if (!forkFiles.ok) return refuseFork('invalid_request')
2608
2625
  // 6.63.0: optional. With it, the fork answers 202 once every gate has passed and runs
2609
2626
  // in the background; without it, the route behaves exactly as before.
2610
2627
  const clientForkId = body.clientForkId
@@ -2628,6 +2645,28 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2628
2645
  if (known) return known.source === forkSource ? replayForkJob(known) : refuseFork('invalid_request')
2629
2646
  }
2630
2647
 
2648
+ // After the replay (a known fork sends nothing), before the workspace, the claim and the spawn: where each file is
2649
+ // on this Mac goes under the words. Without files the prompt is the words, untouched. Nothing is kept yet: the
2650
+ // files are associated only once this fork holds its claim (below, QA 6.99.27).
2651
+ const forkComposed = await composeTurnPrompt(words, forkFiles.ids, {
2652
+ maxChars: maxPromptChars,
2653
+ ...(deps.turnAttachmentStore ? { store: deps.turnAttachmentStore } : {}),
2654
+ })
2655
+ if (!forkComposed.ok) {
2656
+ return refuseFork('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[forkComposed.code], attachmentError: forkComposed.code })
2657
+ }
2658
+ const prompt = forkComposed.prompt
2659
+ // G2 authority Tier 1 (QA 6.99.29): only the server writes a verify footer. Words (or a file
2660
+ // name) carrying anything footer-shaped are refused before anything is spawned.
2661
+ if (deps.provenance && hasFooterShapedLine(prompt)) {
2662
+ return refuseFork('invalid_request', { retryable: false, error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
2663
+ }
2664
+ // A client fork id this server already holds a turn record for is never reused (a new id
2665
+ // would overwrite that record). A repeat of this same fork was replayed above.
2666
+ if (deps.provenance && typeof clientForkId === 'string' && deps.provenance.holds(clientForkId)) {
2667
+ return refuseFork('invalid_request', { retryable: false, error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
2668
+ }
2669
+
2631
2670
  const now = readNow()
2632
2671
  if (now === null) return refuseFork('fork_failed')
2633
2672
 
@@ -2636,7 +2675,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2636
2675
  let cwd: string | null = null
2637
2676
  try {
2638
2677
  // 6.62.0: may answer later (a Cursor IDE composer's folder is read from Cursor's own
2639
- // store). Still before the claim below, which stays the last synchronous step.
2678
+ // store). Before the claim below, whose check and set stay one synchronous call.
2640
2679
  cwd = await resolveWorkspace(providerParam, threadIdParam)
2641
2680
  } catch (error) {
2642
2681
  console.error(`[agent-session-bindings] fork workspace resolve threw: ${error instanceof Error ? error.message : error}`)
@@ -2649,9 +2688,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2649
2688
  return refuseFork('fork_workspace_unresolvable')
2650
2689
  }
2651
2690
 
2652
- // LAST SYNCHRONOUS STATEMENT BEFORE THE FIRST AWAIT. One fork per source
2653
- // thread at a time: this route spawns a provider CLI, and without a claim a
2654
- // client retry loop spawns one child per request with nothing bounding it.
2691
+ // Check and set in ONE synchronous call (awaits above, the workspace read and the
2692
+ // files, come before it; nothing may sit between the check and the set). One fork
2693
+ // per source thread at a time: this route spawns a provider CLI, and without a claim
2694
+ // a client retry loop spawns one child per request with nothing bounding it.
2655
2695
  // A DISTINCT key namespace from the turn claim, so a fork can never block a
2656
2696
  // continuation or be blocked by one — `targetKey` is length-prefixed and
2657
2697
  // therefore unambiguous, and this prefix cannot collide with one.
@@ -2665,6 +2705,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2665
2705
  }
2666
2706
  claimedForkKey = forkKey
2667
2707
 
2708
+ // QA 6.99.27 (Skeptic W2): the files are kept with this fork only now that it holds the claim and every gate
2709
+ // has passed, before the job is recorded or anything is spawned. A file that went in the meantime refuses here.
2710
+ if (forkFiles.ids.length > 0) {
2711
+ const kept = await associateTurnAttachments(forkFiles.ids, `session-fork:${typeof clientForkId === 'string' ? clientForkId : mintId()}`, deps.turnAttachmentStore)
2712
+ if (!kept.ok) return refuseFork('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[kept.code], attachmentError: kept.code })
2713
+ }
2714
+
2668
2715
  if (typeof clientForkId === 'string') {
2669
2716
  // Recorded BEFORE the spawn and before the 202: a job the ledger cannot hold would
2670
2717
  // have an outcome nobody can read, so it does not start.
@@ -2686,12 +2733,26 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2686
2733
  console.log(`[agent-session-bindings] fork accepted provider=${providerParam} background=true`)
2687
2734
  }
2688
2735
 
2736
+ // G2 authority Tier 1: a fork is a turn too, ledgered. Its id is the client's fork id, or one
2737
+ // minted here; the target is the SOURCE thread (the copy's id is not known until it ran). The
2738
+ // hash covers the prompt as composed (words plus any files block). Never vouched (below).
2739
+ let forkPrompt = prompt
2740
+ if (deps.provenance) {
2741
+ // 6.99.29 (Codex audit of e4758a6): the record names the SOURCE session, never the fork child
2742
+ // that actually receives the words, and the child has no binding yet. Until a destination
2743
+ // binding exists, a forked turn is recorded UNVERIFIED for the child: no footer, ever.
2744
+ const forkVerdict = unvouched(deps.provenance.originOf(req), 'fork_destination_unbound')
2745
+ const forkTurnId = typeof clientForkId === 'string' ? clientForkId : `fork-${randomBytes(12).toString('hex')}`
2746
+ forkPrompt = deps.provenance.footed(prompt, forkTurnId, forkVerdict, providerParam)
2747
+ deps.provenance.record({ turnId: forkTurnId, prompt, verdict: forkVerdict, provider: providerParam, sessionId: threadIdParam, via: 'fork', fork: true })
2748
+ }
2749
+
2689
2750
  let raw: unknown
2690
2751
  try {
2691
2752
  raw = await fork({
2692
2753
  provider: providerParam,
2693
2754
  nativeThreadId: threadIdParam,
2694
- prompt,
2755
+ prompt: forkPrompt,
2695
2756
  cwd,
2696
2757
  // Text-only, same as the attached path. A fork runs a real model turn
2697
2758
  // against a workspace the user did not hand us explicitly.
@@ -2973,10 +3034,13 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2973
3034
  // an empty one, and must not fall through as a turn with no claims to check.
2974
3035
  if (body === null) return refuseTurn('invalid_request')
2975
3036
 
2976
- const prompt = body.prompt
2977
- if (typeof prompt !== 'string' || prompt.trim().length === 0 || prompt.length > maxPromptChars) {
3037
+ const words = body.prompt
3038
+ if (typeof words !== 'string' || words.trim().length === 0 || words.length > maxPromptChars) {
2978
3039
  return refuseTurn('invalid_request')
2979
3040
  }
3041
+ // 11/07: files with the turn, as media-store ids. Absent is the old turn; a bad list refuses it whole.
3042
+ const attachIds = parseTurnAttachmentIds(body.attachmentIds)
3043
+ if (!attachIds.ok) return refuseTurn('invalid_request')
2980
3044
  const epoch = body.epoch
2981
3045
  if (!Number.isInteger(epoch) || (epoch as number) < 1) return refuseTurn('invalid_request')
2982
3046
  const claimedTargetKey = body.targetKey
@@ -3014,6 +3078,59 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3014
3078
  return
3015
3079
  }
3016
3080
 
3081
+ // 11/07: the files' paths on this Mac go under the words HERE, after the ledger replay (a replayed turn sends
3082
+ // nothing) and before the receipt (which matches the transcript against what was actually sent). Without files
3083
+ // the prompt is the words, untouched. A file that cannot be read refuses the turn before anything is sent. The
3084
+ // files are kept with the turn only after the claim and the gates (before the pin, below; QA 6.99.27).
3085
+ const composed = await composeTurnPrompt(words, attachIds.ids, {
3086
+ maxChars: maxPromptChars,
3087
+ ...(deps.turnAttachmentStore ? { store: deps.turnAttachmentStore } : {}),
3088
+ })
3089
+ if (!composed.ok) {
3090
+ return refuseTurn('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[composed.code], attachmentError: composed.code })
3091
+ }
3092
+ const prompt = composed.prompt
3093
+ // G2 authority Tier 1 (QA 6.99.29): only the server writes a verify footer. Words (or a file
3094
+ // name) carrying anything footer-shaped are refused here, before any gate, claim or hop.
3095
+ if (deps.provenance && hasFooterShapedLine(prompt)) {
3096
+ return refuseTurn('invalid_request', { retryable: false, error: 'footer_line_refused', reasonCopy: FOOTER_LINE_REFUSED_COPY })
3097
+ }
3098
+
3099
+ // G2 authority Tier 1 (6.99.29 port, with web origin): who said this, and the exact text the
3100
+ // session will see. A turn the queue drains re-enters over loopback with a single-use carry
3101
+ // token naming the origin captured when it was parked (bound to the WORDS it parked, before
3102
+ // any files block); anything else is judged by its own socket, its token and its cookie.
3103
+ // `prompt` stays the unfooted text: the receipts, the turn ledger and the queue evidence
3104
+ // compare against it, and the transcript readers take the footer off before they compare.
3105
+ const provenance = deps.provenance
3106
+ let turnVerdict: OriginVerdict | null = null
3107
+ let turnCarried = false
3108
+ /** The seal a vouched queued turn carries; checked against the files here and the binding below. */
3109
+ let turnSeal: QueueSeal | null = null
3110
+ if (provenance) {
3111
+ // A carry marks the queue's own drain of a parked turn: it carries the parked origin while
3112
+ // one is held (else none, and the hop is judged as the local call it is) and is the one
3113
+ // caller allowed to add to that turn's record.
3114
+ const carried = provenance.takeCarry(req.headers[TURN_CARRY_HEADER], clientTurnId, words)
3115
+ turnCarried = carried !== null
3116
+ turnVerdict = carried?.verdict ?? provenance.originOf(req)
3117
+ turnSeal = carried?.seal ?? null
3118
+ }
3119
+ // 6.99.29 (Codex audit of e4758a6): the queue file holds MUTABLE attachment ids, and the drain
3120
+ // composes them into the text this turn vouches for. A carried seal names the manifest (each id
3121
+ // with its content hash) the origin was given for; files that differ now refuse the turn.
3122
+ if (turnSeal !== null) {
3123
+ const files = attachIds.ids.length === 0 ? { ok: true as const, manifest: [] } : await turnAttachmentManifest(attachIds.ids, deps.turnAttachmentStore)
3124
+ if (!files.ok || attachmentsDigest(files.manifest) !== turnSeal.attachments) {
3125
+ console.warn(`[agent-session-bindings] queued turn seal mismatch (files) turnId=${turnId} clientTurnId=${clientTurnId}`)
3126
+ return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
3127
+ }
3128
+ }
3129
+ /** Ledgered once the text did, or may have, reached a session. Hash of the text WITHOUT the footer. */
3130
+ const ledgerTurn = (via: string, provider: string, sessionId: string): void => {
3131
+ if (provenance && turnVerdict && clientTurnId !== null) provenance.record({ turnId: clientTurnId, prompt, verdict: turnVerdict, provider, sessionId, via: turnCarried ? `drain-${via}` : via, continues: turnCarried })
3132
+ }
3133
+
3017
3134
  // 6.78.0: RECEIPTS, before any gate, claim, pin or hop. The ledger above is dropped
3018
3135
  // with its binding and records nothing for a pre-202 `live_unverified`; a crash after
3019
3136
  // the pin leaves no record at all. The receipt covers those (target-scoped attempt
@@ -3074,6 +3191,22 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3074
3191
  if (!isBindableProvider(binding.provider) || !isValidNativeThreadId(binding.nativeThreadId)) {
3075
3192
  return refuseTurn('binding_unusable')
3076
3193
  }
3194
+ // G2 authority Tier 1 (QA 6.99.29): a turn id this server already holds a record for is never
3195
+ // reused for a different turn (it would overwrite that record: a denial of service on a
3196
+ // vouched turn). An identical retry is not reuse, and the queue's own drain (a carry) continues
3197
+ // its turn while that turn is still parked; never over a delivered record.
3198
+ // ...and the seal names the provider and thread it was parked for: a drain into any other
3199
+ // binding is refused, never delivered under the parked origin.
3200
+ if (turnSeal !== null && !sealMatches(turnSeal, { provider: binding.provider, threadId: binding.nativeThreadId, attachments: turnSeal.attachments })) {
3201
+ console.warn(`[agent-session-bindings] queued turn seal mismatch (target) turnId=${turnId} clientTurnId=${clientTurnId}`)
3202
+ return refuseTurn('invalid_request', { retryable: false, error: 'queued_turn_tampered', reasonCopy: QUEUED_TURN_TAMPERED_COPY })
3203
+ }
3204
+ if (provenance && turnVerdict && provenance.reusesTurnId(clientTurnId, { prompt, verdict: turnVerdict, provider: binding.provider, sessionId: binding.nativeThreadId }, { continues: turnCarried })) {
3205
+ return refuseTurn('invalid_request', { retryable: false, error: 'turn_id_in_use', reasonCopy: TURN_ID_IN_USE_COPY })
3206
+ }
3207
+ // Footed HERE, once the binding names the harness the text goes into: the footer's verify
3208
+ // command carries that provider (Codex re-audit of 910eb90). Every delivery below uses this text.
3209
+ const deliveredPrompt = provenance && turnVerdict ? provenance.footed(prompt, clientTurnId, turnVerdict, binding.provider) : prompt
3077
3210
 
3078
3211
  const key = binding.targetKey
3079
3212
  const fenced = guard.fencedReason(key)
@@ -3082,9 +3215,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3082
3215
  return refuseTurn(fenced, { retryable: false, deliveryState: 'unknown' })
3083
3216
  }
3084
3217
 
3085
- // LAST SYNCHRONOUS STATEMENT BEFORE THE FIRST AWAIT. Check-and-set in one
3086
- // call: with an await between them, two turns admitted in the same tick both
3087
- // see a free target and both deliver.
3218
+ // Check-and-set in ONE synchronous call. Awaits come before it (the files above,
3219
+ // QA 6.99.27) and after it, never between the check and the set: with an await
3220
+ // between them, two turns admitted in the same tick both see a free target and
3221
+ // both deliver.
3088
3222
  if (!guard.tryClaim(key, turnId)) return refuseTurn('native_turn_in_progress')
3089
3223
  claimedKey = key
3090
3224
  // 6.53.0: cancellable from here, with the claim. Same key, same lifetime.
@@ -3137,6 +3271,14 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3137
3271
  guard.acknowledgeHead(bindingId, head.digest)
3138
3272
  }
3139
3273
 
3274
+ // QA 6.99.27 (Skeptic W2): the files are kept with this turn only now, with the claim held and every gate
3275
+ // passed, and before the pin and any hop. A turn refused above keeps nothing past the 4 hour staged TTL.
3276
+ if (attachIds.ids.length > 0) {
3277
+ const kept = await associateTurnAttachments(attachIds.ids, `session-turn:${clientTurnId}`, deps.turnAttachmentStore)
3278
+ stage('files')
3279
+ if (!kept.ok) return refuseTurn('invalid_request', { reasonCopy: TURN_ATTACHMENT_COPY[kept.code], attachmentError: kept.code })
3280
+ }
3281
+
3140
3282
  // A failed pin is FATAL, per the registry's own caller contract: an unpinned
3141
3283
  // binding can expire or be detached mid-turn, which defeats the lease.
3142
3284
  // 6.78.0: the pin names its client turn (COS_PIN_BOOT_REAP) and opens the attempt
@@ -3184,7 +3326,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3184
3326
  live = await deps.deliverLiveTurn({
3185
3327
  provider: binding.provider,
3186
3328
  sessionId: binding.nativeThreadId,
3187
- prompt,
3329
+ prompt: deliveredPrompt,
3188
3330
  verifyTimeoutMs: LIVE_VERIFY_BUDGET_MS,
3189
3331
  clientTurnId,
3190
3332
  })
@@ -3204,6 +3346,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3204
3346
  const latched = latchedHere()
3205
3347
  if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt })
3206
3348
  console.log(`[agent-session-bindings] turn delivered live provider=${binding.provider} turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} pid=${live.pid ?? 'null'}`)
3349
+ ledgerTurn('live', binding.provider, binding.nativeThreadId)
3207
3350
  respond(200, {
3208
3351
  turnId,
3209
3352
  outcome: 'completed',
@@ -3222,6 +3365,8 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3222
3365
  // 6.53.3: something MAY be in the session and the person asked for it to stop: arm
3223
3366
  // the marker as for a landed turn, and settle this turn as cancelled, never as a
3224
3367
  // retryable hold that the queue would send again after the cancel's two minutes.
3368
+ // The text MAY be in the session, footer and all: a record, so a verify finds it.
3369
+ ledgerTurn('live-unverified', binding.provider, binding.nativeThreadId)
3225
3370
  const latched = latchedHere()
3226
3371
  if (latched) {
3227
3372
  settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'maybe', { prompt, sentAt: handOffAt })
@@ -3263,7 +3408,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3263
3408
  live = await deps.deliverCodexLiveTurn({
3264
3409
  provider: binding.provider,
3265
3410
  sessionId: binding.nativeThreadId,
3266
- prompt,
3411
+ prompt: deliveredPrompt,
3267
3412
  foreignHolder: true,
3268
3413
  })
3269
3414
  } catch (error) {
@@ -3278,6 +3423,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3278
3423
  // 6.53.3: queued in the Codex app, which only the app can stop ("Stop it there").
3279
3424
  const latched = latchedHere()
3280
3425
  if (latched) settleLatchedCancel(latched, binding.provider, binding.nativeThreadId, 'reached', { prompt, sentAt: handOffAt, busy: busyCodexHop })
3426
+ ledgerTurn('codex-live', binding.provider, binding.nativeThreadId)
3281
3427
  console.log(`[agent-session-bindings] turn delivered live provider=codex turnId=${turnId} bindingId=${bindingId} verifiedBy=${live.verifiedBy ?? 'unknown'} queuedId=${live.queuedId ?? 'null'}`)
3282
3428
  respond(200, {
3283
3429
  turnId,
@@ -3320,6 +3466,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3320
3466
  // delivery is: ambiguous, ledgered (a replay answers the same), and fenced until
3321
3467
  // a person looks at the Codex app's queue and releases it.
3322
3468
  if (live === null || live.reason === 'unverified') {
3469
+ ledgerTurn('codex-live-unverified', binding.provider, binding.nativeThreadId)
3323
3470
  guard.fence(key, 'native_target_fenced', {
3324
3471
  provider: binding.provider,
3325
3472
  headBefore: head.digest,
@@ -3373,6 +3520,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3373
3520
  let delivery: Delivery
3374
3521
  deliveryAttempted = true
3375
3522
  spawnMay = true
3523
+ ledgerTurn('spawn', binding.provider, binding.nativeThreadId)
3376
3524
  try {
3377
3525
  const result = await deliver({
3378
3526
  turnId,
@@ -3383,7 +3531,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3383
3531
  workspaceFingerprint: binding.workspaceFingerprint,
3384
3532
  sourceFingerprint: binding.sourceFingerprint,
3385
3533
  expectedNativeHead: head.raw,
3386
- prompt,
3534
+ prompt: deliveredPrompt,
3387
3535
  abortSignal: cancelController.signal,
3388
3536
  // 6.62.0 /qa (Q3): only a client that shows `continue_note` gets Cursor Run Everything.
3389
3537
  continueNoteAck: continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]),