@chrysb/alphaclaw 0.7.2-beta.2 → 0.7.2-beta.3

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.
@@ -307,7 +307,7 @@ const App = () => {
307
307
  <${ModelsRoute} onRestartRequired=${controllerActions.setRestartRequired} />
308
308
  </div>
309
309
  <div
310
- class="app-content-pane ac-fixed-header-pane"
310
+ class="app-content-pane"
311
311
  style=${{ display: isNodesRoute ? "block" : "none" }}
312
312
  >
313
313
  <${NodesRoute} onRestartRequired=${controllerActions.setRestartRequired} />
@@ -450,3 +450,14 @@ export const FullscreenLineIcon = ({ className = "" }) => html`
450
450
  <path d="M8 3V5H4V9H2V3H8ZM2 21V15H4V19H8V21H2ZM22 21H16V19H20V15H22V21ZM22 9H20V5H16V3H22V9Z" />
451
451
  </svg>
452
452
  `;
453
+
454
+ export const ComputerLineIcon = ({ className = "" }) => html`
455
+ <svg
456
+ class=${className}
457
+ viewBox="0 0 24 24"
458
+ fill="currentColor"
459
+ aria-hidden="true"
460
+ >
461
+ <path d="M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z" />
462
+ </svg>
463
+ `;
@@ -0,0 +1,85 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useMemo } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { marked } from "https://esm.sh/marked";
5
+
6
+ const html = htm.bind(h);
7
+
8
+ const kReleaseNotesUrl =
9
+ "https://github.com/openclaw/openclaw/releases/tag/v2026.3.13";
10
+ const kSetupInstructionsMarkdown = `Release reference: [OpenClaw 2026.3.13](${kReleaseNotesUrl})
11
+
12
+ ## Requirements
13
+
14
+ - OpenClaw 2026.3.13+
15
+ - Chrome 144+
16
+ - Node.js installed on the Mac node so \`npx\` is available
17
+
18
+ ## Setup
19
+
20
+ ### 1) Enable remote debugging in Chrome
21
+
22
+ Open \`chrome://inspect/#remote-debugging\` and turn it on. Do **not** launch Chrome with \`--remote-debugging-port\`.
23
+
24
+ ### 2) Configure the node
25
+
26
+ In \`~/.openclaw/openclaw.json\` on the Mac node:
27
+
28
+ \`\`\`json
29
+ {
30
+ "browser": {
31
+ "defaultProfile": "user"
32
+ }
33
+ }
34
+ \`\`\`
35
+
36
+ The built-in \`user\` profile uses live Chrome attach. You do not need a custom \`existing-session\` profile.
37
+
38
+ ### 3) Approve Chrome consent
39
+
40
+ On first connect, Chrome prompts for DevTools MCP access. Click **Allow**.
41
+
42
+ ## Troubleshooting
43
+
44
+ | Problem | Fix |
45
+ | --- | --- |
46
+ | Browser proxy times out (20s) | Restart Chrome cleanly and run the check again. |
47
+ | Config validation error on existing-session | Do not define a custom existing-session profile. Use \`defaultProfile: "user"\`. |
48
+ | EADDRINUSE on port 9222 | Quit Chrome launched with \`--remote-debugging-port\` and relaunch normally. |
49
+ | Consent dialog appears but attach hangs | Quit Chrome, relaunch, and approve the dialog again. |
50
+ | \`npx chrome-devtools-mcp\` not found | Install Node.js on the Mac node so \`npx\` exists in PATH. |`;
51
+
52
+ export const BrowserAttachCard = () => {
53
+ const setupInstructionsHtml = useMemo(
54
+ () =>
55
+ marked.parse(kSetupInstructionsMarkdown, {
56
+ gfm: true,
57
+ breaks: true,
58
+ }),
59
+ [],
60
+ );
61
+
62
+ return html`
63
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
64
+ <div class="space-y-1">
65
+ <h3 class="font-semibold text-sm">Live Chrome Attach (Mac Node)</h3>
66
+ <p class="text-xs text-gray-500">
67
+ Connect your agent to real Chrome sessions (logged-in tabs, cookies,
68
+ and all) using the built-in <code>user</code> profile.
69
+ </p>
70
+ </div>
71
+
72
+ <details class="rounded-lg border border-border bg-black/20 px-3 py-2.5">
73
+ <summary
74
+ class="cursor-pointer text-xs text-gray-300 hover:text-gray-200"
75
+ >
76
+ Setup instructions
77
+ </summary>
78
+ <div
79
+ class="pt-3 file-viewer-preview release-notes-preview text-xs leading-5"
80
+ dangerouslySetInnerHTML=${{ __html: setupInstructionsHtml }}
81
+ ></div>
82
+ </details>
83
+ </div>
84
+ `;
85
+ };
@@ -1,9 +1,38 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
+ import { useEffect, useRef, useState } from "https://esm.sh/preact/hooks";
3
4
  import { ActionButton } from "../../action-button.js";
