@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.
- package/dist/index.js +2435 -916
- package/openclaw.plugin.json +5 -0
- package/package.json +1 -1
- package/src/free-skill-directory.test.ts +109 -0
- package/src/free-skill-directory.ts +188 -0
- package/src/free-skill-tool.test.ts +68 -0
- package/src/free-skill-tool.ts +99 -0
- package/src/free-skill-workspace.test.ts +109 -0
- package/src/free-skill-workspace.ts +223 -0
- package/src/free-skill-writer.test.ts +105 -0
- package/src/free-skill-writer.ts +591 -0
- package/src/index.ts +5 -0
- package/src/ws-client.test.ts +146 -0
- package/src/ws-client.ts +119 -6
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
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 { scanFreeSkillDirectory } from "./free-skill-directory.ts";
|
|
7
|
+
|
|
8
|
+
let workspace: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
workspace = await fs.mkdtemp(path.join(os.tmpdir(), "free-skill-directory-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await fs.rm(workspace, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function writeSkill(
|
|
19
|
+
skillPath: string,
|
|
20
|
+
metadata: { name?: string; description?: string; version?: string } = {},
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const lines = ["---"];
|
|
23
|
+
if (metadata.name) lines.push(`name: ${metadata.name}`);
|
|
24
|
+
if (metadata.description) lines.push(`description: ${metadata.description}`);
|
|
25
|
+
if (metadata.version) lines.push(`version: ${metadata.version}`);
|
|
26
|
+
lines.push("---", "", "# Skill");
|
|
27
|
+
await fs.mkdir(skillPath, { recursive: true });
|
|
28
|
+
await fs.writeFile(path.join(skillPath, "SKILL.md"), `${lines.join("\n")}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("scanFreeSkillDirectory", () => {
|
|
32
|
+
it("扫描 .xg-platform 下一级 Skill,并返回元数据和真实路径", async () => {
|
|
33
|
+
const directoryPath = path.join(workspace, ".xg-platform");
|
|
34
|
+
const alphaPath = path.join(directoryPath, "alpha");
|
|
35
|
+
await writeSkill(alphaPath, { name: "Alpha", description: "alpha skill", version: "1.2.3" });
|
|
36
|
+
await writeSkill(path.join(alphaPath, "nested"), { name: "Nested" });
|
|
37
|
+
await fs.mkdir(path.join(directoryPath, "not-a-skill"), { recursive: true });
|
|
38
|
+
await fs.writeFile(path.join(directoryPath, "README.txt"), "ignored");
|
|
39
|
+
|
|
40
|
+
const result = await scanFreeSkillDirectory(path.join(workspace, "nested", ".."));
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(result, [
|
|
43
|
+
{
|
|
44
|
+
code: "alpha",
|
|
45
|
+
name: "Alpha",
|
|
46
|
+
description: "alpha skill",
|
|
47
|
+
version: "1.2.3",
|
|
48
|
+
directoryPath: await fs.realpath(directoryPath),
|
|
49
|
+
skillPath: await fs.realpath(alphaPath),
|
|
50
|
+
skillFilePath: await fs.realpath(path.join(alphaPath, "SKILL.md")),
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("支持 .meta.json 元数据,并在缺省字段时使用稳定回退值", async () => {
|
|
56
|
+
const skillPath = path.join(workspace, ".xg-platform", "metadata-only");
|
|
57
|
+
await writeSkill(skillPath);
|
|
58
|
+
await fs.writeFile(
|
|
59
|
+
path.join(skillPath, ".meta.json"),
|
|
60
|
+
JSON.stringify({ metadata: { name: "Metadata Skill", description: "from meta" }, version: "2.0.0" }),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
assert.deepEqual(await scanFreeSkillDirectory(workspace), [
|
|
64
|
+
{
|
|
65
|
+
code: "metadata-only",
|
|
66
|
+
name: "Metadata Skill",
|
|
67
|
+
description: "from meta",
|
|
68
|
+
version: "2.0.0",
|
|
69
|
+
directoryPath: await fs.realpath(path.join(workspace, ".xg-platform")),
|
|
70
|
+
skillPath: await fs.realpath(skillPath),
|
|
71
|
+
skillFilePath: await fs.realpath(path.join(skillPath, "SKILL.md")),
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("忽略指向 workspace 外部的 Skill 和 SKILL.md 符号链接", async (t) => {
|
|
77
|
+
if (process.platform === "win32") return t.skip("Windows 创建目录符号链接需要额外权限");
|
|
78
|
+
|
|
79
|
+
const directoryPath = path.join(workspace, ".xg-platform");
|
|
80
|
+
const externalRoot = await fs.mkdtemp(path.join(os.tmpdir(), "free-skill-external-"));
|
|
81
|
+
try {
|
|
82
|
+
const externalSkillPath = path.join(externalRoot, "external");
|
|
83
|
+
await writeSkill(externalSkillPath, { name: "External" });
|
|
84
|
+
await fs.mkdir(directoryPath, { recursive: true });
|
|
85
|
+
await fs.symlink(externalSkillPath, path.join(directoryPath, "linked-skill"), "dir");
|
|
86
|
+
|
|
87
|
+
const linkedFileSkillPath = path.join(directoryPath, "linked-file");
|
|
88
|
+
await fs.mkdir(linkedFileSkillPath, { recursive: true });
|
|
89
|
+
await fs.symlink(path.join(externalSkillPath, "SKILL.md"), path.join(linkedFileSkillPath, "SKILL.md"));
|
|
90
|
+
|
|
91
|
+
assert.deepEqual(await scanFreeSkillDirectory(workspace), []);
|
|
92
|
+
} finally {
|
|
93
|
+
await fs.rm(externalRoot, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("拒绝 .xg-platform 本身通过符号链接越出 workspace", async (t) => {
|
|
98
|
+
if (process.platform === "win32") return t.skip("Windows 创建目录符号链接需要额外权限");
|
|
99
|
+
|
|
100
|
+
const externalRoot = await fs.mkdtemp(path.join(os.tmpdir(), "free-skill-directory-external-"));
|
|
101
|
+
try {
|
|
102
|
+
await writeSkill(path.join(externalRoot, "outside"), { name: "Outside" });
|
|
103
|
+
await fs.symlink(externalRoot, path.join(workspace, ".xg-platform"), "dir");
|
|
104
|
+
assert.deepEqual(await scanFreeSkillDirectory(workspace), []);
|
|
105
|
+
} finally {
|
|
106
|
+
await fs.rm(externalRoot, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const FREE_SKILL_DIRECTORY_NAME = ".xg-platform";
|
|
5
|
+
const SKILL_FILE_NAME = "SKILL.md";
|
|
6
|
+
|
|
7
|
+
export type FreeSkillMetadata = {
|
|
8
|
+
code: string;
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
version: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type FreeSkill = FreeSkillMetadata & {
|
|
15
|
+
/** `.xg-platform` 的真实路径。 */
|
|
16
|
+
directoryPath: string;
|
|
17
|
+
/** Skill 目录的真实路径。 */
|
|
18
|
+
skillPath: string;
|
|
19
|
+
/** `SKILL.md` 的真实路径。 */
|
|
20
|
+
skillFilePath: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type StringRecord = Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
function isRecord(value: unknown): value is StringRecord {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isWithin(parent: string, candidate: string): boolean {
|
|
30
|
+
const relative = path.relative(parent, candidate);
|
|
31
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function asNonEmptyString(value: unknown): string | undefined {
|
|
35
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseScalar(value: string): string {
|
|
39
|
+
const trimmed = value.trim().replace(/\s+#.*$/, "").trim();
|
|
40
|
+
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
41
|
+
return trimmed.slice(1, -1).trim();
|
|
42
|
+
}
|
|
43
|
+
return trimmed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseFrontmatter(content: string): StringRecord {
|
|
47
|
+
const lines = content.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
48
|
+
if (lines[0]?.trim() !== "---") return {};
|
|
49
|
+
|
|
50
|
+
const fields: StringRecord = {};
|
|
51
|
+
for (const line of lines.slice(1)) {
|
|
52
|
+
if (line.trim() === "---" || line.trim() === "...") break;
|
|
53
|
+
const match = /^\s*([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*?)\s*$/.exec(line);
|
|
54
|
+
if (match) fields[match[1]] = parseScalar(match[2]);
|
|
55
|
+
}
|
|
56
|
+
return fields;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function realPathIfRegularFile(filePath: string): Promise<string | undefined> {
|
|
60
|
+
try {
|
|
61
|
+
const resolved = await fs.realpath(filePath);
|
|
62
|
+
const stats = await fs.stat(resolved);
|
|
63
|
+
return stats.isFile() ? resolved : undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readOptionalMetadata(skillPath: string, workspacePath: string): Promise<StringRecord> {
|
|
70
|
+
const candidate = path.resolve(skillPath, ".meta.json");
|
|
71
|
+
if (!isWithin(skillPath, candidate) || !isWithin(workspacePath, candidate)) return {};
|
|
72
|
+
|
|
73
|
+
const metadataPath = await realPathIfRegularFile(candidate);
|
|
74
|
+
if (!metadataPath || !isWithin(skillPath, metadataPath) || !isWithin(workspacePath, metadataPath)) return {};
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const parsed: unknown = JSON.parse(await fs.readFile(metadataPath, "utf8"));
|
|
78
|
+
if (!isRecord(parsed)) return {};
|
|
79
|
+
return parsed;
|
|
80
|
+
} catch {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function scanSkill(
|
|
86
|
+
directoryPath: string,
|
|
87
|
+
workspacePath: string,
|
|
88
|
+
entryName: string,
|
|
89
|
+
): Promise<FreeSkill | undefined> {
|
|
90
|
+
const candidateSkillPath = path.resolve(directoryPath, entryName);
|
|
91
|
+
if (!isWithin(directoryPath, candidateSkillPath) || !isWithin(workspacePath, candidateSkillPath)) return undefined;
|
|
92
|
+
|
|
93
|
+
const skillPath = await realPathIfRegularFile(candidateSkillPath);
|
|
94
|
+
if (skillPath) return undefined;
|
|
95
|
+
|
|
96
|
+
let resolvedSkillPath: string;
|
|
97
|
+
try {
|
|
98
|
+
resolvedSkillPath = await fs.realpath(candidateSkillPath);
|
|
99
|
+
const stats = await fs.stat(resolvedSkillPath);
|
|
100
|
+
if (!stats.isDirectory()) return undefined;
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
if (!isWithin(directoryPath, resolvedSkillPath) || !isWithin(workspacePath, resolvedSkillPath)) return undefined;
|
|
105
|
+
|
|
106
|
+
const candidateSkillFilePath = path.resolve(resolvedSkillPath, SKILL_FILE_NAME);
|
|
107
|
+
if (
|
|
108
|
+
!isWithin(resolvedSkillPath, candidateSkillFilePath) ||
|
|
109
|
+
!isWithin(workspacePath, candidateSkillFilePath)
|
|
110
|
+
) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const skillFilePath = await realPathIfRegularFile(candidateSkillFilePath);
|
|
115
|
+
if (!skillFilePath || !isWithin(resolvedSkillPath, skillFilePath) || !isWithin(workspacePath, skillFilePath)) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let skillFileContent: string;
|
|
120
|
+
try {
|
|
121
|
+
skillFileContent = await fs.readFile(skillFilePath, "utf8");
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const frontmatter = parseFrontmatter(skillFileContent);
|
|
127
|
+
const meta = await readOptionalMetadata(resolvedSkillPath, workspacePath);
|
|
128
|
+
const nestedMetadata = isRecord(meta.metadata) ? meta.metadata : {};
|
|
129
|
+
const getValue = (...values: unknown[]): string | undefined => {
|
|
130
|
+
for (const value of values) {
|
|
131
|
+
const result = asNonEmptyString(value);
|
|
132
|
+
if (result) return result;
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const code = entryName;
|
|
138
|
+
return {
|
|
139
|
+
code,
|
|
140
|
+
name: getValue(meta.name, nestedMetadata.name, frontmatter.name) ?? code,
|
|
141
|
+
description: getValue(meta.description, nestedMetadata.description, frontmatter.description) ?? "",
|
|
142
|
+
version: getValue(meta.version, nestedMetadata.version, frontmatter.version) ?? "",
|
|
143
|
+
directoryPath,
|
|
144
|
+
skillPath: resolvedSkillPath,
|
|
145
|
+
skillFilePath,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 扫描 workspace/.xg-platform 下的一级自由 Skill 目录。 */
|
|
150
|
+
export async function scanFreeSkillDirectory(workspace: string): Promise<FreeSkill[]> {
|
|
151
|
+
const resolvedWorkspace = path.resolve(workspace);
|
|
152
|
+
let workspacePath: string;
|
|
153
|
+
try {
|
|
154
|
+
workspacePath = await fs.realpath(resolvedWorkspace);
|
|
155
|
+
const stats = await fs.stat(workspacePath);
|
|
156
|
+
if (!stats.isDirectory()) return [];
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const candidateDirectoryPath = path.resolve(workspacePath, FREE_SKILL_DIRECTORY_NAME);
|
|
162
|
+
if (!isWithin(workspacePath, candidateDirectoryPath)) return [];
|
|
163
|
+
|
|
164
|
+
let directoryPath: string;
|
|
165
|
+
try {
|
|
166
|
+
directoryPath = await fs.realpath(candidateDirectoryPath);
|
|
167
|
+
const stats = await fs.stat(directoryPath);
|
|
168
|
+
if (!stats.isDirectory()) return [];
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
if (!isWithin(workspacePath, directoryPath)) return [];
|
|
173
|
+
|
|
174
|
+
let entries;
|
|
175
|
+
try {
|
|
176
|
+
entries = await fs.readdir(directoryPath, { withFileTypes: true });
|
|
177
|
+
} catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const skills: FreeSkill[] = [];
|
|
182
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
183
|
+
if (!entry.isDirectory()) continue;
|
|
184
|
+
const skill = await scanSkill(directoryPath, workspacePath, entry.name);
|
|
185
|
+
if (skill) skills.push(skill);
|
|
186
|
+
}
|
|
187
|
+
return skills;
|
|
188
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { afterEach, 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 {
|
|
7
|
+
createFreeSkillToolFactory,
|
|
8
|
+
FREE_SKILL_TOOL_NAME,
|
|
9
|
+
} from "./free-skill-tool.ts";
|
|
10
|
+
import { scanFreeSkillDirectory } from "./free-skill-directory.ts";
|
|
11
|
+
|
|
12
|
+
const roots: string[] = [];
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function skillContent(name: string): string {
|
|
19
|
+
return `---\nname: ${name}\ndescription: test skill\n---\n\n# Test\n`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("free skill tool", () => {
|
|
23
|
+
it("uses factory workspace context and returns relative plus actual paths", async () => {
|
|
24
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "free-skill-tool-"));
|
|
25
|
+
roots.push(workspace);
|
|
26
|
+
const tool = createFreeSkillToolFactory({ workspaceDir: workspace, agentId: "agent-a" });
|
|
27
|
+
|
|
28
|
+
assert.equal(tool.name, FREE_SKILL_TOOL_NAME);
|
|
29
|
+
const result = await tool.execute("call-1", {
|
|
30
|
+
skillName: "demo",
|
|
31
|
+
content: skillContent("demo"),
|
|
32
|
+
metadata: { source: "test" },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.equal(result.details.success, true);
|
|
36
|
+
assert.equal(result.details.agentId, "agent-a");
|
|
37
|
+
assert.equal(result.details.workspaceRelativePath, ".xg-platform/demo");
|
|
38
|
+
assert.equal(
|
|
39
|
+
result.details.skillFilePath,
|
|
40
|
+
await fs.realpath(path.join(workspace, ".xg-platform", "demo", "SKILL.md")),
|
|
41
|
+
);
|
|
42
|
+
assert.match(result.content[0].text, /\.xg-platform\/demo/);
|
|
43
|
+
assert.equal(
|
|
44
|
+
await fs.readFile(result.details.skillFilePath, "utf8"),
|
|
45
|
+
skillContent("demo"),
|
|
46
|
+
);
|
|
47
|
+
const discovered = await scanFreeSkillDirectory(workspace);
|
|
48
|
+
assert.equal(discovered.some((skill) => skill.code === "demo"), true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects caller-supplied target/path and keeps workspace isolation", async () => {
|
|
52
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "free-skill-tool-safe-"));
|
|
53
|
+
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "free-skill-tool-outside-"));
|
|
54
|
+
roots.push(workspace, outside);
|
|
55
|
+
const tool = createFreeSkillToolFactory({ workspaceDir: workspace, agentId: "agent-b" });
|
|
56
|
+
|
|
57
|
+
await assert.rejects(
|
|
58
|
+
tool.execute("call-2", {
|
|
59
|
+
skillName: "blocked",
|
|
60
|
+
content: skillContent("blocked"),
|
|
61
|
+
target: path.join(outside, "escape"),
|
|
62
|
+
}),
|
|
63
|
+
(error: unknown) => (error as { code?: string }).code === "INVALID_CONTENT",
|
|
64
|
+
);
|
|
65
|
+
await assert.rejects(fs.access(path.join(outside, "escape")));
|
|
66
|
+
await assert.rejects(fs.access(path.join(workspace, ".xg-platform", "blocked")));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
FreeSkillWriterError,
|
|
4
|
+
writeFreeSkillOrThrow,
|
|
5
|
+
type FreeSkillWriteInput,
|
|
6
|
+
} from "./free-skill-writer.ts";
|
|
7
|
+
import {
|
|
8
|
+
resolveFreeSkillWorkspace,
|
|
9
|
+
type FreeSkillWorkspaceContext,
|
|
10
|
+
} from "./free-skill-workspace.ts";
|
|
11
|
+
|
|
12
|
+
export const FREE_SKILL_TOOL_NAME = "skill_logger_create_free_skill";
|
|
13
|
+
|
|
14
|
+
type ToolParams = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
export type FreeSkillToolContext = FreeSkillWorkspaceContext & {
|
|
17
|
+
workspaceDir?: unknown;
|
|
18
|
+
agentId?: unknown;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type FreeSkillToolDetails = {
|
|
22
|
+
success: true;
|
|
23
|
+
skillName: string;
|
|
24
|
+
workspaceRelativePath: string;
|
|
25
|
+
directoryPath: string;
|
|
26
|
+
skillFilePath: string;
|
|
27
|
+
agentId?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type FreeSkillTool = {
|
|
31
|
+
name: string;
|
|
32
|
+
label: string;
|
|
33
|
+
description: string;
|
|
34
|
+
parameters: Record<string, unknown>;
|
|
35
|
+
execute: (
|
|
36
|
+
toolCallId: string,
|
|
37
|
+
params: ToolParams,
|
|
38
|
+
signal?: AbortSignal,
|
|
39
|
+
) => Promise<{ content: Array<{ type: "text"; text: string }>; details: FreeSkillToolDetails }>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const parameters: Record<string, unknown> = {
|
|
43
|
+
type: "object",
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
properties: {
|
|
46
|
+
skillName: { type: "string", description: "自由 Skill 的一级目录名" },
|
|
47
|
+
content: { type: "string", description: "完整的 SKILL.md 内容" },
|
|
48
|
+
metadata: { type: "object", description: "可选的 JSON 元数据" },
|
|
49
|
+
},
|
|
50
|
+
required: ["skillName", "content"],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function hasOwn(value: ToolParams, key: string): boolean {
|
|
54
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
55
|
+
}
|
|
56
|
+
function asInput(params: ToolParams): FreeSkillWriteInput {
|
|
57
|
+
if (hasOwn(params, "target") || hasOwn(params, "path")) {
|
|
58
|
+
throw new FreeSkillWriterError(
|
|
59
|
+
"INVALID_CONTENT",
|
|
60
|
+
"自由 Skill 工具不接受 target/path,目标路径由当前 Agent workspace 决定",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
skillName: params.skillName ?? params.name,
|
|
65
|
+
content: params.content ?? params.skillContent,
|
|
66
|
+
metadata: params.metadata,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* OpenClaw 2026.7.1-2 registerTool 使用的运行时工厂。
|
|
72
|
+
* workspace 只来自受信任的 factory context,绝不从模型参数读取。
|
|
73
|
+
*/
|
|
74
|
+
export function createFreeSkillToolFactory(context: FreeSkillToolContext): FreeSkillTool {
|
|
75
|
+
return {
|
|
76
|
+
name: FREE_SKILL_TOOL_NAME,
|
|
77
|
+
label: "Create free Skill",
|
|
78
|
+
description:
|
|
79
|
+
"Create a validated Skill in the current Agent workspace under .xg-platform. The target path is runtime controlled.",
|
|
80
|
+
parameters,
|
|
81
|
+
async execute(_toolCallId, params) {
|
|
82
|
+
const workspace = await resolveFreeSkillWorkspace(context);
|
|
83
|
+
const result = await writeFreeSkillOrThrow(asInput(params), workspace);
|
|
84
|
+
const workspaceRelativePath = path.posix.join(".xg-platform", result.skillName);
|
|
85
|
+
const details: FreeSkillToolDetails = {
|
|
86
|
+
success: true,
|
|
87
|
+
skillName: result.skillName,
|
|
88
|
+
workspaceRelativePath,
|
|
89
|
+
directoryPath: result.directoryPath,
|
|
90
|
+
skillFilePath: result.skillFilePath,
|
|
91
|
+
...(workspace.agentId ? { agentId: workspace.agentId } : {}),
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text", text: `自由 Skill 已写入 ${workspaceRelativePath}` }],
|
|
95
|
+
details,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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 {
|
|
7
|
+
FreeSkillWorkspaceError,
|
|
8
|
+
resolveFreeSkillWorkspace,
|
|
9
|
+
} from "./free-skill-workspace.ts";
|
|
10
|
+
|
|
11
|
+
let root: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), "free-skill-workspace-"));
|
|
15
|
+
});
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("resolveFreeSkillWorkspace", () => {
|
|
21
|
+
it("优先使用 registerTool 注入的 workspaceDir 和 agentId", async () => {
|
|
22
|
+
const workspace = path.join(root, "runtime-workspace");
|
|
23
|
+
await fs.mkdir(workspace);
|
|
24
|
+
|
|
25
|
+
const result = await resolveFreeSkillWorkspace(
|
|
26
|
+
{
|
|
27
|
+
agentId: "runtime-agent",
|
|
28
|
+
workspaceDir: workspace,
|
|
29
|
+
workspace: path.join(root, "ignored-workspace"),
|
|
30
|
+
directoryPath: path.join(root, "host-path-from-model"),
|
|
31
|
+
},
|
|
32
|
+
path.join(root, "fallback-workspace"),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(result, {
|
|
36
|
+
agentId: "runtime-agent",
|
|
37
|
+
workspacePath: await fs.realpath(workspace),
|
|
38
|
+
directoryPath: path.join(await fs.realpath(workspace), ".xg-platform"),
|
|
39
|
+
workspaceRelativePath: ".xg-platform",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("兼容 workspace、ctx.workspaceDir 以及 fallbackWorkspace", async () => {
|
|
44
|
+
const workspace = path.join(root, "workspace");
|
|
45
|
+
const fallback = path.join(root, "fallback");
|
|
46
|
+
await fs.mkdir(workspace);
|
|
47
|
+
await fs.mkdir(fallback);
|
|
48
|
+
|
|
49
|
+
const directWorkspace = await resolveFreeSkillWorkspace({ workspace, agentId: "direct" });
|
|
50
|
+
assert.equal(directWorkspace.agentId, "direct");
|
|
51
|
+
assert.equal(directWorkspace.workspaceRelativePath, ".xg-platform");
|
|
52
|
+
|
|
53
|
+
const wrappedWorkspace = await resolveFreeSkillWorkspace({
|
|
54
|
+
ctx: { workspaceDir: workspace, agentId: "wrapped" },
|
|
55
|
+
});
|
|
56
|
+
assert.equal(wrappedWorkspace.agentId, "wrapped");
|
|
57
|
+
assert.equal(wrappedWorkspace.workspacePath, await fs.realpath(workspace));
|
|
58
|
+
|
|
59
|
+
const fallbackWorkspace = await resolveFreeSkillWorkspace(undefined, fallback);
|
|
60
|
+
assert.equal(fallbackWorkspace.workspacePath, await fs.realpath(fallback));
|
|
61
|
+
assert.equal(fallbackWorkspace.agentId, undefined);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("缺失 workspace 或 workspace 不是目录时拒绝解析", async () => {
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
resolveFreeSkillWorkspace({ agentId: "missing" }, path.join(root, "missing")),
|
|
67
|
+
(error: unknown) => error instanceof FreeSkillWorkspaceError && error.code === "WORKSPACE_MISSING",
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const filePath = path.join(root, "workspace-file");
|
|
71
|
+
await fs.writeFile(filePath, "workspace");
|
|
72
|
+
await assert.rejects(
|
|
73
|
+
resolveFreeSkillWorkspace(undefined, filePath),
|
|
74
|
+
(error: unknown) => error instanceof FreeSkillWorkspaceError && error.code === "WORKSPACE_NOT_DIRECTORY",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("拒绝 `.xg-platform` 符号链接越出 workspace", async (t) => {
|
|
79
|
+
if (process.platform === "win32") return t.skip("Windows 创建目录符号链接需要额外权限");
|
|
80
|
+
|
|
81
|
+
const workspace = path.join(root, "workspace");
|
|
82
|
+
const outside = path.join(root, "outside");
|
|
83
|
+
await fs.mkdir(workspace);
|
|
84
|
+
await fs.mkdir(outside);
|
|
85
|
+
await fs.symlink(outside, path.join(workspace, ".xg-platform"), "dir");
|
|
86
|
+
|
|
87
|
+
await assert.rejects(
|
|
88
|
+
resolveFreeSkillWorkspace({ workspaceDir: workspace, agentId: "runtime" }),
|
|
89
|
+
(error: unknown) => error instanceof FreeSkillWorkspaceError && error.code === "PLATFORM_DIRECTORY_ESCAPE",
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("忽略前端或模型注入的绝对 target/path,只返回固定相对目录", async () => {
|
|
94
|
+
const workspace = path.join(root, "workspace");
|
|
95
|
+
await fs.mkdir(workspace);
|
|
96
|
+
|
|
97
|
+
const result = await resolveFreeSkillWorkspace({
|
|
98
|
+
workspaceDir: workspace,
|
|
99
|
+
agentId: "runtime",
|
|
100
|
+
targetPath: "/outside/target",
|
|
101
|
+
absolutePath: "/outside/absolute",
|
|
102
|
+
directoryPath: "/outside/directory",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.equal(result.workspaceRelativePath, ".xg-platform");
|
|
106
|
+
assert.equal(path.isAbsolute(result.workspaceRelativePath), false);
|
|
107
|
+
assert.equal(result.directoryPath, path.join(await fs.realpath(workspace), ".xg-platform"));
|
|
108
|
+
});
|
|
109
|
+
});
|