@spzhongwin/skill-logger-plugin 1.0.11 → 1.0.13

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.
Files changed (54) hide show
  1. package/dist/active-skills.js +67 -0
  2. package/dist/active-skills.test.js +29 -0
  3. package/dist/config-sync.js +439 -0
  4. package/dist/config-sync.test.js +145 -0
  5. package/dist/hooks.js +337 -0
  6. package/dist/hooks.test.js +123 -0
  7. package/dist/http.js +54 -0
  8. package/dist/identity.js +56 -0
  9. package/dist/index.js +240 -78
  10. package/dist/index.test.js +39 -0
  11. package/dist/integration.test.js +102 -0
  12. package/dist/matcher.js +362 -0
  13. package/dist/matcher.test.js +139 -0
  14. package/dist/paths.js +62 -0
  15. package/dist/paths.test.js +49 -0
  16. package/dist/reporter.js +267 -0
  17. package/dist/reporter.test.js +128 -0
  18. package/dist/semver.js +64 -0
  19. package/dist/semver.test.js +21 -0
  20. package/dist/skill-version.js +23 -0
  21. package/dist/types.js +9 -0
  22. package/dist/updater.js +352 -0
  23. package/dist/updater.test.js +212 -0
  24. package/dist/ws-client.js +484 -0
  25. package/openclaw.plugin.json +50 -50
  26. package/package.json +37 -37
  27. package/src/active-skills.test.ts +32 -32
  28. package/src/active-skills.ts +77 -77
  29. package/src/config-sync.test.ts +165 -165
  30. package/src/config-sync.ts +544 -544
  31. package/src/hooks.test.ts +251 -251
  32. package/src/hooks.ts +517 -517
  33. package/src/http.ts +61 -61
  34. package/src/identity.ts +64 -64
  35. package/src/index.test.ts +53 -53
  36. package/src/index.ts +226 -226
  37. package/src/integration.test.ts +119 -119
  38. package/src/matcher.test.ts +170 -170
  39. package/src/matcher.ts +393 -393
  40. package/src/paths.test.ts +57 -57
  41. package/src/paths.ts +84 -84
  42. package/src/reporter.test.ts +139 -139
  43. package/src/reporter.ts +298 -298
  44. package/src/sample-config.json +72 -72
  45. package/src/semver.test.ts +23 -23
  46. package/src/semver.ts +60 -60
  47. package/src/skill-version.ts +53 -53
  48. package/src/types.ts +198 -198
  49. package/src/updater.test.ts +325 -237
  50. package/src/updater.ts +549 -433
  51. package/src/ws-client.test.ts +48 -37
  52. package/src/ws-client.ts +717 -642
  53. package/test-ws.ts +17 -17
  54. package/tsconfig.json +14 -14
