openclaw-navigator 5.2.1 → 5.3.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 (3) hide show
  1. package/cli.mjs +84 -0
  2. package/mcp.mjs +54 -34
  3. package/package.json +1 -1
package/cli.mjs CHANGED
@@ -62,6 +62,10 @@ const validTokens = new Set();
62
62
  // OC Web UI reverse proxy target (configurable via --ui-port or env)
63
63
  let ocUIPort = parseInt(process.env.OPENCLAW_UI_PORT ?? "4000", 10);
64
64
 
65
+ // OC Web UI directory — the pre-installed Next.js app
66
+ const OC_UI_DIR = process.env.OPENCLAW_UI_DIR ?? join(homedir(), "openclaw-ui");
67
+ let uiProcess = null;
68
+
65
69
  // OC Gateway port — where sessions API + WebSocket live (separate from web UI)
66
70
  let ocGatewayPort = parseInt(process.env.OPENCLAW_GATEWAY_PORT ?? "18789", 10);
67
71
 
@@ -144,6 +148,77 @@ function writeJSON(filePath, data) {
144
148
  writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
145
149
  }
146
150
 
151
+ // ── OC Web UI lifecycle ───────────────────────────────────────────────────
152
+
153
+ /** Kill any process listening on a port (best-effort, macOS/Linux) */
154
+ async function killPort(port) {
155
+ const { execSync } = await import("node:child_process");
156
+ try {
157
+ // macOS: lsof -ti :PORT gives PIDs
158
+ const pids = execSync(`lsof -ti :${port} 2>/dev/null`, { encoding: "utf8" }).trim();
159
+ if (pids) {
160
+ for (const pid of pids.split("\n")) {
161
+ try {
162
+ execSync(`kill -9 ${pid.trim()} 2>/dev/null`);
163
+ info(` Killed process ${pid.trim()} on port ${port}`);
164
+ } catch {}
165
+ }
166
+ // Wait a beat for the port to free up
167
+ await new Promise((r) => setTimeout(r, 500));
168
+ }
169
+ } catch {
170
+ // No process on that port, or lsof not available — that's fine
171
+ }
172
+ }
173
+
174
+ /** Start the OC Web UI (Next.js) as a child process */
175
+ function startWebUI() {
176
+ if (!existsSync(join(OC_UI_DIR, "package.json"))) {
177
+ warn(`OC Web UI not found at ${OC_UI_DIR} — /ui/ proxy will return 502`);
178
+ info(` Set OPENCLAW_UI_DIR to the correct path, or install openclaw-ui there`);
179
+ return;
180
+ }
181
+
182
+ if (uiProcess) {
183
+ uiProcess.kill();
184
+ uiProcess = null;
185
+ }
186
+
187
+ uiProcess = spawn("npx", ["next", "start", "-p", String(ocUIPort)], {
188
+ cwd: OC_UI_DIR,
189
+ stdio: ["ignore", "pipe", "pipe"],
190
+ env: { ...process.env, PORT: String(ocUIPort), NODE_ENV: "production" },
191
+ });
192
+
193
+ uiProcess.stdout.on("data", (data) => {
194
+ const line = data.toString().trim();
195
+ if (line) {
196
+ info(` [ui] ${line}`);
197
+ }
198
+ });
199
+
200
+ uiProcess.stderr.on("data", (data) => {
201
+ const line = data.toString().trim();
202
+ if (line && !line.includes("ExperimentalWarning")) {
203
+ info(` [ui] ${line}`);
204
+ }
205
+ });
206
+
207
+ uiProcess.on("error", (err) => {
208
+ warn(`OC Web UI failed to start: ${err.message}`);
209
+ uiProcess = null;
210
+ });
211
+
212
+ uiProcess.on("exit", (code) => {
213
+ if (code !== null && code !== 0) {
214
+ warn(`OC Web UI exited with code ${code}`);
215
+ }
216
+ uiProcess = null;
217
+ });
218
+
219
+ ok(`OC Web UI starting on port ${ocUIPort} (PID ${uiProcess.pid}) from ${OC_UI_DIR}`);
220
+ }
221
+
147
222
  // Pairing code state
148
223
  let pairingCode = null;
149
224
  let pairingData = null;
