@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.
Files changed (36) 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 +54 -4
  11. package/dist/mobile-remote/pending-approvals.d.ts +13 -1
  12. package/dist/mobile-remote/pending-approvals.js +45 -9
  13. package/dist/mobile-remote/remote-host-manager.d.ts +11 -1
  14. package/dist/mobile-remote/remote-host-manager.js +140 -56
  15. package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
  16. package/dist/mobile-remote/tunnel-manager.js +45 -20
  17. package/dist/panels/background-service-process.d.ts +39 -0
  18. package/dist/panels/background-service-process.js +457 -0
  19. package/dist/panels/background-service-store.d.ts +67 -0
  20. package/dist/panels/background-service-store.js +539 -0
  21. package/dist/panels/background-services.d.ts +193 -0
  22. package/dist/panels/background-services.js +1159 -0
  23. package/dist/panels/bridge-contract.d.ts +1 -1
  24. package/dist/panels/execution-gate.d.ts +7 -1
  25. package/dist/panels/execution-gate.js +26 -0
  26. package/dist/panels/management.js +49 -13
  27. package/dist/panels/process-files.js +28 -3
  28. package/dist/panels/resources/library.d.ts +1 -4
  29. package/dist/panels/resources/library.js +19 -21
  30. package/dist/panels/resources/references.js +21 -8
  31. package/dist/panels/runtime.js +2 -2
  32. package/dist/panels/tool-jobs.d.ts +2 -0
  33. package/dist/panels/tool-jobs.js +1 -1
  34. package/dist/sessions-service.d.ts +4 -0
  35. package/dist/sessions-service.js +17 -0
  36. package/package.json +6 -6
