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

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,41 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
+ import { useCallback, 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 { readUiSettings, updateUiSettings } from "../../../lib/ui-settings.js";
11
+ import { showToast } from "../../toast.js";
5
12
 
6
13
  const html = htm.bind(h);
14
+ const kBrowserCheckTimeoutMs = 10000;
15
+ const kBrowserPollIntervalMs = 10000;
16
+ const kBrowserAttachStateByNodeKey = "nodesBrowserAttachStateByNode";
17
+
18
+ const escapeDoubleQuotes = (value) => String(value || "").replace(/"/g, '\\"');
19
+
20
+ const buildReconnectCommand = ({ node, connectInfo, maskToken = false }) => {
21
+ const host = String(connectInfo?.gatewayHost || "").trim() || "localhost";
22
+ const port = Number(connectInfo?.gatewayPort) || 3000;
23
+ const token = String(connectInfo?.gatewayToken || "").trim();
24
+ const tlsFlag = connectInfo?.tls === true ? "--tls" : "";
25
+ const displayName = String(node?.displayName || node?.nodeId || "My Node").trim();
26
+ const tokenValue = maskToken ? "****" : token;
27
+
28
+ return [
29
+ tokenValue ? `OPENCLAW_GATEWAY_TOKEN=${tokenValue}` : "",
30
+ "openclaw node run",
31
+ `--host ${host}`,
32
+ `--port ${port}`,
33
+ tlsFlag,
34
+ `--display-name "${escapeDoubleQuotes(displayName)}"`,
35
+ ]
36
+ .filter(Boolean)
37
+ .join(" ");
38
+ };
7
39
 
8
40
  const renderNodeStatusBadge = (node) => {
9
41
  if (node?.connected) {
@@ -15,48 +47,260 @@ const renderNodeStatusBadge = (node) => {
15
47
  return html`<${Badge} tone="danger">Pending approval</${Badge}>`;
16
48
  };
17
49
 
50
+ const isBrowserCapableNode = (node) => {
51
+ const caps = Array.isArray(node?.caps) ? node.caps : [];
52
+ const commands = Array.isArray(node?.commands) ? node.commands : [];
53
+ return caps.includes("browser") || commands.includes("browser.proxy");
54
+ };
55
+
56
+ const getBrowserStatusTone = (status) => {
57
+ if (status.running) return "success";
58
+ return "warning";
59
+ };
60
+
61
+ const getBrowserStatusLabel = (status) => {
62
+ if (status.running) return "Attached";
63
+ return "Not connected";
64
+ };
65
+
66
+ const withTimeout = async (promise, timeoutMs = kBrowserCheckTimeoutMs) => {
67
+ let timeoutId = null;
68
+ try {
69
+ return await Promise.race([
70
+ promise,
71
+ new Promise((_, reject) => {
72
+ timeoutId = setTimeout(() => {
73
+ reject(new Error("Browser check timed out"));
74
+ }, timeoutMs);
75
+ }),
76
+ ]);
77
+ } finally {
78
+ if (timeoutId) {
79
+ clearTimeout(timeoutId);
80
+ }
81
+ }
82
+ };
83
+
84
+ const readBrowserAttachStateByNode = () => {
85
+ const uiSettings = readUiSettings();
86
+ const attachState = uiSettings?.[kBrowserAttachStateByNodeKey];
87
+ if (!attachState || typeof attachState !== "object" || Array.isArray(attachState)) {
88
+ return {};
89
+ }
90
+ return attachState;
91
+ };
92
+
93
+ const writeBrowserAttachStateByNode = (nextState = {}) => {
94
+ updateUiSettings((currentSettings) => {
95
+ const nextSettings =
96
+ currentSettings && typeof currentSettings === "object" ? currentSettings : {};
97
+ return {
98
+ ...nextSettings,
99
+ [kBrowserAttachStateByNodeKey]:
100
+ nextState && typeof nextState === "object" ? nextState : {},
101
+ };
102
+ });
103
+ };
104
+
18
105
  export const ConnectedNodesCard = ({
19
106
  nodes = [],
20
107
  pending = [],
21
108
  loading = false,
22
109
  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>
110
+ connectInfo = null,
111
+ }) => {
112
+ const [browserStatusByNodeId, setBrowserStatusByNodeId] = useState({});
113
+ const [browserErrorByNodeId, setBrowserErrorByNodeId] = useState({});
114
+ const [checkingBrowserNodeId, setCheckingBrowserNodeId] = useState("");
115
+ const [browserAttachStateByNodeId, setBrowserAttachStateByNodeId] = useState(() =>
116
+ readBrowserAttachStateByNode(),
117
+ );
118
+ const browserPollCursorRef = useRef(0);
119
+ const browserCheckInFlightNodeIdRef = useRef("");
120
+
121
+ const handleCopyCommand = async (command) => {
122
+ const copied = await copyTextToClipboard(command);
123
+ if (copied) {
124
+ showToast("Connection command copied", "success");
125
+ return;
126
+ }
127
+ showToast("Could not copy connection command", "error");
128
+ };
129
+
130
+ const handleCheckNodeBrowser = useCallback(async (nodeId, { silent = false } = {}) => {
131
+ const normalizedNodeId = String(nodeId || "").trim();
132
+ if (!normalizedNodeId || browserCheckInFlightNodeIdRef.current) return;
133
+ browserCheckInFlightNodeIdRef.current = normalizedNodeId;
134
+ if (!silent) {
135
+ setCheckingBrowserNodeId(normalizedNodeId);
136
+ }
137
+ setBrowserErrorByNodeId((prev) => ({
138
+ ...prev,
139
+ [normalizedNodeId]: "",
140
+ }));
141
+ try {
142
+ const result = await withTimeout(
143
+ fetchNodeBrowserStatusForNode(normalizedNodeId, "user"),
144
+ );
145
+ const status = result?.status && typeof result.status === "object" ? result.status : null;
146
+ setBrowserStatusByNodeId((prev) => ({
147
+ ...prev,
148
+ [normalizedNodeId]: status,
149
+ }));
150
+ } catch (error) {
151
+ const message = error.message || "Could not check node browser status";
152
+ setBrowserErrorByNodeId((prev) => ({
153
+ ...prev,
154
+ [normalizedNodeId]: message,
155
+ }));
156
+ if (!silent) {
157
+ showToast(message, "error");
158
+ }
159
+ } finally {
160
+ browserCheckInFlightNodeIdRef.current = "";
161
+ if (!silent) {
162
+ setCheckingBrowserNodeId("");
163
+ }
164
+ }
165
+ }, []);
166
+
167
+ const setBrowserAttachStateForNode = useCallback((nodeId, enabled) => {
168
+ const normalizedNodeId = String(nodeId || "").trim();
169
+ if (!normalizedNodeId) return;
170
+ setBrowserAttachStateByNodeId((prevState) => {
171
+ const nextState = {
172
+ ...(prevState && typeof prevState === "object" ? prevState : {}),
173
+ [normalizedNodeId]: enabled === true,
174
+ };
175
+ writeBrowserAttachStateByNode(nextState);
176
+ return nextState;
177
+ });
178
+ }, []);
179
+
180
+ const handleAttachNodeBrowser = useCallback(async (nodeId) => {
181
+ const normalizedNodeId = String(nodeId || "").trim();
182
+ if (!normalizedNodeId) return;
183
+ setBrowserAttachStateForNode(normalizedNodeId, true);
184
+ await handleCheckNodeBrowser(normalizedNodeId);
185
+ }, [handleCheckNodeBrowser, setBrowserAttachStateForNode]);
186
+
187
+ const handleDetachNodeBrowser = useCallback((nodeId) => {
188
+ const normalizedNodeId = String(nodeId || "").trim();
189
+ if (!normalizedNodeId) return;
190
+ setBrowserAttachStateForNode(normalizedNodeId, false);
191
+ setBrowserStatusByNodeId((prevState) => {
192
+ const nextState = { ...(prevState || {}) };
193
+ delete nextState[normalizedNodeId];
194
+ return nextState;
195
+ });
196
+ setBrowserErrorByNodeId((prevState) => {
197
+ const nextState = { ...(prevState || {}) };
198
+ delete nextState[normalizedNodeId];
199
+ return nextState;
200
+ });
201
+ }, [setBrowserAttachStateForNode]);
40
202
 
203
+ useEffect(() => {
204
+ if (checkingBrowserNodeId) return;
205
+ const pollableNodeIds = nodes
206
+ .map((node) => ({
207
+ nodeId: String(node?.nodeId || "").trim(),
208
+ connected: node?.connected === true,
209
+ browserCapable: isBrowserCapableNode(node),
210
+ }))
211
+ .filter(
212
+ (entry) =>
213
+ entry.nodeId &&
214
+ entry.connected &&
215
+ entry.browserCapable &&
216
+ browserAttachStateByNodeId?.[entry.nodeId] === true,
217
+ )
218
+ .map((entry) => entry.nodeId);
219
+ if (!pollableNodeIds.length) return;
220
+
221
+ let active = true;
222
+ const poll = async () => {
223
+ if (!active || browserCheckInFlightNodeIdRef.current) return;
224
+ const pollIndex = browserPollCursorRef.current % pollableNodeIds.length;
225
+ browserPollCursorRef.current += 1;
226
+ const nextNodeId = pollableNodeIds[pollIndex];
227
+ await handleCheckNodeBrowser(nextNodeId, { silent: true });
228
+ };
229
+ poll();
230
+ const timer = setInterval(poll, kBrowserPollIntervalMs);
231
+ return () => {
232
+ active = false;
233
+ clearInterval(timer);
234
+ };
235
+ }, [browserAttachStateByNodeId, handleCheckNodeBrowser, nodes]);
236
+
237
+ return html`
238
+ <div class="space-y-3">
41
239
  ${pending.length
42
240
  ? html`
43
- <div class="rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-300">
241
+ <div class="bg-surface border border-yellow-500/40 rounded-xl px-4 py-3 text-xs text-yellow-300">
44
242
  ${pending.length} pending node${pending.length === 1 ? "" : "s"} waiting for approval.
45
243
  </div>
46
244
  `
47
245
  : null}
48
246
 
49
247
  ${loading
50
- ? html`<div class="text-xs text-gray-500">Loading nodes...</div>`
248
+ ? html`
249
+ <div class="bg-surface border border-border rounded-xl p-4">
250
+ <div class="flex items-center gap-3 text-sm text-gray-400">
251
+ <${LoadingSpinner} className="h-4 w-4" />
252
+ <span>Loading nodes...</span>
253
+ </div>
254
+ </div>
255
+ `
51
256
  : error
52
- ? html`<div class="text-xs text-red-400">${error}</div>`
257
+ ? html`
258
+ <div class="bg-surface border border-border rounded-xl p-4 text-xs text-red-400">
259
+ ${error}
260
+ </div>
261
+ `
53
262
  : !nodes.length
54
- ? html`<div class="text-xs text-gray-500">No nodes are connected yet.</div>`
263
+ ? html`
264
+ <div
265
+ class="bg-surface border border-border rounded-xl px-6 py-10 min-h-[26rem] flex flex-col items-center justify-center text-center"
266
+ >
267
+ <div class="max-w-md w-full flex flex-col items-center gap-4">
268
+ <${ComputerLineIcon} className="h-12 w-12 text-cyan-400" />
269
+ <div class="space-y-2">
270
+ <h2 class="font-semibold text-lg text-gray-100">
271
+ No connected nodes yet
272
+ </h2>
273
+ <p class="text-xs text-gray-400 leading-5">
274
+ Connect a Mac, iOS, Android, or headless node to run
275
+ system and browser commands through this gateway.
276
+ </p>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ `
55
281
  : html`
56
282
  <div class="space-y-2">
57
283
  ${nodes.map(
58
- (node) => html`
59
- <div class="ac-surface-inset rounded-lg px-3 py-2 space-y-2">
284
+ (node) => {
285
+ const nodeId = String(node?.nodeId || "").trim();
286
+ const browserStatus = browserStatusByNodeId[nodeId] || null;
287
+ const browserError = browserErrorByNodeId[nodeId] || "";
288
+ const checkingBrowser = checkingBrowserNodeId === nodeId;
289
+ const canCheckBrowser =
290
+ node?.connected && isBrowserCapableNode(node) && nodeId;
291
+ const browserAttachEnabled = browserAttachStateByNodeId?.[nodeId] === true;
292
+ const hasBrowserCheckResult = !!browserStatus || !!browserError;
293
+ const browserAttached = browserStatus?.running === true;
294
+ const showResolvingSpinner =
295
+ browserAttachEnabled && !hasBrowserCheckResult && !checkingBrowser;
296
+ const showBrowserCheckButton =
297
+ canCheckBrowser &&
298
+ browserAttachEnabled &&
299
+ !checkingBrowser &&
300
+ hasBrowserCheckResult &&
301
+ !browserAttached;
302
+ return html`
303
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
60
304
  <div class="flex items-center justify-between gap-2">
61
305
  <div class="min-w-0">
62
306
  <div class="text-sm font-medium truncate">
@@ -76,10 +320,144 @@ export const ConnectedNodesCard = ({
76
320
  <code>${Array.isArray(node?.caps) ? node.caps.join(", ") : "none"}</code>
77
321
  </span>
78
322
  </div>
323
+ ${canCheckBrowser
324
+ ? html`
325
+ <div class="space-y-2">
326
+ <div class="ac-surface-inset rounded-lg px-3 py-2 space-y-2">
327
+ <div class="flex items-start justify-between gap-2">
328
+ <div class="space-y-0.5">
329
+ <div class="text-sm font-medium">Browser</div>
330
+ ${browserAttachEnabled
331
+ ? html`
332
+ <div class="text-[11px] text-gray-500">
333
+ profile: <code>user</code>
334
+ </div>
335
+ `
336
+ : html`
337
+ <div class="text-[11px] text-gray-500">
338
+ Attach is disabled until you click
339
+ ${" "}
340
+ <code>Attach</code>
341
+ ${" "}
342
+ (prevents control prompts when opening this tab).
343
+ </div>
344
+ `}
345
+ </div>
346
+ <div class="flex items-start gap-2">
347
+ ${browserStatus
348
+ ? html`
349
+ <span class="inline-flex mt-0.5">
350
+ <${Badge} tone=${getBrowserStatusTone(browserStatus)}
351
+ >${getBrowserStatusLabel(browserStatus)}</${Badge}
352
+ >
353
+ </span>
354
+ `
355
+ : null}
356
+ ${showResolvingSpinner
357
+ ? html`
358
+ <${LoadingSpinner} className="h-3.5 w-3.5" />
359
+ `
360
+ : null}
361
+ ${checkingBrowser
362
+ ? html`
363
+ <${LoadingSpinner} className="h-3.5 w-3.5" />
364
+ `
365
+ : null}
366
+ ${canCheckBrowser && !browserAttachEnabled
367
+ ? html`
368
+ <${ActionButton}
369
+ onClick=${() => handleAttachNodeBrowser(nodeId)}
370
+ idleLabel="Attach"
371
+ tone="primary"
372
+ size="sm"
373
+ />
374
+ `
375
+ : null}
376
+ ${showBrowserCheckButton
377
+ ? html`
378
+ <${ActionButton}
379
+ onClick=${() => handleCheckNodeBrowser(nodeId)}
380
+ idleLabel="Check"
381
+ tone="secondary"
382
+ size="sm"
383
+ />
384
+ `
385
+ : null}
386
+ </div>
387
+ </div>
388
+ ${browserStatus
389
+ ? html`
390
+ <div class="flex items-center justify-between gap-2">
391
+ <div class="flex flex-wrap gap-2 text-[11px] text-gray-500">
392
+ <span>driver: <code>${browserStatus?.driver || "unknown"}</code></span>
393
+ <span>transport: <code>${browserStatus?.transport || "unknown"}</code></span>
394
+ </div>
395
+ ${browserAttachEnabled
396
+ ? html`
397
+ <button
398
+ type="button"
399
+ onclick=${() =>
400
+ handleDetachNodeBrowser(nodeId)}
401
+ class="shrink-0 text-[11px] text-gray-500 hover:text-gray-300"
402
+ >
403
+ Detach
404
+ </button>
405
+ `
406
+ : null}
407
+ </div>
408
+ `
409
+ : null}
410
+ ${browserError
411
+ ? html`<div class="text-[11px] text-red-400">${browserError}</div>`
412
+ : null}
413
+ </div>
414
+ </div>
415
+ `
416
+ : null}
417
+ ${node?.paired && !node?.connected && connectInfo
418
+ ? html`
419
+ <div class="border-t border-border pt-2 space-y-2">
420
+ <div class="text-[11px] text-gray-500">
421
+ Reconnect command
422
+ </div>
423
+ <div class="flex items-center gap-2">
424
+ <input
425
+ type="text"
426
+ readonly
427
+ value=${buildReconnectCommand({
428
+ node,
429
+ connectInfo,
430
+ maskToken: true,
431
+ })}
432
+ 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"
433
+ />
434
+ <${ActionButton}
435
+ onClick=${() =>
436
+ handleCopyCommand(
437
+ buildReconnectCommand({
438
+ node,
439
+ connectInfo,
440
+ maskToken: false,
441
+ }),
442
+ )}
443
+ tone="secondary"
444
+ size="sm"
445
+ iconOnly=${true}
446
+ idleIcon=${FileCopyLineIcon}
447
+ idleIconClassName="w-3.5 h-3.5"
448
+ ariaLabel="Copy reconnect command"
449
+ title="Copy reconnect command"
450
+ />
451
+ </div>
452
+ </div>
453
+ `
454
+ : null}
79
455
  </div>
80
- `,
456
+ `;
457
+ },
81
458
  )}
82
459
  </div>
83
460
  `}
84
461
  </div>
85
462
  `;
463
+ };
@@ -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,14 @@ export const NodesTab = ({ onRestartRequired = () => {} }) => {
16
17
  <${PageHeader}
17
18
  title="Nodes"
18
19
  actions=${html`
20
+ <${ActionButton}
21
+ onClick=${actions.refreshNodes}
22
+ loading=${state.refreshingNodes}
23
+ loadingMode="inline"
24
+ idleLabel="Refresh"
25
+ tone="secondary"
26
+ size="sm"
27
+ />
19
28
  <${ActionButton}
20
29
  onClick=${actions.openWizard}
21
30
  idleLabel="Connect Node"
@@ -30,9 +39,11 @@ export const NodesTab = ({ onRestartRequired = () => {} }) => {
30
39
  pending=${state.pending}
31
40
  loading=${state.loadingNodes}
32
41
  error=${state.nodesError}
33
- onRefresh=${actions.refreshNodes}
42
+ connectInfo=${state.connectInfo}
34
43
  />
35
44
 
45
+ <${BrowserAttachCard} />
46
+
36
47
  <${NodesSetupWizard}
37
48
  visible=${state.wizardVisible}
38
49
  nodes=${state.nodes}
@@ -2,7 +2,7 @@ import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
3
  import { ModalShell } from "../../modal-shell.js";
4
4
  import { ActionButton } from "../../action-button.js";
5
- import { CloseIcon } from "../../icons.js";
5
+ import { CloseIcon, FileCopyLineIcon } from "../../icons.js";
6
6
  import { copyTextToClipboard } from "../../../lib/clipboard.js";
7
7
  import { showToast } from "../../toast.js";
8
8
  import { useSetupWizard } from "./use-setup-wizard.js";
@@ -23,8 +23,9 @@ const renderCommandBlock = ({ command = "", onCopy = () => {} }) => html`
23
23
  <button
24
24
  type="button"
25
25
  onclick=${onCopy}
26
- class="text-xs px-2 py-1 rounded-lg ac-btn-ghost"
26
+ class="text-xs px-2 py-1 rounded-lg ac-btn-ghost inline-flex items-center gap-1.5"
27
27
  >
28
+ <${FileCopyLineIcon} className="w-3.5 h-3.5" />
28
29
  Copy
29
30
  </button>
30
31
  </div>
@@ -57,6 +58,7 @@ export const NodesSetupWizard = ({
57
58
  onClose,
58
59
  });
59
60
  const isFinalStep = state.step === kWizardSteps.length - 1;
61
+ const canApproveSelectedNode = state.selectedNode?.paired === false;
60
62
 
61
63
  return html`
62
64
  <${ModalShell}
@@ -125,6 +127,13 @@ export const NodesSetupWizard = ({
125
127
  onCopy: () =>
126
128
  copyAndToast(state.connectCommand || "", "command"),
127
129
  })}
130
+ <div class="rounded-lg border border-border bg-black/20 px-3 py-2 text-xs text-gray-400">
131
+ ${state.pendingSelectableNodes.length
132
+ ? `${state.pendingSelectableNodes.length} pending node${state.pendingSelectableNodes.length === 1
133
+ ? ""
134
+ : "s"} detected. Continue to Step 3 to approve.`
135
+ : "Pairing requests will show up here. Checks every 3s."}
136
+ </div>
128
137
  </div>
129
138
  `
130
139
  : null}
@@ -135,6 +144,13 @@ export const NodesSetupWizard = ({
135
144
  <div class="text-xs text-gray-500">
136
145
  Select the node to approve after you run the connect command.
137
146
  </div>
147
+ <div class="text-xs text-gray-500">
148
+ This list refreshes automatically every
149
+ ${" "}
150
+ <code>${Math.round(state.nodeDiscoveryPollIntervalMs / 1000)}s</code>
151
+ ${" "}
152
+ while this step is open.
153
+ </div>
138
154
  <div class="flex items-center gap-2">
139
155
  <select
140
156
  value=${state.selectedNodeId}
@@ -149,7 +165,10 @@ export const NodesSetupWizard = ({
149
165
  ${state.selectableNodes.map(
150
166
  (entry) => html`
151
167
  <option value=${entry.nodeId}>
152
- ${entry.displayName} (${entry.nodeId.slice(0, 12)}...)
168
+ ${entry.displayName} (${entry.nodeId.slice(0, 12)}...)${entry.paired ===
169
+ false
170
+ ? " · pending approval"
171
+ : " · already paired"}
153
172
  </option>
154
173
  `,
155
174
  )}
@@ -161,15 +180,17 @@ export const NodesSetupWizard = ({
161
180
  size="sm"
162
181
  />
163
182
  </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
- />
183
+ ${state.selectedNodeId && !canApproveSelectedNode
184
+ ? html`
185
+ <div class="text-xs text-yellow-300">
186
+ This node is already paired. Pick a
187
+ ${" "}
188
+ <code>pending approval</code>
189
+ ${" "}
190
+ node to continue.
191
+ </div>
192
+ `
193
+ : null}
173
194
  </div>
174
195
  `
175
196
  : null}
@@ -229,12 +250,22 @@ export const NodesSetupWizard = ({
229
250
  `
230
251
  : html`
231
252
  <${ActionButton}
232
- onClick=${() => state.setStep(Math.min(kWizardSteps.length - 1, state.step + 1))}
233
- idleLabel="Next"
253
+ onClick=${async () => {
254
+ if (state.step === 2) {
255
+ const approved = await state.approveSelectedNode();
256
+ if (!approved) return;
257
+ }
258
+ state.setStep(Math.min(kWizardSteps.length - 1, state.step + 1));
259
+ }}
260
+ loading=${state.step === 2 &&
261
+ state.approvingNodeId === state.selectedNodeId}
262
+ idleLabel=${state.step === 2 ? "Approve" : "Next"}
263
+ loadingLabel="Approving..."
234
264
  tone="primary"
235
265
  size="md"
236
266
  className="w-full justify-center"
237
- disabled=${state.step === 2 && !state.selectedNodeId}
267
+ disabled=${state.step === 2 &&
268
+ (!state.selectedNodeId || !canApproveSelectedNode)}
238
269
  />
239
270
  `}
240
271
  </div>
@@ -1,4 +1,10 @@
1
- import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from "https://esm.sh/preact/hooks";
2
8
  import {
3
9
  approveNode,
4
10
  fetchNodeConnectInfo,
@@ -6,6 +12,8 @@ import {
6
12
  } from "../../../lib/api.js";
7
13
  import { showToast } from "../../toast.js";
8
14
 
15
+ const kNodeDiscoveryPollIntervalMs = 3000;
16
+
9
17
  export const useSetupWizard = ({
10
18
  visible = false,
11
19
  nodes = [],
@@ -21,6 +29,7 @@ export const useSetupWizard = ({
21
29
  const [selectedNodeId, setSelectedNodeId] = useState("");
22
30
  const [approvingNodeId, setApprovingNodeId] = useState("");
23
31
  const [configuring, setConfiguring] = useState(false);
32
+ const refreshInFlightRef = useRef(false);
24
33
 
25
34
  useEffect(() => {
26
35
  if (!visible) return;
@@ -46,7 +55,10 @@ export const useSetupWizard = ({
46
55
  }, [visible]);
47
56
 
48
57
  const selectableNodes = useMemo(() => {
49
- const all = [...pending, ...nodes];
58
+ const all = [
59
+ ...pending.map((entry) => ({ ...entry, pendingApproval: true })),
60
+ ...nodes.map((entry) => ({ ...entry, pendingApproval: false })),
61
+ ];
50
62
  const seen = new Set();
51
63
  const unique = [];
52
64
  for (const entry of all) {
@@ -56,13 +68,18 @@ export const useSetupWizard = ({
56
68
  unique.push({
57
69
  nodeId,
58
70
  displayName: String(entry?.displayName || entry?.name || nodeId),
59
- paired: entry?.paired !== false,
71
+ paired: entry?.pendingApproval ? false : entry?.paired !== false,
60
72
  connected: entry?.connected === true,
61
73
  });
62
74
  }
63
75
  return unique;
64
76
  }, [nodes, pending]);
65
77
 
78
+ const pendingSelectableNodes = useMemo(
79
+ () => selectableNodes.filter((entry) => entry.paired === false),
80
+ [selectableNodes],
81
+ );
82
+
66
83
  const selectedNode = useMemo(
67
84
  () =>
68
85
  selectableNodes.find(
@@ -93,19 +110,56 @@ export const useSetupWizard = ({
93
110
  }, [connectInfo, displayName]);
94
111
 
95
112
  const refreshNodeList = useCallback(async () => {
96
- await refreshNodes();
113
+ if (refreshInFlightRef.current) return;
114
+ refreshInFlightRef.current = true;
115
+ try {
116
+ await refreshNodes();
117
+ } finally {
118
+ refreshInFlightRef.current = false;
119
+ }
97
120
  }, [refreshNodes]);
98
121
 
122
+ useEffect(() => {
123
+ if (!visible || (step !== 1 && step !== 2)) return;
124
+ let active = true;
125
+ const poll = async () => {
126
+ if (!active) return;
127
+ try {
128
+ await refreshNodeList();
129
+ } catch {}
130
+ };
131
+ poll();
132
+ const timer = setInterval(poll, kNodeDiscoveryPollIntervalMs);
133
+ return () => {
134
+ active = false;
135
+ clearInterval(timer);
136
+ };
137
+ }, [refreshNodeList, step, visible]);
138
+
139
+ useEffect(() => {
140
+ if (!visible || step !== 2) return;
141
+ const hasSelected = selectableNodes.some(
142
+ (entry) => entry.nodeId === String(selectedNodeId || "").trim(),
143
+ );
144
+ if (hasSelected) return;
145
+ const preferredNode =
146
+ pendingSelectableNodes.find((entry) => entry.paired === false) || selectableNodes[0];
147
+ if (!preferredNode) return;
148
+ setSelectedNodeId(preferredNode.nodeId);
149
+ }, [pendingSelectableNodes, selectableNodes, selectedNodeId, step, visible]);
150
+
99
151
  const approveSelectedNode = useCallback(async () => {
100
152
  const nodeId = String(selectedNodeId || "").trim();
101
- if (!nodeId || approvingNodeId) return;
153
+ if (!nodeId || approvingNodeId) return false;
102
154
  setApprovingNodeId(nodeId);
103
155
  try {
104
156
  await approveNode(nodeId);
105
157
  showToast("Node approved", "success");
106
158
  await refreshNodes();
159
+ return true;
107
160
  } catch (err) {
108
161
  showToast(err.message || "Could not approve node", "error");
162
+ return false;
109
163
  } finally {
110
164
  setApprovingNodeId("");
111
165
  }
@@ -148,10 +202,12 @@ export const useSetupWizard = ({
148
202
  setSelectedNodeId,
149
203
  selectedNode,
150
204
  selectableNodes,
205
+ pendingSelectableNodes,
151
206
  approvingNodeId,
152
207
  configuring,
153
208
  connectCommand,
154
209
  refreshNodeList,
210
+ nodeDiscoveryPollIntervalMs: kNodeDiscoveryPollIntervalMs,
155
211
  approveSelectedNode,
156
212
  applyGatewayNodeRouting,
157
213
  completeWizard,
@@ -1,9 +1,33 @@
1
- import { useState } from "https://esm.sh/preact/hooks";
1
+ import { useCallback, 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
+ const [refreshingNodes, setRefreshingNodes] = useState(false);
11
+
12
+ useEffect(() => {
13
+ fetchNodeConnectInfo()
14
+ .then((result) => {
15
+ setConnectInfo(result || null);
16
+ })
17
+ .catch((error) => {
18
+ showToast(error.message || "Could not load node connect command", "error");
19
+ });
20
+ }, []);
21
+
22
+ const refreshNodes = useCallback(async () => {
23
+ if (refreshingNodes) return;
24
+ setRefreshingNodes(true);
25
+ try {
26
+ await connectedNodesState.refresh();
27
+ } finally {
28
+ setRefreshingNodes(false);
29
+ }
30
+ }, [connectedNodesState.refresh, refreshingNodes]);
7
31
 
8
32
  return {
9
33
  state: {
@@ -11,12 +35,14 @@ export const useNodesTab = () => {
11
35
  nodes: connectedNodesState.nodes,
12
36
  pending: connectedNodesState.pending,
13
37
  loadingNodes: connectedNodesState.loading,
38
+ refreshingNodes,
14
39
  nodesError: connectedNodesState.error,
40
+ connectInfo,
15
41
  },
16
42
  actions: {
17
43
  openWizard: () => setWizardVisible(true),
18
44
  closeWizard: () => setWizardVisible(false),
19
- refreshNodes: connectedNodesState.refresh,
45
+ refreshNodes,
20
46
  },
21
47
  };
22
48
  };
@@ -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.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },