@chrysb/alphaclaw 0.9.14 → 0.9.16
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/README.md +2 -1
- package/bin/alphaclaw.js +53 -135
- package/lib/cli/openclaw-config-restore.js +162 -0
- package/lib/public/css/explorer.css +3 -1
- package/lib/public/dist/app.bundle.js +1926 -1921
- package/lib/public/js/components/agents-tab/agent-identity-section.js +1 -1
- package/lib/public/js/components/file-tree.js +82 -6
- package/lib/public/js/components/general/use-general-tab.js +25 -8
- package/lib/public/js/components/sidebar.js +2 -1
- package/lib/public/js/lib/agent-identity.js +8 -0
- package/lib/public/js/lib/api.js +11 -6
- package/lib/server/routes/browse/constants.js +3 -1
- package/lib/server/routes/browse/index.js +39 -11
- package/lib/server/routes/pairings.js +120 -8
- package/lib/server/routes/system.js +104 -4
- package/package.json +2 -2
|
@@ -116,7 +116,7 @@ export const AgentIdentitySection = ({
|
|
|
116
116
|
value=${form.emoji}
|
|
117
117
|
onInput=${(event) => updateField("emoji", event.target.value)}
|
|
118
118
|
class="w-full bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted"
|
|
119
|
-
placeholder="
|
|
119
|
+
placeholder="Single emoji, e.g. ✨"
|
|
120
120
|
/>
|
|
121
121
|
</label>
|
|
122
122
|
<label class="block space-y-1">
|
|
@@ -76,6 +76,16 @@ const collectFolderPaths = (node, folderPaths) => {
|
|
|
76
76
|
);
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
const collectTruncatedExpandedFolderPaths = (node, expandedPaths, folderPaths) => {
|
|
80
|
+
if (!node || node.type !== "folder") return;
|
|
81
|
+
if (node.truncated && expandedPaths.has(node.path || "")) {
|
|
82
|
+
folderPaths.push(node.path || "");
|
|
83
|
+
}
|
|
84
|
+
(node.children || []).forEach((childNode) =>
|
|
85
|
+
collectTruncatedExpandedFolderPaths(childNode, expandedPaths, folderPaths),
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
79
89
|
const collectFilePaths = (node, filePaths) => {
|
|
80
90
|
if (!node) return;
|
|
81
91
|
if (node.type === "file") {
|
|
@@ -104,6 +114,19 @@ const removeTreePath = (node, targetPath) => {
|
|
|
104
114
|
};
|
|
105
115
|
};
|
|
106
116
|
|
|
117
|
+
const replaceTreeNode = (node, nextNode) => {
|
|
118
|
+
if (!node || !nextNode) return node;
|
|
119
|
+
if (String(node.path || "") === String(nextNode.path || "")) return nextNode;
|
|
120
|
+
if (node.type !== "folder") return node;
|
|
121
|
+
const nextChildren = (node.children || []).map((childNode) =>
|
|
122
|
+
replaceTreeNode(childNode, nextNode),
|
|
123
|
+
);
|
|
124
|
+
return {
|
|
125
|
+
...node,
|
|
126
|
+
children: nextChildren,
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
107
130
|
const filterTreeNode = (node, normalizedQuery) => {
|
|
108
131
|
if (!node) return null;
|
|
109
132
|
const query = String(normalizedQuery || "")
|
|
@@ -410,6 +433,7 @@ const TreeNode = ({
|
|
|
410
433
|
onCreationConfirm,
|
|
411
434
|
onCreationCancel,
|
|
412
435
|
dragSourcePath = "",
|
|
436
|
+
loadingFolderPaths = new Set(),
|
|
413
437
|
}) => {
|
|
414
438
|
if (!node) return null;
|
|
415
439
|
if (node.type === "file") {
|
|
@@ -471,6 +495,7 @@ const TreeNode = ({
|
|
|
471
495
|
|
|
472
496
|
const folderPath = node.path || "";
|
|
473
497
|
const isCollapsed = isSearchActive ? false : !expandedPaths.has(folderPath);
|
|
498
|
+
const isLoadingFolder = loadingFolderPaths.has(folderPath);
|
|
474
499
|
const isFolderActive = selectedPath === folderPath;
|
|
475
500
|
const isFolderLocked = folderPath && matchesBrowsePolicyPath(
|
|
476
501
|
kLockedBrowsePaths,
|
|
@@ -484,7 +509,7 @@ const TreeNode = ({
|
|
|
484
509
|
class=${`tree-folder ${isCollapsed ? "collapsed" : ""} ${isFolderActive ? "active" : ""} ${isDropTarget ? "is-drop-target" : ""}`.trim()}
|
|
485
510
|
onclick=${() => {
|
|
486
511
|
if (!folderPath) return;
|
|
487
|
-
onSetFolderExpanded(folderPath, isCollapsed);
|
|
512
|
+
onSetFolderExpanded(folderPath, isCollapsed, node);
|
|
488
513
|
onSelectFolder(folderPath);
|
|
489
514
|
}}
|
|
490
515
|
oncontextmenu=${(e) => {
|
|
@@ -540,8 +565,9 @@ const TreeNode = ({
|
|
|
540
565
|
event.preventDefault();
|
|
541
566
|
event.stopPropagation();
|
|
542
567
|
if (!folderPath) return;
|
|
543
|
-
onSetFolderExpanded(folderPath, isCollapsed);
|
|
568
|
+
onSetFolderExpanded(folderPath, isCollapsed, node);
|
|
544
569
|
}}
|
|
570
|
+
disabled=${isLoadingFolder}
|
|
545
571
|
>
|
|
546
572
|
<span class="arrow">▼</span>
|
|
547
573
|
</button>
|
|
@@ -587,6 +613,7 @@ const TreeNode = ({
|
|
|
587
613
|
onCreationConfirm=${onCreationConfirm}
|
|
588
614
|
onCreationCancel=${onCreationCancel}
|
|
589
615
|
dragSourcePath=${dragSourcePath}
|
|
616
|
+
loadingFolderPaths=${loadingFolderPaths}
|
|
590
617
|
/>
|
|
591
618
|
`,
|
|
592
619
|
)}
|
|
@@ -623,6 +650,7 @@ const TreeNode = ({
|
|
|
623
650
|
onCreationConfirm=${onCreationConfirm}
|
|
624
651
|
onCreationCancel=${onCreationCancel}
|
|
625
652
|
dragSourcePath=${dragSourcePath}
|
|
653
|
+
loadingFolderPaths=${loadingFolderPaths}
|
|
626
654
|
/>
|
|
627
655
|
`,
|
|
628
656
|
)}
|
|
@@ -650,6 +678,7 @@ export const FileTree = ({
|
|
|
650
678
|
const [creatingType, setCreatingType] = useState("");
|
|
651
679
|
const [contextMenu, setContextMenu] = useState(null);
|
|
652
680
|
const [dragSourcePath, setDragSourcePath] = useState("");
|
|
681
|
+
const [loadingFolderPaths, setLoadingFolderPaths] = useState(new Set());
|
|
653
682
|
const [selectedFolder, setSelectedFolder] = useState("");
|
|
654
683
|
const effectiveSelectedPath = selectedFolder || selectedPath;
|
|
655
684
|
const searchInputRef = useRef(null);
|
|
@@ -661,10 +690,31 @@ export const FileTree = ({
|
|
|
661
690
|
try {
|
|
662
691
|
const data = await fetchBrowseTree();
|
|
663
692
|
const nextRoot = data.root || null;
|
|
664
|
-
const
|
|
693
|
+
const nextExpandedPaths =
|
|
694
|
+
expandedPaths instanceof Set ? expandedPaths : new Set();
|
|
695
|
+
let hydratedRoot = nextRoot;
|
|
696
|
+
const hydratedPaths = new Set();
|
|
697
|
+
while (true) {
|
|
698
|
+
const truncatedExpandedPaths = [];
|
|
699
|
+
collectTruncatedExpandedFolderPaths(
|
|
700
|
+
hydratedRoot,
|
|
701
|
+
nextExpandedPaths,
|
|
702
|
+
truncatedExpandedPaths,
|
|
703
|
+
);
|
|
704
|
+
const nextFolderPath = truncatedExpandedPaths.find(
|
|
705
|
+
(folderPath) => !hydratedPaths.has(folderPath),
|
|
706
|
+
);
|
|
707
|
+
if (!nextFolderPath) break;
|
|
708
|
+
hydratedPaths.add(nextFolderPath);
|
|
709
|
+
const subtreeData = await fetchBrowseTree({ path: nextFolderPath });
|
|
710
|
+
if (subtreeData.root) {
|
|
711
|
+
hydratedRoot = replaceTreeNode(hydratedRoot, subtreeData.root);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const nextSignature = JSON.stringify(hydratedRoot || {});
|
|
665
715
|
if (treeSignatureRef.current !== nextSignature) {
|
|
666
716
|
treeSignatureRef.current = nextSignature;
|
|
667
|
-
setTreeRoot(
|
|
717
|
+
setTreeRoot(hydratedRoot);
|
|
668
718
|
}
|
|
669
719
|
setExpandedPaths((previousPaths) =>
|
|
670
720
|
previousPaths instanceof Set ? previousPaths : new Set(),
|
|
@@ -677,7 +727,7 @@ export const FileTree = ({
|
|
|
677
727
|
} finally {
|
|
678
728
|
if (showLoading) setLoading(false);
|
|
679
729
|
}
|
|
680
|
-
}, []);
|
|
730
|
+
}, [expandedPaths]);
|
|
681
731
|
|
|
682
732
|
useEffect(() => {
|
|
683
733
|
loadTree({ showLoading: true });
|
|
@@ -834,7 +884,31 @@ export const FileTree = ({
|
|
|
834
884
|
onPreviewFile("");
|
|
835
885
|
}, [isSearchActive, filteredFilePaths, searchActivePath, onPreviewFile]);
|
|
836
886
|
|
|
837
|
-
const setFolderExpanded = (folderPath, nextExpanded) => {
|
|
887
|
+
const setFolderExpanded = async (folderPath, nextExpanded, node = null) => {
|
|
888
|
+
if (nextExpanded === true && node?.truncated) {
|
|
889
|
+
setLoadingFolderPaths((previousPaths) => {
|
|
890
|
+
const nextPaths =
|
|
891
|
+
previousPaths instanceof Set ? new Set(previousPaths) : new Set();
|
|
892
|
+
nextPaths.add(folderPath);
|
|
893
|
+
return nextPaths;
|
|
894
|
+
});
|
|
895
|
+
try {
|
|
896
|
+
const data = await fetchBrowseTree({ path: folderPath });
|
|
897
|
+
if (data.root) {
|
|
898
|
+
setTreeRoot((previousRoot) => replaceTreeNode(previousRoot, data.root));
|
|
899
|
+
}
|
|
900
|
+
} catch (loadError) {
|
|
901
|
+
showToast(loadError.message || "Could not load folder", "error");
|
|
902
|
+
return;
|
|
903
|
+
} finally {
|
|
904
|
+
setLoadingFolderPaths((previousPaths) => {
|
|
905
|
+
const nextPaths =
|
|
906
|
+
previousPaths instanceof Set ? new Set(previousPaths) : new Set();
|
|
907
|
+
nextPaths.delete(folderPath);
|
|
908
|
+
return nextPaths;
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
}
|
|
838
912
|
setExpandedPaths((previousPaths) => {
|
|
839
913
|
const nextPaths =
|
|
840
914
|
previousPaths instanceof Set ? new Set(previousPaths) : new Set();
|
|
@@ -1210,6 +1284,7 @@ export const FileTree = ({
|
|
|
1210
1284
|
onCreationConfirm=${confirmCreate}
|
|
1211
1285
|
onCreationCancel=${cancelCreate}
|
|
1212
1286
|
dragSourcePath=${dragSourcePath}
|
|
1287
|
+
loadingFolderPaths=${loadingFolderPaths}
|
|
1213
1288
|
/>
|
|
1214
1289
|
`,
|
|
1215
1290
|
)}
|
|
@@ -1245,6 +1320,7 @@ export const FileTree = ({
|
|
|
1245
1320
|
onCreationConfirm=${confirmCreate}
|
|
1246
1321
|
onCreationCancel=${cancelCreate}
|
|
1247
1322
|
dragSourcePath=${dragSourcePath}
|
|
1323
|
+
loadingFolderPaths=${loadingFolderPaths}
|
|
1248
1324
|
/>
|
|
1249
1325
|
`,
|
|
1250
1326
|
)}
|
|
@@ -218,15 +218,27 @@ export const useGeneralTab = ({
|
|
|
218
218
|
};
|
|
219
219
|
|
|
220
220
|
const handleDeviceApprove = async (id) => {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
try {
|
|
222
|
+
await approveDevice(id);
|
|
223
|
+
showToast("Device pairing approved", "success");
|
|
224
|
+
setTimeout(devicePoll.refresh, 500);
|
|
225
|
+
setTimeout(devicePoll.refresh, 2000);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
showToast(err.message || "Could not approve device pairing", "error");
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
224
230
|
};
|
|
225
231
|
|
|
226
232
|
const handleDeviceReject = async (id) => {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
try {
|
|
234
|
+
await rejectDevice(id);
|
|
235
|
+
showToast("Device pairing rejected", "info");
|
|
236
|
+
setTimeout(devicePoll.refresh, 500);
|
|
237
|
+
setTimeout(devicePoll.refresh, 2000);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
showToast(err.message || "Could not reject device pairing", "error");
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
230
242
|
};
|
|
231
243
|
|
|
232
244
|
const handleWatchdogRepair = async () => {
|
|
@@ -252,10 +264,15 @@ export const useGeneralTab = ({
|
|
|
252
264
|
setDashboardLoading(true);
|
|
253
265
|
try {
|
|
254
266
|
const data = await fetchDashboardUrl();
|
|
255
|
-
|
|
267
|
+
if (data.needsAuth) {
|
|
268
|
+
showToast(
|
|
269
|
+
"OpenClaw dashboard token is missing from the AlphaClaw server environment",
|
|
270
|
+
"warning",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
256
273
|
window.open(data.url || "/openclaw", "_blank");
|
|
257
274
|
} catch (err) {
|
|
258
|
-
|
|
275
|
+
showToast(err.message || "Could not open OpenClaw dashboard", "error");
|
|
259
276
|
window.open("/openclaw", "_blank");
|
|
260
277
|
} finally {
|
|
261
278
|
setDashboardLoading(false);
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
getSessionDisplayLabel,
|
|
34
34
|
getSessionRowKey,
|
|
35
35
|
} from "../lib/session-keys.js";
|
|
36
|
+
import { sanitizeAgentEmoji } from "../lib/agent-identity.js";
|
|
36
37
|
import { ThemeToggle } from "./theme-toggle.js";
|
|
37
38
|
|
|
38
39
|
const html = htm.bind(h);
|
|
@@ -91,7 +92,7 @@ const renderNavItem = ({ item, selectedNavId, onSelectNavItem }) => {
|
|
|
91
92
|
`;
|
|
92
93
|
};
|
|
93
94
|
|
|
94
|
-
const getAgentIdentityEmoji = (agent) =>
|
|
95
|
+
const getAgentIdentityEmoji = (agent) => sanitizeAgentEmoji(agent?.identity?.emoji);
|
|
95
96
|
|
|
96
97
|
export const AppSidebar = ({
|
|
97
98
|
mobileSidebarOpen = false,
|
package/lib/public/js/lib/api.js
CHANGED
|
@@ -466,7 +466,7 @@ export async function updateWatchdogSettings(settings) {
|
|
|
466
466
|
|
|
467
467
|
export async function fetchDashboardUrl() {
|
|
468
468
|
const res = await authFetch("/api/gateway/dashboard");
|
|
469
|
-
return res
|
|
469
|
+
return parseJsonOrThrow(res, "Could not load dashboard URL");
|
|
470
470
|
}
|
|
471
471
|
|
|
472
472
|
export async function fetchAlphaclawVersion(refresh = false) {
|
|
@@ -682,13 +682,15 @@ export async function fetchDevicePairings() {
|
|
|
682
682
|
}
|
|
683
683
|
|
|
684
684
|
export async function approveDevice(id) {
|
|
685
|
-
const
|
|
686
|
-
|
|
685
|
+
const safeId = encodeURIComponent(String(id || ""));
|
|
686
|
+
const res = await authFetch(`/api/devices/${safeId}/approve`, { method: "POST" });
|
|
687
|
+
return parseJsonOrThrow(res, "Could not approve device");
|
|
687
688
|
}
|
|
688
689
|
|
|
689
690
|
export async function rejectDevice(id) {
|
|
690
|
-
const
|
|
691
|
-
|
|
691
|
+
const safeId = encodeURIComponent(String(id || ""));
|
|
692
|
+
const res = await authFetch(`/api/devices/${safeId}/reject`, { method: "POST" });
|
|
693
|
+
return parseJsonOrThrow(res, "Could not reject device");
|
|
692
694
|
}
|
|
693
695
|
|
|
694
696
|
export const fetchNodesStatus = async () => {
|
|
@@ -1243,8 +1245,11 @@ export async function fetchWebhookRequest(name, id) {
|
|
|
1243
1245
|
return parseJsonOrThrow(res, "Could not load webhook request");
|
|
1244
1246
|
}
|
|
1245
1247
|
|
|
1246
|
-
export const fetchBrowseTree = async (
|
|
1248
|
+
export const fetchBrowseTree = async (options = {}) => {
|
|
1249
|
+
const { depth = 3, path = "" } =
|
|
1250
|
+
typeof options === "number" ? { depth: options, path: "" } : options;
|
|
1247
1251
|
const params = new URLSearchParams({ depth: String(depth) });
|
|
1252
|
+
if (path) params.set("path", String(path));
|
|
1248
1253
|
const res = await authFetch(`/api/browse/tree?${params.toString()}`);
|
|
1249
1254
|
return parseJsonOrThrow(res, "Could not load file tree");
|
|
1250
1255
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
const kDefaultTreeDepth =
|
|
1
|
+
const kDefaultTreeDepth = 3;
|
|
2
|
+
const kMaxTreeDepth = 3;
|
|
2
3
|
const kIgnoredDirectoryNames = new Set([
|
|
3
4
|
".git",
|
|
4
5
|
".alphaclaw",
|
|
@@ -42,6 +43,7 @@ const kSqliteTablePageSize = 50;
|
|
|
42
43
|
|
|
43
44
|
module.exports = {
|
|
44
45
|
kDefaultTreeDepth,
|
|
46
|
+
kMaxTreeDepth,
|
|
45
47
|
kIgnoredDirectoryNames,
|
|
46
48
|
kImageMimeTypeByExtension,
|
|
47
49
|
kCommitHistoryLimit,
|
|
@@ -2,6 +2,7 @@ const path = require("path");
|
|
|
2
2
|
const { kLockedBrowsePaths, kProtectedBrowsePaths } = require("../../constants");
|
|
3
3
|
const {
|
|
4
4
|
kDefaultTreeDepth,
|
|
5
|
+
kMaxTreeDepth,
|
|
5
6
|
kIgnoredDirectoryNames,
|
|
6
7
|
kCommitHistoryLimit,
|
|
7
8
|
} = require("./constants");
|
|
@@ -34,6 +35,16 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
|
|
|
34
35
|
fs.mkdirSync(kRootResolved, { recursive: true });
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
const readVisibleDirectoryEntries = (absolutePath) =>
|
|
39
|
+
fs
|
|
40
|
+
.readdirSync(absolutePath, { withFileTypes: true })
|
|
41
|
+
.filter((entry) => {
|
|
42
|
+
if (entry.isDirectory() && kIgnoredDirectoryNames.has(entry.name)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return entry.isDirectory() || entry.isFile();
|
|
46
|
+
});
|
|
47
|
+
|
|
37
48
|
const buildTreeNode = (absolutePath, depthRemaining) => {
|
|
38
49
|
const stats = fs.statSync(absolutePath);
|
|
39
50
|
const nodeName = path.basename(absolutePath);
|
|
@@ -44,17 +55,17 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
|
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
if (depthRemaining <= 0) {
|
|
47
|
-
|
|
58
|
+
const hasMoreChildren = readVisibleDirectoryEntries(absolutePath).length > 0;
|
|
59
|
+
return {
|
|
60
|
+
type: "folder",
|
|
61
|
+
name: nodeName,
|
|
62
|
+
path: nodePath,
|
|
63
|
+
children: [],
|
|
64
|
+
truncated: hasMoreChildren,
|
|
65
|
+
};
|
|
48
66
|
}
|
|
49
67
|
|
|
50
|
-
const children =
|
|
51
|
-
.readdirSync(absolutePath, { withFileTypes: true })
|
|
52
|
-
.filter((entry) => {
|
|
53
|
-
if (entry.isDirectory() && kIgnoredDirectoryNames.has(entry.name)) {
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
return entry.isDirectory() || entry.isFile();
|
|
57
|
-
})
|
|
68
|
+
const children = readVisibleDirectoryEntries(absolutePath)
|
|
58
69
|
.map((entry) =>
|
|
59
70
|
buildTreeNode(path.join(absolutePath, entry.name), depthRemaining - 1),
|
|
60
71
|
)
|
|
@@ -70,12 +81,29 @@ const registerBrowseRoutes = ({ app, fs, kRootDir }) => {
|
|
|
70
81
|
|
|
71
82
|
app.get("/api/browse/tree", (req, res) => {
|
|
72
83
|
const depthValue = Number.parseInt(String(req.query.depth || ""), 10);
|
|
73
|
-
const
|
|
84
|
+
const requestedDepth =
|
|
74
85
|
Number.isFinite(depthValue) && depthValue > 0
|
|
75
86
|
? depthValue
|
|
76
87
|
: kDefaultTreeDepth;
|
|
88
|
+
const depth = Math.min(requestedDepth, kMaxTreeDepth);
|
|
89
|
+
const requestedPath = String(req.query.path || "").trim();
|
|
77
90
|
try {
|
|
78
|
-
const
|
|
91
|
+
const resolvedPath = requestedPath
|
|
92
|
+
? resolveSafePath(
|
|
93
|
+
requestedPath,
|
|
94
|
+
kRootResolved,
|
|
95
|
+
kRootWithSep,
|
|
96
|
+
kRootDisplayName,
|
|
97
|
+
)
|
|
98
|
+
: { ok: true, absolutePath: kRootResolved };
|
|
99
|
+
if (!resolvedPath.ok) {
|
|
100
|
+
return res.status(400).json({ ok: false, error: resolvedPath.error });
|
|
101
|
+
}
|
|
102
|
+
const stats = fs.statSync(resolvedPath.absolutePath);
|
|
103
|
+
if (!stats.isDirectory()) {
|
|
104
|
+
return res.status(400).json({ ok: false, error: "Path is not a folder" });
|
|
105
|
+
}
|
|
106
|
+
const tree = buildTreeNode(resolvedPath.absolutePath, depth);
|
|
79
107
|
return res.json({ ok: true, root: tree });
|
|
80
108
|
} catch (error) {
|
|
81
109
|
return res.status(500).json({
|
|
@@ -10,8 +10,88 @@ const kAllowedPairingChannels = new Set(["telegram", "discord", "slack", "whatsa
|
|
|
10
10
|
const kSafePairingArgPattern = /^[\w\-:.]+$/;
|
|
11
11
|
const kDevicesListCliTimeoutMs = 5000;
|
|
12
12
|
const kPairingRequestTtlMs = 60 * 60 * 1000;
|
|
13
|
+
const kDeviceApprovalCallerScopes = [
|
|
14
|
+
"operator.admin",
|
|
15
|
+
"operator.read",
|
|
16
|
+
"operator.write",
|
|
17
|
+
"operator.approvals",
|
|
18
|
+
"operator.pairing",
|
|
19
|
+
"operator.talk.secrets",
|
|
20
|
+
];
|
|
13
21
|
const quoteCliArg = (value) => quoteShellArg(value, { strategy: "single" });
|
|
14
22
|
|
|
23
|
+
let deviceBootstrapModulePromise = null;
|
|
24
|
+
|
|
25
|
+
const loadDeviceBootstrapModule = async () => {
|
|
26
|
+
deviceBootstrapModulePromise ||= import("openclaw/plugin-sdk/device-bootstrap");
|
|
27
|
+
return deviceBootstrapModulePromise;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const defaultApproveDevicePairingDirect = async (requestId, options, baseDir) => {
|
|
31
|
+
const mod = await loadDeviceBootstrapModule();
|
|
32
|
+
if (typeof mod.approveDevicePairing !== "function") {
|
|
33
|
+
throw new Error("OpenClaw device approval helper is unavailable");
|
|
34
|
+
}
|
|
35
|
+
return mod.approveDevicePairing(requestId, options, baseDir);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const formatDevicePairingForbiddenMessage = (result) => {
|
|
39
|
+
switch (result?.reason) {
|
|
40
|
+
case "caller-scopes-required":
|
|
41
|
+
return `missing scope: ${result.scope || "callerScopes-required"}`;
|
|
42
|
+
case "caller-missing-scope":
|
|
43
|
+
return `missing scope: ${result.scope || "unknown"}`;
|
|
44
|
+
case "scope-outside-requested-roles":
|
|
45
|
+
return `invalid scope for requested roles: ${result.scope || "unknown"}`;
|
|
46
|
+
case "bootstrap-role-not-allowed":
|
|
47
|
+
return `bootstrap profile does not allow role: ${result.role || "unknown"}`;
|
|
48
|
+
case "bootstrap-scope-not-allowed":
|
|
49
|
+
return `bootstrap profile does not allow scope: ${result.scope || "unknown"}`;
|
|
50
|
+
default:
|
|
51
|
+
return "Device pairing approval forbidden";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const redactApprovedDevice = (device) => {
|
|
56
|
+
if (!device || typeof device !== "object") return null;
|
|
57
|
+
const safeDevice = { ...device };
|
|
58
|
+
delete safeDevice.publicKey;
|
|
59
|
+
delete safeDevice.tokens;
|
|
60
|
+
return safeDevice;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const normalizeDeviceApprovalResult = (approval, requestId) => {
|
|
64
|
+
if (approval?.status === "approved") {
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
requestId: approval.requestId || requestId,
|
|
68
|
+
device: redactApprovedDevice(approval.device),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (approval?.status === "forbidden") {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
statusCode: 403,
|
|
75
|
+
error: formatDevicePairingForbiddenMessage(approval),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
statusCode: 404,
|
|
81
|
+
error: "Device pairing request not found",
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const toHttpDeviceApprovalPayload = (result) => {
|
|
86
|
+
const { statusCode, ...payload } = result || {};
|
|
87
|
+
return payload;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const isValidDeviceRequestId = (value) => {
|
|
91
|
+
const requestId = String(value || "").trim();
|
|
92
|
+
return Boolean(requestId && kSafePairingArgPattern.test(requestId));
|
|
93
|
+
};
|
|
94
|
+
|
|
15
95
|
const resolvePairingStorePath = ({ openclawDir, channel }) =>
|
|
16
96
|
path.join(openclawDir, "credentials", `${String(channel).trim().toLowerCase()}-pairing.json`);
|
|
17
97
|
|
|
@@ -136,7 +216,14 @@ const removeAccountRequestsFromPairingStore = ({ fsModule, openclawDir, channel,
|
|
|
136
216
|
}
|
|
137
217
|
};
|
|
138
218
|
|
|
139
|
-
const registerPairingRoutes = ({
|
|
219
|
+
const registerPairingRoutes = ({
|
|
220
|
+
app,
|
|
221
|
+
clawCmd,
|
|
222
|
+
isOnboarded,
|
|
223
|
+
fsModule = fs,
|
|
224
|
+
openclawDir = OPENCLAW_DIR,
|
|
225
|
+
approveDevicePairingDirect = defaultApproveDevicePairingDirect,
|
|
226
|
+
}) => {
|
|
140
227
|
let pairingCache = { pending: [], ts: 0, ttlMs: 0 };
|
|
141
228
|
const kPairingCacheTtlMs = 10000;
|
|
142
229
|
const kEmptyPairingCacheTtlMs = 1000;
|
|
@@ -157,6 +244,23 @@ const registerPairingRoutes = ({ app, clawCmd, isOnboarded, fsModule = fs, openc
|
|
|
157
244
|
);
|
|
158
245
|
};
|
|
159
246
|
|
|
247
|
+
const approveDeviceRequestWithAdminScope = async (requestId) => {
|
|
248
|
+
try {
|
|
249
|
+
const approval = await approveDevicePairingDirect(
|
|
250
|
+
requestId,
|
|
251
|
+
{ callerScopes: kDeviceApprovalCallerScopes },
|
|
252
|
+
openclawDir,
|
|
253
|
+
);
|
|
254
|
+
return normalizeDeviceApprovalResult(approval, requestId);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
statusCode: 500,
|
|
259
|
+
error: error?.message || "Could not approve device pairing",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
160
264
|
const parsePendingPairings = (stdout, channel) => {
|
|
161
265
|
const parsed = parseJsonObjectFromNoisyOutput(stdout) || {};
|
|
162
266
|
const requestLists = [
|
|
@@ -320,15 +424,13 @@ const registerPairingRoutes = ({ app, clawCmd, isOnboarded, fsModule = fs, openc
|
|
|
320
424
|
const firstCliPendingId = firstCliPending?.requestId || firstCliPending?.id;
|
|
321
425
|
if (firstCliPendingId) {
|
|
322
426
|
console.log(`[alphaclaw] Auto-approving first CLI device request: ${firstCliPendingId}`);
|
|
323
|
-
const approveResult = await
|
|
324
|
-
quiet: true,
|
|
325
|
-
});
|
|
427
|
+
const approveResult = await approveDeviceRequestWithAdminScope(firstCliPendingId);
|
|
326
428
|
if (approveResult.ok) {
|
|
327
429
|
writeCliAutoApproveMarker();
|
|
328
430
|
autoApprovedRequestId = String(firstCliPendingId);
|
|
329
431
|
} else {
|
|
330
432
|
console.log(
|
|
331
|
-
`[alphaclaw] CLI auto-approve failed: ${(approveResult.
|
|
433
|
+
`[alphaclaw] CLI auto-approve failed: ${(approveResult.error || "").slice(0, 200)}`,
|
|
332
434
|
);
|
|
333
435
|
}
|
|
334
436
|
}
|
|
@@ -353,13 +455,23 @@ const registerPairingRoutes = ({ app, clawCmd, isOnboarded, fsModule = fs, openc
|
|
|
353
455
|
});
|
|
354
456
|
|
|
355
457
|
app.post("/api/devices/:id/approve", async (req, res) => {
|
|
356
|
-
const
|
|
458
|
+
const requestId = String(req.params.id || "").trim();
|
|
459
|
+
if (!isValidDeviceRequestId(requestId)) {
|
|
460
|
+
return res.status(400).json({ ok: false, error: "Invalid device request id" });
|
|
461
|
+
}
|
|
462
|
+
const result = await approveDeviceRequestWithAdminScope(requestId);
|
|
357
463
|
devicePairingCache.ts = 0;
|
|
358
|
-
res
|
|
464
|
+
res
|
|
465
|
+
.status(result.ok ? 200 : result.statusCode || 500)
|
|
466
|
+
.json(toHttpDeviceApprovalPayload(result));
|
|
359
467
|
});
|
|
360
468
|
|
|
361
469
|
app.post("/api/devices/:id/reject", async (req, res) => {
|
|
362
|
-
const
|
|
470
|
+
const requestId = String(req.params.id || "").trim();
|
|
471
|
+
if (!isValidDeviceRequestId(requestId)) {
|
|
472
|
+
return res.status(400).json({ ok: false, error: "Invalid device request id" });
|
|
473
|
+
}
|
|
474
|
+
const result = await clawCmd(`devices reject ${quoteCliArg(requestId)}`);
|
|
363
475
|
devicePairingCache.ts = 0;
|
|
364
476
|
res.json(result);
|
|
365
477
|
});
|