mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +02:00
Add capability-aware slash-goal controls, durable provider goal state, PRP v2 negotiation, autonomous goal execution, and safe local session recovery. Integrate with current master, preserve provider session identity, and verify the browser goal/chat/replacement/clear workflow and unsupported-agent rejection. Co-Authored-By: Paperclip <noreply@paperclip.ing>
179 lines
10 KiB
TypeScript
179 lines
10 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { spawn } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { CompanySkill } from "@paperclipai/shared";
|
|
import { removeRuntimeSkillCache, resolveRuntimeSkillCache, runtimeSkillCacheSpec } from "../services/runtime-skill-cache.js";
|
|
|
|
async function makeWritable(root: string): Promise<void> {
|
|
const stat = await fs.lstat(root);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
await fs.chmod(root, 0o700);
|
|
for (const item of await fs.readdir(root)) await makeWritable(path.join(root, item));
|
|
}
|
|
|
|
async function makeReadonly(root: string): Promise<void> {
|
|
const stat = await fs.lstat(root);
|
|
if (stat.isSymbolicLink()) return;
|
|
if (stat.isDirectory()) {
|
|
for (const item of await fs.readdir(root)) await makeReadonly(path.join(root, item));
|
|
}
|
|
await fs.chmod(root, stat.isDirectory() ? 0o555 : 0o444);
|
|
}
|
|
|
|
describe("runtime skill revision cache", () => {
|
|
let root: string;
|
|
let skill: CompanySkill;
|
|
const contents: Record<string, string> = { "SKILL.md": "# Test", "references/a.md": "Supporting content" };
|
|
beforeEach(async () => {
|
|
root = await fs.mkdtemp(path.join(os.tmpdir(), "runtime-cache-"));
|
|
skill = { id: randomUUID(), companyId: randomUUID(), sourceType: "github", sourceLocator: "https://github.com/org/repo",
|
|
sourceRef: "a".repeat(40), slug: "test", markdown: contents["SKILL.md"], metadata: { owner: "org", repo: "repo", repoSkillDir: "skills/test" },
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }, { path: "references/a.md", kind: "reference" }],
|
|
} as CompanySkill;
|
|
});
|
|
afterEach(async () => { await makeWritable(root); await fs.rm(root, { recursive: true, force: true }); });
|
|
const reader = () => vi.fn(async (file: string) => contents[file]);
|
|
|
|
it("publishes complete contents once for twenty callers and leaves warm files untouched", async () => {
|
|
const spec = runtimeSkillCacheSpec(root, skill)!;
|
|
const read = reader();
|
|
const sources = await Promise.all(Array.from({ length: 20 }, () => resolveRuntimeSkillCache(spec, read)));
|
|
expect(new Set(sources).size).toBe(1);
|
|
expect(read).toHaveBeenCalledTimes(2);
|
|
expect((await fs.stat(sources[0]!)).mode & 0o222).toBe(0);
|
|
expect((await fs.stat(path.join(sources[0]!, "SKILL.md"))).mode & 0o222).toBe(0);
|
|
const before = await fs.stat(path.join(sources[0]!, "SKILL.md"));
|
|
read.mockRejectedValue(new Error("Upstream offline"));
|
|
expect(await resolveRuntimeSkillCache(runtimeSkillCacheSpec(root, { ...skill })!, read)).toBe(sources[0]);
|
|
expect(await resolveRuntimeSkillCache(spec, read, false)).toBe(sources[0]);
|
|
expect((await fs.stat(path.join(sources[0]!, "SKILL.md"))).mtimeMs).toBe(before.mtimeMs);
|
|
expect(await fs.readdir(sources[0]!)).toEqual(["SKILL.md", "references"]);
|
|
expect(read).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("reuses the winning complete directory across concurrent processes and a process restart", async () => {
|
|
const spec = runtimeSkillCacheSpec(root, skill)!;
|
|
const launch = () => new Promise<{ source: string; reads: number }>((resolve, reject) => {
|
|
const code = `
|
|
import { resolveRuntimeSkillCache } from ${JSON.stringify(new URL("../services/runtime-skill-cache.ts", import.meta.url).href)};
|
|
let reads = 0;
|
|
const source = await resolveRuntimeSkillCache(${JSON.stringify(spec)}, async (file) => {
|
|
reads++;
|
|
await new Promise(resolve => setTimeout(resolve, 30));
|
|
return ${JSON.stringify(contents)}[file];
|
|
});
|
|
console.log(JSON.stringify({ source, reads }));
|
|
`;
|
|
const child = spawn(process.execPath, ["--import", fileURLToPath(new URL("../../node_modules/tsx/dist/loader.mjs", import.meta.url)), "--input-type=module", "-e", code], { stdio: ["ignore", "pipe", "pipe"] });
|
|
let output = "", errors = "";
|
|
child.stdout.on("data", (chunk) => { output += chunk; });
|
|
child.stderr.on("data", (chunk) => { errors += chunk; });
|
|
child.on("error", reject);
|
|
child.on("exit", (exitCode) => exitCode === 0 ? resolve(JSON.parse(output)) : reject(new Error(errors)));
|
|
});
|
|
const [first, second] = await Promise.all([launch(), launch()]);
|
|
expect(first.source).toBe(second.source);
|
|
expect(await fs.readdir(spec.root)).toEqual([spec.fingerprint]);
|
|
expect(await launch()).toEqual({ source: first.source, reads: 0 });
|
|
});
|
|
|
|
it("fingerprints installed content and ownership, excluding cosmetic metadata", async () => {
|
|
const spec = runtimeSkillCacheSpec(root, skill)!;
|
|
expect(runtimeSkillCacheSpec(root, { ...skill, name: "Renamed", updatedAt: new Date(), metadata: { ...skill.metadata, starred: true, ref: "new-branch" } })!.fingerprint).toBe(spec.fingerprint);
|
|
expect(runtimeSkillCacheSpec(root, { ...skill, fileInventory: [...skill.fileInventory].reverse() })!.fingerprint).toBe(spec.fingerprint);
|
|
for (const update of [{ companyId: randomUUID() }, { sourceRef: "b".repeat(40) }, { markdown: "changed" },
|
|
{ metadata: { ...skill.metadata, repoSkillDir: "other" } }]) {
|
|
expect(runtimeSkillCacheSpec(root, { ...skill, ...update })!.fingerprint).not.toBe(spec.fingerprint);
|
|
}
|
|
expect(runtimeSkillCacheSpec(root, { ...skill, sourceRef: "main" })).toBeNull();
|
|
});
|
|
|
|
it("retains the old revision when publishing an explicit update", async () => {
|
|
const old = await resolveRuntimeSkillCache(runtimeSkillCacheSpec(root, skill)!, reader());
|
|
const next = await resolveRuntimeSkillCache(runtimeSkillCacheSpec(root, { ...skill, sourceRef: "b".repeat(40) })!, async () => "updated");
|
|
expect(next).not.toBe(old);
|
|
expect(await fs.readFile(path.join(old!, "references/a.md"), "utf8")).toBe(contents["references/a.md"]);
|
|
expect(await fs.readFile(path.join(next!, "references/a.md"), "utf8")).toBe("updated");
|
|
});
|
|
|
|
it("never fingerprints mutable local sources as immutable cached revisions", () => {
|
|
expect(runtimeSkillCacheSpec(root, { ...skill, sourceType: "local_path", sourceRef: null })).toBeNull();
|
|
});
|
|
|
|
it.each(["manifest-missing", "manifest-malformed", "changed", "deleted", "extra", "symlink"])("rejects %s without read-only repair, then rebuilds", async (corruption) => {
|
|
const spec = runtimeSkillCacheSpec(root, skill)!;
|
|
const source = (await resolveRuntimeSkillCache(spec, reader()))!;
|
|
await makeWritable(spec.entry);
|
|
await fs.chmod(path.join(source, "SKILL.md"), 0o600);
|
|
await fs.chmod(path.join(spec.entry, "manifest.json"), 0o600);
|
|
const manifest = path.join(spec.entry, "manifest.json");
|
|
if (corruption === "manifest-missing") await fs.unlink(manifest);
|
|
if (corruption === "manifest-malformed") await fs.writeFile(manifest, "{bad");
|
|
if (corruption === "changed") await fs.writeFile(path.join(source, "SKILL.md"), "tampered");
|
|
if (corruption === "deleted") await fs.unlink(path.join(source, "SKILL.md"));
|
|
if (corruption === "extra") await fs.writeFile(path.join(source, "extra.txt"), "extra");
|
|
if (corruption === "symlink") {
|
|
await fs.unlink(path.join(source, "SKILL.md"));
|
|
await fs.symlink(path.join(root, "outside.txt"), path.join(source, "SKILL.md"));
|
|
await fs.writeFile(path.join(root, "outside.txt"), "outside");
|
|
}
|
|
await makeReadonly(spec.entry);
|
|
const read = reader();
|
|
expect(await resolveRuntimeSkillCache(spec, read, false)).toBeNull();
|
|
expect(read).not.toHaveBeenCalled();
|
|
expect(await resolveRuntimeSkillCache(spec, read)).toBe(source);
|
|
expect(read).toHaveBeenCalledTimes(2);
|
|
expect(await fs.readFile(path.join(source, "SKILL.md"), "utf8")).toBe(contents["SKILL.md"]);
|
|
if (corruption === "symlink") expect(await fs.readFile(path.join(root, "outside.txt"), "utf8")).toBe("outside");
|
|
});
|
|
|
|
it("does not publish partial builds and retries after a failed upstream read", async () => {
|
|
const spec = runtimeSkillCacheSpec(root, skill)!;
|
|
const read = reader().mockRejectedValueOnce(new Error("offline"));
|
|
await expect(resolveRuntimeSkillCache(spec, read)).rejects.toThrow("offline");
|
|
expect(await fs.readdir(spec.root)).toEqual([]);
|
|
expect(await resolveRuntimeSkillCache(spec, read, false)).toBeNull();
|
|
expect(await resolveRuntimeSkillCache(spec, read)).toBe(path.join(spec.entry, "files"));
|
|
});
|
|
|
|
it("coordinates cleanup with an active build and prevents a removed skill from being republished", async () => {
|
|
const spec = runtimeSkillCacheSpec(root, skill)!;
|
|
let installed = true;
|
|
let release!: () => void;
|
|
let entered!: () => void;
|
|
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
const started = new Promise<void>((resolve) => { entered = resolve; });
|
|
const build = resolveRuntimeSkillCache(spec, async (file) => {
|
|
entered(); await gate; return contents[file];
|
|
}, true, async () => installed);
|
|
const rejected = expect(build).rejects.toThrow("renamed or removed");
|
|
await started;
|
|
installed = false;
|
|
const removal = removeRuntimeSkillCache(root, skill.id);
|
|
release();
|
|
await rejected;
|
|
await removal;
|
|
await expect(fs.stat(spec.root)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(resolveRuntimeSkillCache(spec, reader(), true, async () => installed)).rejects.toThrow("renamed or removed");
|
|
await expect(fs.stat(spec.root)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it.each(["../escape", "/absolute", "a/../../escape", "a/../b", "C:\\escape", "a\\..\\escape"])("rejects traversal %s", (file) => {
|
|
expect(() => runtimeSkillCacheSpec(root, { ...skill, fileInventory: [...skill.fileInventory, { path: file, kind: "reference" }] })).toThrow("Invalid runtime skill file path");
|
|
});
|
|
|
|
it("rejects a symlink cache ancestor before invoking the source reader", async () => {
|
|
const spec = runtimeSkillCacheSpec(root, skill)!;
|
|
const outside = await fs.mkdtemp(path.join(root, "outside-"));
|
|
await fs.symlink(outside, path.dirname(spec.root));
|
|
const read = reader();
|
|
await expect(resolveRuntimeSkillCache(spec, read)).rejects.toThrow("Unsafe");
|
|
expect(read).not.toHaveBeenCalled();
|
|
expect(await fs.readdir(outside)).toEqual([]);
|
|
});
|
|
});
|