openclaw-navigator 2.0.0 → 3.0.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/cli.mjs +120 -78
- package/package.json +1 -1
package/cli.mjs
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
* openclaw-navigator
|
|
5
5
|
*
|
|
6
6
|
* Self-contained Navigator bridge server.
|
|
7
|
-
* Starts an HTTP server with all Navigator endpoints,
|
|
8
|
-
*
|
|
7
|
+
* Starts an HTTP server on localhost with all Navigator endpoints,
|
|
8
|
+
* then helps you connect from the Navigator browser via SSH tunnel.
|
|
9
9
|
*
|
|
10
|
-
* Zero dependencies — pure Node.js.
|
|
10
|
+
* Zero dependencies — pure Node.js + macOS built-in SSH.
|
|
11
11
|
*
|
|
12
12
|
* Usage:
|
|
13
|
-
* npx openclaw-navigator
|
|
14
|
-
* npx openclaw-navigator --port 18790
|
|
13
|
+
* npx openclaw-navigator Start bridge (default)
|
|
14
|
+
* npx openclaw-navigator --port 18790 Custom port
|
|
15
|
+
* npx openclaw-navigator --bind 0.0.0.0 Listen on all interfaces (skip SSH)
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { createServer } from "node:http";
|
|
@@ -53,7 +54,7 @@ const recentEvents = [];
|
|
|
53
54
|
const MAX_EVENTS = 200;
|
|
54
55
|
const validTokens = new Set();
|
|
55
56
|
|
|
56
|
-
// ──
|
|
57
|
+
// ── Network helpers ────────────────────────────────────────────────────────
|
|
57
58
|
|
|
58
59
|
function getLocalIP() {
|
|
59
60
|
const ifaces = networkInterfaces();
|
|
@@ -88,6 +89,8 @@ function commandExists(cmd) {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
// ── Interactive prompt ─────────────────────────────────────────────────────
|
|
93
|
+
|
|
91
94
|
async function ask(question, options) {
|
|
92
95
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
93
96
|
return new Promise((resolve) => {
|
|
@@ -110,6 +113,8 @@ async function ask(question, options) {
|
|
|
110
113
|
});
|
|
111
114
|
}
|
|
112
115
|
|
|
116
|
+
// ── Cloudflare tunnel ──────────────────────────────────────────────────────
|
|
117
|
+
|
|
113
118
|
function startCloudflaredTunnel(port) {
|
|
114
119
|
return new Promise((resolve) => {
|
|
115
120
|
const child = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
|
|
@@ -172,7 +177,7 @@ function handleRequest(req, res) {
|
|
|
172
177
|
return;
|
|
173
178
|
}
|
|
174
179
|
|
|
175
|
-
const url = new URL(req.url ?? "/",
|
|
180
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
176
181
|
const path = url.pathname.replace(/\/+$/, "");
|
|
177
182
|
|
|
178
183
|
// ── GET /navigator/status ──
|
|
@@ -191,7 +196,6 @@ function handleRequest(req, res) {
|
|
|
191
196
|
|
|
192
197
|
// ── GET /navigator/commands ──
|
|
193
198
|
if (req.method === "GET" && path === "/navigator/commands") {
|
|
194
|
-
// Mark as connected on first poll
|
|
195
199
|
if (!bridgeState.connected) {
|
|
196
200
|
bridgeState.connected = true;
|
|
197
201
|
bridgeState.connectedAt = Date.now();
|
|
@@ -199,7 +203,6 @@ function handleRequest(req, res) {
|
|
|
199
203
|
}
|
|
200
204
|
bridgeState.lastHeartbeat = Date.now();
|
|
201
205
|
|
|
202
|
-
// Drain pending commands
|
|
203
206
|
const commands = [...pendingCommands];
|
|
204
207
|
pendingCommands.length = 0;
|
|
205
208
|
sendJSON(res, 200, { ok: true, commands });
|
|
@@ -223,7 +226,6 @@ function handleRequest(req, res) {
|
|
|
223
226
|
recentEvents.push(event);
|
|
224
227
|
if (recentEvents.length > MAX_EVENTS) recentEvents.shift();
|
|
225
228
|
|
|
226
|
-
// Update state from heartbeats
|
|
227
229
|
if (body.type === "heartbeat") {
|
|
228
230
|
bridgeState.lastHeartbeat = Date.now();
|
|
229
231
|
bridgeState.activeTabCount = body.data?.tabCount ?? bridgeState.activeTabCount;
|
|
@@ -268,7 +270,7 @@ function handleRequest(req, res) {
|
|
|
268
270
|
return;
|
|
269
271
|
}
|
|
270
272
|
|
|
271
|
-
// ── POST /navigator/command
|
|
273
|
+
// ── POST /navigator/command ──
|
|
272
274
|
if (req.method === "POST" && path === "/navigator/command") {
|
|
273
275
|
readBody(req).then((bodyStr) => {
|
|
274
276
|
try {
|
|
@@ -294,7 +296,7 @@ function handleRequest(req, res) {
|
|
|
294
296
|
return;
|
|
295
297
|
}
|
|
296
298
|
|
|
297
|
-
// ── GET /navigator/events
|
|
299
|
+
// ── GET /navigator/events ──
|
|
298
300
|
if (req.method === "GET" && path === "/navigator/events") {
|
|
299
301
|
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
|
|
300
302
|
const events = recentEvents.slice(-Math.min(limit, MAX_EVENTS));
|
|
@@ -304,11 +306,10 @@ function handleRequest(req, res) {
|
|
|
304
306
|
|
|
305
307
|
// ── Health check ──
|
|
306
308
|
if (req.method === "GET" && (path === "/" || path === "/health")) {
|
|
307
|
-
sendJSON(res, 200, { ok: true, service: "openclaw-navigator-bridge", version: "
|
|
309
|
+
sendJSON(res, 200, { ok: true, service: "openclaw-navigator-bridge", version: "3.0.0" });
|
|
308
310
|
return;
|
|
309
311
|
}
|
|
310
312
|
|
|
311
|
-
// Unknown
|
|
312
313
|
sendJSON(res, 404, { ok: false, error: "Not found" });
|
|
313
314
|
}
|
|
314
315
|
|
|
@@ -334,9 +335,6 @@ function showConnectionBox(gatewayURL, token, deepLink, method) {
|
|
|
334
335
|
console.log("");
|
|
335
336
|
console.log(` ${CYAN}${deepLink}${RESET}`);
|
|
336
337
|
console.log("");
|
|
337
|
-
console.log(`${DIM} Paste URL + Token into Navigator > Settings > OpenClaw${RESET}`);
|
|
338
|
-
console.log(`${DIM} Or paste the deep link into the address bar and press Enter.${RESET}`);
|
|
339
|
-
console.log("");
|
|
340
338
|
}
|
|
341
339
|
|
|
342
340
|
// ── Main ───────────────────────────────────────────────────────────────────
|
|
@@ -344,35 +342,37 @@ function showConnectionBox(gatewayURL, token, deepLink, method) {
|
|
|
344
342
|
async function main() {
|
|
345
343
|
const args = process.argv.slice(2);
|
|
346
344
|
let port = 18790;
|
|
345
|
+
let bindHost = "127.0.0.1"; // Default: localhost only (secure, no firewall issues)
|
|
347
346
|
|
|
348
347
|
for (let i = 0; i < args.length; i++) {
|
|
349
348
|
if (args[i] === "--port" && args[i + 1]) port = parseInt(args[i + 1], 10);
|
|
349
|
+
if (args[i] === "--bind" && args[i + 1]) bindHost = args[i + 1];
|
|
350
350
|
if (args[i] === "--help" || args[i] === "-h") {
|
|
351
351
|
console.log(`
|
|
352
352
|
${BOLD}openclaw-navigator${RESET} — Navigator bridge server
|
|
353
353
|
|
|
354
354
|
${BOLD}Usage:${RESET}
|
|
355
|
-
npx openclaw-navigator
|
|
356
|
-
npx openclaw-navigator --port 18790
|
|
355
|
+
npx openclaw-navigator Start bridge (SSH tunnel mode)
|
|
356
|
+
npx openclaw-navigator --port 18790 Custom port
|
|
357
|
+
npx openclaw-navigator --bind 0.0.0.0 Listen on all interfaces (direct mode)
|
|
357
358
|
|
|
358
359
|
${BOLD}Options:${RESET}
|
|
359
|
-
--port <port>
|
|
360
|
-
--
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
No OpenClaw installation required.
|
|
360
|
+
--port <port> Bridge server port (default: 18790)
|
|
361
|
+
--bind <host> Bind address (default: 127.0.0.1 for SSH tunnel security)
|
|
362
|
+
--help Show this help
|
|
363
|
+
|
|
364
|
+
${BOLD}How it works:${RESET}
|
|
365
|
+
1. Starts a bridge server on localhost (secure, no firewall issues)
|
|
366
|
+
2. You run one SSH command on your Navigator Mac to create a tunnel
|
|
367
|
+
3. Navigator connects to localhost — everything encrypted over SSH
|
|
368
|
+
4. No third-party services required. Just macOS built-in SSH.
|
|
369
369
|
`);
|
|
370
370
|
process.exit(0);
|
|
371
371
|
}
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
heading("🧭 Navigator Bridge");
|
|
375
|
-
info("Self-contained bridge
|
|
375
|
+
info("Self-contained bridge for the Navigator browser\n");
|
|
376
376
|
|
|
377
377
|
// ── Step 1: Start HTTP server ─────────────────────────────────────────
|
|
378
378
|
const server = createServer(handleRequest);
|
|
@@ -385,74 +385,116 @@ ${BOLD}What this does:${RESET}
|
|
|
385
385
|
}
|
|
386
386
|
reject(err);
|
|
387
387
|
});
|
|
388
|
-
server.listen(port,
|
|
388
|
+
server.listen(port, bindHost, () => resolve());
|
|
389
389
|
});
|
|
390
390
|
|
|
391
|
-
ok(`Bridge server running on
|
|
392
|
-
info(` Local: http://127.0.0.1:${port}/navigator/status`);
|
|
391
|
+
ok(`Bridge server running on ${bindHost}:${port}`);
|
|
393
392
|
|
|
394
|
-
// ── Step 2:
|
|
395
|
-
const
|
|
393
|
+
// ── Step 2: Determine connection method ───────────────────────────────
|
|
394
|
+
const isDirectMode = bindHost === "0.0.0.0";
|
|
396
395
|
const tailscaleIP = getTailscaleIP();
|
|
397
396
|
const localIP = getLocalIP();
|
|
397
|
+
const hasCloudflared = commandExists("cloudflared");
|
|
398
|
+
const displayName = hostname().replace(/\.local$/, "");
|
|
399
|
+
const user = userInfo().username;
|
|
398
400
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (tailscaleIP) methods.push({ value: "tailscale", label: "Tailscale", hint: tailscaleIP });
|
|
402
|
-
methods.push({ value: "ssh", label: "SSH Tunnel", hint: "run SSH on your Mac" });
|
|
403
|
-
if (localIP) methods.push({ value: "lan", label: "Direct LAN", hint: localIP });
|
|
404
|
-
|
|
405
|
-
const method = await ask("How will Navigator connect to this machine?", methods);
|
|
401
|
+
// Best reachable IP for SSH (prefer Tailscale, then LAN)
|
|
402
|
+
const sshTarget = tailscaleIP ?? localIP ?? "this-machine";
|
|
406
403
|
|
|
407
|
-
// ── Step 3: Resolve gateway URL ───────────────────────────────────────
|
|
408
404
|
let gatewayURL;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
405
|
+
let method;
|
|
406
|
+
|
|
407
|
+
if (isDirectMode) {
|
|
408
|
+
// ── Direct mode (--bind 0.0.0.0) — skip SSH, use IP directly ──────
|
|
409
|
+
const methods = [];
|
|
410
|
+
if (hasCloudflared) methods.push({ value: "cloudflare", label: "Cloudflare Tunnel", hint: "works anywhere" });
|
|
411
|
+
if (tailscaleIP) methods.push({ value: "tailscale", label: "Tailscale IP", hint: tailscaleIP });
|
|
412
|
+
if (localIP) methods.push({ value: "lan", label: "Direct LAN", hint: localIP });
|
|
413
|
+
|
|
414
|
+
if (methods.length === 0) {
|
|
415
|
+
gatewayURL = `http://${bindHost}:${port}`;
|
|
416
|
+
method = "direct";
|
|
417
|
+
} else {
|
|
418
|
+
method = await ask("How will Navigator reach this machine?", methods);
|
|
419
|
+
|
|
420
|
+
switch (method) {
|
|
421
|
+
case "cloudflare": {
|
|
422
|
+
process.stdout.write(`\n${DIM}Starting Cloudflare tunnel...${RESET}`);
|
|
423
|
+
const tunnelURL = await startCloudflaredTunnel(port);
|
|
424
|
+
if (!tunnelURL) {
|
|
425
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
426
|
+
fail("Failed to start tunnel.");
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
430
|
+
ok(`Tunnel active: ${tunnelURL}`);
|
|
431
|
+
gatewayURL = tunnelURL;
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
case "tailscale": {
|
|
435
|
+
gatewayURL = `http://${tailscaleIP}:${port}`;
|
|
436
|
+
ok(`Using Tailscale: ${tailscaleIP}:${port}`);
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
case "lan": {
|
|
440
|
+
gatewayURL = `http://${localIP}:${port}`;
|
|
441
|
+
ok(`Using LAN: ${localIP}:${port}`);
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
418
444
|
}
|
|
419
|
-
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
420
|
-
ok(`Tunnel active: ${tunnelURL}`);
|
|
421
|
-
gatewayURL = tunnelURL;
|
|
422
|
-
break;
|
|
423
|
-
}
|
|
424
|
-
case "tailscale": {
|
|
425
|
-
gatewayURL = `http://${tailscaleIP}:${port}`;
|
|
426
|
-
ok(`Using Tailscale: ${tailscaleIP}:${port}`);
|
|
427
|
-
break;
|
|
428
445
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
446
|
+
} else {
|
|
447
|
+
// ── SSH tunnel mode (default) ─────────────────────────────────────
|
|
448
|
+
method = "ssh-tunnel";
|
|
449
|
+
gatewayURL = `http://localhost:${port}`;
|
|
450
|
+
|
|
451
|
+
console.log("");
|
|
452
|
+
console.log(`${BOLD}${MAGENTA}━━━ Step 1: Open SSH tunnel from your Navigator Mac ━━━${RESET}`);
|
|
453
|
+
console.log("");
|
|
454
|
+
console.log(` On your ${BOLD}Navigator Mac${RESET}, open Terminal and run:`);
|
|
455
|
+
console.log("");
|
|
456
|
+
console.log(` ${CYAN}${BOLD}ssh -L ${port}:localhost:${port} ${user}@${sshTarget} -N${RESET}`);
|
|
457
|
+
console.log("");
|
|
458
|
+
|
|
459
|
+
if (tailscaleIP) {
|
|
460
|
+
info(` (${sshTarget} is your Tailscale IP — works from anywhere)`);
|
|
461
|
+
} else if (localIP) {
|
|
462
|
+
info(` (${sshTarget} is your LAN IP — both Macs must be on the same network)`);
|
|
442
463
|
}
|
|
464
|
+
|
|
465
|
+
console.log("");
|
|
466
|
+
info(" This creates an encrypted tunnel. Keep it open while using Navigator.");
|
|
467
|
+
info(" The -N flag means 'no shell' — it just forwards the port.");
|
|
468
|
+
console.log("");
|
|
469
|
+
console.log(`${BOLD}${MAGENTA}━━━ Step 2: Connect Navigator ━━━${RESET}`);
|
|
470
|
+
console.log("");
|
|
471
|
+
console.log(` After SSH is connected, paste this in Navigator's address bar:`);
|
|
472
|
+
console.log("");
|
|
443
473
|
}
|
|
444
474
|
|
|
445
|
-
// ── Step
|
|
446
|
-
const displayName = hostname().replace(/\.local$/, "");
|
|
475
|
+
// ── Step 3: Generate pairing token ────────────────────────────────────
|
|
447
476
|
const token = randomUUID().replace(/-/g, "");
|
|
448
477
|
validTokens.add(token);
|
|
449
478
|
ok("Pairing token generated");
|
|
450
479
|
|
|
451
|
-
// ── Step
|
|
480
|
+
// ── Step 4: Show connection info ──────────────────────────────────────
|
|
452
481
|
const deepLink = `navigator://connect?url=${encodeURIComponent(gatewayURL)}&token=${token}&name=${encodeURIComponent(displayName)}`;
|
|
453
482
|
|
|
454
|
-
|
|
483
|
+
if (method === "ssh-tunnel") {
|
|
484
|
+
// Compact display for SSH mode (instructions already shown above)
|
|
485
|
+
console.log("");
|
|
486
|
+
console.log(` ${CYAN}${BOLD}${deepLink}${RESET}`);
|
|
487
|
+
console.log("");
|
|
488
|
+
info(` Or manually enter in Navigator > Settings > OpenClaw:`);
|
|
489
|
+
info(` URL: ${gatewayURL}`);
|
|
490
|
+
info(` Token: ${token}`);
|
|
491
|
+
console.log("");
|
|
492
|
+
console.log(`${BOLD}${MAGENTA}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
|
|
493
|
+
} else {
|
|
494
|
+
showConnectionBox(gatewayURL, token, deepLink, method);
|
|
495
|
+
}
|
|
455
496
|
|
|
497
|
+
console.log("");
|
|
456
498
|
console.log(`${BOLD}${GREEN}Bridge is running.${RESET} Waiting for Navigator to connect...`);
|
|
457
499
|
console.log(`${DIM}Press Ctrl+C to stop.${RESET}\n`);
|
|
458
500
|
|