@cjhyy/code-shell-server 0.9.47 → 0.9.49

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.
Files changed (57) hide show
  1. package/README.md +4 -0
  2. package/dist/desktop-web/http-api.d.ts +9 -0
  3. package/dist/desktop-web/http-api.js +17 -1
  4. package/dist/http-byte-range.d.ts +5 -0
  5. package/dist/http-byte-range.js +19 -0
  6. package/dist/hub/auth-store.d.ts +2 -0
  7. package/dist/hub/auth-store.js +26 -7
  8. package/dist/hub/files.js +71 -9
  9. package/dist/index.panels.d.ts +1 -0
  10. package/dist/index.panels.js +1 -0
  11. package/dist/mobile-remote/access-passcode.d.ts +5 -0
  12. package/dist/mobile-remote/access-passcode.js +48 -9
  13. package/dist/mobile-remote/codex-room-agent.js +3 -1
  14. package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
  15. package/dist/mobile-remote/mobile-chat-turn.js +15 -2
  16. package/dist/mobile-remote/mobile-client-event-validator.js +30 -1
  17. package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
  18. package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
  19. package/dist/mobile-remote/mobile-static.d.ts +5 -6
  20. package/dist/mobile-remote/mobile-static.js +13 -14
  21. package/dist/mobile-remote/remote-host-manager.d.ts +7 -1
  22. package/dist/mobile-remote/remote-host-manager.js +132 -56
  23. package/dist/mobile-remote/resident-agent.js +3 -1
  24. package/dist/mobile-remote/room-manager.d.ts +4 -0
  25. package/dist/mobile-remote/room-manager.js +5 -0
  26. package/dist/mobile-remote/room-process-stop.d.ts +8 -0
  27. package/dist/mobile-remote/room-process-stop.js +18 -0
  28. package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
  29. package/dist/mobile-remote/tunnel-manager.js +45 -20
  30. package/dist/panels/background-service-process.d.ts +39 -0
  31. package/dist/panels/background-service-process.js +457 -0
  32. package/dist/panels/background-service-store.d.ts +67 -0
  33. package/dist/panels/background-service-store.js +539 -0
  34. package/dist/panels/background-services.d.ts +193 -0
  35. package/dist/panels/background-services.js +1159 -0
  36. package/dist/panels/bridge-contract.d.ts +1 -1
  37. package/dist/panels/execution-gate.d.ts +10 -2
  38. package/dist/panels/execution-gate.js +36 -2
  39. package/dist/panels/http.d.ts +1 -0
  40. package/dist/panels/management-http.d.ts +1 -0
  41. package/dist/panels/management.d.ts +3 -0
  42. package/dist/panels/management.js +105 -21
  43. package/dist/panels/package-mutation.d.ts +1 -1
  44. package/dist/panels/package-mutation.js +3 -10
  45. package/dist/panels/process-files.js +28 -3
  46. package/dist/panels/resources/library.d.ts +1 -4
  47. package/dist/panels/resources/library.js +2 -18
  48. package/dist/panels/resources/references.js +21 -8
  49. package/dist/panels/runtime.js +1 -1
  50. package/dist/panels/tool-jobs.d.ts +2 -0
  51. package/dist/panels/tool-jobs.js +1 -1
  52. package/dist/serve/headless-server.js +113 -79
  53. package/dist/sessions-service.d.ts +4 -0
  54. package/dist/sessions-service.js +17 -0
  55. package/dist/static-files.d.ts +7 -0
  56. package/dist/static-files.js +114 -0
  57. package/package.json +6 -6
