@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
@@ -38,12 +38,11 @@ export declare function mobileEntryRedirect(reqUrl: string): string | null;
38
38
  * can see through it. Guarding that would need inode/device policy, and anyone
39
39
  * able to hardlink into the served root can already write files there.
40
40
  *
41
- * The returned path is the REQUESTED one, not the realpath: callers stream it
42
- * directly and the resolved-name contract is relied on elsewhere (on macOS the
43
- * realpath of a /var root is /private/var, which would change every result).
44
- * That leaves the same synchronous stat-then-read window this module always
45
- * had; closing it properly needs an open-then-fstat rework of both callers,
46
- * which is out of scope for this fix. */
41
+ * The returned path is the REQUESTED one, not the realpath: the resolved-name
42
+ * contract is relied on elsewhere (on macOS the realpath of a /var root is
43
+ * /private/var, which would change every result). This is only a routing check;
44
+ * both HTTP callers open and revalidate the selected file through
45
+ * streamStaticFile before returning bytes. */
47
46
  export declare function resolveSafe(root: string, subPath: string): string | null;
48
47
  export interface MobileStaticOptions {
49
48
  /** Absolute path to the built mobile app (out/mobile). */
@@ -1,7 +1,7 @@
1
- import { createReadStream, existsSync, realpathSync, statSync } from "node:fs";
1
+ import { existsSync, realpathSync, statSync } from "node:fs";
2
2
  import { isAbsolute, join, normalize, relative, resolve, sep, extname } from "node:path";
3
3
  import { request as httpRequest } from "node:http";
4
- import { contentTypeFor } from "../static-files.js";
4
+ import { contentTypeFor, streamStaticFile } from "../static-files.js";
5
5
  /**
6
6
  * Serves the built mobile web app (out/mobile) as static assets for the
7
7
  * `/mobile` route family, replacing the old inline `mobileRemoteHtml()` string.
@@ -61,12 +61,11 @@ function isContained(root, target) {
61
61
  * can see through it. Guarding that would need inode/device policy, and anyone
62
62
  * able to hardlink into the served root can already write files there.
63
63
  *
64
- * The returned path is the REQUESTED one, not the realpath: callers stream it
65
- * directly and the resolved-name contract is relied on elsewhere (on macOS the
66
- * realpath of a /var root is /private/var, which would change every result).
67
- * That leaves the same synchronous stat-then-read window this module always
68
- * had; closing it properly needs an open-then-fstat rework of both callers,
69
- * which is out of scope for this fix. */
64
+ * The returned path is the REQUESTED one, not the realpath: the resolved-name
65
+ * contract is relied on elsewhere (on macOS the realpath of a /var root is
66
+ * /private/var, which would change every result). This is only a routing check;
67
+ * both HTTP callers open and revalidate the selected file through
68
+ * streamStaticFile before returning bytes. */
70
69
  export function resolveSafe(root, subPath) {
71
70
  const rootResolved = resolve(root);
72
71
  const rel = normalize(subPath || "index.html");
@@ -77,11 +76,11 @@ export function resolveSafe(root, subPath) {
77
76
  const full = resolve(join(rootResolved, rel));
78
77
  if (!isContained(rootResolved, full))
79
78
  return null;
80
- if (!existsSync(full) || !statSync(full).isFile())
81
- return null;
82
79
  // realpathSync throws on a dangling link or a race-deleted file; a missing
83
80
  // target is a 404 through the normal contract, never a 500.
84
81
  try {
82
+ if (!existsSync(full) || !statSync(full).isFile())
83
+ return null;
85
84
  if (!isContained(realpathSync(rootResolved), realpathSync(full)))
86
85
  return null;
87
86
  }
@@ -143,15 +142,15 @@ export function serveMobile(req, res, opts) {
143
142
  // stale index.html would reference deleted assets → 404 → blank page until a
144
143
  // hard refresh.
145
144
  const isHtml = type.startsWith("text/html");
146
- res.writeHead(200, {
147
- "content-type": type,
148
- "cache-control": isHtml
145
+ void streamStaticFile(req, res, {
146
+ rootDir: opts.rootDir,
147
+ filePath: file,
148
+ cacheControl: isHtml
149
149
  ? "no-store"
150
150
  : sub.startsWith("assets/")
151
151
  ? "public, max-age=31536000, immutable"
152
152
  : "no-cache",
153
153
  });
154
- createReadStream(file).pipe(res);
155
154
  }
156
155
  /** Dev-only: forward the request to the mobile vite dev server. The mobile vite
157
156
  * config sets base "/mobile/", so vite serves its HMR client, module graph, and
@@ -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";
@@ -76,6 +77,8 @@ export declare class RemoteHostManager extends EventEmitter {
76
77
  private publicBaseUrl?;
77
78
  /** Passcode gate, present only in tunnel mode. */
78
79
  private passcode?;
80
+ private socketAccessValidity;
81
+ private accessSweep?;
79
82
  /** Where the built mobile app lives (out/mobile). */
80
83
  private readonly mobileRootDir;
81
84
  /** Dev proxy target for /mobile (HMR); undefined in prod. */
@@ -122,6 +125,8 @@ export declare class RemoteHostManager extends EventEmitter {
122
125
  /** Reply only to the currently authenticated tab; retired/revoked viewers receive nothing. */
123
126
  hasAuthenticatedViewer(viewerId: string, deviceId: string): boolean;
124
127
  sendToViewer(viewerId: string, event: MobileServerEvent): void;
128
+ /** Preserve the full worker envelope while retaining exact authenticated-tab routing. */
129
+ sendRawToViewer(viewerId: string, payload: string): void;
125
130
  /** Send a raw JSON-RPC worker line to ONLY one authenticated device. */
126
131
  sendRawToDevice(deviceId: string, payload: string): void;
127
132
  /** Broadcast a raw line (e.g. a mirrored worker→renderer JSON-RPC line) to
@@ -136,6 +141,7 @@ export declare class RemoteHostManager extends EventEmitter {
136
141
  * socket is still delivered. Returns false when retired.
137
142
  */
138
143
  private sendBounded;
144
+ private hasSocketAccess;
139
145
  private releaseSocket;
140
146
  private bindSocketDevice;
141
147
  /** 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
  /**
@@ -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;
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { stopRoomProcess } from "./room-process-stop.js";
2
+ import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
3
3
  import { createInterface } from "node:readline";
4
4
  import { pathWithCommonBins } from "./path-bins.js";
5
5
  /**
@@ -149,6 +149,8 @@ export class ResidentAgentProcess {
149
149
  stdio: ["pipe", "pipe", "pipe"],
150
150
  });
151
151
  this.child = child;
152
+ // Settle on `close` so trailing output is read, without waiting on grandchildren.
153
+ releaseStdioAfterExit(child);
152
154
  if (child.stdout) {
153
155
  const rl = createInterface({ input: child.stdout });
154
156
  rl.on("line", (line) => {
@@ -119,6 +119,10 @@ export interface RoomManagerOptions {
119
119
  * Returning null means the transcript is absent. The returned identity is
120
120
  * checked again here so a stale or mismatched locator fails closed. */
121
121
  resolveLinkedSession?: (target: Readonly<LinkedSessionTarget>) => LinkedSessionTarget | null | undefined;
122
+ /** Throws while a background task (DriveAgent) may still be writing this
123
+ * external session. Checked before any resident agent can start on it, so
124
+ * desktop and phone entry points share one ownership gate. */
125
+ assertExternalSessionAvailable?: (externalSessionId: string, kind: RoomKind) => void;
122
126
  /** Called whenever a room gains a new persisted message (push to phone). */
123
127
  onMessage: (roomId: string, msg: RoomMessage) => void;
124
128
  /** Called when a room's resident agent requests tool-use approval. For
@@ -777,6 +777,8 @@ export class RoomManager {
777
777
  * so a given claude session maps to exactly one room. Returns the room id.
778
778
  */
779
779
  openForSession(claudeSessionId, cwd, mode, kind = "claude-code") {
780
+ if (claudeSessionId)
781
+ this.opts.assertExternalSessionAvailable?.(claudeSessionId, kind);
780
782
  // Reuse must match BOTH id and kind: a codex thread id and a claude session
781
783
  // id live in the same `claudeSessionId` field, so a bare id match could
782
784
  // otherwise hand back a claude room when a codex room was asked for.
@@ -851,6 +853,7 @@ export class RoomManager {
851
853
  * silently start a different session. */
852
854
  takeOverLinkedSession(roomId, externalSessionId, cwd, kind) {
853
855
  const target = this.requireLinkedSessionTarget({ externalSessionId, cwd, kind });
856
+ this.opts.assertExternalSessionAvailable?.(target.externalSessionId, target.kind);
854
857
  const meta = this.getRoom(roomId);
855
858
  if (!meta ||
856
859
  meta.claudeSessionId !== target.externalSessionId ||
@@ -969,6 +972,8 @@ export class RoomManager {
969
972
  if (meta.linkedSessionMode === "observe-only")
970
973
  return { status: "observing" };
971
974
  if (!this.agents.has(id)) {
975
+ if (meta.claudeSessionId)
976
+ this.opts.assertExternalSessionAvailable?.(meta.claudeSessionId, meta.kind);
972
977
  let agent = undefined;
973
978
  agent = this.opts.createAgent(meta, (event) => {
974
979
  // stop() sends a signal; an old process can still drain output or emit
@@ -1,3 +1,11 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
2
  /** Retire an unusable room transport without leaving an unresponsive CLI alive. */
3
3
  export declare function stopRoomProcess(child: ChildProcess): void;
4
+ /**
5
+ * `close` waits for every holder of the child's stdout/stderr. A grandchild the
6
+ * CLI backgrounded with inherited stdio (a dev server, say) keeps them open
7
+ * after the CLI exits, so the room would never settle. Once the CLI has exited
8
+ * and its buffered output had a grace period to drain, release the pipes;
9
+ * the grandchild itself is left running.
10
+ */
11
+ export declare function releaseStdioAfterExit(child: ChildProcess, graceMs?: number): void;
@@ -26,3 +26,21 @@ export function stopRoomProcess(child) {
26
26
  timer.unref();
27
27
  child.once("exit", () => clearTimeout(timer));
28
28
  }
29
+ /**
30
+ * `close` waits for every holder of the child's stdout/stderr. A grandchild the
31
+ * CLI backgrounded with inherited stdio (a dev server, say) keeps them open
32
+ * after the CLI exits, so the room would never settle. Once the CLI has exited
33
+ * and its buffered output had a grace period to drain, release the pipes;
34
+ * the grandchild itself is left running.
35
+ */
36
+ export function releaseStdioAfterExit(child, graceMs = 1_000) {
37
+ child.once("exit", () => {
38
+ const timer = setTimeout(() => {
39
+ child.stdout?.destroy();
40
+ child.stderr?.destroy();
41
+ child.stdin?.destroy();
42
+ }, graceMs);
43
+ timer.unref();
44
+ child.once("close", () => clearTimeout(timer));
45
+ });
46
+ }
@@ -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?;