@maintainer-pro/ai-bridge 0.1.14 → 0.1.16

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,14 @@ import {
23
23
  lookupShareResponse,
24
24
  prepareShareHttpRequest,
25
25
  processShareHttpResponse,
26
- shouldRewriteBody,
26
+ shouldProcessShareResponse,
27
27
  } from "./share-rewrite.mjs";
28
+ import {
29
+ canonicalActionCode,
30
+ isDiscardedTunnelUrl,
31
+ leftoverStateKeys,
32
+ LEGACY_TUNNEL_FILE,
33
+ } from "./discarded-tunnels.mjs";
28
34
 
29
35
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
36
  const PACKAGE_VERSION = readPackageVersion();
@@ -387,8 +393,7 @@ async function resolveWorkspaceHostApps(ws, opts = {}) {
387
393
  const previous = Array.isArray(ws.hostApps) ? ws.hostApps : [];
388
394
  result.apps = result.apps.map((app) => {
389
395
  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 || [] };
396
+ return match ? { ...app, host: match.host === true || app.host === true } : app;
392
397
  });
393
398
  ws.hostApps = result.apps;
394
399
  if (opts.cfg) persistWorkspaceEntry(opts.cfg, ws);
@@ -819,6 +824,67 @@ function probeUrl(url, timeoutMs = 2500) {
819
824
  });
820
825
  }
821
826
 