package/src/ws-client.ts CHANGED
@@ -1,642 +1,717 @@
1
- import WebSocket from "ws";
2
- import path from "path";
3
- import fs from "fs/promises";
4
- import { SkillUpdater } from "./updater.ts";
5
- import { openclawHome } from "./paths.ts";
6
- import { readSkillVersion } from "./skill-version.ts";
7
-
8
- const HEARTBEAT_INTERVAL_MS = 30_000;
9
- const HEARTBEAT_ACK_TIMEOUT_MS = 75_000;
10
- const AGENT_SCAN_INTERVAL_MS = 3 * 60 * 1000;
11
- const ASSISTANT_WORKSPACE_PREFIX = "workspace-assistant-";
12
- const ASSISTANT_AGENT_PREFIX = "assistant-";
13
- const ASSISTANT_WORKSPACE_ID_RE = /^\d{5,}$/;
14
-
15
- export function parseAssistantWorkspaceAgentId(entryName: string): string | undefined {
16
- if (!entryName.startsWith(ASSISTANT_WORKSPACE_PREFIX)) return undefined;
17
- const suffix = entryName.slice(ASSISTANT_WORKSPACE_PREFIX.length);
18
- if (!ASSISTANT_WORKSPACE_ID_RE.test(suffix)) return undefined;
19
- return `${ASSISTANT_AGENT_PREFIX}${suffix}`;
20
- }
21
-
22
- export function normalizeAssistantUserId(userId: string): string | undefined {
23
- const safeUserId = path.basename(userId);
24
- if (safeUserId !== userId) return undefined;
25
- const pureId = safeUserId.startsWith(ASSISTANT_AGENT_PREFIX)
26
- ? safeUserId.slice(ASSISTANT_AGENT_PREFIX.length)
27
- : safeUserId;
28
- if (!ASSISTANT_WORKSPACE_ID_RE.test(pureId)) return undefined;
29
- return pureId;
30
- }
31
-
32
- export interface WsClientOptions {
33
- serverUrl: string; // 例如: wss://api.aishuo.co/gateway/ws
34
- authToken?: string; // 用于网关鉴权
35
- gatewayId: string; // 当前网关宿主的标识,方便中控做集群分发
36
- updater: SkillUpdater; // 传入原来已有的 updater 实例
37
- enableFileLog?: boolean; // 文件日志开关
38
- }
39
-
40
- export class GatewayWsClient {
41
- private ws: WebSocket | null = null;
42
- private options: WsClientOptions;
43
- private reconnectTimer: NodeJS.Timeout | null = null;
44
- private agentScanTimer: NodeJS.Timeout | null = null;
45
- private pingTimer: NodeJS.Timeout | null = null;
46
- private connectTimeoutTimer: NodeJS.Timeout | null = null;
47
- private lastServerAckAt = 0;
48
- private reconnectAttempts = 0;
49
- private isDestroyed = false;
50
-
51
- // 本地缓存的 agent ID 列表
52
- private currentAgentIds = new Set<string>();
53
-
54
- private appendLogToFile(level: string, category: string, message: string, payload?: any) {
55
- if (!this.options.enableFileLog) return;
56
- try {
57
- const ts = new Date().toISOString();
58
- let logLine = `[${ts}] [${level}] [${category}] ${message}`;
59
- if (payload !== undefined && payload !== null) {
60
- // 如果是 Error 对象,主动提取 stack
61
- if (payload instanceof Error) {
62
- logLine += `\n Stack: ${payload.stack || payload.message}`;
63
- } else {
64
- logLine += ` | Data: ${typeof payload === 'object' ? JSON.stringify(payload) : payload}`;
65
- }
66
- }
67
- logLine += '\n';
68
- const logsDir = path.join(openclawHome(), "logs");
69
- fs.mkdir(logsDir, { recursive: true }).then(() => {
70
- const logPath = path.join(logsDir, "skill-logger.err");
71
- fs.appendFile(logPath, logLine).catch(()=>{});
72
- }).catch(()=>{});
73
- } catch (e) {
74
- // ignore
75
- }
76
- }
77
-
78
- constructor(options: WsClientOptions) {
79
- this.options = options;
80
- }
81
-
82
- public connect() {
83
- if (this.isDestroyed) return;
84
- if (this.ws && (
85
- this.ws.readyState === WebSocket.OPEN ||
86
- this.ws.readyState === WebSocket.CONNECTING
87
- )) {
88
- this.appendLogToFile("INFO", "Connection", "Connect skipped: websocket already active", { readyState: this.ws.readyState });
89
- return;
90
- }
91
-
92
- this.clearConnectTimeout();
93
-
94
- const msgConnect = `Connecting to Central Service: ${this.options.serverUrl}`;
95
- console.log(`[skill-logger-plugin][WS] ${msgConnect}`);
96
- this.appendLogToFile("INFO", "Connection", msgConnect);
97
-
98
- const headers: Record<string, string> = {
99
- "X-Gateway-Id": this.options.gatewayId,
100
- };
101
- if (this.options.authToken) {
102
- headers["Authorization"] = this.options.authToken;
103
- }
104
-
105
- try {
106
- const ws = new WebSocket(this.options.serverUrl, { headers });
107
- this.ws = ws;
108
- this.connectTimeoutTimer = setTimeout(() => {
109
- if (this.ws === ws && ws.readyState === WebSocket.CONNECTING) {
110
- this.appendLogToFile("WARN", "Connection", "Connection timed out before open; terminating socket.");
111
- ws.terminate();
112
- }
113
- }, 15000);
114
- } catch (err: any) {
115
- console.error(`[skill-logger-plugin][WS] Sync init error:`, err);
116
- this.appendLogToFile("ERROR", "Connection", "Sync initialization error", err);
117
- this.scheduleReconnect();
118
- return;
119
- }
120
-
121
- const ws = this.ws;
122
-
123
- ws.on("open", async () => {
124
- if (this.ws !== ws) return;
125
- console.log(`[skill-logger-plugin][WS] Connected successfully!`);
126
- this.appendLogToFile("INFO", "Connection", "Connected successfully!");
127
- this.clearConnectTimeout();
128
- this.reconnectAttempts = 0;
129
- this.lastServerAckAt = Date.now();
130
- this.clearReconnectTimer();
131
-
132
- // 首次连接,全量扫描并上报,同时进行应用层握手,确保服务端能把 DB 在线态刷新成真实状态。
133
- await this.scanAndReportAgents(true);
134
- this.sendGatewayHello();
135
- this.startHeartbeat();
136
-
137
- // 开启 3 分钟定期的自动扫码增量同步
138
- if (!this.agentScanTimer) {
139
- this.agentScanTimer = setInterval(() => {
140
- this.scanAndReportAgents(true);
141
- }, AGENT_SCAN_INTERVAL_MS);
142
- }
143
- });
144
-
145
- ws.on("pong", () => {
146
- this.lastServerAckAt = Date.now();
147
- });
148
-
149
- ws.on("message", async (data) => {
150
- if (this.ws !== ws) return;
151
- try {
152
- const msg = JSON.parse(data.toString());
153
- if (msg?.type === "GATEWAY_HELLO_ACK" || msg?.type === "HEARTBEAT_ACK") {
154
- this.lastServerAckAt = Date.now();
155
- this.appendLogToFile("INFO", "Heartbeat", "Server heartbeat acknowledged", { type: msg.type, connectionId: msg.connectionId });
156
- return;
157
- }
158
- if (msg?.type === "BATCH_COMMAND" && Array.isArray(msg.commands)) {
159
- const { commands, type, ...shared } = msg;
160
- for (const cmd of commands) {
161
- await this.handleMessage({ ...shared, userId: cmd.userId, replyId: cmd.replyId });
162
- }
163
- return;
164
- }
165
- await this.handleMessage(msg);
166
- } catch (err) {
167
- console.error(`[skill-logger-plugin][WS] Failed to parse/handle message:`, err);
168
- }
169
- });
170
-
171
- ws.on("close", () => {
172
- console.warn(`[skill-logger-plugin][WS] Connection closed.`);
173
- this.appendLogToFile("WARN", "Connection", "Connection closed. Starting reconnect timer.");
174
- if (this.ws === ws) {
175
- this.ws = null;
176
- this.clearConnectTimeout();
177
- this.clearAgentScanTimer();
178
- this.clearHeartbeat();
179
- this.scheduleReconnect();
180
- }
181
- });
182
-
183
- ws.on("error", (err) => {
184
- console.error(`[skill-logger-plugin][WS] Connection error:`, err);
185
- this.appendLogToFile("ERROR", "Connection", "Connection error", err);
186
- if (this.ws === ws) {
187
- ws.close(); // 触发 close 事件进行重连
188
- }
189
- });
190
- }
191
-
192
- /**
193
- * 扫描 OpenClaw 根目录下的 workspace-assistant-{userId} 目录。
194
- * userId 必须是至少 5 位数字。
195
- */
196
- private async scanAndReportAgents(isInitialReport: boolean) {
197
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
198
-
199
- try {
200
- const rootPath = openclawHome();
201
- let entries: string[] = [];
202
- try {
203
- entries = await fs.readdir(rootPath);
204
- } catch (e) {
205
- // 读目录失败通常是瞬时性的(磁盘抖动/权限/句柄耗尽等),不代表这台机器真的没有用户了。
206
- // 跳过本轮上报、保留上次已知状态,等下一次扫描自然重试,避免把瞬时故障放大成
207
- // "全部用户离线"(服务端收到空列表会把这台网关下所有用户标记 OFFLINE)。
208
- this.appendLogToFile("WARN", "AgentScan", "OpenClaw home is not readable; skipping this scan cycle", e);
209
- return;
210
- }
211
-
212
- const newAgentIds = new Set<string>();
213
- for (const entry of entries) {
214
- const agentId = parseAssistantWorkspaceAgentId(entry);
215
- if (agentId) newAgentIds.add(agentId);
216
- }
217
-
218
- let changed = false;
219
- if (newAgentIds.size !== this.currentAgentIds.size) {
220
- changed = true;
221
- } else {
222
- for (const id of newAgentIds) {
223
- if (!this.currentAgentIds.has(id)) {
224
- changed = true;
225
- break;
226
- }
227
- }
228
- }
229
-
230
- this.currentAgentIds = newAgentIds;
231
-
232
- if (isInitialReport) {
233
- this.appendLogToFile("INFO", "AgentScan", `Reporting full agent list`, { count: this.currentAgentIds.size, agents: Array.from(this.currentAgentIds) });
234
- this.sendAgentListReport("AGENT_LIST_REPORT");
235
- } else if (changed) {
236
- this.appendLogToFile("INFO", "AgentScan", `Syncing changed agent list`, { count: this.currentAgentIds.size, agents: Array.from(this.currentAgentIds) });
237
- this.sendAgentListReport("AGENT_LIST_SYNC");
238
- }
239
-
240
- } catch (err: any) {
241
- console.error(`[skill-logger-plugin][WS] Failed to scan agents:`, err);
242
- this.appendLogToFile("ERROR", "AgentScan", `Failed to scan agents`, err);
243
- }
244
- }
245
-
246
- private startHeartbeat() {
247
- this.clearHeartbeat();
248
- this.pingTimer = setInterval(() => {
249
- const ws = this.ws;
250
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
251
-
252
- const ackAge = Date.now() - this.lastServerAckAt;
253
- if (ackAge > HEARTBEAT_ACK_TIMEOUT_MS) {
254
- this.appendLogToFile("WARN", "Heartbeat", "Server heartbeat ACK timed out; terminating socket for reconnect", { ackAge });
255
- ws.terminate();
256
- return;
257
- }
258
-
259
- ws.ping();
260
- this.sendClientHeartbeat();
261
- }, HEARTBEAT_INTERVAL_MS);
262
- this.sendClientHeartbeat();
263
- }
264
-
265
- private clearHeartbeat() {
266
- if (this.pingTimer) {
267
- clearInterval(this.pingTimer);
268
- this.pingTimer = null;
269
- }
270
- }
271
-
272
- private scheduleReconnect() {
273
- if (this.isDestroyed || this.reconnectTimer) return;
274
-
275
- // 闭环完善:引入随机 Jitter 抖动,打散服务端重启时可能引发的瞬间重连风暴
276
- const jitter = Math.floor(Math.random() * 5000);
277
- const baseDelay = Math.min(30000, 2000 * Math.max(1, 2 ** this.reconnectAttempts));
278
- const delay = baseDelay + jitter;
279
- this.reconnectAttempts += 1;
280
-
281
- console.log(`[skill-logger-plugin][WS] Reconnecting in ${delay}ms...`);
282
- this.appendLogToFile("INFO", "Connection", "Reconnect scheduled", { delay, attempt: this.reconnectAttempts });
283
- this.reconnectTimer = setTimeout(() => {
284
- this.reconnectTimer = null;
285
- this.connect();
286
- }, delay);
287
- }
288
-
289
- private clearReconnectTimer() {
290
- if (this.reconnectTimer) {
291
- clearTimeout(this.reconnectTimer);
292
- this.reconnectTimer = null;
293
- }
294
- }
295
-
296
- private clearConnectTimeout() {
297
- if (this.connectTimeoutTimer) {
298
- clearTimeout(this.connectTimeoutTimer);
299
- this.connectTimeoutTimer = null;
300
- }
301
- }
302
-
303
- private terminateCurrentSocket(reason: string, payload?: any) {
304
- const ws = this.ws;
305
- if (!ws) return;
306
- this.appendLogToFile("WARN", "Connection", `Terminating websocket: ${reason}`, payload);
307
- try {
308
- ws.terminate();
309
- } catch (err) {
310
- this.appendLogToFile("WARN", "Connection", "Terminate websocket failed", err);
311
- }
312
- }
313
-
314
- private sendJson(payload: any, category: string) {
315
- const ws = this.ws;
316
- if (!ws || ws.readyState !== WebSocket.OPEN) return false;
317
-
318
- try {
319
- ws.send(JSON.stringify(payload), (err) => {
320
- if (!err) return;
321
- this.appendLogToFile("WARN", category, "WebSocket send failed", err);
322
- if (this.ws === ws) {
323
- this.terminateCurrentSocket("send_failed", { category, message: err.message });
324
- }
325
- });
326
- return true;
327
- } catch (err) {
328
- this.appendLogToFile("WARN", category, "WebSocket send threw", err);
329
- if (this.ws === ws) {
330
- this.terminateCurrentSocket("send_threw", err);
331
- }
332
- return false;
333
- }
334
- }
335
-
336
- private sendGatewayHello() {
337
- this.sendJson({
338
- type: "GATEWAY_HELLO",
339
- gatewayId: this.options.gatewayId,
340
- agentIds: Array.from(this.currentAgentIds),
341
- clientTime: Date.now(),
342
- supportsBatch: true,
343
- }, "Heartbeat");
344
- }
345
-
346
- private sendClientHeartbeat() {
347
- this.sendJson({
348
- type: "CLIENT_HEARTBEAT",
349
- gatewayId: this.options.gatewayId,
350
- agentIds: Array.from(this.currentAgentIds),
351
- clientTime: Date.now(),
352
- }, "Heartbeat");
353
- }
354
-
355
- private sendAgentListReport(type: "AGENT_LIST_REPORT" | "AGENT_LIST_SYNC") {
356
- this.sendJson({
357
- type,
358
- gatewayId: this.options.gatewayId,
359
- agentIds: Array.from(this.currentAgentIds),
360
- clientTime: Date.now(),
361
- }, "AgentScan");
362
- }
363
-
364
- private clearAgentScanTimer() {
365
- if (this.agentScanTimer) {
366
- clearInterval(this.agentScanTimer);
367
- this.agentScanTimer = null;
368
- }
369
- }
370
-
371
- /**
372
- * 核心指令分发中心:完全跳过沙盒,基于 userId 直接进行底层物理文件操作
373
- */
374
- private async handleMessage(msg: any) {
375
- const { action, userId, code, url, force, version, replyId } = msg;
376
- this.appendLogToFile("INFO", "Command", `Received WS message`, { action, userId, code, version, replyId });
377
-
378
- if (!action || !userId) {
379
- this.appendLogToFile("WARN", "Command", `Message dropped: missing action or userId`, msg);
380
- return;
381
- }
382
-
383
- // 闭环完善:清理 code,并严格校验 userId,防止恶意指令通过 '../' 引发宿主机目录穿越攻击
384
- const safeCode = code ? path.basename(code) : undefined;
385
-
386
- // 100% 确定性安全寻址:userId 只接受纯数字或 assistant-数字,且数字至少 5 位。
387
- const pureId = normalizeAssistantUserId(userId);
388
- if (!pureId) {
389
- this.reply(replyId, { success: false, message: `Invalid userId: ${userId}`, action });
390
- return;
391
- }
392
- const targetDir = path.join(openclawHome(), `workspace-assistant-${pureId}`, "skills");
393
-
394
- try {
395
- if (action === "INSTALL_SKILL") {
396
- if (!safeCode) throw new Error("Missing code parameter");
397
- console.log(`[skill-logger-plugin][WS] Executing INSTALL for user ${userId}, code: ${safeCode}`);
398
- this.appendLogToFile("INFO", "Command", `INSTALL_SKILL received`, { userId, code: safeCode, version });
399
- const result = await this.options.updater.manualInstall({
400
- code: safeCode, url, version, force: force !== false, targetDir
401
- });
402
- this.reply(replyId, { success: result.success, message: result.message, action });
403
-
404
- } else if (action === "UNINSTALL_SKILL") {
405
- if (!safeCode) throw new Error("Missing code parameter");
406
- console.log(`[skill-logger-plugin][WS] Executing UNINSTALL for user ${userId}, code: ${safeCode}`);
407
- this.appendLogToFile("INFO", "Command", `UNINSTALL_SKILL received`, { userId, code: safeCode });
408
- const skillPath = path.join(targetDir, safeCode);
409
- await fs.rm(skillPath, { recursive: true, force: true });
410
- this.reply(replyId, { success: true, message: `Skill ${safeCode} removed`, action });
411
-
412
- } else if (action === "LIST_SKILLS") {
413
- let list: any[] = [];
414
- let targetDirExists = false;
415
- try {
416
- const targetStat = await fs.stat(targetDir);
417
- targetDirExists = targetStat.isDirectory();
418
- } catch (err: any) {
419
- if (err?.code !== "ENOENT") throw err;
420
- }
421
- if (!targetDirExists) {
422
- throw new Error(`Target skills directory does not exist: ${targetDir}`);
423
- }
424
-
425
- const entries = await fs.readdir(targetDir, { withFileTypes: true });
426
-
427
- const dirs = entries.filter(e => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith("."));
428
-
429
- for (const e of dirs) {
430
- const skillDir = path.join(targetDir, e.name);
431
- const skillMdPath = path.join(skillDir, 'SKILL.md');
432
-
433
- try {
434
- const stat = await fs.stat(skillMdPath);
435
- if (!stat.isFile()) continue;
436
- } catch (err) {
437
- continue;
438
- }
439
-
440
- const metaPath = path.join(skillDir, '.meta.json');
441
- let isPlatform = false;
442
- let isBuiltIn = e.isSymbolicLink();
443
- let metaData: any = null;
444
- let name = e.name;
445
- let description = "";
446
- let skillVersion = "";
447
-
448
- try {
449
- const mdContent = await fs.readFile(skillMdPath, 'utf8');
450
- const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(mdContent)?.[1] ?? "";
451
- const parsedName = /(^|\n)name:\s*(.+)/.exec(fm)?.[2]?.trim();
452
- if (parsedName) name = parsedName;
453
-
454
- const descMatch = /(^|\n)description:\s*(?:>\s*\n\s*)?(.*?)(?=\n[a-z]+:|\n---|$)/is.exec(fm);
455
- if (descMatch && descMatch[2]) {
456
- description = descMatch[2].replace(/\n\s+/g, ' ').trim();
457
- }
458
- } catch (err) {}
459
-
460
- try {
461
- const metaContent = await fs.readFile(metaPath, 'utf8');
462
- const parsed = JSON.parse(metaContent);
463
- if (parsed) {
464
- if (parsed.ownerId === 'CMS' || parsed.ownerId === 'CMS_COMPAT') isPlatform = true;
465
- if (parsed.isBuiltIn === true || parsed.ownerId === 'built-in') isBuiltIn = true;
466
- metaData = parsed;
467
- }
468
- } catch (err) {}
469
-
470
- const resolvedVersion = await readSkillVersion(skillDir);
471
- if (resolvedVersion) skillVersion = resolvedVersion;
472
-
473
- if (isPlatform) {
474
- list.push({
475
- code: e.name,
476
- isPlatform: true,
477
- isBuiltIn: isBuiltIn,
478
- version: skillVersion,
479
- name,
480
- description,
481
- publishedAt: metaData?.publishedAt
482
- });
483
- } else {
484
- list.push({
485
- code: e.name,
486
- isPlatform: false,
487
- isBuiltIn: isBuiltIn,
488
- version: skillVersion,
489
- name: name,
490
- description: description
491
- });
492
- }
493
- }
494
- this.reply(replyId, { success: true, data: list, action });
495
-
496
- } else if (action === "UPDATE_SKILL") {
497
- if (!safeCode) throw new Error("Missing code parameter");
498
- // 更新可能是大批量广播,增加防阻塞的随机延时 (0~5秒)
499
- const delayMs = Math.random() * 5000;
500
- console.log(`[skill-logger-plugin][WS] Scheduled UPDATE for user ${userId}, code: ${safeCode} in ${Math.round(delayMs)}ms`);
501
- this.appendLogToFile("INFO", "Command", `Scheduled UPDATE_SKILL`, { userId, code: safeCode, version, delayMs: Math.round(delayMs) });
502
-
503
- setTimeout(async () => {
504
- try {
505
- await this.options.updater.manualInstall({
506
- code: safeCode, url, version, force: true, targetDir
507
- });
508
- // 广播更新一般无需强阻塞等待 replyId,若有需要也可以回传
509
- if (replyId) {
510
- this.reply(replyId, { success: true, message: `Skill ${safeCode} updated successfully`, action });
511
- }
512
- } catch (e: any) {
513
- if (replyId) this.reply(replyId, { success: false, message: e.message, action });
514
- }
515
- }, delayMs);
516
-
517
- } else if (action === "INSTALL_EXPERT") {
518
- if (!safeCode) throw new Error("Missing code parameter");
519
- const { name, version, downloadUrl, skills } = msg;
520
- console.log(`[skill-logger-plugin][WS] Executing INSTALL_EXPERT for user ${userId}, code: ${safeCode}`);
521
- this.appendLogToFile("INFO", "Command", `INSTALL_EXPERT received`, { userId, code: safeCode, version, downloadUrl });
522
-
523
- const userSkillRoot = path.join(openclawHome(), `workspace-assistant-${pureId}`, ".user");
524
-
525
- // 1. 安装专家本身
526
- const expertTarget = path.join(userSkillRoot, "experts", safeCode);
527
- await fs.mkdir(path.dirname(expertTarget), { recursive: true });
528
- const expertResult = await this.options.updater.installZipFromUrl(downloadUrl, expertTarget);
529
- if (!expertResult.success) {
530
- throw new Error(`专家安装失败: ${expertResult.message}`);
531
- }
532
-
533
- // 2. 安装依赖的 skills
534
- const skillTargetRoot = path.join(userSkillRoot, "skills");
535
- await fs.mkdir(skillTargetRoot, { recursive: true });
536
- const skillResults: string[] = [];
537
- if (Array.isArray(skills)) {
538
- for (const sk of skills) {
539
- if (!sk.code || !sk.downloadUrl) {
540
- skillResults.push(`${sk.code || 'unknown'}: 缺少下载地址`);
541
- continue;
542
- }
543
- try {
544
- const skTarget = path.join(skillTargetRoot, sk.code);
545
- const result = await this.options.updater.installZipFromUrl(sk.downloadUrl, skTarget);
546
- skillResults.push(`${sk.code}: ${result.success ? '成功' : '失败 - ' + result.message}`);
547
- } catch (e: any) {
548
- skillResults.push(`${sk.code}: 失败 - ${e.message}`);
549
- }
550
- }
551
- }
552
-
553
- this.reply(replyId, {
554
- success: true,
555
- message: `专家 ${safeCode} 安装完成`,
556
- action,
557
- data: { expertCode: safeCode, skills: skillResults },
558
- });
559
-
560
- } else if (action === "GET_EXPERT_REGISTRY") {
561
- console.log(`[skill-logger-plugin][WS] Executing GET_EXPERT_REGISTRY for user ${userId}`);
562
- this.appendLogToFile("INFO", "Command", `GET_EXPERT_REGISTRY received`, { userId });
563
-
564
- const registryFilePath = path.join(openclawHome(), `workspace-assistant-${pureId}`, ".user", "expert-registry.yaml");
565
- let content = "";
566
- let fileExists = false;
567
- try {
568
- const stat = await fs.stat(registryFilePath);
569
- fileExists = stat.isFile();
570
- } catch (err: any) {
571
- if (err?.code !== "ENOENT") throw err;
572
- }
573
-
574
- if (fileExists) {
575
- content = await fs.readFile(registryFilePath, "utf8");
576
-
577
- // 解析 YAML 提取需要的字段
578
- const expertsList: any[] = [];
579
- const lines = content.split('\n');
580
- let currentExpert: any = null;
581
-
582
- for (const line of lines) {
583
- const trimmed = line.trim();
584
- if (trimmed.startsWith('#')) continue;
585
-
586
- const idMatch = line.match(/^\s*-\s*id:\s*(.+)$/);
587
- if (idMatch) {
588
- if (currentExpert && currentExpert.id) expertsList.push(currentExpert);
589
- currentExpert = { id: idMatch[1].trim() };
590
- continue;
591
- }
592
-
593
- if (currentExpert) {
594
- const nameMatch = line.match(/^\s*name:\s*(.+)$/);
595
- if (nameMatch) {
596
- currentExpert.name = nameMatch[1].trim();
597
- }
598
- const descMatch = line.match(/^\s*description:\s*(.+)$/);
599
- if (descMatch) {
600
- currentExpert.description = descMatch[1].trim();
601
- }
602
- }
603
- }
604
- if (currentExpert && currentExpert.id) expertsList.push(currentExpert);
605
-
606
- this.reply(replyId, { success: true, data: expertsList, action });
607
- } else {
608
- this.reply(replyId, { success: false, message: `未找到用户技能配置文件,可能尚未注册或文件已丢失`, action });
609
- }
610
-
611
- } else {
612
- console.warn(`[skill-logger-plugin][WS] Unknown action: ${action}`);
613
- this.appendLogToFile("WARN", "Command", `Unknown action: ${action}`);
614
- this.reply(replyId, { success: false, message: `Unknown action: ${action}`, action });
615
- }
616
- } catch (err: any) {
617
- this.appendLogToFile("ERROR", "Command", `Error executing action ${action}`, err);
618
- this.reply(replyId, { success: false, message: err.message, action });
619
- }
620
- }
621
-
622
- private reply(replyId: string, payload: any) {
623
- this.appendLogToFile("INFO", "Command", `Replying to command`, { replyId, payload });
624
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !replyId) {
625
- this.appendLogToFile("WARN", "Command", `Cannot reply: WS closed or missing replyId`, { replyId });
626
- return;
627
- }
628
- this.ws.send(JSON.stringify({ type: "REPLY", replyId, ...payload }));
629
- }
630
-
631
- public destroy() {
632
- this.isDestroyed = true;
633
- this.clearReconnectTimer();
634
- this.clearAgentScanTimer();
635
- this.clearHeartbeat();
636
- this.clearConnectTimeout();
637
- if (this.ws) {
638
- this.ws.terminate(); // 强行销毁,斩断半开连接残留
639
- this.ws = null;
640
- }
641
- }
642
- }
1
+ import WebSocket from "ws";
2
+ import path from "path";
3
+ import fs from "fs/promises";
4
+ import { SkillUpdater } from "./updater.ts";
5
+ import { openclawHome } from "./paths.ts";
6
+ import { readSkillVersion } from "./skill-version.ts";
7
+
8
+ const HEARTBEAT_INTERVAL_MS = 30_000;
9
+ const HEARTBEAT_ACK_TIMEOUT_MS = 75_000;
10
+ const AGENT_SCAN_INTERVAL_MS = 3 * 60 * 1000;
11
+ const ASSISTANT_WORKSPACE_PREFIX = "workspace-assistant-";
12
+ const ASSISTANT_AGENT_PREFIX = "assistant-";
13
+ const ASSISTANT_WORKSPACE_ID_RE = /^\d{5,}$/;
14
+
15
+ export function parseAssistantWorkspaceAgentId(entryName: string): string | undefined {
16
+ if (!entryName.startsWith(ASSISTANT_WORKSPACE_PREFIX)) return undefined;
17
+ const suffix = entryName.slice(ASSISTANT_WORKSPACE_PREFIX.length);
18
+ if (!ASSISTANT_WORKSPACE_ID_RE.test(suffix)) return undefined;
19
+ return `${ASSISTANT_AGENT_PREFIX}${suffix}`;
20
+ }
21
+
22
+ export function normalizeAssistantUserId(userId: string): string | undefined {
23
+ const safeUserId = path.basename(userId);
24
+ if (safeUserId !== userId) return undefined;
25
+ const pureId = safeUserId.startsWith(ASSISTANT_AGENT_PREFIX)
26
+ ? safeUserId.slice(ASSISTANT_AGENT_PREFIX.length)
27
+ : safeUserId;
28
+ if (!ASSISTANT_WORKSPACE_ID_RE.test(pureId)) return undefined;
29
+ return pureId;
30
+ }
31
+
32
+ export function shouldSyncBuiltInTemplate(action: string, isBuiltIn: unknown): boolean {
33
+ return action === "UPDATE_SKILL" && isBuiltIn === true;
34
+ }
35
+
36
+ export interface WsClientOptions {
37
+ serverUrl: string; // 例如: wss://api.aishuo.co/gateway/ws
38
+ authToken?: string; // 用于网关鉴权
39
+ gatewayId: string; // 当前网关宿主的标识,方便中控做集群分发
40
+ updater: SkillUpdater; // 传入原来已有的 updater 实例
41
+ enableFileLog?: boolean; // 文件日志开关
42
+ }
43
+
44
+ export class GatewayWsClient {
45
+ private ws: WebSocket | null = null;
46
+ private options: WsClientOptions;
47
+ private reconnectTimer: NodeJS.Timeout | null = null;
48
+ private agentScanTimer: NodeJS.Timeout | null = null;
49
+ private pingTimer: NodeJS.Timeout | null = null;
50
+ private connectTimeoutTimer: NodeJS.Timeout | null = null;
51
+ private lastServerAckAt = 0;
52
+ private reconnectAttempts = 0;
53
+ private isDestroyed = false;
54
+
55
+ // 本地缓存的 agent ID 列表
56
+ private currentAgentIds = new Set<string>();
57
+
58
+ private appendLogToFile(level: string, category: string, message: string, payload?: any) {
59
+ if (!this.options.enableFileLog) return;
60
+ try {
61
+ const ts = new Date().toISOString();
62
+ let logLine = `[${ts}] [${level}] [${category}] ${message}`;
63
+ if (payload !== undefined && payload !== null) {
64
+ // 如果是 Error 对象,主动提取 stack
65
+ if (payload instanceof Error) {
66
+ logLine += `\n Stack: ${payload.stack || payload.message}`;
67
+ } else {
68
+ logLine += ` | Data: ${typeof payload === 'object' ? JSON.stringify(payload) : payload}`;
69
+ }
70
+ }
71
+ logLine += '\n';
72
+ const logsDir = path.join(openclawHome(), "logs");
73
+ fs.mkdir(logsDir, { recursive: true }).then(() => {
74
+ const logPath = path.join(logsDir, "skill-logger.err");
75
+ fs.appendFile(logPath, logLine).catch(()=>{});
76
+ }).catch(()=>{});
77
+ } catch (e) {
78
+ // ignore
79
+ }
80
+ }
81
+
82
+ private createInstallTrace(context: Record<string, unknown>) {
83
+ return (stage: string, data?: Record<string, unknown>) => {
84
+ this.appendLogToFile(stage === "install.failed" ? "ERROR" : "INFO", "Install", stage, {
85
+ ...context,
86
+ ...data,
87
+ });
88
+ };
89
+ }
90
+
91
+ constructor(options: WsClientOptions) {
92
+ this.options = options;
93
+ }
94
+
95
+ public connect() {
96
+ if (this.isDestroyed) return;
97
+ if (this.ws && (
98
+ this.ws.readyState === WebSocket.OPEN ||
99
+ this.ws.readyState === WebSocket.CONNECTING
100
+ )) {
101
+ this.appendLogToFile("INFO", "Connection", "Connect skipped: websocket already active", { readyState: this.ws.readyState });
102
+ return;
103
+ }
104
+
105
+ this.clearConnectTimeout();
106
+
107
+ const msgConnect = `Connecting to Central Service: ${this.options.serverUrl}`;
108
+ console.log(`[skill-logger-plugin][WS] ${msgConnect}`);
109
+ this.appendLogToFile("INFO", "Connection", msgConnect);
110
+
111
+ const headers: Record<string, string> = {
112
+ "X-Gateway-Id": this.options.gatewayId,
113
+ };
114
+ if (this.options.authToken) {
115
+ headers["Authorization"] = this.options.authToken;
116
+ }
117
+
118
+ try {
119
+ const ws = new WebSocket(this.options.serverUrl, { headers });
120
+ this.ws = ws;
121
+ this.connectTimeoutTimer = setTimeout(() => {
122
+ if (this.ws === ws && ws.readyState === WebSocket.CONNECTING) {
123
+ this.appendLogToFile("WARN", "Connection", "Connection timed out before open; terminating socket.");
124
+ ws.terminate();
125
+ }
126
+ }, 15000);
127
+ } catch (err: any) {
128
+ console.error(`[skill-logger-plugin][WS] Sync init error:`, err);
129
+ this.appendLogToFile("ERROR", "Connection", "Sync initialization error", err);
130
+ this.scheduleReconnect();
131
+ return;
132
+ }
133
+
134
+ const ws = this.ws;
135
+
136
+ ws.on("open", async () => {
137
+ if (this.ws !== ws) return;
138
+ console.log(`[skill-logger-plugin][WS] Connected successfully!`);
139
+ this.appendLogToFile("INFO", "Connection", "Connected successfully!");
140
+ this.clearConnectTimeout();
141
+ this.reconnectAttempts = 0;
142
+ this.lastServerAckAt = Date.now();
143
+ this.clearReconnectTimer();
144
+
145
+ // 首次连接,全量扫描并上报,同时进行应用层握手,确保服务端能把 DB 在线态刷新成真实状态。
146
+ await this.scanAndReportAgents(true);
147
+ this.sendGatewayHello();
148
+ this.startHeartbeat();
149
+
150
+ // 开启 3 分钟定期的自动扫码增量同步
151
+ if (!this.agentScanTimer) {
152
+ this.agentScanTimer = setInterval(() => {
153
+ this.scanAndReportAgents(true);
154
+ }, AGENT_SCAN_INTERVAL_MS);
155
+ }
156
+ });
157
+
158
+ ws.on("pong", () => {
159
+ this.lastServerAckAt = Date.now();
160
+ });
161
+
162
+ ws.on("message", async (data) => {
163
+ if (this.ws !== ws) return;
164
+ try {
165
+ const msg = JSON.parse(data.toString());
166
+ if (msg?.type === "GATEWAY_HELLO_ACK" || msg?.type === "HEARTBEAT_ACK") {
167
+ this.lastServerAckAt = Date.now();
168
+ this.appendLogToFile("INFO", "Heartbeat", "Server heartbeat acknowledged", { type: msg.type, connectionId: msg.connectionId });
169
+ return;
170
+ }
171
+ if (msg?.type === "BATCH_COMMAND" && Array.isArray(msg.commands)) {
172
+ const { commands, type, ...shared } = msg;
173
+ for (const cmd of commands) {
174
+ await this.handleMessage({ ...shared, userId: cmd.userId, replyId: cmd.replyId });
175
+ }
176
+ return;
177
+ }
178
+ await this.handleMessage(msg);
179
+ } catch (err) {
180
+ console.error(`[skill-logger-plugin][WS] Failed to parse/handle message:`, err);
181
+ }
182
+ });
183
+
184
+ ws.on("close", () => {
185
+ console.warn(`[skill-logger-plugin][WS] Connection closed.`);
186
+ this.appendLogToFile("WARN", "Connection", "Connection closed. Starting reconnect timer.");
187
+ if (this.ws === ws) {
188
+ this.ws = null;
189
+ this.clearConnectTimeout();
190
+ this.clearAgentScanTimer();
191
+ this.clearHeartbeat();
192
+ this.scheduleReconnect();
193
+ }
194
+ });
195
+
196
+ ws.on("error", (err) => {
197
+ console.error(`[skill-logger-plugin][WS] Connection error:`, err);
198
+ this.appendLogToFile("ERROR", "Connection", "Connection error", err);
199
+ if (this.ws === ws) {
200
+ ws.close(); // 触发 close 事件进行重连
201
+ }
202
+ });
203
+ }
204
+
205
+ /**
206
+ * 扫描 OpenClaw 根目录下的 workspace-assistant-{userId} 目录。
207
+ * userId 必须是至少 5 位数字。
208
+ */
209
+ private async scanAndReportAgents(isInitialReport: boolean) {
210
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
211
+
212
+ try {
213
+ const rootPath = openclawHome();
214
+ let entries: string[] = [];
215
+ try {
216
+ entries = await fs.readdir(rootPath);
217
+ } catch (e) {
218
+ // 读目录失败通常是瞬时性的(磁盘抖动/权限/句柄耗尽等),不代表这台机器真的没有用户了。
219
+ // 跳过本轮上报、保留上次已知状态,等下一次扫描自然重试,避免把瞬时故障放大成
220
+ // "全部用户离线"(服务端收到空列表会把这台网关下所有用户标记 OFFLINE)。
221
+ this.appendLogToFile("WARN", "AgentScan", "OpenClaw home is not readable; skipping this scan cycle", e);
222
+ return;
223
+ }
224
+
225
+ const newAgentIds = new Set<string>();
226
+ for (const entry of entries) {
227
+ const agentId = parseAssistantWorkspaceAgentId(entry);
228
+ if (agentId) newAgentIds.add(agentId);
229
+ }
230
+
231
+ let changed = false;
232
+ if (newAgentIds.size !== this.currentAgentIds.size) {
233
+ changed = true;
234
+ } else {
235
+ for (const id of newAgentIds) {
236
+ if (!this.currentAgentIds.has(id)) {
237
+ changed = true;
238
+ break;
239
+ }
240
+ }
241
+ }
242
+
243
+ this.currentAgentIds = newAgentIds;
244
+
245
+ if (isInitialReport) {
246
+ this.appendLogToFile("INFO", "AgentScan", `Reporting full agent list`, { count: this.currentAgentIds.size, agents: Array.from(this.currentAgentIds) });
247
+ this.sendAgentListReport("AGENT_LIST_REPORT");
248
+ } else if (changed) {
249
+ this.appendLogToFile("INFO", "AgentScan", `Syncing changed agent list`, { count: this.currentAgentIds.size, agents: Array.from(this.currentAgentIds) });
250
+ this.sendAgentListReport("AGENT_LIST_SYNC");
251
+ }
252
+
253
+ } catch (err: any) {
254
+ console.error(`[skill-logger-plugin][WS] Failed to scan agents:`, err);
255
+ this.appendLogToFile("ERROR", "AgentScan", `Failed to scan agents`, err);
256
+ }
257
+ }
258
+
259
+ private startHeartbeat() {
260
+ this.clearHeartbeat();
261
+ this.pingTimer = setInterval(() => {
262
+ const ws = this.ws;
263
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
264
+
265
+ const ackAge = Date.now() - this.lastServerAckAt;
266
+ if (ackAge > HEARTBEAT_ACK_TIMEOUT_MS) {
267
+ this.appendLogToFile("WARN", "Heartbeat", "Server heartbeat ACK timed out; terminating socket for reconnect", { ackAge });
268
+ ws.terminate();
269
+ return;
270
+ }
271
+
272
+ ws.ping();
273
+ this.sendClientHeartbeat();
274
+ }, HEARTBEAT_INTERVAL_MS);
275
+ this.sendClientHeartbeat();
276
+ }
277
+
278
+ private clearHeartbeat() {
279
+ if (this.pingTimer) {
280
+ clearInterval(this.pingTimer);
281
+ this.pingTimer = null;
282
+ }
283
+ }
284
+
285
+ private scheduleReconnect() {
286
+ if (this.isDestroyed || this.reconnectTimer) return;
287
+
288
+ // 闭环完善:引入随机 Jitter 抖动,打散服务端重启时可能引发的瞬间重连风暴
289
+ const jitter = Math.floor(Math.random() * 5000);
290
+ const baseDelay = Math.min(30000, 2000 * Math.max(1, 2 ** this.reconnectAttempts));
291
+ const delay = baseDelay + jitter;
292
+ this.reconnectAttempts += 1;
293
+
294
+ console.log(`[skill-logger-plugin][WS] Reconnecting in ${delay}ms...`);
295
+ this.appendLogToFile("INFO", "Connection", "Reconnect scheduled", { delay, attempt: this.reconnectAttempts });
296
+ this.reconnectTimer = setTimeout(() => {
297
+ this.reconnectTimer = null;
298
+ this.connect();
299
+ }, delay);
300
+ }
301
+
302
+ private clearReconnectTimer() {
303
+ if (this.reconnectTimer) {
304
+ clearTimeout(this.reconnectTimer);
305
+ this.reconnectTimer = null;
306
+ }
307
+ }
308
+
309
+ private clearConnectTimeout() {
310
+ if (this.connectTimeoutTimer) {
311
+ clearTimeout(this.connectTimeoutTimer);
312
+ this.connectTimeoutTimer = null;
313
+ }
314
+ }
315
+
316
+ private terminateCurrentSocket(reason: string, payload?: any) {
317
+ const ws = this.ws;
318
+ if (!ws) return;
319
+ this.appendLogToFile("WARN", "Connection", `Terminating websocket: ${reason}`, payload);
320
+ try {
321
+ ws.terminate();
322
+ } catch (err) {
323
+ this.appendLogToFile("WARN", "Connection", "Terminate websocket failed", err);
324
+ }
325
+ }
326
+
327
+ private sendJson(payload: any, category: string) {
328
+ const ws = this.ws;
329
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
330
+
331
+ try {
332
+ ws.send(JSON.stringify(payload), (err) => {
333
+ if (!err) return;
334
+ this.appendLogToFile("WARN", category, "WebSocket send failed", err);
335
+ if (this.ws === ws) {
336
+ this.terminateCurrentSocket("send_failed", { category, message: err.message });
337
+ }
338
+ });
339
+ return true;
340
+ } catch (err) {
341
+ this.appendLogToFile("WARN", category, "WebSocket send threw", err);
342
+ if (this.ws === ws) {
343
+ this.terminateCurrentSocket("send_threw", err);
344
+ }
345
+ return false;
346
+ }
347
+ }
348
+
349
+ private sendGatewayHello() {
350
+ this.sendJson({
351
+ type: "GATEWAY_HELLO",
352
+ gatewayId: this.options.gatewayId,
353
+ agentIds: Array.from(this.currentAgentIds),
354
+ clientTime: Date.now(),
355
+ supportsBatch: true,
356
+ }, "Heartbeat");
357
+ }
358
+
359
+ private sendClientHeartbeat() {
360
+ this.sendJson({
361
+ type: "CLIENT_HEARTBEAT",
362
+ gatewayId: this.options.gatewayId,
363
+ agentIds: Array.from(this.currentAgentIds),
364
+ clientTime: Date.now(),
365
+ }, "Heartbeat");
366
+ }
367
+
368
+ private sendAgentListReport(type: "AGENT_LIST_REPORT" | "AGENT_LIST_SYNC") {
369
+ this.sendJson({
370
+ type,
371
+ gatewayId: this.options.gatewayId,
372
+ agentIds: Array.from(this.currentAgentIds),
373
+ clientTime: Date.now(),
374
+ }, "AgentScan");
375
+ }
376
+
377
+ private clearAgentScanTimer() {
378
+ if (this.agentScanTimer) {
379
+ clearInterval(this.agentScanTimer);
380
+ this.agentScanTimer = null;
381
+ }
382
+ }
383
+
384
+ /**
385
+ * 核心指令分发中心:完全跳过沙盒,基于 userId 直接进行底层物理文件操作
386
+ */
387
+ private async handleMessage(msg: any) {
388
+ const { action, userId, code, url, force, version, replyId, isBuiltIn } = msg;
389
+ this.appendLogToFile("INFO", "Command", `Received WS message`, {
390
+ action,
391
+ userId,
392
+ code,
393
+ version,
394
+ replyId,
395
+ isBuiltIn,
396
+ hasDirectUrl: Boolean(url),
397
+ });
398
+
399
+ if (!action || !userId) {
400
+ this.appendLogToFile("WARN", "Command", `Message dropped: missing action or userId`, msg);
401
+ return;
402
+ }
403
+
404
+ // 闭环完善:清理 code,并严格校验 userId,防止恶意指令通过 '../' 引发宿主机目录穿越攻击
405
+ const safeCode = code ? path.basename(code) : undefined;
406
+
407
+ // 100% 确定性安全寻址:userId 只接受纯数字或 assistant-数字,且数字至少 5 位。
408
+ const pureId = normalizeAssistantUserId(userId);
409
+ if (!pureId) {
410
+ this.reply(replyId, { success: false, message: `Invalid userId: ${userId}`, action });
411
+ return;
412
+ }
413
+ const targetDir = path.join(openclawHome(), `workspace-assistant-${pureId}`, "skills");
414
+
415
+ try {
416
+ if (action === "INSTALL_SKILL") {
417
+ if (!safeCode) throw new Error("Missing code parameter");
418
+ console.log(`[skill-logger-plugin][WS] Executing INSTALL for user ${userId}, code: ${safeCode}`);
419
+ this.appendLogToFile("INFO", "Command", `INSTALL_SKILL received`, { userId, code: safeCode, version });
420
+ const result = await this.options.updater.manualInstall({
421
+ code: safeCode,
422
+ url,
423
+ version,
424
+ force: force !== false,
425
+ targetDir,
426
+ trace: this.createInstallTrace({ action, replyId, userId, code: safeCode }),
427
+ });
428
+ this.reply(replyId, { success: result.success, message: result.message, action });
429
+
430
+ } else if (action === "UNINSTALL_SKILL") {
431
+ if (!safeCode) throw new Error("Missing code parameter");
432
+ console.log(`[skill-logger-plugin][WS] Executing UNINSTALL for user ${userId}, code: ${safeCode}`);
433
+ this.appendLogToFile("INFO", "Command", `UNINSTALL_SKILL received`, { userId, code: safeCode });
434
+ const skillPath = path.join(targetDir, safeCode);
435
+ await fs.rm(skillPath, { recursive: true, force: true });
436
+ this.reply(replyId, { success: true, message: `Skill ${safeCode} removed`, action });
437
+
438
+ } else if (action === "LIST_SKILLS") {
439
+ let list: any[] = [];
440
+ let targetDirExists = false;
441
+ try {
442
+ const targetStat = await fs.stat(targetDir);
443
+ targetDirExists = targetStat.isDirectory();
444
+ } catch (err: any) {
445
+ if (err?.code !== "ENOENT") throw err;
446
+ }
447
+ if (!targetDirExists) {
448
+ throw new Error(`Target skills directory does not exist: ${targetDir}`);
449
+ }
450
+
451
+ const entries = await fs.readdir(targetDir, { withFileTypes: true });
452
+
453
+ const dirs = entries.filter(e => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith("."));
454
+
455
+ for (const e of dirs) {
456
+ const skillDir = path.join(targetDir, e.name);
457
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
458
+
459
+ try {
460
+ const stat = await fs.stat(skillMdPath);
461
+ if (!stat.isFile()) continue;
462
+ } catch (err) {
463
+ continue;
464
+ }
465
+
466
+ const metaPath = path.join(skillDir, '.meta.json');
467
+ let isPlatform = false;
468
+ let isBuiltIn = e.isSymbolicLink();
469
+ let metaData: any = null;
470
+ let name = e.name;
471
+ let description = "";
472
+ let skillVersion = "";
473
+
474
+ try {
475
+ const mdContent = await fs.readFile(skillMdPath, 'utf8');
476
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(mdContent)?.[1] ?? "";
477
+ const parsedName = /(^|\n)name:\s*(.+)/.exec(fm)?.[2]?.trim();
478
+ if (parsedName) name = parsedName;
479
+
480
+ const descMatch = /(^|\n)description:\s*(?:>\s*\n\s*)?(.*?)(?=\n[a-z]+:|\n---|$)/is.exec(fm);
481
+ if (descMatch && descMatch[2]) {
482
+ description = descMatch[2].replace(/\n\s+/g, ' ').trim();
483
+ }
484
+ } catch (err) {}
485
+
486
+ try {
487
+ const metaContent = await fs.readFile(metaPath, 'utf8');
488
+ const parsed = JSON.parse(metaContent);
489
+ if (parsed) {
490
+ if (parsed.ownerId === 'CMS' || parsed.ownerId === 'CMS_COMPAT') isPlatform = true;
491
+ if (parsed.isBuiltIn === true || parsed.ownerId === 'built-in') isBuiltIn = true;
492
+ metaData = parsed;
493
+ }
494
+ } catch (err) {}
495
+
496
+ const resolvedVersion = await readSkillVersion(skillDir);
497
+ if (resolvedVersion) skillVersion = resolvedVersion;
498
+
499
+ if (isPlatform) {
500
+ list.push({
501
+ code: e.name,
502
+ isPlatform: true,
503
+ isBuiltIn: isBuiltIn,
504
+ version: skillVersion,
505
+ name,
506
+ description,
507
+ publishedAt: metaData?.publishedAt
508
+ });
509
+ } else {
510
+ list.push({
511
+ code: e.name,
512
+ isPlatform: false,
513
+ isBuiltIn: isBuiltIn,
514
+ version: skillVersion,
515
+ name: name,
516
+ description: description
517
+ });
518
+ }
519
+ }
520
+ this.reply(replyId, { success: true, data: list, action });
521
+
522
+ } else if (action === "UPDATE_SKILL") {
523
+ if (!safeCode) throw new Error("Missing code parameter");
524
+ // 更新可能是大批量广播,增加防阻塞的随机延时 (0~5秒)
525
+ const delayMs = Math.random() * 5000;
526
+ const syncBuiltInTemplate = shouldSyncBuiltInTemplate(action, isBuiltIn);
527
+ console.log(`[skill-logger-plugin][WS] Scheduled UPDATE for user ${userId}, code: ${safeCode} in ${Math.round(delayMs)}ms`);
528
+ this.appendLogToFile("INFO", "Command", `Scheduled UPDATE_SKILL`, {
529
+ userId,
530
+ code: safeCode,
531
+ version,
532
+ isBuiltIn,
533
+ syncBuiltInTemplate,
534
+ delayMs: Math.round(delayMs),
535
+ });
536
+
537
+ setTimeout(async () => {
538
+ try {
539
+ const additionalTargetDirs = syncBuiltInTemplate
540
+ ? [path.join(openclawHome(), "workspace-xgjk-assistant-template", "skills")]
541
+ : [];
542
+ const result = await this.options.updater.manualInstall({
543
+ code: safeCode,
544
+ url,
545
+ version,
546
+ force: true,
547
+ targetDir,
548
+ additionalTargetDirs,
549
+ trace: this.createInstallTrace({
550
+ action,
551
+ replyId,
552
+ userId,
553
+ code: safeCode,
554
+ isBuiltIn,
555
+ syncBuiltInTemplate,
556
+ }),
557
+ });
558
+ this.appendLogToFile(result.success ? "INFO" : "ERROR", "Command", `UPDATE_SKILL completed`, {
559
+ userId,
560
+ code: safeCode,
561
+ replyId,
562
+ success: result.success,
563
+ message: result.message,
564
+ syncBuiltInTemplate,
565
+ });
566
+ if (replyId) {
567
+ this.reply(replyId, { success: result.success, message: result.message, action });
568
+ }
569
+ } catch (e: any) {
570
+ this.appendLogToFile("ERROR", "Command", `UPDATE_SKILL threw`, {
571
+ userId,
572
+ code: safeCode,
573
+ replyId,
574
+ message: e?.message || String(e),
575
+ stack: e?.stack,
576
+ });
577
+ if (replyId) this.reply(replyId, { success: false, message: e.message, action });
578
+ }
579
+ }, delayMs);
580
+
581
+ } else if (action === "INSTALL_EXPERT") {
582
+ if (!safeCode) throw new Error("Missing code parameter");
583
+ const { name, version, downloadUrl, skills } = msg;
584
+ console.log(`[skill-logger-plugin][WS] Executing INSTALL_EXPERT for user ${userId}, code: ${safeCode}`);
585
+ this.appendLogToFile("INFO", "Command", `INSTALL_EXPERT received`, { userId, code: safeCode, version, downloadUrl });
586
+
587
+ const userSkillRoot = path.join(openclawHome(), `workspace-assistant-${pureId}`, ".user");
588
+
589
+ // 1. 检查当前版本,同版本跳过
590
+ const expertTarget = path.join(userSkillRoot, "experts", safeCode);
591
+ let skipInstall = false;
592
+ const metaPath = path.join(expertTarget, ".meta.json");
593
+ try {
594
+ const raw = await fs.readFile(metaPath, "utf-8");
595
+ const existing = JSON.parse(raw);
596
+ if (existing.version && existing.version === (version || '1.0.0')) {
597
+ skipInstall = true;
598
+ }
599
+ } catch {}
600
+
601
+ if (!skipInstall) {
602
+ await fs.mkdir(path.dirname(expertTarget), { recursive: true });
603
+ const expertResult = await this.options.updater.installZipFromUrl(downloadUrl, expertTarget);
604
+ if (!expertResult.success) {
605
+ throw new Error(`专家安装失败: ${expertResult.message}`);
606
+ }
607
+ }
608
+
609
+ // 写入/更新版本信息
610
+ const meta = { code: safeCode, name, version: version || '1.0.0', installedAt: Date.now() };
611
+ await fs.writeFile(metaPath, JSON.stringify(meta, null, 2));
612
+
613
+ // 2. 安装依赖的 skills
614
+ const skillTargetRoot = path.join(userSkillRoot, "skills");
615
+ await fs.mkdir(skillTargetRoot, { recursive: true });
616
+ const skillResults: string[] = [];
617
+ if (Array.isArray(skills)) {
618
+ for (const sk of skills) {
619
+ if (!sk.code || !sk.downloadUrl) {
620
+ skillResults.push(`${sk.code || 'unknown'}: 缺少下载地址`);
621
+ continue;
622
+ }
623
+ try {
624
+ const skTarget = path.join(skillTargetRoot, sk.code);
625
+ const result = await this.options.updater.installZipFromUrl(sk.downloadUrl, skTarget);
626
+ skillResults.push(`${sk.code}: ${result.success ? '成功' : '失败 - ' + result.message}`);
627
+ } catch (e: any) {
628
+ skillResults.push(`${sk.code}: 失败 - ${e.message}`);
629
+ }
630
+ }
631
+ }
632
+
633
+ this.reply(replyId, {
634
+ success: true,
635
+ message: `专家 ${safeCode} 安装完成`,
636
+ action,
637
+ data: { expertCode: safeCode, skills: skillResults },
638
+ });
639
+
640
+ } else if (action === "UNINSTALL_EXPERT") {
641
+ if (!safeCode) throw new Error("Missing code parameter");
642
+ console.log(`[skill-logger-plugin][WS] Executing UNINSTALL_EXPERT for user ${userId}, code: ${safeCode}`);
643
+ this.appendLogToFile("INFO", "Command", `UNINSTALL_EXPERT received`, { userId, code: safeCode });
644
+
645
+ const expertPath = path.join(openclawHome(), `workspace-assistant-${pureId}`, ".user", "experts", safeCode);
646
+ await fs.rm(expertPath, { recursive: true, force: true });
647
+
648
+ this.reply(replyId, { success: true, message: `专家 ${safeCode} 已卸载`, action });
649
+
650
+ } else if (action === "LIST_EXPERTS") {
651
+ console.log(`[skill-logger-plugin][WS] Executing LIST_EXPERTS for user ${userId}`);
652
+ this.appendLogToFile("INFO", "Command", `LIST_EXPERTS received`, { userId });
653
+
654
+ const expertsDir = path.join(openclawHome(), `workspace-assistant-${pureId}`, ".user", "experts");
655
+ const list: any[] = [];
656
+ try {
657
+ const stat = await fs.stat(expertsDir);
658
+ if (stat.isDirectory()) {
659
+ const entries = await fs.readdir(expertsDir, { withFileTypes: true });
660
+ for (const e of entries) {
661
+ if (!e.isDirectory()) continue;
662
+ const metaPath = path.join(expertsDir, e.name, ".meta.json");
663
+ try {
664
+ const raw = await fs.readFile(metaPath, "utf-8");
665
+ const meta = JSON.parse(raw);
666
+ list.push({
667
+ code: meta.code || e.name,
668
+ name: meta.name || e.name,
669
+ version: meta.version || '',
670
+ installedAt: meta.installedAt,
671
+ });
672
+ } catch {
673
+ list.push({ code: e.name, name: e.name, version: '' });
674
+ }
675
+ }
676
+ }
677
+ } catch {}
678
+ this.reply(replyId, { success: true, data: list, action });
679
+
680
+ } else {
681
+ console.warn(`[skill-logger-plugin][WS] Unknown action: ${action}`);
682
+ this.appendLogToFile("WARN", "Command", `Unknown action: ${action}`);
683
+ this.reply(replyId, { success: false, message: `Unknown action: ${action}`, action });
684
+ }
685
+ } catch (err: any) {
686
+ this.appendLogToFile("ERROR", "Command", `Error executing action ${action}`, err);
687
+ this.reply(replyId, { success: false, message: err.message, action });
688
+ }
689
+ }
690
+
691
+ private reply(replyId: string, payload: any) {
692
+ this.appendLogToFile("INFO", "Command", `Replying to command`, { replyId, payload });
693
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !replyId) {
694
+ this.appendLogToFile("WARN", "Command", `Cannot reply: WS closed or missing replyId`, { replyId });
695
+ return;
696
+ }
697
+ this.ws.send(JSON.stringify({ type: "REPLY", replyId, ...payload }), (err) => {
698
+ if (err) {
699
+ this.appendLogToFile("ERROR", "Command", `Reply send failed`, { replyId, message: err.message });
700
+ return;
701
+ }
702
+ this.appendLogToFile("INFO", "Command", `Reply sent`, { replyId });
703
+ });
704
+ }
705
+
706
+ public destroy() {
707
+ this.isDestroyed = true;
708
+ this.clearReconnectTimer();
709
+ this.clearAgentScanTimer();
710
+ this.clearHeartbeat();
711
+ this.clearConnectTimeout();
712
+ if (this.ws) {
713
+ this.ws.terminate(); // 强行销毁,斩断半开连接残留
714
+ this.ws = null;
715
+ }
716
+ }
717
+ }