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.
- package/cli.mjs +122 -138
- 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();
|
|
@@ -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 ?? "/",
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
385
|
-
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)
|
|
386
358
|
|
|
387
359
|
${BOLD}Options:${RESET}
|
|
388
|
-
--port <port>
|
|
389
|
-
--
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
|
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,
|
|
388
|
+
server.listen(port, bindHost, () => resolve());
|
|
418
389
|
});
|
|
419
390
|
|
|
420
|
-
ok(`Bridge server running on
|
|
421
|
-
info(` Local: http://127.0.0.1:${port}/navigator/status`);
|
|
391
|
+
ok(`Bridge server running on ${bindHost}:${port}`);
|
|
422
392
|
|
|
423
|
-
// ── Step 2:
|
|
424
|
-
const
|
|
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
|
-
|
|
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
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
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
|
|
480
|
+
// ── Step 4: Show connection info ──────────────────────────────────────
|
|
502
481
|
const deepLink = `navigator://connect?url=${encodeURIComponent(gatewayURL)}&token=${token}&name=${encodeURIComponent(displayName)}`;
|
|
503
482
|
|
|
504
|
-
|
|
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
|
-
|
|
504
|
+
server.close();
|
|
521
505
|
process.exit(0);
|
|
522
506
|
});
|
|
523
507
|
|
|
524
508
|
process.on("SIGTERM", () => {
|
|
525
|
-
|
|
509
|
+
server.close();
|
|
526
510
|
process.exit(0);
|
|
527
511
|
});
|
|
528
512
|
}
|