@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.
- 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 +402 -24
- package/lib/public/js/components/nodes-tab/index.js +12 -1
- package/lib/public/js/components/nodes-tab/setup-wizard/index.js +46 -15
- package/lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js +61 -5
- package/lib/public/js/components/nodes-tab/use-nodes-tab.js +28 -2
- 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,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
|
-
|
|
24
|
-
}) =>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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=${() =>
|
|
233
|
-
|
|
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 &&
|
|
267
|
+
disabled=${state.step === 2 &&
|
|
268
|
+
(!state.selectedNodeId || !canApproveSelectedNode)}
|
|
238
269
|
/>
|
|
239
270
|
`}
|
|
240
271
|
</div>
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
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 = [
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|