@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.
- package/README.md +4 -0
- package/dist/desktop-web/http-api.d.ts +9 -0
- package/dist/desktop-web/http-api.js +17 -1
- package/dist/http-byte-range.d.ts +5 -0
- package/dist/http-byte-range.js +19 -0
- package/dist/hub/auth-store.d.ts +2 -0
- package/dist/hub/auth-store.js +26 -7
- package/dist/hub/files.js +71 -9
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/mobile-remote/access-passcode.d.ts +5 -0
- package/dist/mobile-remote/access-passcode.js +48 -9
- package/dist/mobile-remote/codex-room-agent.js +3 -1
- package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
- package/dist/mobile-remote/mobile-chat-turn.js +15 -2
- package/dist/mobile-remote/mobile-client-event-validator.js +30 -1
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
- package/dist/mobile-remote/mobile-static.d.ts +5 -6
- package/dist/mobile-remote/mobile-static.js +13 -14
- package/dist/mobile-remote/remote-host-manager.d.ts +7 -1
- package/dist/mobile-remote/remote-host-manager.js +132 -56
- package/dist/mobile-remote/resident-agent.js +3 -1
- package/dist/mobile-remote/room-manager.d.ts +4 -0
- package/dist/mobile-remote/room-manager.js +5 -0
- package/dist/mobile-remote/room-process-stop.d.ts +8 -0
- package/dist/mobile-remote/room-process-stop.js +18 -0
- package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
- package/dist/mobile-remote/tunnel-manager.js +45 -20
- package/dist/panels/background-service-process.d.ts +39 -0
- package/dist/panels/background-service-process.js +457 -0
- package/dist/panels/background-service-store.d.ts +67 -0
- package/dist/panels/background-service-store.js +539 -0
- package/dist/panels/background-services.d.ts +193 -0
- package/dist/panels/background-services.js +1159 -0
- package/dist/panels/bridge-contract.d.ts +1 -1
- package/dist/panels/execution-gate.d.ts +10 -2
- package/dist/panels/execution-gate.js +36 -2
- package/dist/panels/http.d.ts +1 -0
- package/dist/panels/management-http.d.ts +1 -0
- package/dist/panels/management.d.ts +3 -0
- package/dist/panels/management.js +105 -21
- package/dist/panels/package-mutation.d.ts +1 -1
- package/dist/panels/package-mutation.js +3 -10
- package/dist/panels/process-files.js +28 -3
- package/dist/panels/resources/library.d.ts +1 -4
- package/dist/panels/resources/library.js +2 -18
- package/dist/panels/resources/references.js +21 -8
- package/dist/panels/runtime.js +1 -1
- package/dist/panels/tool-jobs.d.ts +2 -0
- package/dist/panels/tool-jobs.js +1 -1
- package/dist/serve/headless-server.js +113 -79
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +17 -0
- package/dist/static-files.d.ts +7 -0
- package/dist/static-files.js +114 -0
- 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:
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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 {
|
|
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:
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
res.
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
+
this.sendBounded(ws, JSON.stringify(reply));
|
|
277
321
|
return;
|
|
278
322
|
}
|
|
279
323
|
if (!this.authed.has(ws)) {
|
|
280
|
-
|
|
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
|
-
|
|
333
|
+
this.sendBounded(ws, JSON.stringify({ type: "error", message: "Client event failed" }));
|
|
290
334
|
}
|
|
291
335
|
});
|
|
292
336
|
}
|
|
293
337
|
catch {
|
|
294
|
-
|
|
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 (
|
|
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
|
-
|
|
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?;
|