@@ -1204,6 +1279,11 @@ module.exports = {
1204
1279
 
1205
1280
  ok(`Bridge server running on ${bindHost}:${port}`);
1206
1281
 
1282
+ // ── Step 1.5: Start OC Web UI ──────────────────────────────────────
1283
+ // Kill anything on the UI port, then start the Next.js app as a child process.
1284
+ await killPort(ocUIPort);
1285
+ startWebUI();
1286
+
1207
1287
  // ── Step 2: Persistent identity ─────────────────────────────────────
1208
1288
  // Reuse the same pairing code + token across restarts so Navigator
1209
1289
  // doesn't need to re-pair. The code resolves to the NEW tunnel URL
@@ -1907,6 +1987,10 @@ module.exports = {
1907
1987
  // ── Graceful shutdown ─────────────────────────────────────────────────
1908
1988
  const shutdown = () => {
1909
1989
  console.log(`\n${DIM}Shutting down bridge...${RESET}`);
1990
+ if (uiProcess) {
1991
+ uiProcess.kill();
1992
+ uiProcess = null;
1993
+ }
1910
1994
  if (mcpProcess) {
1911
1995
  mcpProcess.kill();
1912
1996
  }
package/mcp.mjs CHANGED
@@ -63,46 +63,66 @@ function authHeaders() {
63
63
 
64
64
  // ── HTTP helpers ──────────────────────────────────────────────────────────
65
65
 
66
- async function bridgeGet(path) {
67
- const res = await fetch(`${BRIDGE_URL}${path}`, {
68
- headers: authHeaders(),
69
- });
70
- if (!res.ok) {
71
- // If 401, try reloading the token (bridge may have restarted with new identity)
72
- if (res.status === 401 && !bridgeGet._retrying) {
73
- bridgeGet._retrying = true;
74
- bridgeToken = loadBridgeToken();
75
- const retry = await bridgeGet(path);
76
- bridgeGet._retrying = false;
77
- return retry;
66
+ const MAX_RETRIES = 3;
67
+ const RETRY_DELAYS = [500, 1500, 3000]; // ms — escalating backoff
68
+
69
+ /** Retry wrapper for bridge HTTP calls — handles ECONNREFUSED and 401 */
70
+ async function withRetry(fn, label) {
71
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
72
+ try {
73
+ return await fn();
74
+ } catch (err) {
75
+ const isConnErr =
76
+ err.cause?.code === "ECONNREFUSED" ||
77
+ err.message?.includes("ECONNREFUSED") ||
78
+ err.message?.includes("fetch failed");
79
+ const is401 = err.message?.includes("returned 401");
80
+
81
+ if (is401 && attempt === 0) {
82
+ // Auth mismatch — bridge may have restarted with new token
83
+ log(`${label}: 401 — reloading bridge token and retrying`);
84
+ bridgeToken = loadBridgeToken();
85
+ continue;
86
+ }
87
+
88
+ if (isConnErr && attempt < MAX_RETRIES) {
89
+ const delay = RETRY_DELAYS[attempt] || 3000;
90
+ log(`${label}: bridge unreachable — retry ${attempt + 1}/${MAX_RETRIES} in ${delay}ms`);
91
+ await new Promise((r) => setTimeout(r, delay));
92
+ // Also reload token in case bridge restarted with new identity
93
+ bridgeToken = loadBridgeToken();
94
+ continue;
95
+ }
96
+
97
+ throw err;
78
98
  }
79
- bridgeGet._retrying = false;
80
- throw new Error(`Bridge GET ${path} returned ${res.status}`);
81
99
  }
82
- bridgeGet._retrying = false;
83
- return res.json();
100
+ }
101
+
102
+ async function bridgeGet(path) {
103
+ return withRetry(async () => {
104
+ const res = await fetch(`${BRIDGE_URL}${path}`, {
105
+ headers: authHeaders(),
106
+ });
107
+ if (!res.ok) {
108
+ throw new Error(`Bridge GET ${path} returned ${res.status}`);
109
+ }
110
+ return res.json();
111
+ }, `GET ${path}`);
84
112
  }
85
113
 
86
114
  async function bridgePost(path, body) {
87
- const res = await fetch(`${BRIDGE_URL}${path}`, {
88
- method: "POST",
89
- headers: authHeaders(),
90
- body: JSON.stringify(body),
91
- });
92
- if (!res.ok) {
93
- // If 401, try reloading the token (bridge may have restarted with new identity)
94
- if (res.status === 401 && !bridgePost._retrying) {
95
- bridgePost._retrying = true;
96
- bridgeToken = loadBridgeToken();
97
- const retry = await bridgePost(path, body);
98
- bridgePost._retrying = false;
99
- return retry;
115
+ return withRetry(async () => {
116
+ const res = await fetch(`${BRIDGE_URL}${path}`, {
117
+ method: "POST",
118
+ headers: authHeaders(),
119
+ body: JSON.stringify(body),
120
+ });
121
+ if (!res.ok) {
122
+ throw new Error(`Bridge POST ${path} returned ${res.status}`);
100
123
  }
101
- bridgePost._retrying = false;
102
- throw new Error(`Bridge POST ${path} returned ${res.status}`);
103
- }
104
- bridgePost._retrying = false;
105
- return res.json();
124
+ return res.json();
125
+ }, `POST ${path}`);
106
126
  }
107
127
 
108
128
  // ── Event polling engine ──────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-navigator",
3
- "version": "5.2.1",
3
+ "version": "5.3.0",
4
4
  "description": "One-command bridge + tunnel for the Navigator browser — works on any machine, any OS",
5
5
  "keywords": [
6
6
  "browser",