@spzhongwin/skill-logger-plugin 1.0.15 → 1.0.17

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/src/updater.ts CHANGED
@@ -305,6 +305,26 @@ export class SkillUpdater {
305
305
  return undefined;
306
306
  }
307
307
 
308
+ /** 在解压目录里定位专家根目录;专家包根必须同时包含 AGENTS.md 和 SOUL.md。 */
309
+ private async locateExpertRoot(dir: string, depth: number): Promise<string | undefined> {
310
+ if (depth > 2) return undefined;
311
+ let entries;
312
+ try {
313
+ entries = await fs.readdir(dir, { withFileTypes: true });
314
+ } catch {
315
+ return undefined;
316
+ }
317
+ const files = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
318
+ if (files.has("AGENTS.md") && files.has("SOUL.md")) return dir;
319
+ for (const entry of entries) {
320
+ if (entry.isDirectory()) {
321
+ const found = await this.locateExpertRoot(path.join(dir, entry.name), depth + 1);
322
+ if (found) return found;
323
+ }
324
+ }
325
+ return undefined;
326
+ }
327
+
308
328
  /**
309
329
  * 直接覆盖、不备份,但保证「不丢数据」:
310
330
  * 先把新内容暂存到同级临时目录 → 旧目录改名挪开 → 新内容 rename 换入 → 删掉挪开的旧目录。
@@ -372,6 +392,30 @@ export class SkillUpdater {
372
392
  }
373
393
  }
374
394
 
395
+ async installExpertZipFromUrl(url: string, targetDir: string): Promise<{success: boolean; message: string}> {
396
+ const work = path.join(this.tmpDir, `slp-expert-${randomUUID()}`);
397
+ await fs.mkdir(work, { recursive: true });
398
+ try {
399
+ const zipPath = path.join(work, "pkg.zip");
400
+ const res = await this.fetchImpl(url);
401
+ if (!res.ok) return { success: false, message: `下载失败: HTTP ${res.status}` };
402
+ await fs.writeFile(zipPath, Buffer.from(await res.arrayBuffer()));
403
+
404
+ const staging = path.join(work, "staging");
405
+ await this.unzip(zipPath, staging);
406
+ const expertRoot = await this.locateExpertRoot(staging, 0);
407
+ if (!expertRoot) {
408
+ return { success: false, message: "下载包内未找到同时含 AGENTS.md 和 SOUL.md 的专家根目录" };
409
+ }
410
+ await this.replaceDir(expertRoot, targetDir);
411
+ return { success: true, message: "安装成功" };
412
+ } catch (err: any) {
413
+ return { success: false, message: `执行出错: ${err.message}` };
414
+ } finally {
415
+ await fs.rm(work, { recursive: true, force: true }).catch(() => {});
416
+ }
417
+ }
418
+
375
419
  async manualInstall(options: {
376
420
  code: string;
377
421
  url?: string;
@@ -404,6 +448,14 @@ export class SkillUpdater {
404
448
  });
405
449
 
406
450
  try {
451
+ if (
452
+ !code || code === "." || code === ".." || code.includes("/") ||
453
+ code.includes("\\") || code.includes("\0") || path.basename(code) !== code
454
+ ) {
455
+ const message = `非法的 Skill code: ${code}`;
456
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
457
+ return { success: false, message };
458
+ }
407
459
  if (!url) {
408
460
  // 若没有直接传入 ZIP 的 URL,向平台索取该 skill 的 latest 下载地址
409
461
  const lookupStartedAt = Date.now();
@@ -6,11 +6,115 @@ import path from "node:path";
6
6
  import { DatabaseSync } from "node:sqlite";
7
7
  import {
8
8
  normalizeAssistantUserId,
9
+ normalizeCommandCode,
10
+ enabledAgentIdsFromAccounts,
11
+ resolveSkillInstallTarget,
9
12
  parseAssistantWorkspaceAgentId,
10
13
  readCronJobsByAgentId,
14
+ findInstalledExpertSkillsRoot,
11
15
  shouldSyncBuiltInTemplate,
12
16
  } from "./ws-client.ts";
13
17
 
18
+ describe("command code boundary", () => {
19
+ it("只接受单一路径段,不静默改写路径穿越输入", () => {
20
+ assert.equal(normalizeCommandCode("demo-skill"), "demo-skill");
21
+ assert.equal(normalizeCommandCode("../demo"), undefined);
22
+ assert.equal(normalizeCommandCode("a/b"), undefined);
23
+ assert.equal(normalizeCommandCode("a\\b"), undefined);
24
+ assert.equal(normalizeCommandCode("."), undefined);
25
+ assert.equal(normalizeCommandCode(".."), undefined);
26
+ assert.equal(normalizeCommandCode(""), undefined);
27
+ assert.equal(normalizeCommandCode(123), undefined);
28
+ });
29
+ });
30
+
31
+ describe("gateway heartbeat agent accounts", () => {
32
+ it("reports enabled accounts and accounts without an explicit enabled field", () => {
33
+ const agentIds = enabledAgentIdsFromAccounts({
34
+ channels: {
35
+ xg_cwork_im: {
36
+ accounts: {
37
+ "assistant-enabled": { agentId: "assistant-enabled", enabled: true },
38
+ "assistant-legacy": { agentId: "assistant-legacy" },
39
+ default: { groupPolicy: "mention" },
40
+ },
41
+ },
42
+ },
43
+ });
44
+
45
+ assert.deepEqual(agentIds, ["assistant-enabled", "assistant-legacy"]);
46
+ });
47
+
48
+ it("filters accounts explicitly disabled in openclaw.json", () => {
49
+ const agentIds = enabledAgentIdsFromAccounts({
50
+ channels: {
51
+ xg_cwork_im: {
52
+ accounts: {
53
+ "assistant-1514822133731725314": {
54
+ agentId: "assistant-1514822133731725314",
55
+ enabled: false,
56
+ name: "个人助理",
57
+ robotKey: "secret-is-not-used-for-heartbeats",
58
+ },
59
+ "assistant-active": { agentId: "assistant-active", enabled: true },
60
+ },
61
+ },
62
+ },
63
+ });
64
+
65
+ assert.deepEqual(agentIds, ["assistant-active"]);
66
+ });
67
+
68
+ it("returns no agents when the account path is absent or malformed", () => {
69
+ assert.deepEqual(enabledAgentIdsFromAccounts({}), []);
70
+ assert.deepEqual(enabledAgentIdsFromAccounts({ channels: { xg_cwork_im: { accounts: [] } } }), []);
71
+ });
72
+ });
73
+
74
+ describe("普通 Skill 配置驱动的安装目录", () => {
75
+ const config = {
76
+ channels: {
77
+ xg_cwork_im: {
78
+ accounts: {
79
+ "assistant-12345": { agentId: "assistant-12345", enabled: true },
80
+ "assistant-67890": { agentId: "assistant-67890", enabled: false },
81
+ },
82
+ },
83
+ },
84
+ bindings: [{
85
+ agentId: "sales-agent",
86
+ match: { channel: "xg_cwork_im", accountId: "assistant-12345" },
87
+ }],
88
+ agents: {
89
+ defaults: { workspace: "/var/openclaw/default" },
90
+ list: [{ id: "sales-agent", workspace: "/var/openclaw/sales" }],
91
+ },
92
+ };
93
+
94
+ it("通过 account、binding 和 agent workspace 解析目录", () => {
95
+ assert.deepEqual(resolveSkillInstallTarget(config, "assistant-12345"), {
96
+ localAgentId: "sales-agent",
97
+ skillsDir: "/var/openclaw/sales/skills",
98
+ });
99
+ });
100
+
101
+ it("拒绝显式 disabled 的 account", () => {
102
+ assert.throws(() => resolveSkillInstallTarget(config, "assistant-67890"), /已禁用/);
103
+ });
104
+
105
+ it("找不到 account、binding 后的 agent 或 workspace 时拒绝回退到命名目录", () => {
106
+ assert.throws(() => resolveSkillInstallTarget(config, "assistant-99999"), /未找到 userId/);
107
+ assert.throws(() => resolveSkillInstallTarget({
108
+ ...config,
109
+ bindings: [{ agentId: "missing-agent", match: { channel: "xg_cwork_im", accountId: "assistant-12345" } }],
110
+ }, "assistant-12345"), /未找到本地 Agent/);
111
+ assert.throws(() => resolveSkillInstallTarget({
112
+ ...config,
113
+ agents: { list: [{ id: "sales-agent" }] },
114
+ }, "assistant-12345"), /未配置 workspace/);
115
+ });
116
+ });
117
+
14
118
  describe("assistant workspace naming", () => {
15
119
  it("accepts workspace-assistant-* only when suffix is at least 5 digits", () => {
16
120
  assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-12345"), "assistant-12345");
@@ -52,6 +156,21 @@ describe("built-in template update boundary", () => {
52
156
  });
53
157
  });
54
158
 
159
+ describe("expert skill update target", () => {
160
+ it("返回 .user/skills 父目录,由安装器统一追加 skill code", async () => {
161
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-expert-skill-"));
162
+ try {
163
+ const skillsRoot = path.join(root, "workspace-assistant-12345", ".user", "skills");
164
+ fs.mkdirSync(path.join(skillsRoot, "demo"), { recursive: true });
165
+
166
+ assert.equal(await findInstalledExpertSkillsRoot(root, "12345", "demo"), skillsRoot);
167
+ assert.equal(await findInstalledExpertSkillsRoot(root, "12345", "missing"), undefined);
168
+ } finally {
169
+ fs.rmSync(root, { recursive: true, force: true });
170
+ }
171
+ });
172
+ });
173
+
55
174
  describe("cron job sqlite lookup", () => {
56
175
  it("returns all cron_jobs rows for the requested agent_id", () => {
57
176
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cron-jobs-"));
package/src/ws-client.ts CHANGED
@@ -13,6 +13,105 @@ const ASSISTANT_WORKSPACE_PREFIX = "workspace-assistant-";
13
13
  const ASSISTANT_AGENT_PREFIX = "assistant-";
14
14
  const ASSISTANT_WORKSPACE_ID_RE = /^\d{5,}$/;
15
15
 
16
+ type OpenClawAccount = {
17
+ agentId?: unknown;
18
+ enabled?: unknown;
19
+ };
20
+
21
+ type SkillInstallTarget = {
22
+ /** 普通 Skill 的安装根目录。 */
23
+ skillsDir: string;
24
+ /** 解析出的本地 OpenClaw agent,便于日志与后续专家链路复用。 */
25
+ localAgentId: string;
26
+ };
27
+
28
+ /**
29
+ * 从 `channels.xg_cwork_im.accounts` 提取应上报给中控的 agent ID。
30
+ *
31
+ * `enabled: false` 是唯一的禁用标识;旧配置未填写 enabled 时维持原有可用语义。
32
+ * 没有有效 agentId 的配置项(例如 default)不会被上报。
33
+ */
34
+ export function enabledAgentIdsFromAccounts(config: unknown): string[] {
35
+ if (!config || typeof config !== "object") return [];
36
+
37
+ const channels = (config as { channels?: unknown }).channels;
38
+ if (!channels || typeof channels !== "object") return [];
39
+ const cworkConfig = (channels as { xg_cwork_im?: unknown }).xg_cwork_im;
40
+ if (!cworkConfig || typeof cworkConfig !== "object") return [];
41
+ const accounts = (cworkConfig as { accounts?: unknown }).accounts;
42
+ if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) return [];
43
+
44
+ const agentIds = new Set<string>();
45
+ for (const account of Object.values(accounts as Record<string, OpenClawAccount>)) {
46
+ if (!account || typeof account !== "object" || account.enabled === false) continue;
47
+ if (typeof account.agentId !== "string") continue;
48
+ const agentId = account.agentId.trim();
49
+ if (agentId) agentIds.add(agentId);
50
+ }
51
+ return [...agentIds];
52
+ }
53
+
54
+ /**
55
+ * 根据 OpenClaw 配置解析普通 Skill 的安装目录。
56
+ *
57
+ * 只有同时满足以下条件才返回目标目录:
58
+ * 1. xg_cwork_im 中存在 `agentId === userId` 的 account;
59
+ * 2. account 没有显式 disabled;
60
+ * 3. account(或它的 binding)映射到一个配置了 workspace 的本地 agent。
61
+ *
62
+ * 这里故意不回退到 `workspace-assistant-<id>` 命名规则。配置缺失时拒绝执行,
63
+ * 防止禁用或已迁移 workspace 的用户仍被写入历史目录。
64
+ */
65
+ export function resolveSkillInstallTarget(config: unknown, userId: string): SkillInstallTarget {
66
+ if (!config || typeof config !== "object") throw new Error("openclaw.json 配置无效");
67
+ const root = config as {
68
+ channels?: { xg_cwork_im?: { accounts?: unknown } };
69
+ bindings?: unknown;
70
+ agents?: { defaults?: { workspace?: unknown }; list?: unknown };
71
+ };
72
+ const accounts = root.channels?.xg_cwork_im?.accounts;
73
+ if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) {
74
+ throw new Error(`未找到 userId=${userId} 的 xg_cwork_im account`);
75
+ }
76
+
77
+ const matched = Object.entries(accounts as Record<string, OpenClawAccount>)
78
+ .filter(([, account]) => account && typeof account === "object" && account.agentId === userId);
79
+ if (matched.length !== 1) {
80
+ throw new Error(matched.length > 1
81
+ ? `userId=${userId} 存在多个 xg_cwork_im account,无法确定安装目录`
82
+ : `未找到 userId=${userId} 的 xg_cwork_im account`);
83
+ }
84
+ const [accountId, account] = matched[0];
85
+ if (account.enabled === false) throw new Error(`userId=${userId} 对应 Agent 已禁用`);
86
+
87
+ // 规范配置以 binding 决定本地 agent;兼容历史配置中 account key/agentId 作为 accountId 的写法。
88
+ const bindingAccountIds = new Set([accountId, userId]);
89
+ const bindings = Array.isArray(root.bindings) ? root.bindings : [];
90
+ const binding = bindings.find((item): item is { agentId?: unknown; match?: { channel?: unknown; accountId?: unknown } } => {
91
+ if (!item || typeof item !== "object") return false;
92
+ const candidate = item as { agentId?: unknown; match?: { channel?: unknown; accountId?: unknown } };
93
+ return candidate.match?.channel === "xg_cwork_im"
94
+ && typeof candidate.match.accountId === "string"
95
+ && bindingAccountIds.has(candidate.match.accountId)
96
+ && typeof candidate.agentId === "string"
97
+ && candidate.agentId.length > 0;
98
+ });
99
+ const localAgentId = typeof binding?.agentId === "string" ? binding.agentId : userId;
100
+
101
+ const list = Array.isArray(root.agents?.list) ? root.agents.list : [];
102
+ const agent = list.find((item): item is { id?: unknown; workspace?: unknown } =>
103
+ Boolean(item) && typeof item === "object" && (item as { id?: unknown }).id === localAgentId,
104
+ );
105
+ if (!agent) throw new Error(`未找到本地 Agent 配置: ${localAgentId}`);
106
+ const workspace = typeof agent.workspace === "string"
107
+ ? agent.workspace
108
+ : root.agents?.defaults?.workspace;
109
+ if (typeof workspace !== "string" || !workspace.trim()) {
110
+ throw new Error(`本地 Agent ${localAgentId} 未配置 workspace`);
111
+ }
112
+ return { skillsDir: path.join(workspace, "skills"), localAgentId };
113
+ }
114
+
16
115
  export function parseAssistantWorkspaceAgentId(entryName: string): string | undefined {
17
116
  if (!entryName.startsWith(ASSISTANT_WORKSPACE_PREFIX)) return undefined;
18
117
  const suffix = entryName.slice(ASSISTANT_WORKSPACE_PREFIX.length);
@@ -30,10 +129,31 @@ export function normalizeAssistantUserId(userId: string): string | undefined {
30
129
  return pureId;
31
130
  }
32
131
 
132
+ export function normalizeCommandCode(code: unknown): string | undefined {
133
+ if (typeof code !== "string" || code.length === 0 || code === "." || code === "..") return undefined;
134
+ if (code.includes("/") || code.includes("\\") || code.includes("\0")) return undefined;
135
+ if (path.basename(code) !== code) return undefined;
136
+ return code;
137
+ }
138
+
33
139
  export function shouldSyncBuiltInTemplate(action: string, isBuiltIn: unknown): boolean {
34
140
  return action === "UPDATE_SKILL" && isBuiltIn === true;
35
141
  }
36
142
 
143
+ export async function findInstalledExpertSkillsRoot(
144
+ rootPath: string,
145
+ pureId: string,
146
+ code: string
147
+ ): Promise<string | undefined> {
148
+ const skillsRoot = path.join(rootPath, `workspace-assistant-${pureId}`, ".user", "skills");
149
+ try {
150
+ const stat = await fs.stat(path.join(skillsRoot, code));
151
+ return stat.isDirectory() ? skillsRoot : undefined;
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ }
156
+
37
157
  export function defaultOpenclawSqlitePath(): string {
38
158
  return path.join(openclawHome(), "state", "openclaw.sqlite");
39
159
  }
@@ -110,6 +230,18 @@ export class GatewayWsClient {
110
230
  };
111
231
  }
112
232
 
233
+ /** 普通 Skill 的所有读写操作共用这一个配置驱动的寻址入口。 */
234
+ private async resolveRegularSkillTarget(userId: string): Promise<string> {
235
+ const configPath = path.join(openclawHome(), "openclaw.json");
236
+ let config: unknown;
237
+ try {
238
+ config = JSON.parse(await fs.readFile(configPath, "utf-8"));
239
+ } catch (err: any) {
240
+ throw new Error(`无法读取 openclaw.json: ${err?.message || String(err)}`);
241
+ }
242
+ return resolveSkillInstallTarget(config, userId).skillsDir;
243
+ }
244
+
113
245
  constructor(options: WsClientOptions) {
114
246
  this.options = options;
115
247
  }
@@ -224,31 +356,29 @@ export class GatewayWsClient {
224
356
  });
