@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.
- package/CHANGELOG.md +59 -0
- package/package.json +1 -1
- package/server/index.ts +186 -0
- package/server/lib/agent-session-binding-registry.ts +1225 -0
- package/server/lib/agent-session-binding-store.ts +327 -0
- package/server/lib/agent-session-ownership-store.ts +395 -0
- package/server/lib/attached-provider-adapter.ts +1223 -0
- package/server/lib/attached-workspace.ts +197 -0
- package/server/lib/fork-thread.ts +957 -0
- package/server/lib/native-head.ts +649 -0
- package/server/lib/native-thread-id.ts +23 -0
- package/server/lib/occupancy-probes.ts +444 -0
- package/server/lib/query-job-runtime.ts +24 -0
- package/server/lib/thread-attach-capability.ts +200 -0
- package/server/lib/thread-occupancy.ts +367 -0
- package/server/routes/agent-session-bindings.ts +2024 -0
- package/server/routes/health.ts +24 -0
|
@@ -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
|
+
}
|