@spzhongwin/skill-logger-plugin 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -112,7 +112,7 @@ import { randomUUID, createHash } from "node:crypto";
112
112
  // src/skill-version.ts
113
113
  import fs2 from "node:fs/promises";
114
114
  import path2 from "node:path";
115
- async function readSkillVersion2(rootDir, skillMdContent) {
115
+ async function readSkillVersion(rootDir, skillMdContent) {
116
116
  try {
117
117
  const metaPath = path2.join(rootDir, ".meta.json");
118
118
  const metaContent = await fs2.readFile(metaPath, "utf-8");
@@ -251,8 +251,8 @@ var SkillUpdater = class {
251
251
  let packageVersion;
252
252
  try {
253
253
  packageVersion = await readSkillVersion(srcRoot);
254
- } catch {
255
- console.warn(`[skill-logger-plugin] ${skillName}@${version} \u8BFB\u53D6\u4E0B\u8F7D\u5305\u7248\u672C\u53F7\u5931\u8D25\uFF0C\u653E\u5F03\u8986\u76D6`);
254
+ } catch (err) {
255
+ console.warn(`[skill-logger-plugin] ${skillName}@${version} \u8BFB\u53D6\u4E0B\u8F7D\u5305\u7248\u672C\u53F7\u5931\u8D25\uFF0C\u653E\u5F03\u8986\u76D6`, err);
256
256
  return;
257
257
  }
258
258
  if (!packageVersion) {
@@ -394,8 +394,7 @@ var SkillUpdater = class {
394
394
  let parsedVersion = version || "unknown";
395
395
  if (!version) {
396
396
  try {
397
- const newSkillMd = await fs3.readFile(path3.join(srcRoot, "SKILL.md"), "utf-8");
398
- parsedVersion = parseSkillVersion(newSkillMd) || "unknown";
397
+ parsedVersion = await readSkillVersion(srcRoot) || "unknown";
399
398
  } catch {
400
399
  }
401
400
  }
@@ -935,7 +934,7 @@ var ConfigSync = class {
935
934
  const content = await fs4.readFile(skillMdPath, "utf-8");
936
935
  const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)?.[1] ?? "";
937
936
  const name = /(^|\n)name:\s*(.+)/.exec(fm)?.[2]?.trim() || path5.basename(rootDir);
938
- const version = await readSkillVersion2(rootDir, content);
937
+ const version = await readSkillVersion(rootDir, content);
939
938
  const signature = await this.computeSignature(rootDir, skillMdPath, version);
940
939
  return { name, version, rootDir, signature };
941
940
  } catch {
@@ -1784,7 +1783,7 @@ var GatewayWsClient = class {
1784
1783
  }
1785
1784
  } catch (err) {
1786
1785
  }
1787
- const resolvedVersion = await readSkillVersion2(skillDir);
1786
+ const resolvedVersion = await readSkillVersion(skillDir);
1788
1787
  if (resolvedVersion) skillVersion = resolvedVersion;
1789
1788
  if (isPlatform) {
1790
1789
  list.push({
@@ -1874,7 +1873,7 @@ function isSkillMdReadPath(filePath) {
1874
1873
  function extractAppKey(event, ctx) {
1875
1874
  try {
1876
1875
  const content = JSON.stringify({ event, ctx });
1877
- const match2 = content.match(/(?:app[-_\s]?key)(?:[^\n\r]{0,40}?(?:[:=:]|是|为|\bis\b|\bvalue\b))?[^a-zA-Z0-9_-]*([a-zA-Z0-9_-]{8,})/i);
1876
+ const match2 = content.match(/(?:app[-_\s]?key)[*]*(?:[^\n\r,{}]{0,40}?(?:[:=:]|是|为|\bis\b|\bvalue\b))?[^a-zA-Z0-9_,}-]*([a-zA-Z0-9_-]{12,})/i);
1878
1877
  return match2 ? match2[1] : void 0;
1879
1878
  } catch {
1880
1879
  return void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spzhongwin/skill-logger-plugin",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "exports": "./dist/index.js",
6
6
  "scripts": {
@@ -13,7 +13,9 @@
13
13
  "./dist/index.js"
14
14
  ],
15
15
  "contracts": {
16
- "tools": ["report_skill_error"]
16
+ "tools": [
17
+ "report_skill_error"
18
+ ]
17
19
  },
18
20
  "compat": {
19
21
  "pluginApi": ">=2026.3.28",
@@ -32,4 +34,4 @@
32
34
  "dependencies": {
33
35
  "ws": "^8.21.0"
34
36
  }
35
- }
37
+ }
@@ -226,6 +226,65 @@ export class ConfigSync {
226
226
  return this.installations;
227
227
  }
228
228
 
229
+ /** 基于最近一次版本检查结果,找出所有本地版本落后的安装副本。 */
230
+ detectOutdated(): OutdatedCopy[] {
231
+ const out: OutdatedCopy[] = [];
232
+ for (const [skillName, copies] of this.installations) {
233
+ const cfg = this.configs.get(skillName);
234
+ const latestVersion = this.latestVersions.get(skillName) || cfg?.latestVersion || cfg?.version;
235
+ if (!latestVersion) continue;
236
+ for (const copy of copies) {
237
+ if (!isOutdated(copy.version, latestVersion)) continue;
238
+ out.push({
239
+ skillName,
240
+ rootDir: copy.rootDir,
241
+ localVersion: copy.version || "",
242
+ latestVersion,
243
+ });
244
+ }
245
+ }
246
+ return out;
247
+ }
248
+
249
+ /** 扫描本地安装副本,拉取平台最新版本,检测落后副本并按配置触发自动更新。 */
250
+ async checkVersionsAndUpdate(): Promise<void> {
251
+ if (this.checkingVersions) return;
252
+ this.checkingVersions = true;
253
+ try {
254
+ const installed = await this.scanInstalledSkills();
255
+ this.scanCache = { ts: Date.now(), skills: installed };
256
+
257
+ if (installed.length > 0) {
258
+ const { ok, configs } = await this.pullConfigs(
259
+ installed.map((s) => ({ name: s.name, version: s.version }))
260
+ );
261
+ if (ok) {
262
+ for (const cfg of configs) {
263
+ const latestVersion = cfg.latestVersion || cfg.version;
264
+ if (latestVersion) this.latestVersions.set(cfg.skillName, latestVersion);
265
+ if (cfg.version) {
266
+ this.configPool.set(`${cfg.skillName}@${cfg.version}`, cfg);
267
+ if (!this.configs.has(cfg.skillName)) this.configs.set(cfg.skillName, cfg);
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ const outdated = this.detectOutdated();
274
+ if (outdated.length > 0 && this.updater) {
275
+ await this.updater.applyUpdates(outdated);
276
+ const refreshed = await this.scanInstalledSkills();
277
+ this.scanCache = { ts: Date.now(), skills: refreshed };
278
+ }
279
+
280
+ await this.persist();
281
+ } catch (err) {
282
+ console.warn("[skill-logger-plugin] checkVersionsAndUpdate 异常", err);
283
+ } finally {
284
+ this.checkingVersions = false;
285
+ }
286
+ }
287
+
229
288
  /** lazyCheck 专用:命中短 TTL 缓存则跳过整树遍历;reconcile 不走此路径,始终全新扫描。 */
230
289
  private async scanInstalledSkillsCached(): Promise<InstalledSkill[]> {
231
290
  const now = Date.now();
package/src/hooks.test.ts CHANGED
@@ -61,6 +61,26 @@ describe("Hooks 端到端串联", () => {
61
61
  assert.ok(activeSkills.getActive("s1").has("model-usage"));
62
62
  });
63
63
 
64
+ it("skill 工具直调有 toolCallId 时 after 报错 → 补记 function_call(error)", async () => {
65
+ const { hooks, captured } = setup();
66
+ hooks.onBeforeToolCall(
67
+ { toolName: "skill", params: { skill: "model-usage" }, toolCallId: "skill-t1" },
68
+ { sessionId: "s1" }
69
+ );
70
+ hooks.onAfterToolCall({ toolCallId: "skill-t1", errorMessage: "skill failed", elapsedMs: 9 });
71
+ await tick();
72
+
73
+ assert.equal(captured.length, 2);
74
+ assert.equal(captured[0].event_type, "skill_trigger");
75
+ const call = captured[1];
76
+ assert.equal(call.event_type, "function_call");
77
+ assert.equal(call.skill_name, "model-usage");
78
+ assert.equal(call.invoke_tool, "skill");
79
+ assert.equal(call.status, "error");
80
+ assert.equal(call.error_message, "skill failed");
81
+ assert.equal(call.duration_ms, 9);
82
+ });
83
+
64
84
  it("after 未到达:flushAllPending 补记 unknown", async () => {
65
85
  const { hooks, captured } = setup();
66
86
  hooks.onBeforeToolCall(
@@ -125,6 +145,81 @@ describe("Hooks 端到端串联", () => {
125
145
  assert.equal(captured[0].error_message, "boom");
126
146
  });
127
147
 
148
+ it("after 的结构化错误与失败状态也会归为 error", async () => {
149
+ const { hooks, captured } = setup();
150
+ hooks.onBeforeToolCall(
151
+ { toolName: "exec", params: { command: "python /e/skills/model-usage/scripts/model_usage.py --mode current" }, toolCallId: "t4" },
152
+ { sessionId: "s1" }
153
+ );
154
+ await tick();
155
+ hooks.onAfterToolCall({ toolCallId: "t4", status: "failed", message: "process failed", duration_ms: 7 });
156
+ await tick();
157
+ assert.equal(captured[0].status, "error");
158
+ assert.equal(captured[0].error_message, "process failed");
159
+ assert.equal(captured[0].duration_ms, 7);
160
+ });
161
+
162
+ it("after 的嵌套 result 失败信号会提取 stderr", async () => {
163
+ const { hooks, captured } = setup();
164
+ hooks.onBeforeToolCall(
165
+ { toolName: "exec", params: { command: "python /e/skills/model-usage/scripts/model_usage.py --mode current" }, toolCallId: "t6" },
166
+ { sessionId: "s1" }
167
+ );
168
+ await tick();
169
+ hooks.onAfterToolCall({ toolCallId: "t6", result: { ok: false, stderr: "stderr failed" } });
170
+ await tick();
171
+ assert.equal(captured[0].status, "error");
172
+ assert.equal(captured[0].error_message, "stderr failed");
173
+ });
174
+
175
+ it("未匹配 exec 但会话中只有一个激活 skill → 等 after 补记状态和错误", async () => {
176
+ const { hooks, captured } = setup();
177
+ hooks.onBeforeToolCall({ toolName: "read", params: { path: "/e/skills/model-usage/SKILL.md" } }, { sessionId: "s1" });
178
+ await tick();
179
+ captured.length = 0;
180
+
181
+ hooks.onBeforeToolCall(
182
+ { toolName: "exec", params: { command: "custom command --bad" }, toolCallId: "t5" },
183
+ { sessionId: "s1" }
184
+ );
185
+ await tick();
186
+ assert.equal(captured.length, 0);
187
+
188
+ hooks.onAfterToolCall({ toolCallId: "t5", error: { message: "custom failed" }, durationMs: 11 });
189
+ await tick();
190
+ assert.equal(captured.length, 1);
191
+ assert.equal(captured[0].event_type, "function_call");
192
+ assert.equal(captured[0].skill_name, "model-usage");
193
+ assert.equal(captured[0].invoke_tool, "exec");
194
+ assert.equal(captured[0].command, "custom command --bad");
195
+ assert.equal(captured[0].status, "error");
196
+ assert.equal(captured[0].error_message, "custom failed");
197
+ assert.equal(captured[0].duration_ms, 11);
198
+ });
199
+
200
+ it("report_skill_error 手动上报会记录 error 事件并携带会话 appKey", async () => {
201
+ const { hooks, captured } = setup();
202
+ hooks.onMessageReceived({ content: "appKey: abcdef123456" }, { sessionId: "s1" });
203
+ hooks.onManualErrorRecord(
204
+ {
205
+ skill_name: "model-usage",
206
+ tool_name: "usage_current",
207
+ error_message: "manual failure",
208
+ input_args: "--mode current",
209
+ },
210
+ { sessionId: "s1", agentId: "main", runId: "r1" }
211
+ );
212
+ await tick();
213
+ assert.equal(captured.length, 1);
214
+ assert.equal(captured[0].event_type, "function_call");
215
+ assert.equal(captured[0].skill_name, "model-usage");
216
+ assert.equal(captured[0].invoke_tool, "report_skill_error");
217
+ assert.equal(captured[0].status, "error");
218
+ assert.equal(captured[0].error_message, "manual failure");
219
+ assert.equal(captured[0].app_key, "abcdef123456");
220
+ assert.deepEqual(captured[0].args, { raw_args: "--mode current" });
221
+ });
222
+
128
223
  it("未命中且默认配置 → 不记录", async () => {
129
224
  const { hooks, captured } = setup();
130
225
  hooks.onBeforeToolCall({ toolName: "exec", params: { command: "ls -la" }, toolCallId: "t3" }, { sessionId: "s1" });
package/src/hooks.ts CHANGED
@@ -21,7 +21,12 @@ type HookEvent = Record<string, unknown>;
21
21
  type HookCtx = Record<string, unknown>;
22
22
 
23
23
  type Pending = {
24
- res: MatchResult;
24
+ skillName: string;
25
+ skillVersion?: string;
26
+ functionId?: string;
27
+ functionName?: string;
28
+ matchType?: MatchResult["matchType"];
29
+ args?: Record<string, unknown>;
25
30
  command?: string;
26
31
  invokeTool: string;
27
32
  sessionId?: string;
@@ -49,14 +54,87 @@ export function isSkillMdReadPath(filePath: string): boolean {
49
54
  function extractAppKey(event: HookEvent, ctx: HookCtx): string | undefined {
50
55
  try {
51
56
  const content = JSON.stringify({ event, ctx });
52
- // 终极增强正则:兼容 "app key"、兼容长达40个字符的中文修饰语、兼容各类分隔符(含无分隔符)、并精确提取 8 位以上的密钥字符
53
- const match = content.match(/(?:app[-_\s]?key)(?:[^\n\r]{0,40}?(?:[:=:]|是|为|\bis\b|\bvalue\b))?[^a-zA-Z0-9_-]*([a-zA-Z0-9_-]{8,})/i);
57
+ // 终极增强正则:兼容 Markdown **appKey**,防御 JSON Key 穿透,并且要求长度至少 12 位
58
+ const match = content.match(/(?:app[-_\s]?key)[*]*(?:[^\n\r,{}]{0,40}?(?:[:=:]|是|为|\bis\b|\bvalue\b))?[^a-zA-Z0-9_,}-]*([a-zA-Z0-9_-]{12,})/i);
54
59
  return match ? match[1] : undefined;
55
60
  } catch {
56
61
  return undefined;
57
62
  }
58
63
  }
59
64
 
65
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
66
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
67
+ }
68
+
69
+ function stringifyErrorValue(value: unknown): string | undefined {
70
+ if (value === undefined || value === null || value === "") return undefined;
71
+ if (typeof value === "string") return value;
72
+ if (value instanceof Error) return value.stack || value.message;
73
+ const obj = asRecord(value);
74
+ if (obj) {
75
+ for (const key of ["message", "error", "errorMessage", "error_message", "stderr"]) {
76
+ const nested = stringifyErrorValue(obj[key]);
77
+ if (nested) return nested;
78
+ }
79
+ try {
80
+ return JSON.stringify(value);
81
+ } catch {
82
+ return String(value);
83
+ }
84
+ }
85
+ return String(value);
86
+ }
87
+
88
+ function hasFailureSignal(event: HookEvent): boolean {
89
+ const records = [
90
+ event,
91
+ asRecord(event.result),
92
+ asRecord(event.output),
93
+ asRecord(event.response),
94
+ asRecord(event.data),
95
+ ].filter(Boolean) as Record<string, unknown>[];
96
+ for (const record of records) {
97
+ const status = String(record.status ?? record.state ?? "").toLowerCase();
98
+ if (["error", "failed", "failure"].includes(status)) return true;
99
+ if (record.success === false || record.ok === false || record.isError === true) return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ function extractToolError(event: HookEvent): string | undefined {
105
+ for (const key of ["error", "errorMessage", "error_message"]) {
106
+ const direct = stringifyErrorValue(event[key]);
107
+ if (direct) return direct;
108
+ }
109
+
110
+ for (const key of ["result", "output", "response", "data"]) {
111
+ const obj = asRecord(event[key]);
112
+ if (!obj) continue;
113
+ for (const nestedKey of ["error", "errorMessage", "error_message"]) {
114
+ const nested = stringifyErrorValue(obj[nestedKey]);
115
+ if (nested) return nested;
116
+ }
117
+ }
118
+
119
+ if (hasFailureSignal(event)) {
120
+ for (const key of ["message", "stderr", "result", "output", "response", "data"]) {
121
+ const fallback = stringifyErrorValue(event[key]);
122
+ if (fallback) return fallback;
123
+ }
124
+ return "tool call reported failure";
125
+ }
126
+
127
+ return undefined;
128
+ }
129
+
130
+ function extractDurationMs(event: HookEvent): number | undefined {
131
+ for (const key of ["durationMs", "duration_ms", "elapsedMs", "elapsed_ms"]) {
132
+ const value = event[key];
133
+ if (typeof value === "number" && Number.isFinite(value)) return value;
134
+ }
135
+ return undefined;
136
+ }
137
+
60
138
  export class Hooks {
61
139
  private readonly pending = new Map<string, Pending>();
62
140
  private readonly sessionAppKeys = new Map<string, string>();
@@ -110,14 +188,14 @@ export class Hooks {
110
188
  return {
111
189
  event_id: randomUUID(),
112
190
  event_type: "function_call",
113
- skill_name: p.res.skillName,
114
- skill_version: p.res.skillVersion,
115
- function_id: p.res.functionId,
116
- function_name: p.res.functionName,
117
- match_type: p.res.matchType,
191
+ skill_name: p.skillName,
192
+ skill_version: p.skillVersion,
193
+ function_id: p.functionId,
194
+ function_name: p.functionName,
195
+ match_type: p.matchType,
118
196
  invoke_tool: p.invokeTool,
119
197
  command: p.command,
120
- args: p.res.args,
198
+ args: p.args,
121
199
  status,
122
200
  error_message: error,
123
201
  duration_ms: durationMs,
@@ -169,6 +247,7 @@ export class Hooks {
169
247
  this.sweepStalePending();
170
248
  const toolName = event.toolName as string;
171
249
  const params = (event.params as Record<string, unknown>) ?? {};
250
+ const toolCallId = event.toolCallId as string | undefined;
172
251
 
173
252
  const sk = this.sessionKeyOf(ctx);
174
253
  let appKey: string | undefined = undefined;
@@ -191,6 +270,19 @@ export class Hooks {
191
270
  if (!skillName) return;
192
271
  this.debug(`Intercepted explicit 'skill' tool call for: ${skillName}`);
193
272
  this.recordTrigger(skillName, "tool", "skill", ctx, appKey);
273
+ if (toolCallId) {
274
+ this.pending.set(toolCallId, {
275
+ skillName,
276
+ skillVersion: this.configSync.getVersion(skillName),
277
+ invokeTool: toolName,
278
+ sessionId: this.sessionKeyOf(ctx),
279
+ agentId: ctx.agentId as string,
280
+ runId: ctx.runId as string,
281
+ appKey,
282
+ ts: Date.now(),
283
+ });
284
+ this.capPending();
285
+ }
194
286
  return;
195
287
  }
196
288
 
@@ -214,16 +306,20 @@ export class Hooks {
214
306
 
215
307
  if (!res) {
216
308
  this.debug(`Tool call '${toolName}' did not match any function config.`);
217
- this.maybeRecordUnattributed(toolName, command, active, ctx, appKey);
309
+ this.maybeRecordUnattributed(toolName, command, active, ctx, appKey, toolCallId);
218
310
  return;
219
311
  }
220
312
 
221
313
  this.debug(`Tool call '${toolName}' matched function ID: ${res.functionId} (${res.skillName}@${res.skillVersion})`);
222
314
 
223
- const toolCallId = event.toolCallId as string | undefined;
224
315
  if (toolCallId) {
225
316
  this.pending.set(toolCallId, {
226
- res,
317
+ skillName: res.skillName,
318
+ skillVersion: res.skillVersion,
319
+ functionId: res.functionId,
320
+ functionName: res.functionName,
321
+ matchType: res.matchType,
322
+ args: res.args,
227
323
  command,
228
324
  invokeTool: toolName,
229
325
  sessionId: this.sessionKeyOf(ctx),
@@ -250,8 +346,8 @@ export class Hooks {
250
346
  if (!p) return;
251
347
  this.pending.delete(toolCallId);
252
348
 
253
- const error = event.error as string | undefined;
254
- const durationMs = typeof event.durationMs === "number" ? event.durationMs : undefined;
349
+ const error = extractToolError(event);
350
+ const durationMs = extractDurationMs(event);
255
351
 
256
352
  const appKey = p.appKey || extractAppKey(event, {});
257
353
  this.debug(`After tool call [${toolCallId}]: Status ${error ? "error" : "success"}, Duration ${durationMs}ms`);
@@ -360,12 +456,28 @@ export class Hooks {
360
456
  command: string | undefined,
361
457
  active: ReadonlySet<string>,
362
458
  ctx: HookCtx,
363
- appKey?: string
459
+ appKey?: string,
460
+ toolCallId?: string
364
461
  ): void {
365
462
  if (this.getConfig().recordUnattributed === false) return;
366
463
  if (toolName !== "exec" || active.size !== 1) return;
367
464
  const skillName = [...active][0];
368
465
  this.debug(`Recording unattributed function_call for skill: ${skillName}`);
466
+ if (toolCallId) {
467
+ this.pending.set(toolCallId, {
468
+ skillName,
469
+ skillVersion: this.configSync.getVersion(skillName),
470
+ invokeTool: toolName,
471
+ command,
472
+ appKey,
473
+ sessionId: this.sessionKeyOf(ctx),
474
+ agentId: ctx.agentId as string,
475
+ runId: ctx.runId as string,
476
+ ts: Date.now(),
477
+ });
478
+ this.capPending();
479
+ return;
480
+ }
369
481
  this.emit({
370
482
  event_id: randomUUID(),
371
483
  event_type: "function_call",
package/src/updater.ts CHANGED
@@ -205,6 +205,13 @@ export class SkillUpdater {
205
205
  console.warn(`[skill-logger-plugin] ${skillName}@${version} 下载包无版本号,放弃覆盖`);
206
206
  return;
207
207
  }
208
+ if (dl.sha256) {
209
+ const actualHash = skillIdentityHash(skillName, packageVersion);
210
+ if (actualHash !== dl.sha256) {
211
+ console.warn(`[skill-logger-plugin] ${skillName}@${version} 身份哈希不一致,放弃覆盖`);
212
+ return;
213
+ }
214
+ }
208
215
 
209
216
  // [兼容处理] 缺少 .meta.json 时主动补齐
210
217
  const metaPath = path.join(srcRoot, ".meta.json");
@@ -235,7 +242,7 @@ export class SkillUpdater {
235
242
  private async fetchDownloadUrl(
236
243
  skillName: string,
237
244
  version: string
238
- ): Promise<{ url: string; version?: string } | undefined> {
245
+ ): Promise<{ url: string; version?: string; sha256?: string } | undefined> {
239
246
  const cfg = this.getConfig();
240
247
  const endpoint = cfg.platformBaseUrl!.replace(/\/$/, "") + "/skill_package/pull";
241
248
  const headers: Record<string, string> = { "Content-Type": "application/json" };
@@ -252,8 +259,9 @@ export class SkillUpdater {
252
259
  const data = (await res.json()) as { url?: unknown; downloadUrl?: unknown; version?: unknown; sha256?: unknown };
253
260
  const url = typeof data?.url === "string" ? data.url : typeof data?.downloadUrl === "string" ? data.downloadUrl : undefined;
254
261
  const resolvedVersion = typeof data?.version === "string" ? data.version : undefined;
262
+ const sha256 = typeof data?.sha256 === "string" ? data.sha256 : undefined;
255
263
  if (!url) return undefined;
256
- return { url, version: resolvedVersion };
264
+ return { url, version: resolvedVersion, sha256 };
257
265
  }
258
266
 
259
267
  /** 在解压目录里定位含 SKILL.md 的目录(兼容包内是否带顶层目录)。深度上限 2。 */
@@ -0,0 +1,37 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ normalizeAssistantUserId,
5
+ parseAssistantWorkspaceAgentId,
6
+ } from "./ws-client.ts";
7
+
8
+ describe("assistant workspace naming", () => {
9
+ it("accepts workspace-assistant-* only when suffix is at least 5 digits", () => {
10
+ assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-12345"), "assistant-12345");
11
+ assert.equal(
12
+ parseAssistantWorkspaceAgentId("workspace-assistant-12342325232333223223"),
13
+ "assistant-12342325232333223223"
14
+ );
15
+ assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-000001"), "assistant-000001");
16
+ });
17
+
18
+ it("rejects short, non-numeric, and malformed workspace names", () => {
19
+ assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-1234"), undefined);
20
+ assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-1234a"), undefined);
21
+ assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-abcde"), undefined);
22
+ assert.equal(parseAssistantWorkspaceAgentId("workspace-coder-12345"), undefined);
23
+ });
24
+
25
+ it("normalizes command userId using the same numeric suffix rule", () => {
26
+ assert.equal(normalizeAssistantUserId("12345"), "12345");
27
+ assert.equal(normalizeAssistantUserId("assistant-12345"), "12345");
28
+ assert.equal(normalizeAssistantUserId("assistant-000001"), "000001");
29
+ });
30
+
31
+ it("rejects unsafe or non-conforming command userId values", () => {
32
+ assert.equal(normalizeAssistantUserId("1234"), undefined);
33
+ assert.equal(normalizeAssistantUserId("assistant-1234"), undefined);
34
+ assert.equal(normalizeAssistantUserId("assistant-1234a"), undefined);
35
+ assert.equal(normalizeAssistantUserId("../assistant-12345"), undefined);
36
+ });
37
+ });
package/src/ws-client.ts CHANGED
@@ -8,6 +8,26 @@ import { readSkillVersion } from "./skill-version.ts";
8
8
  const HEARTBEAT_INTERVAL_MS = 30_000;
9
9
  const HEARTBEAT_ACK_TIMEOUT_MS = 75_000;
10
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
+ }
11
31
 
12
32
  export interface WsClientOptions {
13
33
  serverUrl: string; // 例如: wss://api.aishuo.co/gateway/ws
@@ -163,7 +183,8 @@ export class GatewayWsClient {
163
183
  }
164
184
 
165
185
  /**
166
- * 扫描 OpenClaw 根目录下的 workspace-assistant-{userId} 目录
186
+ * 扫描 OpenClaw 根目录下的 workspace-assistant-{userId} 目录。
187
+ * userId 必须是至少 5 位数字。
167
188
  */
168
189
  private async scanAndReportAgents(isInitialReport: boolean) {
169
190
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
@@ -182,12 +203,8 @@ export class GatewayWsClient {
182
203
 
183
204
  const newAgentIds = new Set<string>();
184
205
  for (const entry of entries) {
185
- if (entry.startsWith("workspace-assistant-")) {
186
- const suffix = entry.replace("workspace-assistant-", "").trim();
187
- if (suffix && suffix === path.basename(suffix) && !suffix.startsWith(".")) {
188
- newAgentIds.add(`assistant-${suffix}`);
189
- }
190
- }
206
+ const agentId = parseAssistantWorkspaceAgentId(entry);
207
+ if (agentId) newAgentIds.add(agentId);
191
208
  }
192
209
 
193
210
  let changed = false;
@@ -354,12 +371,15 @@ export class GatewayWsClient {
354
371
  return;
355
372
  }
356
373
 
357
- // 闭环完善:强制清理 userId 和 code 字符串,防止恶意指令通过 '../' 引发宿主机目录穿越攻击
358
- const safeUserId = path.basename(userId);
374
+ // 闭环完善:清理 code,并严格校验 userId,防止恶意指令通过 '../' 引发宿主机目录穿越攻击
359
375
  const safeCode = code ? path.basename(code) : undefined;
360
376
 
361
- // 100% 确定性安全寻址 (去掉 userId 中可能带有的 assistant- 前缀,统一用 workspace-assistant- 拼接)
362
- const pureId = safeUserId.replace(/^assistant-/, "");
377
+ // 100% 确定性安全寻址:userId 只接受纯数字或 assistant-数字,且数字至少 5 位。
378
+ const pureId = normalizeAssistantUserId(userId);
379
+ if (!pureId) {
380
+ this.reply(replyId, { success: false, message: `Invalid userId: ${userId}`, action });
381
+ return;
382
+ }
363
383
  const targetDir = path.join(openclawHome(), `workspace-assistant-${pureId}`, "skills");
364
384
 
365
385
  try {