@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.
- package/lib/public/js/app.js +1 -1
- package/lib/public/js/components/icons.js +11 -0
- package/lib/public/js/components/nodes-tab/browser-attach/index.js +85 -0
- package/lib/public/js/components/nodes-tab/connected-nodes/index.js +263 -24
- package/lib/public/js/components/nodes-tab/index.js +10 -1
- package/lib/public/js/components/nodes-tab/setup-wizard/index.js +11 -11
- package/lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js +3 -1
- package/lib/public/js/components/nodes-tab/use-nodes-tab.js +15 -1
- package/lib/public/js/components/onboarding/welcome-import-step.js +4 -3
- package/lib/public/js/lib/api.js +9 -0
- package/lib/server/openclaw-version.js +5 -1
- package/lib/server/routes/nodes.js +52 -0
- package/lib/server/routes/pairings.js +2 -2
- package/lib/server/webhook-middleware.js +92 -3
- package/package.json +1 -1
package/lib/public/js/app.js
CHANGED
|
@@ -307,7 +307,7 @@ const App = () => {
|
|
|
307
307
|
<${ModelsRoute} onRestartRequired=${controllerActions.setRestartRequired} />
|
|
308
308
|
</div>
|
|
309
309
|
<div
|
|
310
|
-
class="app-content-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
|
-
|
|
24
|
-
}) =>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
59
|
-
|
|
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
|
-
|
|
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=${() =>
|
|
233
|
-
|
|
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
|
|
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=${() =>
|
|
296
|
+
onClick=${() =>
|
|
297
|
+
onApprove(buildApprovedImportSecrets(scanResult.secrets))}
|
|
297
298
|
loading=${scanning}
|
|
298
299
|
tone="primary"
|
|
299
300
|
size="md"
|
package/lib/public/js/lib/api.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
217
|
-
const pendingList = Array.isArray(parsed
|
|
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 (
|
|
150
|
-
|
|
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,
|