forked from EduCraft/curriculum-project-hub
feat: make agent roles and skills dynamic
This commit is contained in:
@@ -14,6 +14,7 @@ describe("agent subprocess security policy", () => {
|
||||
it("passes only the run proxy capability and safe runtime variables and protects the capability from tools", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
const policy = await createAgentSecurityPolicy({
|
||||
runId: "run-test",
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
providerProxyEnv: {
|
||||
@@ -51,14 +52,8 @@ describe("agent subprocess security policy", () => {
|
||||
expect(policy.env.TEMP).toBe(policy.env.TMPDIR);
|
||||
expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t"));
|
||||
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56);
|
||||
expect(policy.skillIds).toEqual([
|
||||
"cph-curated:outline",
|
||||
"cph-curated:lesson-project",
|
||||
"cph-curated:data-processing-spec",
|
||||
]);
|
||||
expect(policy.skillPluginRoot.startsWith(canonicalWorkspace)).toBe(false);
|
||||
expect(policy.sandbox.filesystem.allowRead).toContain(policy.skillPluginRoot);
|
||||
expect(policy.sandbox.filesystem.allowWrite).not.toContain(policy.skillPluginRoot);
|
||||
expect(policy.skillIds).toEqual([]);
|
||||
expect(policy.skillPluginRoot).toBeUndefined();
|
||||
|
||||
expect(policy.sandbox).toMatchObject({
|
||||
enabled: true,
|
||||
@@ -83,6 +78,7 @@ describe("agent subprocess security policy", () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
|
||||
await expect(createAgentSecurityPolicy({
|
||||
runId: "run-test",
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
providerProxyEnv: {
|
||||
@@ -96,6 +92,7 @@ describe("agent subprocess security policy", () => {
|
||||
it("keeps every SDK temp variable on a short path inside the project workspace", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
const policy = await createAgentSecurityPolicy({
|
||||
runId: "run-test",
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
hostEnv: { PATH: "/usr/bin:/bin" },
|
||||
@@ -121,6 +118,7 @@ describe("agent subprocess security policy", () => {
|
||||
await mkdir(workspace, { recursive: true });
|
||||
|
||||
await expect(createAgentSecurityPolicy({
|
||||
runId: "run-test",
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
hostEnv: { PATH: "/usr/bin:/bin" },
|
||||
@@ -135,6 +133,7 @@ describe("agent subprocess security policy", () => {
|
||||
await symlink(outside, linked);
|
||||
|
||||
await expect(createAgentSecurityPolicy({
|
||||
runId: "run-test",
|
||||
workspaceRoot,
|
||||
workspaceDir: linked,
|
||||
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
|
||||
@@ -150,6 +149,7 @@ describe("agent subprocess security policy", () => {
|
||||
await symlink(sibling, linked);
|
||||
|
||||
await expect(createAgentSecurityPolicy({
|
||||
runId: "run-test",
|
||||
workspaceRoot,
|
||||
workspaceDir: linked,
|
||||
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
CURATED_SKILL_IDS,
|
||||
CURATED_SKILL_NAMES,
|
||||
CURATED_SKILL_PLUGIN_NAME,
|
||||
validateCuratedSkillPlugin,
|
||||
} from "../../src/agent/curatedSkills.js";
|
||||
|
||||
describe("validateCuratedSkillPlugin", () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("accepts exactly the release-owned plugin and returns qualified skill ids", async () => {
|
||||
const root = await skillPluginFixture();
|
||||
|
||||
await expect(validateCuratedSkillPlugin(root)).resolves.toEqual({
|
||||
root,
|
||||
skillIds: CURATED_SKILL_IDS,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a curated skill is absent from the release", async () => {
|
||||
const root = await skillPluginFixture();
|
||||
await rm(join(root, "skills", "outline"), { recursive: true });
|
||||
|
||||
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated plugin entry missing: skills\/outline/);
|
||||
});
|
||||
|
||||
it("fails closed when a skill manifest name does not match the allowlist", async () => {
|
||||
const root = await skillPluginFixture();
|
||||
await writeFile(join(root, "skills", "outline", "SKILL.md"), "---\nname: other\n---\n");
|
||||
|
||||
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated skill manifest name mismatch/);
|
||||
});
|
||||
|
||||
it("rejects an extra skill directory", async () => {
|
||||
const root = await skillPluginFixture();
|
||||
await mkdir(join(root, "skills", "extra"));
|
||||
|
||||
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: skills\/extra/);
|
||||
});
|
||||
|
||||
it("rejects plugin capabilities outside the reviewed skill catalog", async () => {
|
||||
const root = await skillPluginFixture();
|
||||
await mkdir(join(root, "hooks"));
|
||||
|
||||
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: hooks/);
|
||||
});
|
||||
|
||||
async function skillPluginFixture(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-skills-"));
|
||||
roots.push(root);
|
||||
await mkdir(join(root, ".claude-plugin"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, ".claude-plugin", "plugin.json"),
|
||||
JSON.stringify({ name: CURATED_SKILL_PLUGIN_NAME }),
|
||||
);
|
||||
for (const name of CURATED_SKILL_NAMES) {
|
||||
const source = join(root, "skills", name);
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\n---\n# ${name}\n`);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAgent } from "../../src/agent/runner.js";
|
||||
import { importSkillDirectory } from "../../src/agent/skillStore.js";
|
||||
|
||||
const queryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -76,7 +77,7 @@ describe("runAgent", () => {
|
||||
workspaceRoot = await realpath(workspaceRoot);
|
||||
workspace = await realpath(workspace);
|
||||
previousSecrets = Object.fromEntries(
|
||||
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET"].map((name) => [name, process.env[name]]),
|
||||
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET", "HUB_SKILL_STORE_ROOT"].map((name) => [name, process.env[name]]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -113,8 +114,7 @@ describe("runAgent", () => {
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: [],
|
||||
settings: { disableBundledSkills: true },
|
||||
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
|
||||
skills: ["cph-curated:outline", "cph-curated:lesson-project", "cph-curated:data-processing-spec"],
|
||||
skills: [],
|
||||
strictMcpConfig: true,
|
||||
sandbox: expect.objectContaining({
|
||||
enabled: true,
|
||||
@@ -149,7 +149,7 @@ describe("runAgent", () => {
|
||||
|
||||
it("returns the skills actually reported by SDK initialization", async () => {
|
||||
queryMock.mockReturnValue(messages(
|
||||
initMessage(["cph-curated:outline"]),
|
||||
initMessage(["cph-runtime:outline"]),
|
||||
assistantMessage("fresh"),
|
||||
resultMessage("sdk-session-1"),
|
||||
));
|
||||
@@ -164,7 +164,7 @@ describe("runAgent", () => {
|
||||
prisma: stubPrisma,
|
||||
});
|
||||
|
||||
expect(result.initializedSkillIds).toEqual(["cph-curated:outline"]);
|
||||
expect(result.initializedSkillIds).toEqual(["cph-runtime:outline"]);
|
||||
});
|
||||
|
||||
it("maps role tool ids to the Claude SDK tool whitelist", async () => {
|
||||
@@ -183,13 +183,13 @@ describe("runAgent", () => {
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
options: {
|
||||
tools: ["Read", "Bash", "Skill"],
|
||||
tools: ["Read", "Bash"],
|
||||
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps only the curated Skill dispatcher for an empty role tool whitelist", async () => {
|
||||
it("disables SDK tools for an empty role tool and skill selection", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||
|
||||
await runAgent({
|
||||
@@ -205,12 +205,42 @@ describe("runAgent", () => {
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
options: {
|
||||
tools: ["Skill"],
|
||||
tools: [],
|
||||
allowedTools: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads only the dynamic skills selected by the role", async () => {
|
||||
const source = join(root, "skill-source");
|
||||
const storeRoot = join(root, "skill-store");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "SKILL.md"), "---\nname: typst\ndescription: Typst\n---\n");
|
||||
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
|
||||
process.env["HUB_SKILL_STORE_ROOT"] = storeRoot;
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||
|
||||
await runAgent({
|
||||
prompt: "排版",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
tools: [],
|
||||
skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }],
|
||||
runId: "run-skill",
|
||||
sessionId: "hub-session-1",
|
||||
prisma: stubPrisma,
|
||||
});
|
||||
|
||||
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
options: {
|
||||
tools: ["Skill"],
|
||||
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
|
||||
skills: ["cph-runtime:typst"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns SDK-reported cost when present", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042)));
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { importSkillDirectory, prepareRunSkillPlugin } from "../../src/agent/skillStore.js";
|
||||
|
||||
describe("content-addressed Agent skill store", () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("imports a skill into an immutable digest directory and materializes a selected run plugin", async () => {
|
||||
const root = await makeRoot();
|
||||
const source = await makeSkill(root, "typst", "Typst help");
|
||||
const storeRoot = join(root, "store");
|
||||
|
||||
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
|
||||
expect(installed).toMatchObject({ name: "typst", description: "Typst help" });
|
||||
expect(installed.contentDigest).toMatch(/^[a-f0-9]{64}$/);
|
||||
await expect(readFile(join(storeRoot, "versions", installed.contentDigest, "SKILL.md"), "utf8"))
|
||||
.resolves.toContain("name: typst");
|
||||
|
||||
const plugin = await prepareRunSkillPlugin({
|
||||
storeRoot,
|
||||
runId: "run-1",
|
||||
skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }],
|
||||
});
|
||||
expect(plugin).not.toBeNull();
|
||||
expect(plugin?.skillIds).toEqual(["cph-runtime:typst"]);
|
||||
await expect(readFile(join(plugin!.root, "skills", "typst", "reference.md"), "utf8"))
|
||||
.resolves.toBe("reference\n");
|
||||
|
||||
await plugin?.cleanup();
|
||||
await expect(readFile(join(plugin!.root, ".claude-plugin", "plugin.json"), "utf8"))
|
||||
.rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("rejects symlinks and detects content tampering before a run", async () => {
|
||||
const root = await makeRoot();
|
||||
const source = await makeSkill(root, "outline", "Outline");
|
||||
await symlink(join(source, "reference.md"), join(source, "link.md"));
|
||||
await expect(importSkillDirectory({ sourceDir: source, storeRoot: join(root, "store") }))
|
||||
.rejects.toThrow(/symlink/);
|
||||
await rm(join(source, "link.md"));
|
||||
|
||||
const storeRoot = join(root, "store");
|
||||
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
|
||||
await writeFile(join(storeRoot, "versions", installed.contentDigest, "reference.md"), "tampered\n");
|
||||
await expect(prepareRunSkillPlugin({
|
||||
storeRoot,
|
||||
runId: "run-2",
|
||||
skills: [{ name: "outline", version: "1", contentDigest: installed.contentDigest }],
|
||||
})).rejects.toThrow(/content digest mismatch/);
|
||||
});
|
||||
|
||||
async function makeRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-skill-store-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
});
|
||||
|
||||
async function makeSkill(root: string, name: string, description: string): Promise<string> {
|
||||
const source = join(root, "source", name);
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n# ${name}\n`);
|
||||
await writeFile(join(source, "reference.md"), "reference\n");
|
||||
return source;
|
||||
}
|
||||
Reference in New Issue
Block a user