@@ -21,7 +21,8 @@ export interface RemoteHostStartOptions {
21
21
  /**
22
22
  * "lan" (default) keeps the existing LAN behaviour unchanged. "tunnel" binds
23
23
  * 127.0.0.1 (cloudflared connects to loopback) and inserts a passcode gate in
24
- * front of every HTTP route and the WS upgrade. "relay" also requires an
24
+ * front of ordinary HTTP routes and the WS upgrade. Host-verified Panel asset
25
+ * capabilities use their own short-lived authorization. "relay" also requires an
25
26
  * initialized passcode and a stable HTTPS origin before pairing/WS upgrades.
26
27
  */
27
28
  mode?: "lan" | "tunnel" | "relay";
@@ -39,6 +40,10 @@ export interface RemoteHostManagerOptions {
39
40
  outputJournal?: boolean;
40
41
  /** Enable only when the attached selected-session host implements legacy base pages. */
41
42
  legacyBasePages?: boolean;
43
+ /** Enable only when the selected-session reader supports opt-in dense pages. */
44
+ outputJournalPageFrames?: 2048;
45
+ /** Selected-session Host implements verified display views and bounded detail parts. */
46
+ historyView?: boolean;
42
47
  onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
43
48
  /**
44
49
  * Absolute path to the built mobile app (out/mobile). Defaults to the
@@ -76,6 +81,8 @@ export declare class RemoteHostManager extends EventEmitter {
76
81
  private publicBaseUrl?;
77
82
  /** Passcode gate, present only in tunnel mode. */
78
83
  private passcode?;
84
+ private socketAccessValidity;
85
+ private accessSweep?;
79
86
  /** Where the built mobile app lives (out/mobile). */
80
87
  private readonly mobileRootDir;
81
88
  /** Dev proxy target for /mobile (HMR); undefined in prod. */
@@ -122,6 +129,8 @@ export declare class RemoteHostManager extends EventEmitter {
122
129
  /** Reply only to the currently authenticated tab; retired/revoked viewers receive nothing. */
123
130
  hasAuthenticatedViewer(viewerId: string, deviceId: string): boolean;
124
131
  sendToViewer(viewerId: string, event: MobileServerEvent): void;
132
+ /** Preserve the full worker envelope while retaining exact authenticated-tab routing. */
133
+ sendRawToViewer(viewerId: string, payload: string): void;
125
134
  /** Send a raw JSON-RPC worker line to ONLY one authenticated device. */
126
135
  sendRawToDevice(deviceId: string, payload: string): void;
127
136
  /** Broadcast a raw line (e.g. a mirrored worker→renderer JSON-RPC line) to
@@ -136,6 +145,7 @@ export declare class RemoteHostManager extends EventEmitter {
136
145
  * socket is still delivered. Returns false when retired.
137
146
  */
138
147
  private sendBounded;
148
+ private hasSocketAccess;
139
149
  private releaseSocket;
140
150
  private bindSocketDevice;
141
151
  /** Call after revoking/removing a trusted device to release both transports immediately. */
@@ -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
  /**
@@ -423,7 +478,11 @@ export class RemoteHostManager extends EventEmitter {
423
478
  ? {
424
479
  capabilities: {
425
480
  outputJournal: 1,
481
+ ...(this.opts.historyView ? { historyView: 1 } : {}),
426
482
  ...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
483
+ ...(this.opts.outputJournalPageFrames === 2048
484
+ ? { outputJournalPageFrames: 2048 }
485
+ : {}),
427
486
  },
428
487
  }
429
488
  : {}),
@@ -446,7 +505,11 @@ export class RemoteHostManager extends EventEmitter {
446
505
  ? {
447
506
  capabilities: {
448
507
  outputJournal: 1,
508
+ ...(this.opts.historyView ? { historyView: 1 } : {}),
449
509
  ...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
510
+ ...(this.opts.outputJournalPageFrames === 2048
511
+ ? { outputJournalPageFrames: 2048 }
512
+ : {}),
450
513
  },
451
514
  }
452
515
  : {}),
@@ -473,7 +536,7 @@ export class RemoteHostManager extends EventEmitter {
473
536
  /** Reply only to the currently authenticated tab; retired/revoked viewers receive nothing. */
474
537
  hasAuthenticatedViewer(viewerId, deviceId) {
475
538
  for (const client of this.wss?.clients ?? []) {
476
- if (client.readyState === client.OPEN &&
539
+ if (this.hasSocketAccess(client) &&
477
540
  this.authed.get(client) === deviceId &&
478
541
  this.viewers.get(client) === viewerId)
479
542
  return true;
@@ -481,7 +544,10 @@ export class RemoteHostManager extends EventEmitter {
481
544
  return false;
482
545
  }
483
546
  sendToViewer(viewerId, event) {
484
- const payload = JSON.stringify(event);
547
+ this.sendRawToViewer(viewerId, JSON.stringify(event));
548
+ }
549
+ /** Preserve the full worker envelope while retaining exact authenticated-tab routing. */
550
+ sendRawToViewer(viewerId, payload) {
485
551
  for (const client of this.wss?.clients ?? []) {
486
552
  if (client.readyState === client.OPEN &&
487
553
  this.authed.has(client) &&
@@ -519,6 +585,8 @@ export class RemoteHostManager extends EventEmitter {
519
585
  * socket is still delivered. Returns false when retired.
520
586
  */
521
587
  sendBounded(client, payload) {
588
+ if (!this.hasSocketAccess(client))
589
+ return false;
522
590
  if (client.bufferedAmount > 0 &&
523
591
  client.bufferedAmount + Buffer.byteLength(payload) > MAX_SOCKET_BACKLOG_BYTES) {
524
592
  this.releaseSocket(client);
@@ -528,6 +596,20 @@ export class RemoteHostManager extends EventEmitter {
528
596
  client.send(payload);
529
597
  return true;
530
598
  }
599
+ hasSocketAccess(client) {
600
+ if (client.readyState !== client.OPEN)
601
+ return false;
602
+ if (this.socketAccessValidity.get(client)?.() !== false)
603
+ return true;
604
+ this.releaseSocket(client);
605
+ // Distinct from paired-device rejection (4401/4403): the browser keeps its
606
+ // identity and offers a fresh HTTP challenge instead of silently retrying.
607
+ client.close(4407, "Access passcode changed");
608
+ const deadline = setTimeout(() => client.terminate(), 1000);
609
+ deadline.unref();
610
+ client.once("close", () => clearTimeout(deadline));
611
+ return false;
612
+ }
531
613
  releaseSocket(ws) {
532
614
  const deviceId = this.authed.get(ws);
533
615
  const viewerId = this.viewers.get(ws);
@@ -584,6 +666,8 @@ export class RemoteHostManager extends EventEmitter {
584
666
  async stopOnce() {
585
667
  if (this.starting)
586
668
  await this.starting.catch(() => undefined);
669
+ clearInterval(this.accessSweep);
670
+ this.accessSweep = undefined;
587
671
  this.relayLifetime?.abort();
588
672
  this.relayLifetime = undefined;
589
673
  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>;