openclaw-navigator 5.3.4 → 5.4.1

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 +244 -57
  2. package/package.json +1 -1
package/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * openclaw-navigator v5.3.4
4
+ * openclaw-navigator v5.4.1
5
5
  *
6
6
  * One-command bridge + tunnel for the Navigator browser.
7
7
  * Starts a local bridge, creates a Cloudflare tunnel automatically,
@@ -17,10 +17,10 @@
17
17
  */
18
18
 
19
19
  import { spawn } from "node:child_process";
20
- import { randomUUID } from "node:crypto";
20
+ import { randomUUID, createHash } from "node:crypto";
21
21
  import { existsSync, appendFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
22
22
  import { createServer, request as httpRequest } from "node:http";
23
- import { connect as netConnect } from "node:net";
23
+ // node:net no longer needed WebSocket is local, not proxied to gateway
24
24
  import { networkInterfaces, hostname, userInfo, homedir } from "node:os";
25
25
  import { dirname, join } from "node:path";
26
26
  import { fileURLToPath } from "node:url";
@@ -362,6 +362,159 @@ function validateBridgeAuth(req) {
362
362
  return validTokens.has(token);
363
363
  }
364
364
 
365
+ // ── WebSocket server for chat (minimal, no dependencies) ────────────────
366
+ // Tracks connected WebSocket clients. When the OC agent pushes messages
367
+ // via /api/sessions/respond or /api/sessions/stream, we broadcast to all
368
+ // connected Navigator chat panes.
369
+
370
+ const wsClients = new Set();
371
+
372
+ /** Accept a raw WebSocket upgrade on a socket. */
373
+ function acceptWebSocket(req, socket) {
374
+ const key = req.headers["sec-websocket-key"];
375
+ if (!key) {
376
+ socket.destroy();
377
+ return null;
378
+ }
379
+ const accept = createHash("sha1")
380
+ .update(key + "258EAFA5-E914-47DA-95CA-5AB9C4F6B258")
381
+ .digest("base64");
382
+ socket.write(
383
+ "HTTP/1.1 101 Switching Protocols\r\n" +
384
+ "Upgrade: websocket\r\n" +
385
+ "Connection: Upgrade\r\n" +
386
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
387
+ );
388
+ return socket;
389
+ }
390
+
391
+ /** Encode a string as a WebSocket text frame. */
392
+ function wsEncodeText(str) {
393
+ const payload = Buffer.from(str, "utf8");
394
+ const len = payload.length;
395
+ let header;
396
+ if (len < 126) {
397
+ header = Buffer.alloc(2);
398
+ header[0] = 0x81; // FIN + text opcode
399
+ header[1] = len;
400
+ } else if (len < 65536) {
401
+ header = Buffer.alloc(4);
402
+ header[0] = 0x81;
403
+ header[1] = 126;
404
+ header.writeUInt16BE(len, 2);
405
+ } else {
406
+ header = Buffer.alloc(10);
407
+ header[0] = 0x81;
408
+ header[1] = 127;
409
+ header.writeBigUInt64BE(BigInt(len), 2);
410
+ }
411
+ return Buffer.concat([header, payload]);
412
+ }
413
+
414
+ /** Broadcast a JSON event to all connected WebSocket clients. */
415
+ function broadcastToWS(event) {
416
+ const frame = wsEncodeText(JSON.stringify(event));
417
+ for (const client of wsClients) {
418
+ try {
419
+ client.write(frame);
420
+ } catch {
421
+ wsClients.delete(client);
422
+ }
423
+ }
424
+ }
425
+
426
+ /** Decode a masked WebSocket frame (client→server frames are always masked). */
427
+ function wsDecodeFrame(buf) {
428
+ if (buf.length < 2) {
429
+ return null;
430
+ }
431
+ const opcode = buf[0] & 0x0f;
432
+ const masked = (buf[1] & 0x80) !== 0;
433
+ let payloadLen = buf[1] & 0x7f;
434
+ let offset = 2;
435
+ if (payloadLen === 126) {
436
+ if (buf.length < 4) {
437
+ return null;
438
+ }
439
+ payloadLen = buf.readUInt16BE(2);
440
+ offset = 4;
441
+ } else if (payloadLen === 127) {
442
+ if (buf.length < 10) {
443
+ return null;
444
+ }
445
+ payloadLen = Number(buf.readBigUInt64BE(2));
446
+ offset = 10;
447
+ }
448
+ let mask = null;
449
+ if (masked) {
450
+ if (buf.length < offset + 4) {
451
+ return null;
452
+ }
453
+ mask = buf.subarray(offset, offset + 4);
454
+ offset += 4;
455
+ }
456
+ if (buf.length < offset + payloadLen) {
457
+ return null;
458
+ }
459
+ const data = buf.subarray(offset, offset + payloadLen);
460
+ if (mask) {
461
+ for (let i = 0; i < data.length; i++) {
462
+ data[i] ^= mask[i % 4];
463
+ }
464
+ }
465
+ return { opcode, data, totalLength: offset + payloadLen };
466
+ }
467
+
468
+ /** Set up WebSocket message/close handling for a client socket. */
469
+ function setupWSClient(socket) {
470
+ wsClients.add(socket);
471
+ console.log(` ${DIM}WebSocket client connected (${wsClients.size} total)${RESET}`);
472
+
473
+ // Send initial connected event
474
+ try {
475
+ socket.write(wsEncodeText(JSON.stringify({ type: "connected", clients: wsClients.size })));
476
+ } catch {
477
+ /* ignore */
478
+ }
479
+
480
+ let buffer = Buffer.alloc(0);
481
+ socket.on("data", (chunk) => {
482
+ buffer = Buffer.concat([buffer, chunk]);
483
+ while (buffer.length > 0) {
484
+ const frame = wsDecodeFrame(buffer);
485
+ if (!frame) {
486
+ break;
487
+ }
488
+ buffer = buffer.subarray(frame.totalLength);
489
+ if (frame.opcode === 0x08) {
490
+ // Close frame
491
+ wsClients.delete(socket);
492
+ socket.destroy();
493
+ return;
494
+ }
495
+ if (frame.opcode === 0x09) {
496
+ // Ping — respond with pong
497
+ const pong = Buffer.alloc(2);
498
+ pong[0] = 0x8a; // FIN + pong
499
+ pong[1] = 0;
500
+ try {
501
+ socket.write(pong);
502
+ } catch {
503
+ /* ignore */
504
+ }
505
+ }
506
+ // Text frames (opcode 1) can be ignored — clients don't send chat messages via WS
507
+ }
508
+ });
509
+ socket.on("close", () => {
510
+ wsClients.delete(socket);
511
+ console.log(` ${DIM}WebSocket client disconnected (${wsClients.size} remaining)${RESET}`);
512
+ });
513
+ socket.on("error", () => {
514
+ wsClients.delete(socket);
515
+ });
516
+ }
517
+
365
518
  function handleRequest(req, res) {
366
519
  // CORS preflight
367
520
  if (req.method === "OPTIONS") {
@@ -828,9 +981,11 @@ function handleRequest(req, res) {
828
981
  }
829
982
 
830
983
  // ── Reverse proxy: /ui/* → OC Web UI (localhost:ocUIPort) ──────────────
831
- // Keep /ui prefix — Next.js basePath: "/ui" expects it
984
+ // Strip /ui prefix — the Next.js app serves at root on port 4000.
985
+ // /ui/ → /, /ui/dashboard → /dashboard, /ui/_next/* → /_next/*
832
986
  if (path === "/ui" || path.startsWith("/ui/")) {
833
- const targetURL = `${path}${url.search}`;
987
+ const strippedPath = path === "/ui" ? "/" : path.slice(3); // remove "/ui"
988
+ const targetURL = `${strippedPath}${url.search}`;
834
989
  const incomingHost = req.headers.host || "localhost";
835
990
 
836
991
  const proxyOpts = {
@@ -856,11 +1011,20 @@ function handleRequest(req, res) {
856
1011
  headers["access-control-allow-headers"] = "Content-Type, Authorization, Cookie";
857
1012
  headers["access-control-allow-credentials"] = "true";
858
1013
 
859
- // Fix redirects — rewrite Location from localhost:4000 to tunnel URL
1014
+ // Fix redirects — rewrite Location: strip localhost, add /ui prefix back
1015
+ // Since we stripped /ui before proxying, any redirect from the app
1016
+ // (e.g. /login, /dashboard) needs the /ui prefix added back for the
1017
+ // browser to stay within our /ui/* proxy.
860
1018
  if (headers.location) {
861
- headers.location = headers.location
1019
+ let loc = headers.location
862
1020
  .replace(`http://127.0.0.1:${ocUIPort}`, "")
863
1021
  .replace(`http://localhost:${ocUIPort}`, "");
1022
+ // If the redirect is a relative path (starts with /), add /ui prefix
1023
+ // BUT don't double-prefix if it already starts with /ui
1024
+ if (loc.startsWith("/") && !loc.startsWith("/ui")) {
1025
+ loc = "/ui" + loc;
1026
+ }
1027
+ headers.location = loc;
864
1028
  }
865
1029
 
866
1030
  // Fix cookies — remove domain restriction so they work through tunnel
@@ -890,8 +1054,71 @@ function handleRequest(req, res) {
890
1054
  return;
891
1055
  }
892
1056
 
1057
+ // ── Local chat handlers: respond + stream → WebSocket broadcast ─────
1058
+ // These endpoints are called by the OC agent (via MCP tools) to push
1059
+ // responses back to Navigator's chat pane. They broadcast via the
1060
+ // local WebSocket server — NOT proxied to the gateway.
1061
+ if (path === "/api/sessions/respond" && req.method === "POST") {
1062
+ readBody(req)
1063
+ .then((body) => {
1064
+ try {
1065
+ const data = JSON.parse(body);
1066
+ const message = data.content || data.message || "";
1067
+ const sessionKey = data.sessionKey || "main";
1068
+ if (!message) {
1069
+ sendJSON(res, 400, { ok: false, error: "Missing 'content' or 'message'" });
1070
+ return;
1071
+ }
1072
+ // Broadcast as a final chat message via WebSocket
1073
+ broadcastToWS({
1074
+ type: "chat.final",
1075
+ text: message,
1076
+ content: message,
1077
+ sessionKey,
1078
+ role: "assistant",
1079
+ timestamp: Date.now(),
1080
+ });
1081
+ console.log(
1082
+ ` ${DIM}Chat respond → ${wsClients.size} WS client(s): ${message.substring(0, 60)}...${RESET}`,
1083
+ );
1084
+ sendJSON(res, 200, { ok: true, delivered: wsClients.size });
1085
+ } catch {
1086
+ sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
1087
+ }
1088
+ })
1089
+ .catch(() => sendJSON(res, 400, { ok: false, error: "Bad request" }));
1090
+ return;
1091
+ }
1092
+
1093
+ if (path === "/api/sessions/stream" && req.method === "POST") {
1094
+ readBody(req)
1095
+ .then((body) => {
1096
+ try {
1097
+ const data = JSON.parse(body);
1098
+ const text = data.text || "";
1099
+ const sessionKey = data.sessionKey || "main";
1100
+ const runId = data.runId || null;
1101
+ // Broadcast as a streaming delta
1102
+ broadcastToWS({
1103
+ type: "chat.delta",
1104
+ text,
1105
+ delta: text,
1106
+ sessionKey,
1107
+ runId,
1108
+ timestamp: Date.now(),
1109
+ });
1110
+ sendJSON(res, 200, { ok: true, delivered: wsClients.size });
1111
+ } catch {
1112
+ sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
1113
+ }
1114
+ })
1115
+ .catch(() => sendJSON(res, 400, { ok: false, error: "Bad request" }));
1116
+ return;
1117
+ }
1118
+
893
1119
  // ── Reverse proxy: /api/sessions/* → OC Gateway (localhost:ocGatewayPort) ──
894
- // ONLY proxy known OC gateway paths — /api/sessions/* (chat/agent endpoints).
1120
+ // Proxies /api/sessions/send and /api/sessions/history to the OC gateway.
1121
+ // /api/sessions/respond and /api/sessions/stream are handled locally above.
895
1122
  // Other /api/* paths (e.g. /api/auth/* from NextAuth) fall through to the
896
1123
  // web UI fallback so login and other web UI API routes work correctly.
897
1124
  if (path.startsWith("/api/sessions/") || path === "/api/sessions") {
@@ -1247,64 +1474,24 @@ module.exports = {
1247
1474
  server.listen(port, bindHost, () => resolve());
1248
1475
  });
1249
1476
 
1250
- // ── WebSocket proxyforward /ws connections to OC gateway ─────────
1251
- // The bridge is a transparent relay: Navigator connects to /ws here,
1252
- // we pipe to the OC gateway WebSocket at ws://localhost:ocGatewayPort/
1253
- // (OC Core accepts WebSocket at root /, not /ws)
1477
+ // ── WebSocket serverlocal chat relay for Navigator clients ────────
1478
+ // Navigator's OCChatService connects to /ws for real-time chat events.
1479
+ // When the OC agent calls /api/sessions/respond or /api/sessions/stream,
1480
+ // broadcastToWS pushes the event to all connected clients here.
1254
1481
  server.on("upgrade", (req, socket, head) => {
1255
1482
  const reqUrl = new URL(req.url ?? "/", "http://localhost");
1256
1483
  const reqPath = reqUrl.pathname;
1257
1484
 
1258
- // Only handle /ws paths (Navigator connects to /ws)
1485
+ // Only handle /ws paths
1259
1486
  if (reqPath !== "/ws" && !reqPath.startsWith("/ws/")) {
1260
1487
  socket.destroy();
1261
1488
  return;
1262
1489
  }
1263
1490
 
1264
- // Connect to the gateway WebSocket
1265
- const gwSocket = netConnect(ocGatewayPort, "127.0.0.1", () => {
1266
- // Rewrite path: Navigator uses /ws but OC Core expects / (root)
1267
- const gwPath = reqPath === "/ws" ? "/" : reqPath.replace(/^\/ws/, "");
1268
- const upgradeHeaders = [
1269
- `${req.method} ${gwPath || "/"} HTTP/${req.httpVersion}`,
1270
- ...Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`),
1271
- "",
1272
- "",
1273
- ].join("\r\n");
1274
- gwSocket.write(upgradeHeaders);
1275
- if (head && head.length > 0) {
1276
- gwSocket.write(head);
1277
- }
1278
- });
1279
-
1280
- gwSocket.on("error", (err) => {
1281
- warn(`WS proxy: gateway on port ${ocGatewayPort} unreachable — ${err.message}`);
1282
- socket.destroy();
1283
- });
1284
-
1285
- // Once the gateway responds with 101, pipe bidirectionally
1286
- gwSocket.once("data", (firstChunk) => {
1287
- const response = firstChunk.toString();
1288
- if (response.startsWith("HTTP/1.1 101")) {
1289
- // Forward the 101 response to the client
1290
- socket.write(firstChunk);
1291
- // Now pipe both directions transparently
1292
- socket.pipe(gwSocket);
1293
- gwSocket.pipe(socket);
1294
-
1295
- socket.on("close", () => gwSocket.destroy());
1296
- gwSocket.on("close", () => socket.destroy());
1297
- socket.on("error", () => gwSocket.destroy());
1298
- gwSocket.on("error", () => socket.destroy());
1299
-
1300
- console.log(` ${DIM}WebSocket proxied to gateway:${ocGatewayPort}${RESET}`);
1301
- } else {
1302
- // Gateway rejected the upgrade
1303
- socket.write(firstChunk);
1304
- socket.destroy();
1305
- gwSocket.destroy();
1306
- }
1307
- });
1491
+ const accepted = acceptWebSocket(req, socket);
1492
+ if (accepted) {
1493
+ setupWSClient(accepted);
1494
+ }
1308
1495
  });
1309
1496
 
1310
1497
  ok(`Bridge server running on ${bindHost}:${port}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-navigator",
3
- "version": "5.3.4",
3
+ "version": "5.4.1",
4
4
  "description": "One-command bridge + tunnel for the Navigator browser — works on any machine, any OS",
5
5
  "keywords": [
6
6
  "browser",