@mono-agent/agent-runtime 0.19.1 → 0.20.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/MIGRATION.md +1 -1
- package/README.md +101 -4
- package/package.json +1 -1
- package/src/agent/sandbox-seam.js +16 -2
- package/src/agent/tools/bash.js +26 -4
- package/src/agent/tools/edit.js +72 -5
- package/src/agent/tools/exec.js +22 -4
- package/src/agent/tools/glob.js +65 -9
- package/src/agent/tools/grep.js +66 -11
- package/src/agent/tools/node-repl.js +5 -2
- package/src/agent/tools/pi-bridge.js +263 -48
- package/src/agent/tools/read.js +50 -10
- package/src/agent/tools/shared/path-resolver.js +67 -2
- package/src/agent/tools/shared/process-jobs.js +188 -0
- package/src/agent/tools/shared/process-runner.js +541 -30
- package/src/agent/tools/shared/protected-filesystem.js +150 -0
- package/src/agent/tools/web-search.js +63 -8
- package/src/agent/tools/write.js +52 -6
- package/src/ai/providers/acp.js +4 -0
- package/src/ai/providers/claude-cli.js +35 -2
- package/src/ai/providers/claude-sdk.js +12 -0
- package/src/ai/providers/codex-app.js +15 -2
- package/src/ai/providers/pi-native/stream-subscriber.js +29 -2
- package/src/ai/providers/pi-native/turn-runner.js +3 -0
- package/src/ai/providers/pi-native.js +7 -1
- package/src/ai/runtime/capabilities.js +2 -0
- package/src/ai/runtime/router.js +78 -6
- package/src/ai/streaming/codex-events.js +15 -0
- package/src/ai/streaming/opencode-events.js +5 -0
- package/src/ai/tool-lifecycle.js +347 -0
- package/src/ai/types.js +58 -0
- package/src/runtime.js +35 -21
- package/types/agent/sandbox-seam.d.ts +19 -6
- package/types/agent/tools/bash.d.ts +11 -26
- package/types/agent/tools/edit.d.ts +3 -2
- package/types/agent/tools/exec.d.ts +13 -26
- package/types/agent/tools/glob.d.ts +3 -2
- package/types/agent/tools/grep.d.ts +3 -2
- package/types/agent/tools/pi-bridge.d.ts +11 -4
- package/types/agent/tools/read.d.ts +3 -2
- package/types/agent/tools/shared/path-resolver.d.ts +8 -0
- package/types/agent/tools/shared/process-jobs.d.ts +64 -0
- package/types/agent/tools/shared/process-runner.d.ts +45 -3
- package/types/agent/tools/shared/protected-filesystem.d.ts +51 -0
- package/types/agent/tools/write.d.ts +3 -2
- package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -0
- package/types/ai/runtime/capabilities.d.ts +3 -0
- package/types/ai/streaming/codex-events.d.ts +1 -0
- package/types/ai/streaming/opencode-events.d.ts +1 -0
- package/types/ai/tool-lifecycle.d.ts +43 -0
- package/types/ai/types.d.ts +118 -0
|
@@ -4,6 +4,129 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
|
|
5
5
|
export const DEFAULT_PROCESS_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
6
6
|
const KILL_GRACE_MS = 1_000;
|
|
7
|
+
const KILL_EXIT_CONFIRM_MS = 1_000;
|
|
8
|
+
const PROCESS_GROUP_REATTEST_POLL_MS = 25;
|
|
9
|
+
const PROCESS_GROUP_REATTEST_MAX_GAP_MS = 250;
|
|
10
|
+
const PROCESS_JOB_LAUNCH_PAYLOAD_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
const PROCESS_JOB_STATUS_BYTES = 64 * 1024;
|
|
12
|
+
|
|
13
|
+
// Background jobs start this command-agnostic group leader first. The actual
|
|
14
|
+
// target crosses fd 3 only after the host has durably recorded the leader's
|
|
15
|
+
// PID, PGID, and incarnation. If the host exits before that release, fd 3
|
|
16
|
+
// closes empty and no target is ever spawned. Raw argv/environment values are
|
|
17
|
+
// therefore absent from both durable state and the gate's process arguments.
|
|
18
|
+
const PROCESS_JOB_LAUNCH_GATE_SOURCE = String.raw`
|
|
19
|
+
"use strict";
|
|
20
|
+
const { spawn } = require("node:child_process");
|
|
21
|
+
const { createReadStream, createWriteStream } = require("node:fs");
|
|
22
|
+
const MAX_PAYLOAD_BYTES = 8 * 1024 * 1024;
|
|
23
|
+
const input = createReadStream(null, { fd: 3, autoClose: true });
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let bytes = 0;
|
|
26
|
+
let finished = false;
|
|
27
|
+
let target;
|
|
28
|
+
let groupTerminationSignal = null;
|
|
29
|
+
|
|
30
|
+
function finish(value) {
|
|
31
|
+
if (finished) return;
|
|
32
|
+
finished = true;
|
|
33
|
+
const output = createWriteStream(null, { fd: 4, autoClose: true });
|
|
34
|
+
output.once("error", () => { process.exitCode = 1; });
|
|
35
|
+
output.end(JSON.stringify(value), () => { process.exitCode = 0; });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function spawnFailure(code) {
|
|
39
|
+
finish({
|
|
40
|
+
code: null,
|
|
41
|
+
signal: null,
|
|
42
|
+
spawnError: {
|
|
43
|
+
message: "The gated target process could not be spawned.",
|
|
44
|
+
...(typeof code === "string" ? { code } : {}),
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Group-wide SIGTERM also reaches this command-agnostic leader. Keep it alive
|
|
50
|
+
// while its direct target settles. Before release there cannot be a target, so
|
|
51
|
+
// close the gate and exit promptly instead of manufacturing a later spawn.
|
|
52
|
+
process.on("SIGTERM", () => {
|
|
53
|
+
groupTerminationSignal = "SIGTERM";
|
|
54
|
+
if (target !== undefined) return;
|
|
55
|
+
input.destroy();
|
|
56
|
+
finish({ code: null, signal: "SIGTERM", spawnError: null });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function forwardOutput(source, destination) {
|
|
60
|
+
let destinationOpen = true;
|
|
61
|
+
const resume = () => source.resume();
|
|
62
|
+
const discard = () => {
|
|
63
|
+
destinationOpen = false;
|
|
64
|
+
source.resume();
|
|
65
|
+
};
|
|
66
|
+
destination.on("drain", resume);
|
|
67
|
+
// If the owning host crashes, its pipe readers disappear. Keep the gate
|
|
68
|
+
// alive as the attestable group leader and drain target output instead of
|
|
69
|
+
// crashing on EPIPE and stranding descendants without a leader.
|
|
70
|
+
destination.on("error", discard);
|
|
71
|
+
source.on("data", (chunk) => {
|
|
72
|
+
if (!destinationOpen) return;
|
|
73
|
+
try {
|
|
74
|
+
if (!destination.write(chunk)) source.pause();
|
|
75
|
+
} catch {
|
|
76
|
+
discard();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
input.on("data", (chunk) => {
|
|
82
|
+
bytes += chunk.length;
|
|
83
|
+
if (bytes > MAX_PAYLOAD_BYTES) {
|
|
84
|
+
input.destroy();
|
|
85
|
+
spawnFailure("PROCESS_JOB_LAUNCH_PAYLOAD_TOO_LARGE");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
chunks.push(chunk);
|
|
89
|
+
});
|
|
90
|
+
input.once("error", (error) => spawnFailure(error && error.code));
|
|
91
|
+
input.once("end", () => {
|
|
92
|
+
if (finished) return;
|
|
93
|
+
if (bytes === 0) {
|
|
94
|
+
process.exitCode = 0;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let spec;
|
|
98
|
+
try {
|
|
99
|
+
spec = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
100
|
+
} catch {
|
|
101
|
+
spawnFailure("PROCESS_JOB_LAUNCH_PAYLOAD_INVALID");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!spec || typeof spec.command !== "string" || !Array.isArray(spec.args)
|
|
105
|
+
|| spec.args.some((argument) => typeof argument !== "string")
|
|
106
|
+
|| (spec.cwd !== undefined && typeof spec.cwd !== "string")
|
|
107
|
+
|| !spec.env || typeof spec.env !== "object" || Array.isArray(spec.env)) {
|
|
108
|
+
spawnFailure("PROCESS_JOB_LAUNCH_PAYLOAD_INVALID");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
target = spawn(spec.command, spec.args, {
|
|
113
|
+
cwd: spec.cwd,
|
|
114
|
+
detached: false,
|
|
115
|
+
env: spec.env,
|
|
116
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
spawnFailure(error && error.code);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
forwardOutput(target.stdout, process.stdout);
|
|
123
|
+
forwardOutput(target.stderr, process.stderr);
|
|
124
|
+
target.once("error", (error) => spawnFailure(error && error.code));
|
|
125
|
+
target.once("close", (code, signal) => {
|
|
126
|
+
finish({ code, signal: signal || groupTerminationSignal, spawnError: null });
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
`;
|
|
7
130
|
|
|
8
131
|
/**
|
|
9
132
|
* Run one already-prepared executable without adding a shell.
|
|
@@ -13,7 +136,7 @@ const KILL_GRACE_MS = 1_000;
|
|
|
13
136
|
* or exceeds that cap.
|
|
14
137
|
*
|
|
15
138
|
* @param {{command: string, args?: string[], cwd?: string, env?: Record<string, string|undefined>}} commandSpec
|
|
16
|
-
* @param {{timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number}} [options]
|
|
139
|
+
* @param {{timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number, input?: string|Buffer}} [options]
|
|
17
140
|
*/
|
|
18
141
|
export function runPreparedProcess(
|
|
19
142
|
commandSpec,
|
|
@@ -21,19 +144,84 @@ export function runPreparedProcess(
|
|
|
21
144
|
timeoutMs,
|
|
22
145
|
signal,
|
|
23
146
|
maxBufferBytes = DEFAULT_PROCESS_BUFFER_BYTES,
|
|
147
|
+
input,
|
|
148
|
+
} = {},
|
|
149
|
+
) {
|
|
150
|
+
return startPreparedProcess(commandSpec, {
|
|
151
|
+
timeoutMs,
|
|
152
|
+
signal,
|
|
153
|
+
maxBufferBytes,
|
|
154
|
+
input,
|
|
155
|
+
}).completion;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Start one already-prepared executable and expose its process-group handle.
|
|
160
|
+
*
|
|
161
|
+
* `waitForProcessGroup` is deliberately opt-in so existing foreground tools
|
|
162
|
+
* retain their exact leader/stdio completion semantics. Process jobs enable it
|
|
163
|
+
* through their bound launcher: sandbox cleanup must not run while a detached
|
|
164
|
+
* descendant in the owned group is still alive.
|
|
165
|
+
*
|
|
166
|
+
* @param {{command: string, args?: string[], cwd?: string, env?: Record<string, string|undefined>}} commandSpec
|
|
167
|
+
* @param {{timeoutMs?: number, signal?: AbortSignal, maxBufferBytes?: number, input?: string|Buffer, waitForProcessGroup?: boolean, exactEnvironment?: boolean, onStdout?: (chunk: Buffer) => void, onStderr?: (chunk: Buffer) => void}} [options]
|
|
168
|
+
* For process jobs, `release()` is the persistence fence: the target cannot
|
|
169
|
+
* spawn until the host has durably recorded the returned ownership metadata.
|
|
170
|
+
* Foreground handles expose a harmless no-op release for one structural shape.
|
|
171
|
+
*
|
|
172
|
+
* @returns {{pid: number|null, pgid: number|null, startedAt: string, completion: Promise<any>, release: () => Promise<void>, cancel: () => void}}
|
|
173
|
+
*/
|
|
174
|
+
export function startPreparedProcess(
|
|
175
|
+
commandSpec,
|
|
176
|
+
{
|
|
177
|
+
timeoutMs,
|
|
178
|
+
signal,
|
|
179
|
+
maxBufferBytes = DEFAULT_PROCESS_BUFFER_BYTES,
|
|
180
|
+
input,
|
|
181
|
+
waitForProcessGroup = false,
|
|
182
|
+
exactEnvironment = false,
|
|
183
|
+
onStdout,
|
|
184
|
+
onStderr,
|
|
24
185
|
} = {},
|
|
25
186
|
) {
|
|
26
187
|
const startedAt = Date.now();
|
|
27
|
-
|
|
28
|
-
|
|
188
|
+
const gated = waitForProcessGroup && process.platform !== "win32";
|
|
189
|
+
const targetEnvironment = exactEnvironment
|
|
190
|
+
? exactProcessEnv(commandSpec.env)
|
|
191
|
+
: commandSpec.env
|
|
192
|
+
? mergedProcessEnv(commandSpec.env)
|
|
193
|
+
: { ...process.env };
|
|
194
|
+
const launchPayload = gated
|
|
195
|
+
? Buffer.from(JSON.stringify({
|
|
196
|
+
command: commandSpec.command,
|
|
197
|
+
args: commandSpec.args || [],
|
|
198
|
+
...(commandSpec.cwd === undefined ? {} : { cwd: commandSpec.cwd }),
|
|
199
|
+
env: targetEnvironment,
|
|
200
|
+
}), "utf8")
|
|
201
|
+
: null;
|
|
202
|
+
if (launchPayload !== null && launchPayload.byteLength > PROCESS_JOB_LAUNCH_PAYLOAD_BYTES) {
|
|
203
|
+
throw new RangeError("Process-job launch payload exceeds the safe in-memory gate limit.");
|
|
204
|
+
}
|
|
205
|
+
/** @type {import("node:child_process").ChildProcess|null} */
|
|
206
|
+
let child = null;
|
|
207
|
+
let cancel = () => {};
|
|
208
|
+
let release = async () => {};
|
|
209
|
+
const completion = new Promise((resolve) => {
|
|
29
210
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
211
|
+
child = spawn(
|
|
212
|
+
gated ? process.execPath : commandSpec.command,
|
|
213
|
+
gated
|
|
214
|
+
? ["--input-type=commonjs", "--eval", PROCESS_JOB_LAUNCH_GATE_SOURCE]
|
|
215
|
+
: (commandSpec.args || []),
|
|
216
|
+
{
|
|
217
|
+
...(gated ? {} : { cwd: commandSpec.cwd }),
|
|
33
218
|
detached: process.platform !== "win32",
|
|
34
|
-
env,
|
|
35
|
-
stdio:
|
|
36
|
-
|
|
219
|
+
env: gated ? {} : targetEnvironment,
|
|
220
|
+
stdio: gated
|
|
221
|
+
? ["ignore", "pipe", "pipe", "pipe", "pipe"]
|
|
222
|
+
: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
223
|
+
},
|
|
224
|
+
);
|
|
37
225
|
} catch (error) {
|
|
38
226
|
resolve({
|
|
39
227
|
code: null,
|
|
@@ -52,6 +240,11 @@ export function runPreparedProcess(
|
|
|
52
240
|
return;
|
|
53
241
|
}
|
|
54
242
|
|
|
243
|
+
if (!gated && input !== undefined) {
|
|
244
|
+
child.stdin?.on("error", () => undefined);
|
|
245
|
+
child.stdin?.end(input);
|
|
246
|
+
}
|
|
247
|
+
|
|
55
248
|
const stdout = [];
|
|
56
249
|
const stderr = [];
|
|
57
250
|
const state = {
|
|
@@ -63,20 +256,189 @@ export function runPreparedProcess(
|
|
|
63
256
|
timedOut: false,
|
|
64
257
|
truncated: false,
|
|
65
258
|
};
|
|
259
|
+
let targetStatus = null;
|
|
260
|
+
let statusInvalid = false;
|
|
261
|
+
const statusChunks = [];
|
|
262
|
+
let statusBytes = 0;
|
|
66
263
|
let killTimer = null;
|
|
264
|
+
let killExitTimer = null;
|
|
265
|
+
let groupProbeTimer = null;
|
|
67
266
|
let timeoutTimer = null;
|
|
68
267
|
let settled = false;
|
|
268
|
+
let childClosed = false;
|
|
269
|
+
let closeCode = null;
|
|
270
|
+
let closeSignal = null;
|
|
271
|
+
let groupAbsentObserved = false;
|
|
272
|
+
let terminationRequested = false;
|
|
273
|
+
let terminationFailure = null;
|
|
274
|
+
const ownedGroup = gated && child.pid ? createOwnedProcessGroupAttestation(child.pid) : null;
|
|
275
|
+
|
|
276
|
+
const finish = (groupExitConfirmed = true) => {
|
|
277
|
+
if (settled) return;
|
|
278
|
+
settled = true;
|
|
279
|
+
if (timeoutTimer !== null) clearTimeout(timeoutTimer);
|
|
280
|
+
if (killTimer !== null) clearTimeout(killTimer);
|
|
281
|
+
if (killExitTimer !== null) clearTimeout(killExitTimer);
|
|
282
|
+
if (groupProbeTimer !== null) clearTimeout(groupProbeTimer);
|
|
283
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
284
|
+
if (!childClosed) {
|
|
285
|
+
child?.stdout?.destroy();
|
|
286
|
+
child?.stderr?.destroy();
|
|
287
|
+
for (const stream of child?.stdio ?? []) stream?.destroy?.();
|
|
288
|
+
child?.unref?.();
|
|
289
|
+
}
|
|
290
|
+
const statusSpawnError = targetStatus?.spawnError
|
|
291
|
+
? processSpawnError(targetStatus.spawnError)
|
|
292
|
+
: null;
|
|
293
|
+
const missingTargetStatus = gated
|
|
294
|
+
&& targetStatus === null
|
|
295
|
+
&& closeSignal === null
|
|
296
|
+
&& closeCode !== 0
|
|
297
|
+
&& !state.timedOut
|
|
298
|
+
&& !state.bufferExceeded;
|
|
299
|
+
resolve({
|
|
300
|
+
code: targetStatus?.code ?? closeCode,
|
|
301
|
+
signal: targetStatus?.signal ?? closeSignal,
|
|
302
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
303
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
304
|
+
...state,
|
|
305
|
+
spawnError: state.spawnError
|
|
306
|
+
?? statusSpawnError
|
|
307
|
+
?? (terminationFailure !== null
|
|
308
|
+
? new Error(terminationFailure)
|
|
309
|
+
: statusInvalid || missingTargetStatus
|
|
310
|
+
? new Error("The process-job launch gate exited without a valid target result.")
|
|
311
|
+
: null),
|
|
312
|
+
groupExitConfirmed,
|
|
313
|
+
durationMs: Date.now() - startedAt,
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const probeOwnedGroup = () => {
|
|
318
|
+
groupProbeTimer = null;
|
|
319
|
+
if (settled || !child?.pid || !waitForProcessGroup || process.platform === "win32") return;
|
|
320
|
+
// A definitely live, unreaped self-led ChildProcess prevents its numeric
|
|
321
|
+
// PGID from being reused. Start continuity polling only after that exact
|
|
322
|
+
// leader reports exit; long-running jobs need no periodic re-attestation.
|
|
323
|
+
if (ownedGroup !== null && !ownedGroup.leaderExited) return;
|
|
324
|
+
const presence = ownedProcessGroupPresence(child.pid);
|
|
325
|
+
if (presence === "absent") {
|
|
326
|
+
groupAbsentObserved = true;
|
|
327
|
+
if (ownedGroup !== null) ownedGroup.ownershipLost = true;
|
|
328
|
+
if (childClosed) finish(true);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (ownedGroup !== null) {
|
|
332
|
+
if (presence === "present") {
|
|
333
|
+
const observedAt = Date.now();
|
|
334
|
+
if (ownedGroup.lastObservedAt === null
|
|
335
|
+
|| observedAt - ownedGroup.lastObservedAt > PROCESS_GROUP_REATTEST_MAX_GAP_MS) {
|
|
336
|
+
// A missed observation window can hide group disappearance and
|
|
337
|
+
// numeric reuse. Once continuity is lost, later presence can never
|
|
338
|
+
// restore authority over that PGID.
|
|
339
|
+
ownedGroup.ownershipLost = true;
|
|
340
|
+
} else if (!ownedGroup.ownershipLost) {
|
|
341
|
+
if (ownedGroup.leaderExited) ownedGroup.observedAfterLeaderExit = true;
|
|
342
|
+
ownedGroup.lastObservedAt = observedAt;
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
// An indeterminate probe cannot extend numeric PGID authority.
|
|
346
|
+
ownedGroup.ownershipLost = true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
groupProbeTimer = setTimeout(probeOwnedGroup, PROCESS_GROUP_REATTEST_POLL_MS);
|
|
350
|
+
groupProbeTimer.unref?.();
|
|
351
|
+
};
|
|
69
352
|
|
|
70
353
|
function terminate() {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
354
|
+
if (settled || terminationRequested) return;
|
|
355
|
+
terminationRequested = true;
|
|
356
|
+
const term = signalAttestedProcessGroup(child, ownedGroup, "SIGTERM");
|
|
357
|
+
if (term === "absent") {
|
|
358
|
+
finish(true);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (term === "unproven") {
|
|
362
|
+
terminationFailure = "Owned process-group termination was withheld because its surviving identity could not be re-attested.";
|
|
75
363
|
}
|
|
364
|
+
killTimer = setTimeout(() => {
|
|
365
|
+
const kill = signalAttestedProcessGroup(child, ownedGroup, "SIGKILL");
|
|
366
|
+
if (kill === "absent") {
|
|
367
|
+
finish(true);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (kill === "unproven") {
|
|
371
|
+
terminationFailure = "Owned process-group escalation was withheld because its surviving identity could not be re-attested.";
|
|
372
|
+
}
|
|
373
|
+
// SIGKILL should make the group disappear promptly, but a kernel/OS
|
|
374
|
+
// anomaly or lost ownership proof must still settle explicitly. The
|
|
375
|
+
// caller can then withhold sandbox cleanup instead of hanging forever.
|
|
376
|
+
killExitTimer = setTimeout(() => {
|
|
377
|
+
const absent = child?.pid
|
|
378
|
+
? ownedProcessGroupPresence(child.pid) === "absent"
|
|
379
|
+
: true;
|
|
380
|
+
if (!absent && terminationFailure === null) {
|
|
381
|
+
terminationFailure = "Owned process-group exit could not be confirmed after SIGKILL.";
|
|
382
|
+
}
|
|
383
|
+
finish(absent);
|
|
384
|
+
}, KILL_EXIT_CONFIRM_MS);
|
|
385
|
+
killExitTimer.unref?.();
|
|
386
|
+
}, KILL_GRACE_MS);
|
|
387
|
+
killTimer.unref?.();
|
|
76
388
|
}
|
|
389
|
+
cancel = terminate;
|
|
77
390
|
|
|
78
|
-
|
|
391
|
+
if (gated) {
|
|
392
|
+
const gate = /** @type {import("node:stream").Writable|undefined} */ (child.stdio?.[3]);
|
|
393
|
+
const status = /** @type {import("node:stream").Readable|undefined} */ (child.stdio?.[4]);
|
|
394
|
+
let releasePromise;
|
|
395
|
+
release = async () => {
|
|
396
|
+
releasePromise ??= new Promise((resolveRelease, rejectRelease) => {
|
|
397
|
+
if (!gate || typeof gate.end !== "function" || launchPayload === null) {
|
|
398
|
+
rejectRelease(new Error("Process-job launch gate is unavailable."));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
let released = false;
|
|
402
|
+
const rejectOnce = () => {
|
|
403
|
+
if (released) return;
|
|
404
|
+
released = true;
|
|
405
|
+
rejectRelease(new Error("Process-job launch gate closed before release."));
|
|
406
|
+
};
|
|
407
|
+
gate.once("error", rejectOnce);
|
|
408
|
+
gate.end(launchPayload, () => {
|
|
409
|
+
if (released) return;
|
|
410
|
+
released = true;
|
|
411
|
+
gate.off("error", rejectOnce);
|
|
412
|
+
resolveRelease();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
await releasePromise;
|
|
416
|
+
};
|
|
417
|
+
status?.on("data", (chunk) => {
|
|
418
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
419
|
+
statusBytes += buffer.length;
|
|
420
|
+
if (statusBytes > PROCESS_JOB_STATUS_BYTES) {
|
|
421
|
+
statusInvalid = true;
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
statusChunks.push(buffer);
|
|
425
|
+
});
|
|
426
|
+
status?.once("error", () => { statusInvalid = true; });
|
|
427
|
+
status?.once("end", () => {
|
|
428
|
+
if (statusInvalid || statusBytes === 0) return;
|
|
429
|
+
try {
|
|
430
|
+
const parsed = JSON.parse(Buffer.concat(statusChunks).toString("utf8"));
|
|
431
|
+
if (validTargetStatus(parsed)) targetStatus = parsed;
|
|
432
|
+
else statusInvalid = true;
|
|
433
|
+
} catch {
|
|
434
|
+
statusInvalid = true;
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function append(target, chunk, observe) {
|
|
79
440
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
441
|
+
try { observe?.(buffer); } catch { /* observers cannot break process ownership */ }
|
|
80
442
|
state.bytes += buffer.length;
|
|
81
443
|
const remaining = Math.max(0, maxBufferBytes - state.storedBytes);
|
|
82
444
|
if (remaining > 0) {
|
|
@@ -106,27 +468,157 @@ export function runPreparedProcess(
|
|
|
106
468
|
if (signal?.aborted) onAbort();
|
|
107
469
|
else signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
108
470
|
|
|
109
|
-
child.stdout?.on("data", (chunk) => append(stdout, chunk));
|
|
110
|
-
child.stderr?.on("data", (chunk) => append(stderr, chunk));
|
|
471
|
+
child.stdout?.on("data", (chunk) => append(stdout, chunk, onStdout));
|
|
472
|
+
child.stderr?.on("data", (chunk) => append(stderr, chunk, onStderr));
|
|
111
473
|
child.once("error", (error) => {
|
|
112
474
|
state.spawnError = error;
|
|
113
475
|
});
|
|
114
|
-
|
|
476
|
+
// Refresh the continuity proof at the kernel exit notification, not
|
|
477
|
+
// `close`: inherited stdio can delay `close` while an in-group descendant
|
|
478
|
+
// survives.
|
|
479
|
+
child.once("exit", (code, childExitSignal) => {
|
|
480
|
+
closeCode = code;
|
|
481
|
+
closeSignal = childExitSignal;
|
|
482
|
+
if (ownedGroup !== null) {
|
|
483
|
+
ownedGroup.leaderExited = true;
|
|
484
|
+
// The reuse hazard begins only when the exact self-led ChildProcess
|
|
485
|
+
// exits. Spawn-to-exit event-loop gaps cannot revoke live authority.
|
|
486
|
+
ownedGroup.lastObservedAt = Date.now();
|
|
487
|
+
}
|
|
488
|
+
if (groupProbeTimer !== null) clearTimeout(groupProbeTimer);
|
|
489
|
+
groupProbeTimer = null;
|
|
490
|
+
probeOwnedGroup();
|
|
491
|
+
});
|
|
492
|
+
child.once("close", (code, childCloseSignal) => {
|
|
115
493
|
if (settled) return;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
...state,
|
|
126
|
-
durationMs: Date.now() - startedAt,
|
|
127
|
-
});
|
|
494
|
+
childClosed = true;
|
|
495
|
+
closeCode = code;
|
|
496
|
+
closeSignal = childCloseSignal;
|
|
497
|
+
if (!waitForProcessGroup || process.platform === "win32" || !child?.pid) {
|
|
498
|
+
finish(true);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (groupAbsentObserved) finish(true);
|
|
502
|
+
else if (groupProbeTimer === null) probeOwnedGroup();
|
|
128
503
|
});
|
|
129
504
|
});
|
|
505
|
+
return {
|
|
506
|
+
pid: child?.pid ?? null,
|
|
507
|
+
pgid: process.platform === "win32" ? null : (child?.pid ?? null),
|
|
508
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
509
|
+
completion,
|
|
510
|
+
release: () => release(),
|
|
511
|
+
cancel: () => cancel(),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function validTargetStatus(value) {
|
|
516
|
+
if (!value || typeof value !== "object") return false;
|
|
517
|
+
if (value.code !== null && !Number.isInteger(value.code)) return false;
|
|
518
|
+
if (value.signal !== null && typeof value.signal !== "string") return false;
|
|
519
|
+
if (value.spawnError === null) return true;
|
|
520
|
+
return typeof value.spawnError === "object"
|
|
521
|
+
&& typeof value.spawnError.message === "string"
|
|
522
|
+
&& (value.spawnError.code === undefined || typeof value.spawnError.code === "string");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function processSpawnError(value) {
|
|
526
|
+
return Object.assign(
|
|
527
|
+
new Error(value.message),
|
|
528
|
+
typeof value.code === "string" ? { code: value.code } : {},
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function createOwnedProcessGroupAttestation(pgid) {
|
|
533
|
+
return {
|
|
534
|
+
pgid,
|
|
535
|
+
leaderExited: false,
|
|
536
|
+
observedAfterLeaderExit: false,
|
|
537
|
+
ownershipLost: false,
|
|
538
|
+
/** @type {number|null} */
|
|
539
|
+
lastObservedAt: null,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function reattestOwnedProcessGroup(attestation) {
|
|
544
|
+
if (attestation.ownershipLost
|
|
545
|
+
|| !attestation.leaderExited
|
|
546
|
+
|| !attestation.observedAfterLeaderExit
|
|
547
|
+
|| attestation.lastObservedAt === null) return false;
|
|
548
|
+
if (Date.now() - attestation.lastObservedAt > PROCESS_GROUP_REATTEST_MAX_GAP_MS) {
|
|
549
|
+
attestation.ownershipLost = true;
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
const presence = ownedProcessGroupPresence(attestation.pgid);
|
|
553
|
+
if (presence === "absent") {
|
|
554
|
+
attestation.ownershipLost = true;
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
if (presence !== "present") {
|
|
558
|
+
// An indeterminate observation is itself a continuity gap. A later
|
|
559
|
+
// successful probe must never restore authority over the numeric PGID.
|
|
560
|
+
attestation.ownershipLost = true;
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
// POSIX cannot reuse the numeric PGID while any member of that exact group
|
|
564
|
+
// survives. Continuous polling begins when the exact detached leader exits
|
|
565
|
+
// and permanently revokes authority on an observation gap or the first
|
|
566
|
+
// observed absence; a later group using the number is never signalled.
|
|
567
|
+
attestation.observedAfterLeaderExit = true;
|
|
568
|
+
attestation.lastObservedAt = Date.now();
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function signalAttestedProcessGroup(child, attestation, signal) {
|
|
573
|
+
if (!child?.pid) return "absent";
|
|
574
|
+
// While the exact self-led ChildProcess is definitely live and unreaped,
|
|
575
|
+
// POSIX cannot recycle its PID/PGID. Event-loop stalls do not weaken that
|
|
576
|
+
// kernel-backed authority, so signal the negative PGID directly.
|
|
577
|
+
if (!processLeaderExited(child)) {
|
|
578
|
+
try {
|
|
579
|
+
process.kill(process.platform === "win32" ? child.pid : -child.pid, signal);
|
|
580
|
+
return "signalled";
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (error?.code === "ESRCH") return "absent";
|
|
583
|
+
return "unproven";
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (process.platform === "win32") return "absent";
|
|
587
|
+
if (attestation === null || !attestation.leaderExited) return "unproven";
|
|
588
|
+
const continuityExpired = attestation.lastObservedAt === null
|
|
589
|
+
|| Date.now() - attestation.lastObservedAt > PROCESS_GROUP_REATTEST_MAX_GAP_MS;
|
|
590
|
+
if (continuityExpired) attestation.ownershipLost = true;
|
|
591
|
+
if (attestation.ownershipLost) {
|
|
592
|
+
// After leader exit, a lost observation window can hide disappearance and
|
|
593
|
+
// numeric reuse. Only observed absence can now confirm completion.
|
|
594
|
+
return ownedProcessGroupPresence(attestation.pgid) === "absent"
|
|
595
|
+
? "absent"
|
|
596
|
+
: "unproven";
|
|
597
|
+
}
|
|
598
|
+
const presence = ownedProcessGroupPresence(child.pid);
|
|
599
|
+
if (presence === "absent") return "absent";
|
|
600
|
+
if (presence !== "present") {
|
|
601
|
+
if (attestation !== null) attestation.ownershipLost = true;
|
|
602
|
+
return "unproven";
|
|
603
|
+
}
|
|
604
|
+
if (!reattestOwnedProcessGroup(attestation)) return "unproven";
|
|
605
|
+
try {
|
|
606
|
+
process.kill(-child.pid, signal);
|
|
607
|
+
return "signalled";
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (error?.code === "ESRCH") return "absent";
|
|
610
|
+
return "unproven";
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function ownedProcessGroupPresence(pgid) {
|
|
615
|
+
try {
|
|
616
|
+
process.kill(-pgid, 0);
|
|
617
|
+
return "present";
|
|
618
|
+
} catch (error) {
|
|
619
|
+
if (error?.code === "ESRCH") return "absent";
|
|
620
|
+
return "unknown";
|
|
621
|
+
}
|
|
130
622
|
}
|
|
131
623
|
|
|
132
624
|
function mergedProcessEnv(overrides) {
|
|
@@ -138,19 +630,38 @@ function mergedProcessEnv(overrides) {
|
|
|
138
630
|
return env;
|
|
139
631
|
}
|
|
140
632
|
|
|
633
|
+
function exactProcessEnv(values = {}) {
|
|
634
|
+
/** @type {Record<string, string>} */
|
|
635
|
+
const env = {};
|
|
636
|
+
for (const [key, value] of Object.entries(values)) {
|
|
637
|
+
if (value !== undefined) env[key] = value;
|
|
638
|
+
}
|
|
639
|
+
return env;
|
|
640
|
+
}
|
|
641
|
+
|
|
141
642
|
/**
|
|
142
643
|
* @param {import("node:child_process").ChildProcess} child
|
|
143
644
|
* @param {NodeJS.Signals} signal
|
|
645
|
+
* @param {{fallbackToChildPid?: boolean}} [options]
|
|
144
646
|
*/
|
|
145
|
-
export function killProcessGroup(child, signal) {
|
|
647
|
+
export function killProcessGroup(child, signal, { fallbackToChildPid = false } = {}) {
|
|
146
648
|
if (!child?.pid) return;
|
|
147
649
|
try {
|
|
148
650
|
process.kill(process.platform === "win32" ? child.pid : -child.pid, signal);
|
|
149
651
|
} catch {
|
|
652
|
+
// Node REPL owns an exact, still-live ChildProcess and opts into the
|
|
653
|
+
// legacy single-PID fallback. Attested process jobs never use this helper.
|
|
654
|
+
if (!fallbackToChildPid || process.platform === "win32" || processLeaderExited(child)) return;
|
|
150
655
|
try { process.kill(child.pid, signal); } catch { /* already gone */ }
|
|
151
656
|
}
|
|
152
657
|
}
|
|
153
658
|
|
|
659
|
+
function processLeaderExited(child) {
|
|
660
|
+
return !child?.pid
|
|
661
|
+
|| (child.exitCode !== null && child.exitCode !== undefined)
|
|
662
|
+
|| (child.signalCode !== null && child.signalCode !== undefined);
|
|
663
|
+
}
|
|
664
|
+
|
|
154
665
|
/**
|
|
155
666
|
* @param {{stdout?: string, stderr?: string}} result
|
|
156
667
|
*/
|