225
357
  }
226
358
 
227
- /**
228
- * 扫描 OpenClaw 根目录下的 workspace-assistant-{userId} 目录。
229
- * userId 必须是至少 5 位数字。
230
- */
359
+ /** 从 openclaw.json 的 xg_cwork_im accounts 读取当前启用的 agent。 */
231
360
  private async scanAndReportAgents(isInitialReport: boolean) {
232
361
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
233
362
 
234
363
  try {
235
- const rootPath = openclawHome();
236
- let entries: string[] = [];
364
+ const configPath = path.join(openclawHome(), "openclaw.json");
365
+ let rawConfig: string;
237
366
  try {
238
- entries = await fs.readdir(rootPath);
367
+ rawConfig = await fs.readFile(configPath, "utf-8");
239
368
  } catch (e) {
240
- // 读目录失败通常是瞬时性的(磁盘抖动/权限/句柄耗尽等),不代表这台机器真的没有用户了。
241
- // 跳过本轮上报、保留上次已知状态,等下一次扫描自然重试,避免把瞬时故障放大成
242
- // "全部用户离线"(服务端收到空列表会把这台网关下所有用户标记 OFFLINE)。
243
- this.appendLogToFile("WARN", "AgentScan", "OpenClaw home is not readable; skipping this scan cycle", e);
369
+ // 配置文件临时不可读时,保留上次成功读取的列表,避免把所有用户误报为离线。
370
+ this.appendLogToFile("WARN", "AgentScan", "OpenClaw config is not readable; skipping this scan cycle", e);
244
371
  return;
245
372
  }
246
373
 
247
- const newAgentIds = new Set<string>();
248
- for (const entry of entries) {
249
- const agentId = parseAssistantWorkspaceAgentId(entry);
250
- if (agentId) newAgentIds.add(agentId);
374
+ let config: unknown;
375
+ try {
376
+ config = JSON.parse(rawConfig);
377
+ } catch (e) {
378
+ this.appendLogToFile("WARN", "AgentScan", "OpenClaw config is invalid JSON; skipping this scan cycle", e);
379
+ return;
251
380
  }
381
+ const newAgentIds = new Set(enabledAgentIdsFromAccounts(config));
252
382
 
253
383
  let changed = false;
254
384
  if (newAgentIds.size !== this.currentAgentIds.size) {
@@ -450,8 +580,12 @@ export class GatewayWsClient {
450
580
  return;
451
581
  }
452
582
 
453
- // 闭环完善:清理 code,并严格校验 userId,防止恶意指令通过 '../' 引发宿主机目录穿越攻击
454
- const safeCode = code ? path.basename(code) : undefined;
583
+ // code 必须是单一路径段;不允许通过 basename 静默改写恶意输入。
584
+ const safeCode = normalizeCommandCode(code);
585
+ if (code !== undefined && !safeCode) {
586
+ this.reply(replyId, { success: false, message: `Invalid code: ${String(code)}`, action });
587
+ return;
588
+ }
455
589
 
456
590
  // 100% 确定性安全寻址:userId 只接受纯数字或 assistant-数字,且数字至少 5 位。
457
591
  const pureId = normalizeAssistantUserId(userId);
@@ -459,11 +593,10 @@ export class GatewayWsClient {
459
593
  this.reply(replyId, { success: false, message: `Invalid userId: ${userId}`, action });
460
594
  return;
461
595
  }
462
- const targetDir = path.join(openclawHome(), `workspace-assistant-${pureId}`, "skills");
463
-
464
596
  try {
465
597
  if (action === "INSTALL_SKILL") {
466
598
  if (!safeCode) throw new Error("Missing code parameter");
599
+ const targetDir = await this.resolveRegularSkillTarget(userId);
467
600
  console.log(`[skill-logger-plugin][WS] Executing INSTALL for user ${userId}, code: ${safeCode}`);
468
601
  this.appendLogToFile("INFO", "Command", `INSTALL_SKILL received`, { userId, code: safeCode, version });
469
602
  const result = await this.options.updater.manualInstall({
@@ -478,6 +611,7 @@ export class GatewayWsClient {
478
611
 
479
612
  } else if (action === "UNINSTALL_SKILL") {
480
613
  if (!safeCode) throw new Error("Missing code parameter");
614
+ const targetDir = await this.resolveRegularSkillTarget(userId);
481
615
  console.log(`[skill-logger-plugin][WS] Executing UNINSTALL for user ${userId}, code: ${safeCode}`);
482
616
  this.appendLogToFile("INFO", "Command", `UNINSTALL_SKILL received`, { userId, code: safeCode });
483
617
  const skillPath = path.join(targetDir, safeCode);
@@ -485,6 +619,7 @@ export class GatewayWsClient {
485
619
  this.reply(replyId, { success: true, message: `Skill ${safeCode} removed`, action });
486
620
 
487
621
  } else if (action === "LIST_SKILLS") {
622
+ const targetDir = await this.resolveRegularSkillTarget(userId);
488
623
  let list: any[] = [];
489
624
  let targetDirExists = false;
490
625
  try {
@@ -585,16 +720,15 @@ export class GatewayWsClient {
585
720
 
586
721
  setTimeout(async () => {
587
722
  try {
723
+ // 延时期间配置可能变化;在真正写盘前重新校验 enabled 与 workspace。
724
+ const targetDir = await this.resolveRegularSkillTarget(userId);
588
725
  const additionalTargetDirs: string[] = syncBuiltInTemplate
589
726
  ? [path.join(openclawHome(), "workspace-xgjk-assistant-template", "skills")]
590
727
  : [];
591
728
 
592
729
  // 同步更新用户的 expert skill 目录
593
- const userSkillDir = path.join(openclawHome(), `workspace-assistant-${pureId}`, ".user", "skills", safeCode);
594
- try {
595
- await fs.stat(userSkillDir);
596
- additionalTargetDirs.push(userSkillDir);
597
- } catch {} // 目录不存在则跳过
730
+ const userSkillRoot = await findInstalledExpertSkillsRoot(openclawHome(), pureId, safeCode);
731
+ if (userSkillRoot) additionalTargetDirs.push(userSkillRoot);
598
732
  const result = await this.options.updater.manualInstall({
599
733
  code: safeCode,
600
734
  url,
@@ -656,7 +790,7 @@ export class GatewayWsClient {
656
790
 
657
791
  if (!skipInstall) {
658
792
  await fs.mkdir(path.dirname(expertTarget), { recursive: true });
659
- const expertResult = await this.options.updater.installZipFromUrl(downloadUrl, expertTarget);
793
+ const expertResult = await this.options.updater.installExpertZipFromUrl(downloadUrl, expertTarget);
660
794
  if (!expertResult.success) {
661
795
  throw new Error(`专家安装失败: ${expertResult.message}`);
662
796
  }
@@ -672,16 +806,26 @@ export class GatewayWsClient {
672
806
  const skillResults: string[] = [];
673
807
  if (Array.isArray(skills)) {
674
808
  for (const sk of skills) {
675
- if (!sk.code || !sk.downloadUrl) {
676
- skillResults.push(`${sk.code || 'unknown'}: 缺少下载地址`);
809
+ const skillCode = normalizeCommandCode(sk?.code);
810
+ if (!skillCode) {
811
+ skillResults.push(`${String(sk?.code || 'unknown')}: 失败 - 非法的 Skill code`);
812
+ continue;
813
+ }
814
+ if (!sk.downloadUrl) {
815
+ skillResults.push(`${skillCode}: 失败 - 缺少下载地址`);
677
816
  continue;
678
817
  }
679
818
  try {
680
- const skTarget = path.join(skillTargetRoot, sk.code);
681
- const result = await this.options.updater.installZipFromUrl(sk.downloadUrl, skTarget);
682
- skillResults.push(`${sk.code}: ${result.success ? '成功' : '失败 - ' + result.message}`);
819
+ const result = await this.options.updater.manualInstall({
820
+ code: skillCode,
821
+ url: sk.downloadUrl,
822
+ version: sk.version,
823
+ force: true,
824
+ targetDir: skillTargetRoot,
825
+ });
826
+ skillResults.push(`${skillCode}: ${result.success ? '成功' : '失败 - ' + result.message}`);
683
827
  } catch (e: any) {
684
- skillResults.push(`${sk.code}: 失败 - ${e.message}`);
828
+ skillResults.push(`${skillCode}: 失败 - ${e.message}`);
685
829
  }
686
830
  }
687
831
  }
@@ -1,67 +0,0 @@
1
- /**
2
- * 按 session 记录「已触发(激活)的 skill」,供 matcher 在通用命令(curl、共享 CLI)
3
- * 出现多候选时消歧——优先归属到本 session 已激活的 skill。
4
- *
5
- * 内存态,带 TTL + 容量上限,防止长跑 gateway 进程里无限增长。
6
- */
7
- const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 分钟
8
- const DEFAULT_MAX_SESSIONS = 500;
9
- export class ActiveSkills {
10
- ttlMs;
11
- maxSessions;
12
- now;
13
- /** sessionKey -> (skillName -> entry)。用 Map 保留插入顺序以便 LRU 淘汰。 */
14
- bySession = new Map();
15
- constructor(opts = {}) {
16
- this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
17
- this.maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
18
- this.now = opts.now ?? Date.now;
19
- }
20
- /** 标记某 session 触发了某 skill。 */
21
- markActive(sessionKey, skillName) {
22
- const key = sessionKey || "__nosession__";
23
- let skills = this.bySession.get(key);
24
- if (!skills) {
25
- skills = new Map();
26
- this.bySession.set(key, skills);
27
- }
28
- else {
29
- // 触碰即刷新 LRU 顺序
30
- this.bySession.delete(key);
31
- this.bySession.set(key, skills);
32
- }
33
- skills.set(skillName, { name: skillName, ts: this.now() });
34
- this.evictIfNeeded();
35
- }
36
- /** 取某 session 当前仍在 TTL 内的已激活 skill 集合。 */
37
- getActive(sessionKey) {
38
- const key = sessionKey || "__nosession__";
39
- const skills = this.bySession.get(key);
40
- const out = new Set();
41
- if (!skills)
42
- return out;
43
- const cutoff = this.now() - this.ttlMs;
44
- for (const [name, entry] of skills) {
45
- if (entry.ts >= cutoff)
46
- out.add(name);
47
- else
48
- skills.delete(name);
49
- }
50
- if (skills.size === 0)
51
- this.bySession.delete(key);
52
- return out;
53
- }
54
- /** session 结束时清掉其激活记录(释放内存 + 避免跨会话误判)。 */
55
- clearSession(sessionKey) {
56
- this.bySession.delete(sessionKey || "__nosession__");
57
- }
58
- /** 超出 session 上限时,按 LRU 淘汰最旧的 session。 */
59
- evictIfNeeded() {
60
- while (this.bySession.size > this.maxSessions) {
61
- const oldest = this.bySession.keys().next().value;
62
- if (oldest === undefined)
63
- break;
64
- this.bySession.delete(oldest);
65
- }
66
- }
67
- }
@@ -1,29 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { ActiveSkills } from "./active-skills.ts";
4
- describe("ActiveSkills", () => {
5
- it("标记并取回激活 skill", () => {
6
- const a = new ActiveSkills();
7
- a.markActive("s1", "skA");
8
- a.markActive("s1", "skB");
9
- assert.deepEqual([...a.getActive("s1")].sort(), ["skA", "skB"]);
10
- assert.deepEqual([...a.getActive("s2")], []);
11
- });
12
- it("TTL 过期后不再返回", () => {
13
- let t = 1000;
14
- const a = new ActiveSkills({ ttlMs: 100, now: () => t });
15
- a.markActive("s", "skA");
16
- t = 1050;
17
- assert.deepEqual([...a.getActive("s")], ["skA"]);
18
- t = 2000;
19
- assert.deepEqual([...a.getActive("s")], []);
20
- });
21
- it("超出 session 上限按 LRU 淘汰最旧", () => {
22
- const a = new ActiveSkills({ maxSessions: 2 });
23
- a.markActive("s1", "x");
24
- a.markActive("s2", "x");
25
- a.markActive("s3", "x"); // 淘汰 s1
26
- assert.deepEqual([...a.getActive("s1")], []);
27
- assert.deepEqual([...a.getActive("s3")], ["x"]);
28
- });
29
- });