Files

172 lines
14 KiB
TypeScript

import { mkdtemp, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
InMemoryApprovalBroker,
InMemoryEventStore,
SafeWorkspace,
SkillCatalog,
createSaveReportDraftTool,
getMockAdsMetrics,
mockAdsMetricsParameters,
redactValue,
validateBaseUrl,
} from "../src/index.js";
import { Value } from "typebox/value";
const projectRoot = path.resolve(import.meta.dirname, "../../..");
describe("InMemoryEventStore", () => {
it("allocates monotonic per-session sequences atomically", () => {
const store = new InMemoryEventStore();
const first = store.append("s", { type: "run.completed", runId: "r", payload: {} });
const second = store.append("s", { type: "run.completed", runId: "r", payload: {} });
expect([first.sequence, second.sequence]).toEqual([1, 2]);
expect(store.append("other", { type: "run.completed", runId: "r", payload: {} }).sequence).toBe(1);
});
it("replays by sequence and event id", () => {
const store = new InMemoryEventStore();
const one = store.append("s", { type: "run.completed", payload: {} });
store.append("s", { type: "run.completed", payload: {} });
expect(store.listAfter("s", { sequence: 1 }).events).toHaveLength(1);
expect(store.listAfter("s", { eventId: one.eventId }).events).toHaveLength(1);
});
it("reports a retained-history gap", () => {
const store = new InMemoryEventStore(2);
for (let index = 0; index < 4; index++) store.append("s", { type: "run.completed", payload: {} });
expect(store.listAfter("s", { sequence: 1 }).gap?.oldestAvailableSequence).toBe(3);
});
it("unsubscribes listeners", () => {
const store = new InMemoryEventStore(); const listener = vi.fn(); const unsubscribe = store.subscribe("s", listener); unsubscribe(); store.append("s", { type: "run.completed", payload: {} }); expect(listener).not.toHaveBeenCalled();
});
it("removes the configured server token from SSE payloads", () => {
vi.stubEnv("SWADS_MCP_TOKEN", "server-sentinel-secret");
try { const store = new InMemoryEventStore(); store.append("s", { type: "assistant.delta", payload: { messageId: "m", delta: "Value server-sentinel-secret" } }); expect(JSON.stringify(store.listAfter("s"))).not.toContain("server-sentinel-secret"); }
finally { vi.unstubAllEnvs(); }
});
});
describe("ApprovalBroker", () => {
const request = { sessionId: "s", runId: "r", toolCallId: "t", toolName: "save_report_draft", arguments: { content: "x" }, riskLevel: "medium" as const };
it("approves and is idempotent for the same decision", async () => {
let id = ""; const broker = new InMemoryApprovalBroker(1_000, undefined, (value) => { id = value.approvalId; });
const pending = broker.request(request, new AbortController().signal); const first = broker.approve(id); const second = broker.approve(id);
expect((await pending).status).toBe("approved"); expect(second.decision).toEqual(first.decision);
});
it("denies and rejects a conflicting repeat", async () => {
let id = ""; const broker = new InMemoryApprovalBroker(1_000, undefined, (value) => { id = value.approvalId; }); const pending = broker.request(request, new AbortController().signal); broker.deny(id); expect((await pending).status).toBe("denied"); expect(() => broker.approve(id)).toThrow(/already resolved/i);
});
it("expires", async () => {
const broker = new InMemoryApprovalBroker(5); const decision = await broker.request(request, new AbortController().signal); expect(decision.status).toBe("expired");
});
it("cancels immediately with AbortSignal", async () => {
const controller = new AbortController(); const broker = new InMemoryApprovalBroker(1_000); const pending = broker.request(request, controller.signal); controller.abort(); expect((await pending).status).toBe("cancelled");
});
it("redacts sensitive arguments", () => {
expect(redactValue({ apiKey: "secret", nested: { authorization: "Bearer x" } })).toEqual({ apiKey: "[REDACTED]", nested: { authorization: "[REDACTED]" } });
});
});
describe("SafeWorkspace", () => {
it("writes only fixed report.md through an atomic operation", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); const result = await workspace.writeReport("正文", "报告"); expect(result.path).toBe("report.md"); expect(await readFile(path.join(root, "report.md"), "utf8")).toContain("# 报告");
});
it("rejects .. traversal and absolute escape", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await expect(workspace.resolveExisting("../outside")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" }); await expect(workspace.resolveExisting("/etc/passwd")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" });
});
it("rejects a symlink escape", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await symlink("/etc/passwd", path.join(root, "linked")); await expect(workspace.resolveExisting("linked")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" });
});
it("rejects protected paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await writeFile(path.join(root, ".env"), "KEY=x"); await expect(workspace.assertReadPath(".env")).rejects.toMatchObject({ code: "PROTECTED_PATH" });
});
it("save tool schema exposes no output path", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); const tool = createSaveReportDraftTool(workspace); expect((tool.parameters as { properties: Record<string, unknown> }).properties).not.toHaveProperty("path");
});
});
describe("mock_ads_metrics", () => {
it("validates days in the TypeBox schema", () => {
expect(Value.Check(mockAdsMetricsParameters, { accountId: "demo-account", days: 7 })).toBe(true); expect(Value.Check(mockAdsMetricsParameters, { accountId: "demo-account", days: 31 })).toBe(false);
});
it("computes deterministic metrics and handles precision", async () => {
const metrics = await getMockAdsMetrics(path.join(projectRoot, "fixtures", "ads-account.json"), "demo-account", 7); expect(metrics.spend).toBe(4415.5); expect(metrics.campaigns).toHaveLength(3); expect(metrics.roas).toBeGreaterThan(4);
});
it("rejects unknown accounts", async () => {
await expect(getMockAdsMetrics(path.join(projectRoot, "fixtures", "ads-account.json"), "missing", 7)).rejects.toThrow("Unknown demo account");
});
});
describe("SkillCatalog", () => {
it("discovers .agents and .pi skills without reading bodies into the catalog", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await createSkill(root, ".pi/skills/two", "two", "Second skill"); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect(catalog.list().filter((item) => ["one", "two"].includes(item.name))).toHaveLength(2); expect(JSON.stringify(catalog.list())).not.toContain("SECRET BODY");
});
it("appends the Session language policy while preserving default prompts, skills and extensions", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-language-"));
const agentDir = path.join(root, "agent-dir");
await createSkill(root, ".agents/skills/one", "one", "First skill");
await mkdir(path.join(root, ".pi/skills"), { recursive: true });
await writeFile(path.join(root, ".pi/APPEND_SYSTEM.md"), "Existing appended instruction");
const catalog = new SkillCatalog(root, agentDir);
await catalog.reload();
const extension = vi.fn();
const loader = await catalog.createSessionLoader(root, agentDir, [extension]);
expect(loader.getSystemPrompt()).toBeUndefined(); // Pi still builds its default Tool/Skill prompt.
expect(loader.getSystemPromptSource()).toBeUndefined();
const appended = loader.getAppendSystemPrompt();
expect(appended[0]).toBe("Existing appended instruction");
const policy = appended.at(-1)!;
expect(policy).toContain("默认使用中文回复,包括中文请求");
expect(policy).toContain("仅当首条用户输入中的自然语言请求明确为英文时");
expect(policy).toContain("Skill 名称、Skill XML/正文、命令、路径、代码和技术标识");
expect(policy).toContain("首条输入仅包含 Skill 内容或 Skill 名称,或自然语言无法明确判断时,选择中文");
expect(policy).toContain("当前 Session 内保持一致");
expect(policy).toContain("Reset 创建新 Session 后重新判断");
expect(loader.getSkills().skills.some((skill) => skill.name === "one")).toBe(true);
expect(extension).toHaveBeenCalledOnce();
await writeFile(path.join(root, ".pi/SYSTEM.md"), "Existing system prompt");
const next = await catalog.createSessionLoader(root, agentDir, []);
expect(next.getSystemPrompt()).toBe("Existing system prompt");
expect(next.getAppendSystemPrompt()).toEqual(appended);
});
it("loads path-delimited AGENT_SKILLS_DIRS and de-duplicates canonical paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const extra = path.join(root, "extra"); await createSkill(root, "extra/custom", "custom", "Extra skill"); const catalog = new SkillCatalog(root, path.join(root, "agent-dir"), [extra, extra].join(path.delimiter)); await catalog.reload(); expect(catalog.list().filter((item) => item.name === "custom")).toHaveLength(1);
});
it("turns an unreadable extra directory into a visible diagnostic", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent-dir"), path.join(root, "missing")); await catalog.reload(); expect(catalog.list().some((item) => item.diagnostics.some((diagnostic) => diagnostic.severity === "error"))).toBe(true);
});
it("matches requested skill reads by canonical path", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect((await catalog.matchReadPath(path.join(root, ".agents/skills/one/SKILL.md"), root))?.name).toBe("one"); expect(await catalog.matchReadPath("SKILL.md", root)).toBeUndefined();
});
it("expands only the exact Slash command on demand and guards reference paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-slash-")); const dir = path.join(root, ".agents/skills/swads-daily-report");
await createSkill(root, ".agents/skills/swads-daily-report", "swads-daily-report", "Daily report"); await mkdir(path.join(dir, "references")); await writeFile(path.join(dir, "references/schema.md"), "schema");
await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent")); await catalog.reload();
expect(JSON.stringify(catalog.list())).not.toContain("SECRET BODY");
await writeFile(path.join(dir, "SKILL.md"), "---\nname: swads-daily-report\ndescription: Daily report\n---\nLATEST BODY");
const command = await catalog.expandCommand("/swads-daily-report account=abc date=2026-09-06"); expect(command.text).toContain("LATEST BODY"); expect(command.text).not.toContain("description:"); expect(command.text).toContain("account=abc date=2026-09-06");
expect((await catalog.expandCommand("/swads-daily-report-other")).skill).toBeUndefined(); expect((await catalog.expandCommand("mention /swads-daily-report")).skill).toBeUndefined();
expect((await catalog.matchReadPath(path.join(dir, "references/schema.md"), root))?.name).toBe("swads-daily-report");
expect(await catalog.matchReadPath(path.join(dir, "references"), root)).toBeUndefined();
expect(await catalog.matchReadPath(`${dir}/references/../SKILL.md`, root)).toBeUndefined();
await writeFile(path.join(root, ".agents/skills/outside.md"), "outside"); expect(await catalog.matchReadPath(path.join(root, ".agents/skills/outside.md"), root)).toBeUndefined();
await symlink(path.join(dir, "references/schema.md"), path.join(dir, "references/link.md")); expect(await catalog.matchReadPath(path.join(dir, "references/link.md"), root)).toBeUndefined();
await symlink(path.join(root, ".agents/skills/outside.md"), path.join(dir, "references/escape.md")); expect(await catalog.matchReadPath(path.join(dir, "references/escape.md"), root)).toBeUndefined();
});
});
describe("Base URL safety", () => {
it("rejects metadata, userinfo, dangerous protocols, and private HTTPS", async () => {
await expect(validateBaseUrl("https://169.254.169.254/latest", false, false)).rejects.toMatchObject({ code: "SSRF_BLOCKED" }); await expect(validateBaseUrl("https://user:pass@example.com", false, false)).rejects.toMatchObject({ code: "INVALID_BASE_URL" }); await expect(validateBaseUrl("file:///etc/passwd", false, false)).rejects.toMatchObject({ code: "UNSAFE_BASE_URL" }); await expect(validateBaseUrl("https://127.0.0.1/v1", false, false)).rejects.toMatchObject({ code: "SSRF_BLOCKED" });
});
it("allows explicit loopback HTTP only in development", async () => {
await expect(validateBaseUrl("http://localhost:11434/v1", false, true)).resolves.toContain("localhost"); await expect(validateBaseUrl("http://localhost:11434/v1", false, false)).rejects.toMatchObject({ code: "UNSAFE_BASE_URL" });
});
it("rejects credential query parameters", async () => { await expect(validateBaseUrl("https://8.8.8.8/v1?api_key=x", false, false)).rejects.toMatchObject({ code: "CREDENTIAL_IN_URL" }); });
});
async function createSkill(root: string, relative: string, name: string, description: string) { const directory = path.join(root, relative); await mkdir(directory, { recursive: true }); await writeFile(path.join(directory, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n\nSECRET BODY\n`); }