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.
- package/cli.mjs +244 -57
- 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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1477
|
+
// ── WebSocket server — local 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
|
|
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
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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}`);
|