@jskit-ai/assistant-core 0.1.199 → 0.1.201
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/examples/conversation/README.md +8 -1
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/App.vue +4 -1
- package/package.json +99 -36
- package/src/client/conversation/AssistantConversationElement.vue +27 -4
- package/src/client/conversation/AssistantConversationStatus.vue +24 -0
- package/src/client/conversation/AssistantGoalControl.vue +8 -5
- package/src/client/conversation/AssistantTranscript.vue +10 -3
- package/src/client/conversation/index.js +2 -0
- package/src/client/conversation/messageDelivery.js +115 -32
- package/src/client/conversation/retainedConversation.js +32 -0
- package/src/client/conversation/useAssistantQuestions.js +76 -0
- package/src/client/lib/assistantApi.js +72 -2
- package/src/server/conversation/agentRun.js +222 -0
- package/src/server/conversation/attachments.js +93 -0
- package/src/server/conversation/claudeClient.js +221 -0
- package/src/server/conversation/claudeHistory.js +256 -0
- package/src/server/conversation/claudeProcess.js +327 -0
- package/src/server/conversation/claudeTools.js +42 -0
- package/src/server/conversation/claudeTurn.js +1082 -0
- package/src/server/conversation/codexConfiguration.js +1183 -0
- package/src/server/conversation/codexConversations.js +1082 -0
- package/src/server/conversation/codexDetachedTurn.js +332 -0
- package/src/server/conversation/codexEvents.js +194 -1
- package/src/server/conversation/codexGoalCommands.js +124 -0
- package/src/server/conversation/codexHelperExecution.js +306 -0
- package/src/server/conversation/codexHelperThreadLedger.js +804 -0
- package/src/server/conversation/codexHelperThreadRecovery.js +395 -0
- package/src/server/conversation/codexHelperThreads.js +638 -0
- package/src/server/conversation/codexHistoryAdapter.js +369 -0
- package/src/server/conversation/codexMessageCommands.js +663 -0
- package/src/server/conversation/codexMessageDelivery.js +211 -0
- package/src/server/conversation/codexNativeHistoryExport.js +95 -0
- package/src/server/conversation/codexNotificationObserver.js +482 -0
- package/src/server/conversation/codexNotificationQueue.js +103 -0
- package/src/server/conversation/codexObservationControl.js +174 -0
- package/src/server/conversation/codexOutput.js +931 -0
- package/src/server/conversation/codexProcess.js +1559 -0
- package/src/server/conversation/codexProcessLeader.js +14 -0
- package/src/server/conversation/codexProvider.js +2710 -0
- package/src/server/conversation/codexProviderOwner.js +1058 -0
- package/src/server/conversation/codexProviderSelection.js +218 -0
- package/src/server/conversation/codexReasoning.js +314 -0
- package/src/server/conversation/codexRenewalCommands.js +290 -0
- package/src/server/conversation/codexResultSettlement.js +409 -0
- package/src/server/conversation/codexRunJournal.js +658 -0
- package/src/server/conversation/codexRuntimeLifecycle.js +288 -0
- package/src/server/conversation/codexSessionCleanup.js +96 -0
- package/src/server/conversation/codexSessionReconciliation.js +284 -0
- package/src/server/conversation/codexThreadReadiness.js +397 -0
- package/src/server/conversation/codexThreadStatus.js +354 -0
- package/src/server/conversation/codexTurn.js +701 -255
- package/src/server/conversation/codexTurnControl.js +317 -0
- package/src/server/conversation/codexTurnRecovery.js +990 -0
- package/src/server/conversation/codexTurnState.js +549 -0
- package/src/server/conversation/commandHook.js +18 -0
- package/src/server/conversation/commandWrapper.js +37 -0
- package/src/server/conversation/configuration.js +43 -0
- package/src/server/conversation/continuity.js +289 -0
- package/src/server/conversation/fileStorage.js +55 -0
- package/src/server/conversation/hookBridge.js +19 -0
- package/src/server/conversation/index.js +13 -0
- package/src/server/conversation/localExecution.js +665 -0
- package/src/server/conversation/memoryStorage.js +7 -72
- package/src/server/conversation/nativeHistoryExport.js +90 -0
- package/src/server/conversation/nativeStorage.js +23 -0
- package/src/server/conversation/openCodeClient.js +209 -19
- package/src/server/conversation/openCodePlugin.js +84 -0
- package/src/server/conversation/openCodeProcess.js +476 -0
- package/src/server/conversation/openCodeRuntime.js +1578 -0
- package/src/server/conversation/openCodeTools.js +60 -0
- package/src/server/conversation/openCodeTurn.js +557 -0
- package/src/server/conversation/provider.js +30 -0
- package/src/server/conversation/providers/api.js +136 -0
- package/src/server/conversation/providers/claude.js +63 -0
- package/src/server/conversation/providers/claudeDriver.js +425 -0
- package/src/server/conversation/providers/codex.js +248 -0
- package/src/server/conversation/providers/codexDriver.js +1078 -0
- package/src/server/conversation/providers/opencode.js +49 -0
- package/src/server/conversation/providers/opencodeDriver.js +650 -0
- package/src/server/conversation/providers/scoped.js +312 -0
- package/src/server/conversation/runtime.js +2220 -0
- package/src/server/conversation/runtimeStateUpgrade.js +125 -0
- package/src/server/conversation/storage.js +184 -0
- package/src/server/conversation/streams.js +7 -2
- package/src/server/conversation/structuredOutput.js +240 -0
- package/src/server/conversation/systemPrompt.js +54 -0
- package/src/server/conversation/tools.js +84 -0
- package/src/server/conversation/transcript.js +5 -4
- package/src/server/hosts/claudeHistory.js +5 -0
- package/src/server/hosts/claudeProcess.js +11 -0
- package/src/server/hosts/claudeTurn.js +4 -0
- package/src/server/hosts/codexConfiguration.js +20 -0
- package/src/server/hosts/codexEvents.js +13 -0
- package/src/server/hosts/codexProcess.js +12 -0
- package/src/server/hosts/codexProvider.js +23 -0
- package/src/server/hosts/codexTurn.js +20 -0
- package/src/server/hosts/nativeHistory.js +3 -0
- package/src/server/hosts/openCodeClient.js +3 -0
- package/src/server/hosts/openCodeProcess.js +23 -0
- package/src/server/hosts/openCodeTurn.js +7 -0
- package/src/server/index.js +2 -0
- package/src/server/lib/aiConnectionClient.js +13 -6
- package/src/server/lib/assistantCompletion.js +190 -0
- package/src/server/lib/assistantToolLoop.js +314 -0
- package/src/server/lib/serviceToolCatalog.js +25 -10
- package/src/shared/assistantResponseText.js +29 -0
- package/src/shared/conversation/attachments.js +19 -0
- package/src/shared/conversation/index.js +3 -0
- package/src/shared/conversation/pagination.js +78 -0
- package/src/shared/conversation/patches.js +150 -0
- package/src/shared/conversation/questions.js +48 -0
- package/src/shared/conversation/streaming.js +7 -4
- package/src/shared/index.js +2 -0
- package/src/shared/nativeProviders.js +45 -0
- package/src/testing/conversationStorageContract.js +31 -1
- package/src/testing/nativeCodex.js +11 -0
- package/src/testing/nativeOpenCode.js +3 -0
- package/test/aiConnectionClient.test.js +2 -0
- package/test/apiConversationDriver.test.js +73 -0
- package/test/assistantApiSurfaceHeader.test.js +85 -0
- package/test/assistantBoundedToolLoop.test.js +285 -0
- package/test/assistantScroll.browser.test.js +16 -0
- package/test/claudeClient.test.js +189 -0
- package/test/claudeConversationSession.test.js +1173 -0
- package/test/claudeInstructionResume.test.js +82 -0
- package/test/claudeProcess.test.js +204 -0
- package/test/claudeTurn.test.js +253 -0
- package/test/codexConversationSession.test.js +2122 -0
- package/test/codexGoal.test.js +37 -0
- package/test/codexHelperDeletion.test.js +163 -0
- package/test/codexHelperThreadLedger.test.js +494 -0
- package/test/codexHistoryAdapter.test.js +541 -0
- package/test/codexInteractiveArguments.test.js +30 -0
- package/test/codexIsolation.test.js +756 -0
- package/test/codexModelCatalog.test.js +362 -0
- package/test/codexNativeHistoryExport.test.js +81 -0
- package/test/codexThreadSelection.test.js +136 -0
- package/test/codexTurn.test.js +2158 -0
- package/test/conversation.test.js +110 -0
- package/test/conversationAgentRun.test.js +326 -0
- package/test/conversationApiFacilities.test.js +90 -0
- package/test/conversationAttachments.test.js +238 -0
- package/test/conversationCommandWrapper.test.js +48 -0
- package/test/conversationConfiguration.test.js +174 -0
- package/test/conversationNativeStorage.test.js +25 -0
- package/test/conversationReplacement.test.js +172 -0
- package/test/conversationRuntime.test.js +241 -0
- package/test/conversationRuntimeStateUpgrade.test.js +144 -0
- package/test/conversationSession.test.js +1080 -0
- package/test/conversationStreaming.test.js +68 -0
- package/test/conversationSystemPrompt.test.js +84 -0
- package/test/conversationTools.test.js +416 -0
- package/test/fileConversationStorage.test.js +73 -0
- package/test/fixtures/codexHelperThreadLedger.js +39 -0
- package/test/localConversationExecution.test.js +90 -0
- package/test/messageDelivery.test.js +234 -1
- package/test/nativeConversationRetirement.test.js +215 -0
- package/test/openCodeClient.test.js +515 -0
- package/test/openCodeProcess.test.js +177 -0
- package/test/openCodeRuntime.test.js +322 -0
- package/test/openCodeTools.test.js +287 -0
- package/test/opencodeConversationSession.test.js +789 -0
- package/test/serviceToolCatalog.test.js +24 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, openSync } from "node:fs";
|
|
4
|
+
import { mkdtemp, open, readFile, readdir, rm } from "node:fs/promises";
|
|
5
|
+
import { isPlainObject, normalizeText } from "./normalize.js";
|
|
6
|
+
import { isAbsolute, join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
9
|
+
|
|
10
|
+
/** Owned POSIX process groups for a trusted, single-user application.
|
|
11
|
+
* Managed hosts replace this facility to enforce resource and identity policy.
|
|
12
|
+
*/
|
|
13
|
+
export function createLocalConversationExecution() {
|
|
14
|
+
if (process.platform === "win32") {
|
|
15
|
+
throw new Error("The local native-agent host requires POSIX process groups. Supply a Windows execution host or use an API connection.");
|
|
16
|
+
}
|
|
17
|
+
const executions = new Map();
|
|
18
|
+
let closing = false;
|
|
19
|
+
|
|
20
|
+
function groupExists(pid) {
|
|
21
|
+
try { process.kill(-pid, 0); return true; }
|
|
22
|
+
catch (error) { if (error.code === "ESRCH") return false; throw error; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function signalGroup(pid, signal) {
|
|
26
|
+
try { process.kill(-pid, signal); }
|
|
27
|
+
catch (error) { if (error.code !== "ESRCH") throw error; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function stop(id, { allowMissingRecordScopeRecovery = false } = {}) {
|
|
31
|
+
const entry = executions.get(id);
|
|
32
|
+
if (!entry) {
|
|
33
|
+
// A native runtime can use its own persisted identity proof. Absence here
|
|
34
|
+
// is not that proof and never authorizes signalling by this facility.
|
|
35
|
+
if (allowMissingRecordScopeRecovery) return { ok: false, scopeEmpty: false };
|
|
36
|
+
throw new Error("This host does not own the requested execution.");
|
|
37
|
+
}
|
|
38
|
+
if (entry.stopping) return entry.stopping;
|
|
39
|
+
entry.stopping = (async () => {
|
|
40
|
+
try { await entry.started; }
|
|
41
|
+
catch { await entry.exited; executions.delete(id); return { scopeEmpty: true }; }
|
|
42
|
+
if (groupExists(entry.child.pid)) {
|
|
43
|
+
if (entry.child.exitCode !== null || entry.child.signalCode !== null) {
|
|
44
|
+
throw new Error("The native process exited but its process group remains. This host cannot safely reclaim it; inspect the local process group before retrying cleanup.");
|
|
45
|
+
}
|
|
46
|
+
signalGroup(entry.child.pid, "SIGTERM");
|
|
47
|
+
const deadline = Date.now() + 3_000;
|
|
48
|
+
while (groupExists(entry.child.pid) && Date.now() < deadline) await delay(25);
|
|
49
|
+
if (groupExists(entry.child.pid) && entry.child.exitCode === null && entry.child.signalCode === null) {
|
|
50
|
+
signalGroup(entry.child.pid, "SIGKILL");
|
|
51
|
+
}
|
|
52
|
+
const killDeadline = Date.now() + 1_000;
|
|
53
|
+
while (groupExists(entry.child.pid) && Date.now() < killDeadline) await delay(25);
|
|
54
|
+
}
|
|
55
|
+
if (groupExists(entry.child.pid)) {
|
|
56
|
+
throw new Error("The owned native process group has not exited. Cleanup must finish before replacing it.");
|
|
57
|
+
}
|
|
58
|
+
await entry.exited;
|
|
59
|
+
executions.delete(id);
|
|
60
|
+
return { scopeEmpty: true };
|
|
61
|
+
})().finally(() => { entry.stopping = null; });
|
|
62
|
+
return entry.stopping;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function start({ command, args = [], cwd, env = process.env, stream = false, limits, logPath } = {}) {
|
|
66
|
+
if (closing) throw new Error("The conversation execution host is closed.");
|
|
67
|
+
if (typeof command !== "string" || !command || !Array.isArray(args) || args.some((arg) => typeof arg !== "string")) {
|
|
68
|
+
throw new TypeError("An executable and string arguments are required.");
|
|
69
|
+
}
|
|
70
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd)) throw new TypeError("Native execution requires an absolute working directory.");
|
|
71
|
+
if (limits && Object.keys(limits).length) {
|
|
72
|
+
throw new Error("The local execution host cannot enforce resource limits. Supply a managed execution host.");
|
|
73
|
+
}
|
|
74
|
+
if (logPath !== undefined && !isAbsolute(logPath)) throw new TypeError("Native logs require an absolute path.");
|
|
75
|
+
let logDescriptor;
|
|
76
|
+
let child;
|
|
77
|
+
try {
|
|
78
|
+
if (logPath) logDescriptor = openSync(logPath, "a", 0o600);
|
|
79
|
+
child = spawn(command, args, { cwd, env, detached: true,
|
|
80
|
+
stdio: stream ? ["pipe", "pipe", logDescriptor ?? "ignore"]
|
|
81
|
+
: ["ignore", logDescriptor ?? "ignore", logDescriptor ?? "ignore"] });
|
|
82
|
+
} finally { if (logDescriptor !== undefined) closeSync(logDescriptor); }
|
|
83
|
+
const id = randomUUID();
|
|
84
|
+
const started = new Promise((resolve, reject) => {
|
|
85
|
+
child.once("spawn", resolve);
|
|
86
|
+
child.once("error", reject);
|
|
87
|
+
});
|
|
88
|
+
const exited = new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal })));
|
|
89
|
+
const entry = { child, started, exited, stopping: null };
|
|
90
|
+
executions.set(id, entry);
|
|
91
|
+
try { await started; }
|
|
92
|
+
catch (cause) {
|
|
93
|
+
await exited;
|
|
94
|
+
executions.delete(id);
|
|
95
|
+
throw new Error(`Could not start ${command}. Check the executable and working directory.`, { cause });
|
|
96
|
+
}
|
|
97
|
+
if (closing) {
|
|
98
|
+
if (executions.has(id)) await stop(id);
|
|
99
|
+
throw new Error("The conversation execution host closed during startup.");
|
|
100
|
+
}
|
|
101
|
+
return Object.freeze({ id, pid: child.pid, stdin: child.stdin, stdout: child.stdout, exited,
|
|
102
|
+
get running() { return child.exitCode === null && child.signalCode === null; } });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return Object.freeze({
|
|
106
|
+
start,
|
|
107
|
+
async run({ command, args, cwd, baseEnv = process.env, credentialHome = {}, mode,
|
|
108
|
+
logPath, timeout = 30_000, limits } = {}) {
|
|
109
|
+
if (!["capture", "detached"].includes(mode)) throw new TypeError("Native execution requires capture or detached mode.");
|
|
110
|
+
if (!Number.isSafeInteger(timeout) || timeout <= 0) throw new TypeError("Native execution requires a positive timeout.");
|
|
111
|
+
const env = { ...baseEnv };
|
|
112
|
+
if (credentialHome.home) {
|
|
113
|
+
if (!isAbsolute(credentialHome.home)) throw new TypeError("A credential home must be absolute.");
|
|
114
|
+
Object.assign(env, { HOME: credentialHome.home,
|
|
115
|
+
XDG_CACHE_HOME: join(credentialHome.home, ".cache"),
|
|
116
|
+
XDG_CONFIG_HOME: join(credentialHome.home, ".config"),
|
|
117
|
+
XDG_DATA_HOME: join(credentialHome.home, ".local", "share") });
|
|
118
|
+
if (credentialHome.username) Object.assign(env, { USER: credentialHome.username, LOGNAME: credentialHome.username });
|
|
119
|
+
}
|
|
120
|
+
const captureRoot = mode === "capture" ? await mkdtemp(join(tmpdir(), "jskit-capture-")) : null;
|
|
121
|
+
const outputPath = captureRoot ? join(captureRoot, "output.log") : logPath;
|
|
122
|
+
let child;
|
|
123
|
+
let timer;
|
|
124
|
+
let cleanup = false;
|
|
125
|
+
try {
|
|
126
|
+
child = await start({ command, args, cwd, env, logPath: outputPath, limits });
|
|
127
|
+
if (mode === "detached") {
|
|
128
|
+
// A shared account service outlives the application's observer. Its
|
|
129
|
+
// durable runtime owner, not this Node event loop, controls shutdown.
|
|
130
|
+
executions.get(child.id).child.unref();
|
|
131
|
+
return { ok: true, pid: child.pid, execution: { id: child.id } };
|
|
132
|
+
}
|
|
133
|
+
let timedOut = false;
|
|
134
|
+
const result = await Promise.race([child.exited, new Promise((resolve, reject) => {
|
|
135
|
+
timer = setTimeout(() => {
|
|
136
|
+
timedOut = true;
|
|
137
|
+
stop(child.id).then(() => child.exited).then(resolve, reject);
|
|
138
|
+
}, timeout);
|
|
139
|
+
})]);
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
// The timeout may have already retired this exact execution.
|
|
142
|
+
if (executions.has(child.id)) await stop(child.id);
|
|
143
|
+
cleanup = true;
|
|
144
|
+
const file = await open(outputPath, "r");
|
|
145
|
+
let output;
|
|
146
|
+
try {
|
|
147
|
+
const { size } = await file.stat();
|
|
148
|
+
const bytes = Buffer.alloc(Math.min(size, 64 * 1024));
|
|
149
|
+
const { bytesRead } = await file.read(bytes, 0, bytes.length, Math.max(0, size - bytes.length));
|
|
150
|
+
output = bytes.subarray(0, bytesRead).toString("utf8");
|
|
151
|
+
} finally { await file.close(); }
|
|
152
|
+
return { ok: !timedOut && result.code === 0, exitCode: result.code, signal: result.signal, timedOut, output };
|
|
153
|
+
} finally {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
if (captureRoot && (!child || cleanup)) await rm(captureRoot, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
stop,
|
|
159
|
+
async close() {
|
|
160
|
+
closing = true;
|
|
161
|
+
const results = await Promise.allSettled([...executions.keys()].map(stop));
|
|
162
|
+
const errors = results.filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
163
|
+
if (errors.length) throw new AggregateError(errors, "Some owned native executions could not be stopped.");
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Process-group ownership proof used by execution hosts; callers retain their metadata contract. */
|
|
169
|
+
export function createConversationProcessIdentity({
|
|
170
|
+
runtimeTokenEnvironmentName = "JSKIT_EXECUTION_RUNTIME_TOKEN",
|
|
171
|
+
commandHashEnvironmentName = "JSKIT_EXECUTION_COMMAND_HASH",
|
|
172
|
+
label = "Native",
|
|
173
|
+
unavailableCode = "native_process_identity_unavailable"
|
|
174
|
+
} = {}) {
|
|
175
|
+
const PROCESS_IDENTITY_SETTLE_TIMEOUT_MS = 1000;
|
|
176
|
+
const PROCESS_IDENTITY_SETTLE_POLL_MS = 25;
|
|
177
|
+
const PROCESS_IDENTITY_VERSION = 1;
|
|
178
|
+
const PROCESS_IDENTITY_PLATFORM = "linux-proc";
|
|
179
|
+
const PROCESS_STATE = Object.freeze({ RUNNING: "running", STOPPED: "stopped" });
|
|
180
|
+
const PROCESS_IDENTITY_STATUS = Object.freeze({
|
|
181
|
+
ABSENT: "absent", AMBIGUOUS: "ambiguous", EXACT: "exact", INVALID: "invalid", MISMATCH: "mismatch"
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
function normalizePositiveInteger(value, fallback) {
|
|
185
|
+
const number = Number(value);
|
|
186
|
+
return Number.isSafeInteger(number) && number > 0 ? number : fallback;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function processMetadataIsIdentifiable(metadata = {}) {
|
|
190
|
+
return Boolean(
|
|
191
|
+
Number.isSafeInteger(Number(metadata.pid)) && Number(metadata.pid) > 0 &&
|
|
192
|
+
processIdentityIsWellFormed(metadata.processIdentity) &&
|
|
193
|
+
[PROCESS_STATE.RUNNING, PROCESS_STATE.STOPPED].includes(metadata.processState)
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function processIsAlive(pid) {
|
|
198
|
+
const normalizedPid = Number(pid);
|
|
199
|
+
if (!Number.isSafeInteger(normalizedPid) || normalizedPid <= 0) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
process.kill(normalizedPid, 0);
|
|
204
|
+
return true;
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function processGroupIsAlive(processGroupId) {
|
|
211
|
+
const normalizedProcessGroupId = Number(processGroupId);
|
|
212
|
+
if (!Number.isSafeInteger(normalizedProcessGroupId) || normalizedProcessGroupId <= 0) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (process.platform === "win32") {
|
|
216
|
+
return processIsAlive(normalizedProcessGroupId);
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
process.kill(-normalizedProcessGroupId, 0);
|
|
220
|
+
return true;
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function signalProcessGroup(processGroupId, signal) {
|
|
227
|
+
const normalizedProcessGroupId = Number(processGroupId);
|
|
228
|
+
const target = process.platform === "win32"
|
|
229
|
+
? normalizedProcessGroupId
|
|
230
|
+
: -normalizedProcessGroupId;
|
|
231
|
+
try {
|
|
232
|
+
process.kill(target, signal);
|
|
233
|
+
return true;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (!["ESRCH", "EPERM"].includes(String(error?.code || ""))) {
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function linuxProcessStat(value = "") {
|
|
243
|
+
const text = String(value || "");
|
|
244
|
+
const commandEnd = text.lastIndexOf(")");
|
|
245
|
+
if (commandEnd < 0) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const fields = text.slice(commandEnd + 1).trim().split(/\s+/u);
|
|
249
|
+
const state = String(fields[0] || "");
|
|
250
|
+
const parentPid = Number(fields[1]);
|
|
251
|
+
const processGroupId = Number(fields[2]);
|
|
252
|
+
const startTimeTicks = String(fields[19] || "");
|
|
253
|
+
if (
|
|
254
|
+
!/^[A-Z]$/u.test(state) ||
|
|
255
|
+
!Number.isSafeInteger(parentPid) || parentPid < 0 ||
|
|
256
|
+
!Number.isSafeInteger(processGroupId) || processGroupId <= 0 ||
|
|
257
|
+
!/^\d+$/u.test(startTimeTicks)
|
|
258
|
+
) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
parentPid,
|
|
263
|
+
processGroupId,
|
|
264
|
+
startTimeTicks,
|
|
265
|
+
state
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function normalizeProcessIdentity(value = {}) {
|
|
270
|
+
const normalized = isPlainObject(value) ? value : {};
|
|
271
|
+
return {
|
|
272
|
+
commandHash: normalizeText(normalized.commandHash),
|
|
273
|
+
platform: normalizeText(normalized.platform),
|
|
274
|
+
runtimeToken: normalizeText(normalized.runtimeToken),
|
|
275
|
+
startTimeTicks: normalizeText(normalized.startTimeTicks),
|
|
276
|
+
version: Number(normalized.version || 0)
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function processIdentityIsWellFormed(value = {}) {
|
|
281
|
+
const identity = normalizeProcessIdentity(value);
|
|
282
|
+
return Boolean(
|
|
283
|
+
identity.version === PROCESS_IDENTITY_VERSION &&
|
|
284
|
+
identity.platform === PROCESS_IDENTITY_PLATFORM &&
|
|
285
|
+
/^[a-f0-9]{12}$/u.test(identity.commandHash) &&
|
|
286
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(identity.runtimeToken) &&
|
|
287
|
+
/^\d+$/u.test(identity.startTimeTicks)
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function linuxProcessEnvironmentValue(buffer = Buffer.alloc(0), name = "") {
|
|
292
|
+
const prefix = `${name}=`;
|
|
293
|
+
for (const entry of buffer.toString("utf8").split("\0")) {
|
|
294
|
+
if (entry.startsWith(prefix)) {
|
|
295
|
+
return entry.slice(prefix.length);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return "";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function linuxProcessRecords() {
|
|
302
|
+
const records = [];
|
|
303
|
+
const entries = await readdir("/proc", {
|
|
304
|
+
withFileTypes: true
|
|
305
|
+
}).catch(() => []);
|
|
306
|
+
for (const entry of entries) {
|
|
307
|
+
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const pid = Number(entry.name);
|
|
311
|
+
const statValue = await readFile(`/proc/${entry.name}/stat`, "utf8").catch(() => "");
|
|
312
|
+
const processStat = linuxProcessStat(statValue);
|
|
313
|
+
if (!processStat || processStat.state === "Z") {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
let environment = null;
|
|
317
|
+
let environmentReadable = true;
|
|
318
|
+
try {
|
|
319
|
+
environment = await readFile(`/proc/${entry.name}/environ`);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (String(error?.code || "") === "ENOENT") {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
environmentReadable = false;
|
|
325
|
+
}
|
|
326
|
+
records.push({
|
|
327
|
+
...processStat,
|
|
328
|
+
environment,
|
|
329
|
+
environmentReadable,
|
|
330
|
+
pid
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return records;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function normalizeProcessIdentityInspection(value = {}) {
|
|
337
|
+
const status = Object.values(PROCESS_IDENTITY_STATUS).includes(value?.status)
|
|
338
|
+
? value.status
|
|
339
|
+
: PROCESS_IDENTITY_STATUS.AMBIGUOUS;
|
|
340
|
+
return {
|
|
341
|
+
processGroupIds: [...new Set((Array.isArray(value?.processGroupIds)
|
|
342
|
+
? value.processGroupIds
|
|
343
|
+
: [])
|
|
344
|
+
.map(Number)
|
|
345
|
+
.filter((processGroupId) => Number.isSafeInteger(processGroupId) && processGroupId > 0))],
|
|
346
|
+
status
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function inspectLinuxProcessIdentity(metadata = {}) {
|
|
351
|
+
if (!processMetadataIsIdentifiable(metadata)) {
|
|
352
|
+
return {
|
|
353
|
+
processGroupIds: [],
|
|
354
|
+
status: PROCESS_IDENTITY_STATUS.INVALID
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (metadata.processState === PROCESS_STATE.STOPPED) {
|
|
358
|
+
return {
|
|
359
|
+
processGroupIds: [],
|
|
360
|
+
status: PROCESS_IDENTITY_STATUS.ABSENT
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
if (process.platform !== "linux") {
|
|
364
|
+
return {
|
|
365
|
+
processGroupIds: [],
|
|
366
|
+
status: PROCESS_IDENTITY_STATUS.AMBIGUOUS
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const identity = metadata.processIdentity;
|
|
370
|
+
const records = await linuxProcessRecords();
|
|
371
|
+
const rootProcessGroupId = Number(metadata.pid);
|
|
372
|
+
const rootGroupMembers = records.filter(({ processGroupId }) => processGroupId === rootProcessGroupId);
|
|
373
|
+
const exactRecords = [];
|
|
374
|
+
const rootUnreadable = rootGroupMembers.some(({ environmentReadable }) => !environmentReadable);
|
|
375
|
+
for (const record of records) {
|
|
376
|
+
if (!record.environmentReadable) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const runtimeToken = linuxProcessEnvironmentValue(
|
|
380
|
+
record.environment,
|
|
381
|
+
runtimeTokenEnvironmentName
|
|
382
|
+
);
|
|
383
|
+
const commandHash = linuxProcessEnvironmentValue(
|
|
384
|
+
record.environment,
|
|
385
|
+
commandHashEnvironmentName
|
|
386
|
+
);
|
|
387
|
+
if (runtimeToken === identity.runtimeToken && commandHash === identity.commandHash) {
|
|
388
|
+
exactRecords.push(record);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const exactLeader = exactRecords.find(({ pid }) => pid === rootProcessGroupId);
|
|
392
|
+
if (exactLeader && exactLeader.startTimeTicks !== identity.startTimeTicks) {
|
|
393
|
+
return {
|
|
394
|
+
processGroupIds: [],
|
|
395
|
+
status: PROCESS_IDENTITY_STATUS.AMBIGUOUS
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
if (exactRecords.length === 0) {
|
|
399
|
+
if (rootUnreadable) {
|
|
400
|
+
return {
|
|
401
|
+
processGroupIds: [],
|
|
402
|
+
status: PROCESS_IDENTITY_STATUS.AMBIGUOUS
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
processGroupIds: [],
|
|
407
|
+
status: rootGroupMembers.length > 0 || processGroupIsAlive(rootProcessGroupId)
|
|
408
|
+
? PROCESS_IDENTITY_STATUS.MISMATCH
|
|
409
|
+
: PROCESS_IDENTITY_STATUS.ABSENT
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const processGroupIds = [...new Set(exactRecords.map(({ processGroupId }) => processGroupId))];
|
|
413
|
+
for (const processGroupId of processGroupIds) {
|
|
414
|
+
const groupMembers = records.filter((record) => record.processGroupId === processGroupId);
|
|
415
|
+
if (groupMembers.some((record) => (
|
|
416
|
+
!record.environmentReadable || !exactRecords.some(({ pid }) => pid === record.pid)
|
|
417
|
+
))) {
|
|
418
|
+
return {
|
|
419
|
+
processGroupIds: [],
|
|
420
|
+
status: PROCESS_IDENTITY_STATUS.AMBIGUOUS
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (
|
|
425
|
+
rootGroupMembers.some((record) => !exactRecords.some(({ pid }) => pid === record.pid))
|
|
426
|
+
) {
|
|
427
|
+
return {
|
|
428
|
+
processGroupIds: [],
|
|
429
|
+
status: PROCESS_IDENTITY_STATUS.AMBIGUOUS
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
processGroupIds,
|
|
434
|
+
status: PROCESS_IDENTITY_STATUS.EXACT
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function inspectProcessIdentity(metadata = {}, options = {}) {
|
|
439
|
+
const inspector = typeof options.processIdentityInspector === "function"
|
|
440
|
+
? options.processIdentityInspector
|
|
441
|
+
: inspectLinuxProcessIdentity;
|
|
442
|
+
return normalizeProcessIdentityInspection(await inspector(metadata));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function waitForProcessIdentityExit(metadata = {}, options = {}, timeoutMs = 0) {
|
|
446
|
+
const deadline = Date.now() + timeoutMs;
|
|
447
|
+
let inspection = await inspectProcessIdentity(metadata, options);
|
|
448
|
+
while (
|
|
449
|
+
Date.now() < deadline &&
|
|
450
|
+
inspection.status === PROCESS_IDENTITY_STATUS.EXACT
|
|
451
|
+
) {
|
|
452
|
+
await delay(100);
|
|
453
|
+
inspection = await inspectProcessIdentity(metadata, options);
|
|
454
|
+
}
|
|
455
|
+
return inspection;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function waitForProcessIdentityToSettle(metadata = {}, options = {}) {
|
|
459
|
+
const timeoutMs = normalizePositiveInteger(
|
|
460
|
+
options.processIdentitySettleTimeoutMs,
|
|
461
|
+
PROCESS_IDENTITY_SETTLE_TIMEOUT_MS
|
|
462
|
+
);
|
|
463
|
+
const pollMs = normalizePositiveInteger(
|
|
464
|
+
options.processIdentitySettlePollMs,
|
|
465
|
+
PROCESS_IDENTITY_SETTLE_POLL_MS
|
|
466
|
+
);
|
|
467
|
+
const deadline = Date.now() + timeoutMs;
|
|
468
|
+
let inspection = await inspectProcessIdentity(metadata, options);
|
|
469
|
+
while (
|
|
470
|
+
inspection.status === PROCESS_IDENTITY_STATUS.AMBIGUOUS &&
|
|
471
|
+
Date.now() < deadline
|
|
472
|
+
) {
|
|
473
|
+
await delay(Math.min(pollMs, Math.max(1, deadline - Date.now())));
|
|
474
|
+
inspection = await inspectProcessIdentity(metadata, options);
|
|
475
|
+
}
|
|
476
|
+
return inspection;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function signalVerifiedProcessGroups(metadata = {}, signal = "SIGTERM", options = {}) {
|
|
480
|
+
const signalProcessGroupImpl = typeof options.signalProcessGroup === "function"
|
|
481
|
+
? options.signalProcessGroup
|
|
482
|
+
: signalProcessGroup;
|
|
483
|
+
const initial = await waitForProcessIdentityToSettle(metadata, options);
|
|
484
|
+
if (initial.status !== PROCESS_IDENTITY_STATUS.EXACT) {
|
|
485
|
+
return {
|
|
486
|
+
inspection: initial,
|
|
487
|
+
signaledProcessGroups: []
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
const rootProcessGroupId = Number(metadata.pid);
|
|
491
|
+
const orderedProcessGroupIds = [
|
|
492
|
+
...initial.processGroupIds.filter((processGroupId) => processGroupId !== rootProcessGroupId),
|
|
493
|
+
...initial.processGroupIds.filter((processGroupId) => processGroupId === rootProcessGroupId)
|
|
494
|
+
];
|
|
495
|
+
const signaledProcessGroups = [];
|
|
496
|
+
for (const processGroupId of orderedProcessGroupIds) {
|
|
497
|
+
const current = await waitForProcessIdentityToSettle(metadata, options);
|
|
498
|
+
if (
|
|
499
|
+
current.status === PROCESS_IDENTITY_STATUS.ABSENT ||
|
|
500
|
+
current.status === PROCESS_IDENTITY_STATUS.MISMATCH
|
|
501
|
+
) {
|
|
502
|
+
return {
|
|
503
|
+
inspection: current,
|
|
504
|
+
signaledProcessGroups
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
if (current.status !== PROCESS_IDENTITY_STATUS.EXACT) {
|
|
508
|
+
return {
|
|
509
|
+
inspection: current,
|
|
510
|
+
signaledProcessGroups
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (!current.processGroupIds.includes(processGroupId)) {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
signalProcessGroupImpl(processGroupId, signal);
|
|
517
|
+
signaledProcessGroups.push(processGroupId);
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
inspection: await waitForProcessIdentityToSettle(metadata, options),
|
|
521
|
+
signaledProcessGroups
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function stopProcessGroup(metadata = {}, options = {}) {
|
|
526
|
+
if (!processMetadataIsIdentifiable(metadata)) {
|
|
527
|
+
return {
|
|
528
|
+
identityStatus: PROCESS_IDENTITY_STATUS.INVALID,
|
|
529
|
+
processExitVerified: false,
|
|
530
|
+
stopped: false
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (
|
|
534
|
+
metadata.processState === PROCESS_STATE.STOPPED &&
|
|
535
|
+
metadata.processExitVerifiedAt
|
|
536
|
+
) {
|
|
537
|
+
return {
|
|
538
|
+
alreadyStopped: true,
|
|
539
|
+
identityStatus: PROCESS_IDENTITY_STATUS.ABSENT,
|
|
540
|
+
processExitVerified: true,
|
|
541
|
+
stopped: false
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const before = await waitForProcessIdentityToSettle(metadata, options);
|
|
545
|
+
if (
|
|
546
|
+
before.status === PROCESS_IDENTITY_STATUS.ABSENT ||
|
|
547
|
+
before.status === PROCESS_IDENTITY_STATUS.MISMATCH
|
|
548
|
+
) {
|
|
549
|
+
return {
|
|
550
|
+
identityStatus: before.status,
|
|
551
|
+
processExitVerified: true,
|
|
552
|
+
stopped: false
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
if (before.status !== PROCESS_IDENTITY_STATUS.EXACT) {
|
|
556
|
+
return {
|
|
557
|
+
identityStatus: before.status,
|
|
558
|
+
processExitVerified: false,
|
|
559
|
+
stopped: false
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
const term = await signalVerifiedProcessGroups(metadata, "SIGTERM", options);
|
|
563
|
+
if (term.inspection.status === PROCESS_IDENTITY_STATUS.AMBIGUOUS) {
|
|
564
|
+
return {
|
|
565
|
+
identityStatus: term.inspection.status,
|
|
566
|
+
processExitVerified: false,
|
|
567
|
+
signaledProcessGroups: term.signaledProcessGroups,
|
|
568
|
+
stopped: false
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
let after = await waitForProcessIdentityExit(
|
|
572
|
+
metadata,
|
|
573
|
+
options,
|
|
574
|
+
normalizePositiveInteger(options.termTimeoutMs, 3000)
|
|
575
|
+
);
|
|
576
|
+
let signaledProcessGroups = [...term.signaledProcessGroups];
|
|
577
|
+
if (after.status === PROCESS_IDENTITY_STATUS.EXACT) {
|
|
578
|
+
const kill = await signalVerifiedProcessGroups(metadata, "SIGKILL", options);
|
|
579
|
+
signaledProcessGroups = [...new Set([
|
|
580
|
+
...signaledProcessGroups,
|
|
581
|
+
...kill.signaledProcessGroups
|
|
582
|
+
])];
|
|
583
|
+
if (kill.inspection.status === PROCESS_IDENTITY_STATUS.AMBIGUOUS) {
|
|
584
|
+
return {
|
|
585
|
+
identityStatus: kill.inspection.status,
|
|
586
|
+
processExitVerified: false,
|
|
587
|
+
signaledProcessGroups,
|
|
588
|
+
stopped: false
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
after = await waitForProcessIdentityExit(
|
|
592
|
+
metadata,
|
|
593
|
+
options,
|
|
594
|
+
normalizePositiveInteger(options.killTimeoutMs, 1000)
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
const processExitVerified = [
|
|
598
|
+
PROCESS_IDENTITY_STATUS.ABSENT,
|
|
599
|
+
PROCESS_IDENTITY_STATUS.MISMATCH
|
|
600
|
+
].includes(after.status);
|
|
601
|
+
return {
|
|
602
|
+
descendantProcessGroups: signaledProcessGroups.filter((processGroupId) => (
|
|
603
|
+
processGroupId !== Number(metadata.pid)
|
|
604
|
+
)),
|
|
605
|
+
identityStatus: after.status,
|
|
606
|
+
pid: Number(metadata.pid),
|
|
607
|
+
processExitVerified,
|
|
608
|
+
signaledProcessGroups,
|
|
609
|
+
stopped: processExitVerified && signaledProcessGroups.length > 0
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function captureProcessIdentity({
|
|
614
|
+
commandHash = "",
|
|
615
|
+
pid = null,
|
|
616
|
+
reportedIdentity = null,
|
|
617
|
+
runtimeToken = ""
|
|
618
|
+
} = {}) {
|
|
619
|
+
const expectedCommandHash = normalizeText(commandHash);
|
|
620
|
+
const expectedRuntimeToken = normalizeText(runtimeToken);
|
|
621
|
+
const reported = normalizeProcessIdentity(reportedIdentity);
|
|
622
|
+
if (
|
|
623
|
+
processIdentityIsWellFormed(reported) &&
|
|
624
|
+
reported.commandHash === expectedCommandHash &&
|
|
625
|
+
reported.runtimeToken === expectedRuntimeToken
|
|
626
|
+
) {
|
|
627
|
+
return reported;
|
|
628
|
+
}
|
|
629
|
+
const normalizedPid = Number(pid);
|
|
630
|
+
if (
|
|
631
|
+
process.platform !== "linux" ||
|
|
632
|
+
!Number.isSafeInteger(normalizedPid) ||
|
|
633
|
+
normalizedPid <= 0
|
|
634
|
+
) {
|
|
635
|
+
const error = new Error(`${label} process identity could not be captured.`);
|
|
636
|
+
error.code = unavailableCode;
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
const processStat = linuxProcessStat(
|
|
640
|
+
await readFile(`/proc/${normalizedPid}/stat`, "utf8").catch(() => "")
|
|
641
|
+
);
|
|
642
|
+
if (!processStat || processStat.processGroupId !== normalizedPid) {
|
|
643
|
+
const error = new Error(`${label} process identity could not be captured.`);
|
|
644
|
+
error.code = unavailableCode;
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
commandHash: expectedCommandHash,
|
|
649
|
+
platform: PROCESS_IDENTITY_PLATFORM,
|
|
650
|
+
runtimeToken: expectedRuntimeToken,
|
|
651
|
+
startTimeTicks: processStat.startTimeTicks,
|
|
652
|
+
version: PROCESS_IDENTITY_VERSION
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return Object.freeze({
|
|
657
|
+
normalize: normalizeProcessIdentity,
|
|
658
|
+
isValid: processIdentityIsWellFormed,
|
|
659
|
+
normalizeInspection: normalizeProcessIdentityInspection,
|
|
660
|
+
capture: captureProcessIdentity,
|
|
661
|
+
inspect: inspectProcessIdentity,
|
|
662
|
+
stop: stopProcessGroup,
|
|
663
|
+
isProcessAlive: processIsAlive
|
|
664
|
+
});
|
|
665
|
+
}
|