squad-openclaw 2026.2.2020 → 2026.2.2021

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