@cjhyy/code-shell-server 0.9.48 → 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 (34) hide show
  1. package/dist/desktop-web/http-api.d.ts +9 -0
  2. package/dist/desktop-web/http-api.js +17 -1
  3. package/dist/http-byte-range.d.ts +5 -0
  4. package/dist/http-byte-range.js +19 -0
  5. package/dist/hub/files.js +71 -9
  6. package/dist/index.panels.d.ts +1 -0
  7. package/dist/index.panels.js +1 -0
  8. package/dist/mobile-remote/access-passcode.d.ts +5 -0
  9. package/dist/mobile-remote/access-passcode.js +48 -9
  10. package/dist/mobile-remote/mobile-client-event-validator.js +30 -1
  11. package/dist/mobile-remote/remote-host-manager.d.ts +7 -1
  12. package/dist/mobile-remote/remote-host-manager.js +132 -56
  13. package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
  14. package/dist/mobile-remote/tunnel-manager.js +45 -20
  15. package/dist/panels/background-service-process.d.ts +39 -0
  16. package/dist/panels/background-service-process.js +457 -0
  17. package/dist/panels/background-service-store.d.ts +67 -0
  18. package/dist/panels/background-service-store.js +539 -0
  19. package/dist/panels/background-services.d.ts +193 -0
  20. package/dist/panels/background-services.js +1159 -0
  21. package/dist/panels/bridge-contract.d.ts +1 -1
  22. package/dist/panels/execution-gate.d.ts +7 -1
  23. package/dist/panels/execution-gate.js +26 -0
  24. package/dist/panels/management.js +49 -13
  25. package/dist/panels/process-files.js +28 -3
  26. package/dist/panels/resources/library.d.ts +1 -4
  27. package/dist/panels/resources/library.js +2 -18
  28. package/dist/panels/resources/references.js +21 -8
  29. package/dist/panels/runtime.js +1 -1
  30. package/dist/panels/tool-jobs.d.ts +2 -0
  31. package/dist/panels/tool-jobs.js +1 -1
  32. package/dist/sessions-service.d.ts +4 -0
  33. package/dist/sessions-service.js +17 -0
  34. package/package.json +6 -6
@@ -10,6 +10,16 @@ import { parseMobileClientEvent } from "./mobile-client-event-validator.js";
10
10
  import { isRelayOrigin } from "../remote-relay/protocol.js";
11
11
  /** Outbound bytes one paired socket may leave unread before it is retired. */
12
12
  const MAX_SOCKET_BACKLOG_BYTES = 4 * 1024 * 1024;
13
+ /** The query suffix ("" or "?…") when the raw request target is exactly the
14
+ * root entry, else undefined. The redirect path is fixed, so only the query
15
+ * is carried over and it can never form an off-site Location. */
16
+ function rootEntryQuery(target) {
17
+ if (target === "/")
18
+ return "";
19
+ if (target?.startsWith("/?"))
20
+ return target.slice(1);
21
+ return undefined;
22
+ }
13
23
  /**
14
24
  * Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
15
25
  * remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
@@ -57,6 +67,8 @@ export class RemoteHostManager extends EventEmitter {
57
67
  publicBaseUrl;
58
68
  /** Passcode gate, present only in tunnel mode. */
59
69
  passcode;
70
+ socketAccessValidity = new WeakMap();
71
+ accessSweep;
60
72
  /** Where the built mobile app lives (out/mobile). */
61
73
  mobileRootDir;
62
74
  /** Dev proxy target for /mobile (HMR); undefined in prod. */
