mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-07 01:54:24 +08:00
feat(agent): add local Codex Skill management and invocation
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { SkillStore, SkillStoreError } from "./store.js";
|
||||
|
||||
test("创建、读取和更新画布专属 Skill", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
|
||||
const created = await store.create({
|
||||
name: "product-grid",
|
||||
description: "生成商品九宫格",
|
||||
instructions: "根据商品信息创建九宫格生成流程。",
|
||||
interface: {
|
||||
displayName: "产品九宫格生成",
|
||||
shortDescription: "根据商品资料与参考图自动规划并生成产品图片九宫格流程",
|
||||
defaultPrompt: "Use $product-grid to build a product image grid.",
|
||||
},
|
||||
});
|
||||
assert.equal(created.managed, true);
|
||||
assert.equal(created.interface?.displayName, "产品九宫格生成");
|
||||
assert.equal(created.revision.length, 64);
|
||||
assert.equal(store.isManagedPath(created.path), true);
|
||||
const openAi = await fs.readFile(path.join(path.dirname(created.path), "agents", "openai.yaml"), "utf8");
|
||||
assert.match(openAi, /^interface:/m);
|
||||
assert.match(openAi, /display_name: "产品九宫格生成"/);
|
||||
|
||||
const updated = await store.update("product-grid", {
|
||||
description: "生成商品图片九宫格",
|
||||
instructions: "先分析商品,再创建九宫格生成流程。",
|
||||
interface: { displayName: "产品九宫格生成" },
|
||||
expectedRevision: created.revision,
|
||||
});
|
||||
assert.equal(updated.description, "生成商品图片九宫格");
|
||||
assert.notEqual(updated.revision, created.revision);
|
||||
assert.deepEqual(updated.interface, { displayName: "产品九宫格生成" });
|
||||
});
|
||||
|
||||
test("revision 不匹配时拒绝覆盖或删除", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({ name: "demo", description: "演示 Skill", instructions: "执行演示流程。" });
|
||||
|
||||
await assert.rejects(store.update("demo", {
|
||||
description: "已过期的修改",
|
||||
instructions: "不会写入。",
|
||||
expectedRevision: "0".repeat(64),
|
||||
}), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409);
|
||||
await assert.rejects(store.delete("demo", "0".repeat(64)), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409);
|
||||
assert.equal((await store.get("demo")).revision, created.revision);
|
||||
});
|
||||
|
||||
test("清空界面字段时保留未由画布管理的 openai 元数据", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({
|
||||
name: "metadata-demo",
|
||||
description: "演示界面元数据",
|
||||
instructions: "执行演示流程。",
|
||||
interface: { displayName: "演示", defaultPrompt: "Use $metadata-demo to run the demo." },
|
||||
});
|
||||
const openAiFile = path.join(path.dirname(created.path), "agents", "openai.yaml");
|
||||
await fs.writeFile(openAiFile, "interface:\n display_name: 演示\n default_prompt: Use $metadata-demo to run the demo.\n icon_small: ./assets/icon.png\n brand_color: '#336699'\n", "utf8");
|
||||
const current = await store.get("metadata-demo");
|
||||
|
||||
const updated = await store.update("metadata-demo", {
|
||||
description: current.description,
|
||||
instructions: current.instructions,
|
||||
interface: null,
|
||||
expectedRevision: current.revision,
|
||||
});
|
||||
const openAi = await fs.readFile(openAiFile, "utf8");
|
||||
assert.equal(updated.interface, undefined);
|
||||
assert.match(openAi, /icon_small:/);
|
||||
assert.match(openAi, /brand_color:/);
|
||||
assert.doesNotMatch(openAi, /display_name:|default_prompt:/);
|
||||
});
|
||||
|
||||
test("名称、正文大小和默认提示词均经过校验", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
|
||||
await assert.rejects(store.create({ name: "Invalid_Name", description: "无效", instructions: "无效" }), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 400);
|
||||
await assert.rejects(store.create({ name: "invalid-description", description: "包含 <标签>", instructions: "无效" }), /尖括号/);
|
||||
await assert.rejects(store.create({ name: "too-large", description: "过大", instructions: "中".repeat(90_000) }), /256KiB/);
|
||||
await assert.rejects(store.create({
|
||||
name: "prompt-check",
|
||||
description: "校验默认提示词",
|
||||
instructions: "执行流程。",
|
||||
interface: { defaultPrompt: "Use this skill." },
|
||||
}), /\$prompt-check/);
|
||||
await assert.rejects(store.create({
|
||||
name: "prompt-check",
|
||||
description: "校验默认提示词边界",
|
||||
instructions: "执行流程。",
|
||||
interface: { defaultPrompt: "Use $prompt-check-extra instead." },
|
||||
}), /\$prompt-check/);
|
||||
await assert.rejects(store.create({
|
||||
name: "prompt-check",
|
||||
description: "校验命名空间提示词边界",
|
||||
instructions: "执行流程。",
|
||||
interface: { defaultPrompt: "Use $prompt-check:other instead." },
|
||||
}), /\$prompt-check/);
|
||||
await assert.rejects(store.create({
|
||||
name: "short-description-check",
|
||||
description: "校验卡片短说明长度",
|
||||
instructions: "执行流程。",
|
||||
interface: { shortDescription: "过短" },
|
||||
}), /不能少于 25 个字符/);
|
||||
await assert.rejects(store.create({
|
||||
name: "prompt-check",
|
||||
description: "校验默认提示词大小写",
|
||||
instructions: "执行流程。",
|
||||
interface: { defaultPrompt: "Use $PROMPT-CHECK instead." },
|
||||
}), /\$prompt-check/);
|
||||
await assert.rejects(store.create({
|
||||
name: "invalid-interface",
|
||||
description: "校验界面元数据",
|
||||
instructions: "执行流程。",
|
||||
interface: false as never,
|
||||
}), /界面元数据无效/);
|
||||
});
|
||||
|
||||
test("更新校验失败时不会提前改写 SKILL.md", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({ name: "safe-update", description: "保留原内容", instructions: "执行原流程。" });
|
||||
|
||||
await assert.rejects(store.update("safe-update", {
|
||||
description: "不应写入",
|
||||
instructions: "不应写入。",
|
||||
interface: { defaultPrompt: "缺少 Skill 名称" },
|
||||
expectedRevision: created.revision,
|
||||
}), /\$safe-update/);
|
||||
assert.deepEqual(await store.get("safe-update"), created);
|
||||
});
|
||||
|
||||
test("拒绝读取非对象格式的 openai.yaml", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({ name: "invalid-metadata", description: "校验元数据", instructions: "执行流程。" });
|
||||
const agentsDir = path.join(path.dirname(created.path), "agents");
|
||||
await fs.mkdir(agentsDir);
|
||||
await fs.writeFile(path.join(agentsDir, "openai.yaml"), "- invalid\n", "utf8");
|
||||
|
||||
await assert.rejects(store.get("invalid-metadata"), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409);
|
||||
});
|
||||
|
||||
test("不完整更新请求返回校验错误而不是运行时异常", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
|
||||
await assert.rejects(store.update("demo", undefined as never), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 400);
|
||||
});
|
||||
|
||||
test("空 interface 元数据读取为空", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({ name: "empty-interface", description: "空界面元数据", instructions: "执行流程。" });
|
||||
const agentsDir = path.join(path.dirname(created.path), "agents");
|
||||
await fs.mkdir(agentsDir);
|
||||
await fs.writeFile(path.join(agentsDir, "openai.yaml"), "interface: {}\n", "utf8");
|
||||
|
||||
assert.equal((await store.get("empty-interface")).interface, undefined);
|
||||
});
|
||||
|
||||
test("读取或修改不存在的 Skill 不会创建目录", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const skillsRoot = path.join(workspace, ".agents", "skills");
|
||||
|
||||
await assert.rejects(store.get("missing"), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 404);
|
||||
await assert.rejects(store.update("missing", {
|
||||
description: "不存在",
|
||||
instructions: "不会写入。",
|
||||
expectedRevision: "0".repeat(64),
|
||||
}), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 404);
|
||||
await assert.rejects(store.delete("missing", "0".repeat(64)), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 404);
|
||||
await assert.rejects(fs.access(skillsRoot));
|
||||
});
|
||||
|
||||
test("更新后只保留完整文件且不遗留临时文件", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({
|
||||
name: "atomic-update",
|
||||
description: "验证原子更新",
|
||||
instructions: "执行旧流程。",
|
||||
interface: { displayName: "原子更新" },
|
||||
});
|
||||
|
||||
const updated = await store.update("atomic-update", {
|
||||
description: "验证完整更新",
|
||||
instructions: "执行新流程。",
|
||||
interface: { displayName: "完整更新" },
|
||||
expectedRevision: created.revision,
|
||||
});
|
||||
const skillDirectory = path.dirname(updated.path);
|
||||
const skillText = await fs.readFile(updated.path, "utf8");
|
||||
const openAiText = await fs.readFile(path.join(skillDirectory, "agents", "openai.yaml"), "utf8");
|
||||
assert.match(skillText, /description: 验证完整更新/);
|
||||
assert.match(skillText, /执行新流程/);
|
||||
assert.match(openAiText, /display_name: "完整更新"/);
|
||||
assert.deepEqual((await fs.readdir(skillDirectory)).filter((name) => name.endsWith(".tmp")), []);
|
||||
assert.deepEqual((await fs.readdir(path.join(skillDirectory, "agents"))).filter((name) => name.endsWith(".tmp")), []);
|
||||
});
|
||||
|
||||
test("拒绝读取非对象格式的 interface 元数据", async (context) => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-"));
|
||||
context.after(() => fs.rm(workspace, { recursive: true, force: true }));
|
||||
const store = new SkillStore(workspace);
|
||||
const created = await store.create({ name: "invalid-interface", description: "校验界面元数据", instructions: "执行流程。" });
|
||||
const agentsDir = path.join(path.dirname(created.path), "agents");
|
||||
await fs.mkdir(agentsDir);
|
||||
await fs.writeFile(path.join(agentsDir, "openai.yaml"), "interface: false\n", "utf8");
|
||||
|
||||
await assert.rejects(store.get("invalid-interface"), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409);
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import matter from "gray-matter";
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
|
||||
const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
const MAX_DESCRIPTION_LENGTH = 1024;
|
||||
const MAX_INSTRUCTIONS_BYTES = 256 * 1024;
|
||||
const MAX_DISPLAY_NAME_LENGTH = 64;
|
||||
const MIN_SHORT_DESCRIPTION_LENGTH = 25;
|
||||
const MAX_SHORT_DESCRIPTION_LENGTH = 64;
|
||||
const MAX_DEFAULT_PROMPT_LENGTH = 1024;
|
||||
|
||||
export type ManagedSkillInterface = {
|
||||
displayName?: string;
|
||||
shortDescription?: string;
|
||||
defaultPrompt?: string;
|
||||
};
|
||||
|
||||
export type ManagedSkillDetail = {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
interface?: ManagedSkillInterface;
|
||||
path: string;
|
||||
revision: string;
|
||||
managed: true;
|
||||
};
|
||||
|
||||
export type CreateManagedSkillInput = {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
interface?: ManagedSkillInterface | null;
|
||||
};
|
||||
|
||||
export type UpdateManagedSkillInput = {
|
||||
description: string;
|
||||
instructions: string;
|
||||
interface?: ManagedSkillInterface | null;
|
||||
expectedRevision: string;
|
||||
};
|
||||
|
||||
type SkillDocument = {
|
||||
raw: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
description: string;
|
||||
instructions: string;
|
||||
};
|
||||
|
||||
type OpenAiDocument = { raw: string; data: Record<string, unknown>; interface?: ManagedSkillInterface };
|
||||
|
||||
export class SkillStoreError extends Error {
|
||||
override name = "SkillStoreError";
|
||||
constructor(message: string, readonly statusCode: 400 | 404 | 409 | 500) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 只管理站点工作空间下 `.agents/skills` 中的画布专属 Skill。 */
|
||||
export class SkillStore {
|
||||
readonly workspacePath: string;
|
||||
readonly skillsPath: string;
|
||||
private writeQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(workspacePath: string) {
|
||||
this.workspacePath = path.resolve(workspacePath);
|
||||
this.skillsPath = path.join(this.workspacePath, ".agents", "skills");
|
||||
}
|
||||
|
||||
/** 判断 Codex 返回的绝对路径是否属于本 Store 的标准 Skill 入口。 */
|
||||
isManagedPath(filePath: string) {
|
||||
if (!path.isAbsolute(filePath)) return false;
|
||||
const relative = path.relative(this.skillsPath, path.resolve(filePath));
|
||||
const segments = relative.split(path.sep);
|
||||
return segments.length === 2 && validName(segments[0]) && segments[1].toLowerCase() === "skill.md";
|
||||
}
|
||||
|
||||
/** 读取一个可编辑 Skill 的正文与界面元数据。 */
|
||||
async get(name: string): Promise<ManagedSkillDetail> {
|
||||
try {
|
||||
await this.writeQueue.catch(() => undefined);
|
||||
const paths = await this.safeExistingPaths(name);
|
||||
return await this.readDetail(name, paths.skillFile, paths.openAiFile);
|
||||
} catch (error) {
|
||||
throw storeError(error, "读取 Skill 失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建新的画布专属 Skill。 */
|
||||
create(input: CreateManagedSkillInput) {
|
||||
return this.mutate(async () => {
|
||||
const name = skillName(input?.name);
|
||||
const description = skillDescription(input?.description);
|
||||
const instructions = skillInstructions(input?.instructions);
|
||||
const skillInterface = skillInterfaceValue(input?.interface, name);
|
||||
await this.ensureRoot();
|
||||
const skillDir = path.join(this.skillsPath, name);
|
||||
const existing = await lstatOptional(skillDir);
|
||||
if (existing) {
|
||||
if (existing.isSymbolicLink()) throw new SkillStoreError("Skill 目录不能是符号链接或目录联接", 400);
|
||||
throw new SkillStoreError("同名 Skill 已存在", 409);
|
||||
}
|
||||
await fs.mkdir(skillDir);
|
||||
try {
|
||||
const skillFile = path.join(skillDir, "SKILL.md");
|
||||
await writeFileAtomic(skillFile, serializeSkill({ name, description }, instructions));
|
||||
const openAiFile = path.join(skillDir, "agents", "openai.yaml");
|
||||
if (skillInterface) await this.writeOpenAi(openAiFile, {}, skillInterface);
|
||||
return await this.readDetail(name, skillFile, openAiFile);
|
||||
} catch (error) {
|
||||
await fs.rm(skillDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 通过 revision 防止覆盖已被其他窗口或外部编辑器修改的内容。 */
|
||||
update(nameValue: string, input: UpdateManagedSkillInput) {
|
||||
return this.mutate(async () => {
|
||||
const name = skillName(nameValue);
|
||||
const description = skillDescription(input?.description);
|
||||
const instructions = skillInstructions(input?.instructions);
|
||||
const expectedRevision = expectedRevisionValue(input?.expectedRevision);
|
||||
const interfaceInput = input?.interface;
|
||||
const skillInterface = interfaceInput === undefined ? undefined : skillInterfaceValue(interfaceInput, name);
|
||||
const paths = await this.safeExistingPaths(name);
|
||||
const currentSkill = await readSkill(paths.skillFile, name);
|
||||
const currentOpenAi = await readOpenAi(paths.openAiFile, name);
|
||||
assertRevision(expectedRevision, revision(currentSkill.raw, currentOpenAi.raw));
|
||||
const frontmatter = { ...currentSkill.frontmatter, name, description };
|
||||
await writeFileAtomic(paths.skillFile, serializeSkill(frontmatter, instructions));
|
||||
if (interfaceInput !== undefined) {
|
||||
try {
|
||||
await this.writeOpenAi(paths.openAiFile, currentOpenAi.data, skillInterface);
|
||||
} catch (error) {
|
||||
if (!await restoreSkillFiles(paths.skillFile, currentSkill.raw, paths.openAiFile, currentOpenAi.raw)) {
|
||||
throw new SkillStoreError("Skill 更新失败且无法完全恢复,请检查本地文件", 500);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return await this.readDetail(name, paths.skillFile, paths.openAiFile);
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除 revision 仍匹配的画布专属 Skill。 */
|
||||
delete(nameValue: string, expectedRevisionValueInput: string) {
|
||||
return this.mutate(async () => {
|
||||
const name = skillName(nameValue);
|
||||
const expectedRevision = expectedRevisionValue(expectedRevisionValueInput);
|
||||
const paths = await this.safeExistingPaths(name);
|
||||
const currentSkill = await readSkill(paths.skillFile, name);
|
||||
const currentOpenAi = await readOpenAi(paths.openAiFile, name);
|
||||
assertRevision(expectedRevision, revision(currentSkill.raw, currentOpenAi.raw));
|
||||
await assertTreeHasNoLinks(paths.skillDir);
|
||||
const realRoot = await fs.realpath(this.skillsPath);
|
||||
const realSkill = await fs.realpath(paths.skillDir);
|
||||
if (!inside(realRoot, realSkill)) throw new SkillStoreError("Skill 路径不安全", 400);
|
||||
await fs.rm(realSkill, { recursive: true });
|
||||
});
|
||||
}
|
||||
|
||||
private mutate<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.writeQueue.catch(() => undefined).then(operation).catch((error) => { throw storeError(error, "修改 Skill 失败"); });
|
||||
this.writeQueue = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async ensureRoot() {
|
||||
const workspace = await lstatOptional(this.workspacePath);
|
||||
if (!workspace) throw new SkillStoreError("站点工作空间不存在", 409);
|
||||
if (workspace.isSymbolicLink()) throw new SkillStoreError("站点工作空间不能是符号链接或目录联接", 400);
|
||||
if (!workspace.isDirectory()) throw new SkillStoreError("站点工作空间不是目录", 409);
|
||||
const agentsPath = path.join(this.workspacePath, ".agents");
|
||||
await ensurePlainDirectory(agentsPath);
|
||||
await ensurePlainDirectory(this.skillsPath);
|
||||
const realWorkspace = await fs.realpath(this.workspacePath);
|
||||
const realRoot = await fs.realpath(this.skillsPath);
|
||||
if (!inside(realWorkspace, realRoot)) throw new SkillStoreError("Skill 根目录不安全", 400);
|
||||
}
|
||||
|
||||
private async safeExistingPaths(nameValue: string) {
|
||||
const name = skillName(nameValue);
|
||||
await this.assertExistingRoot();
|
||||
const skillDir = path.join(this.skillsPath, name);
|
||||
const directory = await lstatOptional(skillDir);
|
||||
if (!directory) throw new SkillStoreError("找不到指定 Skill", 404);
|
||||
if (directory.isSymbolicLink() || !directory.isDirectory()) throw new SkillStoreError("Skill 目录不安全", 400);
|
||||
const realRoot = await fs.realpath(this.skillsPath);
|
||||
const realSkill = await fs.realpath(skillDir);
|
||||
if (!inside(realRoot, realSkill)) throw new SkillStoreError("Skill 路径不安全", 400);
|
||||
const skillFile = path.join(skillDir, "SKILL.md");
|
||||
const skillEntry = await lstatOptional(skillFile);
|
||||
if (!skillEntry) throw new SkillStoreError("Skill 缺少 SKILL.md", 404);
|
||||
if (skillEntry.isSymbolicLink() || !skillEntry.isFile()) throw new SkillStoreError("SKILL.md 路径不安全", 400);
|
||||
const agentsDir = path.join(skillDir, "agents");
|
||||
const agentsEntry = await lstatOptional(agentsDir);
|
||||
if (agentsEntry && (agentsEntry.isSymbolicLink() || !agentsEntry.isDirectory())) throw new SkillStoreError("Skill agents 目录不安全", 400);
|
||||
const openAiFile = path.join(agentsDir, "openai.yaml");
|
||||
const openAiEntry = await lstatOptional(openAiFile);
|
||||
if (openAiEntry && (openAiEntry.isSymbolicLink() || !openAiEntry.isFile())) throw new SkillStoreError("openai.yaml 路径不安全", 400);
|
||||
return { skillDir, skillFile, openAiFile };
|
||||
}
|
||||
|
||||
private async assertExistingRoot() {
|
||||
const workspace = await lstatOptional(this.workspacePath);
|
||||
if (!workspace) throw new SkillStoreError("找不到指定 Skill", 404);
|
||||
if (workspace.isSymbolicLink()) throw new SkillStoreError("站点工作空间不能是符号链接或目录联接", 400);
|
||||
if (!workspace.isDirectory()) throw new SkillStoreError("找不到指定 Skill", 404);
|
||||
const agentsPath = path.join(this.workspacePath, ".agents");
|
||||
const agents = await lstatOptional(agentsPath);
|
||||
const root = await lstatOptional(this.skillsPath);
|
||||
if (!agents || !root) throw new SkillStoreError("找不到指定 Skill", 404);
|
||||
if (agents.isSymbolicLink() || !agents.isDirectory() || root.isSymbolicLink() || !root.isDirectory()) throw new SkillStoreError("Skill 路径中存在符号链接或目录联接", 400);
|
||||
const realWorkspace = await fs.realpath(this.workspacePath);
|
||||
const realRoot = await fs.realpath(this.skillsPath);
|
||||
if (!inside(realWorkspace, realRoot)) throw new SkillStoreError("Skill 根目录不安全", 400);
|
||||
}
|
||||
|
||||
private async readDetail(name: string, skillFile: string, openAiFile: string): Promise<ManagedSkillDetail> {
|
||||
const skill = await readSkill(skillFile, name);
|
||||
const openAi = await readOpenAi(openAiFile, name);
|
||||
return {
|
||||
name,
|
||||
description: skill.description,
|
||||
instructions: skill.instructions,
|
||||
...(openAi.interface ? { interface: openAi.interface } : {}),
|
||||
path: skillFile,
|
||||
revision: revision(skill.raw, openAi.raw),
|
||||
managed: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async writeOpenAi(filePath: string, current: Record<string, unknown>, skillInterface?: ManagedSkillInterface) {
|
||||
const agentsDir = path.dirname(filePath);
|
||||
const existing = recordValue(current.interface);
|
||||
delete existing.display_name;
|
||||
delete existing.short_description;
|
||||
delete existing.default_prompt;
|
||||
const interfaceYaml = {
|
||||
...existing,
|
||||
...(skillInterface?.displayName ? { display_name: skillInterface.displayName } : {}),
|
||||
...(skillInterface?.shortDescription ? { short_description: skillInterface.shortDescription } : {}),
|
||||
...(skillInterface?.defaultPrompt ? { default_prompt: skillInterface.defaultPrompt } : {}),
|
||||
};
|
||||
const next = { ...current };
|
||||
if (Object.keys(interfaceYaml).length) next.interface = interfaceYaml;
|
||||
else delete next.interface;
|
||||
if (Object.keys(next).length) {
|
||||
await ensurePlainDirectory(agentsDir);
|
||||
await writeFileAtomic(filePath, stringifyYaml(next, { defaultKeyType: "PLAIN", defaultStringType: "QUOTE_DOUBLE" }));
|
||||
} else {
|
||||
await fs.unlink(filePath).catch((error) => {
|
||||
if (nodeErrorCode(error) !== "ENOENT") throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validName(value: string | undefined): value is string {
|
||||
return Boolean(value && value.length <= MAX_NAME_LENGTH && NAME_PATTERN.test(value));
|
||||
}
|
||||
|
||||
function skillName(value: unknown) {
|
||||
const name = typeof value === "string" ? value : "";
|
||||
if (!validName(name)) throw new SkillStoreError("Skill 名称只能包含小写字母、数字和连字符", 400);
|
||||
return name;
|
||||
}
|
||||
|
||||
function skillDescription(value: unknown) {
|
||||
const description = typeof value === "string" ? value.trim() : "";
|
||||
if (!description) throw new SkillStoreError("请输入 Skill 描述", 400);
|
||||
if (description.length > MAX_DESCRIPTION_LENGTH) throw new SkillStoreError("Skill 描述过长", 400);
|
||||
if (description.includes("<") || description.includes(">")) throw new SkillStoreError("Skill 描述不能包含尖括号", 400);
|
||||
return description;
|
||||
}
|
||||
|
||||
function skillInstructions(value: unknown) {
|
||||
const instructions = typeof value === "string" ? value.trim() : "";
|
||||
if (!instructions) throw new SkillStoreError("请输入 Skill 正文", 400);
|
||||
if (Buffer.byteLength(instructions, "utf8") > MAX_INSTRUCTIONS_BYTES) throw new SkillStoreError("Skill 正文不能超过 256KiB", 400);
|
||||
return instructions;
|
||||
}
|
||||
|
||||
function skillInterfaceValue(value: unknown, name: string): ManagedSkillInterface | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "object" || Array.isArray(value)) throw new SkillStoreError("Skill 界面元数据无效", 400);
|
||||
const interfaceValue = value as Record<string, unknown>;
|
||||
const displayName = optionalText(interfaceValue.displayName, "显示名称", MAX_DISPLAY_NAME_LENGTH);
|
||||
const shortDescription = optionalText(interfaceValue.shortDescription, "简短描述", MAX_SHORT_DESCRIPTION_LENGTH);
|
||||
const defaultPrompt = optionalText(interfaceValue.defaultPrompt, "默认提示词", MAX_DEFAULT_PROMPT_LENGTH);
|
||||
if (shortDescription && shortDescription.length < MIN_SHORT_DESCRIPTION_LENGTH) throw new SkillStoreError(`简短描述不能少于 ${MIN_SHORT_DESCRIPTION_LENGTH} 个字符`, 400);
|
||||
if (defaultPrompt && !new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(defaultPrompt)) throw new SkillStoreError(`默认提示词必须包含 $${name}`, 400);
|
||||
return displayName || shortDescription || defaultPrompt ? { ...(displayName ? { displayName } : {}), ...(shortDescription ? { shortDescription } : {}), ...(defaultPrompt ? { defaultPrompt } : {}) } : undefined;
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, label: string, maxLength: number) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (typeof value !== "string") throw new SkillStoreError(`${label}格式无效`, 400);
|
||||
const text = value.trim();
|
||||
if (text.length > maxLength) throw new SkillStoreError(`${label}过长`, 400);
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function expectedRevisionValue(value: unknown) {
|
||||
const expected = typeof value === "string" ? value : "";
|
||||
if (!/^[a-f0-9]{64}$/.test(expected)) throw new SkillStoreError("Skill revision 无效,请重新加载后再试", 400);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function assertRevision(expected: string, current: string) {
|
||||
if (expected !== current) throw new SkillStoreError("Skill 已被其他窗口或外部编辑器修改,请重新加载后再试", 409);
|
||||
}
|
||||
|
||||
function serializeSkill(frontmatter: Record<string, unknown>, instructions: string) {
|
||||
return matter.stringify(`${instructions.trim()}\n`, frontmatter);
|
||||
}
|
||||
|
||||
async function readSkill(filePath: string, expectedName: string): Promise<SkillDocument> {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
let parsed: matter.GrayMatterFile<string>;
|
||||
try {
|
||||
parsed = matter(raw);
|
||||
} catch {
|
||||
throw new SkillStoreError("SKILL.md frontmatter 格式无效", 409);
|
||||
}
|
||||
const frontmatter = recordValue(parsed.data);
|
||||
if (frontmatter.name !== expectedName) throw new SkillStoreError("SKILL.md 中的名称与目录不一致", 409);
|
||||
return {
|
||||
raw,
|
||||
frontmatter,
|
||||
description: skillDescription(frontmatter.description),
|
||||
instructions: skillInstructions(parsed.content),
|
||||
};
|
||||
}
|
||||
|
||||
async function readOpenAi(filePath: string, expectedName: string): Promise<OpenAiDocument> {
|
||||
const entry = await lstatOptional(filePath);
|
||||
if (!entry) return { raw: "", data: {} };
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = parseYaml(raw);
|
||||
if (parsed !== null && parsed !== undefined && (typeof parsed !== "object" || Array.isArray(parsed))) throw new Error("invalid document");
|
||||
data = recordValue(parsed);
|
||||
} catch {
|
||||
throw new SkillStoreError("agents/openai.yaml 格式无效", 409);
|
||||
}
|
||||
if (data.interface !== undefined && data.interface !== null && (typeof data.interface !== "object" || Array.isArray(data.interface))) {
|
||||
throw new SkillStoreError("agents/openai.yaml interface 格式无效", 409);
|
||||
}
|
||||
const value = data.interface as Record<string, unknown> | null | undefined;
|
||||
const skillInterface = skillInterfaceValue({ displayName: value?.display_name, shortDescription: value?.short_description, defaultPrompt: value?.default_prompt }, expectedName);
|
||||
return { raw, data, ...(skillInterface ? { interface: skillInterface } : {}) };
|
||||
}
|
||||
|
||||
async function restoreSkillFiles(skillFile: string, skillRaw: string, openAiFile: string, openAiRaw: string) {
|
||||
try {
|
||||
await writeFileAtomic(skillFile, skillRaw);
|
||||
if (openAiRaw) {
|
||||
await ensurePlainDirectory(path.dirname(openAiFile));
|
||||
await writeFileAtomic(openAiFile, openAiRaw);
|
||||
} else {
|
||||
await fs.unlink(openAiFile).catch((error) => {
|
||||
if (nodeErrorCode(error) !== "ENOENT") throw error;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileAtomic(filePath: string, content: string) {
|
||||
const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
|
||||
await fs.rename(temporary, filePath);
|
||||
} finally {
|
||||
await fs.unlink(temporary).catch((error) => {
|
||||
if (nodeErrorCode(error) !== "ENOENT") throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function revision(skillRaw: string, openAiRaw: string) {
|
||||
return crypto.createHash("sha256").update(skillRaw).update("\0").update(openAiRaw).digest("hex");
|
||||
}
|
||||
|
||||
async function ensurePlainDirectory(directory: string) {
|
||||
const entry = await lstatOptional(directory);
|
||||
if (!entry) {
|
||||
await fs.mkdir(directory);
|
||||
return;
|
||||
}
|
||||
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new SkillStoreError("Skill 路径中存在符号链接或目录联接", 400);
|
||||
}
|
||||
|
||||
async function assertTreeHasNoLinks(directory: string): Promise<void> {
|
||||
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
const metadata = await fs.lstat(entryPath);
|
||||
if (metadata.isSymbolicLink()) throw new SkillStoreError("Skill 目录中存在符号链接或目录联接,无法删除", 400);
|
||||
if (metadata.isDirectory()) await assertTreeHasNoLinks(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
function inside(parent: string, child: string) {
|
||||
const relative = path.relative(parent, child);
|
||||
return Boolean(relative && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function lstatOptional(filePath: string) {
|
||||
try {
|
||||
return await fs.lstat(filePath);
|
||||
} catch (error) {
|
||||
if (nodeErrorCode(error) === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record<string, unknown>) } : {};
|
||||
}
|
||||
|
||||
function nodeErrorCode(error: unknown) {
|
||||
return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code || "") : "";
|
||||
}
|
||||
|
||||
function storeError(error: unknown, fallback: string) {
|
||||
if (error instanceof SkillStoreError) return error;
|
||||
if (nodeErrorCode(error) === "ENOENT") return new SkillStoreError("找不到指定 Skill", 404);
|
||||
if (["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes(nodeErrorCode(error))) return new SkillStoreError("Skill 文件当前无法修改", 409);
|
||||
return new SkillStoreError(fallback, 500);
|
||||
}
|
||||
Reference in New Issue
Block a user