openclaw-navigator 5.7.0 → 5.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/cli.mjs +133 -66
  2. package/package.json +1 -1
package/cli.mjs CHANGED
@@ -375,6 +375,22 @@ function getChatSession(sessionKey = "main") {
375
375
  return chatSessions.get(sessionKey);
376
376
  }
377
377
 
378
+ // ── Cookie jar for BFF auth ────────────────────────────────────────────
379
+ // Captures session cookies from web UI /api/auth responses so the bridge
380
+ // can make authenticated server-side requests to /api/chat (for sidepane relay).
381
+ let bffCookieJar = "";
382
+
383
+ function captureBFFCookies(setCookieHeaders) {
384
+ if (!setCookieHeaders) return;
385
+ const cookies = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
386
+ // Extract just the cookie name=value (strip attributes like path, domain, etc.)
387
+ const extracted = cookies.map((c) => c.split(";")[0].trim()).filter(Boolean);
388
+ if (extracted.length > 0) {
389
+ bffCookieJar = extracted.join("; ");
390
+ console.log(` ${DIM}Captured BFF cookies: ${bffCookieJar.substring(0, 60)}...${RESET}`);
391
+ }
392
+ }
393
+
378
394
  // ── WebSocket server for chat (minimal, no dependencies) ────────────────
379
395
  // Tracks connected WebSocket clients. When the OC agent pushes messages
380
396
  // via /api/sessions/respond or /api/sessions/stream, we broadcast to all
@@ -1048,6 +1064,8 @@ function handleRequest(req, res) {
1048
1064
  headers["set-cookie"] = cookies.map((c) =>
1049
1065
  c.replace(/;\s*domain=[^;]*/gi, "").replace(/;\s*secure/gi, ""),
1050
1066
  );
1067
+ // Also capture these for server-side relay auth (sidepane chat)
1068
+ captureBFFCookies(proxyRes.headers["set-cookie"]);
1051
1069
  }
1052
1070
 
1053
1071
  res.writeHead(proxyRes.statusCode ?? 502, headers);
@@ -1183,6 +1201,8 @@ function handleRequest(req, res) {
1183
1201
  messages: chatHistory,
1184
1202
  stream: true,
1185
1203
  });
