mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
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:
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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -125,6 +125,7 @@ function makeVersion(revisionNumber: number, content: string): CompanySkillVersi
|
||||
releaseId: null,
|
||||
releaseName: null,
|
||||
releasedAt: null,
|
||||
retired: false,
|
||||
fileInventory: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user