@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,1223 @@
1
+ // The attached execution path: append ONE turn to a real desktop agent thread.
2
+ //
3
+ // This is the module that actually writes into a human being's live-history
4
+ // conversation. Everything else in this feature decides whether we are allowed
5
+ // to; this is the part that does it. So the bias here is absolute: every
6
+ // unknown, every throw, every value we cannot positively establish resolves to
7
+ // refusing the turn. A refused turn costs the user one Fork. A wrong turn puts
8
+ // words into a conversation they own.
9
+ //
10
+ // WHY THIS IS A SEPARATE MODULE AND NOT AN OPTION ON THE BRIDGES.
11
+ // `claude-bridge.ts` and `codex-bridge.ts` are the ordinary COS path and must
12
+ // stay byte-for-byte identical — plan 4.2 forbids the attached path from
13
+ // inheriting COS history formatting, COS system-prompt construction, pre-warm
14
+ // adoption, engine-session mapping, or provider fallback, and an `if
15
+ // (attached)` branch inside a 1000-line bridge is a promise, not a guarantee.
16
+ // A separate file makes the bypass structural and makes rollback one file.
17
+ // Nothing here imports either bridge.
18
+ //
19
+ // WHAT IS SENT: the prompt bytes, on stdin, unmodified. Nothing else. No system
20
+ // prompt, no history prefix, no capability preamble, no attachment block. A
21
+ // test asserts the stdin bytes equal the prompt exactly, because "bypasses COS
22
+ // formatting" is only true if the wire says so.
23
+ //
24
+ // THE ORDERING PROBLEM, WHICH IS THE WHOLE POINT OF THIS FILE (plan 4.4).
25
+ // When we spawn `claude --resume <targetId>`, that child registers ITSELF in
26
+ // `~/.claude/sessions/<pid>.json` carrying the SAME sessionId we are targeting.
27
+ // From the next occupancy scan's point of view our own child is a live foreign
28
+ // owner of the thread, and the thread becomes permanently unattachable — the
29
+ // second turn on any thread refuses forever, and it looks exactly like a
30
+ // detector bug. The ownership ledger is the only thing that tells the two
31
+ // apart, so the record must be in place before anything can look:
32
+ //
33
+ // preflight -> spawn -> processStartMs(pid) -> recordSpawn(pid, start)
34
+ //
35
+ // with NO await anywhere in that chain. `processStartMs` is synchronous
36
+ // (execFileSync) precisely so this can hold. A single `await` inserted between
37
+ // spawn and record lets a queued microtask observe the child as foreign, and
38
+ // `records the child before any queued microtask can observe the ledger` is the
39
+ // test that catches it.
40
+ //
41
+ // AND THE START TIME MUST BE MEASURED, NOT ASSUMED. `recordSpawn` takes
42
+ // `processStartMs(child.pid)`, never `Date.now()`. Measured n=14 on 2026-08-15:
43
+ // the probe agrees with the kernel reading exactly (0ms), while `Date.now()` at
44
+ // the spawn call is off by 2-992ms because the kernel value is truncated to the
45
+ // second. `PROC_START_TOLERANCE_MS` is 1500, so `Date.now()` leaves ~508ms of
46
+ // headroom, and a `claude` spawn under load eats it. When it does, every
47
+ // COS-spawned owner reads as a foreign desktop process and self-identification
48
+ // silently stops working.
49
+ //
50
+ // DELIVERY STATE IS NOT THE SAME QUESTION AS SUCCESS (plan 4.6 item 4).
51
+ // "Did it fail?" and "might the turn have landed anyway?" are independent, and
52
+ // conflating them produces the worst UX in the feature: either every missing
53
+ // CLI shows the scary "your thread may contain this turn" warning until the
54
+ // user learns to ignore it, or a turn that really did land is reported as a
55
+ // clean failure and gets replayed. So the boundary is drawn at one provable
56
+ // event — the first byte we hand to the child's stdin:
57
+ //
58
+ // not_attempted no child process was ever created
59
+ // aborted a child existed, we terminated it, no prompt byte was written
60
+ // ambiguous we called write(); we cannot prove the provider did not act
61
+ // delivered clean exit 0 AND the provider echoed our exact target id
62
+ //
63
+ // Only the first two are provably safe to report as a clean failure.
64
+ // `attachedDeliveryAmbiguous` makes that mapping total, and treats any state it
65
+ // does not recognise as ambiguous, so adding a state later cannot silently open
66
+ // the replay path.
67
+ //
68
+ // FOUR THINGS THAT LOOK SAFE AND ARE NOT:
69
+ //
70
+ // 1. "the provider returned an id, so it appended to our thread." Only if it
71
+ // is OUR id and it is the ONLY one. A provider that forks emits a different
72
+ // id; a provider that forks and also mentions the old one emits two. Both
73
+ // are failures. Matching "any observed id" would pass the second case.
74
+ // 2. "scan stdout for the target UUID." A model can echo a UUID in prose. Ids
75
+ // are read from KNOWN TOP-LEVEL FIELDS of parsed JSON lines only. Assistant
76
+ // text lives inside a string field and can never be mistaken for one.
77
+ // 3. "`codex` on PATH is the codex binary." On this machine it is a stale
78
+ // shim naming `/Applications/Codex.app`, which no longer exists; the real
79
+ // binary is inside ChatGPT.app. And a GUI/launchd-spawned server has no
80
+ // login PATH at all, so bare-name spawning is a coin flip. Resolution
81
+ // always produces a verified absolute path or refuses.
82
+ // 4. "release the ledger entry when the child exits cleanly." Every path must
83
+ // release — timeout, throw, kill, mismatch. A leaked entry outlives the
84
+ // process and lets a RECYCLED pid inherit our self-ownership claim, which
85
+ // is the one input that can turn a live foreign owner into `attachable`.
86
+
87
+ import { accessSync, constants as fsConstants, statSync } from 'node:fs'
88
+ import { delimiter, isAbsolute, join } from 'node:path'
89
+ import { homedir } from 'node:os'
90
+ import { spawn as nodeSpawn } from 'node:child_process'
91
+
92
+ import { isValidNativeThreadId } from './native-thread-id.js'
93
+ import { isBindableProvider, type BindableProvider } from './agent-session-binding-store.js'
94
+ import { recordCosSpawn, releaseCosSpawn } from './agent-session-ownership-store.js'
95
+ import { processStartMs as realProcessStartMs } from './occupancy-probes.js'
96
+
97
+ /** Providers with a certified attached path. Cursor is Fork-only (plan 2.5). */
98
+ export type AttachedProvider = BindableProvider
99
+
100
+ /**
101
+ * Protocol 1 ships text-only read-only continuation (plan 4.7). The type has
102
+ * one member on purpose: `agent` is not "not implemented yet", it is a value
103
+ * this module must refuse until Control gates it per binding.
104
+ */
105
+ export type AttachedPermissionPolicy = 'read_only'
106
+
107
+ export function isAttachedPermissionPolicy(value: unknown): value is AttachedPermissionPolicy {
108
+ return value === 'read_only'
109
+ }
110
+
111
+ /**
112
+ * Flags that must never reach a provider that is about to write into someone's
113
+ * real conversation (plan 4.7: no always-approve, bypass-permissions,
114
+ * danger-full-access, force, or yolo behavior).
115
+ *
116
+ * Exported so the assertion can be made against the argv that is actually
117
+ * spawned rather than against the source that builds it.
118
+ */
119
+ export const BANNED_PERMISSION_ARGS: readonly string[] = [
120
+ '--dangerously-skip-permissions',
121
+ '--dangerously-bypass-approvals-and-sandbox',
122
+ '--dangerously-bypass-hook-trust',
123
+ '--full-auto',
124
+ '--yolo',
125
+ '--force',
126
+ 'danger-full-access',
127
+ 'bypassPermissions',
128
+ 'acceptEdits',
129
+ ]
130
+
131
+ /** Terminal reason on failure. Never carries prompt or transcript text. */
132
+ export type AttachedTurnFailure =
133
+ | 'invalid_provider'
134
+ | 'invalid_thread_id'
135
+ | 'invalid_prompt'
136
+ | 'invalid_cwd'
137
+ | 'invalid_timeout'
138
+ | 'unsupported_policy'
139
+ | 'native_owner_appeared'
140
+ | 'preflight_failed'
141
+ | 'binary_not_found'
142
+ | 'spawn_failed'
143
+ | 'ownership_record_failed'
144
+ | 'child_stdio_unavailable'
145
+ | 'timeout'
146
+ | 'provider_exit_nonzero'
147
+ | 'no_native_id_returned'
148
+ | 'native_id_mismatch'
149
+ | 'adapter_internal_error'
150
+
151
+ /** See the header. The boundary is the first byte handed to the child's stdin. */
152
+ export type AttachedDeliveryState = 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered'
153
+
154
+ /**
155
+ * Bounded classification of a provider's stderr.
156
+ *
157
+ * The raw text never leaves this module: a provider is free to echo the prompt
158
+ * back in an error message, and an error string is exactly the value that ends
159
+ * up in a log, a journal row, and a support paste. These literals come from
160
+ * this file, so no provider output can travel inside one.
161
+ */
162
+ export type AttachedStderrClass =
163
+ | 'none'
164
+ | 'auth'
165
+ | 'thread_not_found'
166
+ | 'permission'
167
+ | 'rate_limit'
168
+ | 'unclassified'
169
+
170
+ export interface AttachedTurnSuccess {
171
+ ok: true
172
+ provider: AttachedProvider
173
+ nativeThreadId: string
174
+ /** The id the provider echoed. Equal to `nativeThreadId` by construction. */
175
+ returnedNativeId: string
176
+ delivery: 'delivered'
177
+ reason: null
178
+ exitCode: number
179
+ durationMs: number
180
+ }
181
+
182
+ export interface AttachedTurnFailureResult {
183
+ ok: false
184
+ provider: AttachedProvider | null
185
+ nativeThreadId: string | null
186
+ /** Null when the provider never echoed a usable id. */
187
+ returnedNativeId: string | null
188
+ delivery: AttachedDeliveryState
189
+ reason: AttachedTurnFailure
190
+ /**
191
+ * A bounded, self-authored discriminator. Enum-like strings and identifiers
192
+ * only — never provider output, never prompt text.
193
+ */
194
+ detail: string | null
195
+ exitCode: number | null
196
+ stderrClass: AttachedStderrClass
197
+ durationMs: number
198
+ }
199
+
200
+ export type AttachedTurnResult = AttachedTurnSuccess | AttachedTurnFailureResult
201
+
202
+ /**
203
+ * Whether a caller must treat the turn as possibly-landed (plan 4.6 item 1:
204
+ * `deliveryAmbiguous: true` alongside `status: 'failed'`).
205
+ *
206
+ * Written as "ambiguous unless proven otherwise" rather than an equality test,
207
+ * so a delivery state added later defaults to fenced instead of replayable.
208
+ */
209
+ export function attachedDeliveryAmbiguous(result: AttachedTurnResult): boolean {
210
+ if (result.ok) return false
211
+ return result.delivery !== 'not_attempted' && result.delivery !== 'aborted'
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Binary resolution
216
+ // ---------------------------------------------------------------------------
217
+
218
+ export type BinaryResolutionFailure = 'env_override_unusable' | 'not_found'
219
+
220
+ export type BinaryResolution =
221
+ | { ok: true; path: string; source: 'env' | 'absolute' | 'path' }
222
+ | { ok: false; binary: string; detail: BinaryResolutionFailure }
223
+
224
+ /**
225
+ * Path prefixes that can never be a usable provider binary.
226
+ *
227
+ * `codex` resolved through PATH (or a shell alias) points at
228
+ * `/Applications/Codex.app/Contents/Resources/codex` on this machine, and that
229
+ * app no longer exists. Today the shim is dangling, so an existence check
230
+ * already rejects it — but a reinstalled or partially-removed Codex.app puts a
231
+ * real, executable file back on that path, and then only this list stands
232
+ * between an attached turn and a binary that cannot serve it.
233
+ */
234
+ export const STALE_SHIM_PREFIXES: readonly string[] = ['/Applications/Codex.app/']
235
+
236
+ export function isKnownStaleShimPath(
237
+ candidate: string,
238
+ prefixes: readonly string[] = STALE_SHIM_PREFIXES,
239
+ ): boolean {
240
+ // A non-array argument falls back to the known list rather than to "exclude
241
+ // nothing" — the classic version of this bug is `list.some(isKnownStaleShimPath)`,
242
+ // where `.some` passes the INDEX as the second argument and every exclusion
243
+ // silently disappears.
244
+ const list = Array.isArray(prefixes) ? prefixes : STALE_SHIM_PREFIXES
245
+ return list.some(prefix => typeof prefix === 'string' && prefix.length > 0 && candidate.startsWith(prefix))
246
+ }
247
+
248
+ function isUsableExecutable(
249
+ candidate: string,
250
+ excludePrefixes: readonly string[] = STALE_SHIM_PREFIXES,
251
+ ): boolean {
252
+ try {
253
+ if (!isAbsolute(candidate) || candidate.includes('\0')) return false
254
+ if (isKnownStaleShimPath(candidate, excludePrefixes)) return false
255
+ if (!statSync(candidate).isFile()) return false
256
+ accessSync(candidate, fsConstants.X_OK)
257
+ return true
258
+ } catch {
259
+ return false
260
+ }
261
+ }
262
+
263
+ export interface BinarySpec {
264
+ name: string
265
+ envKeys: readonly string[]
266
+ /** Tried in order, BEFORE any PATH scan. */
267
+ absolutes: readonly string[]
268
+ /**
269
+ * Paths that must never be selected, from any source. Defaults to
270
+ * `STALE_SHIM_PREFIXES`.
271
+ *
272
+ * On the spec rather than hardcoded because the exclusion is the only guard
273
+ * standing between resolution and a known-bad binary, and a guard whose input
274
+ * cannot be constructed is a guard nobody can prove works: the real prefix
275
+ * lives under `/Applications`, which no fixture can write to.
276
+ */
277
+ excludePrefixes?: readonly string[]
278
+ }
279
+
280
+ /**
281
+ * Where each provider's binary is looked for, in precedence order.
282
+ *
283
+ * Exported as data rather than kept private so the precedence itself is
284
+ * testable: on a machine where a stale shim sits on PATH, "ChatGPT.app is tried
285
+ * before PATH" is the property that decides whether an attached Codex turn
286
+ * launches the real binary or a dangling one.
287
+ */
288
+ export function providerBinarySpec(provider: AttachedProvider): BinarySpec {
289
+ const home = (() => {
290
+ try {
291
+ return homedir()
292
+ } catch {
293
+ return ''
294
+ }
295
+ })()
296
+ if (provider === 'claude') {
297
+ return {
298
+ name: 'claude',
299
+ envKeys: ['COS_ATTACHED_CLAUDE_BIN', 'COS_CLAUDE_BIN'],
300
+ absolutes: [
301
+ '/opt/homebrew/bin/claude',
302
+ '/usr/local/bin/claude',
303
+ home ? join(home, '.local', 'bin', 'claude') : '',
304
+ ].filter(Boolean),
305
+ }
306
+ }
307
+ return {
308
+ name: 'codex',
309
+ envKeys: ['COS_ATTACHED_CODEX_BIN', 'COS_CODEX_BIN'],
310
+ absolutes: [
311
+ // Verified 2026-08-15: codex-cli 0.148.0-alpha.9 lives here, and there is
312
+ // no `codex` on PATH at all on this machine.
313
+ '/Applications/ChatGPT.app/Contents/Resources/codex',
314
+ home ? join(home, '.codex', 'bin', 'codex') : '',
315
+ '/opt/homebrew/bin/codex',
316
+ '/usr/local/bin/codex',
317
+ ].filter(Boolean),
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Resolve a provider binary to a verified absolute path, or refuse.
323
+ *
324
+ * Never returns a bare name. A bare name is a silent PATH lookup, and the two
325
+ * environments this server runs in disagree about PATH: a login shell finds the
326
+ * CLI, a Finder- or launchd-spawned process gets a minimal PATH and does not
327
+ * (hit twice in COS Control). The PATH scan below is done by us, entry by
328
+ * entry, and still yields an absolute path we have stat'ed — so a failure names
329
+ * the missing binary instead of surfacing as ENOENT from inside a spawn.
330
+ *
331
+ * An unusable env override REFUSES rather than falling through to the
332
+ * candidates: an operator who set it wrongly needs to be told, not overridden.
333
+ */
334
+ export function resolveProviderBinary(
335
+ provider: AttachedProvider,
336
+ env: NodeJS.ProcessEnv = process.env,
337
+ ): BinaryResolution {
338
+ return resolveBinaryFromSpec(providerBinarySpec(provider), env)
339
+ }
340
+
341
+ /**
342
+ * The resolution algorithm itself, separated from the provider tables.
343
+ *
344
+ * Not a testing seam bolted on: on any developer machine at least one real
345
+ * absolute candidate exists, so the PATH-scan and not-found branches of
346
+ * `resolveProviderBinary` are unreachable from a test and would ship
347
+ * unexercised — which is exactly how a launchd-only failure hides. Driving the
348
+ * REAL algorithm with a fixture spec exercises them for real.
349
+ */
350
+ export function resolveBinaryFromSpec(spec: BinarySpec, env: NodeJS.ProcessEnv): BinaryResolution {
351
+ const excluded = spec.excludePrefixes ?? STALE_SHIM_PREFIXES
352
+
353
+ for (const key of spec.envKeys) {
354
+ const raw = env[key]
355
+ if (typeof raw !== 'string' || raw.trim().length === 0) continue
356
+ const candidate = raw.trim()
357
+ if (!isUsableExecutable(candidate, excluded)) {
358
+ return { ok: false, binary: spec.name, detail: 'env_override_unusable' }
359
+ }
360
+ return { ok: true, path: candidate, source: 'env' }
361
+ }
362
+
363
+ for (const candidate of spec.absolutes) {
364
+ if (isUsableExecutable(candidate, excluded)) return { ok: true, path: candidate, source: 'absolute' }
365
+ }
366
+
367
+ const pathValue = typeof env.PATH === 'string' ? env.PATH : ''
368
+ for (const dir of pathValue.split(delimiter)) {
369
+ // A relative PATH entry resolves against the server's cwd, which is not a
370
+ // location we control. Skipped rather than resolved.
371
+ //
372
+ // Redundant with the `isAbsolute` inside `isUsableExecutable` — verified by
373
+ // mutation: removing EITHER one alone changes no outcome, and only removing
374
+ // BOTH lets a relative entry through. Kept because two independent guards
375
+ // on "never resolve against the server cwd" is the correct amount for a
376
+ // path that ends up as a spawned executable.
377
+ if (!dir || !isAbsolute(dir)) continue
378
+ const candidate = join(dir, spec.name)
379
+ if (isUsableExecutable(candidate, excluded)) return { ok: true, path: candidate, source: 'path' }
380
+ }
381
+
382
+ return { ok: false, binary: spec.name, detail: 'not_found' }
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Injected surface
387
+ // ---------------------------------------------------------------------------
388
+
389
+ export interface AttachedChildStream {
390
+ on(event: 'data' | 'error', listener: (chunk: any) => void): unknown
391
+ }
392
+
393
+ export interface AttachedChildStdin {
394
+ write(chunk: string, cb?: (error?: Error | null) => void): boolean
395
+ end(): unknown
396
+ on(event: 'error', listener: (error: Error) => void): unknown
397
+ }
398
+
399
+ /**
400
+ * The minimum of `ChildProcess` this module uses. Narrow on purpose: a test
401
+ * double should not have to fake a whole ChildProcess, and a narrow surface is
402
+ * a list of exactly what the adapter is allowed to touch.
403
+ */
404
+ export interface AttachedChildProcess {
405
+ pid?: number
406
+ stdin: AttachedChildStdin | null
407
+ stdout: AttachedChildStream | null
408
+ stderr: AttachedChildStream | null
409
+ on(event: 'error' | 'close' | 'exit', listener: (...args: any[]) => void): unknown
410
+ }
411
+
412
+ export interface AttachedSpawnRequest {
413
+ binaryPath: string
414
+ args: readonly string[]
415
+ cwd: string
416
+ env: NodeJS.ProcessEnv
417
+ }
418
+
419
+ /** Result of the final occupancy re-check (plan 4.3 step 6). */
420
+ export interface AttachedPreflightVerdict {
421
+ attachable: boolean
422
+ reason: string | null
423
+ }
424
+
425
+ /**
426
+ * Every dependency is REQUIRED and none has a default.
427
+ *
428
+ * A default is how a fail-open ships: an omitted ownership ledger that silently
429
+ * no-ops leaves our own child looking foreign forever, and an omitted preflight
430
+ * that defaults to "go ahead" removes the last barrier before we write into a
431
+ * live thread. Making them required turns each omission into a compile error.
432
+ */
433
+ export interface AttachedTurnDeps {
434
+ /** Epoch ms. */
435
+ now: () => number
436
+ /**
437
+ * The final occupancy re-check, run synchronously immediately before spawn.
438
+ *
439
+ * Synchronous on purpose: awaiting it would reopen the window it exists to
440
+ * close, and a promise is not a verdict — a returned thenable is refused
441
+ * rather than awaited, because `(await p).attachable` is a check this module
442
+ * would not have performed.
443
+ */
444
+ preflight: () => AttachedPreflightVerdict
445
+ resolveBinary: (provider: AttachedProvider) => BinaryResolution
446
+ spawn: (request: AttachedSpawnRequest) => AttachedChildProcess
447
+ /** MUST be the measured kernel start. See the header. */
448
+ processStartMs: (pid: number) => number | null
449
+ recordSpawn: (pid: number, startMs: number) => string
450
+ releaseSpawn: (pid: number) => unknown
451
+ /**
452
+ * Terminate the child. Injected rather than defaulted so a unit test can
453
+ * never reach `process.kill(-pid)` with a fabricated pid and signal a real
454
+ * process group on the developer's machine.
455
+ */
456
+ terminate: (child: AttachedChildProcess, signal: NodeJS.Signals) => void
457
+ /**
458
+ * Argv builder. Optional, defaults to the real one.
459
+ *
460
+ * Exists ONLY so a test can hand back an argv carrying a banned permission flag
461
+ * and prove the turn is refused with zero spawns. Production never sets it.
462
+ */
463
+ buildArgs?: (provider: AttachedProvider, nativeThreadId: string, cwd: string) => string[]
464
+ }
465
+
466
+ export interface AttachedTurnRequest {
467
+ provider: unknown
468
+ nativeThreadId: unknown
469
+ prompt: unknown
470
+ cwd: unknown
471
+ policy: unknown
472
+ deps: AttachedTurnDeps
473
+ /** Wall-clock budget for the provider run. Omitted uses the default. */
474
+ timeoutMs?: number
475
+ }
476
+
477
+ /**
478
+ * Default provider budget.
479
+ *
480
+ * Matches the coordinator's 21-minute `providerTimeoutMs`, so an attached turn
481
+ * cannot outlive the job that owns it and strand the native target reservation.
482
+ */
483
+ export const DEFAULT_ATTACHED_TIMEOUT_MS = 21 * 60_000
484
+
485
+ /** Anything longer would outlive the reservation lifecycle it runs inside. */
486
+ export const MAX_ATTACHED_TIMEOUT_MS = 30 * 60_000
487
+
488
+ /** SIGTERM first; this is how long the child gets before SIGKILL. */
489
+ export const KILL_GRACE_MS = 2_000
490
+
491
+ /**
492
+ * Last-resort settle after SIGKILL.
493
+ *
494
+ * A child that survives SIGKILL is not reachable by any means available here,
495
+ * and blocking forever would wedge the coordinator and, through it, a COS
496
+ * Control drain. Settling releases the ledger entry while a process may still
497
+ * live, which makes our own child read as foreign on the next scan — Fork-only,
498
+ * the safe direction.
499
+ */
500
+ export const FORCE_SETTLE_MS = 2_000
501
+
502
+ /**
503
+ * Prompt ceiling. Generous — this is a guard against a pathological or
504
+ * corrupted value reaching a pipe, not a product limit.
505
+ */
506
+ export const MAX_PROMPT_CHARS = 200_000
507
+
508
+ /** Stop scanning stdout for ids past this. Nothing is retained either way. */
509
+ export const MAX_STDOUT_SCAN_BYTES = 4 * 1024 * 1024
510
+
511
+ /** Stderr is classified, never stored. This bounds the classification input. */
512
+ const MAX_STDERR_CLASSIFY_CHARS = 4_096
513
+
514
+ /**
515
+ * Environment keys stripped from the child.
516
+ *
517
+ * `CLAUDECODE` matches the ordinary bridges: without stripping it, a spawned
518
+ * `claude` believes it is nested. `COS_API_TOKEN` is ours and the child has no
519
+ * use for it; an attached run drives a model inside someone's real thread, and
520
+ * the token should not be one prompt away from being readable.
521
+ *
522
+ * Deliberately does NOT strip provider credentials (ANTHROPIC_*, OPENAI_*):
523
+ * those are how the CLI authenticates, and removing them would break the run.
524
+ */
525
+ const STRIPPED_ENV_KEYS: readonly string[] = ['CLAUDECODE', 'COS_API_TOKEN']
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Argv construction
529
+ // ---------------------------------------------------------------------------
530
+
531
+ /**
532
+ * Claude: `claude -p --resume <id>`, read-only, prompt on stdin.
533
+ *
534
+ * The prompt is NOT an argv element. argv is world-readable through `ps`, and
535
+ * the prompt is the user's private text; stdin also matches what both ordinary
536
+ * bridges already do.
537
+ *
538
+ * Two independent read-only layers, because either alone is one assumption
539
+ * deep: `--permission-mode plan` means the model cannot mutate regardless of
540
+ * its tool list, and the empty `--tools`/`--allowedTools` pair is the exact
541
+ * text-only posture `claude-permissions.ts` already ships for untrusted mode.
542
+ * If an older CLI rejects `plan`, it exits non-zero and we fail closed.
543
+ */
544
+ export function buildClaudeAttachedArgs(nativeThreadId: string): string[] {
545
+ return [
546
+ '-p',
547
+ '--output-format', 'stream-json',
548
+ // stream-json requires --verbose; without it the CLI refuses and we would
549
+ // never observe the session id we are required to verify.
550
+ '--verbose',
551
+ '--resume', nativeThreadId,
552
+ '--permission-mode', 'plan',
553
+ '--tools', '',
554
+ '--allowedTools', '',
555
+ ]
556
+ }
557
+
558
+ /**
559
+ * Codex: `codex exec -s read-only -C <cwd> resume --json <id> -`.
560
+ *
561
+ * Verified against codex-cli 0.148.0-alpha.9 on 2026-08-15:
562
+ * `codex exec resume [OPTIONS] [SESSION_ID] [PROMPT]`. `--sandbox` and `--cd`
563
+ * are options of `exec` and must precede the `resume` subcommand; `--json` and
564
+ * `--skip-git-repo-check` belong to `resume`; and NOTHING may follow the
565
+ * positionals. The trailing `-` is the documented "read the prompt from stdin"
566
+ * form, which keeps the prompt out of argv.
567
+ *
568
+ * `--ephemeral` is deliberately absent: the whole point is that the turn
569
+ * persists into the user's rollout.
570
+ */
571
+ export function buildCodexAttachedArgs(nativeThreadId: string, cwd: string): string[] {
572
+ return [
573
+ 'exec',
574
+ '--sandbox', 'read-only',
575
+ '--cd', cwd,
576
+ 'resume',
577
+ '--json',
578
+ '--skip-git-repo-check',
579
+ nativeThreadId,
580
+ '-',
581
+ ]
582
+ }
583
+
584
+ /**
585
+ * Is this argv free of every flag plan 4.7 bans?
586
+ *
587
+ * Substring, not equality: a banned token can arrive attached to its value
588
+ * (`--permission-mode=bypassPermissions`, `--sandbox danger-full-access`), and an
589
+ * equality check would wave those through while looking correct.
590
+ */
591
+ export function findBannedPermissionArg(args: readonly string[]): string | null {
592
+ for (const arg of args) {
593
+ const value = String(arg)
594
+ for (const banned of BANNED_PERMISSION_ARGS) {
595
+ if (value.includes(banned)) return banned
596
+ }
597
+ }
598
+ return null
599
+ }
600
+
601
+ /**
602
+ * Build the argv, and REFUSE to hand back one carrying a banned flag.
603
+ *
604
+ * `BANNED_PERMISSION_ARGS` existed, was exported, was asserted in a test — and
605
+ * had no runtime consumer at all, so a test was the only thing standing between
606
+ * plan 4.7 and an always-approve flag reaching a provider that is about to write
607
+ * into a real human's conversation. A test protects the argv this build happens
608
+ * to produce; it cannot protect the argv a future edit produces.
609
+ *
610
+ * Enforced HERE because it is the one point both providers pass through, so a new
611
+ * provider inherits the check rather than needing to remember it. Throws rather
612
+ * than returning a sentinel: there is no safe degraded argv, and the caller's
613
+ * outer catch already maps a throw to a terminal refusal before any spawn.
614
+ */
615
+ function buildArgs(provider: AttachedProvider, nativeThreadId: string, cwd: string): string[] {
616
+ const args = provider === 'claude'
617
+ ? buildClaudeAttachedArgs(nativeThreadId)
618
+ : buildCodexAttachedArgs(nativeThreadId, cwd)
619
+ const banned = findBannedPermissionArg(args)
620
+ if (banned !== null) {
621
+ // The flag name only. Never the argv, which carries the thread id and cwd.
622
+ throw new Error(`attached argv carries a banned permission flag: ${banned}`)
623
+ }
624
+ return args
625
+ }
626
+
627
+ export function buildAttachedEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
628
+ const env: NodeJS.ProcessEnv = { ...base }
629
+ for (const key of STRIPPED_ENV_KEYS) delete env[key]
630
+ return env
631
+ }
632
+
633
+ // ---------------------------------------------------------------------------
634
+ // Output reading
635
+ // ---------------------------------------------------------------------------
636
+
637
+ /**
638
+ * Ids observed on one NDJSON line, read from known top-level fields only.
639
+ *
640
+ * Never a regex over raw text. A model can print a UUID in its answer, and a
641
+ * scan that cannot tell an id field from prose would either invent a mismatch
642
+ * or — far worse — accept an echoed target id from a run that actually forked.
643
+ * Assistant text always arrives inside a string field, so field-scoped reading
644
+ * makes that collision unrepresentable.
645
+ */
646
+ export function extractNativeIdsFromLine(line: string): string[] {
647
+ const trimmed = line.trim()
648
+ if (!trimmed.startsWith('{')) return []
649
+ let event: any
650
+ try {
651
+ event = JSON.parse(trimmed)
652
+ } catch {
653
+ return []
654
+ }
655
+ if (!event || typeof event !== 'object' || Array.isArray(event)) return []
656
+
657
+ const found: string[] = []
658
+ const consider = (value: unknown) => {
659
+ if (isValidNativeThreadId(value) && !found.includes(value)) found.push(value)
660
+ }
661
+
662
+ consider(event.session_id)
663
+ consider(event.sessionId)
664
+ consider(event.thread_id)
665
+ consider(event.threadId)
666
+
667
+ const type = typeof event.type === 'string' ? event.type.toLowerCase() : ''
668
+ // `id` is only an identity claim on a thread/session lifecycle event. On any
669
+ // other event it names an item, a tool call, or a message.
670
+ if (/^(thread|session)[.\-_]/.test(type)) consider(event.id)
671
+
672
+ for (const container of [event.thread, event.session]) {
673
+ if (container && typeof container === 'object' && !Array.isArray(container)) {
674
+ consider((container as any).id)
675
+ consider((container as any).thread_id)
676
+ consider((container as any).session_id)
677
+ }
678
+ }
679
+
680
+ return found
681
+ }
682
+
683
+ export function classifyStderr(text: string): AttachedStderrClass {
684
+ const sample = text.slice(0, MAX_STDERR_CLASSIFY_CHARS).toLowerCase()
685
+ if (sample.trim().length === 0) return 'none'
686
+ if (/no conversation found|session not found|thread not found|no such (session|thread)|not found/.test(sample)) {
687
+ return 'thread_not_found'
688
+ }
689
+ if (/unauthor|forbidden|not logged in|sign in|login|auth|credential|token expired/.test(sample)) return 'auth'
690
+ if (/rate limit|too many requests|quota|overloaded/.test(sample)) return 'rate_limit'
691
+ if (/permission|denied|read-only|sandbox|operation not permitted|eacces/.test(sample)) return 'permission'
692
+ return 'unclassified'
693
+ }
694
+
695
+ // ---------------------------------------------------------------------------
696
+ // The adapter
697
+ // ---------------------------------------------------------------------------
698
+
699
+ function fail(
700
+ reason: AttachedTurnFailure,
701
+ delivery: AttachedDeliveryState,
702
+ over: Partial<AttachedTurnFailureResult> = {},
703
+ ): AttachedTurnFailureResult {
704
+ return {
705
+ ok: false,
706
+ provider: null,
707
+ nativeThreadId: null,
708
+ returnedNativeId: null,
709
+ delivery,
710
+ reason,
711
+ detail: null,
712
+ exitCode: null,
713
+ stderrClass: 'none',
714
+ durationMs: 0,
715
+ ...over,
716
+ }
717
+ }
718
+
719
+ function readDuration(deps: AttachedTurnDeps, startedAt: number): number {
720
+ try {
721
+ const now = deps.now()
722
+ if (!Number.isFinite(now)) return 0
723
+ const delta = now - startedAt
724
+ return Number.isFinite(delta) && delta >= 0 ? delta : 0
725
+ } catch {
726
+ return 0
727
+ }
728
+ }
729
+
730
+ /**
731
+ * Append one turn to an existing native provider thread.
732
+ *
733
+ * Resolves; never rejects. A caller that has to wrap this in try/catch to stay
734
+ * safe is a caller that will one day forget, and the catch-all branch would be
735
+ * the one place with no defined delivery state.
736
+ */
737
+ export async function deliverAttachedTurn(request: AttachedTurnRequest): Promise<AttachedTurnResult> {
738
+ const deps = request.deps
739
+ // A malformed deps object is a wiring bug, and a wiring bug must not reach a
740
+ // spawn. Checked before anything else because the failure path below needs
741
+ // `deps.now` to exist.
742
+ if (!deps || typeof deps !== 'object') {
743
+ return fail('adapter_internal_error', 'not_attempted', { detail: 'deps_missing' })
744
+ }
745
+ for (const key of ['now', 'preflight', 'resolveBinary', 'spawn', 'processStartMs', 'recordSpawn', 'releaseSpawn', 'terminate'] as const) {
746
+ if (typeof (deps as any)[key] !== 'function') {
747
+ return { ...fail('adapter_internal_error', 'not_attempted', { detail: `dep_missing:${key}` }) }
748
+ }
749
+ }
750
+
751
+ let startedAt = 0
752
+ try {
753
+ const t0 = deps.now()
754
+ startedAt = Number.isFinite(t0) ? t0 : 0
755
+ } catch {
756
+ return fail('adapter_internal_error', 'not_attempted', { detail: 'clock_failed' })
757
+ }
758
+
759
+ try {
760
+ return await run(request, deps, startedAt)
761
+ } catch (error: any) {
762
+ // Anything unforeseen resolves to a refusal, not a throw. `not_attempted`
763
+ // is only correct here because every path that can have written a prompt
764
+ // byte has its own try/catch and returns its own ambiguous result.
765
+ return fail('adapter_internal_error', 'not_attempted', {
766
+ detail: 'unhandled',
767
+ durationMs: readDuration(deps, startedAt),
768
+ })
769
+ }
770
+ }
771
+
772
+ async function run(
773
+ request: AttachedTurnRequest,
774
+ deps: AttachedTurnDeps,
775
+ startedAt: number,
776
+ ): Promise<AttachedTurnResult> {
777
+ const duration = () => readDuration(deps, startedAt)
778
+
779
+ // --- 1. Validate the request ------------------------------------------------
780
+ if (!isBindableProvider(request.provider)) {
781
+ return fail('invalid_provider', 'not_attempted', { durationMs: duration() })
782
+ }
783
+ const provider: AttachedProvider = request.provider
784
+
785
+ if (!isValidNativeThreadId(request.nativeThreadId)) {
786
+ return fail('invalid_thread_id', 'not_attempted', { provider, durationMs: duration() })
787
+ }
788
+ const nativeThreadId: string = request.nativeThreadId
789
+
790
+ const base = { provider, nativeThreadId, durationMs: 0 }
791
+
792
+ if (!isAttachedPermissionPolicy(request.policy)) {
793
+ // Includes `undefined`. An omitted policy is not a request for the safest
794
+ // one; it is a caller that has not decided, and this module will not decide
795
+ // for it while agent mode is a flag away.
796
+ return fail('unsupported_policy', 'not_attempted', { ...base, durationMs: duration() })
797
+ }
798
+
799
+ if (typeof request.prompt !== 'string' || request.prompt.length === 0) {
800
+ return fail('invalid_prompt', 'not_attempted', { ...base, detail: 'empty', durationMs: duration() })
801
+ }
802
+ if (request.prompt.length > MAX_PROMPT_CHARS) {
803
+ return fail('invalid_prompt', 'not_attempted', { ...base, detail: 'too_long', durationMs: duration() })
804
+ }
805
+ const prompt: string = request.prompt
806
+
807
+ if (typeof request.cwd !== 'string' || request.cwd.length === 0 || !isAbsolute(request.cwd) || request.cwd.includes('\0')) {
808
+ // A relative cwd resolves against the SERVER's working directory, so the
809
+ // provider would run against the wrong workspace while looking correct.
810
+ return fail('invalid_cwd', 'not_attempted', { ...base, durationMs: duration() })
811
+ }
812
+ const cwd: string = request.cwd
813
+
814
+ let timeoutMs = DEFAULT_ATTACHED_TIMEOUT_MS
815
+ if (request.timeoutMs !== undefined) {
816
+ if (typeof request.timeoutMs !== 'number' || !Number.isFinite(request.timeoutMs)
817
+ || request.timeoutMs <= 0 || request.timeoutMs > MAX_ATTACHED_TIMEOUT_MS) {
818
+ return fail('invalid_timeout', 'not_attempted', { ...base, durationMs: duration() })
819
+ }
820
+ timeoutMs = request.timeoutMs
821
+ }
822
+
823
+ // --- 2. Resolve the binary --------------------------------------------------
824
+ let resolution: BinaryResolution
825
+ try {
826
+ resolution = deps.resolveBinary(provider)
827
+ } catch {
828
+ return fail('binary_not_found', 'not_attempted', {
829
+ ...base, detail: `${provider}:resolver_failed`, durationMs: duration(),
830
+ })
831
+ }
832
+ if (!resolution || typeof resolution !== 'object' || resolution.ok !== true) {
833
+ const detail = resolution && typeof resolution === 'object' && resolution.ok === false
834
+ ? `${resolution.binary}:${resolution.detail}`
835
+ : `${provider}:unresolved`
836
+ // No fallback to the other provider, and no bare-name spawn. Plan 4.2 bans
837
+ // the silent downgrade by name; the only correct move is to stop.
838
+ return fail('binary_not_found', 'not_attempted', { ...base, detail, durationMs: duration() })
839
+ }
840
+ if (typeof resolution.path !== 'string' || !isAbsolute(resolution.path)) {
841
+ return fail('binary_not_found', 'not_attempted', {
842
+ ...base, detail: `${provider}:not_absolute`, durationMs: duration(),
843
+ })
844
+ }
845
+ const binaryPath = resolution.path
846
+
847
+ // --- 3. Final occupancy re-check, immediately before spawn (plan 4.3 §6) ----
848
+ let verdict: AttachedPreflightVerdict
849
+ try {
850
+ verdict = deps.preflight()
851
+ } catch {
852
+ return fail('preflight_failed', 'not_attempted', { ...base, detail: 'threw', durationMs: duration() })
853
+ }
854
+ if (!verdict || typeof verdict !== 'object' || typeof (verdict as any).then === 'function') {
855
+ // A thenable is not a verdict. Awaiting one here would reopen the very
856
+ // window this check closes, so it is refused instead.
857
+ return fail('preflight_failed', 'not_attempted', { ...base, detail: 'malformed', durationMs: duration() })
858
+ }
859
+ if (verdict.attachable !== true) {
860
+ const reasonText = typeof verdict.reason === 'string' && /^[a-z0-9_]{1,64}$/.test(verdict.reason)
861
+ ? verdict.reason
862
+ : 'unspecified'
863
+ return fail('native_owner_appeared', 'not_attempted', { ...base, detail: reasonText, durationMs: duration() })
864
+ }
865
+
866
+ // --- 4. Spawn ---------------------------------------------------------------
867
+ // Built through an injectable seam so the banned-flag check below is reachable
868
+ // by a test. Without the seam no test could make the builders emit a banned
869
+ // flag, the check was unreachable, and a mutation deleting it passed — which is
870
+ // how `BANNED_PERMISSION_ARGS` came to be exported, asserted, and enforced
871
+ // nowhere.
872
+ let args: string[]
873
+ try {
874
+ args = (deps.buildArgs ?? buildArgs)(provider, nativeThreadId, cwd)
875
+ } catch {
876
+ return fail('unsupported_policy', 'not_attempted', { ...base, detail: 'argv_build_failed', durationMs: duration() })
877
+ }
878
+ // Plan 4.7, enforced against the argv that is about to be spawned rather than
879
+ // against the source that builds it. No safe degraded argv exists, so this is a
880
+ // terminal refusal before any process is created.
881
+ const bannedArg = findBannedPermissionArg(args)
882
+ if (bannedArg !== null) {
883
+ return fail('unsupported_policy', 'not_attempted', { ...base, detail: `banned_arg:${bannedArg}`, durationMs: duration() })
884
+ }
885
+ let child: AttachedChildProcess
886
+ try {
887
+ child = deps.spawn({ binaryPath, args, cwd, env: buildAttachedEnv() })
888
+ } catch {
889
+ // Includes ENOENT. No process exists, so no turn can have landed.
890
+ return fail('spawn_failed', 'not_attempted', { ...base, detail: 'threw', durationMs: duration() })
891
+ }
892
+ if (!child || typeof child !== 'object' || typeof child.on !== 'function') {
893
+ return fail('spawn_failed', 'not_attempted', { ...base, detail: 'no_child', durationMs: duration() })
894
+ }
895
+
896
+ const pid = child.pid
897
+ if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) {
898
+ // Node leaves `pid` undefined when the spawn itself failed. If a process
899
+ // does exist behind an unusable pid we cannot record it, cannot release it,
900
+ // and cannot kill it on timeout — so refuse rather than run blind.
901
+ safeTerminate(deps, child, 'SIGKILL')
902
+ return fail('spawn_failed', 'not_attempted', { ...base, detail: 'no_pid', durationMs: duration() })
903
+ }
904
+
905
+ // --- 5. Claim the child, synchronously, before anything can observe it ------
906
+ // NO `await` between the spawn above and the record below. See the header.
907
+ let claimed = false
908
+ let claimDetail = 'unknown'
909
+ try {
910
+ const startMs = deps.processStartMs(pid)
911
+ if (typeof startMs === 'number' && Number.isFinite(startMs) && startMs > 0) {
912
+ const outcome = deps.recordSpawn(pid, startMs)
913
+ claimed = outcome === 'recorded'
914
+ claimDetail = typeof outcome === 'string' ? outcome : 'non_string_outcome'
915
+ } else {
916
+ claimDetail = 'start_unavailable'
917
+ }
918
+ } catch {
919
+ claimed = false
920
+ claimDetail = 'threw'
921
+ }
922
+
923
+ if (!claimed) {
924
+ // An unclaimed child is a live process that the occupancy detector will
925
+ // read as a foreign desktop owner of this exact thread — permanently, if it
926
+ // outlives us. Kill it before a single prompt byte is written, so the turn
927
+ // is provably not delivered, and release defensively in case the record
928
+ // partially landed.
929
+ safeTerminate(deps, child, 'SIGKILL')
930
+ safeRelease(deps, pid)
931
+ return fail('ownership_record_failed', 'aborted', {
932
+ ...base, detail: claimDetail, durationMs: duration(),
933
+ })
934
+ }
935
+
936
+ try {
937
+ return await driveChild({ child, pid, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt })
938
+ } finally {
939
+ // Every path: success, mismatch, non-zero exit, timeout, throw. A leaked
940
+ // entry lets a recycled pid inherit our self-ownership claim.
941
+ safeRelease(deps, pid)
942
+ }
943
+ }
944
+
945
+ function safeTerminate(deps: AttachedTurnDeps, child: AttachedChildProcess, signal: NodeJS.Signals): void {
946
+ try {
947
+ deps.terminate(child, signal)
948
+ } catch {
949
+ // A kill that fails changes nothing a caller can act on, and must never
950
+ // replace the terminal result being assembled around it.
951
+ }
952
+ }
953
+
954
+ function safeRelease(deps: AttachedTurnDeps, pid: number): void {
955
+ try {
956
+ deps.releaseSpawn(pid)
957
+ } catch {
958
+ // Same reasoning: a throwing release must not mask the turn's outcome.
959
+ }
960
+ }
961
+
962
+ interface DriveInput {
963
+ child: AttachedChildProcess
964
+ pid: number
965
+ deps: AttachedTurnDeps
966
+ provider: AttachedProvider
967
+ nativeThreadId: string
968
+ prompt: string
969
+ timeoutMs: number
970
+ startedAt: number
971
+ }
972
+
973
+ function driveChild(input: DriveInput): Promise<AttachedTurnResult> {
974
+ const { child, deps, provider, nativeThreadId, prompt, timeoutMs, startedAt } = input
975
+
976
+ return new Promise<AttachedTurnResult>((resolve) => {
977
+ /** Distinct ids seen. More than one means a fork happened; see the header. */
978
+ const observedIds: string[] = []
979
+ let stdoutTail = ''
980
+ let scannedBytes = 0
981
+ let stderrSample = ''
982
+ let delivery: AttachedDeliveryState = 'not_attempted'
983
+ let exitCode: number | null = null
984
+ let settled = false
985
+ let timedOut = false
986
+ let spawnErrored = false
987
+
988
+ let deadline: ReturnType<typeof setTimeout> | null = null
989
+ let graceTimer: ReturnType<typeof setTimeout> | null = null
990
+ let forceTimer: ReturnType<typeof setTimeout> | null = null
991
+
992
+ const clearTimers = () => {
993
+ if (deadline) { clearTimeout(deadline); deadline = null }
994
+ if (graceTimer) { clearTimeout(graceTimer); graceTimer = null }
995
+ if (forceTimer) { clearTimeout(forceTimer); forceTimer = null }
996
+ }
997
+
998
+ const duration = () => readDuration(deps, startedAt)
999
+
1000
+ const settle = (result: AttachedTurnResult) => {
1001
+ if (settled) return
1002
+ settled = true
1003
+ clearTimers()
1004
+ resolve(result)
1005
+ }
1006
+
1007
+ const settleFailure = (
1008
+ reason: AttachedTurnFailure,
1009
+ over: Partial<AttachedTurnFailureResult> = {},
1010
+ ) => {
1011
+ settle(fail(reason, delivery, {
1012
+ provider,
1013
+ nativeThreadId,
1014
+ returnedNativeId: observedIds.length === 1 ? observedIds[0]! : null,
1015
+ exitCode,
1016
+ stderrClass: classifyStderr(stderrSample),
1017
+ durationMs: duration(),
1018
+ ...over,
1019
+ }))
1020
+ }
1021
+
1022
+ const consumeStdout = (chunk: any) => {
1023
+ try {
1024
+ if (scannedBytes >= MAX_STDOUT_SCAN_BYTES) return
1025
+ const text = typeof chunk === 'string' ? chunk : String(chunk)
1026
+ scannedBytes += text.length
1027
+ stdoutTail += text
1028
+ const lines = stdoutTail.split('\n')
1029
+ // Keep only the trailing partial line; everything before it is complete.
1030
+ stdoutTail = lines.pop() ?? ''
1031
+ // Bound the carry-over so a provider emitting one enormous line cannot
1032
+ // grow this without limit.
1033
+ if (stdoutTail.length > 1_000_000) stdoutTail = ''
1034
+ for (const line of lines) {
1035
+ for (const id of extractNativeIdsFromLine(line)) {
1036
+ if (!observedIds.includes(id)) observedIds.push(id)
1037
+ }
1038
+ }
1039
+ // Deliberately nothing else: no text, no tool calls, no transcript. The
1040
+ // adapter cannot leak what it never held.
1041
+ } catch {
1042
+ // A malformed chunk costs us id evidence, which ends as
1043
+ // `no_native_id_returned` — a refusal, which is the safe direction.
1044
+ }
1045
+ }
1046
+
1047
+ const consumeStderr = (chunk: any) => {
1048
+ try {
1049
+ if (stderrSample.length >= MAX_STDERR_CLASSIFY_CHARS) return
1050
+ const text = typeof chunk === 'string' ? chunk : String(chunk)
1051
+ stderrSample = (stderrSample + text).slice(0, MAX_STDERR_CLASSIFY_CHARS)
1052
+ } catch {
1053
+ /* classification degrades to 'unclassified'; nothing else depends on it */
1054
+ }
1055
+ }
1056
+
1057
+ const finishTerminal = () => {
1058
+ // Drain whatever sat in the trailing partial line before judging.
1059
+ if (stdoutTail.length > 0) {
1060
+ for (const id of extractNativeIdsFromLine(stdoutTail)) {
1061
+ if (!observedIds.includes(id)) observedIds.push(id)
1062
+ }
1063
+ stdoutTail = ''
1064
+ }
1065
+
1066
+ if (timedOut) return settleFailure('timeout')
1067
+ if (spawnErrored) return settleFailure('spawn_failed', { detail: 'child_error' })
1068
+
1069
+ if (exitCode !== 0) {
1070
+ return settleFailure('provider_exit_nonzero')
1071
+ }
1072
+ if (observedIds.length === 0) {
1073
+ // Exit 0 with no id is not a success. We were required to prove the
1074
+ // turn landed on the target, and no evidence is not proof.
1075
+ return settleFailure('no_native_id_returned')
1076
+ }
1077
+ if (observedIds.length > 1 || observedIds[0] !== nativeThreadId) {
1078
+ // Either a different thread, or ours plus another — a fork emits both.
1079
+ return settleFailure('native_id_mismatch', {
1080
+ returnedNativeId: observedIds.length === 1 ? observedIds[0]! : null,
1081
+ detail: observedIds.length > 1 ? 'multiple_ids' : 'different_id',
1082
+ })
1083
+ }
1084
+ if (delivery !== 'ambiguous') {
1085
+ // Exit 0 with a matching id but no prompt ever written is incoherent;
1086
+ // refuse rather than report a turn we did not send.
1087
+ return settleFailure('adapter_internal_error', { detail: 'no_delivery_attempt' })
1088
+ }
1089
+
1090
+ settle({
1091
+ ok: true,
1092
+ provider,
1093
+ nativeThreadId,
1094
+ returnedNativeId: nativeThreadId,
1095
+ delivery: 'delivered',
1096
+ reason: null,
1097
+ exitCode: 0,
1098
+ durationMs: duration(),
1099
+ })
1100
+ }
1101
+
1102
+ // --- wire the child ------------------------------------------------------
1103
+ try {
1104
+ child.stdout?.on('data', consumeStdout)
1105
+ child.stderr?.on('data', consumeStderr)
1106
+ child.stdout?.on('error', () => { /* stream errors surface via close/exit */ })
1107
+ child.stderr?.on('error', () => { /* ditto */ })
1108
+
1109
+ child.on('error', () => {
1110
+ spawnErrored = true
1111
+ // Delivery stays whatever it was: `not_attempted` if this fired before
1112
+ // the write (ENOENT), `ambiguous` if after.
1113
+ finishTerminal()
1114
+ })
1115
+ child.on('exit', (code: number | null) => {
1116
+ if (typeof code === 'number') exitCode = code
1117
+ else if (exitCode === null) exitCode = null
1118
+ })
1119
+ child.on('close', (code: number | null) => {
1120
+ if (typeof code === 'number') exitCode = code
1121
+ finishTerminal()
1122
+ })
1123
+ } catch {
1124
+ safeTerminate(deps, child, 'SIGKILL')
1125
+ return settleFailure('adapter_internal_error', { detail: 'wire_failed' })
1126
+ }
1127
+
1128
+ // --- bounded budget ------------------------------------------------------
1129
+ deadline = setTimeout(() => {
1130
+ timedOut = true
1131
+ safeTerminate(deps, child, 'SIGTERM')
1132
+ graceTimer = setTimeout(() => {
1133
+ safeTerminate(deps, child, 'SIGKILL')
1134
+ forceTimer = setTimeout(() => {
1135
+ // A child that survived SIGKILL cannot be reached from here, and
1136
+ // blocking forever would wedge the coordinator and any Control drain
1137
+ // behind it.
1138
+ settleFailure('timeout', { detail: 'unreaped' })
1139
+ }, FORCE_SETTLE_MS)
1140
+ }, KILL_GRACE_MS)
1141
+ }, timeoutMs)
1142
+
1143
+ // --- deliver the prompt --------------------------------------------------
1144
+ const stdin = child.stdin
1145
+ if (!stdin || typeof stdin.write !== 'function' || typeof stdin.end !== 'function' || !child.stdout) {
1146
+ // No way to send the prompt, or no way to observe the id we must verify.
1147
+ // Nothing was written, so this is a provable abort.
1148
+ safeTerminate(deps, child, 'SIGKILL')
1149
+ delivery = 'aborted'
1150
+ return settleFailure('child_stdio_unavailable', {
1151
+ detail: stdin ? 'stdout_missing' : 'stdin_missing',
1152
+ })
1153
+ }
1154
+
1155
+ try {
1156
+ stdin.on('error', () => { /* reported through close/exit; never fatal here */ })
1157
+ } catch {
1158
+ /* an stdin that cannot take a listener still gets the write attempt below */
1159
+ }
1160
+
1161
+ try {
1162
+ // THE BOUNDARY. From this call on, we cannot prove the provider did not
1163
+ // act on the prompt, so every non-success outcome is ambiguous.
1164
+ delivery = 'ambiguous'
1165
+ stdin.write(prompt)
1166
+ stdin.end()
1167
+ } catch {
1168
+ // A synchronous throw means the pipe rejected the write, but not that no
1169
+ // byte was queued. Fail closed: stay ambiguous.
1170
+ safeTerminate(deps, child, 'SIGKILL')
1171
+ return settleFailure('child_stdio_unavailable', { detail: 'write_failed' })
1172
+ }
1173
+ })
1174
+ }
1175
+
1176
+ // ---------------------------------------------------------------------------
1177
+ // Production wiring
1178
+ // ---------------------------------------------------------------------------
1179
+
1180
+ /**
1181
+ * The real dependency set.
1182
+ *
1183
+ * `preflight` has no default and never will: the occupancy re-check needs the
1184
+ * binding's provider and thread id plus live probes, all of which belong to the
1185
+ * coordinator. A default here would be a placeholder that says "attachable",
1186
+ * which is the one answer this module must never invent.
1187
+ */
1188
+ export function realAttachedTurnDeps(preflight: () => AttachedPreflightVerdict): AttachedTurnDeps {
1189
+ return {
1190
+ now: () => Date.now(),
1191
+ preflight,
1192
+ resolveBinary: provider => resolveProviderBinary(provider),
1193
+ spawn: request => nodeSpawn(request.binaryPath, [...request.args], {
1194
+ stdio: ['pipe', 'pipe', 'pipe'],
1195
+ cwd: request.cwd,
1196
+ env: request.env,
1197
+ // Group leader, so the whole provider tree can be signalled on timeout —
1198
+ // matching what both ordinary bridges do.
1199
+ detached: true,
1200
+ }) as unknown as AttachedChildProcess,
1201
+ processStartMs: pid => realProcessStartMs(pid),
1202
+ recordSpawn: (pid, startMs) => recordCosSpawn(pid, startMs),
1203
+ releaseSpawn: pid => releaseCosSpawn(pid),
1204
+ terminate: (child, signal) => {
1205
+ const pid = child.pid
1206
+ if (typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) {
1207
+ try {
1208
+ // Negative pid = process group, reachable because we spawned
1209
+ // detached. Kills the CLI's own children too.
1210
+ process.kill(-pid, signal)
1211
+ return
1212
+ } catch {
1213
+ /* fall through to the direct signal */
1214
+ }
1215
+ }
1216
+ try {
1217
+ ;(child as any).kill?.(signal)
1218
+ } catch {
1219
+ /* nothing further is available */
1220
+ }
1221
+ },
1222
+ }
1223
+ }