@@ -49,6 +49,7 @@ export class TunnelManager extends EventEmitter {
49
49
  killGraceMs;
50
50
  killHardTimeoutMs;
51
51
  child;
52
+ cancelPendingStart;
52
53
  /** In-flight teardown of a previous child. Any start() awaits this first so a
53
54
  * new cloudflared never spawns while the old one still owns the metrics port. */
54
55
  pendingTeardown;
@@ -102,7 +103,7 @@ export class TunnelManager extends EventEmitter {
102
103
  // Force http2 (TCP). cloudflared defaults to QUIC (UDP/7844), which is
103
104
  // blocked on many networks (corp Wi-Fi, some carriers) — the connection
104
105
  // object registers but never becomes ready, so the phone hits Cloudflare
105
- // error 1033. http2 falls back to TCP/443 and reliably connects.
106
+ // error 1033. HTTP/2 still requires outbound TCP/7844 to the edge.
106
107
  "--protocol",
107
108
  "http2",
108
109
  // Fixed local metrics endpoint so we can poll /ready for edge readiness.
@@ -114,37 +115,55 @@ export class TunnelManager extends EventEmitter {
114
115
  this.child = child;
115
116
  return new Promise((resolve, reject) => {
116
117
  let settled = false;
117
- const finishReject = (err) => {
118
+ const settle = () => {
118
119
  if (settled)
119
- return;
120
+ return false;
120
121
  settled = true;
121
122
  clearTimeout(timer);
123
+ if (this.cancelPendingStart === cancelStart)
124
+ this.cancelPendingStart = undefined;
125
+ return true;
126
+ };
127
+ const cancelStart = () => {
128
+ if (settle())
129
+ reject(new Error("隧道启动已取消。"));
130
+ };
131
+ const finishReject = (err) => {
132
+ if (!settle())
133
+ return;
122
134
  // Route through the shared teardown so a retried start() awaits this
123
135
  // child's real exit (releasing the metrics port) before re-spawning.
124
- void this.beginTeardown();
136
+ if (this.child === child)
137
+ void this.beginTeardown();
125
138
  reject(err);
126
139
  };
127
140
  const timer = setTimeout(() => {
128
141
  finishReject(new Error("隧道启动失败:15s 内未取得公网地址(超时)"));
129
142
  }, this.timeoutMs);
130
- const scan = (buf) => {
143
+ this.cancelPendingStart = cancelStart;
144
+ const scanTails = { stdout: "", stderr: "" };
145
+ const scan = (stream, buf) => {
131
146
  if (settled)
132
147
  return;
133
- const match = TRYCLOUDFLARE_RE.exec(String(buf));
134
- if (!match)
148
+ const text = scanTails[stream] + String(buf);
149
+ const match = TRYCLOUDFLARE_RE.exec(text);
150
+ if (!match) {
151
+ // A pipe chunk can end anywhere in the URL. Keep a bounded suffix
152
+ // per stream so stdout and stderr cannot combine into a false URL.
153
+ scanTails[stream] = text.slice(-256);
135
154
  return;
155
+ }
136
156
  // URL captured, but the edge connection is NOT necessarily ready yet —
137
157
  // resolving here was the 1033 bug (handing back a dead QR). Stop the
138
158
  // URL scanners and wait for /ready before declaring connected.
139
- child.stdout?.off("data", scan);
140
- child.stderr?.off("data", scan);
159
+ child.stdout?.off("data", scanStdout);
160
+ child.stderr?.off("data", scanStderr);
141
161
  clearTimeout(timer);
142
162
  const url = match[0];
143
- this.waitForReady()
163
+ this.waitForReady(child)
144
164
  .then(() => {
145
- if (settled)
165
+ if (this.child !== child || !settle())
146
166
  return;
147
- settled = true;
148
167
  this.connected = true;
149
168
  this.currentUrl = url;
150
169
  this.emit("status", "connected", url);
@@ -153,8 +172,10 @@ export class TunnelManager extends EventEmitter {
153
172
  })
154
173
  .catch((err) => finishReject(err));
155
174
  };
156
- child.stdout?.on("data", scan);
157
- child.stderr?.on("data", scan);
175
+ const scanStdout = (buf) => scan("stdout", buf);
176
+ const scanStderr = (buf) => scan("stderr", buf);
177
+ child.stdout?.on("data", scanStdout);
178
+ child.stderr?.on("data", scanStderr);
158
179
  child.on("error", (err) => {
159
180
  // A superseded child (torn down by a restart / stop-during-start) must
160
181
  // NOT touch shared state — killAndWait owns its lifecycle. Match the exit
@@ -163,8 +184,7 @@ export class TunnelManager extends EventEmitter {
163
184
  if (this.child !== child)
164
185
  return;
165
186
  if (!settled) {
166
- settled = true;
167
- clearTimeout(timer);
187
+ settle();
168
188
  this.child = undefined;
169
189
  this.connected = false;
170
190
  this.currentUrl = undefined;
@@ -193,8 +213,7 @@ export class TunnelManager extends EventEmitter {
193
213
  this.emit("status", "disconnected", { code, signal });
194
214
  }
195
215
  else if (!settled) {
196
- settled = true;
197
- clearTimeout(timer);
216
+ settle();
198
217
  reject(new Error(`cloudflared 退出(code=${code ?? "?"} signal=${signal ?? "?"})`));
199
218
  }
200
219
  });
@@ -205,10 +224,15 @@ export class TunnelManager extends EventEmitter {
205
224
  * `readyTimeoutMs`. This is what distinguishes a working tunnel from one that
206
225
  * registered a connection but can't actually serve traffic (error 1033).
207
226
  */
208
- async waitForReady() {
227
+ async waitForReady(child) {
209
228
  const deadline = Date.now() + this.readyTimeoutMs;
210
229
  for (;;) {
211
- if (this.child && (await this.checkReady(this.metricsPort)))
230
+ if (this.child !== child || this.stopping)
231
+ throw new Error("隧道启动已取消。");
232
+ const ready = await this.checkReady(this.metricsPort);
233
+ if (this.child !== child || this.stopping)
234
+ throw new Error("隧道启动已取消。");
235
+ if (ready)
212
236
  return;
213
237
  if (Date.now() >= deadline) {
214
238
  throw new Error("隧道注册失败:公网边缘连接未就绪(可能网络限制,扫码会报 1033)");
@@ -285,6 +309,7 @@ export class TunnelManager extends EventEmitter {
285
309
  */
286
310
  beginTeardown() {
287
311
  this.stopping = true;
312
+ this.cancelPendingStart?.();
288
313
  this.connected = false;
289
314
  this.currentUrl = undefined;
290
315
  this.stopHealthMonitor();
@@ -0,0 +1,39 @@
1
+ import type { Readable } from "node:stream";
2
+ import { groupStatus, type ServiceProcessIdentity, type ServiceIdentityInspector } from "./background-service-store.js";
3
+ export declare function safeServiceNodeVersion(version: string): boolean;
4
+ export declare function probeServiceRuntime(executable: string): Promise<string[]>;
5
+ export interface ServiceProcess {
6
+ identity: ServiceProcessIdentity;
7
+ send(message: unknown): void;
8
+ prepare(): Promise<ServiceProcessIdentity>;
9
+ activate(hello: unknown): void;
10
+ terminate(timeoutMs: number): Promise<boolean>;
11
+ /** Guardian stdio closed; use terminate() to confirm the entire group is dead. */
12
+ exited: Promise<void>;
13
+ }
14
+ /** This private guardian protocol has exactly two messages and one total byte
15
+ * budget, not an ever-growing line buffer. Reject before any identity syscall. */
16
+ export declare function readServiceCustody(stream: Readable, guardianPid: number, identities: ServiceIdentityInspector): {
17
+ custody: Promise<ServiceProcessIdentity>;
18
+ member: Promise<ServiceProcessIdentity>;
19
+ close: (error?: Error) => void;
20
+ readonly memberIdentity: ServiceProcessIdentity | undefined;
21
+ readonly retainedBytes: number;
22
+ };
23
+ /** A guardian's close event only confirms its stdio closed. A signalled native
24
+ * member may still be executing; only the OS group observation confirms death. */
25
+ export declare function waitForServiceGroupExit(pid: number, timeoutMs: number, observe?: () => ReturnType<typeof groupStatus>, clock?: () => number, yieldTurn?: (delayMs: number) => Promise<void>): Promise<boolean>;
26
+ export declare function spawnServiceProcess(input: {
27
+ executable: string;
28
+ entry: string;
29
+ sha256: string;
30
+ packageRoot: string;
31
+ cwd: string;
32
+ maxLineBytes: number;
33
+ onLine(message: unknown): void;
34
+ onExit(error?: string): void;
35
+ }, adapters?: {
36
+ /** Explicit OS/runtime boundaries for owned-process fault tests; never guest supplied. */
37
+ identities?: ServiceIdentityInspector;
38
+ probeRuntime?: typeof probeServiceRuntime;
39
+ }): Promise<ServiceProcess>;
@@ -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
+ }