@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.
@@ -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="Optional emoji"
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 nextSignature = JSON.stringify(nextRoot || {});
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(nextRoot);
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
- await approveDevice(id);
222
- setTimeout(devicePoll.refresh, 500);
223
- setTimeout(devicePoll.refresh, 2000);
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
- await rejectDevice(id);
228
- setTimeout(devicePoll.refresh, 500);
229
- setTimeout(devicePoll.refresh, 2000);
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
- console.log("[dashboard] response:", JSON.stringify(data));
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
- console.error("[dashboard] error:", err);
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) => String(agent?.identity?.emoji || "").trim();
95
+ const getAgentIdentityEmoji = (agent) => sanitizeAgentEmoji(agent?.identity?.emoji);
95
96
 
96
97
  export const AppSidebar = ({
97
98
  mobileSidebarOpen = false,
@@ -0,0 +1,8 @@
1
+ const kNonEmojiPattern = /[A-Za-z0-9:]/;
2
+
3
+ export const sanitizeAgentEmoji = (rawEmoji) => {
4
+ const trimmed = String(rawEmoji ?? "").trim();
5
+ if (!trimmed) return "";
6
+ if (kNonEmojiPattern.test(trimmed)) return "";
7
+ return trimmed;
8
+ };
@@ -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.json();
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 res = await authFetch(`/api/devices/${id}/approve`, { method: "POST" });
686
- return res.json();
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 res = await authFetch(`/api/devices/${id}/reject`, { method: "POST" });
691
- return res.json();
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 (depth = 10) => {
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 = 10;
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
- return { type: "folder", name: nodeName, path: nodePath, children: [] };
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 = fs
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 depth =
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 tree = buildTreeNode(kRootResolved, depth);
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 = ({ app, clawCmd, isOnboarded, fsModule = fs, openclawDir = OPENCLAW_DIR }) => {
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 clawCmd(`devices approve ${firstCliPendingId}`, {
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.stderr || "").slice(0, 200)}`,
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 result = await clawCmd(`devices approve ${req.params.id}`);
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.json(result);
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 result = await clawCmd(`devices reject ${req.params.id}`);
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
  });