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/README.md +11 -3
- package/dist/index.d.ts +0 -34
- package/dist/index.js +2386 -2291
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,2482 +1,2577 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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/
|
|
111
|
-
import
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
var
|
|
115
|
-
var
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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/
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
const
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
|
878
|
-
|
|
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
|
-
|
|
881
|
-
} catch {
|
|
882
|
-
|
|
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
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
|
|
952
|
-
const
|
|
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 =
|
|
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
|
|
960
|
-
|
|
961
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
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
|
|
1045
|
-
|
|
1046
|
-
mtime = stat.mtimeMs;
|
|
311
|
+
const msg = JSON.parse(data.toString());
|
|
312
|
+
this.handleRelayMessage(msg);
|
|
1047
313
|
} catch {
|
|
1048
314
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
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
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
);
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
return
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
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
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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.
|
|
1488
|
-
async ({ respond }) => {
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
|
1496
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
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
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
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
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
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/
|
|
1682
|
-
import
|
|
1683
|
-
import fs7 from "fs";
|
|
911
|
+
// src/entities.ts
|
|
912
|
+
import { Type as T } from "@sinclair/typebox";
|
|
1684
913
|
import path7 from "path";
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
}
|
|
1700
|
-
|
|
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
|
|
1703
|
-
|
|
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
|
|
1706
|
-
const
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
const
|
|
1711
|
-
|
|
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
|
|
1722
|
-
const
|
|
1723
|
-
const
|
|
1724
|
-
|
|
1725
|
-
|
|
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
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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
|
|
1744
|
-
|
|
1745
|
-
|
|
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
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
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
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
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
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
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
|
-
|
|
1822
|
-
if (
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
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
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
} catch {
|
|
1841
|
-
}
|
|
1842
|
-
this.userConnections.delete(userId);
|
|
1101
|
+
if (isOpenClawConfig(filePath, configDir)) {
|
|
1102
|
+
debounced("tools", () => scanTools(configDir));
|
|
1103
|
+
return;
|
|
1843
1104
|
}
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
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
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
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
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
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
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
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
|
-
|
|
1928
|
-
|
|
1929
|
-
if (
|
|
1930
|
-
|
|
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
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
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
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
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
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
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
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
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 (
|
|
2003
|
-
|
|
2004
|
-
|
|
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
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
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
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
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 (
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
}
|
|
2045
|
-
|
|
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
|
-
|
|
1397
|
+
results.push(entry);
|
|
2048
1398
|
}
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
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
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
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
|
-
|
|
2084
|
-
|
|
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
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
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
|
|
2109
|
-
|
|
2110
|
-
|
|
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
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
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
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
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
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
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
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2196
|
-
|
|
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
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
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
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
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
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
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
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
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
|
|
2230
|
-
const
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
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
|
-
|
|
2242
|
-
} catch (err2) {
|
|
2243
|
-
console.error(`[relay-client] E2E exchange failed for ${userId}:`, err2);
|
|
1815
|
+
} catch {
|
|
2244
1816
|
}
|
|
2245
1817
|
}
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
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
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
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
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
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
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
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
|
-
|
|
2283
|
-
|
|
2284
|
-
"
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2302
|
-
|
|
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/
|
|
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
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
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
|
|
2313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2320
|
-
|
|
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
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
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
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
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
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
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/
|
|
2388
|
-
|
|
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
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
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
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
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
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
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) {
|