diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 137b7fe1810..966595b7536 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -185,6 +185,13 @@ queries after any older request. Follow-up reads belong to the current mounted scope and stop when it changes or unmounts. The first open and reconnect still reconcile missed events. +Bundled skill release checks read the current stored version ID and a SHA-256 +fingerprint of its file inventory. A per-database memo keeps at most 64 verified +snapshots. It reuses a verification only when the current database fingerprint +and current on-disk inventory hash both match. A changed or missing snapshot +still follows full validation or seeding. Company access and local source +reconciliation still run for each request; authorization is not cached. + Connection-assignment checks select profile bindings for the current company or agent and read rules that can match the selected connection's catalog rows. All five selector kinds, null application matching, exclusions, and profile diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index 7217eb4f2be..c1c9a448232 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -16,6 +16,7 @@ import { projectWorkspaces, } from "@paperclipai/db"; import { parseFrontmatterMarkdown } from "@paperclipai/shared"; +import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -722,6 +723,73 @@ describeEmbeddedPostgres("companySkillService.list", () => { expect(refreshed.filter((skill) => skill.key === key)).toHaveLength(1); }); + it("validates warm bundled releases without returning their stored file bodies", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Release read checks", issuePrefix: "RRC" }); + await svc.list(companyId); + const expected = await svc.list(companyId); + const before = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId)) + .orderBy(companySkillVersions.id); + expect(before.length).toBeGreaterThan(0); + const events: Array> = [], now = Date.now(); + const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event)); + installDatabaseResponseCapture(db, { companySkillVersions }, capture); + try { expect(await companySkillService(db).list(companyId)).toEqual(expected); } + finally { capture.flush(); } + const reads = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1" + && event.table === "company_skill_versions" && event.operation === "select"); + expect(reads.reduce((sum, event) => sum + Number(event.calls), 0)).toBeGreaterThan(0); + expect(reads.reduce((sum, event) => sum + Number(event.responseValueBytes), 0)).toBeLessThan(4096); + expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId)) + .orderBy(companySkillVersions.id)).toEqual(before); + }); + + it.each(["agent", "board"] as const)("checks %s company access before using a warm release validation", async type => { + const companyId = randomUUID(), otherCompanyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Release access checks", issuePrefix: "RAC" }); + await svc.list(companyId); + await svc.list(companyId); + const actor = type === "agent" + ? { type, companyId: otherCompanyId, agentId: randomUUID() } + : { type, companyIds: [otherCompanyId] }; + const events: Array> = [], now = Date.now(); + const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event)); + installDatabaseResponseCapture(db, { companySkillVersions }, capture); + try { expect(await companySkillService(db, { actor }).list(companyId)).toEqual([]); } + finally { capture.flush(); } + expect(events.find(event => event.event === "paperclip.db_response_capture.summary.v1")?.completedQueries).toBe(0); + }); + + it("restores a deleted release after warm validation", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Release deletion checks", issuePrefix: "RDC" }); + await svc.list(companyId); + await svc.list(companyId); + const before = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId)); + const original = before[0]!; + await db.delete(companySkillVersions).where(eq(companySkillVersions.id, original.id)); + await companySkillService(db).list(companyId); + const after = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId)); + expect(after).toHaveLength(before.length); + expect(after.find(version => version.releaseId === original.releaseId)).toMatchObject({ + companyId, companySkillId: original.companySkillId, fileInventory: original.fileInventory, + }); + expect(after.some(version => version.id === original.id)).toBe(false); + }); + + it("rejects a changed bundled snapshot after warm validation without overwriting it", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Release integrity checks", issuePrefix: "RIC" }); + await svc.list(companyId); + await svc.list(companyId); + const [version] = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId)); + const changed = [{ path: "SKILL.md", kind: "skill" as const, content: "Changed stored snapshot" }]; + await db.update(companySkillVersions).set({ fileInventory: changed }).where(eq(companySkillVersions.id, version!.id)); + await expect(companySkillService(db).list(companyId)).rejects.toThrow("does not match its seeded snapshot"); + expect((await db.select().from(companySkillVersions).where(eq(companySkillVersions.id, version!.id)))[0]!.fileInventory) + .toEqual(changed); + }); + it("seeds bundled skill releases idempotently and materializes the frozen champion snapshot", async () => { const companyId = randomUUID(); await db.insert(companies).values({ diff --git a/server/src/services/bundled-skill-release-validation.test.ts b/server/src/services/bundled-skill-release-validation.test.ts new file mode 100644 index 00000000000..4c4c1c42857 --- /dev/null +++ b/server/src/services/bundled-skill-release-validation.test.ts @@ -0,0 +1,103 @@ +import { createHash, randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { companies, companySkills, companySkillVersions, createDb } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js"; +import { verifyExistingBundledSkillRelease } from "./bundled-skill-release-validation.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const hashInventory = (files: Array<{ path: string; content: string }>) => createHash("sha256") + .update(JSON.stringify(files.map(file => [file.path, file.content]))).digest("hex"); + +(support.supported ? describe : describe.skip)("bundled release validation with current database fingerprints", () => { + let db: ReturnType; + let temporary: Awaited>; + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("bundled-release-validation-"); + db = createDb(temporary.connectionString); + }, 30_000); + afterAll(async () => { await db?.$client.end(); await temporary?.cleanup(); }); + + async function seed(content = "saved release content") { + const companyId = randomUUID(), skillId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Release validation", issuePrefix: companyId.slice(0, 8) }); + await db.insert(companySkills).values({ id: skillId, companyId, key: "fixture/release", slug: "release", + name: "Release", markdown: "# Release", sourceType: "local_path" }); + const files = [{ path: "SKILL.md", kind: "skill" as const, content }]; + const [version] = await db.insert(companySkillVersions).values({ companyId, companySkillId: skillId, + revisionNumber: 1, releaseId: "release-1", fileInventory: files }).returning(); + const expectedInventoryHash = hashInventory(files); + const validateInventory = vi.fn((row: typeof companySkillVersions.$inferSelect) => { + if (hashInventory(row.fileInventory) !== expectedInventoryHash) throw new Error("snapshot mismatch"); + }); + return { version: version!, input: { db, companyId, skillId, releaseId: "release-1", expectedInventoryHash, validateInventory } }; + } + + it("reuses proof only within current company, skill and release bindings", async () => { + const f = await seed(); + expect(await verifyExistingBundledSkillRelease(f.input)).toBe(true); + expect(await verifyExistingBundledSkillRelease(f.input)).toBe(true); + expect(f.input.validateInventory).toHaveBeenCalledTimes(1); + expect(await verifyExistingBundledSkillRelease({ ...f.input, companyId: randomUUID() })).toBe(false); + expect(await verifyExistingBundledSkillRelease({ ...f.input, skillId: randomUUID() })).toBe(false); + expect(await verifyExistingBundledSkillRelease({ ...f.input, releaseId: "other-release" })).toBe(false); + expect(f.input.validateInventory).toHaveBeenCalledTimes(1); + expect(await verifyExistingBundledSkillRelease(f.input)).toBe(true); + }); + + it("revalidates when the expected on-disk inventory changes without poisoning valid proof", async () => { + const f = await seed(); + await verifyExistingBundledSkillRelease(f.input); + const changedHash = hashInventory([{ path: "SKILL.md", content: "new on-disk content" }]); + const validateChanged = vi.fn((row: typeof companySkillVersions.$inferSelect) => { + if (hashInventory(row.fileInventory) !== changedHash) throw new Error("snapshot mismatch"); + }); + await expect(verifyExistingBundledSkillRelease({ ...f.input, expectedInventoryHash: changedHash, + validateInventory: validateChanged })).rejects.toThrow("snapshot mismatch"); + expect(validateChanged).toHaveBeenCalledOnce(); + expect(await verifyExistingBundledSkillRelease(f.input)).toBe(true); + expect(f.input.validateInventory).toHaveBeenCalledOnce(); + }); + + it("rechecks current content after a change overlaps an earlier validation", async () => { + const f = await seed(); + let entered!: () => void, release!: () => void; + const validating = new Promise(resolve => { entered = resolve; }); + const held = new Promise(resolve => { release = resolve; }); + const first = verifyExistingBundledSkillRelease({ ...f.input, validateInventory: async row => { + f.input.validateInventory(row); + entered(); + await held; + } }); + await validating; + try { + await db.update(companySkillVersions).set({ fileInventory: [{ path: "SKILL.md", kind: "skill", content: "changed" }] }) + .where(and(eq(companySkillVersions.companyId, f.input.companyId), eq(companySkillVersions.id, f.version.id))); + } finally { release(); } + expect(await first).toBe(true); + await expect(verifyExistingBundledSkillRelease(f.input)).rejects.toThrow("snapshot mismatch"); + expect(f.input.validateInventory).toHaveBeenCalledTimes(2); + }); + + it("does not accept a deleted row from earlier proof", async () => { + const f = await seed(); + await verifyExistingBundledSkillRelease(f.input); + await db.delete(companySkillVersions).where(eq(companySkillVersions.id, f.version.id)); + expect(await verifyExistingBundledSkillRelease(f.input)).toBe(false); + expect(f.input.validateInventory).toHaveBeenCalledOnce(); + }); + + it("bounds retained proof and safely revalidates an evicted release", async () => { + const f = await seed(); + await verifyExistingBundledSkillRelease(f.input); + for (let revisionNumber = 2; revisionNumber <= 66; revisionNumber++) { + const releaseId = `release-${revisionNumber}`; + await db.insert(companySkillVersions).values({ companyId: f.input.companyId, companySkillId: f.input.skillId, + revisionNumber, releaseId, fileInventory: f.version.fileInventory }); + expect(await verifyExistingBundledSkillRelease({ ...f.input, releaseId })).toBe(true); + } + const before = f.input.validateInventory.mock.calls.length; + expect(await verifyExistingBundledSkillRelease(f.input)).toBe(true); + expect(f.input.validateInventory).toHaveBeenCalledTimes(before + 1); + }); +}); diff --git a/server/src/services/bundled-skill-release-validation.ts b/server/src/services/bundled-skill-release-validation.ts new file mode 100644 index 00000000000..b29f8ee28a6 --- /dev/null +++ b/server/src/services/bundled-skill-release-validation.ts @@ -0,0 +1,45 @@ +import { companySkillVersions, type Db } from "@paperclipai/db"; +import { and, eq, getTableColumns, sql } from "drizzle-orm"; + +type Version = typeof companySkillVersions.$inferSelect; +type Verification = { inventorySha256: string; expectedInventoryHash: string }; +const verifiedInventories = new WeakMap>(); +const inventorySha256 = sql`encode(sha256(convert_to(${companySkillVersions.fileInventory}::text, 'UTF8')), 'hex')`; + +export async function verifyExistingBundledSkillRelease(input: { + db: Db; + companyId: string; + skillId: string; + releaseId: string; + expectedInventoryHash: string; + validateInventory: (version: Version) => void | Promise; +}): Promise { + const scope = and(eq(companySkillVersions.companyId, input.companyId), + eq(companySkillVersions.companySkillId, input.skillId), eq(companySkillVersions.releaseId, input.releaseId)); + const [current] = await input.db.select({ id: companySkillVersions.id, inventorySha256 }) + .from(companySkillVersions).where(scope).limit(1); + if (!current) return false; + let verified = verifiedInventories.get(input.db); + if (!verified) { + verified = new Map(); + verifiedInventories.set(input.db, verified); + } + const previous = verified.get(current.id); + if (previous?.inventorySha256 === current.inventorySha256 + && previous.expectedInventoryHash === input.expectedInventoryHash) { + verified.delete(current.id); + verified.set(current.id, previous); + return true; + } + const [version] = await input.db.select({ ...getTableColumns(companySkillVersions), inventorySha256 }) + .from(companySkillVersions).where(scope).limit(1); + if (!version) return false; + await input.validateInventory(version); + verified.delete(version.id); + verified.set(version.id, { inventorySha256: version.inventorySha256, expectedInventoryHash: input.expectedInventoryHash }); + while (verified.size > 64) { + const oldest = verified.keys().next().value; + if (oldest !== undefined) verified.delete(oldest); + } + return true; +} diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index ac114ee692f..f29f4ece222 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -1,4 +1,5 @@ import { ensureSkillCacheDirectory, touchSkillVersionCache, withSkillVersionCacheLock } from "./skill-version-cache.js"; +import { verifyExistingBundledSkillRelease } from "./bundled-skill-release-validation.js"; import { retainProjectSkillAccess, skillAccessService } from "./skill-access.js"; import type { AuthorizationActor } from "./authorization.js"; import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js"; @@ -3094,30 +3095,22 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo const fileInventory = serializeVersionFileInventory( await collectVersionFileInventoryFromDirectory(release.releaseDir), ); - const existing = await db - .select() - .from(companySkillVersions) - .where(and( - eq(companySkillVersions.companyId, companyId), - eq(companySkillVersions.companySkillId, paperclipSkill.id), - eq(companySkillVersions.releaseId, release.id), - )) - .then((rows) => rows[0] ?? null); - if (existing) { - const existingInventory = toCompanySkillVersion(existing).fileInventory; - const existingHash = buildInventoryContentHash(existingInventory.map((entry) => ({ - path: entry.path, - sha256: sha256Buffer(entry.content), - }))); - const registryHash = buildInventoryContentHash(fileInventory.map((entry) => ({ - path: entry.path, - sha256: sha256Buffer(entry.content), - }))); - if (existingHash !== registryHash) { - throw new Error(`Bundled skill release ${release.id} does not match its seeded snapshot.`); - } - continue; - } + const registryHash = buildInventoryContentHash(fileInventory.map((entry) => ({ + path: entry.path, + sha256: sha256Buffer(entry.content), + }))); + if (await verifyExistingBundledSkillRelease({ db, companyId, skillId: paperclipSkill.id, releaseId: release.id, + expectedInventoryHash: registryHash, validateInventory: existing => { + const existingInventory = toCompanySkillVersion(existing).fileInventory; + const existingHash = buildInventoryContentHash(existingInventory.map((entry) => ({ + path: entry.path, + sha256: sha256Buffer(entry.content), + }))); + if (existingHash !== registryHash) { + throw new Error(`Bundled skill release ${release.id} does not match its seeded snapshot.`); + } + }, + })) continue; const releasedAt = new Date(release.releasedAt); if (Number.isNaN(releasedAt.getTime())) { throw new Error(`Invalid bundled skill release date: ${release.releasedAt}`);