@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/dist/index.js +462 -121
- package/package.json +2 -2
- package/src/expert-skill-layout.test.ts +196 -0
- package/src/expert-skill-layout.ts +233 -0
- package/src/index.ts +26 -5
- package/src/semver.test.ts +11 -1
- package/src/semver.ts +5 -0
- package/src/updater.test.ts +117 -0
- package/src/updater.ts +52 -0
- package/src/ws-client.test.ts +119 -0
- package/src/ws-client.ts +175 -31
- package/dist/active-skills.js +0 -67
- package/dist/active-skills.test.js +0 -29
- package/dist/config-sync.js +0 -439
- package/dist/config-sync.test.js +0 -145
- package/dist/hooks.js +0 -337
- package/dist/hooks.test.js +0 -123
- package/dist/http.js +0 -54
- package/dist/identity.js +0 -56
- package/dist/index.test.js +0 -39
- package/dist/integration.test.js +0 -102
- package/dist/matcher.js +0 -362
- package/dist/matcher.test.js +0 -139
- package/dist/paths.js +0 -62
- package/dist/paths.test.js +0 -49
- package/dist/reporter.js +0 -267
- package/dist/reporter.test.js +0 -128
- package/dist/semver.js +0 -64
- package/dist/semver.test.js +0 -21
- package/dist/skill-version.js +0 -23
- package/dist/types.js +0 -9
- package/dist/updater.js +0 -352
- package/dist/updater.test.js +0 -212
- package/dist/ws-client.js +0 -484
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();
|
package/src/ws-client.test.ts
CHANGED
|
@@ -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
|
|
236
|
-
let
|
|
364
|
+
const configPath = path.join(openclawHome(), "openclaw.json");
|
|
365
|
+
let rawConfig: string;
|
|
237
366
|
try {
|
|
238
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
//
|
|
454
|
-
const safeCode =
|
|
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
|
|
594
|
-
|
|
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.
|
|
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
|
-
|
|
676
|
-
|
|
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
|
|
681
|
-
|
|
682
|
-
|
|
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(`${
|
|
828
|
+
skillResults.push(`${skillCode}: 失败 - ${e.message}`);
|
|
685
829
|
}
|
|
686
830
|
}
|
|
687
831
|
}
|
package/dist/active-skills.js
DELETED
|
@@ -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
|
-
});
|