@spzhongwin/skill-logger-plugin 1.0.18 → 1.0.19

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.
@@ -0,0 +1,223 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const FREE_SKILL_DIRECTORY_NAME = ".xg-platform";
5
+
6
+ export type FreeSkillWorkspaceContext = {
7
+ agentId?: unknown;
8
+ workspaceDir?: unknown;
9
+ workspace?: unknown;
10
+ agent?: { id?: unknown } | unknown;
11
+ ctx?: {
12
+ agentId?: unknown;
13
+ workspaceDir?: unknown;
14
+ workspace?: unknown;
15
+ agent?: { id?: unknown } | unknown;
16
+ } | unknown;
17
+ /** Compatibility for OpenClaw registerTool contexts wrapped by adapters. */
18
+ context?: FreeSkillWorkspaceContext | unknown;
19
+ };
20
+
21
+ export type FreeSkillWorkspace = {
22
+ agentId?: string;
23
+ /** Canonical real path of the active Agent workspace. */
24
+ workspacePath: string;
25
+ /** Canonical path when `.xg-platform` exists; lexical child path otherwise. */
26
+ directoryPath: string;
27
+ /** Stable model-facing path; this value is deliberately always relative. */
28
+ workspaceRelativePath: ".xg-platform";
29
+ };
30
+
31
+ export class FreeSkillWorkspaceError extends Error {
32
+ public readonly code:
33
+ | "WORKSPACE_MISSING"
34
+ | "WORKSPACE_NOT_DIRECTORY"
35
+ | "PLATFORM_DIRECTORY_ESCAPE"
36
+ | "PLATFORM_DIRECTORY_NOT_DIRECTORY"
37
+ | "PLATFORM_DIRECTORY_BROKEN_LINK";
38
+
39
+ constructor(
40
+ code:
41
+ | "WORKSPACE_MISSING"
42
+ | "WORKSPACE_NOT_DIRECTORY"
43
+ | "PLATFORM_DIRECTORY_ESCAPE"
44
+ | "PLATFORM_DIRECTORY_NOT_DIRECTORY"
45
+ | "PLATFORM_DIRECTORY_BROKEN_LINK",
46
+ message: string,
47
+ ) {
48
+ super(message);
49
+ this.code = code;
50
+ this.name = "FreeSkillWorkspaceError";
51
+ }
52
+ }
53
+
54
+ type UnknownRecord = Record<string, unknown>;
55
+
56
+ function isRecord(value: unknown): value is UnknownRecord {
57
+ return typeof value === "object" && value !== null;
58
+ }
59
+
60
+ function nonEmptyString(value: unknown): string | undefined {
61
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
62
+ }
63
+
64
+ function isWithin(parent: string, candidate: string): boolean {
65
+ const relative = path.relative(parent, candidate);
66
+ return relative === ""
67
+ || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
68
+ }
69
+
70
+ function nestedRecord(value: unknown, key: string): UnknownRecord | undefined {
71
+ if (!isRecord(value)) return undefined;
72
+ const nested = value[key];
73
+ return isRecord(nested) ? nested : undefined;
74
+ }
75
+
76
+ function firstWorkspaceValue(context: unknown): string | undefined {
77
+ if (!isRecord(context)) return undefined;
78
+
79
+ const wrapped = nestedRecord(context, "ctx");
80
+ const nestedContext = nestedRecord(context, "context");
81
+ const candidates = [
82
+ context.workspaceDir,
83
+ context.workspace,
84
+ wrapped?.workspaceDir,
85
+ wrapped?.workspace,
86
+ nestedContext?.workspaceDir,
87
+ nestedContext?.workspace,
88
+ ];
89
+ return candidates.map(nonEmptyString).find((value): value is string => Boolean(value));
90
+ }
91
+
92
+ function firstAgentIdValue(context: unknown): string | undefined {
93
+ if (!isRecord(context)) return undefined;
94
+
95
+ const wrapped = nestedRecord(context, "ctx");
96
+ const nestedContext = nestedRecord(context, "context");
97
+ const agent = isRecord(context.agent) ? context.agent.id : undefined;
98
+ const wrappedAgent = isRecord(wrapped?.agent) ? wrapped?.agent.id : undefined;
99
+ const nestedContextAgent = isRecord(nestedContext?.agent) ? nestedContext?.agent.id : undefined;
100
+ const candidates = [
101
+ context.agentId,
102
+ agent,
103
+ wrapped?.agentId,
104
+ wrappedAgent,
105
+ nestedContext?.agentId,
106
+ nestedContextAgent,
107
+ ];
108
+ return candidates.map(nonEmptyString).find((value): value is string => Boolean(value));
109
+ }
110
+
111
+ async function resolveWorkspacePath(workspaceInput: string): Promise<string> {
112
+ let workspacePath: string;
113
+ try {
114
+ workspacePath = await fs.realpath(path.resolve(workspaceInput));
115
+ } catch {
116
+ throw new FreeSkillWorkspaceError(
117
+ "WORKSPACE_MISSING",
118
+ `Agent workspace 不存在或不可访问: ${workspaceInput}`,
119
+ );
120
+ }
121
+
122
+ try {
123
+ const stat = await fs.stat(workspacePath);
124
+ if (!stat.isDirectory()) {
125
+ throw new FreeSkillWorkspaceError(
126
+ "WORKSPACE_NOT_DIRECTORY",
127
+ `Agent workspace 不是目录: ${workspaceInput}`,
128
+ );
129
+ }
130
+ } catch (error) {
131
+ if (error instanceof FreeSkillWorkspaceError) throw error;
132
+ throw new FreeSkillWorkspaceError(
133
+ "WORKSPACE_MISSING",
134
+ `Agent workspace 不存在或不可访问: ${workspaceInput}`,
135
+ );
136
+ }
137
+ return workspacePath;
138
+ }
139
+
140
+ async function resolvePlatformDirectory(workspacePath: string): Promise<string> {
141
+ const candidate = path.resolve(workspacePath, FREE_SKILL_DIRECTORY_NAME);
142
+ if (!isWithin(workspacePath, candidate)) {
143
+ throw new FreeSkillWorkspaceError(
144
+ "PLATFORM_DIRECTORY_ESCAPE",
145
+ "`.xg-platform` 不在 Agent workspace 内",
146
+ );
147
+ }
148
+
149
+ let entry;
150
+ try {
151
+ entry = await fs.lstat(candidate);
152
+ } catch (error) {
153
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return candidate;
154
+ throw new FreeSkillWorkspaceError(
155
+ "PLATFORM_DIRECTORY_BROKEN_LINK",
156
+ "无法检查 Agent workspace 下的 `.xg-platform`",
157
+ );
158
+ }
159
+
160
+ let resolved = candidate;
161
+ if (entry.isSymbolicLink()) {
162
+ try {
163
+ resolved = await fs.realpath(candidate);
164
+ } catch {
165
+ throw new FreeSkillWorkspaceError(
166
+ "PLATFORM_DIRECTORY_BROKEN_LINK",
167
+ "`.xg-platform` 是失效符号链接",
168
+ );
169
+ }
170
+ if (!isWithin(workspacePath, resolved)) {
171
+ throw new FreeSkillWorkspaceError(
172
+ "PLATFORM_DIRECTORY_ESCAPE",
173
+ "`.xg-platform` 符号链接越出 Agent workspace",
174
+ );
175
+ }
176
+ }
177
+
178
+ try {
179
+ const stat = await fs.stat(resolved);
180
+ if (!stat.isDirectory()) {
181
+ throw new FreeSkillWorkspaceError(
182
+ "PLATFORM_DIRECTORY_NOT_DIRECTORY",
183
+ "Agent workspace 下的 `.xg-platform` 不是目录",
184
+ );
185
+ }
186
+ } catch (error) {
187
+ if (error instanceof FreeSkillWorkspaceError) throw error;
188
+ throw new FreeSkillWorkspaceError(
189
+ "PLATFORM_DIRECTORY_BROKEN_LINK",
190
+ "无法访问 Agent workspace 下的 `.xg-platform`",
191
+ );
192
+ }
193
+ return resolved;
194
+ }
195
+
196
+ /**
197
+ * Resolve the current Agent's private free-Skill directory.
198
+ *
199
+ * Only runtime-injected workspace fields are considered. Arbitrary target/path
200
+ * fields are intentionally ignored so model or frontend input cannot select a
201
+ * host path. `fallbackWorkspace` is an internal compatibility fallback.
202
+ */
203
+ export async function resolveFreeSkillWorkspace(
204
+ context?: FreeSkillWorkspaceContext | unknown,
205
+ fallbackWorkspace?: string,
206
+ ): Promise<FreeSkillWorkspace> {
207
+ const workspaceInput = firstWorkspaceValue(context) ?? nonEmptyString(fallbackWorkspace);
208
+ if (!workspaceInput) {
209
+ throw new FreeSkillWorkspaceError(
210
+ "WORKSPACE_MISSING",
211
+ "未提供当前 Agent workspace",
212
+ );
213
+ }
214
+
215
+ const workspacePath = await resolveWorkspacePath(workspaceInput);
216
+ const directoryPath = await resolvePlatformDirectory(workspacePath);
217
+ return {
218
+ agentId: firstAgentIdValue(context),
219
+ workspacePath,
220
+ directoryPath,
221
+ workspaceRelativePath: FREE_SKILL_DIRECTORY_NAME,
222
+ };
223
+ }
@@ -0,0 +1,105 @@
1
+ import { afterEach, beforeEach, describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { resolveFreeSkillWorkspace } from "./free-skill-workspace.ts";
7
+ import { writeFreeSkill } from "./free-skill-writer.ts";
8
+
9
+ let workspaceRoot: string;
10
+
11
+ beforeEach(async () => {
12
+ workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "free-skill-writer-"));
13
+ });
14
+ afterEach(async () => {
15
+ await fs.rm(workspaceRoot, { recursive: true, force: true });
16
+ });
17
+
18
+ function skillContent(description = "A free skill"): string {
19
+ return `---\nname: Demo Skill\ndescription: ${description}\n---\n\n# Demo\n\nUse this skill.\n`;
20
+ }
21
+
22
+ describe("writeFreeSkill", () => {
23
+ it("通过最小校验后原子写入 SKILL.md 和 metadata,并拒绝重复覆盖", async () => {
24
+ const workspace = await resolveFreeSkillWorkspace({ workspaceDir: workspaceRoot, agentId: "agent-1" });
25
+ const first = await writeFreeSkill(
26
+ { skillName: "demo", content: skillContent(), metadata: { version: "1.0.0", source: "test" } },
27
+ workspace,
28
+ );
29
+
30
+ assert.equal(first.success, true);
31
+ if (!first.success) return;
32
+ assert.equal(await fs.readFile(first.skillFilePath, "utf8"), skillContent());
33
+ assert.deepEqual(JSON.parse(await fs.readFile(first.metadataFilePath!, "utf8")), {
34
+ version: "1.0.0",
35
+ source: "test",
36
+ });
37
+
38
+ const duplicate = await writeFreeSkill(
39
+ { skillName: "demo", content: skillContent("replacement") },
40
+ workspace,
41
+ );
42
+ assert.deepEqual(duplicate, {
43
+ success: false,
44
+ code: "SKILL_EXISTS",
45
+ message: "Skill 已存在: demo",
46
+ });
47
+ assert.equal(await fs.readFile(first.skillFilePath, "utf8"), skillContent());
48
+ });
49
+
50
+ it("对非法 frontmatter、空正文和路径穿越返回稳定错误码", async () => {
51
+ const workspace = await resolveFreeSkillWorkspace({ workspaceDir: workspaceRoot });
52
+ const invalidFrontmatter = await writeFreeSkill(
53
+ { skillName: "invalid", content: "---\nname: Demo\ndescription: [broken\n---\n\n# Body\n" },
54
+ workspace,
55
+ );
56
+ assert.equal(invalidFrontmatter.success, false);
57
+ if (!invalidFrontmatter.success) assert.equal(invalidFrontmatter.code, "INVALID_FRONTMATTER");
58
+
59
+ const emptyBody = await writeFreeSkill(
60
+ { skillName: "empty-body", content: "---\nname: Demo\ndescription: desc\n---\n\n" },
61
+ workspace,
62
+ );
63
+ assert.equal(emptyBody.success, false);
64
+ if (!emptyBody.success) assert.equal(emptyBody.code, "EMPTY_BODY");
65
+
66
+ const traversal = await writeFreeSkill(
67
+ { skillName: "../outside", content: skillContent(), targetPath: path.join(workspaceRoot, "outside") } as never,
68
+ workspace,
69
+ );
70
+ assert.equal(traversal.success, false);
71
+ if (!traversal.success) assert.equal(traversal.code, "INVALID_SKILL_NAME");
72
+ await assert.rejects(fs.access(path.join(workspaceRoot, "outside")));
73
+ });
74
+
75
+ it("拒绝符号链接目标,并在 rename 失败时清理新目录", async (t) => {
76
+ if (process.platform === "win32") return t.skip("Windows 创建目录符号链接需要额外权限");
77
+
78
+ const workspace = await resolveFreeSkillWorkspace({ workspaceDir: workspaceRoot });
79
+ await fs.mkdir(workspace.directoryPath, { recursive: true });
80
+ const external = await fs.mkdtemp(path.join(os.tmpdir(), "free-skill-writer-external-"));
81
+ try {
82
+ await fs.symlink(external, path.join(workspace.directoryPath, "linked"), "dir");
83
+ const linked = await writeFreeSkill({ skillName: "linked", content: skillContent() }, workspace);
84
+ assert.equal(linked.success, false);
85
+ if (!linked.success) assert.equal(linked.code, "TARGET_SYMLINK");
86
+
87
+ const failed = await writeFreeSkill(
88
+ { skillName: "rename-fails", content: skillContent() },
89
+ workspace,
90
+ {
91
+ fileSystem: {
92
+ rename: async () => {
93
+ throw new Error("injected rename failure");
94
+ },
95
+ },
96
+ },
97
+ );
98
+ assert.equal(failed.success, false);
99
+ if (!failed.success) assert.equal(failed.code, "ATOMIC_WRITE_FAILED");
100
+ await assert.rejects(fs.access(path.join(workspace.directoryPath, "rename-fails")));
101
+ } finally {
102
+ await fs.rm(external, { recursive: true, force: true });
103
+ }
104
+ });
105
+ });