openclaw-navigator 2.1.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.
Files changed (2) hide show
  1. package/cli.mjs +122 -138
  2. 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, sets up a tunnel,
8
- * generates pairing info, and keeps running as the bridge.
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
- // ── Helpers ────────────────────────────────────────────────────────────────
57
+ // ── Network helpers ────────────────────────────────────────────────────────
57
58
 
58
59
  function getLocalIP() {
59
60
  const ifaces = networkInterfaces();
@@ -79,35 +80,6 @@ function getTailscaleIP() {
79
80
  return null;
80
81
  }
81
82
 
82
- function getTailscaleDNS() {
83
- try {
84
- const raw = execSync("tailscale status --json", { encoding: "utf8", timeout: 5000 });
85
- const status = JSON.parse(raw);
86
- const dns = status?.Self?.DNSName;
87
- if (dns) return dns.replace(/\.$/, "");
88
- } catch { /* ignore */ }
89
- return null;
90
- }
91
-
92
- function setupTailscaleServe(localPort, httpsPort = 8443) {
93
- try {
94
- // Set up tailscale serve to proxy HTTPS → our local HTTP server
95
- execSync(`tailscale serve --bg --https=${httpsPort} http://localhost:${localPort}`, {
96
- stdio: "ignore",
97
- timeout: 10_000,
98
- });
99
- return true;
100
- } catch {
101
- return false;
102
- }
103
- }
104
-
105
- function teardownTailscaleServe(httpsPort = 8443) {
106
- try {
107
- execSync(`tailscale serve --https=${httpsPort} off`, { stdio: "ignore", timeout: 5000 });
108
- } catch { /* ignore */ }
109
- }
110
-
111
83
  function commandExists(cmd) {
112
84
  try {
113
85
  execSync(`which ${cmd}`, { stdio: "ignore" });
@@ -117,6 +89,8 @@ function commandExists(cmd) {
117
89
  }
118
90
  }
119
91
 
92
+ // ── Interactive prompt ─────────────────────────────────────────────────────
93
+
120
94
  async function ask(question, options) {
121
95
  const rl = createInterface({ input: process.stdin, output: process.stdout });
122
96
  return new Promise((resolve) => {
@@ -139,6 +113,8 @@ async function ask(question, options) {
139
113
  });
140
114
  }
141
115
 
116
+ // ── Cloudflare tunnel ──────────────────────────────────────────────────────
117
+
142
118
  function startCloudflaredTunnel(port) {
143
119
  return new Promise((resolve) => {
144
120
  const child = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
@@ -201,7 +177,7 @@ function handleRequest(req, res) {
201
177
  return;
202
178
  }
203
179
 
204
- const url = new URL(req.url ?? "/", `http://localhost`);
180
+ const url = new URL(req.url ?? "/", "http://localhost");
205
181
  const path = url.pathname.replace(/\/+$/, "");
206
182
 
207
183
  // ── GET /navigator/status ──
@@ -220,7 +196,6 @@ function handleRequest(req, res) {
220
196
 
221
197
  // ── GET /navigator/commands ──
222
198
  if (req.method === "GET" && path === "/navigator/commands") {
223
- // Mark as connected on first poll
224
199
  if (!bridgeState.connected) {
225
200
  bridgeState.connected = true;
226
201
  bridgeState.connectedAt = Date.now();
@@ -228,7 +203,6 @@ function handleRequest(req, res) {
228
203
  }
229
204
  bridgeState.lastHeartbeat = Date.now();
230
205
 
231
- // Drain pending commands
232
206
  const commands = [...pendingCommands];
233
207
  pendingCommands.length = 0;
234
208
  sendJSON(res, 200, { ok: true, commands });
@@ -252,7 +226,6 @@ function handleRequest(req, res) {
252
226
  recentEvents.push(event);
253
227
  if (recentEvents.length > MAX_EVENTS) recentEvents.shift();
254
228
 
255
- // Update state from heartbeats
256
229
  if (body.type === "heartbeat") {
257
230
  bridgeState.lastHeartbeat = Date.now();
258
231
  bridgeState.activeTabCount = body.data?.tabCount ?? bridgeState.activeTabCount;
@@ -297,7 +270,7 @@ function handleRequest(req, res) {
297
270
  return;
298
271
  }
299
272
 
300
- // ── POST /navigator/command (push command to Navigator) ──
273
+ // ── POST /navigator/command ──
301
274
  if (req.method === "POST" && path === "/navigator/command") {
302
275
  readBody(req).then((bodyStr) => {
303
276
  try {
@@ -323,7 +296,7 @@ function handleRequest(req, res) {
323
296
  return;
324
297
  }
325
298
 
326
- // ── GET /navigator/events (read recent events) ──
299
+ // ── GET /navigator/events ──
327
300
  if (req.method === "GET" && path === "/navigator/events") {
328
301
  const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
329
302
  const events = recentEvents.slice(-Math.min(limit, MAX_EVENTS));
@@ -333,11 +306,10 @@ function handleRequest(req, res) {
333
306
 
334
307
  // ── Health check ──
335
308
  if (req.method === "GET" && (path === "/" || path === "/health")) {
336
- sendJSON(res, 200, { ok: true, service: "openclaw-navigator-bridge", version: "1.1.0" });
309
+ sendJSON(res, 200, { ok: true, service: "openclaw-navigator-bridge", version: "3.0.0" });
337
310
  return;
338
311
  }
339
312
 
340
- // Unknown
341
313
  sendJSON(res, 404, { ok: false, error: "Not found" });
342
314
  }
343
315
 
@@ -363,9 +335,6 @@ function showConnectionBox(gatewayURL, token, deepLink, method) {
363
335
  console.log("");
364
336
  console.log(` ${CYAN}${deepLink}${RESET}`);
365
337
  console.log("");
366
- console.log(`${DIM} Paste URL + Token into Navigator > Settings > OpenClaw${RESET}`);
367
- console.log(`${DIM} Or paste the deep link into the address bar and press Enter.${RESET}`);
368
- console.log("");
369
338
  }
370
339
 
371
340
  // ── Main ───────────────────────────────────────────────────────────────────
@@ -373,35 +342,37 @@ function showConnectionBox(gatewayURL, token, deepLink, method) {
373
342
  async function main() {
374
343
  const args = process.argv.slice(2);
375
344
  let port = 18790;
345
+ let bindHost = "127.0.0.1"; // Default: localhost only (secure, no firewall issues)
376
346
 
377
347
  for (let i = 0; i < args.length; i++) {
378
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];
379
350
  if (args[i] === "--help" || args[i] === "-h") {
380
351
  console.log(`
381
352
  ${BOLD}openclaw-navigator${RESET} — Navigator bridge server
382
353
 
383
354
  ${BOLD}Usage:${RESET}
384
- npx openclaw-navigator Start bridge + tunnel setup
385
- npx openclaw-navigator --port 18790 Use a specific port
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)
386
358
 
387
359
  ${BOLD}Options:${RESET}
388
- --port <port> Bridge server port (default: 18790)
389
- --help Show this help
390
-
391
- ${BOLD}What this does:${RESET}
392
- 1. Starts a local bridge server with Navigator endpoints
393
- 2. Sets up a tunnel so Navigator can reach it
394
- 3. Generates pairing info (URL + Token)
395
- 4. Keeps running as the bridge between Navigator and this machine
396
-
397
- 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.
398
369
  `);
399
370
  process.exit(0);
400
371
  }
401
372
  }
402
373
 
403
374
  heading("🧭 Navigator Bridge");
404
- info("Self-contained bridge server for the Navigator browser\n");
375
+ info("Self-contained bridge for the Navigator browser\n");
405
376
 
406
377
  // ── Step 1: Start HTTP server ─────────────────────────────────────────
407
378
  const server = createServer(handleRequest);
@@ -414,115 +385,128 @@ ${BOLD}What this does:${RESET}
414
385
  }
415
386
  reject(err);
416
387
  });
417
- server.listen(port, "0.0.0.0", () => resolve());
388
+ server.listen(port, bindHost, () => resolve());
418
389
  });
419
390
 
420
- ok(`Bridge server running on port ${port}`);
421
- info(` Local: http://127.0.0.1:${port}/navigator/status`);
391
+ ok(`Bridge server running on ${bindHost}:${port}`);
422
392
 
423
- // ── Step 2: Choose connection method ──────────────────────────────────
424
- const hasCloudflared = commandExists("cloudflared");
393
+ // ── Step 2: Determine connection method ───────────────────────────────
394
+ const isDirectMode = bindHost === "0.0.0.0";
425
395
  const tailscaleIP = getTailscaleIP();
426
396
  const localIP = getLocalIP();
397
+ const hasCloudflared = commandExists("cloudflared");
398
+ const displayName = hostname().replace(/\.local$/, "");
399
+ const user = userInfo().username;
427
400
 
428
- const tailscaleDNS = tailscaleIP ? getTailscaleDNS() : null;
429
-
430
- const methods = [];
431
- if (hasCloudflared) methods.push({ value: "cloudflare", label: "Cloudflare Tunnel (recommended)", hint: "works anywhere, no config" });
432
- if (tailscaleDNS) methods.push({ value: "tailscale-https", label: "Tailscale HTTPS (recommended)", hint: tailscaleDNS });
433
- else if (tailscaleIP) methods.push({ value: "tailscale", label: "Tailscale (raw IP)", hint: tailscaleIP });
434
- methods.push({ value: "ssh", label: "SSH Tunnel", hint: "run SSH on your Mac" });
435
- if (localIP) methods.push({ value: "lan", label: "Direct LAN", hint: localIP });
436
-
437
- 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";
438
403
 
439
- // ── Step 3: Resolve gateway URL ───────────────────────────────────────
440
404
  let gatewayURL;
441
- let usedTailscaleServe = false;
442
- const tailscaleHttpsPort = 8443;
443
-
444
- switch (method) {
445
- case "cloudflare": {
446
- process.stdout.write(`\n${DIM}Starting Cloudflare tunnel...${RESET}`);
447
- const tunnelURL = await startCloudflaredTunnel(port);
448
- if (!tunnelURL) {
449
- process.stdout.write("\r" + " ".repeat(60) + "\r");
450
- fail("Failed to start tunnel. Install: brew install cloudflare/cloudflare/cloudflared");
451
- process.exit(1);
452
- }
453
- process.stdout.write("\r" + " ".repeat(60) + "\r");
454
- ok(`Tunnel active: ${tunnelURL}`);
455
- gatewayURL = tunnelURL;
456
- break;
457
- }
458
- case "tailscale-https": {
459
- process.stdout.write(`\n${DIM}Setting up Tailscale HTTPS on port ${tailscaleHttpsPort}...${RESET}`);
460
- const served = setupTailscaleServe(port, tailscaleHttpsPort);
461
- process.stdout.write("\r" + " ".repeat(70) + "\r");
462
- if (served) {
463
- gatewayURL = `https://${tailscaleDNS}:${tailscaleHttpsPort}`;
464
- usedTailscaleServe = true;
465
- ok(`Tailscale HTTPS active: ${gatewayURL}`);
466
- } else {
467
- // Fallback to raw IP
468
- warn("tailscale serve failed, falling back to raw IP");
469
- gatewayURL = `http://${tailscaleIP}:${port}`;
470
- ok(`Using Tailscale IP: ${tailscaleIP}:${port}`);
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
+ }
471
444
  }
472
- break;
473
- }
474
- case "tailscale": {
475
- gatewayURL = `http://${tailscaleIP}:${port}`;
476
- ok(`Using Tailscale: ${tailscaleIP}:${port}`);
477
- break;
478
445
  }
479
- case "ssh": {
480
- gatewayURL = `http://127.0.0.1:${port}`;
481
- console.log("");
482
- console.log(`${BOLD}Run this on your Mac:${RESET}`);
483
- console.log(` ${CYAN}ssh -L ${port}:127.0.0.1:${port} ${userInfo().username}@${hostname()}${RESET}`);
484
- console.log(`${DIM}Keep SSH open while using Navigator.${RESET}`);
485
- break;
486
- }
487
- case "lan": {
488
- gatewayURL = `http://${localIP}:${port}`;
489
- ok(`Using LAN: ${localIP}:${port}`);
490
- warn("Both machines must be on the same network.");
491
- break;
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)`);
492
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("");
493
473
  }
494
474
 
495
- // ── Step 4: Generate pairing token ────────────────────────────────────
496
- const displayName = hostname().replace(/\.local$/, "");
475
+ // ── Step 3: Generate pairing token ────────────────────────────────────
497
476
  const token = randomUUID().replace(/-/g, "");
498
477
  validTokens.add(token);
499
478
  ok("Pairing token generated");
500
479
 
501
- // ── Step 5: Show result ───────────────────────────────────────────────
480
+ // ── Step 4: Show connection info ──────────────────────────────────────
502
481
  const deepLink = `navigator://connect?url=${encodeURIComponent(gatewayURL)}&token=${token}&name=${encodeURIComponent(displayName)}`;
503
482
 
504
- showConnectionBox(gatewayURL, token, deepLink, method);
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
+ }
505
496
 
497
+ console.log("");
506
498
  console.log(`${BOLD}${GREEN}Bridge is running.${RESET} Waiting for Navigator to connect...`);
507
499
  console.log(`${DIM}Press Ctrl+C to stop.${RESET}\n`);
508
500
 
509
501
  // Graceful shutdown
510
- const cleanup = () => {
511
- if (usedTailscaleServe) {
512
- console.log(`${DIM}Removing tailscale serve...${RESET}`);
513
- teardownTailscaleServe(tailscaleHttpsPort);
514
- }
515
- server.close();
516
- };
517
-
518
502
  process.on("SIGINT", () => {
519
503
  console.log(`\n${DIM}Shutting down bridge...${RESET}`);
520
- cleanup();
504
+ server.close();
521
505
  process.exit(0);
522
506
  });
523
507
 
524
508
  process.on("SIGTERM", () => {
525
- cleanup();
509
+ server.close();
526
510
  process.exit(0);
527
511
  });
528
512
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-navigator",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "Self-contained Navigator bridge server — connects the Navigator browser to any machine",
5
5
  "license": "MIT",
6
6
  "bin": {