@agent-device/proxy 0.0.0-stage → 0.21.23
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/LICENSE +21 -0
- package/README.md +107 -2
- package/dist/errors.mjs +304 -0
- package/dist/index.d.mts +36 -0
- package/dist/index.mjs +463 -0
- package/dist/node-http.mjs +68 -0
- package/dist/process.mjs +1147 -0
- package/dist/version.mjs +179 -0
- package/package.json +32 -3
package/dist/process.mjs
ADDED
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
import { i as redactDiagnosticData, n as createRequestCanceledError, t as AppError } from "./errors.mjs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
//#region ../host-kit/src/internal/diagnostics.ts
|
|
10
|
+
const diagnosticsStorage = new AsyncLocalStorage();
|
|
11
|
+
function updateDiagnosticsScope(options) {
|
|
12
|
+
const scope = diagnosticsStorage.getStore();
|
|
13
|
+
if (!scope) return;
|
|
14
|
+
Object.assign(scope, options);
|
|
15
|
+
}
|
|
16
|
+
function getDiagnosticsMeta() {
|
|
17
|
+
const scope = diagnosticsStorage.getStore();
|
|
18
|
+
if (!scope) return {};
|
|
19
|
+
const { diagnosticId, requestId, session, command, debug, flushOnSuccess } = scope;
|
|
20
|
+
return {
|
|
21
|
+
diagnosticId,
|
|
22
|
+
requestId,
|
|
23
|
+
session,
|
|
24
|
+
command,
|
|
25
|
+
debug,
|
|
26
|
+
flushOnSuccess
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function emitDiagnostic(event) {
|
|
30
|
+
const scope = diagnosticsStorage.getStore();
|
|
31
|
+
if (!scope) return;
|
|
32
|
+
const payload = {
|
|
33
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
34
|
+
level: event.level ?? "info",
|
|
35
|
+
phase: event.phase,
|
|
36
|
+
session: scope.session,
|
|
37
|
+
requestId: scope.requestId,
|
|
38
|
+
command: scope.command,
|
|
39
|
+
durationMs: event.durationMs,
|
|
40
|
+
data: event.data ? redactScopeData(scope, event.data) : void 0
|
|
41
|
+
};
|
|
42
|
+
scope.events.push(payload);
|
|
43
|
+
scope.phaseCounts.set(event.phase, (scope.phaseCounts.get(event.phase) ?? 0) + 1);
|
|
44
|
+
if (!scope.debug && !scope.traceLogPath) return;
|
|
45
|
+
const fileLine = `${JSON.stringify(payload)}\n`;
|
|
46
|
+
try {
|
|
47
|
+
if (scope.debug && scope.logPath) {
|
|
48
|
+
appendDiagnosticLine(scope, scope.logPath, fileLine);
|
|
49
|
+
scope.liveWrittenEventCount = scope.events.length;
|
|
50
|
+
}
|
|
51
|
+
if (scope.traceLogPath) appendDiagnosticLine(scope, scope.traceLogPath, fileLine);
|
|
52
|
+
if (scope.debug && !scope.logPath && !scope.traceLogPath) process.stderr.write(`[agent-device][diag] ${fileLine}`);
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
/** Longest-first so an overlapping shorter literal cannot truncate a longer one. */
|
|
56
|
+
function sortedScopeSensitiveValues(scope) {
|
|
57
|
+
scope.sortedSensitiveValues ??= [...scope.sensitiveValues].sort((left, right) => right.length - left.length);
|
|
58
|
+
return scope.sortedSensitiveValues;
|
|
59
|
+
}
|
|
60
|
+
function redactScopeData(scope, input) {
|
|
61
|
+
return replaceSensitiveValues(redactDiagnosticData(input), sortedScopeSensitiveValues(scope));
|
|
62
|
+
}
|
|
63
|
+
function replaceSensitiveValues(value, sensitiveValues) {
|
|
64
|
+
if (typeof value === "string") return sensitiveValues.reduce((output, sensitive) => output.replaceAll(sensitive, "[REDACTED]"), value);
|
|
65
|
+
if (Array.isArray(value)) return value.map((entry) => replaceSensitiveValues(entry, sensitiveValues));
|
|
66
|
+
if (!value || typeof value !== "object") return value;
|
|
67
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, replaceSensitiveValues(entry, sensitiveValues)]));
|
|
68
|
+
}
|
|
69
|
+
function appendDiagnosticLine(scope, logPath, line) {
|
|
70
|
+
const dir = path.dirname(logPath);
|
|
71
|
+
if (!scope.ensuredDirectories.has(dir)) {
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
73
|
+
scope.ensuredDirectories.add(dir);
|
|
74
|
+
}
|
|
75
|
+
fs.appendFileSync(logPath, line, "utf8");
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region ../kernel/src/source-value.ts
|
|
79
|
+
const BOOLEAN_TRUE_VALUES = /* @__PURE__ */ new Set([
|
|
80
|
+
"1",
|
|
81
|
+
"true",
|
|
82
|
+
"yes",
|
|
83
|
+
"on"
|
|
84
|
+
]);
|
|
85
|
+
const BOOLEAN_FALSE_VALUES = /* @__PURE__ */ new Set([
|
|
86
|
+
"0",
|
|
87
|
+
"false",
|
|
88
|
+
"no",
|
|
89
|
+
"off"
|
|
90
|
+
]);
|
|
91
|
+
function parseBooleanLiteral(value) {
|
|
92
|
+
const normalized = value.trim().toLowerCase();
|
|
93
|
+
if (BOOLEAN_TRUE_VALUES.has(normalized)) return true;
|
|
94
|
+
if (BOOLEAN_FALSE_VALUES.has(normalized)) return false;
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region ../host-kit/src/internal/exec.ts
|
|
98
|
+
const BARE_COMMAND_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
99
|
+
const EXEC_DIAGNOSTIC_ARG_LIMIT = 6;
|
|
100
|
+
const commandExecutorOverrideScope = new AsyncLocalStorage();
|
|
101
|
+
const requestCommandEnvScope = new AsyncLocalStorage();
|
|
102
|
+
/** Effective toolchain selection; an explicitly empty request value selects `xcode-select`. */
|
|
103
|
+
function commandDeveloperDir() {
|
|
104
|
+
const requestEnv = requestCommandEnvScope.getStore();
|
|
105
|
+
if (requestEnv && Object.hasOwn(requestEnv, "DEVELOPER_DIR")) return requestEnv.DEVELOPER_DIR || void 0;
|
|
106
|
+
return process.env.DEVELOPER_DIR || void 0;
|
|
107
|
+
}
|
|
108
|
+
function resolveSpawnEnv(env) {
|
|
109
|
+
const requestEnv = requestCommandEnvScope.getStore();
|
|
110
|
+
if (!requestEnv) return env;
|
|
111
|
+
return {
|
|
112
|
+
...env ?? process.env,
|
|
113
|
+
...requestEnv
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function runCmd(cmd, args, options = {}) {
|
|
117
|
+
const overrideResult = commandExecutorOverrideScope.getStore()?.(cmd, args, options);
|
|
118
|
+
if (overrideResult) return coerceExecResult(await overrideResult);
|
|
119
|
+
return await runSpawnedCommand(cmd, args, options);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Normalize an exec result produced outside this module. Tool providers,
|
|
123
|
+
* executor overrides, and SDK-supplied adb executors are plain callbacks whose
|
|
124
|
+
* results cross an unchecked boundary; coercing once here lets downstream code
|
|
125
|
+
* trust the ExecResult types instead of re-wrapping fields defensively at every
|
|
126
|
+
* use. A non-number exitCode coerces to 1 — the same failure branch such a
|
|
127
|
+
* result already landed in at every `exitCode !== 0` guard.
|
|
128
|
+
*/
|
|
129
|
+
function coerceExecResult(result) {
|
|
130
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : String(result.stdout ?? "");
|
|
131
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : String(result.stderr ?? "");
|
|
132
|
+
const exitCode = typeof result.exitCode === "number" ? result.exitCode : 1;
|
|
133
|
+
if (stdout === result.stdout && stderr === result.stderr && exitCode === result.exitCode) return result;
|
|
134
|
+
return {
|
|
135
|
+
...result,
|
|
136
|
+
stdout,
|
|
137
|
+
stderr,
|
|
138
|
+
exitCode
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function runSpawnedCommand(cmd, args, options = {}) {
|
|
142
|
+
const executable = normalizeExecutableCommand(cmd);
|
|
143
|
+
const execTrace = createExecTraceContext();
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const child = spawn(executable, args, {
|
|
146
|
+
cwd: options.cwd,
|
|
147
|
+
env: resolveSpawnEnv(options.env),
|
|
148
|
+
stdio: [
|
|
149
|
+
"pipe",
|
|
150
|
+
"pipe",
|
|
151
|
+
"pipe"
|
|
152
|
+
],
|
|
153
|
+
detached: options.detached,
|
|
154
|
+
windowsHide: true,
|
|
155
|
+
shell: false
|
|
156
|
+
});
|
|
157
|
+
options.onSpawn?.(child);
|
|
158
|
+
let stdout = "";
|
|
159
|
+
const stdoutChunks = options.binaryStdout ? [] : void 0;
|
|
160
|
+
let stderr = "";
|
|
161
|
+
let didTimeout = false;
|
|
162
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
163
|
+
let timeoutHandle = null;
|
|
164
|
+
let settled = false;
|
|
165
|
+
function finish() {
|
|
166
|
+
if (settled) return false;
|
|
167
|
+
settled = true;
|
|
168
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
169
|
+
abort.dispose();
|
|
170
|
+
destroyCommandStreams(child);
|
|
171
|
+
execTrace.emitForegroundCompletion(cmd, args);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
function fail(error) {
|
|
175
|
+
if (finish()) reject(error);
|
|
176
|
+
}
|
|
177
|
+
function settle(code) {
|
|
178
|
+
if (!finish()) return;
|
|
179
|
+
const finalExitCode = code ?? 1;
|
|
180
|
+
if (!abort.didAbort && didTimeout && timeoutMs) {
|
|
181
|
+
reject(createTimeoutError(executable, cmd, args, timeoutMs, finalExitCode, stdout, stderr));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const failure = commandCloseFailure(abort, executable, cmd, args, finalExitCode, options.allowFailure, stdout, stderr);
|
|
185
|
+
if (failure) {
|
|
186
|
+
reject(failure);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
resolve({
|
|
190
|
+
stdout,
|
|
191
|
+
stderr,
|
|
192
|
+
exitCode: finalExitCode,
|
|
193
|
+
stdoutBuffer: stdoutChunks ? Buffer.concat(stdoutChunks) : void 0
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const settlement = createCommandKillSettlement({
|
|
197
|
+
killProcessTree: () => killProcessTree(child, options),
|
|
198
|
+
settle
|
|
199
|
+
});
|
|
200
|
+
const abort = watchCommandAbort(options, settlement.requestKill);
|
|
201
|
+
timeoutHandle = timeoutMs ? setTimeout(() => {
|
|
202
|
+
didTimeout = true;
|
|
203
|
+
settlement.requestKill();
|
|
204
|
+
}, timeoutMs) : null;
|
|
205
|
+
if (!options.binaryStdout) child.stdout.setEncoding("utf8");
|
|
206
|
+
child.stderr.setEncoding("utf8");
|
|
207
|
+
writeChildStdin(child, options.stdin).catch((error) => {
|
|
208
|
+
if (abort.didAbort || didTimeout) return;
|
|
209
|
+
if (isEpipeError(error)) return;
|
|
210
|
+
fail(createStdinError(executable, cmd, args, error));
|
|
211
|
+
killProcessTree(child, options);
|
|
212
|
+
});
|
|
213
|
+
child.stdout.on("data", (chunk) => {
|
|
214
|
+
if (options.binaryStdout) {
|
|
215
|
+
stdoutChunks?.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const text = String(chunk);
|
|
219
|
+
stdout += text;
|
|
220
|
+
options.onStdoutChunk?.(text);
|
|
221
|
+
});
|
|
222
|
+
child.stderr.on("data", (chunk) => {
|
|
223
|
+
const text = String(chunk);
|
|
224
|
+
stderr += text;
|
|
225
|
+
options.onStderrChunk?.(text);
|
|
226
|
+
});
|
|
227
|
+
child.on("error", (err) => {
|
|
228
|
+
fail(spawnRejectionError(abort, executable, cmd, args, err));
|
|
229
|
+
});
|
|
230
|
+
child.once("exit", settlement.recordExit);
|
|
231
|
+
child.once("close", settle);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function runCmdSync(cmd, args, options = {}) {
|
|
235
|
+
const executable = normalizeExecutableCommand(cmd);
|
|
236
|
+
const result = spawnSync(executable, args, {
|
|
237
|
+
cwd: options.cwd,
|
|
238
|
+
env: resolveSpawnEnv(options.env),
|
|
239
|
+
stdio: [
|
|
240
|
+
"pipe",
|
|
241
|
+
"pipe",
|
|
242
|
+
"pipe"
|
|
243
|
+
],
|
|
244
|
+
encoding: options.binaryStdout ? void 0 : "utf8",
|
|
245
|
+
input: options.stdin,
|
|
246
|
+
timeout: normalizeTimeoutMs(options.timeoutMs),
|
|
247
|
+
windowsHide: true,
|
|
248
|
+
shell: false,
|
|
249
|
+
...options.maxBuffer !== void 0 ? { maxBuffer: options.maxBuffer } : {}
|
|
250
|
+
});
|
|
251
|
+
if (result.error) {
|
|
252
|
+
const code = result.error.code;
|
|
253
|
+
if (code === "ETIMEDOUT") throw new AppError("COMMAND_FAILED", `${executable} timed out after ${normalizeTimeoutMs(options.timeoutMs)}ms`, {
|
|
254
|
+
cmd,
|
|
255
|
+
args,
|
|
256
|
+
timeoutMs: normalizeTimeoutMs(options.timeoutMs)
|
|
257
|
+
}, result.error);
|
|
258
|
+
if (code === "ENOENT") throw createMissingToolError(executable, cmd, result.error);
|
|
259
|
+
throw createCommandFailedError(executable, cmd, args, result.error);
|
|
260
|
+
}
|
|
261
|
+
const stdoutBuffer = options.binaryStdout ? Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "") : void 0;
|
|
262
|
+
const stdout = options.binaryStdout ? "" : typeof result.stdout === "string" ? result.stdout : (result.stdout ?? "").toString();
|
|
263
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : (result.stderr ?? "").toString();
|
|
264
|
+
const exitCode = result.status ?? 1;
|
|
265
|
+
if (exitCode !== 0 && !options.allowFailure) throw createExitError(executable, cmd, args, exitCode, stdout, stderr);
|
|
266
|
+
return {
|
|
267
|
+
stdout,
|
|
268
|
+
stderr,
|
|
269
|
+
exitCode,
|
|
270
|
+
stdoutBuffer
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function createExecTraceContext() {
|
|
274
|
+
const diagnosticsMeta = getDiagnosticsMeta();
|
|
275
|
+
const diagnosticsDebugEnabled = diagnosticsMeta.debug === true;
|
|
276
|
+
const envTraceEnabled = parseBooleanLiteral(process.env.AGENT_DEVICE_EXEC_TRACE ?? "") === true;
|
|
277
|
+
if (!diagnosticsDebugEnabled && !envTraceEnabled) return createDisabledExecTraceContext();
|
|
278
|
+
if (envTraceEnabled && diagnosticsMeta.flushOnSuccess !== true) updateDiagnosticsScope({ flushOnSuccess: true });
|
|
279
|
+
const startedAtMs = Date.now();
|
|
280
|
+
let completionEmitted = false;
|
|
281
|
+
return {
|
|
282
|
+
emitForegroundCompletion: (cmd, args) => {
|
|
283
|
+
if (completionEmitted) return;
|
|
284
|
+
completionEmitted = true;
|
|
285
|
+
emitExecCommandDiagnostic({
|
|
286
|
+
cmd,
|
|
287
|
+
args,
|
|
288
|
+
startedAtMs
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
emitBackgroundSpawn: (cmd, args) => {
|
|
292
|
+
emitExecCommandDiagnostic({
|
|
293
|
+
cmd,
|
|
294
|
+
args,
|
|
295
|
+
data: { event: "spawn" }
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
emitBackgroundCompletion: (cmd, args, event) => {
|
|
299
|
+
if (completionEmitted) return;
|
|
300
|
+
completionEmitted = true;
|
|
301
|
+
emitExecCommandDiagnostic({
|
|
302
|
+
cmd,
|
|
303
|
+
args,
|
|
304
|
+
startedAtMs,
|
|
305
|
+
data: { event }
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function createDisabledExecTraceContext() {
|
|
311
|
+
return {
|
|
312
|
+
emitForegroundCompletion: () => {},
|
|
313
|
+
emitBackgroundSpawn: () => {},
|
|
314
|
+
emitBackgroundCompletion: () => {}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function emitExecCommandDiagnostic(params) {
|
|
318
|
+
const argsPrefix = params.args.slice(0, EXEC_DIAGNOSTIC_ARG_LIMIT);
|
|
319
|
+
emitDiagnostic({
|
|
320
|
+
level: "debug",
|
|
321
|
+
phase: "exec_command",
|
|
322
|
+
durationMs: params.startedAtMs === void 0 ? void 0 : Math.max(0, Date.now() - params.startedAtMs),
|
|
323
|
+
data: {
|
|
324
|
+
command: params.cmd,
|
|
325
|
+
argsPrefix,
|
|
326
|
+
...params.args.length > argsPrefix.length ? { omittedArgCount: params.args.length - argsPrefix.length } : {},
|
|
327
|
+
...params.data ?? {}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
function normalizeExecutableCommand(cmd) {
|
|
332
|
+
const candidate = normalizeExecutableLookup(cmd);
|
|
333
|
+
if (!candidate) throw new AppError("INVALID_ARGS", `Invalid executable command: ${JSON.stringify(cmd)}`, {
|
|
334
|
+
cmd,
|
|
335
|
+
hint: "Use a bare command name from PATH or an absolute executable path."
|
|
336
|
+
});
|
|
337
|
+
return candidate;
|
|
338
|
+
}
|
|
339
|
+
function createSpawnError(executable, cmd, args, err) {
|
|
340
|
+
if (err.code === "ENOENT") return createMissingToolError(executable, cmd, err);
|
|
341
|
+
return createCommandFailedError(executable, cmd, args, err);
|
|
342
|
+
}
|
|
343
|
+
function createMissingToolError(executable, cmd, cause) {
|
|
344
|
+
return new AppError("TOOL_MISSING", `${executable} not found in PATH`, { cmd }, cause);
|
|
345
|
+
}
|
|
346
|
+
function createCommandFailedError(executable, cmd, args, cause) {
|
|
347
|
+
return new AppError("COMMAND_FAILED", `Failed to run ${executable}`, {
|
|
348
|
+
cmd,
|
|
349
|
+
args
|
|
350
|
+
}, cause);
|
|
351
|
+
}
|
|
352
|
+
function createStdinError(executable, cmd, args, cause) {
|
|
353
|
+
return new AppError("COMMAND_FAILED", `Failed to write stdin for ${executable}`, {
|
|
354
|
+
cmd,
|
|
355
|
+
args
|
|
356
|
+
}, cause instanceof Error ? cause : void 0);
|
|
357
|
+
}
|
|
358
|
+
function createCommandCanceledError(executable, cmd, args) {
|
|
359
|
+
return createRequestCanceledError({
|
|
360
|
+
cmd,
|
|
361
|
+
args,
|
|
362
|
+
executable
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
function createTimeoutError(executable, cmd, args, timeoutMs, exitCode, stdout, stderr) {
|
|
366
|
+
return new AppError("COMMAND_FAILED", `${executable} timed out after ${timeoutMs}ms`, {
|
|
367
|
+
cmd,
|
|
368
|
+
args,
|
|
369
|
+
stdout,
|
|
370
|
+
stderr,
|
|
371
|
+
exitCode,
|
|
372
|
+
timeoutMs
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
function createExitError(executable, cmd, args, exitCode, stdout, stderr) {
|
|
376
|
+
return new AppError("COMMAND_FAILED", `${executable} exited with code ${exitCode}`, execFailureDetails({
|
|
377
|
+
stdout,
|
|
378
|
+
stderr,
|
|
379
|
+
exitCode
|
|
380
|
+
}, {
|
|
381
|
+
cmd,
|
|
382
|
+
args
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
const XCRUN_TOOL_NOT_FOUND_STDERR = /xcrun: error: unable to find utility "([^"]+)"/;
|
|
386
|
+
function xcrunToolNotFoundDetails(stderr) {
|
|
387
|
+
const tool = XCRUN_TOOL_NOT_FOUND_STDERR.exec(stderr)?.[1];
|
|
388
|
+
if (!tool) return {};
|
|
389
|
+
const developerDir = commandDeveloperDir();
|
|
390
|
+
const source = developerDir ? `DEVELOPER_DIR ${developerDir}` : "the developer dir selected by xcode-select (see xcode-select -p)";
|
|
391
|
+
return {
|
|
392
|
+
reason: "xcrun-tool-not-found",
|
|
393
|
+
developerDir: developerDir ?? null,
|
|
394
|
+
hint: `xcrun could not find ${tool} in ${source}. Export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer (or your Xcode's path) before running agent-device, or run sudo xcode-select -s /Applications/Xcode.app.`
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Exit-failure details for normalizeError, including null exit codes from signalled processes.
|
|
399
|
+
*/
|
|
400
|
+
function execFailureDetails(result, extra) {
|
|
401
|
+
return {
|
|
402
|
+
stdout: result.stdout,
|
|
403
|
+
stderr: result.stderr,
|
|
404
|
+
exitCode: result.exitCode,
|
|
405
|
+
processExitError: true,
|
|
406
|
+
...extra,
|
|
407
|
+
...xcrunToolNotFoundDetails(result.stderr)
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function spawnRejectionError(abort, executable, cmd, args, err) {
|
|
411
|
+
return abort.didAbort ? createCommandCanceledError(executable, cmd, args) : createSpawnError(executable, cmd, args, err);
|
|
412
|
+
}
|
|
413
|
+
function commandCloseFailure(abort, executable, cmd, args, exitCode, allowFailure, stdout, stderr) {
|
|
414
|
+
if (abort.didAbort) return createCommandCanceledError(executable, cmd, args);
|
|
415
|
+
if (exitCode !== 0 && !allowFailure) return createExitError(executable, cmd, args, exitCode, stdout, stderr);
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
function normalizeExecutableLookup(cmd) {
|
|
419
|
+
const candidate = cmd.trim();
|
|
420
|
+
if (!candidate || candidate.includes("\0")) return null;
|
|
421
|
+
if (path.isAbsolute(candidate)) return candidate;
|
|
422
|
+
if (candidate.includes("/") || candidate.includes("\\")) return null;
|
|
423
|
+
return BARE_COMMAND_RE.test(candidate) ? candidate : null;
|
|
424
|
+
}
|
|
425
|
+
function normalizeTimeoutMs(value) {
|
|
426
|
+
if (!Number.isFinite(value)) return void 0;
|
|
427
|
+
const timeout = Math.floor(value);
|
|
428
|
+
if (timeout <= 0) return void 0;
|
|
429
|
+
return timeout;
|
|
430
|
+
}
|
|
431
|
+
function createCommandKillSettlement(input) {
|
|
432
|
+
let killRequested = false;
|
|
433
|
+
let exited = false;
|
|
434
|
+
let exitCode = null;
|
|
435
|
+
const settleIfKilledAndGone = () => {
|
|
436
|
+
if (killRequested && exited) input.settle(exitCode);
|
|
437
|
+
};
|
|
438
|
+
return {
|
|
439
|
+
requestKill: () => {
|
|
440
|
+
killRequested = true;
|
|
441
|
+
input.killProcessTree();
|
|
442
|
+
settleIfKilledAndGone();
|
|
443
|
+
},
|
|
444
|
+
recordExit: (code) => {
|
|
445
|
+
exited = true;
|
|
446
|
+
exitCode = code ?? 1;
|
|
447
|
+
settleIfKilledAndGone();
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function watchCommandAbort(options, onKill) {
|
|
452
|
+
let didAbort = false;
|
|
453
|
+
const onAbort = () => {
|
|
454
|
+
didAbort = true;
|
|
455
|
+
onKill();
|
|
456
|
+
};
|
|
457
|
+
if (options.signal?.aborted) onAbort();
|
|
458
|
+
else options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
459
|
+
return {
|
|
460
|
+
get didAbort() {
|
|
461
|
+
return didAbort;
|
|
462
|
+
},
|
|
463
|
+
dispose: () => {
|
|
464
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Best-effort process-group signalling, shared by host-kit callers. This lives below
|
|
470
|
+
* host-process.ts to avoid an import cycle. Only positive integer pids are accepted:
|
|
471
|
+
* zero targets this process's group, and negative values can target unrelated processes.
|
|
472
|
+
*/
|
|
473
|
+
function signalProcessGroupBestEffort(pid, signal) {
|
|
474
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
475
|
+
try {
|
|
476
|
+
process.kill(-pid, signal);
|
|
477
|
+
return true;
|
|
478
|
+
} catch {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function killProcessTree(child, options) {
|
|
483
|
+
if (!options.kill) {
|
|
484
|
+
signalProcessTree(child, options.detached, "SIGKILL");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
signalProcessTree(child, options.detached, options.kill.signal);
|
|
488
|
+
const escalation = setTimeout(() => signalProcessTree(child, options.detached, "SIGKILL"), options.kill.graceMs);
|
|
489
|
+
escalation.unref();
|
|
490
|
+
child.once("exit", () => clearTimeout(escalation));
|
|
491
|
+
}
|
|
492
|
+
function signalProcessTree(child, detached, signal) {
|
|
493
|
+
if (detached && child.pid && process.platform !== "win32") {
|
|
494
|
+
signalProcessGroupBestEffort(child.pid, signal);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
498
|
+
child.kill(signal);
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* A kill that cannot reach an inherited-pipe holder must at least stop this process
|
|
502
|
+
* from holding the other end of those pipes open after it has settled.
|
|
503
|
+
*/
|
|
504
|
+
function destroyCommandStreams(child) {
|
|
505
|
+
child.stdin?.destroy();
|
|
506
|
+
child.stdout?.destroy();
|
|
507
|
+
child.stderr?.destroy();
|
|
508
|
+
}
|
|
509
|
+
async function writeChildStdin(child, stdin) {
|
|
510
|
+
if (!child.stdin) return;
|
|
511
|
+
if (stdin === void 0) {
|
|
512
|
+
child.stdin?.end();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
await pipeline(Readable.from([stdin]), child.stdin);
|
|
516
|
+
}
|
|
517
|
+
function isEpipeError(error) {
|
|
518
|
+
return error instanceof Error && "code" in error && error.code === "EPIPE";
|
|
519
|
+
}
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region ../host-kit/src/internal/timeouts.ts
|
|
522
|
+
function sleep(ms) {
|
|
523
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region ../host-kit/src/internal/host-process.ts
|
|
527
|
+
const PS_TIMEOUT_MS = 1e3;
|
|
528
|
+
const HOST_PS_COMMAND = process.platform === "win32" ? "ps" : "/bin/ps";
|
|
529
|
+
function hostEnvironment() {
|
|
530
|
+
return process.env;
|
|
531
|
+
}
|
|
532
|
+
function readHostEnvironmentVariable(name) {
|
|
533
|
+
return hostEnvironment()[name];
|
|
534
|
+
}
|
|
535
|
+
function writeHostStderr(value) {
|
|
536
|
+
process.stderr.write(value);
|
|
537
|
+
}
|
|
538
|
+
function hostCurrentWorkingDirectory() {
|
|
539
|
+
return process.cwd();
|
|
540
|
+
}
|
|
541
|
+
function hostNodeExecutablePath() {
|
|
542
|
+
return process.execPath;
|
|
543
|
+
}
|
|
544
|
+
function hostNodeVersion() {
|
|
545
|
+
return process.version;
|
|
546
|
+
}
|
|
547
|
+
function hostPlatform() {
|
|
548
|
+
return process.platform;
|
|
549
|
+
}
|
|
550
|
+
function hostProcessId() {
|
|
551
|
+
return process.pid;
|
|
552
|
+
}
|
|
553
|
+
function isProcessAlive(pid) {
|
|
554
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
555
|
+
try {
|
|
556
|
+
process.kill(pid, 0);
|
|
557
|
+
return true;
|
|
558
|
+
} catch (error) {
|
|
559
|
+
return error.code === "EPERM";
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function isProcessGroupAlive(pid) {
|
|
563
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
564
|
+
try {
|
|
565
|
+
process.kill(-pid, 0);
|
|
566
|
+
return true;
|
|
567
|
+
} catch (error) {
|
|
568
|
+
return error.code === "EPERM";
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Reads who owns a pid, off the event loop, with a budget the caller names. The three fields
|
|
573
|
+
* are read concurrently so a loaded host pays one budget for the set rather than one per
|
|
574
|
+
* field. Use this rather than the synchronous probes when the answer decides whether a
|
|
575
|
+
* process is signaled: waiting is reversible and signaling the wrong process is not.
|
|
576
|
+
*/
|
|
577
|
+
async function readProcessIdentityFacts(pid, timeoutMs = PS_TIMEOUT_MS) {
|
|
578
|
+
const [startTime, command, state] = await Promise.all([
|
|
579
|
+
readProcessFieldAsync(pid, "lstart=", timeoutMs),
|
|
580
|
+
readProcessFieldAsync(pid, "command=", timeoutMs),
|
|
581
|
+
readProcessFieldAsync(pid, "state=", timeoutMs)
|
|
582
|
+
]);
|
|
583
|
+
return {
|
|
584
|
+
startTime,
|
|
585
|
+
command,
|
|
586
|
+
zombie: state === null ? null : state.startsWith("Z")
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function readProcessStartTime(pid) {
|
|
590
|
+
return readProcessField(pid, "lstart=");
|
|
591
|
+
}
|
|
592
|
+
function readProcessCommand(pid) {
|
|
593
|
+
return readProcessField(pid, "command=");
|
|
594
|
+
}
|
|
595
|
+
function isProcessZombie(pid) {
|
|
596
|
+
return readProcessField(pid, "state=")?.startsWith("Z") ?? false;
|
|
597
|
+
}
|
|
598
|
+
function readHostProcessIdentityObservations(pids) {
|
|
599
|
+
const observations = /* @__PURE__ */ new Map();
|
|
600
|
+
const selected = uniquePositivePids(pids);
|
|
601
|
+
if (selected.length === 0) return observations;
|
|
602
|
+
try {
|
|
603
|
+
const result = runCmdSync("ps", [
|
|
604
|
+
"-p",
|
|
605
|
+
selected.join(","),
|
|
606
|
+
"-o",
|
|
607
|
+
"pid=,state=,lstart="
|
|
608
|
+
], {
|
|
609
|
+
allowFailure: true,
|
|
610
|
+
timeoutMs: PS_TIMEOUT_MS
|
|
611
|
+
});
|
|
612
|
+
if (result.exitCode !== 0) return observations;
|
|
613
|
+
for (const line of result.stdout.split("\n")) {
|
|
614
|
+
const match = /^\s*(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line);
|
|
615
|
+
if (!match) continue;
|
|
616
|
+
const pid = Number.parseInt(match[1], 10);
|
|
617
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
618
|
+
observations.set(pid, {
|
|
619
|
+
state: match[2],
|
|
620
|
+
startTime: match[3]
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
} catch {}
|
|
624
|
+
return observations;
|
|
625
|
+
}
|
|
626
|
+
function readProcessField(pid, field) {
|
|
627
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
628
|
+
try {
|
|
629
|
+
return processFieldValue(runCmdSync(HOST_PS_COMMAND, [
|
|
630
|
+
"-p",
|
|
631
|
+
String(pid),
|
|
632
|
+
"-o",
|
|
633
|
+
field
|
|
634
|
+
], {
|
|
635
|
+
allowFailure: true,
|
|
636
|
+
timeoutMs: PS_TIMEOUT_MS
|
|
637
|
+
}));
|
|
638
|
+
} catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
async function readProcessFieldAsync(pid, field, timeoutMs) {
|
|
643
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
644
|
+
try {
|
|
645
|
+
return processFieldValue(await runCmd(HOST_PS_COMMAND, [
|
|
646
|
+
"-p",
|
|
647
|
+
String(pid),
|
|
648
|
+
"-o",
|
|
649
|
+
field
|
|
650
|
+
], {
|
|
651
|
+
allowFailure: true,
|
|
652
|
+
timeoutMs
|
|
653
|
+
}));
|
|
654
|
+
} catch {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function processFieldValue(result) {
|
|
659
|
+
if (result.exitCode !== 0) return null;
|
|
660
|
+
const value = result.stdout.trim();
|
|
661
|
+
return value.length > 0 ? value : null;
|
|
662
|
+
}
|
|
663
|
+
function parseHostProcessList(stdout) {
|
|
664
|
+
const processes = [];
|
|
665
|
+
for (const line of stdout.split("\n")) {
|
|
666
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line);
|
|
667
|
+
if (!match) continue;
|
|
668
|
+
const pid = Number.parseInt(match[1], 10);
|
|
669
|
+
const ppid = Number.parseInt(match[2], 10);
|
|
670
|
+
const command = match[3];
|
|
671
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
672
|
+
processes.push({
|
|
673
|
+
pid,
|
|
674
|
+
ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : void 0,
|
|
675
|
+
command
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
return processes;
|
|
679
|
+
}
|
|
680
|
+
async function listHostProcesses(options) {
|
|
681
|
+
const result = await (options.runCommand ?? runCmd)(options.runCommand ? "ps" : HOST_PS_COMMAND, [
|
|
682
|
+
"-ax",
|
|
683
|
+
"-o",
|
|
684
|
+
"pid=,ppid=,command="
|
|
685
|
+
], {
|
|
686
|
+
allowFailure: true,
|
|
687
|
+
timeoutMs: options.timeoutMs
|
|
688
|
+
});
|
|
689
|
+
if (result.exitCode !== 0) return [];
|
|
690
|
+
return parseHostProcessList(result.stdout);
|
|
691
|
+
}
|
|
692
|
+
function expandProcessTree(rootPids, processes) {
|
|
693
|
+
const selected = new Set(uniquePositivePids(rootPids));
|
|
694
|
+
let changed = true;
|
|
695
|
+
while (changed) {
|
|
696
|
+
changed = false;
|
|
697
|
+
for (const processInfo of processes) {
|
|
698
|
+
if (processInfo.ppid === void 0 || !selected.has(processInfo.ppid)) continue;
|
|
699
|
+
if (selected.has(processInfo.pid)) continue;
|
|
700
|
+
selected.add(processInfo.pid);
|
|
701
|
+
changed = true;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return processes.filter((processInfo) => selected.has(processInfo.pid));
|
|
705
|
+
}
|
|
706
|
+
function uniquePositivePids(values, options = {}) {
|
|
707
|
+
return [...new Set(values)].filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== options.excludePid);
|
|
708
|
+
}
|
|
709
|
+
function signalPidsBestEffort(pidsToSignal, signal) {
|
|
710
|
+
const pids = uniquePositivePids(pidsToSignal, { excludePid: process.pid });
|
|
711
|
+
let signaled = 0;
|
|
712
|
+
for (const pid of pids) try {
|
|
713
|
+
process.kill(pid, signal);
|
|
714
|
+
signaled += 1;
|
|
715
|
+
} catch {}
|
|
716
|
+
return signaled;
|
|
717
|
+
}
|
|
718
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
719
|
+
if (!isProcessAlive(pid)) return true;
|
|
720
|
+
const start = Date.now();
|
|
721
|
+
while (Date.now() - start < timeoutMs) {
|
|
722
|
+
await sleep(50);
|
|
723
|
+
if (!isProcessAlive(pid)) return true;
|
|
724
|
+
}
|
|
725
|
+
return !isProcessAlive(pid);
|
|
726
|
+
}
|
|
727
|
+
async function stopPidsWithEscalation(options) {
|
|
728
|
+
const pids = uniquePositivePids(options.pids, { excludePid: process.pid });
|
|
729
|
+
if (pids.length === 0) return;
|
|
730
|
+
signalPidsBestEffort(pids, "SIGTERM");
|
|
731
|
+
await Promise.all(pids.map(async (pid) => await waitForProcessExit(pid, options.termTimeoutMs)));
|
|
732
|
+
signalPidsBestEffort(pids.filter((pid) => isProcessAlive(pid)), "SIGKILL");
|
|
733
|
+
await Promise.all(pids.map(async (pid) => await waitForProcessExit(pid, options.killTimeoutMs)));
|
|
734
|
+
}
|
|
735
|
+
const SYSCTL_TIMEOUT_MS = 1e3;
|
|
736
|
+
const APPLE_SILICON_SYSCTL = ["-n", "hw.optional.arm64"];
|
|
737
|
+
let pendingHostCpuArch;
|
|
738
|
+
let settledHostCpuArch;
|
|
739
|
+
/**
|
|
740
|
+
* The machine's native CPU architecture in Apple naming (`arm64`, `x86_64`), which is what
|
|
741
|
+
* simulators on a Mac run by default. Other CPUs keep Node's `process.arch` name. Resolved once
|
|
742
|
+
* per process.
|
|
743
|
+
*/
|
|
744
|
+
function readHostCpuArch() {
|
|
745
|
+
if (settledHostCpuArch !== void 0) return Promise.resolve(settledHostCpuArch);
|
|
746
|
+
pendingHostCpuArch ??= resolveHostCpuArch(process.platform, process.arch).then(settleHostCpuArch);
|
|
747
|
+
return pendingHostCpuArch;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* {@link readHostCpuArch} for a caller that cannot await. It shares the same per-process value,
|
|
751
|
+
* resolving it with a blocking `sysctl` only when nothing has resolved it yet.
|
|
752
|
+
*/
|
|
753
|
+
function readHostCpuArchSync() {
|
|
754
|
+
return settledHostCpuArch ?? settleHostCpuArch(hostCpuArchName(process.platform === "darwin" && isAppleSiliconMacSync(), process.arch));
|
|
755
|
+
}
|
|
756
|
+
function settleHostCpuArch(arch) {
|
|
757
|
+
settledHostCpuArch ??= arch;
|
|
758
|
+
return settledHostCpuArch;
|
|
759
|
+
}
|
|
760
|
+
async function resolveHostCpuArch(platform, nodeArch) {
|
|
761
|
+
return hostCpuArchName(platform === "darwin" && await isAppleSiliconMac(), nodeArch);
|
|
762
|
+
}
|
|
763
|
+
function hostCpuArchName(appleSilicon, nodeArch) {
|
|
764
|
+
if (appleSilicon) return "arm64";
|
|
765
|
+
return nodeArch === "x64" ? "x86_64" : nodeArch;
|
|
766
|
+
}
|
|
767
|
+
async function isAppleSiliconMac() {
|
|
768
|
+
try {
|
|
769
|
+
const result = await runCmd("/usr/sbin/sysctl", APPLE_SILICON_SYSCTL, {
|
|
770
|
+
allowFailure: true,
|
|
771
|
+
timeoutMs: SYSCTL_TIMEOUT_MS
|
|
772
|
+
});
|
|
773
|
+
return result.exitCode === 0 && result.stdout.trim() === "1";
|
|
774
|
+
} catch {
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
function isAppleSiliconMacSync() {
|
|
779
|
+
try {
|
|
780
|
+
const result = runCmdSync("/usr/sbin/sysctl", APPLE_SILICON_SYSCTL, {
|
|
781
|
+
allowFailure: true,
|
|
782
|
+
timeoutMs: SYSCTL_TIMEOUT_MS
|
|
783
|
+
});
|
|
784
|
+
return result.exitCode === 0 && result.stdout.trim() === "1";
|
|
785
|
+
} catch {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
//#endregion
|
|
790
|
+
//#region ../host-kit/src/internal/owned-process-reaper.ts
|
|
791
|
+
const DEFAULT_TERM_TIMEOUT_MS = 1500;
|
|
792
|
+
const DEFAULT_KILL_TIMEOUT_MS = 1e3;
|
|
793
|
+
async function reapOwnedProcessRecordsAtStartup(store, options = {}) {
|
|
794
|
+
const summary = createReapSummary();
|
|
795
|
+
const policy = createReapPolicy(options);
|
|
796
|
+
for (const entry of store.read()) {
|
|
797
|
+
if (!isDecodedRecord(entry)) continue;
|
|
798
|
+
const retained = await reapDecodedEntry(entry, policy, summary);
|
|
799
|
+
if (retained.length === 0) store.clear(entry.scope);
|
|
800
|
+
else if (retained.length !== entry.records.length) store.replace(entry.scope, retained);
|
|
801
|
+
}
|
|
802
|
+
return Object.freeze(summary);
|
|
803
|
+
}
|
|
804
|
+
function isDecodedRecord(entry) {
|
|
805
|
+
return entry.status === "decoded";
|
|
806
|
+
}
|
|
807
|
+
function createReapSummary() {
|
|
808
|
+
return {
|
|
809
|
+
inspected: 0,
|
|
810
|
+
inspectedPids: [],
|
|
811
|
+
terminated: 0,
|
|
812
|
+
terminatedPids: [],
|
|
813
|
+
missing: 0,
|
|
814
|
+
missingPids: [],
|
|
815
|
+
ownershipLost: 0,
|
|
816
|
+
ownershipLostPids: [],
|
|
817
|
+
skippedForOpenWebSession: false
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
function createReapPolicy(options) {
|
|
821
|
+
return {
|
|
822
|
+
openWebSession: (options.openWebSessionNames ?? []).length > 0,
|
|
823
|
+
purposes: options.purposes ? new Set(options.purposes) : void 0,
|
|
824
|
+
termTimeoutMs: options.termTimeoutMs ?? DEFAULT_TERM_TIMEOUT_MS,
|
|
825
|
+
killTimeoutMs: options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
async function reapDecodedEntry(entry, policy, summary) {
|
|
829
|
+
const retained = [];
|
|
830
|
+
for (const record of entry.records) {
|
|
831
|
+
if (shouldRetainWithoutInspection(entry.scope.kind, record, policy)) {
|
|
832
|
+
retained.push(record);
|
|
833
|
+
if (isOpenWebSessionSkip(entry.scope.kind, record, policy)) summary.skippedForOpenWebSession = true;
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
summary.inspected += 1;
|
|
837
|
+
summary.inspectedPids.push(record.pid);
|
|
838
|
+
appendReapOutcome(summary, record, await reapOwnedProcess(record, policy), retained);
|
|
839
|
+
}
|
|
840
|
+
return retained;
|
|
841
|
+
}
|
|
842
|
+
function shouldRetainWithoutInspection(scope, record, policy) {
|
|
843
|
+
return !isSelectedPurpose(record, policy) || isOpenWebSessionSkip(scope, record, policy);
|
|
844
|
+
}
|
|
845
|
+
function isSelectedPurpose(record, policy) {
|
|
846
|
+
return policy.purposes === void 0 || policy.purposes.has(record.purpose);
|
|
847
|
+
}
|
|
848
|
+
function isOpenWebSessionSkip(scope, record, policy) {
|
|
849
|
+
return scope === "daemon" && record.purpose === "managed-web-browser" && policy.openWebSession;
|
|
850
|
+
}
|
|
851
|
+
function appendReapOutcome(summary, record, outcome, retained) {
|
|
852
|
+
if (outcome === "terminated") {
|
|
853
|
+
summary.terminated += 1;
|
|
854
|
+
summary.terminatedPids.push(record.pid);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (outcome === "missing") {
|
|
858
|
+
summary.missing += 1;
|
|
859
|
+
summary.missingPids.push(record.pid);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
summary.ownershipLost += 1;
|
|
863
|
+
summary.ownershipLostPids.push(record.pid);
|
|
864
|
+
retained.push(record);
|
|
865
|
+
}
|
|
866
|
+
async function reapOwnedProcess(record, policy) {
|
|
867
|
+
const initial = inspectOwnedProcess(record);
|
|
868
|
+
if (initial !== void 0) return initial;
|
|
869
|
+
signalBestEffort(record.pid, initialSignalFor(record));
|
|
870
|
+
return await escalateOwnedProcess(record, policy);
|
|
871
|
+
}
|
|
872
|
+
function inspectOwnedProcess(record) {
|
|
873
|
+
if (record.pid === process.pid || !isProcessAlive(record.pid) || isProcessZombie(record.pid)) return "missing";
|
|
874
|
+
return matchesRecord(record) ? void 0 : "ownership-lost";
|
|
875
|
+
}
|
|
876
|
+
function initialSignalFor(record) {
|
|
877
|
+
return record.purpose === "simctl-screen-recording" ? "SIGINT" : "SIGTERM";
|
|
878
|
+
}
|
|
879
|
+
async function escalateOwnedProcess(record, policy) {
|
|
880
|
+
const termOutcome = await waitForOwnedProcessExit(record, policy.termTimeoutMs);
|
|
881
|
+
if (termOutcome === "exited") return "terminated";
|
|
882
|
+
if (termOutcome === "ownership-lost") return "ownership-lost";
|
|
883
|
+
signalBestEffort(record.pid, "SIGKILL");
|
|
884
|
+
return await waitForOwnedProcessExit(record, policy.killTimeoutMs) === "exited" ? "terminated" : "ownership-lost";
|
|
885
|
+
}
|
|
886
|
+
async function waitForOwnedProcessExit(record, timeoutMs) {
|
|
887
|
+
await waitForProcessExit(record.pid, timeoutMs);
|
|
888
|
+
if (!isProcessAlive(record.pid) || isProcessZombie(record.pid)) return "exited";
|
|
889
|
+
return matchesRecord(record) ? "owned-alive" : "ownership-lost";
|
|
890
|
+
}
|
|
891
|
+
function matchesRecord(record) {
|
|
892
|
+
return readProcessStartTime(record.pid) === record.startTime && readProcessCommand(record.pid) === record.command;
|
|
893
|
+
}
|
|
894
|
+
function signalBestEffort(pid, signal) {
|
|
895
|
+
try {
|
|
896
|
+
process.kill(pid, signal);
|
|
897
|
+
} catch {}
|
|
898
|
+
}
|
|
899
|
+
//#endregion
|
|
900
|
+
//#region ../host-kit/src/internal/atomic-file.ts
|
|
901
|
+
/**
|
|
902
|
+
* Gives a specialized durable publisher a canonical temp path and cleanup
|
|
903
|
+
* ownership while it performs its own open/fsync/safety protocol.
|
|
904
|
+
*/
|
|
905
|
+
function withAtomicPublishTempPathSync(destination, publish) {
|
|
906
|
+
const temporaryPath = createAtomicPublishTempPath(destination);
|
|
907
|
+
try {
|
|
908
|
+
return publish(temporaryPath);
|
|
909
|
+
} finally {
|
|
910
|
+
try {
|
|
911
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
912
|
+
} catch (error) {
|
|
913
|
+
emitDiagnostic({
|
|
914
|
+
level: "warn",
|
|
915
|
+
phase: "atomic_file_cleanup_failed",
|
|
916
|
+
data: {
|
|
917
|
+
destination,
|
|
918
|
+
temporaryPath,
|
|
919
|
+
error: error instanceof Error ? error.message : String(error)
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
/** Returns the canonical same-directory temp path used by atomic publishers. */
|
|
926
|
+
function createAtomicPublishTempPath(destination) {
|
|
927
|
+
return path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}-${crypto.randomUUID()}.tmp`);
|
|
928
|
+
}
|
|
929
|
+
//#endregion
|
|
930
|
+
//#region ../host-kit/src/internal/owned-process-record.ts
|
|
931
|
+
const RECORD_FILE_NAME = "owned-processes.json";
|
|
932
|
+
const RECORD_VERSION = 1;
|
|
933
|
+
function createOwnedProcessRecordStore(options) {
|
|
934
|
+
const stateDir = path.resolve(options.stateDir);
|
|
935
|
+
const sessionsDir = path.resolve(options.sessionsDir);
|
|
936
|
+
const daemonPath = path.join(stateDir, RECORD_FILE_NAME);
|
|
937
|
+
const resolvePath = (scope) => {
|
|
938
|
+
if (scope.kind === "daemon") return daemonPath;
|
|
939
|
+
const sessionDir = path.resolve(options.resolveSessionDir(scope.sessionId));
|
|
940
|
+
if (path.dirname(sessionDir) !== sessionsDir) throw new TypeError("owned process session scope must resolve directly under the sessions dir");
|
|
941
|
+
return path.join(sessionDir, RECORD_FILE_NAME);
|
|
942
|
+
};
|
|
943
|
+
return Object.freeze({
|
|
944
|
+
replace(scope, records) {
|
|
945
|
+
const normalized = normalizeRecords(records);
|
|
946
|
+
const recordPath = resolvePath(scope);
|
|
947
|
+
if (normalized.length === 0) {
|
|
948
|
+
clearRecordPath(recordPath);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const directory = path.dirname(recordPath);
|
|
952
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
953
|
+
withAtomicPublishTempPathSync(recordPath, (temporaryPath) => {
|
|
954
|
+
const descriptor = fs.openSync(temporaryPath, "wx", 384);
|
|
955
|
+
try {
|
|
956
|
+
fs.writeFileSync(descriptor, `${JSON.stringify({
|
|
957
|
+
version: RECORD_VERSION,
|
|
958
|
+
processes: normalized
|
|
959
|
+
})}\n`, "utf8");
|
|
960
|
+
fs.fsyncSync(descriptor);
|
|
961
|
+
} finally {
|
|
962
|
+
fs.closeSync(descriptor);
|
|
963
|
+
}
|
|
964
|
+
assertSafeRecordDestination(recordPath);
|
|
965
|
+
fs.renameSync(temporaryPath, recordPath);
|
|
966
|
+
});
|
|
967
|
+
},
|
|
968
|
+
clear(scope) {
|
|
969
|
+
clearRecordPath(resolvePath(scope));
|
|
970
|
+
},
|
|
971
|
+
read() {
|
|
972
|
+
return readRecordFiles(sessionsDir, daemonPath);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
function readOwnedProcessRecordFile(recordPath, scope) {
|
|
977
|
+
let value;
|
|
978
|
+
try {
|
|
979
|
+
assertSafeRecordDestination(recordPath);
|
|
980
|
+
value = JSON.parse(fs.readFileSync(recordPath, "utf8"));
|
|
981
|
+
} catch (error) {
|
|
982
|
+
return {
|
|
983
|
+
scope,
|
|
984
|
+
path: recordPath,
|
|
985
|
+
status: "invalid",
|
|
986
|
+
records: [],
|
|
987
|
+
message: errorMessage(error)
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
if (!isRecordFile(value)) return {
|
|
991
|
+
scope,
|
|
992
|
+
path: recordPath,
|
|
993
|
+
status: "invalid",
|
|
994
|
+
records: [],
|
|
995
|
+
message: "owned process record must contain version 1 and a processes array"
|
|
996
|
+
};
|
|
997
|
+
try {
|
|
998
|
+
return {
|
|
999
|
+
scope,
|
|
1000
|
+
path: recordPath,
|
|
1001
|
+
status: "decoded",
|
|
1002
|
+
records: normalizeRecords(value.processes)
|
|
1003
|
+
};
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
return {
|
|
1006
|
+
scope,
|
|
1007
|
+
path: recordPath,
|
|
1008
|
+
status: "invalid",
|
|
1009
|
+
records: [],
|
|
1010
|
+
message: errorMessage(error)
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
function readRecordFiles(sessionsDir, daemonPath) {
|
|
1015
|
+
const entries = [];
|
|
1016
|
+
if (fileExists(daemonPath)) entries.push(readOwnedProcessRecordFile(daemonPath, { kind: "daemon" }));
|
|
1017
|
+
if (!fs.existsSync(sessionsDir)) return entries;
|
|
1018
|
+
for (const entry of fs.readdirSync(sessionsDir, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
1019
|
+
if (!entry.isDirectory()) continue;
|
|
1020
|
+
const recordPath = path.join(sessionsDir, entry.name, RECORD_FILE_NAME);
|
|
1021
|
+
if (fileExists(recordPath)) entries.push(readOwnedProcessRecordFile(recordPath, {
|
|
1022
|
+
kind: "session",
|
|
1023
|
+
sessionId: entry.name
|
|
1024
|
+
}));
|
|
1025
|
+
}
|
|
1026
|
+
return entries;
|
|
1027
|
+
}
|
|
1028
|
+
function normalizeRecords(records) {
|
|
1029
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1030
|
+
const normalized = [];
|
|
1031
|
+
for (const record of records) {
|
|
1032
|
+
if (!isValidOwnedProcessRecord(record)) throw new TypeError("owned process record has an invalid identity or purpose");
|
|
1033
|
+
if (seen.has(record.pid)) throw new TypeError(`duplicate owned process pid ${record.pid}`);
|
|
1034
|
+
seen.add(record.pid);
|
|
1035
|
+
normalized.push(Object.freeze({
|
|
1036
|
+
pid: record.pid,
|
|
1037
|
+
startTime: record.startTime,
|
|
1038
|
+
command: record.command,
|
|
1039
|
+
purpose: record.purpose
|
|
1040
|
+
}));
|
|
1041
|
+
}
|
|
1042
|
+
return normalized.sort((left, right) => left.pid - right.pid);
|
|
1043
|
+
}
|
|
1044
|
+
function isValidOwnedProcessRecord(record) {
|
|
1045
|
+
return Number.isInteger(record.pid) && record.pid > 0 && typeof record.startTime === "string" && record.startTime.length > 0 && typeof record.command === "string" && record.command.length > 0 && typeof record.purpose === "string" && record.purpose.length > 0;
|
|
1046
|
+
}
|
|
1047
|
+
function isRecordFile(value) {
|
|
1048
|
+
return typeof value === "object" && value !== null && "version" in value && value.version === RECORD_VERSION && "processes" in value && Array.isArray(value.processes);
|
|
1049
|
+
}
|
|
1050
|
+
function clearRecordPath(recordPath) {
|
|
1051
|
+
try {
|
|
1052
|
+
assertSafeRecordDestination(recordPath);
|
|
1053
|
+
fs.unlinkSync(recordPath);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
if (!isMissingFile(error)) throw error;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
function assertSafeRecordDestination(recordPath) {
|
|
1059
|
+
try {
|
|
1060
|
+
const stats = fs.lstatSync(recordPath);
|
|
1061
|
+
if (stats.isSymbolicLink()) throw new Error("Refusing to follow an owned process record symlink");
|
|
1062
|
+
if (!stats.isFile()) throw new Error("Owned process record is not a regular file");
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
if (!isMissingFile(error)) throw error;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
function fileExists(filePath) {
|
|
1068
|
+
try {
|
|
1069
|
+
return fs.lstatSync(filePath).isFile();
|
|
1070
|
+
} catch {
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
function isMissingFile(error) {
|
|
1075
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
1076
|
+
}
|
|
1077
|
+
function errorMessage(error) {
|
|
1078
|
+
return error instanceof Error ? error.message : "owned process record is invalid";
|
|
1079
|
+
}
|
|
1080
|
+
//#endregion
|
|
1081
|
+
//#region ../host-kit/src/internal/owner-identity.ts
|
|
1082
|
+
/** A process id accepted by Node's native signal API. */
|
|
1083
|
+
function isProcessPid(value) {
|
|
1084
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 2147483647;
|
|
1085
|
+
}
|
|
1086
|
+
function readCurrentOwnerIdentity() {
|
|
1087
|
+
return {
|
|
1088
|
+
pid: process.pid,
|
|
1089
|
+
startTime: readProcessStartTime(process.pid)
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
function ownerIdentityMatches(left, right) {
|
|
1093
|
+
return left.pid === right.pid && left.startTime === right.startTime;
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Positive proof that two records name DIFFERENT processes. Deliberately not
|
|
1097
|
+
* the negation of {@link ownerIdentityMatches}: equal pids with an unreadable
|
|
1098
|
+
* start time on either side are unproven rather than different, so a caller
|
|
1099
|
+
* that acts on this can never mistake one owner for two and hand its resource
|
|
1100
|
+
* away.
|
|
1101
|
+
*/
|
|
1102
|
+
function ownerIdentityDiffers(left, right) {
|
|
1103
|
+
if (left.pid !== right.pid) return true;
|
|
1104
|
+
return Boolean(left.startTime && right.startTime && left.startTime !== right.startTime);
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* This is deliberately proof-oriented, in both directions. A filesystem read
|
|
1108
|
+
* error is not proof that an owner state directory disappeared, so callers
|
|
1109
|
+
* must surface it as an unknown owner rather than treating the resource as
|
|
1110
|
+
* free. Likewise a failed `ps` read (it shells out with a short timeout and
|
|
1111
|
+
* loses under CPU contention) is not proof the owner died, so it never
|
|
1112
|
+
* condemns a pid that kill(pid, 0) says is alive. Death is only concluded
|
|
1113
|
+
* from positive evidence: the pid is gone or the process is a zombie (already
|
|
1114
|
+
* terminated, merely unreaped). A different readable start time proves PID
|
|
1115
|
+
* reuse, but remains a distinct result so each ownership policy must decide
|
|
1116
|
+
* explicitly whether that proof permits recovery. An unreadable start time is
|
|
1117
|
+
* not proof of reuse. An owner recorded without a start time stays fail-closed
|
|
1118
|
+
* while its pid is alive: there is no same-clock-domain proof of birth order (wall-clock
|
|
1119
|
+
* arithmetic over `ps etime` shifts under clock steps), and misreading a live
|
|
1120
|
+
* owner as recycled would let a waiter steal a held resource.
|
|
1121
|
+
*/
|
|
1122
|
+
function classifyOwnerLiveness(params) {
|
|
1123
|
+
return classifyOwnerLivenessFromObservation(params);
|
|
1124
|
+
}
|
|
1125
|
+
function classifyOwnerLivenessFromObservation(params, observation) {
|
|
1126
|
+
const { owner, stateDir } = params;
|
|
1127
|
+
if (!isProcessPid(owner.pid)) return "unknown";
|
|
1128
|
+
if (!isProcessAlive(owner.pid)) return "owner-process-dead";
|
|
1129
|
+
if (observation !== void 0 ? observation?.state.startsWith("Z") : isProcessZombie(owner.pid)) return "owner-process-dead";
|
|
1130
|
+
if (owner.startTime) {
|
|
1131
|
+
const currentStartTime = observation !== void 0 ? observation?.startTime ?? null : readProcessStartTime(owner.pid);
|
|
1132
|
+
if (currentStartTime !== null && currentStartTime !== owner.startTime) return "owner-process-reused";
|
|
1133
|
+
}
|
|
1134
|
+
return stateDir ? classifyOwnerStateDirectory(stateDir) : "live";
|
|
1135
|
+
}
|
|
1136
|
+
function classifyOwnerStateDirectory(stateDir) {
|
|
1137
|
+
try {
|
|
1138
|
+
fs.statSync(stateDir);
|
|
1139
|
+
return "live";
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
const code = error?.code;
|
|
1142
|
+
if (code === "ENOENT" || code === "ENOTDIR") return "owner-state-dir-gone";
|
|
1143
|
+
return "unknown";
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
//#endregion
|
|
1147
|
+
export { classifyOwnerLiveness, classifyOwnerLivenessFromObservation, createOwnedProcessRecordStore, expandProcessTree, hostCurrentWorkingDirectory, hostEnvironment, hostNodeExecutablePath, hostNodeVersion, hostPlatform, hostProcessId, isProcessAlive, isProcessGroupAlive, isProcessPid, isProcessZombie, listHostProcesses, ownerIdentityDiffers, ownerIdentityMatches, readCurrentOwnerIdentity, readHostCpuArch, readHostCpuArchSync, readHostEnvironmentVariable, readHostProcessIdentityObservations, readOwnedProcessRecordFile, readProcessCommand, readProcessIdentityFacts, readProcessStartTime, reapOwnedProcessRecordsAtStartup, signalPidsBestEffort, signalProcessGroupBestEffort, stopPidsWithEscalation, uniquePositivePids, waitForProcessExit, writeHostStderr };
|