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(); }); }); 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 }).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("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(); }); }); 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`); }