@gotcos/glasses-server 6.27.13 → 6.29.0

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.
@@ -0,0 +1,327 @@
1
+ // The operational lease that says "this COS Chat is driving that desktop thread".
2
+ //
3
+ // Phase 1 of Continue Original Agent Thread. A binding is NOT historical
4
+ // provenance — it is a lease with a state, an expiry, and an epoch. Provenance
5
+ // is stamped into the job at admission and read from there forever after
6
+ // (plan 3.4). Reading the CURRENT binding to describe a FINISHED turn is the bug
7
+ // this separation exists to prevent.
8
+ //
9
+ // NAMING: this file holds the binding VALUE TYPE and its transitions. It is not
10
+ // a store — there is no persistence here yet, and the durable per-target epoch
11
+ // high-water mark that the replay defense depends on does not exist. Until it
12
+ // does, `priorEpoch` is caller-supplied and the anti-replay guarantee is only as
13
+ // good as whatever the caller reads it from. That gap is deliberate and named
14
+ // rather than implied.
15
+ //
16
+ // WHY AN EPOCH AND NOT JUST AN ID. The prompt queue is client-owned: up to five
17
+ // prompts can sit in an attached Chat holding no server state at all. If the
18
+ // binding expires or the user detaches, those rows are still on the phone and
19
+ // will eventually be submitted. Without an epoch, a prompt composed against
20
+ // binding N can land on a re-attach of the SAME target and execute against a
21
+ // conversation the user never intended.
22
+ //
23
+ // KEY ALIASING IS A REAL RISK, NOT A THEORETICAL ONE. `SAFE_ID_RE` in
24
+ // query-job-types.ts is `/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/`, which permits both
25
+ // `:` and `/` inside a session id, so `provider + ':' + id` is forgeable. Every
26
+ // composite identifier in this file is LENGTH-PREFIXED, which is injective for
27
+ // all string content. That applies to `boundToMarker` too: an earlier version
28
+ // built it by dot-joining three fields sixteen lines below a comment banning
29
+ // exactly that, and its safety rested on the incidental fact that a targetKey
30
+ // never contains `.` before its first `:`. Do not reintroduce concatenation.
31
+ //
32
+ // STATES ARE AN ALLOWLIST. Only `active` may run work. An earlier version
33
+ // rejected `detached`/`detaching` by denylist, which silently let `staging` —
34
+ // the pre-commit state of the journaled Chat handoff (plan 4.8) — execute real
35
+ // turns against a Chat that might still be rolled back.
36
+
37
+ import { isValidNativeThreadId } from './native-thread-id.js'
38
+ import type { AgentProvider } from './agent-session-store.js'
39
+
40
+ export type BindingState = 'staging' | 'active' | 'detaching' | 'detached'
41
+
42
+ /** Providers that can carry a binding. Cursor is Fork-only (plan 2.5). */
43
+ export const BINDABLE_PROVIDERS = ['claude', 'codex'] as const
44
+ export type BindableProvider = (typeof BINDABLE_PROVIDERS)[number]
45
+
46
+ export interface NativeBinding {
47
+ bindingId: string
48
+ cosSessionId: string
49
+ /** Narrowed to the bindable subset so an unbindable provider is unrepresentable. */
50
+ provider: BindableProvider
51
+ /** Exact private native thread id. Never prefix-matched, never truncated. */
52
+ nativeThreadId: string
53
+ /** Injective mutex key. */
54
+ targetKey: string
55
+ workspaceFingerprint: string
56
+ sourceFingerprint: string
57
+ /** Opaque revision token observed at attach. Null when unsupported. */
58
+ nativeHeadAtAttach: string | null
59
+ /** Increments per attach to the same target. Positive integer. */
60
+ epoch: number
61
+ state: BindingState
62
+ /** Epoch ms. Ignored while pinned. */
63
+ expiresAt: number
64
+ pinnedJobs: readonly string[]
65
+ }
66
+
67
+ export type BindingRejection =
68
+ | 'invalid_thread_id'
69
+ | 'invalid_provider'
70
+ | 'invalid_binding_id'
71
+ | 'invalid_epoch'
72
+ | 'invalid_ttl'
73
+ | 'unknown_binding'
74
+ | 'binding_not_active'
75
+ | 'binding_detached'
76
+ | 'binding_expired'
77
+ | 'stale_epoch'
78
+ | 'target_mismatch'
79
+ | 'missing_target_key'
80
+ | 'binding_pinned'
81
+ | 'terminal_state'
82
+
83
+ export interface BindingCheck {
84
+ ok: boolean
85
+ reason: BindingRejection | null
86
+ }
87
+
88
+ const OK: BindingCheck = { ok: true, reason: null }
89
+ const no = (reason: BindingRejection): BindingCheck => ({ ok: false, reason })
90
+
91
+ /** A bindingId must be safe to embed in a marker and to log. */
92
+ export const BINDING_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
93
+
94
+ export function isBindableProvider(value: unknown): value is BindableProvider {
95
+ return typeof value === 'string' && (BINDABLE_PROVIDERS as readonly string[]).includes(value)
96
+ }
97
+
98
+ /**
99
+ * Injective composite key: `<len>:<provider>:<len>:<threadId>`.
100
+ *
101
+ * Length-prefixing is what makes it unforgeable. With plain `a:b`, provider `x`
102
+ * with id `y:z` collides with provider `x:y` and id `z`. With lengths the parse
103
+ * is unambiguous for every possible content.
104
+ *
105
+ * NOTE: this function validates nothing — the safety property lives in the
106
+ * callers, all of which validate first.
107
+ */
108
+ export function targetKey(provider: string, nativeThreadId: string): string {
109
+ return `${provider.length}:${provider}:${nativeThreadId.length}:${nativeThreadId}`
110
+ }
111
+
112
+ export interface CreateBindingInput {
113
+ bindingId: string
114
+ cosSessionId: string
115
+ provider: string
116
+ nativeThreadId: string
117
+ workspaceFingerprint: string
118
+ sourceFingerprint: string
119
+ nativeHeadAtAttach?: string | null
120
+ /**
121
+ * Highest epoch previously issued FOR THIS TARGET, from durable state.
122
+ *
123
+ * Reading this from the current in-memory binding rather than a persisted
124
+ * high-water mark reopens the replay window: after detach and eviction the
125
+ * next attach restarts at 1 and a queued row from the first attach matches.
126
+ */
127
+ priorEpoch?: number
128
+ ttlMs: number
129
+ now: number
130
+ }
131
+
132
+ export type CreateBindingResult =
133
+ | { binding: NativeBinding; reason: null }
134
+ | { binding: null; reason: BindingRejection }
135
+
136
+ export function createBinding(input: CreateBindingInput): CreateBindingResult {
137
+ if (!isBindableProvider(input.provider)) return { binding: null, reason: 'invalid_provider' }
138
+ if (!isValidNativeThreadId(input.nativeThreadId)) return { binding: null, reason: 'invalid_thread_id' }
139
+ if (typeof input.bindingId !== 'string' || !BINDING_ID_RE.test(input.bindingId)) {
140
+ return { binding: null, reason: 'invalid_binding_id' }
141
+ }
142
+ const prior = input.priorEpoch ?? 0
143
+ if (!Number.isInteger(prior) || prior < 0) return { binding: null, reason: 'invalid_epoch' }
144
+ if (!Number.isFinite(input.ttlMs) || input.ttlMs <= 0) return { binding: null, reason: 'invalid_ttl' }
145
+ if (!Number.isFinite(input.now)) return { binding: null, reason: 'invalid_ttl' }
146
+
147
+ return {
148
+ binding: {
149
+ bindingId: input.bindingId,
150
+ cosSessionId: input.cosSessionId,
151
+ provider: input.provider,
152
+ nativeThreadId: input.nativeThreadId,
153
+ targetKey: targetKey(input.provider, input.nativeThreadId),
154
+ workspaceFingerprint: input.workspaceFingerprint,
155
+ sourceFingerprint: input.sourceFingerprint,
156
+ nativeHeadAtAttach: input.nativeHeadAtAttach ?? null,
157
+ epoch: prior + 1,
158
+ state: 'staging',
159
+ expiresAt: input.now + input.ttlMs,
160
+ pinnedJobs: [],
161
+ },
162
+ reason: null,
163
+ }
164
+ }
165
+
166
+ export function isTerminal(binding: NativeBinding): boolean {
167
+ return binding.state === 'detached' || binding.state === 'detaching'
168
+ }
169
+
170
+ /**
171
+ * staging -> active. Refused from any other state.
172
+ *
173
+ * Without this guard `activate(detach(b))` resurrects a detached binding, which
174
+ * QA verified against the first version.
175
+ */
176
+ export function activate(binding: NativeBinding): { binding: NativeBinding | null; reason: BindingRejection | null } {
177
+ if (binding.state !== 'staging') return { binding: null, reason: 'terminal_state' }
178
+ return { binding: { ...binding, state: 'active' }, reason: null }
179
+ }
180
+
181
+ /** active -> detaching. The drain step that makes `detaching` reachable. */
182
+ export function beginDetach(binding: NativeBinding): NativeBinding {
183
+ return isTerminal(binding) ? binding : { ...binding, state: 'detaching' }
184
+ }
185
+
186
+ export function pin(binding: NativeBinding, jobId: string): NativeBinding {
187
+ // A terminal binding must not be re-pinned into permanent non-expiry.
188
+ if (isTerminal(binding) || binding.pinnedJobs.includes(jobId)) return binding
189
+ return { ...binding, pinnedJobs: [...binding.pinnedJobs, jobId] }
190
+ }
191
+
192
+ export function unpin(binding: NativeBinding, jobId: string): NativeBinding {
193
+ return { ...binding, pinnedJobs: binding.pinnedJobs.filter(id => id !== jobId) }
194
+ }
195
+
196
+ export function isPinned(binding: NativeBinding): boolean {
197
+ return binding.pinnedJobs.length > 0
198
+ }
199
+
200
+ /**
201
+ * A pinned binding NEVER expires. Accepted or running work outranks the TTL.
202
+ *
203
+ * Known gap: `pinnedJobs` carries no timestamps, so a job that dies without
204
+ * unpinning makes the binding immortal. A reaper needs per-pin ages; this shape
205
+ * cannot express one.
206
+ */
207
+ export function isExpired(binding: NativeBinding, now: number): boolean {
208
+ if (isPinned(binding)) return false
209
+ return now >= binding.expiresAt
210
+ }
211
+
212
+ /** Extend a lease. Never shortens, and never revives a terminal binding. */
213
+ export function renew(binding: NativeBinding, ttlMs: number, now: number): NativeBinding {
214
+ if (isTerminal(binding) || !Number.isFinite(ttlMs) || !Number.isFinite(now)) return binding
215
+ const next = now + ttlMs
216
+ return next > binding.expiresAt ? { ...binding, expiresAt: next } : binding
217
+ }
218
+
219
+ /** Detach is refused while work is active (plan 3.4). */
220
+ export function canDetach(binding: NativeBinding): BindingCheck {
221
+ return isPinned(binding) ? no('binding_pinned') : OK
222
+ }
223
+
224
+ /**
225
+ * Detach, honoring `canDetach`.
226
+ *
227
+ * The first version exported an unguarded mutator next to its guard, so
228
+ * `detach(pinnedBinding)` silently dropped the pins and orphaned the live job
229
+ * the guard existed to protect.
230
+ */
231
+ export function detach(
232
+ binding: NativeBinding,
233
+ ): { binding: NativeBinding; reason: null } | { binding: null; reason: BindingRejection } {
234
+ const gate = canDetach(binding)
235
+ if (!gate.ok) return { binding: null, reason: gate.reason! }
236
+ return { binding: { ...binding, state: 'detached', pinnedJobs: [] }, reason: null }
237
+ }
238
+
239
+ /** Force-detach after an explicit user cancel that reached terminal state. */
240
+ export function forceDetach(binding: NativeBinding): NativeBinding {
241
+ return { ...binding, state: 'detached', pinnedJobs: [] }
242
+ }
243
+
244
+ /**
245
+ * The single gate every execution path must clear.
246
+ *
247
+ * `checkQueuedPrompt` and `verifyBoundTo` both delegate here so they cannot
248
+ * disagree — the first version had the full set of checks in one and a bare
249
+ * string comparison in the other, so a detached or expired binding passed
250
+ * `verifyBoundTo` cleanly.
251
+ */
252
+ export function assertUsable(binding: NativeBinding | null, now: number): BindingCheck {
253
+ if (!binding) return no('unknown_binding')
254
+ if (binding.state === 'detached' || binding.state === 'detaching') return no('binding_detached')
255
+ // Allowlist: only `active` runs work. `staging` is pre-commit (plan 4.8).
256
+ if (binding.state !== 'active') return no('binding_not_active')
257
+ if (isExpired(binding, now)) return no('binding_expired')
258
+ return OK
259
+ }
260
+
261
+ export interface QueuedPromptClaim {
262
+ bindingId: string
263
+ epoch: number
264
+ /** Required. The strongest cross-target guard must not be opt-in. */
265
+ targetKey: string
266
+ }
267
+
268
+ /**
269
+ * Can this client-queued prompt still run?
270
+ *
271
+ * The queue lives on the phone and holds no server state, so a row can outlive
272
+ * the binding it was composed against. Every rejection here is a row that would
273
+ * otherwise either 409 opaquely or execute as a plain COS turn — the silent
274
+ * Continue-to-ordinary downgrade the product contract bans (plan 4.2).
275
+ */
276
+ export function checkQueuedPrompt(
277
+ claim: QueuedPromptClaim,
278
+ binding: NativeBinding | null,
279
+ now: number,
280
+ ): BindingCheck {
281
+ if (!binding) return no('unknown_binding')
282
+ if (claim.bindingId !== binding.bindingId) return no('unknown_binding')
283
+ if (typeof claim.targetKey !== 'string' || claim.targetKey.length === 0) return no('missing_target_key')
284
+
285
+ // State before epoch: detached is the more actionable answer for a user whose
286
+ // queue drained after they detached.
287
+ const usable = assertUsable(binding, now)
288
+ if (!usable.ok && usable.reason !== 'binding_expired') return usable
289
+
290
+ // Epoch before expiry: a re-attach is a different and more dangerous fault
291
+ // than a timeout, and collapsing it into 'expired' hides that the target moved.
292
+ if (claim.epoch !== binding.epoch) return no('stale_epoch')
293
+ if (claim.targetKey !== binding.targetKey) return no('target_mismatch')
294
+ return usable.ok ? OK : usable
295
+ }
296
+
297
+ /**
298
+ * Opaque marker persisted INTO the request so a re-admission is recognizable.
299
+ *
300
+ * Without it, a re-admission through the generic route carries no attachment
301
+ * fields at all — the binding lives in the route path and 4.2 forbids the client
302
+ * sending execution fields — so the generic route's rejection cannot fire and an
303
+ * attached turn degrades silently into an ordinary one.
304
+ *
305
+ * Length-prefixed for the same reason `targetKey` is.
306
+ */
307
+ export function boundToMarker(binding: NativeBinding): string {
308
+ const epoch = String(binding.epoch)
309
+ return [
310
+ `${binding.bindingId.length}:${binding.bindingId}`,
311
+ `${epoch.length}:${epoch}`,
312
+ `${binding.targetKey.length}:${binding.targetKey}`,
313
+ ].join('')
314
+ }
315
+
316
+ /** Verify a marker AND that the binding it names may still run work. */
317
+ export function verifyBoundTo(marker: unknown, binding: NativeBinding | null, now: number): BindingCheck {
318
+ const usable = assertUsable(binding, now)
319
+ if (!usable.ok) return usable
320
+ if (typeof marker !== 'string' || marker !== boundToMarker(binding!)) return no('target_mismatch')
321
+ return OK
322
+ }
323
+
324
+ /** Is this request an attached turn at all? Used to reject it on generic routes. */
325
+ export function carriesBoundTo(request: Record<string, unknown> | null | undefined): boolean {
326
+ return typeof request?.boundTo === 'string' && request.boundTo.length > 0
327
+ }