4
5
  import { Badge } from "../../badge.js";
6
+ import { ComputerLineIcon, FileCopyLineIcon } from "../../icons.js";
7
+ import { LoadingSpinner } from "../../loading-spinner.js";
8
+ import { copyTextToClipboard } from "../../../lib/clipboard.js";
9
+ import { fetchNodeBrowserStatusForNode } from "../../../lib/api.js";
10
+ import { showToast } from "../../toast.js";
5
11
 
6
12
  const html = htm.bind(h);
13
+ const kBrowserCheckTimeoutMs = 10000;
14
+
15
+ const escapeDoubleQuotes = (value) => String(value || "").replace(/"/g, '\\"');
16
+
17
+ const buildReconnectCommand = ({ node, connectInfo, maskToken = false }) => {
18
+ const host = String(connectInfo?.gatewayHost || "").trim() || "localhost";
19
+ const port = Number(connectInfo?.gatewayPort) || 3000;
20
+ const token = String(connectInfo?.gatewayToken || "").trim();
21
+ const tlsFlag = connectInfo?.tls === true ? "--tls" : "";
22
+ const displayName = String(node?.displayName || node?.nodeId || "My Node").trim();
23
+ const tokenValue = maskToken ? "****" : token;
24
+
25
+ return [
26
+ tokenValue ? `OPENCLAW_GATEWAY_TOKEN=${tokenValue}` : "",
27
+ "openclaw node run",
28
+ `--host ${host}`,
29
+ `--port ${port}`,
30
+ tlsFlag,
31
+ `--display-name "${escapeDoubleQuotes(displayName)}"`,
32
+ ]
33
+ .filter(Boolean)
34
+ .join(" ");
35
+ };
7
36
 
8
37
  const renderNodeStatusBadge = (node) => {
9
38
  if (node?.connected) {
@@ -15,48 +44,164 @@ const renderNodeStatusBadge = (node) => {
15
44
  return html`<${Badge} tone="danger">Pending approval</${Badge}>`;
16
45
  };
17
46
 
47
+ const isBrowserCapableNode = (node) => {
48
+ const caps = Array.isArray(node?.caps) ? node.caps : [];
49
+ const commands = Array.isArray(node?.commands) ? node.commands : [];
50
+ return caps.includes("browser") || commands.includes("browser.proxy");
51
+ };
52
+
53
+ const getBrowserStatusTone = (status) => {
54
+ if (status.running) return "success";
55
+ return "warning";
56
+ };
57
+
58
+ const getBrowserStatusLabel = (status) => {
59
+ if (status.running) return "Attached";
60
+ return "Not connected";
61
+ };
62
+
63
+ const withTimeout = async (promise, timeoutMs = kBrowserCheckTimeoutMs) => {
64
+ let timeoutId = null;
65
+ try {
66
+ return await Promise.race([
67
+ promise,
68
+ new Promise((_, reject) => {
69
+ timeoutId = setTimeout(() => {
70
+ reject(new Error("Browser check timed out"));
71
+ }, timeoutMs);
72
+ }),
73
+ ]);
74
+ } finally {
75
+ if (timeoutId) {
76
+ clearTimeout(timeoutId);
77
+ }
78
+ }
79
+ };
80
+
18
81
  export const ConnectedNodesCard = ({
19
82
  nodes = [],
20
83
  pending = [],
21
84
  loading = false,
22
85
  error = "",
23
- onRefresh = () => {},
24
- }) => html`
25
- <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
26
- <div class="flex items-center justify-between gap-2">
27
- <div class="space-y-1">
28
- <h3 class="font-semibold text-sm">Connected Nodes</h3>
29
- <p class="text-xs text-gray-500">
30
- Nodes can run <code>system.run</code> and browser proxy commands for this gateway.
31
- </p>
32
- </div>
33
- <${ActionButton}
34
- onClick=${onRefresh}
35
- idleLabel="Refresh"
36
- tone="secondary"
37
- size="sm"
38
- />
39
- </div>
86
+ connectInfo = null,
87
+ }) => {
88
+ const [browserStatusByNodeId, setBrowserStatusByNodeId] = useState({});
89
+ const [browserErrorByNodeId, setBrowserErrorByNodeId] = useState({});
90
+ const [checkingBrowserNodeId, setCheckingBrowserNodeId] = useState("");
91
+ const autoCheckedNodeIdsRef = useRef(new Set());
92
+
93
+ const handleCopyCommand = async (command) => {
94
+ const copied = await copyTextToClipboard(command);
95
+ if (copied) {
96
+ showToast("Connection command copied", "success");
97
+ return;
98
+ }
99
+ showToast("Could not copy connection command", "error");
100
+ };
101
+
102
+ const handleCheckNodeBrowser = async (nodeId, { silent = false } = {}) => {
103
+ const normalizedNodeId = String(nodeId || "").trim();
104
+ if (!normalizedNodeId || checkingBrowserNodeId) return;
105
+ setCheckingBrowserNodeId(normalizedNodeId);
106
+ setBrowserErrorByNodeId((prev) => ({
107
+ ...prev,
108
+ [normalizedNodeId]: "",
109
+ }));
110
+ try {
111
+ const result = await withTimeout(
112
+ fetchNodeBrowserStatusForNode(normalizedNodeId, "user"),
113
+ );
114
+ const status = result?.status && typeof result.status === "object" ? result.status : null;
115
+ setBrowserStatusByNodeId((prev) => ({
116
+ ...prev,
117
+ [normalizedNodeId]: status,
118
+ }));
119
+ } catch (error) {
120
+ const message = error.message || "Could not check node browser status";
121
+ setBrowserErrorByNodeId((prev) => ({
122
+ ...prev,
123
+ [normalizedNodeId]: message,
124
+ }));
125
+ if (!silent) {
126
+ showToast(message, "error");
127
+ }
128
+ } finally {
129
+ setCheckingBrowserNodeId("");
130
+ }
131
+ };
132
+
133
+ useEffect(() => {
134
+ if (checkingBrowserNodeId) return;
135
+ for (const node of nodes) {
136
+ const nodeId = String(node?.nodeId || "").trim();
137
+ if (!nodeId) continue;
138
+ if (!node?.connected || !isBrowserCapableNode(node)) continue;
139
+ if (autoCheckedNodeIdsRef.current.has(nodeId)) continue;
140
+ autoCheckedNodeIdsRef.current.add(nodeId);
141
+ handleCheckNodeBrowser(nodeId, { silent: true });
142
+ break;
143
+ }
144
+ }, [checkingBrowserNodeId, nodes]);
40
145
 
146
+ return html`
147
+ <div class="space-y-3">
41
148
  ${pending.length
42
149
  ? html`
43
- <div class="rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-300">
150
+ <div class="bg-surface border border-yellow-500/40 rounded-xl px-4 py-3 text-xs text-yellow-300">
44
151
  ${pending.length} pending node${pending.length === 1 ? "" : "s"} waiting for approval.
45
152
  </div>
46
153
  `
47
154
  : null}
48
155
 
49
156
  ${loading
50
- ? html`<div class="text-xs text-gray-500">Loading nodes...</div>`
157
+ ? html`
158
+ <div class="bg-surface border border-border rounded-xl p-4">
159
+ <div class="flex items-center gap-3 text-sm text-gray-400">
160
+ <${LoadingSpinner} className="h-4 w-4" />
161
+ <span>Loading nodes...</span>
162
+ </div>
163
+ </div>
164
+ `
51
165
  : error
52
- ? html`<div class="text-xs text-red-400">${error}</div>`
166
+ ? html`
167
+ <div class="bg-surface border border-border rounded-xl p-4 text-xs text-red-400">
168
+ ${error}
169
+ </div>
170
+ `
53
171
  : !nodes.length
54
- ? html`<div class="text-xs text-gray-500">No nodes are connected yet.</div>`
172
+ ? html`
173
+ <div
174
+ class="bg-surface border border-border rounded-xl px-6 py-10 min-h-[26rem] flex flex-col items-center justify-center text-center"
175
+ >
176
+ <div class="max-w-md w-full flex flex-col items-center gap-4">
177
+ <${ComputerLineIcon} className="h-12 w-12 text-cyan-400" />
178
+ <div class="space-y-2">
179
+ <h2 class="font-semibold text-lg text-gray-100">
180
+ No connected nodes yet
181
+ </h2>
182
+ <p class="text-xs text-gray-400 leading-5">
183
+ Connect a Mac, iOS, Android, or headless node to run
184
+ system and browser commands through this gateway.
185
+ </p>
186
+ </div>
187
+ </div>
188
+ </div>
189
+ `
55
190
  : html`
56
191
  <div class="space-y-2">
57
192
  ${nodes.map(
58
- (node) => html`
59
- <div class="ac-surface-inset rounded-lg px-3 py-2 space-y-2">
193
+ (node) => {
194
+ const nodeId = String(node?.nodeId || "").trim();
195
+ const browserStatus = browserStatusByNodeId[nodeId] || null;
196
+ const browserError = browserErrorByNodeId[nodeId] || "";
197
+ const checkingBrowser = checkingBrowserNodeId === nodeId;
198
+ const canCheckBrowser =
199
+ node?.connected && isBrowserCapableNode(node) && nodeId;
200
+ const hasBrowserCheckResult = !!browserStatus || !!browserError;
201
+ const showBrowserCheckButton =
202
+ canCheckBrowser && !checkingBrowser && !hasBrowserCheckResult;
203
+ return html`
204
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
60
205
  <div class="flex items-center justify-between gap-2">
61
206
  <div class="min-w-0">
62
207
  <div class="text-sm font-medium truncate">
@@ -76,10 +221,104 @@ export const ConnectedNodesCard = ({
76
221
  <code>${Array.isArray(node?.caps) ? node.caps.join(", ") : "none"}</code>
77
222
  </span>
78
223
  </div>
224
+ ${canCheckBrowser
225
+ ? html`
226
+ <div class="space-y-2">
227
+ <div class="ac-surface-inset rounded-lg px-3 py-2 space-y-2">
228
+ <div class="flex items-start justify-between gap-2">
229
+ <div class="space-y-0.5">
230
+ <div class="text-sm font-medium">Browser</div>
231
+ <div class="text-[11px] text-gray-500">
232
+ profile: <code>user</code>
233
+ </div>
234
+ </div>
235
+ <div class="flex items-start gap-2">
236
+ ${browserStatus
237
+ ? html`
238
+ <span class="inline-flex mt-0.5">
239
+ <${Badge} tone=${getBrowserStatusTone(browserStatus)}
240
+ >${getBrowserStatusLabel(browserStatus)}</${Badge}
241
+ >
242
+ </span>
243
+ `
244
+ : null}
245
+ ${checkingBrowser
246
+ ? html`
247
+ <${LoadingSpinner} className="h-3.5 w-3.5" />
248
+ `
249
+ : null}
250
+ ${showBrowserCheckButton
251
+ ? html`
252
+ <${ActionButton}
253
+ onClick=${() => handleCheckNodeBrowser(nodeId)}
254
+ idleLabel="Check"
255
+ tone="secondary"
256
+ size="sm"
257
+ />
258
+ `
259
+ : null}
260
+ </div>
261
+ </div>
262
+ ${browserStatus
263
+ ? html`
264
+ <div class="flex flex-wrap gap-2 text-[11px] text-gray-500">
265
+ <span>tabs: <code>${Number(browserStatus?.tabCount || 0)}</code></span>
266
+ <span>driver: <code>${browserStatus?.driver || "unknown"}</code></span>
267
+ <span>transport: <code>${browserStatus?.transport || "unknown"}</code></span>
268
+ </div>
269
+ `
270
+ : null}
271
+ ${browserError
272
+ ? html`<div class="text-[11px] text-red-400">${browserError}</div>`
273
+ : null}
274
+ </div>
275
+ </div>
276
+ `
277
+ : null}
278
+ ${node?.paired && !node?.connected && connectInfo
279
+ ? html`
280
+ <div class="border-t border-border pt-2 space-y-2">
281
+ <div class="text-[11px] text-gray-500">
282
+ Reconnect command
283
+ </div>
284
+ <div class="flex items-center gap-2">
285
+ <input
286
+ type="text"
287
+ readonly
288
+ value=${buildReconnectCommand({
289
+ node,
290
+ connectInfo,
291
+ maskToken: true,
292
+ })}
293
+ class="flex-1 min-w-0 bg-black/30 border border-border rounded-lg px-2 py-1.5 text-[11px] font-mono text-gray-300"
294
+ />
295
+ <${ActionButton}
296
+ onClick=${() =>
297
+ handleCopyCommand(
298
+ buildReconnectCommand({
299
+ node,
300
+ connectInfo,
301
+ maskToken: false,
302
+ }),
303
+ )}
304
+ tone="secondary"
305
+ size="sm"
306
+ iconOnly=${true}
307
+ idleIcon=${FileCopyLineIcon}
308
+ idleIconClassName="w-3.5 h-3.5"
309
+ ariaLabel="Copy reconnect command"
310
+ title="Copy reconnect command"
311
+ />
312
+ </div>
313
+ </div>
314
+ `
315
+ : null}
79
316
  </div>
80
- `,
317
+ `;
318
+ },
81
319
  )}
82
320
  </div>
83
321
  `}
84
322
  </div>
85
323
  `;
324
+ };
@@ -4,6 +4,7 @@ import { PageHeader } from "../page-header.js";
4
4
  import { ActionButton } from "../action-button.js";
5
5
  import { useNodesTab } from "./use-nodes-tab.js";
6
6
  import { ConnectedNodesCard } from "./connected-nodes/index.js";
7
+ import { BrowserAttachCard } from "./browser-attach/index.js";
7
8
  import { NodesSetupWizard } from "./setup-wizard/index.js";
8
9
 
9
10
  const html = htm.bind(h);
@@ -16,6 +17,12 @@ export const NodesTab = ({ onRestartRequired = () => {} }) => {
16
17
  <${PageHeader}
17
18
  title="Nodes"
18
19
  actions=${html`
20
+ <${ActionButton}
21
+ onClick=${actions.refreshNodes}
22
+ idleLabel="Refresh"
23
+ tone="secondary"
24
+ size="sm"
25
+ />
19
26
  <${ActionButton}
20
27
  onClick=${actions.openWizard}
21
28
  idleLabel="Connect Node"
@@ -30,9 +37,11 @@ export const NodesTab = ({ onRestartRequired = () => {} }) => {
30
37
  pending=${state.pending}
31
38
  loading=${state.loadingNodes}
32
39
  error=${state.nodesError}
33
- onRefresh=${actions.refreshNodes}
40
+ connectInfo=${state.connectInfo}
34
41
  />
35
42
 
43
+ <${BrowserAttachCard} />
44
+
36
45
  <${NodesSetupWizard}
37
46
  visible=${state.wizardVisible}
38
47
  nodes=${state.nodes}
@@ -161,15 +161,6 @@ export const NodesSetupWizard = ({
161
161
  size="sm"
162
162
  />
163
163
  </div>
164
- <${ActionButton}
165
- onClick=${state.approveSelectedNode}
166
- loading=${state.approvingNodeId === state.selectedNodeId}
167
- idleLabel="Approve Selected Node"
168
- loadingLabel="Approving..."
169
- tone="primary"
170
- size="sm"
171
- disabled=${!state.selectedNodeId}
172
- />
173
164
  </div>
174
165
  `
175
166
  : null}
@@ -229,8 +220,17 @@ export const NodesSetupWizard = ({
229
220
  `
230
221
  : html`
231
222
  <${ActionButton}
232
- onClick=${() => state.setStep(Math.min(kWizardSteps.length - 1, state.step + 1))}
233
- idleLabel="Next"
223
+ onClick=${async () => {
224
+ if (state.step === 2) {
225
+ const approved = await state.approveSelectedNode();
226
+ if (!approved) return;
227
+ }
228
+ state.setStep(Math.min(kWizardSteps.length - 1, state.step + 1));
229
+ }}
230
+ loading=${state.step === 2 &&
231
+ state.approvingNodeId === state.selectedNodeId}
232
+ idleLabel=${state.step === 2 ? "Approve" : "Next"}
233
+ loadingLabel="Approving..."
234
234
  tone="primary"
235
235
  size="md"
236
236
  className="w-full justify-center"
@@ -98,14 +98,16 @@ export const useSetupWizard = ({
98
98
 
99
99
  const approveSelectedNode = useCallback(async () => {
100
100
  const nodeId = String(selectedNodeId || "").trim();
101
- if (!nodeId || approvingNodeId) return;
101
+ if (!nodeId || approvingNodeId) return false;
102
102
  setApprovingNodeId(nodeId);
103
103
  try {
104
104
  await approveNode(nodeId);
105
105
  showToast("Node approved", "success");
106
106
  await refreshNodes();
107
+ return true;
107
108
  } catch (err) {
108
109
  showToast(err.message || "Could not approve node", "error");
110
+ return false;
109
111
  } finally {
110
112
  setApprovingNodeId("");
111
113
  }
@@ -1,9 +1,22 @@
1
- import { useState } from "https://esm.sh/preact/hooks";
1
+ import { useEffect, useState } from "https://esm.sh/preact/hooks";
2
+ import { fetchNodeConnectInfo } from "../../lib/api.js";
3
+ import { showToast } from "../toast.js";
2
4
  import { useConnectedNodes } from "./connected-nodes/user-connected-nodes.js";
3
5
 
4
6
  export const useNodesTab = () => {
5
7
  const connectedNodesState = useConnectedNodes({ enabled: true });
6
8
  const [wizardVisible, setWizardVisible] = useState(false);
9
+ const [connectInfo, setConnectInfo] = useState(null);
10
+
11
+ useEffect(() => {
12
+ fetchNodeConnectInfo()
13
+ .then((result) => {
14
+ setConnectInfo(result || null);
15
+ })
16
+ .catch((error) => {
17
+ showToast(error.message || "Could not load node connect command", "error");
18
+ });
19
+ }, []);
7
20
 
8
21
  return {
9
22
  state: {
@@ -12,6 +25,7 @@ export const useNodesTab = () => {
12
25
  pending: connectedNodesState.pending,
13
26
  loadingNodes: connectedNodesState.loading,
14
27
  nodesError: connectedNodesState.error,
28
+ connectInfo,
15
29
  },
16
30
  actions: {
17
31
  openWizard: () => setWizardVisible(true),
@@ -240,8 +240,8 @@ export const WelcomeImportStep = ({
240
240
  >
241
241
  AlphaClaw controls deployment tokens and env vars
242
242
  (${(scanResult.managedEnvConflicts.vars || []).join(", ")}).
243
- Imported values for these will be overwritten with AlphaClaw-managed
244
- env var references during import.
243
+ Imported values for these will be overwritten with
244
+ AlphaClaw-managed env var references during import.
245
245
  </div>
246
246
  `
247
247
  : null}
@@ -293,7 +293,8 @@ export const WelcomeImportStep = ({
293
293
  className="w-full"
294
294
  />
295
295
  <${ActionButton}
296
- onClick=${() => onApprove(buildApprovedImportSecrets(scanResult.secrets))}
296
+ onClick=${() =>
297
+ onApprove(buildApprovedImportSecrets(scanResult.secrets))}
297
298
  loading=${scanning}
298
299
  tone="primary"
299
300
  size="md"
@@ -684,6 +684,15 @@ export const fetchNodeConnectInfo = async () => {
684
684
  return parseJsonOrThrow(res, "Could not load connect info");
685
685
  };
686
686
 
687
+ export const fetchNodeBrowserStatusForNode = async (nodeId, profile = "user") => {
688
+ const safeNodeId = encodeURIComponent(String(nodeId || ""));
689
+ const params = new URLSearchParams({ profile: String(profile || "user") });
690
+ const res = await authFetch(
691
+ `/api/nodes/${safeNodeId}/browser-status?${params.toString()}`,
692
+ );
693
+ return parseJsonOrThrow(res, "Could not load node browser status");
694
+ };
695
+
687
696
  export const fetchNodeExecConfig = async () => {
688
697
  const res = await authFetch("/api/nodes/exec-config");
689
698
  return parseJsonOrThrow(res, "Could not load node exec config");
@@ -8,6 +8,7 @@ const {
8
8
  kNpmPackageRoot,
9
9
  } = require("./constants");
10
10
  const { normalizeOpenclawVersion } = require("./helpers");
11
+ const { parseJsonObjectFromNoisyOutput } = require("./utils/json");
11
12
 
12
13
  const createOpenclawVersionService = ({
13
14
  gatewayEnv,
@@ -62,7 +63,10 @@ const createOpenclawVersionService = ({
62
63
  timeout: 8000,
63
64
  encoding: "utf8",
64
65
  }).trim();
65
- const parsed = JSON.parse(raw);
66
+ const parsed = parseJsonObjectFromNoisyOutput(raw);
67
+ if (!parsed) {
68
+ throw new Error("openclaw update status returned invalid JSON payload");
69
+ }
66
70
  const latestVersion = normalizeOpenclawVersion(
67
71
  parsed?.availability?.latestVersion ||
68
72
  parsed?.update?.registry?.latestVersion,
@@ -7,6 +7,8 @@ const kAllowedExecHosts = new Set(["gateway", "node"]);
7
7
  const kAllowedExecSecurity = new Set(["deny", "allowlist", "full"]);
8
8
  const kAllowedExecAsk = new Set(["off", "on-miss", "always"]);
9
9
  const kSafeNodeIdPattern = /^[\w\-:.]+$/;
10
+ const kNodeBrowserInvokeTimeoutMs = 5000;
11
+ const kNodeBrowserCliTimeoutMs = 9000;
10
12
 
11
13
  const quoteCliArg = (value) => quoteShellArg(value, { strategy: "single" });
12
14
 
@@ -32,6 +34,25 @@ const parseNodesStatus = (stdout) => {
32
34
  return { nodes, pending };
33
35
  };
34
36
 
37
+ const parseNodeBrowserStatus = (stdout) => {
38
+ const parsed = parseJsonObjectFromNoisyOutput(stdout) || {};
39
+ const payload =
40
+ parsed.payload && typeof parsed.payload === "object" ? parsed.payload : {};
41
+ const payloadResult = payload.result;
42
+ let decodedResult = payloadResult;
43
+ if (typeof decodedResult === "string") {
44
+ const parsedResult = parseJsonObjectFromNoisyOutput(decodedResult);
45
+ decodedResult = parsedResult || decodedResult;
46
+ }
47
+ if (decodedResult && typeof decodedResult === "object" && decodedResult.result) {
48
+ const nestedResult = decodedResult.result;
49
+ if (nestedResult && typeof nestedResult === "object") {
50
+ decodedResult = nestedResult;
51
+ }
52
+ }
53
+ return decodedResult && typeof decodedResult === "object" ? decodedResult : null;
54
+ };
55
+
35
56
  const readExecApprovalsFile = ({ fsModule, openclawDir }) => {
36
57
  const filePath = path.join(openclawDir, "exec-approvals.json");
37
58
  try {
@@ -164,6 +185,37 @@ const registerNodeRoutes = ({
164
185
  });
165
186
  });
166
187
 
188
+ app.get("/api/nodes/:id/browser-status", async (req, res) => {
189
+ const nodeId = String(req.params.id || "").trim();
190
+ if (!nodeId || !kSafeNodeIdPattern.test(nodeId)) {
191
+ return res.status(400).json({ ok: false, error: "Invalid node id" });
192
+ }
193
+ const profile = String(req.query?.profile || "user").trim() || "user";
194
+ const params = JSON.stringify({
195
+ method: "GET",
196
+ path: "/",
197
+ query: { profile },
198
+ });
199
+ const result = await clawCmd(
200
+ `nodes invoke --node ${quoteCliArg(nodeId)} --command browser.proxy --params ${quoteCliArg(params)} --invoke-timeout ${kNodeBrowserInvokeTimeoutMs} --json`,
201
+ { quiet: true, timeoutMs: kNodeBrowserCliTimeoutMs },
202
+ );
203
+ if (!result.ok) {
204
+ return res.status(500).json({
205
+ ok: false,
206
+ error: result.stderr || "Could not probe node browser status",
207
+ });
208
+ }
209
+ const status = parseNodeBrowserStatus(result.stdout);
210
+ if (!status) {
211
+ return res.status(500).json({
212
+ ok: false,
213
+ error: "Could not parse node browser status",
214
+ });
215
+ }
216
+ return res.json({ ok: true, status, profile });
217
+ });
218
+
167
219
  app.get("/api/nodes/exec-config", async (_req, res) => {
168
220
  const result = await clawCmd("config get tools.exec --json", { quiet: true });
169
221
  if (!result.ok) {
@@ -213,8 +213,8 @@ const registerPairingRoutes = ({ app, clawCmd, isOnboarded, fsModule = fs, openc
213
213
  return res.json({ pending: [], cliAutoApproveComplete: hasCliAutoApproveMarker() });
214
214
  }
215
215
  try {
216
- const parsed = JSON.parse(result.stdout);
217
- const pendingList = Array.isArray(parsed.pending) ? parsed.pending : [];
216
+ const parsed = parseJsonObjectFromNoisyOutput(result.stdout);
217
+ const pendingList = Array.isArray(parsed?.pending) ? parsed.pending : [];
218
218
  let autoApprovedRequestId = null;
219
219
  if (!hasCliAutoApproveMarker()) {
220
220
  const firstCliPending = pendingList.find((d) => {
@@ -4,6 +4,15 @@ const { URL } = require("url");
4
4
  const { normalizeIp } = require("./utils/network");
5
5
 
6
6
  const kRedactedHeaderKeys = new Set(["authorization", "cookie", "x-webhook-token"]);
7
+ const kRedactedPayloadKeys = new Set([
8
+ "authorization",
9
+ "code",
10
+ "token",
11
+ "access_token",
12
+ "refresh_token",
13
+ "id_token",
14
+ "client_secret",
15
+ ]);
7
16
  const kGmailDedupeTtlMs = 24 * 60 * 60 * 1000;
8
17
  const kGmailDedupeCleanupIntervalMs = 60 * 1000;
9
18
 
@@ -41,6 +50,36 @@ const truncateText = (text, maxBytes) => {
41
50
  };
42
51
  };
43
52
 
53
+ const redactPayloadData = (value, key = "") => {
54
+ const normalizedKey = String(key || "").toLowerCase();
55
+ if (normalizedKey && kRedactedPayloadKeys.has(normalizedKey)) {
56
+ return "[REDACTED]";
57
+ }
58
+
59
+ if (Array.isArray(value)) {
60
+ return value.map((item) => redactPayloadData(item));
61
+ }
62
+
63
+ if (value && typeof value === "object") {
64
+ const redacted = {};
65
+ for (const [childKey, childValue] of Object.entries(value)) {
66
+ redacted[childKey] = redactPayloadData(childValue, childKey);
67
+ }
68
+ return redacted;
69
+ }
70
+
71
+ return value;
72
+ };
73
+
74
+ const sanitizePayloadForLogging = (bodyBuffer) => {
75
+ if (!Buffer.isBuffer(bodyBuffer) || bodyBuffer.length === 0) return bodyBuffer;
76
+ const parsedBody = parseJsonSafe(bodyBuffer.toString("utf8"));
77
+ if (!parsedBody || typeof parsedBody !== "object") {
78
+ return bodyBuffer;
79
+ }
80
+ return Buffer.from(JSON.stringify(redactPayloadData(parsedBody)), "utf8");
81
+ };
82
+
44
83
  const toGatewayRequestHeaders = ({ reqHeaders, contentLength, authorization }) => {
45
84
  const headers = { ...reqHeaders };
46
85
  delete headers.host;
@@ -83,6 +122,42 @@ const parseJsonSafe = (rawValue) => {
83
122
  }
84
123
  };
85
124
 
125
+ const queryParamsToObject = (searchParams) => {
126
+ const params = {};
127
+ for (const [key, value] of searchParams.entries()) {
128
+ if (Object.prototype.hasOwnProperty.call(params, key)) {
129
+ const currentValue = params[key];
130
+ if (Array.isArray(currentValue)) {
131
+ currentValue.push(value);
132
+ } else {
133
+ params[key] = [currentValue, value];
134
+ }
135
+ continue;
136
+ }
137
+ params[key] = value;
138
+ }
139
+ return params;
140
+ };
141
+
142
+ const buildBodyFromQueryParams = ({ bodyBuffer, queryParams }) => {
143
+ if (!queryParams || Object.keys(queryParams).length === 0) {
144
+ return null;
145
+ }
146
+
147
+ if (bodyBuffer.length === 0) {
148
+ return Buffer.from(JSON.stringify(queryParams), "utf8");
149
+ }
150
+
151
+ const parsedBody = parseJsonSafe(bodyBuffer.toString("utf8"));
152
+ if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
153
+ return null;
154
+ }
155
+
156
+ // Keep explicit body values authoritative when both are provided.
157
+ const mergedBody = { ...queryParams, ...parsedBody };
158
+ return Buffer.from(JSON.stringify(mergedBody), "utf8");
159
+ };
160
+
86
161
  const getGmailPayloadData = (parsedBody) => {
87
162
  if (!parsedBody || typeof parsedBody !== "object") return null;
88
163
  if (parsedBody.payload && typeof parsedBody.payload === "object") {
@@ -146,12 +221,23 @@ const createWebhookMiddleware = ({
146
221
  const protocolClient = gateway.protocol === "https:" ? https : http;
147
222
  const inboundUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);
148
223
  let tokenFromQuery = "";
149
- if (!req.headers.authorization && inboundUrl.searchParams.has("token")) {
150
- tokenFromQuery = String(inboundUrl.searchParams.get("token") || "");
224
+ if (inboundUrl.searchParams.has("token")) {
225
+ const tokenValue = String(inboundUrl.searchParams.get("token") || "");
226
+ if (!req.headers.authorization) {
227
+ tokenFromQuery = tokenValue;
228
+ }
151
229
  inboundUrl.searchParams.delete("token");
152
230
  }
153
231
 
154
232
  let bodyBuffer = extractBodyBuffer(req);
233
+ const queryBody = queryParamsToObject(inboundUrl.searchParams);
234
+ const bodyWithQueryParams = buildBodyFromQueryParams({
235
+ bodyBuffer,
236
+ queryParams: queryBody,
237
+ });
238
+ if (bodyWithQueryParams) {
239
+ bodyBuffer = bodyWithQueryParams;
240
+ }
155
241
  const hookName = resolveHookName(req);
156
242
 
157
243
  if (hookName === "gmail" && bodyBuffer.length > 0) {
@@ -196,13 +282,16 @@ const createWebhookMiddleware = ({
196
282
  req.ip || req.headers["x-forwarded-for"] || req.socket?.remoteAddress || "",
197
283
  );
198
284
  const sanitizedHeaders = sanitizeHeaders(req.headers);
199
- const payload = truncateText(bodyBuffer, maxPayloadBytes);
285
+ const payload = truncateText(sanitizePayloadForLogging(bodyBuffer), maxPayloadBytes);
200
286
 
201
287
  const gatewayHeaders = toGatewayRequestHeaders({
202
288
  reqHeaders: req.headers,
203
289
  contentLength: bodyBuffer.length,
204
290
  authorization: tokenFromQuery ? `Bearer ${tokenFromQuery}` : req.headers.authorization,
205
291
  });
292
+ if (bodyWithQueryParams && !gatewayHeaders["content-type"]) {
293
+ gatewayHeaders["content-type"] = "application/json";
294
+ }
206
295
 
207
296
  const requestOptions = {
208
297
  protocol: gateway.protocol,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.7.2-beta.2",
3
+ "version": "0.7.2-beta.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },