@gotcos/glasses-server 6.27.13 → 6.28.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 +29 -0
- package/package.json +1 -1
- package/server/index.ts +141 -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/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-occupancy.ts +367 -0
- package/server/routes/agent-session-bindings.ts +1550 -0
|
@@ -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
|
+
}
|