fix(server): contain retired skill releases and reject bearer headers in skill content

Mark the two frozen skill-release snapshots retired in the release
manifest, and enforce that flag at every point that can hand one to an
agent: reject a new pin on a retired release, fail closed instead of
materializing a retired snapshot at runtime, migrate existing agent
pins off retired releases, and disable a retired release in the agent
selector while keeping it visible for history.

Add a server-side content guard that rejects any company-skill file
carrying a bearer Authorization header, applied to both the single-file
write path and a new restore endpoint that validates a version's whole
file set before writing any of it back.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
nickyleachandPaperclip committed 2026-09-16 11:43:54 +00:00
1 parent 3ae75a1d0e
commit 888e64cd73
21 files changed
+686 -80

No files matched your search

@@ -162,6 +162,12 @@ export interface CompanySkillVersion {
releaseId: string | null;
releaseName: string | null;
releasedAt: Date | null;
/**
* True when this version's release is retired: it teaches an unsafe credential
* form. A retired version stays viewable in history but cannot be selected for
* an agent or materialized at runtime.
*/
retired: boolean;
fileInventory: CompanySkillVersionFileInventoryEntry[];
authorAgentId: string | null;
authorUserId: string | null;
@@ -489,7 +489,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(refreshedSkill?.updatedAt.toISOString()).toBe(preservedUpdatedAt.toISOString());
});
it("seeds bundled skill releases idempotently and materializes the frozen champion snapshot", async () => {
it("seeds bundled skill releases idempotently, marks them retired, and refuses to materialize the frozen champion snapshot", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
@@ -507,6 +507,9 @@ describeEmbeddedPostgres("companySkillService.list", () => {
const versions = await svc.listVersions(companyId, paperclipSkill.id);
expect(versions.map((version) => version.releaseId).sort()).toEqual(["v0", "v7-roster"]);
expect(versions).toHaveLength(2);
// Both seeded snapshots teach the unsafe credential form on purpose, so
// the manifest marks them retired and the service must surface that flag.
expect(versions.every((version) => version.retired)).toBe(true);
const storedSkill = await db
.select({ currentVersionId: companySkills.currentVersionId })
.from(companySkills)
@@ -518,6 +521,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(champion).toMatchObject({
releaseName: "V7 — Roster champion",
releasedAt: new Date("2026-07-21T00:00:00.000Z"),
retired: true,
});
if (!champion) throw new Error("Expected seeded v7-roster release");
const championHashes = Object.fromEntries(champion.fileInventory.map((entry) => [
@@ -531,29 +535,17 @@ describeEmbeddedPostgres("companySkillService.list", () => {
});
expect(championHashes).not.toHaveProperty("EDITS.md");
// A runtime pin at a retired release must fail closed: no frozen file
// reaches the runtime skills directory an agent reads.
const runtimeEntries = await svc.listRuntimeSkillEntries(companyId, {
versionSelections: new Map([[paperclipSkill.key, champion.id]]),
});
const materialized = runtimeEntries.find((entry) => entry.key === paperclipSkill.key);
expect(materialized).toMatchObject({ versionId: champion.id, sourceStatus: "available" });
if (!materialized) throw new Error("Expected materialized release entry");
const materializedHashes: Record<string, string> = {};
async function walk(root: string, current = root): Promise<void> {
for (const entry of await fs.readdir(current, { withFileTypes: true })) {
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
await walk(root, absolutePath);
continue;
}
const relativePath = path.relative(root, absolutePath).split(path.sep).join("/");
materializedHashes[relativePath] = createHash("sha256")
.update(await fs.readFile(absolutePath))
.digest("hex");
}
}
await walk(materialized.source);
expect(materializedHashes).toEqual(championHashes);
expect(materializedHashes).not.toHaveProperty("EDITS.md");
expect(materialized).toMatchObject({ versionId: champion.id, sourceStatus: "missing" });
expect(materialized?.missingDetail).toMatch(/retired/i);
if (!materialized) throw new Error("Expected a missing release entry");
const materializedExists = await fs.stat(materialized.source).then(() => true).catch(() => false);
expect(materializedExists).toBe(false);
});
it("repairs a squatted bundled root during bundled-skill list refresh", async () => {
@@ -1453,6 +1445,39 @@ describeEmbeddedPostgres("companySkillService.list", () => {
])).rejects.toMatchObject({ status: 422 });
});
it("rejects a new pin on a retired bundled skill release, and still allows pinning a release that is not retired", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const list = await svc.list(companyId);
const paperclipSkill = list.find((skill) => skill.key === "paperclipai/paperclip/paperclip");
if (!paperclipSkill) throw new Error("Expected bundled Paperclip skill");
const versions = await svc.listVersions(companyId, paperclipSkill.id);
const retiredVersion = versions.find((version) => version.releaseId === "v0");
if (!retiredVersion) throw new Error("Expected seeded v0 release");
await expect(svc.resolveRequestedSkillEntries(companyId, [
{ key: paperclipSkill.key, versionId: retiredVersion.id },
])).rejects.toMatchObject({
status: 422,
message: expect.stringMatching(/unsafe credential/i),
});
// A version that carries no release id (the ordinary, un-retired case)
// still pins cleanly through the same code path.
const localSkill = await svc.createLocalSkill(companyId, { name: "Plain skill", slug: "plain-skill" });
await expect(svc.resolveRequestedSkillEntries(companyId, [
{ key: localSkill.key, versionId: localSkill.currentVersionId },
])).resolves.toEqual({
resolved: [{ key: localSkill.key, versionId: localSkill.currentVersionId }],
unresolved: [],
});
});
it("rejects unknown desired keys by default but preserves them when tolerating (PAP-13222)", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
@@ -2339,6 +2364,93 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(versions).toHaveLength(2);
});
it("rejects a single-file write that carries a bearer Authorization header, and never writes it to disk", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const actor = { type: "user" as const, userId: "board" };
const skill = await svc.createLocalSkill(companyId, { name: "Guarded skill", slug: "guarded-skill" }, actor);
const before = await svc.readFile(companyId, skill.id, "SKILL.md");
await expect(svc.updateFile(
companyId,
skill.id,
"SKILL.md",
"---\nname: Guarded skill\n---\n\nRun: curl -H 'Authorization: Bearer eyJraWQi.eyJ.abc' https://example.com\n",
actor,
)).rejects.toMatchObject({
status: 422,
message: expect.stringMatching(/paperclip-api/i),
});
const after = await svc.readFile(companyId, skill.id, "SKILL.md");
expect(after?.content).toBe(before?.content);
const versions = await svc.listVersions(companyId, skill.id);
expect(versions).toHaveLength(1);
});
it("restores a version cleanly, and rejects a multi-file restore holding one forbidden file without writing any file", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const actor = { type: "user" as const, userId: "board" };
const skill = await svc.createLocalSkill(companyId, { name: "Restorable skill", slug: "restorable-skill" }, actor);
const version1 = (await svc.listVersions(companyId, skill.id))[0]!;
expect(version1.retired).toBe(false);
const editedMarkdown = "---\nname: Restorable skill\n---\n\n# Restorable skill\n\nEdited body.\n";
await svc.updateFile(companyId, skill.id, "SKILL.md", editedMarkdown, actor);
await expect(svc.readFile(companyId, skill.id, "notes.md")).rejects.toMatchObject({ status: 404 });
// A clean restore to version 1 pins and materializes like any other
// un-retired version: it writes the file back and cuts a new head version.
const restored = await svc.restoreVersion(companyId, skill.id, version1.id, actor);
expect(restored).toMatchObject({ label: `Restore of v${version1.revisionNumber}` });
await expect(svc.readFile(companyId, skill.id, "SKILL.md")).resolves.toMatchObject({
content: version1.fileInventory[0]!.content,
});
const runtimeEntries = await svc.listRuntimeSkillEntries(companyId, {
versionSelections: new Map([[skill.key, restored.id]]),
});
expect(runtimeEntries.find((entry) => entry.key === skill.key)).toMatchObject({ sourceStatus: "available" });
// Simulate a stored version that carries a forbidden file alongside a
// clean one (the shape the guard cannot have produced through the write
// path itself, but a restore must still validate the complete inventory
// before it writes anything).
const [forbiddenVersion] = await db
.insert(companySkillVersions)
.values({
companyId,
companySkillId: skill.id,
revisionNumber: 99,
label: "Forbidden snapshot",
fileInventory: [
{ path: "SKILL.md", kind: "skill", content: "---\nname: Restorable skill\n---\n\nClean.\n" },
{ path: "notes.md", kind: "other", content: "authorization: bearer abc.def.ghi\n" },
],
})
.returning();
if (!forbiddenVersion) throw new Error("Expected inserted forbidden version");
await expect(svc.restoreVersion(companyId, skill.id, forbiddenVersion.id, actor))
.rejects.toMatchObject({ status: 422 });
// Neither file changed: the pre-flight ran before the first write.
await expect(svc.readFile(companyId, skill.id, "SKILL.md")).resolves.toMatchObject({
content: version1.fileInventory[0]!.content,
});
await expect(svc.readFile(companyId, skill.id, "notes.md")).rejects.toMatchObject({ status: 404 });
});
it("browses project folders and imports a selected non-standard skill", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
+5
View File
@@ -65,6 +65,7 @@ import {
attentionService,
backfillPrincipalAccessCompatibility,
backfillLegacyToolOAuthTokens,
migrateRetiredSkillReleasePins,
bootstrapExecutionPolicyFromEnv,
environmentCustomImageService,
decisionService,
@@ -705,6 +706,10 @@ async function startServerWithDatabaseTeardown(
if (toolOAuthBackfill.sanitizedConnections > 0 || toolOAuthBackfill.migratedConnections > 0) {
logger.info(toolOAuthBackfill, "Backfilled legacy tool OAuth credentials into company secrets");
}
const retiredSkillReleasePinMigration = await migrateRetiredSkillReleasePins(db as any);
if (retiredSkillReleasePinMigration.migratedAgents > 0) {
logger.info(retiredSkillReleasePinMigration, "Repointed agent desired-skill entries pinned to a retired skill release");
}
const confirmationSweep = await issueThreadInteractionService(db as any)
.sweepSupersededPendingRequestConfirmations();
if (confirmationSweep.expired > 0) {
+30
View File
@@ -780,6 +780,36 @@ export function companySkillRoutes(db: Db) {
},
);
router.post(
"/companies/:companyId/skills/:skillId/versions/:versionId/restore",
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
const versionId = req.params.versionId as string;
await assertCanMutateCompanySkills(req, companyId, "skills.edit", () => skillPolicyResource({ companyId, skillId }));
const result = await svc.restoreVersion(companyId, skillId, versionId, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "company.skill_version_restored",
entityType: "company_skill_version",
entityId: result.id,
details: {
skillId,
restoredFromVersionId: versionId,
revisionNumber: result.revisionNumber,
label: result.label,
},
});
res.status(201).json(result);
},
);
router.post("/companies/:companyId/skills/:skillId/star", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
+5
View File
@@ -9473,6 +9473,11 @@ for (const route of [
"/api/companies/{companyId}/skills/{skillId}/versions/{versionId}",
"Get a skill version",
],
[
"post",
"/api/companies/{companyId}/skills/{skillId}/versions/{versionId}/restore",
"Restore a company skill to a past version",
],
[
"post",
"/api/companies/{companyId}/skills/{skillId}/star",
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
assertSkillContentHasNoBearerAuthorizationHeader,
containsForbiddenBearerAuthorizationHeader,
} from "./company-skill-content-guard.js";
describe("containsForbiddenBearerAuthorizationHeader", () => {
it("rejects the plain header form", () => {
expect(containsForbiddenBearerAuthorizationHeader("Authorization: Bearer eyJhbGciOi.abc.def\n")).toBe(true);
});
it("rejects case-insensitively", () => {
expect(containsForbiddenBearerAuthorizationHeader("authorization: bearer abc\n")).toBe(true);
expect(containsForbiddenBearerAuthorizationHeader("AUTHORIZATION: BEARER abc\n")).toBe(true);
expect(containsForbiddenBearerAuthorizationHeader("AuThOrIzAtIoN: BeArEr abc\n")).toBe(true);
});
it("rejects whitespace variants around the colon and after Bearer", () => {
expect(containsForbiddenBearerAuthorizationHeader("Authorization : Bearer abc\n")).toBe(true);
expect(containsForbiddenBearerAuthorizationHeader("Authorization\t:\tBearer\tabc\n")).toBe(true);
expect(containsForbiddenBearerAuthorizationHeader("Authorization:Bearer abc\n")).toBe(true);
expect(containsForbiddenBearerAuthorizationHeader("Authorization: Bearer abc\n")).toBe(true);
});
it("rejects a match at the very end of the content, with no trailing newline", () => {
expect(containsForbiddenBearerAuthorizationHeader("Authorization: Bearer")).toBe(true);
});
it("rejects the header after normalizing CRLF to LF", () => {
expect(containsForbiddenBearerAuthorizationHeader("Run this:\r\nAuthorization: Bearer abc\r\n")).toBe(true);
});
it("rejects the header embedded mid-document, quoted as an example", () => {
const content = [
"# A skill",
"",
"Call the API like this:",
"",
"```",
"curl -H 'Authorization: Bearer $PAPERCLIP_API_KEY' https://example.com",
"```",
"",
].join("\n");
expect(containsForbiddenBearerAuthorizationHeader(content)).toBe(true);
});
it("does not reject unrelated content", () => {
expect(containsForbiddenBearerAuthorizationHeader("# A skill\n\nNo secrets here.\n")).toBe(false);
expect(containsForbiddenBearerAuthorizationHeader("Authorization is required for this action.\n")).toBe(false);
expect(containsForbiddenBearerAuthorizationHeader("Use a Bearer token from paperclip-api.\n")).toBe(false);
});
it("does not reject Bearer glued to more text with no whitespace boundary", () => {
expect(containsForbiddenBearerAuthorizationHeader("Authorization: Bearertoken abc\n")).toBe(false);
});
});
describe("assertSkillContentHasNoBearerAuthorizationHeader", () => {
it("passes silently for safe content", () => {
expect(() => assertSkillContentHasNoBearerAuthorizationHeader("# Fine\n", "SKILL.md")).not.toThrow();
});
it("throws a 422-style error naming paperclip-api, and never rewrites the content", () => {
const content = "Authorization: Bearer abc\n";
let caught: unknown;
try {
assertSkillContentHasNoBearerAuthorizationHeader(content, "references/notes.md");
} catch (error) {
caught = error;
}
expect(caught).toMatchObject({
status: 422,
message: expect.stringMatching(/paperclip-api/i),
details: { path: "references/notes.md" },
});
// The guard rejects; it must never hand back a rewritten version of the content.
expect(content).toBe("Authorization: Bearer abc\n");
});
});
@@ -0,0 +1,36 @@
import { unprocessable } from "../errors.js";
/**
* Matches a bearer Authorization header field: the word "Authorization",
* optional spaces or tabs, a colon, optional spaces or tabs, the word
* "Bearer", then whitespace or the end of the text. The match is
* case-insensitive and runs after CRLF line endings are normalized to LF.
*/
const FORBIDDEN_BEARER_AUTHORIZATION_HEADER_RE = /authorization[ \t]*:[ \t]*bearer(?=[ \t\r\n]|$)/i;
/** Normalize CRLF line endings to LF before a content scan. */
export function normalizeSkillContentLineEndings(content: string): string {
return content.replace(/\r\n/g, "\n");
}
/**
* Test skill file content for a bearer Authorization header field. This
* intentionally matches a quoted example or a command-line argument, not
* only a live credential — the caller must never embed that header shape in
* skill content, no matter the reason.
*/
export function containsForbiddenBearerAuthorizationHeader(content: string): boolean {
return FORBIDDEN_BEARER_AUTHORIZATION_HEADER_RE.test(normalizeSkillContentLineEndings(content));
}
/**
* Reject skill file content that carries a bearer Authorization header
* field. Throws a 422-style error and never rewrites the caller's content.
*/
export function assertSkillContentHasNoBearerAuthorizationHeader(content: string, filePath: string): void {
if (!containsForbiddenBearerAuthorizationHeader(content)) return;
throw unprocessable(
`The file "${filePath}" contains a bearer Authorization header. Pass a bearer Authorization header only through paperclip-api. Do not put it in skill content.`,
{ path: filePath },
);
}
+151 -45
View File
@@ -1,4 +1,5 @@
import { logger } from "../middleware/logger.js";
import { assertSkillContentHasNoBearerAuthorizationHeader } from "./company-skill-content-guard.js";
import { removeRuntimeSkillCache, resolveRuntimeSkillCache, runtimeSkillCacheSpec } from "./runtime-skill-cache.js";
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
@@ -385,6 +386,8 @@ type BundledSkillReleaseManifestEntry = {
releasedAt: string;
notes: string;
dir: string;
/** True when the release is withdrawn and must never reach a company or an agent again. Absent means not retired. */
retired: boolean;
};
type CreateVersionOptions = {
@@ -961,6 +964,51 @@ function resolveBundledSkillReleasesRoot() {
];
}
/**
* Read the bundled skill release manifest (`skills-releases/paperclip/releases.json`).
* This is the single parser for that manifest; every caller that needs release
* metadata, including retirement status, must go through this function so the
* manifest cannot drift between callers.
*/
export async function readBundledSkillReleaseRegistry(): Promise<Array<BundledSkillReleaseManifestEntry & { releaseDir: string }>> {
for (const registryRoot of resolveBundledSkillReleasesRoot()) {
const manifestPath = path.join(registryRoot, "releases.json");
const manifestText = await fs.readFile(manifestPath, "utf8").catch(() => null);
if (!manifestText) continue;
const parsed = JSON.parse(manifestText) as unknown;
if (!Array.isArray(parsed)) throw new Error(`Invalid bundled skill release manifest: ${manifestPath}`);
return parsed.map((entry): BundledSkillReleaseManifestEntry & { releaseDir: string } => {
if (!isPlainRecord(entry)) throw new Error(`Invalid bundled skill release entry: ${manifestPath}`);
const id = asString(entry.id);
const releaseName = asString(entry.releaseName);
const releasedAt = asString(entry.releasedAt);
const notes = asString(entry.notes);
const dir = asString(entry.dir);
if (!id || !releaseName || !releasedAt || !notes || !dir) {
throw new Error(`Incomplete bundled skill release entry: ${manifestPath}`);
}
const releaseDir = path.resolve(registryRoot, dir);
const relativeReleaseDir = path.relative(registryRoot, releaseDir);
if (relativeReleaseDir.startsWith("..") || path.isAbsolute(relativeReleaseDir)) {
throw new Error(`Bundled skill release directory escapes registry root: ${dir}`);
}
return { id, releaseName, releasedAt, notes, dir, releaseDir, retired: entry.retired === true };
});
}
return [];
}
/**
* The one place that decides which bundled skill releases are retired. Every
* enforcement point (new-pin rejection, runtime materialization, the pin
* migration, and the agent selector) must call this function instead of
* reading the manifest itself, so the retired set cannot drift between them.
*/
export async function resolveRetiredBundledSkillReleaseIds(): Promise<Set<string>> {
const releases = await readBundledSkillReleaseRegistry();
return new Set(releases.filter((release) => release.retired).map((release) => release.id));
}
function matchesRequestedSkill(relativeSkillPath: string, requestedSkillSlug: string | null) {
if (!requestedSkillSlug) return true;
const skillDir = path.posix.dirname(relativeSkillPath);
@@ -1895,13 +1943,19 @@ function serializeVersionFileInventory(
}));
}
function toCompanySkillVersion(row: CompanySkillVersionRow): CompanySkillVersion {
const NO_RETIRED_RELEASE_IDS: ReadonlySet<string> = new Set();
function toCompanySkillVersion(
row: CompanySkillVersionRow,
retiredReleaseIds: ReadonlySet<string> = NO_RETIRED_RELEASE_IDS,
): CompanySkillVersion {
return {
...row,
label: row.label ?? null,
releaseId: row.releaseId ?? null,
releaseName: row.releaseName ?? null,
releasedAt: row.releasedAt ?? null,
retired: Boolean(row.releaseId && retiredReleaseIds.has(row.releaseId)),
fileInventory: Array.isArray(row.fileInventory)
? row.fileInventory.flatMap((entry) => {
if (!isPlainRecord(entry)) return [];
@@ -2211,7 +2265,7 @@ async function assertVersionMatchesSkill(
) {
if (!versionId) return;
const row = await db
.select({ id: companySkillVersions.id })
.select({ id: companySkillVersions.id, releaseId: companySkillVersions.releaseId })
.from(companySkillVersions)
.where(and(
eq(companySkillVersions.companyId, companyId),
@@ -2225,6 +2279,15 @@ async function assertVersionMatchesSkill(
skillId,
});
}
if (row.releaseId) {
const retiredReleaseIds = await resolveRetiredBundledSkillReleaseIds();
if (retiredReleaseIds.has(row.releaseId)) {
throw unprocessable(
"This skill release is retired because it teaches an unsafe credential form. Select the current skill content or another release instead.",
{ versionId, skillId, releaseId: row.releaseId },
);
}
}
}
export interface ResolvedRequestedSkillEntries {
@@ -2968,34 +3031,6 @@ export function companySkillService(db: Db) {
return [];
}
async function readBundledSkillReleaseRegistry() {
for (const registryRoot of resolveBundledSkillReleasesRoot()) {
const manifestPath = path.join(registryRoot, "releases.json");
const manifestText = await fs.readFile(manifestPath, "utf8").catch(() => null);
if (!manifestText) continue;
const parsed = JSON.parse(manifestText) as unknown;
if (!Array.isArray(parsed)) throw new Error(`Invalid bundled skill release manifest: ${manifestPath}`);
return parsed.map((entry): BundledSkillReleaseManifestEntry & { releaseDir: string } => {
if (!isPlainRecord(entry)) throw new Error(`Invalid bundled skill release entry: ${manifestPath}`);
const id = asString(entry.id);
const releaseName = asString(entry.releaseName);
const releasedAt = asString(entry.releasedAt);
const notes = asString(entry.notes);
const dir = asString(entry.dir);
if (!id || !releaseName || !releasedAt || !notes || !dir) {
throw new Error(`Incomplete bundled skill release entry: ${manifestPath}`);
}
const releaseDir = path.resolve(registryRoot, dir);
const relativeReleaseDir = path.relative(registryRoot, releaseDir);
if (relativeReleaseDir.startsWith("..") || path.isAbsolute(relativeReleaseDir)) {
throw new Error(`Bundled skill release directory escapes registry root: ${dir}`);
}
return { id, releaseName, releasedAt, notes, dir, releaseDir };
});
}
return [];
}
async function collectVersionFileInventoryFromDirectory(
skillDir: string,
): Promise<CompanySkillVersionFileInventoryEntry[]> {
@@ -3378,7 +3413,9 @@ export function companySkillService(db: Db) {
eq(companySkillVersions.id, versionId),
))
.then((rows) => rows[0] ?? null);
return row ? toCompanySkillVersion(row) : null;
if (!row) return null;
const retiredReleaseIds = row.releaseId ? await resolveRetiredBundledSkillReleaseIds() : NO_RETIRED_RELEASE_IDS;
return toCompanySkillVersion(row, retiredReleaseIds);
}
async function getCurrentVersion(skill: CompanySkill): Promise<CompanySkillVersion | null> {
@@ -3559,7 +3596,10 @@ export function companySkillService(db: Db) {
.from(companySkillVersions)
.where(and(eq(companySkillVersions.companyId, companyId), eq(companySkillVersions.companySkillId, skillId)))
.orderBy(desc(companySkillVersions.revisionNumber));
return rows.map((row) => toCompanySkillVersion(row));
const retiredReleaseIds = rows.some((row) => row.releaseId)
? await resolveRetiredBundledSkillReleaseIds()
: NO_RETIRED_RELEASE_IDS;
return rows.map((row) => toCompanySkillVersion(row, retiredReleaseIds));
}
async function createVersion(
@@ -4514,23 +4554,25 @@ export function companySkillService(db: Db) {
return (await getById(companyId, created.id)) ?? (row ? toCompanySkill(row) : created);
}
async function updateFile(
companyId: string,
skillId: string,
relativePath: string,
content: string,
actor: SkillActor | null = null,
): Promise<CompanySkillFileDetail> {
await ensureSkillInventoryCurrent(companyId);
const skill = await getById(companyId, skillId);
if (!skill) throw notFound("Skill not found");
function assertSkillIsLocallyEditable(skill: CompanySkill) {
const source = deriveSkillSourceInfo(skill);
if (!source.editable || skill.sourceType !== "local_path") {
throw unprocessable(source.editableReason ?? "This skill cannot be edited.");
}
}
const normalizedPath = normalizePortablePath(relativePath);
/**
* Write one file's content to disk and keep the skill row's SKILL.md-derived
* fields in sync. Returns whether the content changed. Callers must validate
* content and resolve the skill's editability before calling this — it does
* not create a version, so a caller that writes several files can validate
* and write the whole set before cutting one new version.
*/
async function writeLocalSkillFileToDisk(
skill: CompanySkill,
normalizedPath: string,
content: string,
): Promise<boolean> {
const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath);
if (!absolutePath) throw notFound("Skill file not found");
@@ -4557,7 +4599,26 @@ export function companySkillService(db: Db) {
.where(eq(companySkills.id, skill.id));
}
if (previousContent !== content) {
return previousContent !== content;
}
async function updateFile(
companyId: string,
skillId: string,
relativePath: string,
content: string,
actor: SkillActor | null = null,
): Promise<CompanySkillFileDetail> {
await ensureSkillInventoryCurrent(companyId);
const skill = await getById(companyId, skillId);
if (!skill) throw notFound("Skill not found");
assertSkillIsLocallyEditable(skill);
const normalizedPath = normalizePortablePath(relativePath);
assertSkillContentHasNoBearerAuthorizationHeader(content, normalizedPath);
const changed = await writeLocalSkillFileToDisk(skill, normalizedPath, content);
if (changed) {
await createVersion(companyId, skillId, {}, actor);
}
@@ -4566,6 +4627,41 @@ export function companySkillService(db: Db) {
return detail;
}
/**
* Restore a past skill version: write every file it carries back onto the
* live skill, then cut a new head version (immutability — history is never
* rewritten). Every file is validated before the first write so a forbidden
* file cannot leave the skill half-restored.
*/
async function restoreVersion(
companyId: string,
skillId: string,
versionId: string,
actor: SkillActor | null = null,
): Promise<CompanySkillVersion> {
await ensureSkillInventoryCurrent(companyId);
const skill = await getById(companyId, skillId);
if (!skill) throw notFound("Skill not found");
assertSkillIsLocallyEditable(skill);
const version = await getVersion(companyId, skillId, versionId);
if (!version) throw notFound("Skill version not found");
const files = version.fileInventory.map((file) => ({
normalizedPath: normalizePortablePath(file.path),
content: file.content,
}));
for (const file of files) {
assertSkillContentHasNoBearerAuthorizationHeader(file.content, file.normalizedPath);
}
for (const file of files) {
await writeLocalSkillFileToDisk(skill, file.normalizedPath, file.content);
}
return createVersion(companyId, skillId, { label: `Restore of v${version.revisionNumber}` }, actor);
}
async function deleteFile(
companyId: string,
skillId: string,
@@ -5808,6 +5904,15 @@ export function companySkillService(db: Db) {
detail: "The selected skill version no longer exists.",
};
}
// Fail closed before any frozen file reaches disk: a retired release
// teaches an unsafe credential form, so the agent must never receive it.
if (version.retired) {
return {
status: "missing",
source: versionPath,
detail: "The selected skill version is retired because it teaches an unsafe credential form. Select the current skill content or another release instead.",
};
}
// A failed snapshot materialization must surface as a "missing" entry
// with the real cause — a silent drop makes the skill vanish from the
// runtime while the library still shows it installed.
@@ -7011,6 +7116,7 @@ export function companySkillService(db: Db) {
readFile,
updateSkill,
updateFile,
restoreVersion,
deleteFile,
createLocalSkill,
deleteSkill,
+1
View File
@@ -101,6 +101,7 @@ export {
export { createComposioClient, ComposioApiError, type ComposioClient } from "./composio.js";
export { smokeLabService } from "./smoke-lab.js";
export { backfillLegacyToolOAuthTokens } from "./tool-oauth-legacy-backfill.js";
export { migrateRetiredSkillReleasePins } from "./retired-skill-release-pin-migration.js";
export { toolAccessPolicyService } from "./tool-access-policy.js";
export { routineService } from "./routines.js";
export { costService } from "./costs.js";
@@ -0,0 +1,134 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, companySkillVersions, companySkills, createDb } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { migrateRetiredSkillReleasePins } from "./retired-skill-release-pin-migration.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("migrateRetiredSkillReleasePins", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("retired-skill-release-pins-");
db = createDb(database.connectionString);
}, 30000);
afterAll(async () => {
await database?.cleanup();
});
async function seedCompanyAndSkill() {
const companyId = randomUUID();
const skillId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Migration test",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
});
await db.insert(companySkills).values({
id: skillId,
companyId,
key: "paperclipai/paperclip/paperclip",
slug: "paperclip",
name: "Paperclip",
markdown: "# Paperclip",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
});
return { companyId, skillId };
}
async function insertVersion(companyId: string, skillId: string, revisionNumber: number, releaseId: string | null) {
const [row] = await db
.insert(companySkillVersions)
.values({
companyId,
companySkillId: skillId,
revisionNumber,
releaseId,
fileInventory: [],
})
.returning();
if (!row) throw new Error("Expected inserted skill version");
return row;
}
async function insertAgentPinnedTo(companyId: string, key: string, versionId: string | null) {
const [row] = await db
.insert(agents)
.values({
companyId,
name: "Pinned agent",
adapterConfig: {
paperclipSkillSync: {
desiredSkills: [{ key, versionId }],
},
},
})
.returning();
if (!row) throw new Error("Expected inserted agent");
return row;
}
async function desiredSkills(db: ReturnType<typeof createDb>, agentId: string) {
const row = await db.select({ adapterConfig: agents.adapterConfig }).from(agents)
.where(eq(agents.id, agentId)).then((rows) => rows[0]);
return (row?.adapterConfig as { paperclipSkillSync?: { desiredSkills?: unknown[] } })
.paperclipSkillSync?.desiredSkills;
}
it("migrates an existing agent pinned to the retired v0 release onto the unpinned default", async () => {
const { companyId, skillId } = await seedCompanyAndSkill();
const v0 = await insertVersion(companyId, skillId, 1, "v0");
const agentOnV0 = await insertAgentPinnedTo(companyId, "paperclipai/paperclip/paperclip", v0.id);
const result = await migrateRetiredSkillReleasePins(db);
expect(result.migratedAgents).toBe(1);
expect(result.clearedPins).toBe(1);
// Once no entry carries a versionId, the preference writer collapses back
// to the plain string form — the same shape an unpinned desired skill has
// always used.
await expect(desiredSkills(db, agentOnV0.id)).resolves.toEqual(["paperclipai/paperclip/paperclip"]);
});
it("migrates an existing agent pinned to the retired v7-roster release onto the unpinned default", async () => {
const { companyId, skillId } = await seedCompanyAndSkill();
const v7 = await insertVersion(companyId, skillId, 1, "v7-roster");
const agentOnV7 = await insertAgentPinnedTo(companyId, "paperclipai/paperclip/paperclip", v7.id);
const result = await migrateRetiredSkillReleasePins(db);
expect(result.migratedAgents).toBe(1);
expect(result.clearedPins).toBe(1);
await expect(desiredSkills(db, agentOnV7.id)).resolves.toEqual(["paperclipai/paperclip/paperclip"]);
});
it("leaves a pin on a release that is not retired, and an agent with no pin, untouched; a second run changes nothing", async () => {
const { companyId, skillId } = await seedCompanyAndSkill();
const v0 = await insertVersion(companyId, skillId, 1, "v0");
const current = await insertVersion(companyId, skillId, 2, null);
const agentOnV0 = await insertAgentPinnedTo(companyId, "paperclipai/paperclip/paperclip", v0.id);
const agentOnCurrent = await insertAgentPinnedTo(companyId, "paperclipai/paperclip/paperclip", current.id);
const agentUnpinned = await insertAgentPinnedTo(companyId, "paperclipai/paperclip/paperclip", null);
const firstRun = await migrateRetiredSkillReleasePins(db);
expect(firstRun.migratedAgents).toBe(1);
expect(firstRun.clearedPins).toBe(1);
await expect(desiredSkills(db, agentOnV0.id)).resolves.toEqual(["paperclipai/paperclip/paperclip"]);
// A pin on a release that is not retired, and an agent with no pin at
// all, must be left exactly as they were.
await expect(desiredSkills(db, agentOnCurrent.id)).resolves.toEqual([
{ key: "paperclipai/paperclip/paperclip", versionId: current.id },
]);
// Already unpinned, so migration skips it and leaves its stored shape untouched.
await expect(desiredSkills(db, agentUnpinned.id)).resolves.toEqual([
{ key: "paperclipai/paperclip/paperclip", versionId: null },
]);
const secondRun = await migrateRetiredSkillReleasePins(db);
expect(secondRun.migratedAgents).toBe(0);
expect(secondRun.clearedPins).toBe(0);
});
});
@@ -0,0 +1,85 @@
import { asc, eq, gt, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, companySkillVersions } from "@paperclipai/db";
import {
readPaperclipSkillSyncPreference,
writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils";
import { resolveRetiredBundledSkillReleaseIds } from "./company-skills.js";
export type RetiredSkillReleasePinMigrationResult = {
scannedAgents: number;
migratedAgents: number;
clearedPins: number;
};
/** Agents per pass. Bounds one run's work so a large agent table stays cheap to retry. */
const AGENT_BATCH_SIZE = 500;
function asPlainRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
/**
* Repoint every agent desired-skill entry pinned to a retired bundled skill
* release onto the safe unpinned (current) content. Reads and updates agents
* in ordered batches of `AGENT_BATCH_SIZE`, keyed on the agent id (an indexed
* column), instead of one unbounded scan or an offset that re-reads earlier
* rows on every pass. It is safe to call again: an agent that carries no
* retired pin is left untouched, so a second run changes nothing.
*/
export async function migrateRetiredSkillReleasePins(db: Db): Promise<RetiredSkillReleasePinMigrationResult> {
const result: RetiredSkillReleasePinMigrationResult = { scannedAgents: 0, migratedAgents: 0, clearedPins: 0 };
const retiredReleaseIds = await resolveRetiredBundledSkillReleaseIds();
if (retiredReleaseIds.size === 0) return result;
const retiredVersionRows = await db
.select({ id: companySkillVersions.id })
.from(companySkillVersions)
.where(inArray(companySkillVersions.releaseId, Array.from(retiredReleaseIds)));
const retiredVersionIds = new Set(retiredVersionRows.map((row) => row.id));
if (retiredVersionIds.size === 0) return result;
let cursor: string | null = null;
for (;;) {
const rows = await db
.select({ id: agents.id, adapterConfig: agents.adapterConfig })
.from(agents)
.where(cursor ? gt(agents.id, cursor) : undefined)
.orderBy(asc(agents.id))
.limit(AGENT_BATCH_SIZE);
if (rows.length === 0) break;
result.scannedAgents += rows.length;
for (const row of rows) {
const config = asPlainRecord(row.adapterConfig);
const preference = readPaperclipSkillSyncPreference(config);
const pinnedRetiredCount = preference.desiredSkillEntries.filter(
(entry) => entry.versionId && retiredVersionIds.has(entry.versionId),
).length;
if (pinnedRetiredCount === 0) continue;
const nextEntries = preference.desiredSkillEntries.map((entry) =>
entry.versionId && retiredVersionIds.has(entry.versionId)
? { key: entry.key, versionId: null }
: entry,
);
const nextConfig = writePaperclipSkillSyncPreference(config, nextEntries);
await db
.update(agents)
.set({ adapterConfig: nextConfig, updatedAt: new Date() })
.where(eq(agents.id, row.id));
result.migratedAgents += 1;
result.clearedPins += pinnedRetiredCount;
}
cursor = rows[rows.length - 1]!.id;
if (rows.length < AGENT_BATCH_SIZE) break;
}
return result;
}
+4 -2
View File
@@ -4,13 +4,15 @@
"releaseName": "V0 — Original",
"releasedAt": "2026-07-15T07:52:54-05:00",
"notes": "Original Paperclip core skill snapshot from upstream commit ea66ea81.",
"dir": "v0"
"dir": "v0",
"retired": true
},
{
"id": "v7-roster",
"releaseName": "V7 — Roster champion",
"releasedAt": "2026-07-21",
"notes": "Adopted by PAP-14624 G3 confirmation card 9c304fc2. This frozen historical release predates three later master edits to SKILL.md, references/api-reference.md, and references/company-skills.md; the live default keeps the newer guidance.",
"dir": "v7-roster"
"dir": "v7-roster",
"retired": true
}
]
+5
View File
@@ -78,6 +78,11 @@ export const companySkillsApi = {
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions`,
payload,
),
restoreVersion: (companyId: string, skillId: string, versionId: string) =>
api.post<CompanySkillVersion>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions/${encodeURIComponent(versionId)}/restore`,
{},
),
// --- Skill Studio test inputs (PAP-12960 P1 API) ---
testInputs: (companyId: string, skillId: string) =>
api.get<CompanySkillTestInput[]>(
@@ -124,6 +124,7 @@ function makeVersion(overrides: Partial<CompanySkillVersion> = {}): CompanySkill
releaseId: null,
releaseName: null,
releasedAt: null,
retired: false,
fileInventory: [],
authorAgentId: null,
authorUserId: null,
+1
View File
@@ -125,6 +125,7 @@ function makeVersion(revisionNumber: number, content: string): CompanySkillVersi
releaseId: null,
releaseName: null,
releasedAt: null,
retired: false,
fileInventory: [
{
path: "SKILL.md",
+2 -1
View File
@@ -2324,8 +2324,9 @@ function AttachAgentsPopover({
>
<option value="__latest__">Latest</option>
{sortedVersions.map((version) => (
<option key={version.id} value={version.id}>
<option key={version.id} value={version.id} disabled={version.retired}>
v{version.revisionNumber}{version.label ? ` · ${version.label}` : ""}
{version.retired ? " (retired)" : ""}
</option>
))}
</select>
+2 -10
View File
@@ -3436,16 +3436,8 @@ function VersionHistorySheet({
const [rightId, setRightId] = useState<string | null>(null);
const restore = useMutation({
mutationFn: async (version: CompanySkillVersion) => {
// Restore = write each file from the chosen version back, then cut a new
// head version (immutability: never rewrites history).
for (const file of version.fileInventory) {
await companySkillsApi.updateFile(companyId, skillId, file.path, file.content);
}
return companySkillsApi.createVersion(companyId, skillId, {
label: `Restore of v${version.revisionNumber}`,
});
},
mutationFn: async (version: CompanySkillVersion) =>
companySkillsApi.restoreVersion(companyId, skillId, version.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.versions(companyId, skillId) });
onRestored();
@@ -17,6 +17,7 @@ function makeRelease(overrides: Partial<CompanySkillVersion> = {}): CompanySkill
releaseId: "v7-roster",
releaseName: "V7 — Roster champion",
releasedAt: "2026-07-21" as unknown as CompanySkillVersion["releasedAt"],
retired: false,
fileInventory: [],
authorAgentId: null,
authorUserId: null,
@@ -89,11 +89,11 @@ export function AgentSkillReleasePicker({
<SelectContent align="end" className="max-w-(--sz-20rem)">
<SelectItem value={RELEASE_DEFAULT_VALUE}>{DEFAULT_LABEL}</SelectItem>
{releases.map((release) => (
<SelectItem key={release.id} value={release.id}>
<SelectItem key={release.id} value={release.id} disabled={release.retired}>
<span className="flex items-center gap-2">
<span className="truncate">{releaseOptionLabel(release)}</span>
<Badge variant="secondary" className="shrink-0 text-(length:--text-nano)">
Beta
{release.retired ? "Retired" : "Beta"}
</Badge>
</span>
</SelectItem>
@@ -25,6 +25,7 @@ function makeVersion(overrides: Partial<CompanySkillVersion>): CompanySkillVersi
releaseId: null,
releaseName: null,
releasedAt: null,
retired: false,
fileInventory: [],
authorAgentId: null,
authorUserId: null,
@@ -69,6 +69,7 @@ const MOCK_DETAIL: CompanySkillDetail = {
releaseId: null,
releaseName: null,
releasedAt: null,
retired: false,
fileInventory: [],
authorAgentId: "a-1",
authorUserId: null,
@@ -87,6 +88,7 @@ const MOCK_VERSIONS: CompanySkillVersion[] = [
releaseId: null,
releaseName: null,
releasedAt: null,
retired: false,
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# v2" }],
authorAgentId: "a-1",
authorUserId: null,
@@ -101,6 +103,7 @@ const MOCK_VERSIONS: CompanySkillVersion[] = [
releaseId: null,
releaseName: null,
releasedAt: null,
retired: false,
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# v1" }],
authorAgentId: "a-1",
authorUserId: null,