827
+ /** Some Windows apps bind `localhost` (::1) but refuse `127.0.0.1`. */
828
+ const LOOPBACK_HOSTS = ["127.0.0.1", "localhost"];
829
+ /** @type {Map<number, string>} */
830
+ const loopbackHostByPort = new Map();
831
+
832
+ function isLoopbackConnError(err) {
833
+ const code = err && typeof err === "object" ? String(err.code || "") : "";
834
+ return (
835
+ code === "ECONNREFUSED" ||
836
+ code === "EHOSTUNREACH" ||
837
+ code === "EADDRNOTAVAIL" ||
838
+ code === "ENOTFOUND" ||
839
+ code === "ETIMEDOUT"
840
+ );
841
+ }
842
+
843
+ function loopbackHostsForPort(port) {
844
+ const cached = loopbackHostByPort.get(Number(port));
845
+ if (cached === "localhost") return ["localhost", "127.0.0.1"];
846
+ return LOOPBACK_HOSTS;
847
+ }
848
+
849
+ function rememberLoopbackHost(port, host) {
850
+ const n = Number(port);
851
+ const name = String(host || "").trim();
852
+ if (n && (name === "127.0.0.1" || name === "localhost")) {
853
+ loopbackHostByPort.set(n, name);
854
+ }
855
+ }
856
+
857
+ function loopbackUrlVariants(url) {
858
+ try {
859
+ const parsed = new URL(String(url));
860
+ const host = String(parsed.hostname || "").toLowerCase();
861
+ if (host !== "localhost" && host !== "127.0.0.1") return [String(url)];
862
+ const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
863
+ return loopbackHostsForPort(port).map((name) => {
864
+ const next = new URL(parsed);
865
+ next.hostname = name;
866
+ return next.toString();
867
+ });
868
+ } catch {
869
+ return [String(url)];
870
+ }
871
+ }
872
+
873
+ async function probeLoopbackUrl(url, timeoutMs = 2500) {
874
+ for (const candidate of loopbackUrlVariants(url)) {
875
+ if (await probeUrl(candidate, timeoutMs)) {
876
+ try {
877
+ const parsed = new URL(candidate);
878
+ rememberLoopbackHost(parsed.port, parsed.hostname);
879
+ } catch {
880
+ /* ignore */
881
+ }
882
+ return true;
883
+ }
884
+ }
885
+ return false;
886
+ }
887
+
822
888
  function adminListenPort(cfg = bridgeCfg) {
823
889
  try {
824
890
  const u = new URL(String(cfg?.adminUrl || "http://localhost:4100"));
@@ -1027,9 +1093,8 @@ function mergeEnvFile(file, values, opts = {}) {
1027
1093
  }
1028
1094
 
1029
1095
  /**
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.
1096
+ * Do not write Maintainer Pro keys or share URLs into the app.
1097
+ * Only strip leftover tunnel URLs we previously wrote.
1033
1098
  */
1034
1099
  function writeProjectEnv(folder, _values, opts = {}) {
1035
1100
  const remove = (opts.remove || []).filter(Boolean);
@@ -1061,37 +1126,9 @@ function readProjectEnvValues(folder) {
1061
1126
  return map;
1062
1127
  }
1063
1128
 
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) {
1129
+ function clearLegacyTunnelFile(folder) {
1093
1130
  if (!folder) return;
1094
- const file = path.join(path.resolve(folder), ".cloudflare-tunnel-url");
1131
+ const file = path.join(path.resolve(folder), LEGACY_TUNNEL_FILE);
1095
1132
  try {
1096
1133
  if (fs.existsSync(file)) fs.unlinkSync(file);
1097
1134
  } catch {
@@ -1099,8 +1136,7 @@ function clearCloudflareTunnelFile(folder) {
1099
1136
  }
1100
1137
  }
1101
1138
 
1102
- /** Stop rediscovering dead trycloudflare URLs from old cloudflared logs. */
1103
- function archiveStaleCloudflareLogs(folder, sandboxId) {
1139
+ function archiveLegacyTunnelLogs(folder, sandboxId) {
1104
1140
  if (!folder) return;
1105
1141
  const logDir = dataDirFor(folder, sandboxId);
1106
1142
  if (!fs.existsSync(logDir)) return;
@@ -1123,185 +1159,25 @@ function archiveStaleCloudflareLogs(folder, sandboxId) {
1123
1159
  }
1124
1160
  }
1125
1161
 
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 = {}) {
1162
+ /** Drop leftover tunnel state so heartbeats never revive old public URLs. */
1163
+ function stripDiscardedTunnelState(ws, cfg) {
1152
1164
  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(", ")}`
1165
+ const leftoverKeys = leftoverStateKeys(ws);
1166
+ const had = leftoverKeys.length > 0 || isDiscardedTunnelUrl(ws.appUrl);
1167
+ for (const key of leftoverKeys) delete ws[key];
1168
+ if (isDiscardedTunnelUrl(ws.appUrl)) ws.appUrl = null;
1169
+
1170
+ if (folder && fs.existsSync(folder)) {
1171
+ archiveLegacyTunnelLogs(folder, ws.sandboxId);
1172
+ clearLegacyTunnelFile(folder);
1173
+ const current = readProjectEnvValues(folder);
1174
+ const remove = Object.keys(current).filter((key) =>
1175
+ isDiscardedTunnelUrl(current[key])
1162
1176
  );
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);
1177
+ if (remove.length) writeProjectEnv(folder, {}, { remove });
1178
1178
  }
1179
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
- }
1245
- }
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
-
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;
1180
+ if (had) persistWorkspaceEntry(cfg, ws);
1305
1181
  }
1306
1182
 
1307
1183
  function workspaceHostReport(ws) {
@@ -1326,7 +1202,7 @@ function workspaceHostReport(ws) {
1326
1202
  env.NEXT_PUBLIC_AI_SERVER_URL,
1327
1203
  ws.appUrl,
1328
1204
  ]) {
1329
- if (!value || isTryCloudflareUrl(value)) continue;
1205
+ if (!value || isDiscardedTunnelUrl(value)) continue;
1330
1206
  add(value);
1331
1207
  }
1332
1208
  if (chatPort) {
@@ -1360,7 +1236,7 @@ function workspaceHostReport(ws) {
1360
1236
  /**
1361
1237
  * Status-only reconcile for a workspace:
1362
1238
  * 1) probe chat / ui / backend
1363
- * 2) drop leftover trycloudflare URLs (never start or reuse tunnels)
1239
+ * 2) drop leftover tunnel URLs
1364
1240
  * 3) compute host appUrl + CORS origins for Maintainer Pro
1365
1241
  *
1366
1242
  * Does not start apps. Does not write the app's .env files.
@@ -1375,19 +1251,8 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
1375
1251
  ws.port = probe.chatPort;
1376
1252
  }
1377
1253
 
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
- }
1254
+ // 2. Remove leftover tunnel URLs / env — share URLs replace them.
1255
+ stripDiscardedTunnelState(ws, cfg);
1391
1256
 
1392
1257
  // 3. Host + CORS origins for Maintainer Pro
1393
1258
  const host = workspaceHostReport(ws);
@@ -1445,8 +1310,6 @@ async function reconcileWorkspacePresence(ws, cfg, opts = {}) {
1445
1310
 
1446
1311
  return {
1447
1312
  probe,
1448
- usingCloudflare: false,
1449
- cloudflare: null,
1450
1313
  host,
1451
1314
  appsRunning: probe.running,
1452
1315
  aiServerUp: probe.chatUp,
@@ -1467,18 +1330,28 @@ async function probeHttpPaths(port, paths, timeoutMs = 2500) {
1467
1330
  function probePortOpen(port, timeoutMs = 800) {
1468
1331
  const n = Number(port);
1469
1332
  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
- });
1333
+ const tryHost = (host) =>
1334
+ new Promise((resolve) => {
1335
+ const socket = net.connect({ host, port: n });
1336
+ const done = (ok) => {
1337
+ socket.removeAllListeners();
1338
+ socket.destroy();
1339
+ resolve(ok);
1340
+ };
1341
+ socket.setTimeout(timeoutMs);
1342
+ socket.once("connect", () => done(true));
1343
+ socket.once("timeout", () => done(false));
1344
+ socket.once("error", () => done(false));
1345
+ });
1346
+ return (async () => {
1347
+ for (const host of loopbackHostsForPort(n)) {
1348
+ if (await tryHost(host)) {
1349
+ rememberLoopbackHost(n, host);
1350
+ return true;
1351
+ }
1352
+ }
1353
+ return false;
1354
+ })();
1482
1355
  }
1483
1356
 
1484
1357
  async function portIsLive(port, timeoutMs = 1200) {
@@ -1605,15 +1478,6 @@ function proxyUrlForApp(ws, app) {
1605
1478
  return `${origin}/p/${token}/${slug}`;
1606
1479
  }
1607
1480
 
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
1481
  function jobsFromHostApps(ws) {
1618
1482
  const apps = Array.isArray(ws.hostApps) ? ws.hostApps : [];
1619
1483
  const hostId = hostAppOf(ws)?.id;
@@ -1678,7 +1542,6 @@ async function probeRunningApps(ws, timeoutMs = 2500) {
1678
1542
  ...app,
1679
1543
  port: port || app.port,
1680
1544
  running: Boolean(up),
1681
- cloudflareUrl: null,
1682
1545
  publicUrl: proxyUrlForApp(ws, app) || null,
1683
1546
  lastCheckedAt: new Date().toISOString(),
1684
1547
  };
@@ -1722,66 +1585,20 @@ async function restoreHostsAfterReconnect(cfg) {
1722
1585
  }
1723
1586
  }
1724
1587
 
1725
- function envForWorkspacePorts(ws, jobs) {
1588
+ function chatLaunchEnv(ws, cfg) {
1726
1589
  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 = {
1590
+ const admin = originFromUrl(cfg?.adminUrl);
1591
+ const origins = [];
1592
+ if (admin) origins.push(admin);
1593
+ origins.push(`http://localhost:${aiPort}`);
1594
+ origins.push(`http://127.0.0.1:${aiPort}`);
1595
+ return {
1734
1596
  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
1597
  MAINTAINER_PRO_DATA_DIR: dataDirFor(ws.folderPath, ws.sandboxId),
1740
1598
  MAINTAINER_PRO_SANDBOX_ID: String(ws.sandboxId || ""),
1599
+ CORS_ORIGIN: origins.join(","),
1600
+ CORS_ORIGINS: origins.join(","),
1741
1601
  };
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
1602
  }
1786
1603
 
1787
1604
  async function prepareWorkspaceLaunch(ws, cfg, reserved) {
@@ -1790,7 +1607,7 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1790
1607
  if (!folder || !fs.existsSync(folder)) {
1791
1608
  const aiPort = Number(ws.port) || 3100;
1792
1609
  log(`ports skip ${label}: folder missing (${folder || "none"})`);
1793
- return { aiPort, jobs: [], env: {} };
1610
+ return { aiPort, jobs: [] };
1794
1611
  }
1795
1612
  log(`ports pick ${label} in ${folder}`);
1796
1613
  const listedAi = Array.isArray(ws.hostApps)
@@ -1826,11 +1643,8 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1826
1643
  const planned = [];
1827
1644
  for (const job of jobs) {
1828
1645
  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);
1646
+ const probe = job.probeUrl || `http://127.0.0.1:${preferred}`;
1647
+ const up = await probeLoopbackUrl(probe);
1834
1648
  const port = up
1835
1649
  ? (reserved.add(portFromText(probe, preferred)), portFromText(probe, preferred))
1836
1650
  : await findFreePort(preferred, reserved);
@@ -1850,10 +1664,6 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1850
1664
  }
1851
1665
  }
1852
1666
 
1853
- const env = envForWorkspacePorts(ws, planned);
1854
- log(
1855
- `env inject ${label} (process only): ${Object.keys(env).join(" ") || "(none)"}`
1856
- );
1857
1667
  if (planned.length) {
1858
1668
  ws.projectInfo = {
1859
1669
  ...(ws.projectInfo || {}),
@@ -1869,7 +1679,7 @@ async function prepareWorkspaceLaunch(ws, cfg, reserved) {
1869
1679
  .map((job) => ` ${job.role}=${job.port}${job.up ? "(up)" : ""}`)
1870
1680
  .join("")}`
1871
1681
  );
1872
- return { aiPort, jobs: planned, env };
1682
+ return { aiPort, jobs: planned };
1873
1683
  }
1874
1684
 
1875
1685
  const DEFAULT_AI_IGNORE_PATHS = [
@@ -1934,7 +1744,7 @@ const IGNORE_NAMES = new Set([
1934
1744
  ".maintainer-pro",
1935
1745
  ".collaborater",
1936
1746
  ".maintainer-pro-bridge.json",
1937
- ".cloudflare-tunnel-url",
1747
+ LEGACY_TUNNEL_FILE,
1938
1748
  ]);
1939
1749
 
1940
1750
  function isIgnorableEntry(name) {
@@ -2204,8 +2014,6 @@ function listLaunchRoots(allowed) {
2204
2014
 
2205
2015
  /** Prevents opening a new window on every heartbeat while a process is starting. */
2206
2016
  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
2017
  /** In-process chat servers, one per sandbox/project. */
2210
2018
  /** @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
2019
  const embeddedChat = new Map();
@@ -2528,7 +2336,6 @@ function handleProxyHttpFromAdmin(msg) {
2528
2336
  }
2529
2337
  }
2530
2338
  const headers = prepared.headers;
2531
- headers.host = `127.0.0.1:${port}`;
2532
2339
  // Next.js dev 403s `/_next` when Origin/sec-fetch look cross-site.
2533
2340
  // This hop is server-to-server; drop those so chunks always load.
2534
2341
  for (const key of Object.keys(headers)) {
@@ -2545,91 +2352,126 @@ function handleProxyHttpFromAdmin(msg) {
2545
2352
  }
2546
2353
  }
2547
2354
  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 (shouldRewriteBody(outHeaders)) {
2569
- /** @type {Buffer[]} */
2570
- const chunks = [];
2355
+ const initialBody =
2356
+ typeof msg.body === "string" && msg.body
2357
+ ? Buffer.from(msg.body, "base64")
2358
+ : null;
2359
+ const endAfter = msg.bodyEof !== false;
2360
+ const hosts = loopbackHostsForPort(port);
2361
+ let hostIndex = 0;
2362
+
2363
+ const connect = () => {
2364
+ const host = hosts[hostIndex];
2365
+ const reqHeaders = { ...headers, host: `${host}:${port}` };
2366
+ let req;
2367
+ try {
2368
+ req = http.request(
2369
+ {
2370
+ hostname: host,
2371
+ port,
2372
+ path,
2373
+ method,
2374
+ headers: reqHeaders,
2375
+ agent: proxyKeepAliveAgent,
2376
+ },
2377
+ (res) => {
2378
+ rememberLoopbackHost(port, host);
2379
+ /** @type {Record<string, string>} */
2380
+ const outHeaders = {};
2381
+ for (const [key, value] of Object.entries(res.headers)) {
2382
+ if (value == null) continue;
2383
+ outHeaders[key] = Array.isArray(value)
2384
+ ? value.join(", ")
2385
+ : String(value);
2386
+ }
2387
+ const status = res.statusCode || 502;
2388
+ applyShareCacheHeaders(outHeaders, path);
2389
+ if (shouldProcessShareResponse(outHeaders)) {
2390
+ /** @type {Buffer[]} */
2391
+ const chunks = [];
2392
+ res.on("data", (chunk) => {
2393
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2394
+ });
2395
+ res.on("end", () => {
2396
+ proxyHttpReqs.delete(id);
2397
+ sendProcessedProxyHttp(
2398
+ id,
2399
+ ctx,
2400
+ status,
2401
+ outHeaders,
2402
+ Buffer.concat(chunks)
2403
+ );
2404
+ });
2405
+ return;
2406
+ }
2407
+ bridgeSend({
2408
+ type: "proxy.http.start",
2409
+ id,
2410
+ stream,
2411
+ status,
2412
+ headers: outHeaders,
2413
+ });
2571
2414
  res.on("data", (chunk) => {
2572
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2415
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2416
+ for (
2417
+ let offset = 0;
2418
+ offset < buf.length;
2419
+ offset += PROXY_CHUNK_BYTES
2420
+ ) {
2421
+ const end = Math.min(offset + PROXY_CHUNK_BYTES, buf.length);
2422
+ bridgeSend({
2423
+ type: "proxy.http.chunk",
2424
+ id,
2425
+ stream,
2426
+ data: Buffer.from(buf.subarray(offset, end)).toString("base64"),
2427
+ eof: false,
2428
+ });
2429
+ }
2573
2430
  });
2574
2431
  res.on("end", () => {
2575
2432
  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
2433
  bridgeSend({
2598
2434
  type: "proxy.http.chunk",
2599
2435
  id,
2600
2436
  stream,
2601
- data: Buffer.from(buf.subarray(offset, end)).toString("base64"),
2602
- eof: false,
2437
+ data: "",
2438
+ eof: true,
2603
2439
  });
2604
- }
2605
- });
2606
- res.on("end", () => {
2607
- proxyHttpReqs.delete(id);
2608
- bridgeSend({ type: "proxy.http.chunk", id, stream, data: "", eof: true });
2609
- });
2440
+ });
2441
+ }
2442
+ );
2443
+ } catch (err) {
2444
+ if (hostIndex + 1 < hosts.length) {
2445
+ hostIndex += 1;
2446
+ connect();
2447
+ return;
2610
2448
  }
2611
- );
2612
- } catch (err) {
2613
- bridgeSend({
2614
- type: "proxy.http.error",
2615
- id,
2616
- error: err instanceof Error ? err.message : String(err),
2617
- });
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),
2449
+ bridgeSend({
2450
+ type: "proxy.http.error",
2451
+ id,
2452
+ error: err instanceof Error ? err.message : String(err),
2453
+ });
2454
+ return;
2455
+ }
2456
+ req.on("error", (err) => {
2457
+ if (isLoopbackConnError(err) && hostIndex + 1 < hosts.length) {
2458
+ proxyHttpReqs.delete(id);
2459
+ hostIndex += 1;
2460
+ connect();
2461
+ return;
2462
+ }
2463
+ proxyHttpReqs.delete(id);
2464
+ bridgeSend({
2465
+ type: "proxy.http.error",
2466
+ id,
2467
+ error: err instanceof Error ? err.message : String(err),
2468
+ });
2626
2469
  });
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();
2470
+ proxyHttpReqs.set(id, req);
2471
+ if (initialBody?.length) req.write(initialBody);
2472
+ if (endAfter) req.end();
2473
+ };
2474
+ connect();
2633
2475
  }
2634
2476
 
2635
2477
  function handleProxyHttpBodyFromAdmin(msg) {
@@ -2659,9 +2501,11 @@ function attachProxyLocalWs(id, socket) {
2659
2501
  /* WHATWG WebSocket in Node 22 */
2660
2502
  }
2661
2503
  proxyLocalSockets.set(id, socket);
2662
- socket.addEventListener("open", () => {
2504
+ const announceOpen = () => {
2663
2505
  bridgeSend({ type: "proxy.ws.opened", id });
2664
- });
2506
+ };
2507
+ if (socket.readyState === 1) announceOpen();
2508
+ else socket.addEventListener("open", announceOpen);
2665
2509
  socket.addEventListener("message", (event) => {
2666
2510
  try {
2667
2511
  if (typeof event.data === "string") {
@@ -2713,23 +2557,51 @@ function wsProtocolsFromMsg(msg) {
2713
2557
  }
2714
2558
 
2715
2559
  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),
2560
+ const proto = Array.isArray(protocols)
2561
+ ? protocols.map((p) => String(p || "").trim()).filter(Boolean)
2562
+ : [];
2563
+ const hosts = loopbackHostsForPort(port);
2564
+ const tryHost = (index) => {
2565
+ const host = hosts[index];
2566
+ const url = `ws://${host}:${port}${path}`;
2567
+ let socket;
2568
+ try {
2569
+ // Vite HMR only accepts upgrades with subprotocol `vite-hmr` / `vite-ping`.
2570
+ socket = proto.length ? new WebSocket(url, proto) : new WebSocket(url);
2571
+ } catch (err) {
2572
+ if (index + 1 < hosts.length) {
2573
+ tryHost(index + 1);
2574
+ return;
2575
+ }
2576
+ bridgeSend({
2577
+ type: "proxy.ws.error",
2578
+ id,
2579
+ error: err instanceof Error ? err.message : String(err),
2580
+ });
2581
+ return;
2582
+ }
2583
+ let settled = false;
2584
+ socket.addEventListener("open", () => {
2585
+ if (settled) return;
2586
+ settled = true;
2587
+ rememberLoopbackHost(port, host);
2588
+ attachProxyLocalWs(id, socket);
2729
2589
  });
2730
- return;
2731
- }
2732
- attachProxyLocalWs(id, socket);
2590
+ socket.addEventListener("close", () => {
2591
+ if (settled) return;
2592
+ settled = true;
2593
+ if (index + 1 < hosts.length) {
2594
+ tryHost(index + 1);
2595
+ return;
2596
+ }
2597
+ bridgeSend({
2598
+ type: "proxy.ws.error",
2599
+ id,
2600
+ error: `Cannot reach ${url}`,
2601
+ });
2602
+ });
2603
+ };
2604
+ tryHost(0);
2733
2605
  }
2734
2606
 
2735
2607
  function handleProxyWsOpenFromAdmin(msg) {
@@ -2865,7 +2737,6 @@ async function forgetLaunch(sandboxId) {
2865
2737
  if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
2866
2738
  }
2867
2739
  await closeRememberedTerminals(sandboxId);
2868
- await stopCloudflare(sandboxId);
2869
2740
  }
2870
2741
 
2871
2742
  function problemKey(sandboxId, code, role = "") {
@@ -3254,15 +3125,10 @@ async function ensureEmbeddedChat(ws) {
3254
3125
  if (inflight) return inflight;
3255
3126
  const task = (async () => {
3256
3127
  const cfg = bridgeCfg || loadConfig();
3257
- const jobs = jobsFromHostApps(ws);
3258
- const shareEnv = {
3259
- ...envForWorkspacePorts(ws, jobs),
3260
- ...uiPublicEnv({}, ws),
3261
- };
3262
3128
  log(`lazy-start in-process chat for ${ws.sandboxName || shortId(ws.sandboxId)}`);
3263
3129
  const result = await startAiServerForWorkspace(ws, {
3264
3130
  cfg,
3265
- env: shareEnv,
3131
+ env: chatLaunchEnv(ws, cfg),
3266
3132
  forceEmbed: true,
3267
3133
  });
3268
3134
  if (!result.up) return null;
@@ -3343,14 +3209,6 @@ async function startAiServerForWorkspace(ws, opts = {}) {
3343
3209
  const localAi = `http://localhost:${port}`;
3344
3210
  const overrideEnv =
3345
3211
  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
3212
  persistWorkspaceEntry(cfg, ws);
3355
3213
 
3356
3214
  const storeEnv = await loadSandboxStoreEnv(ws, cfg);
@@ -3360,7 +3218,7 @@ async function startAiServerForWorkspace(ws, opts = {}) {
3360
3218
  ...folderEnv,
3361
3219
  ...overrideEnv,
3362
3220
  ...storeEnv,
3363
- AI_SERVER_URL: aiUrl,
3221
+ AI_SERVER_URL: localAi,
3364
3222
  CORS_ORIGIN: overrideEnv.CORS_ORIGIN || folderEnv.CORS_ORIGIN || "",
3365
3223
  CORS_ORIGINS: overrideEnv.CORS_ORIGINS || folderEnv.CORS_ORIGINS || "",
3366
3224
  MAINTAINER_PRO_DATA_DIR: dataDir,
@@ -3403,37 +3261,6 @@ function sleep(ms) {
3403
3261
  return new Promise((resolve) => setTimeout(resolve, ms));
3404
3262
  }
3405
3263
 
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
3264
  function killPort(port) {
3438
3265
  const n = Number(port);
3439
3266
  if (!n) return Promise.resolve();
@@ -3465,29 +3292,6 @@ function killPort(port) {
3465
3292
  });
3466
3293
  }
3467
3294
 
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
3295
  function forgetProcessLaunches(sandboxId) {
3492
3296
  for (const key of [...launchedAt.keys()]) {
3493
3297
  if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
@@ -3545,231 +3349,6 @@ async function waitUntilReachable(url, timeoutMs, label, onWait) {
3545
3349
  throw new Error(`${label} did not become reachable at ${url}`);
3546
3350
  }
3547
3351
 
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
3352
  function reservedPortsFor(cfg, sandboxId) {
3774
3353
  const reserved = new Set();
3775
3354
  for (const other of cfg.workspaces || []) {
@@ -3784,17 +3363,12 @@ function appsWanted(ws) {
3784
3363
  return Boolean(ws?.appsRequested);
3785
3364
  }
3786
3365
 
3787
- async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
3366
+ async function configureShareForWorkspace(ws, cfg, opts = {}) {
3788
3367
  const sandboxId = ws.sandboxId;
3789
3368
  const progress = (message) =>
3790
3369
  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;
3370
+ await progress("Using Maintainer Pro share URLs.");
3371
+ stripDiscardedTunnelState(ws, cfg);
3798
3372
  persistWorkspaceEntry(cfg, ws);
3799
3373
  const status = await reconcileWorkspacePresence(ws, cfg, {
3800
3374
  timeoutMs: 2500,
@@ -3808,12 +3382,10 @@ async function configureCloudflareForWorkspace(ws, cfg, opts = {}) {
3808
3382
  folderPath: ws.folderPath,
3809
3383
  port: ws.port,
3810
3384
  pending: false,
3811
- cloudflarePending: false,
3812
3385
  waitingForStart: false,
3813
3386
  appUrl: status.host.appUrl || share,
3814
3387
  origins: status.host.origins,
3815
3388
  publicUrl: share || null,
3816
- cloudflare: false,
3817
3389
  };
3818
3390
  }
3819
3391
 
@@ -3825,12 +3397,7 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3825
3397
  `Checking running apps for ${label}…`
3826
3398
  );
3827
3399
 
3828
- try {
3829
- await stopCloudflare(ws.sandboxId, ws.folderPath);
3830
- } catch {
3831
- /* leftover cloudflared */
3832
- }
3833
- ws.cloudflarePending = false;
3400
+ stripDiscardedTunnelState(ws, cfg);
3834
3401
 
3835
3402
  let status = await reconcileWorkspacePresence(ws, cfg, {
3836
3403
  timeoutMs: 2500,
@@ -3858,11 +3425,7 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3858
3425
  timeoutMs: 800,
3859
3426
  });
3860
3427
 
3861
- const shareEnv = {
3862
- ...envForWorkspacePorts(ws, plan.jobs),
3863
- ...uiPublicEnv({}, ws),
3864
- ...envFromConfiguredMaps(ws),
3865
- };
3428
+ const chatEnv = chatLaunchEnv(ws, cfg);
3866
3429
 
3867
3430
  if (!cfg.noAiServer) {
3868
3431
  if (status.probe.chatUp) {
@@ -3875,7 +3438,7 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3875
3438
  reserved,
3876
3439
  cfg,
3877
3440
  port: plan.aiPort,
3878
- env: shareEnv,
3441
+ env: chatEnv,
3879
3442
  });
3880
3443
  await sleep(1500);
3881
3444
  }
@@ -3890,14 +3453,9 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3890
3453
  const probed = status.probe.hosts.find((h) => h.role === job.role);
3891
3454
  return probed?.up ? { ...job, up: true, port: probed.port } : job;
3892
3455
  }),
3893
- extraEnv: shareEnv,
3894
3456
  });
3895
3457
  await sleep(800);
3896
3458
 
3897
- await applyPublicUrlsToRunningApps(ws, cfg, {
3898
- onProgress: (message) => reportActionProgress(cfg, opts.actionId, message),
3899
- });
3900
-
3901
3459
  status = await reconcileWorkspacePresence(ws, cfg, {
3902
3460
  timeoutMs: 2500,
3903
3461
  });
@@ -3929,7 +3487,6 @@ async function startAppsForWorkspace(ws, cfg, opts = {}) {
3929
3487
  ws,
3930
3488
  hostAppOf(ws) || { id: "ui", role: "ui", host: true }
3931
3489
  ) || null,
3932
- cloudflare: false,
3933
3490
  hostApps: status.probe.hostApps || ws.hostApps || [],
3934
3491
  processIssues,
3935
3492
  warning,
@@ -3975,9 +3532,6 @@ async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
3975
3532
  await progress(`Starting ${app.name} on port ${app.port}…`);
3976
3533
  ws.appsRequested = true;
3977
3534
  persistWorkspaceEntry(cfg, ws);
3978
- const publicEnv = {
3979
- ...cloudflarePublicEnv(ws),
3980
- };
3981
3535
  if (app.role === "ai-server") {
3982
3536
  const reserved = reservedPortsFor(cfg, ws.sandboxId);
3983
3537
  const chat = await discoverChatPort(ws, 1200);
@@ -3989,7 +3543,7 @@ async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
3989
3543
  reserved,
3990
3544
  cfg,
3991
3545
  port: Number(app.port) || ws.port || 3100,
3992
- env: publicEnv,
3546
+ env: chatLaunchEnv(ws, cfg),
3993
3547
  });
3994
3548
  }
3995
3549
  } else {
@@ -3998,7 +3552,6 @@ async function startSingleApp(ws, cfg, payload = {}, opts = {}) {
3998
3552
  reserved,
3999
3553
  cfg,
4000
3554
  onlyRoles: [app.role === "custom" ? "app" : app.role],
4001
- extraEnv: publicEnv,
4002
3555
  force: true,
4003
3556
  plannedJobs: jobsFromHostApps(ws),
4004
3557
  });
@@ -4068,7 +3621,7 @@ async function restartSingleApp(ws, cfg, payload = {}, opts = {}) {
4068
3621
  return startSingleApp(ws, cfg, payload, opts);
4069
3622
  }
4070
3623
 
4071
- async function startCloudflareForApp(ws, cfg, payload = {}, opts = {}) {
3624
+ async function startShareForApp(ws, cfg, payload = {}, opts = {}) {
4072
3625
  const app = findHostApp(ws, payload);
4073
3626
  if (!app) {
4074
3627
  activity(ws.sandboxId, "error", "Share URL failed: unknown app/port");
@@ -4086,9 +3639,6 @@ async function startCloudflareForApp(ws, cfg, payload = {}, opts = {}) {
4086
3639
  await progress(`Using the Maintainer Pro share URL for ${app.name}…`);
4087
3640
  const started = await startSingleApp(ws, cfg, { appId: app.id }, opts);
4088
3641
  if (started.error) return started;
4089
- const applied = await applyPublicUrlsToRunningApps(ws, cfg, {
4090
- onProgress: progress,
4091
- });
4092
3642
  const status = await reconcileWorkspacePresence(ws, cfg, {
4093
3643
  timeoutMs: 2500,
4094
3644
  });
@@ -4098,12 +3648,9 @@ async function startCloudflareForApp(ws, cfg, payload = {}, opts = {}) {
4098
3648
  running: Boolean(started.running),
4099
3649
  port: started.port,
4100
3650
  publicUrl: shareUrl,
4101
- cloudflareUrl: null,
4102
3651
  appUrl: status.host.appUrl,
4103
3652
  origins: status.host.origins,
4104
3653
  hostApps: status.probe.hostApps || [],
4105
- rewritten: applied.rewritten,
4106
- restarted: applied.restarted,
4107
3654
  log: activityLogFor(ws.sandboxId),
4108
3655
  };
4109
3656
  }
@@ -4158,12 +3705,10 @@ async function ensureHostProcesses(ws, opts = {}) {
4158
3705
  for (const job of jobs) {
4159
3706
  if (onlyRoles && !onlyRoles.has(job.role)) continue;
4160
3707
  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))) {
3708
+ const probe = job.port
3709
+ ? `http://127.0.0.1:${job.port}`
3710
+ : job.probeUrl || `http://127.0.0.1:${preferred}`;
3711
+ if (job.up || (await probeLoopbackUrl(probe))) {
4167
3712
  reserved.add(portFromText(probe, preferred));
4168
3713
  clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
4169
3714
  clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
@@ -4265,11 +3810,8 @@ async function inspectHostJobs(ws) {
4265
3810
  const hosts = [];
4266
3811
  for (const job of jobs) {
4267
3812
  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);
3813
+ const probe = job.probeUrl || `http://127.0.0.1:${preferred}`;
3814
+ const up = await probeLoopbackUrl(probe);
4273
3815
  const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
4274
3816
  const starting = recentlyLaunched(launchKey);
4275
3817
  hosts.push({
@@ -4391,7 +3933,6 @@ async function setupWorkspace(cfg, action) {
4391
3933
  appUrl,
4392
3934
  sameOrigin: Boolean(client.sameOrigin),
4393
3935
  appsRequested: false,
4394
- cloudflarePending: false,
4395
3936
  store: {
4396
3937
  serverKey: String(config.env?.MAINTAINER_PRO_API_KEY || "").trim(),
4397
3938
  clientKey: String(
@@ -4497,8 +4038,7 @@ async function proposeSetupForWorkspace(ws, cfg, opts = {}) {
4497
4038
  const previous = Array.isArray(ws.hostApps) ? ws.hostApps : [];
4498
4039
  const apps = (proposal.apps || []).map((app) => {
4499
4040
  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 || [] };
4041
+ return match ? { ...app, host: match.host === true || app.host === true } : app;
4502
4042
  });
4503
4043
  // Keep last known apps on the workspace; do not auto-apply proposal.
4504
4044
  if (!Array.isArray(ws.hostApps) || !ws.hostApps.length) {
@@ -4565,6 +4105,7 @@ async function runActions(cfg, actions) {
4565
4105
  if (!actions.length) return;
4566
4106
  log(`actions received ${actions.length}: ${actions.map((a) => a.code).join(", ")}`);
4567
4107
  for (const action of actions) {
4108
+ action.code = canonicalActionCode(action.code);
4568
4109
  const startedAt = Date.now();
4569
4110
  const label = actionLabel(action);
4570
4111
  log(`${label} start`);
@@ -4693,14 +4234,14 @@ async function runActions(cfg, actions) {
4693
4234
  });
4694
4235
  if (result.error) ok = false;
4695
4236
  }
4696
- } else if (action.code === "start_cloudflare_app") {
4237
+ } else if (action.code === "start_share_app") {
4697
4238
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
4698
4239
  const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
4699
4240
  if (!ws) {
4700
4241
  ok = false;
4701
4242
  result = { error: "No folder is attached for this sandbox" };
4702
4243
  } else {
4703
- result = await startCloudflareForApp(ws, cfg, action.payload || {}, {
4244
+ result = await startShareForApp(ws, cfg, action.payload || {}, {
4704
4245
  actionId: action.id,
4705
4246
  });
4706
4247
  if (result.error) ok = false;
@@ -4805,7 +4346,6 @@ async function runActions(cfg, actions) {
4805
4346
  sandboxId: sandboxId || ws?.sandboxId || null,
4806
4347
  appUrl:
4807
4348
  host.appUrl ||
4808
- ws?.cloudflareUrl ||
4809
4349
  ws?.appUrl ||
4810
4350
  process.env.APP_URL ||
4811
4351
  process.env.PUBLIC_URL ||
@@ -4817,7 +4357,7 @@ async function runActions(cfg, actions) {
4817
4357
  host.origins.join(",") || "none"
4818
4358
  }`
4819
4359
  );
4820
- } else if (action.code === "configure_cloudflare") {
4360
+ } else if (action.code === "configure_share") {
4821
4361
  const sandboxId = action.sandboxId || action.payload?.sandboxId;
4822
4362
  const ws =
4823
4363
  (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
@@ -4838,7 +4378,7 @@ async function runActions(cfg, actions) {
4838
4378
  result = { error: "No folder is attached for this sandbox" };
4839
4379
  warn(`${label} skipped: ${result.error}`);
4840
4380
  } else {
4841
- result = await configureCloudflareForWorkspace(ws, cfg, {
4381
+ result = await configureShareForWorkspace(ws, cfg, {
4842
4382
  actionId: action.id,
4843
4383
  });
4844
4384
  }
@@ -5643,7 +5183,6 @@ async function main() {
5643
5183
  clearInterval(watchdogTimer);
5644
5184
  watchdogTimer = null;
5645
5185
  }
5646
- stopAllCloudflare();
5647
5186
  dropSocket(socket);
5648
5187
  socket = null;
5649
5188
  log("shutting down (other terminals stay open)");