1204
+ // Build cookie header: prefer captured BFF cookies, fall back to browser's cookies
1205
+ const relayCookie = bffCookieJar || req.headers.cookie || "";
1186
1206
  const proxyOpts = {
1187
1207
  hostname: "127.0.0.1",
1188
1208
  port: ocUIPort,
@@ -1192,12 +1212,68 @@ function handleRequest(req, res) {
1192
1212
  headers: {
1193
1213
  "content-type": "application/json",
1194
1214
  "content-length": Buffer.byteLength(proxyBody),
1215
+ ...(relayCookie ? { cookie: relayCookie } : {}),
1195
1216
  },
1196
1217
  };
1197
1218
 
1198
- console.log(` ${DIM}→ Relaying to BFF /api/chat (port ${ocUIPort}) with ${chatHistory.length} messages${RESET}`);
1219
+ console.log(` ${DIM}→ Relaying to BFF /api/chat (port ${ocUIPort}) with ${chatHistory.length} messages${relayCookie ? " +cookie" : " NO-COOKIE"}${RESET}`);
1220
+
1221
+ // Helper: send the relay request (with optional retry after login)
1222
+ function sendBFFRelay(opts, body, retryCount = 0) {
1223
+ const proxyReq = httpRequest(opts, (proxyRes) => {
1224
+ // Capture any cookies the BFF sends (login session, etc.)
1225
+ if (proxyRes.headers["set-cookie"]) {
1226
+ captureBFFCookies(proxyRes.headers["set-cookie"]);
1227
+ }
1228
+
1229
+ // If redirect (307/302 to /login) — follow it to seed cookies, then retry
1230
+ if ((proxyRes.statusCode === 307 || proxyRes.statusCode === 302) && retryCount < 2) {
1231
+ proxyRes.resume(); // drain
1232
+ const loginPath = proxyRes.headers.location || "/login";
1233
+ console.log(` ${DIM}← BFF redirect → ${loginPath} — following to seed cookies...${RESET}`);
1234
+
1235
+ // Hit the login page to get session cookies
1236
+ const loginReq = httpRequest(
1237
+ {
1238
+ hostname: "127.0.0.1",
1239
+ port: ocUIPort,
1240
+ path: loginPath,
1241
+ method: "GET",
1242
+ timeout: 5000,
1243
+ headers: bffCookieJar ? { cookie: bffCookieJar } : {},
1244
+ },
1245
+ (loginRes) => {
1246
+ if (loginRes.headers["set-cookie"]) {
1247
+ captureBFFCookies(loginRes.headers["set-cookie"]);
1248
+ }
1249
+ loginRes.resume(); // drain
1250
+ console.log(` ${DIM}← Login page: ${loginRes.statusCode} — cookies: ${bffCookieJar ? "yes" : "no"}${RESET}`);
1251
+
1252
+ // Retry the original request with new cookies
1253
+ if (bffCookieJar) {
1254
+ opts.headers = { ...opts.headers, cookie: bffCookieJar };
1255
+ console.log(` ${DIM}→ Retrying /api/chat with cookies...${RESET}`);
1256
+ sendBFFRelay(opts, body, retryCount + 1);
1257
+ } else {
1258
+ console.log(` ${DIM}No cookies from login — BFF may require real auth${RESET}`);
1259
+ broadcastToWS({
1260
+ type: "chat.final",
1261
+ text: "⚠️ Could not authenticate with OC — try opening the web UI first to log in.",
1262
+ content: "⚠️ Could not authenticate with OC — try opening the web UI first to log in.",
1263
+ sessionKey,
1264
+ role: "assistant",
1265
+ timestamp: Date.now(),
1266
+ });
1267
+ }
1268
+ },
1269
+ );
1270
+ loginReq.on("error", () => {
1271
+ console.log(` ${DIM}Login page unreachable${RESET}`);
1272
+ });
1273
+ loginReq.end();
1274
+ return;
1275
+ }
1199
1276
 
1200
- const proxyReq = httpRequest(proxyOpts, (proxyRes) => {
1201
1277
  const contentType = (proxyRes.headers["content-type"] || "").toLowerCase();
1202
1278
  const isSSE = contentType.includes("text/event-stream");
1203
1279
  console.log(` ${DIM}← BFF response: ${proxyRes.statusCode} ${contentType || "no-content-type"}${RESET}`);
@@ -1299,8 +1375,11 @@ function handleRequest(req, res) {
1299
1375
  console.log(` ${DIM}BFF relay failed: ${err.message}${RESET}`);
1300
1376
  });
1301
1377
 
1302
- proxyReq.write(proxyBody);
1378
+ proxyReq.write(body);
1303
1379
  proxyReq.end();
1380
+ } // end sendBFFRelay
1381
+
1382
+ sendBFFRelay(proxyOpts, proxyBody);
1304
1383
  })
1305
1384
  .catch(() => sendJSON(res, 400, { ok: false, error: "Bad request body" }));
1306
1385
  return;
@@ -1411,6 +1490,11 @@ function handleRequest(req, res) {
1411
1490
  console.log(` ${DIM}← ${proxyRes.statusCode} ${ct} for ${path}${RESET}`);
1412
1491
  }
1413
1492
 
1493
+ // Capture session cookies from BFF (for server-side relay auth)
1494
+ if (proxyRes.headers["set-cookie"]) {
1495
+ captureBFFCookies(proxyRes.headers["set-cookie"]);
1496
+ }
1497
+
1414
1498
  // CORS
1415
1499
  headers["access-control-allow-origin"] = "*";
1416
1500
  headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, OPTIONS";
@@ -1501,78 +1585,62 @@ function handleRequest(req, res) {
1501
1585
  return;
1502
1586
  }
1503
1587
 
1504
- // ── SSE passthrough for streaming endpoints (/api/chat) ──────────
1505
- // Pipe SSE through transparently. Use setNoDelay to prevent TCP buffering
1506
- // that would cause the browser to receive multiple SSE events in one chunk.
1507
- // Also tap the stream to broadcast via WebSocket (for Navigator sidepane)
1508
- // and store the response in the bridge chat session.
1588
+ // ── SSE→JSON for streaming endpoints (/api/chat) ──────────────────
1589
+ // The BFF returns SSE but the web UI's parser can't handle SSE through
1590
+ // a reverse proxy (it does JSON.parse on raw chunks without stripping
1591
+ // the "data: " prefix). So we collect the full SSE stream, extract the
1592
+ // text, and return a single JSON response. Also store + broadcast via WS.
1509
1593
  if (isSSE && isStreamingEndpoint) {
1510
- console.log(` ${DIM}SSE passthrough: ${path}${RESET}`);
1511
-
1512
- // Disable TCP Nagle for immediate per-event delivery
1513
- if (res.socket) res.socket.setNoDelay(true);
1514
-
1515
- // Clean transfer headers — let Node.js manage chunked encoding
1516
- delete headers["content-length"];
1517
- delete headers["transfer-encoding"];
1518
- res.writeHead(proxyRes.statusCode ?? 200, headers);
1594
+ console.log(` ${DIM}SSE→JSON collect: ${path}${RESET}`);
1519
1595
 
1520
1596
  let fullText = "";
1521
- let sseBuffer = "";
1597
+ let sseData = "";
1522
1598
 
1523
1599
  proxyRes.setEncoding("utf-8");
1524
1600
  proxyRes.on("data", (chunk) => {
1525
- sseBuffer += chunk;
1526
-
1527
- // Split by double-newline (SSE event boundary)
1528
- const events = sseBuffer.split("\n\n");
1529
- sseBuffer = events.pop() || ""; // Keep incomplete event in buffer
1530
-
1531
- for (const event of events) {
1532
- const trimmed = event.trim();
1533
- if (!trimmed) continue;
1534
-
1535
- // Forward the COMPLETE SSE event to browser (preserving data: prefix)
1536
- res.write(trimmed + "\n\n");
1537
-
1538
- // Tap for WebSocket broadcast
1539
- for (const line of trimmed.split("\n")) {
1540
- if (line.startsWith("data: ")) {
1541
- const raw = line.slice(6).trim();
1542
- if (raw === "[DONE]" || !raw) continue;
1543
- try {
1544
- const evt = JSON.parse(raw);
1545
- const delta =
1546
- evt.choices?.[0]?.delta?.content ||
1547
- evt.delta?.text ||
1548
- evt.text ||
1549
- evt.content ||
1550
- "";
1551
- if (delta) {
1552
- fullText += delta;
1553
- broadcastToWS({
1554
- type: "chat.delta",
1555
- text: fullText,
1556
- delta,
1557
- sessionKey: "main",
1558
- timestamp: Date.now(),
1559
- });
1560
- }
1561
- } catch {
1562
- /* non-JSON SSE event */
1563
- }
1564
- }
1565
- }
1566
- }
1601
+ sseData += chunk;
1567
1602
  });
1568
1603
 
1569
1604
  proxyRes.on("end", () => {
1570
- // Flush remaining buffer
1571
- if (sseBuffer.trim()) {
1572
- res.write(sseBuffer + "\n\n");
1605
+ // Extract text from all SSE events
1606
+ for (const line of sseData.split("\n")) {
1607
+ if (line.startsWith("data: ")) {
1608
+ const raw = line.slice(6).trim();
1609
+ if (raw === "[DONE]" || !raw) continue;
1610
+ try {
1611
+ const evt = JSON.parse(raw);
1612
+ const delta =
1613
+ evt.choices?.[0]?.delta?.content ||
1614
+ evt.delta?.text ||
1615
+ evt.text ||
1616
+ evt.content ||
1617
+ "";
1618
+ if (delta) fullText += delta;
1619
+ } catch {
1620
+ if (raw) fullText += raw;
1621
+ }
1622
+ }
1573
1623
  }
1574
1624
 
1575
- // Store and broadcast final response
1625
+ // Return as a single OpenAI-compatible JSON response
1626
+ const result = {
1627
+ id: "chatcmpl_bridge_" + Date.now(),
1628
+ object: "chat.completion",
1629
+ created: Math.floor(Date.now() / 1000),
1630
+ choices: [{
1631
+ index: 0,
1632
+ message: { role: "assistant", content: fullText },
1633
+ finish_reason: "stop",
1634
+ }],
1635
+ };
1636
+
1637
+ headers["content-type"] = "application/json";
1638
+ delete headers["content-length"];
1639
+ delete headers["transfer-encoding"];
1640
+ res.writeHead(200, headers);
1641
+ res.end(JSON.stringify(result));
1642
+
1643
+ // Store in bridge session + broadcast via WebSocket
1576
1644
  if (fullText) {
1577
1645
  const session = getChatSession("main");
1578
1646
  session.messages.push({ role: "assistant", content: fullText, timestamp: Date.now() });
@@ -1589,12 +1657,11 @@ function handleRequest(req, res) {
1589
1657
  } else {
1590
1658
  console.log(` ${DIM}SSE stream ended with no content${RESET}`);
1591
1659
  }
1592
- res.end();
1593
1660
  });
1594
1661
 
1595
1662
  proxyRes.on("error", (err) => {
1596
1663
  console.log(` ${DIM}SSE stream error: ${err.message}${RESET}`);
1597
- res.end();
1664
+ sendJSON(res, 502, { ok: false, error: "Stream error" });
1598
1665
  });
1599
1666
  return;
1600
1667
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-navigator",
3
- "version": "5.7.0",
3
+ "version": "5.7.2",
4
4
  "description": "One-command bridge + tunnel for the Navigator browser — works on any machine, any OS",
5
5
  "keywords": [
6
6
  "browser",