@cjhyy/code-shell-server 0.9.48 → 0.9.50
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/dist/desktop-web/http-api.d.ts +9 -0
- package/dist/desktop-web/http-api.js +17 -1
- package/dist/http-byte-range.d.ts +5 -0
- package/dist/http-byte-range.js +19 -0
- package/dist/hub/files.js +71 -9
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/mobile-remote/access-passcode.d.ts +5 -0
- package/dist/mobile-remote/access-passcode.js +48 -9
- package/dist/mobile-remote/mobile-client-event-validator.js +54 -4
- package/dist/mobile-remote/pending-approvals.d.ts +13 -1
- package/dist/mobile-remote/pending-approvals.js +45 -9
- package/dist/mobile-remote/remote-host-manager.d.ts +11 -1
- package/dist/mobile-remote/remote-host-manager.js +140 -56
- package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
- package/dist/mobile-remote/tunnel-manager.js +45 -20
- package/dist/panels/background-service-process.d.ts +39 -0
- package/dist/panels/background-service-process.js +457 -0
- package/dist/panels/background-service-store.d.ts +67 -0
- package/dist/panels/background-service-store.js +539 -0
- package/dist/panels/background-services.d.ts +193 -0
- package/dist/panels/background-services.js +1159 -0
- package/dist/panels/bridge-contract.d.ts +1 -1
- package/dist/panels/execution-gate.d.ts +7 -1
- package/dist/panels/execution-gate.js +26 -0
- package/dist/panels/management.js +49 -13
- package/dist/panels/process-files.js +28 -3
- package/dist/panels/resources/library.d.ts +1 -4
- package/dist/panels/resources/library.js +19 -21
- package/dist/panels/resources/references.js +21 -8
- package/dist/panels/runtime.js +2 -2
- package/dist/panels/tool-jobs.d.ts +2 -0
- package/dist/panels/tool-jobs.js +1 -1
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +17 -0
- package/package.json +6 -6
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { spawn, execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { verifyProcessEntry } from "./process-entry.js";
|
|
5
|
+
import { groupStatus, serviceIdentityInspector, } from "./background-service-store.js";
|
|
6
|
+
// The guardian cannot execute package code until the Host has durably recorded its
|
|
7
|
+
// PID/start identity and sends go. An EOF before go cannot leave an unrecorded worker.
|
|
8
|
+
const GUARDIAN = String.raw `
|
|
9
|
+
const { spawn } = require('node:child_process');
|
|
10
|
+
const nonce = require('node:crypto').randomUUID();
|
|
11
|
+
const control = require('node:fs');
|
|
12
|
+
control.writeSync(3,JSON.stringify({t:'custody',pid:process.pid,nonce})+'\n');
|
|
13
|
+
let child, first = '', started = false, ending = false;
|
|
14
|
+
const stop = () => {
|
|
15
|
+
if (ending) return; ending = true;
|
|
16
|
+
if (!child) process.exit(0);
|
|
17
|
+
child.stdin.end();
|
|
18
|
+
setTimeout(() => { try { process.kill(-process.pid, 'SIGKILL'); } catch {} }, 1800);
|
|
19
|
+
};
|
|
20
|
+
process.stdin.on('data', (chunk) => {
|
|
21
|
+
if (started) {
|
|
22
|
+
if (!ending && child?.stdin.writable && !child.stdin.write(chunk)) { process.stdin.pause(); child.stdin.once('drain', () => process.stdin.resume()); }
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
first += chunk.toString();
|
|
26
|
+
const end = first.indexOf('\n');
|
|
27
|
+
if ((end < 0 && first.length > 131072) || end > 131072) process.exit(2);
|
|
28
|
+
if (end < 0) return;
|
|
29
|
+
const command = JSON.parse(first.slice(0, end));
|
|
30
|
+
if (command.t !== 'go') process.exit(2);
|
|
31
|
+
started = true;
|
|
32
|
+
// FD 3 is custody-only. Explicit ignore prevents any inherited channel even
|
|
33
|
+
// on platforms whose spawn implementation keeps extra descriptors open.
|
|
34
|
+
child = spawn(command.executable, command.args, { cwd: command.cwd, env: command.env, stdio: ['pipe','pipe','pipe','ignore'] });
|
|
35
|
+
control.writeSync(3,JSON.stringify({t:'member',pid:child.pid})+'\n');
|
|
36
|
+
control.closeSync(3);
|
|
37
|
+
child.stdout.pipe(process.stdout); child.stderr.pipe(process.stderr);
|
|
38
|
+
child.stdin.on('error', () => {});
|
|
39
|
+
child.on('error', () => process.exit(2));
|
|
40
|
+
child.on('exit', (code) => process.exit(code ?? 1));
|
|
41
|
+
const rest = first.slice(end + 1); first = ''; if (rest) child.stdin.write(rest);
|
|
42
|
+
});
|
|
43
|
+
process.stdin.on('end', stop); process.on('SIGTERM', stop); process.on('SIGINT', stop);
|
|
44
|
+
process.stdout.on('error', stop); process.stderr.on('error', stop);
|
|
45
|
+
`;
|
|
46
|
+
const execute = promisify(execFile);
|
|
47
|
+
const permissionFlags = new Map();
|
|
48
|
+
// Latest supported security baselines at 2026-10-28. No EOL odd-major or experimental Node20.
|
|
49
|
+
// https://nodejs.org/en/blog/vulnerability/october-2026-security-releases
|
|
50
|
+
export function safeServiceNodeVersion(version) {
|
|
51
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
|
|
52
|
+
if (!match)
|
|
53
|
+
return false;
|
|
54
|
+
const [major, minor, patch] = match.slice(1).map(Number);
|
|
55
|
+
const minimum = new Map([
|
|
56
|
+
[22, [23, 4]],
|
|
57
|
+
[24, [22, 1]],
|
|
58
|
+
[26, [12, 1]],
|
|
59
|
+
]).get(major);
|
|
60
|
+
return !!minimum && (minor > minimum[0] || (minor === minimum[0] && patch >= minimum[1]));
|
|
61
|
+
}
|
|
62
|
+
export async function probeServiceRuntime(executable) {
|
|
63
|
+
const info = await stat(executable);
|
|
64
|
+
const key = `${executable}:${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}`;
|
|
65
|
+
const cached = permissionFlags.get(key);
|
|
66
|
+
if (cached && cached.until > Date.now())
|
|
67
|
+
return cached.result;
|
|
68
|
+
const entry = { until: Infinity, result: Promise.resolve([]) };
|
|
69
|
+
entry.result = (async () => {
|
|
70
|
+
const { stdout: version } = await execute(executable, ["--version"], {
|
|
71
|
+
timeout: 5000,
|
|
72
|
+
maxBuffer: 4096,
|
|
73
|
+
});
|
|
74
|
+
if (!safeServiceNodeVersion(version))
|
|
75
|
+
throw new Error("SERVICE_NODE_SECURITY_VERSION_REQUIRED:22.23.4/24.22.1/26.12.1");
|
|
76
|
+
const { stdout: help } = await execute(executable, ["--help"], {
|
|
77
|
+
timeout: 5000,
|
|
78
|
+
maxBuffer: 512 * 1024,
|
|
79
|
+
});
|
|
80
|
+
if (!help.includes("--permission "))
|
|
81
|
+
throw new Error("SERVICE_NODE_PERMISSION_UNAVAILABLE");
|
|
82
|
+
return ["--permission", ...(help.includes("--allow-net") ? ["--allow-net"] : [])];
|
|
83
|
+
})();
|
|
84
|
+
permissionFlags.set(key, entry);
|
|
85
|
+
if (permissionFlags.size > 64)
|
|
86
|
+
permissionFlags.delete(permissionFlags.keys().next().value);
|
|
87
|
+
void entry.result.then(() => {
|
|
88
|
+
entry.until = Date.now() + 600_000;
|
|
89
|
+
}, () => {
|
|
90
|
+
entry.until = Date.now() + 300_000;
|
|
91
|
+
});
|
|
92
|
+
return entry.result;
|
|
93
|
+
}
|
|
94
|
+
// The package cannot run before its second, native member identity is durable.
|
|
95
|
+
// Keep hello buffered until the reviewed entry has an actual consumer. Explicit
|
|
96
|
+
// pause survives adding a data listener, including one before a top-level await;
|
|
97
|
+
// waiting for import() to finish before resuming would deadlock that entry.
|
|
98
|
+
const BOOTSTRAP = String.raw `let bytes=Buffer.alloc(0);const begin=chunk=>{
|
|
99
|
+
bytes=Buffer.concat([bytes,chunk]);const end=bytes.indexOf(10);
|
|
100
|
+
if(end<0){if(bytes.length>4096)process.exit(2);return;}
|
|
101
|
+
process.stdin.off('data',begin);process.stdin.pause();
|
|
102
|
+
if(JSON.parse(bytes.subarray(0,end)).t!=='start')process.exit(2);
|
|
103
|
+
const rest=bytes.subarray(end+1);if(rest.length)process.stdin.unshift(rest);
|
|
104
|
+
const resumeForData=event=>{if(event==='data')queueMicrotask(()=>{
|
|
105
|
+
if(process.stdin.listenerCount('data')){process.stdin.off('newListener',resumeForData);process.stdin.resume();}
|
|
106
|
+
});};
|
|
107
|
+
process.stdin.on('newListener',resumeForData);
|
|
108
|
+
process.stdin.once('close',()=>process.stdin.off('newListener',resumeForData));
|
|
109
|
+
import(require('node:url').pathToFileURL(process.argv[1]).href).catch(error=>{console.error(error);process.exit(2)});
|
|
110
|
+
};process.stdin.on('data',begin);`;
|
|
111
|
+
/** This private guardian protocol has exactly two messages and one total byte
|
|
112
|
+
* budget, not an ever-growing line buffer. Reject before any identity syscall. */
|
|
113
|
+
export function readServiceCustody(stream, guardianPid, identities) {
|
|
114
|
+
let custodyResolve;
|
|
115
|
+
let custodyReject;
|
|
116
|
+
let memberResolve;
|
|
117
|
+
let memberReject;
|
|
118
|
+
const custody = new Promise((resolve, reject) => {
|
|
119
|
+
custodyResolve = resolve;
|
|
120
|
+
custodyReject = reject;
|
|
121
|
+
});
|
|
122
|
+
const member = new Promise((resolve, reject) => {
|
|
123
|
+
memberResolve = resolve;
|
|
124
|
+
memberReject = reject;
|
|
125
|
+
});
|
|
126
|
+
void custody.catch(() => { });
|
|
127
|
+
void member.catch(() => { });
|
|
128
|
+
let phase = "custody";
|
|
129
|
+
let pending = Buffer.alloc(0), total = 0;
|
|
130
|
+
let verifiedMember;
|
|
131
|
+
const close = (error) => {
|
|
132
|
+
if (phase === "closed")
|
|
133
|
+
return;
|
|
134
|
+
phase = "closed";
|
|
135
|
+
pending = Buffer.alloc(0);
|
|
136
|
+
stream.off("data", receive);
|
|
137
|
+
stream.off("end", ended);
|
|
138
|
+
stream.off("close", ended);
|
|
139
|
+
// A destroy/error may already be queued by the pipe. Keep its single error
|
|
140
|
+
// consumer until close so rejecting custody cannot become an unhandled event.
|
|
141
|
+
stream.once("close", () => stream.off("error", failed));
|
|
142
|
+
if (error) {
|
|
143
|
+
custodyReject(error);
|
|
144
|
+
memberReject(error);
|
|
145
|
+
}
|
|
146
|
+
stream.destroy();
|
|
147
|
+
};
|
|
148
|
+
const ended = () => close(new Error("SERVICE_CUSTODY_EXITED"));
|
|
149
|
+
const failed = (cause) => close(new Error(`SERVICE_CUSTODY_IO${cause.code ? `:${cause.code}` : ""}${cause.syscall ? `:${cause.syscall}` : ""}`, { cause }));
|
|
150
|
+
const receive = (chunk) => {
|
|
151
|
+
if (phase === "closed")
|
|
152
|
+
return;
|
|
153
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
154
|
+
if (bytes.length > 8192 - total) {
|
|
155
|
+
close(new Error("SERVICE_CUSTODY_LIMIT"));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
total += bytes.length;
|
|
159
|
+
pending = Buffer.concat([pending, bytes]);
|
|
160
|
+
while (true) {
|
|
161
|
+
const end = pending.indexOf(10);
|
|
162
|
+
if (end < 0)
|
|
163
|
+
break;
|
|
164
|
+
const line = pending.subarray(0, end);
|
|
165
|
+
pending = pending.subarray(end + 1);
|
|
166
|
+
try {
|
|
167
|
+
const value = JSON.parse(line.toString("utf8"));
|
|
168
|
+
if (!value ||
|
|
169
|
+
!Number.isSafeInteger(value.pid) ||
|
|
170
|
+
value.pid <= 0 ||
|
|
171
|
+
(phase === "custody"
|
|
172
|
+
? value.t !== "custody" ||
|
|
173
|
+
value.pid !== guardianPid ||
|
|
174
|
+
!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value.nonce) ||
|
|
175
|
+
Object.keys(value).length !== 3
|
|
176
|
+
: value.t !== "member" || value.pid === guardianPid || Object.keys(value).length !== 2))
|
|
177
|
+
throw new Error("SERVICE_CUSTODY_INVALID");
|
|
178
|
+
const identity = identities.current(value.pid);
|
|
179
|
+
if (!identity || identity.pid !== value.pid)
|
|
180
|
+
throw new Error("SERVICE_IDENTITY_UNAVAILABLE");
|
|
181
|
+
if (phase === "custody") {
|
|
182
|
+
phase = "member";
|
|
183
|
+
custodyResolve(identity);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
verifiedMember = identity;
|
|
187
|
+
memberResolve(identity);
|
|
188
|
+
close();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
close(error instanceof Error ? error : new Error("SERVICE_CUSTODY_INVALID"));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
stream.on("data", receive);
|
|
199
|
+
stream.once("end", ended);
|
|
200
|
+
stream.once("close", ended);
|
|
201
|
+
stream.once("error", failed);
|
|
202
|
+
return {
|
|
203
|
+
custody,
|
|
204
|
+
member,
|
|
205
|
+
close,
|
|
206
|
+
get memberIdentity() {
|
|
207
|
+
return verifiedMember;
|
|
208
|
+
},
|
|
209
|
+
get retainedBytes() {
|
|
210
|
+
return pending.length;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/** A guardian's close event only confirms its stdio closed. A signalled native
|
|
215
|
+
* member may still be executing; only the OS group observation confirms death. */
|
|
216
|
+
export async function waitForServiceGroupExit(pid, timeoutMs, observe = () => groupStatus(pid), clock = () => performance.now(), yieldTurn = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))) {
|
|
217
|
+
const deadline = clock() + Math.max(0, timeoutMs);
|
|
218
|
+
while (true) {
|
|
219
|
+
// Keep the exact successful observation. A second /proc scan can encounter
|
|
220
|
+
// unrelated PID churn and become unknown after death was already proven.
|
|
221
|
+
if (observe() === "dead")
|
|
222
|
+
return true;
|
|
223
|
+
const remaining = deadline - clock();
|
|
224
|
+
if (remaining <= 0)
|
|
225
|
+
return false;
|
|
226
|
+
await yieldTurn(Math.min(20, remaining));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
export async function spawnServiceProcess(input, adapters = {}) {
|
|
230
|
+
const identities = adapters.identities ?? serviceIdentityInspector;
|
|
231
|
+
if (process.platform === "win32")
|
|
232
|
+
throw new Error("SERVICE_PROCESS_IDENTITY_UNSUPPORTED");
|
|
233
|
+
const flags = await (adapters.probeRuntime ?? probeServiceRuntime)(input.executable);
|
|
234
|
+
await verifyProcessEntry(input.entry, input.sha256);
|
|
235
|
+
const env = Object.fromEntries(["PATH", "LANG", "LC_ALL", "TMPDIR", "SSL_CERT_FILE", "SSL_CERT_DIR"].flatMap((name) => process.env[name] ? [[name, process.env[name]]] : []));
|
|
236
|
+
// A new group is solely for bounded custody/termination; it is never unref'd.
|
|
237
|
+
const child = spawn(input.executable, ["-e", GUARDIAN], {
|
|
238
|
+
cwd: input.cwd,
|
|
239
|
+
env,
|
|
240
|
+
detached: true,
|
|
241
|
+
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
242
|
+
});
|
|
243
|
+
const custodyChannel = readServiceCustody(child.stdio[3], child.pid, identities);
|
|
244
|
+
const { custody, member } = custodyChannel;
|
|
245
|
+
let failure;
|
|
246
|
+
let pending = Buffer.alloc(0), stderr = Buffer.alloc(0);
|
|
247
|
+
let rateAt = Date.now(), rateBytes = 0, rateLines = 0;
|
|
248
|
+
let activated = false, ended = false, leaderExited = false;
|
|
249
|
+
child.stdin.on("error", () => { });
|
|
250
|
+
const fail = (reason) => {
|
|
251
|
+
failure = reason;
|
|
252
|
+
child.stdin.end();
|
|
253
|
+
};
|
|
254
|
+
child.stdout.on("data", (chunk) => {
|
|
255
|
+
if (failure)
|
|
256
|
+
return;
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
if (now - rateAt >= 1000) {
|
|
259
|
+
rateAt = now;
|
|
260
|
+
rateBytes = 0;
|
|
261
|
+
rateLines = 0;
|
|
262
|
+
}
|
|
263
|
+
rateBytes += chunk.length;
|
|
264
|
+
if (rateBytes > 2 * 1024 * 1024)
|
|
265
|
+
return fail("SERVICE_OUTPUT_RATE");
|
|
266
|
+
pending = Buffer.concat([pending, chunk]);
|
|
267
|
+
while (true) {
|
|
268
|
+
const end = pending.indexOf(10);
|
|
269
|
+
if (end < 0)
|
|
270
|
+
break;
|
|
271
|
+
if (end > input.maxLineBytes || ++rateLines > 64)
|
|
272
|
+
return fail("SERVICE_OUTPUT_LIMIT");
|
|
273
|
+
const line = pending.subarray(0, end);
|
|
274
|
+
pending = pending.subarray(end + 1);
|
|
275
|
+
try {
|
|
276
|
+
const message = JSON.parse(line.toString("utf8"));
|
|
277
|
+
input.onLine(message);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return fail("SERVICE_INVALID_LINE");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (pending.length > input.maxLineBytes)
|
|
284
|
+
fail("SERVICE_OUTPUT_LINE_TOO_LARGE");
|
|
285
|
+
});
|
|
286
|
+
child.stderr.on("data", (chunk) => {
|
|
287
|
+
if (failure)
|
|
288
|
+
return;
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
if (now - rateAt >= 1000) {
|
|
291
|
+
rateAt = now;
|
|
292
|
+
rateBytes = 0;
|
|
293
|
+
rateLines = 0;
|
|
294
|
+
}
|
|
295
|
+
rateBytes += chunk.length;
|
|
296
|
+
if (rateBytes > 2 * 1024 * 1024)
|
|
297
|
+
return fail("SERVICE_OUTPUT_RATE");
|
|
298
|
+
stderr = Buffer.concat([stderr, chunk]).subarray(-64 * 1024);
|
|
299
|
+
});
|
|
300
|
+
let exitResolve;
|
|
301
|
+
const exited = new Promise((resolve) => {
|
|
302
|
+
exitResolve = resolve;
|
|
303
|
+
});
|
|
304
|
+
child.on("error", (error) => {
|
|
305
|
+
failure = error.message;
|
|
306
|
+
});
|
|
307
|
+
child.on("exit", () => {
|
|
308
|
+
leaderExited = true;
|
|
309
|
+
if (child.pid && groupStatus(child.pid) !== "dead")
|
|
310
|
+
input.onExit("SERVICE_CUSTODY_LEADER_EXITED");
|
|
311
|
+
// A killed guardian does not release custody: the separately authenticated
|
|
312
|
+
// native member proves this process group is still ours, even after leader reap.
|
|
313
|
+
if (custodyChannel.memberIdentity &&
|
|
314
|
+
identities.status(custodyChannel.memberIdentity) === "alive") {
|
|
315
|
+
try {
|
|
316
|
+
process.kill(-child.pid, "SIGKILL");
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
/* recovery remains conservative */
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
child.on("close", (code) => {
|
|
324
|
+
ended = true;
|
|
325
|
+
custodyChannel.close(new Error("SERVICE_CUSTODY_EXITED"));
|
|
326
|
+
exitResolve();
|
|
327
|
+
input.onExit(failure ?? (code ? `Service exited ${code}: ${stderr.toString().slice(-2048)}` : undefined));
|
|
328
|
+
});
|
|
329
|
+
await new Promise((resolve, reject) => {
|
|
330
|
+
child.once("spawn", resolve);
|
|
331
|
+
child.once("error", reject);
|
|
332
|
+
});
|
|
333
|
+
let custodyTimer;
|
|
334
|
+
let identity;
|
|
335
|
+
try {
|
|
336
|
+
identity = await Promise.race([
|
|
337
|
+
custody,
|
|
338
|
+
new Promise((_resolve, reject) => {
|
|
339
|
+
custodyTimer = setTimeout(() => reject(new Error("SERVICE_CUSTODY_TIMEOUT")), 10_000);
|
|
340
|
+
}),
|
|
341
|
+
]);
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
// This exact ChildProcess has not closed, so its PID has not been reaped/reused.
|
|
345
|
+
if (!leaderExited && !ended && child.pid) {
|
|
346
|
+
try {
|
|
347
|
+
process.kill(-child.pid, "SIGKILL");
|
|
348
|
+
}
|
|
349
|
+
catch { }
|
|
350
|
+
}
|
|
351
|
+
let cleanupTimer;
|
|
352
|
+
await Promise.race([
|
|
353
|
+
exited,
|
|
354
|
+
new Promise((resolve) => {
|
|
355
|
+
cleanupTimer = setTimeout(resolve, 2000);
|
|
356
|
+
}),
|
|
357
|
+
]);
|
|
358
|
+
if (cleanupTimer)
|
|
359
|
+
clearTimeout(cleanupTimer);
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
if (custodyTimer)
|
|
364
|
+
clearTimeout(custodyTimer);
|
|
365
|
+
}
|
|
366
|
+
const send = (message) => {
|
|
367
|
+
if (leaderExited || ended || !child.stdin.writable)
|
|
368
|
+
return;
|
|
369
|
+
const bytes = JSON.stringify(message) + "\n";
|
|
370
|
+
if (Buffer.byteLength(bytes) > 5 * 1024 * 1024 || child.stdin.writableLength > 128 * 1024)
|
|
371
|
+
throw new Error("SERVICE_STDIN_LIMIT");
|
|
372
|
+
child.stdin.write(bytes);
|
|
373
|
+
};
|
|
374
|
+
return {
|
|
375
|
+
identity,
|
|
376
|
+
exited,
|
|
377
|
+
send,
|
|
378
|
+
async prepare() {
|
|
379
|
+
send({
|
|
380
|
+
t: "go",
|
|
381
|
+
executable: input.executable,
|
|
382
|
+
args: [
|
|
383
|
+
...flags,
|
|
384
|
+
`--allow-fs-read=${input.packageRoot}`,
|
|
385
|
+
`--allow-fs-read=${input.cwd}`,
|
|
386
|
+
`--allow-fs-write=${input.cwd}`,
|
|
387
|
+
"-e",
|
|
388
|
+
BOOTSTRAP,
|
|
389
|
+
input.entry,
|
|
390
|
+
],
|
|
391
|
+
cwd: input.cwd,
|
|
392
|
+
env,
|
|
393
|
+
});
|
|
394
|
+
let timer;
|
|
395
|
+
try {
|
|
396
|
+
return await Promise.race([
|
|
397
|
+
member,
|
|
398
|
+
new Promise((_r, reject) => {
|
|
399
|
+
timer = setTimeout(() => reject(new Error("SERVICE_MEMBER_TIMEOUT")), 10_000);
|
|
400
|
+
}),
|
|
401
|
+
]);
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
if (timer)
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
activate(hello) {
|
|
409
|
+
if (activated)
|
|
410
|
+
throw new Error("SERVICE_ALREADY_ACTIVATED");
|
|
411
|
+
if (!custodyChannel.memberIdentity)
|
|
412
|
+
throw new Error("SERVICE_MEMBER_UNCONFIRMED");
|
|
413
|
+
activated = true;
|
|
414
|
+
send({ t: "start" });
|
|
415
|
+
send(hello);
|
|
416
|
+
},
|
|
417
|
+
async terminate(timeoutMs) {
|
|
418
|
+
if (!ended) {
|
|
419
|
+
try {
|
|
420
|
+
send({ t: "drain" });
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
/* still terminate custody */
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
let timer;
|
|
427
|
+
await Promise.race([
|
|
428
|
+
exited,
|
|
429
|
+
new Promise((resolve) => {
|
|
430
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
431
|
+
}),
|
|
432
|
+
]);
|
|
433
|
+
if (timer)
|
|
434
|
+
clearTimeout(timer);
|
|
435
|
+
if (groupStatus(identity.pid) === "dead")
|
|
436
|
+
return true;
|
|
437
|
+
// Only this still-live verified leader proves ownership of the group. If its
|
|
438
|
+
// identity cannot be checked, preserve stop-unconfirmed and never guess/kill.
|
|
439
|
+
if ((leaderExited || ended || child.pid !== identity.pid) &&
|
|
440
|
+
(!custodyChannel.memberIdentity ||
|
|
441
|
+
identities.status(custodyChannel.memberIdentity) !== "alive"))
|
|
442
|
+
// No verified live owner means no signal authority. A previous signal
|
|
443
|
+
// can still be taking effect, so bounded read-only confirmation remains
|
|
444
|
+
// useful; unknown throughout the deadline stays unconfirmed.
|
|
445
|
+
return waitForServiceGroupExit(identity.pid, 2000);
|
|
446
|
+
try {
|
|
447
|
+
process.kill(-identity.pid, "SIGKILL");
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
/* confirm below */
|
|
451
|
+
}
|
|
452
|
+
// `exited` may already have resolved when the guardian died. It is not a
|
|
453
|
+
// completion barrier for SIGKILL delivery to the authenticated member.
|
|
454
|
+
return waitForServiceGroupExit(identity.pid, 2000);
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Immutable OS boot/start identity; lack of inspection is never proof of death. */
|
|
2
|
+
export interface ServiceProcessIdentity {
|
|
3
|
+
pid: number;
|
|
4
|
+
start: string;
|
|
5
|
+
host: string;
|
|
6
|
+
boot?: string;
|
|
7
|
+
/** Stable local OS machine identity; hostname alone is not shared-root ownership. */
|
|
8
|
+
machine?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ServiceIdentityInspector {
|
|
11
|
+
current(pid: number): ServiceProcessIdentity | null;
|
|
12
|
+
status(identity: ServiceProcessIdentity): "alive" | "dead" | "unknown";
|
|
13
|
+
}
|
|
14
|
+
/** A live Host cannot cross OS boots. Only this constant is cached; PID/start
|
|
15
|
+
* identity and process/group liveness must always be inspected afresh. */
|
|
16
|
+
export declare function createBootSessionReader(read: () => string, now?: () => number): () => string | null;
|
|
17
|
+
/** Machine identity is constant for this Host lifetime. Failed OS inspection may
|
|
18
|
+
* be slow, so retry once per minute rather than on each reconciliation tick. */
|
|
19
|
+
export declare function createMachineIdentityReader(read: () => string | undefined, platform?: NodeJS.Platform, now?: () => number): () => string | undefined;
|
|
20
|
+
export declare function serviceSystemIdentity(): Pick<ServiceProcessIdentity, "host" | "boot" | "machine">;
|
|
21
|
+
export declare function parseLinuxProcessStat(stat: string): {
|
|
22
|
+
pid: number;
|
|
23
|
+
group: number;
|
|
24
|
+
state: string;
|
|
25
|
+
start: string;
|
|
26
|
+
} | null;
|
|
27
|
+
export declare function processIdentity(pid: number): ServiceProcessIdentity | null;
|
|
28
|
+
/** Kernel unique process ID and exec version, never a wall-clock timestamp. */
|
|
29
|
+
export declare function darwinProcessStart(pid: number): string | null;
|
|
30
|
+
export declare function identityStatus(identity: ServiceProcessIdentity, inspect?: (pid: number) => ServiceProcessIdentity | null, system?: Pick<ServiceProcessIdentity, "host" | "boot" | "machine"> | null): "alive" | "dead" | "unknown";
|
|
31
|
+
/** Online migration is safe only inside the same verified OS boot. A legacy
|
|
32
|
+
* record first encountered after reboot has no proof it came from this machine. */
|
|
33
|
+
export declare function upgradeLocalServiceIdentity(identity: ServiceProcessIdentity, system?: Pick<ServiceProcessIdentity, "host" | "boot" | "machine">): ServiceProcessIdentity;
|
|
34
|
+
export declare function identityGroupStatus(identity: ServiceProcessIdentity, system?: Pick<ServiceProcessIdentity, "host" | "boot" | "machine">, inspectGroup?: typeof groupStatus): "alive" | "dead" | "unknown";
|
|
35
|
+
export declare const serviceIdentityInspector: ServiceIdentityInspector;
|
|
36
|
+
export declare function groupStatus(pid: number): "alive" | "dead" | "unknown";
|
|
37
|
+
/** kill(0) includes unreaped zombies on Linux. Prove that every visible group
|
|
38
|
+
* member is terminal; incomplete /proc visibility remains unknown. */
|
|
39
|
+
export declare function linuxGroupStatus(pid: number, read?: (path: string) => string, list?: () => string[]): "alive" | "dead" | "unknown";
|
|
40
|
+
export declare const backgroundServiceReadFormats: Set<string>;
|
|
41
|
+
export declare const backgroundServiceWriteFormats: Set<string>;
|
|
42
|
+
export type CommitBoundary = "write" | "file-fsync" | "rename" | "directory-fsync" | "lock-write" | "lock-file-fsync" | "lock-stage-fsync" | "lock-rename" | "lock-root-fsync";
|
|
43
|
+
export declare class BackgroundServiceStore<T extends {
|
|
44
|
+
format: string;
|
|
45
|
+
}> {
|
|
46
|
+
readonly root: string;
|
|
47
|
+
private readonly fault?;
|
|
48
|
+
private readonly identities;
|
|
49
|
+
private owner?;
|
|
50
|
+
private poisoned;
|
|
51
|
+
private lockPending;
|
|
52
|
+
constructor(root: string, fault?: ((boundary: CommitBoundary) => void) | undefined, identities?: ServiceIdentityInspector);
|
|
53
|
+
acquire(): void;
|
|
54
|
+
available(): boolean;
|
|
55
|
+
/** Only the retained exclusive owner may reload after an uncertain write. */
|
|
56
|
+
recover(validate?: (record: T) => void): T[];
|
|
57
|
+
quarantine(): void;
|
|
58
|
+
release(): void;
|
|
59
|
+
key(scope: {
|
|
60
|
+
appId: string;
|
|
61
|
+
projectPath: string;
|
|
62
|
+
name: string;
|
|
63
|
+
}): string;
|
|
64
|
+
read(key: string): T | undefined;
|
|
65
|
+
all(): T[];
|
|
66
|
+
commit(key: string, record: T): void;
|
|
67
|
+
}
|