@cjhyy/code-shell-server 0.9.7 → 0.9.9

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 (109) hide show
  1. package/README.md +85 -23
  2. package/dist/bin/code-shell-project-runtime.d.ts +2 -0
  3. package/dist/bin/code-shell-project-runtime.js +8 -0
  4. package/dist/desktop-web/http-api.d.ts +43 -0
  5. package/dist/desktop-web/http-api.js +376 -0
  6. package/dist/hub/approval-lease.d.ts +30 -0
  7. package/dist/hub/approval-lease.js +89 -0
  8. package/dist/hub/auth-http.d.ts +23 -0
  9. package/dist/hub/auth-http.js +259 -0
  10. package/dist/hub/auth-store.d.ts +52 -0
  11. package/dist/hub/auth-store.js +316 -0
  12. package/dist/hub/configuration.d.ts +64 -0
  13. package/dist/hub/configuration.js +555 -0
  14. package/dist/hub/files.d.ts +9 -0
  15. package/dist/hub/files.js +278 -0
  16. package/dist/hub/mcp-configuration.d.ts +51 -0
  17. package/dist/hub/mcp-configuration.js +558 -0
  18. package/dist/hub/mcp-probe.d.ts +24 -0
  19. package/dist/hub/mcp-probe.js +392 -0
  20. package/dist/hub/model-probe.d.ts +20 -0
  21. package/dist/hub/model-probe.js +193 -0
  22. package/dist/hub/outbound-transport.d.ts +36 -0
  23. package/dist/hub/outbound-transport.js +122 -0
  24. package/dist/hub/run-replay.d.ts +42 -0
  25. package/dist/hub/run-replay.js +89 -0
  26. package/dist/hub/session-management.d.ts +36 -0
  27. package/dist/hub/session-management.js +405 -0
  28. package/dist/hub/skills-management.d.ts +43 -0
  29. package/dist/hub/skills-management.js +487 -0
  30. package/dist/hub/uploads.d.ts +22 -0
  31. package/dist/hub/uploads.js +241 -0
  32. package/dist/index.d.ts +3 -0
  33. package/dist/index.desktop-web.d.ts +2 -0
  34. package/dist/index.desktop-web.js +2 -0
  35. package/dist/index.js +3 -0
  36. package/dist/index.links.d.ts +6 -0
  37. package/dist/index.links.js +6 -0
  38. package/dist/index.panels.d.ts +9 -0
  39. package/dist/index.panels.js +8 -0
  40. package/dist/index.serve.d.ts +1 -0
  41. package/dist/index.serve.js +1 -0
  42. package/dist/index.storage.d.ts +2 -0
  43. package/dist/index.storage.js +2 -0
  44. package/dist/links/catalog.d.ts +10 -0
  45. package/dist/links/catalog.js +41 -0
  46. package/dist/links/device-oauth.d.ts +52 -0
  47. package/dist/links/device-oauth.js +255 -0
  48. package/dist/links/http.d.ts +34 -0
  49. package/dist/links/http.js +144 -0
  50. package/dist/links/service.d.ts +54 -0
  51. package/dist/links/service.js +578 -0
  52. package/dist/links/types.d.ts +6 -0
  53. package/dist/links/types.js +1 -0
  54. package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
  55. package/dist/mobile-remote/pending-approvals.js +2 -0
  56. package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
  57. package/dist/mobile-remote/remote-host-manager.js +90 -15
  58. package/dist/mobile-remote/room-manager.js +17 -5
  59. package/dist/panels/agent-task-host.d.ts +32 -0
  60. package/dist/panels/agent-task-host.js +457 -0
  61. package/dist/panels/agent-task-models.d.ts +27 -0
  62. package/dist/panels/agent-task-models.js +51 -0
  63. package/dist/panels/agent-task-service.d.ts +105 -0
  64. package/dist/panels/agent-task-service.js +283 -0
  65. package/dist/panels/http.d.ts +34 -0
  66. package/dist/panels/http.js +37 -0
  67. package/dist/panels/hub-binding.d.ts +12 -0
  68. package/dist/panels/hub-binding.js +174 -0
  69. package/dist/panels/management-http.d.ts +29 -0
  70. package/dist/panels/management-http.js +119 -0
  71. package/dist/panels/management.d.ts +39 -0
  72. package/dist/panels/management.js +494 -0
  73. package/dist/panels/process-files.d.ts +14 -0
  74. package/dist/panels/process-files.js +299 -0
  75. package/dist/panels/process-service.d.ts +96 -0
  76. package/dist/panels/process-service.js +501 -0
  77. package/dist/panels/runtime-services.d.ts +24 -0
  78. package/dist/panels/runtime-services.js +462 -0
  79. package/dist/panels/runtime.d.ts +51 -0
  80. package/dist/panels/runtime.js +1063 -0
  81. package/dist/panels/storage-store.d.ts +9 -0
  82. package/dist/panels/storage-store.js +154 -0
  83. package/dist/panels/types.d.ts +52 -0
  84. package/dist/panels/types.js +1 -0
  85. package/dist/project-runtime/control-server.d.ts +19 -0
  86. package/dist/project-runtime/control-server.js +300 -0
  87. package/dist/project-runtime/docker-provider.d.ts +33 -0
  88. package/dist/project-runtime/docker-provider.js +487 -0
  89. package/dist/project-runtime/lock.d.ts +6 -0
  90. package/dist/project-runtime/lock.js +43 -0
  91. package/dist/project-runtime/managed-entry.d.ts +10 -0
  92. package/dist/project-runtime/managed-entry.js +113 -0
  93. package/dist/project-runtime/manager.d.ts +40 -0
  94. package/dist/project-runtime/manager.js +249 -0
  95. package/dist/project-runtime/proxy.d.ts +22 -0
  96. package/dist/project-runtime/proxy.js +706 -0
  97. package/dist/project-runtime/registry.d.ts +54 -0
  98. package/dist/project-runtime/registry.js +286 -0
  99. package/dist/project-runtime/types.d.ts +43 -0
  100. package/dist/project-runtime/types.js +1 -0
  101. package/dist/serve/cli.d.ts +5 -0
  102. package/dist/serve/cli.js +82 -23
  103. package/dist/serve/headless-server.d.ts +11 -0
  104. package/dist/serve/headless-server.js +737 -83
  105. package/dist/session-titles-store.d.ts +17 -0
  106. package/dist/session-titles-store.js +174 -0
  107. package/dist/static-files.js +1 -0
  108. package/dist/worker-bridge-core.js +17 -6
  109. package/package.json +19 -5
