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.
Files changed (2) hide show
  1. package/cli.mjs +254 -85
  2. package/package.json +2 -2
package/cli.mjs CHANGED
@@ -3,18 +3,22 @@
3
3
  /**
4
4
  * openclaw-navigator
5
5
  *
6
- * Standalone CLI to pair the Navigator macOS browser with an OpenClaw gateway.
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 18789
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 = 18789;
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} — Connect Navigator browser to OpenClaw
352
+ ${BOLD}openclaw-navigator${RESET} — Navigator bridge server
189
353
 
190
354
  ${BOLD}Usage:${RESET}
191
- npx openclaw-navigator
192
- npx openclaw-navigator --port 18789
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> Gateway port (default: 18789)
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
- const baseURL = explicitURL ?? `http://127.0.0.1:${port}`;
374
+ heading("🧭 Navigator Bridge");
375
+ info("Self-contained bridge server for the Navigator browser\n");
205
376
 
206
- heading("🧭 Navigator Setup");
207
- info("Connect the Navigator browser to this OpenClaw gateway\n");
377
+ // ── Step 1: Start HTTP server ─────────────────────────────────────────
378
+ const server = createServer(handleRequest);
208
379
 
209
- // ── Step 1: Check gateway ───────────────────────────────────────────────
210
- process.stdout.write(`${DIM}Checking gateway at ${baseURL}...${RESET}`);
211
- const running = await checkGateway(baseURL);
212
-
213
- if (!running) {
214
- process.stdout.write("\r" + " ".repeat(60) + "\r");
215
- fail(`Gateway not reachable at ${baseURL}/navigator/status`);
216
- console.log("");
217
- console.log(" Make sure the gateway is running:");
218
- console.log(` ${CYAN}openclaw gateway --allow-unconfigured${RESET}`);
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
- process.stdout.write("\r" + " ".repeat(60) + "\r");
224
- ok(`Gateway running at ${baseURL}`);
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.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
421
+ gatewayURL = tunnelURL;
254
422
  break;
255
423
  }
256
424
  case "tailscale": {
257
- gatewayURL = `ws://${tailscaleIP}:${port}`;
425
+ gatewayURL = `http://${tailscaleIP}:${port}`;
258
426
  ok(`Using Tailscale: ${tailscaleIP}:${port}`);
259
427
  break;
260
428
  }
261
429
  case "ssh": {
262
- gatewayURL = `ws://127.0.0.1:${port}`;
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 = `ws://${localIP}:${port}`;
438
+ gatewayURL = `http://${localIP}:${port}`;
271
439
  ok(`Using LAN: ${localIP}:${port}`);
272
- warn("Make sure gateway is bound to LAN (--bind lan) and both machines are on the same network.");
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
- process.stdout.write(`\n${DIM}Generating pairing token...${RESET}`);
280
- const pairing = await generateToken(baseURL, displayName);
447
+ const token = randomUUID().replace(/-/g, "");
448
+ validTokens.add(token);
449
+ ok("Pairing token generated");
281
450
 
282
- if (!pairing?.token) {
283
- process.stdout.write("\r" + " ".repeat(60) + "\r");
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
- process.stdout.write("\r" + " ".repeat(60) + "\r");
289
- ok("Pairing token generated");
454
+ showConnectionBox(gatewayURL, token, deepLink, method);
290
455
 
291
- // ── Step 5: Show result ─────────────────────────────────────────────────
292
- const deepLink = `navigator://connect?url=${encodeURIComponent(gatewayURL)}&token=${pairing.token}&name=${encodeURIComponent(displayName)}`;
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
- showConnectionBox(gatewayURL, pairing.token, deepLink, method);
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
- // Keep tunnel alive if cloudflare
297
- if (method === "cloudflare") {
298
- console.log(`${YELLOW}Tunnel is running. Press Ctrl+C to stop.${RESET}\n`);
299
- await new Promise(() => {}); // wait forever
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": "1.0.0",
4
- "description": "Connect the Navigator browser to your OpenClaw gateway",
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"