From 888e64cd731203ac077120aa2926ad1d627645e2 Mon Sep 17 00:00:00 2001 From: nickyleach <331803+nickyleach@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:43:54 +0000 Subject: [PATCH] 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 --- packages/shared/src/types/company-skill.ts | 6 + .../__tests__/company-skills-service.test.ts | 152 ++++++++++++-- server/src/index.ts | 5 + server/src/routes/company-skills.ts | 30 +++ server/src/routes/openapi.ts | 5 + .../company-skill-content-guard.test.ts | 79 +++++++ .../services/company-skill-content-guard.ts | 36 ++++ server/src/services/company-skills.ts | 196 ++++++++++++++---- server/src/services/index.ts | 1 + ...etired-skill-release-pin-migration.test.ts | 134 ++++++++++++ .../retired-skill-release-pin-migration.ts | 85 ++++++++ skills-releases/paperclip/releases.json | 6 +- ui/src/api/companySkills.ts | 5 + .../AgentsUsingSkillDialog.test.tsx | 1 + ui/src/pages/CompanySkills.test.tsx | 1 + ui/src/pages/CompanySkills.tsx | 3 +- ui/src/pages/SkillStudio.tsx | 12 +- .../AgentSkillReleasePicker.test.ts | 1 + .../agent-skills/AgentSkillReleasePicker.tsx | 4 +- .../stories/agents-using-skill.stories.tsx | 1 + .../stories/skills-store-detail.stories.tsx | 3 + 21 files changed, 686 insertions(+), 80 deletions(-) create mode 100644 server/src/services/company-skill-content-guard.test.ts create mode 100644 server/src/services/company-skill-content-guard.ts create mode 100644 server/src/services/retired-skill-release-pin-migration.test.ts create mode 100644 server/src/services/retired-skill-release-pin-migration.ts diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index ad8bb1fb638..14ec0825019 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -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; diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index 00c44340222..48918df9855 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -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 = {}; - async function walk(root: string, current = root): Promise { - 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(); diff --git a/server/src/index.ts b/server/src/index.ts index ffd6523607f..9b3cca83f2a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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) { diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 1aaba318e31..75d63c11235 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -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; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 3ba131044fb..9bdb41a5731 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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", diff --git a/server/src/services/company-skill-content-guard.test.ts b/server/src/services/company-skill-content-guard.test.ts new file mode 100644 index 00000000000..178a4655585 --- /dev/null +++ b/server/src/services/company-skill-content-guard.test.ts @@ -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"); + }); +}); diff --git a/server/src/services/company-skill-content-guard.ts b/server/src/services/company-skill-content-guard.ts new file mode 100644 index 00000000000..24ba957fde1 --- /dev/null +++ b/server/src/services/company-skill-content-guard.ts @@ -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 }, + ); +} diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 25ecb3bc1f1..3e6abbdb1b8 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -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> { + 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> { + 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 = new Set(); + +function toCompanySkillVersion( + row: CompanySkillVersionRow, + retiredReleaseIds: ReadonlySet = 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 { @@ -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 { @@ -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 { - 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 { 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 { + 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 { + 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, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 15ea31a7ad3..73607e14a24 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -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"; diff --git a/server/src/services/retired-skill-release-pin-migration.test.ts b/server/src/services/retired-skill-release-pin-migration.test.ts new file mode 100644 index 00000000000..90cf38d1a9a --- /dev/null +++ b/server/src/services/retired-skill-release-pin-migration.test.ts @@ -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>; + let db: ReturnType; + + 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, 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); + }); +}); diff --git a/server/src/services/retired-skill-release-pin-migration.ts b/server/src/services/retired-skill-release-pin-migration.ts new file mode 100644 index 00000000000..41a4345ac7b --- /dev/null +++ b/server/src/services/retired-skill-release-pin-migration.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +/** + * 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 { + 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; +} diff --git a/skills-releases/paperclip/releases.json b/skills-releases/paperclip/releases.json index 07e6499e332..d8a8f28e468 100644 --- a/skills-releases/paperclip/releases.json +++ b/skills-releases/paperclip/releases.json @@ -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 } ] diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index f80d0b86291..176070bccab 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -78,6 +78,11 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions`, payload, ), + restoreVersion: (companyId: string, skillId: string, versionId: string) => + api.post( + `/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( diff --git a/ui/src/components/skill-studio/AgentsUsingSkillDialog.test.tsx b/ui/src/components/skill-studio/AgentsUsingSkillDialog.test.tsx index 84529ad32dd..d88a793273a 100644 --- a/ui/src/components/skill-studio/AgentsUsingSkillDialog.test.tsx +++ b/ui/src/components/skill-studio/AgentsUsingSkillDialog.test.tsx @@ -124,6 +124,7 @@ function makeVersion(overrides: Partial = {}): CompanySkill releaseId: null, releaseName: null, releasedAt: null, + retired: false, fileInventory: [], authorAgentId: null, authorUserId: null, diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx index 56c365708d4..1a4bd960e47 100644 --- a/ui/src/pages/CompanySkills.test.tsx +++ b/ui/src/pages/CompanySkills.test.tsx @@ -125,6 +125,7 @@ function makeVersion(revisionNumber: number, content: string): CompanySkillVersi releaseId: null, releaseName: null, releasedAt: null, + retired: false, fileInventory: [ { path: "SKILL.md", diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 1ac354a8077..45fedf86f90 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -2324,8 +2324,9 @@ function AttachAgentsPopover({ > {sortedVersions.map((version) => ( - ))} diff --git a/ui/src/pages/SkillStudio.tsx b/ui/src/pages/SkillStudio.tsx index 36dfee3ac30..26bfbd63fe2 100644 --- a/ui/src/pages/SkillStudio.tsx +++ b/ui/src/pages/SkillStudio.tsx @@ -3436,16 +3436,8 @@ function VersionHistorySheet({ const [rightId, setRightId] = useState(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(); diff --git a/ui/src/pages/agent-skills/AgentSkillReleasePicker.test.ts b/ui/src/pages/agent-skills/AgentSkillReleasePicker.test.ts index 6fc383362f4..109efb2bbdf 100644 --- a/ui/src/pages/agent-skills/AgentSkillReleasePicker.test.ts +++ b/ui/src/pages/agent-skills/AgentSkillReleasePicker.test.ts @@ -17,6 +17,7 @@ function makeRelease(overrides: Partial = {}): CompanySkill releaseId: "v7-roster", releaseName: "V7 — Roster champion", releasedAt: "2026-07-21" as unknown as CompanySkillVersion["releasedAt"], + retired: false, fileInventory: [], authorAgentId: null, authorUserId: null, diff --git a/ui/src/pages/agent-skills/AgentSkillReleasePicker.tsx b/ui/src/pages/agent-skills/AgentSkillReleasePicker.tsx index c606d9ba1aa..3a0c843a1d5 100644 --- a/ui/src/pages/agent-skills/AgentSkillReleasePicker.tsx +++ b/ui/src/pages/agent-skills/AgentSkillReleasePicker.tsx @@ -89,11 +89,11 @@ export function AgentSkillReleasePicker({ {DEFAULT_LABEL} {releases.map((release) => ( - + {releaseOptionLabel(release)} - Beta + {release.retired ? "Retired" : "Beta"} diff --git a/ui/storybook/stories/agents-using-skill.stories.tsx b/ui/storybook/stories/agents-using-skill.stories.tsx index f024c2283af..299cf405fa6 100644 --- a/ui/storybook/stories/agents-using-skill.stories.tsx +++ b/ui/storybook/stories/agents-using-skill.stories.tsx @@ -25,6 +25,7 @@ function makeVersion(overrides: Partial): CompanySkillVersi releaseId: null, releaseName: null, releasedAt: null, + retired: false, fileInventory: [], authorAgentId: null, authorUserId: null, diff --git a/ui/storybook/stories/skills-store-detail.stories.tsx b/ui/storybook/stories/skills-store-detail.stories.tsx index 7d91ba56b09..2c56c992601 100644 --- a/ui/storybook/stories/skills-store-detail.stories.tsx +++ b/ui/storybook/stories/skills-store-detail.stories.tsx @@ -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,