openclaw-navigator 2.1.0 → 4.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 +294 -280
- package/package.json +15 -10
package/cli.mjs
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* openclaw-navigator
|
|
4
|
+
* openclaw-navigator v4.0.0
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* Starts
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* Zero dependencies — pure Node.js.
|
|
6
|
+
* One-command bridge + tunnel for the Navigator browser.
|
|
7
|
+
* Starts a local bridge, creates a Cloudflare tunnel automatically,
|
|
8
|
+
* and gives you a 6-digit pairing code. Works on any OS.
|
|
11
9
|
*
|
|
12
10
|
* Usage:
|
|
13
|
-
* npx openclaw-navigator
|
|
14
|
-
* npx openclaw-navigator --
|
|
11
|
+
* npx openclaw-navigator Auto-tunnel (default)
|
|
12
|
+
* npx openclaw-navigator --no-tunnel Local only (SSH/LAN mode)
|
|
13
|
+
* npx openclaw-navigator --port 18790 Custom port
|
|
15
14
|
*/
|
|
16
15
|
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
17
19
|
import { createServer } from "node:http";
|
|
18
|
-
import { createInterface } from "node:readline";
|
|
19
|
-
import { execSync, spawn } from "node:child_process";
|
|
20
20
|
import { networkInterfaces, hostname, userInfo } from "node:os";
|
|
21
|
-
|
|
21
|
+
// readline reserved for future interactive mode
|
|
22
22
|
|
|
23
23
|
// ── Colors (ANSI) ──────────────────────────────────────────────────────────
|
|
24
24
|
|
|
@@ -53,14 +53,26 @@ const recentEvents = [];
|
|
|
53
53
|
const MAX_EVENTS = 200;
|
|
54
54
|
const validTokens = new Set();
|
|
55
55
|
|
|
56
|
-
//
|
|
56
|
+
// Pairing code state
|
|
57
|
+
let pairingCode = null;
|
|
58
|
+
let pairingData = null;
|
|
59
|
+
|
|
60
|
+
function generatePairingCode() {
|
|
61
|
+
return String(Math.floor(100000 + Math.random() * 900000));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Network helpers ────────────────────────────────────────────────────────
|
|
57
65
|
|
|
58
66
|
function getLocalIP() {
|
|
59
67
|
const ifaces = networkInterfaces();
|
|
60
68
|
for (const entries of Object.values(ifaces)) {
|
|
61
|
-
if (!entries)
|
|
69
|
+
if (!entries) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
62
72
|
for (const e of entries) {
|
|
63
|
-
if (e.family === "IPv4" && !e.internal)
|
|
73
|
+
if (e.family === "IPv4" && !e.internal) {
|
|
74
|
+
return e.address;
|
|
75
|
+
}
|
|
64
76
|
}
|
|
65
77
|
}
|
|
66
78
|
return null;
|
|
@@ -69,85 +81,64 @@ function getLocalIP() {
|
|
|
69
81
|
function getTailscaleIP() {
|
|
70
82
|
const ifaces = networkInterfaces();
|
|
71
83
|
for (const entries of Object.values(ifaces)) {
|
|
72
|
-
if (!entries)
|
|
84
|
+
if (!entries) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
73
87
|
for (const e of entries) {
|
|
74
|
-
if (e.family !== "IPv4" || e.internal)
|
|
88
|
+
if (e.family !== "IPv4" || e.internal) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
75
91
|
const [a, b] = e.address.split(".").map(Number);
|
|
76
|
-
if (a === 100 && b >= 64 && b <= 127)
|
|
92
|
+
if (a === 100 && b >= 64 && b <= 127) {
|
|
93
|
+
return e.address;
|
|
94
|
+
}
|
|
77
95
|
}
|
|
78
96
|
}
|
|
79
97
|
return null;
|
|
80
98
|
}
|
|
81
99
|
|
|
82
|
-
|
|
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
|
-
}
|
|
100
|
+
// ── Cloudflared auto-install + tunnel ─────────────────────────────────────
|
|
91
101
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
});
|
|
99
|
-
return true;
|
|
100
|
-
} catch {
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function teardownTailscaleServe(httpsPort = 8443) {
|
|
102
|
+
/**
|
|
103
|
+
* Ensure the cloudflared binary is available.
|
|
104
|
+
* Uses the `cloudflared` npm package which auto-downloads the right binary
|
|
105
|
+
* for the current platform on first use.
|
|
106
|
+
*/
|
|
107
|
+
async function ensureCloudflared() {
|
|
106
108
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
const cf = await import("cloudflared");
|
|
110
|
+
const binPath = cf.bin;
|
|
111
|
+
|
|
112
|
+
if (!existsSync(binPath)) {
|
|
113
|
+
process.stdout.write(` ${DIM}Downloading tunnel tool (one-time)...${RESET}`);
|
|
114
|
+
await cf.install(binPath);
|
|
115
|
+
process.stdout.write(`\r${" ".repeat(60)}\r`);
|
|
116
|
+
ok("Tunnel tool installed");
|
|
117
|
+
}
|
|
110
118
|
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
execSync(`which ${cmd}`, { stdio: "ignore" });
|
|
114
|
-
return true;
|
|
119
|
+
return binPath;
|
|
115
120
|
} catch {
|
|
116
|
-
return
|
|
121
|
+
return null;
|
|
117
122
|
}
|
|
118
123
|
}
|
|
119
124
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
options.forEach((opt, i) => {
|
|
126
|
-
console.log(` ${CYAN}${i + 1}${RESET}) ${opt.label}${opt.hint ? ` ${DIM}${opt.hint}${RESET}` : ""}`);
|
|
127
|
-
});
|
|
128
|
-
rl.question(`\n${DIM}Enter choice [1-${options.length}]:${RESET} `, (answer) => {
|
|
129
|
-
rl.close();
|
|
130
|
-
const idx = parseInt(answer, 10) - 1;
|
|
131
|
-
resolve(options[idx]?.value ?? options[0]?.value);
|
|
132
|
-
});
|
|
133
|
-
} else {
|
|
134
|
-
rl.question(`${question} `, (answer) => {
|
|
135
|
-
rl.close();
|
|
136
|
-
resolve(answer.trim());
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function startCloudflaredTunnel(port) {
|
|
125
|
+
/**
|
|
126
|
+
* Start a Cloudflare Quick Tunnel pointing at our local bridge.
|
|
127
|
+
* Returns the public https:// URL, or null on failure.
|
|
128
|
+
*/
|
|
129
|
+
function startTunnel(cloudflaredBin, port) {
|
|
143
130
|
return new Promise((resolve) => {
|
|
144
|
-
const child = spawn(
|
|
131
|
+
const child = spawn(cloudflaredBin, ["tunnel", "--url", `http://localhost:${port}`], {
|
|
145
132
|
stdio: ["ignore", "pipe", "pipe"],
|
|
146
133
|
});
|
|
147
134
|
|
|
148
135
|
let done = false;
|
|
149
136
|
const timeout = setTimeout(() => {
|
|
150
|
-
if (!done) {
|
|
137
|
+
if (!done) {
|
|
138
|
+
done = true;
|
|
139
|
+
child.kill();
|
|
140
|
+
resolve(null);
|
|
141
|
+
}
|
|
151
142
|
}, 30_000);
|
|
152
143
|
|
|
153
144
|
const onData = (data) => {
|
|
@@ -155,14 +146,19 @@ function startCloudflaredTunnel(port) {
|
|
|
155
146
|
if (match && !done) {
|
|
156
147
|
done = true;
|
|
157
148
|
clearTimeout(timeout);
|
|
158
|
-
child
|
|
159
|
-
resolve(match[0]);
|
|
149
|
+
resolve({ url: match[0], process: child });
|
|
160
150
|
}
|
|
161
151
|
};
|
|
162
152
|
|
|
163
153
|
child.stdout?.on("data", onData);
|
|
164
154
|
child.stderr?.on("data", onData);
|
|
165
|
-
child.on("error", () => {
|
|
155
|
+
child.on("error", () => {
|
|
156
|
+
if (!done) {
|
|
157
|
+
done = true;
|
|
158
|
+
clearTimeout(timeout);
|
|
159
|
+
resolve(null);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
166
162
|
});
|
|
167
163
|
}
|
|
168
164
|
|
|
@@ -173,7 +169,9 @@ async function readBody(req, maxBytes = 1024 * 1024) {
|
|
|
173
169
|
let size = 0;
|
|
174
170
|
for await (const chunk of req) {
|
|
175
171
|
size += chunk.length;
|
|
176
|
-
if (size > maxBytes)
|
|
172
|
+
if (size > maxBytes) {
|
|
173
|
+
throw new Error("payload too large");
|
|
174
|
+
}
|
|
177
175
|
chunks.push(chunk);
|
|
178
176
|
}
|
|
179
177
|
return Buffer.concat(chunks).toString("utf8");
|
|
@@ -201,7 +199,7 @@ function handleRequest(req, res) {
|
|
|
201
199
|
return;
|
|
202
200
|
}
|
|
203
201
|
|
|
204
|
-
const url = new URL(req.url ?? "/",
|
|
202
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
205
203
|
const path = url.pathname.replace(/\/+$/, "");
|
|
206
204
|
|
|
207
205
|
// ── GET /navigator/status ──
|
|
@@ -220,7 +218,6 @@ function handleRequest(req, res) {
|
|
|
220
218
|
|
|
221
219
|
// ── GET /navigator/commands ──
|
|
222
220
|
if (req.method === "GET" && path === "/navigator/commands") {
|
|
223
|
-
// Mark as connected on first poll
|
|
224
221
|
if (!bridgeState.connected) {
|
|
225
222
|
bridgeState.connected = true;
|
|
226
223
|
bridgeState.connectedAt = Date.now();
|
|
@@ -228,7 +225,6 @@ function handleRequest(req, res) {
|
|
|
228
225
|
}
|
|
229
226
|
bridgeState.lastHeartbeat = Date.now();
|
|
230
227
|
|
|
231
|
-
// Drain pending commands
|
|
232
228
|
const commands = [...pendingCommands];
|
|
233
229
|
pendingCommands.length = 0;
|
|
234
230
|
sendJSON(res, 200, { ok: true, commands });
|
|
@@ -237,93 +233,118 @@ function handleRequest(req, res) {
|
|
|
237
233
|
|
|
238
234
|
// ── POST /navigator/events ──
|
|
239
235
|
if (req.method === "POST" && path === "/navigator/events") {
|
|
240
|
-
readBody(req)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
236
|
+
readBody(req)
|
|
237
|
+
.then((bodyStr) => {
|
|
238
|
+
try {
|
|
239
|
+
const body = JSON.parse(bodyStr);
|
|
240
|
+
const event = {
|
|
241
|
+
type: body.type ?? "unknown",
|
|
242
|
+
url: body.url,
|
|
243
|
+
title: body.title,
|
|
244
|
+
content: body.content,
|
|
245
|
+
tabId: body.tabId,
|
|
246
|
+
timestamp: body.timestamp ?? Date.now(),
|
|
247
|
+
data: body.data,
|
|
248
|
+
};
|
|
249
|
+
recentEvents.push(event);
|
|
250
|
+
if (recentEvents.length > MAX_EVENTS) {
|
|
251
|
+
recentEvents.shift();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (body.type === "heartbeat") {
|
|
255
|
+
bridgeState.lastHeartbeat = Date.now();
|
|
256
|
+
bridgeState.activeTabCount = body.data?.tabCount ?? bridgeState.activeTabCount;
|
|
257
|
+
bridgeState.currentURL = body.url ?? bridgeState.currentURL;
|
|
258
|
+
}
|
|
259
|
+
if (body.type === "page.navigated") {
|
|
260
|
+
bridgeState.currentURL = body.url;
|
|
261
|
+
console.log(` ${DIM}📄 ${body.title || body.url}${RESET}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
sendJSON(res, 200, { ok: true, received: event.type });
|
|
265
|
+
} catch {
|
|
266
|
+
sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
|
|
264
267
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
}).catch(() => {
|
|
271
|
-
sendJSON(res, 400, { ok: false, error: "Bad request" });
|
|
272
|
-
});
|
|
268
|
+
})
|
|
269
|
+
.catch(() => {
|
|
270
|
+
sendJSON(res, 400, { ok: false, error: "Bad request" });
|
|
271
|
+
});
|
|
273
272
|
return;
|
|
274
273
|
}
|
|
275
274
|
|
|
276
275
|
// ── POST /navigator/pair ──
|
|
277
276
|
if (req.method === "POST" && path === "/navigator/pair") {
|
|
278
|
-
readBody(req)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
277
|
+
readBody(req)
|
|
278
|
+
.then((bodyStr) => {
|
|
279
|
+
try {
|
|
280
|
+
const body = JSON.parse(bodyStr);
|
|
281
|
+
const displayName = body.displayName ?? hostname();
|
|
282
|
+
const token = randomUUID().replace(/-/g, "");
|
|
283
|
+
validTokens.add(token);
|
|
284
|
+
|
|
285
|
+
sendJSON(res, 200, {
|
|
286
|
+
ok: true,
|
|
287
|
+
token,
|
|
288
|
+
displayName,
|
|
289
|
+
expiresIn: "24 hours",
|
|
290
|
+
});
|
|
291
|
+
} catch {
|
|
292
|
+
sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
.catch(() => {
|
|
296
|
+
sendJSON(res, 400, { ok: false, error: "Bad request" });
|
|
297
|
+
});
|
|
297
298
|
return;
|
|
298
299
|
}
|
|
299
300
|
|
|
300
|
-
// ── POST /navigator/command
|
|
301
|
+
// ── POST /navigator/command ──
|
|
301
302
|
if (req.method === "POST" && path === "/navigator/command") {
|
|
302
|
-
readBody(req)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
303
|
+
readBody(req)
|
|
304
|
+
.then((bodyStr) => {
|
|
305
|
+
try {
|
|
306
|
+
const body = JSON.parse(bodyStr);
|
|
307
|
+
const command = body.command ?? body.action;
|
|
308
|
+
const payload = body.payload ?? {};
|
|
309
|
+
|
|
310
|
+
if (!command || typeof command !== "string") {
|
|
311
|
+
sendJSON(res, 400, { ok: false, error: "Missing 'command' field" });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const id = randomUUID();
|
|
316
|
+
pendingCommands.push({ id, command, payload, createdAt: Date.now() });
|
|
317
|
+
console.log(` ${CYAN}⌘${RESET} Command queued: ${command}`);
|
|
318
|
+
sendJSON(res, 200, { ok: true, commandId: id, command });
|
|
319
|
+
} catch {
|
|
320
|
+
sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
|
|
311
321
|
}
|
|
322
|
+
})
|
|
323
|
+
.catch(() => {
|
|
324
|
+
sendJSON(res, 400, { ok: false, error: "Bad request" });
|
|
325
|
+
});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
312
328
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
329
|
+
// ── GET /navigator/resolve-code ──
|
|
330
|
+
// Navigator sends the 6-digit code; bridge returns URL + token for auto-connect
|
|
331
|
+
if (req.method === "GET" && path === "/navigator/resolve-code") {
|
|
332
|
+
const code = url.searchParams.get("code")?.trim();
|
|
333
|
+
if (!code || !pairingCode || code !== pairingCode) {
|
|
334
|
+
sendJSON(res, 403, { ok: false, error: "Invalid pairing code" });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// Code is valid — return connection details
|
|
338
|
+
sendJSON(res, 200, {
|
|
339
|
+
ok: true,
|
|
340
|
+
url: pairingData.url,
|
|
341
|
+
token: pairingData.token,
|
|
342
|
+
name: pairingData.name,
|
|
322
343
|
});
|
|
323
344
|
return;
|
|
324
345
|
}
|
|
325
346
|
|
|
326
|
-
// ── GET /navigator/events
|
|
347
|
+
// ── GET /navigator/events ──
|
|
327
348
|
if (req.method === "GET" && path === "/navigator/events") {
|
|
328
349
|
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
|
|
329
350
|
const events = recentEvents.slice(-Math.min(limit, MAX_EVENTS));
|
|
@@ -333,38 +354,34 @@ function handleRequest(req, res) {
|
|
|
333
354
|
|
|
334
355
|
// ── Health check ──
|
|
335
356
|
if (req.method === "GET" && (path === "/" || path === "/health")) {
|
|
336
|
-
sendJSON(res, 200, { ok: true, service: "openclaw-navigator-bridge", version: "
|
|
357
|
+
sendJSON(res, 200, { ok: true, service: "openclaw-navigator-bridge", version: "4.0.0" });
|
|
337
358
|
return;
|
|
338
359
|
}
|
|
339
360
|
|
|
340
|
-
// Unknown
|
|
341
361
|
sendJSON(res, 404, { ok: false, error: "Not found" });
|
|
342
362
|
}
|
|
343
363
|
|
|
344
|
-
// ──
|
|
345
|
-
|
|
346
|
-
function showConnectionBox(gatewayURL, token, deepLink, method) {
|
|
347
|
-
const w = 58;
|
|
348
|
-
const bar = "═".repeat(w);
|
|
349
|
-
const pad = (s) => s + " ".repeat(Math.max(0, w - s.length));
|
|
364
|
+
// ── Display helpers ────────────────────────────────────────────────────────
|
|
350
365
|
|
|
366
|
+
function showPairingCode(code) {
|
|
367
|
+
const spaced = code.split("").join(" ");
|
|
351
368
|
console.log("");
|
|
352
|
-
console.log(`${MAGENTA}
|
|
353
|
-
console.log(
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
console.log(`${MAGENTA}║${RESET}${
|
|
357
|
-
console.log(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
console.log(`${MAGENTA}
|
|
361
|
-
console.log(
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
console.log(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
console.log(`${
|
|
369
|
+
console.log(`${MAGENTA}╔══════════════════════════════════════════╗${RESET}`);
|
|
370
|
+
console.log(
|
|
371
|
+
`${MAGENTA}║${RESET} ${BOLD}Your Pairing Code:${RESET} ${MAGENTA}║${RESET}`,
|
|
372
|
+
);
|
|
373
|
+
console.log(`${MAGENTA}║${RESET} ${MAGENTA}║${RESET}`);
|
|
374
|
+
console.log(
|
|
375
|
+
`${MAGENTA}║${RESET} ${BOLD}${GREEN} ${spaced} ${RESET} ${MAGENTA}║${RESET}`,
|
|
376
|
+
);
|
|
377
|
+
console.log(`${MAGENTA}║${RESET} ${MAGENTA}║${RESET}`);
|
|
378
|
+
console.log(
|
|
379
|
+
`${MAGENTA}║${RESET} ${DIM}Enter this in Navigator > Settings > ${RESET}${MAGENTA}║${RESET}`,
|
|
380
|
+
);
|
|
381
|
+
console.log(
|
|
382
|
+
`${MAGENTA}║${RESET} ${DIM}OpenClaw > Quick Connect${RESET} ${MAGENTA}║${RESET}`,
|
|
383
|
+
);
|
|
384
|
+
console.log(`${MAGENTA}╚══════════════════════════════════════════╝${RESET}`);
|
|
368
385
|
console.log("");
|
|
369
386
|
}
|
|
370
387
|
|
|
@@ -373,35 +390,47 @@ function showConnectionBox(gatewayURL, token, deepLink, method) {
|
|
|
373
390
|
async function main() {
|
|
374
391
|
const args = process.argv.slice(2);
|
|
375
392
|
let port = 18790;
|
|
393
|
+
let bindHost = "127.0.0.1";
|
|
394
|
+
let noTunnel = false;
|
|
376
395
|
|
|
377
396
|
for (let i = 0; i < args.length; i++) {
|
|
378
|
-
if (args[i] === "--port" && args[i + 1])
|
|
397
|
+
if (args[i] === "--port" && args[i + 1]) {
|
|
398
|
+
port = parseInt(args[i + 1], 10);
|
|
399
|
+
}
|
|
400
|
+
if (args[i] === "--bind" && args[i + 1]) {
|
|
401
|
+
bindHost = args[i + 1];
|
|
402
|
+
}
|
|
403
|
+
if (args[i] === "--no-tunnel") {
|
|
404
|
+
noTunnel = true;
|
|
405
|
+
}
|
|
379
406
|
if (args[i] === "--help" || args[i] === "-h") {
|
|
380
407
|
console.log(`
|
|
381
|
-
${BOLD}openclaw-navigator${RESET} —
|
|
408
|
+
${BOLD}openclaw-navigator${RESET} — One-command bridge + tunnel for Navigator
|
|
382
409
|
|
|
383
410
|
${BOLD}Usage:${RESET}
|
|
384
|
-
npx openclaw-navigator
|
|
385
|
-
npx openclaw-navigator --
|
|
411
|
+
npx openclaw-navigator Auto-tunnel mode (default, works anywhere)
|
|
412
|
+
npx openclaw-navigator --no-tunnel Local-only mode (SSH/LAN)
|
|
413
|
+
npx openclaw-navigator --port 18790 Custom port
|
|
386
414
|
|
|
387
415
|
${BOLD}Options:${RESET}
|
|
388
|
-
--port <port>
|
|
389
|
-
--
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
416
|
+
--port <port> Bridge server port (default: 18790)
|
|
417
|
+
--no-tunnel Skip auto-tunnel, use SSH or LAN instead
|
|
418
|
+
--bind <host> Bind address (default: 127.0.0.1)
|
|
419
|
+
--help Show this help
|
|
420
|
+
|
|
421
|
+
${BOLD}How it works:${RESET}
|
|
422
|
+
1. Starts a bridge server on localhost
|
|
423
|
+
2. Creates a secure Cloudflare tunnel automatically (one-time download)
|
|
424
|
+
3. Shows a 6-digit pairing code
|
|
425
|
+
4. Enter the code in Navigator > Settings > OpenClaw > Quick Connect
|
|
426
|
+
5. That's it. Magic starts happening.
|
|
398
427
|
`);
|
|
399
428
|
process.exit(0);
|
|
400
429
|
}
|
|
401
430
|
}
|
|
402
431
|
|
|
403
432
|
heading("🧭 Navigator Bridge");
|
|
404
|
-
info("
|
|
433
|
+
info("One-command bridge + tunnel for the Navigator browser\n");
|
|
405
434
|
|
|
406
435
|
// ── Step 1: Start HTTP server ─────────────────────────────────────────
|
|
407
436
|
const server = createServer(handleRequest);
|
|
@@ -414,117 +443,102 @@ ${BOLD}What this does:${RESET}
|
|
|
414
443
|
}
|
|
415
444
|
reject(err);
|
|
416
445
|
});
|
|
417
|
-
server.listen(port,
|
|
446
|
+
server.listen(port, bindHost, () => resolve());
|
|
418
447
|
});
|
|
419
448
|
|
|
420
|
-
ok(`Bridge server running on
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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}`);
|
|
449
|
+
ok(`Bridge server running on ${bindHost}:${port}`);
|
|
450
|
+
|
|
451
|
+
// ── Step 2: Set up connectivity ───────────────────────────────────────
|
|
452
|
+
const displayName = hostname().replace(/\.local$/, "");
|
|
453
|
+
let gatewayURL = `http://localhost:${port}`;
|
|
454
|
+
let tunnelURL = null;
|
|
455
|
+
let tunnelProcess = null;
|
|
456
|
+
|
|
457
|
+
if (!noTunnel) {
|
|
458
|
+
// ── Auto-tunnel mode (default) ──────────────────────────────────
|
|
459
|
+
process.stdout.write(` ${DIM}Setting up tunnel...${RESET}`);
|
|
460
|
+
const cloudflaredBin = await ensureCloudflared();
|
|
461
|
+
|
|
462
|
+
if (cloudflaredBin) {
|
|
463
|
+
const result = await startTunnel(cloudflaredBin, port);
|
|
464
|
+
process.stdout.write(`\r${" ".repeat(60)}\r`);
|
|
465
|
+
|
|
466
|
+
if (result) {
|
|
467
|
+
tunnelURL = result.url;
|
|
468
|
+
tunnelProcess = result.process;
|
|
469
|
+
gatewayURL = tunnelURL; // Use tunnel URL as the gateway URL
|
|
470
|
+
|
|
471
|
+
ok(`Tunnel active: ${CYAN}${tunnelURL}${RESET}`);
|
|
466
472
|
} else {
|
|
467
|
-
|
|
468
|
-
warn("
|
|
469
|
-
|
|
470
|
-
ok(`Using Tailscale IP: ${tailscaleIP}:${port}`);
|
|
473
|
+
process.stdout.write(`\r${" ".repeat(60)}\r`);
|
|
474
|
+
warn("Tunnel failed to start. Falling back to local-only mode.");
|
|
475
|
+
warn("Navigator must be on the same machine, or use --no-tunnel + SSH.");
|
|
471
476
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
gatewayURL = `http://${tailscaleIP}:${port}`;
|
|
476
|
-
ok(`Using Tailscale: ${tailscaleIP}:${port}`);
|
|
477
|
-
break;
|
|
478
|
-
}
|
|
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;
|
|
477
|
+
} else {
|
|
478
|
+
process.stdout.write(`\r${" ".repeat(60)}\r`);
|
|
479
|
+
warn("Could not install tunnel tool. Falling back to local-only mode.");
|
|
486
480
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
481
|
+
} else {
|
|
482
|
+
// ── No-tunnel mode (SSH/LAN) ────────────────────────────────────
|
|
483
|
+
const tailscaleIP = getTailscaleIP();
|
|
484
|
+
const localIP = getLocalIP();
|
|
485
|
+
const user = userInfo().username;
|
|
486
|
+
const sshTarget = tailscaleIP ?? localIP ?? "this-machine";
|
|
487
|
+
|
|
488
|
+
console.log("");
|
|
489
|
+
console.log(`${BOLD}${MAGENTA}━━━ SSH Tunnel (for remote Navigator) ━━━${RESET}`);
|
|
490
|
+
console.log(` On your Navigator Mac, run:`);
|
|
491
|
+
console.log(` ${CYAN}${BOLD}ssh -L ${port}:localhost:${port} ${user}@${sshTarget} -N${RESET}`);
|
|
492
|
+
if (tailscaleIP) {
|
|
493
|
+
info(` (${sshTarget} is your Tailscale IP)`);
|
|
494
|
+
} else if (localIP) {
|
|
495
|
+
info(` (${sshTarget} is your LAN IP — same network required)`);
|
|
492
496
|
}
|
|
497
|
+
console.log("");
|
|
493
498
|
}
|
|
494
499
|
|
|
495
|
-
// ── Step
|
|
496
|
-
const displayName = hostname().replace(/\.local$/, "");
|
|
500
|
+
// ── Step 3: Generate pairing token + code ─────────────────────────────
|
|
497
501
|
const token = randomUUID().replace(/-/g, "");
|
|
498
502
|
validTokens.add(token);
|
|
499
|
-
|
|
503
|
+
pairingCode = generatePairingCode();
|
|
504
|
+
pairingData = { url: gatewayURL, token, name: displayName };
|
|
500
505
|
|
|
501
|
-
// ── Step
|
|
506
|
+
// ── Step 4: Show connection info ──────────────────────────────────────
|
|
507
|
+
showPairingCode(pairingCode);
|
|
508
|
+
|
|
509
|
+
// Deep link (always show as fallback)
|
|
502
510
|
const deepLink = `navigator://connect?url=${encodeURIComponent(gatewayURL)}&token=${token}&name=${encodeURIComponent(displayName)}`;
|
|
503
511
|
|
|
504
|
-
|
|
512
|
+
if (tunnelURL) {
|
|
513
|
+
console.log(` ${DIM}Or paste the deep link in Navigator's address bar:${RESET}`);
|
|
514
|
+
console.log(` ${CYAN}${deepLink}${RESET}`);
|
|
515
|
+
console.log("");
|
|
516
|
+
info(` Tunnel URL: ${tunnelURL}`);
|
|
517
|
+
info(` Token: ${token}`);
|
|
518
|
+
} else {
|
|
519
|
+
info(` Or paste the deep link in Navigator's address bar:`);
|
|
520
|
+
console.log(` ${CYAN}${deepLink}${RESET}`);
|
|
521
|
+
console.log("");
|
|
522
|
+
info(` URL: ${gatewayURL}`);
|
|
523
|
+
info(` Token: ${token}`);
|
|
524
|
+
}
|
|
505
525
|
|
|
526
|
+
console.log("");
|
|
506
527
|
console.log(`${BOLD}${GREEN}Bridge is running.${RESET} Waiting for Navigator to connect...`);
|
|
507
528
|
console.log(`${DIM}Press Ctrl+C to stop.${RESET}\n`);
|
|
508
529
|
|
|
509
|
-
// Graceful shutdown
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
530
|
+
// ── Graceful shutdown ─────────────────────────────────────────────────
|
|
531
|
+
const shutdown = () => {
|
|
532
|
+
console.log(`\n${DIM}Shutting down bridge...${RESET}`);
|
|
533
|
+
if (tunnelProcess) {
|
|
534
|
+
tunnelProcess.kill();
|
|
514
535
|
}
|
|
515
536
|
server.close();
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
process.on("SIGINT", () => {
|
|
519
|
-
console.log(`\n${DIM}Shutting down bridge...${RESET}`);
|
|
520
|
-
cleanup();
|
|
521
537
|
process.exit(0);
|
|
522
|
-
}
|
|
538
|
+
};
|
|
523
539
|
|
|
524
|
-
process.on("
|
|
525
|
-
|
|
526
|
-
process.exit(0);
|
|
527
|
-
});
|
|
540
|
+
process.on("SIGINT", shutdown);
|
|
541
|
+
process.on("SIGTERM", shutdown);
|
|
528
542
|
}
|
|
529
543
|
|
|
530
544
|
main().catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-navigator",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "One-command bridge + tunnel for the Navigator browser — works on any machine, any OS",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"browser",
|
|
7
|
+
"cloudflare",
|
|
8
|
+
"gateway",
|
|
9
|
+
"navigator",
|
|
10
|
+
"openclaw",
|
|
11
|
+
"setup",
|
|
12
|
+
"tunnel"
|
|
13
|
+
],
|
|
5
14
|
"license": "MIT",
|
|
6
15
|
"bin": {
|
|
7
16
|
"openclaw-navigator": "cli.mjs"
|
|
@@ -10,14 +19,10 @@
|
|
|
10
19
|
"cli.mjs"
|
|
11
20
|
],
|
|
12
21
|
"type": "module",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"cloudflared": "^0.7.1"
|
|
24
|
+
},
|
|
13
25
|
"engines": {
|
|
14
26
|
"node": ">=18.0.0"
|
|
15
|
-
}
|
|
16
|
-
"keywords": [
|
|
17
|
-
"openclaw",
|
|
18
|
-
"navigator",
|
|
19
|
-
"browser",
|
|
20
|
-
"gateway",
|
|
21
|
-
"setup"
|
|
22
|
-
]
|
|
27
|
+
}
|
|
23
28
|
}
|