@maintainer-pro/ai-bridge 0.1.15 → 0.1.18

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/src/daemon.mjs CHANGED
@@ -23,8 +23,20 @@ import {
23
23
  lookupShareResponse,
24
24
  prepareShareHttpRequest,
25
25
  processShareHttpResponse,
26
+ rewriteMappedLocalUrls,
27
+ rewriteShareRequestBody,
28
+ shareServiceWorkerScript,
29
+ shareShimScript,
30
+ shareTokenRoot,
26
31
  shouldProcessShareResponse,
32
+ shouldRewriteBody,
27
33
  } from "./share-rewrite.mjs";
34
+ import {
35
+ canonicalActionCode,
36
+ isDiscardedTunnelUrl,
37
+ leftoverStateKeys,
38
+ LEGACY_TUNNEL_FILE,
39
+ } from "./discarded-tunnels.mjs";
28
40
 
29
41
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
42
  const PACKAGE_VERSION = readPackageVersion();
@@ -74,6 +86,34 @@ function shortId(value) {
74
86
  return text.length > 12 ? `${text.slice(0, 8)}…` : text;
75
87
  }
76
88
 
89
+ function sameFolder(a, b) {
90
+ const na = path.resolve(String(a || ""));
91
+ const nb = path.resolve(String(b || ""));
92
+ if (process.platform === "win32") return na.toLowerCase() === nb.toLowerCase();
93
+ return na === nb;
94
+ }
95
+
96
+ function workspaceFolders(ws) {
97
+ const primary = String(ws?.folderPath || "").trim();
98
+ const extra = Array.isArray(ws?.extraFolders) ? ws.extraFolders : [];
99
+ const out = [];
100
+ const seen = new Set();
101
+ for (const raw of [primary, ...extra]) {
102
+ if (!raw || typeof raw !== "string") continue;
103
+ const resolved = path.resolve(raw);
104
+ const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
105
+ if (seen.has(key)) continue;
106
+ seen.add(key);
107
+ out.push(resolved);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ function folderForApp(ws, app) {
113
+ if (app?.folderPath) return path.resolve(String(app.folderPath));
114
+ return path.resolve(ws?.folderPath || "");
115
+ }
116
+
77
117
  function actionLabel(action) {
78
118
  const sandbox = action.sandboxId || action.payload?.sandboxId;
79
119
  const folder = action.payload?.folderPath;
@@ -358,7 +398,8 @@ async function detectCliProviders() {
358
398
  }
359
399
 
360
400
  async function resolveWorkspaceHostApps(ws, opts = {}) {
361
- const folder = path.resolve(ws.folderPath || "");
401
+ const folders = workspaceFolders(ws);
402
+ const folder = folders[0] || path.resolve(ws.folderPath || "");
362
403
  const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
363
404
  const desired = Array.isArray(opts.desired)
364
405
  ? opts.desired
@@ -373,10 +414,11 @@ async function resolveWorkspaceHostApps(ws, opts = {}) {
373
414
  activity(
374
415
  ws.sandboxId,
375
416
  "info",
376
- `detecting ports for ${label} in ${folder} (env first${opts.force ? ", redetect" : ""})`
417
+ `detecting ports for ${label} in ${folders.join(", ") || folder} (env first${opts.force ? ", redetect" : ""})`
377
418
  );
378
419
  const result = await cli.resolveHostApps({
379
420
  workspaceDir: folder,
421
+ workspaceDirs: folders,
380
422
  appName: ws.applicationName || ws.sandboxName,
381
423
  preferredAiPort: Number(ws.port) || 3100,
382
424
  desired: opts.ignoreDesired ? [] : desired,
@@ -385,10 +427,12 @@ async function resolveWorkspaceHostApps(ws, opts = {}) {
385
427
  sandboxId: ws.sandboxId,
386
428
  });
387
429
  const previous = Array.isArray(ws.hostApps) ? ws.hostApps : [];
430
+ if (opts.unionDetected && previous.length && typeof cli.unionHostApps === "function") {
431
+ result.apps = cli.unionHostApps(previous, result.apps);
432
+ }
388
433
  result.apps = result.apps.map((app) => {
389
434
  const match = previous.find((row) => row && row.id === app.id);
390
- if (!match || (app.envMaps && app.envMaps.length)) return app;
391
- return { ...app, envMaps: match.envMaps || [] };
435
+ return match ? { ...app, host: match.host === true || app.host === true } : app;
392
436
  });
393
437
  ws.hostApps = result.apps;
394
438
  if (opts.cfg) persistWorkspaceEntry(opts.cfg, ws);
@@ -489,6 +533,14 @@ function projectFingerprint(folder) {
489
533
  "next.config.js",
490
534
  "next.config.mjs",
491
535
  "next.config.ts",
536
+ "pom.xml",
537
+ "build.gradle",
538
+ "build.gradle.kts",
539
+ "build.sbt",
540
+ "src/main/resources/application.yml",
541
+ "src/main/resources/application.properties",
542
+ "src/main/resources/application.conf",
543
+ "conf/application.conf",
492
544
  ]) {
493
545
  const file = path.join(resolved, rel);
494
546
  if (!fs.existsSync(file)) continue;
@@ -819,6 +871,67 @@ function probeUrl(url, timeoutMs = 2500) {
819
871
  });
820
872
  }
821
873
 
874
+ /** Some Windows apps bind `localhost` (::1) but refuse `127.0.0.1`. */
875
+ const LOOPBACK_HOSTS = ["127.0.0.1", "localhost"];
876
+ /** @type {Map<number, string>} */
877
+ const loopbackHostByPort = new Map();
878
+
879
+ function isLoopbackConnError(err) {
880
+ const code = err && typeof err === "object" ? String(err.code || "") : "";
881
+ return (
882
+ code === "ECONNREFUSED" ||
883
+ code === "EHOSTUNREACH" ||
884
+ code === "EADDRNOTAVAIL" ||
885
+ code === "ENOTFOUND" ||
886
+ code === "ETIMEDOUT"
887
+ );
888
+ }
889
+
890
+ function loopbackHostsForPort(port) {
891
+ const cached = loopbackHostByPort.get(Number(port));
892
+ if (cached === "localhost") return ["localhost", "127.0.0.1"];
893
+ return LOOPBACK_HOSTS;
894
+ }
895
+
896
+ function rememberLoopbackHost(port, host) {
897
+ const n = Number(port);
898
+ const name = String(host || "").trim();
899
+ if (n && (name === "127.0.0.1" || name === "localhost")) {
900
+ loopbackHostByPort.set(n, name);
901
+ }
902
+ }
903
+
904
+ function loopbackUrlVariants(url) {
905
+ try {
906
+ const parsed = new URL(String(url));
907
+ const host = String(parsed.hostname || "").toLowerCase();
908
+ if (host !== "localhost" && host !== "127.0.0.1") return [String(url)];
909
+ const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
910
+ return loopbackHostsForPort(port).map((name) => {
911
+ const next = new URL(parsed);
912
+ next.hostname = name;
913
+ return next.toString();
914
+ });
915
+ } catch {
916
+ return [String(url)];
917
+ }
918
+ }
919
+
920
+ async function probeLoopbackUrl(url, timeoutMs = 2500) {
921
+ for (const candidate of loopbackUrlVariants(url)) {
922
+ if (await probeUrl(candidate, timeoutMs)) {
923
+ try {
924
+ const parsed = new URL(candidate);
925
+ rememberLoopbackHost(parsed.port, parsed.hostname);
926
+ } catch {
927
+ /* ignore */
928
+ }
929
+ return true;
930
+ }
931
+ }
932
+ return false;
933
+ }
934
+
822
935
  function adminListenPort(cfg = bridgeCfg) {
823
936
  try {
824
937
  const u = new URL(String(cfg?.adminUrl || "http://localhost:4100"));
@@ -1027,9 +1140,8 @@ function mergeEnvFile(file, values, opts = {}) {
1027
1140
  }
1028
1141
 
1029
1142
  /**
1030
- * Do not write Maintainer Pro keys into the app. The bridge injects env into
1031
- * the process it launches (terminal script / in-process chat).
1032
- * Only strip leftover trycloudflare values we previously wrote.
1143
+ * Do not write Maintainer Pro keys or share URLs into the app.
1144
+ * Only strip leftover tunnel URLs we previously wrote.
1033
1145
  */
1034
1146
  function writeProjectEnv(folder, _values, opts = {}) {
1035
1147
  const remove = (opts.remove || []).filter(Boolean);
@@ -1061,37 +1173,9 @@ function readProjectEnvValues(folder) {
1061
1173
  return map;
1062
1174
  }
1063
1175
 
1064
- function isTryCloudflareUrl(value) {
1065
- try {
1066
- return new URL(String(value || "").trim()).hostname.endsWith(
1067
- ".trycloudflare.com"
1068
- );
1069
- } catch {
1070
- return false;
1071
- }
1072
- }
1073
-
1074
- function readCloudflareTunnelFile(folder) {
1075
- if (!folder) return null;
1076
- const file = path.join(path.resolve(folder), ".cloudflare-tunnel-url");
1077
- if (!fs.existsSync(file)) return null;
1078
- /** @type {Record<string, string>} */
1079
- const tunnels = {};
1080
- for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
1081
- const line = raw.trim();
1082
- if (!line || line.startsWith("#")) continue;
1083
- const eq = line.indexOf("=");
1084
- if (eq < 1) continue;
1085
- const role = line.slice(0, eq).trim();
1086
- const url = line.slice(eq + 1).trim().replace(/\/$/, "");
1087
- if (role && isTryCloudflareUrl(url)) tunnels[role] = url;
1088
- }
1089
- return Object.keys(tunnels).length ? tunnels : null;
1090
- }
1091
-
1092
- function clearCloudflareTunnelFile(folder) {
1176
+ function clearLegacyTunnelFile(folder) {
1093
1177
  if (!folder) return;
1094
- const file = path.join(path.resolve(folder), ".cloudflare-tunnel-url");
1178
+ const file = path.join(path.resolve(folder), LEGACY_TUNNEL_FILE);
1095
1179
  try {
1096
1180
  if (fs.existsSync(file)) fs.unlinkSync(file);
1097
1181
  } catch {
@@ -1099,8 +1183,7 @@ function clearCloudflareTunnelFile(folder) {
1099
1183
  }
1100
1184
  }
1101
1185
 
1102
- /** Stop rediscovering dead trycloudflare URLs from old cloudflared logs. */
1103
- function archiveStaleCloudflareLogs(folder, sandboxId) {
1186
+ function archiveLegacyTunnelLogs(folder, sandboxId) {
1104
1187
  if (!folder) return;
1105
1188
  const logDir = dataDirFor(folder, sandboxId);
1106
1189
  if (!fs.existsSync(logDir)) return;
@@ -1123,185 +1206,25 @@ function archiveStaleCloudflareLogs(folder, sandboxId) {
1123
1206
  }
1124
1207
  }
1125
1208
 
1126
- const CLOUDFLARE_ENV_KEYS = [
1127
- "APP_URL",
1128
- "PUBLIC_URL",
1129
- "CORS_ORIGIN",
1130
- "CORS_ORIGINS",
1131
- "NEXT_PUBLIC_APP_URL",
1132
- "VITE_APP_URL",
1133
- "REACT_APP_APP_URL",
1134
- "AI_SERVER_URL",
1135
- "NEXT_PUBLIC_AI_SERVER_URL",
1136
- "VITE_AI_SERVER_URL",
1137
- "REACT_APP_AI_SERVER_URL",
1138
- "API_URL",
1139
- "API_BASE_URL",
1140
- "VITE_API_URL",
1141
- "VITE_API_BASE_URL",
1142
- "NEXT_PUBLIC_API_URL",
1143
- "NEXT_PUBLIC_API_BASE_URL",
1144
- "BACKEND_URL",
1145
- ];
1146
-
1147
- /**
1148
- * Remove dead trycloudflare URLs from workspace state, tunnel file, env, and
1149
- * archived logs so heartbeats stop re-probing them.
1150
- */
1151
- function purgeUnreachableCloudflare(ws, cfg, opts = {}) {
1209
+ /** Drop leftover tunnel state so heartbeats never revive old public URLs. */
1210
+ function stripDiscardedTunnelState(ws, cfg) {
1152
1211
  const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
1153
- const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
1154
- const deadUrls = (opts.deadUrls || [])
1155
- .map((u) => String(u || "").replace(/\/$/, ""))
1156
- .filter((u) => isTryCloudflareUrl(u));
1157
- const keep = opts.keep && typeof opts.keep === "object" ? opts.keep : null;
1158
-
1159
- if (deadUrls.length) {
1160
- log(
1161
- `clearing stale Cloudflare for ${label}: ${deadUrls.join(", ")}`
1212
+ const leftoverKeys = leftoverStateKeys(ws);
1213
+ const had = leftoverKeys.length > 0 || isDiscardedTunnelUrl(ws.appUrl);
1214
+ for (const key of leftoverKeys) delete ws[key];
1215
+ if (isDiscardedTunnelUrl(ws.appUrl)) ws.appUrl = null;
1216
+
1217
+ if (folder && fs.existsSync(folder)) {
1218
+ archiveLegacyTunnelLogs(folder, ws.sandboxId);
1219
+ clearLegacyTunnelFile(folder);
1220
+ const current = readProjectEnvValues(folder);
1221
+ const remove = Object.keys(current).filter((key) =>
1222
+ isDiscardedTunnelUrl(current[key])
1162
1223
  );
1163
- } else {
1164
- log(`clearing stale Cloudflare for ${label}`);
1165
- }
1166
-
1167
- ws.cloudflareUrl = null;
1168
- ws.cloudflare = keep && Object.keys(keep).length ? { ...keep } : null;
1169
- if (keep?.ui || keep?.ai) {
1170
- ws.cloudflareUrl = keep.ui || keep.ai;
1171
- ws.appUrl = keep.ui || keep.ai;
1172
- } else if (ws.appUrl && !isLocalAppUrl(ws.appUrl)) {
1173
- ws.appUrl = null;
1174
- }
1175
-
1176
- if (ws.sandboxId && !(keep && Object.keys(keep).length)) {
1177
- cloudflareTunnels.delete(ws.sandboxId);
1178
- }
1179
-
1180
- if (!folder || !fs.existsSync(folder)) {
1181
- persistWorkspaceEntry(cfg, ws);
1182
- return;
1183
- }
1184
-
1185
- // Always archive cf-*.log so dead trycloudflare hosts are not rediscovered.
1186
- archiveStaleCloudflareLogs(folder, ws.sandboxId);
1187
- if (keep && Object.keys(keep).length) {
1188
- writeTunnelEnv(ws, keep);
1189
- } else {
1190
- clearCloudflareTunnelFile(folder);
1191
- }
1192
-
1193
- const current = readProjectEnvValues(folder);
1194
- /** @type {string[]} */
1195
- const remove = [];
1196
- for (const key of CLOUDFLARE_ENV_KEYS) {
1197
- const value = current[key];
1198
- if (!isTryCloudflareUrl(value)) continue;
1199
- const normalized = String(value).replace(/\/$/, "");
1200
- if (keep && Object.values(keep).some((u) => String(u).replace(/\/$/, "") === normalized)) {
1201
- continue;
1202
- }
1203
- remove.push(key);
1204
- }
1205
- if (remove.length) {
1206
- writeProjectEnv(folder, {}, { remove });
1207
- }
1208
-
1209
- persistWorkspaceEntry(cfg, ws);
1210
- }
1211
-
1212
- function parseAllTryCloudflareUrls(text) {
1213
- const matches = [
1214
- ...String(text || "").matchAll(
1215
- /https:\/\/[a-z0-9-]+\.trycloudflare\.com/gi
1216
- ),
1217
- ];
1218
- return matches.map((m) => m[0].replace(/\/$/, ""));
1219
- }
1220
-
1221
- function lastTryCloudflareUrl(text) {
1222
- const all = parseAllTryCloudflareUrls(text);
1223
- return all.length ? all[all.length - 1] : null;
1224
- }
1225
-
1226
- /**
1227
- * Discover Cloudflare tunnel URLs from bridge state, tunnel file, project env,
1228
- * and cloudflared log files — even if this bridge process did not start them.
1229
- * @returns {Record<string, string> | null}
1230
- */
1231
- function discoverCloudflareTunnels(ws) {
1232
- if (!ws?.folderPath) return null;
1233
- const folder = path.resolve(ws.folderPath);
1234
- /** @type {Record<string, string>} */
1235
- const tunnels = {};
1236
- const setRole = (role, value) => {
1237
- if (!role || tunnels[role] || !isTryCloudflareUrl(value)) return;
1238
- tunnels[role] = String(value).replace(/\/$/, "");
1239
- };
1240
-
1241
- if (ws.cloudflare && typeof ws.cloudflare === "object") {
1242
- for (const [role, value] of Object.entries(ws.cloudflare)) {
1243
- setRole(role, value);
1244
- }
1224
+ if (remove.length) writeProjectEnv(folder, {}, { remove });
1245
1225
  }
1246
- if (ws.cloudflareUrl) setRole("ui", ws.cloudflareUrl);
1247
-
1248
- const fromFile = readCloudflareTunnelFile(folder);
1249
- if (fromFile) {
1250
- for (const [role, value] of Object.entries(fromFile)) setRole(role, value);
1251
- }
1252
-
1253
- const env = readProjectEnvValues(folder);
1254
- const envRoles = [
1255
- ["ai", env.NEXT_PUBLIC_AI_SERVER_URL],
1256
- ["ai", env.VITE_AI_SERVER_URL],
1257
- ["ai", env.AI_SERVER_URL],
1258
- ["ai", env.REACT_APP_AI_SERVER_URL],
1259
- ["ui", env.NEXT_PUBLIC_APP_URL],
1260
- ["ui", env.VITE_APP_URL],
1261
- ["ui", env.APP_URL],
1262
- ["ui", env.PUBLIC_URL],
1263
- ["backend", env.NEXT_PUBLIC_API_URL],
1264
- ["backend", env.VITE_API_URL],
1265
- ["backend", env.API_URL],
1266
- ];
1267
- for (const [role, value] of envRoles) setRole(role, value);
1268
1226
 
1269
- const logDir = dataDirFor(folder, ws.sandboxId);
1270
- if (fs.existsSync(logDir)) {
1271
- const sandboxPrefix = `cf-${String(ws.sandboxId || "").slice(0, 8)}-`;
1272
- let names = [];
1273
- try {
1274
- names = fs.readdirSync(logDir);
1275
- } catch {
1276
- names = [];
1277
- }
1278
- // Prefer sandbox-scoped logs, then any cf-*-role.log in the project.
1279
- const ranked = names
1280
- .filter((name) => /^cf-.*\.log$/i.test(name) && !name.endsWith(".stale"))
1281
- .sort((a, b) => {
1282
- const aScore = a.startsWith(sandboxPrefix) ? 0 : 1;
1283
- const bScore = b.startsWith(sandboxPrefix) ? 0 : 1;
1284
- return aScore - bScore || a.localeCompare(b);
1285
- });
1286
- for (const name of ranked) {
1287
- const roleMatch = name.match(
1288
- /cf-(?:[a-f0-9]{6,}-)?(ai|ui|backend|app)\.log$/i
1289
- );
1290
- if (!roleMatch) continue;
1291
- let role = roleMatch[1].toLowerCase();
1292
- if (role === "app") role = "ui";
1293
- if (tunnels[role]) continue;
1294
- try {
1295
- const text = fs.readFileSync(path.join(logDir, name), "utf8");
1296
- const url = lastTryCloudflareUrl(text);
1297
- if (url) setRole(role, url);
1298
- } catch {
1299
- /* ignore unreadable logs */
1300
- }
1301
- }
1302
- }
1303
-
1304
- return Object.keys(tunnels).length ? tunnels : null;
1227
+ if (had) persistWorkspaceEntry(cfg, ws);
1305
1228
  }
1306
1229
 
1307
1230
  function workspaceHostReport(ws) {
@@ -1326,7 +1249,7 @@ function workspaceHostReport(ws) {
1326
1249
  env.NEXT_PUBLIC_AI_SERVER_URL,
1327
1250
  ws.appUrl,
1328
1251
  ]) {
1329
- if (!value || isTryCloudflareUrl(value)) continue;
1252
+ if (!value || isDiscardedTunnelUrl(value)) continue;
1330
1253
  add(value);
1331
1254
  }
1332
1255
  if (chatPort) {
@@ -1360,7 +1283,7 @@ function workspaceHostReport(ws) {
1360
1283
  /**
1361
1284
  * Status-only reconcile for a workspace:
1362
1285
  * 1) probe chat / ui / backend
1363
- * 2) drop leftover trycloudflare URLs (never start or reuse tunnels)
1286
+ * 2) drop leftover tunnel URLs
1364
1287
  * 3) compute host appUrl + CORS origins for Maintainer Pro
1365
1288
  *
1366
1289
  * Does not start apps. Does not write the app's .env files.
@@ -1375,19 +1298,8 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
1375
1298
  ws.port = probe.chatPort;
1376
1299
  }
1377
1300
 
1378
- // 2. Remove leftover Cloudflare tunnels / env — share URLs replace them.
1379
- const leftover = discoverCloudflareTunnels(ws);
1380
- if (leftover || ws.cloudflareUrl || ws.cloudflare) {
1381
- purgeUnreachableCloudflare(ws, cfg, {
1382
- deadUrls: leftover ? Object.values(leftover) : [],
1383
- keep: null,
1384
- });
1385
- try {
1386
- await stopCloudflare(ws.sandboxId, ws.folderPath);
1387
- } catch {
1388
- /* leftover cloudflared */
1389
- }
1390
- }
1301
+ // 2. Remove leftover tunnel URLs / env — share URLs replace them.
1302
+ stripDiscardedTunnelState(ws, cfg);
1391
1303
 
1392
1304
  // 3. Host + CORS origins for Maintainer Pro
1393
1305
  const host = workspaceHostReport(ws);
@@ -1445,8 +1357,6 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
1445
1357
 
1446
1358
  return {
1447
1359
  probe,
1448
- usingCloudflare: false,
1449
- cloudflare: null,
1450
1360
  host,
1451
1361
  appsRunning: probe.running,
1452
1362
  aiServerUp: probe.chatUp,
@@ -1467,18 +1377,28 @@ async function probeHttpPaths(port, paths, timeoutMs = 2500) {
1467
1377
  function probePortOpen(port, timeoutMs = 800) {
1468
1378
  const n = Number(port);
1469
1379
  if (!n) return Promise.resolve(false);
1470
- return new Promise((resolve) => {
1471
- const socket = net.connect({ host: "127.0.0.1", port: n });
1472
- const done = (ok) => {
1473
- socket.removeAllListeners();
1474
- socket.destroy();
1475
- resolve(ok);
1476
- };
1477
- socket.setTimeout(timeoutMs);
1478
- socket.once("connect", () => done(true));
1479
- socket.once("timeout", () => done(false));
1480
- socket.once("error", () => done(false));
1481
- });
1380
+ const tryHost = (host) =>
1381
+ new Promise((resolve) => {
1382
+ const socket = net.connect({ host, port: n });
1383
+ const done = (ok) => {
1384
+ socket.removeAllListeners();
1385
+ socket.destroy();
1386
+ resolve(ok);
1387
+ };
1388
+ socket.setTimeout(timeoutMs);
1389
+ socket.once("connect", () => done(true));
1390
+ socket.once("timeout", () => done(false));
1391
+ socket.once("error", () => done(false));
1392
+ });
1393
+ return (async () => {
1394
+ for (const host of loopbackHostsForPort(n)) {
1395
+ if (await tryHost(host)) {
1396
+ rememberLoopbackHost(n, host);
1397
+ return true;
1398
+ }
1399
+ }
1400
+ return false;
1401
+ })();
1482
1402
  }
1483
1403
 
1484
1404
  async function portIsLive(port, timeoutMs = 1200) {
@@ -1567,28 +1487,71 @@ function proxySlugForApp(app) {
1567
1487
  return id || "app";
1568
1488
  }
1569
1489
 
1490
+ function uniqueProxySlugs(apps) {
1491
+ const used = new Set();
1492
+ /** @type {Record<string, string>} */
1493
+ const slugs = {};
1494
+ for (const app of apps || []) {
1495
+ let slug = proxySlugForApp(app);
1496
+ if (!slug || !app?.id) continue;
1497
+ if (used.has(slug)) {
1498
+ const fromId = String(app.id)
1499
+ .trim()
1500
+ .toLowerCase()
1501
+ .replace(/[^a-z0-9_-]+/g, "-")
1502
+ .replace(/^-+|-+$/g, "");
1503
+ slug =
1504
+ fromId && !used.has(fromId)
1505
+ ? fromId
1506
+ : `${slug}-${Number(app.port) || used.size}`;
1507
+ }
1508
+ used.add(slug);
1509
+ slugs[app.id] = slug;
1510
+ }
1511
+ return slugs;
1512
+ }
1513
+
1514
+ function sharePortUrls(ws) {
1515
+ const apps = Array.isArray(ws?.hostApps) ? ws.hostApps : [];
1516
+ /** @type {Record<string, string>} */
1517
+ const out = {};
1518
+ for (const app of apps) {
1519
+ const port = Number(app.port);
1520
+ if (!port) continue;
1521
+ const url = proxyUrlForApp(ws, app);
1522
+ if (url) out[String(port)] = url.replace(/\/$/, "");
1523
+ }
1524
+ return out;
1525
+ }
1526
+
1570
1527
  function applyAssignedProxy(ws, remote, cfg) {
1571
1528
  const proxy =
1572
1529
  remote?.proxy && typeof remote.proxy === "object" ? remote.proxy : null;
1573
1530
  if (!proxy?.token) return;
1574
1531
  const origin = String(proxy.origin || cfg?.adminUrl || "").replace(/\/$/, "");
1575
- const slugs =
1532
+ const assigned =
1576
1533
  proxy.slugs && typeof proxy.slugs === "object" ? proxy.slugs : {};
1534
+ const unique = uniqueProxySlugs(appsFrom(ws, remote));
1535
+ /** @type {Record<string, string>} */
1536
+ const slugs = {};
1577
1537
  /** @type {Record<string, string>} */
1578
1538
  const urls = {};
1579
- const apps = Array.isArray(ws.hostApps)
1580
- ? ws.hostApps
1581
- : Array.isArray(remote.hostApps)
1582
- ? remote.hostApps
1583
- : [];
1539
+ const apps = appsFrom(ws, remote);
1584
1540
  for (const app of apps) {
1585
- const slug = slugs[app.id] || proxySlugForApp(app);
1541
+ const slug = assigned[app.id] || unique[app.id] || proxySlugForApp(app);
1586
1542
  if (!slug || !origin) continue;
1543
+ slugs[app.id] = slug;
1587
1544
  urls[app.id] = `${origin}/p/${proxy.token}/${slug}`;
1588
1545
  }
1589
1546
  ws.proxy = { origin, token: String(proxy.token), slugs, urls };
1590
1547
  }
1591
1548
 
1549
+ function appsFrom(ws, remote) {
1550
+ if (Array.isArray(ws?.hostApps) && ws.hostApps.length) return ws.hostApps;
1551
+ if (Array.isArray(remote?.hostApps)) return remote.hostApps;
1552
+ return [];
1553
+ }
1554
+
1592
1555
  function proxyUrlForApp(ws, app) {
1593
1556
  if (!usesBridgeProxy(app)) return "";
1594
1557
  const urls =
@@ -1605,24 +1568,17 @@ function proxyUrlForApp(ws, app) {
1605
1568
  return `${origin}/p/${token}/${slug}`;
1606
1569
  }
1607
1570
 
1608
- function tunnelRoleForApp(app) {
1609
- if (!app) return "app";
1610
- if (app.role === "ai-server") return "ai";
1611
- if (app.host === true || app.role === "ui" || app.role === "app") return "ui";
1612
- if (isBackendApp(app)) return "backend";
1613
- if (app.role === "custom") return "app";
1614
- return app.role;
1615
- }
1616
-
1617
- function jobsFromHostApps(ws) {
1571
+ function jobsFromHostApps(ws, filter = {}) {
1618
1572
  const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
1619
1573
  const hostId = hostAppOf(ws)?.id;
1620
1574
  return apps
1621
1575
  .filter((app) => app && app.role !== "ai-server")
1576
+ .filter((app) => (filter.appId ? app.id === filter.appId : true))
1622
1577
  .map((app) => {
1623
1578
  const port = Number(app.port) || 3000;
1624
1579
  const command = String(app.startCommand || "").trim();
1625
- const script = command.replace(/^npm\s+run\s+/, "") || "dev";
1580
+ const script = command.replace(/^npm\s+run\s+/, "") || command || "dev";
1581
+ const folder = folderForApp(ws, app);
1626
1582
  return {
1627
1583
  role: isBackendApp(app)
1628
1584
  ? "backend"
@@ -1636,6 +1592,7 @@ function jobsFromHostApps(ws) {
1636
1592
  port,
1637
1593
  probeUrl: `http://127.0.0.1:${port}`,
1638
1594
  appId: app.id,
1595
+ folder,
1639
1596
  };
1640
1597
  });
1641
1598
  }
@@ -1678,7 +1635,6 @@ async function probeRunningApps(ws, timeoutMs = 2500) {
1678
1635
  ...app,
1679
1636
  port: port || app.port,
1680
1637
  running: Boolean(up),
1681
- cloudflareUrl: null,
1682
1638
  publicUrl: proxyUrlForApp(ws, app) || null,
1683
1639
  lastCheckedAt: new Date().toISOString(),
1684
1640
  };
@@ -1722,66 +1678,20 @@ async function restoreHostsAfterReconnect(cfg) {
1722
1678
  }
1723
1679
  }
1724
1680
 
1725
- function envForWorkspacePorts(ws, jobs) {
1681
+ function chatLaunchEnv(ws, cfg) {
1726
1682
  const aiPort = Number(ws.port) || 3100;
1727
- const aiApp = (Array.isArray(ws.hostApps) ? ws.hostApps : []).find(
1728
- (app) => app.role === "ai-server" || app.id === "ai-server"
1729
- ) || { id: "ai-server", role: "ai-server" };
1730
- const publicAi = proxyUrlForApp(ws, aiApp) || "";
1731
- const ai = publicAi || `http://localhost:${aiPort}`;
1732
- /** @type {Record<string, string>} */
1733
- const env = {
1683
+ const admin = originFromUrl(cfg?.adminUrl);
1684
+ const origins = [];
1685
+ if (admin) origins.push(admin);
1686
+ origins.push(`http://localhost:${aiPort}`);
1687
+ origins.push(`http://127.0.0.1:${aiPort}`);
1688
+ return {
1734
1689
  AI_SERVER_PORT: String(aiPort),
1735
- AI_SERVER_URL: ai,
1736
- NEXT_PUBLIC_AI_SERVER_URL: ai,
1737
- VITE_AI_SERVER_URL: ai,
1738
- REACT_APP_AI_SERVER_URL: ai,
1739
1690
  MAINTAINER_PRO_DATA_DIR: dataDirFor(ws.folderPath, ws.sandboxId),
1740
1691
  MAINTAINER_PRO_SANDBOX_ID: String(ws.sandboxId || ""),
1692
+ CORS_ORIGIN: origins.join(","),
1693
+ CORS_ORIGINS: origins.join(","),
1741
1694
  };
1742
- const ui =
1743
- jobs.find((job) => job.host) ||
1744
- jobs.find((job) => job.role === "ui" || job.role === "app");
1745
- const uiApp =
1746
- hostAppOf(ws) ||
1747
- (Array.isArray(ws.hostApps) ? ws.hostApps : []).find(
1748
- (app) => app && (app.host || app.role === "ui" || app.role === "app")
1749
- ) ||
1750
- { id: "ui", role: "ui", host: true };
1751
- const publicUi = proxyUrlForApp(ws, uiApp) || "";
1752
- if (publicUi) {
1753
- env.PORT = ui?.port ? String(ui.port) : env.PORT;
1754
- env.APP_URL = publicUi;
1755
- env.CORS_ORIGIN = publicUi;
1756
- env.PUBLIC_URL = publicUi;
1757
- env.NEXT_PUBLIC_APP_URL = publicUi;
1758
- env.VITE_APP_URL = publicUi;
1759
- } else if (ui?.port) {
1760
- const app = `http://localhost:${ui.port}`;
1761
- env.PORT = String(ui.port);
1762
- env.APP_URL = app;
1763
- env.CORS_ORIGIN = app;
1764
- env.NEXT_PUBLIC_APP_URL = app;
1765
- env.VITE_APP_URL = app;
1766
- }
1767
- const backend = jobs.find((job) => job.role === "backend");
1768
- if (backend?.port) {
1769
- const backendApp = (Array.isArray(ws.hostApps) ? ws.hostApps : []).find(
1770
- (app) => isBackendApp(app)
1771
- ) || { id: "backend", role: "backend", port: backend.port };
1772
- const publicBackend = proxyUrlForApp(ws, backendApp) || "";
1773
- const api = publicBackend || `http://localhost:${backend.port}`;
1774
- env.API_URL = api;
1775
- env.API_BASE_URL = api;
1776
- env.API_PORT = String(backend.port);
1777
- env.BACKEND_URL = api;
1778
- env.VITE_API_URL = api;
1779
- env.VITE_API_BASE_URL = api;
1780
- env.NEXT_PUBLIC_API_URL = api;
1781
- env.NEXT_PUBLIC_API_BASE_URL = api;
1782
- env.REACT_APP_API_URL = api;
1783
- }
1784
- return env;
1785
1695
  }
1786
1696
 
1787
1697
  async function prepareWorkspaceLaunch(ws, cfg, reserved) {
@@ -1790,7 +1700,7 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1790
1700
  if (!folder || !fs.existsSync(folder)) {
1791
1701
  const aiPort = Number(ws.port) || 3100;
1792
1702
  log(`ports skip ${label}: folder missing (${folder || "none"})`);
1793
- return { aiPort, jobs: [], env: {} };
1703
+ return { aiPort, jobs: [] };
1794
1704
  }
1795
1705
  log(`ports pick ${label} in ${folder}`);
1796
1706
  const listedAi = Array.isArray(ws.hostApps)
@@ -1826,11 +1736,8 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1826
1736
  const planned = [];
1827
1737
  for (const job of jobs) {
1828
1738
  const preferred = Number(job.preferredPort) || 3000;
1829
- const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
1830
- "localhost",
1831
- "127.0.0.1"
1832
- );
1833
- const up = await probeUrl(probe);
1739
+ const probe = job.probeUrl || `http://127.0.0.1:${preferred}`;
1740
+ const up = await probeLoopbackUrl(probe);
1834
1741
  const port = up
1835
1742
  ? (reserved.add(portFromText(probe, preferred)), portFromText(probe, preferred))
1836
1743
  : await findFreePort(preferred, reserved);
@@ -1850,10 +1757,6 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1850
1757
  }
1851
1758
  }
1852
1759
 
1853
- const env = envForWorkspacePorts(ws, planned);
1854
- log(
1855
- `env inject ${label} (process only): ${Object.keys(env).join(" ") || "(none)"}`
1856
- );
1857
1760
  if (planned.length) {
1858
1761
  ws.projectInfo = {
1859
1762
  ...(ws.projectInfo || {}),
@@ -1869,7 +1772,7 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1869
1772
  .map((job) => ` ${job.role}=${job.port}${job.up ? "(up)" : ""}`)
1870
1773
  .join("")}`
1871
1774
  );
1872
- return { aiPort, jobs: planned, env };
1775
+ return { aiPort, jobs: planned };
1873
1776
  }
1874
1777
 
1875
1778
  const DEFAULT_AI_IGNORE_PATHS = [
@@ -1934,7 +1837,7 @@ const IGNORE_NAMES = new Set([
1934
1837
  ".maintainer-pro",
1935
1838
  ".collaborater",
1936
1839
  ".maintainer-pro-bridge.json",
1937
- ".cloudflare-tunnel-url",
1840
+ LEGACY_TUNNEL_FILE,
1938
1841
  ]);
1939
1842
 
1940
1843
  function isIgnorableEntry(name) {
@@ -2204,8 +2107,6 @@ function listLaunchRoots(allowed) {
2204
2107
 
2205
2108
  /** Prevents opening a new window on every heartbeat while a process is starting. */
2206
2109
  const launchedAt = new Map();
2207
- /** @type {Map<string, { tunnels: Array<{ role: string, localUrl: string, publicUrl: string | null, logFile: string }> }>} */
2208
- const cloudflareTunnels = new Map();
2209
2110
  /** In-process chat servers, one per sandbox/project. */
2210
2111
  /** @type {Map<string, { port: number, workspaceDir: string, close: () => Promise<void>, runChat: (body: Record<string, unknown>) => Promise<{ ok: boolean, status: number, data: Record<string, unknown> }>, handleHttp?: Function }>} */
2211
2112
  const embeddedChat = new Map();
@@ -2213,7 +2114,7 @@ const embeddedChat = new Map();
2213
2114
  const embeddedChatStarting = new Map();
2214
2115
 
2215
2116
  const PROXY_CHUNK_BYTES = 256 * 1024;
2216
- /** @type {Map<string, import("node:http").ClientRequest>} */
2117
+ /** @type {Map<string, { req: import("node:http").ClientRequest, rewrite?: boolean, chunks?: Buffer[], headers?: Record<string, string>, portUrls?: Record<string, string> } | import("node:http").ClientRequest>} */
2217
2118
  const proxyHttpReqs = new Map();
2218
2119
  /** Reuse sockets to the local app — Next/Vite fetch many files per page. */
2219
2120
  const proxyKeepAliveAgent = new http.Agent({
@@ -2224,6 +2125,8 @@ const proxyKeepAliveAgent = new http.Agent({
2224
2125
  });
2225
2126
  /** @type {Map<string, WebSocket>} */
2226
2127
  const proxyLocalSockets = new Map();
2128
+ /** @type {Map<string, Record<string, string>>} */
2129
+ const proxyWsPortUrls = new Map();
2227
2130
  /** @type {Record<string, unknown> | null} */
2228
2131
  let bridgeCfg = null;
2229
2132
 
@@ -2337,6 +2240,9 @@ function shareProxyContext(msg, ws, appId) {
2337
2240
  ).replace(/\/$/, "");
2338
2241
  const acceptEncoding =
2339
2242
  typeof msg.acceptEncoding === "string" ? msg.acceptEncoding : "";
2243
+ const fromWs = sharePortUrls(ws);
2244
+ const fromMsg =
2245
+ msg?.portUrls && typeof msg.portUrls === "object" ? msg.portUrls : {};
2340
2246
  return {
2341
2247
  path: safeProxyPath(msg.path),
2342
2248
  slug,
@@ -2344,6 +2250,7 @@ function shareProxyContext(msg, ws, appId) {
2344
2250
  aiPublicBase,
2345
2251
  acceptEncoding,
2346
2252
  port: localPortForProxy(ws, appId),
2253
+ portUrls: { ...fromWs, ...fromMsg },
2347
2254
  };
2348
2255
  }
2349
2256
 
@@ -2357,6 +2264,43 @@ function sendProcessedProxyHttp(id, ctx, status, headers, body) {
2357
2264
  replyProxyHttp(id, processed.status, processed.headers, processed.body);
2358
2265
  }
2359
2266
 
2267
+ function publicPathPrefix(publicBase) {
2268
+ try {
2269
+ return new URL(String(publicBase || "")).pathname.replace(/\/$/, "") || "";
2270
+ } catch {
2271
+ return "";
2272
+ }
2273
+ }
2274
+
2275
+ function replyShareInterceptor(id, ctx, path) {
2276
+ const pathname = String(path || "").split("?")[0] || "";
2277
+ if (pathname !== "/__mp/shim.js" && pathname !== "/__mp/sw.js") return false;
2278
+ const tokenRoot = shareTokenRoot(ctx.publicBase) || "/";
2279
+ /** @type {Record<string, string>} */
2280
+ const headers = {
2281
+ "content-type": "application/javascript; charset=utf-8",
2282
+ "cache-control": "private, no-store",
2283
+ };
2284
+ let body = "";
2285
+ if (pathname === "/__mp/sw.js") {
2286
+ headers["service-worker-allowed"] = tokenRoot;
2287
+ body = shareServiceWorkerScript(ctx.portUrls, tokenRoot);
2288
+ } else {
2289
+ body = shareShimScript(publicPathPrefix(ctx.publicBase), ctx.portUrls);
2290
+ }
2291
+ replyProxyHttp(id, 200, headers, body);
2292
+ return true;
2293
+ }
2294
+
2295
+ function destroyProxyHttpReq(entry) {
2296
+ const req = entry?.req || entry;
2297
+ try {
2298
+ req?.destroy?.();
2299
+ } catch {
2300
+ /* ignore */
2301
+ }
2302
+ }
2303
+
2360
2304
  function bridgeEmbedConfigJs(ws) {
2361
2305
  const store = ws?.store && typeof ws.store === "object" ? ws.store : {};
2362
2306
  const aiApp = { id: "ai-server", role: "ai-server" };
@@ -2383,19 +2327,24 @@ async function handleAiProxyHttpFromAdmin(msg, ws) {
2383
2327
  const reqPath = safeProxyPath(msg.path);
2384
2328
  const pathname = reqPath.split("?")[0] || "/";
2385
2329
  const ctx = shareProxyContext(msg, ws, "ai-server");
2330
+ if (replyShareInterceptor(id, ctx, reqPath)) return;
2386
2331
  const prepared = prepareShareHttpRequest(
2387
2332
  proxyReqHeaders(msg.headers),
2388
2333
  ctx.publicBase,
2389
2334
  ctx.acceptEncoding,
2390
- reqPath
2335
+ reqPath,
2336
+ ctx.portUrls
2391
2337
  );
2392
2338
  ctx.acceptEncoding = prepared.acceptEncoding;
2393
2339
  ctx.ifNoneMatch = prepared.ifNoneMatch;
2394
2340
  const headers = prepared.headers;
2395
- const body =
2341
+ const body = rewriteShareRequestBody(
2396
2342
  typeof msg.body === "string" && msg.body
2397
2343
  ? Buffer.from(msg.body, "base64")
2398
- : Buffer.alloc(0);
2344
+ : Buffer.alloc(0),
2345
+ headers,
2346
+ ctx.portUrls
2347
+ );
2399
2348
 
2400
2349
  if (pathname === "/ai-ui.iife.js") {
2401
2350
  const file = findIife();
@@ -2487,11 +2436,7 @@ function handleProxyHttpFromAdmin(msg) {
2487
2436
  }
2488
2437
  const existing = proxyHttpReqs.get(id);
2489
2438
  if (existing) {
2490
- try {
2491
- existing.destroy();
2492
- } catch {
2493
- /* ignore */
2494
- }
2439
+ destroyProxyHttpReq(existing);
2495
2440
  proxyHttpReqs.delete(id);
2496
2441
  }
2497
2442
  const port = localPortForProxy(ws, appId);
@@ -2507,11 +2452,13 @@ function handleProxyHttpFromAdmin(msg) {
2507
2452
  const path = safeProxyPath(msg.path);
2508
2453
  const ctx = shareProxyContext(msg, ws, appId);
2509
2454
  ctx.port = port;
2455
+ if (replyShareInterceptor(id, ctx, path)) return;
2510
2456
  const prepared = prepareShareHttpRequest(
2511
2457
  proxyReqHeaders(msg.headers),
2512
2458
  ctx.publicBase,
2513
2459
  ctx.acceptEncoding,
2514
- path
2460
+ path,
2461
+ ctx.portUrls
2515
2462
  );
2516
2463
  ctx.acceptEncoding = prepared.acceptEncoding;
2517
2464
  ctx.ifNoneMatch = prepared.ifNoneMatch;
@@ -2528,7 +2475,6 @@ function handleProxyHttpFromAdmin(msg) {
2528
2475
  }
2529
2476
  }
2530
2477
  const headers = prepared.headers;
2531
- headers.host = `127.0.0.1:${port}`;
2532
2478
  // Next.js dev 403s `/_next` when Origin/sec-fetch look cross-site.
2533
2479
  // This hop is server-to-server; drop those so chunks always load.
2534
2480
  for (const key of Object.keys(headers)) {
@@ -2545,100 +2491,174 @@ function handleProxyHttpFromAdmin(msg) {
2545
2491
  }
2546
2492
  }
2547
2493
  const stream = randomBytes(8).toString("hex");
2548
- let req;
2549
- try {
2550
- req = http.request(
2551
- {
2552
- hostname: "127.0.0.1",
2553
- port,
2554
- path,
2555
- method,
2556
- headers,
2557
- agent: proxyKeepAliveAgent,
2558
- },
2559
- (res) => {
2560
- /** @type {Record<string, string>} */
2561
- const outHeaders = {};
2562
- for (const [key, value] of Object.entries(res.headers)) {
2563
- if (value == null) continue;
2564
- outHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value);
2565
- }
2566
- const status = res.statusCode || 502;
2567
- applyShareCacheHeaders(outHeaders, path);
2568
- if (shouldProcessShareResponse(outHeaders)) {
2569
- /** @type {Buffer[]} */
2570
- const chunks = [];
2494
+ const initialBody =
2495
+ typeof msg.body === "string" && msg.body
2496
+ ? Buffer.from(msg.body, "base64")
2497
+ : null;
2498
+ const endAfter = msg.bodyEof !== false;
2499
+ const hosts = loopbackHostsForPort(port);
2500
+ let hostIndex = 0;
2501
+
2502
+ const connect = () => {
2503
+ const host = hosts[hostIndex];
2504
+ const reqHeaders = { ...headers, host: `${host}:${port}` };
2505
+ let req;
2506
+ try {
2507
+ req = http.request(
2508
+ {
2509
+ hostname: host,
2510
+ port,
2511
+ path,
2512
+ method,
2513
+ headers: reqHeaders,
2514
+ agent: proxyKeepAliveAgent,
2515
+ },
2516
+ (res) => {
2517
+ rememberLoopbackHost(port, host);
2518
+ /** @type {Record<string, string>} */
2519
+ const outHeaders = {};
2520
+ for (const [key, value] of Object.entries(res.headers)) {
2521
+ if (value == null) continue;
2522
+ outHeaders[key] = Array.isArray(value)
2523
+ ? value.join(", ")
2524
+ : String(value);
2525
+ }
2526
+ const status = res.statusCode || 502;
2527
+ applyShareCacheHeaders(outHeaders, path);
2528
+ if (shouldProcessShareResponse(outHeaders)) {
2529
+ /** @type {Buffer[]} */
2530
+ const chunks = [];
2531
+ res.on("data", (chunk) => {
2532
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2533
+ });
2534
+ res.on("end", () => {
2535
+ proxyHttpReqs.delete(id);
2536
+ sendProcessedProxyHttp(
2537
+ id,
2538
+ ctx,
2539
+ status,
2540
+ outHeaders,
2541
+ Buffer.concat(chunks)
2542
+ );
2543
+ });
2544
+ return;
2545
+ }
2546
+ bridgeSend({
2547
+ type: "proxy.http.start",
2548
+ id,
2549
+ stream,
2550
+ status,
2551
+ headers: outHeaders,
2552
+ });
2571
2553
  res.on("data", (chunk) => {
2572
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2554
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2555
+ for (
2556
+ let offset = 0;
2557
+ offset < buf.length;
2558
+ offset += PROXY_CHUNK_BYTES
2559
+ ) {
2560
+ const end = Math.min(offset + PROXY_CHUNK_BYTES, buf.length);
2561
+ bridgeSend({
2562
+ type: "proxy.http.chunk",
2563
+ id,
2564
+ stream,
2565
+ data: Buffer.from(buf.subarray(offset, end)).toString("base64"),
2566
+ eof: false,
2567
+ });
2568
+ }
2573
2569
  });
2574
2570
  res.on("end", () => {
2575
2571
  proxyHttpReqs.delete(id);
2576
- sendProcessedProxyHttp(
2577
- id,
2578
- ctx,
2579
- status,
2580
- outHeaders,
2581
- Buffer.concat(chunks)
2582
- );
2583
- });
2584
- return;
2585
- }
2586
- bridgeSend({
2587
- type: "proxy.http.start",
2588
- id,
2589
- stream,
2590
- status,
2591
- headers: outHeaders,
2592
- });
2593
- res.on("data", (chunk) => {
2594
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2595
- for (let offset = 0; offset < buf.length; offset += PROXY_CHUNK_BYTES) {
2596
- const end = Math.min(offset + PROXY_CHUNK_BYTES, buf.length);
2597
2572
  bridgeSend({
2598
2573
  type: "proxy.http.chunk",
2599
2574
  id,
2600
2575
  stream,
2601
- data: Buffer.from(buf.subarray(offset, end)).toString("base64"),
2602
- eof: false,
2576
+ data: "",
2577
+ eof: true,
2603
2578
  });
2604
- }
2605
- });
2606
- res.on("end", () => {
2607
- proxyHttpReqs.delete(id);
2608
- bridgeSend({ type: "proxy.http.chunk", id, stream, data: "", eof: true });
2609
- });
2579
+ });
2580
+ }
2581
+ );
2582
+ } catch (err) {
2583
+ if (hostIndex + 1 < hosts.length) {
2584
+ hostIndex += 1;
2585
+ connect();
2586
+ return;
2610
2587
  }
2611
- );
2612
- } catch (err) {
2613
- bridgeSend({
2614
- type: "proxy.http.error",
2615
- id,
2616
- error: err instanceof Error ? err.message : String(err),
2588
+ bridgeSend({
2589
+ type: "proxy.http.error",
2590
+ id,
2591
+ error: err instanceof Error ? err.message : String(err),
2592
+ });
2593
+ return;
2594
+ }
2595
+ req.on("error", (err) => {
2596
+ if (isLoopbackConnError(err) && hostIndex + 1 < hosts.length) {
2597
+ proxyHttpReqs.delete(id);
2598
+ hostIndex += 1;
2599
+ connect();
2600
+ return;
2601
+ }
2602
+ proxyHttpReqs.delete(id);
2603
+ bridgeSend({
2604
+ type: "proxy.http.error",
2605
+ id,
2606
+ error: err instanceof Error ? err.message : String(err),
2607
+ });
2617
2608
  });
2618
- return;
2619
- }
2620
- req.on("error", (err) => {
2621
- proxyHttpReqs.delete(id);
2622
- bridgeSend({
2623
- type: "proxy.http.error",
2624
- id,
2625
- error: err instanceof Error ? err.message : String(err),
2609
+ proxyHttpReqs.set(id, {
2610
+ req,
2611
+ rewrite:
2612
+ shouldRewriteBody(headers) &&
2613
+ ctx.portUrls &&
2614
+ Object.keys(ctx.portUrls).length > 0,
2615
+ chunks: [],
2616
+ headers,
2617
+ portUrls: ctx.portUrls,
2626
2618
  });
2627
- });
2628
- proxyHttpReqs.set(id, req);
2629
- if (typeof msg.body === "string" && msg.body) {
2630
- req.write(Buffer.from(msg.body, "base64"));
2631
- }
2632
- if (msg.bodyEof !== false) req.end();
2619
+ const entry = proxyHttpReqs.get(id);
2620
+ if (entry.rewrite) {
2621
+ if (initialBody?.length) entry.chunks.push(initialBody);
2622
+ if (endAfter) {
2623
+ const body = rewriteShareRequestBody(
2624
+ Buffer.concat(entry.chunks),
2625
+ headers,
2626
+ ctx.portUrls
2627
+ );
2628
+ if (body?.length) req.write(body);
2629
+ req.end();
2630
+ }
2631
+ } else {
2632
+ if (initialBody?.length) req.write(initialBody);
2633
+ if (endAfter) req.end();
2634
+ }
2635
+ };
2636
+ connect();
2633
2637
  }
2634
2638
 
2635
2639
  function handleProxyHttpBodyFromAdmin(msg) {
2636
2640
  const id = typeof msg.id === "string" ? msg.id : "";
2637
- const req = proxyHttpReqs.get(id);
2638
- if (!req) return;
2639
- if (typeof msg.data === "string" && msg.data) {
2640
- req.write(Buffer.from(msg.data, "base64"));
2641
+ const entry = proxyHttpReqs.get(id);
2642
+ if (!entry) return;
2643
+ const req = entry.req || entry;
2644
+ const chunk =
2645
+ typeof msg.data === "string" && msg.data
2646
+ ? Buffer.from(msg.data, "base64")
2647
+ : null;
2648
+ if (entry.rewrite) {
2649
+ if (chunk?.length) entry.chunks.push(chunk);
2650
+ if (msg.eof === true) {
2651
+ const body = rewriteShareRequestBody(
2652
+ Buffer.concat(entry.chunks || []),
2653
+ entry.headers,
2654
+ entry.portUrls
2655
+ );
2656
+ if (body?.length) req.write(body);
2657
+ req.end();
2658
+ }
2659
+ return;
2641
2660
  }
2661
+ if (chunk?.length) req.write(chunk);
2642
2662
  if (msg.eof === true) req.end();
2643
2663
  }
2644
2664
 
@@ -2659,16 +2679,22 @@ function attachProxyLocalWs(id, socket) {
2659
2679
  /* WHATWG WebSocket in Node 22 */
2660
2680
  }
2661
2681
  proxyLocalSockets.set(id, socket);
2662
- socket.addEventListener("open", () => {
2682
+ const announceOpen = () => {
2663
2683
  bridgeSend({ type: "proxy.ws.opened", id });
2664
- });
2684
+ };
2685
+ if (socket.readyState === 1) announceOpen();
2686
+ else socket.addEventListener("open", announceOpen);
2665
2687
  socket.addEventListener("message", (event) => {
2666
2688
  try {
2667
2689
  if (typeof event.data === "string") {
2690
+ const portUrls = proxyWsPortUrls.get(id);
2691
+ const data = portUrls
2692
+ ? rewriteMappedLocalUrls(event.data, portUrls)
2693
+ : event.data;
2668
2694
  bridgeSend({
2669
2695
  type: "proxy.ws.frame",
2670
2696
  id,
2671
- data: event.data,
2697
+ data,
2672
2698
  binary: false,
2673
2699
  });
2674
2700
  return;
@@ -2688,6 +2714,7 @@ function attachProxyLocalWs(id, socket) {
2688
2714
  });
2689
2715
  socket.addEventListener("close", (event) => {
2690
2716
  proxyLocalSockets.delete(id);
2717
+ proxyWsPortUrls.delete(id);
2691
2718
  bridgeSend({
2692
2719
  type: "proxy.ws.close",
2693
2720
  id,
@@ -2713,23 +2740,51 @@ function wsProtocolsFromMsg(msg) {
2713
2740
  }
2714
2741
 
2715
2742
  function openProxyLocalWs(id, port, path, protocols) {
2716
- let socket;
2717
- try {
2718
- const url = `ws://127.0.0.1:${port}${path}`;
2719
- const proto = Array.isArray(protocols)
2720
- ? protocols.map((p) => String(p || "").trim()).filter(Boolean)
2721
- : [];
2722
- // Vite HMR only accepts upgrades with subprotocol `vite-hmr` / `vite-ping`.
2723
- socket = proto.length ? new WebSocket(url, proto) : new WebSocket(url);
2724
- } catch (err) {
2725
- bridgeSend({
2726
- type: "proxy.ws.error",
2727
- id,
2728
- error: err instanceof Error ? err.message : String(err),
2743
+ const proto = Array.isArray(protocols)
2744
+ ? protocols.map((p) => String(p || "").trim()).filter(Boolean)
2745
+ : [];
2746
+ const hosts = loopbackHostsForPort(port);
2747
+ const tryHost = (index) => {
2748
+ const host = hosts[index];
2749
+ const url = `ws://${host}:${port}${path}`;
2750
+ let socket;
2751
+ try {
2752
+ // Vite HMR only accepts upgrades with subprotocol `vite-hmr` / `vite-ping`.
2753
+ socket = proto.length ? new WebSocket(url, proto) : new WebSocket(url);
2754
+ } catch (err) {
2755
+ if (index + 1 < hosts.length) {
2756
+ tryHost(index + 1);
2757
+ return;
2758
+ }
2759
+ bridgeSend({
2760
+ type: "proxy.ws.error",
2761
+ id,
2762
+ error: err instanceof Error ? err.message : String(err),
2763
+ });
2764
+ return;
2765
+ }
2766
+ let settled = false;
2767
+ socket.addEventListener("open", () => {
2768
+ if (settled) return;
2769
+ settled = true;
2770
+ rememberLoopbackHost(port, host);
2771
+ attachProxyLocalWs(id, socket);
2729
2772
  });
2730
- return;
2731
- }
2732
- attachProxyLocalWs(id, socket);
2773
+ socket.addEventListener("close", () => {
2774
+ if (settled) return;
2775
+ settled = true;
2776
+ if (index + 1 < hosts.length) {
2777
+ tryHost(index + 1);
2778
+ return;
2779
+ }
2780
+ bridgeSend({
2781
+ type: "proxy.ws.error",
2782
+ id,
2783
+ error: `Cannot reach ${url}`,
2784
+ });
2785
+ });
2786
+ };
2787
+ tryHost(0);
2733
2788
  }
2734
2789
 
2735
2790
  function handleProxyWsOpenFromAdmin(msg) {
@@ -2744,6 +2799,9 @@ function handleProxyWsOpenFromAdmin(msg) {
2744
2799
  }
2745
2800
  const path = safeProxyPath(msg.path);
2746
2801
  const protocols = wsProtocolsFromMsg(msg);
2802
+ const fromMsg =
2803
+ msg?.portUrls && typeof msg.portUrls === "object" ? msg.portUrls : {};
2804
+ proxyWsPortUrls.set(id, { ...sharePortUrls(ws), ...fromMsg });
2747
2805
  if (isAiServerAppId(appId, ws)) {
2748
2806
  void (async () => {
2749
2807
  const embedded = await ensureEmbeddedChat(ws);
@@ -2772,7 +2830,9 @@ function handleProxyWsFrameFromAdmin(msg) {
2772
2830
  if (msg.binary === true) {
2773
2831
  socket.send(Buffer.from(String(msg.data || ""), "base64"));
2774
2832
  } else {
2775
- socket.send(String(msg.data || ""));
2833
+ const portUrls = proxyWsPortUrls.get(id);
2834
+ const data = String(msg.data || "");
2835
+ socket.send(portUrls ? rewriteMappedLocalUrls(data, portUrls) : data);
2776
2836
  }
2777
2837
  } catch {
2778
2838
  /* ignore */
@@ -2784,6 +2844,7 @@ function handleProxyWsCloseFromAdmin(msg) {
2784
2844
  const socket = proxyLocalSockets.get(id);
2785
2845
  if (!socket) return;
2786
2846
  proxyLocalSockets.delete(id);
2847
+ proxyWsPortUrls.delete(id);
2787
2848
  try {
2788
2849
  socket.close(
2789
2850
  typeof msg.code === "number" ? msg.code : 1000,
@@ -2865,7 +2926,6 @@ async function forgetLaunch(sandboxId) {
2865
2926
  if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
2866
2927
  }
2867
2928
  await closeRememberedTerminals(sandboxId);
2868
- await stopCloudflare(sandboxId);
2869
2929
  }
2870
2930
 
2871
2931
  function problemKey(sandboxId, code, role = "") {
@@ -3218,6 +3278,10 @@ async function openInNewTerminal(opts) {
3218
3278
  }
3219
3279
 
3220
3280
  function commandWithPort(job, scripts, port) {
3281
+ const command = String(job.command || "");
3282
+ if (!/^(npm|pnpm|yarn|npx)\s/i.test(command)) {
3283
+ return command;
3284
+ }
3221
3285
  const raw = String(scripts?.[job.script] || "");
3222
3286
  if (
3223
3287
  job.role === "ui" ||
@@ -3225,9 +3289,9 @@ function commandWithPort(job, scripts, port) {
3225
3289
  isUiCommand(raw) ||
3226
3290
  /--port\b/i.test(raw)
3227
3291
  ) {
3228
- return `${job.command} -- --port ${port}`;
3292
+ return `${command} -- --port ${port}`;
3229
3293
  }
3230
- return job.command;
3294
+ return command;
3231
3295
  }
3232
3296
 
3233
3297
  async function stopEmbeddedChat(sandboxId) {
@@ -3254,15 +3318,10 @@ async function ensureEmbeddedChat(ws) {
3254
3318
  if (inflight) return inflight;
3255
3319
  const task = (async () => {
3256
3320
  const cfg = bridgeCfg || loadConfig();
3257
- const jobs = jobsFromHostApps(ws);
3258
- const shareEnv = {
3259
- ...envForWorkspacePorts(ws, jobs),
3260
- ...uiPublicEnv({}, ws),
3261
- };
3262
3321
  log(`lazy-start in-process chat for ${ws.sandboxName || shortId(ws.sandboxId)}`);
3263
3322
  const result = await startAiServerForWorkspace(ws, {
3264
3323
  cfg,
3265
- env: shareEnv,
3324
+ env: chatLaunchEnv(ws, cfg),
3266
3325
  forceEmbed: true,
3267
3326
  });
3268
3327
  if (!result.up) return null;
@@ -3343,14 +3402,6 @@ async function startAiServerForWorkspace(ws, opts = {}) {
3343
3402
  const localAi = `http://localhost:${port}`;
3344
3403
  const overrideEnv =
3345
3404
  opts.env && typeof opts.env === "object" ? opts.env : {};
3346
- const publicAi =
3347
- (typeof overrideEnv.AI_SERVER_URL === "string" &&
3348
- overrideEnv.AI_SERVER_URL.trim()) ||
3349
- (typeof overrideEnv.NEXT_PUBLIC_AI_SERVER_URL === "string" &&
3350
- overrideEnv.NEXT_PUBLIC_AI_SERVER_URL.trim()) ||
3351
- (typeof ws.cloudflare?.ai === "string" && ws.cloudflare.ai.trim()) ||
3352
- "";
3353
- const aiUrl = publicAi || localAi;
3354
3405
  persistWorkspaceEntry(cfg, ws);
3355
3406
 
3356
3407
  const storeEnv = await loadSandboxStoreEnv(ws, cfg);
@@ -3360,7 +3411,7 @@ async function startAiServerForWorkspace(ws, opts = {}) {
3360
3411
  ...folderEnv,
3361
3412
  ...overrideEnv,
3362
3413
  ...storeEnv,
3363
- AI_SERVER_URL: aiUrl,
3414
+ AI_SERVER_URL: localAi,
3364
3415
  CORS_ORIGIN: overrideEnv.CORS_ORIGIN || folderEnv.CORS_ORIGIN || "",
3365
3416
  CORS_ORIGINS: overrideEnv.CORS_ORIGINS || folderEnv.CORS_ORIGINS || "",
3366
3417
  MAINTAINER_PRO_DATA_DIR: dataDir,
@@ -3403,37 +3454,6 @@ function sleep(ms) {
3403
3454
  return new Promise((resolve) => setTimeout(resolve, ms));
3404
3455
  }
3405
3456
 
3406
- function stopAllCloudflare() {
3407
- for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
3408
- }
3409
-
3410
- function killProcessesByCommand(fragment) {
3411
- if (!fragment) return Promise.resolve();
3412
- return new Promise((resolve) => {
3413
- const done = () => resolve();
3414
- if (process.platform === "win32") {
3415
- const escaped = String(fragment).replace(/'/g, "''");
3416
- const child = spawn(
3417
- "powershell.exe",
3418
- [
3419
- "-NoProfile",
3420
- "-Command",
3421
- `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escaped}*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`,
3422
- ],
3423
- { windowsHide: true, stdio: "ignore" }
3424
- );
3425
- child.on("exit", done);
3426
- child.on("error", done);
3427
- setTimeout(done, 8000);
3428
- return;
3429
- }
3430
- const child = spawn("pkill", ["-f", String(fragment)], { stdio: "ignore" });
3431
- child.on("exit", done);
3432
- child.on("error", done);
3433
- setTimeout(done, 4000);
3434
- });
3435
- }
3436
-
3437
3457
  function killPort(port) {
3438
3458
  const n = Number(port);
3439
3459
  if (!n) return Promise.resolve();
@@ -3465,29 +3485,6 @@ function killPort(port) {
3465
3485
  });
3466
3486
  }
3467
3487
 
3468
- function stopCloudflare(sandboxId, folder) {
3469
- const row = cloudflareTunnels.get(sandboxId);
3470
- const files = new Set(
3471
- (row?.tunnels?.map((t) => t.logFile).filter(Boolean) ?? [])
3472
- );
3473
- cloudflareTunnels.delete(sandboxId);
3474
- const logDir = folder
3475
- ? dataDirFor(folder, sandboxId)
3476
- : null;
3477
- if (logDir && fs.existsSync(logDir)) {
3478
- try {
3479
- for (const name of fs.readdirSync(logDir)) {
3480
- if (/^cf-.*\.log$/i.test(name) && !name.endsWith(".stale")) {
3481
- files.add(path.join(logDir, name));
3482
- }
3483
- }
3484
- } catch {
3485
- /* ignore */
3486
- }
3487
- }
3488
- return Promise.all([...files].map((file) => killProcessesByCommand(file)));
3489
- }
3490
-
3491
3488
  function forgetProcessLaunches(sandboxId) {
3492
3489
  for (const key of [...launchedAt.keys()]) {
3493
3490
  if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
@@ -3545,231 +3542,6 @@ async function waitUntilReachable(url, timeoutMs, label, onWait) {
3545
3542
  throw new Error(`${label} did not become reachable at ${url}`);
3546
3543
  }
3547
3544
 
3548
- function applyBackendPublicEnv(env, url) {
3549
- const backend = String(url || "").replace(/\/$/, "");
3550
- if (!backend) return env;
3551
- env.API_URL = backend;
3552
- env.API_BASE_URL = backend;
3553
- env.BACKEND_URL = backend;
3554
- env.VITE_API_URL = backend;
3555
- env.VITE_API_BASE_URL = backend;
3556
- env.NEXT_PUBLIC_API_URL = backend;
3557
- env.NEXT_PUBLIC_API_BASE_URL = backend;
3558
- env.REACT_APP_API_URL = backend;
3559
- return env;
3560
- }
3561
-
3562
- function uiPublicEnv(tunnels, ws) {
3563
- /** @type {Record<string, string>} */
3564
- const env = {};
3565
- const aiApp = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).find(
3566
- (app) => app.role === "ai-server" || app.id === "ai-server"
3567
- ) || { id: "ai-server", role: "ai-server" };
3568
- const ai =
3569
- proxyUrlForApp(ws, aiApp) ||
3570
- (tunnels.ai ? String(tunnels.ai).replace(/\/$/, "") : "");
3571
- if (ai) {
3572
- env.AI_SERVER_URL = ai;
3573
- env.NEXT_PUBLIC_AI_SERVER_URL = ai;
3574
- env.VITE_AI_SERVER_URL = ai;
3575
- env.REACT_APP_AI_SERVER_URL = ai;
3576
- }
3577
- const backendApp = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).find(
3578
- (app) => isBackendApp(app)
3579
- );
3580
- const backendUrl =
3581
- (backendApp ? proxyUrlForApp(ws, backendApp) : "") ||
3582
- (typeof tunnels.backend === "string" && tunnels.backend) ||
3583
- (backendApp ? publicUrlForApp(ws, backendApp) : "") ||
3584
- "";
3585
- if (backendUrl && backendApp) {
3586
- applyBackendPublicEnv(env, backendUrl);
3587
- }
3588
- if (tunnels.ui) {
3589
- const ui = String(tunnels.ui).replace(/\/$/, "");
3590
- env.APP_URL = ui;
3591
- env.PUBLIC_URL = ui;
3592
- env.CORS_ORIGIN = ui;
3593
- env.NEXT_PUBLIC_APP_URL = ui;
3594
- env.VITE_APP_URL = ui;
3595
- env.REACT_APP_APP_URL = ui;
3596
- } else if (tunnels.app) {
3597
- const app = String(tunnels.app).replace(/\/$/, "");
3598
- env.APP_URL = app;
3599
- env.PUBLIC_URL = app;
3600
- env.CORS_ORIGIN = app;
3601
- env.NEXT_PUBLIC_APP_URL = app;
3602
- env.VITE_APP_URL = app;
3603
- env.REACT_APP_APP_URL = app;
3604
- } else if (ai) {
3605
- const hostUi = (Array.isArray(ws?.hostApps) ? ws.hostApps : []).some(
3606
- (app) => app.host || app.role === "ui" || app.role === "app"
3607
- );
3608
- if (!hostUi) {
3609
- env.CORS_ORIGIN = ai;
3610
- env.APP_URL = ai;
3611
- env.PUBLIC_URL = ai;
3612
- env.NEXT_PUBLIC_APP_URL = ai;
3613
- env.VITE_APP_URL = ai;
3614
- }
3615
- }
3616
- return env;
3617
- }
3618
-
3619
- function publicUrlForApp(ws, app) {
3620
- if (!app) return "";
3621
- const proxied = proxyUrlForApp(ws, app);
3622
- if (proxied) return proxied;
3623
- const port = Number(app.port) || 0;
3624
- return port ? `http://localhost:${port}` : "";
3625
- }
3626
-
3627
- function envFromConfiguredMaps(ws) {
3628
- /** @type {Record<string, string>} */
3629
- const env = {};
3630
- const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
3631
- for (const app of apps) {
3632
- const maps = Array.isArray(app.envMaps) ? app.envMaps : [];
3633
- for (const row of maps) {
3634
- const key = String(row?.key || "").trim();
3635
- const sourceId = String(row?.sourceAppId || "").trim();
3636
- if (!key || !sourceId) continue;
3637
- const source =
3638
- sourceId === "self"
3639
- ? app
3640
- : apps.find((item) => item.id === sourceId) || null;
3641
- const url = publicUrlForApp(ws, source);
3642
- if (url) env[key] = url;
3643
- }
3644
- }
3645
- return env;
3646
- }
3647
-
3648
- function cloudflarePublicEnv(ws) {
3649
- const tunnels =
3650
- ws.cloudflare && typeof ws.cloudflare === "object" ? ws.cloudflare : {};
3651
- const jobs = jobsFromHostApps(ws);
3652
- const env = {
3653
- ...envForWorkspacePorts(ws, jobs),
3654
- ...uiPublicEnv(tunnels, ws),
3655
- };
3656
- const origins = [];
3657
- const add = (value) => {
3658
- const origin = originFromUrl(value);
3659
- if (origin && !origins.includes(origin)) origins.push(origin);
3660
- };
3661
- add(tunnels.ui);
3662
- add(tunnels.app);
3663
- add(tunnels.backend);
3664
- add(tunnels.ai);
3665
- add(env.APP_URL);
3666
- add(env.CORS_ORIGIN);
3667
- for (const job of jobs) {
3668
- if (job.port) add(`http://localhost:${job.port}`);
3669
- }
3670
- add(`http://localhost:${Number(ws.port) || 3100}`);
3671
- if (origins.length) {
3672
- env.CORS_ORIGIN = origins.join(",");
3673
- env.CORS_ORIGINS = origins.join(",");
3674
- }
3675
- Object.assign(env, envFromConfiguredMaps(ws));
3676
- return env;
3677
- }
3678
-
3679
- async function applyPublicUrlsToRunningApps(ws, cfg, opts = {}) {
3680
- const folder = ws.folderPath ? path.resolve(ws.folderPath) : "";
3681
- const progress =
3682
- typeof opts.onProgress === "function" ? opts.onProgress : async () => {};
3683
- const tunnels =
3684
- ws.cloudflare && typeof ws.cloudflare === "object" ? ws.cloudflare : {};
3685
- const hasProxy = Boolean(ws.proxy?.token);
3686
- if (
3687
- !folder ||
3688
- !fs.existsSync(folder) ||
3689
- (!Object.keys(tunnels).length && !hasProxy)
3690
- ) {
3691
- return { restarted: [], rewritten: [] };
3692
- }
3693
- const env = {
3694
- ...cloudflarePublicEnv(ws),
3695
- };
3696
- if (Object.keys(tunnels).length) writeTunnelEnv(ws, tunnels);
3697
- const mappedKeys = Object.keys(envFromConfiguredMaps(ws));
3698
- if (mappedKeys.length) {
3699
- activity(
3700
- ws.sandboxId,
3701
- "info",
3702
- `Injecting public URLs into process env for ${mappedKeys.join(", ")}`
3703
- );
3704
- await progress(`Passing ${mappedKeys.join(", ")} via process env`);
3705
- }
3706
-
3707
- const probe = await probeRunningApps(ws, 1200);
3708
- const runningRoles = new Set(
3709
- (probe.hostApps || [])
3710
- .filter((app) => app.running)
3711
- .map((app) =>
3712
- app.role === "ai-server" ? "ai-server" : app.role === "custom" ? "app" : app.role
3713
- )
3714
- );
3715
- const reserved = reservedPortsFor(cfg, ws.sandboxId);
3716
- const restarted = [];
3717
-
3718
- if (probe.chatUp || runningRoles.has("ai-server") || embeddedChat.has(ws.sandboxId)) {
3719
- await progress("Restarting in-process chat with public URLs and CORS…");
3720
- launchedAt.delete(`${ws.sandboxId}:${folder}:ai`);
3721
- await stopEmbeddedChat(ws.sandboxId);
3722
- await startAiServerForWorkspace(ws, {
3723
- reserved,
3724
- cfg,
3725
- port: ws.port,
3726
- env,
3727
- });
3728
- restarted.push("ai-server");
3729
- }
3730
-
3731
- const jobs = jobsFromHostApps(ws).filter((job) =>
3732
- runningRoles.has(job.role === "custom" ? "app" : job.role)
3733
- );
3734
- if (jobs.length) {
3735
- const roles = [...new Set(jobs.map((job) => job.role))];
3736
- await progress(
3737
- `Restarting ${roles.join(", ")} so they load the public URLs…`
3738
- );
3739
- for (const job of jobs) {
3740
- launchedAt.delete(`${ws.sandboxId}:${folder}:${job.script}`);
3741
- await killPort(job.port);
3742
- }
3743
- await sleep(1500);
3744
- await ensureHostProcesses(ws, {
3745
- reserved,
3746
- cfg,
3747
- onlyRoles: roles,
3748
- extraEnv: env,
3749
- force: true,
3750
- plannedJobs: jobsFromHostApps(ws),
3751
- });
3752
- restarted.push(...roles);
3753
- }
3754
-
3755
- if (restarted.length) {
3756
- activity(
3757
- ws.sandboxId,
3758
- "info",
3759
- `Restarted ${[...new Set(restarted)].join(", ")} with public URLs`
3760
- );
3761
- }
3762
- return { restarted: [...new Set(restarted)], rewritten: [], env };
3763
- }
3764
-
3765
- function writeTunnelEnv(ws, tunnels) {
3766
- const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
3767
- if (!folder || !fs.existsSync(folder)) return { ok: false, env: {} };
3768
- clearCloudflareTunnelFile(folder);
3769
- const env = uiPublicEnv(tunnels, ws);
3770
- return { ok: true, env };
3771
- }
3772
-
3773
3545
  function reservedPortsFor(cfg, sandboxId) {
3774
3546
  const reserved = new Set();
3775
3547
  for (const other of cfg.workspaces || []) {
@@ -3784,17 +3556,12 @@ function appsWanted(ws) {
3784
3556
  return Boolean(ws?.appsRequested);
3785
3557
  }
3786
3558
 
3787
- async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
3559
+ async function configureShareForWorkspace(ws, cfg, opts = {}) {
3788
3560
  const sandboxId = ws.sandboxId;
3789
3561
  const progress = (message) =>
3790
3562
  reportActionProgress(cfg, opts.actionId, message);
3791
- await progress("Using Maintainer Pro share URLs (no Cloudflare).");
3792
- try {
3793
- await stopCloudflare(sandboxId, ws.folderPath);
3794
- } catch {
3795
- /* leftover tunnels */
3796
- }
3797
- ws.cloudflarePending = false;
3563
+ await progress("Using Maintainer Pro share URLs.");
3564
+ stripDiscardedTunnelState(ws, cfg);
3798
3565
  persistWorkspaceEntry(cfg, ws);
3799
3566
  const status = await reconcileWorkspacePresence(ws, cfg, {
3800
3567
  timeoutMs: 2500,
@@ -3808,12 +3575,10 @@ async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
3808
3575
  folderPath: ws.folderPath,
3809
3576
  port: ws.port,
3810
3577
  pending: false,
3811
- cloudflarePending: false,
3812
3578
  waitingForStart: false,
3813
3579
  appUrl: status.host.appUrl || share,
3814
3580
  origins: status.host.origins,
3815
3581
  publicUrl: share || null,
3816
- cloudflare: false,
3817
3582
  };
3818
3583
  }
3819
3584
 
@@ -3825,12 +3590,7 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3825
3590
  `Checking running apps for ${label}…`
3826
3591
  );
3827
3592
 
3828
- try {
3829
- await stopCloudflare(ws.sandboxId, ws.folderPath);
3830
- } catch {
3831
- /* leftover cloudflared */
3832
- }
3833
- ws.cloudflarePending = false;
3593
+ stripDiscardedTunnelState(ws, cfg);
3834
3594
 
3835
3595
  let status = await reconcileWorkspacePresence(ws, cfg, {
3836
3596
  timeoutMs: 2500,
@@ -3858,11 +3618,7 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3858
3618
  timeoutMs: 800,
3859
3619
  });
3860
3620
 
3861
- const shareEnv = {
3862
- ...envForWorkspacePorts(ws, plan.jobs),
3863
- ...uiPublicEnv({}, ws),
3864
- ...envFromConfiguredMaps(ws),
3865
- };
3621
+ const chatEnv = chatLaunchEnv(ws, cfg);
3866
3622
 
3867
3623
  if (!cfg.noAiServer) {
3868
3624
  if (status.probe.chatUp) {
@@ -3875,7 +3631,7 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3875
3631
  reserved,
3876
3632
  cfg,
3877
3633
  port: plan.aiPort,
3878
- env: shareEnv,
3634
+ env: chatEnv,
3879
3635
  });
3880
3636
  await sleep(1500);
3881
3637
  }
@@ -3890,14 +3646,9 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3890
3646
  const probed = status.probe.hosts.find((h) => h.role === job.role);
3891
3647
  return probed?.up ? { ...job, up: true, port: probed.port } : job;
3892
3648
  }),
3893
- extraEnv: shareEnv,
3894
3649
  });
3895
3650
  await sleep(800);
3896
3651
 
3897
- await applyPublicUrlsToRunningApps(ws, cfg, {
3898
- onProgress: (message) => reportActionProgress(cfg, opts.actionId, message),
3899
- });
3900
-
3901
3652
  status = await reconcileWorkspacePresence(ws, cfg, {
3902
3653
  timeoutMs: 2500,
3903
3654
  });
@@ -3929,7 +3680,6 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3929
3680
  ws,
3930
3681
  hostAppOf(ws) || { id: "ui", role: "ui", host: true }
3931
3682
  ) || null,
3932
- cloudflare: false,
3933
3683
  hostApps: status.probe.hostApps || ws.hostApps || [],
3934
3684
  processIssues,
3935
3685
  warning,
@@ -3975,9 +3725,6 @@ async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
3975
3725
  await progress(`Starting ${app.name} on port ${app.port}…`);
3976
3726
  ws.appsRequested = true;
3977
3727
  persistWorkspaceEntry(cfg, ws);
3978
- const publicEnv = {
3979
- ...cloudflarePublicEnv(ws),
3980
- };
3981
3728
  if (app.role === "ai-server") {
3982
3729
  const reserved = reservedPortsFor(cfg, ws.sandboxId);
3983
3730
  const chat = await discoverChatPort(ws, 1200);
@@ -3989,7 +3736,7 @@ async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
3989
3736
  reserved,
3990
3737
  cfg,
3991
3738
  port: Number(app.port) || ws.port || 3100,
3992
- env: publicEnv,
3739
+ env: chatLaunchEnv(ws, cfg),
3993
3740
  });
3994
3741
  }
3995
3742
  } else {
@@ -3997,10 +3744,8 @@ async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
3997
3744
  await ensureHostProcesses(ws, {
3998
3745
  reserved,
3999
3746
  cfg,
4000
- onlyRoles: [app.role === "custom" ? "app" : app.role],
4001
- extraEnv: publicEnv,
4002
3747
  force: true,
4003
- plannedJobs: jobsFromHostApps(ws),
3748
+ plannedJobs: jobsFromHostApps(ws, { appId: app.id }),
4004
3749
  });
4005
3750
  }
4006
3751
  const status = await reconcileWorkspacePresence(ws, cfg, {
@@ -4068,7 +3813,7 @@ async function restartSingleApp(ws, cfg, payload = {}, opts = {}) {
4068
3813
  return startSingleApp(ws, cfg, payload, opts);
4069
3814
  }
4070
3815
 
4071
- async function startCloudflareForApp(ws, cfg, payload = {}, opts = {}) {
3816
+ async function startShareForApp(ws, cfg, payload = {}, opts = {}) {
4072
3817
  const app = findHostApp(ws, payload);
4073
3818
  if (!app) {
4074
3819
  activity(ws.sandboxId, "error", "Share URL failed: unknown app/port");
@@ -4086,9 +3831,6 @@ async function startCloudflareForApp(ws, cfg, payload = {}, opts = {}) {
4086
3831
  await progress(`Using the Maintainer Pro share URL for ${app.name}…`);
4087
3832
  const started = await startSingleApp(ws, cfg, { appId: app.id }, opts);
4088
3833
  if (started.error) return started;
4089
- const applied = await applyPublicUrlsToRunningApps(ws, cfg, {
4090
- onProgress: progress,
4091
- });
4092
3834
  const status = await reconcileWorkspacePresence(ws, cfg, {
4093
3835
  timeoutMs: 2500,
4094
3836
  });
@@ -4098,12 +3840,9 @@ async function startCloudflareForApp(ws, cfg, payload = {}, opts = {}) {
4098
3840
  running: Boolean(started.running),
4099
3841
  port: started.port,
4100
3842
  publicUrl: shareUrl,
4101
- cloudflareUrl: null,
4102
3843
  appUrl: status.host.appUrl,
4103
3844
  origins: status.host.origins,
4104
3845
  hostApps: status.probe.hostApps || [],
4105
- rewritten: applied.rewritten,
4106
- restarted: applied.restarted,
4107
3846
  log: activityLogFor(ws.sandboxId),
4108
3847
  };
4109
3848
  }
@@ -4118,32 +3857,21 @@ async function ensureHostProcesses(ws, opts = {}) {
4118
3857
  const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
4119
3858
  const cfg = opts.cfg || null;
4120
3859
  const folder = path.resolve(ws.folderPath);
4121
- const scripts = readPackageJson(folder)?.scripts || {};
4122
3860
  const jobs = Array.isArray(opts.plannedJobs)
4123
3861
  ? opts.plannedJobs
4124
- : planHostJobs(
4125
- folder,
4126
- ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
4127
- ws.projectInfo
4128
- );
3862
+ : jobsFromHostApps(ws).length
3863
+ ? jobsFromHostApps(ws)
3864
+ : planHostJobs(
3865
+ folder,
3866
+ ws.appUrl && isLocalAppUrl(ws.appUrl) ? ws.appUrl : null,
3867
+ ws.projectInfo
3868
+ );
4129
3869
  const onlyRoles = Array.isArray(opts.onlyRoles) ? new Set(opts.onlyRoles) : null;
4130
3870
  const extraEnv = opts.extraEnv && typeof opts.extraEnv === "object" ? opts.extraEnv : {};
4131
3871
  const started = [];
4132
3872
  let persisted = false;
4133
3873
  const label = ws.sandboxName || "this sandbox";
4134
3874
 
4135
- if (jobs.length > 0 && !fs.existsSync(folder)) {
4136
- recordProcessProblem({
4137
- sandboxId: ws.sandboxId,
4138
- code: "host_process_launch",
4139
- role: "app",
4140
- title: `Could not start the app (${label})`,
4141
- message: `The project folder is missing: ${folder}`,
4142
- resolution: "Attach the folder again from Local setup.",
4143
- });
4144
- return started;
4145
- }
4146
-
4147
3875
  log(
4148
3876
  `start hosts ${label}: ${
4149
3877
  jobs.length
@@ -4157,20 +3885,31 @@ async function ensureHostProcesses(ws, opts = {}) {
4157
3885
 
4158
3886
  for (const job of jobs) {
4159
3887
  if (onlyRoles && !onlyRoles.has(job.role)) continue;
3888
+ const jobFolder = path.resolve(job.folder || folder);
3889
+ if (!fs.existsSync(jobFolder)) {
3890
+ recordProcessProblem({
3891
+ sandboxId: ws.sandboxId,
3892
+ code: "host_process_launch",
3893
+ role: job.role,
3894
+ title: `Could not start the ${processRoleLabel(job.role)} (${label})`,
3895
+ message: `The project folder is missing: ${jobFolder}`,
3896
+ resolution: "Attach the folder again from Local setup.",
3897
+ });
3898
+ continue;
3899
+ }
3900
+ const scripts = readPackageJson(jobFolder)?.scripts || {};
4160
3901
  const preferred = Number(job.port || job.preferredPort) || 3000;
4161
- const probe = (
4162
- job.port
4163
- ? `http://127.0.0.1:${job.port}`
4164
- : job.probeUrl || `http://127.0.0.1:${preferred}`
4165
- ).replace("localhost", "127.0.0.1");
4166
- if (job.up || (await probeUrl(probe))) {
3902
+ const probe = job.port
3903
+ ? `http://127.0.0.1:${job.port}`
3904
+ : job.probeUrl || `http://127.0.0.1:${preferred}`;
3905
+ if (job.up || (await probeLoopbackUrl(probe))) {
4167
3906
  reserved.add(portFromText(probe, preferred));
4168
3907
  clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
4169
3908
  clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
4170
3909
  log(`${job.role} already running at ${job.probeUrl || probe}`);
4171
3910
  continue;
4172
3911
  }
4173
- const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
3912
+ const launchKey = `${ws.sandboxId}:${jobFolder}:${job.command || job.script}:${preferred}`;
4174
3913
  if (!opts.force && recentlyLaunched(launchKey)) {
4175
3914
  reserved.add(preferred);
4176
3915
  log(`start ${job.role} skip ${label}: launched recently`);
@@ -4202,13 +3941,13 @@ async function ensureHostProcesses(ws, opts = {}) {
4202
3941
  }
4203
3942
 
4204
3943
  const needsInstall =
4205
- fs.existsSync(path.join(folder, "package.json")) &&
4206
- !fs.existsSync(path.join(folder, "node_modules"));
3944
+ fs.existsSync(path.join(jobFolder, "package.json")) &&
3945
+ !fs.existsSync(path.join(jobFolder, "node_modules"));
4207
3946
  const run = commandWithPort(job, scripts, port);
4208
3947
  const command = needsInstall ? `npm install && ${run}` : run;
4209
3948
  const opened = await openInNewTerminal({
4210
3949
  title: `MP-${job.role}-${port}`,
4211
- folder,
3950
+ folder: jobFolder,
4212
3951
  command,
4213
3952
  env: { PORT: String(port), ...extraEnv },
4214
3953
  launchKey,
@@ -4265,12 +4004,9 @@ async function inspectHostJobs(ws) {
4265
4004
  const hosts = [];
4266
4005
  for (const job of jobs) {
4267
4006
  const preferred = Number(job.preferredPort) || 3000;
4268
- const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
4269
- "localhost",
4270
- "127.0.0.1"
4271
- );
4272
- const up = await probeUrl(probe);
4273
- const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
4007
+ const probe = job.probeUrl || `http://127.0.0.1:${preferred}`;
4008
+ const up = await probeLoopbackUrl(probe);
4009
+ const launchKey = `${ws.sandboxId}:${job.folder || folder}:${job.command || job.script}:${preferred}`;
4274
4010
  const starting = recentlyLaunched(launchKey);
4275
4011
  hosts.push({
4276
4012
  role: job.role,
@@ -4362,6 +4098,79 @@ async function setupWorkspace(cfg, action) {
4362
4098
  config.sandbox?.name ||
4363
4099
  "Maintainer Pro App";
4364
4100
 
4101
+ const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
4102
+ ? config.aiIgnorePaths
4103
+ : [];
4104
+ const addFolder = Boolean(action.payload?.addFolder);
4105
+ cfg.workspaces = cfg.workspaces || [];
4106
+ const existingIdx = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
4107
+ const existingWs = existingIdx >= 0 ? cfg.workspaces[existingIdx] : null;
4108
+
4109
+ if (addFolder && existingWs?.folderPath) {
4110
+ const extra = Array.isArray(existingWs.extraFolders)
4111
+ ? [...existingWs.extraFolders]
4112
+ : [];
4113
+ if (
4114
+ !sameFolder(existingWs.folderPath, resolved) &&
4115
+ !extra.some((folder) => sameFolder(folder, resolved))
4116
+ ) {
4117
+ extra.push(resolved);
4118
+ }
4119
+ existingWs.extraFolders = extra;
4120
+ const access = applyAccessPolicy(resolved, partnerIgnorePaths, sandboxId);
4121
+ persistWorkspaceEntry(cfg, existingWs);
4122
+ const ports = await resolveWorkspaceHostApps(existingWs, {
4123
+ cfg,
4124
+ allowAi: false,
4125
+ ignoreDesired: true,
4126
+ unionDetected: true,
4127
+ force: true,
4128
+ });
4129
+ const needsReview =
4130
+ Boolean(ports.confused) ||
4131
+ !(ports.apps || []).some((app) => app && app.role !== "ai-server");
4132
+ if (needsReview) {
4133
+ activity(
4134
+ sandboxId,
4135
+ "warn",
4136
+ "Review the suggested apps and ports in Maintainer Pro before Start Apps."
4137
+ );
4138
+ }
4139
+ await inspectHostJobs(existingWs);
4140
+ const host = workspaceHostReport(existingWs);
4141
+ const aiServerUp = await isChatServerOnPort(existingWs.port);
4142
+ log(`setup added folder ${resolved} sandbox=${shortId(sandboxId)}`);
4143
+ return {
4144
+ sandboxId,
4145
+ folderPath: existingWs.folderPath,
4146
+ extraFolders: extra,
4147
+ addFolder: true,
4148
+ addedFolder: resolved,
4149
+ port: existingWs.port,
4150
+ appUrl: host.appUrl || existingWs.appUrl || null,
4151
+ origins: host.origins,
4152
+ wroteEnv: false,
4153
+ clientKind: existingWs.clientKind || "skip",
4154
+ clientFiles: [],
4155
+ clientNotes: [`Added folder ${resolved}`],
4156
+ aiServerUp,
4157
+ startedHosts: [],
4158
+ openUrl: existingWs.appUrl || `http://localhost:${existingWs.port}`,
4159
+ processIssues: issuesForSandbox(sandboxId).map(
4160
+ ({ role: _role, ...issue }) => issue
4161
+ ),
4162
+ warning: needsReview
4163
+ ? "Folder attached. Review the suggested apps and ports in Maintainer Pro, then Start Apps."
4164
+ : "Folder attached. Review apps if needed, then use Start Apps.",
4165
+ waitingForStart: !aiServerUp,
4166
+ needsReview,
4167
+ hostApps: ports.apps || existingWs.hostApps || [],
4168
+ reasons: ports.reasons || [],
4169
+ projectInfo: existingWs.projectInfo || null,
4170
+ ignorePaths: access.ignorePaths,
4171
+ };
4172
+ }
4173
+
4365
4174
  const client = configureClient({
4366
4175
  dir: resolved,
4367
4176
  port,
@@ -4374,24 +4183,22 @@ async function setupWorkspace(cfg, action) {
4374
4183
  const corsOrigin = client.corsOrigin || aiOrigin;
4375
4184
  const appUrl = client.appUrl || corsOrigin;
4376
4185
 
4377
- const partnerIgnorePaths = Array.isArray(config.aiIgnorePaths)
4378
- ? config.aiIgnorePaths
4379
- : [];
4380
4186
  const access = applyAccessPolicy(resolved, partnerIgnorePaths, sandboxId);
4381
4187
 
4382
- cfg.workspaces = cfg.workspaces || [];
4383
- const existing = cfg.workspaces.findIndex((w) => w.sandboxId === sandboxId);
4384
4188
  const entry = {
4385
4189
  sandboxId,
4386
4190
  folderPath: resolved,
4191
+ extraFolders: Array.isArray(existingWs?.extraFolders)
4192
+ ? existingWs.extraFolders.filter((folder) => !sameFolder(folder, resolved))
4193
+ : [],
4387
4194
  port,
4388
4195
  sandboxName: config.sandbox?.name,
4389
4196
  applicationName: config.sandbox?.applicationName,
4390
4197
  clientKind: client.kind,
4391
4198
  appUrl,
4392
4199
  sameOrigin: Boolean(client.sameOrigin),
4393
- appsRequested: false,
4394
- cloudflarePending: false,
4200
+ appsRequested: existingWs?.appsRequested || false,
4201
+ hostApps: existingWs?.hostApps,
4395
4202
  store: {
4396
4203
  serverKey: String(config.env?.MAINTAINER_PRO_API_KEY || "").trim(),
4397
4204
  clientKey: String(
@@ -4401,7 +4208,7 @@ async function setupWorkspace(cfg, action) {
4401
4208
  ).trim(),
4402
4209
  },
4403
4210
  };
4404
- if (existing >= 0) cfg.workspaces[existing] = entry;
4211
+ if (existingIdx >= 0) cfg.workspaces[existingIdx] = { ...existingWs, ...entry };
4405
4212
  else cfg.workspaces.push(entry);
4406
4213
  saveConfig(cfg);
4407
4214
 
@@ -4453,7 +4260,9 @@ async function setupWorkspace(cfg, action) {
4453
4260
  const host = workspaceHostReport(entry);
4454
4261
  return {
4455
4262
  sandboxId,
4456
- folderPath: resolved,
4263
+ folderPath: entry.folderPath,
4264
+ extraFolders: entry.extraFolders || [],
4265
+ addFolder: false,
4457
4266
  port: entry.port,
4458
4267
  appUrl: host.appUrl || entry.appUrl || appUrl,
4459
4268
  origins: host.origins.length
@@ -4478,7 +4287,8 @@ async function setupWorkspace(cfg, action) {
4478
4287
  }
4479
4288
 
4480
4289
  async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
4481
- const folder = path.resolve(ws.folderPath || "");
4290
+ const folders = workspaceFolders(ws);
4291
+ const folder = folders[0] || path.resolve(ws.folderPath || "");
4482
4292
  const label = ws.sandboxName || shortId(ws.sandboxId) || "sandbox";
4483
4293
  activity(ws.sandboxId, "info", `Suggesting setup for ${label} from config files`);
4484
4294
  try {
@@ -4490,6 +4300,7 @@ async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
4490
4300
  }
4491
4301
  const proposal = await cli.proposeHostAppsFromConfig({
4492
4302
  workspaceDir: folder,
4303
+ workspaceDirs: folders,
4493
4304
  appName: ws.applicationName || ws.sandboxName,
4494
4305
  preferredAiPort: Number(ws.port) || 3100,
4495
4306
  allowAi: opts.allowAi !== false,
@@ -4497,8 +4308,7 @@ async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
4497
4308
  const previous = Array.isArray(ws.hostApps) ? ws.hostApps : [];
4498
4309
  const apps = (proposal.apps || []).map((app) => {
4499
4310
  const match = previous.find((row) => row && row.id === app.id);
4500
- if (!match || (app.envMaps && app.envMaps.length)) return app;
4501
- return { ...app, envMaps: match.envMaps || [] };
4311
+ return match ? { ...app, host: match.host === true || app.host === true } : app;
4502
4312
  });
4503
4313
  // Keep last known apps on the workspace; do not auto-apply proposal.
4504
4314
  if (!Array.isArray(ws.hostApps) || !ws.hostApps.length) {
@@ -4565,6 +4375,7 @@ async function runActions(cfg, actions) {
4565
4375
  if (!actions.length) return;
4566
4376
  log(`actions received ${actions.length}: ${actions.map((a) => a.code).join(", ")}`);
4567
4377
  for (const action of actions) {
4378
+ action.code = canonicalActionCode(action.code);
4568
4379
  const startedAt = Date.now();
4569
4380
  const label = actionLabel(action);
4570
4381
  log(`${label} start`);
@@ -4604,12 +4415,17 @@ async function runActions(cfg, actions) {
4604
4415
  partnerIgnorePaths,
4605
4416
  ws.sandboxId
4606
4417
  );
4418
+ const extras = workspaceFolders(ws).slice(1).map((folder) =>
4419
+ applyAccessPolicy(folder, partnerIgnorePaths, ws.sandboxId)
4420
+ );
4607
4421
  log(
4608
- `access policy synced for ${ws.folderPath} (${access.ignorePaths.length} ignore rules)`
4422
+ `access policy synced for ${workspaceFolders(ws).join(", ")} (${access.ignorePaths.length} ignore rules)`
4609
4423
  );
4610
4424
  result = {
4611
4425
  folderPath: path.resolve(ws.folderPath),
4426
+ extraFolders: workspaceFolders(ws).slice(1),
4612
4427
  ignorePaths: access.ignorePaths,
4428
+ extraIgnorePaths: extras.map((row) => row.ignorePaths),
4613
4429
  syncedAt: new Date().toISOString(),
4614
4430
  };
4615
4431
  }
@@ -4693,14 +4509,14 @@ async function runActions(cfg, actions) {
4693
4509
  });
4694
4510
  if (result.error) ok = false;
4695
4511
  }
4696
- } else if (action.code === "start_cloudflare_app") {
4512
+ } else if (action.code === "start_share_app") {
4697
4513
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
4698
4514
  const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4699
4515
  if (!ws) {
4700
4516
  ok = false;
4701
4517
  result = { error: "No folder is attached for this sandbox" };
4702
4518
  } else {
4703
- result = await startCloudflareForApp(ws, cfg, action.payload || {}, {
4519
+ result = await startShareForApp(ws, cfg, action.payload || {}, {
4704
4520
  actionId: action.id,
4705
4521
  });
4706
4522
  if (result.error) ok = false;
@@ -4805,7 +4621,6 @@ async function runActions(cfg, actions) {
4805
4621
  sandboxId: sandboxId || ws?.sandboxId || null,
4806
4622
  appUrl:
4807
4623
  host.appUrl ||
4808
- ws?.cloudflareUrl ||
4809
4624
  ws?.appUrl ||
4810
4625
  process.env.APP_URL ||
4811
4626
  process.env.PUBLIC_URL ||
@@ -4817,7 +4632,7 @@ async function runActions(cfg, actions) {
4817
4632
  host.origins.join(",") || "none"
4818
4633
  }`
4819
4634
  );
4820
- } else if (action.code === "configure_cloudflare") {
4635
+ } else if (action.code === "configure_share") {
4821
4636
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
4822
4637
  const ws =
4823
4638
  (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
@@ -4838,7 +4653,7 @@ async function runActions(cfg, actions) {
4838
4653
  result = { error: "No folder is attached for this sandbox" };
4839
4654
  warn(`${label} skipped: ${result.error}`);
4840
4655
  } else {
4841
- result = await configureCloudflareForWorkspace(ws, cfg, {
4656
+ result = await configureShareForWorkspace(ws, cfg, {
4842
4657
  actionId: action.id,
4843
4658
  });
4844
4659
  }
@@ -4846,13 +4661,46 @@ async function runActions(cfg, actions) {
4846
4661
  const sandboxId = String(
4847
4662
  action.sandboxId || action.payload?.sandboxId || ""
4848
4663
  );
4849
- await forgetLaunch(sandboxId);
4850
- cfg.workspaces = (cfg.workspaces || []).filter(
4851
- (w) => w.sandboxId !== sandboxId
4852
- );
4853
- saveConfig(cfg);
4854
- result = { removedSandboxId: sandboxId };
4855
- log(`${label} removed workspace`);
4664
+ if (action.payload?.keepWorkspace) {
4665
+ const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4666
+ if (!ws) {
4667
+ ok = false;
4668
+ result = { error: "No folder is attached for this sandbox" };
4669
+ } else {
4670
+ const removed = String(action.payload.folderPath || "");
4671
+ const remaining = Array.isArray(action.payload.extraFolders)
4672
+ ? action.payload.extraFolders
4673
+ : workspaceFolders(ws).filter((folder) => !sameFolder(folder, removed));
4674
+ const primary =
4675
+ String(action.payload.folderPathRemaining || remaining[0] || ws.folderPath);
4676
+ ws.folderPath = primary;
4677
+ ws.extraFolders = remaining.filter((folder) => !sameFolder(folder, primary));
4678
+ ws.hostApps = (Array.isArray(ws.hostApps) ? ws.hostApps : []).filter(
4679
+ (app) =>
4680
+ !app ||
4681
+ app.role === "ai-server" ||
4682
+ !app.folderPath ||
4683
+ !sameFolder(app.folderPath, removed)
4684
+ );
4685
+ persistWorkspaceEntry(cfg, ws);
4686
+ result = {
4687
+ sandboxId,
4688
+ folderPath: ws.folderPath,
4689
+ extraFolders: ws.extraFolders,
4690
+ port: ws.port,
4691
+ hostApps: ws.hostApps,
4692
+ };
4693
+ log(`${label} removed extra folder ${removed}`);
4694
+ }
4695
+ } else {
4696
+ await forgetLaunch(sandboxId);
4697
+ cfg.workspaces = (cfg.workspaces || []).filter(
4698
+ (w) => w.sandboxId !== sandboxId
4699
+ );
4700
+ saveConfig(cfg);
4701
+ result = { removedSandboxId: sandboxId };
4702
+ log(`${label} removed workspace`);
4703
+ }
4856
4704
  } else {
4857
4705
  ok = false;
4858
4706
  result = { error: `Unknown action ${action.code}` };
@@ -5643,7 +5491,6 @@ async function main() {
5643
5491
  clearInterval(watchdogTimer);
5644
5492
  watchdogTimer = null;
5645
5493
  }
5646
- stopAllCloudflare();
5647
5494
  dropSocket(socket);
5648
5495
  socket = null;
5649
5496
  log("shutting down (other terminals stay open)");