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/README.md +11 -3
- package/dist/index.d.ts +0 -34
- package/dist/index.js +2335 -2246
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,2488 +1,2577 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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/
|
|
84
|
+
// src/paths.ts
|
|
111
85
|
import path from "path";
|
|
86
|
+
import os from "os";
|
|
112
87
|
import fs from "fs";
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
164
|
-
|
|
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
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
"
|
|
223
|
-
|
|
224
|
-
|
|
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 =
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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 ("
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
532
|
-
if (
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
605
|
-
entry.size = stat.size;
|
|
606
|
-
entry.modified = stat.mtime.toISOString();
|
|
677
|
+
this.relayWs.send(JSON.stringify(msg));
|
|
607
678
|
} catch {
|
|
608
679
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
function
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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.
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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.
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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.
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
const match =
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
return
|
|
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
|
|
896
|
-
const
|
|
897
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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:
|
|
932
|
-
type: "
|
|
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:
|
|
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
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
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(
|
|
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
|
|
964
|
-
let entries;
|
|
1197
|
+
function readOpenclawConfig(configPath) {
|
|
965
1198
|
try {
|
|
966
|
-
|
|
1199
|
+
const raw = fs5.readFileSync(configPath, "utf-8");
|
|
1200
|
+
return JSON.parse(raw);
|
|
967
1201
|
} catch {
|
|
968
|
-
return;
|
|
1202
|
+
return null;
|
|
969
1203
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
1285
|
+
config = JSON.parse(rawContent);
|
|
1104
1286
|
} catch {
|
|
1105
|
-
return;
|
|
1287
|
+
return rawContent;
|
|
1106
1288
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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
|
|
1151
|
-
|
|
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
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
-
|
|
1239
|
-
stopWatcher?.();
|
|
1240
|
-
};
|
|
1241
|
-
process.on("SIGTERM", cleanup);
|
|
1242
|
-
process.on("SIGINT", cleanup);
|
|
1343
|
+
return resolved;
|
|
1243
1344
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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
|
-
|
|
1258
|
-
|
|
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
|
-
`
|
|
1358
|
+
`Write denied: "${p}" is a protected configuration file (openclaw.json)`
|
|
1261
1359
|
);
|
|
1262
1360
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
-
|
|
1397
|
+
results.push(entry);
|
|
1273
1398
|
}
|
|
1274
|
-
return
|
|
1399
|
+
return results;
|
|
1275
1400
|
}
|
|
1276
|
-
function
|
|
1277
|
-
return
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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
|
|
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: "
|
|
1295
|
-
label: "
|
|
1296
|
-
description: "
|
|
1297
|
-
parameters:
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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
|
|
1311
|
-
const
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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
|
-
|
|
1501
|
+
return err(`fs_write failed: ${msg}`);
|
|
1569
1502
|
}
|
|
1570
1503
|
}
|
|
1571
|
-
);
|
|
1572
|
-
api.
|
|
1573
|
-
"
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
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
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
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
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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
|
-
|
|
1677
|
-
} finally {
|
|
1678
|
-
updateInProgress = false;
|
|
1638
|
+
return err(`fs_delete failed: ${msg}`);
|
|
1679
1639
|
}
|
|
1680
1640
|
}
|
|
1681
|
-
);
|
|
1641
|
+
});
|
|
1682
1642
|
}
|
|
1683
1643
|
|
|
1684
|
-
// src/
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
var
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
-
|
|
1777
|
-
return JSON.parse(raw);
|
|
1681
|
+
entries = fs7.readdirSync(configDir, { withFileTypes: true });
|
|
1778
1682
|
} catch {
|
|
1779
|
-
return
|
|
1683
|
+
return;
|
|
1780
1684
|
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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
|
|
1808
|
-
|
|
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
|
-
|
|
1819
|
-
} catch
|
|
1820
|
-
|
|
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
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1837
|
-
const
|
|
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 =
|
|
1822
|
+
const raw = fs7.readFileSync(
|
|
1823
|
+
path7.join(configDir, "openclaw.json"),
|
|
1824
|
+
"utf-8"
|
|
1825
|
+
);
|
|
1846
1826
|
const config = JSON.parse(raw);
|
|
1847
|
-
const
|
|
1848
|
-
|
|
1849
|
-
|
|
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
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
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
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
if (
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
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
|
-
|
|
1912
|
+
const stat = fs7.statSync(entryPath);
|
|
1913
|
+
size = stat.size;
|
|
1914
|
+
mtime = stat.mtimeMs;
|
|
1933
1915
|
} catch {
|
|
1934
1916
|
}
|
|
1935
|
-
|
|
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
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
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
|
-
|
|
1996
|
-
|
|
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
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
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
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
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
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
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
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
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
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
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
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
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
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
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 (
|
|
2128
|
-
|
|
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
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
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
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
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
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
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
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
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
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
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
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
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
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2349
|
-
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
|
|
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/
|
|
2392
|
-
|
|
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
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
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
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
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
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
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) {
|