openclaw-navigator 1.0.0 → 2.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 +254 -85
- package/package.json +2 -2
package/cli.mjs
CHANGED
|
@@ -3,18 +3,22 @@
|
|
|
3
3
|
/**
|
|
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.
|
|
9
|
+
*
|
|
7
10
|
* Zero dependencies — pure Node.js.
|
|
8
11
|
*
|
|
9
12
|
* Usage:
|
|
10
13
|
* npx openclaw-navigator
|
|
11
|
-
* npx openclaw-navigator --port
|
|
12
|
-
* npx openclaw-navigator --url http://192.168.1.50:18789
|
|
14
|
+
* npx openclaw-navigator --port 18790
|
|
13
15
|
*/
|
|
14
16
|
|
|
17
|
+
import { createServer } from "node:http";
|
|
15
18
|
import { createInterface } from "node:readline";
|
|
16
19
|
import { execSync, spawn } from "node:child_process";
|
|
17
20
|
import { networkInterfaces, hostname, userInfo } from "node:os";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
18
22
|
|
|
19
23
|
// ── Colors (ANSI) ──────────────────────────────────────────────────────────
|
|
20
24
|
|
|
@@ -33,6 +37,22 @@ const fail = (msg) => console.error(`${RED}✗${RESET} ${msg}`);
|
|
|
33
37
|
const info = (msg) => console.log(`${DIM}${msg}${RESET}`);
|
|
34
38
|
const heading = (msg) => console.log(`\n${BOLD}${MAGENTA}${msg}${RESET}`);
|
|
35
39
|
|
|
40
|
+
// ── In-memory bridge state ─────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const bridgeState = {
|
|
43
|
+
connected: false,
|
|
44
|
+
connectedAt: null,
|
|
45
|
+
lastHeartbeat: null,
|
|
46
|
+
activeTabCount: 0,
|
|
47
|
+
currentURL: null,
|
|
48
|
+
displayName: null,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const pendingCommands = [];
|
|
52
|
+
const recentEvents = [];
|
|
53
|
+
const MAX_EVENTS = 200;
|
|
54
|
+
const validTokens = new Set();
|
|
55
|
+
|
|
36
56
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
37
57
|
|
|
38
58
|
function getLocalIP() {
|
|
@@ -90,34 +110,6 @@ async function ask(question, options) {
|
|
|
90
110
|
});
|
|
91
111
|
}
|
|
92
112
|
|
|
93
|
-
async function checkGateway(baseURL) {
|
|
94
|
-
try {
|
|
95
|
-
const resp = await fetch(`${baseURL}/navigator/status`, {
|
|
96
|
-
signal: AbortSignal.timeout(5000),
|
|
97
|
-
});
|
|
98
|
-
if (!resp.ok) return false;
|
|
99
|
-
const json = await resp.json();
|
|
100
|
-
return json.ok === true;
|
|
101
|
-
} catch {
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async function generateToken(baseURL, displayName) {
|
|
107
|
-
try {
|
|
108
|
-
const resp = await fetch(`${baseURL}/navigator/pair`, {
|
|
109
|
-
method: "POST",
|
|
110
|
-
headers: { "Content-Type": "application/json" },
|
|
111
|
-
body: JSON.stringify({ displayName }),
|
|
112
|
-
signal: AbortSignal.timeout(5000),
|
|
113
|
-
});
|
|
114
|
-
if (!resp.ok) return null;
|
|
115
|
-
return await resp.json();
|
|
116
|
-
} catch {
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
113
|
function startCloudflaredTunnel(port) {
|
|
122
114
|
return new Promise((resolve) => {
|
|
123
115
|
const child = spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], {
|
|
@@ -145,6 +137,181 @@ function startCloudflaredTunnel(port) {
|
|
|
145
137
|
});
|
|
146
138
|
}
|
|
147
139
|
|
|
140
|
+
// ── HTTP Server (Navigator Bridge) ────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
async function readBody(req, maxBytes = 1024 * 1024) {
|
|
143
|
+
const chunks = [];
|
|
144
|
+
let size = 0;
|
|
145
|
+
for await (const chunk of req) {
|
|
146
|
+
size += chunk.length;
|
|
147
|
+
if (size > maxBytes) throw new Error("payload too large");
|
|
148
|
+
chunks.push(chunk);
|
|
149
|
+
}
|
|
150
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sendJSON(res, status, body) {
|
|
154
|
+
res.writeHead(status, {
|
|
155
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
156
|
+
"Access-Control-Allow-Origin": "*",
|
|
157
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
158
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
159
|
+
});
|
|
160
|
+
res.end(JSON.stringify(body));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function handleRequest(req, res) {
|
|
164
|
+
// CORS preflight
|
|
165
|
+
if (req.method === "OPTIONS") {
|
|
166
|
+
res.writeHead(204, {
|
|
167
|
+
"Access-Control-Allow-Origin": "*",
|
|
168
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
169
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
170
|
+
});
|
|
171
|
+
res.end();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
176
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
177
|
+
|
|
178
|
+
// ── GET /navigator/status ──
|
|
179
|
+
if (req.method === "GET" && (path === "/navigator/status" || path === "/navigator")) {
|
|
180
|
+
sendJSON(res, 200, {
|
|
181
|
+
ok: true,
|
|
182
|
+
navigator: {
|
|
183
|
+
...bridgeState,
|
|
184
|
+
uptime: bridgeState.connectedAt ? Date.now() - bridgeState.connectedAt : null,
|
|
185
|
+
pendingCommandCount: pendingCommands.length,
|
|
186
|
+
recentEventCount: recentEvents.length,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── GET /navigator/commands ──
|
|
193
|
+
if (req.method === "GET" && path === "/navigator/commands") {
|
|
194
|
+
// Mark as connected on first poll
|
|
195
|
+
if (!bridgeState.connected) {
|
|
196
|
+
bridgeState.connected = true;
|
|
197
|
+
bridgeState.connectedAt = Date.now();
|
|
198
|
+
console.log(`\n${GREEN}✓${RESET} ${BOLD}Navigator connected!${RESET}`);
|
|
199
|
+
}
|
|
200
|
+
bridgeState.lastHeartbeat = Date.now();
|
|
201
|
+
|
|
202
|
+
// Drain pending commands
|
|
203
|
+
const commands = [...pendingCommands];
|
|
204
|
+
pendingCommands.length = 0;
|
|
205
|
+
sendJSON(res, 200, { ok: true, commands });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── POST /navigator/events ──
|
|
210
|
+
if (req.method === "POST" && path === "/navigator/events") {
|
|
211
|
+
readBody(req).then((bodyStr) => {
|
|
212
|
+
try {
|
|
213
|
+
const body = JSON.parse(bodyStr);
|
|
214
|
+
const event = {
|
|
215
|
+
type: body.type ?? "unknown",
|
|
216
|
+
url: body.url,
|
|
217
|
+
title: body.title,
|
|
218
|
+
content: body.content,
|
|
219
|
+
tabId: body.tabId,
|
|
220
|
+
timestamp: body.timestamp ?? Date.now(),
|
|
221
|
+
data: body.data,
|
|
222
|
+
};
|
|
223
|
+
recentEvents.push(event);
|
|
224
|
+
if (recentEvents.length > MAX_EVENTS) recentEvents.shift();
|
|
225
|
+
|
|
226
|
+
// Update state from heartbeats
|
|
227
|
+
if (body.type === "heartbeat") {
|
|
228
|
+
bridgeState.lastHeartbeat = Date.now();
|
|
229
|
+
bridgeState.activeTabCount = body.data?.tabCount ?? bridgeState.activeTabCount;
|
|
230
|
+
bridgeState.currentURL = body.url ?? bridgeState.currentURL;
|
|
231
|
+
}
|
|
232
|
+
if (body.type === "page.navigated") {
|
|
233
|
+
bridgeState.currentURL = body.url;
|
|
234
|
+
console.log(` ${DIM}📄 ${body.title || body.url}${RESET}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
sendJSON(res, 200, { ok: true, received: event.type });
|
|
238
|
+
} catch {
|
|
239
|
+
sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
|
|
240
|
+
}
|
|
241
|
+
}).catch(() => {
|
|
242
|
+
sendJSON(res, 400, { ok: false, error: "Bad request" });
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── POST /navigator/pair ──
|
|
248
|
+
if (req.method === "POST" && path === "/navigator/pair") {
|
|
249
|
+
readBody(req).then((bodyStr) => {
|
|
250
|
+
try {
|
|
251
|
+
const body = JSON.parse(bodyStr);
|
|
252
|
+
const displayName = body.displayName ?? hostname();
|
|
253
|
+
const token = randomUUID().replace(/-/g, "");
|
|
254
|
+
validTokens.add(token);
|
|
255
|
+
|
|
256
|
+
sendJSON(res, 200, {
|
|
257
|
+
ok: true,
|
|
258
|
+
token,
|
|
259
|
+
displayName,
|
|
260
|
+
expiresIn: "24 hours",
|
|
261
|
+
});
|
|
262
|
+
} catch {
|
|
263
|
+
sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
|
|
264
|
+
}
|
|
265
|
+
}).catch(() => {
|
|
266
|
+
sendJSON(res, 400, { ok: false, error: "Bad request" });
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── POST /navigator/command (push command to Navigator) ──
|
|
272
|
+
if (req.method === "POST" && path === "/navigator/command") {
|
|
273
|
+
readBody(req).then((bodyStr) => {
|
|
274
|
+
try {
|
|
275
|
+
const body = JSON.parse(bodyStr);
|
|
276
|
+
const command = body.command ?? body.action;
|
|
277
|
+
const payload = body.payload ?? {};
|
|
278
|
+
|
|
279
|
+
if (!command || typeof command !== "string") {
|
|
280
|
+
sendJSON(res, 400, { ok: false, error: "Missing 'command' field" });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const id = randomUUID();
|
|
285
|
+
pendingCommands.push({ id, command, payload, createdAt: Date.now() });
|
|
286
|
+
console.log(` ${CYAN}⌘${RESET} Command queued: ${command}`);
|
|
287
|
+
sendJSON(res, 200, { ok: true, commandId: id, command });
|
|
288
|
+
} catch {
|
|
289
|
+
sendJSON(res, 400, { ok: false, error: "Invalid JSON" });
|
|
290
|
+
}
|
|
291
|
+
}).catch(() => {
|
|
292
|
+
sendJSON(res, 400, { ok: false, error: "Bad request" });
|
|
293
|
+
});
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── GET /navigator/events (read recent events) ──
|
|
298
|
+
if (req.method === "GET" && path === "/navigator/events") {
|
|
299
|
+
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
|
|
300
|
+
const events = recentEvents.slice(-Math.min(limit, MAX_EVENTS));
|
|
301
|
+
sendJSON(res, 200, { ok: true, events, total: recentEvents.length });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Health check ──
|
|
306
|
+
if (req.method === "GET" && (path === "/" || path === "/health")) {
|
|
307
|
+
sendJSON(res, 200, { ok: true, service: "openclaw-navigator-bridge", version: "1.1.0" });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Unknown
|
|
312
|
+
sendJSON(res, 404, { ok: false, error: "Not found" });
|
|
313
|
+
}
|
|
314
|
+
|
|
148
315
|
// ── Connection Box ─────────────────────────────────────────────────────────
|
|
149
316
|
|
|
150
317
|
function showConnectionBox(gatewayURL, token, deepLink, method) {
|
|
@@ -175,68 +342,69 @@ function showConnectionBox(gatewayURL, token, deepLink, method) {
|
|
|
175
342
|
// ── Main ───────────────────────────────────────────────────────────────────
|
|
176
343
|
|
|
177
344
|
async function main() {
|
|
178
|
-
// Parse args
|
|
179
345
|
const args = process.argv.slice(2);
|
|
180
|
-
let port =
|
|
181
|
-
let explicitURL = null;
|
|
346
|
+
let port = 18790;
|
|
182
347
|
|
|
183
348
|
for (let i = 0; i < args.length; i++) {
|
|
184
349
|
if (args[i] === "--port" && args[i + 1]) port = parseInt(args[i + 1], 10);
|
|
185
|
-
if (args[i] === "--url" && args[i + 1]) explicitURL = args[i + 1];
|
|
186
350
|
if (args[i] === "--help" || args[i] === "-h") {
|
|
187
351
|
console.log(`
|
|
188
|
-
${BOLD}openclaw-navigator${RESET} —
|
|
352
|
+
${BOLD}openclaw-navigator${RESET} — Navigator bridge server
|
|
189
353
|
|
|
190
354
|
${BOLD}Usage:${RESET}
|
|
191
|
-
npx openclaw-navigator
|
|
192
|
-
npx openclaw-navigator --port
|
|
193
|
-
npx openclaw-navigator --url http://192.168.1.50:18789
|
|
355
|
+
npx openclaw-navigator Start bridge + tunnel setup
|
|
356
|
+
npx openclaw-navigator --port 18790 Use a specific port
|
|
194
357
|
|
|
195
358
|
${BOLD}Options:${RESET}
|
|
196
|
-
--port <port>
|
|
197
|
-
--url <url> Explicit gateway HTTP base URL
|
|
359
|
+
--port <port> Bridge server port (default: 18790)
|
|
198
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.
|
|
199
369
|
`);
|
|
200
370
|
process.exit(0);
|
|
201
371
|
}
|
|
202
372
|
}
|
|
203
373
|
|
|
204
|
-
|
|
374
|
+
heading("🧭 Navigator Bridge");
|
|
375
|
+
info("Self-contained bridge server for the Navigator browser\n");
|
|
205
376
|
|
|
206
|
-
|
|
207
|
-
|
|
377
|
+
// ── Step 1: Start HTTP server ─────────────────────────────────────────
|
|
378
|
+
const server = createServer(handleRequest);
|
|
208
379
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
console.log("");
|
|
220
|
-
process.exit(1);
|
|
221
|
-
}
|
|
380
|
+
await new Promise((resolve, reject) => {
|
|
381
|
+
server.on("error", (err) => {
|
|
382
|
+
if (err.code === "EADDRINUSE") {
|
|
383
|
+
fail(`Port ${port} is already in use. Try: npx openclaw-navigator --port ${port + 1}`);
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
reject(err);
|
|
387
|
+
});
|
|
388
|
+
server.listen(port, "0.0.0.0", () => resolve());
|
|
389
|
+
});
|
|
222
390
|
|
|
223
|
-
|
|
224
|
-
|
|
391
|
+
ok(`Bridge server running on port ${port}`);
|
|
392
|
+
info(` Local: http://127.0.0.1:${port}/navigator/status`);
|
|
225
393
|
|
|
226
|
-
// ── Step 2: Choose connection method
|
|
394
|
+
// ── Step 2: Choose connection method ──────────────────────────────────
|
|
227
395
|
const hasCloudflared = commandExists("cloudflared");
|
|
228
396
|
const tailscaleIP = getTailscaleIP();
|
|
229
397
|
const localIP = getLocalIP();
|
|
230
398
|
|
|
231
399
|
const methods = [];
|
|
232
|
-
if (hasCloudflared) methods.push({ value: "cloudflare", label: "Cloudflare Tunnel (recommended)", hint: "works anywhere" });
|
|
400
|
+
if (hasCloudflared) methods.push({ value: "cloudflare", label: "Cloudflare Tunnel (recommended)", hint: "works anywhere, no config" });
|
|
233
401
|
if (tailscaleIP) methods.push({ value: "tailscale", label: "Tailscale", hint: tailscaleIP });
|
|
234
402
|
methods.push({ value: "ssh", label: "SSH Tunnel", hint: "run SSH on your Mac" });
|
|
235
403
|
if (localIP) methods.push({ value: "lan", label: "Direct LAN", hint: localIP });
|
|
236
404
|
|
|
237
405
|
const method = await ask("How will Navigator connect to this machine?", methods);
|
|
238
406
|
|
|
239
|
-
// ── Step 3: Resolve gateway URL
|
|
407
|
+
// ── Step 3: Resolve gateway URL ───────────────────────────────────────
|
|
240
408
|
let gatewayURL;
|
|
241
409
|
|
|
242
410
|
switch (method) {
|
|
@@ -250,16 +418,16 @@ ${BOLD}Options:${RESET}
|
|
|
250
418
|
}
|
|
251
419
|
process.stdout.write("\r" + " ".repeat(60) + "\r");
|
|
252
420
|
ok(`Tunnel active: ${tunnelURL}`);
|
|
253
|
-
gatewayURL = tunnelURL
|
|
421
|
+
gatewayURL = tunnelURL;
|
|
254
422
|
break;
|
|
255
423
|
}
|
|
256
424
|
case "tailscale": {
|
|
257
|
-
gatewayURL = `
|
|
425
|
+
gatewayURL = `http://${tailscaleIP}:${port}`;
|
|
258
426
|
ok(`Using Tailscale: ${tailscaleIP}:${port}`);
|
|
259
427
|
break;
|
|
260
428
|
}
|
|
261
429
|
case "ssh": {
|
|
262
|
-
gatewayURL = `
|
|
430
|
+
gatewayURL = `http://127.0.0.1:${port}`;
|
|
263
431
|
console.log("");
|
|
264
432
|
console.log(`${BOLD}Run this on your Mac:${RESET}`);
|
|
265
433
|
console.log(` ${CYAN}ssh -L ${port}:127.0.0.1:${port} ${userInfo().username}@${hostname()}${RESET}`);
|
|
@@ -267,37 +435,38 @@ ${BOLD}Options:${RESET}
|
|
|
267
435
|
break;
|
|
268
436
|
}
|
|
269
437
|
case "lan": {
|
|
270
|
-
gatewayURL = `
|
|
438
|
+
gatewayURL = `http://${localIP}:${port}`;
|
|
271
439
|
ok(`Using LAN: ${localIP}:${port}`);
|
|
272
|
-
warn("
|
|
440
|
+
warn("Both machines must be on the same network.");
|
|
273
441
|
break;
|
|
274
442
|
}
|
|
275
443
|
}
|
|
276
444
|
|
|
277
|
-
// ── Step 4: Generate pairing token
|
|
445
|
+
// ── Step 4: Generate pairing token ────────────────────────────────────
|
|
278
446
|
const displayName = hostname().replace(/\.local$/, "");
|
|
279
|
-
|
|
280
|
-
|
|
447
|
+
const token = randomUUID().replace(/-/g, "");
|
|
448
|
+
validTokens.add(token);
|
|
449
|
+
ok("Pairing token generated");
|
|
281
450
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
fail("Failed to generate pairing token from /navigator/pair");
|
|
285
|
-
process.exit(1);
|
|
286
|
-
}
|
|
451
|
+
// ── Step 5: Show result ───────────────────────────────────────────────
|
|
452
|
+
const deepLink = `navigator://connect?url=${encodeURIComponent(gatewayURL)}&token=${token}&name=${encodeURIComponent(displayName)}`;
|
|
287
453
|
|
|
288
|
-
|
|
289
|
-
ok("Pairing token generated");
|
|
454
|
+
showConnectionBox(gatewayURL, token, deepLink, method);
|
|
290
455
|
|
|
291
|
-
|
|
292
|
-
|
|
456
|
+
console.log(`${BOLD}${GREEN}Bridge is running.${RESET} Waiting for Navigator to connect...`);
|
|
457
|
+
console.log(`${DIM}Press Ctrl+C to stop.${RESET}\n`);
|
|
293
458
|
|
|
294
|
-
|
|
459
|
+
// Graceful shutdown
|
|
460
|
+
process.on("SIGINT", () => {
|
|
461
|
+
console.log(`\n${DIM}Shutting down bridge...${RESET}`);
|
|
462
|
+
server.close();
|
|
463
|
+
process.exit(0);
|
|
464
|
+
});
|
|
295
465
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
466
|
+
process.on("SIGTERM", () => {
|
|
467
|
+
server.close();
|
|
468
|
+
process.exit(0);
|
|
469
|
+
});
|
|
301
470
|
}
|
|
302
471
|
|
|
303
472
|
main().catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-navigator",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Self-contained Navigator bridge server — connects the Navigator browser to any machine",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
7
|
"openclaw-navigator": "cli.mjs"
|