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.
Files changed (2) hide show
  1. package/cli.mjs +120 -78
  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();
@@ -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 ?? "/", `http://localhost`);
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 (push command to Navigator) ──
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 (read recent 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: "1.1.0" });
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 Start bridge + tunnel setup
356
- 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)
357
358
 
358
359
  ${BOLD}Options:${RESET}
359
- --port <port> Bridge server port (default: 18790)
360
- --help Show this help
361
-
362
- ${BOLD}What this does:${RESET}
363
- 1. Starts a local bridge server with Navigator endpoints
364
- 2. Sets up a tunnel so Navigator can reach it
365
- 3. Generates pairing info (URL + Token)
366
- 4. Keeps running as the bridge between Navigator and this machine
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 server for the Navigator browser\n");
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, "0.0.0.0", () => resolve());
388
+ server.listen(port, bindHost, () => resolve());
389
389
  });
390
390
 
391
- ok(`Bridge server running on port ${port}`);
392
- info(` Local: http://127.0.0.1:${port}/navigator/status`);
391
+ ok(`Bridge server running on ${bindHost}:${port}`);
393
392
 
394
- // ── Step 2: Choose connection method ──────────────────────────────────
395
- const hasCloudflared = commandExists("cloudflared");
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
- const methods = [];
400
- if (hasCloudflared) methods.push({ value: "cloudflare", label: "Cloudflare Tunnel (recommended)", hint: "works anywhere, no config" });
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
- switch (method) {
411
- case "cloudflare": {
412
- process.stdout.write(`\n${DIM}Starting Cloudflare tunnel...${RESET}`);
413
- const tunnelURL = await startCloudflaredTunnel(port);
414
- if (!tunnelURL) {
415
- process.stdout.write("\r" + " ".repeat(60) + "\r");
416
- fail("Failed to start tunnel. Install: brew install cloudflare/cloudflare/cloudflared");
417
- process.exit(1);
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
- case "ssh": {
430
- gatewayURL = `http://127.0.0.1:${port}`;
431
- console.log("");
432
- console.log(`${BOLD}Run this on your Mac:${RESET}`);
433
- console.log(` ${CYAN}ssh -L ${port}:127.0.0.1:${port} ${userInfo().username}@${hostname()}${RESET}`);
434
- console.log(`${DIM}Keep SSH open while using Navigator.${RESET}`);
435
- break;
436
- }
437
- case "lan": {
438
- gatewayURL = `http://${localIP}:${port}`;
439
- ok(`Using LAN: ${localIP}:${port}`);
440
- warn("Both machines must be on the same network.");
441
- 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)`);
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 4: Generate pairing token ────────────────────────────────────
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 5: Show result ───────────────────────────────────────────────
480
+ // ── Step 4: Show connection info ──────────────────────────────────────
452
481
  const deepLink = `navigator://connect?url=${encodeURIComponent(gatewayURL)}&token=${token}&name=${encodeURIComponent(displayName)}`;
453
482
 
454
- 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
+ }
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-navigator",
3
- "version": "2.0.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": {