@chrysb/alphaclaw 0.9.13 → 0.9.15

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">
@@ -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);
@@ -46,9 +46,6 @@ export const ModalShell = ({
46
46
  onpointercancel=${() => {
47
47
  overlayPointerDownRef.current = false;
48
48
  }}
49
- onclick=${(event) => {
50
- event.preventDefault();
51
- }}
52
49
  >
53
50
  <div class=${panelClassName}>${children}</div>
54
51
  </div>
@@ -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 () => {
@@ -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
  });
@@ -28,6 +28,7 @@ const registerSystemRoutes = ({
28
28
  doctorService,
29
29
  }) => {
30
30
  let envRestartPending = false;
31
+ let openclawSecretRuntimePromise = null;
31
32
  const kManagedChannelTokenPattern =
32
33
  /^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/;
33
34
  const kEnvVarsReservedForUserInput = new Set([
@@ -92,6 +93,97 @@ const registerSystemRoutes = ({
92
93
  }
93
94
  return null;
94
95
  };
96
+ const getEnvFileValue = (key) =>
97
+ (typeof readEnvFile === "function" ? readEnvFile() : []).find(
98
+ (entry) => entry?.key === key,
99
+ )?.value;
100
+ const normalizeSecretValue = (value) => {
101
+ if (typeof value !== "string") return "";
102
+ const trimmed = String(value || "").trim();
103
+ if (trimmed.length >= 2) {
104
+ const first = trimmed[0];
105
+ const last = trimmed[trimmed.length - 1];
106
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
107
+ return trimmed.slice(1, -1).trim();
108
+ }
109
+ }
110
+ return trimmed;
111
+ };
112
+ const getEnvObject = () => {
113
+ const env = { ...process.env };
114
+ for (const entry of typeof readEnvFile === "function" ? readEnvFile() : []) {
115
+ const key = String(entry?.key || "").trim();
116
+ if (!key) continue;
117
+ if (!normalizeSecretValue(env[key])) {
118
+ env[key] = normalizeSecretValue(entry?.value);
119
+ }
120
+ }
121
+ return env;
122
+ };
123
+ const loadOpenclawSecretRuntime = async () => {
124
+ openclawSecretRuntimePromise ||= Promise.all([
125
+ import("openclaw/plugin-sdk/secret-input"),
126
+ import("openclaw/plugin-sdk/runtime-secret-resolution"),
127
+ ]).then(([secretInput, runtimeSecretResolution]) => ({
128
+ coerceSecretRef: secretInput.coerceSecretRef,
129
+ resolveSecretRefValues: runtimeSecretResolution.resolveSecretRefValues,
130
+ }));
131
+ return openclawSecretRuntimePromise;
132
+ };
133
+ const resolveSecretRefToken = async ({ config, value, env }) => {
134
+ try {
135
+ const { coerceSecretRef, resolveSecretRefValues } =
136
+ await loadOpenclawSecretRuntime();
137
+ const ref = coerceSecretRef(value, config?.secrets?.defaults);
138
+ if (!ref) return "";
139
+ const resolved = await resolveSecretRefValues([ref], { config, env });
140
+ const refKey = `${ref.source}:${ref.provider}:${ref.id}`;
141
+ return normalizeSecretValue(resolved.get(refKey));
142
+ } catch {
143
+ return "";
144
+ }
145
+ };
146
+ const resolveEnvReference = (value) => {
147
+ const match = String(value || "").trim().match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/);
148
+ if (!match) return "";
149
+ const envKey = match[1];
150
+ const envValue = process.env[envKey] || getEnvFileValue(envKey);
151
+ return normalizeSecretValue(envValue);
152
+ };
153
+ const getDashboardTokenFromConfig = async () => {
154
+ const config = readOpenclawConfig({
155
+ fsModule: fs,
156
+ openclawDir: OPENCLAW_DIR,
157
+ fallback: {},
158
+ });
159
+ const env = getEnvObject();
160
+ const configuredToken = config?.gateway?.auth?.token;
161
+ const resolvedSecretRefToken = await resolveSecretRefToken({
162
+ config,
163
+ value: configuredToken,
164
+ env,
165
+ });
166
+ if (resolvedSecretRefToken) return resolvedSecretRefToken;
167
+ if (typeof configuredToken === "string" && configuredToken.trim()) {
168
+ const trimmedToken = normalizeSecretValue(configuredToken);
169
+ if (/^\$\{[A-Z_][A-Z0-9_]*\}$/.test(trimmedToken)) {
170
+ return resolveEnvReference(trimmedToken);
171
+ }
172
+ return trimmedToken;
173
+ }
174
+ return normalizeSecretValue(env.OPENCLAW_GATEWAY_TOKEN);
175
+ };
176
+ const buildDashboardUrl = (token) =>
177
+ token ? `/openclaw/#token=${encodeURIComponent(token)}` : "/openclaw";
178
+ const extractDashboardTokenFromOutput = (stdout) => {
179
+ const tokenMatch = String(stdout || "").match(/[#?&]token=([^\s&#]+)/);
180
+ if (!tokenMatch) return "";
181
+ try {
182
+ return decodeURIComponent(tokenMatch[1]);
183
+ } catch {
184
+ return tokenMatch[1];
185
+ }
186
+ };
95
187
  const getRawSessionKey = (sessionRow = {}) =>
96
188
  String(sessionRow?.key || sessionRow?.sessionKey || sessionRow?.id || "").trim();
97
189
  const getRawSessionsFromPayload = (payload) => {
@@ -692,14 +784,22 @@ const registerSystemRoutes = ({
692
784
 
693
785
  app.get("/api/gateway/dashboard", async (req, res) => {
694
786
  if (!isOnboarded()) return res.json({ ok: false, url: "/openclaw" });
787
+ const token = await getDashboardTokenFromConfig();
788
+ if (token) {
789
+ return res.json({
790
+ ok: true,
791
+ url: buildDashboardUrl(token),
792
+ source: "config",
793
+ });
794
+ }
695
795
  const result = await clawCmd("dashboard --no-open");
696
796
  if (result.ok && result.stdout) {
697
- const tokenMatch = result.stdout.match(/#token=([a-zA-Z0-9]+)/);
698
- if (tokenMatch) {
699
- return res.json({ ok: true, url: `/openclaw/#token=${tokenMatch[1]}` });
797
+ const cliToken = extractDashboardTokenFromOutput(result.stdout);
798
+ if (cliToken) {
799
+ return res.json({ ok: true, url: buildDashboardUrl(cliToken) });
700
800
  }
701
801
  }
702
- res.json({ ok: true, url: "/openclaw" });
802
+ res.json({ ok: true, url: "/openclaw", needsAuth: true });
703
803
  });
704
804
 
705
805
  app.get("/api/restart-status", async (req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "express": "^4.21.0",
35
35
  "http-proxy": "^1.18.1",
36
- "openclaw": "2026.5.2",
36
+ "openclaw": "2026.5.6",
37
37
  "ws": "^8.19.0"
38
38
  },
39
39
  "devDependencies": {