@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
|
@@ -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
|
|
2605
|
-
if (typeof
|
|
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).
|
|
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
|
-
//
|
|
2653
|
-
//
|
|
2654
|
-
//
|
|
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
|
|
2977
|
-
if (typeof
|
|
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
|
-
//
|
|
3086
|
-
//
|
|
3087
|
-
// see a free target and
|
|
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]),
|