sentinelayer-cli 0.46.2 → 0.46.4
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/package.json +1 -1
- package/src/commands/session.js +21 -25
- package/src/session/listener-process.js +461 -61
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -78,7 +78,6 @@ import {
|
|
|
78
78
|
getListenerProcessStatus,
|
|
79
79
|
LISTENER_PROCESS_SCAN_RETRY_TIMEOUT_MS,
|
|
80
80
|
removeListenerPidRecord,
|
|
81
|
-
requestListenerProcessStop,
|
|
82
81
|
summarizeLocalListenerProcesses,
|
|
83
82
|
stopMatchingListenerProcesses,
|
|
84
83
|
writeListenerPidRecord,
|
|
@@ -5515,33 +5514,30 @@ export function registerSessionCommand(program) {
|
|
|
5515
5514
|
);
|
|
5516
5515
|
}
|
|
5517
5516
|
if (existingListener.running && options.force) {
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
`
|
|
5526
|
-
),
|
|
5517
|
+
// --force never stops a process this command runs under, and never on a guess:
|
|
5518
|
+
// it refuses, and stops and starts nothing, unless the takeover scan confirms
|
|
5519
|
+
// what it stops.
|
|
5520
|
+
const refuseTakeover = ({ pid, reason }) =>
|
|
5521
|
+
new Error(
|
|
5522
|
+
reason === "ancestor_listener"
|
|
5523
|
+
? `A session listener is already running for session ${normalizedSessionId} as ${agentId} (pid ${pid}), and this command runs under it, so --force will not stop it. No listener was stopped or started. Stop that listener first, or use --allow-duplicate if you intentionally need more than one.`
|
|
5524
|
+
: `Could not confirm that pid ${pid} is a session listener --force can replace for session ${normalizedSessionId} as ${agentId} (${reason}). No listener was stopped or started. Run the command again, or use --allow-duplicate to start without this check (it can then run alongside an existing listener).`,
|
|
5527
5525
|
);
|
|
5526
|
+
if (existingListener.ancestor) {
|
|
5527
|
+
throw refuseTakeover({ pid: existingListener.pid, reason: "ancestor_listener" });
|
|
5528
5528
|
}
|
|
5529
|
-
const
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5529
|
+
const stopMatchesResult = await stopMatchingListenerProcesses(normalizedSessionId, agentId, {
|
|
5530
|
+
listener: existingListener,
|
|
5531
|
+
});
|
|
5532
|
+
if (!stopMatchesResult.ok) {
|
|
5533
|
+
// What the status check found may no longer hold, and an unread table cannot
|
|
5534
|
+
// show which processes this command runs under, so nothing is stopped.
|
|
5535
|
+
throw refuseTakeover({ pid: existingListener.pid, reason: stopMatchesResult.reason });
|
|
5536
|
+
}
|
|
5537
|
+
if (stopMatchesResult.refused) {
|
|
5538
|
+
throw refuseTakeover(stopMatchesResult.refused);
|
|
5538
5539
|
}
|
|
5539
|
-
const failedStops =
|
|
5540
|
-
...scannedStops.filter((result) => !result.stopped),
|
|
5541
|
-
...(explicitStopResult && !explicitStopResult.stopped
|
|
5542
|
-
? [{ pid: existingListener.pid, ...explicitStopResult }]
|
|
5543
|
-
: []),
|
|
5544
|
-
];
|
|
5540
|
+
const failedStops = stopMatchesResult.results.filter((result) => !result.stopped);
|
|
5545
5541
|
if (failedStops.length > 0) {
|
|
5546
5542
|
const failed = failedStops[0];
|
|
5547
5543
|
throw new Error(
|
|
@@ -40,6 +40,31 @@ function escapeRegExp(value) {
|
|
|
40
40
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// The CLI's entry points as package.json "bin" declares them (a test keeps the two in step):
|
|
44
|
+
// the scripts node runs, and the command names npm links to them.
|
|
45
|
+
const CLI_ENTRY_SCRIPTS = ["create-sentinelayer.js", "sentinelayer-cli.js", "sl.js"];
|
|
46
|
+
const CLI_COMMAND_NAMES = ["create-sentinelayer", "sentinel", "sentinelayer-cli", "sl-cli", "slc", "sl"];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The one test of "this command line runs this CLI": an argument that is a path ending in an
|
|
50
|
+
* entry point (a path separator, the entry name, then the end of the argument). So
|
|
51
|
+
* `.../bin/create-sentinelayer.js` counts, but `xcreate-sentinelayer.js`,
|
|
52
|
+
* `create-sentinelayer.json` and a directory named after a command do not.
|
|
53
|
+
*
|
|
54
|
+
* On POSIX, npm links each command name to its script and the kernel hands node the link path
|
|
55
|
+
* (`node /usr/local/bin/sl ...`), so the command names count there too. On Windows npm's shims
|
|
56
|
+
* start node on the script itself, and the sh shim Git Bash runs stays alive as node's parent
|
|
57
|
+
* with only the command name on its command line, so there only the scripts count.
|
|
58
|
+
*
|
|
59
|
+
* The pattern is valid in JavaScript and in .NET (the Windows scan runs it in PowerShell), and
|
|
60
|
+
* writes its quotes as \x22 and \x27 so it can sit inside a quoted PowerShell string.
|
|
61
|
+
*/
|
|
62
|
+
function cliEntryPattern(platform) {
|
|
63
|
+
const entries =
|
|
64
|
+
platform === "win32" ? CLI_ENTRY_SCRIPTS : [...CLI_ENTRY_SCRIPTS, ...CLI_COMMAND_NAMES];
|
|
65
|
+
return `[\\\\/](?:${entries.map(escapeRegExp).join("|")})(?=$|[\\s\\x22\\x27])`;
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
function commandLineFlagValueMatches(commandLine, flagName, expectedValue) {
|
|
44
69
|
const expected = normalizeString(expectedValue).toLowerCase();
|
|
45
70
|
if (!expected) return { found: false, matches: true };
|
|
@@ -58,15 +83,20 @@ function commandLineFlagValueMatches(commandLine, flagName, expectedValue) {
|
|
|
58
83
|
return { found, matches: false };
|
|
59
84
|
}
|
|
60
85
|
|
|
61
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Whether a command line is `session listen` for this session and agent, run through any of
|
|
88
|
+
* this CLI's entry points. `platform` is the system the command line was read on.
|
|
89
|
+
*/
|
|
90
|
+
export function listenerCommandLineMatches(
|
|
91
|
+
commandLine,
|
|
92
|
+
{ sessionId = "", agentId = "", platform = process.platform } = {},
|
|
93
|
+
) {
|
|
62
94
|
const rawCommandLine = normalizeString(commandLine);
|
|
63
95
|
const normalized = normalizeCommandLine(rawCommandLine);
|
|
64
96
|
if (!normalized) return false;
|
|
65
97
|
const normalizedSessionId = normalizeString(sessionId).toLowerCase();
|
|
66
98
|
const normalizedAgentId = normalizeString(agentId).toLowerCase();
|
|
67
|
-
const hasCliEntry =
|
|
68
|
-
normalized.includes("sentinelayer-cli") ||
|
|
69
|
-
/[\\/](?:sl|sentinelayer-cli)\.js\b/.test(normalized);
|
|
99
|
+
const hasCliEntry = new RegExp(cliEntryPattern(platform)).test(normalized);
|
|
70
100
|
const sessionFlagMatch = commandLineFlagValueMatches(rawCommandLine, "session", normalizedSessionId);
|
|
71
101
|
const agentFlagMatch = commandLineFlagValueMatches(rawCommandLine, "agent", normalizedAgentId);
|
|
72
102
|
return (
|
|
@@ -84,22 +114,44 @@ function listenerCommandLineMatches(commandLine, { sessionId = "", agentId = ""
|
|
|
84
114
|
);
|
|
85
115
|
}
|
|
86
116
|
|
|
117
|
+
function optionalPositiveNumber(value) {
|
|
118
|
+
if (value === null || value === undefined || value === "") return null;
|
|
119
|
+
const number = Number(value);
|
|
120
|
+
return Number.isFinite(number) && number > 0 ? number : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A row is { pid, ppid, startedAtMs, name, commandLine }, one for every process the scan
|
|
124
|
+
// lists, so a parent chain can be followed through processes that are not this CLI. `ppid`
|
|
125
|
+
// (the parent's pid) and `startedAtMs` (when the process was created) are null, and `name`
|
|
126
|
+
// (the executable's file name) is "", when the scan did not give them. `commandLine` is ""
|
|
127
|
+
// when the scan left it out: the Windows scan keeps only the ones that name a CLI entry.
|
|
87
128
|
function normalizeProcessRows(rows = []) {
|
|
88
129
|
if (!Array.isArray(rows)) return [];
|
|
89
130
|
return rows
|
|
90
131
|
.map((row) => ({
|
|
91
132
|
pid: Number(row?.pid ?? row?.ProcessId ?? row?.processId),
|
|
133
|
+
ppid: optionalPositiveNumber(row?.ppid ?? row?.ParentProcessId),
|
|
134
|
+
startedAtMs: optionalPositiveNumber(row?.startedAtMs ?? row?.CreationMs),
|
|
135
|
+
name: normalizeString(row?.name ?? row?.Name),
|
|
92
136
|
commandLine: normalizeString(row?.commandLine ?? row?.CommandLine ?? row?.command ?? row?.args),
|
|
93
137
|
}))
|
|
94
|
-
.filter((row) => Number.isInteger(row.pid) && row.pid > 0
|
|
138
|
+
.filter((row) => Number.isInteger(row.pid) && row.pid > 0);
|
|
95
139
|
}
|
|
96
140
|
|
|
141
|
+
// Win32_Process.CreationDate as milliseconds since the epoch, for a PowerShell expression.
|
|
142
|
+
const WINDOWS_CREATION_MS = "if ($_.CreationDate) { ([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds() }";
|
|
143
|
+
|
|
144
|
+
// Every process, so the parent chain is complete, with its command line only when that names
|
|
145
|
+
// a CLI entry point, which keeps the output small: about 600 processes make some 60 KB of
|
|
146
|
+
// JSON and take under a second, as the filtered scan did.
|
|
97
147
|
async function listWindowsProcesses({ timeoutMs }) {
|
|
148
|
+
const namesCliEntry = `$_.CommandLine -and ($_.CommandLine -match '${cliEntryPattern("win32")}')`;
|
|
98
149
|
const command = [
|
|
99
150
|
"$ErrorActionPreference = 'Stop';",
|
|
100
151
|
"$rows = Get-CimInstance Win32_Process |",
|
|
101
|
-
"
|
|
102
|
-
|
|
152
|
+
"Select-Object ProcessId,ParentProcessId,Name,",
|
|
153
|
+
`@{n='CreationMs';e={ ${WINDOWS_CREATION_MS} }},`,
|
|
154
|
+
`@{n='CommandLine';e={ if (${namesCliEntry}) { $_.CommandLine } else { $null } }};`,
|
|
103
155
|
"if ($rows) { $rows | ConvertTo-Json -Compress }",
|
|
104
156
|
].join(" ");
|
|
105
157
|
const { stdout } = await execFileAsync(
|
|
@@ -113,20 +165,248 @@ async function listWindowsProcesses({ timeoutMs }) {
|
|
|
113
165
|
return normalizeProcessRows(Array.isArray(parsed) ? parsed : [parsed]);
|
|
114
166
|
}
|
|
115
167
|
|
|
168
|
+
// ps(1) prints elapsed time as [[dd-]hh:]mm:ss on Linux, macOS and busybox alike.
|
|
169
|
+
function parseElapsedSeconds(value) {
|
|
170
|
+
const match = normalizeString(value).match(/^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/);
|
|
171
|
+
if (!match) return null;
|
|
172
|
+
const [days, hours, minutes, seconds] = match.slice(1).map((part) => Number(part || 0));
|
|
173
|
+
return ((days * 24 + hours) * 60 + minutes) * 60 + seconds;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Start times come from ps's whole seconds of elapsed time, counted from just after
|
|
177
|
+
// `scannedAtMs`, so each is good to about a second (startTimeSlackMs).
|
|
116
178
|
async function listPosixProcesses({ timeoutMs }) {
|
|
117
|
-
const
|
|
179
|
+
const scannedAtMs = Date.now();
|
|
180
|
+
const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,etime=,command="], {
|
|
118
181
|
timeout: timeoutMs,
|
|
119
182
|
maxBuffer: 10 * 1024 * 1024,
|
|
120
183
|
});
|
|
121
184
|
return String(stdout || "")
|
|
122
185
|
.split(/\r?\n/)
|
|
123
186
|
.map((line) => {
|
|
124
|
-
const match = line.match(/^\s*(\d+)\s+(.*)
|
|
125
|
-
|
|
187
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(.*))?$/);
|
|
188
|
+
if (!match) return null;
|
|
189
|
+
const elapsedSeconds = parseElapsedSeconds(match[3]);
|
|
190
|
+
return {
|
|
191
|
+
pid: Number(match[1]),
|
|
192
|
+
ppid: Number(match[2]),
|
|
193
|
+
startedAtMs: elapsedSeconds === null ? null : scannedAtMs - elapsedSeconds * 1000,
|
|
194
|
+
commandLine: normalizeString(match[4]),
|
|
195
|
+
};
|
|
126
196
|
})
|
|
127
197
|
.filter(Boolean);
|
|
128
198
|
}
|
|
129
199
|
|
|
200
|
+
// How far apart two readings of one start time can be. Windows gives creation times to the
|
|
201
|
+
// millisecond. A POSIX reading can be up to a second late (whole seconds of elapsed time)
|
|
202
|
+
// and slightly early (the time ps takes to start), so 2 s covers it.
|
|
203
|
+
const POSIX_START_TIME_SLACK_MS = 2_000;
|
|
204
|
+
|
|
205
|
+
function startTimeSlackMs(platform = process.platform) {
|
|
206
|
+
return platform === "win32" ? 0 : POSIX_START_TIME_SLACK_MS;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// How far up the parent chain the walk goes. Real launch chains are a handful deep.
|
|
210
|
+
const MAX_ANCESTOR_DEPTH = 64;
|
|
211
|
+
|
|
212
|
+
// Whether `parent` (the row at `child`'s recorded parent pid) is still the process that
|
|
213
|
+
// started `child`. A parent is always created before its child, so a "parent" created after
|
|
214
|
+
// it holds a reused pid. On POSIX a recorded parent is always the live parent, because an
|
|
215
|
+
// exiting process's children are re-parented to init or a subreaper; so with no start times
|
|
216
|
+
// the link holds there. Windows never re-parents: the parent pid of a child whose parent
|
|
217
|
+
// exited can name any process started since, so there a link holds only when both creation
|
|
218
|
+
// times are known and in order.
|
|
219
|
+
function parentLinkHolds(parent, child, platform) {
|
|
220
|
+
if (Number.isFinite(parent.startedAtMs) && Number.isFinite(child.startedAtMs)) {
|
|
221
|
+
return parent.startedAtMs <= child.startedAtMs + startTimeSlackMs(platform);
|
|
222
|
+
}
|
|
223
|
+
return platform !== "win32";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The pids of a process's ancestors in one scan of the process table: its parent, that
|
|
228
|
+
* process's parent, and so on, while each parent is in the table, still the process that
|
|
229
|
+
* started its child (parentLinkHolds), not seen before (a cycle), and within
|
|
230
|
+
* MAX_ANCESTOR_DEPTH. The walk starts at the parent the table records for `pid`, or at
|
|
231
|
+
* `parentPid` when the table records none. Every row counts, with or without a command line,
|
|
232
|
+
* so the chain runs through processes that are not this CLI (cmd.exe, a service host).
|
|
233
|
+
*
|
|
234
|
+
* The walk ends early under Git Bash (MSYS) on Windows: an MSYS program such as bash, nohup or
|
|
235
|
+
* timeout is started from a forked copy of the shell that exits once the program runs, so its
|
|
236
|
+
* parent pid names no live process. Counting does not rest on the walk (a launcher is never
|
|
237
|
+
* counted, wherever it sits: readListenerScan); the walk marks the listeners --force must not
|
|
238
|
+
* stop, and a listener above such a break is not marked.
|
|
239
|
+
*/
|
|
240
|
+
export function listenerAncestorPids(
|
|
241
|
+
rows,
|
|
242
|
+
{ pid = process.pid, parentPid = null, platform = process.platform } = {},
|
|
243
|
+
) {
|
|
244
|
+
const table = new Map(normalizeProcessRows(rows).map((row) => [row.pid, row]));
|
|
245
|
+
const selfPid = Number(pid);
|
|
246
|
+
let child = table.get(selfPid) || { pid: selfPid, ppid: null, startedAtMs: null };
|
|
247
|
+
let next = child.ppid ?? optionalPositiveNumber(parentPid);
|
|
248
|
+
const ancestors = new Set();
|
|
249
|
+
while (next !== null && next !== selfPid && !ancestors.has(next) && ancestors.size < MAX_ANCESTOR_DEPTH) {
|
|
250
|
+
const parent = table.get(next);
|
|
251
|
+
if (!parent || !parentLinkHolds(parent, child, platform)) break;
|
|
252
|
+
ancestors.add(parent.pid);
|
|
253
|
+
child = parent;
|
|
254
|
+
next = parent.ppid;
|
|
255
|
+
}
|
|
256
|
+
return ancestors;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// The checking process's ancestors in a scan. When the checker is this process and its row
|
|
260
|
+
// records no parent, its parent is process.ppid.
|
|
261
|
+
function checkerAncestors(rows, selfPid) {
|
|
262
|
+
return listenerAncestorPids(rows, {
|
|
263
|
+
pid: selfPid,
|
|
264
|
+
parentPid: selfPid === process.pid ? process.ppid : null,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* An executable's name as the process kinds below compare it: its file name, without the
|
|
270
|
+
* directory, lower-cased, and without a trailing ".exe". So `C:\Program Files\nodejs\node.exe`,
|
|
271
|
+
* `/usr/bin/node` and `NODE` all read as "node".
|
|
272
|
+
*/
|
|
273
|
+
export function normalizeImageName(value) {
|
|
274
|
+
const base = normalizeString(value).split(/[\\/]/).pop().toLowerCase();
|
|
275
|
+
return base.endsWith(".exe") ? base.slice(0, -4) : base;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Executables that run this CLI as node. Also the checker's own node, whatever it is named.
|
|
279
|
+
const NODE_IMAGE_NAMES = ["node", "nodejs"];
|
|
280
|
+
// Executables that only start other programs: a command line naming the CLI there is the
|
|
281
|
+
// command they run, not this CLI running.
|
|
282
|
+
const LAUNCHER_IMAGE_NAMES = new Set([
|
|
283
|
+
"bash", "sh", "dash", "zsh", "cmd", "powershell", "pwsh", "nohup", "timeout", "env",
|
|
284
|
+
"screen", "tmux", "script", "setsid", "nice",
|
|
285
|
+
]);
|
|
286
|
+
// Node options known to take no value, so the next argument is not theirs.
|
|
287
|
+
const NODE_FLAGS = new Set([
|
|
288
|
+
"--inspect", "--inspect-brk", "--inspect-wait", "--no-warnings", "--no-deprecation",
|
|
289
|
+
"--trace-warnings", "--trace-deprecation", "--trace-uncaught", "--throw-deprecation",
|
|
290
|
+
"--pending-deprecation", "--enable-source-maps", "--expose-gc", "--preserve-symlinks",
|
|
291
|
+
"--preserve-symlinks-main", "--abort-on-uncaught-exception", "--experimental-vm-modules",
|
|
292
|
+
]);
|
|
293
|
+
// Of those, the ones that may also be given a value after "=" (--inspect=127.0.0.1:9229).
|
|
294
|
+
const NODE_FLAGS_WITH_OPTIONAL_VALUE = new Set(["--inspect", "--inspect-brk", "--inspect-wait"]);
|
|
295
|
+
// Node options known to take a value, as the next argument or after "=".
|
|
296
|
+
const NODE_OPTIONS_WITH_VALUE = new Set([
|
|
297
|
+
"--inspect-port", "--title", "-C", "--conditions", "--input-type", "--env-file",
|
|
298
|
+
"--disable-warning", "--redirect-warnings", "--max-old-space-size", "--stack-size",
|
|
299
|
+
]);
|
|
300
|
+
// Node options that load a module before the script; that module may be this CLI.
|
|
301
|
+
const NODE_PRELOAD_OPTIONS = new Set(["-r", "--require", "--import", "--loader", "--experimental-loader"]);
|
|
302
|
+
// Node options that run code given on the command line instead of a script.
|
|
303
|
+
const NODE_INLINE_CODE_OPTIONS = new Set(["-e", "--eval", "-p", "--print", "-pe", "-ep"]);
|
|
304
|
+
|
|
305
|
+
// A command line's arguments, in order, and whether they were read with certainty.
|
|
306
|
+
// - Windows quotes an argument with spaces, or an empty one, in double quotes. An empty
|
|
307
|
+
// argument ("") stays an argument, so a value option never takes the next one in its
|
|
308
|
+
// place. A quote left open, or one escaped with a backslash, is not read with certainty.
|
|
309
|
+
// - ps(1) prints POSIX arguments joined by single spaces with no quoting. An argument with a
|
|
310
|
+
// space reads as two (listenerProcessKind allows for that), and a run of spaces may hide an
|
|
311
|
+
// empty argument or an argument made of spaces, so it is not read with certainty. A quote
|
|
312
|
+
// character there is part of an argument, not quoting: it is kept as it is, and a command line
|
|
313
|
+
// that has one is not read with certainty, so no file name is recognised by removing it.
|
|
314
|
+
function commandLineArguments(commandLine, platform) {
|
|
315
|
+
const text = normalizeString(commandLine);
|
|
316
|
+
if (platform === "win32") {
|
|
317
|
+
const certain = (text.match(/"/g) || []).length % 2 === 0 && !/\\"/.test(text);
|
|
318
|
+
const parts = text.match(/(?:"[^"]*"?|[^\s"]+)+/g) || [];
|
|
319
|
+
return { args: parts.map((part) => part.replace(/"/g, "")), certain };
|
|
320
|
+
}
|
|
321
|
+
const parts = text.split(/\s+/);
|
|
322
|
+
return { args: parts.filter(Boolean), certain: !/\s{2,}/.test(text) && !/["']/.test(text) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* What a process whose command line matches a listen command is. Both kinds are allow-listed;
|
|
327
|
+
* anything else is "unknown", which the listener checks treat as possibly a listener, so
|
|
328
|
+
* starting and --force refuse.
|
|
329
|
+
* - "listener": this CLI running it. The executable is node (node, nodejs, or the name of the
|
|
330
|
+
* checker's own node) and node's first argument that is not a node option is a CLI entry
|
|
331
|
+
* point, read with certainty.
|
|
332
|
+
* - "wrapper": a launcher, which carries the command for the process it starts: an executable
|
|
333
|
+
* in LAUNCHER_IMAGE_NAMES (bash, sh, dash, zsh, cmd, powershell, pwsh, nohup, timeout, env,
|
|
334
|
+
* screen, tmux, script, setsid, nice); or node whose first non-option argument is certainly
|
|
335
|
+
* another script, or node running inline code (-e, -p) with no CLI entry among its arguments.
|
|
336
|
+
* - "unknown": anything else, including node whose arguments cannot be read with certainty: a
|
|
337
|
+
* preload option (-r, --require, --import, --loader), inline code next to a CLI entry, an
|
|
338
|
+
* option this does not know (it may take the next argument), a quote left open, or, on
|
|
339
|
+
* POSIX, a first argument that is not an entry (it may be the first piece of an entry path
|
|
340
|
+
* with a space, which ps prints unquoted).
|
|
341
|
+
* The executable is Win32_Process.Name on Windows and the first argument on POSIX, compared
|
|
342
|
+
* as normalizeImageName gives it.
|
|
343
|
+
*/
|
|
344
|
+
export function listenerProcessKind(
|
|
345
|
+
row,
|
|
346
|
+
{ platform = process.platform, nodeExecutable = process.execPath } = {},
|
|
347
|
+
) {
|
|
348
|
+
const { args, certain } = commandLineArguments(row?.commandLine, platform);
|
|
349
|
+
const image = normalizeImageName(normalizeString(row?.name) || args[0]);
|
|
350
|
+
if (LAUNCHER_IMAGE_NAMES.has(image)) return "wrapper";
|
|
351
|
+
const nodeImages = new Set([...NODE_IMAGE_NAMES, normalizeImageName(nodeExecutable)].filter(Boolean));
|
|
352
|
+
if (!nodeImages.has(image)) return "unknown";
|
|
353
|
+
if (!certain) return "unknown";
|
|
354
|
+
const entries =
|
|
355
|
+
platform === "win32" ? CLI_ENTRY_SCRIPTS : [...CLI_ENTRY_SCRIPTS, ...CLI_COMMAND_NAMES];
|
|
356
|
+
const entryPath = new RegExp(`[\\\\/](?:${entries.map(escapeRegExp).join("|")})$`);
|
|
357
|
+
const isEntry = (arg) => entryPath.test(normalizeString(arg).toLowerCase());
|
|
358
|
+
let index = 1;
|
|
359
|
+
for (; index < args.length; index += 1) {
|
|
360
|
+
const arg = args[index];
|
|
361
|
+
if (arg === "--") {
|
|
362
|
+
index += 1;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (!arg.startsWith("-") || arg === "-") break;
|
|
366
|
+
// Every spelling is judged by the option's name: --name, --name value, --name=value.
|
|
367
|
+
const equals = arg.indexOf("=");
|
|
368
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
369
|
+
const valueAttached = equals !== -1;
|
|
370
|
+
if (NODE_INLINE_CODE_OPTIONS.has(name)) {
|
|
371
|
+
return args.slice(1).some(isEntry) ? "unknown" : "wrapper";
|
|
372
|
+
}
|
|
373
|
+
if (NODE_PRELOAD_OPTIONS.has(name)) return "unknown";
|
|
374
|
+
if (NODE_OPTIONS_WITH_VALUE.has(name)) {
|
|
375
|
+
if (!valueAttached) index += 1;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (NODE_FLAGS.has(name) && (!valueAttached || NODE_FLAGS_WITH_OPTIONAL_VALUE.has(name))) continue;
|
|
379
|
+
return "unknown";
|
|
380
|
+
}
|
|
381
|
+
const script = args[index];
|
|
382
|
+
if (!script) return "unknown";
|
|
383
|
+
if (isEntry(script)) return "listener";
|
|
384
|
+
return platform === "win32" ? "wrapper" : "unknown";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* One scan read for one session and agent from the checking process's side. Every process
|
|
389
|
+
* whose command line matches, other than the checker, is judged by listenerProcessKind:
|
|
390
|
+
* `listeners` are the listeners, `unconfirmed` the processes that may be one, and launchers
|
|
391
|
+
* are in neither, wherever they sit. Ancestry is the second layer: a listener or possible
|
|
392
|
+
* listener that is an ancestor of the checker is marked `ancestor: true`, and the takeover
|
|
393
|
+
* never stops one.
|
|
394
|
+
*/
|
|
395
|
+
function readListenerScan(rows, { sessionId, agentId, selfPid, ancestors }) {
|
|
396
|
+
const listeners = [];
|
|
397
|
+
const unconfirmed = [];
|
|
398
|
+
for (const row of rows) {
|
|
399
|
+
if (row.pid === selfPid || !listenerCommandLineMatches(row.commandLine, { sessionId, agentId })) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const kind = listenerProcessKind(row);
|
|
403
|
+
if (kind === "wrapper") continue;
|
|
404
|
+
const marked = ancestors.has(row.pid) ? { ...row, ancestor: true } : row;
|
|
405
|
+
(kind === "listener" ? listeners : unconfirmed).push(marked);
|
|
406
|
+
}
|
|
407
|
+
return { listeners, unconfirmed };
|
|
408
|
+
}
|
|
409
|
+
|
|
130
410
|
// Resolves to the process rows, or rejects when the table cannot be read (a timeout, a
|
|
131
411
|
// missing tool, unreadable output). It never turns a failed read into an empty table.
|
|
132
412
|
async function listProcessTable({ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS } = {}) {
|
|
@@ -173,7 +453,10 @@ async function scanProcessTable(listProcesses, timeoutMs) {
|
|
|
173
453
|
/**
|
|
174
454
|
* Listener processes for this session and agent: { ok: true, matches } when the process
|
|
175
455
|
* table was read, { ok: false, reason } when it could not be. A failed scan has no
|
|
176
|
-
* `matches`, so it can never be read as "no listener".
|
|
456
|
+
* `matches`, so it can never be read as "no listener". The matches are the listeners and the
|
|
457
|
+
* processes that may be one (readListenerScan); the checking process (`excludePid`) and
|
|
458
|
+
* launchers never are, and a match that is an ancestor of the checker is marked
|
|
459
|
+
* `ancestor: true`.
|
|
177
460
|
*/
|
|
178
461
|
export async function listMatchingListenerProcesses(
|
|
179
462
|
sessionId,
|
|
@@ -186,14 +469,10 @@ export async function listMatchingListenerProcesses(
|
|
|
186
469
|
) {
|
|
187
470
|
const scan = await scanProcessTable(_listProcesses, timeoutMs);
|
|
188
471
|
if (!scan.ok) return scan;
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
row.pid !== excluded &&
|
|
194
|
-
listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
|
|
195
|
-
),
|
|
196
|
-
};
|
|
472
|
+
const selfPid = Number(excludePid);
|
|
473
|
+
const ancestors = checkerAncestors(scan.rows, selfPid);
|
|
474
|
+
const { listeners, unconfirmed } = readListenerScan(scan.rows, { sessionId, agentId, selfPid, ancestors });
|
|
475
|
+
return { ok: true, matches: [...listeners, ...unconfirmed] };
|
|
197
476
|
}
|
|
198
477
|
|
|
199
478
|
export async function summarizeLocalListenerProcesses(
|
|
@@ -213,16 +492,16 @@ export async function summarizeLocalListenerProcesses(
|
|
|
213
492
|
if (!scan.ok) {
|
|
214
493
|
return { ok: false, reason: scan.reason, sessionId: normalizedSessionId };
|
|
215
494
|
}
|
|
216
|
-
const
|
|
217
|
-
const
|
|
495
|
+
const selfPid = Number(excludePid);
|
|
496
|
+
const ancestors = checkerAncestors(scan.rows, selfPid);
|
|
218
497
|
const agents = uniqueAgentIds.map((agentId) => {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const pids =
|
|
498
|
+
const { listeners, unconfirmed } = readListenerScan(scan.rows, {
|
|
499
|
+
sessionId: normalizedSessionId,
|
|
500
|
+
agentId,
|
|
501
|
+
selfPid,
|
|
502
|
+
ancestors,
|
|
503
|
+
});
|
|
504
|
+
const pids = [...listeners, ...unconfirmed].map((match) => match.pid).sort((a, b) => a - b);
|
|
226
505
|
return {
|
|
227
506
|
agentId,
|
|
228
507
|
processCount: pids.length,
|
|
@@ -463,11 +742,50 @@ async function listenerStatusFromRecord(
|
|
|
463
742
|
};
|
|
464
743
|
}
|
|
465
744
|
|
|
745
|
+
/**
|
|
746
|
+
* What the process at a pid record's pid is, judged on one scan (`row` is its row there, or
|
|
747
|
+
* null; `commandLine` is the one read for the record, used when the row has none). A
|
|
748
|
+
* listener writes its record after it starts, so the process that wrote it started no later
|
|
749
|
+
* than the record's `startedAt`, within the platform's precision (startTimeSlackMs).
|
|
750
|
+
* - "confirmed": a listener (listenerProcessKind) that started by then: the one that wrote it.
|
|
751
|
+
* - "ancestor": one of the checking process's ancestors (`ancestors`). The scan judges those
|
|
752
|
+
* (readListenerScan), so the record adds nothing.
|
|
753
|
+
* - "wrapper": a launcher, which never wrote a listener's record.
|
|
754
|
+
* - "reused": a listener started after the record was written, so not the one that wrote it.
|
|
755
|
+
* - "gone": no longer in the process table.
|
|
756
|
+
* - "unconfirmed": a process that may be a listener, or a start time that is unknown, so the
|
|
757
|
+
* record proves nothing either way.
|
|
758
|
+
*/
|
|
759
|
+
export function pidRecordHolder(
|
|
760
|
+
record,
|
|
761
|
+
row,
|
|
762
|
+
{ ancestors = new Set(), commandLine = "", platform = process.platform } = {},
|
|
763
|
+
) {
|
|
764
|
+
if (!row) return "gone";
|
|
765
|
+
if (ancestors.has(row.pid)) return "ancestor";
|
|
766
|
+
const kind = listenerProcessKind(
|
|
767
|
+
{ name: row.name, commandLine: row.commandLine || commandLine },
|
|
768
|
+
{ platform },
|
|
769
|
+
);
|
|
770
|
+
if (kind === "wrapper") return "wrapper";
|
|
771
|
+
if (kind === "unknown") return "unconfirmed";
|
|
772
|
+
const recordedAtMs = Date.parse(normalizeString(record?.startedAt));
|
|
773
|
+
if (!Number.isFinite(row.startedAtMs) || !Number.isFinite(recordedAtMs)) return "unconfirmed";
|
|
774
|
+
return row.startedAtMs > recordedAtMs + startTimeSlackMs(platform) ? "reused" : "confirmed";
|
|
775
|
+
}
|
|
776
|
+
|
|
466
777
|
/**
|
|
467
778
|
* Whether a listener for this session and agent is running on this machine. `state` is
|
|
468
|
-
* "running", "not_running" or "unknown".
|
|
469
|
-
*
|
|
470
|
-
*
|
|
779
|
+
* "running", "not_running" or "unknown".
|
|
780
|
+
*
|
|
781
|
+
* A pid record counts when its pid is alive with a matching command line and one scan of the
|
|
782
|
+
* process table confirms it (pidRecordHolder). The same scan finds untracked listeners
|
|
783
|
+
* (readListenerScan). A running answer carries the process's `startedAtMs`, and `ancestor:
|
|
784
|
+
* true` when the listener is an ancestor of this process, which --force must never stop.
|
|
785
|
+
*
|
|
786
|
+
* Anything uncertain makes the answer "unknown" (`running: null`, with `reason`), never
|
|
787
|
+
* "not_running": a scan that could not be read, a record whose process cannot be confirmed
|
|
788
|
+
* ("pid_record_unconfirmed"), or a process that may be a listener ("listener_unconfirmed").
|
|
471
789
|
*/
|
|
472
790
|
export async function getListenerProcessStatus(
|
|
473
791
|
sessionId,
|
|
@@ -480,6 +798,7 @@ export async function getListenerProcessStatus(
|
|
|
480
798
|
_listProcesses = activeListProcessTable,
|
|
481
799
|
} = {},
|
|
482
800
|
) {
|
|
801
|
+
const listenerKey = normalizeListenerProcessKey(agentId);
|
|
483
802
|
const records = [
|
|
484
803
|
{
|
|
485
804
|
record: await readGlobalListenerPidRecord(sessionId, agentId, { homeDir }),
|
|
@@ -490,6 +809,7 @@ export async function getListenerProcessStatus(
|
|
|
490
809
|
recordScope: "local",
|
|
491
810
|
},
|
|
492
811
|
];
|
|
812
|
+
const tracked = [];
|
|
493
813
|
const staleStatuses = [];
|
|
494
814
|
for (const entry of records) {
|
|
495
815
|
if (!entry.record) continue;
|
|
@@ -499,38 +819,74 @@ export async function getListenerProcessStatus(
|
|
|
499
819
|
recordScope: entry.recordScope,
|
|
500
820
|
_readProcessCommandLine,
|
|
501
821
|
});
|
|
502
|
-
|
|
503
|
-
|
|
822
|
+
(status.running ? tracked : staleStatuses).push(status);
|
|
823
|
+
}
|
|
824
|
+
const unknown = (reason, record) => ({
|
|
825
|
+
state: "unknown",
|
|
826
|
+
running: null,
|
|
827
|
+
pid: null,
|
|
828
|
+
reason,
|
|
829
|
+
record: record || tracked[0]?.record || staleStatuses[0]?.record || null,
|
|
830
|
+
listenerKey,
|
|
831
|
+
});
|
|
832
|
+
const scan = await scanProcessTable(_listProcesses, scanTimeoutMs);
|
|
833
|
+
if (!scan.ok) return unknown(scan.reason);
|
|
834
|
+
|
|
835
|
+
const ancestors = checkerAncestors(scan.rows, process.pid);
|
|
836
|
+
const table = new Map(scan.rows.map((row) => [row.pid, row]));
|
|
837
|
+
const confirmed = [];
|
|
838
|
+
const unconfirmedRecords = [];
|
|
839
|
+
for (const status of tracked) {
|
|
840
|
+
const row = table.get(Number(status.pid)) || null;
|
|
841
|
+
const holder = pidRecordHolder(status.record, row, { ancestors, commandLine: status.commandLine });
|
|
842
|
+
if (holder === "confirmed") {
|
|
843
|
+
confirmed.push({ ...status, startedAtMs: row.startedAtMs, ancestor: false });
|
|
844
|
+
} else if (holder === "unconfirmed") {
|
|
845
|
+
unconfirmedRecords.push(status);
|
|
846
|
+
} else {
|
|
847
|
+
staleStatuses.push({
|
|
848
|
+
state: "not_running",
|
|
849
|
+
running: false,
|
|
850
|
+
pid: null,
|
|
851
|
+
stale: true,
|
|
852
|
+
reused: holder !== "gone",
|
|
853
|
+
holder,
|
|
854
|
+
record: status.record,
|
|
855
|
+
recordScope: status.recordScope,
|
|
856
|
+
listenerKey,
|
|
857
|
+
commandLine: status.commandLine,
|
|
858
|
+
});
|
|
504
859
|
}
|
|
505
|
-
staleStatuses.push(status);
|
|
506
860
|
}
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
861
|
+
const { listeners, unconfirmed } = readListenerScan(scan.rows, {
|
|
862
|
+
sessionId,
|
|
863
|
+
agentId,
|
|
864
|
+
selfPid: process.pid,
|
|
865
|
+
ancestors,
|
|
510
866
|
});
|
|
511
|
-
if (
|
|
512
|
-
return
|
|
513
|
-
state: "unknown",
|
|
514
|
-
running: null,
|
|
515
|
-
pid: null,
|
|
516
|
-
reason: scan.reason,
|
|
517
|
-
record: staleStatuses[0]?.record || null,
|
|
518
|
-
listenerKey: normalizeListenerProcessKey(agentId),
|
|
519
|
-
};
|
|
867
|
+
if (unconfirmedRecords.length > 0) {
|
|
868
|
+
return unknown("pid_record_unconfirmed", unconfirmedRecords[0].record);
|
|
520
869
|
}
|
|
521
|
-
|
|
522
|
-
|
|
870
|
+
if (unconfirmed.length > 0) return unknown("listener_unconfirmed");
|
|
871
|
+
// A listener that is an ancestor of this process is the one reported, so that --force
|
|
872
|
+
// refuses rather than stop it.
|
|
873
|
+
const ancestorListener = listeners.find((row) => row.ancestor);
|
|
874
|
+
if (confirmed.length > 0 && !ancestorListener) return confirmed[0];
|
|
875
|
+
const reported = ancestorListener || listeners[0];
|
|
876
|
+
if (reported) {
|
|
523
877
|
return {
|
|
524
878
|
state: "running",
|
|
525
879
|
running: true,
|
|
526
|
-
pid:
|
|
880
|
+
pid: reported.pid,
|
|
881
|
+
startedAtMs: reported.startedAtMs,
|
|
882
|
+
ancestor: Boolean(reported.ancestor),
|
|
527
883
|
stale: false,
|
|
528
884
|
record: null,
|
|
529
885
|
recordScope: "process_scan",
|
|
530
886
|
untracked: true,
|
|
531
|
-
listenerKey
|
|
532
|
-
commandLine:
|
|
533
|
-
matchingProcesses,
|
|
887
|
+
listenerKey,
|
|
888
|
+
commandLine: reported.commandLine,
|
|
889
|
+
matchingProcesses: listeners,
|
|
534
890
|
};
|
|
535
891
|
}
|
|
536
892
|
if (staleStatuses.length === 0) {
|
|
@@ -540,34 +896,78 @@ export async function getListenerProcessStatus(
|
|
|
540
896
|
pid: null,
|
|
541
897
|
stale: false,
|
|
542
898
|
record: null,
|
|
543
|
-
listenerKey
|
|
899
|
+
listenerKey,
|
|
544
900
|
};
|
|
545
901
|
}
|
|
546
902
|
return staleStatuses[0];
|
|
547
903
|
}
|
|
548
904
|
|
|
905
|
+
// Why the takeover's own scan does not bear out `listener`, the status check's answer, or
|
|
906
|
+
// null when it does. `row` is the scan's row for that pid (null when it is gone, which
|
|
907
|
+
// leaves nothing to stop for it), and `isListener` says whether the scan counts that row as
|
|
908
|
+
// a current listener for this session and agent (readListenerScan). The pid must name the
|
|
909
|
+
// same process the status check saw, by start time (exact on Windows, within 2 s on POSIX:
|
|
910
|
+
// startTimeSlackMs): otherwise it changed hands ("reused"), even when the new holder is a
|
|
911
|
+
// listener too. And it must still be a current listener; a start time that cannot be
|
|
912
|
+
// compared, or the same process no longer counted as a listener, is "unconfirmed".
|
|
913
|
+
function takeoverTargetRefusal(listener, row, { isListener, platform = process.platform }) {
|
|
914
|
+
if (!row) return null;
|
|
915
|
+
if (!Number.isFinite(listener?.startedAtMs) || !Number.isFinite(row.startedAtMs)) return "unconfirmed";
|
|
916
|
+
if (Math.abs(row.startedAtMs - listener.startedAtMs) > startTimeSlackMs(platform)) return "reused";
|
|
917
|
+
return isListener ? null : "unconfirmed";
|
|
918
|
+
}
|
|
919
|
+
|
|
549
920
|
/**
|
|
550
|
-
*
|
|
551
|
-
*
|
|
921
|
+
* The --force takeover. --force stops only current listeners for this session and agent, and
|
|
922
|
+
* refuses with no stops if the listener the status check saw has changed hands or can no
|
|
923
|
+
* longer be confirmed. One scan is taken, and the stop set is exactly that scan's listeners
|
|
924
|
+
* for this session and agent (readListenerScan): never the checker, never a launcher, and
|
|
925
|
+
* never the pid the status check found unless this scan counts it as a listener. Every
|
|
926
|
+
* refusal is decided before the first stop: a listener that is an ancestor of the checker
|
|
927
|
+
* ("ancestor_listener"), a process that may be a listener ("listener_unconfirmed"), or the
|
|
928
|
+
* status check's listener changed hands or unconfirmed (takeoverTargetRefusal). It returns
|
|
929
|
+
* { ok: true, refused: { pid, reason } } then. When the process table cannot be read it stops
|
|
930
|
+
* nothing and returns { ok: false, reason }; the caller then stops nothing either, since what
|
|
931
|
+
* it found earlier may no longer hold.
|
|
552
932
|
*/
|
|
553
933
|
export async function stopMatchingListenerProcesses(
|
|
554
934
|
sessionId,
|
|
555
935
|
agentId,
|
|
556
936
|
{
|
|
557
937
|
excludePid = process.pid,
|
|
938
|
+
listener = null,
|
|
558
939
|
timeoutMs = 3000,
|
|
559
940
|
pollIntervalMs = 100,
|
|
560
941
|
scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
|
|
561
942
|
_listProcesses = activeListProcessTable,
|
|
562
943
|
} = {},
|
|
563
944
|
) {
|
|
564
|
-
const scan = await
|
|
565
|
-
excludePid,
|
|
566
|
-
timeoutMs: scanTimeoutMs,
|
|
567
|
-
_listProcesses,
|
|
568
|
-
});
|
|
945
|
+
const scan = await scanProcessTable(_listProcesses, scanTimeoutMs);
|
|
569
946
|
if (!scan.ok) return scan;
|
|
570
|
-
const
|
|
947
|
+
const selfPid = Number(excludePid);
|
|
948
|
+
const ancestors = checkerAncestors(scan.rows, selfPid);
|
|
949
|
+
const { listeners, unconfirmed } = readListenerScan(scan.rows, { sessionId, agentId, selfPid, ancestors });
|
|
950
|
+
const refuse = (pid, reason) => ({
|
|
951
|
+
ok: true,
|
|
952
|
+
refused: { pid, reason },
|
|
953
|
+
matches: listeners,
|
|
954
|
+
results: [],
|
|
955
|
+
stoppedCount: 0,
|
|
956
|
+
failedCount: 0,
|
|
957
|
+
failed: [],
|
|
958
|
+
});
|
|
959
|
+
const ancestorListener = listeners.find((row) => row.ancestor);
|
|
960
|
+
if (ancestorListener) return refuse(ancestorListener.pid, "ancestor_listener");
|
|
961
|
+
if (unconfirmed.length > 0) return refuse(unconfirmed[0].pid, "listener_unconfirmed");
|
|
962
|
+
// Decide everything, then act: every refusal above and here comes before the first stop.
|
|
963
|
+
const matches = [...listeners];
|
|
964
|
+
const listenerPid = Number(listener?.pid);
|
|
965
|
+
if (Number.isInteger(listenerPid) && listenerPid > 0) {
|
|
966
|
+
const row = scan.rows.find((entry) => entry.pid === listenerPid) || null;
|
|
967
|
+
const isListener = matches.some((match) => match.pid === listenerPid);
|
|
968
|
+
const refusal = takeoverTargetRefusal(listener, row, { isListener });
|
|
969
|
+
if (refusal) return refuse(listenerPid, refusal);
|
|
970
|
+
}
|
|
571
971
|
const results = [];
|
|
572
972
|
for (const match of matches) {
|
|
573
973
|
const result = await requestListenerProcessStop(match.pid, {
|