squad-openclaw 2026.2.2019 → 2026.2.2021

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/dist/index.js CHANGED
@@ -1,2482 +1,2577 @@
1
- // src/agents.ts
2
- import { execSync } from "child_process";
3
- function registerAgentMethods(api) {
4
- api.registerGatewayMethod(
5
- "squad.agents.add",
6
- async ({ params, respond }) => {
7
- const name = params?.name;
8
- const model = params?.model;
9
- if (!name || typeof name !== "string" || !name.trim()) {
10
- respond(false, { error: "Missing or empty 'name' parameter" });
11
- return;
12
- }
13
- const safeName = name.trim();
14
- if (!/^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/.test(safeName)) {
15
- respond(false, { error: "Agent name must start with a letter/number and contain only letters, numbers, spaces, hyphens, or underscores" });
16
- return;
17
- }
18
- try {
19
- let cmd = `openclaw agents add ${JSON.stringify(safeName)} --non-interactive`;
20
- if (model) {
21
- cmd += ` --model ${JSON.stringify(model)}`;
22
- }
23
- const output = execSync(cmd, {
24
- timeout: 3e4,
25
- encoding: "utf-8",
26
- stdio: ["pipe", "pipe", "pipe"]
27
- });
28
- respond(true, { ok: true, output: output.slice(0, 1e3) });
29
- } catch (err2) {
30
- const msg = err2 instanceof Error ? err2.message : String(err2);
31
- const stderr = err2?.stderr;
32
- respond(false, {
33
- error: `Failed to add agent: ${stderr || msg}`.slice(0, 500)
34
- });
35
- }
36
- }
37
- );
38
- api.registerGatewayMethod(
39
- "squad.agents.delete",
40
- async ({ params, respond }) => {
41
- const agentId = params?.agentId;
42
- if (!agentId || typeof agentId !== "string" || !agentId.trim()) {
43
- respond(false, { error: "Missing or empty 'agentId' parameter" });
44
- return;
45
- }
46
- if (agentId === "main") {
47
- respond(false, { error: "Cannot delete the main agent" });
48
- return;
49
- }
50
- if (!/^[a-z0-9][a-z0-9-]*$/.test(agentId)) {
51
- respond(false, { error: "Invalid agent ID format" });
52
- return;
53
- }
54
- try {
55
- const output = execSync(
56
- `openclaw agents delete ${JSON.stringify(agentId)} --non-interactive 2>&1`,
57
- { timeout: 3e4, encoding: "utf-8" }
58
- );
59
- respond(true, { ok: true, output: output.slice(0, 1e3) });
60
- } catch (err2) {
61
- const msg = err2 instanceof Error ? err2.message : String(err2);
62
- const stderr = err2?.stderr;
63
- respond(false, {
64
- error: `Failed to delete agent: ${stderr || msg}`.slice(0, 500)
65
- });
66
- }
67
- }
68
- );
69
- api.registerGatewayMethod(
70
- "squad.agents.set-identity",
71
- async ({ params, respond }) => {
72
- const agentId = params?.agentId;
73
- const name = params?.name;
74
- const emoji = params?.emoji;
75
- const theme = params?.theme;
76
- if (!agentId || typeof agentId !== "string") {
77
- respond(false, { error: "Missing 'agentId' parameter" });
78
- return;
79
- }
80
- const args = [`--agent`, JSON.stringify(agentId)];
81
- if (name) args.push(`--name`, JSON.stringify(name));
82
- if (emoji) args.push(`--emoji`, JSON.stringify(emoji));
83
- if (theme) args.push(`--theme`, JSON.stringify(theme));
84
- if (args.length <= 2) {
85
- respond(false, { error: "No identity fields provided (name, emoji, or theme)" });
86
- return;
87
- }
88
- try {
89
- const output = execSync(
90
- `openclaw agents set-identity ${args.join(" ")} 2>&1`,
91
- { timeout: 15e3, encoding: "utf-8" }
92
- );
93
- respond(true, { ok: true, output: output.slice(0, 1e3) });
94
- } catch (err2) {
95
- const msg = err2 instanceof Error ? err2.message : String(err2);
96
- const stderr = err2?.stderr;
97
- respond(false, {
98
- error: `Failed to set identity: ${stderr || msg}`.slice(0, 500)
99
- });
100
- }
101
- }
102
- );
103
- }
104
-
105
- // src/entities.ts
106
- import { Type as T } from "@sinclair/typebox";
107
- import path4 from "path";
108
- import fs4 from "fs";
1
+ // src/relay-client.ts
2
+ import { WebSocket as NodeWebSocket } from "ws";
3
+ import crypto3 from "crypto";
4
+ import fs3 from "fs";
5
+ import path3 from "path";
109
6
 
