@gotcos/glasses-server 6.99.20 → 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 +257 -0
- package/docs/work-orchestrator.md +20 -0
- package/package.json +1 -1
- package/server/index.ts +48 -5
- 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/lib/web-control-static.ts +226 -0
- package/server/lib/web-memories-bridge.ts +9 -8
- package/server/lib/web-pet-choice.ts +225 -0
- package/server/lib/web-pet.ts +226 -0
- package/server/lib/work-lens-rounds.ts +29 -4
- 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/routes/web-control.ts +110 -7
- package/server/routes/work-lens-rounds.ts +19 -5
- package/server/web-control/app.css +26 -5
- package/server/web-control/app.js +1351 -177
- package/server/web-control/control.css +1 -1
- package/server/web-control/index.html +31 -13
- package/server/web-control/memories/memory-workspace.js +1 -1
- package/server/web-control/panes/cos-chat.css +22 -0
- package/server/web-control/panes/cos-chat.js +264 -48
- package/server/web-control/panes/meetings.css +15 -2
- package/server/web-control/panes/meetings.js +20 -9
- package/server/web-control/panes/memories.css +291 -0
- package/server/web-control/panes/memories.js +2208 -0
- package/server/web-control/panes/questions.css +39 -1
- package/server/web-control/panes/questions.js +419 -64
- 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 +136 -6
- package/server/web-control/panes/today-actions.css +167 -0
- package/server/web-control/panes/today-actions.js +1303 -0
- package/server/web-control/panes/work.css +54 -0
- package/server/web-control/panes/work.js +1037 -18
- package/server/web-control/shell.js +6 -2
- package/server/web-control/theme.js +0 -2
- package/server/web-control/memories/memories-app.js +0 -1561
- package/server/web-control/memories/memories-theme.css +0 -73
- package/server/web-control/memories/memories.html +0 -72
- package/server/web-control/memories/memory-stewardship.js +0 -63
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
// G2 authority, Tier 1 (ported to 6.99.29 from the unmerged 6.66 design, a3c8c4b): verifiable
|
|
2
|
+
// provenance for the turns this server delivers into an agent session on behalf of the glasses or
|
|
3
|
+
// the phone. Turns from a signed-in COS Control Web browser are recorded too (6.99.29), never vouched.
|
|
4
|
+
//
|
|
5
|
+
// WHY. A turn Miles dictates on the G2 should carry his authority in the Claude, Codex or Cursor
|
|
6
|
+
// session it lands in, and a turn some other local process sends must not. Both arrive through
|
|
7
|
+
// the same routes with the same X-Cos-Token: the token sits in ~/.cos-glasses/.env, and every
|
|
8
|
+
// COS agent session on this Mac can read it. So "an authenticated call made this" proves
|
|
9
|
+
// nothing about who spoke.
|
|
10
|
+
//
|
|
11
|
+
// WHAT COUNTS AS PROOF, and nothing more is claimed:
|
|
12
|
+
// 1. The request's SOCKET address (never X-Forwarded-For, never req.ip) is not loopback and
|
|
13
|
+
// is not one of this Mac's own interface addresses (LAN, Tailscale 100.x, utun, anything
|
|
14
|
+
// os.networkInterfaces() lists). A process on this Mac can only reach the server from one
|
|
15
|
+
// of those, so this rules out every local caller. Also local: link-local addresses, and any
|
|
16
|
+
// address inside the subnet of a VM or container bridge this Mac hosts (bridge100 and kin),
|
|
17
|
+
// because a container dialing the bridge arrives from an address no interface lists.
|
|
18
|
+
// 2. The request names, in X-Cos-Client-Instance, the COS app copy that currently holds the
|
|
19
|
+
// ring for that same device address (the client-instance referee, 6.50.3), seen within
|
|
20
|
+
// PAIRED_INSTANCE_FRESH_MS.
|
|
21
|
+
// verifiedOrigin = (1) && (2). `client` (glasses / phone) is the app's own word in X-Cos-Client
|
|
22
|
+
// and labels the turn; it never makes a turn verified.
|
|
23
|
+
//
|
|
24
|
+
// WEB ORIGIN (6.99.29): RECORDED, NEVER VOUCHED. Miles types into a session's box on COS Control Web
|
|
25
|
+
// from his phone. The browser sends no X-Cos-Token header; it sends the HttpOnly `cos_web_session`
|
|
26
|
+
// cookie. That cookie is NOT a secret the token cannot produce: its signature is an HMAC keyed by
|
|
27
|
+
// the API token itself (web-session-auth.ts, `cos_web_session_v2:<token>`), the epoch sits in
|
|
28
|
+
// web-session.json, and the pairing token also signs a browser in. So any holder of X-Cos-Token
|
|
29
|
+
// (every agent on this Mac) can mint a valid cookie and send from another tailnet node with no
|
|
30
|
+
// token header. A web turn is therefore ledgered with originKind 'web', verifiedOrigin false and
|
|
31
|
+
// reason 'web_cookie_derivable_from_token', gets NO footer, and the endpoint never vouches for it.
|
|
32
|
+
// Real web authority needs a key the token cannot derive (a device key or passkey enrolled out of
|
|
33
|
+
// band); until that exists there is no vouched web origin. `webSession` is still checked in full
|
|
34
|
+
// (cookie signature, expiry, epoch, X-COS-Web and an Origin naming this server, no X-Cos-Token
|
|
35
|
+
// header, a socket that is not this Mac) so the ledger labels the turn honestly.
|
|
36
|
+
//
|
|
37
|
+
// WHAT IS NOT PROVEN. Another device on the tailnet or the LAN that holds the token and has read
|
|
38
|
+
// the live instance id (the app logs it in client diagnostics on this Mac) could pass both
|
|
39
|
+
// tests. So could a VM this Mac runs in BRIDGED mode (it takes its own LAN address from the
|
|
40
|
+
// router, indistinguishable from a second machine). Both are outside this tier's threat model.
|
|
41
|
+
//
|
|
42
|
+
// THE LEDGER is append-only JSONL at <data>/g2-turn-ledger.jsonl, rotated at 5 MB. It is an audit
|
|
43
|
+
// trail. The file is NOT the proof: any local process can write it. The endpoint answers
|
|
44
|
+
// `verifiedOrigin: true` only for a record THIS process wrote and still holds in memory
|
|
45
|
+
// (`sealed`). A record read back from disk (after a restart, or one somebody appended by hand)
|
|
46
|
+
// is reported, never vouched for. A restart therefore makes recent turns unverifiable, which
|
|
47
|
+
// fails closed.
|
|
48
|
+
//
|
|
49
|
+
// QUEUED TURNS re-enter the turn route over loopback (thread-turn-queue-deliver.ts), so their
|
|
50
|
+
// socket origin at delivery is always local. The origin is captured at enqueue (and again at an
|
|
51
|
+
// edit), kept in memory with the hash of the text it vouches for, and carried across the
|
|
52
|
+
// loopback hop by a single-use random token only this process knows. A queue file edited on
|
|
53
|
+
// disk no longer matches the hash and drains as local. A queued origin is forgotten the moment
|
|
54
|
+
// its row settles (delivered, steered, cancelled, refused, expired) and lapses QUEUED_ORIGIN_TTL_MS
|
|
55
|
+
// after it was judged, so a turn that waits longer drains unvouched. Every drain carries a token,
|
|
56
|
+
// even one with no origin left to carry, so the drain is the one caller allowed to add to the
|
|
57
|
+
// parked turn's ledger record.
|
|
58
|
+
//
|
|
59
|
+
// TURN IDS ARE NOT REUSABLE. A record this process holds is never replaced by a different turn
|
|
60
|
+
// under the same id (a client-chosen id could otherwise overwrite a vouched record: a denial of
|
|
61
|
+
// service on verification). Only an identical retry (same target, same text, same origin) or the
|
|
62
|
+
// server's own continuation of a parked turn (drain carry, queue edit, Cursor Stop hand-off) may
|
|
63
|
+
// add to it; the routes refuse anything else before delivery.
|
|
64
|
+
|
|
65
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
66
|
+
import { appendFileSync, existsSync, renameSync, statSync } from 'node:fs'
|
|
67
|
+
import { readFile } from 'node:fs/promises'
|
|
68
|
+
import { networkInterfaces } from 'node:os'
|
|
69
|
+
import { turnFooter } from './turn-footer.js'
|
|
70
|
+
|
|
71
|
+
export { FOOTER_LINE_REFUSED_COPY, G2_TURN_FOOTER_RE, g2TurnFooter, hasFooterShapedLine, stripG2TurnFooter, TURN_FOOTER_SHAPED_RE, TURN_ID_IN_USE_COPY, turnFooter, turnFooterTag, withoutTurnFooter } from './turn-footer.js'
|
|
72
|
+
|
|
73
|
+
export const G2_TURN_LEDGER_FILE = 'g2-turn-ledger.jsonl'
|
|
74
|
+
export const G2_TURN_LEDGER_ROTATE_BYTES = 5 * 1024 * 1024
|
|
75
|
+
/** Records held in memory (sealed). A turn is verified within minutes, so this is generous. */
|
|
76
|
+
export const G2_TURN_MEMORY_MAX = 2048
|
|
77
|
+
/** The strict id shape: covers the client turn id, the client fork id and the ids minted here. */
|
|
78
|
+
export const G2_TURN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
|
|
79
|
+
export const CLIENT_INSTANCE_HEADER = 'x-cos-client-instance'
|
|
80
|
+
export const CLIENT_SURFACE_HEADER = 'x-cos-client'
|
|
81
|
+
export const TURN_CARRY_HEADER = 'x-cos-turn-carry'
|
|
82
|
+
/** The pairing-token header the /api gate reads. Its presence alone rules out a web turn. */
|
|
83
|
+
export const TOKEN_HEADER = 'x-cos-token'
|
|
84
|
+
/** How recently the ring's owner must have claimed for its id to vouch for a turn (the reclaim window). */
|
|
85
|
+
export const PAIRED_INSTANCE_FRESH_MS = 10 * 60_000
|
|
86
|
+
/** A carry token lives this long: the loopback hop answers in well under a second. */
|
|
87
|
+
export const TURN_CARRY_TTL_MS = 30_000
|
|
88
|
+
/** Queued origins held at once (a queue holds 8 per thread). */
|
|
89
|
+
export const QUEUED_ORIGIN_MAX = 512
|
|
90
|
+
/** A queued origin lapses this long after it was judged: a turn that waits longer drains unvouched. */
|
|
91
|
+
export const QUEUED_ORIGIN_TTL_MS = 30 * 60_000
|
|
92
|
+
/**
|
|
93
|
+
* The endpoint vouches for a sealed record only this long after its origin was judged (never from
|
|
94
|
+
* a later delivery). The verifier's own
|
|
95
|
+
* window is 15 minutes; this bounds how long any record can ever vouch, so the verifier's
|
|
96
|
+
* consumed-turn store (kept 7 days) always outlives it.
|
|
97
|
+
*/
|
|
98
|
+
export const G2_TURN_VOUCH_MAX_MS = 24 * 3_600_000
|
|
99
|
+
/** Why a Send again copy is not vouched: the request that sent it again was not itself a verified origin. */
|
|
100
|
+
export const RESEND_NOT_REVERIFIED_REASON = 'resend_not_reverified'
|
|
101
|
+
/** Why a web turn is never vouched in 6.99.29 (see the header). */
|
|
102
|
+
export const WEB_UNVOUCHED_REASON = 'web_cookie_derivable_from_token'
|
|
103
|
+
const OWN_ADDRESS_CACHE_MS = 30_000
|
|
104
|
+
|
|
105
|
+
export type TurnClient = 'glasses' | 'phone' | 'web' | 'local' | 'unknown'
|
|
106
|
+
|
|
107
|
+
/** Where a footed turn came from, as the ledger, the endpoint and the verifier name it. */
|
|
108
|
+
export type TurnOriginKind = 'g2' | 'phone' | 'web'
|
|
109
|
+
|
|
110
|
+
export interface TurnOrigin {
|
|
111
|
+
remote: string
|
|
112
|
+
loopback: boolean
|
|
113
|
+
ownAddress: boolean
|
|
114
|
+
clientInstance: string | null
|
|
115
|
+
client: TurnClient
|
|
116
|
+
/** 6.99.29: the request presented an X-Cos-Token header (any value). Never a web turn. */
|
|
117
|
+
tokenPresented?: boolean
|
|
118
|
+
/** 6.99.29: the signed-in COS Control Web cookie path passed, with no token presented. */
|
|
119
|
+
webSession?: boolean
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The origin kind a client label stands for; null for a local or unknown caller (never footed). */
|
|
123
|
+
export function originKindOf(client: TurnClient | string | undefined): TurnOriginKind | null {
|
|
124
|
+
if (client === 'glasses') return 'g2'
|
|
125
|
+
if (client === 'phone') return 'phone'
|
|
126
|
+
if (client === 'web') return 'web'
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface OriginVerdict {
|
|
131
|
+
origin: TurnOrigin
|
|
132
|
+
verifiedOrigin: boolean
|
|
133
|
+
/** Why it is not vouched (null or absent when it is). */
|
|
134
|
+
reason?: string | null
|
|
135
|
+
/** When this origin was judged (ms). A queued origin lapses from here, never later. */
|
|
136
|
+
judgedAt?: number
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface G2TurnRecord {
|
|
140
|
+
turnId: string
|
|
141
|
+
createdAt: string
|
|
142
|
+
textSha256: string
|
|
143
|
+
origin: TurnOrigin
|
|
144
|
+
/** 6.99.29: g2, phone or web; null when the turn was not footed (a local or unknown caller). */
|
|
145
|
+
originKind: TurnOriginKind | null
|
|
146
|
+
verifiedOrigin: boolean
|
|
147
|
+
/** 6.99.29: why it is not vouched; null when it is. A web turn says WEB_UNVOUCHED_REASON. */
|
|
148
|
+
reason?: string | null
|
|
149
|
+
/** 6.99.29: when its origin was judged (ms): the parking request, for a drained turn. */
|
|
150
|
+
originJudgedAt?: number
|
|
151
|
+
target: { provider: string; sessionId: string; fork?: true }
|
|
152
|
+
via: string
|
|
153
|
+
/**
|
|
154
|
+
* Send again: the row this copy was made from. AN ANNOTATION ONLY, never authority (Codex
|
|
155
|
+
* re-audit of 910eb90): the copy is judged by the request that sent it again, nothing is inherited.
|
|
156
|
+
*/
|
|
157
|
+
resendOf?: string
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The shape of an Express request this module reads. Nothing else. */
|
|
161
|
+
export interface OriginRequest {
|
|
162
|
+
method?: string
|
|
163
|
+
socket?: { remoteAddress?: string | null } | null
|
|
164
|
+
headers: Record<string, string | string[] | undefined>
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ASCII whitespace only, spelled out, so the Python verifier normalizes identically (JS `\s` and
|
|
168
|
+
// Python `\s` disagree on U+FEFF, U+0085 and U+001C..U+001F).
|
|
169
|
+
const WS_RUN = /[\t\n\v\f\r ]+/g
|
|
170
|
+
const WS_EDGE = /^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g
|
|
171
|
+
|
|
172
|
+
/** Trim, and collapse every run of ASCII whitespace to one space. */
|
|
173
|
+
export function normalizeTurnText(text: string): string {
|
|
174
|
+
return text.replace(WS_EDGE, '').replace(WS_RUN, ' ')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function turnTextSha256(text: string): string {
|
|
178
|
+
return createHash('sha256').update(normalizeTurnText(text), 'utf8').digest('hex')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether a turn of this origin gets the footer: ONLY a vouched glasses or phone turn (QA 6.99.29
|
|
183
|
+
* 5b: it used to read the label, so an unverified glasses turn and every web turn were footed). A
|
|
184
|
+
* web turn is never footed, whatever its verdict says.
|
|
185
|
+
*/
|
|
186
|
+
export function footerApplies(verdict: OriginVerdict): boolean {
|
|
187
|
+
if (verdict.verifiedOrigin !== true) return false
|
|
188
|
+
const kind = originKindOf(verdict.origin.client)
|
|
189
|
+
return kind === 'g2' || kind === 'phone'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The same verdict, never vouched, with the reason why. */
|
|
193
|
+
export function unvouched(verdict: OriginVerdict, reason: string): OriginVerdict {
|
|
194
|
+
return { ...verdict, verifiedOrigin: false, reason }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The vias of a turn still parked in the queue: the only records a continuation may extend. */
|
|
198
|
+
export const PARKED_TURN_VIAS: ReadonlySet<string> = new Set(['queue', 'queue-edit', 'queue-resend'])
|
|
199
|
+
|
|
200
|
+
/** Whether a later record under the same id is the same turn: same target, same text, same origin. */
|
|
201
|
+
function sameTurn(a: G2TurnRecord, b: G2TurnRecord): boolean {
|
|
202
|
+
return a.target.provider === b.target.provider
|
|
203
|
+
&& a.target.sessionId === b.target.sessionId
|
|
204
|
+
&& (a.target.fork === true) === (b.target.fork === true)
|
|
205
|
+
&& a.textSha256 === b.textSha256
|
|
206
|
+
&& a.originKind === b.originKind
|
|
207
|
+
&& a.verifiedOrigin === b.verifiedOrigin
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `::ffff:100.64.1.2` and `100.64.1.2` are one address; IPv6 scope ids and case are dropped. */
|
|
211
|
+
export function normalizeAddress(address: string | null | undefined): string {
|
|
212
|
+
let a = (address ?? '').trim().toLowerCase()
|
|
213
|
+
if (a.startsWith('::ffff:') && a.includes('.')) a = a.slice(7)
|
|
214
|
+
const pct = a.indexOf('%')
|
|
215
|
+
if (pct >= 0) a = a.slice(0, pct)
|
|
216
|
+
return a
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function isLoopbackAddress(address: string): boolean {
|
|
220
|
+
const a = normalizeAddress(address)
|
|
221
|
+
return a === '::1' || a === '0:0:0:0:0:0:0:1' || a === 'localhost' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(a)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Interfaces whose whole subnet is this Mac: the host side of a VM or container bridge. Measured
|
|
226
|
+
* 2026-10-07: a process in the cos-qdrant container that dials the bridge100 host address
|
|
227
|
+
* (192.168.64.1) arrives from 192.168.64.2, which is in no interface list. Without this rule any
|
|
228
|
+
* local process could `docker exec` its way to a non-local address.
|
|
229
|
+
*/
|
|
230
|
+
export const VIRTUAL_BRIDGE_IFACE_RE = /^(bridge|vmnet|vnic|docker|veth|vboxnet|anpi)/
|
|
231
|
+
|
|
232
|
+
function ipv4ToInt(a: string): number | null {
|
|
233
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(a)
|
|
234
|
+
if (!m) return null
|
|
235
|
+
const parts = m.slice(1).map(Number)
|
|
236
|
+
if (parts.some(n => n > 255)) return null
|
|
237
|
+
return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Link-local addresses (169.254/16, fe80::/10) never come from a paired phone over a routed path. */
|
|
241
|
+
export function isLinkLocalAddress(address: string): boolean {
|
|
242
|
+
const a = normalizeAddress(address)
|
|
243
|
+
return /^169\.254\./.test(a) || /^fe[89ab][0-9a-f]:/.test(a)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface OwnAddressView {
|
|
247
|
+
addresses: ReadonlySet<string>
|
|
248
|
+
/** IPv4 subnets of virtual bridges, as [network, mask]. */
|
|
249
|
+
bridgeSubnets: ReadonlyArray<readonly [number, number]>
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** This Mac's own addresses and bridge subnets. Throws through to the caller, which fails closed. */
|
|
253
|
+
export function readOwnAddressView(): OwnAddressView {
|
|
254
|
+
const addresses = new Set<string>()
|
|
255
|
+
const bridgeSubnets: Array<readonly [number, number]> = []
|
|
256
|
+
for (const [name, list] of Object.entries(networkInterfaces())) {
|
|
257
|
+
for (const entry of list ?? []) {
|
|
258
|
+
addresses.add(normalizeAddress(entry.address))
|
|
259
|
+
if (entry.family === 'IPv4' && VIRTUAL_BRIDGE_IFACE_RE.test(name)) {
|
|
260
|
+
const ip = ipv4ToInt(entry.address)
|
|
261
|
+
const mask = ipv4ToInt(entry.netmask)
|
|
262
|
+
if (ip !== null && mask !== null) bridgeSubnets.push([(ip & mask) >>> 0, mask])
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { addresses, bridgeSubnets }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Whether this normalized address is this Mac, a VM or container it hosts, or link-local. */
|
|
270
|
+
export function isOwnAddress(address: string, view: OwnAddressView): boolean {
|
|
271
|
+
const a = normalizeAddress(address)
|
|
272
|
+
if (view.addresses.has(a) || isLinkLocalAddress(a)) return true
|
|
273
|
+
const ip = ipv4ToInt(a)
|
|
274
|
+
return ip !== null && view.bridgeSubnets.some(([network, mask]) => ((ip & mask) >>> 0) === network)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function headerValue(headers: OriginRequest['headers'], name: string): string | null {
|
|
278
|
+
const raw = headers[name]
|
|
279
|
+
const v = Array.isArray(raw) ? raw[0] : raw
|
|
280
|
+
return typeof v === 'string' ? v.trim() : null
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const INSTANCE_RE = /^[a-z0-9]{1,16}-[a-z0-9]{1,8}$/
|
|
284
|
+
|
|
285
|
+
export class G2TurnLedger {
|
|
286
|
+
private readonly memory = new Map<string, G2TurnRecord>()
|
|
287
|
+
|
|
288
|
+
constructor(
|
|
289
|
+
private readonly file: string,
|
|
290
|
+
private readonly opts: { rotateBytes?: number; memoryMax?: number } = {},
|
|
291
|
+
) {}
|
|
292
|
+
|
|
293
|
+
/** The record this process holds (sealed) for this id, or null. Synchronous: memory only. */
|
|
294
|
+
held(turnId: string): G2TurnRecord | null {
|
|
295
|
+
return this.memory.get(turnId) ?? null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Append one record. Kept in memory (sealed) even when the disk write fails. Never throws.
|
|
300
|
+
* A held id is never replaced unless the caller says this record continues it (`replace`):
|
|
301
|
+
* false, and nothing written, otherwise.
|
|
302
|
+
*/
|
|
303
|
+
append(record: G2TurnRecord, opts: { replace?: boolean } = {}): boolean {
|
|
304
|
+
if (this.memory.has(record.turnId) && opts.replace !== true) {
|
|
305
|
+
console.warn(`[g2-turn-ledger] refused to replace held record turnId=${record.turnId}`)
|
|
306
|
+
return false
|
|
307
|
+
}
|
|
308
|
+
this.memory.delete(record.turnId)
|
|
309
|
+
this.memory.set(record.turnId, record)
|
|
310
|
+
const max = this.opts.memoryMax ?? G2_TURN_MEMORY_MAX
|
|
311
|
+
while (this.memory.size > max) this.memory.delete(this.memory.keys().next().value as string)
|
|
312
|
+
try {
|
|
313
|
+
const rotateAt = this.opts.rotateBytes ?? G2_TURN_LEDGER_ROTATE_BYTES
|
|
314
|
+
if (existsSync(this.file) && statSync(this.file).size >= rotateAt) renameSync(this.file, `${this.file}.1`)
|
|
315
|
+
// One write of one line under O_APPEND: a reader never sees half a record.
|
|
316
|
+
appendFileSync(this.file, `${JSON.stringify(record)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
317
|
+
return true
|
|
318
|
+
} catch (error) {
|
|
319
|
+
console.error(`[g2-turn-ledger] append failed: ${error instanceof Error ? error.message : error}`)
|
|
320
|
+
return false
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The newest record for this id. `sealed`: written by this process and held in memory. */
|
|
325
|
+
async find(turnId: string): Promise<{ record: G2TurnRecord; sealed: boolean } | null> {
|
|
326
|
+
const held = this.memory.get(turnId)
|
|
327
|
+
if (held) return { record: held, sealed: true }
|
|
328
|
+
for (const path of [this.file, `${this.file}.1`]) {
|
|
329
|
+
let raw: string
|
|
330
|
+
try { raw = await readFile(path, 'utf8') } catch { continue }
|
|
331
|
+
const lines = raw.split('\n')
|
|
332
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
333
|
+
const line = lines[i]
|
|
334
|
+
if (!line || !line.includes(turnId)) continue
|
|
335
|
+
try {
|
|
336
|
+
const parsed = JSON.parse(line) as G2TurnRecord
|
|
337
|
+
if (parsed && parsed.turnId === turnId) return { record: parsed, sealed: false }
|
|
338
|
+
} catch { /* a torn or foreign line is skipped */ }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return null
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface G2TurnProvenanceDeps {
|
|
346
|
+
ledger: G2TurnLedger
|
|
347
|
+
/** The ring owner's instance id for this device address, when it claimed within `freshMs`. */
|
|
348
|
+
pairedInstance: (device: string, now: number, freshMs: number) => string | null
|
|
349
|
+
/** This Mac's addresses (default: os.networkInterfaces(), cached 30 s). A throw fails closed. */
|
|
350
|
+
ownAddresses?: () => OwnAddressView
|
|
351
|
+
/**
|
|
352
|
+
* 6.99.29: whether this request passes the signed-in COS Control Web cookie path in full
|
|
353
|
+
* (`isWebSessionAuthorized` with the /api gate's options, in production). Asked only for a
|
|
354
|
+
* request with no X-Cos-Token header. Absent, or a throw: no turn is ever a web turn.
|
|
355
|
+
*/
|
|
356
|
+
webSession?: (req: OriginRequest) => boolean
|
|
357
|
+
now?: () => number
|
|
358
|
+
randomToken?: () => string
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* What a queued origin is sealed to besides the words (6.99.29, Codex audit of e4758a6): the provider
|
|
363
|
+
* and thread it was parked for, and the digest of its attachment manifest (each file id with the hash
|
|
364
|
+
* of its content; '' for none). The drain recomputes it and a vouched turn whose seal changed is refused.
|
|
365
|
+
*/
|
|
366
|
+
export interface QueueSeal { provider: string; threadId: string; attachments: string }
|
|
367
|
+
|
|
368
|
+
/** The digest of an attachment manifest ('' for no files): ids in order, each with its content hash. */
|
|
369
|
+
export function attachmentsDigest(manifest: ReadonlyArray<{ id: string; sha256: string }>): string {
|
|
370
|
+
if (manifest.length === 0) return ''
|
|
371
|
+
return createHash('sha256').update(JSON.stringify(manifest.map(e => [e.id, e.sha256])), 'utf8').digest('hex')
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Whether a seal names exactly this provider, thread and attachment digest. */
|
|
375
|
+
export function sealMatches(seal: QueueSeal, actual: QueueSeal): boolean {
|
|
376
|
+
return seal.provider === actual.provider && seal.threadId === actual.threadId && seal.attachments === actual.attachments
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The refusal copy for a vouched queued turn whose seal no longer matches at its drain. */
|
|
380
|
+
export const QUEUED_TURN_TAMPERED_COPY = 'This queued message changed after it was queued (its words, files or session no longer match), so it was not sent. Send it again from where you typed it.'
|
|
381
|
+
|
|
382
|
+
interface QueuedOrigin { verdict: OriginVerdict; textSha256: string; judgedAt: number; attachments: string }
|
|
383
|
+
interface Carry { clientTurnId: string; verdict: OriginVerdict | null; seal: QueueSeal | null; textSha256: string; expiresAt: number }
|
|
384
|
+
|
|
385
|
+
/** What a route asks before it delivers a client-chosen id: the turn it is about to record. */
|
|
386
|
+
export interface TurnCandidate { prompt: string; verdict: OriginVerdict; provider: string; sessionId: string; fork?: boolean }
|
|
387
|
+
|
|
388
|
+
export class G2TurnProvenance {
|
|
389
|
+
private readonly now: () => number
|
|
390
|
+
private readonly queued = new Map<string, QueuedOrigin>()
|
|
391
|
+
private readonly carries = new Map<string, Carry>()
|
|
392
|
+
private ownCache: { at: number; view: OwnAddressView } | null = null
|
|
393
|
+
|
|
394
|
+
constructor(private readonly deps: G2TurnProvenanceDeps) {
|
|
395
|
+
this.now = deps.now ?? Date.now
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
get ledger(): G2TurnLedger { return this.deps.ledger }
|
|
399
|
+
|
|
400
|
+
/** Null when this Mac's addresses cannot be read: the caller then treats every address as its own. */
|
|
401
|
+
private own(): OwnAddressView | null {
|
|
402
|
+
const at = this.now()
|
|
403
|
+
if (this.ownCache && at - this.ownCache.at < OWN_ADDRESS_CACHE_MS) return this.ownCache.view
|
|
404
|
+
try {
|
|
405
|
+
const view = (this.deps.ownAddresses ?? readOwnAddressView)()
|
|
406
|
+
this.ownCache = { at, view }
|
|
407
|
+
return view
|
|
408
|
+
} catch {
|
|
409
|
+
this.ownCache = null
|
|
410
|
+
return null
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** What this request can prove about where it came from. Never throws. */
|
|
415
|
+
originOf(req: OriginRequest): OriginVerdict {
|
|
416
|
+
const rawRemote = req.socket?.remoteAddress ?? null
|
|
417
|
+
const remote = rawRemote ? normalizeAddress(rawRemote) : ''
|
|
418
|
+
const known = remote.length > 0
|
|
419
|
+
const loopback = known && isLoopbackAddress(remote)
|
|
420
|
+
const own = this.own()
|
|
421
|
+
// Fails closed: an unreadable interface list means every address may be this Mac's.
|
|
422
|
+
const ownAddress = known && (own === null || isOwnAddress(remote, own))
|
|
423
|
+
const instanceRaw = headerValue(req.headers, CLIENT_INSTANCE_HEADER)
|
|
424
|
+
const clientInstance = instanceRaw && INSTANCE_RE.test(instanceRaw) ? instanceRaw : null
|
|
425
|
+
const said = headerValue(req.headers, CLIENT_SURFACE_HEADER)
|
|
426
|
+
// 6.99.29: any X-Cos-Token header at all, valid or not, rules the web path out. The token is
|
|
427
|
+
// what agents on this Mac hold; a request that shows it is never Miles's browser.
|
|
428
|
+
const tokenRaw = req.headers[TOKEN_HEADER]
|
|
429
|
+
const tokenPresented = Array.isArray(tokenRaw) ? tokenRaw.length > 0 : typeof tokenRaw === 'string' && tokenRaw.length > 0
|
|
430
|
+
let webSession = false
|
|
431
|
+
if (!tokenPresented && this.deps.webSession) {
|
|
432
|
+
try { webSession = this.deps.webSession(req) === true } catch { webSession = false }
|
|
433
|
+
}
|
|
434
|
+
const local = loopback || ownAddress
|
|
435
|
+
const client: TurnClient = local ? 'local' : !known ? 'unknown' : webSession ? 'web' : said === 'glasses' || said === 'phone' ? said : 'unknown'
|
|
436
|
+
let verifiedOrigin = false
|
|
437
|
+
let reason: string | null = null
|
|
438
|
+
if (!known) reason = 'unknown_socket'
|
|
439
|
+
else if (local) reason = 'local_socket'
|
|
440
|
+
else if (webSession) {
|
|
441
|
+
// The cookie path passed, no token was shown, and the socket is not this Mac. RECORDED, never
|
|
442
|
+
// vouched: the cookie is an HMAC of the API token, so any token holder can mint one (header).
|
|
443
|
+
reason = WEB_UNVOUCHED_REASON
|
|
444
|
+
} else if (clientInstance === null) reason = 'no_client_instance'
|
|
445
|
+
else {
|
|
446
|
+
let paired: string | null = null
|
|
447
|
+
try { paired = this.deps.pairedInstance(remote, this.now(), PAIRED_INSTANCE_FRESH_MS) } catch { paired = null }
|
|
448
|
+
verifiedOrigin = paired !== null && paired === clientInstance
|
|
449
|
+
if (!verifiedOrigin) reason = 'not_ring_owner'
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
origin: { remote: known ? remote : 'unknown', loopback, ownAddress, clientInstance, client, tokenPresented, webSession },
|
|
453
|
+
verifiedOrigin,
|
|
454
|
+
reason,
|
|
455
|
+
judgedAt: this.now(),
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The text to deliver: with the footer for a vouched glasses or phone turn, otherwise unchanged.
|
|
461
|
+
* `provider` is the harness the text is delivered into (claude, codex or cursor), named in the
|
|
462
|
+
* footer's verify command; every caller knows it at this point (Codex re-audit of 910eb90).
|
|
463
|
+
*/
|
|
464
|
+
footed(prompt: string, turnId: string, verdict: OriginVerdict, provider: string): string {
|
|
465
|
+
if (!footerApplies(verdict) || !G2_TURN_ID_RE.test(turnId)) return prompt
|
|
466
|
+
return `${prompt.replace(/[\t\n\v\f\r ]+$/, '')}\n${turnFooter(turnId, verdict.origin.client, provider)}`
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private build(input: { turnId: string; prompt: string; verdict: OriginVerdict; provider: string; sessionId: string; via: string; fork?: boolean; resendOf?: string }): G2TurnRecord {
|
|
470
|
+
const originKind = originKindOf(input.verdict.origin.client)
|
|
471
|
+
// A web turn is never vouched, whatever verdict reached here (header).
|
|
472
|
+
const verifiedOrigin = input.verdict.verifiedOrigin === true && originKind !== 'web'
|
|
473
|
+
const reason = verifiedOrigin ? null : (originKind === 'web' ? WEB_UNVOUCHED_REASON : (input.verdict.reason ?? 'unverified'))
|
|
474
|
+
return {
|
|
475
|
+
turnId: input.turnId,
|
|
476
|
+
createdAt: new Date(this.now()).toISOString(),
|
|
477
|
+
textSha256: turnTextSha256(input.prompt),
|
|
478
|
+
origin: { ...input.verdict.origin },
|
|
479
|
+
originKind,
|
|
480
|
+
verifiedOrigin,
|
|
481
|
+
reason,
|
|
482
|
+
originJudgedAt: typeof input.verdict.judgedAt === 'number' ? input.verdict.judgedAt : this.now(),
|
|
483
|
+
target: { provider: input.provider, sessionId: input.sessionId, ...(input.fork ? { fork: true as const } : {}) },
|
|
484
|
+
via: input.via,
|
|
485
|
+
...(typeof input.resendOf === 'string' && input.resendOf.length > 0 ? { resendOf: input.resendOf } : {}),
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Whether delivering this candidate under `turnId` would reuse an id this process already holds
|
|
491
|
+
* for a DIFFERENT turn. An identical retry (same target, text and origin) is not reuse. The routes
|
|
492
|
+
* refuse a reuse before anything is sent. Never throws (a throw reads as reuse: refuse).
|
|
493
|
+
*/
|
|
494
|
+
reusesTurnId(turnId: string, candidate: TurnCandidate, opts: { continues?: boolean } = {}): boolean {
|
|
495
|
+
try {
|
|
496
|
+
const held = this.deps.ledger.held(turnId)
|
|
497
|
+
if (!held) return false
|
|
498
|
+
return this.conflicts(held, this.build({ turnId, via: 'check', ...candidate }), opts.continues === true)
|
|
499
|
+
} catch {
|
|
500
|
+
return true
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The one reuse rule. A held record is extended only by an identical retry, or by the server's
|
|
506
|
+
* own continuation of a turn that is STILL PARKED (a queue, edit or Send again record). A
|
|
507
|
+
* continuation never replaces a delivered record: a row written into a queue file by hand under
|
|
508
|
+
* a delivered turn's id cannot drain over it.
|
|
509
|
+
*/
|
|
510
|
+
private conflicts(held: G2TurnRecord, next: G2TurnRecord, continues: boolean): boolean {
|
|
511
|
+
if (sameTurn(held, next)) return false
|
|
512
|
+
return !(continues && PARKED_TURN_VIAS.has(held.via))
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Whether this process holds any record under this id (a throw reads as held: refuse). */
|
|
516
|
+
holds(turnId: string): boolean {
|
|
517
|
+
try { return this.deps.ledger.held(turnId) !== null } catch { return true }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Ledger one delivered or queued turn. `prompt` is the text WITHOUT the footer. Never throws.
|
|
522
|
+
* `continues`: the server's own continuation of the turn parked under this id (drain carry, queue
|
|
523
|
+
* edit, Cursor Stop hand-off), which may extend a parked record. Otherwise a held id is never
|
|
524
|
+
* replaced: an identical retry keeps the first record, a different turn is refused (null, nothing
|
|
525
|
+
* written).
|
|
526
|
+
*/
|
|
527
|
+
record(input: { turnId: string; prompt: string; verdict: OriginVerdict; provider: string; sessionId: string; via: string; fork?: boolean; continues?: boolean; resendOf?: string }): G2TurnRecord | null {
|
|
528
|
+
try {
|
|
529
|
+
const record = this.build(input)
|
|
530
|
+
const held = this.deps.ledger.held(input.turnId)
|
|
531
|
+
if (held) {
|
|
532
|
+
if (this.conflicts(held, record, input.continues === true)) {
|
|
533
|
+
console.warn(`[g2-turn-ledger] turn id reuse refused turnId=${input.turnId} via=${input.via} held=${held.via}`)
|
|
534
|
+
return null
|
|
535
|
+
}
|
|
536
|
+
// An identical retry that is not a continuation keeps the first record (and its time).
|
|
537
|
+
if (input.continues !== true && sameTurn(held, record)) return held
|
|
538
|
+
}
|
|
539
|
+
this.deps.ledger.append(record, { replace: held !== null })
|
|
540
|
+
return record
|
|
541
|
+
} catch (error) {
|
|
542
|
+
console.error(`[g2-turn-ledger] record failed: ${error instanceof Error ? error.message : error}`)
|
|
543
|
+
return null
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private queueKey(provider: string, threadId: string, clientTurnId: string): string {
|
|
548
|
+
return `${provider}\u0000${threadId}\u0000${clientTurnId}`
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* A turn parked in the queue (or edited there): remember who said these words. The origin lapses
|
|
553
|
+
* QUEUED_ORIGIN_TTL_MS after the verdict was judged (`verdict.judgedAt`), never after this call:
|
|
554
|
+
* an origin cannot be made to last longer by noting it again.
|
|
555
|
+
*/
|
|
556
|
+
noteQueued(provider: string, threadId: string, clientTurnId: string, prompt: string, verdict: OriginVerdict, attachments = ''): void {
|
|
557
|
+
const at = this.now()
|
|
558
|
+
for (const [k, held] of this.queued) if (at - held.judgedAt > QUEUED_ORIGIN_TTL_MS) this.queued.delete(k)
|
|
559
|
+
const key = this.queueKey(provider, threadId, clientTurnId)
|
|
560
|
+
this.queued.delete(key)
|
|
561
|
+
this.queued.set(key, { verdict, textSha256: turnTextSha256(prompt), judgedAt: typeof verdict.judgedAt === 'number' ? verdict.judgedAt : at, attachments })
|
|
562
|
+
while (this.queued.size > QUEUED_ORIGIN_MAX) this.queued.delete(this.queued.keys().next().value as string)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
forgetQueued(provider: string, threadId: string, clientTurnId: string): void {
|
|
566
|
+
this.queued.delete(this.queueKey(provider, threadId, clientTurnId))
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The origin remembered for this queued turn, only while its text is the text it vouched for and
|
|
571
|
+
* within QUEUED_ORIGIN_TTL_MS of when it was judged. A lapsed origin is forgotten here.
|
|
572
|
+
*/
|
|
573
|
+
queuedVerdict(provider: string, threadId: string, clientTurnId: string, prompt: string): OriginVerdict | null {
|
|
574
|
+
return this.queuedOrigin(provider, threadId, clientTurnId, prompt)?.verdict ?? null
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private queuedOrigin(provider: string, threadId: string, clientTurnId: string, prompt: string): QueuedOrigin | null {
|
|
578
|
+
const key = this.queueKey(provider, threadId, clientTurnId)
|
|
579
|
+
const held = this.queued.get(key)
|
|
580
|
+
if (!held) return null
|
|
581
|
+
if (this.now() - held.judgedAt > QUEUED_ORIGIN_TTL_MS) { this.queued.delete(key); return null }
|
|
582
|
+
if (held.textSha256 !== turnTextSha256(prompt)) return null
|
|
583
|
+
return held
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Send again (Codex re-audit of 910eb90, P1). The row's words are MUTABLE (any local process can
|
|
588
|
+
* rewrite the queue file), so a copy never inherits the original row's authority: inheriting it by
|
|
589
|
+
* id once let a local caller change a refused G2 row's words or files and mint a new vouched turn
|
|
590
|
+
* from them. The copy is judged by THE REQUEST THAT SENT IT AGAIN, fresh: a G2 or phone client
|
|
591
|
+
* resending is vouched by its own socket and ring-owner instance, exactly like a new turn, and
|
|
592
|
+
* anything else (an agent's token over loopback, a web cookie, an unknown caller) is recorded
|
|
593
|
+
* unvouched with RESEND_NOT_REVERIFIED_REASON, so it gets no footer. The original id survives only
|
|
594
|
+
* as the record's `resendOf` annotation, which vouches for nothing.
|
|
595
|
+
*/
|
|
596
|
+
resendVerdict(req: OriginRequest): OriginVerdict {
|
|
597
|
+
const fresh = this.originOf(req)
|
|
598
|
+
return fresh.verifiedOrigin === true ? fresh : unvouched(fresh, RESEND_NOT_REVERIFIED_REASON)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* A single-use token for the drainer's loopback hop. ALWAYS issued for a drain: it carries the
|
|
603
|
+
* parked origin while one is held for these words (else none, and the turn route judges the hop as
|
|
604
|
+
* the local call it is), and it marks the hop as the queue's own continuation of the parked turn,
|
|
605
|
+
* the one caller allowed to add to that turn's ledger record. Null only if no token can be made.
|
|
606
|
+
*/
|
|
607
|
+
issueCarry(turn: { provider: string; threadId: string; clientTurnId: string; prompt: string }): string | null {
|
|
608
|
+
try {
|
|
609
|
+
const held = this.queuedOrigin(turn.provider, turn.threadId, turn.clientTurnId, turn.prompt)
|
|
610
|
+
const at = this.now()
|
|
611
|
+
for (const [token, carry] of this.carries) if (carry.expiresAt <= at) this.carries.delete(token)
|
|
612
|
+
const token = (this.deps.randomToken ?? (() => randomBytes(32).toString('hex')))()
|
|
613
|
+
// The seal rides with the origin: the turn route checks the binding's provider and thread and
|
|
614
|
+
// the files it is about to compose against it, and refuses a vouched turn that no longer matches.
|
|
615
|
+
const seal: QueueSeal | null = held ? { provider: turn.provider, threadId: turn.threadId, attachments: held.attachments } : null
|
|
616
|
+
this.carries.set(token, { clientTurnId: turn.clientTurnId, verdict: held?.verdict ?? null, seal, textSha256: turnTextSha256(turn.prompt), expiresAt: at + TURN_CARRY_TTL_MS })
|
|
617
|
+
return token
|
|
618
|
+
} catch {
|
|
619
|
+
return null
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Spend a carry token. Null unless it is live, unspent, and names this turn and these words;
|
|
625
|
+
* otherwise `{ verdict, seal }`: the parked origin and what it is sealed to, or nulls when none was
|
|
626
|
+
* left to carry.
|
|
627
|
+
*/
|
|
628
|
+
takeCarry(token: unknown, clientTurnId: string, prompt: string): { verdict: OriginVerdict | null; seal: QueueSeal | null } | null {
|
|
629
|
+
if (typeof token !== 'string' || token.length === 0) return null
|
|
630
|
+
const carry = this.carries.get(token)
|
|
631
|
+
if (!carry) return null
|
|
632
|
+
this.carries.delete(token)
|
|
633
|
+
if (carry.expiresAt <= this.now()) return null
|
|
634
|
+
if (carry.clientTurnId !== clientTurnId || carry.textSha256 !== turnTextSha256(prompt)) return null
|
|
635
|
+
return { verdict: carry.verdict, seal: carry.seal }
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** One answer of `GET /api/g2-turns/:turnId`. */
|
|
640
|
+
export async function describeG2Turn(provenance: G2TurnProvenance, turnId: string, now: number): Promise<Record<string, unknown> | null> {
|
|
641
|
+
const found = await provenance.ledger.find(turnId)
|
|
642
|
+
if (!found) return null
|
|
643
|
+
const { record, sealed } = found
|
|
644
|
+
// Codex re-audit of 910eb90 (P2): the age of an APPROVAL is measured from when its origin was
|
|
645
|
+
// judged (the request Miles sent), never from when it was delivered. A queued, edited or Cursor
|
|
646
|
+
// Stop turn is recorded again at delivery with a new createdAt; measuring from that made a turn
|
|
647
|
+
// that waited 20 minutes in the queue read as seconds old to the verifier's 15-minute check. The
|
|
648
|
+
// earlier of the two is used, so an approval can never read younger than its delivery.
|
|
649
|
+
const createdMs = Date.parse(record.createdAt)
|
|
650
|
+
const judgedMs = typeof record.originJudgedAt === 'number' && Number.isFinite(record.originJudgedAt) ? record.originJudgedAt : null
|
|
651
|
+
const fromMs = judgedMs !== null && Number.isFinite(createdMs) ? Math.min(judgedMs, createdMs) : (judgedMs ?? createdMs)
|
|
652
|
+
const ageMs = Number.isFinite(fromMs) ? Math.max(0, now - fromMs) : null
|
|
653
|
+
// 6.99.29: g2, phone or web (older records carry only `origin.client`).
|
|
654
|
+
const originKind = record.originKind ?? originKindOf(record.origin?.client)
|
|
655
|
+
// Only a record this process wrote and still holds vouches for anything (see the header), never a
|
|
656
|
+
// web turn (its cookie is derivable from the token), and never past G2_TURN_VOUCH_MAX_MS.
|
|
657
|
+
let reason: string | null = null
|
|
658
|
+
if (originKind === 'web') reason = WEB_UNVOUCHED_REASON
|
|
659
|
+
else if (record.verifiedOrigin !== true) reason = typeof record.reason === 'string' ? record.reason : 'unverified'
|
|
660
|
+
else if (!sealed) reason = 'not_sealed'
|
|
661
|
+
else if (ageMs === null || ageMs > G2_TURN_VOUCH_MAX_MS) reason = 'record_too_old'
|
|
662
|
+
return {
|
|
663
|
+
found: true,
|
|
664
|
+
turnId: record.turnId,
|
|
665
|
+
createdAt: record.createdAt,
|
|
666
|
+
/** When the origin this turn carries was judged; `ageMs` (and the vouch limit) run from here. */
|
|
667
|
+
originJudgedAt: Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : null,
|
|
668
|
+
ageMs,
|
|
669
|
+
textSha256: record.textSha256,
|
|
670
|
+
origin: record.origin,
|
|
671
|
+
originKind,
|
|
672
|
+
verifiedOrigin: reason === null,
|
|
673
|
+
reason,
|
|
674
|
+
sealed,
|
|
675
|
+
target: record.target,
|
|
676
|
+
via: record.via,
|
|
677
|
+
...(typeof record.resendOf === 'string' ? { resendOf: record.resendOf } : {}),
|
|
678
|
+
}
|
|
679
|
+
}
|