import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ServerResourceService } from "../src/server-resource-service.js"; import { readSkillFileSnapshot, writeSkillFile } from "../src/skill-files.js"; import { OppiExtensionSettingsStore } from "../src/storage/oppi-extension-settings-store.js"; interface Fixture { root: string; homeDir: string; agentDir: string; dataDir: string; } const fixtures: Fixture[] = []; const originalHome = process.env.HOME; const originalAgentDir = process.env.PI_CODING_AGENT_DIR; const originalOffline = process.env.PI_OFFLINE; function makeFixture(): Fixture { const root = mkdtempSync(join(tmpdir(), "oppi-server-resources-")); const fixture = { root, homeDir: join(root, "home"), agentDir: join(root, "home", ".pi", "agent"), dataDir: join(root, "oppi-data"), }; mkdirSync(fixture.agentDir, { recursive: true }); mkdirSync(fixture.dataDir, { recursive: true }); process.env.HOME = fixture.homeDir; process.env.PI_CODING_AGENT_DIR = fixture.agentDir; process.env.PI_OFFLINE = "1"; fixtures.push(fixture); return fixture; } function writeSkill(baseDir: string, name: string, description = `${name} description`): string { const skillDir = join(baseDir, name); mkdirSync(skillDir, { recursive: true }); writeFileSync( join(skillDir, "SKILL.md"), ["---", `name: ${name}`, `description: ${description}`, "---", `# ${name}`].join("\n"), ); return skillDir; } function makeService(fixture: Fixture): ServerResourceService { return new ServerResourceService({ dataDir: fixture.dataDir, agentDir: fixture.agentDir, oppiSettings: new OppiExtensionSettingsStore(fixture.dataDir), }); } afterEach(() => { if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = originalAgentDir; if (originalOffline === undefined) delete process.env.PI_OFFLINE; else process.env.PI_OFFLINE = originalOffline; for (const fixture of fixtures.splice(0)) { rmSync(fixture.root, { recursive: true, force: true }); } }); describe("contained Skill file race safety", () => { it("does not follow a target symlink substituted between resolution and open", () => { const fixture = makeFixture(); const skillDir = writeSkill(join(fixture.agentDir, "skills"), "read-race"); const notesPath = join(skillDir, "notes.md"); const outside = join(fixture.root, "outside-secret.md"); writeFileSync(notesPath, "safe\n"); writeFileSync(outside, "secret\n"); expect(() => readSkillFileSnapshot(skillDir, "notes.md", { beforeOpen: () => { rmSync(notesPath); symlinkSync(outside, notesPath); }, }), ).toThrow(/not found/i); expect(readFileSync(outside, "utf8")).toBe("secret\n"); }); it("propagates directory durability failures instead of reporting success", () => { const fixture = makeFixture(); const skillDir = writeSkill(join(fixture.agentDir, "skills"), "sync-failure"); const notesPath = join(skillDir, "notes.md"); writeFileSync(notesPath, "first\n"); const snapshot = readSkillFileSnapshot(skillDir, "notes.md"); expect(() => writeSkillFile(skillDir, "notes.md", "session edit\n", snapshot.revision, { beforeDirectorySync: () => { throw new Error("simulated directory fsync failure"); }, }), ).toThrow(/directory fsync failure/i); expect(readFileSync(notesPath, "utf8")).toBe("session edit\n"); }); it("rejects an intermediate parent symlink substituted before descriptor open", () => { const fixture = makeFixture(); const skillDir = writeSkill(join(fixture.agentDir, "skills"), "parent-race"); const refsDir = join(skillDir, "refs"); const outsideDir = join(fixture.root, "outside-refs"); mkdirSync(refsDir); mkdirSync(outsideDir); writeFileSync(join(refsDir, "note.md"), "safe\n"); writeFileSync(join(outsideDir, "note.md"), "secret\n"); expect(() => readSkillFileSnapshot(skillDir, "refs/note.md", { beforeOpen: () => { rmSync(refsDir, { recursive: true }); symlinkSync(outsideDir, refsDir); }, }), ).toThrow(/not found/i); }); it("detects an atomic local replacement immediately before its final replace check", () => { const fixture = makeFixture(); const skillDir = writeSkill(join(fixture.agentDir, "skills"), "atomic-race"); const notesPath = join(skillDir, "notes.md"); const localTempPath = join(skillDir, ".local-notes.tmp"); writeFileSync(notesPath, "first\n"); const snapshot = readSkillFileSnapshot(skillDir, "notes.md"); expect(() => writeSkillFile(skillDir, "notes.md", "session edit\n", snapshot.revision, { beforeAtomicReplace: () => { writeFileSync(localTempPath, "local atomic edit\n"); renameSync(localTempPath, notesPath); }, }), ).toThrow(/changed since it was read/i); expect(readFileSync(notesPath, "utf8")).toBe("local atomic edit\n"); }); it("detects an edit made after the replacement temp file is fsynced", () => { const fixture = makeFixture(); const skillDir = writeSkill(join(fixture.agentDir, "skills"), "write-race"); const notesPath = join(skillDir, "notes.md"); writeFileSync(notesPath, "first\n"); const snapshot = readSkillFileSnapshot(skillDir, "notes.md"); expect(() => writeSkillFile(skillDir, "notes.md", "session edit\n", snapshot.revision, { beforeRenameValidation: () => writeFileSync(notesPath, "local edit\n"), }), ).toThrow(/changed since it was read/i); expect(readFileSync(notesPath, "utf8")).toBe("local edit\n"); }); }); describe("ServerResourceService catalogs", () => { it("projects only user-scope Pi candidates, preserves disabled rows, and keeps Oppi first", async () => { const fixture = makeFixture(); writeSkill(join(fixture.agentDir, "skills"), "global-skill", "Global skill"); writeSkill( join(fixture.dataDir, "resource-catalog-cwd", ".pi", "skills"), "project-leak", "Must not leak", ); mkdirSync(join(fixture.agentDir, "extensions"), { recursive: true }); writeFileSync( join(fixture.agentDir, "extensions", "enabled.js"), "export default function (pi) { pi.registerCommand('enabled-command', { handler: async () => {} }); }\n", ); writeFileSync( join(fixture.agentDir, "extensions", "disabled.js"), `import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(join(fixture.root, "disabled-executed"))}, "bad");\nthrow new Error("disabled extension executed");\n`, ); writeFileSync( join(fixture.agentDir, "settings.json"), JSON.stringify({ skills: ["-skills/global-skill/SKILL.md"], extensions: ["-extensions/disabled.js"], }), ); const service = makeService(fixture); const [skillsResult, extensionsResult] = await Promise.all([ service.listSkills(), service.listExtensions(), ]); expect(skillsResult.skills.map((skill) => skill.name)).toContain("global-skill"); expect(skillsResult.skills.find((skill) => skill.name === "global-skill")?.state).toBe( "disabled", ); expect(skillsResult.skills.map((skill) => skill.name)).not.toContain("project-leak"); expect(skillsResult.skills[0]?.id).toMatch(/^skill_[a-f0-9]{64}$/); expect(extensionsResult.extensions[0]).toEqual( expect.objectContaining({ id: "oppi", name: "Oppi", kind: "builtIn", state: "off", isRemovable: false, }), ); expect(extensionsResult.extensions[0]).not.toHaveProperty("path"); expect(extensionsResult.oppiConfiguration).toEqual({ enabled: false, approvalPolicy: "confirmDestructiveOnly", revision: 0, }); expect( extensionsResult.extensions.find((extension) => extension.name === "disabled")?.state, ).toBe("off"); expect(existsSync(join(fixture.root, "disabled-executed"))).toBe(false); expect( extensionsResult.extensions.find((extension) => extension.name === "enabled") ?.contributedCommands, ).toEqual(["enabled-command"]); }); it("uses semantic Pi provenance and configured package source", async () => { const fixture = makeFixture(); const packageDir = join(fixture.root, "resource-package"); writeSkill(join(packageDir, "skills"), "package-skill", "Packaged skill"); mkdirSync(join(packageDir, "extensions"), { recursive: true }); writeFileSync( join(packageDir, "extensions", "package-extension.js"), "export default function (pi) { pi.registerCommand('package-command', { handler: async () => {} }); }\n", ); writeFileSync( join(packageDir, "package.json"), JSON.stringify({ name: "truthful-resource-package", version: "1.0.0", pi: { skills: ["skills/package-skill/SKILL.md"], extensions: ["extensions/package-extension.js"], }, }), ); writeFileSync( join(fixture.agentDir, "settings.json"), JSON.stringify({ packages: [packageDir] }), ); const service = makeService(fixture); const [skillsResult, extensionsResult] = await Promise.all([ service.listSkills(), service.listExtensions(), ]); const skill = skillsResult.skills.find((item) => item.name === "package-skill"); const extension = extensionsResult.extensions.find((item) => item.name === "package-extension"); expect(skill?.provenance).toEqual({ kind: "package", label: packageDir }); expect(extension?.provenance).toEqual({ kind: "package", label: packageDir }); expect(extension?.kind).toBe("package"); expect(extension?.contributedCommands).toEqual(["package-command"]); }); it("bounds skill warnings and extension load errors without executing disabled extensions", async () => { const fixture = makeFixture(); const malformedDir = join(fixture.agentDir, "skills", "malformed"); mkdirSync(malformedDir, { recursive: true }); writeFileSync( join(malformedDir, "SKILL.md"), "---\nname: malformed\n---\nmissing description\n", ); mkdirSync(join(fixture.agentDir, "extensions"), { recursive: true }); writeFileSync( join(fixture.agentDir, "extensions", "bad.js"), `throw new Error(${JSON.stringify(`bad\u0000${"x".repeat(10_000)}`)});\n`, ); const service = makeService(fixture); const [skillsResult, extensionsResult] = await Promise.all([ service.listSkills(), service.listExtensions(), ]); const malformed = skillsResult.skills.find((skill) => skill.name === "malformed"); const bad = extensionsResult.extensions.find((extension) => extension.name === "bad"); expect(malformed?.state).toBe("error"); expect(malformed?.loadError).toContain("description is required"); expect(bad?.state).toBe("error"); expect(bad?.loadError?.length).toBeLessThanOrEqual(2048); expect(bad?.loadError).not.toContain("\u0000"); expect(bad?.warnings.length).toBeLessThanOrEqual(8); }); it("skips missing configured packages and never installs them during listing", async () => { const fixture = makeFixture(); const missingPackage = join(fixture.root, "missing-package"); writeFileSync( join(fixture.agentDir, "settings.json"), JSON.stringify({ packages: [missingPackage] }), ); const service = makeService(fixture); await service.listExtensions(); await service.listSkills(); expect(existsSync(missingPackage)).toBe(false); }); it("surfaces malformed Oppi settings as a bounded built-in load error", async () => { const fixture = makeFixture(); mkdirSync(join(fixture.dataDir, "extensions"), { recursive: true }); writeFileSync(join(fixture.dataDir, "extensions", "oppi.json"), "{" + "x".repeat(10_000)); const result = await makeService(fixture).listExtensions(); const oppi = result.extensions[0]; expect(oppi?.state).toBe("error"); expect(oppi?.loadError?.length).toBeLessThanOrEqual(2048); expect(result.oppiConfiguration.enabled).toBe(false); }); }); describe("ServerResourceService mutations and skill details", () => { it("serializes fresh same-array mutations and returns authoritative summaries", async () => { const fixture = makeFixture(); writeSkill(join(fixture.agentDir, "skills"), "alpha"); writeSkill(join(fixture.agentDir, "skills"), "beta"); const service = makeService(fixture); const initial = await service.listSkills(); const alpha = initial.skills.find((skill) => skill.name === "alpha"); const beta = initial.skills.find((skill) => skill.name === "beta"); expect(alpha).toBeDefined(); expect(beta).toBeDefined(); const [updatedAlpha, updatedBeta] = await Promise.all([ service.setSkillEnabled(alpha!.id, false), service.setSkillEnabled(beta!.id, false), ]); expect(updatedAlpha.state).toBe("disabled"); expect(updatedBeta.state).toBe("disabled"); const settings = JSON.parse(readFileSync(join(fixture.agentDir, "settings.json"), "utf8")) as { skills?: string[]; }; expect(settings.skills?.filter((entry) => entry.startsWith("-")).sort()).toEqual([ "-skills/alpha/SKILL.md", "-skills/beta/SKILL.md", ]); }); it("preserves package filters while toggling one package resource", async () => { const fixture = makeFixture(); const packageDir = join(fixture.root, "filter-package"); const extensionDir = join(packageDir, "extensions"); mkdirSync(extensionDir, { recursive: true }); writeFileSync(join(extensionDir, "one.js"), "export default function () {}\n"); writeFileSync(join(extensionDir, "two.js"), "export default function () {}\n"); writeFileSync( join(packageDir, "package.json"), JSON.stringify({ name: "filter-package", version: "1.0.0", pi: { extensions: ["extensions/*.js"] }, }), ); writeFileSync( join(fixture.agentDir, "settings.json"), JSON.stringify({ packages: [{ source: packageDir, extensions: ["-extensions/two.js"], skills: [] }], }), ); const service = makeService(fixture); const initial = await service.listExtensions(); const one = initial.extensions.find((extension) => extension.name === "one"); const two = initial.extensions.find((extension) => extension.name === "two"); expect(one).toBeDefined(); expect(two?.state).toBe("off"); const updated = await service.setExtensionEnabled(one!.id, false); expect(updated.state).toBe("off"); const settings = JSON.parse(readFileSync(join(fixture.agentDir, "settings.json"), "utf8")) as { packages: Array<{ extensions?: string[]; skills?: string[] }>; }; expect(settings.packages[0]?.extensions).toEqual(["-extensions/two.js", "-extensions/one.js"]); expect(settings.packages[0]?.skills).toEqual([]); }); it("reads skill detail through a symlinked user skill directory", async () => { const fixture = makeFixture(); const externalSkills = join(fixture.root, "external-skills"); const skillDir = writeSkill(externalSkills, "linked-skill"); writeFileSync(join(skillDir, "reference.md"), "# Reference"); const agentSkills = join(fixture.agentDir, "skills"); mkdirSync(agentSkills, { recursive: true }); symlinkSync(skillDir, join(agentSkills, "linked-skill")); const service = makeService(fixture); const summary = (await service.listSkills()).skills.find( (skill) => skill.name === "linked-skill", ); expect(summary).toBeDefined(); const detail = await service.getSkillDetail(summary!.id); expect(detail.skillMarkdown).toContain("# linked-skill"); expect(detail.files).toEqual(["reference.md", "SKILL.md"]); expect(await service.readSkillFile(summary!.id, "reference.md")).toBe("# Reference"); }); it("provides bounded symlink-safe skill detail and file reads", async () => { const fixture = makeFixture(); const skillDir = writeSkill(join(fixture.agentDir, "skills"), "detail-skill"); writeFileSync(join(skillDir, "notes.txt"), "safe notes"); const secret = join(fixture.root, "secret.txt"); writeFileSync(secret, "outside secret"); symlinkSync(secret, join(skillDir, "secret-link.txt")); const service = makeService(fixture); const summary = (await service.listSkills()).skills.find( (skill) => skill.name === "detail-skill", ); expect(summary).toBeDefined(); const detail = await service.getSkillDetail(summary!.id); expect(detail.skillMarkdown).toContain("# detail-skill"); expect(detail.files).toContain("notes.txt"); expect(detail.files).not.toContain("secret-link.txt"); expect(await service.readSkillFile(summary!.id, "notes.txt")).toBe("safe notes"); await expect(service.readSkillFile(summary!.id, "secret-link.txt")).rejects.toThrow( /not found/i, ); await expect(service.readSkillFile(summary!.id, "../secret.txt")).rejects.toThrow(/not found/i); }); it("marks only top-level skills editable and safely replaces existing skill files", async () => { const fixture = makeFixture(); const localSkillDir = writeSkill(join(fixture.agentDir, "skills"), "editable-skill"); writeFileSync(join(localSkillDir, "notes.md"), "before\n"); const packageDir = join(fixture.root, "resource-package"); writeSkill(join(packageDir, "skills"), "package-skill"); writeFileSync( join(packageDir, "package.json"), JSON.stringify({ name: "read-only-skill-package", version: "1.0.0", pi: { skills: ["skills/package-skill/SKILL.md"] }, }), ); writeFileSync( join(fixture.agentDir, "settings.json"), JSON.stringify({ packages: [packageDir] }), ); const service = makeService(fixture); const catalog = await service.listSkills(); const local = catalog.skills.find((skill) => skill.name === "editable-skill"); const packaged = catalog.skills.find((skill) => skill.name === "package-skill"); expect(local?.editable).toBe(true); expect(packaged?.editable).toBe(false); const localSnapshot = await service.readSkillFileSnapshot(local!.id, "notes.md"); await expect( service.updateSkillFile(local!.id, "notes.md", "after\n", localSnapshot.revision), ).resolves.toEqual({ content: "after\n", revision: expect.stringMatching(/^[a-f0-9]{64}$/), }); expect(readFileSync(join(localSkillDir, "notes.md"), "utf8")).toBe("after\n"); await expect( service.updateSkillFile(local!.id, "../outside.md", "bad", localSnapshot.revision), ).rejects.toThrow(/not found/i); await expect( service.updateSkillFile(packaged!.id, "SKILL.md", "bad", localSnapshot.revision), ).rejects.toThrow(/read-only/i); }); it("refuses symlink targets and oversized or invalid UTF-8 skill file replacements", async () => { const fixture = makeFixture(); const skillDir = writeSkill(join(fixture.agentDir, "skills"), "safe-write"); const outside = join(fixture.root, "outside.md"); writeFileSync(outside, "outside\n"); symlinkSync(outside, join(skillDir, "linked.md")); const service = makeService(fixture); const summary = (await service.listSkills()).skills.find( (skill) => skill.name === "safe-write", ); expect(summary).toBeDefined(); const snapshot = await service.readSkillFileSnapshot(summary!.id, "SKILL.md"); await expect( service.updateSkillFile(summary!.id, "linked.md", "bad", snapshot.revision), ).rejects.toThrow(/not found/i); await expect( service.updateSkillFile( summary!.id, "SKILL.md", "x".repeat(1024 * 1024 + 1), snapshot.revision, ), ).rejects.toThrow(/too large/i); await expect( service.updateSkillFile(summary!.id, "SKILL.md", "\ud800", snapshot.revision), ).rejects.toThrow(/valid Unicode/i); expect(readFileSync(outside, "utf8")).toBe("outside\n"); }); it("keeps a package Skill read-only when a top-level symlink aliases the same canonical path", async () => { const fixture = makeFixture(); const packageDir = join(fixture.root, "aliased-package"); const packagedSkill = writeSkill(join(packageDir, "skills"), "package-skill"); writeFileSync( join(packageDir, "package.json"), JSON.stringify({ name: "aliased-package", version: "1.0.0", pi: { skills: ["skills/package-skill/SKILL.md"] }, }), ); writeFileSync( join(fixture.agentDir, "settings.json"), JSON.stringify({ packages: [packageDir] }), ); const agentSkills = join(fixture.agentDir, "skills"); mkdirSync(agentSkills, { recursive: true }); symlinkSync(packagedSkill, join(agentSkills, "package-alias")); const service = makeService(fixture); const matching = (await service.listSkills()).skills.filter( (skill) => skill.name === "package-skill", ); expect(matching).toHaveLength(1); expect(matching[0]).toMatchObject({ editable: false, provenance: { kind: "package" } }); const snapshot = await service.readSkillFileSnapshot(matching[0]!.id, "SKILL.md"); await expect( service.updateSkillFile(matching[0]!.id, "SKILL.md", "# Bypass\n", snapshot.revision), ).rejects.toThrow(/read-only/i); }); it("rejects a stale file revision instead of overwriting an intervening local edit", async () => { const fixture = makeFixture(); const skillDir = writeSkill(join(fixture.agentDir, "skills"), "conflict-skill"); const notesPath = join(skillDir, "notes.md"); writeFileSync(notesPath, "first\n"); const service = makeService(fixture); const summary = (await service.listSkills()).skills.find( (skill) => skill.name === "conflict-skill", ); const stale = await service.readSkillFileSnapshot(summary!.id, "notes.md"); writeFileSync(notesPath, "local edit\n"); await expect( service.updateSkillFile(summary!.id, "notes.md", "session edit\n", stale.revision), ).rejects.toThrow(/changed since it was read/i); expect(readFileSync(notesPath, "utf8")).toBe("local edit\n"); }); it("uses kind plus canonical path for opaque IDs", async () => { const fixture = makeFixture(); const sharedName = "same-name"; writeSkill(join(fixture.agentDir, "skills"), sharedName); mkdirSync(join(fixture.agentDir, "extensions"), { recursive: true }); writeFileSync( join(fixture.agentDir, "extensions", `${sharedName}.js`), "export default function () {}\n", ); const service = makeService(fixture); const skill = (await service.listSkills()).skills.find((item) => item.name === sharedName); const extension = (await service.listExtensions()).extensions.find( (item) => item.name === sharedName, ); expect(skill?.id).toMatch(/^skill_[a-f0-9]{64}$/); expect(extension?.id).toMatch(/^extension_[a-f0-9]{64}$/); expect(skill?.id.slice("skill_".length)).not.toBe(extension?.id.slice("extension_".length)); expect(basename(skill?.path ?? "")).toMatch(/same-name|SKILL\.md/); }); });