@@ -117,8 +129,8 @@ export class RemoteHostManager extends EventEmitter {
117
129
  async startOnce(options) {
118
130
  const relay = options.mode === "relay";
119
131
  const publicAccess = relay || options.mode === "tunnel";
120
- if (relay && !options.passcode?.isSet()) {
121
- throw new Error("Relay mode requires an initialized access passcode");
132
+ if (publicAccess && !options.passcode?.isSet()) {
133
+ throw new Error(`${relay ? "Relay" : "Tunnel"} mode requires an initialized access passcode`);
122
134
  }
123
135
  if (relay)
124
136
  this.publicBaseUrl = undefined;
@@ -136,60 +148,88 @@ export class RemoteHostManager extends EventEmitter {
136
148
  req.resume();
137
149
  return;
138
150
  }
139
- // Tunnel mode: a passcode gate sits in front of EVERY route. The gate
140
- // either allows (returns true) or writes its own 401/403 challenge.
141
- if (gate && !gate.gate(req, res))
142
- return;
143
- if (req.url === "/health") {
144
- res.writeHead(200, { "content-type": "application/json" });
145
- res.end(JSON.stringify({ ok: true }));
146
- return;
147
- }
148
- if (req.url?.startsWith("/api/v1/") && this.opts.webApi) {
149
- const baseUrl = this.publicBaseUrl ?? this.started?.url;
150
- if (!baseUrl) {
151
- res.writeHead(503);
152
- res.end("remote host is starting");
151
+ const serve = () => {
152
+ // All ordinary routes retain the tunnel passcode gate. Only a Host-verified
153
+ // short-lived Panel asset capability may use independent authorization.
154
+ if (gate && !gate.gate(req, res))
155
+ return;
156
+ if (req.url === "/health") {
157
+ res.writeHead(200, { "content-type": "application/json" });
158
+ res.end(JSON.stringify({ ok: true }));
153
159
  return;
154
160
  }
155
- void this.opts.webApi
156
- .handle(req, res, { baseUrl })
157
- .then((handled) => {
158
- if (!handled && !res.writableEnded) {
159
- res.writeHead(404);
160
- res.end("not found");
161
+ if (req.url?.startsWith("/api/v1/") && this.opts.webApi) {
162
+ const baseUrl = this.publicBaseUrl ?? this.started?.url;
163
+ if (!baseUrl) {
164
+ res.writeHead(503);
165
+ res.end("remote host is starting");
166
+ return;
161
167
  }
162
- })
163
- .catch(() => {
168
+ void this.opts.webApi
169
+ .handle(req, res, { baseUrl, isAccessAuthorized: gate?.captureValidity() })
170
+ .then((handled) => {
171
+ if (!handled && !res.writableEnded) {
172
+ res.writeHead(404);
173
+ res.end("not found");
174
+ }
175
+ })
176
+ .catch(() => {
177
+ if (!res.headersSent)
178
+ res.writeHead(500);
179
+ if (!res.writableEnded)
180
+ res.end("remote request failed");
181
+ });
182
+ return;
183
+ }
184
+ const uploadPath = pathname.match(/^\/api\/mobile\/uploads\/([A-Za-z0-9_-]+)$/);
185
+ if (uploadPath && this.opts.uploads) {
186
+ void this.opts.uploads.acceptPut(uploadPath[1], req, res);
187
+ return;
188
+ }
189
+ // The mobile app is built/served with vite base "/mobile/", so ALL of its
190
+ // assets — and in dev, vite's HMR/module URLs — live under the /mobile
191
+ // prefix. A single prefix route serves prod (static out/mobile) and dev
192
+ // (proxy to the mobile vite server) symmetrically. Tunnel mode never
193
+ // proxies dev: Vite's HMR socket and @fs module URLs don't survive a
194
+ // public trycloudflare hop, so a public tunnel always serves the build.
195
+ // Match the route family exactly ("/mobile", "/mobile/…", "/mobile?…",
196
+ // "/mobile#…") — NOT a sibling like "/mobilexyz", which falls to 404.
197
+ if (req.url && /^\/mobile(?:[/?#]|$)/.test(req.url)) {
198
+ serveMobile(req, res, {
199
+ rootDir: this.mobileRootDir,
200
+ devUrl: publicAccess ? undefined : this.mobileDevUrl,
201
+ });
202
+ return;
203
+ }
204
+ // The bare origin is what people type or share; send it to the app with
205
+ // the query (e.g. ?pairing=) intact. Match the raw target, not the
206
+ // parsed pathname: "//other.host/" parses to "/" but must stay a 404.
207
+ const rootQuery = rootEntryQuery(req.url);
208
+ if (rootQuery !== undefined && (req.method === "GET" || req.method === "HEAD")) {
209
+ res.writeHead(302, { location: `/mobile/${rootQuery}`, "cache-control": "no-store" });
210
+ res.end();
211
+ return;
212
+ }
213
+ res.writeHead(404);
214
+ res.end("not found");
215
+ };
216
+ const webApi = this.opts.webApi;
217
+ if (gate?.isSet() &&
218
+ ["GET", "HEAD"].includes(req.method ?? "") &&
219
+ pathname.startsWith("/api/v1/panel-assets/") &&
220
+ webApi?.handleCapabilityAsset) {
221
+ void webApi.handleCapabilityAsset(req, res).then((handled) => {
222
+ if (!handled && !res.writableEnded)
223
+ serve();
224
+ }, () => {
164
225
  if (!res.headersSent)
165
226
  res.writeHead(500);
166
227
  if (!res.writableEnded)
167
- res.end("remote request failed");
168
- });
169
- return;
170
- }
171
- const uploadPath = pathname.match(/^\/api\/mobile\/uploads\/([A-Za-z0-9_-]+)$/);
172
- if (uploadPath && this.opts.uploads) {
173
- void this.opts.uploads.acceptPut(uploadPath[1], req, res);
174
- return;
175
- }
176
- // The mobile app is built/served with vite base "/mobile/", so ALL of its
177
- // assets — and in dev, vite's HMR/module URLs — live under the /mobile
178
- // prefix. A single prefix route serves prod (static out/mobile) and dev
179
- // (proxy to the mobile vite server) symmetrically. Tunnel mode never
180
- // proxies dev: Vite's HMR socket and @fs module URLs don't survive a
181
- // public trycloudflare hop, so a public tunnel always serves the build.
182
- // Match the route family exactly ("/mobile", "/mobile/…", "/mobile?…",
183
- // "/mobile#…") — NOT a sibling like "/mobilexyz", which falls to 404.
184
- if (req.url && /^\/mobile(?:[/?#]|$)/.test(req.url)) {
185
- serveMobile(req, res, {
186
- rootDir: this.mobileRootDir,
187
- devUrl: publicAccess ? undefined : this.mobileDevUrl,
228
+ res.end("remote asset request failed");
188
229
  });
189
230
  return;
190
231
  }
191
- res.writeHead(404);
192
- res.end("not found");
232
+ serve();
193
233
  });
194
234
  // In tunnel mode we must gate the WS handshake too. `noServer` lets us run
195
235
  // the passcode check during the HTTP `upgrade` event before completing the
@@ -225,7 +265,9 @@ export class RemoteHostManager extends EventEmitter {
225
265
  socket.destroy();
226
266
  return;
227
267
  }
268
+ const isAccessAuthorized = gate.captureValidity();
228
269
  this.wss.handleUpgrade(req, socket, head, (ws) => {
270
+ this.socketAccessValidity.set(ws, isAccessAuthorized);
229
271
  this.wss.emit("connection", ws, req);
230
272
  });
231
273
  });
@@ -241,17 +283,19 @@ export class RemoteHostManager extends EventEmitter {
241
283
  this.emit("host-error", error);
242
284
  });
243
285
  ws.on("message", (raw) => {
286
+ if (!this.hasSocketAccess(ws))
287
+ return;
244
288
  let parsed;
245
289
  try {
246
290
  parsed = JSON.parse(String(raw));
247
291
  }
248
292
  catch {
249
- ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
293
+ this.sendBounded(ws, JSON.stringify({ type: "error", message: "Invalid JSON" }));
250
294
  return;
251
295
  }
252
296
  const event = parseMobileClientEvent(parsed);
253
297
  if (!event) {
254
- ws.send(JSON.stringify({ type: "error", message: "Invalid client event" }));
298
+ this.sendBounded(ws, JSON.stringify({ type: "error", message: "Invalid client event" }));
255
299
  return;
256
300
  }
257
301
  // Pairing/auth are handled inline and bind device identity to this
@@ -262,7 +306,7 @@ export class RemoteHostManager extends EventEmitter {
262
306
  reply = this.handleClientEvent(event);
263
307
  }
264
308
  catch {
265
- ws.send(JSON.stringify({ type: "error", message: "Client event failed" }));
309
+ this.sendBounded(ws, JSON.stringify({ type: "error", message: "Client event failed" }));
266
310
  return;
267
311
  }
268
312
  if (event.type === "auth.device" && reply?.type === "auth.ok") {
@@ -273,11 +317,11 @@ export class RemoteHostManager extends EventEmitter {
273
317
  this.bindSocketDevice(ws, reply.device.id);
274
318
  }
275
319
  if (reply) {
276
- ws.send(JSON.stringify(reply));
320
+ this.sendBounded(ws, JSON.stringify(reply));
277
321
  return;
278
322
  }
279
323
  if (!this.authed.has(ws)) {
280
- ws.send(JSON.stringify({ type: "auth.failed", message: "Not authenticated" }));
324
+ this.sendBounded(ws, JSON.stringify({ type: "auth.failed", message: "Not authenticated" }));
281
325
  return;
282
326
  }
283
327
  // Authenticated, non-auth event → hand to the main dispatcher, which
@@ -286,12 +330,12 @@ export class RemoteHostManager extends EventEmitter {
286
330
  const dispatched = this.opts.onClientEvent({ ...event, deviceId: this.authed.get(ws), viewerId: this.viewers.get(ws) }, ws);
287
331
  void Promise.resolve(dispatched).catch(() => {
288
332
  if (ws.readyState === ws.OPEN) {
289
- ws.send(JSON.stringify({ type: "error", message: "Client event failed" }));
333
+ this.sendBounded(ws, JSON.stringify({ type: "error", message: "Client event failed" }));
290
334
  }
291
335
  });
292
336
  }
293
337
  catch {
294
- ws.send(JSON.stringify({ type: "error", message: "Client event failed" }));
338
+ this.sendBounded(ws, JSON.stringify({ type: "error", message: "Client event failed" }));
295
339
  }
296
340
  });
297
341
  ws.on("close", () => {
@@ -346,6 +390,17 @@ export class RemoteHostManager extends EventEmitter {
346
390
  url: `http://${bindHost}:${port}`,
347
391
  mode: options.mode ?? "lan",
348
392
  };
393
+ if (gate) {
394
+ const sockets = this.wss;
395
+ // Idle public tabs must also relinquish viewer-owned state after rotation.
396
+ // Traffic is checked synchronously in both directions, without rehashing
397
+ // any plaintext or spending the passcode attempt budget.
398
+ this.accessSweep = setInterval(() => {
399
+ for (const client of sockets?.clients ?? [])
400
+ this.hasSocketAccess(client);
401
+ }, 1000);
402
+ this.accessSweep.unref();
403
+ }
349
404
  return this.started;
350
405
  }
351
406
  /**
@@ -473,7 +528,7 @@ export class RemoteHostManager extends EventEmitter {
473
528
  /** Reply only to the currently authenticated tab; retired/revoked viewers receive nothing. */
474
529
  hasAuthenticatedViewer(viewerId, deviceId) {
475
530
  for (const client of this.wss?.clients ?? []) {
476
- if (client.readyState === client.OPEN &&
531
+ if (this.hasSocketAccess(client) &&
477
532
  this.authed.get(client) === deviceId &&
478
533
  this.viewers.get(client) === viewerId)
479
534
  return true;
@@ -481,7 +536,10 @@ export class RemoteHostManager extends EventEmitter {
481
536
  return false;
482
537
  }
483
538
  sendToViewer(viewerId, event) {
484
- const payload = JSON.stringify(event);
539
+ this.sendRawToViewer(viewerId, JSON.stringify(event));
540
+ }
541
+ /** Preserve the full worker envelope while retaining exact authenticated-tab routing. */
542
+ sendRawToViewer(viewerId, payload) {
485
543
  for (const client of this.wss?.clients ?? []) {
486
544
  if (client.readyState === client.OPEN &&
487
545
  this.authed.has(client) &&
@@ -519,6 +577,8 @@ export class RemoteHostManager extends EventEmitter {
519
577
  * socket is still delivered. Returns false when retired.
520
578
  */
521
579
  sendBounded(client, payload) {
580
+ if (!this.hasSocketAccess(client))
581
+ return false;
522
582
  if (client.bufferedAmount > 0 &&
523
583
  client.bufferedAmount + Buffer.byteLength(payload) > MAX_SOCKET_BACKLOG_BYTES) {
524
584
  this.releaseSocket(client);
@@ -528,6 +588,20 @@ export class RemoteHostManager extends EventEmitter {
528
588
  client.send(payload);
529
589
  return true;
530
590
  }
591
+ hasSocketAccess(client) {
592
+ if (client.readyState !== client.OPEN)
593
+ return false;
594
+ if (this.socketAccessValidity.get(client)?.() !== false)
595
+ return true;
596
+ this.releaseSocket(client);
597
+ // Distinct from paired-device rejection (4401/4403): the browser keeps its
598
+ // identity and offers a fresh HTTP challenge instead of silently retrying.
599
+ client.close(4407, "Access passcode changed");
600
+ const deadline = setTimeout(() => client.terminate(), 1000);
601
+ deadline.unref();
602
+ client.once("close", () => clearTimeout(deadline));
603
+ return false;
604
+ }
531
605
  releaseSocket(ws) {
532
606
  const deviceId = this.authed.get(ws);
533
607
  const viewerId = this.viewers.get(ws);
@@ -584,6 +658,8 @@ export class RemoteHostManager extends EventEmitter {
584
658
  async stopOnce() {
585
659
  if (this.starting)
586
660
  await this.starting.catch(() => undefined);
661
+ clearInterval(this.accessSweep);
662
+ this.accessSweep = undefined;
587
663
  this.relayLifetime?.abort();
588
664
  this.relayLifetime = undefined;
589
665
  const server = this.server;
@@ -51,6 +51,7 @@ export declare class TunnelManager extends EventEmitter {
51
51
  private readonly killGraceMs;
52
52
  private readonly killHardTimeoutMs;
53
53
  private child?;
54
+ private cancelPendingStart?;
54
55
  /** In-flight teardown of a previous child. Any start() awaits this first so a
55
56
  * new cloudflared never spawns while the old one still owns the metrics port. */
56
57
  private pendingTeardown?;
@@ -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>;