@@ -0,0 +1,706 @@
1
+ import { request as httpRequest } from "node:http";
2
+ import { Transform } from "node:stream";
3
+ import { WebSocket, WebSocketServer } from "ws";
4
+ import { HUB_SESSION_COOKIE } from "../hub/auth-http.js";
5
+ const UUID = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
6
+ const PROJECT_PATH = new RegExp(`^/p/(${UUID})(/.*)$`);
7
+ const API_PATH = /^\/api\/v1\/(?:configuration|mcp|skills|links|panels|panel-assets|files|sessions|uploads)(?:\/|$)/;
8
+ const ASSET_PATH = /^\/api\/v1\/panel-assets\/([A-Za-z0-9_-]{43})\//;
9
+ const PREPARE_PATH = "/api/v1/panels/runtime/prepare";
10
+ const MAX_REQUEST_BYTES = 32 * 1024 * 1024;
11
+ const MAX_PREPARE_BYTES = 1024 * 1024;
12
+ // Match Hub's existing 32 MiB transcript / 64 MiB response contract. Following
13
+ // frames are independently bounded, and paused until the previous write drains.
14
+ const MAX_FRAME_BYTES = 64 * 1024 * 1024;
15
+ const MAX_FRAME_TAIL_BYTES = 8 * 1024 * 1024;
16
+ const MAX_TOTAL_FRAME_BYTES = 128 * 1024 * 1024;
17
+ const MAX_LEASES = 1024;
18
+ const MAX_ASSETS = 4096;
19
+ const MAX_ASSETS_PER_LEASE = 128;
20
+ const REQUEST_HEADERS = [
21
+ "accept",
22
+ "content-type",
23
+ "content-length",
24
+ "range",
25
+ "if-range",
26
+ "if-none-match",
27
+ "if-modified-since",
28
+ "x-file-name",
29
+ ];
30
+ const RESPONSE_HEADERS = [
31
+ "content-type",
32
+ "content-length",
33
+ "content-disposition",
34
+ "content-range",
35
+ "accept-ranges",
36
+ "etag",
37
+ "last-modified",
38
+ "content-security-policy",
39
+ "permissions-policy",
40
+ "cross-origin-resource-policy",
41
+ "access-control-allow-origin",
42
+ "access-control-allow-methods",
43
+ "access-control-allow-headers",
44
+ "referrer-policy",
45
+ "x-content-type-options",
46
+ ];
47
+ class ProxyError extends Error {
48
+ status;
49
+ constructor(status, message) {
50
+ super(message);
51
+ this.status = status;
52
+ }
53
+ }
54
+ /** Private runtime credentials and cookies never cross this control-plane boundary. */
55
+ export function createProjectRuntimeProxy(options) {
56
+ const leases = new Map();
57
+ const assets = new Map();
58
+ const revokedOwners = new Set();
59
+ const projectRevisions = new Map();
60
+ const retirements = new Set();
61
+ const failedLogouts = new Set();
62
+ const upgrades = new Set();
63
+ const wss = new WebSocketServer({
64
+ noServer: true,
65
+ maxPayload: MAX_FRAME_BYTES,
66
+ perMessageDeflate: false,
67
+ });
68
+ let closed = false;
69
+ let sweeping = false;
70
+ let queuedFrameBytes = 0;
71
+ function liveSession(id) {
72
+ if (closed || revokedOwners.has(id))
73
+ throw new ProxyError(401, "Project session expired");
74
+ const session = options.auth.store.listSessions().find((item) => item.id === id);
75
+ if (!session || session.expiresAt <= Date.now())
76
+ throw new ProxyError(401, "Project session expired");
77
+ return session;
78
+ }
79
+ function assertLease(lease) {
80
+ const session = liveSession(lease.sessionId);
81
+ if (lease.revoked || (projectRevisions.get(lease.projectId) ?? 0) !== lease.projectRevision)
82
+ throw new ProxyError(410, "Project runtime changed; reopen the project");
83
+ return session;
84
+ }
85
+ async function validate(lease) {
86
+ const session = assertLease(lease);
87
+ const current = checkedTarget(await options.resolveTarget(lease.projectId, session));
88
+ assertLease(lease);
89
+ if (!sameTarget(current, lease.target)) {
90
+ void retire(lease);
91
+ throw new ProxyError(410, "Project runtime changed; reopen the project");
92
+ }
93
+ }
94
+ function forgetAssets(lease, instanceId) {
95
+ for (const key of lease.assets) {
96
+ if (instanceId && assets.get(key)?.instanceId !== instanceId)
97
+ continue;
98
+ assets.delete(key);
99
+ lease.assets.delete(key);
100
+ }
101
+ }
102
+ function retire(lease) {
103
+ if (lease.retirement)
104
+ return lease.retirement;
105
+ lease.revoked = true;
106
+ if (leases.get(lease.key) === lease)
107
+ leases.delete(lease.key);
108
+ forgetAssets(lease);
109
+ for (const cancel of [...lease.active])
110
+ cancel();
111
+ lease.active.clear();
112
+ // A login already accepted by the runtime may finish after revocation. Its
113
+ // resulting cookie must be logged out too, and must never start a request.
114
+ const retirement = (async () => {
115
+ const cookie = lease.cookie ?? (await lease.login.catch(() => undefined));
116
+ if (cookie)
117
+ await innerJson(lease.target, "/api/v1/auth/logout", "{}", cookie, 3_000);
118
+ })();
119
+ lease.retirement = retirement;
120
+ retirements.add(retirement);
121
+ void retirement.then(() => {
122
+ retirements.delete(retirement);
123
+ failedLogouts.delete(lease);
124
+ }, () => {
125
+ retirements.delete(retirement);
126
+ failedLogouts.add(lease);
127
+ void Promise.resolve()
128
+ .then(() => options.onRevocationFailure?.(lease.projectId))
129
+ .catch(() => { });
130
+ });
131
+ return retirement;
132
+ }
133
+ async function acquire(req, projectId) {
134
+ if (!options.auth.isOriginAllowed(req))
135
+ throw new ProxyError(403, "Request origin is not allowed");
136
+ const session = await options.auth.authenticate(req);
137
+ if (!session)
138
+ throw new ProxyError(401, "Authentication required");
139
+ liveSession(session.id);
140
+ const projectRevision = projectRevisions.get(projectId) ?? 0;
141
+ const target = checkedTarget(await options.resolveTarget(projectId, session));
142
+ liveSession(session.id);
143
+ if (projectRevision !== (projectRevisions.get(projectId) ?? 0))
144
+ throw new ProxyError(410, "Project runtime changed; reopen the project");
145
+ const key = `${session.id}:${projectId}:${target.generation}`;
146
+ let lease = leases.get(key);
147
+ if (lease && !sameTarget(lease.target, target)) {
148
+ await retire(lease);
149
+ throw new ProxyError(410, "Project runtime changed; reopen the project");
150
+ }
151
+ if (!lease) {
152
+ for (const previous of leases.values()) {
153
+ if (previous.sessionId === session.id && previous.projectId === projectId)
154
+ void retire(previous);
155
+ }
156
+ if (leases.size + retirements.size + failedLogouts.size >= MAX_LEASES)
157
+ throw new ProxyError(503, "Too many project connections");
158
+ lease = {
159
+ key,
160
+ projectId,
161
+ sessionId: session.id,
162
+ projectRevision,
163
+ target,
164
+ login: Promise.resolve(""),
165
+ revoked: false,
166
+ active: new Set(),
167
+ assets: new Set(),
168
+ };
169
+ const created = lease;
170
+ leases.set(key, created);
171
+ created.login = innerJson(target, "/api/v1/auth/login", JSON.stringify({
172
+ username: target.username,
173
+ password: target.password,
174
+ deviceName: `project-proxy:${session.id}`,
175
+ })).then(({ response, bytes }) => {
176
+ const body = JSON.parse(bytes.toString("utf8"));
177
+ const cookies = response.headers["set-cookie"] ?? [];
178
+ const cookie = cookies
179
+ .map((value) => value.split(";", 1)[0])
180
+ .find((value) => new RegExp(`^${HUB_SESSION_COOKIE}=[A-Za-z0-9_-]{43}$`).test(value));
181
+ if (!cookie || body.authenticated !== true)
182
+ throw new ProxyError(502, "Runtime login failed");
183
+ created.cookie = cookie;
184
+ return cookie;
185
+ });
186
+ }
187
+ try {
188
+ await lease.login;
189
+ await validate(lease);
190
+ return lease;
191
+ }
192
+ catch (error) {
193
+ void retire(lease);
194
+ throw error;
195
+ }
196
+ }
197
+ async function acquireAsset(req, route) {
198
+ if (!["GET", "HEAD", "OPTIONS"].includes(req.method ?? ""))
199
+ throw new ProxyError(405, "Panel assets are read-only");
200
+ const token = ASSET_PATH.exec(route.pathname)?.[1];
201
+ const key = `${route.projectId}:${token}`;
202
+ const grant = token && assets.get(key);
203
+ if (!grant || grant.expiresAt <= Date.now()) {
204
+ if (grant) {
205
+ assets.delete(key);
206
+ grant.lease.assets.delete(key);
207
+ }
208
+ throw new ProxyError(410, "Panel asset grant expired");
209
+ }
210
+ // An opaque iframe has no cookie and Origin:null. Only its already issued
211
+ // random capability takes this path; another signed-in owner cannot borrow it.
212
+ if (req.headers.authorization !== undefined ||
213
+ req.headers.cookie?.includes(`${HUB_SESSION_COOKIE}=`)) {
214
+ const session = await options.auth.authenticate(req);
215
+ if (!session || session.id !== grant.lease.sessionId)
216
+ throw new ProxyError(403, "Panel asset belongs to another session");
217
+ }
218
+ await validate(grant.lease);
219
+ if (assets.get(key) !== grant)
220
+ throw new ProxyError(410, "Panel asset grant expired");
221
+ return grant.lease;
222
+ }
223
+ function rememberPrepare(lease, bytes) {
224
+ const value = JSON.parse(bytes.toString("utf8"));
225
+ const match = typeof value.src === "string" && ASSET_PATH.exec(value.src);
226
+ if (!match ||
227
+ typeof value.instanceId !== "string" ||
228
+ !/^[A-Za-z0-9_-]{16,128}$/.test(value.instanceId) ||
229
+ !Number.isFinite(value.expiresAt) ||
230
+ value.expiresAt <= Date.now())
231
+ throw new ProxyError(502, "Runtime returned an invalid panel grant");
232
+ if (assets.size >= MAX_ASSETS || lease.assets.size >= MAX_ASSETS_PER_LEASE)
233
+ throw new ProxyError(503, "Too many panel resource grants");
234
+ const key = `${lease.projectId}:${match[1]}`;
235
+ if (assets.has(key))
236
+ throw new ProxyError(502, "Runtime returned a duplicate panel grant");
237
+ assets.set(key, { lease, instanceId: value.instanceId, expiresAt: value.expiresAt });
238
+ lease.assets.add(key);
239
+ }
240
+ function headers(lease, req) {
241
+ const result = {
242
+ origin: options.publicOrigin(),
243
+ cookie: lease.cookie,
244
+ };
245
+ for (const name of REQUEST_HEADERS) {
246
+ const value = req?.headers[name];
247
+ if (value !== undefined)
248
+ result[name] = value;
249
+ }
250
+ return result;
251
+ }
252
+ async function innerJson(target, path, body, cookie, timeout = 10_000) {
253
+ return await new Promise((resolve, reject) => {
254
+ const request = httpRequest(new URL(path, target.url), {
255
+ method: "POST",
256
+ headers: {
257
+ origin: options.publicOrigin(),
258
+ "content-type": "application/json",
259
+ "content-length": Buffer.byteLength(body),
260
+ ...(cookie ? { cookie } : {}),
261
+ },
262
+ }, (response) => {
263
+ if (response.statusCode !== 200 &&
264
+ !(path === "/api/v1/auth/logout" && response.statusCode === 401)) {
265
+ response.resume();
266
+ reject(new ProxyError(502, "Runtime authentication unavailable"));
267
+ return;
268
+ }
269
+ void readBounded(response, 32 * 1024).then((bytes) => resolve({ response, bytes }), reject);
270
+ });
271
+ const timer = setTimeout(() => request.destroy(new ProxyError(504, "Runtime authentication timed out")), timeout);
272
+ timer.unref();
273
+ request.on("error", reject);
274
+ request.on("close", () => clearTimeout(timer));
275
+ request.end(body);
276
+ });
277
+ }
278
+ async function forwardHttp(req, res, route, lease) {
279
+ assertLease(lease);
280
+ if (Number(req.headers["content-length"] ?? 0) > MAX_REQUEST_BYTES)
281
+ throw new ProxyError(413, "Project request is too large");
282
+ await new Promise((resolve, reject) => {
283
+ let settled = false;
284
+ let response;
285
+ let received = 0;
286
+ const limit = new Transform({
287
+ transform(chunk, _encoding, callback) {
288
+ received += chunk.length;
289
+ callback(received > MAX_REQUEST_BYTES
290
+ ? new ProxyError(413, "Project request is too large")
291
+ : null, chunk);
292
+ },
293
+ });
294
+ const finish = (error) => {
295
+ if (settled)
296
+ return;
297
+ settled = true;
298
+ lease.active.delete(cancel);
299
+ req.off("aborted", cancel);
300
+ res.off("close", disconnected);
301
+ res.off("finish", finished);
302
+ req.unpipe(limit);
303
+ limit.unpipe(upstream);
304
+ if (error) {
305
+ upstream.destroy();
306
+ response?.destroy();
307
+ limit.destroy();
308
+ reject(error);
309
+ }
310
+ else
311
+ resolve();
312
+ };
313
+ const cancel = () => finish(new ProxyError(410, "Project connection closed"));
314
+ const disconnected = () => {
315
+ if (!res.writableFinished)
316
+ cancel();
317
+ };
318
+ const finished = () => finish();
319
+ const upstream = httpRequest(new URL(route.path, lease.target.url), {
320
+ method: req.method,
321
+ headers: headers(lease, req),
322
+ });
323
+ upstream.setTimeout(120_000, () => finish(new ProxyError(504, "Runtime request timed out")));
324
+ upstream.on("error", () => finish(new ProxyError(502, "Project runtime unavailable")));
325
+ limit.on("error", (error) => finish(error));
326
+ upstream.on("response", (incoming) => {
327
+ response = incoming;
328
+ incoming.on("error", () => finish(new ProxyError(502, "Runtime response interrupted")));
329
+ void (async () => {
330
+ await validate(lease);
331
+ if (settled)
332
+ return;
333
+ if ((incoming.statusCode ?? 502) >= 300 &&
334
+ (incoming.statusCode ?? 502) < 400 &&
335
+ incoming.statusCode !== 304)
336
+ throw new ProxyError(502, "Runtime redirect is not allowed");
337
+ const prepare = req.method === "POST" && route.pathname === PREPARE_PATH && incoming.statusCode === 200;
338
+ const renew = req.method === "POST" &&
339
+ incoming.statusCode === 200 &&
340
+ /^\/api\/v1\/panels\/runtime\/([A-Za-z0-9_-]+)\/renew$/.exec(route.pathname);
341
+ const bytes = prepare || renew ? await readBounded(incoming, MAX_PREPARE_BYTES) : undefined;
342
+ if (bytes) {
343
+ await validate(lease);
344
+ if (settled)
345
+ return;
346
+ if (prepare)
347
+ rememberPrepare(lease, bytes);
348
+ else if (renew) {
349
+ const { expiresAt } = JSON.parse(bytes.toString("utf8"));
350
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now())
351
+ throw new ProxyError(502, "Runtime returned an invalid panel renewal");
352
+ for (const key of lease.assets) {
353
+ const grant = assets.get(key);
354
+ if (grant?.instanceId === renew[1])
355
+ grant.expiresAt = expiresAt;
356
+ }
357
+ }
358
+ }
359
+ if (req.method === "DELETE" &&
360
+ /^\/api\/v1\/panels\/runtime\/[A-Za-z0-9_-]+$/.test(route.pathname) &&
361
+ (incoming.statusCode ?? 500) < 300)
362
+ forgetAssets(lease, route.pathname.split("/").at(-1));
363
+ for (const name of RESPONSE_HEADERS) {
364
+ const value = incoming.headers[name];
365
+ if (value !== undefined)
366
+ res.setHeader(name, value);
367
+ }
368
+ res.setHeader("Cache-Control", "no-store");
369
+ res.writeHead(incoming.statusCode ?? 502);
370
+ if (bytes)
371
+ res.end(bytes);
372
+ else
373
+ incoming.pipe(res);
374
+ })().catch((error) => finish(error instanceof ProxyError ? error : new ProxyError(502, "Invalid runtime response")));
375
+ });
376
+ lease.active.add(cancel);
377
+ req.once("aborted", cancel);
378
+ req.once("error", cancel);
379
+ res.once("error", cancel);
380
+ res.once("close", disconnected);
381
+ res.once("finish", finished);
382
+ req.pipe(limit).pipe(upstream);
383
+ });
384
+ }
385
+ async function handle(req, res) {
386
+ if (!(req.url ?? "").startsWith("/p/"))
387
+ return false;
388
+ try {
389
+ const route = parseRoute(req.url);
390
+ if (route.pathname === "/ws")
391
+ throw new ProxyError(426, "WebSocket upgrade required");
392
+ const lease = route.pathname.startsWith("/api/v1/panel-assets/")
393
+ ? await acquireAsset(req, route)
394
+ : await acquire(req, route.projectId);
395
+ if (req.aborted || res.destroyed)
396
+ return true;
397
+ await forwardHttp(req, res, route, lease);
398
+ }
399
+ catch (error) {
400
+ httpError(res, error);
401
+ }
402
+ return true;
403
+ }
404
+ async function handleUpgrade(req, socket, head) {
405
+ if (!(req.url ?? "").startsWith("/p/"))
406
+ return false;
407
+ let cancelled = false;
408
+ let peer;
409
+ const cancel = () => {
410
+ cancelled = true;
411
+ peer?.terminate();
412
+ socket.destroy();
413
+ };
414
+ upgrades.add(cancel);
415
+ socket.once("close", cancel);
416
+ socket.once("error", cancel);
417
+ let lease;
418
+ try {
419
+ const route = parseRoute(req.url);
420
+ if (req.method !== "GET" || route.pathname !== "/ws")
421
+ throw new ProxyError(404, "Project endpoint not found");
422
+ lease = await acquire(req, route.projectId);
423
+ if (cancelled)
424
+ return true;
425
+ lease.active.add(cancel);
426
+ const url = new URL("/ws", lease.target.url);
427
+ url.protocol = "ws:";
428
+ peer = new WebSocket(url, {
429
+ headers: headers(lease),
430
+ maxPayload: MAX_FRAME_BYTES,
431
+ perMessageDeflate: false,
432
+ followRedirects: false,
433
+ handshakeTimeout: 10_000,
434
+ });
435
+ peer.on("error", () => { });
436
+ await new Promise((resolve, reject) => {
437
+ peer.once("open", () => {
438
+ peer.pause();
439
+ resolve();
440
+ });
441
+ peer.once("error", () => reject(new ProxyError(502, "Project socket unavailable")));
442
+ peer.once("close", () => reject(new ProxyError(502, "Project socket closed")));
443
+ });
444
+ await validate(lease);
445
+ if (cancelled)
446
+ return true;
447
+ const owned = lease;
448
+ const upstream = peer;
449
+ wss.handleUpgrade(req, socket, head, (client) => {
450
+ lease.active.delete(cancel);
451
+ socket.off("close", cancel);
452
+ upgrades.delete(cancel);
453
+ let stopped = false;
454
+ const stop = () => {
455
+ if (stopped)
456
+ return;
457
+ stopped = true;
458
+ owned.active.delete(stop);
459
+ client.terminate();
460
+ upstream.terminate();
461
+ };
462
+ owned.active.add(stop);
463
+ client.on("close", stop);
464
+ upstream.on("close", stop);
465
+ client.on("error", stop);
466
+ upstream.on("error", stop);
467
+ const bridge = (source, destination) => {
468
+ let chain = Promise.resolve();
469
+ let bytesQueued = 0;
470
+ const frameSizes = [];
471
+ source.on("message", (data, binary) => {
472
+ if (stopped)
473
+ return;
474
+ const bytes = rawSize(data);
475
+ if (bytes > MAX_FRAME_BYTES ||
476
+ (frameSizes.length > 0 &&
477
+ bytesQueued - frameSizes[0] + bytes > MAX_FRAME_TAIL_BYTES) ||
478
+ frameSizes.length >= 128 ||
479
+ queuedFrameBytes + bytes > MAX_TOTAL_FRAME_BYTES) {
480
+ stop();
481
+ return;
482
+ }
483
+ // Native Node/ws exposes flow control; Bun's server-side ws shim
484
+ // does not. The same queue bounds still fail closed on that host.
485
+ source.pause?.();
486
+ bytesQueued += bytes;
487
+ frameSizes.push(bytes);
488
+ queuedFrameBytes += bytes;
489
+ chain = chain
490
+ .then(async () => {
491
+ if (stopped)
492
+ return;
493
+ await validate(owned);
494
+ if (stopped)
495
+ return;
496
+ await new Promise((resolve, reject) => destination.send(data, { binary }, (error) => error ? reject(error) : resolve()));
497
+ })
498
+ .catch(stop)
499
+ .finally(() => {
500
+ bytesQueued -= bytes;
501
+ frameSizes.shift();
502
+ queuedFrameBytes -= bytes;
503
+ if (!stopped && frameSizes.length === 0)
504
+ source.resume?.();
505
+ });
506
+ });
507
+ };
508
+ bridge(client, upstream);
509
+ bridge(upstream, client);
510
+ upstream.resume();
511
+ });
512
+ }
513
+ catch (error) {
514
+ peer?.terminate();
515
+ if (!cancelled && !socket.destroyed)
516
+ upgradeError(socket, error);
517
+ }
518
+ finally {
519
+ upgrades.delete(cancel);
520
+ socket.off("close", cancel);
521
+ socket.off("error", cancel);
522
+ lease?.active.delete(cancel);
523
+ }
524
+ return true;
525
+ }
526
+ async function revokeOwner(sessionId) {
527
+ revokedOwners.add(sessionId);
528
+ await Promise.all([...new Set([...leases.values(), ...failedLogouts])]
529
+ .filter((lease) => lease.sessionId === sessionId)
530
+ .map(retire));
531
+ }
532
+ async function revokeProject(projectId) {
533
+ projectRevisions.set(projectId, (projectRevisions.get(projectId) ?? 0) + 1);
534
+ await Promise.all([...new Set([...leases.values(), ...failedLogouts])]
535
+ .filter((lease) => lease.projectId === projectId)
536
+ .map(retire));
537
+ }
538
+ const timer = setInterval(() => {
539
+ if (closed)
540
+ return;
541
+ let current;
542
+ try {
543
+ current = new Set(options.auth.store.listSessions().map((session) => session.id));
544
+ }
545
+ catch {
546
+ current = new Set();
547
+ }
548
+ // Logout retries or a slow Docker status check must not delay the next
549
+ // authentication sweep: retire all invalid outer owners synchronously.
550
+ for (const lease of [...leases.values()])
551
+ if (!current.has(lease.sessionId))
552
+ void revokeOwner(lease.sessionId).catch(() => { });
553
+ if (sweeping)
554
+ return;
555
+ sweeping = true;
556
+ void (async () => {
557
+ for (const [key, grant] of assets)
558
+ if (grant.expiresAt <= Date.now()) {
559
+ assets.delete(key);
560
+ grant.lease.assets.delete(key);
561
+ }
562
+ // Failed revocations never restore browser access. Retry only the fixed
563
+ // private logout endpoint until the runtime acknowledges invalidation.
564
+ await Promise.allSettled([
565
+ ...[...leases.values()].map(async (lease) => {
566
+ try {
567
+ await validate(lease);
568
+ }
569
+ catch {
570
+ await retire(lease);
571
+ }
572
+ }),
573
+ ...[...failedLogouts].map(async (lease) => {
574
+ if (lease.cookie)
575
+ await innerJson(lease.target, "/api/v1/auth/logout", "{}", lease.cookie, 3_000);
576
+ failedLogouts.delete(lease);
577
+ }),
578
+ ]);
579
+ })().finally(() => {
580
+ sweeping = false;
581
+ });
582
+ }, 2_000);
583
+ timer.unref();
584
+ return {
585
+ handle,
586
+ handleUpgrade,
587
+ revokeOwner,
588
+ revokeProject,
589
+ async close() {
590
+ closed = true;
591
+ clearInterval(timer);
592
+ for (const cancel of upgrades)
593
+ cancel();
594
+ const results = await Promise.allSettled([
595
+ ...[...leases.values()].map(retire),
596
+ ...retirements,
597
+ ...[...failedLogouts].map(async (lease) => {
598
+ if (lease.cookie)
599
+ await innerJson(lease.target, "/api/v1/auth/logout", "{}", lease.cookie, 3_000);
600
+ }),
601
+ ]);
602
+ wss.close();
603
+ const failure = results.find((result) => result.status === "rejected");
604
+ if (failure?.status === "rejected")
605
+ throw failure.reason;
606
+ },
607
+ };
608
+ }
609
+ function checkedTarget(target) {
610
+ let url;
611
+ try {
612
+ url = new URL(target.url);
613
+ }
614
+ catch {
615
+ throw new ProxyError(503, "Project runtime unavailable");
616
+ }
617
+ if (url.protocol !== "http:" ||
618
+ !["127.0.0.1", "[::1]"].includes(url.hostname) ||
619
+ !url.port ||
620
+ url.username ||
621
+ url.password ||
622
+ url.pathname !== "/" ||
623
+ url.search ||
624
+ url.hash ||
625
+ !Number.isSafeInteger(target.generation) ||
626
+ target.generation < 1 ||
627
+ !target.username ||
628
+ !target.password)
629
+ throw new ProxyError(503, "Invalid project runtime target");
630
+ return {
631
+ url: url.origin,
632
+ username: target.username,
633
+ password: target.password,
634
+ generation: target.generation,
635
+ };
636
+ }
637
+ function sameTarget(left, right) {
638
+ return (left.url === right.url &&
639
+ left.generation === right.generation &&
640
+ left.username === right.username &&
641
+ left.password === right.password);
642
+ }
643
+ function parseRoute(raw) {
644
+ if (raw.includes("#") || raw.includes("\\") || /[\u0000-\u0020\u007f]/.test(raw))
645
+ throw new ProxyError(400, "Invalid project path");
646
+ const match = PROJECT_PATH.exec(raw);
647
+ if (!match)
648
+ throw new ProxyError(404, "Project endpoint not found");
649
+ const pathname = match[2].split("?", 1)[0];
650
+ try {
651
+ for (const segment of pathname.split("/")) {
652
+ const decoded = decodeURIComponent(segment);
653
+ if (decoded === "." ||
654
+ decoded === ".." ||
655
+ decoded.includes("/") ||
656
+ /[\\\u0000-\u001f\u007f]/.test(decoded))
657
+ throw new Error("invalid segment");
658
+ }
659
+ }
660
+ catch {
661
+ throw new ProxyError(400, "Invalid project path");
662
+ }
663
+ if (pathname !== "/ws" && !API_PATH.test(pathname))
664
+ throw new ProxyError(404, "Project endpoint not found");
665
+ const url = new URL(match[2], "http://runtime.invalid");
666
+ // The private host owns exactly one workspace. Browser-selected paths never
667
+ // select a host filesystem or override the project's environment.
668
+ url.searchParams.delete("workspace");
669
+ return { projectId: match[1], pathname, path: url.pathname + url.search };
670
+ }
671
+ async function readBounded(response, maximum) {
672
+ const chunks = [];
673
+ let size = 0;
674
+ for await (const chunk of response) {
675
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
676
+ size += bytes.length;
677
+ if (size > maximum) {
678
+ response.destroy();
679
+ throw new ProxyError(502, "Runtime response is too large");
680
+ }
681
+ chunks.push(bytes);
682
+ }
683
+ return Buffer.concat(chunks, size);
684
+ }
685
+ function rawSize(data) {
686
+ return Array.isArray(data)
687
+ ? data.reduce((size, buffer) => size + buffer.byteLength, 0)
688
+ : data.byteLength;
689
+ }
690
+ function httpError(res, error) {
691
+ if (res.destroyed || res.writableEnded)
692
+ return;
693
+ if (res.headersSent) {
694
+ res.destroy();
695
+ return;
696
+ }
697
+ const status = error instanceof ProxyError ? error.status : 503;
698
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
699
+ res.end(JSON.stringify({
700
+ error: error instanceof ProxyError ? error.message : "Project runtime unavailable",
701
+ }));
702
+ }
703
+ function upgradeError(socket, error) {
704
+ const status = error instanceof ProxyError ? error.status : 503;
705
+ socket.end(`HTTP/1.1 ${status} Error\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
706
+ }