mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Add native read, update, history, and restore operations with server-bound identity and revision compare-and-swap safety. Register the default catalog and API discovery paths; cover real authority authorization and exact revision restore. Co-Authored-By: Paperclip <noreply@paperclip.ing>
92 lines
7.3 KiB
TypeScript
92 lines
7.3 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import { agents, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, issues, agentInstructionRevisions, createDb } from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
|
import { resolveManagedInstructionsRoot } from "../agent-instructions.js";
|
|
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
|
|
|
describe("canonical instruction tools through native authority", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
let home: string;
|
|
const previousHome = process.env.PAPERCLIP_HOME;
|
|
let companyId: string, agentId: string, targetAgentId: string, userId: string, runId: string, issueId: string, root: string;
|
|
let authority: PaperclipRunnerToolAuthority;
|
|
const entryFile = "policy/ENTRY.md";
|
|
const original = "\uFEFF# Original\r\n\0☃\n";
|
|
beforeAll(async () => {
|
|
home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-tool-home-")));
|
|
process.env.PAPERCLIP_HOME = home;
|
|
database = await startEmbeddedPostgresTestDatabase("instruction-tool-db-");
|
|
db = createDb(database.connectionString);
|
|
}, 90_000);
|
|
afterAll(async () => {
|
|
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
|
await database?.cleanup();
|
|
if (home) await fs.rm(home, { recursive: true, force: true });
|
|
});
|
|
beforeEach(async () => {
|
|
[companyId, agentId, targetAgentId, userId, runId, issueId] = Array.from({ length: 6 }, () => randomUUID());
|
|
await db.insert(companies).values({ id: companyId, name: "Tool scope", issuePrefix: randomUUID().slice(0, 8) });
|
|
await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
|
root = resolveManagedInstructionsRoot({ id: targetAgentId, companyId, name: "Target", adapterConfig: {} });
|
|
await db.insert(agents).values([
|
|
{ id: agentId, companyId, name: "Caller", status: "active" },
|
|
{ id: targetAgentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } },
|
|
]);
|
|
await db.insert(companyMemberships).values([
|
|
{ companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
|
|
{ companyId, principalType: "agent", principalId: agentId, membershipRole: "member" },
|
|
]);
|
|
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [targetAgentId] } });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Maintain instructions", status: "in_progress", workMode: "standard", assigneeAgentId: agentId });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "on_demand", responsibleUserId: userId });
|
|
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
|
authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
|
|
await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true });
|
|
await fs.writeFile(path.join(root, entryFile), original);
|
|
});
|
|
const call = (tool: string, args: Record<string, unknown> = {}) => authority.execute({ tool, callId: randomUUID(), arguments: { targetAgentId, ...args } }) as Promise<any>;
|
|
it("reads, commits, lists, inspects, and restores exact content with server provenance", async () => {
|
|
const first = await call("read_agent_instructions");
|
|
expect(first).toMatchObject({ entryFile, content: original });
|
|
const changed = await call("update_agent_instructions", { entryFile, content: "changed\r\n", baseRevisionId: first.revision.id });
|
|
expect(changed.revision).toMatchObject({ source: "tool", responsibleUserId: userId, actorAgentId: agentId, sourceRunId: runId });
|
|
const history = await call("get_agent_instruction_history", { entryFile, limit: 1 });
|
|
expect(history.revisions.map((revision: { id: string }) => revision.id)).toEqual([changed.revision.id]);
|
|
expect(history.nextCursor).toEqual(expect.any(String));
|
|
expect(await call("read_agent_instructions", { entryFile, revisionId: first.revision.id })).toMatchObject({ content: original });
|
|
const restored = await call("restore_agent_instructions", { entryFile, revisionId: first.revision.id, baseRevisionId: changed.revision.id });
|
|
expect(restored.revision).toMatchObject({ source: "restore", restoredFromRevisionId: first.revision.id });
|
|
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(original);
|
|
await expect(call("update_agent_instructions", { entryFile, content: "stale", baseRevisionId: changed.revision.id })).rejects.toMatchObject({ status: 409 });
|
|
expect((await call("read_agent_instructions")).content).toBe(original);
|
|
});
|
|
it("rejects supplied identity and missing CAS bases before changing content", async () => {
|
|
const first = await call("read_agent_instructions");
|
|
for (const identity of ["companyId", "agentId", "runId", "responsibleUserId", "onBehalfOfUserId", "actor"]) {
|
|
await expect(call("update_agent_instructions", { entryFile, content: "forged", baseRevisionId: first.revision.id, [identity]: randomUUID() })).rejects.toMatchObject({ status: 400 });
|
|
}
|
|
await expect(call("update_agent_instructions", { entryFile, content: "missing base" })).rejects.toMatchObject({ status: 400 });
|
|
expect((await call("read_agent_instructions")).content).toBe(original);
|
|
expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, targetAgentId))).toHaveLength(1);
|
|
});
|
|
it("rechecks responsible-user access and rejects a cross-company target", async () => {
|
|
const first = await call("read_agent_instructions");
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
|
|
await expect(call("update_agent_instructions", { entryFile, content: "revoked", baseRevisionId: first.revision.id })).rejects.toMatchObject({ status: 403 });
|
|
const foreignCompanyId = randomUUID(), foreignAgentId = randomUUID();
|
|
await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign", issuePrefix: randomUUID().slice(0, 8) });
|
|
await db.insert(agents).values({ id: foreignAgentId, companyId: foreignCompanyId, name: "Foreign" });
|
|
await expect(call("read_agent_instructions", { targetAgentId: foreignAgentId })).rejects.toMatchObject({ status: 404 });
|
|
await expect(call("update_agent_instructions", { targetAgentId: foreignAgentId, entryFile, content: "foreign", baseRevisionId: null })).rejects.toMatchObject({ status: 404 });
|
|
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId));
|
|
await expect(call("read_agent_instructions")).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
|
|
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(original);
|
|
});
|
|
});
|