@gotcos/glasses-server 6.28.0 → 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,957 @@
1
+ // Fork: create a NEW native provider thread carrying an existing thread's
2
+ // history, and leave the ORIGINAL byte-identical.
3
+ //
4
+ // WHY THIS FILE EXISTS. The server refuses to continue a native thread in
5
+ // seventeen separate places, and every one of those refusals ends with the same
6
+ // four words: "Fork it instead." Until this module there was nothing behind that
7
+ // sentence — no route, no handler, no code path. Every refusal was pointing at a
8
+ // door that did not exist. This is the door.
9
+ //
10
+ // THERE IS NO OCCUPANCY GATE HERE, DELIBERATELY, AND THAT IS THE WHOLE POINT.
11
+ // `attached-provider-adapter.ts` re-checks occupancy immediately before it
12
+ // spawns, because it is about to APPEND to a conversation a human being may have
13
+ // open on their desktop. Fork appends to nothing. It reads the source thread and
14
+ // writes a new one, so a live desktop owner is not a hazard to be avoided — it is
15
+ // the ordinary case, and the reason the user was routed here. A fork that refused
16
+ // because the source is busy would refuse exactly when it is needed and leave the
17
+ // user with no path at all. So: no occupancy probe, no owner scan, no preflight
18
+ // verdict. Nothing in this file may grow one.
19
+ //
20
+ // WHAT WAS MEASURED, NOT ASSUMED (both providers, live, 2026-08-16).
21
+ //
22
+ // claude -p --output-format stream-json --verbose --resume <id> --fork-session
23
+ // exit 0. Source transcript sha256 UNCHANGED, 7440 bytes before and after.
24
+ // A new transcript appeared (9448 bytes: the source's history plus the new
25
+ // turn). stdout carried EXACTLY ONE distinct id, `session_id` on every event,
26
+ // and it was the NEW one. The source id was never printed.
27
+ //
28
+ // codex exec --sandbox read-only --cd <cwd> fork --json --skip-git-repo-check <id> -
29
+ // exit 0. Source rollout sha256 UNCHANGED, 48632 bytes before and after.
30
+ // stdout carried EXACTLY ONE distinct id, on `thread.started` as `thread_id`,
31
+ // and it was the NEW one.
32
+ //
33
+ // Both of those runs are what makes the exactly-one-id rule below a measurement
34
+ // rather than a hopeful default: neither provider echoes the source id, so
35
+ // "more than one id" really does mean we cannot tell what happened.
36
+ //
37
+ // THE SINGLE MOST IMPORTANT ASSERTION IN THIS MODULE is that the returned id
38
+ // DIFFERS from the source id. If a provider hands back the id we asked it to fork
39
+ // FROM, then whatever just happened was not a fork — it was an append into the
40
+ // user's real conversation, performed by the code path we offer as the safe
41
+ // alternative to appending. That is a terminal failure, never a success. It is the
42
+ // exact mirror of the adapter's id-EQUALITY check: that module demands the ids
43
+ // match, this one demands they differ, and both refuse on anything else.
44
+ //
45
+ // NEVER COMPARE IDS BY PREFIX. Measured on the codex canary above: the source and
46
+ // its fork were `01a00ab4-7350-74a3-…` and `01a00ab4-b9fd-7680-…`. Codex mints
47
+ // UUIDv7, so two ids created seconds apart SHARE THEIR ENTIRE FIRST BLOCK. A
48
+ // `startsWith` or an eight-character display-form comparison would have read that
49
+ // fork as a write into the source, or — flip the branch — read a write into the
50
+ // source as a fork. Comparison is full-string equality, and `isValidNativeThreadId`
51
+ // is what stands between this rule and a truncated id that cannot express it.
52
+ //
53
+ // PROMPT GOES ON STDIN FOR BOTH PROVIDERS. argv is world-readable through `ps` and
54
+ // the prompt is the user's private text; the adapter established this and fork does
55
+ // not get to relax it. Codex documents `-` as "read the prompt from stdin" on
56
+ // `exec fork` and the canary above used it, so the argv form is not needed. Note
57
+ // that the codex CLI reads stdin whether or not a prompt argument is present, so
58
+ // stdin must always be ENDED — an unclosed pipe hangs the child until the timeout.
59
+ //
60
+ // WHAT THIS MODULE REFUSES TO IMPORT: the bridges, the occupancy detector, the
61
+ // binding registry. It resolves a binary, spawns one child, reads ids off stdout,
62
+ // and reports. Everything else is the caller's.
63
+
64
+ import { isAbsolute } from 'node:path'
65
+ import { spawn as nodeSpawn } from 'node:child_process'
66
+
67
+ import { isValidNativeThreadId } from './native-thread-id.js'
68
+ import { isBindableProvider } from './agent-session-binding-store.js'
69
+ import { recordCosSpawn, releaseCosSpawn } from './agent-session-ownership-store.js'
70
+ import { processStartMs as realProcessStartMs } from './occupancy-probes.js'
71
+ import {
72
+ DEFAULT_ATTACHED_TIMEOUT_MS,
73
+ FORCE_SETTLE_MS,
74
+ KILL_GRACE_MS,
75
+ MAX_ATTACHED_TIMEOUT_MS,
76
+ MAX_PROMPT_CHARS,
77
+ MAX_STDOUT_SCAN_BYTES,
78
+ buildAttachedEnv,
79
+ classifyStderr,
80
+ extractNativeIdsFromLine,
81
+ findBannedPermissionArg,
82
+ isAttachedPermissionPolicy,
83
+ resolveProviderBinary,
84
+ type AttachedChildProcess,
85
+ type AttachedProvider,
86
+ type AttachedSpawnRequest,
87
+ type AttachedStderrClass,
88
+ type BinaryResolution,
89
+ } from './attached-provider-adapter.js'
90
+
91
+ /**
92
+ * Providers that can be forked. Identical to the attached set on purpose.
93
+ *
94
+ * Re-exported from the adapter rather than redeclared: a provider that gains an
95
+ * attached path and not a fork path (or the reverse) would leave one of the two
96
+ * halves of this feature silently unreachable.
97
+ */
98
+ export type ForkProvider = AttachedProvider
99
+
100
+ /** Same one-member policy as the attached path. An omitted policy is refused. */
101
+ export type ForkPermissionPolicy = 'read_only'
102
+
103
+ /** Terminal reason on failure. Never carries prompt, transcript or provider text. */
104
+ export type ForkFailure =
105
+ | 'invalid_provider'
106
+ | 'invalid_thread_id'
107
+ | 'invalid_prompt'
108
+ | 'invalid_cwd'
109
+ | 'invalid_timeout'
110
+ | 'unsupported_policy'
111
+ | 'binary_not_found'
112
+ | 'spawn_failed'
113
+ | 'ownership_record_failed'
114
+ | 'child_stdio_unavailable'
115
+ | 'timeout'
116
+ | 'provider_exit_nonzero'
117
+ | 'no_native_id_returned'
118
+ /** The provider handed back the id we asked it to fork FROM. See the header. */
119
+ | 'fork_returned_source_id'
120
+ | 'ambiguous_native_id'
121
+ /** The source thread changed while we forked it. The one unforgivable outcome. */
122
+ | 'source_thread_mutated'
123
+ | 'fork_internal_error'
124
+
125
+ /**
126
+ * How much of a new thread may exist on the user's Mac as a result of this call.
127
+ *
128
+ * This is NOT the adapter's delivery state and must not be read as one. Delivery
129
+ * asks "might we have written into their conversation?", which for a fork is
130
+ * answered by `sourceIntegrity`. This asks a much smaller question: "is there a
131
+ * thread out there we created and cannot name?" — an orphan, which costs disk and
132
+ * confusion but harms nothing.
133
+ *
134
+ * none no child process was ever created, or it was killed pre-prompt
135
+ * possible a child ran; a forked thread may exist whose id we never observed
136
+ * created we observed exactly one new id and it is reported below
137
+ */
138
+ export type ForkState = 'none' | 'possible' | 'created'
139
+
140
+ /**
141
+ * Whether the source thread was proved untouched.
142
+ *
143
+ * verified_unchanged a watermark was read before and after, and they match
144
+ * unverified no watermark was available at one or both ends
145
+ * mutated both were read and they DIFFER — terminal
146
+ *
147
+ * `unverified` is a deliberate, named exception to this feature's "every unknown
148
+ * refuses" rule, and it is the only one in this file. The rule exists to stop an
149
+ * unproven assumption authorizing a WRITE into someone's conversation. Fork
150
+ * performs no such write by construction, so the watermark is corroboration, not
151
+ * a precondition — and making it a precondition would mean an unreadable
152
+ * transcript disables the fallback at the exact moment the primary path has
153
+ * already refused, leaving the user nothing. So a missing watermark degrades to a
154
+ * REPORTED `unverified`, never to a fabricated `verified_unchanged`. Positive
155
+ * evidence of mutation is still terminal.
156
+ */
157
+ export type SourceIntegrity = 'verified_unchanged' | 'unverified' | 'mutated'
158
+
159
+ export interface ForkSuccess {
160
+ ok: true
161
+ provider: ForkProvider
162
+ sourceNativeThreadId: string
163
+ /** The forked thread. Guaranteed !== `sourceNativeThreadId`. Bind to this. */
164
+ newNativeThreadId: string
165
+ forkState: 'created'
166
+ sourceIntegrity: 'verified_unchanged' | 'unverified'
167
+ reason: null
168
+ exitCode: number
169
+ durationMs: number
170
+ }
171
+
172
+ export interface ForkFailureResult {
173
+ ok: false
174
+ provider: ForkProvider | null
175
+ sourceNativeThreadId: string | null
176
+ /**
177
+ * The forked thread, when we saw exactly one usable id before failing.
178
+ *
179
+ * Present on failure so an orphan can be found and cleaned up rather than
180
+ * silently accumulating. Server-side only — the route projects an opaque
181
+ * reference, never this.
182
+ */
183
+ newNativeThreadId: string | null
184
+ forkState: ForkState
185
+ sourceIntegrity: SourceIntegrity
186
+ reason: ForkFailure
187
+ /** Bounded, self-authored discriminator. Enum-like strings only. */
188
+ detail: string | null
189
+ exitCode: number | null
190
+ stderrClass: AttachedStderrClass
191
+ durationMs: number
192
+ }
193
+
194
+ export type ForkResult = ForkSuccess | ForkFailureResult
195
+
196
+ /**
197
+ * Might this call have left a thread behind that nobody can name?
198
+ *
199
+ * Written as "possible unless proven otherwise" rather than an equality test, so a
200
+ * `ForkState` added later defaults to "warn the user" instead of "say nothing".
201
+ */
202
+ export function forkOrphanPossible(result: ForkResult): boolean {
203
+ if (result.ok) return false
204
+ return result.forkState !== 'none'
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Argv
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /**
212
+ * `claude -p --resume <id> --fork-session`, read-only, prompt on stdin.
213
+ *
214
+ * `--fork-session` is documented by the installed CLI as "When resuming, create a
215
+ * new session ID" and is only meaningful alongside `--resume`. The read-only pair
216
+ * (`--permission-mode plan` plus the empty tool lists) is carried over from the
217
+ * attached path unchanged: a fork runs a real model turn, and it does so against a
218
+ * workspace the user did not explicitly hand us, so it gets no more authority than
219
+ * a continuation does.
220
+ *
221
+ * `stream-json` requires `--verbose`; without it the CLI refuses and we would never
222
+ * see the id we are required to verify.
223
+ */
224
+ export function buildClaudeForkArgs(nativeThreadId: string): string[] {
225
+ return [
226
+ '-p',
227
+ '--output-format', 'stream-json',
228
+ '--verbose',
229
+ '--resume', nativeThreadId,
230
+ '--fork-session',
231
+ '--permission-mode', 'plan',
232
+ '--tools', '',
233
+ '--allowedTools', '',
234
+ ]
235
+ }
236
+
237
+ /**
238
+ * `codex exec -s read-only -C <cwd> fork --json --skip-git-repo-check <id> -`.
239
+ *
240
+ * Verified against codex-cli 0.148.0-alpha.9 on 2026-08-16:
241
+ * `codex exec fork [OPTIONS] <SESSION_ID> [PROMPT]`. `--sandbox` and `--cd` are
242
+ * options of `exec` and must precede the `fork` subcommand; `--json` and
243
+ * `--skip-git-repo-check` belong to `fork`; and NOTHING may follow the positionals.
244
+ * The trailing `-` is the documented stdin form, which keeps the prompt out of argv.
245
+ *
246
+ * `--ephemeral` is deliberately absent: a fork that is not written to disk is not a
247
+ * thread the user can go back to, which is the entire deliverable.
248
+ */
249
+ export function buildCodexForkArgs(nativeThreadId: string, cwd: string): string[] {
250
+ return [
251
+ 'exec',
252
+ '--sandbox', 'read-only',
253
+ '--cd', cwd,
254
+ 'fork',
255
+ '--json',
256
+ '--skip-git-repo-check',
257
+ nativeThreadId,
258
+ '-',
259
+ ]
260
+ }
261
+
262
+ /**
263
+ * Build the argv, and REFUSE to hand back one carrying a banned permission flag.
264
+ *
265
+ * The predicate is IMPORTED from the adapter, not re-listed here. Two copies of a
266
+ * ban list in two modules is precisely the drift `native-thread-id.ts` was created
267
+ * to end: the occupancy detector and the binding store each had their own idea of
268
+ * what a thread id was, and a truncated id walked through the gap. A fork spawns a
269
+ * model against the user's workspace, so it needs the same list the attached path
270
+ * has — and needs it to keep being the same list after someone edits one of them.
271
+ */
272
+ function buildForkArgs(provider: ForkProvider, nativeThreadId: string, cwd: string): string[] {
273
+ const args = provider === 'claude'
274
+ ? buildClaudeForkArgs(nativeThreadId)
275
+ : buildCodexForkArgs(nativeThreadId, cwd)
276
+ const banned = findBannedPermissionArg(args)
277
+ if (banned !== null) {
278
+ // The flag name only. Never the argv, which carries the thread id and the cwd.
279
+ throw new Error(`fork argv carries a banned permission flag: ${banned}`)
280
+ }
281
+ return args
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // The id rule
286
+ // ---------------------------------------------------------------------------
287
+
288
+ export type ForkIdVerdict =
289
+ | { ok: true; newNativeThreadId: string }
290
+ | {
291
+ ok: false
292
+ reason: 'no_native_id_returned' | 'fork_returned_source_id' | 'ambiguous_native_id'
293
+ /** The single id we saw, when there was exactly one. Null otherwise. */
294
+ observed: string | null
295
+ }
296
+
297
+ /**
298
+ * Which thread did the provider actually create?
299
+ *
300
+ * Four outcomes, three of them refusals:
301
+ *
302
+ * 0 ids `no_native_id_returned`. Exit 0 with no evidence is not a fork. We were
303
+ * required to prove a new thread exists and name it; nothing is not proof.
304
+ * >1 ids `ambiguous_native_id`. Both canaries printed exactly one id, so more
305
+ * than one means this build no longer understands the provider's output —
306
+ * and binding a COS chat to the WRONG one of two candidate threads is a
307
+ * worse outcome than asking the user to try again. Never guess by
308
+ * "the one that isn't the source": a second id can just as easily be a
309
+ * field this build misread as an id at all.
310
+ * = source `fork_returned_source_id`. The header explains why this is the most
311
+ * important branch in the file. Not a degraded success, not a warning:
312
+ * a fork that returns the source id is an append, and an append is the
313
+ * thing fork exists to avoid.
314
+ * else the fork, named.
315
+ *
316
+ * Full-string equality. See the header on why a prefix comparison is catastrophic
317
+ * against codex's UUIDv7 ids.
318
+ */
319
+ export function selectForkedId(
320
+ observed: readonly string[],
321
+ sourceNativeThreadId: string,
322
+ ): ForkIdVerdict {
323
+ // A non-array is a wiring bug, and a wiring bug resolves to the refusal that
324
+ // says "we cannot tell", not to the one that says "nothing happened".
325
+ if (!Array.isArray(observed)) return { ok: false, reason: 'ambiguous_native_id', observed: null }
326
+
327
+ const distinct: string[] = []
328
+ for (const value of observed) {
329
+ // Re-validated at the point of USE rather than trusted from the scanner. This
330
+ // id becomes a spawn argument and a lock key downstream.
331
+ if (isValidNativeThreadId(value) && !distinct.includes(value)) distinct.push(value)
332
+ }
333
+
334
+ if (distinct.length === 0) return { ok: false, reason: 'no_native_id_returned', observed: null }
335
+ if (distinct.length > 1) return { ok: false, reason: 'ambiguous_native_id', observed: null }
336
+
337
+ const only = distinct[0]!
338
+ if (!isValidNativeThreadId(sourceNativeThreadId)) {
339
+ // Cannot run the comparison that makes a fork safe, so there is no verdict to
340
+ // give. Reached only through a caller that skipped validation.
341
+ return { ok: false, reason: 'ambiguous_native_id', observed: only }
342
+ }
343
+ if (only === sourceNativeThreadId) {
344
+ return { ok: false, reason: 'fork_returned_source_id', observed: only }
345
+ }
346
+ return { ok: true, newNativeThreadId: only }
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Injected surface
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /**
354
+ * Every dependency is REQUIRED except the two marked optional.
355
+ *
356
+ * Same reasoning as the adapter: a default is how a fail-open ships. An omitted
357
+ * ownership ledger that silently no-ops leaves our own child looking like a
358
+ * foreign desktop owner of the SOURCE thread forever, which would make the source
359
+ * permanently un-continuable — a fork that quietly breaks Continue for the thread
360
+ * it was forked from.
361
+ */
362
+ export interface ForkDeps {
363
+ /** Epoch ms. */
364
+ now: () => number
365
+ resolveBinary: (provider: ForkProvider) => BinaryResolution
366
+ spawn: (request: AttachedSpawnRequest) => AttachedChildProcess
367
+ /** MUST be the measured kernel start, never `Date.now()`. */
368
+ processStartMs: (pid: number) => number | null
369
+ recordSpawn: (pid: number, startMs: number) => string
370
+ releaseSpawn: (pid: number) => unknown
371
+ /**
372
+ * Terminate the child. Injected rather than defaulted so a unit test can never
373
+ * reach `process.kill(-pid)` with a fabricated pid and signal a real process
374
+ * group on the developer's machine.
375
+ */
376
+ terminate: (child: AttachedChildProcess, signal: NodeJS.Signals) => void
377
+ /**
378
+ * Bounded, non-identifying watermark of the SOURCE thread, or null.
379
+ *
380
+ * Wire it to `nativeHead`. Read once before the spawn and once after the child
381
+ * settles; a difference is terminal. Optional because a null at either end is
382
+ * already handled as `unverified` — but leaving it unwired means this module can
383
+ * never prove the property that makes fork safe to offer, so production should
384
+ * always supply it.
385
+ */
386
+ sourceWatermark?: (provider: ForkProvider, nativeThreadId: string) => string | null
387
+ /**
388
+ * Argv builder. Optional, defaults to the real one.
389
+ *
390
+ * Exists ONLY so a test can hand back an argv carrying a banned permission flag
391
+ * and prove the fork is refused with zero spawns. Production never sets it.
392
+ */
393
+ buildArgs?: (provider: ForkProvider, nativeThreadId: string, cwd: string) => string[]
394
+ }
395
+
396
+ export interface ForkRequest {
397
+ provider: unknown
398
+ nativeThreadId: unknown
399
+ prompt: unknown
400
+ cwd: unknown
401
+ policy: unknown
402
+ deps: ForkDeps
403
+ /** Wall-clock budget for the provider run. Omitted uses the attached default. */
404
+ timeoutMs?: number
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Implementation
409
+ // ---------------------------------------------------------------------------
410
+
411
+ function fail(
412
+ reason: ForkFailure,
413
+ forkState: ForkState,
414
+ over: Partial<ForkFailureResult> = {},
415
+ ): ForkFailureResult {
416
+ return {
417
+ ok: false,
418
+ provider: null,
419
+ sourceNativeThreadId: null,
420
+ newNativeThreadId: null,
421
+ forkState,
422
+ sourceIntegrity: 'unverified',
423
+ reason,
424
+ detail: null,
425
+ exitCode: null,
426
+ stderrClass: 'none',
427
+ durationMs: 0,
428
+ ...over,
429
+ }
430
+ }
431
+
432
+ function readDuration(deps: ForkDeps, startedAt: number): number {
433
+ try {
434
+ const now = deps.now()
435
+ if (!Number.isFinite(now)) return 0
436
+ const delta = now - startedAt
437
+ return Number.isFinite(delta) && delta >= 0 ? delta : 0
438
+ } catch {
439
+ return 0
440
+ }
441
+ }
442
+
443
+ function safeTerminate(deps: ForkDeps, child: AttachedChildProcess, signal: NodeJS.Signals): void {
444
+ try {
445
+ deps.terminate(child, signal)
446
+ } catch {
447
+ // A kill that fails changes nothing a caller can act on, and must never
448
+ // replace the terminal result being assembled around it.
449
+ }
450
+ }
451
+
452
+ function safeRelease(deps: ForkDeps, pid: number): void {
453
+ try {
454
+ deps.releaseSpawn(pid)
455
+ } catch {
456
+ // Same reasoning: a throwing release must not mask the fork's outcome.
457
+ }
458
+ }
459
+
460
+ /** Read the source watermark, or null. Never throws. */
461
+ function readWatermark(deps: ForkDeps, provider: ForkProvider, threadId: string): string | null {
462
+ const read = deps.sourceWatermark
463
+ if (typeof read !== 'function') return null
464
+ try {
465
+ const value = read(provider, threadId)
466
+ return typeof value === 'string' && value.length > 0 ? value : null
467
+ } catch {
468
+ return null
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Compare two watermark readings.
474
+ *
475
+ * A null at EITHER end is `unverified`, never `verified_unchanged`: "I could not
476
+ * read it twice" is not "it did not change", and this feature has a documented
477
+ * history of exactly that substitution.
478
+ */
479
+ export function compareWatermarks(before: string | null, after: string | null): SourceIntegrity {
480
+ if (before === null || after === null) return 'unverified'
481
+ return before === after ? 'verified_unchanged' : 'mutated'
482
+ }
483
+
484
+ /**
485
+ * Fork a native provider thread.
486
+ *
487
+ * Resolves; never rejects. A caller that must wrap this in try/catch to stay safe
488
+ * is a caller that will one day forget, and the catch-all branch would be the one
489
+ * place in the flow with no defined `forkState`.
490
+ */
491
+ export async function forkThread(request: ForkRequest): Promise<ForkResult> {
492
+ const deps = request?.deps
493
+ if (!deps || typeof deps !== 'object') {
494
+ return fail('fork_internal_error', 'none', { detail: 'deps_missing' })
495
+ }
496
+ for (const key of ['now', 'resolveBinary', 'spawn', 'processStartMs', 'recordSpawn', 'releaseSpawn', 'terminate'] as const) {
497
+ if (typeof (deps as any)[key] !== 'function') {
498
+ return fail('fork_internal_error', 'none', { detail: `dep_missing:${key}` })
499
+ }
500
+ }
501
+
502
+ let startedAt = 0
503
+ try {
504
+ const t0 = deps.now()
505
+ startedAt = Number.isFinite(t0) ? t0 : 0
506
+ } catch {
507
+ return fail('fork_internal_error', 'none', { detail: 'clock_failed' })
508
+ }
509
+
510
+ try {
511
+ return await run(request, deps, startedAt)
512
+ } catch {
513
+ // Anything unforeseen resolves to a refusal, not a throw. `possible` rather
514
+ // than `none`: a throw from an unknown point cannot prove no child ran, and
515
+ // over-warning about an orphan costs a sentence while under-warning loses one.
516
+ return fail('fork_internal_error', 'possible', {
517
+ detail: 'unhandled',
518
+ durationMs: readDuration(deps, startedAt),
519
+ })
520
+ }
521
+ }
522
+
523
+ async function run(request: ForkRequest, deps: ForkDeps, startedAt: number): Promise<ForkResult> {
524
+ const duration = () => readDuration(deps, startedAt)
525
+
526
+ // --- 1. Validate ------------------------------------------------------------
527
+ if (!isBindableProvider(request.provider)) {
528
+ return fail('invalid_provider', 'none', { durationMs: duration() })
529
+ }
530
+ const provider: ForkProvider = request.provider
531
+
532
+ if (!isValidNativeThreadId(request.nativeThreadId)) {
533
+ return fail('invalid_thread_id', 'none', { provider, durationMs: duration() })
534
+ }
535
+ const sourceNativeThreadId: string = request.nativeThreadId
536
+
537
+ const base = { provider, sourceNativeThreadId }
538
+
539
+ if (!isAttachedPermissionPolicy(request.policy)) {
540
+ // Includes `undefined`. An omitted policy is not a request for the safest one;
541
+ // it is a caller that has not decided.
542
+ return fail('unsupported_policy', 'none', { ...base, durationMs: duration() })
543
+ }
544
+
545
+ if (typeof request.prompt !== 'string' || request.prompt.length === 0) {
546
+ return fail('invalid_prompt', 'none', { ...base, detail: 'empty', durationMs: duration() })
547
+ }
548
+ if (request.prompt.length > MAX_PROMPT_CHARS) {
549
+ return fail('invalid_prompt', 'none', { ...base, detail: 'too_long', durationMs: duration() })
550
+ }
551
+ const prompt: string = request.prompt
552
+
553
+ if (typeof request.cwd !== 'string' || request.cwd.length === 0
554
+ || !isAbsolute(request.cwd) || request.cwd.includes('\0')) {
555
+ // A relative cwd resolves against the SERVER's working directory, so the
556
+ // provider would fork into the wrong project while looking correct.
557
+ return fail('invalid_cwd', 'none', { ...base, durationMs: duration() })
558
+ }
559
+ const cwd: string = request.cwd
560
+
561
+ let timeoutMs = DEFAULT_ATTACHED_TIMEOUT_MS
562
+ if (request.timeoutMs !== undefined) {
563
+ if (typeof request.timeoutMs !== 'number' || !Number.isFinite(request.timeoutMs)
564
+ || request.timeoutMs <= 0 || request.timeoutMs > MAX_ATTACHED_TIMEOUT_MS) {
565
+ return fail('invalid_timeout', 'none', { ...base, durationMs: duration() })
566
+ }
567
+ timeoutMs = request.timeoutMs
568
+ }
569
+
570
+ // --- 2. Resolve the binary --------------------------------------------------
571
+ // Imported resolution, so the stale-shim rejection is the SAME rule the attached
572
+ // path uses. `codex` on PATH here names /Applications/Codex.app, which no longer
573
+ // exists; a reinstall would put a real executable back on that dead path.
574
+ let resolution: BinaryResolution
575
+ try {
576
+ resolution = deps.resolveBinary(provider)
577
+ } catch {
578
+ return fail('binary_not_found', 'none', {
579
+ ...base, detail: `${provider}:resolver_failed`, durationMs: duration(),
580
+ })
581
+ }
582
+ if (!resolution || typeof resolution !== 'object' || resolution.ok !== true) {
583
+ const detail = resolution && typeof resolution === 'object' && resolution.ok === false
584
+ ? `${resolution.binary}:${resolution.detail}`
585
+ : `${provider}:unresolved`
586
+ return fail('binary_not_found', 'none', { ...base, detail, durationMs: duration() })
587
+ }
588
+ if (typeof resolution.path !== 'string' || !isAbsolute(resolution.path)) {
589
+ return fail('binary_not_found', 'none', {
590
+ ...base, detail: `${provider}:not_absolute`, durationMs: duration(),
591
+ })
592
+ }
593
+ const binaryPath = resolution.path
594
+
595
+ // --- 3. Baseline for the property that makes fork safe ----------------------
596
+ // Read BEFORE the spawn. A watermark taken afterwards proves nothing.
597
+ const watermarkBefore = readWatermark(deps, provider, sourceNativeThreadId)
598
+
599
+ // --- 4. Spawn ---------------------------------------------------------------
600
+ let args: string[]
601
+ try {
602
+ args = (deps.buildArgs ?? buildForkArgs)(provider, sourceNativeThreadId, cwd)
603
+ } catch {
604
+ return fail('unsupported_policy', 'none', { ...base, detail: 'argv_build_failed', durationMs: duration() })
605
+ }
606
+ // Enforced against the argv that is about to be spawned, not against the source
607
+ // that builds it, and after the injectable seam so a test can actually reach it.
608
+ const bannedArg = findBannedPermissionArg(args)
609
+ if (bannedArg !== null) {
610
+ return fail('unsupported_policy', 'none', { ...base, detail: `banned_arg:${bannedArg}`, durationMs: duration() })
611
+ }
612
+
613
+ let child: AttachedChildProcess
614
+ try {
615
+ child = deps.spawn({ binaryPath, args, cwd, env: buildAttachedEnv() })
616
+ } catch {
617
+ // Includes ENOENT. No process exists, so no thread can have been created.
618
+ return fail('spawn_failed', 'none', { ...base, detail: 'threw', durationMs: duration() })
619
+ }
620
+ if (!child || typeof child !== 'object' || typeof child.on !== 'function') {
621
+ return fail('spawn_failed', 'none', { ...base, detail: 'no_child', durationMs: duration() })
622
+ }
623
+
624
+ const pid = child.pid
625
+ if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) {
626
+ // Node leaves `pid` undefined when the spawn itself failed. If a process does
627
+ // exist behind an unusable pid we cannot record it, release it, or kill it on
628
+ // timeout — so refuse rather than run blind.
629
+ safeTerminate(deps, child, 'SIGKILL')
630
+ return fail('spawn_failed', 'possible', { ...base, detail: 'no_pid', durationMs: duration() })
631
+ }
632
+
633
+ // --- 5. Claim the child, synchronously, before anything can observe it ------
634
+ //
635
+ // NO `await` between the spawn above and the record below, exactly as in the
636
+ // attached adapter. `claude --resume <id> --fork-session` still RESUMES before it
637
+ // forks, so while it runs there is a live process on this machine associated with
638
+ // the SOURCE thread. Unless the ownership ledger already holds it, the next
639
+ // occupancy scan reads our own child as a foreign desktop owner and the source
640
+ // thread becomes permanently un-continuable — a fork that silently disables
641
+ // Continue for the thread it forked from. `processStartMs` is synchronous
642
+ // (execFileSync) precisely so this chain can hold with no await in it.
643
+ let claimed = false
644
+ let claimDetail = 'unknown'
645
+ try {
646
+ const startMs = deps.processStartMs(pid)
647
+ if (typeof startMs === 'number' && Number.isFinite(startMs) && startMs > 0) {
648
+ const outcome = deps.recordSpawn(pid, startMs)
649
+ claimed = outcome === 'recorded'
650
+ claimDetail = typeof outcome === 'string' ? outcome : 'non_string_outcome'
651
+ } else {
652
+ claimDetail = 'start_unavailable'
653
+ }
654
+ } catch {
655
+ claimed = false
656
+ claimDetail = 'threw'
657
+ }
658
+
659
+ if (!claimed) {
660
+ safeTerminate(deps, child, 'SIGKILL')
661
+ safeRelease(deps, pid)
662
+ return fail('ownership_record_failed', 'none', {
663
+ ...base, detail: claimDetail, durationMs: duration(),
664
+ })
665
+ }
666
+
667
+ let outcome: ForkResult
668
+ try {
669
+ outcome = await driveChild({ child, deps, provider, sourceNativeThreadId, prompt, timeoutMs, startedAt })
670
+ } finally {
671
+ // EVERY path: success, mismatch, non-zero exit, timeout, throw. A leaked entry
672
+ // outlives the process and lets a RECYCLED pid inherit our self-ownership
673
+ // claim, which is the one input that can turn a live foreign owner into
674
+ // `attachable`.
675
+ safeRelease(deps, pid)
676
+ }
677
+
678
+ // --- 6. Did the source move? ------------------------------------------------
679
+ const watermarkAfter = readWatermark(deps, provider, sourceNativeThreadId)
680
+ const integrity = compareWatermarks(watermarkBefore, watermarkAfter)
681
+
682
+ if (integrity === 'mutated') {
683
+ // Positive evidence that the thing fork promises not to do, happened. It
684
+ // outranks whatever else the run reported, and a success becomes a failure:
685
+ // the caller must not present this as a clean fork, and the user needs to open
686
+ // the original and look. The prior reason is preserved in `detail` so the
687
+ // diagnosis is not lost.
688
+ const priorReason = outcome.ok ? 'ok' : outcome.reason
689
+ return {
690
+ ok: false,
691
+ provider,
692
+ sourceNativeThreadId,
693
+ newNativeThreadId: outcome.newNativeThreadId,
694
+ forkState: outcome.forkState,
695
+ sourceIntegrity: 'mutated',
696
+ reason: 'source_thread_mutated',
697
+ detail: `after:${priorReason}`,
698
+ exitCode: outcome.exitCode,
699
+ stderrClass: outcome.ok ? 'none' : outcome.stderrClass,
700
+ durationMs: outcome.durationMs,
701
+ }
702
+ }
703
+
704
+ if (outcome.ok) return { ...outcome, sourceIntegrity: integrity === 'verified_unchanged' ? 'verified_unchanged' : 'unverified' }
705
+ return { ...outcome, sourceIntegrity: integrity }
706
+ }
707
+
708
+ interface DriveInput {
709
+ child: AttachedChildProcess
710
+ deps: ForkDeps
711
+ provider: ForkProvider
712
+ sourceNativeThreadId: string
713
+ prompt: string
714
+ timeoutMs: number
715
+ startedAt: number
716
+ }
717
+
718
+ function driveChild(input: DriveInput): Promise<ForkResult> {
719
+ const { child, deps, provider, sourceNativeThreadId, prompt, timeoutMs, startedAt } = input
720
+
721
+ return new Promise<ForkResult>((resolve) => {
722
+ const observedIds: string[] = []
723
+ let stdoutTail = ''
724
+ let scannedBytes = 0
725
+ let stderrSample = ''
726
+ let forkState: ForkState = 'none'
727
+ let exitCode: number | null = null
728
+ let settled = false
729
+ let timedOut = false
730
+ let spawnErrored = false
731
+
732
+ let deadline: ReturnType<typeof setTimeout> | null = null
733
+ let graceTimer: ReturnType<typeof setTimeout> | null = null
734
+ let forceTimer: ReturnType<typeof setTimeout> | null = null
735
+
736
+ const clearTimers = () => {
737
+ if (deadline) { clearTimeout(deadline); deadline = null }
738
+ if (graceTimer) { clearTimeout(graceTimer); graceTimer = null }
739
+ if (forceTimer) { clearTimeout(forceTimer); forceTimer = null }
740
+ }
741
+
742
+ const duration = () => readDuration(deps, startedAt)
743
+
744
+ const settle = (result: ForkResult) => {
745
+ if (settled) return
746
+ settled = true
747
+ clearTimers()
748
+ resolve(result)
749
+ }
750
+
751
+ const settleFailure = (reason: ForkFailure, over: Partial<ForkFailureResult> = {}) => {
752
+ settle(fail(reason, forkState, {
753
+ provider,
754
+ sourceNativeThreadId,
755
+ exitCode,
756
+ stderrClass: classifyStderr(stderrSample),
757
+ durationMs: duration(),
758
+ ...over,
759
+ }))
760
+ }
761
+
762
+ const consumeStdout = (chunk: any) => {
763
+ try {
764
+ if (scannedBytes >= MAX_STDOUT_SCAN_BYTES) return
765
+ const text = typeof chunk === 'string' ? chunk : String(chunk)
766
+ scannedBytes += text.length
767
+ stdoutTail += text
768
+ const lines = stdoutTail.split('\n')
769
+ stdoutTail = lines.pop() ?? ''
770
+ // Bound the carry-over so a provider emitting one enormous line cannot grow
771
+ // this without limit.
772
+ if (stdoutTail.length > 1_000_000) stdoutTail = ''
773
+ for (const line of lines) {
774
+ for (const id of extractNativeIdsFromLine(line)) {
775
+ if (!observedIds.includes(id)) observedIds.push(id)
776
+ }
777
+ }
778
+ // Deliberately nothing else: no text, no tool calls, no transcript. This
779
+ // module cannot leak what it never held.
780
+ } catch {
781
+ // Losing id evidence ends as a refusal, which is the safe direction.
782
+ }
783
+ }
784
+
785
+ const consumeStderr = (chunk: any) => {
786
+ try {
787
+ if (stderrSample.length >= 4_096) return
788
+ const text = typeof chunk === 'string' ? chunk : String(chunk)
789
+ stderrSample = (stderrSample + text).slice(0, 4_096)
790
+ } catch {
791
+ /* classification degrades to 'unclassified'; nothing else depends on it */
792
+ }
793
+ }
794
+
795
+ const finishTerminal = () => {
796
+ // Drain whatever sat in the trailing partial line before judging. A provider
797
+ // that never emits a final newline would otherwise have its only id thrown
798
+ // away, turning a good fork into `no_native_id_returned`.
799
+ if (stdoutTail.length > 0) {
800
+ for (const id of extractNativeIdsFromLine(stdoutTail)) {
801
+ if (!observedIds.includes(id)) observedIds.push(id)
802
+ }
803
+ stdoutTail = ''
804
+ }
805
+
806
+ if (timedOut) return settleFailure('timeout')
807
+ if (spawnErrored) return settleFailure('spawn_failed', { detail: 'child_error' })
808
+ if (exitCode !== 0) return settleFailure('provider_exit_nonzero')
809
+
810
+ const verdict = selectForkedId(observedIds, sourceNativeThreadId)
811
+ if (!verdict.ok) {
812
+ return settleFailure(verdict.reason, {
813
+ // Carried so an orphan created by an ambiguous run can still be found.
814
+ // NOT carried for `fork_returned_source_id`: that id is the SOURCE, and
815
+ // reporting the user's own live thread in a field named "new" is exactly
816
+ // the confusion that gets something bound to it.
817
+ newNativeThreadId: verdict.reason === 'fork_returned_source_id' ? null : verdict.observed,
818
+ detail: verdict.reason === 'fork_returned_source_id' ? 'append_not_fork' : null,
819
+ })
820
+ }
821
+
822
+ if (forkState !== 'possible') {
823
+ // Exit 0 with a usable new id but no prompt ever written is incoherent.
824
+ return settleFailure('fork_internal_error', { detail: 'no_prompt_written' })
825
+ }
826
+
827
+ settle({
828
+ ok: true,
829
+ provider,
830
+ sourceNativeThreadId,
831
+ newNativeThreadId: verdict.newNativeThreadId,
832
+ forkState: 'created',
833
+ // Overwritten by the caller once the after-watermark is read. Never
834
+ // upgraded here — this function has no second reading to compare.
835
+ sourceIntegrity: 'unverified',
836
+ reason: null,
837
+ exitCode: 0,
838
+ durationMs: duration(),
839
+ })
840
+ }
841
+
842
+ // --- wire the child ------------------------------------------------------
843
+ try {
844
+ child.stdout?.on('data', consumeStdout)
845
+ child.stderr?.on('data', consumeStderr)
846
+ child.stdout?.on('error', () => { /* stream errors surface via close/exit */ })
847
+ child.stderr?.on('error', () => { /* ditto */ })
848
+
849
+ child.on('error', () => {
850
+ spawnErrored = true
851
+ finishTerminal()
852
+ })
853
+ child.on('exit', (code: number | null) => {
854
+ if (typeof code === 'number') exitCode = code
855
+ })
856
+ child.on('close', (code: number | null) => {
857
+ if (typeof code === 'number') exitCode = code
858
+ finishTerminal()
859
+ })
860
+ } catch {
861
+ safeTerminate(deps, child, 'SIGKILL')
862
+ return settleFailure('fork_internal_error', { detail: 'wire_failed' })
863
+ }
864
+
865
+ // --- bounded budget ------------------------------------------------------
866
+ deadline = setTimeout(() => {
867
+ timedOut = true
868
+ safeTerminate(deps, child, 'SIGTERM')
869
+ graceTimer = setTimeout(() => {
870
+ safeTerminate(deps, child, 'SIGKILL')
871
+ forceTimer = setTimeout(() => {
872
+ // A child that survived SIGKILL cannot be reached from here, and blocking
873
+ // forever would wedge the caller and any COS Control drain behind it.
874
+ settleFailure('timeout', { detail: 'unreaped' })
875
+ }, FORCE_SETTLE_MS)
876
+ }, KILL_GRACE_MS)
877
+ }, timeoutMs)
878
+
879
+ // --- hand over the prompt ------------------------------------------------
880
+ const stdin = child.stdin
881
+ if (!stdin || typeof stdin.write !== 'function' || typeof stdin.end !== 'function' || !child.stdout) {
882
+ // No way to send the prompt, or no way to observe the id we must verify.
883
+ safeTerminate(deps, child, 'SIGKILL')
884
+ return settleFailure('child_stdio_unavailable', {
885
+ detail: stdin ? 'stdout_missing' : 'stdin_missing',
886
+ })
887
+ }
888
+
889
+ try {
890
+ stdin.on('error', () => { /* reported through close/exit; never fatal here */ })
891
+ } catch {
892
+ /* an stdin that cannot take a listener still gets the write attempt below */
893
+ }
894
+
895
+ try {
896
+ // From here a fork may exist even if we never learn its id.
897
+ forkState = 'possible'
898
+ stdin.write(prompt)
899
+ // ALWAYS ended. The codex CLI reads stdin regardless of whether a prompt
900
+ // argument was supplied, so an unclosed pipe leaves the child waiting for EOF
901
+ // until the timeout kills it — which reads as a provider hang.
902
+ stdin.end()
903
+ } catch {
904
+ safeTerminate(deps, child, 'SIGKILL')
905
+ return settleFailure('child_stdio_unavailable', { detail: 'write_failed' })
906
+ }
907
+ })
908
+ }
909
+
910
+ // ---------------------------------------------------------------------------
911
+ // Production wiring
912
+ // ---------------------------------------------------------------------------
913
+
914
+ /**
915
+ * The real dependency set.
916
+ *
917
+ * `sourceWatermark` has no default and should always be supplied by the caller —
918
+ * it needs `nativeHead`'s dependency shape, which belongs to the route, and a
919
+ * default here would be a placeholder that reports `unverified` forever while
920
+ * looking wired.
921
+ */
922
+ export function realForkDeps(
923
+ sourceWatermark?: (provider: ForkProvider, nativeThreadId: string) => string | null,
924
+ ): ForkDeps {
925
+ return {
926
+ now: () => Date.now(),
927
+ resolveBinary: provider => resolveProviderBinary(provider),
928
+ spawn: request => nodeSpawn(request.binaryPath, [...request.args], {
929
+ stdio: ['pipe', 'pipe', 'pipe'],
930
+ cwd: request.cwd,
931
+ env: request.env,
932
+ // Group leader, so the whole provider tree can be signalled on timeout.
933
+ detached: true,
934
+ }) as unknown as AttachedChildProcess,
935
+ processStartMs: pid => realProcessStartMs(pid),
936
+ recordSpawn: (pid, startMs) => recordCosSpawn(pid, startMs),
937
+ releaseSpawn: pid => releaseCosSpawn(pid),
938
+ terminate: (child, signal) => {
939
+ const pid = child.pid
940
+ if (typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) {
941
+ try {
942
+ // Negative pid = process group, reachable because we spawned detached.
943
+ process.kill(-pid, signal)
944
+ return
945
+ } catch {
946
+ /* fall through to the direct signal */
947
+ }
948
+ }
949
+ try {
950
+ ;(child as any).kill?.(signal)
951
+ } catch {
952
+ /* nothing further is available */
953
+ }
954
+ },
955
+ ...(sourceWatermark ? { sourceWatermark } : {}),
956
+ }
957
+ }