110
- // src/watcher.ts
111
- import path from "path";
112
- import fs from "fs";
113
- import chokidar from "chokidar";
114
- var debounceTimers = /* @__PURE__ */ new Map();
115
- var DEBOUNCE_MS = 500;
116
- function debounced(key, fn) {
117
- const existing = debounceTimers.get(key);
118
- if (existing) clearTimeout(existing);
119
- debounceTimers.set(
120
- key,
121
- setTimeout(() => {
122
- debounceTimers.delete(key);
123
- fn();
124
- }, DEBOUNCE_MS)
125
- );
126
- }
127
- var fsDebounceTimers = /* @__PURE__ */ new Map();
128
- var FS_DEBOUNCE_MS = 300;
129
- function debouncedFs(relPath, action, fn) {
130
- const key = `fs:${action}:${relPath}`;
131
- const existing = fsDebounceTimers.get(key);
132
- if (existing) clearTimeout(existing);
133
- fsDebounceTimers.set(
134
- key,
135
- setTimeout(() => {
136
- fsDebounceTimers.delete(key);
137
- fn();
138
- }, FS_DEBOUNCE_MS)
139
- );
140
- }
141
- function isWorkspaceIdentity(filePath, configDir) {
142
- const rel = path.relative(configDir, filePath);
143
- const match = rel.match(/^(workspace(?:-([^/]+))?)\/IDENTITY\.md$/);
144
- if (!match) return null;
145
- const dirName = match[1];
146
- const agentId = match[2] ?? "main";
147
- return { agentId, workspacePath: path.join(configDir, dirName) };
148
- }
149
- function isWorkspaceAgentJson(filePath, configDir) {
150
- const rel = path.relative(configDir, filePath);
151
- const match = rel.match(/^(workspace(?:-([^/]+))?)\/agent\.json$/);
152
- if (!match) return null;
153
- const dirName = match[1];
154
- const agentId = match[2] ?? "main";
155
- return { agentId, workspacePath: path.join(configDir, dirName) };
156
- }
157
- function isGlobalSkillDir(filePath, configDir) {
158
- const rel = path.relative(configDir, filePath);
159
- const match = rel.match(/^skills\/([^/]+)\/?$/);
160
- if (!match) return null;
161
- return { skillKey: match[1] };
162
- }
163
- function isWorkspaceSkillDir(filePath, configDir) {
164
- const rel = path.relative(configDir, filePath);
165
- const match = rel.match(
166
- /^workspace(?:-([^/]+))?\/skills\/([^/]+)\/?$/
167
- );
168
- if (!match) return null;
169
- return { agentId: match[1] ?? "main", skillKey: match[2] };
170
- }
171
- function isPluginManifest(filePath, configDir) {
172
- const rel = path.relative(configDir, filePath);
173
- const match = rel.match(/^extensions\/([^/]+)\/openclaw\.plugin\.json$/);
174
- if (!match) return null;
175
- return { pluginDirName: match[1] };
176
- }
177
- function isOpenClawConfig(filePath, configDir) {
178
- return path.relative(configDir, filePath) === "openclaw.json";
179
- }
180
- function updateAgent(agentId, workspacePath) {
181
- const now = Date.now();
182
- let name = agentId;
183
- const metadata = { workspacePath };
184
- try {
185
- const content = fs.readFileSync(
186
- path.join(workspacePath, "IDENTITY.md"),
187
- "utf-8"
188
- );
189
- const parsed = parseIdentityName(content);
190
- if (parsed) name = parsed;
191
- } catch {
192
- }
193
- if (name === agentId) {
194
- try {
195
- const raw = fs.readFileSync(
196
- path.join(workspacePath, "agent.json"),
197
- "utf-8"
198
- );
199
- const config = JSON.parse(raw);
200
- if (config.displayName) name = config.displayName;
201
- if (config.model) metadata.model = config.model;
202
- } catch {
203
- }
204
- }
205
- registrySet({
206
- id: agentId,
207
- type: "agent",
208
- name,
209
- title: name,
210
- description: null,
211
- metadata,
212
- source: "filesystem",
213
- source_key: workspacePath,
214
- created_at: now,
215
- updated_at: now
216
- });
217
- }
218
- function updatePlugin(pluginDirName, configDir) {
219
- const now = Date.now();
220
- const manifestPath = path.join(
221
- configDir,
222
- "extensions",
223
- pluginDirName,
224
- "openclaw.plugin.json"
225
- );
226
- try {
227
- const raw = fs.readFileSync(manifestPath, "utf-8");
228
- const manifest = JSON.parse(raw);
229
- const pluginId = manifest.id || pluginDirName;
230
- const name = manifest.name || pluginId;
231
- registrySet({
232
- id: `plugin:${pluginId}`,
233
- type: "plugin",
234
- name,
235
- title: name,
236
- description: manifest.description || null,
237
- metadata: { pluginId, pluginDir: path.dirname(manifestPath) },
238
- source: "filesystem",
239
- source_key: manifestPath,
240
- created_at: now,
241
- updated_at: now
242
- });
243
- } catch {
244
- registryDelete(`plugin:${pluginDirName}`);
245
- }
246
- }
247
- function startWatcher(configDir, onFsChange) {
248
- const watcher = chokidar.watch(configDir, {
249
- persistent: true,
250
- usePolling: false,
251
- ignoreInitial: true,
252
- awaitWriteFinish: { stabilityThreshold: 300 },
253
- depth: 4,
254
- ignored: [
255
- // Ignore heavy directories that aren't relevant
256
- "**/node_modules/**",
257
- "**/dist/**",
258
- "**/.git/**",
259
- "**/data/**"
260
- ]
261
- });
262
- const emitFsChange = (action, filePath) => {
263
- if (!onFsChange) return;
264
- const rel = path.relative(configDir, filePath);
265
- debouncedFs(rel, action, () => {
266
- onFsChange({ action, path: rel });
267
- });
268
- };
269
- const handleChange = (filePath, action) => {
270
- emitFsChange(action, filePath);
271
- const identity = isWorkspaceIdentity(filePath, configDir);
272
- if (identity) {
273
- debounced(
274
- `agent:${identity.agentId}`,
275
- () => updateAgent(identity.agentId, identity.workspacePath)
276
- );
277
- return;
278
- }
279
- const agentJson = isWorkspaceAgentJson(filePath, configDir);
280
- if (agentJson) {
281
- debounced(
282
- `agent:${agentJson.agentId}`,
283
- () => updateAgent(agentJson.agentId, agentJson.workspacePath)
284
- );
285
- return;
286
- }
287
- const plugin = isPluginManifest(filePath, configDir);
288
- if (plugin) {
289
- debounced(
290
- `plugin:${plugin.pluginDirName}`,
291
- () => updatePlugin(plugin.pluginDirName, configDir)
292
- );
293
- return;
294
- }
295
- if (isOpenClawConfig(filePath, configDir)) {
296
- debounced("tools", () => scanTools(configDir));
297
- return;
298
- }
299
- };
300
- const handleAddDir = (dirPath) => {
301
- emitFsChange("addDir", dirPath);
302
- const globalSkill = isGlobalSkillDir(dirPath, configDir);
303
- if (globalSkill) {
304
- debounced(
305
- `skill:${globalSkill.skillKey}`,
306
- () => scanSkills(configDir)
307
- );
308
- return;
309
- }
310
- const wsSkill = isWorkspaceSkillDir(dirPath, configDir);
311
- if (wsSkill) {
312
- debounced(
313
- `skill:${wsSkill.agentId}:${wsSkill.skillKey}`,
314
- () => scanSkills(configDir)
315
- );
316
- return;
317
- }
318
- const rel = path.relative(configDir, dirPath);
319
- if (/^workspace(-[^/]+)?$/.test(rel)) {
320
- debounced("agents", () => scanAgents(configDir));
321
- return;
322
- }
323
- };
324
- const handleUnlinkDir = (dirPath) => {
325
- emitFsChange("unlinkDir", dirPath);
326
- const rel = path.relative(configDir, dirPath);
327
- const wsMatch = rel.match(/^workspace(?:-([^/]+))?$/);
328
- if (wsMatch) {
329
- const agentId = wsMatch[1] ?? "main";
330
- registryDelete(agentId);
331
- return;
332
- }
333
- const globalSkill = isGlobalSkillDir(dirPath, configDir);
334
- if (globalSkill) {
335
- registryDelete(`skill:${globalSkill.skillKey}`);
336
- return;
337
- }
338
- const wsSkill = isWorkspaceSkillDir(dirPath, configDir);
339
- if (wsSkill) {
340
- registryDelete(`skill:${wsSkill.agentId}:${wsSkill.skillKey}`);
341
- return;
342
- }
343
- };
344
- watcher.on("add", (fp) => handleChange(fp, "add"));
345
- watcher.on("change", (fp) => handleChange(fp, "change"));
346
- watcher.on("unlink", (fp) => handleChange(fp, "unlink"));
347
- watcher.on("addDir", handleAddDir);
348
- watcher.on("unlinkDir", handleUnlinkDir);
349
- return () => {
350
- for (const timer of debounceTimers.values()) {
351
- clearTimeout(timer);
352
- }
353
- debounceTimers.clear();
354
- for (const timer of fsDebounceTimers.values()) {
355
- clearTimeout(timer);
356
- }
357
- fsDebounceTimers.clear();
358
- watcher.close();
359
- };
360
- }
361
-
362
- // src/filesystem.ts
363
- import fs3 from "fs";
364
- import path3 from "path";
365
-
366
- // src/paths.ts
367
- import path2 from "path";
368
- import os from "os";
369
- import fs2 from "fs";
370
- function getOpenclawStateDir() {
371
- if (process.env.OPENCLAW_STATE_DIR) {
372
- return process.env.OPENCLAW_STATE_DIR;
373
- }
374
- if (process.env.OPENCLAW_CONFIG_PATH) {
375
- return path2.dirname(process.env.OPENCLAW_CONFIG_PATH);
376
- }
377
- const legacyDir = process.env.OPENCLAW_DIR;
378
- if (legacyDir) {
379
- const resolvedLegacyDir = path2.resolve(legacyDir);
380
- const configPath = path2.join(resolvedLegacyDir, "openclaw.json");
381
- const hasStateMarkers = fs2.existsSync(configPath) || fs2.existsSync(path2.join(resolvedLegacyDir, "agents")) || fs2.existsSync(path2.join(resolvedLegacyDir, "workspace"));
382
- const looksLikeStateDir = resolvedLegacyDir.endsWith(`${path2.sep}.openclaw`);
383
- if (hasStateMarkers || looksLikeStateDir) {
384
- return resolvedLegacyDir;
385
- }
386
- }
387
- return path2.join(os.homedir(), ".openclaw");
388
- }
389
-
390
- // src/filesystem.ts
391
- var HOME_DIR = process.env.HOME ?? "/root";
392
- var OPENCLAW_DIR = getOpenclawStateDir();
393
- var SENSITIVE_BLOCKED_DIRS = [
394
- path3.join(OPENCLAW_DIR, "credentials"),
395
- path3.join(OPENCLAW_DIR, "devices"),
396
- path3.join(OPENCLAW_DIR, "identity")
397
- ];
398
- var SENSITIVE_BLOCKED_FILES = [
399
- path3.join(OPENCLAW_DIR, "squad-ceo-data", "relay", "squad-relay.json")
400
- ];
401
- function isSensitivePath(resolvedPath) {
402
- for (const blocked of SENSITIVE_BLOCKED_DIRS) {
403
- if (resolvedPath === blocked || resolvedPath.startsWith(blocked + path3.sep)) {
404
- return true;
405
- }
406
- }
407
- for (const blocked of SENSITIVE_BLOCKED_FILES) {
408
- if (resolvedPath === blocked) {
409
- return true;
410
- }
411
- }
412
- if (path3.dirname(resolvedPath) === OPENCLAW_DIR && resolvedPath.endsWith(".bak")) {
413
- return true;
414
- }
415
- return false;
416
- }
417
- var OPENCLAW_JSON_FILENAME = "openclaw.json";
418
- function redactOpenclawJson(rawContent) {
419
- let config;
420
- try {
421
- config = JSON.parse(rawContent);
422
- } catch {
423
- return rawContent;
424
- }
425
- let redactedCount = 0;
426
- const channels = config.channels;
427
- if (channels && typeof channels === "object") {
428
- for (const channelKey of Object.keys(channels)) {
429
- const channel = channels[channelKey];
430
- if (channel && typeof channel === "object" && "botToken" in channel) {
431
- channel.botToken = "[REDACTED]";
432
- redactedCount++;
433
- }
434
- }
435
- }
436
- const gateway = config.gateway;
437
- if (gateway && typeof gateway === "object") {
438
- if (gateway.auth && typeof gateway.auth === "object") {
439
- const auth = gateway.auth;
440
- for (const key of Object.keys(auth)) {
441
- auth[key] = "[REDACTED]";
442
- redactedCount++;
443
- }
444
- }
445
- if ("token" in gateway) {
446
- gateway.token = "[REDACTED]";
447
- redactedCount++;
448
- }
449
- const remote = gateway.remote;
450
- if (remote && typeof remote === "object" && "token" in remote) {
451
- remote.token = "[REDACTED]";
452
- redactedCount++;
453
- }
454
- }
455
- if (redactedCount > 0) {
456
- console.log(`[security] Redacted ${redactedCount} sensitive field(s) from openclaw.json before returning to client`);
457
- }
458
- return JSON.stringify(config, null, 2);
459
- }
460
- function isOpenclawJson(resolvedPath) {
461
- return path3.basename(resolvedPath) === OPENCLAW_JSON_FILENAME && resolvedPath.startsWith(OPENCLAW_DIR);
462
- }
463
- function expandHome(p) {
464
- if (p.startsWith("~/") || p === "~") {
465
- return path3.join(HOME_DIR, p.slice(1));
466
- }
467
- return p;
468
- }
469
- function validatePath(p, allowedRoots) {
470
- const resolved = path3.resolve(expandHome(p));
471
- if (!allowedRoots || allowedRoots.length === 0) return resolved;
472
- const allowed = allowedRoots.some((root) => {
473
- const resolvedRoot = path3.resolve(expandHome(root));
474
- return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path3.sep);
475
- });
476
- if (!allowed) {
477
- throw new Error(`Path "${p}" is outside allowed roots`);
478
- }
479
- return resolved;
480
- }
481
- function validateAndBlockSensitive(p, allowedRoots) {
482
- const resolved = validatePath(p, allowedRoots);
483
- if (isSensitivePath(resolved)) {
484
- throw new Error(
485
- `Access denied: path "${p}" is inside a protected directory (credentials/devices/identity)`
486
- );
487
- }
488
- return resolved;
489
- }
490
- function validateWritePath(p, allowedRoots) {
491
- const resolved = validateAndBlockSensitive(p, allowedRoots);
492
- if (isOpenclawJson(resolved)) {
493
- throw new Error(
494
- `Write denied: "${p}" is a protected configuration file (openclaw.json)`
495
- );
496
- }
497
- return resolved;
498
- }
499
- function ok(data) {
500
- return {
501
- content: [{ type: "text", text: JSON.stringify(data) }]
502
- };
503
- }
504
- function err(message) {
505
- return {
506
- content: [{ type: "text", text: JSON.stringify({ error: message }) }],
507
- isError: true
508
- };
509
- }
510
- function listDir(dirPath, opts) {
511
- const dirents = fs3.readdirSync(dirPath, { withFileTypes: true });
512
- const results = [];
513
- for (const dirent of dirents) {
514
- if (!opts.includeHidden && dirent.name.startsWith(".")) continue;
515
- const entryPath = path3.join(dirPath, dirent.name);
516
- let type = "other";
517
- if (dirent.isFile()) type = "file";
518
- else if (dirent.isDirectory()) type = "directory";
519
- else if (dirent.isSymbolicLink()) type = "symlink";
520
- const entry = { name: dirent.name, path: entryPath, type };
521
- try {
522
- const stat = fs3.statSync(entryPath);
523
- entry.size = stat.size;
524
- entry.modified = stat.mtime.toISOString();
525
- } catch {
526
- }
527
- if (type === "directory" && opts.recursive && opts.depth < opts.maxDepth) {
528
- try {
529
- entry.children = listDir(entryPath, { ...opts, depth: opts.depth + 1 });
530
- } catch {
531
- }
532
- }
533
- results.push(entry);
534
- }
535
- return results;
536
- }
537
- function filterSensitiveEntries(entries) {
538
- return entries.filter((entry) => !isSensitivePath(entry.path)).map((entry) => {
539
- if (entry.children) {
540
- return { ...entry, children: filterSensitiveEntries(entry.children) };
541
- }
542
- return entry;
543
- });
544
- }
545
- function registerFilesystemTools(api) {
546
- const DEFAULT_ALLOWED_ROOTS = [OPENCLAW_DIR];
547
- const allowedRoots = api.pluginConfig?.["fs.allowedRoots"] ?? DEFAULT_ALLOWED_ROOTS;
548
- api.registerTool({
549
- name: "fs_read",
550
- label: "Read File",
551
- description: "Read a file from the server filesystem. Returns the file contents as text. Supports ~ for home directory expansion. Sensitive directories (credentials, devices, identity) are blocked. Config files are returned with auth tokens redacted.",
552
- parameters: {
553
- type: "object",
554
- properties: {
555
- path: {
556
- type: "string",
557
- description: "Absolute or ~-prefixed path to the file to read"
558
- },
559
- encoding: {
560
- type: "string",
561
- description: "File encoding (default: utf-8)",
562
- enum: ["utf-8", "base64", "ascii", "latin1"]
563
- }
564
- },
565
- required: ["path"]
566
- },
567
- async execute(_id, params) {
568
- try {
569
- const filePath = validateAndBlockSensitive(params.path, allowedRoots);
570
- const encoding = params.encoding ?? "utf-8";
571
- let content = fs3.readFileSync(filePath, encoding);
572
- const stat = fs3.statSync(filePath);
573
- if (isOpenclawJson(filePath) && encoding === "utf-8") {
574
- content = redactOpenclawJson(content);
575
- }
576
- return ok({
577
- path: filePath,
578
- content,
579
- size: stat.size,
580
- modified: stat.mtime.toISOString()
581
- });
582
- } catch (e) {
583
- const msg = e instanceof Error ? e.message : String(e);
584
- return err(`fs_read failed: ${msg}`);
585
- }
586
- }
587
- });
588
- api.registerTool({
589
- name: "fs_write",
590
- label: "Write File",
591
- description: "Write content to a file on the server filesystem. Creates parent directories if they don't exist. Supports ~ for home directory expansion. Writes to protected directories (credentials, devices, identity) and config files (openclaw.json) are denied.",
592
- parameters: {
593
- type: "object",
594
- properties: {
595
- path: {
596
- type: "string",
597
- description: "Absolute or ~-prefixed path to the file to write"
598
- },
599
- content: {
600
- type: "string",
601
- description: "Content to write to the file"
602
- },
603
- encoding: {
604
- type: "string",
605
- description: "File encoding (default: utf-8)",
606
- enum: ["utf-8", "base64", "ascii", "latin1"]
607
- },
608
- mkdir: {
609
- type: "boolean",
610
- description: "Create parent directories if they don't exist (default: true)"
611
- }
612
- },
613
- required: ["path", "content"]
614
- },
615
- async execute(_id, params) {
616
- try {
617
- const filePath = validateWritePath(params.path, allowedRoots);
618
- const content = params.content;
619
- const encoding = params.encoding ?? "utf-8";
620
- const mkdir = params.mkdir !== false;
621
- if (mkdir) {
622
- fs3.mkdirSync(path3.dirname(filePath), { recursive: true });
623
- }
624
- fs3.writeFileSync(filePath, content, encoding);
625
- const stat = fs3.statSync(filePath);
626
- return ok({
627
- path: filePath,
628
- size: stat.size,
629
- written: true
630
- });
631
- } catch (e) {
632
- const msg = e instanceof Error ? e.message : String(e);
633
- return err(`fs_write failed: ${msg}`);
634
- }
635
- }
636
- });
637
- api.registerTool({
638
- name: "fs_list",
639
- label: "List Directory",
640
- description: "List contents of a directory on the server filesystem. Returns file metadata including name, type, size, and modification time. Supports ~ for home directory expansion. Protected directories (credentials, devices, identity) are excluded from results.",
641
- parameters: {
642
- type: "object",
643
- properties: {
644
- path: {
645
- type: "string",
646
- description: "Absolute or ~-prefixed path to the directory to list"
647
- },
648
- recursive: {
649
- type: "boolean",
650
- description: "List recursively (default: false, max depth 3)"
651
- },
652
- includeHidden: {
653
- type: "boolean",
654
- description: "Include hidden files/directories starting with . (default: false)"
655
- }
656
- },
657
- required: ["path"]
658
- },
659
- async execute(_id, params) {
660
- try {
661
- const dirPath = validateAndBlockSensitive(params.path, allowedRoots);
662
- const recursive = params.recursive === true;
663
- const includeHidden = params.includeHidden === true;
664
- let entries = listDir(dirPath, { recursive, includeHidden, depth: 0, maxDepth: 3 });
665
- entries = filterSensitiveEntries(entries);
666
- return ok({
667
- path: dirPath,
668
- count: entries.length,
669
- entries
670
- });
671
- } catch (e) {
672
- const msg = e instanceof Error ? e.message : String(e);
673
- return err(`fs_list failed: ${msg}`);
674
- }
675
- }
676
- });
677
- api.registerTool({
678
- name: "fs_mkdir",
679
- label: "Create Directory",
680
- description: "Create a directory on the server filesystem. Creates parent directories as needed. Supports ~ for home directory expansion. Cannot create directories inside protected paths (credentials, devices, identity).",
681
- parameters: {
682
- type: "object",
683
- properties: {
684
- path: {
685
- type: "string",
686
- description: "Absolute or ~-prefixed path of the directory to create"
687
- }
688
- },
689
- required: ["path"]
690
- },
691
- async execute(_id, params) {
692
- try {
693
- const targetPath = validateWritePath(params.path, allowedRoots);
694
- fs3.mkdirSync(targetPath, { recursive: true });
695
- return ok({
696
- path: targetPath,
697
- created: true
698
- });
699
- } catch (e) {
700
- const msg = e instanceof Error ? e.message : String(e);
701
- return err(`fs_mkdir failed: ${msg}`);
702
- }
7
+ // src/e2e-crypto.ts
8
+ import crypto from "crypto";
9
+ var CURVE = "prime256v1";
10
+ var HKDF_SALT = "squad-e2e-v1";
11
+ var HKDF_INFO = "aes-gcm-key";
12
+ var AES_KEY_LENGTH = 32;
13
+ var IV_LENGTH = 12;
14
+ var E2ECrypto = class {
15
+ ecdh = null;
16
+ aesKey = null;
17
+ publicKeyB64 = null;
18
+ /** Generate an ephemeral ECDH keypair. Returns the public key as base64. */
19
+ async generateKeyPair() {
20
+ this.ecdh = crypto.createECDH(CURVE);
21
+ const publicKey = this.ecdh.generateKeys();
22
+ this.publicKeyB64 = publicKey.toString("base64");
23
+ return this.publicKeyB64;
24
+ }
25
+ /** Derive the shared secret from the peer's public key. */
26
+ async deriveSharedSecret(peerPublicKeyB64) {
27
+ if (!this.ecdh) {
28
+ throw new Error("Must call generateKeyPair() first");
703
29
  }
704
- });
705
- api.registerTool({
706
- name: "fs_rename",
707
- label: "Rename / Move",
708
- description: "Rename or move a file or directory on the server filesystem. Supports ~ for home directory expansion. Cannot move files into or out of protected directories.",
709
- parameters: {
710
- type: "object",
711
- properties: {
712
- oldPath: {
713
- type: "string",
714
- description: "Current absolute or ~-prefixed path"
715
- },
716
- newPath: {
717
- type: "string",
718
- description: "New absolute or ~-prefixed path"
719
- }
720
- },
721
- required: ["oldPath", "newPath"]
722
- },
723
- async execute(_id, params) {
724
- try {
725
- const resolvedOld = validateWritePath(params.oldPath, allowedRoots);
726
- const resolvedNew = validateWritePath(params.newPath, allowedRoots);
727
- fs3.renameSync(resolvedOld, resolvedNew);
728
- return ok({
729
- oldPath: resolvedOld,
730
- newPath: resolvedNew,
731
- renamed: true
732
- });
733
- } catch (e) {
734
- const msg = e instanceof Error ? e.message : String(e);
735
- return err(`fs_rename failed: ${msg}`);
736
- }
30
+ const peerPublicKey = Buffer.from(peerPublicKeyB64, "base64");
31
+ const sharedSecret = this.ecdh.computeSecret(peerPublicKey);
32
+ this.aesKey = crypto.hkdfSync(
33
+ "sha256",
34
+ sharedSecret,
35
+ Buffer.from(HKDF_SALT),
36
+ Buffer.from(HKDF_INFO),
37
+ AES_KEY_LENGTH
38
+ );
39
+ }
40
+ /** Encrypt a plaintext string. Returns base64-encoded ciphertext + iv + tag. */
41
+ encrypt(plaintext) {
42
+ if (!this.aesKey) {
43
+ throw new Error("E2E not established \u2014 call deriveSharedSecret() first");
737
44
  }
738
- });
739
- api.registerTool({
740
- name: "fs_delete",
741
- label: "Delete File or Directory",
742
- description: "Delete a file or directory from the server filesystem. For directories, removes recursively. Supports ~ for home directory expansion. Cannot delete protected directories or config files.",
743
- parameters: {
744
- type: "object",
745
- properties: {
746
- path: {
747
- type: "string",
748
- description: "Absolute or ~-prefixed path to the file or directory to delete"
749
- }
750
- },
751
- required: ["path"]
752
- },
753
- async execute(_id, params) {
754
- try {
755
- const targetPath = validateWritePath(params.path, allowedRoots);
756
- const stat = fs3.statSync(targetPath);
757
- const wasDirectory = stat.isDirectory();
758
- if (wasDirectory) {
759
- fs3.rmSync(targetPath, { recursive: true });
760
- } else {
761
- fs3.unlinkSync(targetPath);
762
- }
763
- return ok({
764
- path: targetPath,
765
- deleted: true,
766
- type: wasDirectory ? "directory" : "file"
767
- });
768
- } catch (e) {
769
- const msg = e instanceof Error ? e.message : String(e);
770
- return err(`fs_delete failed: ${msg}`);
771
- }
45
+ const iv = crypto.randomBytes(IV_LENGTH);
46
+ const cipher = crypto.createCipheriv("aes-256-gcm", this.aesKey, iv);
47
+ const encrypted = Buffer.concat([
48
+ cipher.update(plaintext, "utf-8"),
49
+ cipher.final()
50
+ ]);
51
+ const tag = cipher.getAuthTag();
52
+ return {
53
+ ciphertext: encrypted.toString("base64"),
54
+ iv: iv.toString("base64"),
55
+ tag: tag.toString("base64")
56
+ };
57
+ }
58
+ /** Decrypt a payload. Returns the plaintext string. */
59
+ decrypt(payload) {
60
+ if (!this.aesKey) {
61
+ throw new Error("E2E not established \u2014 call deriveSharedSecret() first");
772
62
  }
773
- });
774
- }
63
+ const ciphertext = Buffer.from(payload.ciphertext, "base64");
64
+ const iv = Buffer.from(payload.iv, "base64");
65
+ const tag = Buffer.from(payload.tag, "base64");
66
+ const decipher = crypto.createDecipheriv("aes-256-gcm", this.aesKey, iv);
67
+ decipher.setAuthTag(tag);
68
+ const decrypted = Buffer.concat([
69
+ decipher.update(ciphertext),
70
+ decipher.final()
71
+ ]);
72
+ return decrypted.toString("utf-8");
73
+ }
74
+ /** Whether E2E encryption has been established */
75
+ get isEstablished() {
76
+ return this.aesKey !== null;
77
+ }
78
+ /** Get the local public key (base64) */
79
+ get publicKey() {
80
+ return this.publicKeyB64;
81
+ }
82
+ };
775
83
 
776
- // src/entities.ts
777
- var EntityType = T.Union([
778
- T.Literal("agent"),
779
- T.Literal("skill"),
780
- T.Literal("tool"),
781
- T.Literal("plugin"),
782
- T.Literal("session"),
783
- T.Literal("file"),
784
- T.Literal("directory"),
785
- T.Literal("url"),
786
- T.Literal("memory"),
787
- T.Literal("asset")
788
- ]);
789
- var registry = /* @__PURE__ */ new Map();
790
- function registrySet(entity) {
791
- registry.set(entity.id, entity);
792
- }
793
- function registryDelete(id) {
794
- registry.delete(id);
795
- }
796
- function registryList(type) {
797
- const all = Array.from(registry.values());
798
- if (!type) return all;
799
- return all.filter((e) => e.type === type);
800
- }
801
- var IDENTITY_NAME_RE = /\*\*Name:\*\*\s*\n?\s*(.+?)(?=\n|$)/;
802
- function parseIdentityName(content) {
803
- const match = content.match(IDENTITY_NAME_RE);
804
- const name = match?.[1]?.trim();
805
- if (!name) return null;
806
- if (/^_\(.+\)_$/.test(name)) return null;
807
- return name;
808
- }
809
- function scanAgents(configDir) {
810
- const now = Date.now();
811
- let entries;
812
- try {
813
- entries = fs4.readdirSync(configDir, { withFileTypes: true });
814
- } catch {
815
- return;
84
+ // src/paths.ts
85
+ import path from "path";
86
+ import os from "os";
87
+ import fs from "fs";
88
+ function getOpenclawStateDir() {
89
+ if (process.env.OPENCLAW_STATE_DIR) {
90
+ return process.env.OPENCLAW_STATE_DIR;
816
91
  }
817
- const workspaceDirs = entries.filter(
818
- (e) => e.isDirectory() && (e.name === "workspace" || e.name.startsWith("workspace-"))
819
- );
820
- for (const dir of workspaceDirs) {
821
- const agentId = dir.name === "workspace" ? "main" : dir.name.replace("workspace-", "");
822
- const workspacePath = path4.join(configDir, dir.name);
823
- let name = agentId;
824
- const metadata = { workspacePath };
825
- const identityPath = path4.join(workspacePath, "IDENTITY.md");
826
- try {
827
- const content = fs4.readFileSync(identityPath, "utf-8");
828
- const parsed = parseIdentityName(content);
829
- if (parsed) name = parsed;
830
- } catch {
831
- }
832
- if (name === agentId) {
833
- const agentJsonPath = path4.join(workspacePath, "agent.json");
834
- try {
835
- const raw = fs4.readFileSync(agentJsonPath, "utf-8");
836
- const config = JSON.parse(raw);
837
- if (config.displayName) name = config.displayName;
838
- if (config.model) metadata.model = config.model;
839
- if (config.tools) metadata.tools = config.tools;
840
- if (config.skills) metadata.skills = config.skills;
841
- } catch {
842
- }
92
+ if (process.env.OPENCLAW_CONFIG_PATH) {
93
+ return path.dirname(process.env.OPENCLAW_CONFIG_PATH);
94
+ }
95
+ const legacyDir = process.env.OPENCLAW_DIR;
96
+ if (legacyDir) {
97
+ const resolvedLegacyDir = path.resolve(legacyDir);
98
+ const configPath = path.join(resolvedLegacyDir, "openclaw.json");
99
+ const hasStateMarkers = fs.existsSync(configPath) || fs.existsSync(path.join(resolvedLegacyDir, "agents")) || fs.existsSync(path.join(resolvedLegacyDir, "workspace"));
100
+ const looksLikeStateDir = resolvedLegacyDir.endsWith(`${path.sep}.openclaw`);
101
+ if (hasStateMarkers || looksLikeStateDir) {
102
+ return resolvedLegacyDir;
843
103
  }
844
- registrySet({
845
- id: agentId,
846
- type: "agent",
847
- name,
848
- title: name,
849
- description: null,
850
- metadata,
851
- source: "filesystem",
852
- source_key: workspacePath,
853
- created_at: now,
854
- updated_at: now
855
- });
856
104
  }
105
+ return path.join(os.homedir(), ".openclaw");
857
106
  }
858
- function scanSkills(configDir) {
859
- const now = Date.now();
860
- const globalSkillsDir = path4.join(configDir, "skills");
861
- scanSkillsDir(globalSkillsDir, "global", now);
862
- let entries;
107
+
108
+ // src/device-keys.ts
109
+ import crypto2 from "crypto";
110
+ import fs2 from "fs";
111
+ import path2 from "path";
112
+ var RELAY_DATA_DIR = path2.join(getOpenclawStateDir(), "squad-ceo-data", "relay");
113
+ var RELAY_STATE_PATH = path2.join(RELAY_DATA_DIR, "squad-relay.json");
114
+ var PENDING_APPROVAL_PATH = path2.join(RELAY_DATA_DIR, "pending-approval.json");
115
+ function readRelayState() {
863
116
  try {
864
- entries = fs4.readdirSync(configDir, { withFileTypes: true });
117
+ const raw = fs2.readFileSync(RELAY_STATE_PATH, "utf-8");
118
+ return JSON.parse(raw);
865
119
  } catch {
866
- return;
120
+ return {};
867
121
  }
868
- for (const dir of entries) {
869
- if (!dir.isDirectory() || !(dir.name === "workspace" || dir.name.startsWith("workspace-"))) {
870
- continue;
871
- }
872
- const agentId = dir.name === "workspace" ? "main" : dir.name.replace("workspace-", "");
873
- const agentSkillsDir = path4.join(configDir, dir.name, "skills");
874
- scanSkillsDir(agentSkillsDir, agentId, now);
122
+ }
123
+ function writeRelayState(state) {
124
+ if (!fs2.existsSync(RELAY_DATA_DIR)) {
125
+ fs2.mkdirSync(RELAY_DATA_DIR, { recursive: true });
126
+ }
127
+ fs2.writeFileSync(RELAY_STATE_PATH, JSON.stringify(state, null, 2), { mode: 384 });
128
+ }
129
+ function toBase64Url(buf) {
130
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
131
+ }
132
+ function loadOrCreateRelayDeviceKeys() {
133
+ const state = readRelayState();
134
+ if (state.deviceKeys) {
135
+ return state.deviceKeys;
875
136
  }
137
+ const { publicKey, privateKey } = crypto2.generateKeyPairSync("ed25519");
138
+ const pubDer = publicKey.export({ type: "spki", format: "der" });
139
+ const rawPub = pubDer.subarray(pubDer.length - 32);
140
+ const deviceId = crypto2.createHash("sha256").update(rawPub).digest("hex");
141
+ const publicKeyB64 = toBase64Url(rawPub);
142
+ const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
143
+ const keys = { deviceId, publicKey: publicKeyB64, privateKeyPem };
144
+ writeRelayState({ ...state, deviceKeys: keys });
145
+ console.log(`[device-keys] Generated relay device identity: ${deviceId.substring(0, 12)}...`);
146
+ return keys;
876
147
  }
877
- function scanSkillsDir(skillsDir, scope, now) {
878
- let entries;
148
+ function writeDeviceInfoFile(keys) {
149
+ const stateDir = getOpenclawStateDir();
150
+ const infoPath = path2.join(stateDir, "squad-ceo-data", "relay", "relay-device-info.json");
151
+ const info = {
152
+ deviceId: keys.deviceId,
153
+ publicKey: keys.publicKey,
154
+ displayName: "squad-relay",
155
+ platform: process.platform,
156
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
157
+ };
879
158
  try {
880
- entries = fs4.readdirSync(skillsDir, { withFileTypes: true });
881
- } catch {
882
- return;
883
- }
884
- for (const entry of entries) {
885
- if (!entry.isDirectory()) continue;
886
- const skillKey = entry.name;
887
- const skillPath = path4.join(skillsDir, skillKey);
888
- let name = skillKey;
889
- for (const manifestName of ["manifest.json", "package.json"]) {
890
- try {
891
- const raw = fs4.readFileSync(
892
- path4.join(skillPath, manifestName),
893
- "utf-8"
894
- );
895
- const manifest = JSON.parse(raw);
896
- if (manifest.name) name = manifest.name;
897
- break;
898
- } catch {
899
- continue;
900
- }
901
- }
902
- const entityId = scope === "global" ? `skill:${skillKey}` : `skill:${scope}:${skillKey}`;
903
- registrySet({
904
- id: entityId,
905
- type: "skill",
906
- name,
907
- title: name,
908
- description: null,
909
- metadata: { skillKey, scope, skillPath },
910
- source: "filesystem",
911
- source_key: skillPath,
912
- created_at: now,
913
- updated_at: now
914
- });
159
+ fs2.writeFileSync(infoPath, JSON.stringify(info, null, 2));
160
+ } catch (err2) {
161
+ console.error("[device-keys] Failed to write relay-device-info.json:", err2);
915
162
  }
916
163
  }
917
- function scanPlugins2(configDir) {
918
- const now = Date.now();
919
- const extensionsDir = path4.join(configDir, "extensions");
920
- let entries;
164
+
165
+ // src/relay-client.ts
166
+ function readOperatorToken() {
167
+ const stateDir = getOpenclawStateDir();
168
+ const configPath = path3.join(stateDir, "openclaw.json");
921
169
  try {
922
- entries = fs4.readdirSync(extensionsDir, { withFileTypes: true });
170
+ const raw = fs3.readFileSync(configPath, "utf-8");
171
+ const config = JSON.parse(raw);
172
+ return config?.gateway?.auth?.token ?? config?.gateway?.remote?.token ?? config?.gateway?.token ?? null;
923
173
  } catch {
924
- return;
925
- }
926
- for (const dir of entries) {
927
- if (!dir.isDirectory()) continue;
928
- const pluginDir = path4.join(extensionsDir, dir.name);
929
- const manifestPath = path4.join(pluginDir, "openclaw.plugin.json");
930
- try {
931
- const raw = fs4.readFileSync(manifestPath, "utf-8");
932
- const manifest = JSON.parse(raw);
933
- const pluginId = manifest.id || dir.name;
934
- const name = manifest.name || pluginId;
935
- registrySet({
936
- id: `plugin:${pluginId}`,
937
- type: "plugin",
938
- name,
939
- title: name,
940
- description: manifest.description || null,
941
- metadata: { pluginId, pluginDir },
942
- source: "filesystem",
943
- source_key: manifestPath,
944
- created_at: now,
945
- updated_at: now
946
- });
947
- } catch {
948
- }
174
+ return null;
949
175
  }
950
176
  }
951
- function scanTools(configDir) {
952
- const now = Date.now();
177
+ function readGatewayLocalWsConfig() {
178
+ const defaults = {
179
+ port: 18789,
180
+ // Try IPv4, hostname, then IPv6 loopback.
181
+ hosts: ["127.0.0.1", "localhost", "[::1]"]
182
+ };
183
+ const stateDir = getOpenclawStateDir();
184
+ const configPath = path3.join(stateDir, "openclaw.json");
953
185
  try {
954
- const raw = fs4.readFileSync(
955
- path4.join(configDir, "openclaw.json"),
956
- "utf-8"
957
- );
186
+ const raw = fs3.readFileSync(configPath, "utf-8");
958
187
  const config = JSON.parse(raw);
959
- const allowedTools = config?.tools?.allow ?? [];
960
- for (const toolName of allowedTools) {
961
- registrySet({
962
- id: `tool:${toolName}`,
963
- type: "tool",
964
- name: toolName,
965
- title: toolName,
966
- description: null,
967
- metadata: { tool_name: toolName },
968
- source: "filesystem",
969
- source_key: "openclaw.json:tools.allow",
970
- created_at: now,
971
- updated_at: now
972
- });
188
+ const parsedPort = Number(config?.gateway?.port);
189
+ if (Number.isFinite(parsedPort) && parsedPort > 0) {
190
+ defaults.port = parsedPort;
973
191
  }
974
192
  } catch {
975
193
  }
194
+ return defaults;
976
195
  }
977
- var MIME_MAP = {
978
- ".png": "image/png",
979
- ".jpg": "image/jpeg",
980
- ".jpeg": "image/jpeg",
981
- ".gif": "image/gif",
982
- ".webp": "image/webp",
983
- ".svg": "image/svg+xml",
984
- ".bmp": "image/bmp",
985
- ".ico": "image/x-icon",
986
- ".mp4": "video/mp4",
987
- ".webm": "video/webm",
988
- ".mov": "video/quicktime",
989
- ".avi": "video/x-msvideo",
990
- ".mkv": "video/x-matroska",
991
- ".mp3": "audio/mpeg",
992
- ".wav": "audio/wav",
993
- ".ogg": "audio/ogg",
994
- ".flac": "audio/flac",
995
- ".aac": "audio/aac",
996
- ".pdf": "application/pdf",
997
- ".json": "application/json",
998
- ".txt": "text/plain",
999
- ".md": "text/markdown",
1000
- ".csv": "text/csv",
1001
- ".zip": "application/zip",
1002
- ".tar": "application/x-tar",
1003
- ".gz": "application/gzip"
1004
- };
1005
- function getMimeType(filename) {
1006
- const ext = path4.extname(filename).toLowerCase();
1007
- return MIME_MAP[ext] ?? "application/octet-stream";
1008
- }
1009
- function scanMedia(configDir) {
1010
- const now = Date.now();
1011
- const mediaDir = path4.join(configDir, "media");
1012
- scanMediaDir(mediaDir, now);
196
+ function signDeviceIdentity(keys, clientId, clientMode, role, scopes, token, challengeNonce) {
197
+ const signedAtMs = Date.now();
198
+ const nonce = challengeNonce || crypto3.randomBytes(16).toString("hex");
199
+ const scopeStr = scopes.join(",");
200
+ const payload = `v2|${keys.deviceId}|${clientId}|${clientMode}|${role}|${scopeStr}|${signedAtMs}|${token ?? ""}|${nonce}`;
201
+ const privateKey = crypto3.createPrivateKey(keys.privateKeyPem);
202
+ const signature = crypto3.sign(null, Buffer.from(payload), privateKey);
203
+ return {
204
+ id: keys.deviceId,
205
+ publicKey: keys.publicKey,
206
+ signature: toBase64Url(signature),
207
+ signedAt: signedAtMs,
208
+ nonce
209
+ };
1013
210
  }
1014
- function scanMediaDir(dirPath, now) {
1015
- let entries;
1016
- try {
1017
- entries = fs4.readdirSync(dirPath, { withFileTypes: true });
1018
- } catch {
1019
- return;
211
+ var RelayClient = class {
212
+ config;
213
+ relayWs = null;
214
+ userConnections = /* @__PURE__ */ new Map();
215
+ localConnectAttempts = /* @__PURE__ */ new Map();
216
+ reconnectAttempts = 0;
217
+ maxReconnectAttempts = 100;
218
+ reconnectTimer = null;
219
+ shouldReconnect = true;
220
+ destroyed = false;
221
+ /** Pending claim token — sent on first successful connect, then cleared */
222
+ pendingClaimToken = null;
223
+ /** Device keys for authenticating local WS connections to the gateway */
224
+ deviceKeys;
225
+ constructor(config) {
226
+ const state = readRelayState();
227
+ const localWs = readGatewayLocalWsConfig();
228
+ this.config = {
229
+ relayUrl: config.relayUrl,
230
+ localGatewayPort: config.localGatewayPort ?? localWs.port,
231
+ localGatewayHosts: config.localGatewayHosts ?? localWs.hosts,
232
+ operatorToken: config.operatorToken ?? readOperatorToken(),
233
+ claimToken: config.claimToken ?? state.claimToken ?? null,
234
+ roomId: config.roomId ?? state.roomId ?? null
235
+ };
236
+ this.pendingClaimToken = this.config.roomId ? null : this.config.claimToken;
237
+ this.deviceKeys = loadOrCreateRelayDeviceKeys();
238
+ writeDeviceInfoFile(this.deviceKeys);
239
+ console.log(`[relay-client] Device ID: ${this.deviceKeys.deviceId}`);
1020
240
  }
1021
- for (const entry of entries) {
1022
- if (entry.name.startsWith(".")) continue;
1023
- const entryPath = path4.join(dirPath, entry.name);
1024
- if (isSensitivePath(entryPath)) continue;
1025
- if (entry.isDirectory()) {
1026
- registrySet({
1027
- id: entryPath,
1028
- type: "directory",
1029
- name: entry.name,
1030
- title: entry.name,
1031
- description: null,
1032
- metadata: { path: entryPath },
1033
- source: "filesystem",
1034
- source_key: entryPath,
1035
- created_at: now,
1036
- updated_at: now
241
+ /** Start connecting to the relay */
242
+ start() {
243
+ if (!this.config.roomId && !this.pendingClaimToken) {
244
+ console.log("[relay-client] No room ID or claim token found.");
245
+ console.log("[relay-client] Complete the setup from the Squad web app to generate a claim token.");
246
+ return;
247
+ }
248
+ console.log(`[relay-client] Starting relay connection to ${this.config.relayUrl}`);
249
+ if (this.config.roomId) {
250
+ console.log(`[relay-client] Room ID: ${this.config.roomId.substring(0, 8)}...`);
251
+ } else {
252
+ console.log(`[relay-client] Using claim token for first connect`);
253
+ }
254
+ this.connectToRelay();
255
+ }
256
+ /** Stop the relay client and close all connections */
257
+ destroy() {
258
+ this.destroyed = true;
259
+ this.shouldReconnect = false;
260
+ if (this.reconnectTimer) {
261
+ clearTimeout(this.reconnectTimer);
262
+ this.reconnectTimer = null;
263
+ }
264
+ for (const [userId, conn] of this.userConnections) {
265
+ try {
266
+ conn.localWs.close(1e3, "Relay client shutting down");
267
+ } catch {
268
+ }
269
+ this.userConnections.delete(userId);
270
+ }
271
+ if (this.relayWs) {
272
+ try {
273
+ this.relayWs.close(1e3, "Relay client shutting down");
274
+ } catch {
275
+ }
276
+ this.relayWs = null;
277
+ }
278
+ }
279
+ // ── Relay Connection ──
280
+ connectToRelay() {
281
+ if (this.destroyed) return;
282
+ let wsUrl;
283
+ if (this.pendingClaimToken) {
284
+ wsUrl = `${this.config.relayUrl}/gw?claim=${encodeURIComponent(this.pendingClaimToken)}`;
285
+ console.log(`[relay-client] Connecting with claim token`);
286
+ } else if (this.config.roomId) {
287
+ wsUrl = `${this.config.relayUrl}/gw?room=${encodeURIComponent(this.config.roomId)}`;
288
+ console.log(`[relay-client] Reconnecting with room ID`);
289
+ } else {
290
+ console.error("[relay-client] No claim token or room ID \u2014 cannot connect");
291
+ return;
292
+ }
293
+ try {
294
+ this.relayWs = new NodeWebSocket(wsUrl);
295
+ } catch (err2) {
296
+ console.error("[relay-client] Failed to create WebSocket:", err2);
297
+ this.scheduleReconnect();
298
+ return;
299
+ }
300
+ this.relayWs.on("open", () => {
301
+ console.log("[relay-client] Connected to relay");
302
+ this.reconnectAttempts = 0;
303
+ this.sendToRelay({
304
+ type: "relay.hello",
305
+ deviceId: this.deviceKeys.deviceId,
306
+ publicKey: this.deviceKeys.publicKey
1037
307
  });
1038
- scanMediaDir(entryPath, now);
1039
- } else if (entry.isFile()) {
1040
- const mimeType = getMimeType(entry.name);
1041
- let size;
1042
- let mtime = now;
308
+ });
309
+ this.relayWs.on("message", (data) => {
1043
310
  try {
1044
- const stat = fs4.statSync(entryPath);
1045
- size = stat.size;
1046
- mtime = stat.mtimeMs;
311
+ const msg = JSON.parse(data.toString());
312
+ this.handleRelayMessage(msg);
1047
313
  } catch {
1048
314
  }
1049
- registrySet({
1050
- id: entryPath,
1051
- type: "asset",
1052
- name: entry.name,
1053
- title: entry.name,
1054
- description: null,
1055
- metadata: { path: entryPath, size, mime_type: mimeType, original_name: entry.name },
1056
- source: "filesystem",
1057
- source_key: entryPath,
1058
- created_at: mtime,
1059
- updated_at: mtime
1060
- });
315
+ });
316
+ this.relayWs.on("close", (code, reason) => {
317
+ const reasonStr = reason.toString();
318
+ console.log(`[relay-client] Relay connection closed: ${code} ${reasonStr}`);
319
+ this.relayWs = null;
320
+ if (code === 1e3 && reasonStr.includes("Replaced")) {
321
+ console.log("[relay-client] Replaced by newer instance, stopping reconnect");
322
+ this.shouldReconnect = false;
323
+ this.destroyed = true;
324
+ }
325
+ for (const [userId, conn] of this.userConnections) {
326
+ try {
327
+ conn.localWs.close(1001, "Relay disconnected");
328
+ } catch {
329
+ }
330
+ this.userConnections.delete(userId);
331
+ }
332
+ if (this.shouldReconnect) {
333
+ this.scheduleReconnect();
334
+ }
335
+ });
336
+ this.relayWs.on("error", (err2) => {
337
+ console.error("[relay-client] Relay WebSocket error:", err2.message);
338
+ });
339
+ this.relayWs.on("unexpected-response", (_req, res) => {
340
+ console.warn(`[relay-client] Unexpected response: ${res.statusCode}`);
341
+ if (res.statusCode === 401 && this.pendingClaimToken) {
342
+ console.log("[relay-client] Claim token rejected \u2014 checking for stored room ID");
343
+ this.pendingClaimToken = null;
344
+ const state = readRelayState();
345
+ if (state.roomId) {
346
+ this.config.roomId = state.roomId;
347
+ console.log(`[relay-client] Found stored room ID, will use on next reconnect`);
348
+ }
349
+ }
350
+ this.relayWs = null;
351
+ this.scheduleReconnect();
352
+ });
353
+ }
354
+ scheduleReconnect() {
355
+ if (this.destroyed || !this.shouldReconnect) return;
356
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
357
+ console.error("[relay-client] Max reconnect attempts reached");
358
+ return;
1061
359
  }
360
+ const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), 6e4);
361
+ this.reconnectAttempts++;
362
+ console.log(`[relay-client] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
363
+ this.reconnectTimer = setTimeout(() => {
364
+ this.reconnectTimer = null;
365
+ this.connectToRelay();
366
+ }, delay);
1062
367
  }
1063
- }
1064
- function fullScan(configDir) {
1065
- registry.clear();
1066
- scanAgents(configDir);
1067
- scanSkills(configDir);
1068
- scanPlugins2(configDir);
1069
- scanTools(configDir);
1070
- scanMedia(configDir);
1071
- }
1072
- function registerEntityTools(api, onFsChange) {
1073
- const configDir = getOpenclawStateDir();
1074
- api.registerTool({
1075
- name: "entity_list",
1076
- description: "List all entities in the registry, optionally filtered by type. Returns lightweight entity data for @mention autocomplete.",
1077
- parameters: T.Object({
1078
- type: T.Optional(EntityType),
1079
- limit: T.Optional(
1080
- T.Number({ description: "Max results (default 500)" })
1081
- )
1082
- }),
1083
- async execute(_id, params, _ctx) {
1084
- const results = registryList(params.type);
1085
- const limit = params.limit ?? 500;
1086
- return {
1087
- content: [
1088
- { type: "text", text: JSON.stringify(results.slice(0, limit)) }
1089
- ]
1090
- };
368
+ // ── Message Handling ──
369
+ handleRelayMessage(msg) {
370
+ switch (msg.type) {
371
+ case "relay.welcome":
372
+ this.handleWelcome(msg);
373
+ break;
374
+ case "relay.forward":
375
+ if (msg.userId && msg.inner) {
376
+ this.routeToUser(msg.userId, msg.inner);
377
+ }
378
+ break;
379
+ case "relay.pair.request":
380
+ if (msg.userId && msg.email) {
381
+ this.handlePairingRequest(msg.userId, msg.email);
382
+ }
383
+ break;
384
+ case "relay.e2e.exchange":
385
+ if (msg.userId && msg.publicKey) {
386
+ this.handleE2EExchange(msg.userId, msg.publicKey);
387
+ }
388
+ break;
389
+ case "relay.ping":
390
+ this.sendToRelay({ type: "relay.pong" });
391
+ break;
392
+ default:
393
+ console.log(`[relay-client] Unknown relay message type: ${msg.type}`);
1091
394
  }
1092
- });
1093
- api.registerTool({
1094
- name: "entity_search",
1095
- description: "Search entities by name/title substring match for @mention autocomplete.",
1096
- parameters: T.Object({
1097
- query: T.String({ description: "Search query text" }),
1098
- type: T.Optional(
1099
- T.String({ description: "Filter results by entity type" })
1100
- ),
1101
- limit: T.Optional(
1102
- T.Number({ description: "Max results (default 20)" })
1103
- )
1104
- }),
1105
- async execute(_id, params, _ctx) {
1106
- const q = (params.query ?? "").toLowerCase();
1107
- const limit = params.limit ?? 20;
1108
- let results = Array.from(registry.values());
1109
- if (params.type) {
1110
- results = results.filter((e) => e.type === params.type);
395
+ }
396
+ /** Handle relay.welcome — store room ID for reconnection */
397
+ handleWelcome(msg) {
398
+ if (msg.roomId) {
399
+ console.log(`[relay-client] Received room ID: ${msg.roomId.substring(0, 8)}...`);
400
+ this.config.roomId = msg.roomId;
401
+ this.pendingClaimToken = null;
402
+ const state = readRelayState();
403
+ state.roomId = msg.roomId;
404
+ writeRelayState(state);
405
+ }
406
+ }
407
+ /** Route a message from the relay to the appropriate user's local WS */
408
+ routeToUser(userId, innerMsg) {
409
+ let msg = innerMsg;
410
+ if (msg.type === "event" && typeof msg.event === "string" && msg.event.startsWith("relay.")) {
411
+ if (msg.event === "relay.user.connected") {
412
+ console.log(`[relay-client] User ${userId} connected via relay \u2014 creating local WS`);
413
+ this.createUserConnection(userId);
1111
414
  }
1112
- if (q) {
1113
- results = results.filter(
1114
- (e) => e.name.toLowerCase().includes(q) || (e.title ?? "").toLowerCase().includes(q)
1115
- );
415
+ return;
416
+ }
417
+ if (typeof msg.type === "string" && msg.type.startsWith("relay.")) {
418
+ if (msg.type === "relay.e2e.exchange" && msg.publicKey) {
419
+ this.handleE2EExchange(userId, msg.publicKey);
1116
420
  }
1117
- return {
1118
- content: [
1119
- { type: "text", text: JSON.stringify(results.slice(0, limit)) }
1120
- ]
1121
- };
421
+ return;
1122
422
  }
1123
- });
1124
- api.registerTool({
1125
- name: "entity_sync",
1126
- description: "Re-scan the filesystem to refresh the entity registry. Call after configuration changes for immediate updates.",
1127
- parameters: T.Object({}),
1128
- async execute(_id, _params, _ctx) {
1129
- const before = registry.size;
1130
- fullScan(configDir);
1131
- return {
1132
- content: [
1133
- {
1134
- type: "text",
1135
- text: JSON.stringify({ synced: registry.size, previous: before })
1136
- }
1137
- ]
1138
- };
423
+ let conn = this.userConnections.get(userId);
424
+ if (!conn || conn.localWs.readyState >= NodeWebSocket.CLOSING) {
425
+ this.createUserConnection(userId);
426
+ conn = this.userConnections.get(userId);
427
+ if (!conn) return;
428
+ }
429
+ if (msg._e2e && conn.e2e) {
430
+ try {
431
+ const plaintext = conn.e2e.decrypt({
432
+ ciphertext: msg.ciphertext,
433
+ iv: msg.iv,
434
+ tag: msg.tag
435
+ });
436
+ msg = JSON.parse(plaintext);
437
+ } catch (err2) {
438
+ console.error(`[relay-client] E2E decrypt error for ${userId}:`, err2);
439
+ return;
440
+ }
441
+ }
442
+ if (msg.type === "req" && msg.method === "connect") {
443
+ if (conn.connectHandshakeComplete) {
444
+ console.log(`[relay-client] New connect from ${userId} \u2014 creating fresh local WS for handshake`);
445
+ this.createUserConnection(userId);
446
+ conn = this.userConnections.get(userId);
447
+ if (!conn) return;
448
+ }
449
+ if (!conn.challengeNonce) {
450
+ console.log(`[relay-client] Connect request for ${userId} deferred \u2014 waiting for challenge nonce`);
451
+ conn.pendingConnect = msg;
452
+ return;
453
+ }
454
+ this.injectDeviceIdentity(conn, msg);
455
+ if (conn.localWs.readyState === NodeWebSocket.CONNECTING) {
456
+ conn.localWs.once("open", () => {
457
+ conn.localWs.send(JSON.stringify(msg));
458
+ });
459
+ } else {
460
+ conn.localWs.send(JSON.stringify(msg));
461
+ }
462
+ return;
463
+ }
464
+ if (!conn.connectHandshakeComplete) {
465
+ conn.pendingMessages.push(msg);
466
+ return;
467
+ }
468
+ if (conn.localWs.readyState === NodeWebSocket.CONNECTING) {
469
+ conn.localWs.once("open", () => {
470
+ conn.localWs.send(JSON.stringify(msg));
471
+ });
472
+ return;
1139
473
  }
1140
- });
1141
- try {
1142
- fullScan(configDir);
1143
- } catch (err2) {
1144
- console.error("[squad-openclaw] Initial scan failed:", err2);
1145
- }
1146
- let stopWatcher = null;
1147
- try {
1148
- stopWatcher = startWatcher(configDir, onFsChange);
1149
- } catch (err2) {
1150
- console.error("[squad-openclaw] Watcher failed to start:", err2);
1151
- }
1152
- const cleanup = () => {
1153
- stopWatcher?.();
1154
- };
1155
- process.on("SIGTERM", cleanup);
1156
- process.on("SIGINT", cleanup);
1157
- }
1158
-
1159
- // src/sql.ts
1160
- import { execFile } from "child_process";
1161
- import path5 from "path";
1162
- import fs5 from "fs";
1163
- import { Type as T2 } from "@sinclair/typebox";
1164
- var HOME_DIR2 = process.env.HOME ?? "/root";
1165
- var ALLOWED_DATA_DIR = path5.join(getOpenclawStateDir(), "squad-ceo-data");
1166
- function validateDbPath(dbPath) {
1167
- let expanded = dbPath;
1168
- if (expanded.startsWith("~/") || expanded === "~") {
1169
- expanded = path5.join(HOME_DIR2, expanded.slice(1));
474
+ conn.localWs.send(JSON.stringify(msg));
1170
475
  }
1171
- const resolved = path5.resolve(expanded);
1172
- if (resolved !== ALLOWED_DATA_DIR && !resolved.startsWith(ALLOWED_DATA_DIR + path5.sep)) {
1173
- throw new Error(
1174
- `Access denied: database path must be within ~/.openclaw/squad-ceo-data/`
476
+ /**
477
+ * Inject auth token and device identity into a connect request.
478
+ *
479
+ * SECURITY: The token is added to the message IN MEMORY, then sent to the
480
+ * LOCAL gateway WebSocket (localhost:18789). It NEVER traverses the relay —
481
+ * the relay only sees the outer relay.forward envelope. A compromised relay
482
+ * server cannot intercept this token.
483
+ */
484
+ injectDeviceIdentity(conn, msg) {
485
+ const params = msg.params ?? {};
486
+ if (this.config.operatorToken) {
487
+ params.auth = { token: this.config.operatorToken };
488
+ }
489
+ const client = params.client ?? {};
490
+ const role = params.role ?? "operator";
491
+ const scopes = params.scopes ?? [];
492
+ params.device = signDeviceIdentity(
493
+ this.deviceKeys,
494
+ client.id ?? "cli",
495
+ client.mode ?? "ui",
496
+ role,
497
+ scopes,
498
+ this.config.operatorToken,
499
+ conn.challengeNonce
1175
500
  );
501
+ msg.params = params;
502
+ conn.connectHandshakeComplete = false;
503
+ console.log(`[relay-client] Injected device identity for ${conn.userId}: nonce=${conn.challengeNonce?.substring(0, 12)}...`);
1176
504
  }
1177
- try {
1178
- const stat = fs5.statSync(resolved);
1179
- if (!stat.isFile()) {
1180
- throw new Error(`Not a file: ${dbPath}`);
1181
- }
1182
- } catch (e) {
1183
- if (e.code === "ENOENT") {
1184
- throw new Error(`Database file not found: ${dbPath}`);
505
+ /** Create a local WS connection to the gateway for a specific user */
506
+ createUserConnection(userId, carry) {
507
+ const existing = this.userConnections.get(userId);
508
+ if (existing) {
509
+ try {
510
+ existing.localWs.close(1e3, "Replaced");
511
+ } catch {
512
+ }
1185
513
  }
1186
- throw e;
1187
- }
1188
- return resolved;
1189
- }
1190
- function runSqlite3(dbPath, args) {
1191
- return new Promise((resolve, reject) => {
1192
- execFile(
1193
- "sqlite3",
1194
- [dbPath, ...args],
1195
- { timeout: 3e4, maxBuffer: 10 * 1024 * 1024 },
1196
- (error, stdout, stderr) => {
1197
- if (error) {
1198
- reject(new Error(stderr || error.message));
514
+ const attempt = this.localConnectAttempts.get(userId) ?? 0;
515
+ const host = this.config.localGatewayHosts[attempt % this.config.localGatewayHosts.length];
516
+ const localUrl = `ws://${host}:${this.config.localGatewayPort}`;
517
+ console.log(`[relay-client] Creating local WS for user ${userId} \u2192 ${localUrl}`);
518
+ const localWs = new NodeWebSocket(localUrl);
519
+ const conn = {
520
+ localWs,
521
+ userId,
522
+ e2e: carry?.e2e ?? null,
523
+ connectHandshakeComplete: false,
524
+ challengeNonce: null,
525
+ pendingConnect: carry?.pendingConnect ?? null,
526
+ pendingMessages: carry?.pendingMessages ?? []
527
+ };
528
+ this.userConnections.set(userId, conn);
529
+ localWs.on("open", () => {
530
+ console.log(`[relay-client] Local WS for user ${userId} connected`);
531
+ this.localConnectAttempts.delete(userId);
532
+ });
533
+ localWs.on("message", (data) => {
534
+ try {
535
+ const msg = JSON.parse(data.toString());
536
+ this.routeFromGateway(userId, msg);
537
+ } catch {
538
+ }
539
+ });
540
+ localWs.on("close", (code, reason) => {
541
+ const reasonStr = reason.toString();
542
+ console.log(`[relay-client] Local WS for user ${userId} closed: ${code} ${reasonStr}`);
543
+ if (code === 1008) {
544
+ console.error(
545
+ `[relay-client] Gateway rejected device identity (code 1008). The gateway auto-pairs devices with a valid operator token, so this usually means the operator token is missing, expired, or incorrect.
546
+ Check: ~/.openclaw/openclaw.json \u2192 gateway.auth.token
547
+ Device ID: ${this.deviceKeys.deviceId}`
548
+ );
549
+ }
550
+ const current = this.userConnections.get(userId);
551
+ if (current && current.localWs === localWs) {
552
+ this.userConnections.delete(userId);
553
+ const nextAttempt = (this.localConnectAttempts.get(userId) ?? 0) + 1;
554
+ const shouldRetryLocalConnect = code === 1006 && !conn.connectHandshakeComplete && nextAttempt <= 8 && this.relayWs?.readyState === NodeWebSocket.OPEN;
555
+ if (shouldRetryLocalConnect) {
556
+ this.localConnectAttempts.set(userId, nextAttempt);
557
+ const delay = Math.min(300 * nextAttempt, 2e3);
558
+ console.log(
559
+ `[relay-client] Local WS unavailable for ${userId}, retrying in ${delay}ms (attempt ${nextAttempt}/8)`
560
+ );
561
+ const carry2 = {
562
+ pendingConnect: conn.pendingConnect,
563
+ pendingMessages: conn.pendingMessages,
564
+ e2e: conn.e2e
565
+ };
566
+ setTimeout(() => {
567
+ if (this.destroyed) return;
568
+ if (this.relayWs?.readyState !== NodeWebSocket.OPEN) return;
569
+ if (!this.userConnections.has(userId)) {
570
+ this.createUserConnection(userId, carry2);
571
+ }
572
+ }, delay);
1199
573
  return;
1200
574
  }
1201
- resolve(stdout);
575
+ this.localConnectAttempts.delete(userId);
576
+ this.sendToRelay({
577
+ type: "relay.forward",
578
+ userId,
579
+ inner: {
580
+ type: "event",
581
+ event: "relay.gateway.connection.closed",
582
+ payload: { code }
583
+ }
584
+ });
1202
585
  }
1203
- );
1204
- });
1205
- }
1206
- function registerSqlTools(api) {
1207
- api.registerTool({
1208
- name: "sql_query",
1209
- label: "SQL Query",
1210
- description: "Execute a sqlite3 query on a database file within ~/.openclaw/squad-ceo-data/. Only sqlite3 is allowed \u2014 no arbitrary shell commands. Use jsonOutput: true for structured JSON results.",
1211
- parameters: T2.Object({
1212
- dbPath: T2.String({
1213
- description: "Path to the SQLite database file (must be within ~/.openclaw/squad-ceo-data/)"
1214
- }),
1215
- query: T2.String({ description: "SQL query to execute" }),
1216
- jsonOutput: T2.Optional(
1217
- T2.Boolean({
1218
- description: "Return results as JSON (sqlite3 -json flag)"
1219
- })
1220
- )
1221
- }),
1222
- async execute(_id, params) {
1223
- try {
1224
- const resolvedDb = validateDbPath(params.dbPath);
1225
- const args = [];
1226
- if (params.jsonOutput) args.push("-json");
1227
- args.push(params.query);
1228
- const output = await runSqlite3(resolvedDb, args);
1229
- return {
1230
- content: [{ type: "text", text: output }]
1231
- };
1232
- } catch (e) {
1233
- const msg = e instanceof Error ? e.message : String(e);
1234
- return {
1235
- content: [{ type: "text", text: JSON.stringify({ error: msg }) }],
1236
- isError: true
1237
- };
586
+ });
587
+ localWs.on("error", (err2) => {
588
+ console.error(`[relay-client] Local WS error for user ${userId}:`, err2.message);
589
+ });
590
+ }
591
+ /** Route a message from the gateway back through the relay to the user */
592
+ routeFromGateway(userId, msg) {
593
+ const conn = this.userConnections.get(userId);
594
+ if (!conn) return;
595
+ const parsed = msg;
596
+ if (parsed.type === "event" && parsed.event === "connect.challenge") {
597
+ const payload = parsed.payload;
598
+ if (payload?.nonce) {
599
+ conn.challengeNonce = payload.nonce;
600
+ console.log(`[relay-client] Captured challenge nonce for ${userId}: ${conn.challengeNonce.substring(0, 12)}...`);
601
+ if (conn.pendingConnect) {
602
+ const pending = conn.pendingConnect;
603
+ conn.pendingConnect = null;
604
+ console.log(`[relay-client] Flushing deferred connect for ${userId}`);
605
+ this.injectDeviceIdentity(conn, pending);
606
+ if (conn.localWs.readyState === NodeWebSocket.OPEN) {
607
+ conn.localWs.send(JSON.stringify(pending));
608
+ }
609
+ }
1238
610
  }
1239
611
  }
1240
- });
1241
- }
1242
-
1243
- // src/version.ts
1244
- import { execSync as execSync2 } from "child_process";
1245
- import fs6 from "fs";
1246
- import path6 from "path";
1247
- import { fileURLToPath } from "url";
1248
- var PACKAGE_NAME = "squad-openclaw";
1249
- var CONFIG_PATH = path6.join(getOpenclawStateDir(), "openclaw.json");
1250
- var updateInProgress = false;
1251
- var VERIFY_TIMEOUT_MS = 2e4;
1252
- var VERIFY_INTERVAL_MS = 500;
1253
- var RESTART_BUFFER_MS = 5e3;
1254
- function readInstalledVersionFromConfig() {
1255
- try {
1256
- const raw = fs6.readFileSync(CONFIG_PATH, "utf-8");
1257
- const cfg = JSON.parse(raw);
1258
- const v = cfg?.plugins?.installs?.[PACKAGE_NAME]?.version;
1259
- return typeof v === "string" ? v : null;
1260
- } catch {
1261
- return null;
1262
- }
1263
- }
1264
- function reconcileInstallMetadata(verification) {
1265
- if (!verification.installPath || !verification.packageVersion) return;
1266
- try {
1267
- const raw = fs6.readFileSync(CONFIG_PATH, "utf-8");
1268
- const config = JSON.parse(raw);
1269
- if (!config.plugins || typeof config.plugins !== "object") config.plugins = {};
1270
- if (!config.plugins.installs || typeof config.plugins.installs !== "object") {
1271
- config.plugins.installs = {};
612
+ if (parsed.type === "res" && parsed.id === "connect-1" && parsed.ok) {
613
+ conn.connectHandshakeComplete = true;
614
+ if (conn.pendingMessages.length > 0) {
615
+ console.log(`[relay-client] Flushing ${conn.pendingMessages.length} buffered messages for ${userId}`);
616
+ for (const queued of conn.pendingMessages) {
617
+ conn.localWs.send(JSON.stringify(queued));
618
+ }
619
+ conn.pendingMessages = [];
620
+ }
1272
621
  }
1273
- if (!config.plugins.entries || typeof config.plugins.entries !== "object") {
1274
- config.plugins.entries = {};
622
+ let innerMsg = msg;
623
+ if (conn.e2e) {
624
+ try {
625
+ const encrypted = conn.e2e.encrypt(JSON.stringify(msg));
626
+ innerMsg = { _e2e: true, ...encrypted };
627
+ } catch (err2) {
628
+ console.error(`[relay-client] E2E encrypt failed for ${userId} \u2014 dropping message:`, err2);
629
+ return;
630
+ }
1275
631
  }
1276
- const current = config.plugins.installs[PACKAGE_NAME] ?? {};
1277
- config.plugins.installs[PACKAGE_NAME] = {
1278
- ...current,
1279
- source: "npm",
1280
- spec: PACKAGE_NAME,
1281
- installPath: verification.installPath,
1282
- version: verification.packageVersion,
1283
- installedAt: (/* @__PURE__ */ new Date()).toISOString()
1284
- };
1285
- const entry = config.plugins.entries[PACKAGE_NAME] ?? {};
1286
- config.plugins.entries[PACKAGE_NAME] = {
1287
- ...entry,
1288
- enabled: true
1289
- };
1290
- fs6.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
1291
- } catch {
1292
- }
1293
- }
1294
- function getCurrentVersion() {
1295
- const thisFile = fileURLToPath(import.meta.url);
1296
- const pkgPath = path6.resolve(path6.dirname(thisFile), "..", "package.json");
1297
- try {
1298
- const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf-8"));
1299
- return pkg.version ?? "0.0.0";
1300
- } catch {
1301
- return "0.0.0";
1302
- }
1303
- }
1304
- async function fetchLatestVersion() {
1305
- const controller = new AbortController();
1306
- const timeout = setTimeout(() => controller.abort(), 1e4);
1307
- try {
1308
- const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}`, {
1309
- signal: controller.signal
632
+ this.sendToRelay({
633
+ type: "relay.forward",
634
+ userId,
635
+ inner: innerMsg
1310
636
  });
1311
- if (!res.ok) throw new Error(`npm registry returned ${res.status}`);
1312
- const data = await res.json();
1313
- return data["dist-tags"]?.latest ?? "0.0.0";
1314
- } finally {
1315
- clearTimeout(timeout);
1316
637
  }
1317
- }
1318
- function runDoctorFixSilently() {
1319
- try {
1320
- execSync2("openclaw doctor --fix 2>/dev/null || true", {
1321
- timeout: 3e4,
1322
- encoding: "utf-8"
638
+ // ── Pairing ──
639
+ handlePairingRequest(userId, email) {
640
+ console.log(`[relay-client] Pairing request from ${email} (${userId})`);
641
+ this.sendToRelay({
642
+ type: "relay.pair.status",
643
+ userId,
644
+ status: "pending"
1323
645
  });
1324
- } catch {
1325
- }
1326
- }
1327
- function sleep(ms) {
1328
- return new Promise((resolve) => setTimeout(resolve, ms));
1329
- }
1330
- function compareVersions(a, b) {
1331
- const pa = a.split(".").map((x) => Number(x) || 0);
1332
- const pb = b.split(".").map((x) => Number(x) || 0);
1333
- const len = Math.max(pa.length, pb.length);
1334
- for (let i = 0; i < len; i++) {
1335
- const d = (pa[i] ?? 0) - (pb[i] ?? 0);
1336
- if (d !== 0) return d;
1337
- }
1338
- return 0;
1339
- }
1340
- function verifyInstalledPluginState() {
1341
- let configRaw;
1342
- try {
1343
- configRaw = fs6.readFileSync(CONFIG_PATH, "utf-8");
1344
- } catch (err2) {
1345
- const msg = err2 instanceof Error ? err2.message : String(err2);
1346
- return {
1347
- ok: false,
1348
- reason: `Could not read openclaw.json: ${msg}`,
1349
- installPath: null,
1350
- configVersion: null,
1351
- packageVersion: null,
1352
- requiredFilesMissing: []
1353
- };
1354
- }
1355
- let config;
1356
- try {
1357
- config = JSON.parse(configRaw);
1358
- } catch (err2) {
1359
- const msg = err2 instanceof Error ? err2.message : String(err2);
1360
- return {
1361
- ok: false,
1362
- reason: `Could not parse openclaw.json: ${msg}`,
1363
- installPath: null,
1364
- configVersion: null,
1365
- packageVersion: null,
1366
- requiredFilesMissing: []
1367
- };
1368
- }
1369
- const installMeta = config?.plugins?.installs?.[PACKAGE_NAME];
1370
- const installPath = typeof installMeta?.installPath === "string" ? installMeta.installPath : null;
1371
- const configVersion = typeof installMeta?.version === "string" ? installMeta.version : null;
1372
- if (!installPath) {
1373
- return {
1374
- ok: false,
1375
- reason: "Missing plugins.installs entry or installPath for squad-openclaw",
1376
- installPath: null,
1377
- configVersion,
1378
- packageVersion: null,
1379
- requiredFilesMissing: []
1380
- };
1381
- }
1382
- const requiredFiles = [
1383
- path6.join(installPath, "package.json"),
1384
- path6.join(installPath, "openclaw.plugin.json"),
1385
- path6.join(installPath, "dist", "index.js")
1386
- ];
1387
- const requiredFilesMissing = requiredFiles.filter((p) => !fs6.existsSync(p));
1388
- if (requiredFilesMissing.length > 0) {
1389
- return {
1390
- ok: false,
1391
- reason: "Missing required installed plugin files",
1392
- installPath,
1393
- configVersion,
1394
- packageVersion: null,
1395
- requiredFilesMissing
1396
- };
1397
- }
1398
- let installedPackage;
1399
- try {
1400
- installedPackage = JSON.parse(
1401
- fs6.readFileSync(path6.join(installPath, "package.json"), "utf-8")
646
+ console.log(
647
+ `[relay-client] Pairing request pending for ${email}. The gateway will auto-pair this device if the operator token is valid.`
1402
648
  );
1403
- } catch (err2) {
1404
- const msg = err2 instanceof Error ? err2.message : String(err2);
1405
- return {
1406
- ok: false,
1407
- reason: `Could not parse installed package.json: ${msg}`,
1408
- installPath,
1409
- configVersion,
1410
- packageVersion: null,
1411
- requiredFilesMissing: []
1412
- };
1413
649
  }
1414
- try {
1415
- JSON.parse(
1416
- fs6.readFileSync(path6.join(installPath, "openclaw.plugin.json"), "utf-8")
1417
- );
1418
- } catch (err2) {
1419
- const msg = err2 instanceof Error ? err2.message : String(err2);
1420
- return {
1421
- ok: false,
1422
- reason: `Could not parse installed openclaw.plugin.json: ${msg}`,
1423
- installPath,
1424
- configVersion,
1425
- packageVersion: null,
1426
- requiredFilesMissing: []
1427
- };
650
+ // ── E2E Key Exchange ──
651
+ async handleE2EExchange(userId, browserPublicKey) {
652
+ console.log(`[relay-client] E2E key exchange with user ${userId}`);
653
+ const conn = this.userConnections.get(userId);
654
+ if (!conn) return;
655
+ try {
656
+ const e2e = new E2ECrypto();
657
+ const gatewayPublicKey = await e2e.generateKeyPair();
658
+ await e2e.deriveSharedSecret(browserPublicKey);
659
+ conn.e2e = e2e;
660
+ this.sendToRelay({
661
+ type: "relay.forward",
662
+ userId,
663
+ inner: {
664
+ type: "relay.e2e.exchange",
665
+ publicKey: gatewayPublicKey
666
+ }
667
+ });
668
+ console.log(`[relay-client] E2E established for user ${userId}`);
669
+ } catch (err2) {
670
+ console.error(`[relay-client] E2E exchange failed for ${userId}:`, err2);
671
+ }
1428
672
  }
1429
- const packageVersion = typeof installedPackage?.version === "string" ? installedPackage.version : null;
1430
- if (!packageVersion) {
1431
- return {
1432
- ok: false,
1433
- reason: "Installed package.json missing version",
1434
- installPath,
1435
- configVersion,
1436
- packageVersion,
1437
- requiredFilesMissing: []
1438
- };
673
+ // ── Send to Relay ──
674
+ sendToRelay(msg) {
675
+ if (!this.relayWs || this.relayWs.readyState !== NodeWebSocket.OPEN) return;
676
+ try {
677
+ this.relayWs.send(JSON.stringify(msg));
678
+ } catch {
679
+ }
1439
680
  }
1440
- return {
1441
- ok: true,
1442
- installPath,
1443
- configVersion,
1444
- packageVersion,
1445
- requiredFilesMissing: []
1446
- };
1447
- }
1448
- async function waitForVerifiedInstall() {
1449
- const deadline = Date.now() + VERIFY_TIMEOUT_MS;
1450
- let last = verifyInstalledPluginState();
1451
- while (!last.ok && Date.now() < deadline) {
1452
- await sleep(VERIFY_INTERVAL_MS);
1453
- last = verifyInstalledPluginState();
681
+ /** Broadcast an event to all connected users, E2E encrypted per-user */
682
+ broadcastToUsers(event, payload) {
683
+ const msg = { type: "event", event, payload };
684
+ for (const [userId, conn] of this.userConnections) {
685
+ if (!conn.connectHandshakeComplete) continue;
686
+ let innerMsg = msg;
687
+ if (conn.e2e) {
688
+ try {
689
+ const encrypted = conn.e2e.encrypt(JSON.stringify(msg));
690
+ innerMsg = { _e2e: true, ...encrypted };
691
+ } catch (err2) {
692
+ console.error(`[relay-client] E2E encrypt failed for broadcast to ${userId} \u2014 skipping:`, err2);
693
+ continue;
694
+ }
695
+ }
696
+ this.sendToRelay({
697
+ type: "relay.forward",
698
+ userId,
699
+ inner: innerMsg
700
+ });
701
+ }
1454
702
  }
1455
- return last;
703
+ };
704
+ var relayClient = null;
705
+ function startRelayClient(api, relayUrl) {
706
+ relayClient = new RelayClient({
707
+ relayUrl
708
+ });
709
+ relayClient.start();
710
+ api.registerGatewayMethod(
711
+ "squad.relay.status",
712
+ async ({ respond }) => {
713
+ respond(true, {
714
+ connected: relayClient !== null,
715
+ relayUrl
716
+ });
717
+ }
718
+ );
719
+ const cleanup = () => {
720
+ if (relayClient) {
721
+ relayClient.destroy();
722
+ relayClient = null;
723
+ }
724
+ };
725
+ process.on("SIGTERM", cleanup);
726
+ process.on("SIGINT", cleanup);
1456
727
  }
1457
- function registerVersionMethods(api) {
728
+ function broadcastToUsers(event, payload) {
729
+ relayClient?.broadcastToUsers(event, payload);
730
+ }
731
+
732
+ // src/agents.ts
733
+ import { execSync } from "child_process";
734
+ function registerAgentMethods(api) {
735
+ const callGateway = async (ctx, method, params = {}) => {
736
+ const ctxRequest = ctx.request;
737
+ if (typeof ctxRequest === "function") return ctxRequest(method, params);
738
+ const apiRequest = api?.request;
739
+ if (typeof apiRequest === "function") return apiRequest(method, params);
740
+ const apiCallGatewayMethod = api?.callGatewayMethod;
741
+ if (typeof apiCallGatewayMethod === "function") return apiCallGatewayMethod(method, params);
742
+ throw new Error("Gateway method invocation API unavailable in plugin context");
743
+ };
1458
744
  api.registerGatewayMethod(
1459
- "squad.version.check",
1460
- async ({ respond }) => {
745
+ "squad.agents.add",
746
+ async ({ params, respond }) => {
747
+ const name = params?.name;
748
+ const model = params?.model;
749
+ if (!name || typeof name !== "string" || !name.trim()) {
750
+ respond(false, { error: "Missing or empty 'name' parameter" });
751
+ return;
752
+ }
753
+ const safeName = name.trim();
754
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9 _-]*$/.test(safeName)) {
755
+ respond(false, { error: "Agent name must start with a letter/number and contain only letters, numbers, spaces, hyphens, or underscores" });
756
+ return;
757
+ }
1461
758
  try {
1462
- const current = getCurrentVersion();
1463
- let latest;
1464
- try {
1465
- latest = await fetchLatestVersion();
1466
- } catch {
1467
- respond(true, {
1468
- current,
1469
- latest: null,
1470
- updateAvailable: false,
1471
- registryError: "Could not reach npm registry"
1472
- });
1473
- return;
759
+ let cmd = `openclaw agents add ${JSON.stringify(safeName)} --non-interactive`;
760
+ if (model) {
761
+ cmd += ` --model ${JSON.stringify(model)}`;
1474
762
  }
1475
- respond(true, {
1476
- current,
1477
- latest,
1478
- updateAvailable: latest !== current && latest !== "0.0.0"
763
+ const output = execSync(cmd, {
764
+ timeout: 3e4,
765
+ encoding: "utf-8",
766
+ stdio: ["pipe", "pipe", "pipe"]
767
+ });
768
+ respond(true, { ok: true, output: output.slice(0, 1e3) });
769
+ } catch (err2) {
770
+ const msg = err2 instanceof Error ? err2.message : String(err2);
771
+ const stderr = err2?.stderr;
772
+ respond(false, {
773
+ error: `Failed to add agent: ${stderr || msg}`.slice(0, 500)
1479
774
  });
1480
- } catch (e) {
1481
- const msg = e instanceof Error ? e.message : String(e);
1482
- respond(false, { error: msg });
1483
775
  }
1484
776
  }
1485
777
  );
1486
778
  api.registerGatewayMethod(
1487
- "squad.version.update",
1488
- async ({ respond }) => {
1489
- if (updateInProgress) {
1490
- respond(false, { error: "Update already in progress" });
779
+ "squad.agents.delete",
780
+ async ({ params, respond }) => {
781
+ const agentId = params?.agentId;
782
+ if (!agentId || typeof agentId !== "string" || !agentId.trim()) {
783
+ respond(false, { error: "Missing or empty 'agentId' parameter" });
784
+ return;
785
+ }
786
+ if (agentId === "main") {
787
+ respond(false, { error: "Cannot delete the main agent" });
788
+ return;
789
+ }
790
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(agentId)) {
791
+ respond(false, { error: "Invalid agent ID format" });
1491
792
  return;
1492
793
  }
1493
- updateInProgress = true;
1494
794
  try {
1495
- const before = getCurrentVersion();
1496
- const beforeInstalledVersion = readInstalledVersionFromConfig();
1497
- let latestVersion = null;
1498
- try {
1499
- latestVersion = await fetchLatestVersion();
1500
- } catch {
1501
- latestVersion = null;
1502
- }
1503
- let updateOutput = "";
1504
- let configBackup = null;
1505
- try {
1506
- configBackup = fs6.readFileSync(CONFIG_PATH, "utf-8");
1507
- } catch {
1508
- }
1509
- runDoctorFixSilently();
1510
- try {
1511
- updateOutput = execSync2(
1512
- `openclaw plugins update ${PACKAGE_NAME} 2>&1`,
1513
- { timeout: 12e4, encoding: "utf-8" }
1514
- );
1515
- } catch (firstErr) {
1516
- runDoctorFixSilently();
1517
- try {
1518
- updateOutput = execSync2(
1519
- `openclaw plugins update ${PACKAGE_NAME} 2>&1`,
1520
- { timeout: 12e4, encoding: "utf-8" }
1521
- );
1522
- } catch (installErr) {
1523
- if (configBackup) {
1524
- try {
1525
- fs6.writeFileSync(CONFIG_PATH, configBackup, "utf-8");
1526
- } catch {
1527
- }
1528
- }
1529
- const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr);
1530
- const retryMsg = installErr instanceof Error ? installErr.message : String(installErr);
1531
- respond(false, {
1532
- error: `Update failed after doctor fix retry: ${retryMsg}`,
1533
- output: updateOutput,
1534
- firstError: firstMsg
1535
- });
1536
- return;
1537
- }
1538
- }
1539
- const verification = await waitForVerifiedInstall();
1540
- if (!verification.ok) {
1541
- if (configBackup) {
1542
- try {
1543
- fs6.writeFileSync(CONFIG_PATH, configBackup, "utf-8");
1544
- } catch {
1545
- }
1546
- }
1547
- respond(false, {
1548
- error: `Update verification failed: ${verification.reason ?? "unknown error"}`,
1549
- output: updateOutput.slice(0, 500),
1550
- verification
1551
- });
1552
- return;
1553
- }
1554
- reconcileInstallMetadata(verification);
1555
- const verificationAfterReconcile = verifyInstalledPluginState();
1556
- if (beforeInstalledVersion && verificationAfterReconcile.packageVersion && beforeInstalledVersion === verificationAfterReconcile.packageVersion) {
1557
- const alreadyLatest = !!latestVersion && compareVersions(verificationAfterReconcile.packageVersion, latestVersion) >= 0;
1558
- respond(false, {
1559
- error: alreadyLatest ? `Already at latest version (${verificationAfterReconcile.packageVersion}).` : `Update command completed but installed version did not change (${verificationAfterReconcile.packageVersion}).`,
1560
- output: updateOutput.slice(0, 500),
1561
- verification: verificationAfterReconcile,
1562
- latestVersion
1563
- });
1564
- return;
1565
- }
1566
- const after = getCurrentVersion();
1567
- respond(true, {
1568
- previousVersion: before,
1569
- currentVersion: after,
1570
- updated: true,
1571
- restartRequired: true,
1572
- restartInMs: RESTART_BUFFER_MS,
1573
- verification: verificationAfterReconcile,
1574
- latestVersion,
1575
- output: updateOutput.slice(0, 500)
1576
- });
1577
- await sleep(RESTART_BUFFER_MS);
1578
- console.log(
1579
- `[version] Plugin update verified (was ${before}), restarting gateway...`
795
+ const output = execSync(
796
+ `openclaw agents delete ${JSON.stringify(agentId)} --non-interactive 2>&1`,
797
+ { timeout: 3e4, encoding: "utf-8" }
1580
798
  );
1581
- try {
1582
- execSync2("openclaw gateway restart 2>&1", {
1583
- timeout: 3e4,
1584
- encoding: "utf-8"
1585
- });
1586
- } catch {
1587
- }
1588
- } catch (e) {
1589
- const msg = e instanceof Error ? e.message : String(e);
1590
- respond(false, { error: msg });
1591
- } finally {
1592
- updateInProgress = false;
799
+ respond(true, { ok: true, output: output.slice(0, 1e3) });
800
+ } catch (err2) {
801
+ const msg = err2 instanceof Error ? err2.message : String(err2);
802
+ const stderr = err2?.stderr;
803
+ respond(false, {
804
+ error: `Failed to delete agent: ${stderr || msg}`.slice(0, 500)
805
+ });
1593
806
  }
1594
807
  }
1595
808
  );
1596
- }
1597
-
1598
- // src/relay-client.ts
1599
- import { WebSocket as NodeWebSocket } from "ws";
1600
- import crypto3 from "crypto";
1601
- import fs8 from "fs";
1602
- import path8 from "path";
1603
-
1604
- // src/e2e-crypto.ts
1605
- import crypto from "crypto";
1606
- var CURVE = "prime256v1";
1607
- var HKDF_SALT = "squad-e2e-v1";
1608
- var HKDF_INFO = "aes-gcm-key";
1609
- var AES_KEY_LENGTH = 32;
1610
- var IV_LENGTH = 12;
1611
- var E2ECrypto = class {
1612
- ecdh = null;
1613
- aesKey = null;
1614
- publicKeyB64 = null;
1615
- /** Generate an ephemeral ECDH keypair. Returns the public key as base64. */
1616
- async generateKeyPair() {
1617
- this.ecdh = crypto.createECDH(CURVE);
1618
- const publicKey = this.ecdh.generateKeys();
1619
- this.publicKeyB64 = publicKey.toString("base64");
1620
- return this.publicKeyB64;
1621
- }
1622
- /** Derive the shared secret from the peer's public key. */
1623
- async deriveSharedSecret(peerPublicKeyB64) {
1624
- if (!this.ecdh) {
1625
- throw new Error("Must call generateKeyPair() first");
1626
- }
1627
- const peerPublicKey = Buffer.from(peerPublicKeyB64, "base64");
1628
- const sharedSecret = this.ecdh.computeSecret(peerPublicKey);
1629
- this.aesKey = crypto.hkdfSync(
1630
- "sha256",
1631
- sharedSecret,
1632
- Buffer.from(HKDF_SALT),
1633
- Buffer.from(HKDF_INFO),
1634
- AES_KEY_LENGTH
1635
- );
1636
- }
1637
- /** Encrypt a plaintext string. Returns base64-encoded ciphertext + iv + tag. */
1638
- encrypt(plaintext) {
1639
- if (!this.aesKey) {
1640
- throw new Error("E2E not established \u2014 call deriveSharedSecret() first");
1641
- }
1642
- const iv = crypto.randomBytes(IV_LENGTH);
1643
- const cipher = crypto.createCipheriv("aes-256-gcm", this.aesKey, iv);
1644
- const encrypted = Buffer.concat([
1645
- cipher.update(plaintext, "utf-8"),
1646
- cipher.final()
1647
- ]);
1648
- const tag = cipher.getAuthTag();
1649
- return {
1650
- ciphertext: encrypted.toString("base64"),
1651
- iv: iv.toString("base64"),
1652
- tag: tag.toString("base64")
1653
- };
1654
- }
1655
- /** Decrypt a payload. Returns the plaintext string. */
1656
- decrypt(payload) {
1657
- if (!this.aesKey) {
1658
- throw new Error("E2E not established \u2014 call deriveSharedSecret() first");
809
+ api.registerGatewayMethod(
810
+ "squad.agents.set-identity",
811
+ async (ctx) => {
812
+ const { params, respond } = ctx;
813
+ const agentId = params?.agentId;
814
+ const name = params?.name;
815
+ const emoji = params?.emoji;
816
+ const theme = params?.theme;
817
+ if (!agentId || typeof agentId !== "string") {
818
+ respond(false, { error: "Missing 'agentId' parameter" });
819
+ return;
820
+ }
821
+ const identity = {};
822
+ const trimmedName = typeof name === "string" ? name.trim() : "";
823
+ const trimmedEmoji = typeof emoji === "string" ? emoji.trim() : "";
824
+ const trimmedTheme = typeof theme === "string" ? theme.trim() : "";
825
+ if (trimmedName) identity.name = trimmedName;
826
+ if (trimmedEmoji) identity.emoji = trimmedEmoji;
827
+ if (trimmedTheme) identity.theme = trimmedTheme;
828
+ if (Object.keys(identity).length === 0) {
829
+ respond(false, { error: "No identity fields provided (name, emoji, or theme)" });
830
+ return;
831
+ }
832
+ try {
833
+ const doPatch = async (baseHash) => {
834
+ await callGateway(ctx, "config.patch", {
835
+ ...baseHash ? { baseHash } : {},
836
+ raw: JSON.stringify({
837
+ agents: {
838
+ list: [{ id: agentId, identity }]
839
+ }
840
+ })
841
+ });
842
+ };
843
+ let snapshot = await callGateway(ctx, "config.get", {});
844
+ try {
845
+ await doPatch(snapshot?.hash);
846
+ } catch (firstErr) {
847
+ const msg = firstErr instanceof Error ? firstErr.message : String(firstErr);
848
+ if (!/config changed since last load/i.test(msg)) throw firstErr;
849
+ snapshot = await callGateway(ctx, "config.get", {});
850
+ await doPatch(snapshot?.hash);
851
+ }
852
+ respond(true, { ok: true, identity });
853
+ } catch (err2) {
854
+ const msg = err2 instanceof Error ? err2.message : String(err2);
855
+ respond(false, {
856
+ error: `Failed to set identity: ${msg}`.slice(0, 500)
857
+ });
858
+ }
1659
859
  }
1660
- const ciphertext = Buffer.from(payload.ciphertext, "base64");
1661
- const iv = Buffer.from(payload.iv, "base64");
1662
- const tag = Buffer.from(payload.tag, "base64");
1663
- const decipher = crypto.createDecipheriv("aes-256-gcm", this.aesKey, iv);
1664
- decipher.setAuthTag(tag);
1665
- const decrypted = Buffer.concat([
1666
- decipher.update(ciphertext),
1667
- decipher.final()
1668
- ]);
1669
- return decrypted.toString("utf-8");
1670
- }
1671
- /** Whether E2E encryption has been established */
1672
- get isEstablished() {
1673
- return this.aesKey !== null;
1674
- }
1675
- /** Get the local public key (base64) */
1676
- get publicKey() {
1677
- return this.publicKeyB64;
1678
- }
1679
- };
860
+ );
861
+ api.registerGatewayMethod(
862
+ "squad.agents.patch-config",
863
+ async (ctx) => {
864
+ const { params, respond } = ctx;
865
+ const agentId = params?.agentId;
866
+ const fields = params?.fields ?? {};
867
+ if (!agentId || typeof agentId !== "string") {
868
+ respond(false, { error: "Missing 'agentId' parameter" });
869
+ return;
870
+ }
871
+ const allowedFieldNames = /* @__PURE__ */ new Set(["tools", "skills", "default", "model"]);
872
+ const filteredFields = {};
873
+ for (const [k, v] of Object.entries(fields)) {
874
+ if (allowedFieldNames.has(k) && v !== void 0) filteredFields[k] = v;
875
+ }
876
+ if (Object.keys(filteredFields).length === 0) {
877
+ respond(false, { error: "No patchable fields provided (tools, skills, default, model)" });
878
+ return;
879
+ }
880
+ try {
881
+ const doPatch = async (baseHash) => {
882
+ await callGateway(ctx, "config.patch", {
883
+ ...baseHash ? { baseHash } : {},
884
+ raw: JSON.stringify({
885
+ agents: {
886
+ list: [{ id: agentId, ...filteredFields }]
887
+ }
888
+ })
889
+ });
890
+ };
891
+ let snapshot = await callGateway(ctx, "config.get", {});
892
+ try {
893
+ await doPatch(snapshot?.hash);
894
+ } catch (firstErr) {
895
+ const msg = firstErr instanceof Error ? firstErr.message : String(firstErr);
896
+ if (!/config changed since last load/i.test(msg)) throw firstErr;
897
+ snapshot = await callGateway(ctx, "config.get", {});
898
+ await doPatch(snapshot?.hash);
899
+ }
900
+ respond(true, { ok: true, fields: filteredFields });
901
+ } catch (err2) {
902
+ const msg = err2 instanceof Error ? err2.message : String(err2);
903
+ respond(false, {
904
+ error: `Failed to patch agent config: ${msg}`.slice(0, 500)
905
+ });
906
+ }
907
+ }
908
+ );
909
+ }
1680
910
 
1681
- // src/device-keys.ts
1682
- import crypto2 from "crypto";
1683
- import fs7 from "fs";
911
+ // src/entities.ts
912
+ import { Type as T } from "@sinclair/typebox";
1684
913
  import path7 from "path";
1685
- var RELAY_DATA_DIR = path7.join(getOpenclawStateDir(), "squad-ceo-data", "relay");
1686
- var RELAY_STATE_PATH = path7.join(RELAY_DATA_DIR, "squad-relay.json");
1687
- var PENDING_APPROVAL_PATH = path7.join(RELAY_DATA_DIR, "pending-approval.json");
1688
- function readRelayState() {
1689
- try {
1690
- const raw = fs7.readFileSync(RELAY_STATE_PATH, "utf-8");
1691
- return JSON.parse(raw);
1692
- } catch {
1693
- return {};
1694
- }
914
+ import fs7 from "fs";
915
+
916
+ // src/watcher.ts
917
+ import path4 from "path";
918
+ import fs4 from "fs";
919
+ import chokidar from "chokidar";
920
+ var debounceTimers = /* @__PURE__ */ new Map();
921
+ var DEBOUNCE_MS = 500;
922
+ function debounced(key, fn) {
923
+ const existing = debounceTimers.get(key);
924
+ if (existing) clearTimeout(existing);
925
+ debounceTimers.set(
926
+ key,
927
+ setTimeout(() => {
928
+ debounceTimers.delete(key);
929
+ fn();
930
+ }, DEBOUNCE_MS)
931
+ );
1695
932
  }
1696
- function writeRelayState(state) {
1697
- if (!fs7.existsSync(RELAY_DATA_DIR)) {
1698
- fs7.mkdirSync(RELAY_DATA_DIR, { recursive: true });
1699
- }
1700
- fs7.writeFileSync(RELAY_STATE_PATH, JSON.stringify(state, null, 2), { mode: 384 });
933
+ var fsDebounceTimers = /* @__PURE__ */ new Map();
934
+ var FS_DEBOUNCE_MS = 300;
935
+ function debouncedFs(relPath, action, fn) {
936
+ const key = `fs:${action}:${relPath}`;
937
+ const existing = fsDebounceTimers.get(key);
938
+ if (existing) clearTimeout(existing);
939
+ fsDebounceTimers.set(
940
+ key,
941
+ setTimeout(() => {
942
+ fsDebounceTimers.delete(key);
943
+ fn();
944
+ }, FS_DEBOUNCE_MS)
945
+ );
1701
946
  }
1702
- function toBase64Url(buf) {
1703
- return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
947
+ function isWorkspaceIdentity(filePath, configDir) {
948
+ const rel = path4.relative(configDir, filePath);
949
+ const match = rel.match(/^(workspace(?:-([^/]+))?)\/IDENTITY\.md$/);
950
+ if (!match) return null;
951
+ const dirName = match[1];
952
+ const agentId = match[2] ?? "main";
953
+ return { agentId, workspacePath: path4.join(configDir, dirName) };
1704
954
  }
1705
- function loadOrCreateRelayDeviceKeys() {
1706
- const state = readRelayState();
1707
- if (state.deviceKeys) {
1708
- return state.deviceKeys;
1709
- }
1710
- const { publicKey, privateKey } = crypto2.generateKeyPairSync("ed25519");
1711
- const pubDer = publicKey.export({ type: "spki", format: "der" });
1712
- const rawPub = pubDer.subarray(pubDer.length - 32);
1713
- const deviceId = crypto2.createHash("sha256").update(rawPub).digest("hex");
1714
- const publicKeyB64 = toBase64Url(rawPub);
1715
- const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
1716
- const keys = { deviceId, publicKey: publicKeyB64, privateKeyPem };
1717
- writeRelayState({ ...state, deviceKeys: keys });
1718
- console.log(`[device-keys] Generated relay device identity: ${deviceId.substring(0, 12)}...`);
1719
- return keys;
955
+ function isWorkspaceAgentJson(filePath, configDir) {
956
+ const rel = path4.relative(configDir, filePath);
957
+ const match = rel.match(/^(workspace(?:-([^/]+))?)\/agent\.json$/);
958
+ if (!match) return null;
959
+ const dirName = match[1];
960
+ const agentId = match[2] ?? "main";
961
+ return { agentId, workspacePath: path4.join(configDir, dirName) };
1720
962
  }
1721
- function writeDeviceInfoFile(keys) {
1722
- const stateDir = getOpenclawStateDir();
1723
- const infoPath = path7.join(stateDir, "squad-ceo-data", "relay", "relay-device-info.json");
1724
- const info = {
1725
- deviceId: keys.deviceId,
1726
- publicKey: keys.publicKey,
1727
- displayName: "squad-relay",
1728
- platform: process.platform,
1729
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
1730
- };
1731
- try {
1732
- fs7.writeFileSync(infoPath, JSON.stringify(info, null, 2));
1733
- } catch (err2) {
1734
- console.error("[device-keys] Failed to write relay-device-info.json:", err2);
1735
- }
963
+ function isGlobalSkillDir(filePath, configDir) {
964
+ const rel = path4.relative(configDir, filePath);
965
+ const match = rel.match(/^skills\/([^/]+)\/?$/);
966
+ if (!match) return null;
967
+ return { skillKey: match[1] };
1736
968
  }
1737
-
1738
- // src/relay-client.ts
1739
- function readOperatorToken() {
1740
- const stateDir = getOpenclawStateDir();
1741
- const configPath = path8.join(stateDir, "openclaw.json");
969
+ function isWorkspaceSkillDir(filePath, configDir) {
970
+ const rel = path4.relative(configDir, filePath);
971
+ const match = rel.match(
972
+ /^workspace(?:-([^/]+))?\/skills\/([^/]+)\/?$/
973
+ );
974
+ if (!match) return null;
975
+ return { agentId: match[1] ?? "main", skillKey: match[2] };
976
+ }
977
+ function isPluginManifest(filePath, configDir) {
978
+ const rel = path4.relative(configDir, filePath);
979
+ const match = rel.match(/^extensions\/([^/]+)\/openclaw\.plugin\.json$/);
980
+ if (!match) return null;
981
+ return { pluginDirName: match[1] };
982
+ }
983
+ function isOpenClawConfig(filePath, configDir) {
984
+ return path4.relative(configDir, filePath) === "openclaw.json";
985
+ }
986
+ function updateAgent(agentId, workspacePath) {
987
+ const now = Date.now();
988
+ let name = agentId;
989
+ const metadata = { workspacePath };
1742
990
  try {
1743
- const raw = fs8.readFileSync(configPath, "utf-8");
1744
- const config = JSON.parse(raw);
1745
- return config?.gateway?.auth?.token ?? config?.gateway?.remote?.token ?? config?.gateway?.token ?? null;
991
+ const content = fs4.readFileSync(
992
+ path4.join(workspacePath, "IDENTITY.md"),
993
+ "utf-8"
994
+ );
995
+ const parsed = parseIdentityName(content);
996
+ if (parsed) name = parsed;
1746
997
  } catch {
1747
- return null;
1748
998
  }
1749
- }
1750
- function readGatewayLocalWsConfig() {
1751
- const defaults = {
1752
- port: 18789,
1753
- // Try IPv4, hostname, then IPv6 loopback.
1754
- hosts: ["127.0.0.1", "localhost", "[::1]"]
1755
- };
1756
- const stateDir = getOpenclawStateDir();
1757
- const configPath = path8.join(stateDir, "openclaw.json");
1758
- try {
1759
- const raw = fs8.readFileSync(configPath, "utf-8");
1760
- const config = JSON.parse(raw);
1761
- const parsedPort = Number(config?.gateway?.port);
1762
- if (Number.isFinite(parsedPort) && parsedPort > 0) {
1763
- defaults.port = parsedPort;
999
+ if (name === agentId) {
1000
+ try {
1001
+ const raw = fs4.readFileSync(
1002
+ path4.join(workspacePath, "agent.json"),
1003
+ "utf-8"
1004
+ );
1005
+ const config = JSON.parse(raw);
1006
+ if (config.displayName) name = config.displayName;
1007
+ if (config.model) metadata.model = config.model;
1008
+ } catch {
1764
1009
  }
1765
- } catch {
1766
1010
  }
1767
- return defaults;
1768
- }
1769
- function signDeviceIdentity(keys, clientId, clientMode, role, scopes, token, challengeNonce) {
1770
- const signedAtMs = Date.now();
1771
- const nonce = challengeNonce || crypto3.randomBytes(16).toString("hex");
1772
- const scopeStr = scopes.join(",");
1773
- const payload = `v2|${keys.deviceId}|${clientId}|${clientMode}|${role}|${scopeStr}|${signedAtMs}|${token ?? ""}|${nonce}`;
1774
- const privateKey = crypto3.createPrivateKey(keys.privateKeyPem);
1775
- const signature = crypto3.sign(null, Buffer.from(payload), privateKey);
1776
- return {
1777
- id: keys.deviceId,
1778
- publicKey: keys.publicKey,
1779
- signature: toBase64Url(signature),
1780
- signedAt: signedAtMs,
1781
- nonce
1782
- };
1783
- }
1784
- var RelayClient = class {
1785
- config;
1786
- relayWs = null;
1787
- userConnections = /* @__PURE__ */ new Map();
1788
- localConnectAttempts = /* @__PURE__ */ new Map();
1789
- reconnectAttempts = 0;
1790
- maxReconnectAttempts = 100;
1791
- reconnectTimer = null;
1792
- shouldReconnect = true;
1793
- destroyed = false;
1794
- /** Pending claim token — sent on first successful connect, then cleared */
1795
- pendingClaimToken = null;
1796
- /** Device keys for authenticating local WS connections to the gateway */
1797
- deviceKeys;
1798
- constructor(config) {
1799
- const state = readRelayState();
1800
- const localWs = readGatewayLocalWsConfig();
1801
- this.config = {
1802
- relayUrl: config.relayUrl,
1803
- localGatewayPort: config.localGatewayPort ?? localWs.port,
1804
- localGatewayHosts: config.localGatewayHosts ?? localWs.hosts,
1805
- operatorToken: config.operatorToken ?? readOperatorToken(),
1806
- claimToken: config.claimToken ?? state.claimToken ?? null,
1807
- roomId: config.roomId ?? state.roomId ?? null
1808
- };
1809
- this.pendingClaimToken = this.config.roomId ? null : this.config.claimToken;
1810
- this.deviceKeys = loadOrCreateRelayDeviceKeys();
1811
- writeDeviceInfoFile(this.deviceKeys);
1812
- console.log(`[relay-client] Device ID: ${this.deviceKeys.deviceId}`);
1011
+ registrySet({
1012
+ id: agentId,
1013
+ type: "agent",
1014
+ name,
1015
+ title: name,
1016
+ description: null,
1017
+ metadata,
1018
+ source: "filesystem",
1019
+ source_key: workspacePath,
1020
+ created_at: now,
1021
+ updated_at: now
1022
+ });
1023
+ }
1024
+ function updatePlugin(pluginDirName, configDir) {
1025
+ const now = Date.now();
1026
+ const manifestPath = path4.join(
1027
+ configDir,
1028
+ "extensions",
1029
+ pluginDirName,
1030
+ "openclaw.plugin.json"
1031
+ );
1032
+ try {
1033
+ const raw = fs4.readFileSync(manifestPath, "utf-8");
1034
+ const manifest = JSON.parse(raw);
1035
+ const pluginId = manifest.id || pluginDirName;
1036
+ const name = manifest.name || pluginId;
1037
+ registrySet({
1038
+ id: `plugin:${pluginId}`,
1039
+ type: "plugin",
1040
+ name,
1041
+ title: name,
1042
+ description: manifest.description || null,
1043
+ metadata: { pluginId, pluginDir: path4.dirname(manifestPath) },
1044
+ source: "filesystem",
1045
+ source_key: manifestPath,
1046
+ created_at: now,
1047
+ updated_at: now
1048
+ });
1049
+ } catch {
1050
+ registryDelete(`plugin:${pluginDirName}`);
1813
1051
  }
1814
- /** Start connecting to the relay */
1815
- start() {
1816
- if (!this.config.roomId && !this.pendingClaimToken) {
1817
- console.log("[relay-client] No room ID or claim token found.");
1818
- console.log("[relay-client] Complete the setup from the Squad web app to generate a claim token.");
1052
+ }
1053
+ function startWatcher(configDir, onFsChange) {
1054
+ const watcher = chokidar.watch(configDir, {
1055
+ persistent: true,
1056
+ usePolling: false,
1057
+ ignoreInitial: true,
1058
+ awaitWriteFinish: { stabilityThreshold: 300 },
1059
+ depth: 4,
1060
+ ignored: [
1061
+ // Ignore heavy directories that aren't relevant
1062
+ "**/node_modules/**",
1063
+ "**/dist/**",
1064
+ "**/.git/**",
1065
+ "**/data/**"
1066
+ ]
1067
+ });
1068
+ const emitFsChange = (action, filePath) => {
1069
+ if (!onFsChange) return;
1070
+ const rel = path4.relative(configDir, filePath);
1071
+ debouncedFs(rel, action, () => {
1072
+ onFsChange({ action, path: rel });
1073
+ });
1074
+ };
1075
+ const handleChange = (filePath, action) => {
1076
+ emitFsChange(action, filePath);
1077
+ const identity = isWorkspaceIdentity(filePath, configDir);
1078
+ if (identity) {
1079
+ debounced(
1080
+ `agent:${identity.agentId}`,
1081
+ () => updateAgent(identity.agentId, identity.workspacePath)
1082
+ );
1819
1083
  return;
1820
1084
  }
1821
- console.log(`[relay-client] Starting relay connection to ${this.config.relayUrl}`);
1822
- if (this.config.roomId) {
1823
- console.log(`[relay-client] Room ID: ${this.config.roomId.substring(0, 8)}...`);
1824
- } else {
1825
- console.log(`[relay-client] Using claim token for first connect`);
1085
+ const agentJson = isWorkspaceAgentJson(filePath, configDir);
1086
+ if (agentJson) {
1087
+ debounced(
1088
+ `agent:${agentJson.agentId}`,
1089
+ () => updateAgent(agentJson.agentId, agentJson.workspacePath)
1090
+ );
1091
+ return;
1826
1092
  }
1827
- this.connectToRelay();
1828
- }
1829
- /** Stop the relay client and close all connections */
1830
- destroy() {
1831
- this.destroyed = true;
1832
- this.shouldReconnect = false;
1833
- if (this.reconnectTimer) {
1834
- clearTimeout(this.reconnectTimer);
1835
- this.reconnectTimer = null;
1093
+ const plugin = isPluginManifest(filePath, configDir);
1094
+ if (plugin) {
1095
+ debounced(
1096
+ `plugin:${plugin.pluginDirName}`,
1097
+ () => updatePlugin(plugin.pluginDirName, configDir)
1098
+ );
1099
+ return;
1836
1100
  }
1837
- for (const [userId, conn] of this.userConnections) {
1838
- try {
1839
- conn.localWs.close(1e3, "Relay client shutting down");
1840
- } catch {
1841
- }
1842
- this.userConnections.delete(userId);
1101
+ if (isOpenClawConfig(filePath, configDir)) {
1102
+ debounced("tools", () => scanTools(configDir));
1103
+ return;
1843
1104
  }
1844
- if (this.relayWs) {
1845
- try {
1846
- this.relayWs.close(1e3, "Relay client shutting down");
1847
- } catch {
1848
- }
1849
- this.relayWs = null;
1105
+ };
1106
+ const handleAddDir = (dirPath) => {
1107
+ emitFsChange("addDir", dirPath);
1108
+ const globalSkill = isGlobalSkillDir(dirPath, configDir);
1109
+ if (globalSkill) {
1110
+ debounced(
1111
+ `skill:${globalSkill.skillKey}`,
1112
+ () => scanSkills(configDir)
1113
+ );
1114
+ return;
1850
1115
  }
1851
- }
1852
- // ── Relay Connection ──
1853
- connectToRelay() {
1854
- if (this.destroyed) return;
1855
- let wsUrl;
1856
- if (this.pendingClaimToken) {
1857
- wsUrl = `${this.config.relayUrl}/gw?claim=${encodeURIComponent(this.pendingClaimToken)}`;
1858
- console.log(`[relay-client] Connecting with claim token`);
1859
- } else if (this.config.roomId) {
1860
- wsUrl = `${this.config.relayUrl}/gw?room=${encodeURIComponent(this.config.roomId)}`;
1861
- console.log(`[relay-client] Reconnecting with room ID`);
1862
- } else {
1863
- console.error("[relay-client] No claim token or room ID \u2014 cannot connect");
1116
+ const wsSkill = isWorkspaceSkillDir(dirPath, configDir);
1117
+ if (wsSkill) {
1118
+ debounced(
1119
+ `skill:${wsSkill.agentId}:${wsSkill.skillKey}`,
1120
+ () => scanSkills(configDir)
1121
+ );
1864
1122
  return;
1865
1123
  }
1866
- try {
1867
- this.relayWs = new NodeWebSocket(wsUrl);
1868
- } catch (err2) {
1869
- console.error("[relay-client] Failed to create WebSocket:", err2);
1870
- this.scheduleReconnect();
1124
+ const rel = path4.relative(configDir, dirPath);
1125
+ if (/^workspace(-[^/]+)?$/.test(rel)) {
1126
+ debounced("agents", () => scanAgents(configDir));
1871
1127
  return;
1872
1128
  }
1873
- this.relayWs.on("open", () => {
1874
- console.log("[relay-client] Connected to relay");
1875
- this.reconnectAttempts = 0;
1876
- this.sendToRelay({
1877
- type: "relay.hello",
1878
- deviceId: this.deviceKeys.deviceId,
1879
- publicKey: this.deviceKeys.publicKey
1880
- });
1881
- });
1882
- this.relayWs.on("message", (data) => {
1883
- try {
1884
- const msg = JSON.parse(data.toString());
1885
- this.handleRelayMessage(msg);
1886
- } catch {
1887
- }
1888
- });
1889
- this.relayWs.on("close", (code, reason) => {
1890
- const reasonStr = reason.toString();
1891
- console.log(`[relay-client] Relay connection closed: ${code} ${reasonStr}`);
1892
- this.relayWs = null;
1893
- if (code === 1e3 && reasonStr.includes("Replaced")) {
1894
- console.log("[relay-client] Replaced by newer instance, stopping reconnect");
1895
- this.shouldReconnect = false;
1896
- this.destroyed = true;
1897
- }
1898
- for (const [userId, conn] of this.userConnections) {
1899
- try {
1900
- conn.localWs.close(1001, "Relay disconnected");
1901
- } catch {
1902
- }
1903
- this.userConnections.delete(userId);
1904
- }
1905
- if (this.shouldReconnect) {
1906
- this.scheduleReconnect();
1907
- }
1908
- });
1909
- this.relayWs.on("error", (err2) => {
1910
- console.error("[relay-client] Relay WebSocket error:", err2.message);
1911
- });
1912
- this.relayWs.on("unexpected-response", (_req, res) => {
1913
- console.warn(`[relay-client] Unexpected response: ${res.statusCode}`);
1914
- if (res.statusCode === 401 && this.pendingClaimToken) {
1915
- console.log("[relay-client] Claim token rejected \u2014 checking for stored room ID");
1916
- this.pendingClaimToken = null;
1917
- const state = readRelayState();
1918
- if (state.roomId) {
1919
- this.config.roomId = state.roomId;
1920
- console.log(`[relay-client] Found stored room ID, will use on next reconnect`);
1921
- }
1922
- }
1923
- this.relayWs = null;
1924
- this.scheduleReconnect();
1129
+ };
1130
+ const handleUnlinkDir = (dirPath) => {
1131
+ emitFsChange("unlinkDir", dirPath);
1132
+ const rel = path4.relative(configDir, dirPath);
1133
+ const wsMatch = rel.match(/^workspace(?:-([^/]+))?$/);
1134
+ if (wsMatch) {
1135
+ const agentId = wsMatch[1] ?? "main";
1136
+ registryDelete(agentId);
1137
+ return;
1138
+ }
1139
+ const globalSkill = isGlobalSkillDir(dirPath, configDir);
1140
+ if (globalSkill) {
1141
+ registryDelete(`skill:${globalSkill.skillKey}`);
1142
+ return;
1143
+ }
1144
+ const wsSkill = isWorkspaceSkillDir(dirPath, configDir);
1145
+ if (wsSkill) {
1146
+ registryDelete(`skill:${wsSkill.agentId}:${wsSkill.skillKey}`);
1147
+ return;
1148
+ }
1149
+ };
1150
+ watcher.on("add", (fp) => handleChange(fp, "add"));
1151
+ watcher.on("change", (fp) => handleChange(fp, "change"));
1152
+ watcher.on("unlink", (fp) => handleChange(fp, "unlink"));
1153
+ watcher.on("addDir", handleAddDir);
1154
+ watcher.on("unlinkDir", handleUnlinkDir);
1155
+ return () => {
1156
+ for (const timer of debounceTimers.values()) {
1157
+ clearTimeout(timer);
1158
+ }
1159
+ debounceTimers.clear();
1160
+ for (const timer of fsDebounceTimers.values()) {
1161
+ clearTimeout(timer);
1162
+ }
1163
+ fsDebounceTimers.clear();
1164
+ watcher.close();
1165
+ };
1166
+ }
1167
+
1168
+ // src/filesystem.ts
1169
+ import fs6 from "fs";
1170
+ import path6 from "path";
1171
+
1172
+ // src/layout.ts
1173
+ import fs5 from "fs";
1174
+ import path5 from "path";
1175
+ function resolveMaybeRelativePath(stateDir, p) {
1176
+ if (path5.isAbsolute(p)) return path5.resolve(p);
1177
+ return path5.resolve(stateDir, p);
1178
+ }
1179
+ function listWorkspaceFallbacks(stateDir) {
1180
+ let entries;
1181
+ try {
1182
+ entries = fs5.readdirSync(stateDir, { withFileTypes: true });
1183
+ } catch {
1184
+ return [];
1185
+ }
1186
+ return entries.filter((entry) => entry.isDirectory() && (entry.name === "workspace" || entry.name.startsWith("workspace-"))).map((entry) => {
1187
+ const agentId = entry.name === "workspace" ? "main" : entry.name.replace("workspace-", "");
1188
+ const workspacePath = path5.join(stateDir, entry.name);
1189
+ return {
1190
+ agentId,
1191
+ path: workspacePath,
1192
+ source: "filesystem",
1193
+ exists: true
1194
+ };
1195
+ });
1196
+ }
1197
+ function readOpenclawConfig(configPath) {
1198
+ try {
1199
+ const raw = fs5.readFileSync(configPath, "utf-8");
1200
+ return JSON.parse(raw);
1201
+ } catch {
1202
+ return null;
1203
+ }
1204
+ }
1205
+ function resolveGatewayLayout() {
1206
+ const stateDir = getOpenclawStateDir();
1207
+ const configPath = path5.join(stateDir, "openclaw.json");
1208
+ const config = readOpenclawConfig(configPath);
1209
+ const workspaces = [];
1210
+ if (config?.agents?.main?.workspace || config?.agents?.main?.workspacePath) {
1211
+ const rawPath = config.agents.main.workspace ?? config.agents.main.workspacePath;
1212
+ if (rawPath) {
1213
+ const resolvedPath = resolveMaybeRelativePath(stateDir, rawPath);
1214
+ workspaces.push({
1215
+ agentId: "main",
1216
+ path: resolvedPath,
1217
+ source: "config",
1218
+ exists: fs5.existsSync(resolvedPath)
1219
+ });
1220
+ }
1221
+ }
1222
+ for (const agent of config?.agents?.list ?? []) {
1223
+ const agentId = typeof agent.id === "string" && agent.id.trim() ? agent.id : null;
1224
+ const rawPath = agent.workspace ?? agent.workspacePath;
1225
+ if (!agentId || !rawPath) continue;
1226
+ const resolvedPath = resolveMaybeRelativePath(stateDir, rawPath);
1227
+ workspaces.push({
1228
+ agentId,
1229
+ path: resolvedPath,
1230
+ source: "config",
1231
+ exists: fs5.existsSync(resolvedPath)
1925
1232
  });
1926
1233
  }
1927
- scheduleReconnect() {
1928
- if (this.destroyed || !this.shouldReconnect) return;
1929
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
1930
- console.error("[relay-client] Max reconnect attempts reached");
1931
- return;
1234
+ const deduped = /* @__PURE__ */ new Map();
1235
+ for (const ws of [...workspaces, ...listWorkspaceFallbacks(stateDir)]) {
1236
+ if (!deduped.has(ws.agentId)) {
1237
+ deduped.set(ws.agentId, ws);
1932
1238
  }
1933
- const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), 6e4);
1934
- this.reconnectAttempts++;
1935
- console.log(`[relay-client] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
1936
- this.reconnectTimer = setTimeout(() => {
1937
- this.reconnectTimer = null;
1938
- this.connectToRelay();
1939
- }, delay);
1940
1239
  }
1941
- // ── Message Handling ──
1942
- handleRelayMessage(msg) {
1943
- switch (msg.type) {
1944
- case "relay.welcome":
1945
- this.handleWelcome(msg);
1946
- break;
1947
- case "relay.forward":
1948
- if (msg.userId && msg.inner) {
1949
- this.routeToUser(msg.userId, msg.inner);
1950
- }
1951
- break;
1952
- case "relay.pair.request":
1953
- if (msg.userId && msg.email) {
1954
- this.handlePairingRequest(msg.userId, msg.email);
1955
- }
1956
- break;
1957
- case "relay.e2e.exchange":
1958
- if (msg.userId && msg.publicKey) {
1959
- this.handleE2EExchange(msg.userId, msg.publicKey);
1960
- }
1961
- break;
1962
- case "relay.ping":
1963
- this.sendToRelay({ type: "relay.pong" });
1964
- break;
1965
- default:
1966
- console.log(`[relay-client] Unknown relay message type: ${msg.type}`);
1240
+ const resolvedWorkspaces = Array.from(deduped.values());
1241
+ const mainWorkspace = resolvedWorkspaces.find((ws) => ws.agentId === "main");
1242
+ const defaultFileBrowserRoot = mainWorkspace?.path ?? stateDir;
1243
+ return {
1244
+ stateDir,
1245
+ configPath,
1246
+ mediaDir: path5.join(stateDir, "media"),
1247
+ skillsDir: path5.join(stateDir, "skills"),
1248
+ extensionsDir: path5.join(stateDir, "extensions"),
1249
+ defaultFileBrowserRoot,
1250
+ workspaces: resolvedWorkspaces
1251
+ };
1252
+ }
1253
+
1254
+ // src/filesystem.ts
1255
+ var HOME_DIR = process.env.HOME ?? "/root";
1256
+ var OPENCLAW_DIR = getOpenclawStateDir();
1257
+ var SENSITIVE_BLOCKED_DIRS = [
1258
+ path6.join(OPENCLAW_DIR, "credentials"),
1259
+ path6.join(OPENCLAW_DIR, "devices"),
1260
+ path6.join(OPENCLAW_DIR, "identity")
1261
+ ];
1262
+ var SENSITIVE_BLOCKED_FILES = [
1263
+ path6.join(OPENCLAW_DIR, "squad-ceo-data", "relay", "squad-relay.json")
1264
+ ];
1265
+ function isSensitivePath(resolvedPath) {
1266
+ for (const blocked of SENSITIVE_BLOCKED_DIRS) {
1267
+ if (resolvedPath === blocked || resolvedPath.startsWith(blocked + path6.sep)) {
1268
+ return true;
1967
1269
  }
1968
1270
  }
1969
- /** Handle relay.welcome store room ID for reconnection */
1970
- handleWelcome(msg) {
1971
- if (msg.roomId) {
1972
- console.log(`[relay-client] Received room ID: ${msg.roomId.substring(0, 8)}...`);
1973
- this.config.roomId = msg.roomId;
1974
- this.pendingClaimToken = null;
1975
- const state = readRelayState();
1976
- state.roomId = msg.roomId;
1977
- writeRelayState(state);
1271
+ for (const blocked of SENSITIVE_BLOCKED_FILES) {
1272
+ if (resolvedPath === blocked) {
1273
+ return true;
1978
1274
  }
1979
1275
  }
1980
- /** Route a message from the relay to the appropriate user's local WS */
1981
- routeToUser(userId, innerMsg) {
1982
- let msg = innerMsg;
1983
- if (msg.type === "event" && typeof msg.event === "string" && msg.event.startsWith("relay.")) {
1984
- if (msg.event === "relay.user.connected") {
1985
- console.log(`[relay-client] User ${userId} connected via relay \u2014 creating local WS`);
1986
- this.createUserConnection(userId);
1276
+ if (path6.dirname(resolvedPath) === OPENCLAW_DIR && resolvedPath.endsWith(".bak")) {
1277
+ return true;
1278
+ }
1279
+ return false;
1280
+ }
1281
+ var OPENCLAW_JSON_FILENAME = "openclaw.json";
1282
+ function redactOpenclawJson(rawContent) {
1283
+ let config;
1284
+ try {
1285
+ config = JSON.parse(rawContent);
1286
+ } catch {
1287
+ return rawContent;
1288
+ }
1289
+ let redactedCount = 0;
1290
+ const channels = config.channels;
1291
+ if (channels && typeof channels === "object") {
1292
+ for (const channelKey of Object.keys(channels)) {
1293
+ const channel = channels[channelKey];
1294
+ if (channel && typeof channel === "object" && "botToken" in channel) {
1295
+ channel.botToken = "[REDACTED]";
1296
+ redactedCount++;
1987
1297
  }
1988
- return;
1989
1298
  }
1990
- if (typeof msg.type === "string" && msg.type.startsWith("relay.")) {
1991
- if (msg.type === "relay.e2e.exchange" && msg.publicKey) {
1992
- this.handleE2EExchange(userId, msg.publicKey);
1299
+ }
1300
+ const gateway = config.gateway;
1301
+ if (gateway && typeof gateway === "object") {
1302
+ if (gateway.auth && typeof gateway.auth === "object") {
1303
+ const auth = gateway.auth;
1304
+ for (const key of Object.keys(auth)) {
1305
+ auth[key] = "[REDACTED]";
1306
+ redactedCount++;
1993
1307
  }
1994
- return;
1995
- }
1996
- let conn = this.userConnections.get(userId);
1997
- if (!conn || conn.localWs.readyState >= NodeWebSocket.CLOSING) {
1998
- this.createUserConnection(userId);
1999
- conn = this.userConnections.get(userId);
2000
- if (!conn) return;
2001
1308
  }
2002
- if (msg._e2e && conn.e2e) {
2003
- try {
2004
- const plaintext = conn.e2e.decrypt({
2005
- ciphertext: msg.ciphertext,
2006
- iv: msg.iv,
2007
- tag: msg.tag
2008
- });
2009
- msg = JSON.parse(plaintext);
2010
- } catch (err2) {
2011
- console.error(`[relay-client] E2E decrypt error for ${userId}:`, err2);
2012
- return;
2013
- }
1309
+ if ("token" in gateway) {
1310
+ gateway.token = "[REDACTED]";
1311
+ redactedCount++;
2014
1312
  }
2015
- if (msg.type === "req" && msg.method === "connect") {
2016
- if (conn.connectHandshakeComplete) {
2017
- console.log(`[relay-client] New connect from ${userId} \u2014 creating fresh local WS for handshake`);
2018
- this.createUserConnection(userId);
2019
- conn = this.userConnections.get(userId);
2020
- if (!conn) return;
2021
- }
2022
- if (!conn.challengeNonce) {
2023
- console.log(`[relay-client] Connect request for ${userId} deferred \u2014 waiting for challenge nonce`);
2024
- conn.pendingConnect = msg;
2025
- return;
2026
- }
2027
- this.injectDeviceIdentity(conn, msg);
2028
- if (conn.localWs.readyState === NodeWebSocket.CONNECTING) {
2029
- conn.localWs.once("open", () => {
2030
- conn.localWs.send(JSON.stringify(msg));
2031
- });
2032
- } else {
2033
- conn.localWs.send(JSON.stringify(msg));
2034
- }
2035
- return;
1313
+ const remote = gateway.remote;
1314
+ if (remote && typeof remote === "object" && "token" in remote) {
1315
+ remote.token = "[REDACTED]";
1316
+ redactedCount++;
2036
1317
  }
2037
- if (!conn.connectHandshakeComplete) {
2038
- conn.pendingMessages.push(msg);
2039
- return;
1318
+ }
1319
+ if (redactedCount > 0) {
1320
+ console.log(`[security] Redacted ${redactedCount} sensitive field(s) from openclaw.json before returning to client`);
1321
+ }
1322
+ return JSON.stringify(config, null, 2);
1323
+ }
1324
+ function isOpenclawJson(resolvedPath) {
1325
+ return path6.basename(resolvedPath) === OPENCLAW_JSON_FILENAME && resolvedPath.startsWith(OPENCLAW_DIR);
1326
+ }
1327
+ function expandHome(p) {
1328
+ if (p.startsWith("~/") || p === "~") {
1329
+ return path6.join(HOME_DIR, p.slice(1));
1330
+ }
1331
+ return p;
1332
+ }
1333
+ function validatePath(p, allowedRoots) {
1334
+ const resolved = path6.resolve(expandHome(p));
1335
+ if (!allowedRoots || allowedRoots.length === 0) return resolved;
1336
+ const allowed = allowedRoots.some((root) => {
1337
+ const resolvedRoot = path6.resolve(expandHome(root));
1338
+ return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path6.sep);
1339
+ });
1340
+ if (!allowed) {
1341
+ throw new Error(`Path "${p}" is outside allowed roots`);
1342
+ }
1343
+ return resolved;
1344
+ }
1345
+ function validateAndBlockSensitive(p, allowedRoots) {
1346
+ const resolved = validatePath(p, allowedRoots);
1347
+ if (isSensitivePath(resolved)) {
1348
+ throw new Error(
1349
+ `Access denied: path "${p}" is inside a protected directory (credentials/devices/identity)`
1350
+ );
1351
+ }
1352
+ return resolved;
1353
+ }
1354
+ function validateWritePath(p, allowedRoots) {
1355
+ const resolved = validateAndBlockSensitive(p, allowedRoots);
1356
+ if (isOpenclawJson(resolved)) {
1357
+ throw new Error(
1358
+ `Write denied: "${p}" is a protected configuration file (openclaw.json)`
1359
+ );
1360
+ }
1361
+ return resolved;
1362
+ }
1363
+ function ok(data) {
1364
+ return {
1365
+ content: [{ type: "text", text: JSON.stringify(data) }]
1366
+ };
1367
+ }
1368
+ function err(message) {
1369
+ return {
1370
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
1371
+ isError: true
1372
+ };
1373
+ }
1374
+ function listDir(dirPath, opts) {
1375
+ const dirents = fs6.readdirSync(dirPath, { withFileTypes: true });
1376
+ const results = [];
1377
+ for (const dirent of dirents) {
1378
+ if (!opts.includeHidden && dirent.name.startsWith(".")) continue;
1379
+ const entryPath = path6.join(dirPath, dirent.name);
1380
+ let type = "other";
1381
+ if (dirent.isFile()) type = "file";
1382
+ else if (dirent.isDirectory()) type = "directory";
1383
+ else if (dirent.isSymbolicLink()) type = "symlink";
1384
+ const entry = { name: dirent.name, path: entryPath, type };
1385
+ try {
1386
+ const stat = fs6.statSync(entryPath);
1387
+ entry.size = stat.size;
1388
+ entry.modified = stat.mtime.toISOString();
1389
+ } catch {
2040
1390
  }
2041
- if (conn.localWs.readyState === NodeWebSocket.CONNECTING) {
2042
- conn.localWs.once("open", () => {
2043
- conn.localWs.send(JSON.stringify(msg));
2044
- });
2045
- return;
1391
+ if (type === "directory" && opts.recursive && opts.depth < opts.maxDepth) {
1392
+ try {
1393
+ entry.children = listDir(entryPath, { ...opts, depth: opts.depth + 1 });
1394
+ } catch {
1395
+ }
2046
1396
  }
2047
- conn.localWs.send(JSON.stringify(msg));
1397
+ results.push(entry);
2048
1398
  }
2049
- /**
2050
- * Inject auth token and device identity into a connect request.
2051
- *
2052
- * SECURITY: The token is added to the message IN MEMORY, then sent to the
2053
- * LOCAL gateway WebSocket (localhost:18789). It NEVER traverses the relay —
2054
- * the relay only sees the outer relay.forward envelope. A compromised relay
2055
- * server cannot intercept this token.
2056
- */
2057
- injectDeviceIdentity(conn, msg) {
2058
- const params = msg.params ?? {};
2059
- if (this.config.operatorToken) {
2060
- params.auth = { token: this.config.operatorToken };
1399
+ return results;
1400
+ }
1401
+ function filterSensitiveEntries(entries) {
1402
+ return entries.filter((entry) => !isSensitivePath(entry.path)).map((entry) => {
1403
+ if (entry.children) {
1404
+ return { ...entry, children: filterSensitiveEntries(entry.children) };
2061
1405
  }
2062
- const client = params.client ?? {};
2063
- const role = params.role ?? "operator";
2064
- const scopes = params.scopes ?? [];
2065
- params.device = signDeviceIdentity(
2066
- this.deviceKeys,
2067
- client.id ?? "cli",
2068
- client.mode ?? "ui",
2069
- role,
2070
- scopes,
2071
- this.config.operatorToken,
2072
- conn.challengeNonce
2073
- );
2074
- msg.params = params;
2075
- conn.connectHandshakeComplete = false;
2076
- console.log(`[relay-client] Injected device identity for ${conn.userId}: nonce=${conn.challengeNonce?.substring(0, 12)}...`);
2077
- }
2078
- /** Create a local WS connection to the gateway for a specific user */
2079
- createUserConnection(userId, carry) {
2080
- const existing = this.userConnections.get(userId);
2081
- if (existing) {
1406
+ return entry;
1407
+ });
1408
+ }
1409
+ function registerFilesystemTools(api) {
1410
+ const layout = resolveGatewayLayout();
1411
+ const DEFAULT_ALLOWED_ROOTS = Array.from(/* @__PURE__ */ new Set([
1412
+ OPENCLAW_DIR,
1413
+ ...layout.workspaces.map((ws) => ws.path)
1414
+ ]));
1415
+ const allowedRoots = api.pluginConfig?.["fs.allowedRoots"] ?? DEFAULT_ALLOWED_ROOTS;
1416
+ api.registerTool({
1417
+ name: "fs_read",
1418
+ label: "Read File",
1419
+ description: "Read a file from the server filesystem. Returns the file contents as text. Supports ~ for home directory expansion. Sensitive directories (credentials, devices, identity) are blocked. Config files are returned with auth tokens redacted.",
1420
+ parameters: {
1421
+ type: "object",
1422
+ properties: {
1423
+ path: {
1424
+ type: "string",
1425
+ description: "Absolute or ~-prefixed path to the file to read"
1426
+ },
1427
+ encoding: {
1428
+ type: "string",
1429
+ description: "File encoding (default: utf-8)",
1430
+ enum: ["utf-8", "base64", "ascii", "latin1"]
1431
+ }
1432
+ },
1433
+ required: ["path"]
1434
+ },
1435
+ async execute(_id, params) {
2082
1436
  try {
2083
- existing.localWs.close(1e3, "Replaced");
2084
- } catch {
1437
+ const filePath = validateAndBlockSensitive(params.path, allowedRoots);
1438
+ const encoding = params.encoding ?? "utf-8";
1439
+ let content = fs6.readFileSync(filePath, encoding);
1440
+ const stat = fs6.statSync(filePath);
1441
+ if (isOpenclawJson(filePath) && encoding === "utf-8") {
1442
+ content = redactOpenclawJson(content);
1443
+ }
1444
+ return ok({
1445
+ path: filePath,
1446
+ content,
1447
+ size: stat.size,
1448
+ modified: stat.mtime.toISOString()
1449
+ });
1450
+ } catch (e) {
1451
+ const msg = e instanceof Error ? e.message : String(e);
1452
+ return err(`fs_read failed: ${msg}`);
2085
1453
  }
2086
1454
  }
2087
- const attempt = this.localConnectAttempts.get(userId) ?? 0;
2088
- const host = this.config.localGatewayHosts[attempt % this.config.localGatewayHosts.length];
2089
- const localUrl = `ws://${host}:${this.config.localGatewayPort}`;
2090
- console.log(`[relay-client] Creating local WS for user ${userId} \u2192 ${localUrl}`);
2091
- const localWs = new NodeWebSocket(localUrl);
2092
- const conn = {
2093
- localWs,
2094
- userId,
2095
- e2e: carry?.e2e ?? null,
2096
- connectHandshakeComplete: false,
2097
- challengeNonce: null,
2098
- pendingConnect: carry?.pendingConnect ?? null,
2099
- pendingMessages: carry?.pendingMessages ?? []
2100
- };
2101
- this.userConnections.set(userId, conn);
2102
- localWs.on("open", () => {
2103
- console.log(`[relay-client] Local WS for user ${userId} connected`);
2104
- this.localConnectAttempts.delete(userId);
2105
- });
2106
- localWs.on("message", (data) => {
1455
+ });
1456
+ api.registerTool({
1457
+ name: "fs_write",
1458
+ label: "Write File",
1459
+ description: "Write content to a file on the server filesystem. Creates parent directories if they don't exist. Supports ~ for home directory expansion. Writes to protected directories (credentials, devices, identity) and config files (openclaw.json) are denied.",
1460
+ parameters: {
1461
+ type: "object",
1462
+ properties: {
1463
+ path: {
1464
+ type: "string",
1465
+ description: "Absolute or ~-prefixed path to the file to write"
1466
+ },
1467
+ content: {
1468
+ type: "string",
1469
+ description: "Content to write to the file"
1470
+ },
1471
+ encoding: {
1472
+ type: "string",
1473
+ description: "File encoding (default: utf-8)",
1474
+ enum: ["utf-8", "base64", "ascii", "latin1"]
1475
+ },
1476
+ mkdir: {
1477
+ type: "boolean",
1478
+ description: "Create parent directories if they don't exist (default: true)"
1479
+ }
1480
+ },
1481
+ required: ["path", "content"]
1482
+ },
1483
+ async execute(_id, params) {
2107
1484
  try {
2108
- const msg = JSON.parse(data.toString());
2109
- this.routeFromGateway(userId, msg);
2110
- } catch {
1485
+ const filePath = validateWritePath(params.path, allowedRoots);
1486
+ const content = params.content;
1487
+ const encoding = params.encoding ?? "utf-8";
1488
+ const mkdir = params.mkdir !== false;
1489
+ if (mkdir) {
1490
+ fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
1491
+ }
1492
+ fs6.writeFileSync(filePath, content, encoding);
1493
+ const stat = fs6.statSync(filePath);
1494
+ return ok({
1495
+ path: filePath,
1496
+ size: stat.size,
1497
+ written: true
1498
+ });
1499
+ } catch (e) {
1500
+ const msg = e instanceof Error ? e.message : String(e);
1501
+ return err(`fs_write failed: ${msg}`);
2111
1502
  }
2112
- });
2113
- localWs.on("close", (code, reason) => {
2114
- const reasonStr = reason.toString();
2115
- console.log(`[relay-client] Local WS for user ${userId} closed: ${code} ${reasonStr}`);
2116
- if (code === 1008) {
2117
- console.error(
2118
- `[relay-client] Gateway rejected device identity (code 1008). The gateway auto-pairs devices with a valid operator token, so this usually means the operator token is missing, expired, or incorrect.
2119
- Check: ~/.openclaw/openclaw.json \u2192 gateway.auth.token
2120
- Device ID: ${this.deviceKeys.deviceId}`
2121
- );
1503
+ }
1504
+ });
1505
+ api.registerTool({
1506
+ name: "fs_list",
1507
+ label: "List Directory",
1508
+ description: "List contents of a directory on the server filesystem. Returns file metadata including name, type, size, and modification time. Supports ~ for home directory expansion. Protected directories (credentials, devices, identity) are excluded from results.",
1509
+ parameters: {
1510
+ type: "object",
1511
+ properties: {
1512
+ path: {
1513
+ type: "string",
1514
+ description: "Absolute or ~-prefixed path to the directory to list"
1515
+ },
1516
+ recursive: {
1517
+ type: "boolean",
1518
+ description: "List recursively (default: false, max depth 3)"
1519
+ },
1520
+ includeHidden: {
1521
+ type: "boolean",
1522
+ description: "Include hidden files/directories starting with . (default: false)"
1523
+ }
1524
+ },
1525
+ required: ["path"]
1526
+ },
1527
+ async execute(_id, params) {
1528
+ try {
1529
+ const dirPath = validateAndBlockSensitive(params.path, allowedRoots);
1530
+ const recursive = params.recursive === true;
1531
+ const includeHidden = params.includeHidden === true;
1532
+ let entries = listDir(dirPath, { recursive, includeHidden, depth: 0, maxDepth: 3 });
1533
+ entries = filterSensitiveEntries(entries);
1534
+ return ok({
1535
+ path: dirPath,
1536
+ count: entries.length,
1537
+ entries
1538
+ });
1539
+ } catch (e) {
1540
+ const msg = e instanceof Error ? e.message : String(e);
1541
+ return err(`fs_list failed: ${msg}`);
2122
1542
  }
2123
- const current = this.userConnections.get(userId);
2124
- if (current && current.localWs === localWs) {
2125
- this.userConnections.delete(userId);
2126
- const nextAttempt = (this.localConnectAttempts.get(userId) ?? 0) + 1;
2127
- const shouldRetryLocalConnect = code === 1006 && !conn.connectHandshakeComplete && nextAttempt <= 8 && this.relayWs?.readyState === NodeWebSocket.OPEN;
2128
- if (shouldRetryLocalConnect) {
2129
- this.localConnectAttempts.set(userId, nextAttempt);
2130
- const delay = Math.min(300 * nextAttempt, 2e3);
2131
- console.log(
2132
- `[relay-client] Local WS unavailable for ${userId}, retrying in ${delay}ms (attempt ${nextAttempt}/8)`
2133
- );
2134
- const carry2 = {
2135
- pendingConnect: conn.pendingConnect,
2136
- pendingMessages: conn.pendingMessages,
2137
- e2e: conn.e2e
2138
- };
2139
- setTimeout(() => {
2140
- if (this.destroyed) return;
2141
- if (this.relayWs?.readyState !== NodeWebSocket.OPEN) return;
2142
- if (!this.userConnections.has(userId)) {
2143
- this.createUserConnection(userId, carry2);
2144
- }
2145
- }, delay);
2146
- return;
1543
+ }
1544
+ });
1545
+ api.registerTool({
1546
+ name: "fs_mkdir",
1547
+ label: "Create Directory",
1548
+ description: "Create a directory on the server filesystem. Creates parent directories as needed. Supports ~ for home directory expansion. Cannot create directories inside protected paths (credentials, devices, identity).",
1549
+ parameters: {
1550
+ type: "object",
1551
+ properties: {
1552
+ path: {
1553
+ type: "string",
1554
+ description: "Absolute or ~-prefixed path of the directory to create"
2147
1555
  }
2148
- this.localConnectAttempts.delete(userId);
2149
- this.sendToRelay({
2150
- type: "relay.forward",
2151
- userId,
2152
- inner: {
2153
- type: "event",
2154
- event: "relay.gateway.connection.closed",
2155
- payload: { code }
2156
- }
1556
+ },
1557
+ required: ["path"]
1558
+ },
1559
+ async execute(_id, params) {
1560
+ try {
1561
+ const targetPath = validateWritePath(params.path, allowedRoots);
1562
+ fs6.mkdirSync(targetPath, { recursive: true });
1563
+ return ok({
1564
+ path: targetPath,
1565
+ created: true
2157
1566
  });
1567
+ } catch (e) {
1568
+ const msg = e instanceof Error ? e.message : String(e);
1569
+ return err(`fs_mkdir failed: ${msg}`);
2158
1570
  }
2159
- });
2160
- localWs.on("error", (err2) => {
2161
- console.error(`[relay-client] Local WS error for user ${userId}:`, err2.message);
2162
- });
2163
- }
2164
- /** Route a message from the gateway back through the relay to the user */
2165
- routeFromGateway(userId, msg) {
2166
- const conn = this.userConnections.get(userId);
2167
- if (!conn) return;
2168
- const parsed = msg;
2169
- if (parsed.type === "event" && parsed.event === "connect.challenge") {
2170
- const payload = parsed.payload;
2171
- if (payload?.nonce) {
2172
- conn.challengeNonce = payload.nonce;
2173
- console.log(`[relay-client] Captured challenge nonce for ${userId}: ${conn.challengeNonce.substring(0, 12)}...`);
2174
- if (conn.pendingConnect) {
2175
- const pending = conn.pendingConnect;
2176
- conn.pendingConnect = null;
2177
- console.log(`[relay-client] Flushing deferred connect for ${userId}`);
2178
- this.injectDeviceIdentity(conn, pending);
2179
- if (conn.localWs.readyState === NodeWebSocket.OPEN) {
2180
- conn.localWs.send(JSON.stringify(pending));
2181
- }
1571
+ }
1572
+ });
1573
+ api.registerTool({
1574
+ name: "fs_rename",
1575
+ label: "Rename / Move",
1576
+ description: "Rename or move a file or directory on the server filesystem. Supports ~ for home directory expansion. Cannot move files into or out of protected directories.",
1577
+ parameters: {
1578
+ type: "object",
1579
+ properties: {
1580
+ oldPath: {
1581
+ type: "string",
1582
+ description: "Current absolute or ~-prefixed path"
1583
+ },
1584
+ newPath: {
1585
+ type: "string",
1586
+ description: "New absolute or ~-prefixed path"
2182
1587
  }
1588
+ },
1589
+ required: ["oldPath", "newPath"]
1590
+ },
1591
+ async execute(_id, params) {
1592
+ try {
1593
+ const resolvedOld = validateWritePath(params.oldPath, allowedRoots);
1594
+ const resolvedNew = validateWritePath(params.newPath, allowedRoots);
1595
+ fs6.renameSync(resolvedOld, resolvedNew);
1596
+ return ok({
1597
+ oldPath: resolvedOld,
1598
+ newPath: resolvedNew,
1599
+ renamed: true
1600
+ });
1601
+ } catch (e) {
1602
+ const msg = e instanceof Error ? e.message : String(e);
1603
+ return err(`fs_rename failed: ${msg}`);
2183
1604
  }
2184
1605
  }
2185
- if (parsed.type === "res" && parsed.id === "connect-1" && parsed.ok) {
2186
- conn.connectHandshakeComplete = true;
2187
- if (conn.pendingMessages.length > 0) {
2188
- console.log(`[relay-client] Flushing ${conn.pendingMessages.length} buffered messages for ${userId}`);
2189
- for (const queued of conn.pendingMessages) {
2190
- conn.localWs.send(JSON.stringify(queued));
1606
+ });
1607
+ api.registerTool({
1608
+ name: "fs_delete",
1609
+ label: "Delete File or Directory",
1610
+ description: "Delete a file or directory from the server filesystem. For directories, removes recursively. Supports ~ for home directory expansion. Cannot delete protected directories or config files.",
1611
+ parameters: {
1612
+ type: "object",
1613
+ properties: {
1614
+ path: {
1615
+ type: "string",
1616
+ description: "Absolute or ~-prefixed path to the file or directory to delete"
2191
1617
  }
2192
- conn.pendingMessages = [];
1618
+ },
1619
+ required: ["path"]
1620
+ },
1621
+ async execute(_id, params) {
1622
+ try {
1623
+ const targetPath = validateWritePath(params.path, allowedRoots);
1624
+ const stat = fs6.statSync(targetPath);
1625
+ const wasDirectory = stat.isDirectory();
1626
+ if (wasDirectory) {
1627
+ fs6.rmSync(targetPath, { recursive: true });
1628
+ } else {
1629
+ fs6.unlinkSync(targetPath);
1630
+ }
1631
+ return ok({
1632
+ path: targetPath,
1633
+ deleted: true,
1634
+ type: wasDirectory ? "directory" : "file"
1635
+ });
1636
+ } catch (e) {
1637
+ const msg = e instanceof Error ? e.message : String(e);
1638
+ return err(`fs_delete failed: ${msg}`);
2193
1639
  }
2194
1640
  }
2195
- let innerMsg = msg;
2196
- if (conn.e2e) {
1641
+ });
1642
+ }
1643
+
1644
+ // src/entities.ts
1645
+ var EntityType = T.Union([
1646
+ T.Literal("agent"),
1647
+ T.Literal("skill"),
1648
+ T.Literal("tool"),
1649
+ T.Literal("plugin"),
1650
+ T.Literal("session"),
1651
+ T.Literal("file"),
1652
+ T.Literal("directory"),
1653
+ T.Literal("url"),
1654
+ T.Literal("memory"),
1655
+ T.Literal("asset")
1656
+ ]);
1657
+ var registry = /* @__PURE__ */ new Map();
1658
+ function registrySet(entity) {
1659
+ registry.set(entity.id, entity);
1660
+ }
1661
+ function registryDelete(id) {
1662
+ registry.delete(id);
1663
+ }
1664
+ function registryList(type) {
1665
+ const all = Array.from(registry.values());
1666
+ if (!type) return all;
1667
+ return all.filter((e) => e.type === type);
1668
+ }
1669
+ var IDENTITY_NAME_RE = /\*\*Name:\*\*\s*\n?\s*(.+?)(?=\n|$)/;
1670
+ function parseIdentityName(content) {
1671
+ const match = content.match(IDENTITY_NAME_RE);
1672
+ const name = match?.[1]?.trim();
1673
+ if (!name) return null;
1674
+ if (/^_\(.+\)_$/.test(name)) return null;
1675
+ return name;
1676
+ }
1677
+ function scanAgents(configDir) {
1678
+ const now = Date.now();
1679
+ let entries;
1680
+ try {
1681
+ entries = fs7.readdirSync(configDir, { withFileTypes: true });
1682
+ } catch {
1683
+ return;
1684
+ }
1685
+ const workspaceDirs = entries.filter(
1686
+ (e) => e.isDirectory() && (e.name === "workspace" || e.name.startsWith("workspace-"))
1687
+ );
1688
+ for (const dir of workspaceDirs) {
1689
+ const agentId = dir.name === "workspace" ? "main" : dir.name.replace("workspace-", "");
1690
+ const workspacePath = path7.join(configDir, dir.name);
1691
+ let name = agentId;
1692
+ const metadata = { workspacePath };
1693
+ const identityPath = path7.join(workspacePath, "IDENTITY.md");
1694
+ try {
1695
+ const content = fs7.readFileSync(identityPath, "utf-8");
1696
+ const parsed = parseIdentityName(content);
1697
+ if (parsed) name = parsed;
1698
+ } catch {
1699
+ }
1700
+ if (name === agentId) {
1701
+ const agentJsonPath = path7.join(workspacePath, "agent.json");
2197
1702
  try {
2198
- const encrypted = conn.e2e.encrypt(JSON.stringify(msg));
2199
- innerMsg = { _e2e: true, ...encrypted };
2200
- } catch (err2) {
2201
- console.error(`[relay-client] E2E encrypt failed for ${userId} \u2014 dropping message:`, err2);
2202
- return;
1703
+ const raw = fs7.readFileSync(agentJsonPath, "utf-8");
1704
+ const config = JSON.parse(raw);
1705
+ if (config.displayName) name = config.displayName;
1706
+ if (config.model) metadata.model = config.model;
1707
+ if (config.tools) metadata.tools = config.tools;
1708
+ if (config.skills) metadata.skills = config.skills;
1709
+ } catch {
2203
1710
  }
2204
1711
  }
2205
- this.sendToRelay({
2206
- type: "relay.forward",
2207
- userId,
2208
- inner: innerMsg
1712
+ registrySet({
1713
+ id: agentId,
1714
+ type: "agent",
1715
+ name,
1716
+ title: name,
1717
+ description: null,
1718
+ metadata,
1719
+ source: "filesystem",
1720
+ source_key: workspacePath,
1721
+ created_at: now,
1722
+ updated_at: now
2209
1723
  });
2210
1724
  }
2211
- // ── Pairing ──
2212
- handlePairingRequest(userId, email) {
2213
- console.log(`[relay-client] Pairing request from ${email} (${userId})`);
2214
- this.sendToRelay({
2215
- type: "relay.pair.status",
2216
- userId,
2217
- status: "pending"
1725
+ }
1726
+ function scanSkills(configDir) {
1727
+ const now = Date.now();
1728
+ const globalSkillsDir = path7.join(configDir, "skills");
1729
+ scanSkillsDir(globalSkillsDir, "global", now);
1730
+ let entries;
1731
+ try {
1732
+ entries = fs7.readdirSync(configDir, { withFileTypes: true });
1733
+ } catch {
1734
+ return;
1735
+ }
1736
+ for (const dir of entries) {
1737
+ if (!dir.isDirectory() || !(dir.name === "workspace" || dir.name.startsWith("workspace-"))) {
1738
+ continue;
1739
+ }
1740
+ const agentId = dir.name === "workspace" ? "main" : dir.name.replace("workspace-", "");
1741
+ const agentSkillsDir = path7.join(configDir, dir.name, "skills");
1742
+ scanSkillsDir(agentSkillsDir, agentId, now);
1743
+ }
1744
+ }
1745
+ function scanSkillsDir(skillsDir, scope, now) {
1746
+ let entries;
1747
+ try {
1748
+ entries = fs7.readdirSync(skillsDir, { withFileTypes: true });
1749
+ } catch {
1750
+ return;
1751
+ }
1752
+ for (const entry of entries) {
1753
+ if (!entry.isDirectory()) continue;
1754
+ const skillKey = entry.name;
1755
+ const skillPath = path7.join(skillsDir, skillKey);
1756
+ let name = skillKey;
1757
+ for (const manifestName of ["manifest.json", "package.json"]) {
1758
+ try {
1759
+ const raw = fs7.readFileSync(
1760
+ path7.join(skillPath, manifestName),
1761
+ "utf-8"
1762
+ );
1763
+ const manifest = JSON.parse(raw);
1764
+ if (manifest.name) name = manifest.name;
1765
+ break;
1766
+ } catch {
1767
+ continue;
1768
+ }
1769
+ }
1770
+ const entityId = scope === "global" ? `skill:${skillKey}` : `skill:${scope}:${skillKey}`;
1771
+ registrySet({
1772
+ id: entityId,
1773
+ type: "skill",
1774
+ name,
1775
+ title: name,
1776
+ description: null,
1777
+ metadata: { skillKey, scope, skillPath },
1778
+ source: "filesystem",
1779
+ source_key: skillPath,
1780
+ created_at: now,
1781
+ updated_at: now
2218
1782
  });
2219
- console.log(
2220
- `[relay-client] Pairing request pending for ${email}. The gateway will auto-pair this device if the operator token is valid.`
2221
- );
2222
1783
  }
2223
- // ── E2E Key Exchange ──
2224
- async handleE2EExchange(userId, browserPublicKey) {
2225
- console.log(`[relay-client] E2E key exchange with user ${userId}`);
2226
- const conn = this.userConnections.get(userId);
2227
- if (!conn) return;
1784
+ }
1785
+ function scanPlugins2(configDir) {
1786
+ const now = Date.now();
1787
+ const extensionsDir = path7.join(configDir, "extensions");
1788
+ let entries;
1789
+ try {
1790
+ entries = fs7.readdirSync(extensionsDir, { withFileTypes: true });
1791
+ } catch {
1792
+ return;
1793
+ }
1794
+ for (const dir of entries) {
1795
+ if (!dir.isDirectory()) continue;
1796
+ const pluginDir = path7.join(extensionsDir, dir.name);
1797
+ const manifestPath = path7.join(pluginDir, "openclaw.plugin.json");
2228
1798
  try {
2229
- const e2e = new E2ECrypto();
2230
- const gatewayPublicKey = await e2e.generateKeyPair();
2231
- await e2e.deriveSharedSecret(browserPublicKey);
2232
- conn.e2e = e2e;
2233
- this.sendToRelay({
2234
- type: "relay.forward",
2235
- userId,
2236
- inner: {
2237
- type: "relay.e2e.exchange",
2238
- publicKey: gatewayPublicKey
2239
- }
1799
+ const raw = fs7.readFileSync(manifestPath, "utf-8");
1800
+ const manifest = JSON.parse(raw);
1801
+ const pluginId = manifest.id || dir.name;
1802
+ const name = manifest.name || pluginId;
1803
+ registrySet({
1804
+ id: `plugin:${pluginId}`,
1805
+ type: "plugin",
1806
+ name,
1807
+ title: name,
1808
+ description: manifest.description || null,
1809
+ metadata: { pluginId, pluginDir },
1810
+ source: "filesystem",
1811
+ source_key: manifestPath,
1812
+ created_at: now,
1813
+ updated_at: now
2240
1814
  });
2241
- console.log(`[relay-client] E2E established for user ${userId}`);
2242
- } catch (err2) {
2243
- console.error(`[relay-client] E2E exchange failed for ${userId}:`, err2);
1815
+ } catch {
2244
1816
  }
2245
1817
  }
2246
- // ── Send to Relay ──
2247
- sendToRelay(msg) {
2248
- if (!this.relayWs || this.relayWs.readyState !== NodeWebSocket.OPEN) return;
2249
- try {
2250
- this.relayWs.send(JSON.stringify(msg));
2251
- } catch {
1818
+ }
1819
+ function scanTools(configDir) {
1820
+ const now = Date.now();
1821
+ try {
1822
+ const raw = fs7.readFileSync(
1823
+ path7.join(configDir, "openclaw.json"),
1824
+ "utf-8"
1825
+ );
1826
+ const config = JSON.parse(raw);
1827
+ const allowedTools = config?.tools?.allow ?? [];
1828
+ for (const toolName of allowedTools) {
1829
+ registrySet({
1830
+ id: `tool:${toolName}`,
1831
+ type: "tool",
1832
+ name: toolName,
1833
+ title: toolName,
1834
+ description: null,
1835
+ metadata: { tool_name: toolName },
1836
+ source: "filesystem",
1837
+ source_key: "openclaw.json:tools.allow",
1838
+ created_at: now,
1839
+ updated_at: now
1840
+ });
2252
1841
  }
1842
+ } catch {
2253
1843
  }
2254
- /** Broadcast an event to all connected users, E2E encrypted per-user */
2255
- broadcastToUsers(event, payload) {
2256
- const msg = { type: "event", event, payload };
2257
- for (const [userId, conn] of this.userConnections) {
2258
- if (!conn.connectHandshakeComplete) continue;
2259
- let innerMsg = msg;
2260
- if (conn.e2e) {
2261
- try {
2262
- const encrypted = conn.e2e.encrypt(JSON.stringify(msg));
2263
- innerMsg = { _e2e: true, ...encrypted };
2264
- } catch (err2) {
2265
- console.error(`[relay-client] E2E encrypt failed for broadcast to ${userId} \u2014 skipping:`, err2);
2266
- continue;
2267
- }
1844
+ }
1845
+ var MIME_MAP = {
1846
+ ".png": "image/png",
1847
+ ".jpg": "image/jpeg",
1848
+ ".jpeg": "image/jpeg",
1849
+ ".gif": "image/gif",
1850
+ ".webp": "image/webp",
1851
+ ".svg": "image/svg+xml",
1852
+ ".bmp": "image/bmp",
1853
+ ".ico": "image/x-icon",
1854
+ ".mp4": "video/mp4",
1855
+ ".webm": "video/webm",
1856
+ ".mov": "video/quicktime",
1857
+ ".avi": "video/x-msvideo",
1858
+ ".mkv": "video/x-matroska",
1859
+ ".mp3": "audio/mpeg",
1860
+ ".wav": "audio/wav",
1861
+ ".ogg": "audio/ogg",
1862
+ ".flac": "audio/flac",
1863
+ ".aac": "audio/aac",
1864
+ ".pdf": "application/pdf",
1865
+ ".json": "application/json",
1866
+ ".txt": "text/plain",
1867
+ ".md": "text/markdown",
1868
+ ".csv": "text/csv",
1869
+ ".zip": "application/zip",
1870
+ ".tar": "application/x-tar",
1871
+ ".gz": "application/gzip"
1872
+ };
1873
+ function getMimeType(filename) {
1874
+ const ext = path7.extname(filename).toLowerCase();
1875
+ return MIME_MAP[ext] ?? "application/octet-stream";
1876
+ }
1877
+ function scanMedia(configDir) {
1878
+ const now = Date.now();
1879
+ const mediaDir = path7.join(configDir, "media");
1880
+ scanMediaDir(mediaDir, now);
1881
+ }
1882
+ function scanMediaDir(dirPath, now) {
1883
+ let entries;
1884
+ try {
1885
+ entries = fs7.readdirSync(dirPath, { withFileTypes: true });
1886
+ } catch {
1887
+ return;
1888
+ }
1889
+ for (const entry of entries) {
1890
+ if (entry.name.startsWith(".")) continue;
1891
+ const entryPath = path7.join(dirPath, entry.name);
1892
+ if (isSensitivePath(entryPath)) continue;
1893
+ if (entry.isDirectory()) {
1894
+ registrySet({
1895
+ id: entryPath,
1896
+ type: "directory",
1897
+ name: entry.name,
1898
+ title: entry.name,
1899
+ description: null,
1900
+ metadata: { path: entryPath },
1901
+ source: "filesystem",
1902
+ source_key: entryPath,
1903
+ created_at: now,
1904
+ updated_at: now
1905
+ });
1906
+ scanMediaDir(entryPath, now);
1907
+ } else if (entry.isFile()) {
1908
+ const mimeType = getMimeType(entry.name);
1909
+ let size;
1910
+ let mtime = now;
1911
+ try {
1912
+ const stat = fs7.statSync(entryPath);
1913
+ size = stat.size;
1914
+ mtime = stat.mtimeMs;
1915
+ } catch {
2268
1916
  }
2269
- this.sendToRelay({
2270
- type: "relay.forward",
2271
- userId,
2272
- inner: innerMsg
1917
+ registrySet({
1918
+ id: entryPath,
1919
+ type: "asset",
1920
+ name: entry.name,
1921
+ title: entry.name,
1922
+ description: null,
1923
+ metadata: { path: entryPath, size, mime_type: mimeType, original_name: entry.name },
1924
+ source: "filesystem",
1925
+ source_key: entryPath,
1926
+ created_at: mtime,
1927
+ updated_at: mtime
2273
1928
  });
2274
1929
  }
2275
1930
  }
2276
- };
2277
- var relayClient = null;
2278
- function startRelayClient(api, relayUrl) {
2279
- relayClient = new RelayClient({
2280
- relayUrl
1931
+ }
1932
+ function fullScan(configDir) {
1933
+ registry.clear();
1934
+ scanAgents(configDir);
1935
+ scanSkills(configDir);
1936
+ scanPlugins2(configDir);
1937
+ scanTools(configDir);
1938
+ scanMedia(configDir);
1939
+ }
1940
+ function registerEntityTools(api, onFsChange) {
1941
+ const configDir = getOpenclawStateDir();
1942
+ api.registerTool({
1943
+ name: "entity_list",
1944
+ description: "List all entities in the registry, optionally filtered by type. Returns lightweight entity data for @mention autocomplete.",
1945
+ parameters: T.Object({
1946
+ type: T.Optional(EntityType),
1947
+ limit: T.Optional(
1948
+ T.Number({ description: "Max results (default 500)" })
1949
+ )
1950
+ }),
1951
+ async execute(_id, params, _ctx) {
1952
+ const results = registryList(params.type);
1953
+ const limit = params.limit ?? 500;
1954
+ return {
1955
+ content: [
1956
+ { type: "text", text: JSON.stringify(results.slice(0, limit)) }
1957
+ ]
1958
+ };
1959
+ }
2281
1960
  });
2282
- relayClient.start();
2283
- api.registerGatewayMethod(
2284
- "squad.relay.status",
2285
- async ({ respond }) => {
2286
- respond(true, {
2287
- connected: relayClient !== null,
2288
- relayUrl
2289
- });
1961
+ api.registerTool({
1962
+ name: "entity_search",
1963
+ description: "Search entities by name/title substring match for @mention autocomplete.",
1964
+ parameters: T.Object({
1965
+ query: T.String({ description: "Search query text" }),
1966
+ type: T.Optional(
1967
+ T.String({ description: "Filter results by entity type" })
1968
+ ),
1969
+ limit: T.Optional(
1970
+ T.Number({ description: "Max results (default 20)" })
1971
+ )
1972
+ }),
1973
+ async execute(_id, params, _ctx) {
1974
+ const q = (params.query ?? "").toLowerCase();
1975
+ const limit = params.limit ?? 20;
1976
+ let results = Array.from(registry.values());
1977
+ if (params.type) {
1978
+ results = results.filter((e) => e.type === params.type);
1979
+ }
1980
+ if (q) {
1981
+ results = results.filter(
1982
+ (e) => e.name.toLowerCase().includes(q) || (e.title ?? "").toLowerCase().includes(q)
1983
+ );
1984
+ }
1985
+ return {
1986
+ content: [
1987
+ { type: "text", text: JSON.stringify(results.slice(0, limit)) }
1988
+ ]
1989
+ };
2290
1990
  }
2291
- );
1991
+ });
1992
+ api.registerTool({
1993
+ name: "entity_sync",
1994
+ description: "Re-scan the filesystem to refresh the entity registry. Call after configuration changes for immediate updates.",
1995
+ parameters: T.Object({}),
1996
+ async execute(_id, _params, _ctx) {
1997
+ const before = registry.size;
1998
+ fullScan(configDir);
1999
+ return {
2000
+ content: [
2001
+ {
2002
+ type: "text",
2003
+ text: JSON.stringify({ synced: registry.size, previous: before })
2004
+ }
2005
+ ]
2006
+ };
2007
+ }
2008
+ });
2009
+ try {
2010
+ fullScan(configDir);
2011
+ } catch (err2) {
2012
+ console.error("[squad-openclaw] Initial scan failed:", err2);
2013
+ }
2014
+ let stopWatcher = null;
2015
+ try {
2016
+ stopWatcher = startWatcher(configDir, onFsChange);
2017
+ } catch (err2) {
2018
+ console.error("[squad-openclaw] Watcher failed to start:", err2);
2019
+ }
2292
2020
  const cleanup = () => {
2293
- if (relayClient) {
2294
- relayClient.destroy();
2295
- relayClient = null;
2296
- }
2021
+ stopWatcher?.();
2297
2022
  };
2298
2023
  process.on("SIGTERM", cleanup);
2299
2024
  process.on("SIGINT", cleanup);
2300
2025
  }
2301
- function broadcastToUsers(event, payload) {
2302
- relayClient?.broadcastToUsers(event, payload);
2026
+
2027
+ // src/sql.ts
2028
+ import { execFile } from "child_process";
2029
+ import path8 from "path";
2030
+ import fs8 from "fs";
2031
+ import { Type as T2 } from "@sinclair/typebox";
2032
+ var HOME_DIR2 = process.env.HOME ?? "/root";
2033
+ var ALLOWED_DATA_DIR = path8.join(getOpenclawStateDir(), "squad-ceo-data");
2034
+ function validateDbPath(dbPath) {
2035
+ let expanded = dbPath;
2036
+ if (expanded.startsWith("~/") || expanded === "~") {
2037
+ expanded = path8.join(HOME_DIR2, expanded.slice(1));
2038
+ }
2039
+ const resolved = path8.resolve(expanded);
2040
+ if (resolved !== ALLOWED_DATA_DIR && !resolved.startsWith(ALLOWED_DATA_DIR + path8.sep)) {
2041
+ throw new Error(
2042
+ `Access denied: database path must be within ~/.openclaw/squad-ceo-data/`
2043
+ );
2044
+ }
2045
+ try {
2046
+ const stat = fs8.statSync(resolved);
2047
+ if (!stat.isFile()) {
2048
+ throw new Error(`Not a file: ${dbPath}`);
2049
+ }
2050
+ } catch (e) {
2051
+ if (e.code === "ENOENT") {
2052
+ throw new Error(`Database file not found: ${dbPath}`);
2053
+ }
2054
+ throw e;
2055
+ }
2056
+ return resolved;
2057
+ }
2058
+ function runSqlite3(dbPath, args) {
2059
+ return new Promise((resolve, reject) => {
2060
+ execFile(
2061
+ "sqlite3",
2062
+ [dbPath, ...args],
2063
+ { timeout: 3e4, maxBuffer: 10 * 1024 * 1024 },
2064
+ (error, stdout, stderr) => {
2065
+ if (error) {
2066
+ reject(new Error(stderr || error.message));
2067
+ return;
2068
+ }
2069
+ resolve(stdout);
2070
+ }
2071
+ );
2072
+ });
2073
+ }
2074
+ function registerSqlTools(api) {
2075
+ api.registerTool({
2076
+ name: "sql_query",
2077
+ label: "SQL Query",
2078
+ description: "Execute a sqlite3 query on a database file within ~/.openclaw/squad-ceo-data/. Only sqlite3 is allowed \u2014 no arbitrary shell commands. Use jsonOutput: true for structured JSON results.",
2079
+ parameters: T2.Object({
2080
+ dbPath: T2.String({
2081
+ description: "Path to the SQLite database file (must be within ~/.openclaw/squad-ceo-data/)"
2082
+ }),
2083
+ query: T2.String({ description: "SQL query to execute" }),
2084
+ jsonOutput: T2.Optional(
2085
+ T2.Boolean({
2086
+ description: "Return results as JSON (sqlite3 -json flag)"
2087
+ })
2088
+ )
2089
+ }),
2090
+ async execute(_id, params) {
2091
+ try {
2092
+ const resolvedDb = validateDbPath(params.dbPath);
2093
+ const args = [];
2094
+ if (params.jsonOutput) args.push("-json");
2095
+ args.push(params.query);
2096
+ const output = await runSqlite3(resolvedDb, args);
2097
+ return {
2098
+ content: [{ type: "text", text: output }]
2099
+ };
2100
+ } catch (e) {
2101
+ const msg = e instanceof Error ? e.message : String(e);
2102
+ return {
2103
+ content: [{ type: "text", text: JSON.stringify({ error: msg }) }],
2104
+ isError: true
2105
+ };
2106
+ }
2107
+ }
2108
+ });
2303
2109
  }
2304
2110
 
2305
- // src/layout.ts
2111
+ // src/version.ts
2112
+ import { execSync as execSync2 } from "child_process";
2306
2113
  import fs9 from "fs";
2307
2114
  import path9 from "path";
2308
- function resolveMaybeRelativePath(stateDir, p) {
2309
- if (path9.isAbsolute(p)) return path9.resolve(p);
2310
- return path9.resolve(stateDir, p);
2115
+ import { fileURLToPath } from "url";
2116
+ var PACKAGE_NAME = "squad-openclaw";
2117
+ var CONFIG_PATH = path9.join(getOpenclawStateDir(), "openclaw.json");
2118
+ var updateInProgress = false;
2119
+ var VERIFY_TIMEOUT_MS = 2e4;
2120
+ var VERIFY_INTERVAL_MS = 500;
2121
+ var RESTART_BUFFER_MS = 5e3;
2122
+ function readInstalledVersionFromConfig() {
2123
+ try {
2124
+ const raw = fs9.readFileSync(CONFIG_PATH, "utf-8");
2125
+ const cfg = JSON.parse(raw);
2126
+ const v = cfg?.plugins?.installs?.[PACKAGE_NAME]?.version;
2127
+ return typeof v === "string" ? v : null;
2128
+ } catch {
2129
+ return null;
2130
+ }
2311
2131
  }
2312
- function listWorkspaceFallbacks(stateDir) {
2313
- let entries;
2132
+ function reconcileInstallMetadata(verification) {
2133
+ if (!verification.installPath || !verification.packageVersion) return;
2134
+ try {
2135
+ const raw = fs9.readFileSync(CONFIG_PATH, "utf-8");
2136
+ const config = JSON.parse(raw);
2137
+ if (!config.plugins || typeof config.plugins !== "object") config.plugins = {};
2138
+ if (!config.plugins.installs || typeof config.plugins.installs !== "object") {
2139
+ config.plugins.installs = {};
2140
+ }
2141
+ if (!config.plugins.entries || typeof config.plugins.entries !== "object") {
2142
+ config.plugins.entries = {};
2143
+ }
2144
+ const current = config.plugins.installs[PACKAGE_NAME] ?? {};
2145
+ config.plugins.installs[PACKAGE_NAME] = {
2146
+ ...current,
2147
+ source: "npm",
2148
+ spec: PACKAGE_NAME,
2149
+ installPath: verification.installPath,
2150
+ version: verification.packageVersion,
2151
+ installedAt: (/* @__PURE__ */ new Date()).toISOString()
2152
+ };
2153
+ const entry = config.plugins.entries[PACKAGE_NAME] ?? {};
2154
+ config.plugins.entries[PACKAGE_NAME] = {
2155
+ ...entry,
2156
+ enabled: true
2157
+ };
2158
+ fs9.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
2159
+ } catch {
2160
+ }
2161
+ }
2162
+ function getCurrentVersion() {
2163
+ const thisFile = fileURLToPath(import.meta.url);
2164
+ const pkgPath = path9.resolve(path9.dirname(thisFile), "..", "package.json");
2314
2165
  try {
2315
- entries = fs9.readdirSync(stateDir, { withFileTypes: true });
2166
+ const pkg = JSON.parse(fs9.readFileSync(pkgPath, "utf-8"));
2167
+ return pkg.version ?? "0.0.0";
2316
2168
  } catch {
2317
- return [];
2169
+ return "0.0.0";
2170
+ }
2171
+ }
2172
+ async function fetchLatestVersion() {
2173
+ const controller = new AbortController();
2174
+ const timeout = setTimeout(() => controller.abort(), 1e4);
2175
+ try {
2176
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}`, {
2177
+ signal: controller.signal
2178
+ });
2179
+ if (!res.ok) throw new Error(`npm registry returned ${res.status}`);
2180
+ const data = await res.json();
2181
+ return data["dist-tags"]?.latest ?? "0.0.0";
2182
+ } finally {
2183
+ clearTimeout(timeout);
2184
+ }
2185
+ }
2186
+ function runDoctorFixSilently() {
2187
+ try {
2188
+ execSync2("openclaw doctor --fix 2>/dev/null || true", {
2189
+ timeout: 3e4,
2190
+ encoding: "utf-8"
2191
+ });
2192
+ } catch {
2193
+ }
2194
+ }
2195
+ function sleep(ms) {
2196
+ return new Promise((resolve) => setTimeout(resolve, ms));
2197
+ }
2198
+ function compareVersions(a, b) {
2199
+ const pa = a.split(".").map((x) => Number(x) || 0);
2200
+ const pb = b.split(".").map((x) => Number(x) || 0);
2201
+ const len = Math.max(pa.length, pb.length);
2202
+ for (let i = 0; i < len; i++) {
2203
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
2204
+ if (d !== 0) return d;
2205
+ }
2206
+ return 0;
2207
+ }
2208
+ function verifyInstalledPluginState() {
2209
+ let configRaw;
2210
+ try {
2211
+ configRaw = fs9.readFileSync(CONFIG_PATH, "utf-8");
2212
+ } catch (err2) {
2213
+ const msg = err2 instanceof Error ? err2.message : String(err2);
2214
+ return {
2215
+ ok: false,
2216
+ reason: `Could not read openclaw.json: ${msg}`,
2217
+ installPath: null,
2218
+ configVersion: null,
2219
+ packageVersion: null,
2220
+ requiredFilesMissing: []
2221
+ };
2222
+ }
2223
+ let config;
2224
+ try {
2225
+ config = JSON.parse(configRaw);
2226
+ } catch (err2) {
2227
+ const msg = err2 instanceof Error ? err2.message : String(err2);
2228
+ return {
2229
+ ok: false,
2230
+ reason: `Could not parse openclaw.json: ${msg}`,
2231
+ installPath: null,
2232
+ configVersion: null,
2233
+ packageVersion: null,
2234
+ requiredFilesMissing: []
2235
+ };
2236
+ }
2237
+ const installMeta = config?.plugins?.installs?.[PACKAGE_NAME];
2238
+ const installPath = typeof installMeta?.installPath === "string" ? installMeta.installPath : null;
2239
+ const configVersion = typeof installMeta?.version === "string" ? installMeta.version : null;
2240
+ if (!installPath) {
2241
+ return {
2242
+ ok: false,
2243
+ reason: "Missing plugins.installs entry or installPath for squad-openclaw",
2244
+ installPath: null,
2245
+ configVersion,
2246
+ packageVersion: null,
2247
+ requiredFilesMissing: []
2248
+ };
2249
+ }
2250
+ const requiredFiles = [
2251
+ path9.join(installPath, "package.json"),
2252
+ path9.join(installPath, "openclaw.plugin.json"),
2253
+ path9.join(installPath, "dist", "index.js")
2254
+ ];
2255
+ const requiredFilesMissing = requiredFiles.filter((p) => !fs9.existsSync(p));
2256
+ if (requiredFilesMissing.length > 0) {
2257
+ return {
2258
+ ok: false,
2259
+ reason: "Missing required installed plugin files",
2260
+ installPath,
2261
+ configVersion,
2262
+ packageVersion: null,
2263
+ requiredFilesMissing
2264
+ };
2265
+ }
2266
+ let installedPackage;
2267
+ try {
2268
+ installedPackage = JSON.parse(
2269
+ fs9.readFileSync(path9.join(installPath, "package.json"), "utf-8")
2270
+ );
2271
+ } catch (err2) {
2272
+ const msg = err2 instanceof Error ? err2.message : String(err2);
2273
+ return {
2274
+ ok: false,
2275
+ reason: `Could not parse installed package.json: ${msg}`,
2276
+ installPath,
2277
+ configVersion,
2278
+ packageVersion: null,
2279
+ requiredFilesMissing: []
2280
+ };
2281
+ }
2282
+ try {
2283
+ JSON.parse(
2284
+ fs9.readFileSync(path9.join(installPath, "openclaw.plugin.json"), "utf-8")
2285
+ );
2286
+ } catch (err2) {
2287
+ const msg = err2 instanceof Error ? err2.message : String(err2);
2288
+ return {
2289
+ ok: false,
2290
+ reason: `Could not parse installed openclaw.plugin.json: ${msg}`,
2291
+ installPath,
2292
+ configVersion,
2293
+ packageVersion: null,
2294
+ requiredFilesMissing: []
2295
+ };
2318
2296
  }
2319
- return entries.filter((entry) => entry.isDirectory() && (entry.name === "workspace" || entry.name.startsWith("workspace-"))).map((entry) => {
2320
- const agentId = entry.name === "workspace" ? "main" : entry.name.replace("workspace-", "");
2321
- const workspacePath = path9.join(stateDir, entry.name);
2297
+ const packageVersion = typeof installedPackage?.version === "string" ? installedPackage.version : null;
2298
+ if (!packageVersion) {
2322
2299
  return {
2323
- agentId,
2324
- path: workspacePath,
2325
- source: "filesystem",
2326
- exists: true
2300
+ ok: false,
2301
+ reason: "Installed package.json missing version",
2302
+ installPath,
2303
+ configVersion,
2304
+ packageVersion,
2305
+ requiredFilesMissing: []
2327
2306
  };
2328
- });
2307
+ }
2308
+ return {
2309
+ ok: true,
2310
+ installPath,
2311
+ configVersion,
2312
+ packageVersion,
2313
+ requiredFilesMissing: []
2314
+ };
2329
2315
  }
2330
- function readOpenclawConfig(configPath) {
2331
- try {
2332
- const raw = fs9.readFileSync(configPath, "utf-8");
2333
- return JSON.parse(raw);
2334
- } catch {
2335
- return null;
2316
+ async function waitForVerifiedInstall() {
2317
+ const deadline = Date.now() + VERIFY_TIMEOUT_MS;
2318
+ let last = verifyInstalledPluginState();
2319
+ while (!last.ok && Date.now() < deadline) {
2320
+ await sleep(VERIFY_INTERVAL_MS);
2321
+ last = verifyInstalledPluginState();
2336
2322
  }
2323
+ return last;
2337
2324
  }
2338
- function resolveGatewayLayout() {
2339
- const stateDir = getOpenclawStateDir();
2340
- const configPath = path9.join(stateDir, "openclaw.json");
2341
- const config = readOpenclawConfig(configPath);
2342
- const workspaces = [];
2343
- if (config?.agents?.main?.workspace || config?.agents?.main?.workspacePath) {
2344
- const rawPath = config.agents.main.workspace ?? config.agents.main.workspacePath;
2345
- if (rawPath) {
2346
- const resolvedPath = resolveMaybeRelativePath(stateDir, rawPath);
2347
- workspaces.push({
2348
- agentId: "main",
2349
- path: resolvedPath,
2350
- source: "config",
2351
- exists: fs9.existsSync(resolvedPath)
2352
- });
2325
+ function registerVersionMethods(api) {
2326
+ api.registerGatewayMethod(
2327
+ "squad.version.check",
2328
+ async ({ respond }) => {
2329
+ try {
2330
+ const current = getCurrentVersion();
2331
+ let latest;
2332
+ try {
2333
+ latest = await fetchLatestVersion();
2334
+ } catch {
2335
+ respond(true, {
2336
+ current,
2337
+ latest: null,
2338
+ updateAvailable: false,
2339
+ registryError: "Could not reach npm registry"
2340
+ });
2341
+ return;
2342
+ }
2343
+ respond(true, {
2344
+ current,
2345
+ latest,
2346
+ updateAvailable: latest !== current && latest !== "0.0.0"
2347
+ });
2348
+ } catch (e) {
2349
+ const msg = e instanceof Error ? e.message : String(e);
2350
+ respond(false, { error: msg });
2351
+ }
2353
2352
  }
2354
- }
2355
- for (const agent of config?.agents?.list ?? []) {
2356
- const agentId = typeof agent.id === "string" && agent.id.trim() ? agent.id : null;
2357
- const rawPath = agent.workspace ?? agent.workspacePath;
2358
- if (!agentId || !rawPath) continue;
2359
- const resolvedPath = resolveMaybeRelativePath(stateDir, rawPath);
2360
- workspaces.push({
2361
- agentId,
2362
- path: resolvedPath,
2363
- source: "config",
2364
- exists: fs9.existsSync(resolvedPath)
2365
- });
2366
- }
2367
- const deduped = /* @__PURE__ */ new Map();
2368
- for (const ws of [...workspaces, ...listWorkspaceFallbacks(stateDir)]) {
2369
- if (!deduped.has(ws.agentId)) {
2370
- deduped.set(ws.agentId, ws);
2353
+ );
2354
+ api.registerGatewayMethod(
2355
+ "squad.version.update",
2356
+ async ({ respond }) => {
2357
+ if (updateInProgress) {
2358
+ respond(false, { error: "Update already in progress" });
2359
+ return;
2360
+ }
2361
+ updateInProgress = true;
2362
+ try {
2363
+ const before = getCurrentVersion();
2364
+ const beforeInstalledVersion = readInstalledVersionFromConfig();
2365
+ let latestVersion = null;
2366
+ try {
2367
+ latestVersion = await fetchLatestVersion();
2368
+ } catch {
2369
+ latestVersion = null;
2370
+ }
2371
+ let updateOutput = "";
2372
+ let configBackup = null;
2373
+ try {
2374
+ configBackup = fs9.readFileSync(CONFIG_PATH, "utf-8");
2375
+ } catch {
2376
+ }
2377
+ runDoctorFixSilently();
2378
+ try {
2379
+ updateOutput = execSync2(
2380
+ `openclaw plugins update ${PACKAGE_NAME} 2>&1`,
2381
+ { timeout: 12e4, encoding: "utf-8" }
2382
+ );
2383
+ } catch (firstErr) {
2384
+ runDoctorFixSilently();
2385
+ try {
2386
+ updateOutput = execSync2(
2387
+ `openclaw plugins update ${PACKAGE_NAME} 2>&1`,
2388
+ { timeout: 12e4, encoding: "utf-8" }
2389
+ );
2390
+ } catch (installErr) {
2391
+ if (configBackup) {
2392
+ try {
2393
+ fs9.writeFileSync(CONFIG_PATH, configBackup, "utf-8");
2394
+ } catch {
2395
+ }
2396
+ }
2397
+ const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr);
2398
+ const retryMsg = installErr instanceof Error ? installErr.message : String(installErr);
2399
+ respond(false, {
2400
+ error: `Update failed after doctor fix retry: ${retryMsg}`,
2401
+ output: updateOutput,
2402
+ firstError: firstMsg
2403
+ });
2404
+ return;
2405
+ }
2406
+ }
2407
+ const verification = await waitForVerifiedInstall();
2408
+ if (!verification.ok) {
2409
+ if (configBackup) {
2410
+ try {
2411
+ fs9.writeFileSync(CONFIG_PATH, configBackup, "utf-8");
2412
+ } catch {
2413
+ }
2414
+ }
2415
+ respond(false, {
2416
+ error: `Update verification failed: ${verification.reason ?? "unknown error"}`,
2417
+ output: updateOutput.slice(0, 500),
2418
+ verification
2419
+ });
2420
+ return;
2421
+ }
2422
+ reconcileInstallMetadata(verification);
2423
+ const verificationAfterReconcile = verifyInstalledPluginState();
2424
+ if (beforeInstalledVersion && verificationAfterReconcile.packageVersion && beforeInstalledVersion === verificationAfterReconcile.packageVersion) {
2425
+ const alreadyLatest = !!latestVersion && compareVersions(verificationAfterReconcile.packageVersion, latestVersion) >= 0;
2426
+ respond(false, {
2427
+ error: alreadyLatest ? `Already at latest version (${verificationAfterReconcile.packageVersion}).` : `Update command completed but installed version did not change (${verificationAfterReconcile.packageVersion}).`,
2428
+ output: updateOutput.slice(0, 500),
2429
+ verification: verificationAfterReconcile,
2430
+ latestVersion
2431
+ });
2432
+ return;
2433
+ }
2434
+ const after = getCurrentVersion();
2435
+ respond(true, {
2436
+ previousVersion: before,
2437
+ currentVersion: after,
2438
+ updated: true,
2439
+ restartRequired: true,
2440
+ restartInMs: RESTART_BUFFER_MS,
2441
+ verification: verificationAfterReconcile,
2442
+ latestVersion,
2443
+ output: updateOutput.slice(0, 500)
2444
+ });
2445
+ await sleep(RESTART_BUFFER_MS);
2446
+ console.log(
2447
+ `[version] Plugin update verified (was ${before}), restarting gateway...`
2448
+ );
2449
+ try {
2450
+ execSync2("openclaw gateway restart 2>&1", {
2451
+ timeout: 3e4,
2452
+ encoding: "utf-8"
2453
+ });
2454
+ } catch {
2455
+ }
2456
+ } catch (e) {
2457
+ const msg = e instanceof Error ? e.message : String(e);
2458
+ respond(false, { error: msg });
2459
+ } finally {
2460
+ updateInProgress = false;
2461
+ }
2371
2462
  }
2372
- }
2373
- const resolvedWorkspaces = Array.from(deduped.values());
2374
- const mainWorkspace = resolvedWorkspaces.find((ws) => ws.agentId === "main");
2375
- const defaultFileBrowserRoot = mainWorkspace?.path ?? stateDir;
2376
- return {
2377
- stateDir,
2378
- configPath,
2379
- mediaDir: path9.join(stateDir, "media"),
2380
- skillsDir: path9.join(stateDir, "skills"),
2381
- extensionsDir: path9.join(stateDir, "extensions"),
2382
- defaultFileBrowserRoot,
2383
- workspaces: resolvedWorkspaces
2384
- };
2463
+ );
2385
2464
  }
2386
2465
 
2387
- // src/index.ts
2388
- function squadAppPlugin(api) {
2466
+ // src/shared-api.ts
2467
+ var CORE_TOOLS = [
2468
+ "exec",
2469
+ "bash",
2470
+ "process",
2471
+ "read",
2472
+ "write",
2473
+ "edit",
2474
+ "apply_patch",
2475
+ "web_search",
2476
+ "web_fetch",
2477
+ "browser",
2478
+ "canvas",
2479
+ "nodes",
2480
+ "image",
2481
+ "message",
2482
+ "cron",
2483
+ "gateway",
2484
+ "sessions_list",
2485
+ "sessions_history",
2486
+ "sessions_send",
2487
+ "sessions_spawn",
2488
+ "session_status",
2489
+ "agents_list",
2490
+ "memory_search"
2491
+ ];
2492
+ var CORE_TOOL_GROUPS = [
2493
+ "group:fs",
2494
+ "group:runtime",
2495
+ "group:sessions",
2496
+ "group:memory",
2497
+ "group:web",
2498
+ "group:ui",
2499
+ "group:automation",
2500
+ "group:messaging",
2501
+ "group:nodes"
2502
+ ];
2503
+ function registerSquadSharedApi(api, onFsChange) {
2389
2504
  const toolExecutors = /* @__PURE__ */ new Map();
2390
2505
  const origRegisterTool = api.registerTool.bind(api);
2391
2506
  api.registerTool = (toolDef) => {
2392
- if (toolDef.name && typeof toolDef.execute === "function") {
2507
+ if (typeof toolDef.name === "string" && typeof toolDef.execute === "function") {
2393
2508
  toolExecutors.set(toolDef.name, toolDef.execute);
2394
2509
  }
2395
2510
  return origRegisterTool(toolDef);
2396
2511
  };
2397
- const onFsChange = (evt) => broadcastToUsers("fs.change", evt);
2398
2512
  registerEntityTools(api, onFsChange);
2399
2513
  registerFilesystemTools(api);
2400
2514
  registerSqlTools(api);
2401
2515
  registerVersionMethods(api);
2402
2516
  registerAgentMethods(api);
2403
- api.registerGatewayMethod(
2404
- "tools.invoke",
2405
- async ({ params, respond }) => {
2406
- const tool = params?.tool;
2407
- const args = params?.args ?? {};
2408
- if (!tool) {
2409
- respond(false, { errorMessage: "Missing 'tool' parameter" });
2410
- return;
2411
- }
2412
- const executeFn = toolExecutors.get(tool);
2413
- if (!executeFn) {
2414
- respond(false, { errorMessage: `Unknown tool: ${tool}` });
2415
- return;
2517
+ const invokeTool = async (tool, args) => {
2518
+ const executeFn = toolExecutors.get(tool);
2519
+ if (!executeFn) {
2520
+ throw new Error(`Unknown tool: ${tool}`);
2521
+ }
2522
+ return executeFn(`internal-${Date.now()}`, args);
2523
+ };
2524
+ const listTools = () => [...CORE_TOOLS, ...CORE_TOOL_GROUPS, ...Array.from(toolExecutors.keys())];
2525
+ const registerCoreGatewayMethods = () => {
2526
+ api.registerGatewayMethod(
2527
+ "tools.invoke",
2528
+ async ({ params, respond }) => {
2529
+ const tool = params?.tool;
2530
+ const args = params?.args ?? {};
2531
+ if (!tool) {
2532
+ respond(false, { errorMessage: "Missing 'tool' parameter" });
2533
+ return;
2534
+ }
2535
+ try {
2536
+ const result = await invokeTool(tool, args);
2537
+ respond(true, result);
2538
+ } catch (err2) {
2539
+ const msg = err2 instanceof Error ? err2.message : String(err2);
2540
+ respond(false, { errorMessage: msg });
2541
+ }
2416
2542
  }
2417
- try {
2418
- const result = await executeFn(`ws-${Date.now()}`, args);
2419
- respond(true, result);
2420
- } catch (err2) {
2421
- const msg = err2 instanceof Error ? err2.message : String(err2);
2422
- respond(false, { errorMessage: msg });
2543
+ );
2544
+ api.registerGatewayMethod(
2545
+ "tools.list",
2546
+ async ({ respond }) => {
2547
+ respond(true, { tools: listTools() });
2423
2548
  }
2424
- }
2425
- );
2426
- api.registerGatewayMethod(
2427
- "tools.list",
2428
- async ({ respond }) => {
2429
- const coreTools = [
2430
- "exec",
2431
- "bash",
2432
- "process",
2433
- "read",
2434
- "write",
2435
- "edit",
2436
- "apply_patch",
2437
- "web_search",
2438
- "web_fetch",
2439
- "browser",
2440
- "canvas",
2441
- "nodes",
2442
- "image",
2443
- "message",
2444
- "cron",
2445
- "gateway",
2446
- "sessions_list",
2447
- "sessions_history",
2448
- "sessions_send",
2449
- "sessions_spawn",
2450
- "session_status",
2451
- "agents_list",
2452
- "memory_search"
2453
- ];
2454
- const groups = [
2455
- "group:fs",
2456
- "group:runtime",
2457
- "group:sessions",
2458
- "group:memory",
2459
- "group:web",
2460
- "group:ui",
2461
- "group:automation",
2462
- "group:messaging",
2463
- "group:nodes"
2464
- ];
2465
- const pluginTools = Array.from(toolExecutors.keys());
2466
- respond(true, { tools: [...coreTools, ...groups, ...pluginTools] });
2467
- }
2468
- );
2469
- api.registerGatewayMethod(
2470
- "squad.layout.get",
2471
- async ({ respond }) => {
2472
- try {
2473
- respond(true, resolveGatewayLayout());
2474
- } catch (err2) {
2475
- const msg = err2 instanceof Error ? err2.message : String(err2);
2476
- respond(false, { errorMessage: msg });
2549
+ );
2550
+ api.registerGatewayMethod(
2551
+ "squad.layout.get",
2552
+ async ({ respond }) => {
2553
+ try {
2554
+ const layout = resolveGatewayLayout();
2555
+ respond(true, layout);
2556
+ } catch (err2) {
2557
+ const msg = err2 instanceof Error ? err2.message : String(err2);
2558
+ respond(false, { errorMessage: msg });
2559
+ }
2477
2560
  }
2478
- }
2479
- );
2561
+ );
2562
+ };
2563
+ return {
2564
+ invokeTool,
2565
+ listTools,
2566
+ registerCoreGatewayMethods
2567
+ };
2568
+ }
2569
+
2570
+ // src/index.ts
2571
+ function squadAppPlugin(api) {
2572
+ const onFsChange = (evt) => broadcastToUsers("fs.change", evt);
2573
+ const sharedApi = registerSquadSharedApi(api, onFsChange);
2574
+ sharedApi.registerCoreGatewayMethods();
2480
2575
  const relayState = readRelayState();
2481
2576
  const relayEnabled = !!(relayState.claimToken || relayState.roomId);
2482
2577
  if (relayEnabled) {