squad-openclaw 2026.2.2209 → 2026.2.2702
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1283 -932
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,86 +1,7 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import {
|
|
3
|
-
import crypto3 from "crypto";
|
|
4
|
-
import fs3 from "fs";
|
|
1
|
+
// src/agents.ts
|
|
2
|
+
import { execSync } from "child_process";
|
|
5
3
|
import path3 from "path";
|
|
6
4
|
|
|
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");
|
|
29
|
-
}
|
|
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");
|
|
44
|
-
}
|
|
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");
|
|
62
|
-
}
|
|
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
|
-
};
|
|
83
|
-
|
|
84
5
|
// src/paths.ts
|
|
85
6
|
import path from "path";
|
|
86
7
|
import os from "os";
|
|
@@ -105,690 +26,42 @@ function getOpenclawStateDir() {
|
|
|
105
26
|
return path.join(os.homedir(), ".openclaw");
|
|
106
27
|
}
|
|
107
28
|
|
|
108
|
-
// src/
|
|
109
|
-
import crypto2 from "crypto";
|
|
29
|
+
// src/auth-profiles.ts
|
|
110
30
|
import fs2 from "fs";
|
|
111
31
|
import path2 from "path";
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
}
|
|
122
|
-
}
|
|
123
|
-
function writeRelayState(state) {
|
|
124
|
-
if (!fs2.existsSync(RELAY_DATA_DIR)) {
|
|
125
|
-
fs2.mkdirSync(RELAY_DATA_DIR, { recursive: true });
|
|
126
|
-
}
|
|
127
|
-
fs2.writeFileSync(RELAY_STATE_PATH, JSON.stringify(state, null, 2), { mode: 384 });
|
|
128
|
-
}
|
|
129
|
-
function toBase64Url(buf) {
|
|
130
|
-
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
32
|
+
function getMainAuthProfilesPath(stateDir) {
|
|
33
|
+
return path2.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
|
|
131
34
|
}
|
|
132
|
-
function
|
|
133
|
-
|
|
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;
|
|
147
|
-
}
|
|
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
|
-
}
|
|
35
|
+
function getAgentAuthProfilesPath(stateDir, agentId) {
|
|
36
|
+
return path2.join(stateDir, "agents", agentId, "agent", "auth-profiles.json");
|
|
163
37
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
38
|
+
function ensureAgentAuthProfiles(agentId) {
|
|
39
|
+
const normalizedAgentId = agentId.trim();
|
|
40
|
+
if (!normalizedAgentId || normalizedAgentId === "main") return false;
|
|
167
41
|
const stateDir = getOpenclawStateDir();
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
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
|
-
};
|
|
42
|
+
const sourcePath = getMainAuthProfilesPath(stateDir);
|
|
43
|
+
const targetPath = getAgentAuthProfilesPath(stateDir, normalizedAgentId);
|
|
44
|
+
if (!fs2.existsSync(sourcePath) || fs2.existsSync(targetPath)) return false;
|
|
45
|
+
fs2.mkdirSync(path2.dirname(targetPath), { recursive: true });
|
|
46
|
+
fs2.copyFileSync(sourcePath, targetPath);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
function backfillAgentAuthProfiles() {
|
|
183
50
|
const stateDir = getOpenclawStateDir();
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
} catch {
|
|
193
|
-
}
|
|
194
|
-
return defaults;
|
|
195
|
-
}
|
|
196
|
-
function signDeviceIdentity(keys, clientId, clientMode, role, scopes, token, challengeNonce) {
|
|
197
|
-
const signedAtMs = Date.now();
|
|
198
|
-
const nonce = challengeNonce || crypto3.randomBytes(16).toString("hex");
|
|
199
|
-
const scopeStr = scopes.join(",");
|
|
200
|
-
const payload = `v2|${keys.deviceId}|${clientId}|${clientMode}|${role}|${scopeStr}|${signedAtMs}|${token ?? ""}|${nonce}`;
|
|
201
|
-
const privateKey = crypto3.createPrivateKey(keys.privateKeyPem);
|
|
202
|
-
const signature = crypto3.sign(null, Buffer.from(payload), privateKey);
|
|
203
|
-
return {
|
|
204
|
-
id: keys.deviceId,
|
|
205
|
-
publicKey: keys.publicKey,
|
|
206
|
-
signature: toBase64Url(signature),
|
|
207
|
-
signedAt: signedAtMs,
|
|
208
|
-
nonce
|
|
209
|
-
};
|
|
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
|
-
autoConnectCounter = 0;
|
|
219
|
-
reconnectTimer = null;
|
|
220
|
-
shouldReconnect = true;
|
|
221
|
-
destroyed = false;
|
|
222
|
-
/** Pending claim token — sent on first successful connect, then cleared */
|
|
223
|
-
pendingClaimToken = null;
|
|
224
|
-
/** Device keys for authenticating local WS connections to the gateway */
|
|
225
|
-
deviceKeys;
|
|
226
|
-
constructor(config) {
|
|
227
|
-
const state = readRelayState();
|
|
228
|
-
const localWs = readGatewayLocalWsConfig();
|
|
229
|
-
this.config = {
|
|
230
|
-
relayUrl: config.relayUrl,
|
|
231
|
-
localGatewayPort: config.localGatewayPort ?? localWs.port,
|
|
232
|
-
localGatewayHosts: config.localGatewayHosts ?? localWs.hosts,
|
|
233
|
-
operatorToken: config.operatorToken ?? readOperatorToken(),
|
|
234
|
-
claimToken: config.claimToken ?? state.claimToken ?? null,
|
|
235
|
-
roomId: config.roomId ?? state.roomId ?? null
|
|
236
|
-
};
|
|
237
|
-
this.pendingClaimToken = this.config.roomId ? null : this.config.claimToken;
|
|
238
|
-
this.deviceKeys = loadOrCreateRelayDeviceKeys();
|
|
239
|
-
writeDeviceInfoFile(this.deviceKeys);
|
|
240
|
-
console.log(`[relay-client] Device ID: ${this.deviceKeys.deviceId}`);
|
|
241
|
-
}
|
|
242
|
-
/** Start connecting to the relay */
|
|
243
|
-
start() {
|
|
244
|
-
if (!this.config.roomId && !this.pendingClaimToken) {
|
|
245
|
-
console.log("[relay-client] No room ID or claim token found.");
|
|
246
|
-
console.log("[relay-client] Complete the setup from the Squad web app to generate a claim token.");
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
console.log(`[relay-client] Starting relay connection to ${this.config.relayUrl}`);
|
|
250
|
-
if (this.config.roomId) {
|
|
251
|
-
console.log(`[relay-client] Room ID: ${this.config.roomId.substring(0, 8)}...`);
|
|
252
|
-
} else {
|
|
253
|
-
console.log(`[relay-client] Using claim token for first connect`);
|
|
254
|
-
}
|
|
255
|
-
this.connectToRelay();
|
|
256
|
-
}
|
|
257
|
-
/** Stop the relay client and close all connections */
|
|
258
|
-
destroy() {
|
|
259
|
-
this.destroyed = true;
|
|
260
|
-
this.shouldReconnect = false;
|
|
261
|
-
if (this.reconnectTimer) {
|
|
262
|
-
clearTimeout(this.reconnectTimer);
|
|
263
|
-
this.reconnectTimer = null;
|
|
264
|
-
}
|
|
265
|
-
for (const [userId, conn] of this.userConnections) {
|
|
266
|
-
try {
|
|
267
|
-
conn.localWs.close(1e3, "Relay client shutting down");
|
|
268
|
-
} catch {
|
|
269
|
-
}
|
|
270
|
-
this.userConnections.delete(userId);
|
|
271
|
-
}
|
|
272
|
-
if (this.relayWs) {
|
|
273
|
-
try {
|
|
274
|
-
this.relayWs.close(1e3, "Relay client shutting down");
|
|
275
|
-
} catch {
|
|
276
|
-
}
|
|
277
|
-
this.relayWs = null;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
// ── Relay Connection ──
|
|
281
|
-
connectToRelay() {
|
|
282
|
-
if (this.destroyed) return;
|
|
283
|
-
let wsUrl;
|
|
284
|
-
if (this.pendingClaimToken) {
|
|
285
|
-
wsUrl = `${this.config.relayUrl}/gw?claim=${encodeURIComponent(this.pendingClaimToken)}`;
|
|
286
|
-
console.log(`[relay-client] Connecting with claim token`);
|
|
287
|
-
} else if (this.config.roomId) {
|
|
288
|
-
wsUrl = `${this.config.relayUrl}/gw?room=${encodeURIComponent(this.config.roomId)}`;
|
|
289
|
-
console.log(`[relay-client] Reconnecting with room ID`);
|
|
290
|
-
} else {
|
|
291
|
-
console.error("[relay-client] No claim token or room ID \u2014 cannot connect");
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
try {
|
|
295
|
-
this.relayWs = new NodeWebSocket(wsUrl);
|
|
296
|
-
} catch (err2) {
|
|
297
|
-
console.error("[relay-client] Failed to create WebSocket:", err2);
|
|
298
|
-
this.scheduleReconnect();
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
this.relayWs.on("open", () => {
|
|
302
|
-
console.log("[relay-client] Connected to relay");
|
|
303
|
-
this.reconnectAttempts = 0;
|
|
304
|
-
this.sendToRelay({
|
|
305
|
-
type: "relay.hello",
|
|
306
|
-
deviceId: this.deviceKeys.deviceId,
|
|
307
|
-
publicKey: this.deviceKeys.publicKey
|
|
308
|
-
});
|
|
309
|
-
});
|
|
310
|
-
this.relayWs.on("message", (data) => {
|
|
311
|
-
try {
|
|
312
|
-
const msg = JSON.parse(data.toString());
|
|
313
|
-
this.handleRelayMessage(msg);
|
|
314
|
-
} catch {
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
this.relayWs.on("close", (code, reason) => {
|
|
318
|
-
const reasonStr = reason.toString();
|
|
319
|
-
console.log(`[relay-client] Relay connection closed: ${code} ${reasonStr}`);
|
|
320
|
-
this.relayWs = null;
|
|
321
|
-
if (code === 1e3 && reasonStr.includes("Replaced")) {
|
|
322
|
-
console.log("[relay-client] Replaced by newer instance, stopping reconnect");
|
|
323
|
-
this.shouldReconnect = false;
|
|
324
|
-
this.destroyed = true;
|
|
325
|
-
}
|
|
326
|
-
for (const [userId, conn] of this.userConnections) {
|
|
327
|
-
try {
|
|
328
|
-
conn.localWs.close(1001, "Relay disconnected");
|
|
329
|
-
} catch {
|
|
330
|
-
}
|
|
331
|
-
this.userConnections.delete(userId);
|
|
332
|
-
}
|
|
333
|
-
if (this.shouldReconnect) {
|
|
334
|
-
this.scheduleReconnect();
|
|
335
|
-
}
|
|
336
|
-
});
|
|
337
|
-
this.relayWs.on("error", (err2) => {
|
|
338
|
-
console.error("[relay-client] Relay WebSocket error:", err2.message);
|
|
339
|
-
});
|
|
340
|
-
this.relayWs.on("unexpected-response", (_req, res) => {
|
|
341
|
-
console.warn(`[relay-client] Unexpected response: ${res.statusCode}`);
|
|
342
|
-
if (res.statusCode === 401 && this.pendingClaimToken) {
|
|
343
|
-
console.log("[relay-client] Claim token rejected \u2014 checking for stored room ID");
|
|
344
|
-
this.pendingClaimToken = null;
|
|
345
|
-
const state = readRelayState();
|
|
346
|
-
if (state.roomId) {
|
|
347
|
-
this.config.roomId = state.roomId;
|
|
348
|
-
console.log(`[relay-client] Found stored room ID, will use on next reconnect`);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
this.relayWs = null;
|
|
352
|
-
this.scheduleReconnect();
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
scheduleReconnect() {
|
|
356
|
-
if (this.destroyed || !this.shouldReconnect) return;
|
|
357
|
-
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
358
|
-
console.error("[relay-client] Max reconnect attempts reached");
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), 6e4);
|
|
362
|
-
this.reconnectAttempts++;
|
|
363
|
-
console.log(`[relay-client] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
364
|
-
this.reconnectTimer = setTimeout(() => {
|
|
365
|
-
this.reconnectTimer = null;
|
|
366
|
-
this.connectToRelay();
|
|
367
|
-
}, delay);
|
|
368
|
-
}
|
|
369
|
-
// ── Message Handling ──
|
|
370
|
-
handleRelayMessage(msg) {
|
|
371
|
-
switch (msg.type) {
|
|
372
|
-
case "relay.welcome":
|
|
373
|
-
this.handleWelcome(msg);
|
|
374
|
-
break;
|
|
375
|
-
case "relay.forward":
|
|
376
|
-
if (msg.userId && msg.inner) {
|
|
377
|
-
this.routeToUser(msg.userId, msg.inner);
|
|
378
|
-
}
|
|
379
|
-
break;
|
|
380
|
-
case "relay.pair.request":
|
|
381
|
-
if (msg.userId && msg.email) {
|
|
382
|
-
this.handlePairingRequest(msg.userId, msg.email);
|
|
383
|
-
}
|
|
384
|
-
break;
|
|
385
|
-
case "relay.e2e.exchange":
|
|
386
|
-
if (msg.userId && msg.publicKey) {
|
|
387
|
-
this.handleE2EExchange(msg.userId, msg.publicKey);
|
|
388
|
-
}
|
|
389
|
-
break;
|
|
390
|
-
case "relay.ping":
|
|
391
|
-
this.sendToRelay({ type: "relay.pong" });
|
|
392
|
-
break;
|
|
393
|
-
default:
|
|
394
|
-
console.log(`[relay-client] Unknown relay message type: ${msg.type}`);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
/** Handle relay.welcome — store room ID for reconnection */
|
|
398
|
-
handleWelcome(msg) {
|
|
399
|
-
if (msg.roomId) {
|
|
400
|
-
console.log(`[relay-client] Received room ID: ${msg.roomId.substring(0, 8)}...`);
|
|
401
|
-
this.config.roomId = msg.roomId;
|
|
402
|
-
this.pendingClaimToken = null;
|
|
403
|
-
const state = readRelayState();
|
|
404
|
-
state.roomId = msg.roomId;
|
|
405
|
-
writeRelayState(state);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
/** Route a message from the relay to the appropriate user's local WS */
|
|
409
|
-
routeToUser(userId, innerMsg) {
|
|
410
|
-
let msg = innerMsg;
|
|
411
|
-
if (msg.type === "event" && typeof msg.event === "string" && msg.event.startsWith("relay.")) {
|
|
412
|
-
if (msg.event === "relay.user.connected") {
|
|
413
|
-
console.log(`[relay-client] User ${userId} connected via relay \u2014 creating local WS`);
|
|
414
|
-
this.createUserConnection(userId);
|
|
415
|
-
}
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
if (typeof msg.type === "string" && msg.type.startsWith("relay.")) {
|
|
419
|
-
if (msg.type === "relay.e2e.exchange" && msg.publicKey) {
|
|
420
|
-
this.handleE2EExchange(userId, msg.publicKey);
|
|
421
|
-
}
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
let conn = this.userConnections.get(userId);
|
|
425
|
-
if (!conn || conn.localWs.readyState >= NodeWebSocket.CLOSING) {
|
|
426
|
-
this.createUserConnection(userId);
|
|
427
|
-
conn = this.userConnections.get(userId);
|
|
428
|
-
if (!conn) return;
|
|
429
|
-
}
|
|
430
|
-
if (msg._e2e && conn.e2e) {
|
|
431
|
-
try {
|
|
432
|
-
const plaintext = conn.e2e.decrypt({
|
|
433
|
-
ciphertext: msg.ciphertext,
|
|
434
|
-
iv: msg.iv,
|
|
435
|
-
tag: msg.tag
|
|
436
|
-
});
|
|
437
|
-
msg = JSON.parse(plaintext);
|
|
438
|
-
} catch (err2) {
|
|
439
|
-
console.error(`[relay-client] E2E decrypt error for ${userId}:`, err2);
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
if (msg.type === "req" && msg.method === "connect") {
|
|
444
|
-
const connectRequestId = typeof msg.id === "string" ? msg.id : null;
|
|
445
|
-
if (conn.connectHandshakeComplete) {
|
|
446
|
-
if (connectRequestId && conn.lastSuccessfulConnectRequestId === connectRequestId) {
|
|
447
|
-
console.log(`[relay-client] Duplicate connect id for ${userId} (${connectRequestId}) \u2014 reusing existing session`);
|
|
448
|
-
this.sendToRelay({
|
|
449
|
-
type: "relay.forward",
|
|
450
|
-
userId,
|
|
451
|
-
inner: {
|
|
452
|
-
type: "res",
|
|
453
|
-
id: connectRequestId,
|
|
454
|
-
ok: true,
|
|
455
|
-
payload: { protocol: 3 }
|
|
456
|
-
}
|
|
457
|
-
});
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
console.log(`[relay-client] New connect from ${userId} \u2014 creating fresh local WS for handshake`);
|
|
461
|
-
this.createUserConnection(userId, {
|
|
462
|
-
pendingConnect: null,
|
|
463
|
-
pendingMessages: conn.pendingMessages,
|
|
464
|
-
e2e: conn.e2e
|
|
465
|
-
});
|
|
466
|
-
conn = this.userConnections.get(userId);
|
|
467
|
-
if (!conn) return;
|
|
468
|
-
}
|
|
469
|
-
if (!conn.challengeNonce) {
|
|
470
|
-
console.log(`[relay-client] Connect request for ${userId} deferred \u2014 waiting for challenge nonce`);
|
|
471
|
-
conn.pendingConnect = msg;
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
this.injectDeviceIdentity(conn, msg);
|
|
475
|
-
if (conn.localWs.readyState === NodeWebSocket.CONNECTING) {
|
|
476
|
-
conn.localWs.once("open", () => {
|
|
477
|
-
conn.localWs.send(JSON.stringify(msg));
|
|
478
|
-
});
|
|
479
|
-
} else {
|
|
480
|
-
conn.localWs.send(JSON.stringify(msg));
|
|
481
|
-
}
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
if (!conn.connectHandshakeComplete) {
|
|
485
|
-
conn.pendingMessages.push(msg);
|
|
486
|
-
if (!conn.pendingConnect) {
|
|
487
|
-
const autoConnect = this.buildAutoConnectRequest();
|
|
488
|
-
conn.pendingConnect = autoConnect;
|
|
489
|
-
console.log(`[relay-client] Auto-starting local connect handshake for ${userId} (${String(autoConnect.id)})`);
|
|
490
|
-
if (conn.challengeNonce) {
|
|
491
|
-
const pending = conn.pendingConnect;
|
|
492
|
-
conn.pendingConnect = null;
|
|
493
|
-
if (pending) {
|
|
494
|
-
this.injectDeviceIdentity(conn, pending);
|
|
495
|
-
if (conn.localWs.readyState === NodeWebSocket.OPEN) {
|
|
496
|
-
conn.localWs.send(JSON.stringify(pending));
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
if (conn.localWs.readyState === NodeWebSocket.CONNECTING) {
|
|
504
|
-
conn.localWs.once("open", () => {
|
|
505
|
-
conn.localWs.send(JSON.stringify(msg));
|
|
506
|
-
});
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
conn.localWs.send(JSON.stringify(msg));
|
|
510
|
-
}
|
|
511
|
-
buildAutoConnectRequest() {
|
|
512
|
-
return {
|
|
513
|
-
type: "req",
|
|
514
|
-
id: `connect-auto-${++this.autoConnectCounter}`,
|
|
515
|
-
method: "connect",
|
|
516
|
-
params: {
|
|
517
|
-
minProtocol: 3,
|
|
518
|
-
maxProtocol: 3,
|
|
519
|
-
client: {
|
|
520
|
-
id: "cli",
|
|
521
|
-
version: "relay-client-auto",
|
|
522
|
-
platform: "gateway",
|
|
523
|
-
mode: "ui"
|
|
524
|
-
},
|
|
525
|
-
role: "operator",
|
|
526
|
-
scopes: ["operator.admin", "operator.read", "operator.write"],
|
|
527
|
-
auth: {}
|
|
528
|
-
}
|
|
529
|
-
};
|
|
530
|
-
}
|
|
531
|
-
/**
|
|
532
|
-
* Inject auth token and device identity into a connect request.
|
|
533
|
-
*
|
|
534
|
-
* SECURITY: The token is added to the message IN MEMORY, then sent to the
|
|
535
|
-
* LOCAL gateway WebSocket (localhost:18789). It NEVER traverses the relay —
|
|
536
|
-
* the relay only sees the outer relay.forward envelope. A compromised relay
|
|
537
|
-
* server cannot intercept this token.
|
|
538
|
-
*/
|
|
539
|
-
injectDeviceIdentity(conn, msg) {
|
|
540
|
-
const params = msg.params ?? {};
|
|
541
|
-
if (this.config.operatorToken) {
|
|
542
|
-
params.auth = { token: this.config.operatorToken };
|
|
543
|
-
}
|
|
544
|
-
const client = params.client ?? {};
|
|
545
|
-
const role = params.role ?? "operator";
|
|
546
|
-
const scopes = params.scopes ?? [];
|
|
547
|
-
params.device = signDeviceIdentity(
|
|
548
|
-
this.deviceKeys,
|
|
549
|
-
client.id ?? "cli",
|
|
550
|
-
client.mode ?? "ui",
|
|
551
|
-
role,
|
|
552
|
-
scopes,
|
|
553
|
-
this.config.operatorToken,
|
|
554
|
-
conn.challengeNonce
|
|
555
|
-
);
|
|
556
|
-
msg.params = params;
|
|
557
|
-
conn.connectHandshakeComplete = false;
|
|
558
|
-
console.log(`[relay-client] Injected device identity for ${conn.userId}: nonce=${conn.challengeNonce?.substring(0, 12)}...`);
|
|
559
|
-
}
|
|
560
|
-
/** Create a local WS connection to the gateway for a specific user */
|
|
561
|
-
createUserConnection(userId, carry) {
|
|
562
|
-
const existing = this.userConnections.get(userId);
|
|
563
|
-
if (existing) {
|
|
564
|
-
try {
|
|
565
|
-
existing.localWs.close(1e3, "Replaced");
|
|
566
|
-
} catch {
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
const attempt = this.localConnectAttempts.get(userId) ?? 0;
|
|
570
|
-
const host = this.config.localGatewayHosts[attempt % this.config.localGatewayHosts.length];
|
|
571
|
-
const localUrl = `ws://${host}:${this.config.localGatewayPort}`;
|
|
572
|
-
console.log(`[relay-client] Creating local WS for user ${userId} \u2192 ${localUrl}`);
|
|
573
|
-
const localWs = new NodeWebSocket(localUrl);
|
|
574
|
-
const conn = {
|
|
575
|
-
localWs,
|
|
576
|
-
userId,
|
|
577
|
-
e2e: carry?.e2e ?? null,
|
|
578
|
-
connectHandshakeComplete: false,
|
|
579
|
-
challengeNonce: null,
|
|
580
|
-
pendingConnect: carry?.pendingConnect ?? null,
|
|
581
|
-
pendingMessages: carry?.pendingMessages ?? [],
|
|
582
|
-
lastSuccessfulConnectRequestId: null
|
|
583
|
-
};
|
|
584
|
-
this.userConnections.set(userId, conn);
|
|
585
|
-
localWs.on("open", () => {
|
|
586
|
-
console.log(`[relay-client] Local WS for user ${userId} connected`);
|
|
587
|
-
this.localConnectAttempts.delete(userId);
|
|
588
|
-
});
|
|
589
|
-
localWs.on("message", (data) => {
|
|
590
|
-
try {
|
|
591
|
-
const msg = JSON.parse(data.toString());
|
|
592
|
-
this.routeFromGateway(userId, msg);
|
|
593
|
-
} catch {
|
|
594
|
-
}
|
|
595
|
-
});
|
|
596
|
-
localWs.on("close", (code, reason) => {
|
|
597
|
-
const reasonStr = reason.toString();
|
|
598
|
-
console.log(`[relay-client] Local WS for user ${userId} closed: ${code} ${reasonStr}`);
|
|
599
|
-
if (code === 1008) {
|
|
600
|
-
console.error(
|
|
601
|
-
`[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.
|
|
602
|
-
Check: ~/.openclaw/openclaw.json \u2192 gateway.auth.token
|
|
603
|
-
Device ID: ${this.deviceKeys.deviceId}`
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
const current = this.userConnections.get(userId);
|
|
607
|
-
if (current && current.localWs === localWs) {
|
|
608
|
-
this.userConnections.delete(userId);
|
|
609
|
-
const nextAttempt = (this.localConnectAttempts.get(userId) ?? 0) + 1;
|
|
610
|
-
const shouldRetryLocalConnect = code === 1006 && !conn.connectHandshakeComplete && nextAttempt <= 8 && this.relayWs?.readyState === NodeWebSocket.OPEN;
|
|
611
|
-
if (shouldRetryLocalConnect) {
|
|
612
|
-
this.localConnectAttempts.set(userId, nextAttempt);
|
|
613
|
-
const delay = Math.min(300 * nextAttempt, 2e3);
|
|
614
|
-
console.log(
|
|
615
|
-
`[relay-client] Local WS unavailable for ${userId}, retrying in ${delay}ms (attempt ${nextAttempt}/8)`
|
|
616
|
-
);
|
|
617
|
-
const carry2 = {
|
|
618
|
-
pendingConnect: conn.pendingConnect,
|
|
619
|
-
pendingMessages: conn.pendingMessages,
|
|
620
|
-
e2e: conn.e2e
|
|
621
|
-
};
|
|
622
|
-
setTimeout(() => {
|
|
623
|
-
if (this.destroyed) return;
|
|
624
|
-
if (this.relayWs?.readyState !== NodeWebSocket.OPEN) return;
|
|
625
|
-
if (!this.userConnections.has(userId)) {
|
|
626
|
-
this.createUserConnection(userId, carry2);
|
|
627
|
-
}
|
|
628
|
-
}, delay);
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
this.localConnectAttempts.delete(userId);
|
|
632
|
-
this.sendToRelay({
|
|
633
|
-
type: "relay.forward",
|
|
634
|
-
userId,
|
|
635
|
-
inner: {
|
|
636
|
-
type: "event",
|
|
637
|
-
event: "relay.gateway.connection.closed",
|
|
638
|
-
payload: { code }
|
|
639
|
-
}
|
|
640
|
-
});
|
|
641
|
-
}
|
|
642
|
-
});
|
|
643
|
-
localWs.on("error", (err2) => {
|
|
644
|
-
console.error(`[relay-client] Local WS error for user ${userId}:`, err2.message);
|
|
645
|
-
});
|
|
646
|
-
}
|
|
647
|
-
/** Route a message from the gateway back through the relay to the user */
|
|
648
|
-
routeFromGateway(userId, msg) {
|
|
649
|
-
const conn = this.userConnections.get(userId);
|
|
650
|
-
if (!conn) return;
|
|
651
|
-
const parsed = msg;
|
|
652
|
-
if (parsed.type === "event" && parsed.event === "connect.challenge") {
|
|
653
|
-
const payload = parsed.payload;
|
|
654
|
-
if (payload?.nonce) {
|
|
655
|
-
conn.challengeNonce = payload.nonce;
|
|
656
|
-
console.log(`[relay-client] Captured challenge nonce for ${userId}: ${conn.challengeNonce.substring(0, 12)}...`);
|
|
657
|
-
if (conn.pendingConnect) {
|
|
658
|
-
const pending = conn.pendingConnect;
|
|
659
|
-
conn.pendingConnect = null;
|
|
660
|
-
console.log(`[relay-client] Flushing deferred connect for ${userId}`);
|
|
661
|
-
this.injectDeviceIdentity(conn, pending);
|
|
662
|
-
if (conn.localWs.readyState === NodeWebSocket.OPEN) {
|
|
663
|
-
conn.localWs.send(JSON.stringify(pending));
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
if (parsed.type === "res" && typeof parsed.id === "string" && parsed.id.startsWith("connect-") && parsed.ok) {
|
|
669
|
-
conn.connectHandshakeComplete = true;
|
|
670
|
-
conn.lastSuccessfulConnectRequestId = parsed.id;
|
|
671
|
-
if (conn.pendingMessages.length > 0) {
|
|
672
|
-
console.log(`[relay-client] Flushing ${conn.pendingMessages.length} buffered messages for ${userId}`);
|
|
673
|
-
for (const queued of conn.pendingMessages) {
|
|
674
|
-
conn.localWs.send(JSON.stringify(queued));
|
|
675
|
-
}
|
|
676
|
-
conn.pendingMessages = [];
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
let innerMsg = msg;
|
|
680
|
-
if (conn.e2e) {
|
|
681
|
-
try {
|
|
682
|
-
const encrypted = conn.e2e.encrypt(JSON.stringify(msg));
|
|
683
|
-
innerMsg = { _e2e: true, ...encrypted };
|
|
684
|
-
} catch (err2) {
|
|
685
|
-
console.error(`[relay-client] E2E encrypt failed for ${userId} \u2014 dropping message:`, err2);
|
|
686
|
-
return;
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
this.sendToRelay({
|
|
690
|
-
type: "relay.forward",
|
|
691
|
-
userId,
|
|
692
|
-
inner: innerMsg
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
// ── Pairing ──
|
|
696
|
-
handlePairingRequest(userId, email) {
|
|
697
|
-
console.log(`[relay-client] Pairing request from ${email} (${userId})`);
|
|
698
|
-
this.sendToRelay({
|
|
699
|
-
type: "relay.pair.status",
|
|
700
|
-
userId,
|
|
701
|
-
status: "pending"
|
|
702
|
-
});
|
|
703
|
-
console.log(
|
|
704
|
-
`[relay-client] Pairing request pending for ${email}. The gateway will auto-pair this device if the operator token is valid.`
|
|
705
|
-
);
|
|
706
|
-
}
|
|
707
|
-
// ── E2E Key Exchange ──
|
|
708
|
-
async handleE2EExchange(userId, browserPublicKey) {
|
|
709
|
-
console.log(`[relay-client] E2E key exchange with user ${userId}`);
|
|
710
|
-
const conn = this.userConnections.get(userId);
|
|
711
|
-
if (!conn) return;
|
|
712
|
-
try {
|
|
713
|
-
const e2e = new E2ECrypto();
|
|
714
|
-
const gatewayPublicKey = await e2e.generateKeyPair();
|
|
715
|
-
await e2e.deriveSharedSecret(browserPublicKey);
|
|
716
|
-
conn.e2e = e2e;
|
|
717
|
-
this.sendToRelay({
|
|
718
|
-
type: "relay.forward",
|
|
719
|
-
userId,
|
|
720
|
-
inner: {
|
|
721
|
-
type: "relay.e2e.exchange",
|
|
722
|
-
publicKey: gatewayPublicKey
|
|
723
|
-
}
|
|
724
|
-
});
|
|
725
|
-
console.log(`[relay-client] E2E established for user ${userId}`);
|
|
726
|
-
} catch (err2) {
|
|
727
|
-
console.error(`[relay-client] E2E exchange failed for ${userId}:`, err2);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
// ── Send to Relay ──
|
|
731
|
-
sendToRelay(msg) {
|
|
732
|
-
if (!this.relayWs || this.relayWs.readyState !== NodeWebSocket.OPEN) return;
|
|
733
|
-
try {
|
|
734
|
-
this.relayWs.send(JSON.stringify(msg));
|
|
735
|
-
} catch {
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
/** Broadcast an event to all connected users, E2E encrypted per-user */
|
|
739
|
-
broadcastToUsers(event, payload) {
|
|
740
|
-
const msg = { type: "event", event, payload };
|
|
741
|
-
for (const [userId, conn] of this.userConnections) {
|
|
742
|
-
if (!conn.connectHandshakeComplete) continue;
|
|
743
|
-
let innerMsg = msg;
|
|
744
|
-
if (conn.e2e) {
|
|
745
|
-
try {
|
|
746
|
-
const encrypted = conn.e2e.encrypt(JSON.stringify(msg));
|
|
747
|
-
innerMsg = { _e2e: true, ...encrypted };
|
|
748
|
-
} catch (err2) {
|
|
749
|
-
console.error(`[relay-client] E2E encrypt failed for broadcast to ${userId} \u2014 skipping:`, err2);
|
|
750
|
-
continue;
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
this.sendToRelay({
|
|
754
|
-
type: "relay.forward",
|
|
755
|
-
userId,
|
|
756
|
-
inner: innerMsg
|
|
757
|
-
});
|
|
51
|
+
const agentsDir = path2.join(stateDir, "agents");
|
|
52
|
+
if (!fs2.existsSync(agentsDir)) return [];
|
|
53
|
+
const copied = [];
|
|
54
|
+
for (const entry of fs2.readdirSync(agentsDir, { withFileTypes: true })) {
|
|
55
|
+
if (!entry.isDirectory()) continue;
|
|
56
|
+
const agentId = entry.name;
|
|
57
|
+
if (ensureAgentAuthProfiles(agentId)) {
|
|
58
|
+
copied.push(agentId);
|
|
758
59
|
}
|
|
759
60
|
}
|
|
760
|
-
|
|
761
|
-
var relayClient = null;
|
|
762
|
-
function startRelayClient(api, relayUrl) {
|
|
763
|
-
relayClient = new RelayClient({
|
|
764
|
-
relayUrl
|
|
765
|
-
});
|
|
766
|
-
relayClient.start();
|
|
767
|
-
api.registerGatewayMethod(
|
|
768
|
-
"squad.relay.status",
|
|
769
|
-
async ({ respond }) => {
|
|
770
|
-
respond(true, {
|
|
771
|
-
connected: relayClient !== null,
|
|
772
|
-
relayUrl
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
);
|
|
776
|
-
const cleanup = () => {
|
|
777
|
-
if (relayClient) {
|
|
778
|
-
relayClient.destroy();
|
|
779
|
-
relayClient = null;
|
|
780
|
-
}
|
|
781
|
-
};
|
|
782
|
-
process.on("SIGTERM", cleanup);
|
|
783
|
-
process.on("SIGINT", cleanup);
|
|
784
|
-
}
|
|
785
|
-
function broadcastToUsers(event, payload) {
|
|
786
|
-
relayClient?.broadcastToUsers(event, payload);
|
|
61
|
+
return copied;
|
|
787
62
|
}
|
|
788
63
|
|
|
789
64
|
// src/agents.ts
|
|
790
|
-
import { execSync } from "child_process";
|
|
791
|
-
import path4 from "path";
|
|
792
65
|
function deriveAgentIdFromName(name) {
|
|
793
66
|
const normalized = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
794
67
|
return normalized || "agent";
|
|
@@ -825,7 +98,7 @@ function registerAgentMethods(api) {
|
|
|
825
98
|
return;
|
|
826
99
|
}
|
|
827
100
|
const effectiveAgentId = providedAgentId || deriveAgentIdFromName(safeName);
|
|
828
|
-
const defaultWorkspace =
|
|
101
|
+
const defaultWorkspace = path3.join(
|
|
829
102
|
getOpenclawStateDir(),
|
|
830
103
|
effectiveAgentId === "main" ? "workspace" : `workspace-${effectiveAgentId}`
|
|
831
104
|
);
|
|
@@ -840,6 +113,11 @@ function registerAgentMethods(api) {
|
|
|
840
113
|
encoding: "utf-8",
|
|
841
114
|
stdio: ["pipe", "pipe", "pipe"]
|
|
842
115
|
});
|
|
116
|
+
try {
|
|
117
|
+
ensureAgentAuthProfiles(effectiveAgentId);
|
|
118
|
+
} catch (authErr) {
|
|
119
|
+
console.warn("[squad.agents.add] failed to copy main auth profile to agent:", authErr);
|
|
120
|
+
}
|
|
843
121
|
respond(true, { ok: true, output: output.slice(0, 1e3) });
|
|
844
122
|
} catch (err2) {
|
|
845
123
|
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
@@ -985,12 +263,12 @@ function registerAgentMethods(api) {
|
|
|
985
263
|
|
|
986
264
|
// src/entities.ts
|
|
987
265
|
import { Type as T } from "@sinclair/typebox";
|
|
988
|
-
import
|
|
989
|
-
import
|
|
266
|
+
import path7 from "path";
|
|
267
|
+
import fs6 from "fs";
|
|
990
268
|
|
|
991
269
|
// src/watcher.ts
|
|
992
|
-
import
|
|
993
|
-
import
|
|
270
|
+
import path4 from "path";
|
|
271
|
+
import fs3 from "fs";
|
|
994
272
|
import chokidar from "chokidar";
|
|
995
273
|
var debounceTimers = /* @__PURE__ */ new Map();
|
|
996
274
|
var DEBOUNCE_MS = 500;
|
|
@@ -1020,29 +298,29 @@ function debouncedFs(relPath, action, fn) {
|
|
|
1020
298
|
);
|
|
1021
299
|
}
|
|
1022
300
|
function isWorkspaceIdentity(filePath, configDir) {
|
|
1023
|
-
const rel =
|
|
301
|
+
const rel = path4.relative(configDir, filePath);
|
|
1024
302
|
const match = rel.match(/^(workspace(?:-([^/]+))?)\/IDENTITY\.md$/);
|
|
1025
303
|
if (!match) return null;
|
|
1026
304
|
const dirName = match[1];
|
|
1027
305
|
const agentId = match[2] ?? "main";
|
|
1028
|
-
return { agentId, workspacePath:
|
|
306
|
+
return { agentId, workspacePath: path4.join(configDir, dirName) };
|
|
1029
307
|
}
|
|
1030
308
|
function isWorkspaceAgentJson(filePath, configDir) {
|
|
1031
|
-
const rel =
|
|
309
|
+
const rel = path4.relative(configDir, filePath);
|
|
1032
310
|
const match = rel.match(/^(workspace(?:-([^/]+))?)\/agent\.json$/);
|
|
1033
311
|
if (!match) return null;
|
|
1034
312
|
const dirName = match[1];
|
|
1035
313
|
const agentId = match[2] ?? "main";
|
|
1036
|
-
return { agentId, workspacePath:
|
|
314
|
+
return { agentId, workspacePath: path4.join(configDir, dirName) };
|
|
1037
315
|
}
|
|
1038
316
|
function isGlobalSkillDir(filePath, configDir) {
|
|
1039
|
-
const rel =
|
|
317
|
+
const rel = path4.relative(configDir, filePath);
|
|
1040
318
|
const match = rel.match(/^skills\/([^/]+)\/?$/);
|
|
1041
319
|
if (!match) return null;
|
|
1042
320
|
return { skillKey: match[1] };
|
|
1043
321
|
}
|
|
1044
322
|
function isWorkspaceSkillDir(filePath, configDir) {
|
|
1045
|
-
const rel =
|
|
323
|
+
const rel = path4.relative(configDir, filePath);
|
|
1046
324
|
const match = rel.match(
|
|
1047
325
|
/^workspace(?:-([^/]+))?\/skills\/([^/]+)\/?$/
|
|
1048
326
|
);
|
|
@@ -1050,21 +328,21 @@ function isWorkspaceSkillDir(filePath, configDir) {
|
|
|
1050
328
|
return { agentId: match[1] ?? "main", skillKey: match[2] };
|
|
1051
329
|
}
|
|
1052
330
|
function isPluginManifest(filePath, configDir) {
|
|
1053
|
-
const rel =
|
|
331
|
+
const rel = path4.relative(configDir, filePath);
|
|
1054
332
|
const match = rel.match(/^extensions\/([^/]+)\/openclaw\.plugin\.json$/);
|
|
1055
333
|
if (!match) return null;
|
|
1056
334
|
return { pluginDirName: match[1] };
|
|
1057
335
|
}
|
|
1058
336
|
function isOpenClawConfig(filePath, configDir) {
|
|
1059
|
-
return
|
|
337
|
+
return path4.relative(configDir, filePath) === "openclaw.json";
|
|
1060
338
|
}
|
|
1061
339
|
function updateAgent(agentId, workspacePath) {
|
|
1062
340
|
const now = Date.now();
|
|
1063
341
|
let name = agentId;
|
|
1064
342
|
const metadata = { workspacePath };
|
|
1065
343
|
try {
|
|
1066
|
-
const content =
|
|
1067
|
-
|
|
344
|
+
const content = fs3.readFileSync(
|
|
345
|
+
path4.join(workspacePath, "IDENTITY.md"),
|
|
1068
346
|
"utf-8"
|
|
1069
347
|
);
|
|
1070
348
|
const parsed = parseIdentityName(content);
|
|
@@ -1073,8 +351,8 @@ function updateAgent(agentId, workspacePath) {
|
|
|
1073
351
|
}
|
|
1074
352
|
if (name === agentId) {
|
|
1075
353
|
try {
|
|
1076
|
-
const raw =
|
|
1077
|
-
|
|
354
|
+
const raw = fs3.readFileSync(
|
|
355
|
+
path4.join(workspacePath, "agent.json"),
|
|
1078
356
|
"utf-8"
|
|
1079
357
|
);
|
|
1080
358
|
const config = JSON.parse(raw);
|
|
@@ -1098,14 +376,14 @@ function updateAgent(agentId, workspacePath) {
|
|
|
1098
376
|
}
|
|
1099
377
|
function updatePlugin(pluginDirName, configDir) {
|
|
1100
378
|
const now = Date.now();
|
|
1101
|
-
const manifestPath =
|
|
379
|
+
const manifestPath = path4.join(
|
|
1102
380
|
configDir,
|
|
1103
381
|
"extensions",
|
|
1104
382
|
pluginDirName,
|
|
1105
383
|
"openclaw.plugin.json"
|
|
1106
384
|
);
|
|
1107
385
|
try {
|
|
1108
|
-
const raw =
|
|
386
|
+
const raw = fs3.readFileSync(manifestPath, "utf-8");
|
|
1109
387
|
const manifest = JSON.parse(raw);
|
|
1110
388
|
const pluginId = manifest.id || pluginDirName;
|
|
1111
389
|
const name = manifest.name || pluginId;
|
|
@@ -1115,7 +393,7 @@ function updatePlugin(pluginDirName, configDir) {
|
|
|
1115
393
|
name,
|
|
1116
394
|
title: name,
|
|
1117
395
|
description: manifest.description || null,
|
|
1118
|
-
metadata: { pluginId, pluginDir:
|
|
396
|
+
metadata: { pluginId, pluginDir: path4.dirname(manifestPath) },
|
|
1119
397
|
source: "filesystem",
|
|
1120
398
|
source_key: manifestPath,
|
|
1121
399
|
created_at: now,
|
|
@@ -1142,7 +420,7 @@ function startWatcher(configDir, onFsChange) {
|
|
|
1142
420
|
});
|
|
1143
421
|
const emitFsChange = (action, filePath) => {
|
|
1144
422
|
if (!onFsChange) return;
|
|
1145
|
-
const rel =
|
|
423
|
+
const rel = path4.relative(configDir, filePath);
|
|
1146
424
|
debouncedFs(rel, action, () => {
|
|
1147
425
|
onFsChange({ action, path: rel });
|
|
1148
426
|
});
|
|
@@ -1196,7 +474,7 @@ function startWatcher(configDir, onFsChange) {
|
|
|
1196
474
|
);
|
|
1197
475
|
return;
|
|
1198
476
|
}
|
|
1199
|
-
const rel =
|
|
477
|
+
const rel = path4.relative(configDir, dirPath);
|
|
1200
478
|
if (/^workspace(-[^/]+)?$/.test(rel)) {
|
|
1201
479
|
debounced("agents", () => scanAgents(configDir));
|
|
1202
480
|
return;
|
|
@@ -1204,7 +482,7 @@ function startWatcher(configDir, onFsChange) {
|
|
|
1204
482
|
};
|
|
1205
483
|
const handleUnlinkDir = (dirPath) => {
|
|
1206
484
|
emitFsChange("unlinkDir", dirPath);
|
|
1207
|
-
const rel =
|
|
485
|
+
const rel = path4.relative(configDir, dirPath);
|
|
1208
486
|
const wsMatch = rel.match(/^workspace(?:-([^/]+))?$/);
|
|
1209
487
|
if (wsMatch) {
|
|
1210
488
|
const agentId = wsMatch[1] ?? "main";
|
|
@@ -1241,26 +519,26 @@ function startWatcher(configDir, onFsChange) {
|
|
|
1241
519
|
}
|
|
1242
520
|
|
|
1243
521
|
// src/filesystem.ts
|
|
1244
|
-
import fs6 from "fs";
|
|
1245
|
-
import path7 from "path";
|
|
1246
|
-
|
|
1247
|
-
// src/layout.ts
|
|
1248
522
|
import fs5 from "fs";
|
|
1249
523
|
import path6 from "path";
|
|
524
|
+
|
|
525
|
+
// src/layout.ts
|
|
526
|
+
import fs4 from "fs";
|
|
527
|
+
import path5 from "path";
|
|
1250
528
|
function resolveMaybeRelativePath(stateDir, p) {
|
|
1251
|
-
if (
|
|
1252
|
-
return
|
|
529
|
+
if (path5.isAbsolute(p)) return path5.resolve(p);
|
|
530
|
+
return path5.resolve(stateDir, p);
|
|
1253
531
|
}
|
|
1254
532
|
function listWorkspaceFallbacks(stateDir) {
|
|
1255
533
|
let entries;
|
|
1256
534
|
try {
|
|
1257
|
-
entries =
|
|
535
|
+
entries = fs4.readdirSync(stateDir, { withFileTypes: true });
|
|
1258
536
|
} catch {
|
|
1259
537
|
return [];
|
|
1260
538
|
}
|
|
1261
539
|
return entries.filter((entry) => entry.isDirectory() && (entry.name === "workspace" || entry.name.startsWith("workspace-"))).map((entry) => {
|
|
1262
540
|
const agentId = entry.name === "workspace" ? "main" : entry.name.replace("workspace-", "");
|
|
1263
|
-
const workspacePath =
|
|
541
|
+
const workspacePath = path5.join(stateDir, entry.name);
|
|
1264
542
|
return {
|
|
1265
543
|
agentId,
|
|
1266
544
|
path: workspacePath,
|
|
@@ -1271,7 +549,7 @@ function listWorkspaceFallbacks(stateDir) {
|
|
|
1271
549
|
}
|
|
1272
550
|
function readOpenclawConfig(configPath) {
|
|
1273
551
|
try {
|
|
1274
|
-
const raw =
|
|
552
|
+
const raw = fs4.readFileSync(configPath, "utf-8");
|
|
1275
553
|
return JSON.parse(raw);
|
|
1276
554
|
} catch {
|
|
1277
555
|
return null;
|
|
@@ -1279,7 +557,7 @@ function readOpenclawConfig(configPath) {
|
|
|
1279
557
|
}
|
|
1280
558
|
function resolveGatewayLayout() {
|
|
1281
559
|
const stateDir = getOpenclawStateDir();
|
|
1282
|
-
const configPath =
|
|
560
|
+
const configPath = path5.join(stateDir, "openclaw.json");
|
|
1283
561
|
const config = readOpenclawConfig(configPath);
|
|
1284
562
|
const workspaces = [];
|
|
1285
563
|
if (config?.agents?.main?.workspace || config?.agents?.main?.workspacePath) {
|
|
@@ -1290,7 +568,7 @@ function resolveGatewayLayout() {
|
|
|
1290
568
|
agentId: "main",
|
|
1291
569
|
path: resolvedPath,
|
|
1292
570
|
source: "config",
|
|
1293
|
-
exists:
|
|
571
|
+
exists: fs4.existsSync(resolvedPath)
|
|
1294
572
|
});
|
|
1295
573
|
}
|
|
1296
574
|
}
|
|
@@ -1303,7 +581,7 @@ function resolveGatewayLayout() {
|
|
|
1303
581
|
agentId,
|
|
1304
582
|
path: resolvedPath,
|
|
1305
583
|
source: "config",
|
|
1306
|
-
exists:
|
|
584
|
+
exists: fs4.existsSync(resolvedPath)
|
|
1307
585
|
});
|
|
1308
586
|
}
|
|
1309
587
|
const deduped = /* @__PURE__ */ new Map();
|
|
@@ -1317,9 +595,9 @@ function resolveGatewayLayout() {
|
|
|
1317
595
|
return {
|
|
1318
596
|
stateDir,
|
|
1319
597
|
configPath,
|
|
1320
|
-
mediaDir:
|
|
1321
|
-
skillsDir:
|
|
1322
|
-
extensionsDir:
|
|
598
|
+
mediaDir: path5.join(stateDir, "media"),
|
|
599
|
+
skillsDir: path5.join(stateDir, "skills"),
|
|
600
|
+
extensionsDir: path5.join(stateDir, "extensions"),
|
|
1323
601
|
defaultFileBrowserRoot,
|
|
1324
602
|
workspaces: resolvedWorkspaces
|
|
1325
603
|
};
|
|
@@ -1329,16 +607,16 @@ function resolveGatewayLayout() {
|
|
|
1329
607
|
var HOME_DIR = process.env.HOME ?? "/root";
|
|
1330
608
|
var OPENCLAW_DIR = getOpenclawStateDir();
|
|
1331
609
|
var SENSITIVE_BLOCKED_DIRS = [
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
610
|
+
path6.join(OPENCLAW_DIR, "credentials"),
|
|
611
|
+
path6.join(OPENCLAW_DIR, "devices"),
|
|
612
|
+
path6.join(OPENCLAW_DIR, "identity")
|
|
1335
613
|
];
|
|
1336
614
|
var SENSITIVE_BLOCKED_FILES = [
|
|
1337
|
-
|
|
615
|
+
path6.join(OPENCLAW_DIR, "squad-ceo-data", "relay", "squad-relay.json")
|
|
1338
616
|
];
|
|
1339
617
|
function isSensitivePath(resolvedPath) {
|
|
1340
618
|
for (const blocked of SENSITIVE_BLOCKED_DIRS) {
|
|
1341
|
-
if (resolvedPath === blocked || resolvedPath.startsWith(blocked +
|
|
619
|
+
if (resolvedPath === blocked || resolvedPath.startsWith(blocked + path6.sep)) {
|
|
1342
620
|
return true;
|
|
1343
621
|
}
|
|
1344
622
|
}
|
|
@@ -1347,7 +625,7 @@ function isSensitivePath(resolvedPath) {
|
|
|
1347
625
|
return true;
|
|
1348
626
|
}
|
|
1349
627
|
}
|
|
1350
|
-
if (
|
|
628
|
+
if (path6.dirname(resolvedPath) === OPENCLAW_DIR && resolvedPath.endsWith(".bak")) {
|
|
1351
629
|
return true;
|
|
1352
630
|
}
|
|
1353
631
|
return false;
|
|
@@ -1396,20 +674,20 @@ function redactOpenclawJson(rawContent) {
|
|
|
1396
674
|
return JSON.stringify(config, null, 2);
|
|
1397
675
|
}
|
|
1398
676
|
function isOpenclawJson(resolvedPath) {
|
|
1399
|
-
return
|
|
677
|
+
return path6.basename(resolvedPath) === OPENCLAW_JSON_FILENAME && resolvedPath.startsWith(OPENCLAW_DIR);
|
|
1400
678
|
}
|
|
1401
679
|
function expandHome(p) {
|
|
1402
680
|
if (p.startsWith("~/") || p === "~") {
|
|
1403
|
-
return
|
|
681
|
+
return path6.join(HOME_DIR, p.slice(1));
|
|
1404
682
|
}
|
|
1405
683
|
return p;
|
|
1406
684
|
}
|
|
1407
685
|
function validatePath(p, allowedRoots) {
|
|
1408
|
-
const resolved =
|
|
686
|
+
const resolved = path6.resolve(expandHome(p));
|
|
1409
687
|
if (!allowedRoots || allowedRoots.length === 0) return resolved;
|
|
1410
688
|
const allowed = allowedRoots.some((root) => {
|
|
1411
|
-
const resolvedRoot =
|
|
1412
|
-
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot +
|
|
689
|
+
const resolvedRoot = path6.resolve(expandHome(root));
|
|
690
|
+
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path6.sep);
|
|
1413
691
|
});
|
|
1414
692
|
if (!allowed) {
|
|
1415
693
|
throw new Error(`Path "${p}" is outside allowed roots`);
|
|
@@ -1446,18 +724,18 @@ function err(message) {
|
|
|
1446
724
|
};
|
|
1447
725
|
}
|
|
1448
726
|
function listDir(dirPath, opts) {
|
|
1449
|
-
const dirents =
|
|
727
|
+
const dirents = fs5.readdirSync(dirPath, { withFileTypes: true });
|
|
1450
728
|
const results = [];
|
|
1451
729
|
for (const dirent of dirents) {
|
|
1452
730
|
if (!opts.includeHidden && dirent.name.startsWith(".")) continue;
|
|
1453
|
-
const entryPath =
|
|
731
|
+
const entryPath = path6.join(dirPath, dirent.name);
|
|
1454
732
|
let type = "other";
|
|
1455
733
|
if (dirent.isFile()) type = "file";
|
|
1456
734
|
else if (dirent.isDirectory()) type = "directory";
|
|
1457
735
|
else if (dirent.isSymbolicLink()) type = "symlink";
|
|
1458
736
|
const entry = { name: dirent.name, path: entryPath, type };
|
|
1459
737
|
try {
|
|
1460
|
-
const stat =
|
|
738
|
+
const stat = fs5.statSync(entryPath);
|
|
1461
739
|
entry.size = stat.size;
|
|
1462
740
|
entry.modified = stat.mtime.toISOString();
|
|
1463
741
|
} catch {
|
|
@@ -1510,8 +788,8 @@ function registerFilesystemTools(api) {
|
|
|
1510
788
|
try {
|
|
1511
789
|
const filePath = validateAndBlockSensitive(params.path, allowedRoots);
|
|
1512
790
|
const encoding = params.encoding ?? "utf-8";
|
|
1513
|
-
let content =
|
|
1514
|
-
const stat =
|
|
791
|
+
let content = fs5.readFileSync(filePath, encoding);
|
|
792
|
+
const stat = fs5.statSync(filePath);
|
|
1515
793
|
if (isOpenclawJson(filePath) && encoding === "utf-8") {
|
|
1516
794
|
content = redactOpenclawJson(content);
|
|
1517
795
|
}
|
|
@@ -1561,10 +839,10 @@ function registerFilesystemTools(api) {
|
|
|
1561
839
|
const encoding = params.encoding ?? "utf-8";
|
|
1562
840
|
const mkdir = params.mkdir !== false;
|
|
1563
841
|
if (mkdir) {
|
|
1564
|
-
|
|
842
|
+
fs5.mkdirSync(path6.dirname(filePath), { recursive: true });
|
|
1565
843
|
}
|
|
1566
|
-
|
|
1567
|
-
const stat =
|
|
844
|
+
fs5.writeFileSync(filePath, content, encoding);
|
|
845
|
+
const stat = fs5.statSync(filePath);
|
|
1568
846
|
return ok({
|
|
1569
847
|
path: filePath,
|
|
1570
848
|
size: stat.size,
|
|
@@ -1633,7 +911,7 @@ function registerFilesystemTools(api) {
|
|
|
1633
911
|
async execute(_id, params) {
|
|
1634
912
|
try {
|
|
1635
913
|
const targetPath = validateWritePath(params.path, allowedRoots);
|
|
1636
|
-
|
|
914
|
+
fs5.mkdirSync(targetPath, { recursive: true });
|
|
1637
915
|
return ok({
|
|
1638
916
|
path: targetPath,
|
|
1639
917
|
created: true
|
|
@@ -1666,7 +944,7 @@ function registerFilesystemTools(api) {
|
|
|
1666
944
|
try {
|
|
1667
945
|
const resolvedOld = validateWritePath(params.oldPath, allowedRoots);
|
|
1668
946
|
const resolvedNew = validateWritePath(params.newPath, allowedRoots);
|
|
1669
|
-
|
|
947
|
+
fs5.renameSync(resolvedOld, resolvedNew);
|
|
1670
948
|
return ok({
|
|
1671
949
|
oldPath: resolvedOld,
|
|
1672
950
|
newPath: resolvedNew,
|
|
@@ -1695,12 +973,12 @@ function registerFilesystemTools(api) {
|
|
|
1695
973
|
async execute(_id, params) {
|
|
1696
974
|
try {
|
|
1697
975
|
const targetPath = validateWritePath(params.path, allowedRoots);
|
|
1698
|
-
const stat =
|
|
976
|
+
const stat = fs5.statSync(targetPath);
|
|
1699
977
|
const wasDirectory = stat.isDirectory();
|
|
1700
978
|
if (wasDirectory) {
|
|
1701
|
-
|
|
979
|
+
fs5.rmSync(targetPath, { recursive: true });
|
|
1702
980
|
} else {
|
|
1703
|
-
|
|
981
|
+
fs5.unlinkSync(targetPath);
|
|
1704
982
|
}
|
|
1705
983
|
return ok({
|
|
1706
984
|
path: targetPath,
|
|
@@ -1752,7 +1030,7 @@ function scanAgents(configDir) {
|
|
|
1752
1030
|
const now = Date.now();
|
|
1753
1031
|
let entries;
|
|
1754
1032
|
try {
|
|
1755
|
-
entries =
|
|
1033
|
+
entries = fs6.readdirSync(configDir, { withFileTypes: true });
|
|
1756
1034
|
} catch {
|
|
1757
1035
|
return;
|
|
1758
1036
|
}
|
|
@@ -1761,20 +1039,20 @@ function scanAgents(configDir) {
|
|
|
1761
1039
|
);
|
|
1762
1040
|
for (const dir of workspaceDirs) {
|
|
1763
1041
|
const agentId = dir.name === "workspace" ? "main" : dir.name.replace("workspace-", "");
|
|
1764
|
-
const workspacePath =
|
|
1042
|
+
const workspacePath = path7.join(configDir, dir.name);
|
|
1765
1043
|
let name = agentId;
|
|
1766
1044
|
const metadata = { workspacePath };
|
|
1767
|
-
const identityPath =
|
|
1045
|
+
const identityPath = path7.join(workspacePath, "IDENTITY.md");
|
|
1768
1046
|
try {
|
|
1769
|
-
const content =
|
|
1047
|
+
const content = fs6.readFileSync(identityPath, "utf-8");
|
|
1770
1048
|
const parsed = parseIdentityName(content);
|
|
1771
1049
|
if (parsed) name = parsed;
|
|
1772
1050
|
} catch {
|
|
1773
1051
|
}
|
|
1774
1052
|
if (name === agentId) {
|
|
1775
|
-
const agentJsonPath =
|
|
1053
|
+
const agentJsonPath = path7.join(workspacePath, "agent.json");
|
|
1776
1054
|
try {
|
|
1777
|
-
const raw =
|
|
1055
|
+
const raw = fs6.readFileSync(agentJsonPath, "utf-8");
|
|
1778
1056
|
const config = JSON.parse(raw);
|
|
1779
1057
|
if (config.displayName) name = config.displayName;
|
|
1780
1058
|
if (config.model) metadata.model = config.model;
|
|
@@ -1799,11 +1077,11 @@ function scanAgents(configDir) {
|
|
|
1799
1077
|
}
|
|
1800
1078
|
function scanSkills(configDir) {
|
|
1801
1079
|
const now = Date.now();
|
|
1802
|
-
const globalSkillsDir =
|
|
1080
|
+
const globalSkillsDir = path7.join(configDir, "skills");
|
|
1803
1081
|
scanSkillsDir(globalSkillsDir, "global", now);
|
|
1804
1082
|
let entries;
|
|
1805
1083
|
try {
|
|
1806
|
-
entries =
|
|
1084
|
+
entries = fs6.readdirSync(configDir, { withFileTypes: true });
|
|
1807
1085
|
} catch {
|
|
1808
1086
|
return;
|
|
1809
1087
|
}
|
|
@@ -1812,26 +1090,26 @@ function scanSkills(configDir) {
|
|
|
1812
1090
|
continue;
|
|
1813
1091
|
}
|
|
1814
1092
|
const agentId = dir.name === "workspace" ? "main" : dir.name.replace("workspace-", "");
|
|
1815
|
-
const agentSkillsDir =
|
|
1093
|
+
const agentSkillsDir = path7.join(configDir, dir.name, "skills");
|
|
1816
1094
|
scanSkillsDir(agentSkillsDir, agentId, now);
|
|
1817
1095
|
}
|
|
1818
1096
|
}
|
|
1819
1097
|
function scanSkillsDir(skillsDir, scope, now) {
|
|
1820
1098
|
let entries;
|
|
1821
1099
|
try {
|
|
1822
|
-
entries =
|
|
1100
|
+
entries = fs6.readdirSync(skillsDir, { withFileTypes: true });
|
|
1823
1101
|
} catch {
|
|
1824
1102
|
return;
|
|
1825
1103
|
}
|
|
1826
1104
|
for (const entry of entries) {
|
|
1827
1105
|
if (!entry.isDirectory()) continue;
|
|
1828
1106
|
const skillKey = entry.name;
|
|
1829
|
-
const skillPath =
|
|
1107
|
+
const skillPath = path7.join(skillsDir, skillKey);
|
|
1830
1108
|
let name = skillKey;
|
|
1831
1109
|
for (const manifestName of ["manifest.json", "package.json"]) {
|
|
1832
1110
|
try {
|
|
1833
|
-
const raw =
|
|
1834
|
-
|
|
1111
|
+
const raw = fs6.readFileSync(
|
|
1112
|
+
path7.join(skillPath, manifestName),
|
|
1835
1113
|
"utf-8"
|
|
1836
1114
|
);
|
|
1837
1115
|
const manifest = JSON.parse(raw);
|
|
@@ -1858,19 +1136,19 @@ function scanSkillsDir(skillsDir, scope, now) {
|
|
|
1858
1136
|
}
|
|
1859
1137
|
function scanPlugins2(configDir) {
|
|
1860
1138
|
const now = Date.now();
|
|
1861
|
-
const extensionsDir =
|
|
1139
|
+
const extensionsDir = path7.join(configDir, "extensions");
|
|
1862
1140
|
let entries;
|
|
1863
1141
|
try {
|
|
1864
|
-
entries =
|
|
1142
|
+
entries = fs6.readdirSync(extensionsDir, { withFileTypes: true });
|
|
1865
1143
|
} catch {
|
|
1866
1144
|
return;
|
|
1867
1145
|
}
|
|
1868
1146
|
for (const dir of entries) {
|
|
1869
1147
|
if (!dir.isDirectory()) continue;
|
|
1870
|
-
const pluginDir =
|
|
1871
|
-
const manifestPath =
|
|
1148
|
+
const pluginDir = path7.join(extensionsDir, dir.name);
|
|
1149
|
+
const manifestPath = path7.join(pluginDir, "openclaw.plugin.json");
|
|
1872
1150
|
try {
|
|
1873
|
-
const raw =
|
|
1151
|
+
const raw = fs6.readFileSync(manifestPath, "utf-8");
|
|
1874
1152
|
const manifest = JSON.parse(raw);
|
|
1875
1153
|
const pluginId = manifest.id || dir.name;
|
|
1876
1154
|
const name = manifest.name || pluginId;
|
|
@@ -1893,8 +1171,8 @@ function scanPlugins2(configDir) {
|
|
|
1893
1171
|
function scanTools(configDir) {
|
|
1894
1172
|
const now = Date.now();
|
|
1895
1173
|
try {
|
|
1896
|
-
const raw =
|
|
1897
|
-
|
|
1174
|
+
const raw = fs6.readFileSync(
|
|
1175
|
+
path7.join(configDir, "openclaw.json"),
|
|
1898
1176
|
"utf-8"
|
|
1899
1177
|
);
|
|
1900
1178
|
const config = JSON.parse(raw);
|
|
@@ -1945,24 +1223,24 @@ var MIME_MAP = {
|
|
|
1945
1223
|
".gz": "application/gzip"
|
|
1946
1224
|
};
|
|
1947
1225
|
function getMimeType(filename) {
|
|
1948
|
-
const ext =
|
|
1226
|
+
const ext = path7.extname(filename).toLowerCase();
|
|
1949
1227
|
return MIME_MAP[ext] ?? "application/octet-stream";
|
|
1950
1228
|
}
|
|
1951
1229
|
function scanMedia(configDir) {
|
|
1952
1230
|
const now = Date.now();
|
|
1953
|
-
const mediaDir =
|
|
1231
|
+
const mediaDir = path7.join(configDir, "media");
|
|
1954
1232
|
scanMediaDir(mediaDir, now);
|
|
1955
1233
|
}
|
|
1956
1234
|
function scanMediaDir(dirPath, now) {
|
|
1957
1235
|
let entries;
|
|
1958
1236
|
try {
|
|
1959
|
-
entries =
|
|
1237
|
+
entries = fs6.readdirSync(dirPath, { withFileTypes: true });
|
|
1960
1238
|
} catch {
|
|
1961
1239
|
return;
|
|
1962
1240
|
}
|
|
1963
1241
|
for (const entry of entries) {
|
|
1964
1242
|
if (entry.name.startsWith(".")) continue;
|
|
1965
|
-
const entryPath =
|
|
1243
|
+
const entryPath = path7.join(dirPath, entry.name);
|
|
1966
1244
|
if (isSensitivePath(entryPath)) continue;
|
|
1967
1245
|
if (entry.isDirectory()) {
|
|
1968
1246
|
registrySet({
|
|
@@ -1983,7 +1261,7 @@ function scanMediaDir(dirPath, now) {
|
|
|
1983
1261
|
let size;
|
|
1984
1262
|
let mtime = now;
|
|
1985
1263
|
try {
|
|
1986
|
-
const stat =
|
|
1264
|
+
const stat = fs6.statSync(entryPath);
|
|
1987
1265
|
size = stat.size;
|
|
1988
1266
|
mtime = stat.mtimeMs;
|
|
1989
1267
|
} catch {
|
|
@@ -2100,24 +1378,24 @@ function registerEntityTools(api, onFsChange) {
|
|
|
2100
1378
|
|
|
2101
1379
|
// src/sql.ts
|
|
2102
1380
|
import { execFile } from "child_process";
|
|
2103
|
-
import
|
|
2104
|
-
import
|
|
1381
|
+
import path8 from "path";
|
|
1382
|
+
import fs7 from "fs";
|
|
2105
1383
|
import { Type as T2 } from "@sinclair/typebox";
|
|
2106
1384
|
var HOME_DIR2 = process.env.HOME ?? "/root";
|
|
2107
|
-
var ALLOWED_DATA_DIR =
|
|
1385
|
+
var ALLOWED_DATA_DIR = path8.join(getOpenclawStateDir(), "squad-ceo-data");
|
|
2108
1386
|
function validateDbPath(dbPath) {
|
|
2109
1387
|
let expanded = dbPath;
|
|
2110
1388
|
if (expanded.startsWith("~/") || expanded === "~") {
|
|
2111
|
-
expanded =
|
|
1389
|
+
expanded = path8.join(HOME_DIR2, expanded.slice(1));
|
|
2112
1390
|
}
|
|
2113
|
-
const resolved =
|
|
2114
|
-
if (resolved !== ALLOWED_DATA_DIR && !resolved.startsWith(ALLOWED_DATA_DIR +
|
|
1391
|
+
const resolved = path8.resolve(expanded);
|
|
1392
|
+
if (resolved !== ALLOWED_DATA_DIR && !resolved.startsWith(ALLOWED_DATA_DIR + path8.sep)) {
|
|
2115
1393
|
throw new Error(
|
|
2116
1394
|
`Access denied: database path must be within ~/.openclaw/squad-ceo-data/`
|
|
2117
1395
|
);
|
|
2118
1396
|
}
|
|
2119
1397
|
try {
|
|
2120
|
-
const stat =
|
|
1398
|
+
const stat = fs7.statSync(resolved);
|
|
2121
1399
|
if (!stat.isFile()) {
|
|
2122
1400
|
throw new Error(`Not a file: ${dbPath}`);
|
|
2123
1401
|
}
|
|
@@ -2184,11 +1462,11 @@ function registerSqlTools(api) {
|
|
|
2184
1462
|
|
|
2185
1463
|
// src/version.ts
|
|
2186
1464
|
import { spawn } from "child_process";
|
|
2187
|
-
import
|
|
2188
|
-
import
|
|
1465
|
+
import fs8 from "fs";
|
|
1466
|
+
import path9 from "path";
|
|
2189
1467
|
import { fileURLToPath } from "url";
|
|
2190
1468
|
var PACKAGE_NAME = "squad-openclaw";
|
|
2191
|
-
var CONFIG_PATH =
|
|
1469
|
+
var CONFIG_PATH = path9.join(getOpenclawStateDir(), "openclaw.json");
|
|
2192
1470
|
var updateInProgress = false;
|
|
2193
1471
|
var VERIFY_TIMEOUT_MS = 2e4;
|
|
2194
1472
|
var VERIFY_INTERVAL_MS = 500;
|
|
@@ -2198,7 +1476,7 @@ var COMMAND_OUTPUT_LIMIT = 8e3;
|
|
|
2198
1476
|
var COMMAND_KILL_GRACE_MS = 2e3;
|
|
2199
1477
|
function readInstalledVersionFromConfig() {
|
|
2200
1478
|
try {
|
|
2201
|
-
const raw =
|
|
1479
|
+
const raw = fs8.readFileSync(CONFIG_PATH, "utf-8");
|
|
2202
1480
|
const cfg = JSON.parse(raw);
|
|
2203
1481
|
const v = cfg?.plugins?.installs?.[PACKAGE_NAME]?.version;
|
|
2204
1482
|
return typeof v === "string" ? v : null;
|
|
@@ -2209,7 +1487,7 @@ function readInstalledVersionFromConfig() {
|
|
|
2209
1487
|
function reconcileInstallMetadata(verification) {
|
|
2210
1488
|
if (!verification.installPath || !verification.packageVersion) return;
|
|
2211
1489
|
try {
|
|
2212
|
-
const raw =
|
|
1490
|
+
const raw = fs8.readFileSync(CONFIG_PATH, "utf-8");
|
|
2213
1491
|
const config = JSON.parse(raw);
|
|
2214
1492
|
if (!config.plugins || typeof config.plugins !== "object") config.plugins = {};
|
|
2215
1493
|
if (!config.plugins.installs || typeof config.plugins.installs !== "object") {
|
|
@@ -2232,15 +1510,15 @@ function reconcileInstallMetadata(verification) {
|
|
|
2232
1510
|
...entry,
|
|
2233
1511
|
enabled: true
|
|
2234
1512
|
};
|
|
2235
|
-
|
|
1513
|
+
fs8.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
2236
1514
|
} catch {
|
|
2237
1515
|
}
|
|
2238
1516
|
}
|
|
2239
1517
|
function getCurrentVersion() {
|
|
2240
1518
|
const thisFile = fileURLToPath(import.meta.url);
|
|
2241
|
-
const pkgPath =
|
|
1519
|
+
const pkgPath = path9.resolve(path9.dirname(thisFile), "..", "package.json");
|
|
2242
1520
|
try {
|
|
2243
|
-
const pkg = JSON.parse(
|
|
1521
|
+
const pkg = JSON.parse(fs8.readFileSync(pkgPath, "utf-8"));
|
|
2244
1522
|
return pkg.version ?? "0.0.0";
|
|
2245
1523
|
} catch {
|
|
2246
1524
|
return "0.0.0";
|
|
@@ -2335,7 +1613,7 @@ function compareVersions(a, b) {
|
|
|
2335
1613
|
function verifyInstalledPluginState() {
|
|
2336
1614
|
let configRaw;
|
|
2337
1615
|
try {
|
|
2338
|
-
configRaw =
|
|
1616
|
+
configRaw = fs8.readFileSync(CONFIG_PATH, "utf-8");
|
|
2339
1617
|
} catch (err2) {
|
|
2340
1618
|
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
2341
1619
|
return {
|
|
@@ -2375,11 +1653,11 @@ function verifyInstalledPluginState() {
|
|
|
2375
1653
|
};
|
|
2376
1654
|
}
|
|
2377
1655
|
const requiredFiles = [
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
1656
|
+
path9.join(installPath, "package.json"),
|
|
1657
|
+
path9.join(installPath, "openclaw.plugin.json"),
|
|
1658
|
+
path9.join(installPath, "dist", "index.js")
|
|
2381
1659
|
];
|
|
2382
|
-
const requiredFilesMissing = requiredFiles.filter((p) => !
|
|
1660
|
+
const requiredFilesMissing = requiredFiles.filter((p) => !fs8.existsSync(p));
|
|
2383
1661
|
if (requiredFilesMissing.length > 0) {
|
|
2384
1662
|
return {
|
|
2385
1663
|
ok: false,
|
|
@@ -2393,7 +1671,7 @@ function verifyInstalledPluginState() {
|
|
|
2393
1671
|
let installedPackage;
|
|
2394
1672
|
try {
|
|
2395
1673
|
installedPackage = JSON.parse(
|
|
2396
|
-
|
|
1674
|
+
fs8.readFileSync(path9.join(installPath, "package.json"), "utf-8")
|
|
2397
1675
|
);
|
|
2398
1676
|
} catch (err2) {
|
|
2399
1677
|
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
@@ -2408,7 +1686,7 @@ function verifyInstalledPluginState() {
|
|
|
2408
1686
|
}
|
|
2409
1687
|
try {
|
|
2410
1688
|
JSON.parse(
|
|
2411
|
-
|
|
1689
|
+
fs8.readFileSync(path9.join(installPath, "openclaw.plugin.json"), "utf-8")
|
|
2412
1690
|
);
|
|
2413
1691
|
} catch (err2) {
|
|
2414
1692
|
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
@@ -2511,7 +1789,7 @@ function registerVersionMethods(api) {
|
|
|
2511
1789
|
let updateOutput = "";
|
|
2512
1790
|
let configBackup = null;
|
|
2513
1791
|
try {
|
|
2514
|
-
configBackup =
|
|
1792
|
+
configBackup = fs8.readFileSync(CONFIG_PATH, "utf-8");
|
|
2515
1793
|
} catch {
|
|
2516
1794
|
}
|
|
2517
1795
|
await runCommand(["doctor", "--fix"], 3e4);
|
|
@@ -2536,7 +1814,7 @@ function registerVersionMethods(api) {
|
|
|
2536
1814
|
} catch (installErr) {
|
|
2537
1815
|
if (configBackup) {
|
|
2538
1816
|
try {
|
|
2539
|
-
|
|
1817
|
+
fs8.writeFileSync(CONFIG_PATH, configBackup, "utf-8");
|
|
2540
1818
|
} catch {
|
|
2541
1819
|
}
|
|
2542
1820
|
}
|
|
@@ -2556,7 +1834,7 @@ function registerVersionMethods(api) {
|
|
|
2556
1834
|
if (!verification.ok) {
|
|
2557
1835
|
if (configBackup) {
|
|
2558
1836
|
try {
|
|
2559
|
-
|
|
1837
|
+
fs8.writeFileSync(CONFIG_PATH, configBackup, "utf-8");
|
|
2560
1838
|
} catch {
|
|
2561
1839
|
}
|
|
2562
1840
|
}
|
|
@@ -2682,34 +1960,22 @@ function registerQuestionMethods(api) {
|
|
|
2682
1960
|
}
|
|
2683
1961
|
|
|
2684
1962
|
// src/sessions.ts
|
|
2685
|
-
import
|
|
2686
|
-
import
|
|
2687
|
-
import
|
|
1963
|
+
import fs9 from "fs";
|
|
1964
|
+
import path10 from "path";
|
|
1965
|
+
import crypto from "crypto";
|
|
1966
|
+
|
|
1967
|
+
// src/gateway-invoke.ts
|
|
2688
1968
|
function asRecord(value) {
|
|
2689
1969
|
return value && typeof value === "object" ? value : null;
|
|
2690
1970
|
}
|
|
2691
|
-
function extractSessionKey(value) {
|
|
2692
|
-
const record = asRecord(value);
|
|
2693
|
-
if (!record) return null;
|
|
2694
|
-
if (typeof record.key === "string" && record.key.trim()) {
|
|
2695
|
-
return record.key.trim();
|
|
2696
|
-
}
|
|
2697
|
-
const nestedKey = asRecord(record.key);
|
|
2698
|
-
if (nestedKey && typeof nestedKey.key === "string" && nestedKey.key.trim()) {
|
|
2699
|
-
return nestedKey.key.trim();
|
|
2700
|
-
}
|
|
2701
|
-
if (typeof record.sessionKey === "string" && record.sessionKey.trim()) {
|
|
2702
|
-
return record.sessionKey.trim();
|
|
2703
|
-
}
|
|
2704
|
-
const nestedSession = asRecord(record.session);
|
|
2705
|
-
if (nestedSession && typeof nestedSession.key === "string" && nestedSession.key.trim()) {
|
|
2706
|
-
return nestedSession.key.trim();
|
|
2707
|
-
}
|
|
2708
|
-
return null;
|
|
2709
|
-
}
|
|
2710
1971
|
function isInvoker(fn) {
|
|
2711
1972
|
return typeof fn === "function";
|
|
2712
1973
|
}
|
|
1974
|
+
function isUnknownGatewayMethodError(message) {
|
|
1975
|
+
return /unknown method|method .* unavailable|not found|invalid[_ ]request|does not exist/i.test(
|
|
1976
|
+
message
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
2713
1979
|
async function callGatewayAny(ctx, api, method, params) {
|
|
2714
1980
|
const ctxGateway = asRecord(ctx.gateway);
|
|
2715
1981
|
const apiGateway = asRecord(api?.gateway);
|
|
@@ -2735,9 +2001,7 @@ async function callGatewayAny(ctx, api, method, params) {
|
|
|
2735
2001
|
} catch (err2) {
|
|
2736
2002
|
lastErr = err2;
|
|
2737
2003
|
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
2738
|
-
if (
|
|
2739
|
-
msg
|
|
2740
|
-
)) {
|
|
2004
|
+
if (isUnknownGatewayMethodError(msg)) {
|
|
2741
2005
|
continue;
|
|
2742
2006
|
}
|
|
2743
2007
|
throw err2;
|
|
@@ -2746,16 +2010,40 @@ async function callGatewayAny(ctx, api, method, params) {
|
|
|
2746
2010
|
if (lastErr) throw lastErr;
|
|
2747
2011
|
throw new Error("Gateway method invocation API unavailable in plugin context");
|
|
2748
2012
|
}
|
|
2013
|
+
|
|
2014
|
+
// src/sessions.ts
|
|
2015
|
+
function asRecord2(value) {
|
|
2016
|
+
return value && typeof value === "object" ? value : null;
|
|
2017
|
+
}
|
|
2018
|
+
function extractSessionKey(value) {
|
|
2019
|
+
const record = asRecord2(value);
|
|
2020
|
+
if (!record) return null;
|
|
2021
|
+
if (typeof record.key === "string" && record.key.trim()) {
|
|
2022
|
+
return record.key.trim();
|
|
2023
|
+
}
|
|
2024
|
+
const nestedKey = asRecord2(record.key);
|
|
2025
|
+
if (nestedKey && typeof nestedKey.key === "string" && nestedKey.key.trim()) {
|
|
2026
|
+
return nestedKey.key.trim();
|
|
2027
|
+
}
|
|
2028
|
+
if (typeof record.sessionKey === "string" && record.sessionKey.trim()) {
|
|
2029
|
+
return record.sessionKey.trim();
|
|
2030
|
+
}
|
|
2031
|
+
const nestedSession = asRecord2(record.session);
|
|
2032
|
+
if (nestedSession && typeof nestedSession.key === "string" && nestedSession.key.trim()) {
|
|
2033
|
+
return nestedSession.key.trim();
|
|
2034
|
+
}
|
|
2035
|
+
return null;
|
|
2036
|
+
}
|
|
2749
2037
|
function parseAgentIdFromSessionKey(sessionKey) {
|
|
2750
2038
|
const m = sessionKey.match(/^agent:([^:]+):/);
|
|
2751
2039
|
return m?.[1] ?? null;
|
|
2752
2040
|
}
|
|
2753
2041
|
function ensureDir(dirPath) {
|
|
2754
|
-
|
|
2042
|
+
fs9.mkdirSync(dirPath, { recursive: true });
|
|
2755
2043
|
}
|
|
2756
2044
|
function readSessionsMap(sessionsJsonPath) {
|
|
2757
2045
|
try {
|
|
2758
|
-
const raw =
|
|
2046
|
+
const raw = fs9.readFileSync(sessionsJsonPath, "utf-8");
|
|
2759
2047
|
const parsed = JSON.parse(raw);
|
|
2760
2048
|
if (parsed && typeof parsed === "object") {
|
|
2761
2049
|
return parsed;
|
|
@@ -2765,22 +2053,22 @@ function readSessionsMap(sessionsJsonPath) {
|
|
|
2765
2053
|
return {};
|
|
2766
2054
|
}
|
|
2767
2055
|
function writeSessionsMap(sessionsJsonPath, sessionsMap) {
|
|
2768
|
-
|
|
2056
|
+
fs9.writeFileSync(sessionsJsonPath, JSON.stringify(sessionsMap, null, 2), "utf-8");
|
|
2769
2057
|
}
|
|
2770
2058
|
function createSessionOnDisk(input) {
|
|
2771
2059
|
const requestedSessionKey = input.requestedSessionKey?.trim();
|
|
2772
2060
|
const requestedAgentId = input.requestedAgentId?.trim();
|
|
2773
2061
|
const derivedAgentId = requestedAgentId || (requestedSessionKey ? parseAgentIdFromSessionKey(requestedSessionKey) : null) || "main";
|
|
2774
|
-
const sessionKey = requestedSessionKey || `agent:${derivedAgentId}:${
|
|
2062
|
+
const sessionKey = requestedSessionKey || `agent:${derivedAgentId}:${crypto.randomUUID()}`;
|
|
2775
2063
|
const stateDir = getOpenclawStateDir();
|
|
2776
|
-
const sessionsDir =
|
|
2777
|
-
const sessionsJsonPath =
|
|
2064
|
+
const sessionsDir = path10.join(stateDir, "agents", derivedAgentId, "sessions");
|
|
2065
|
+
const sessionsJsonPath = path10.join(sessionsDir, "sessions.json");
|
|
2778
2066
|
ensureDir(sessionsDir);
|
|
2779
2067
|
const now = Date.now();
|
|
2780
2068
|
const sessionsMap = readSessionsMap(sessionsJsonPath);
|
|
2781
2069
|
const existing = sessionsMap[sessionKey];
|
|
2782
|
-
const sessionId = typeof existing?.sessionId === "string" && existing.sessionId.trim() ? existing.sessionId :
|
|
2783
|
-
const jsonlPath =
|
|
2070
|
+
const sessionId = typeof existing?.sessionId === "string" && existing.sessionId.trim() ? existing.sessionId : crypto.randomUUID();
|
|
2071
|
+
const jsonlPath = path10.join(sessionsDir, `${sessionId}.jsonl`);
|
|
2784
2072
|
sessionsMap[sessionKey] = {
|
|
2785
2073
|
...existing ?? {},
|
|
2786
2074
|
sessionId,
|
|
@@ -2791,8 +2079,8 @@ function createSessionOnDisk(input) {
|
|
|
2791
2079
|
lastAssistantHasText: typeof existing?.lastAssistantHasText === "boolean" ? existing.lastAssistantHasText : true
|
|
2792
2080
|
};
|
|
2793
2081
|
writeSessionsMap(sessionsJsonPath, sessionsMap);
|
|
2794
|
-
if (!
|
|
2795
|
-
|
|
2082
|
+
if (!fs9.existsSync(jsonlPath)) {
|
|
2083
|
+
fs9.writeFileSync(jsonlPath, "", "utf-8");
|
|
2796
2084
|
}
|
|
2797
2085
|
return {
|
|
2798
2086
|
key: sessionKey,
|
|
@@ -2834,15 +2122,16 @@ function registerSessionMethods(api) {
|
|
|
2834
2122
|
"sessions.create",
|
|
2835
2123
|
createParams
|
|
2836
2124
|
);
|
|
2837
|
-
const normalizedKey = extractSessionKey(nativeResult)
|
|
2125
|
+
const normalizedKey = extractSessionKey(nativeResult);
|
|
2838
2126
|
if (!normalizedKey) {
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2127
|
+
const local = createSessionOnDisk({
|
|
2128
|
+
requestedSessionKey: typeof sessionKey === "string" ? sessionKey : void 0,
|
|
2129
|
+
requestedAgentId: typeof agentId === "string" ? agentId : void 0
|
|
2842
2130
|
});
|
|
2131
|
+
respond(true, local);
|
|
2843
2132
|
return;
|
|
2844
2133
|
}
|
|
2845
|
-
const nativeRecord =
|
|
2134
|
+
const nativeRecord = asRecord2(nativeResult);
|
|
2846
2135
|
respond(true, {
|
|
2847
2136
|
...nativeRecord ?? {},
|
|
2848
2137
|
key: normalizedKey
|
|
@@ -2873,6 +2162,294 @@ function registerSessionMethods(api) {
|
|
|
2873
2162
|
);
|
|
2874
2163
|
}
|
|
2875
2164
|
|
|
2165
|
+
// src/agent-send.ts
|
|
2166
|
+
import fs10 from "fs";
|
|
2167
|
+
import path11 from "path";
|
|
2168
|
+
import { execFile as execFile2 } from "child_process";
|
|
2169
|
+
import { promisify } from "util";
|
|
2170
|
+
var ASSET_REFERENCE_RE = /\{\{asset:([^}]+)\}\}/g;
|
|
2171
|
+
var RETRY_INTERVAL_MS = 1e3;
|
|
2172
|
+
var RETRY_BUDGET_MS = 2e4;
|
|
2173
|
+
var CLI_AGENT_TIMEOUT_MS = 3e4;
|
|
2174
|
+
var execFileAsync = promisify(execFile2);
|
|
2175
|
+
var IMAGE_MIME_BY_EXT = {
|
|
2176
|
+
".png": "image/png",
|
|
2177
|
+
".jpg": "image/jpeg",
|
|
2178
|
+
".jpeg": "image/jpeg",
|
|
2179
|
+
".gif": "image/gif",
|
|
2180
|
+
".webp": "image/webp",
|
|
2181
|
+
".bmp": "image/bmp",
|
|
2182
|
+
".svg": "image/svg+xml",
|
|
2183
|
+
".ico": "image/x-icon",
|
|
2184
|
+
".tif": "image/tiff",
|
|
2185
|
+
".tiff": "image/tiff",
|
|
2186
|
+
".avif": "image/avif"
|
|
2187
|
+
};
|
|
2188
|
+
function asRecord3(value) {
|
|
2189
|
+
return value && typeof value === "object" ? value : null;
|
|
2190
|
+
}
|
|
2191
|
+
function sleep2(ms) {
|
|
2192
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2193
|
+
}
|
|
2194
|
+
function pathWithin(root, candidate) {
|
|
2195
|
+
return candidate === root || candidate.startsWith(`${root}${path11.sep}`);
|
|
2196
|
+
}
|
|
2197
|
+
function parseAssetPathsFromMessage(message) {
|
|
2198
|
+
const result = [];
|
|
2199
|
+
const re = new RegExp(ASSET_REFERENCE_RE.source, "g");
|
|
2200
|
+
let match;
|
|
2201
|
+
while ((match = re.exec(message)) !== null) {
|
|
2202
|
+
const rawPath = String(match[1] ?? "").trim();
|
|
2203
|
+
if (rawPath) result.push(rawPath);
|
|
2204
|
+
}
|
|
2205
|
+
return result;
|
|
2206
|
+
}
|
|
2207
|
+
function parseAssetPathsFromContext(params) {
|
|
2208
|
+
const context = asRecord3(params.context);
|
|
2209
|
+
const assets = context?.assets;
|
|
2210
|
+
if (!Array.isArray(assets)) return [];
|
|
2211
|
+
const result = [];
|
|
2212
|
+
for (const entry of assets) {
|
|
2213
|
+
if (typeof entry === "string") {
|
|
2214
|
+
const trimmed = entry.trim();
|
|
2215
|
+
if (trimmed) result.push(trimmed);
|
|
2216
|
+
continue;
|
|
2217
|
+
}
|
|
2218
|
+
const record = asRecord3(entry);
|
|
2219
|
+
if (!record) continue;
|
|
2220
|
+
const assetPath = typeof record.path === "string" ? record.path.trim() : "";
|
|
2221
|
+
if (assetPath) result.push(assetPath);
|
|
2222
|
+
}
|
|
2223
|
+
return result;
|
|
2224
|
+
}
|
|
2225
|
+
function resolveOpenClawPath(rawPath, stateDir) {
|
|
2226
|
+
const trimmed = rawPath.trim();
|
|
2227
|
+
if (!trimmed) return "";
|
|
2228
|
+
if (trimmed === "~/.openclaw") return stateDir;
|
|
2229
|
+
if (trimmed.startsWith("~/.openclaw/")) {
|
|
2230
|
+
return path11.join(stateDir, trimmed.slice("~/.openclaw/".length));
|
|
2231
|
+
}
|
|
2232
|
+
return trimmed;
|
|
2233
|
+
}
|
|
2234
|
+
function resolveMediaAssetCandidate(rawPath, stateDir) {
|
|
2235
|
+
const mapped = resolveOpenClawPath(rawPath, stateDir);
|
|
2236
|
+
if (!mapped) return null;
|
|
2237
|
+
const mediaRoot = path11.resolve(path11.join(stateDir, "media"));
|
|
2238
|
+
const resolved = path11.resolve(mapped);
|
|
2239
|
+
if (!pathWithin(mediaRoot, resolved)) return null;
|
|
2240
|
+
return {
|
|
2241
|
+
sourcePath: rawPath,
|
|
2242
|
+
resolvedPath: resolved
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
function dedupeAssetCandidates(candidates) {
|
|
2246
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2247
|
+
const deduped = [];
|
|
2248
|
+
for (const candidate of candidates) {
|
|
2249
|
+
if (seen.has(candidate.resolvedPath)) continue;
|
|
2250
|
+
seen.add(candidate.resolvedPath);
|
|
2251
|
+
deduped.push(candidate);
|
|
2252
|
+
}
|
|
2253
|
+
return deduped;
|
|
2254
|
+
}
|
|
2255
|
+
async function waitForAvailableAssets(candidates) {
|
|
2256
|
+
if (candidates.length === 0) return [];
|
|
2257
|
+
const pending = new Map(
|
|
2258
|
+
candidates.map((candidate) => [candidate.resolvedPath, candidate])
|
|
2259
|
+
);
|
|
2260
|
+
const deadline = Date.now() + RETRY_BUDGET_MS;
|
|
2261
|
+
while (pending.size > 0) {
|
|
2262
|
+
for (const [resolvedPath, candidate] of pending.entries()) {
|
|
2263
|
+
try {
|
|
2264
|
+
const stat = await fs10.promises.stat(resolvedPath);
|
|
2265
|
+
if (stat.isFile()) {
|
|
2266
|
+
pending.delete(resolvedPath);
|
|
2267
|
+
}
|
|
2268
|
+
} catch {
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
if (pending.size === 0) break;
|
|
2272
|
+
if (Date.now() >= deadline) break;
|
|
2273
|
+
await sleep2(RETRY_INTERVAL_MS);
|
|
2274
|
+
}
|
|
2275
|
+
return Array.from(pending.values());
|
|
2276
|
+
}
|
|
2277
|
+
function resolveImageMimeType(filePath) {
|
|
2278
|
+
const ext = path11.extname(filePath).toLowerCase();
|
|
2279
|
+
return IMAGE_MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
2280
|
+
}
|
|
2281
|
+
function extractGatewayToken(config) {
|
|
2282
|
+
const gateway = asRecord3(config.gateway);
|
|
2283
|
+
const auth = asRecord3(gateway?.auth);
|
|
2284
|
+
const remote = asRecord3(gateway?.remote);
|
|
2285
|
+
const fromAuth = typeof auth?.token === "string" ? auth.token.trim() : "";
|
|
2286
|
+
if (fromAuth) return fromAuth;
|
|
2287
|
+
const fromRemote = typeof remote?.token === "string" ? remote.token.trim() : "";
|
|
2288
|
+
if (fromRemote) return fromRemote;
|
|
2289
|
+
const fromGateway = typeof gateway?.token === "string" ? gateway.token.trim() : "";
|
|
2290
|
+
if (fromGateway) return fromGateway;
|
|
2291
|
+
return void 0;
|
|
2292
|
+
}
|
|
2293
|
+
function resolveGatewayUrlFromConfig(config) {
|
|
2294
|
+
const gateway = asRecord3(config.gateway);
|
|
2295
|
+
const host = typeof gateway?.host === "string" && gateway.host.trim() ? gateway.host.trim() : "127.0.0.1";
|
|
2296
|
+
const port = typeof gateway?.port === "number" && Number.isFinite(gateway.port) ? gateway.port : 18789;
|
|
2297
|
+
const tlsRaw = gateway?.tls;
|
|
2298
|
+
const tls = typeof tlsRaw === "boolean" ? tlsRaw : Boolean(asRecord3(tlsRaw)?.enabled);
|
|
2299
|
+
return `${tls ? "wss" : "ws"}://${host}:${port}`;
|
|
2300
|
+
}
|
|
2301
|
+
function extractCliErrorMessage(payload) {
|
|
2302
|
+
const payloadRecord = asRecord3(payload);
|
|
2303
|
+
if (typeof payloadRecord?.error === "string") return payloadRecord.error;
|
|
2304
|
+
const errorRecord = asRecord3(payloadRecord?.error);
|
|
2305
|
+
if (typeof errorRecord?.message === "string") return errorRecord.message;
|
|
2306
|
+
const nestedPayload = asRecord3(payloadRecord?.payload);
|
|
2307
|
+
if (typeof nestedPayload?.error === "string") return nestedPayload.error;
|
|
2308
|
+
return "";
|
|
2309
|
+
}
|
|
2310
|
+
function parseCliJson(raw) {
|
|
2311
|
+
const trimmed = raw.trim();
|
|
2312
|
+
if (!trimmed) return null;
|
|
2313
|
+
try {
|
|
2314
|
+
const parsed = JSON.parse(trimmed);
|
|
2315
|
+
return asRecord3(parsed);
|
|
2316
|
+
} catch {
|
|
2317
|
+
return null;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
function isGatewayCallUnavailableError(message) {
|
|
2321
|
+
return /gateway method invocation api unavailable in plugin context/i.test(message);
|
|
2322
|
+
}
|
|
2323
|
+
async function callAgentViaCli(stateDir, params) {
|
|
2324
|
+
const configPath = path11.join(stateDir, "openclaw.json");
|
|
2325
|
+
const configRaw = fs10.readFileSync(configPath, "utf-8");
|
|
2326
|
+
const parsedConfig = JSON.parse(configRaw);
|
|
2327
|
+
const token = extractGatewayToken(parsedConfig);
|
|
2328
|
+
const url = resolveGatewayUrlFromConfig(parsedConfig);
|
|
2329
|
+
const args = [
|
|
2330
|
+
"gateway",
|
|
2331
|
+
"call",
|
|
2332
|
+
"agent",
|
|
2333
|
+
"--json",
|
|
2334
|
+
"--timeout",
|
|
2335
|
+
String(CLI_AGENT_TIMEOUT_MS),
|
|
2336
|
+
"--params",
|
|
2337
|
+
JSON.stringify(params),
|
|
2338
|
+
"--url",
|
|
2339
|
+
url
|
|
2340
|
+
];
|
|
2341
|
+
if (token) {
|
|
2342
|
+
args.push("--token", token);
|
|
2343
|
+
}
|
|
2344
|
+
try {
|
|
2345
|
+
const { stdout } = await execFileAsync("openclaw", args, {
|
|
2346
|
+
env: process.env,
|
|
2347
|
+
maxBuffer: 10 * 1024 * 1024
|
|
2348
|
+
});
|
|
2349
|
+
const json2 = parseCliJson(stdout);
|
|
2350
|
+
if (!json2) {
|
|
2351
|
+
throw new Error("openclaw gateway call agent returned non-JSON output");
|
|
2352
|
+
}
|
|
2353
|
+
if (json2.ok === false) {
|
|
2354
|
+
const msg = extractCliErrorMessage(json2) || JSON.stringify(json2.error ?? json2);
|
|
2355
|
+
throw new Error(msg);
|
|
2356
|
+
}
|
|
2357
|
+
if ("payload" in json2) return json2.payload;
|
|
2358
|
+
return json2;
|
|
2359
|
+
} catch (err2) {
|
|
2360
|
+
const error = err2;
|
|
2361
|
+
const stdoutJson = parseCliJson(typeof error.stdout === "string" ? error.stdout : "");
|
|
2362
|
+
if (stdoutJson) {
|
|
2363
|
+
if (stdoutJson.ok === false) {
|
|
2364
|
+
const msg = extractCliErrorMessage(stdoutJson) || JSON.stringify(stdoutJson.error ?? stdoutJson);
|
|
2365
|
+
throw new Error(msg);
|
|
2366
|
+
}
|
|
2367
|
+
if ("payload" in stdoutJson) return stdoutJson.payload;
|
|
2368
|
+
return stdoutJson;
|
|
2369
|
+
}
|
|
2370
|
+
const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
|
|
2371
|
+
const message = stderr || (typeof error.message === "string" ? error.message : String(err2));
|
|
2372
|
+
throw new Error(message);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
async function buildImageAttachments(candidates) {
|
|
2376
|
+
const attachments = [];
|
|
2377
|
+
for (const candidate of candidates) {
|
|
2378
|
+
const buffer = await fs10.promises.readFile(candidate.resolvedPath);
|
|
2379
|
+
attachments.push({
|
|
2380
|
+
type: "image",
|
|
2381
|
+
mimeType: resolveImageMimeType(candidate.resolvedPath),
|
|
2382
|
+
fileName: path11.basename(candidate.resolvedPath),
|
|
2383
|
+
content: buffer.toString("base64")
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
return attachments;
|
|
2387
|
+
}
|
|
2388
|
+
function registerAgentSendMethod(api) {
|
|
2389
|
+
api.registerGatewayMethod(
|
|
2390
|
+
"squad.agent.send",
|
|
2391
|
+
async (ctx) => {
|
|
2392
|
+
const { params, respond } = ctx;
|
|
2393
|
+
const message = typeof params?.message === "string" ? params.message : "";
|
|
2394
|
+
if (!message.trim()) {
|
|
2395
|
+
respond(false, { error: "Invalid 'message' parameter (must be a non-empty string)" });
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
const stateDir = getOpenclawStateDir();
|
|
2399
|
+
const rawAssetPaths = [
|
|
2400
|
+
...parseAssetPathsFromMessage(message),
|
|
2401
|
+
...parseAssetPathsFromContext(params)
|
|
2402
|
+
];
|
|
2403
|
+
const candidates = dedupeAssetCandidates(
|
|
2404
|
+
rawAssetPaths.map((rawPath) => resolveMediaAssetCandidate(rawPath, stateDir)).filter((candidate) => candidate !== null)
|
|
2405
|
+
);
|
|
2406
|
+
if (rawAssetPaths.length > 0 && candidates.length === 0) {
|
|
2407
|
+
respond(false, {
|
|
2408
|
+
error: "Referenced image assets must be under ~/.openclaw/media/"
|
|
2409
|
+
});
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
if (candidates.length > 0) {
|
|
2413
|
+
const missing = await waitForAvailableAssets(candidates);
|
|
2414
|
+
if (missing.length > 0) {
|
|
2415
|
+
const missingPaths = missing.map((entry) => entry.sourcePath).join(", ");
|
|
2416
|
+
respond(false, {
|
|
2417
|
+
error: `Referenced image assets not available after ${RETRY_BUDGET_MS}ms: ${missingPaths}`
|
|
2418
|
+
});
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
try {
|
|
2423
|
+
const imageAttachments = await buildImageAttachments(candidates);
|
|
2424
|
+
const existingAttachments = Array.isArray(params.attachments) ? params.attachments : [];
|
|
2425
|
+
const forwardedParams = {
|
|
2426
|
+
...params,
|
|
2427
|
+
attachments: [...existingAttachments, ...imageAttachments]
|
|
2428
|
+
};
|
|
2429
|
+
let nativeResult;
|
|
2430
|
+
try {
|
|
2431
|
+
nativeResult = await callGatewayAny(
|
|
2432
|
+
ctx,
|
|
2433
|
+
api,
|
|
2434
|
+
"agent",
|
|
2435
|
+
forwardedParams
|
|
2436
|
+
);
|
|
2437
|
+
} catch (err2) {
|
|
2438
|
+
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
2439
|
+
if (!isGatewayCallUnavailableError(msg)) {
|
|
2440
|
+
throw err2;
|
|
2441
|
+
}
|
|
2442
|
+
nativeResult = await callAgentViaCli(stateDir, forwardedParams);
|
|
2443
|
+
}
|
|
2444
|
+
respond(true, nativeResult);
|
|
2445
|
+
} catch (err2) {
|
|
2446
|
+
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
2447
|
+
respond(false, { error: msg });
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
);
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2876
2453
|
// src/shared-api.ts
|
|
2877
2454
|
var CORE_TOOLS = [
|
|
2878
2455
|
"exec",
|
|
@@ -2926,6 +2503,7 @@ function registerSquadSharedApi(api, onFsChange) {
|
|
|
2926
2503
|
registerAgentMethods(api);
|
|
2927
2504
|
registerQuestionMethods(api);
|
|
2928
2505
|
registerSessionMethods(api);
|
|
2506
|
+
registerAgentSendMethod(api);
|
|
2929
2507
|
const invokeTool = async (tool, args) => {
|
|
2930
2508
|
const executeFn = toolExecutors.get(tool);
|
|
2931
2509
|
if (!executeFn) {
|
|
@@ -2980,14 +2558,79 @@ function registerSquadSharedApi(api, onFsChange) {
|
|
|
2980
2558
|
}
|
|
2981
2559
|
|
|
2982
2560
|
// src/migrations/runner.ts
|
|
2983
|
-
import
|
|
2984
|
-
import
|
|
2561
|
+
import fs12 from "fs";
|
|
2562
|
+
import path13 from "path";
|
|
2985
2563
|
|
|
2986
2564
|
// src/migrations/001-enable-main-subagent-access.ts
|
|
2565
|
+
import fs11 from "fs";
|
|
2566
|
+
import path12 from "path";
|
|
2567
|
+
function asRecord4(value) {
|
|
2568
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2569
|
+
return value;
|
|
2570
|
+
}
|
|
2571
|
+
function mergeStringArrayWithWildcard(value) {
|
|
2572
|
+
const next = /* @__PURE__ */ new Set();
|
|
2573
|
+
if (Array.isArray(value)) {
|
|
2574
|
+
for (const entry of value) {
|
|
2575
|
+
if (typeof entry === "string" && entry.trim()) next.add(entry.trim());
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
next.add("*");
|
|
2579
|
+
return Array.from(next);
|
|
2580
|
+
}
|
|
2581
|
+
function patchConfigOnDisk() {
|
|
2582
|
+
const configPath = path12.join(getOpenclawStateDir(), "openclaw.json");
|
|
2583
|
+
const raw = fs11.readFileSync(configPath, "utf-8");
|
|
2584
|
+
const parsed = JSON.parse(raw);
|
|
2585
|
+
const agents = asRecord4(parsed.agents) ?? {};
|
|
2586
|
+
const defaults = asRecord4(agents.defaults) ?? {};
|
|
2587
|
+
const subagentsDefaults = asRecord4(defaults.subagents) ?? {};
|
|
2588
|
+
defaults.maxConcurrent = 4;
|
|
2589
|
+
defaults.subagents = {
|
|
2590
|
+
...subagentsDefaults,
|
|
2591
|
+
maxConcurrent: 8
|
|
2592
|
+
};
|
|
2593
|
+
const listRaw = Array.isArray(agents.list) ? agents.list : [];
|
|
2594
|
+
const list = listRaw.map((entry) => asRecord4(entry)).filter((entry) => Boolean(entry));
|
|
2595
|
+
const mainIndex = list.findIndex((entry) => entry.id === "main");
|
|
2596
|
+
const existingMain = mainIndex >= 0 ? list[mainIndex] : {};
|
|
2597
|
+
const existingIdentity = asRecord4(existingMain.identity) ?? {};
|
|
2598
|
+
const existingTools = asRecord4(existingMain.tools) ?? {};
|
|
2599
|
+
const existingSubagents = asRecord4(existingMain.subagents) ?? {};
|
|
2600
|
+
const nextMain = {
|
|
2601
|
+
...existingMain,
|
|
2602
|
+
id: "main",
|
|
2603
|
+
identity: {
|
|
2604
|
+
...existingIdentity,
|
|
2605
|
+
name: typeof existingIdentity.name === "string" && existingIdentity.name.trim() ? existingIdentity.name : "Pepper"
|
|
2606
|
+
},
|
|
2607
|
+
tools: {
|
|
2608
|
+
...existingTools,
|
|
2609
|
+
allow: mergeStringArrayWithWildcard(existingTools.allow)
|
|
2610
|
+
},
|
|
2611
|
+
subagents: {
|
|
2612
|
+
...existingSubagents,
|
|
2613
|
+
allowAgents: mergeStringArrayWithWildcard(existingSubagents.allowAgents)
|
|
2614
|
+
}
|
|
2615
|
+
};
|
|
2616
|
+
if (mainIndex >= 0) list[mainIndex] = nextMain;
|
|
2617
|
+
else list.push(nextMain);
|
|
2618
|
+
parsed.agents = {
|
|
2619
|
+
...agents,
|
|
2620
|
+
defaults,
|
|
2621
|
+
list
|
|
2622
|
+
};
|
|
2623
|
+
fs11.writeFileSync(configPath, `${JSON.stringify(parsed, null, 2)}
|
|
2624
|
+
`, "utf-8");
|
|
2625
|
+
}
|
|
2987
2626
|
var migration = {
|
|
2988
2627
|
id: "001-enable-main-subagent-access",
|
|
2989
2628
|
description: "Enable full main-agent tool and subagent spawning defaults",
|
|
2990
2629
|
run: async ({ gatewayCall }) => {
|
|
2630
|
+
const isGatewayApiUnavailableError = (error) => {
|
|
2631
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2632
|
+
return /gateway method invocation api unavailable in plugin context/i.test(msg);
|
|
2633
|
+
};
|
|
2991
2634
|
const doPatch = async (baseHash) => {
|
|
2992
2635
|
await gatewayCall("config.patch", {
|
|
2993
2636
|
...baseHash ? { baseHash } : {},
|
|
@@ -3017,28 +2660,63 @@ var migration = {
|
|
|
3017
2660
|
})
|
|
3018
2661
|
});
|
|
3019
2662
|
};
|
|
3020
|
-
let snapshot
|
|
2663
|
+
let snapshot;
|
|
2664
|
+
try {
|
|
2665
|
+
snapshot = await gatewayCall("config.get", {});
|
|
2666
|
+
} catch (error) {
|
|
2667
|
+
if (isGatewayApiUnavailableError(error)) {
|
|
2668
|
+
patchConfigOnDisk();
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
throw error;
|
|
2672
|
+
}
|
|
3021
2673
|
try {
|
|
3022
2674
|
await doPatch(snapshot?.hash);
|
|
3023
2675
|
} catch (firstErr) {
|
|
3024
2676
|
const msg = firstErr instanceof Error ? firstErr.message : String(firstErr);
|
|
2677
|
+
if (isGatewayApiUnavailableError(firstErr)) {
|
|
2678
|
+
patchConfigOnDisk();
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
3025
2681
|
if (!/config changed since last load/i.test(msg)) throw firstErr;
|
|
3026
|
-
|
|
2682
|
+
try {
|
|
2683
|
+
snapshot = await gatewayCall("config.get", {});
|
|
2684
|
+
} catch (secondErr) {
|
|
2685
|
+
if (isGatewayApiUnavailableError(secondErr)) {
|
|
2686
|
+
patchConfigOnDisk();
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
throw secondErr;
|
|
2690
|
+
}
|
|
3027
2691
|
await doPatch(snapshot?.hash);
|
|
3028
2692
|
}
|
|
3029
2693
|
}
|
|
3030
2694
|
};
|
|
3031
2695
|
var enable_main_subagent_access_default = migration;
|
|
3032
2696
|
|
|
2697
|
+
// src/migrations/002-backfill-agent-auth-profiles.ts
|
|
2698
|
+
var migration2 = {
|
|
2699
|
+
id: "002-backfill-agent-auth-profiles",
|
|
2700
|
+
description: "Ensure non-main agents have auth-profiles copied from main when missing",
|
|
2701
|
+
run: async () => {
|
|
2702
|
+
const copied = backfillAgentAuthProfiles();
|
|
2703
|
+
if (copied.length > 0) {
|
|
2704
|
+
console.log(`[startup-migrations] auth profile backfill copied: ${copied.join(", ")}`);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
};
|
|
2708
|
+
var backfill_agent_auth_profiles_default = migration2;
|
|
2709
|
+
|
|
3033
2710
|
// src/migrations/index.ts
|
|
3034
2711
|
var STARTUP_MIGRATIONS = [
|
|
3035
|
-
enable_main_subagent_access_default
|
|
2712
|
+
enable_main_subagent_access_default,
|
|
2713
|
+
backfill_agent_auth_profiles_default
|
|
3036
2714
|
// Append new startup migrations here.
|
|
3037
2715
|
];
|
|
3038
2716
|
|
|
3039
2717
|
// src/migrations/runner.ts
|
|
3040
|
-
var MIGRATIONS_DIR =
|
|
3041
|
-
var MIGRATIONS_PATH =
|
|
2718
|
+
var MIGRATIONS_DIR = path13.join(getOpenclawStateDir(), "squad-ceo-data");
|
|
2719
|
+
var MIGRATIONS_PATH = path13.join(MIGRATIONS_DIR, "migrations.json");
|
|
3042
2720
|
function defaultState() {
|
|
3043
2721
|
return {
|
|
3044
2722
|
version: 1,
|
|
@@ -3047,7 +2725,7 @@ function defaultState() {
|
|
|
3047
2725
|
}
|
|
3048
2726
|
function readState() {
|
|
3049
2727
|
try {
|
|
3050
|
-
const raw =
|
|
2728
|
+
const raw = fs12.readFileSync(MIGRATIONS_PATH, "utf-8");
|
|
3051
2729
|
const parsed = JSON.parse(raw);
|
|
3052
2730
|
if (!Array.isArray(parsed.completed)) return defaultState();
|
|
3053
2731
|
return {
|
|
@@ -3059,8 +2737,8 @@ function readState() {
|
|
|
3059
2737
|
}
|
|
3060
2738
|
}
|
|
3061
2739
|
function writeState(state) {
|
|
3062
|
-
|
|
3063
|
-
|
|
2740
|
+
fs12.mkdirSync(MIGRATIONS_DIR, { recursive: true });
|
|
2741
|
+
fs12.writeFileSync(MIGRATIONS_PATH, JSON.stringify(state, null, 2), "utf-8");
|
|
3064
2742
|
}
|
|
3065
2743
|
function makeGatewayCaller(api) {
|
|
3066
2744
|
return async (method, params = {}) => {
|
|
@@ -3075,11 +2753,11 @@ function makeGatewayCaller(api) {
|
|
|
3075
2753
|
throw new Error("Gateway method invocation API unavailable in plugin context");
|
|
3076
2754
|
};
|
|
3077
2755
|
}
|
|
3078
|
-
async function runMigration(state,
|
|
3079
|
-
if (state.completed.some((row) => row.id ===
|
|
2756
|
+
async function runMigration(state, migration3, gatewayCall) {
|
|
2757
|
+
if (state.completed.some((row) => row.id === migration3.id)) {
|
|
3080
2758
|
return { state, applied: false };
|
|
3081
2759
|
}
|
|
3082
|
-
await
|
|
2760
|
+
await migration3.run({ gatewayCall });
|
|
3083
2761
|
return {
|
|
3084
2762
|
applied: true,
|
|
3085
2763
|
state: {
|
|
@@ -3087,7 +2765,7 @@ async function runMigration(state, migration2, gatewayCall) {
|
|
|
3087
2765
|
completed: [
|
|
3088
2766
|
...state.completed,
|
|
3089
2767
|
{
|
|
3090
|
-
id:
|
|
2768
|
+
id: migration3.id,
|
|
3091
2769
|
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3092
2770
|
}
|
|
3093
2771
|
]
|
|
@@ -3097,34 +2775,707 @@ async function runMigration(state, migration2, gatewayCall) {
|
|
|
3097
2775
|
async function runStartupMigrations(api) {
|
|
3098
2776
|
const gatewayCall = makeGatewayCaller(api);
|
|
3099
2777
|
let state = readState();
|
|
3100
|
-
for (const
|
|
2778
|
+
for (const migration3 of STARTUP_MIGRATIONS) {
|
|
3101
2779
|
try {
|
|
3102
|
-
const result = await runMigration(state,
|
|
2780
|
+
const result = await runMigration(state, migration3, gatewayCall);
|
|
3103
2781
|
state = result.state;
|
|
3104
2782
|
if (result.applied) {
|
|
3105
2783
|
writeState(state);
|
|
3106
|
-
console.log(`[startup-migrations] applied: ${
|
|
2784
|
+
console.log(`[startup-migrations] applied: ${migration3.id}`);
|
|
3107
2785
|
}
|
|
3108
2786
|
} catch (err2) {
|
|
3109
2787
|
const msg = err2 instanceof Error ? err2.message : String(err2);
|
|
3110
|
-
console.warn(`[startup-migrations] failed: ${
|
|
2788
|
+
console.warn(`[startup-migrations] failed: ${migration3.id}: ${msg}`);
|
|
3111
2789
|
break;
|
|
3112
2790
|
}
|
|
3113
2791
|
}
|
|
3114
2792
|
}
|
|
3115
2793
|
|
|
2794
|
+
// src/http-routes.ts
|
|
2795
|
+
import crypto2 from "crypto";
|
|
2796
|
+
var DEFAULT_ALLOWED_ORIGINS = [
|
|
2797
|
+
"https://squad.ceo",
|
|
2798
|
+
"https://www.squad.ceo",
|
|
2799
|
+
"https://squad.pages.dev",
|
|
2800
|
+
"http://localhost:5174",
|
|
2801
|
+
"http://localhost:5800"
|
|
2802
|
+
];
|
|
2803
|
+
var PAIRING_REQUEST_METHODS = [
|
|
2804
|
+
"node.pair.request",
|
|
2805
|
+
"devices.pair.request",
|
|
2806
|
+
"device.pair.request"
|
|
2807
|
+
];
|
|
2808
|
+
var PAIRING_STATUS_METHODS = [
|
|
2809
|
+
"node.pair.status",
|
|
2810
|
+
"devices.pair.status",
|
|
2811
|
+
"device.pair.status",
|
|
2812
|
+
"node.pair.get"
|
|
2813
|
+
];
|
|
2814
|
+
var PROOF_MAX_SKEW_MS = 5 * 60 * 1e3;
|
|
2815
|
+
var DEFAULT_PAIRING_TTL_MS = 15 * 60 * 1e3;
|
|
2816
|
+
var NONCE_TTL_MS = 10 * 60 * 1e3;
|
|
2817
|
+
var LOCATOR_REFRESH_INTERVAL_MS = 15 * 60 * 1e3;
|
|
2818
|
+
var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
2819
|
+
function asRecord5(value) {
|
|
2820
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2821
|
+
return value;
|
|
2822
|
+
}
|
|
2823
|
+
function pickString(value) {
|
|
2824
|
+
if (typeof value !== "string") return null;
|
|
2825
|
+
const trimmed = value.trim();
|
|
2826
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
2827
|
+
}
|
|
2828
|
+
function pickNumber(value) {
|
|
2829
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
2830
|
+
return value;
|
|
2831
|
+
}
|
|
2832
|
+
function json(body, status = 200) {
|
|
2833
|
+
return new Response(JSON.stringify(body), {
|
|
2834
|
+
status,
|
|
2835
|
+
headers: {
|
|
2836
|
+
"Content-Type": "application/json",
|
|
2837
|
+
"Cache-Control": "no-store"
|
|
2838
|
+
}
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
function withCors(request, response, allowMethods = "GET, POST, OPTIONS") {
|
|
2842
|
+
const headers = new Headers(response.headers);
|
|
2843
|
+
const origin = request.headers.get("origin");
|
|
2844
|
+
const allowedOrigin = resolveAllowedOrigin(origin);
|
|
2845
|
+
if (allowedOrigin) {
|
|
2846
|
+
headers.set("Access-Control-Allow-Origin", allowedOrigin);
|
|
2847
|
+
headers.set("Access-Control-Allow-Methods", allowMethods);
|
|
2848
|
+
headers.set("Access-Control-Allow-Headers", "Content-Type");
|
|
2849
|
+
headers.set("Vary", "Origin");
|
|
2850
|
+
}
|
|
2851
|
+
return new Response(response.body, {
|
|
2852
|
+
status: response.status,
|
|
2853
|
+
statusText: response.statusText,
|
|
2854
|
+
headers
|
|
2855
|
+
});
|
|
2856
|
+
}
|
|
2857
|
+
function jsonError(request, code, message, status = 500, extra = {}) {
|
|
2858
|
+
return withCors(request, json({ ok: false, code, error: message, ...extra }, status));
|
|
2859
|
+
}
|
|
2860
|
+
function resolveAllowedOrigin(origin) {
|
|
2861
|
+
if (!origin) return null;
|
|
2862
|
+
const configured = process.env.SQUAD_ALLOWED_ORIGINS ? process.env.SQUAD_ALLOWED_ORIGINS.split(",").map((item) => item.trim()).filter(Boolean) : [];
|
|
2863
|
+
const allowed = configured.length > 0 ? configured : DEFAULT_ALLOWED_ORIGINS;
|
|
2864
|
+
return allowed.includes(origin) ? origin : null;
|
|
2865
|
+
}
|
|
2866
|
+
function isTailnetContext(request) {
|
|
2867
|
+
if (/^(1|true)$/i.test(process.env.SQUAD_ALLOW_NON_TAILNET_INTERNAL ?? "")) {
|
|
2868
|
+
return true;
|
|
2869
|
+
}
|
|
2870
|
+
const hasTailnetHeader = !!request.headers.get("x-tailscale-user-login") || !!request.headers.get("x-tailscale-user-name") || !!request.headers.get("x-tailscale-user-email") || !!request.headers.get("x-tailscale-node-name") || !!request.headers.get("x-tailscale-tailnet");
|
|
2871
|
+
if (hasTailnetHeader) return true;
|
|
2872
|
+
try {
|
|
2873
|
+
const url = new URL(request.url);
|
|
2874
|
+
if (url.hostname.toLowerCase().endsWith(".ts.net")) return true;
|
|
2875
|
+
} catch {
|
|
2876
|
+
}
|
|
2877
|
+
const forwardedHost = request.headers.get("x-forwarded-host")?.toLowerCase() ?? "";
|
|
2878
|
+
return forwardedHost.endsWith(".ts.net");
|
|
2879
|
+
}
|
|
2880
|
+
function ensureOriginAllowed(request) {
|
|
2881
|
+
const origin = request.headers.get("origin");
|
|
2882
|
+
if (!origin) return null;
|
|
2883
|
+
return resolveAllowedOrigin(origin);
|
|
2884
|
+
}
|
|
2885
|
+
function decodeBase64Url(value) {
|
|
2886
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
2887
|
+
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
|
|
2888
|
+
return Uint8Array.from(Buffer.from(padded, "base64"));
|
|
2889
|
+
}
|
|
2890
|
+
function base64UrlEncode(bytes) {
|
|
2891
|
+
return Buffer.from(bytes).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
|
2892
|
+
}
|
|
2893
|
+
function encodeUtf8(value) {
|
|
2894
|
+
return new TextEncoder().encode(value);
|
|
2895
|
+
}
|
|
2896
|
+
function normalizeDevicePublicKeyBase64Url(publicKey) {
|
|
2897
|
+
try {
|
|
2898
|
+
if (publicKey.includes("BEGIN")) {
|
|
2899
|
+
const spki = crypto2.createPublicKey(publicKey).export({
|
|
2900
|
+
type: "spki",
|
|
2901
|
+
format: "der"
|
|
2902
|
+
});
|
|
2903
|
+
if (spki.length === ED25519_SPKI_PREFIX.length + 32 && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
|
|
2904
|
+
return base64UrlEncode(spki.subarray(ED25519_SPKI_PREFIX.length));
|
|
2905
|
+
}
|
|
2906
|
+
return null;
|
|
2907
|
+
}
|
|
2908
|
+
return base64UrlEncode(decodeBase64Url(publicKey));
|
|
2909
|
+
} catch {
|
|
2910
|
+
return null;
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
function deriveDeviceIdFromPublicKey(publicKey) {
|
|
2914
|
+
try {
|
|
2915
|
+
const normalized = normalizeDevicePublicKeyBase64Url(publicKey);
|
|
2916
|
+
if (!normalized) return null;
|
|
2917
|
+
const raw = decodeBase64Url(normalized);
|
|
2918
|
+
if (raw.length !== 32) return null;
|
|
2919
|
+
return crypto2.createHash("sha256").update(raw).digest("hex");
|
|
2920
|
+
} catch {
|
|
2921
|
+
return null;
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
function verifyEd25519Signature(publicKey, payload, signatureBase64Url) {
|
|
2925
|
+
try {
|
|
2926
|
+
const normalized = normalizeDevicePublicKeyBase64Url(publicKey);
|
|
2927
|
+
if (!normalized) return false;
|
|
2928
|
+
const key = crypto2.createPublicKey({
|
|
2929
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, decodeBase64Url(normalized)]),
|
|
2930
|
+
type: "spki",
|
|
2931
|
+
format: "der"
|
|
2932
|
+
});
|
|
2933
|
+
const signature = (() => {
|
|
2934
|
+
try {
|
|
2935
|
+
return Buffer.from(decodeBase64Url(signatureBase64Url));
|
|
2936
|
+
} catch {
|
|
2937
|
+
return Buffer.from(signatureBase64Url, "base64");
|
|
2938
|
+
}
|
|
2939
|
+
})();
|
|
2940
|
+
return crypto2.verify(null, Buffer.from(payload, "utf8"), key, signature);
|
|
2941
|
+
} catch {
|
|
2942
|
+
return false;
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
function canonicalizeP256Jwk(value) {
|
|
2946
|
+
const record = asRecord5(value);
|
|
2947
|
+
const kty = pickString(record?.kty);
|
|
2948
|
+
const crv = pickString(record?.crv);
|
|
2949
|
+
const x = pickString(record?.x);
|
|
2950
|
+
const y = pickString(record?.y);
|
|
2951
|
+
if (kty !== "EC" || crv !== "P-256" || !x || !y) {
|
|
2952
|
+
throw new Error("INVALID_PROOF_KEY");
|
|
2953
|
+
}
|
|
2954
|
+
return { kty: "EC", crv: "P-256", x, y };
|
|
2955
|
+
}
|
|
2956
|
+
function computeDeviceIdFromJwk(jwk) {
|
|
2957
|
+
const canonical = JSON.stringify(jwk);
|
|
2958
|
+
return crypto2.createHash("sha256").update(canonical).digest("hex");
|
|
2959
|
+
}
|
|
2960
|
+
function buildProofPayload(action, deviceId, nonce, signedAt, origin) {
|
|
2961
|
+
return `squad.${action}|${deviceId}|${nonce}|${signedAt}|${origin}`;
|
|
2962
|
+
}
|
|
2963
|
+
async function verifyBrowserProof(payload, origin, action, usedProofNonces) {
|
|
2964
|
+
const deviceId = pickString(payload.deviceId);
|
|
2965
|
+
const signature = pickString(payload.signature);
|
|
2966
|
+
const nonce = pickString(payload.nonce);
|
|
2967
|
+
const signedAtRaw = pickNumber(payload.signedAt);
|
|
2968
|
+
if (!deviceId || !signature || !nonce || signedAtRaw == null) {
|
|
2969
|
+
return {
|
|
2970
|
+
ok: false,
|
|
2971
|
+
code: "INVALID_PROOF",
|
|
2972
|
+
status: 400,
|
|
2973
|
+
message: "Missing browser proof fields"
|
|
2974
|
+
};
|
|
2975
|
+
}
|
|
2976
|
+
const signedAt = Math.trunc(signedAtRaw);
|
|
2977
|
+
if (Math.abs(Date.now() - signedAt) > PROOF_MAX_SKEW_MS) {
|
|
2978
|
+
return {
|
|
2979
|
+
ok: false,
|
|
2980
|
+
code: "INVALID_PROOF",
|
|
2981
|
+
status: 401,
|
|
2982
|
+
message: "Proof timestamp expired"
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
const nonceKey = `${deviceId}:${nonce}`;
|
|
2986
|
+
const existingNonce = usedProofNonces.get(nonceKey);
|
|
2987
|
+
if (existingNonce && existingNonce > Date.now()) {
|
|
2988
|
+
return {
|
|
2989
|
+
ok: false,
|
|
2990
|
+
code: "INVALID_PROOF",
|
|
2991
|
+
status: 401,
|
|
2992
|
+
message: "Proof nonce replay detected"
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
const proofPayload = buildProofPayload(action, deviceId, nonce, signedAt, origin);
|
|
2996
|
+
const publicKey = pickString(payload.publicKey);
|
|
2997
|
+
let verified = false;
|
|
2998
|
+
if (publicKey) {
|
|
2999
|
+
const expectedDeviceId = deriveDeviceIdFromPublicKey(publicKey);
|
|
3000
|
+
if (!expectedDeviceId) {
|
|
3001
|
+
return {
|
|
3002
|
+
ok: false,
|
|
3003
|
+
code: "INVALID_PROOF",
|
|
3004
|
+
status: 400,
|
|
3005
|
+
message: "Invalid browser public key"
|
|
3006
|
+
};
|
|
3007
|
+
}
|
|
3008
|
+
if (expectedDeviceId !== deviceId) {
|
|
3009
|
+
return {
|
|
3010
|
+
ok: false,
|
|
3011
|
+
code: "INVALID_PROOF",
|
|
3012
|
+
status: 401,
|
|
3013
|
+
message: "Proof deviceId does not match browser key"
|
|
3014
|
+
};
|
|
3015
|
+
}
|
|
3016
|
+
verified = verifyEd25519Signature(publicKey, proofPayload, signature);
|
|
3017
|
+
} else {
|
|
3018
|
+
let jwk;
|
|
3019
|
+
try {
|
|
3020
|
+
jwk = canonicalizeP256Jwk(payload.publicKeyJwk);
|
|
3021
|
+
} catch {
|
|
3022
|
+
return {
|
|
3023
|
+
ok: false,
|
|
3024
|
+
code: "INVALID_PROOF",
|
|
3025
|
+
status: 400,
|
|
3026
|
+
message: "Invalid browser public key"
|
|
3027
|
+
};
|
|
3028
|
+
}
|
|
3029
|
+
const expectedDeviceId = computeDeviceIdFromJwk(jwk);
|
|
3030
|
+
if (expectedDeviceId !== deviceId) {
|
|
3031
|
+
return {
|
|
3032
|
+
ok: false,
|
|
3033
|
+
code: "INVALID_PROOF",
|
|
3034
|
+
status: 401,
|
|
3035
|
+
message: "Proof deviceId does not match browser key"
|
|
3036
|
+
};
|
|
3037
|
+
}
|
|
3038
|
+
try {
|
|
3039
|
+
const key = await crypto2.webcrypto.subtle.importKey(
|
|
3040
|
+
"jwk",
|
|
3041
|
+
jwk,
|
|
3042
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
3043
|
+
false,
|
|
3044
|
+
["verify"]
|
|
3045
|
+
);
|
|
3046
|
+
verified = await crypto2.webcrypto.subtle.verify(
|
|
3047
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
3048
|
+
key,
|
|
3049
|
+
decodeBase64Url(signature),
|
|
3050
|
+
encodeUtf8(proofPayload)
|
|
3051
|
+
);
|
|
3052
|
+
} catch {
|
|
3053
|
+
verified = false;
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
if (!verified) {
|
|
3057
|
+
return {
|
|
3058
|
+
ok: false,
|
|
3059
|
+
code: "INVALID_PROOF",
|
|
3060
|
+
status: 401,
|
|
3061
|
+
message: "Proof signature verification failed"
|
|
3062
|
+
};
|
|
3063
|
+
}
|
|
3064
|
+
usedProofNonces.set(nonceKey, Date.now() + NONCE_TTL_MS);
|
|
3065
|
+
return { ok: true, deviceId };
|
|
3066
|
+
}
|
|
3067
|
+
function normalizePairingStatus(value) {
|
|
3068
|
+
if (typeof value !== "string") return "unknown";
|
|
3069
|
+
const normalized = value.trim().toLowerCase();
|
|
3070
|
+
if (["pending", "requested", "waiting", "open"].includes(normalized)) return "pending";
|
|
3071
|
+
if (["approved", "paired", "accepted", "granted", "success"].includes(normalized)) return "approved";
|
|
3072
|
+
if (["rejected", "denied", "failed"].includes(normalized)) return "rejected";
|
|
3073
|
+
if (["expired", "timeout", "timed_out"].includes(normalized)) return "expired";
|
|
3074
|
+
return "unknown";
|
|
3075
|
+
}
|
|
3076
|
+
function extractRequestId(result) {
|
|
3077
|
+
const obj = asRecord5(result);
|
|
3078
|
+
if (!obj) return null;
|
|
3079
|
+
const nestedRequest = asRecord5(obj.request);
|
|
3080
|
+
return pickString(obj.requestId) ?? pickString(obj.id) ?? pickString(nestedRequest?.requestId) ?? pickString(nestedRequest?.id);
|
|
3081
|
+
}
|
|
3082
|
+
function extractExpiresAt(result) {
|
|
3083
|
+
const obj = asRecord5(result);
|
|
3084
|
+
const candidates = [
|
|
3085
|
+
obj?.expiresAt,
|
|
3086
|
+
obj?.expiresAtMs,
|
|
3087
|
+
asRecord5(obj?.request)?.expiresAt,
|
|
3088
|
+
asRecord5(obj?.request)?.expiresAtMs
|
|
3089
|
+
];
|
|
3090
|
+
for (const candidate of candidates) {
|
|
3091
|
+
if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > Date.now()) {
|
|
3092
|
+
return Math.trunc(candidate);
|
|
3093
|
+
}
|
|
3094
|
+
if (typeof candidate === "string") {
|
|
3095
|
+
const asNumber = Number(candidate);
|
|
3096
|
+
if (Number.isFinite(asNumber) && asNumber > Date.now()) {
|
|
3097
|
+
return Math.trunc(asNumber);
|
|
3098
|
+
}
|
|
3099
|
+
const parsedDate = Date.parse(candidate);
|
|
3100
|
+
if (Number.isFinite(parsedDate) && parsedDate > Date.now()) {
|
|
3101
|
+
return Math.trunc(parsedDate);
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
return Date.now() + DEFAULT_PAIRING_TTL_MS;
|
|
3106
|
+
}
|
|
3107
|
+
function extractPairingStatus(result) {
|
|
3108
|
+
const obj = asRecord5(result);
|
|
3109
|
+
if (!obj) return "unknown";
|
|
3110
|
+
const candidates = [
|
|
3111
|
+
obj.status,
|
|
3112
|
+
asRecord5(obj.request)?.status,
|
|
3113
|
+
asRecord5(obj.pairing)?.status
|
|
3114
|
+
];
|
|
3115
|
+
for (const candidate of candidates) {
|
|
3116
|
+
const parsed = normalizePairingStatus(candidate);
|
|
3117
|
+
if (parsed !== "unknown") return parsed;
|
|
3118
|
+
}
|
|
3119
|
+
return "unknown";
|
|
3120
|
+
}
|
|
3121
|
+
function isRateLimited(bucket, key, limit, windowMs) {
|
|
3122
|
+
const now = Date.now();
|
|
3123
|
+
const existing = bucket.get(key);
|
|
3124
|
+
if (!existing || existing.resetAt <= now) {
|
|
3125
|
+
bucket.set(key, { count: 1, resetAt: now + windowMs });
|
|
3126
|
+
return false;
|
|
3127
|
+
}
|
|
3128
|
+
if (existing.count >= limit) {
|
|
3129
|
+
return true;
|
|
3130
|
+
}
|
|
3131
|
+
existing.count += 1;
|
|
3132
|
+
return false;
|
|
3133
|
+
}
|
|
3134
|
+
function normalizeTailnetHostname(value) {
|
|
3135
|
+
const trimmed = value.trim();
|
|
3136
|
+
if (!trimmed) return null;
|
|
3137
|
+
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
3138
|
+
try {
|
|
3139
|
+
const parsed = new URL(withProtocol);
|
|
3140
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
3141
|
+
if (!hostname.endsWith(".ts.net")) return null;
|
|
3142
|
+
return hostname.endsWith(".") ? hostname.slice(0, -1) : hostname;
|
|
3143
|
+
} catch {
|
|
3144
|
+
return null;
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
function readRegistrationConfig() {
|
|
3148
|
+
const token = pickString(process.env.SQUAD_TAILNET_REGISTRATION_TOKEN) ?? pickString(process.env.OPENCLAW_SQUAD_TAILNET_REGISTRATION_TOKEN);
|
|
3149
|
+
const hostname = pickString(process.env.SQUAD_TAILNET_HOSTNAME) ?? pickString(process.env.TS_HOSTNAME) ?? pickString(process.env.TAILSCALE_HOSTNAME);
|
|
3150
|
+
const normalizedHostname = hostname ? normalizeTailnetHostname(hostname) : null;
|
|
3151
|
+
if (!token || !normalizedHostname) return null;
|
|
3152
|
+
const relayUrlRaw = pickString(process.env.OPENCLAW_SQUAD_RELAY_HTTP_URL) ?? pickString(process.env.SQUAD_RELAY_HTTP_URL) ?? pickString(process.env.OPENCLAW_SQUAD_RELAY_URL) ?? pickString(process.env.SQUAD_RELAY_URL) ?? "https://relay.squad.ceo";
|
|
3153
|
+
const relayApiBaseUrl = relayUrlRaw.replace(/^wss:/i, "https:").replace(/^ws:/i, "http:").replace(/\/+$/, "");
|
|
3154
|
+
const version = pickString(process.env.SQUAD_PLUGIN_VERSION) ?? pickString(process.env.npm_package_version) ?? "unknown";
|
|
3155
|
+
const displayName = pickString(process.env.SQUAD_TAILNET_DISPLAY_NAME) ?? "squad-openclaw";
|
|
3156
|
+
return {
|
|
3157
|
+
relayApiBaseUrl,
|
|
3158
|
+
registrationToken: token,
|
|
3159
|
+
hostname: normalizedHostname,
|
|
3160
|
+
displayName,
|
|
3161
|
+
version
|
|
3162
|
+
};
|
|
3163
|
+
}
|
|
3164
|
+
async function registerLocatorToRelay(config) {
|
|
3165
|
+
const response = await fetch(`${config.relayApiBaseUrl}/api/gateway/register-tailnet`, {
|
|
3166
|
+
method: "POST",
|
|
3167
|
+
headers: {
|
|
3168
|
+
"Content-Type": "application/json",
|
|
3169
|
+
Authorization: `Bearer ${config.registrationToken}`
|
|
3170
|
+
},
|
|
3171
|
+
body: JSON.stringify({
|
|
3172
|
+
hostname: config.hostname,
|
|
3173
|
+
displayName: config.displayName,
|
|
3174
|
+
version: config.version
|
|
3175
|
+
})
|
|
3176
|
+
});
|
|
3177
|
+
if (!response.ok) {
|
|
3178
|
+
const text = await response.text();
|
|
3179
|
+
throw new Error(`Locator registration failed (${response.status}): ${text}`);
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
function registerTailnetInternalRoutes(api) {
|
|
3183
|
+
const pendingPairings = /* @__PURE__ */ new Map();
|
|
3184
|
+
const proofNonces = /* @__PURE__ */ new Map();
|
|
3185
|
+
const rateLimitBucket = /* @__PURE__ */ new Map();
|
|
3186
|
+
let preferredPairingRequestMethod = null;
|
|
3187
|
+
let preferredPairingStatusMethod = null;
|
|
3188
|
+
const cleanupCaches = () => {
|
|
3189
|
+
const now = Date.now();
|
|
3190
|
+
for (const [key, value] of pendingPairings) {
|
|
3191
|
+
if (value.expiresAt <= now) pendingPairings.delete(key);
|
|
3192
|
+
}
|
|
3193
|
+
for (const [key, expiresAt] of proofNonces) {
|
|
3194
|
+
if (expiresAt <= now) proofNonces.delete(key);
|
|
3195
|
+
}
|
|
3196
|
+
for (const [key, bucket] of rateLimitBucket) {
|
|
3197
|
+
if (bucket.resetAt <= now) rateLimitBucket.delete(key);
|
|
3198
|
+
}
|
|
3199
|
+
};
|
|
3200
|
+
const callGatewayMethod = async (method, params) => {
|
|
3201
|
+
return callGatewayAny({}, api, method, params);
|
|
3202
|
+
};
|
|
3203
|
+
const requestPairingFromGateway = async () => {
|
|
3204
|
+
const methods = preferredPairingRequestMethod ? [preferredPairingRequestMethod, ...PAIRING_REQUEST_METHODS.filter((m) => m !== preferredPairingRequestMethod)] : [...PAIRING_REQUEST_METHODS];
|
|
3205
|
+
let lastError = null;
|
|
3206
|
+
for (const method of methods) {
|
|
3207
|
+
try {
|
|
3208
|
+
const result = await callGatewayMethod(method, { displayName: "squad-browser" });
|
|
3209
|
+
const requestId = extractRequestId(result);
|
|
3210
|
+
if (!requestId) {
|
|
3211
|
+
throw new Error("Pairing request created but no requestId was returned");
|
|
3212
|
+
}
|
|
3213
|
+
preferredPairingRequestMethod = method;
|
|
3214
|
+
return {
|
|
3215
|
+
requestId,
|
|
3216
|
+
expiresAt: extractExpiresAt(result)
|
|
3217
|
+
};
|
|
3218
|
+
} catch (error) {
|
|
3219
|
+
lastError = error;
|
|
3220
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3221
|
+
if (isUnknownGatewayMethodError(message)) {
|
|
3222
|
+
continue;
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
const details = lastError instanceof Error ? lastError.message : String(lastError ?? "");
|
|
3227
|
+
throw new Error(
|
|
3228
|
+
details ? `PAIRING_REQUEST_UNAVAILABLE: ${details}` : "PAIRING_REQUEST_UNAVAILABLE: No pairing request method supported"
|
|
3229
|
+
);
|
|
3230
|
+
};
|
|
3231
|
+
const readPairingStatusFromGateway = async (requestId) => {
|
|
3232
|
+
const methods = preferredPairingStatusMethod ? [preferredPairingStatusMethod, ...PAIRING_STATUS_METHODS.filter((m) => m !== preferredPairingStatusMethod)] : [...PAIRING_STATUS_METHODS];
|
|
3233
|
+
for (const method of methods) {
|
|
3234
|
+
try {
|
|
3235
|
+
const result = await callGatewayMethod(method, { requestId });
|
|
3236
|
+
const status = extractPairingStatus(result);
|
|
3237
|
+
preferredPairingStatusMethod = method;
|
|
3238
|
+
return status;
|
|
3239
|
+
} catch (error) {
|
|
3240
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3241
|
+
if (isUnknownGatewayMethodError(message)) continue;
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
return "unknown";
|
|
3245
|
+
};
|
|
3246
|
+
const handle = async (request) => {
|
|
3247
|
+
const url = new URL(request.url);
|
|
3248
|
+
const path14 = url.pathname;
|
|
3249
|
+
cleanupCaches();
|
|
3250
|
+
if (request.method === "OPTIONS" && path14.startsWith("/squad-internal/")) {
|
|
3251
|
+
const origin = ensureOriginAllowed(request);
|
|
3252
|
+
if (!origin) {
|
|
3253
|
+
return new Response(null, { status: 403 });
|
|
3254
|
+
}
|
|
3255
|
+
return withCors(
|
|
3256
|
+
request,
|
|
3257
|
+
new Response(null, {
|
|
3258
|
+
status: 204,
|
|
3259
|
+
headers: {
|
|
3260
|
+
"Access-Control-Allow-Origin": origin,
|
|
3261
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
3262
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
3263
|
+
"Access-Control-Max-Age": "86400"
|
|
3264
|
+
}
|
|
3265
|
+
})
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
if (request.method === "GET" && path14 === "/squad-internal/health") {
|
|
3269
|
+
if (!isTailnetContext(request)) {
|
|
3270
|
+
return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
|
|
3271
|
+
}
|
|
3272
|
+
return withCors(
|
|
3273
|
+
request,
|
|
3274
|
+
json({
|
|
3275
|
+
ok: true,
|
|
3276
|
+
mode: "tailnet-direct",
|
|
3277
|
+
pairing: {
|
|
3278
|
+
requestSupported: preferredPairingRequestMethod ?? PAIRING_REQUEST_METHODS[0],
|
|
3279
|
+
statusSupported: preferredPairingStatusMethod ?? "auto"
|
|
3280
|
+
}
|
|
3281
|
+
})
|
|
3282
|
+
);
|
|
3283
|
+
}
|
|
3284
|
+
if (request.method === "POST" && path14 === "/squad-internal/pairing/request") {
|
|
3285
|
+
const origin = ensureOriginAllowed(request);
|
|
3286
|
+
if (!origin) {
|
|
3287
|
+
return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
|
|
3288
|
+
}
|
|
3289
|
+
if (!isTailnetContext(request)) {
|
|
3290
|
+
return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
|
|
3291
|
+
}
|
|
3292
|
+
let body;
|
|
3293
|
+
try {
|
|
3294
|
+
body = await request.json();
|
|
3295
|
+
} catch {
|
|
3296
|
+
return jsonError(request, "INVALID_REQUEST", "Invalid JSON body", 400);
|
|
3297
|
+
}
|
|
3298
|
+
const proofCheck = await verifyBrowserProof(body, origin, "pairing.request", proofNonces);
|
|
3299
|
+
if (!proofCheck.ok) {
|
|
3300
|
+
return jsonError(request, proofCheck.code, proofCheck.message, proofCheck.status);
|
|
3301
|
+
}
|
|
3302
|
+
const forwardedFor = request.headers.get("x-forwarded-for") ?? "unknown";
|
|
3303
|
+
const ipKey = `ip:${forwardedFor}`;
|
|
3304
|
+
const deviceKey = `device:${proofCheck.deviceId}`;
|
|
3305
|
+
if (isRateLimited(rateLimitBucket, ipKey, 20, 6e4) || isRateLimited(rateLimitBucket, deviceKey, 8, 6e4)) {
|
|
3306
|
+
return jsonError(request, "RATE_LIMITED", "Too many pairing requests", 429);
|
|
3307
|
+
}
|
|
3308
|
+
try {
|
|
3309
|
+
const pairing = await requestPairingFromGateway();
|
|
3310
|
+
pendingPairings.set(pairing.requestId, {
|
|
3311
|
+
requestId: pairing.requestId,
|
|
3312
|
+
deviceId: proofCheck.deviceId,
|
|
3313
|
+
createdAt: Date.now(),
|
|
3314
|
+
expiresAt: pairing.expiresAt
|
|
3315
|
+
});
|
|
3316
|
+
return withCors(
|
|
3317
|
+
request,
|
|
3318
|
+
json({
|
|
3319
|
+
ok: true,
|
|
3320
|
+
requestId: pairing.requestId,
|
|
3321
|
+
approveCommand: `openclaw devices approve ${pairing.requestId}`,
|
|
3322
|
+
expiresAt: pairing.expiresAt
|
|
3323
|
+
})
|
|
3324
|
+
);
|
|
3325
|
+
} catch (error) {
|
|
3326
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3327
|
+
return jsonError(
|
|
3328
|
+
request,
|
|
3329
|
+
"PAIRING_REQUEST_UNAVAILABLE",
|
|
3330
|
+
message.replace(/^PAIRING_REQUEST_UNAVAILABLE:\s*/i, ""),
|
|
3331
|
+
501,
|
|
3332
|
+
{ nextStep: "openclaw devices list --json" }
|
|
3333
|
+
);
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
if (request.method === "GET" && path14 === "/squad-internal/pairing/status") {
|
|
3337
|
+
const origin = ensureOriginAllowed(request);
|
|
3338
|
+
if (!origin) {
|
|
3339
|
+
return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
|
|
3340
|
+
}
|
|
3341
|
+
if (!isTailnetContext(request)) {
|
|
3342
|
+
return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
|
|
3343
|
+
}
|
|
3344
|
+
const requestId = pickString(url.searchParams.get("requestId"));
|
|
3345
|
+
const deviceId = pickString(url.searchParams.get("deviceId"));
|
|
3346
|
+
if (!requestId || !deviceId) {
|
|
3347
|
+
return jsonError(request, "INVALID_REQUEST", "requestId and deviceId are required", 400);
|
|
3348
|
+
}
|
|
3349
|
+
const record = pendingPairings.get(requestId);
|
|
3350
|
+
if (!record || record.deviceId !== deviceId) {
|
|
3351
|
+
return jsonError(request, "NOT_FOUND", "No pending pairing request for this device", 404);
|
|
3352
|
+
}
|
|
3353
|
+
if (record.expiresAt <= Date.now()) {
|
|
3354
|
+
pendingPairings.delete(requestId);
|
|
3355
|
+
return withCors(request, json({ ok: true, status: "expired" }));
|
|
3356
|
+
}
|
|
3357
|
+
const forwardedFor = request.headers.get("x-forwarded-for") ?? "unknown";
|
|
3358
|
+
if (isRateLimited(rateLimitBucket, `status:${forwardedFor}`, 90, 6e4)) {
|
|
3359
|
+
return jsonError(request, "RATE_LIMITED", "Too many status checks", 429);
|
|
3360
|
+
}
|
|
3361
|
+
const status = await readPairingStatusFromGateway(requestId);
|
|
3362
|
+
if (status === "approved" || status === "expired" || status === "rejected") {
|
|
3363
|
+
pendingPairings.delete(requestId);
|
|
3364
|
+
}
|
|
3365
|
+
return withCors(request, json({ ok: true, status }));
|
|
3366
|
+
}
|
|
3367
|
+
if (request.method === "POST" && path14 === "/squad-internal/locator/register") {
|
|
3368
|
+
const origin = ensureOriginAllowed(request);
|
|
3369
|
+
if (!origin) {
|
|
3370
|
+
return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
|
|
3371
|
+
}
|
|
3372
|
+
if (!isTailnetContext(request)) {
|
|
3373
|
+
return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
|
|
3374
|
+
}
|
|
3375
|
+
let body;
|
|
3376
|
+
try {
|
|
3377
|
+
body = await request.json();
|
|
3378
|
+
} catch {
|
|
3379
|
+
return jsonError(request, "INVALID_REQUEST", "Invalid JSON body", 400);
|
|
3380
|
+
}
|
|
3381
|
+
const proofCheck = await verifyBrowserProof(body, origin, "locator.register", proofNonces);
|
|
3382
|
+
if (!proofCheck.ok) {
|
|
3383
|
+
return jsonError(request, proofCheck.code, proofCheck.message, proofCheck.status);
|
|
3384
|
+
}
|
|
3385
|
+
const registrationToken = pickString(body.registrationToken);
|
|
3386
|
+
const hostname = normalizeTailnetHostname(pickString(body.hostname) ?? "");
|
|
3387
|
+
if (!registrationToken || !hostname) {
|
|
3388
|
+
return jsonError(
|
|
3389
|
+
request,
|
|
3390
|
+
"INVALID_REQUEST",
|
|
3391
|
+
"registrationToken and hostname (*.ts.net) are required",
|
|
3392
|
+
400
|
|
3393
|
+
);
|
|
3394
|
+
}
|
|
3395
|
+
const relayApiBaseUrl = pickString(body.relayApiBaseUrl) ?? readRegistrationConfig()?.relayApiBaseUrl ?? "https://relay.squad.ceo";
|
|
3396
|
+
const displayName = pickString(body.displayName) ?? "squad-openclaw";
|
|
3397
|
+
const version = pickString(body.version) ?? pickString(process.env.SQUAD_PLUGIN_VERSION) ?? pickString(process.env.npm_package_version) ?? "unknown";
|
|
3398
|
+
const config = {
|
|
3399
|
+
relayApiBaseUrl: relayApiBaseUrl.replace(/\/+$/, ""),
|
|
3400
|
+
registrationToken,
|
|
3401
|
+
hostname,
|
|
3402
|
+
displayName,
|
|
3403
|
+
version
|
|
3404
|
+
};
|
|
3405
|
+
try {
|
|
3406
|
+
await registerLocatorToRelay(config);
|
|
3407
|
+
return withCors(
|
|
3408
|
+
request,
|
|
3409
|
+
json({
|
|
3410
|
+
ok: true,
|
|
3411
|
+
locator: {
|
|
3412
|
+
hostname,
|
|
3413
|
+
source: "plugin",
|
|
3414
|
+
lastSeenAt: Date.now(),
|
|
3415
|
+
displayName,
|
|
3416
|
+
version
|
|
3417
|
+
}
|
|
3418
|
+
})
|
|
3419
|
+
);
|
|
3420
|
+
} catch (error) {
|
|
3421
|
+
return jsonError(
|
|
3422
|
+
request,
|
|
3423
|
+
"LOCATOR_REGISTRATION_FAILED",
|
|
3424
|
+
error instanceof Error ? error.message : String(error),
|
|
3425
|
+
502
|
|
3426
|
+
);
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
return new Response("Not Found", { status: 404 });
|
|
3430
|
+
};
|
|
3431
|
+
if (typeof api.registerHttpHandler === "function") {
|
|
3432
|
+
api.registerHttpHandler(handle);
|
|
3433
|
+
} else if (typeof api.registerHttpRoute === "function") {
|
|
3434
|
+
api.registerHttpRoute("/squad-internal/health", handle);
|
|
3435
|
+
api.registerHttpRoute("/squad-internal/pairing/request", handle);
|
|
3436
|
+
api.registerHttpRoute("/squad-internal/pairing/status", handle);
|
|
3437
|
+
api.registerHttpRoute("/squad-internal/locator/register", handle);
|
|
3438
|
+
api.registerHttpRoute("/squad-internal/*", handle);
|
|
3439
|
+
} else if (typeof api.registerHttpMiddleware === "function") {
|
|
3440
|
+
api.registerHttpMiddleware(handle);
|
|
3441
|
+
} else {
|
|
3442
|
+
console.warn("[squad-openclaw] no supported HTTP registration API found for tailnet routes");
|
|
3443
|
+
}
|
|
3444
|
+
const registrationConfig = readRegistrationConfig();
|
|
3445
|
+
if (!registrationConfig) return;
|
|
3446
|
+
const runRegistration = async () => {
|
|
3447
|
+
try {
|
|
3448
|
+
await registerLocatorToRelay(registrationConfig);
|
|
3449
|
+
console.log("[squad-openclaw] tailnet locator registered");
|
|
3450
|
+
} catch (error) {
|
|
3451
|
+
console.warn(
|
|
3452
|
+
"[squad-openclaw] tailnet locator registration failed:",
|
|
3453
|
+
error instanceof Error ? error.message : String(error)
|
|
3454
|
+
);
|
|
3455
|
+
}
|
|
3456
|
+
};
|
|
3457
|
+
void runRegistration();
|
|
3458
|
+
const timer = setInterval(() => {
|
|
3459
|
+
void runRegistration();
|
|
3460
|
+
}, LOCATOR_REFRESH_INTERVAL_MS);
|
|
3461
|
+
const teardown = () => {
|
|
3462
|
+
clearInterval(timer);
|
|
3463
|
+
};
|
|
3464
|
+
if (typeof api.onShutdown === "function") {
|
|
3465
|
+
api.onShutdown(teardown);
|
|
3466
|
+
} else if (typeof api.on === "function") {
|
|
3467
|
+
const maybeOff = api.on("shutdown", teardown);
|
|
3468
|
+
if (typeof maybeOff === "function") {
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3116
3473
|
// src/index.ts
|
|
3117
3474
|
function squadAppPlugin(api) {
|
|
3118
|
-
const
|
|
3119
|
-
const sharedApi = registerSquadSharedApi(api, onFsChange);
|
|
3475
|
+
const sharedApi = registerSquadSharedApi(api);
|
|
3120
3476
|
sharedApi.registerCoreGatewayMethods();
|
|
3477
|
+
registerTailnetInternalRoutes(api);
|
|
3121
3478
|
void runStartupMigrations(api);
|
|
3122
|
-
const relayState = readRelayState();
|
|
3123
|
-
const relayEnabled = !!(relayState.claimToken || relayState.roomId);
|
|
3124
|
-
if (relayEnabled) {
|
|
3125
|
-
const relayUrl = process.env.OPENCLAW_SQUAD_RELAY_URL || process.env.SQUAD_RELAY_URL || "wss://relay.squad.ceo";
|
|
3126
|
-
startRelayClient(api, relayUrl);
|
|
3127
|
-
}
|
|
3128
3479
|
}
|
|
3129
3480
|
export {
|
|
3130
3481
|
squadAppPlugin as default
|