@wrongstack/webui 0.273.1 → 0.274.0
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/dist/assets/index-CwMm5VgW.js +140 -0
- package/dist/assets/index-DqD59GFW.css +2 -0
- package/dist/assets/{vendor-P9eRrO6V.js → vendor-CzID01pz.js} +254 -254
- package/dist/index.html +3 -3
- package/dist/index.js +2752 -2278
- package/dist/index.js.map +1 -1
- package/dist/server/entry.js +404 -78
- package/dist/server/entry.js.map +1 -1
- package/dist/server/index.d.ts +85 -15
- package/dist/server/index.js +326 -73
- package/dist/server/index.js.map +1 -1
- package/dist/types.d.ts +14 -1
- package/package.json +7 -7
- package/dist/assets/index-BGzM4-Zu.css +0 -2
- package/dist/assets/index-D0dNaLPf.js +0 -140
package/dist/server/entry.js
CHANGED
|
@@ -1167,7 +1167,7 @@ function isTrustedLoopbackOrigin(origin) {
|
|
|
1167
1167
|
try {
|
|
1168
1168
|
const url = new URL(origin);
|
|
1169
1169
|
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
1170
|
-
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
1170
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
|
|
1171
1171
|
} catch {
|
|
1172
1172
|
return false;
|
|
1173
1173
|
}
|
|
@@ -1178,6 +1178,14 @@ function isLoopbackBind(wsHost) {
|
|
|
1178
1178
|
function isWildcardBind(wsHost) {
|
|
1179
1179
|
return wsHost === "0.0.0.0" || wsHost === "::" || wsHost === "[::]";
|
|
1180
1180
|
}
|
|
1181
|
+
function normalizeHostname(hostname) {
|
|
1182
|
+
const h = hostname.trim().toLowerCase();
|
|
1183
|
+
return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
|
|
1184
|
+
}
|
|
1185
|
+
function allowedHostname(hostname, allowedHostnames) {
|
|
1186
|
+
const normalized = normalizeHostname(hostname);
|
|
1187
|
+
return (allowedHostnames ?? []).some((candidate) => normalizeHostname(candidate) === normalized);
|
|
1188
|
+
}
|
|
1181
1189
|
function tokenMatches(provided, expected) {
|
|
1182
1190
|
if (!provided) return false;
|
|
1183
1191
|
const a = Buffer.from(provided);
|
|
@@ -1216,28 +1224,37 @@ function hostHeaderOk(input) {
|
|
|
1216
1224
|
} catch {
|
|
1217
1225
|
return false;
|
|
1218
1226
|
}
|
|
1219
|
-
return isLoopbackHostname(hostname);
|
|
1227
|
+
return isLoopbackHostname(hostname) || allowedHostname(hostname, input.allowedHostnames);
|
|
1220
1228
|
}
|
|
1221
1229
|
function verifyClient(input) {
|
|
1222
|
-
const {
|
|
1230
|
+
const {
|
|
1231
|
+
origin,
|
|
1232
|
+
url,
|
|
1233
|
+
hostHeader,
|
|
1234
|
+
remoteAddress,
|
|
1235
|
+
cookieHeader,
|
|
1236
|
+
wsHost,
|
|
1237
|
+
expectedToken,
|
|
1238
|
+
requireToken,
|
|
1239
|
+
allowedHostnames,
|
|
1240
|
+
allowBrowserUrlToken
|
|
1241
|
+
} = input;
|
|
1223
1242
|
const urlTokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
|
|
1224
1243
|
const cookieTokenOk = tokenMatches(extractTokenFromCookie(cookieHeader), expectedToken);
|
|
1225
|
-
if (!hostHeaderOk({ hostHeader, wsHost })) return false;
|
|
1244
|
+
if (!hostHeaderOk({ hostHeader, wsHost, allowedHostnames })) return false;
|
|
1226
1245
|
if (!origin) {
|
|
1227
1246
|
const remoteIp = remoteAddress ?? "";
|
|
1228
1247
|
const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
|
|
1229
1248
|
if (!isRemoteLoopback && isWildcardBind(wsHost)) return false;
|
|
1230
|
-
return urlTokenOk || cookieTokenOk || isLoopbackBind(wsHost);
|
|
1249
|
+
return urlTokenOk || cookieTokenOk || isLoopbackBind(wsHost) && !requireToken;
|
|
1231
1250
|
}
|
|
1232
1251
|
try {
|
|
1233
|
-
const { hostname } = new URL(origin);
|
|
1234
|
-
if (isLoopbackHostname(
|
|
1235
|
-
if (
|
|
1236
|
-
|
|
1237
|
-
}
|
|
1238
|
-
return true;
|
|
1252
|
+
const { hostname: originHostname } = new URL(origin);
|
|
1253
|
+
if (isLoopbackHostname(originHostname)) {
|
|
1254
|
+
if (requireToken || !isLoopbackBind(wsHost)) return cookieTokenOk;
|
|
1255
|
+
return isTrustedLoopbackOrigin(origin);
|
|
1239
1256
|
}
|
|
1240
|
-
return cookieTokenOk;
|
|
1257
|
+
return cookieTokenOk || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
|
|
1241
1258
|
} catch {
|
|
1242
1259
|
return false;
|
|
1243
1260
|
}
|
|
@@ -1263,8 +1280,69 @@ function injectWsPort(html, wsPort) {
|
|
|
1263
1280
|
return `${tag}
|
|
1264
1281
|
${html}`;
|
|
1265
1282
|
}
|
|
1266
|
-
function
|
|
1267
|
-
return
|
|
1283
|
+
function escapeHtmlAttr(value) {
|
|
1284
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
1285
|
+
}
|
|
1286
|
+
function injectWsConfig(html, opts) {
|
|
1287
|
+
let out = injectWsPort(html, opts.wsPort);
|
|
1288
|
+
if (!opts.publicWsUrl || out.includes('name="wrongstack-ws-url"')) return out;
|
|
1289
|
+
const tag = `<meta name="wrongstack-ws-url" content="${escapeHtmlAttr(opts.publicWsUrl)}" />`;
|
|
1290
|
+
if (out.includes("</head>")) {
|
|
1291
|
+
return out.replace("</head>", ` ${tag}
|
|
1292
|
+
</head>`);
|
|
1293
|
+
}
|
|
1294
|
+
return `${tag}
|
|
1295
|
+
${out}`;
|
|
1296
|
+
}
|
|
1297
|
+
function firstHeader(value) {
|
|
1298
|
+
return Array.isArray(value) ? value[0] : value;
|
|
1299
|
+
}
|
|
1300
|
+
function wsTokenCookie(token) {
|
|
1301
|
+
return `ws_token=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`;
|
|
1302
|
+
}
|
|
1303
|
+
function requestToken(req, url) {
|
|
1304
|
+
return url.searchParams.get("token") ?? firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
|
|
1305
|
+
}
|
|
1306
|
+
function requestHostForCsp(hostHeader) {
|
|
1307
|
+
const raw = firstHeader(hostHeader)?.trim();
|
|
1308
|
+
if (!raw) return void 0;
|
|
1309
|
+
try {
|
|
1310
|
+
return new URL(`http://${raw}`).hostname;
|
|
1311
|
+
} catch {
|
|
1312
|
+
return void 0;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
function formatCspHostname(hostname) {
|
|
1316
|
+
return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
|
|
1317
|
+
}
|
|
1318
|
+
function cspSourceFromUrl(rawUrl) {
|
|
1319
|
+
try {
|
|
1320
|
+
const url = new URL(rawUrl);
|
|
1321
|
+
if (url.protocol !== "ws:" && url.protocol !== "wss:") return void 0;
|
|
1322
|
+
return `${url.protocol}//${formatCspHostname(url.hostname)}${url.port ? `:${url.port}` : ""}`;
|
|
1323
|
+
} catch {
|
|
1324
|
+
return void 0;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
var ALLOWED_INLINE_SCRIPT_HASHES = [
|
|
1328
|
+
"'sha256-6PXDy0zrpXa6mvYOl11bZ8nubNUL7ushPUhGDZtaexg='",
|
|
1329
|
+
"'sha256-6sIdwbEBx7jj0drqSHHm7MqvmoYD3CQ4lp8Zp8blcb0='"
|
|
1330
|
+
];
|
|
1331
|
+
function buildCspHeader(wsPort, requestHost, publicWsUrl) {
|
|
1332
|
+
const connect = /* @__PURE__ */ new Set([
|
|
1333
|
+
"'self'",
|
|
1334
|
+
`ws://127.0.0.1:${wsPort}`,
|
|
1335
|
+
`wss://127.0.0.1:${wsPort}`
|
|
1336
|
+
]);
|
|
1337
|
+
if (requestHost && requestHost !== "127.0.0.1") {
|
|
1338
|
+
const host = formatCspHostname(requestHost);
|
|
1339
|
+
connect.add(`ws://${host}:${wsPort}`);
|
|
1340
|
+
connect.add(`wss://${host}:${wsPort}`);
|
|
1341
|
+
}
|
|
1342
|
+
const publicWsSource = publicWsUrl ? cspSourceFromUrl(publicWsUrl) : void 0;
|
|
1343
|
+
if (publicWsSource) connect.add(publicWsSource);
|
|
1344
|
+
const scriptSrc = ["'self'", ...ALLOWED_INLINE_SCRIPT_HASHES].join(" ");
|
|
1345
|
+
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; connect-src ${Array.from(connect).join(" ")}; img-src 'self' data:; font-src 'self' data:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'`;
|
|
1268
1346
|
}
|
|
1269
1347
|
function isInsideDist(candidate, distDir) {
|
|
1270
1348
|
const root = path.resolve(distDir);
|
|
@@ -1282,12 +1360,15 @@ function createHttpServer(opts) {
|
|
|
1282
1360
|
const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
|
|
1283
1361
|
const distDir = path.resolve(opts.distDir);
|
|
1284
1362
|
const wsPort = opts.wsPort;
|
|
1285
|
-
const
|
|
1363
|
+
const requireAccessToken = Boolean(opts.requireToken) || !isLoopbackBind(opts.host);
|
|
1286
1364
|
return http.createServer(async (req, res) => {
|
|
1287
1365
|
try {
|
|
1288
1366
|
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
1367
|
+
const providedAccessToken = requestToken(req, url);
|
|
1368
|
+
const accessTokenOk = Boolean(opts.apiToken) && tokenMatches(providedAccessToken, opts.apiToken ?? "");
|
|
1369
|
+
const shouldSetAuthCookie = Boolean(opts.apiToken) && tokenMatches(url.searchParams.get("token") ?? void 0, opts.apiToken ?? "");
|
|
1289
1370
|
if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
|
|
1290
|
-
const provided = url
|
|
1371
|
+
const provided = requestToken(req, url);
|
|
1291
1372
|
if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
|
|
1292
1373
|
res.writeHead(401, { "Content-Type": "text/plain" });
|
|
1293
1374
|
res.end("Unauthorized");
|
|
@@ -1295,7 +1376,7 @@ function createHttpServer(opts) {
|
|
|
1295
1376
|
}
|
|
1296
1377
|
res.writeHead(200, {
|
|
1297
1378
|
"Content-Type": "text/plain",
|
|
1298
|
-
"Set-Cookie":
|
|
1379
|
+
"Set-Cookie": wsTokenCookie(opts.apiToken),
|
|
1299
1380
|
// Belt-and-braces: tell any caches the cookie response itself
|
|
1300
1381
|
// is sensitive.
|
|
1301
1382
|
"Cache-Control": "no-store"
|
|
@@ -1303,10 +1384,20 @@ function createHttpServer(opts) {
|
|
|
1303
1384
|
res.end("ok");
|
|
1304
1385
|
return;
|
|
1305
1386
|
}
|
|
1387
|
+
if (requireAccessToken && !accessTokenOk) {
|
|
1388
|
+
res.writeHead(401, {
|
|
1389
|
+
"Content-Type": "text/plain",
|
|
1390
|
+
"Cache-Control": "no-store"
|
|
1391
|
+
});
|
|
1392
|
+
res.end("Unauthorized");
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
if (shouldSetAuthCookie && opts.apiToken) {
|
|
1396
|
+
res.setHeader("Set-Cookie", wsTokenCookie(opts.apiToken));
|
|
1397
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1398
|
+
}
|
|
1306
1399
|
if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
|
|
1307
|
-
|
|
1308
|
-
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
1309
|
-
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
1400
|
+
if (requireAccessToken && !accessTokenOk) {
|
|
1310
1401
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1311
1402
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1312
1403
|
return;
|
|
@@ -1320,9 +1411,7 @@ function createHttpServer(opts) {
|
|
|
1320
1411
|
return;
|
|
1321
1412
|
}
|
|
1322
1413
|
if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
1323
|
-
|
|
1324
|
-
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
1325
|
-
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
1414
|
+
if (requireAccessToken && !accessTokenOk) {
|
|
1326
1415
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1327
1416
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1328
1417
|
return;
|
|
@@ -1332,9 +1421,7 @@ function createHttpServer(opts) {
|
|
|
1332
1421
|
}
|
|
1333
1422
|
const agentsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/agents$/);
|
|
1334
1423
|
if (agentsMatch && req.method === "GET") {
|
|
1335
|
-
|
|
1336
|
-
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
1337
|
-
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
1424
|
+
if (requireAccessToken && !accessTokenOk) {
|
|
1338
1425
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1339
1426
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1340
1427
|
return;
|
|
@@ -1344,9 +1431,7 @@ function createHttpServer(opts) {
|
|
|
1344
1431
|
}
|
|
1345
1432
|
const eventsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
|
|
1346
1433
|
if (eventsMatch && req.method === "GET") {
|
|
1347
|
-
|
|
1348
|
-
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
1349
|
-
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
1434
|
+
if (requireAccessToken && !accessTokenOk) {
|
|
1350
1435
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1351
1436
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1352
1437
|
return;
|
|
@@ -1358,9 +1443,7 @@ function createHttpServer(opts) {
|
|
|
1358
1443
|
}
|
|
1359
1444
|
const msgMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/message$/);
|
|
1360
1445
|
if (msgMatch && req.method === "POST") {
|
|
1361
|
-
|
|
1362
|
-
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
1363
|
-
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
1446
|
+
if (requireAccessToken && !accessTokenOk) {
|
|
1364
1447
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1365
1448
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1366
1449
|
return;
|
|
@@ -1370,9 +1453,7 @@ function createHttpServer(opts) {
|
|
|
1370
1453
|
}
|
|
1371
1454
|
const mailboxMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/mailbox$/);
|
|
1372
1455
|
if (mailboxMatch && req.method === "GET") {
|
|
1373
|
-
|
|
1374
|
-
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
1375
|
-
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
1456
|
+
if (requireAccessToken && !accessTokenOk) {
|
|
1376
1457
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1377
1458
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1378
1459
|
return;
|
|
@@ -1382,9 +1463,7 @@ function createHttpServer(opts) {
|
|
|
1382
1463
|
}
|
|
1383
1464
|
const interruptMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/interrupt$/);
|
|
1384
1465
|
if (interruptMatch && req.method === "POST") {
|
|
1385
|
-
|
|
1386
|
-
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
1387
|
-
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
1466
|
+
if (requireAccessToken && !accessTokenOk) {
|
|
1388
1467
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1389
1468
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1390
1469
|
return;
|
|
@@ -1398,9 +1477,7 @@ function createHttpServer(opts) {
|
|
|
1398
1477
|
return;
|
|
1399
1478
|
}
|
|
1400
1479
|
if (url.pathname === "/api/fleet/broadcast" && req.method === "POST") {
|
|
1401
|
-
|
|
1402
|
-
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
1403
|
-
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
1480
|
+
if (requireAccessToken && !accessTokenOk) {
|
|
1404
1481
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1405
1482
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1406
1483
|
return;
|
|
@@ -1447,11 +1524,14 @@ function createHttpServer(opts) {
|
|
|
1447
1524
|
res.setHeader("X-Frame-Options", "DENY");
|
|
1448
1525
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
1449
1526
|
if (ext === ".html") {
|
|
1450
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
1451
|
-
res.setHeader(
|
|
1527
|
+
if (!shouldSetAuthCookie) res.setHeader("Cache-Control", "no-cache");
|
|
1528
|
+
res.setHeader(
|
|
1529
|
+
"Content-Security-Policy",
|
|
1530
|
+
buildCspHeader(wsPort, requestHostForCsp(req.headers.host), opts.publicWsUrl)
|
|
1531
|
+
);
|
|
1452
1532
|
const html = await fs.readFile(resolvedPath, "utf8");
|
|
1453
1533
|
res.writeHead(200);
|
|
1454
|
-
res.end(
|
|
1534
|
+
res.end(injectWsConfig(html, { wsPort, publicWsUrl: opts.publicWsUrl }));
|
|
1455
1535
|
return;
|
|
1456
1536
|
}
|
|
1457
1537
|
const fileContent = await fs.readFile(resolvedPath);
|
|
@@ -1466,9 +1546,13 @@ function createHttpServer(opts) {
|
|
|
1466
1546
|
"X-Content-Type-Options": "nosniff",
|
|
1467
1547
|
"X-Frame-Options": "DENY",
|
|
1468
1548
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
1469
|
-
"Content-Security-Policy": buildCspHeader(
|
|
1549
|
+
"Content-Security-Policy": buildCspHeader(
|
|
1550
|
+
wsPort,
|
|
1551
|
+
requestHostForCsp(req.headers.host),
|
|
1552
|
+
opts.publicWsUrl
|
|
1553
|
+
)
|
|
1470
1554
|
});
|
|
1471
|
-
res.end(
|
|
1555
|
+
res.end(injectWsConfig(html, { wsPort, publicWsUrl: opts.publicWsUrl }));
|
|
1472
1556
|
} catch {
|
|
1473
1557
|
res.writeHead(404);
|
|
1474
1558
|
res.end("Not found");
|
|
@@ -1700,6 +1784,37 @@ function errMessage(err) {
|
|
|
1700
1784
|
function generateAuthToken() {
|
|
1701
1785
|
return randomBytes(16).toString("hex");
|
|
1702
1786
|
}
|
|
1787
|
+
function resolveAuthToken(explicit) {
|
|
1788
|
+
const configured = explicit?.trim() || process.env["WEBUI_TOKEN"]?.trim() || process.env["WEBUI_AUTH_TOKEN"]?.trim();
|
|
1789
|
+
return configured || generateAuthToken();
|
|
1790
|
+
}
|
|
1791
|
+
function hostForBrowserUrl(bindHost) {
|
|
1792
|
+
if (bindHost === "0.0.0.0") return "127.0.0.1";
|
|
1793
|
+
if (bindHost === "::" || bindHost === "[::]") return "[::1]";
|
|
1794
|
+
if (bindHost.includes(":") && !bindHost.startsWith("[")) return `[${bindHost}]`;
|
|
1795
|
+
return bindHost;
|
|
1796
|
+
}
|
|
1797
|
+
function buildWebUIAccessUrl(opts) {
|
|
1798
|
+
const protocol = opts.protocol ?? "http";
|
|
1799
|
+
const base = opts.publicUrl?.trim() || `${protocol}://${hostForBrowserUrl(opts.host)}:${opts.port}`;
|
|
1800
|
+
if (!opts.token) return base;
|
|
1801
|
+
try {
|
|
1802
|
+
const url = new URL(base);
|
|
1803
|
+
url.searchParams.set("token", opts.token);
|
|
1804
|
+
const rendered = url.toString();
|
|
1805
|
+
const afterOrigin = base.slice(url.origin.length);
|
|
1806
|
+
if (url.pathname === "/" && !afterOrigin.startsWith("/")) {
|
|
1807
|
+
return `${url.origin}${url.search}${url.hash}`;
|
|
1808
|
+
}
|
|
1809
|
+
return rendered;
|
|
1810
|
+
} catch {
|
|
1811
|
+
return `${base}${base.includes("?") ? "&" : "?"}token=${encodeURIComponent(opts.token)}`;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
function envFlag(name2) {
|
|
1815
|
+
const value = process.env[name2]?.trim().toLowerCase();
|
|
1816
|
+
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
1817
|
+
}
|
|
1703
1818
|
|
|
1704
1819
|
// src/server/file-handlers.ts
|
|
1705
1820
|
async function resolveFileInsideProject(projectRoot, filePath) {
|
|
@@ -3031,6 +3146,13 @@ import {
|
|
|
3031
3146
|
PhaseStore,
|
|
3032
3147
|
WorktreeManager
|
|
3033
3148
|
} from "@wrongstack/core";
|
|
3149
|
+
function deriveTitle(goal) {
|
|
3150
|
+
const firstLine = goal.split("\n").map((l) => l.trim()).find(Boolean);
|
|
3151
|
+
if (!firstLine) return "AutoPhase";
|
|
3152
|
+
const sentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
|
|
3153
|
+
const trimmed = sentence.length <= 64 ? sentence : `${sentence.slice(0, 63).trimEnd()}\u2026`;
|
|
3154
|
+
return trimmed || "AutoPhase";
|
|
3155
|
+
}
|
|
3034
3156
|
function isGitRepo(cwd) {
|
|
3035
3157
|
try {
|
|
3036
3158
|
const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, encoding: "utf8", windowsHide: true });
|
|
@@ -3039,6 +3161,19 @@ function isGitRepo(cwd) {
|
|
|
3039
3161
|
return false;
|
|
3040
3162
|
}
|
|
3041
3163
|
}
|
|
3164
|
+
function commitsSince(cwd, baseSha, branch) {
|
|
3165
|
+
try {
|
|
3166
|
+
const r = spawnSync("git", ["log", "--reverse", "--format=%H", `${baseSha}..${branch}`], {
|
|
3167
|
+
cwd,
|
|
3168
|
+
encoding: "utf8",
|
|
3169
|
+
windowsHide: true
|
|
3170
|
+
});
|
|
3171
|
+
if (r.status !== 0) return [];
|
|
3172
|
+
return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
3173
|
+
} catch {
|
|
3174
|
+
return [];
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3042
3177
|
var AutoPhaseWebSocketHandler = class {
|
|
3043
3178
|
constructor(agent, context, logger, storeDir, events, projectRoot) {
|
|
3044
3179
|
this.agent = agent;
|
|
@@ -3058,10 +3193,17 @@ var AutoPhaseWebSocketHandler = class {
|
|
|
3058
3193
|
store;
|
|
3059
3194
|
clients = /* @__PURE__ */ new Set();
|
|
3060
3195
|
broadcastInterval = null;
|
|
3061
|
-
/** Aborts in-flight task agents when the run is stopped. */
|
|
3196
|
+
/** Aborts in-flight task agents AND the planning turn when the run is stopped. */
|
|
3062
3197
|
abort = null;
|
|
3198
|
+
/** Set the instant a stop/clear/revert is requested, so a planning turn that
|
|
3199
|
+
* resolves afterwards never launches the orchestrator (the abort alone can't
|
|
3200
|
+
* cover the window between the LLM call resolving and the orchestrator start). */
|
|
3201
|
+
stopping = false;
|
|
3063
3202
|
/** Optional per-phase git-worktree isolation (lazily created at start). */
|
|
3064
3203
|
worktrees = null;
|
|
3204
|
+
/** Base branch + tip SHA captured at run start so a revert can git-revert the
|
|
3205
|
+
* run's squash commits (history-preserving) instead of a destructive reset. */
|
|
3206
|
+
runBase = null;
|
|
3065
3207
|
/** Per-run worker identities so the board can show "who is on what". */
|
|
3066
3208
|
usedNicknames = /* @__PURE__ */ new Set();
|
|
3067
3209
|
addClient(ws) {
|
|
@@ -3085,11 +3227,13 @@ var AutoPhaseWebSocketHandler = class {
|
|
|
3085
3227
|
this.broadcast({ type: "autophase.resumed", payload: {} });
|
|
3086
3228
|
break;
|
|
3087
3229
|
case "autophase.stop":
|
|
3088
|
-
this.
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3230
|
+
await this.handleStop();
|
|
3231
|
+
break;
|
|
3232
|
+
case "autophase.clear":
|
|
3233
|
+
await this.handleClear();
|
|
3234
|
+
break;
|
|
3235
|
+
case "autophase.revert":
|
|
3236
|
+
await this.handleRevert();
|
|
3093
3237
|
break;
|
|
3094
3238
|
case "autophase.status":
|
|
3095
3239
|
this.broadcastState();
|
|
@@ -3166,17 +3310,27 @@ var AutoPhaseWebSocketHandler = class {
|
|
|
3166
3310
|
}
|
|
3167
3311
|
}
|
|
3168
3312
|
async handleStart(payload) {
|
|
3169
|
-
const
|
|
3313
|
+
const goal = payload?.goal || payload?.title || "Untitled Project";
|
|
3314
|
+
const title = deriveTitle(goal);
|
|
3170
3315
|
const autonomous = payload?.autonomous ?? true;
|
|
3171
|
-
|
|
3316
|
+
this.abort = new AbortController();
|
|
3317
|
+
this.stopping = false;
|
|
3318
|
+
const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(goal, this.abort.signal);
|
|
3319
|
+
if (this.stopping || this.abort.signal.aborted) {
|
|
3320
|
+
this.broadcast({ type: "autophase.stopped", payload: { title } });
|
|
3321
|
+
return;
|
|
3322
|
+
}
|
|
3172
3323
|
this.logger.info(`[AutoPhase] Starting: ${title}`);
|
|
3173
|
-
const graph = await new PhaseGraphBuilder({ title, phases, autonomous }).build();
|
|
3324
|
+
const graph = await new PhaseGraphBuilder({ title, description: goal, phases, autonomous }).build();
|
|
3174
3325
|
this.graph = graph;
|
|
3175
|
-
this.abort = new AbortController();
|
|
3176
3326
|
await this.store.save(graph);
|
|
3177
|
-
|
|
3327
|
+
const useWorktrees = payload?.worktrees ?? process.env["WRONGSTACK_AUTOPHASE_WORKTREES"] !== "0";
|
|
3328
|
+
if (!this.worktrees && this.events && this.projectRoot && useWorktrees && isGitRepo(this.projectRoot)) {
|
|
3178
3329
|
this.worktrees = new WorktreeManager({ projectRoot: this.projectRoot, events: this.events });
|
|
3179
3330
|
}
|
|
3331
|
+
if (this.worktrees) {
|
|
3332
|
+
this.runBase = await this.worktrees.currentBase();
|
|
3333
|
+
}
|
|
3180
3334
|
this.orchestrator = new PhaseOrchestrator({
|
|
3181
3335
|
graph,
|
|
3182
3336
|
ctx: {
|
|
@@ -3223,6 +3377,62 @@ var AutoPhaseWebSocketHandler = class {
|
|
|
3223
3377
|
this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
|
|
3224
3378
|
});
|
|
3225
3379
|
}
|
|
3380
|
+
/**
|
|
3381
|
+
* Halt the run NOW — at any phase. Sets `stopping` (so a planning turn that
|
|
3382
|
+
* resolves afterwards bails), aborts in-flight agents, stops the orchestrator
|
|
3383
|
+
* tick, and ends the live broadcast. The board is kept for review; use
|
|
3384
|
+
* `autophase.clear` to reset or `autophase.revert` to undo the changes.
|
|
3385
|
+
*/
|
|
3386
|
+
async handleStop() {
|
|
3387
|
+
this.stopping = true;
|
|
3388
|
+
this.abort?.abort();
|
|
3389
|
+
this.orchestrator?.stop();
|
|
3390
|
+
this.stopBroadcast();
|
|
3391
|
+
if (this.graph) await this.store.save(this.graph).catch(() => void 0);
|
|
3392
|
+
this.broadcast({ type: "autophase.stopped", payload: { title: this.graph?.title } });
|
|
3393
|
+
}
|
|
3394
|
+
/**
|
|
3395
|
+
* Stop + wipe: tear down phase worktrees and reset to an empty board so the UI
|
|
3396
|
+
* returns to the start screen ("new one"). Does NOT touch already-merged commits
|
|
3397
|
+
* on the base branch — that is `autophase.revert`.
|
|
3398
|
+
*/
|
|
3399
|
+
async handleClear() {
|
|
3400
|
+
await this.handleStop();
|
|
3401
|
+
if (this.worktrees) await this.worktrees.cleanupAllManaged().catch(() => void 0);
|
|
3402
|
+
this.orchestrator = null;
|
|
3403
|
+
this.graph = null;
|
|
3404
|
+
this.runBase = null;
|
|
3405
|
+
this.usedNicknames.clear();
|
|
3406
|
+
this.broadcast({ type: "autophase.cleared", payload: {} });
|
|
3407
|
+
this.broadcast({ type: "autophase.state", payload: this.buildState() });
|
|
3408
|
+
}
|
|
3409
|
+
/**
|
|
3410
|
+
* Stop + undo: remove phase worktrees, then history-preservingly `git revert`
|
|
3411
|
+
* every commit this run landed on the base branch (captured `runBase`..HEAD),
|
|
3412
|
+
* then reset to an empty board. Refuses (reports a reason) on a dirty tree or a
|
|
3413
|
+
* conflicting revert rather than leaving the tree half-reverted.
|
|
3414
|
+
*/
|
|
3415
|
+
async handleRevert() {
|
|
3416
|
+
await this.handleStop();
|
|
3417
|
+
if (!this.worktrees || !this.runBase || !this.projectRoot) {
|
|
3418
|
+
this.broadcast({
|
|
3419
|
+
type: "autophase.reverted",
|
|
3420
|
+
payload: { ok: false, reverted: 0, reason: "no git baseline was captured for this run" }
|
|
3421
|
+
});
|
|
3422
|
+
return;
|
|
3423
|
+
}
|
|
3424
|
+
await this.worktrees.cleanupAllManaged().catch(() => void 0);
|
|
3425
|
+
const shas = commitsSince(this.projectRoot, this.runBase.sha, this.runBase.branch);
|
|
3426
|
+
const res = await this.worktrees.revertCommits(this.runBase.branch, shas);
|
|
3427
|
+
this.broadcast({ type: "autophase.reverted", payload: res });
|
|
3428
|
+
if (res.ok) {
|
|
3429
|
+
this.orchestrator = null;
|
|
3430
|
+
this.graph = null;
|
|
3431
|
+
this.runBase = null;
|
|
3432
|
+
this.broadcast({ type: "autophase.cleared", payload: {} });
|
|
3433
|
+
this.broadcast({ type: "autophase.state", payload: this.buildState() });
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3226
3436
|
/** Generic fallback phases when the LLM planner produces nothing usable. */
|
|
3227
3437
|
defaultPhases() {
|
|
3228
3438
|
return [
|
|
@@ -3233,13 +3443,18 @@ var AutoPhaseWebSocketHandler = class {
|
|
|
3233
3443
|
{ name: "Deployment", description: "Deploy to production", priority: "medium", estimateHours: 2, parallelizable: false }
|
|
3234
3444
|
];
|
|
3235
3445
|
}
|
|
3236
|
-
/** Plan phases+todos for the goal via the LLM; fall back to defaults on failure.
|
|
3237
|
-
|
|
3446
|
+
/** Plan phases+todos for the goal via the LLM; fall back to defaults on failure.
|
|
3447
|
+
* The caller passes the run's abort signal so a stop during planning cancels
|
|
3448
|
+
* the LLM turn (the previous fresh, never-aborted controller made planning
|
|
3449
|
+
* uninterruptible). */
|
|
3450
|
+
async planPhases(goal, signal) {
|
|
3238
3451
|
try {
|
|
3239
3452
|
const planner = new AutoPhasePlanner({
|
|
3240
3453
|
goal,
|
|
3241
3454
|
runOnce: async (prompt) => {
|
|
3242
|
-
const result = await this.agent.run(prompt, {
|
|
3455
|
+
const result = await this.agent.run(prompt, {
|
|
3456
|
+
signal: signal ?? new AbortController().signal
|
|
3457
|
+
});
|
|
3243
3458
|
return result.status === "done" ? result.finalText ?? "" : "";
|
|
3244
3459
|
}
|
|
3245
3460
|
});
|
|
@@ -3374,6 +3589,10 @@ Type: ${task.type}`;
|
|
|
3374
3589
|
const lastError = lastFailed ? `${lastFailed.name}: ${lastFailed.metadata?.integrationError ?? "phase failed"}` : null;
|
|
3375
3590
|
return {
|
|
3376
3591
|
title: this.graph.title,
|
|
3592
|
+
// Full operator prompt, shown verbatim in a dedicated goal block (the
|
|
3593
|
+
// title is only a short derived heading). Fall back to the title for
|
|
3594
|
+
// legacy boards saved before the title/goal split.
|
|
3595
|
+
goal: this.graph.description || this.graph.title,
|
|
3377
3596
|
phases: phaseItems,
|
|
3378
3597
|
tasks: taskItems,
|
|
3379
3598
|
activePhaseId: currentActiveId,
|
|
@@ -3684,6 +3903,12 @@ var SddBoardWebSocketHandler = class {
|
|
|
3684
3903
|
};
|
|
3685
3904
|
|
|
3686
3905
|
// src/server/sdd-wizard-ws-handler.ts
|
|
3906
|
+
function deriveTitle2(goal) {
|
|
3907
|
+
const firstLine = goal.split("\n").map((l) => l.trim()).find(Boolean);
|
|
3908
|
+
if (!firstLine) return "New SDD Project";
|
|
3909
|
+
const sentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
|
|
3910
|
+
return sentence.length <= 64 ? sentence : `${sentence.slice(0, 63).trimEnd()}\u2026`;
|
|
3911
|
+
}
|
|
3687
3912
|
var SddWizardWebSocketHandler = class {
|
|
3688
3913
|
constructor(deps2) {
|
|
3689
3914
|
this.deps = deps2;
|
|
@@ -3722,7 +3947,8 @@ var SddWizardWebSocketHandler = class {
|
|
|
3722
3947
|
parallelSlots: msg.payload?.parallelSlots,
|
|
3723
3948
|
defaultModel: msg.payload?.model,
|
|
3724
3949
|
defaultProvider: msg.payload?.provider,
|
|
3725
|
-
fallbackModels: Array.isArray(msg.payload?.fallbackModels) ? msg.payload?.fallbackModels : void 0
|
|
3950
|
+
fallbackModels: Array.isArray(msg.payload?.fallbackModels) ? msg.payload?.fallbackModels : void 0,
|
|
3951
|
+
worktrees: typeof msg.payload?.worktrees === "boolean" ? msg.payload.worktrees : void 0
|
|
3726
3952
|
});
|
|
3727
3953
|
break;
|
|
3728
3954
|
}
|
|
@@ -3742,7 +3968,7 @@ var SddWizardWebSocketHandler = class {
|
|
|
3742
3968
|
}
|
|
3743
3969
|
if (this.busy) return;
|
|
3744
3970
|
this.driver = this.deps.makeDriver();
|
|
3745
|
-
const prompt = this.driver.start(goal);
|
|
3971
|
+
const prompt = this.driver.start(deriveTitle2(goal), goal);
|
|
3746
3972
|
await this.runTurn(prompt);
|
|
3747
3973
|
}
|
|
3748
3974
|
async onMessage(text) {
|
|
@@ -3827,6 +4053,7 @@ import {
|
|
|
3827
4053
|
TaskGraphStore as TaskGraphStore2,
|
|
3828
4054
|
WorktreeManager as WorktreeManager2
|
|
3829
4055
|
} from "@wrongstack/core";
|
|
4056
|
+
var PLANNING_ONLY_GUARD = "SYSTEM: You are running a PLANNING-ONLY specification interview. Do NOT write, create, or edit any files, and do NOT run shell/terminal commands or use any code-editing tools \u2014 they are disabled here and any attempt will fail and waste the turn. Respond with TEXT ONLY: ask your question, or emit the requested spec / plan / task JSON. All code is written later, automatically, once the plan is approved and the multi-agent run starts.\n\n---\n\n";
|
|
3830
4057
|
function buildSddWizardDeps(opts) {
|
|
3831
4058
|
const registry = new SddRunRegistry();
|
|
3832
4059
|
let isolatedSeq = 0;
|
|
@@ -3835,11 +4062,11 @@ function buildSddWizardDeps(opts) {
|
|
|
3835
4062
|
id: `sdd-${name2.toLowerCase().replace(/\s+/g, "-")}-${isolatedSeq++}`,
|
|
3836
4063
|
role: "executor",
|
|
3837
4064
|
name: name2,
|
|
3838
|
-
disabledTools: ["delegate"],
|
|
4065
|
+
disabledTools: ["delegate", "write", "edit", "patch", "bash", "exec"],
|
|
3839
4066
|
allowedCapabilities: ["fs.read", "net.outbound"]
|
|
3840
4067
|
});
|
|
3841
4068
|
try {
|
|
3842
|
-
const res = await result.agent.run([{ type: "text", text: prompt }]);
|
|
4069
|
+
const res = await result.agent.run([{ type: "text", text: PLANNING_ONLY_GUARD + prompt }]);
|
|
3843
4070
|
return res.finalText ?? "";
|
|
3844
4071
|
} finally {
|
|
3845
4072
|
await result.dispose?.();
|
|
@@ -3852,14 +4079,15 @@ function buildSddWizardDeps(opts) {
|
|
|
3852
4079
|
sessionPath: path7.join(opts.paths.projectDir, "sdd-wizard-session.json")
|
|
3853
4080
|
}),
|
|
3854
4081
|
runInterviewTurn: (prompt) => runIsolatedTurn(prompt, "Spec Architect"),
|
|
3855
|
-
startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels }) => {
|
|
4082
|
+
startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels, worktrees: useWorktrees }) => {
|
|
3856
4083
|
const graph = driver.getGraph();
|
|
3857
4084
|
const tracker = driver.getTracker();
|
|
3858
4085
|
if (!graph || !tracker) {
|
|
3859
4086
|
throw new Error("No task graph to run \u2014 finish the interview first.");
|
|
3860
4087
|
}
|
|
4088
|
+
const worktreesEnabled = useWorktrees ?? process.env["WRONGSTACK_SDD_WORKTREES"] !== "0";
|
|
3861
4089
|
let worktrees;
|
|
3862
|
-
if (
|
|
4090
|
+
if (worktreesEnabled) {
|
|
3863
4091
|
const inGit = spawnSync2("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
3864
4092
|
cwd: opts.projectRoot,
|
|
3865
4093
|
encoding: "utf8",
|
|
@@ -7768,8 +7996,11 @@ async function handleGoalGet(projectRoot, broadcast2) {
|
|
|
7768
7996
|
async function startWebUI(opts = {}) {
|
|
7769
7997
|
ensureSessionShell();
|
|
7770
7998
|
const requestedWsPort = opts.wsPort ?? 3457;
|
|
7771
|
-
const wsHost = opts.wsHost ?? "127.0.0.1";
|
|
7772
|
-
const requestedHttpPort = Number.parseInt(process.env["PORT"] ?? "3456", 10);
|
|
7999
|
+
const wsHost = opts.wsHost ?? process.env["WEBUI_HOST"] ?? process.env["WS_HOST"] ?? "127.0.0.1";
|
|
8000
|
+
const requestedHttpPort = opts.httpPort ?? opts.webuiPort ?? opts.port ?? Number.parseInt(process.env["WEBUI_PORT"] ?? process.env["PORT"] ?? "3456", 10);
|
|
8001
|
+
const publicUrl = opts.publicUrl ?? process.env["WEBUI_PUBLIC_URL"];
|
|
8002
|
+
const publicWsUrl = opts.publicWsUrl ?? process.env["WEBUI_PUBLIC_WS_URL"];
|
|
8003
|
+
const requireToken = opts.requireToken ?? envFlag("WEBUI_REQUIRE_TOKEN");
|
|
7773
8004
|
const strictPort = process.env["WEBUI_STRICT_PORT"] === "1" || process.env["WEBUI_STRICT_PORT"] === "true";
|
|
7774
8005
|
let wsPort = requestedWsPort;
|
|
7775
8006
|
let httpPort = requestedHttpPort;
|
|
@@ -8564,8 +8795,16 @@ async function startWebUI(opts = {}) {
|
|
|
8564
8795
|
contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID2)
|
|
8565
8796
|
};
|
|
8566
8797
|
}
|
|
8567
|
-
const wsToken =
|
|
8568
|
-
console.log("[WebUI] WS auth token
|
|
8798
|
+
const wsToken = resolveAuthToken(opts.accessToken);
|
|
8799
|
+
console.log("[WebUI] WS auth token ready");
|
|
8800
|
+
const publicHostnames = [publicUrl, publicWsUrl].map((value) => {
|
|
8801
|
+
if (!value) return void 0;
|
|
8802
|
+
try {
|
|
8803
|
+
return new URL(value).hostname;
|
|
8804
|
+
} catch {
|
|
8805
|
+
return void 0;
|
|
8806
|
+
}
|
|
8807
|
+
}).filter((value) => Boolean(value));
|
|
8569
8808
|
const verifyClient2 = (info) => verifyClient({
|
|
8570
8809
|
origin: info.origin,
|
|
8571
8810
|
url: info.req.url ?? "",
|
|
@@ -8577,7 +8816,10 @@ async function startWebUI(opts = {}) {
|
|
|
8577
8816
|
// exposure class.
|
|
8578
8817
|
cookieHeader: info.req.headers.cookie,
|
|
8579
8818
|
wsHost,
|
|
8580
|
-
expectedToken: wsToken
|
|
8819
|
+
expectedToken: wsToken,
|
|
8820
|
+
requireToken,
|
|
8821
|
+
allowedHostnames: publicHostnames,
|
|
8822
|
+
allowBrowserUrlToken: Boolean(publicWsUrl)
|
|
8581
8823
|
});
|
|
8582
8824
|
const WS_MAX_PAYLOAD = 8 * 1024 * 1024;
|
|
8583
8825
|
const wssPrimary = new WebSocketServer({
|
|
@@ -9517,8 +9759,10 @@ async function startWebUI(opts = {}) {
|
|
|
9517
9759
|
host: wsHost,
|
|
9518
9760
|
distDir: path16.resolve(import.meta.dirname, "../../dist"),
|
|
9519
9761
|
wsPort,
|
|
9762
|
+
publicWsUrl,
|
|
9520
9763
|
globalRoot: wpaths.globalRoot,
|
|
9521
9764
|
apiToken: wsToken,
|
|
9765
|
+
requireToken,
|
|
9522
9766
|
watcherMetrics,
|
|
9523
9767
|
onFleetPing: () => {
|
|
9524
9768
|
void fleetBroadcast?.();
|
|
@@ -9526,7 +9770,12 @@ async function startWebUI(opts = {}) {
|
|
|
9526
9770
|
});
|
|
9527
9771
|
const registryBaseDir = path16.dirname(globalConfigPath);
|
|
9528
9772
|
httpServer.listen(httpPort, wsHost, () => {
|
|
9529
|
-
const openUrl =
|
|
9773
|
+
const openUrl = buildWebUIAccessUrl({
|
|
9774
|
+
host: wsHost,
|
|
9775
|
+
port: httpPort,
|
|
9776
|
+
token: wsToken,
|
|
9777
|
+
publicUrl
|
|
9778
|
+
});
|
|
9530
9779
|
console.log(`[WebUI] HTTP server running on ${openUrl}`);
|
|
9531
9780
|
if (opts.open) openBrowser(openUrl);
|
|
9532
9781
|
void registerInstance(
|
|
@@ -9538,7 +9787,7 @@ async function startWebUI(opts = {}) {
|
|
|
9538
9787
|
projectRoot,
|
|
9539
9788
|
projectName: path16.basename(projectRoot) || projectRoot,
|
|
9540
9789
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9541
|
-
url:
|
|
9790
|
+
url: buildWebUIAccessUrl({ host: wsHost, port: httpPort, publicUrl })
|
|
9542
9791
|
},
|
|
9543
9792
|
registryBaseDir
|
|
9544
9793
|
).catch((err) => console.warn(JSON.stringify({
|
|
@@ -9580,7 +9829,55 @@ async function startWebUI(opts = {}) {
|
|
|
9580
9829
|
|
|
9581
9830
|
// src/server/entry.ts
|
|
9582
9831
|
var argv = process.argv.slice(2);
|
|
9583
|
-
|
|
9832
|
+
function readArg(names) {
|
|
9833
|
+
for (let i = 0; i < argv.length; i++) {
|
|
9834
|
+
const current = argv[i];
|
|
9835
|
+
if (!current) continue;
|
|
9836
|
+
for (const name2 of names) {
|
|
9837
|
+
if (current === name2) {
|
|
9838
|
+
const next = argv[i + 1];
|
|
9839
|
+
if (!next || next.startsWith("-")) {
|
|
9840
|
+
throw new Error(`${name2} requires a value`);
|
|
9841
|
+
}
|
|
9842
|
+
return next;
|
|
9843
|
+
}
|
|
9844
|
+
if (current.startsWith(`${name2}=`)) return current.slice(name2.length + 1);
|
|
9845
|
+
}
|
|
9846
|
+
}
|
|
9847
|
+
return void 0;
|
|
9848
|
+
}
|
|
9849
|
+
function parsePort(value, fallback, label) {
|
|
9850
|
+
if (value === void 0) return fallback;
|
|
9851
|
+
const parsed = Number.parseInt(value, 10);
|
|
9852
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
|
9853
|
+
throw new Error(`${label} must be a port between 1 and 65535`);
|
|
9854
|
+
}
|
|
9855
|
+
return parsed;
|
|
9856
|
+
}
|
|
9857
|
+
function envFlag2(name2) {
|
|
9858
|
+
const value = process.env[name2]?.trim().toLowerCase();
|
|
9859
|
+
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
9860
|
+
}
|
|
9861
|
+
function printHelp() {
|
|
9862
|
+
console.log(`Usage: wstackui [options]
|
|
9863
|
+
|
|
9864
|
+
Options:
|
|
9865
|
+
--host <host> Bind host/interface (default: 127.0.0.1)
|
|
9866
|
+
--port <port> HTTP frontend port (default: 3456)
|
|
9867
|
+
--ws-port <port> WebSocket backend port (default: 3457)
|
|
9868
|
+
--token <token> Fixed access token/password (default: random per process)
|
|
9869
|
+
--public-url <url> Browser-facing HTTP URL for tunnels/proxies
|
|
9870
|
+
--public-ws-url <url> Browser-facing ws:// or wss:// URL for tunnels/proxies
|
|
9871
|
+
--require-token Require token/password even on loopback binds
|
|
9872
|
+
--open, -o Open the browser after startup
|
|
9873
|
+
--list, -l, ls List running WebUI instances
|
|
9874
|
+
--help, -h Show this help
|
|
9875
|
+
`);
|
|
9876
|
+
}
|
|
9877
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
9878
|
+
printHelp();
|
|
9879
|
+
process.exit(0);
|
|
9880
|
+
} else if (argv.includes("--list") || argv.includes("-l") || argv[0] === "ls") {
|
|
9584
9881
|
listInstances().then((instances) => {
|
|
9585
9882
|
console.log(formatInstances(instances));
|
|
9586
9883
|
process.exit(0);
|
|
@@ -9594,11 +9891,40 @@ if (argv.includes("--list") || argv.includes("-l") || argv[0] === "ls") {
|
|
|
9594
9891
|
process.exit(1);
|
|
9595
9892
|
});
|
|
9596
9893
|
} else {
|
|
9597
|
-
|
|
9598
|
-
|
|
9894
|
+
let wsPort;
|
|
9895
|
+
let httpPort;
|
|
9896
|
+
let wsHost;
|
|
9897
|
+
let accessToken;
|
|
9898
|
+
let publicUrl;
|
|
9899
|
+
let publicWsUrl;
|
|
9900
|
+
try {
|
|
9901
|
+
wsHost = readArg(["--host"]) ?? process.env["WEBUI_HOST"] ?? process.env["WS_HOST"] ?? "127.0.0.1";
|
|
9902
|
+
httpPort = parsePort(
|
|
9903
|
+
readArg(["--port", "--http-port"]) ?? process.env["WEBUI_PORT"] ?? process.env["PORT"],
|
|
9904
|
+
3456,
|
|
9905
|
+
"--port"
|
|
9906
|
+
);
|
|
9907
|
+
wsPort = parsePort(readArg(["--ws-port"]) ?? process.env["WS_PORT"], 3457, "--ws-port");
|
|
9908
|
+
accessToken = readArg(["--token", "--auth-token"]) ?? process.env["WEBUI_TOKEN"] ?? process.env["WEBUI_AUTH_TOKEN"];
|
|
9909
|
+
publicUrl = readArg(["--public-url"]) ?? process.env["WEBUI_PUBLIC_URL"];
|
|
9910
|
+
publicWsUrl = readArg(["--public-ws-url"]) ?? process.env["WEBUI_PUBLIC_WS_URL"];
|
|
9911
|
+
} catch (err) {
|
|
9912
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
9913
|
+
process.exit(1);
|
|
9914
|
+
}
|
|
9599
9915
|
const open = argv.includes("--open") || argv.includes("-o") || process.env["WEBUI_OPEN"] === "1";
|
|
9600
|
-
|
|
9601
|
-
|
|
9916
|
+
const requireToken = argv.includes("--require-token") || envFlag2("WEBUI_REQUIRE_TOKEN");
|
|
9917
|
+
console.log(`[WebUI] Starting standalone server on ${wsHost} (http:${httpPort}, ws:${wsPort})...`);
|
|
9918
|
+
startWebUI({
|
|
9919
|
+
wsPort,
|
|
9920
|
+
wsHost,
|
|
9921
|
+
httpPort,
|
|
9922
|
+
accessToken,
|
|
9923
|
+
publicUrl,
|
|
9924
|
+
publicWsUrl,
|
|
9925
|
+
requireToken,
|
|
9926
|
+
open
|
|
9927
|
+
}).catch((err) => {
|
|
9602
9928
|
console.error(JSON.stringify({
|
|
9603
9929
|
level: "fatal",
|
|
9604
9930
|
event: "webui.startup_failed",
|