PAP-10287 harden teams catalog install path

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-05 04:14:18 +00:00
1 parent 83278f0760
commit 4234c9bbea
4 files changed
+211 -116

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
export interface MarkdownDoc {
frontmatter: Record<string, unknown>;
body: string;
hasFrontmatter: boolean;
}
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function asBoolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
export function asStringArray(value: unknown): string[] | null {
if (value === undefined) return [];
if (!Array.isArray(value)) return null;
const out: string[] = [];
for (const item of value) {
const text = asString(item);
if (!text) return null;
out.push(text);
}
return out;
}
export function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
const normalized = raw.replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) {
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
}
const closing = normalized.indexOf("\n---\n", 4);
if (closing < 0) {
return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false };
}
const frontmatterRaw = normalized.slice(4, closing).trim();
const body = normalized.slice(closing + 5).trim();
return {
frontmatter: parseYamlFrontmatter(frontmatterRaw),
body,
hasFrontmatter: true,
};
}
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
const prepared = prepareYamlLines(raw);
if (prepared.length === 0) return {};
const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent);
return isPlainRecord(parsed.value) ? parsed.value : {};
}
function prepareYamlLines(raw: string) {
return raw
.split("\n")
.map((line) => ({
indent: line.match(/^ */)?.[0].length ?? 0,
content: line.trim(),
}))
.filter((line) => line.content.length > 0 && !line.content.startsWith("#"));
}
function parseYamlBlock(
lines: Array<{ indent: number; content: string }>,
startIndex: number,
indentLevel: number,
): { value: unknown; nextIndex: number } {
let index = startIndex;
if (index >= lines.length || lines[index]!.indent < indentLevel) {
return { value: {}, nextIndex: index };
}
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
if (isArray) {
const values: unknown[] = [];
while (index < lines.length) {
const line = lines[index]!;
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
const remainder = line.content.slice(1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
values.push(nested.value);
index = nested.nextIndex;
continue;
}
values.push(parseYamlScalar(remainder));
}
return { value: values, nextIndex: index };
}
const record: Record<string, unknown> = {};
while (index < lines.length) {
const line = lines[index]!;
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel) {
index += 1;
continue;
}
const separatorIndex = line.content.indexOf(":");
if (separatorIndex <= 0) {
index += 1;
continue;
}
const key = line.content.slice(0, separatorIndex).trim();
const remainder = line.content.slice(separatorIndex + 1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
record[key] = nested.value;
index = nested.nextIndex;
continue;
}
record[key] = parseYamlScalar(remainder);
}
return { value: record, nextIndex: index };
}
function parseYamlScalar(rawValue: string): unknown {
const trimmed = rawValue.trim();
if (trimmed === "") return "";
if (trimmed === "null" || trimmed === "~") return null;
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (trimmed === "[]") return [];
if (trimmed === "{}") return {};
if (/^-?\d+(\.\d)?\d*$/.test(trimmed)) return Number(trimmed);
if (
trimmed.startsWith("\"") ||
trimmed.startsWith("[") ||
trimmed.startsWith("{")
) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
}
+8
View File
@@ -1,4 +1,12 @@
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
export {
asBoolean,
asString,
asStringArray,
isPlainRecord as isFrontmatterPlainRecord,
parseFrontmatterMarkdown,
type MarkdownDoc,
} from "./frontmatter.js";
export {
COMPANY_STATUSES,
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
@@ -226,6 +226,16 @@ describe("teamsCatalogService", () => {
expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled();
});
it("does not install catalog skills when bundle import fails", async () => {
mockCompanyPortabilityService.importBundle.mockRejectedValueOnce(new Error("import failed"));
const svc = teamsCatalogService({} as any);
await expect(svc.installCatalogTeam("company-1", "core-exec-team")).rejects.toThrow("import failed");
expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled();
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
});
it("injects safe claude_local adapter defaults for every bundled agent when no overrides are supplied", async () => {
const svc = teamsCatalogService({} as any);
@@ -239,6 +249,29 @@ describe("teamsCatalogService", () => {
});
});
it("uses the configured safe adapter default for bundled agents", async () => {
const previousDefault = process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE;
process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE = "opencode_local";
try {
const svc = teamsCatalogService({} as any);
await svc.installCatalogTeam("company-1", "core-exec-team");
const [importInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!;
expect(importInput.adapterOverrides).toEqual({
ceo: { adapterType: "opencode_local" },
cto: { adapterType: "opencode_local" },
qa: { adapterType: "opencode_local" },
});
} finally {
if (previousDefault === undefined) {
delete process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE;
} else {
process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE = previousDefault;
}
}
});
it("supplies safe adapter defaults for product-design and product-engineering installs", async () => {
const svc = teamsCatalogService({} as any);
+16 -116
View File
@@ -21,6 +21,7 @@ import type {
CompanyPortabilitySource,
} from "@paperclipai/shared";
import { normalizeAgentUrlKey } from "@paperclipai/shared";
import { parseFrontmatterMarkdown } from "@paperclipai/shared/frontmatter";
import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { agentService } from "./agents.js";
import { companyPortabilityService } from "./company-portability.js";
@@ -509,116 +510,8 @@ function mergePlainRecords(
return merged;
}
interface MarkdownDoc {
frontmatter: Record<string, unknown>;
body: string;
}
function parseYamlScalar(rawValue: string): unknown {
const trimmed = rawValue.trim();
if (trimmed === "") return "";
if (trimmed === "null" || trimmed === "~") return null;
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (trimmed === "[]") return [];
if (trimmed === "{}") return {};
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
if (trimmed.startsWith("\"") || trimmed.startsWith("[") || trimmed.startsWith("{")) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
}
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
const lines = raw
.split("\n")
.map((line) => ({
indent: line.match(/^ */)?.[0].length ?? 0,
content: line.trim(),
}))
.filter((line) => line.content.length > 0 && !line.content.startsWith("#"));
if (lines.length === 0) return {};
const parsed = parseYamlBlock(lines, 0, lines[0]!.indent);
return isPlainRecord(parsed.value) ? parsed.value : {};
}
function parseYamlBlock(
lines: Array<{ indent: number; content: string }>,
startIndex: number,
indentLevel: number,
): { value: unknown; nextIndex: number } {
let index = startIndex;
if (index >= lines.length || lines[index]!.indent < indentLevel) {
return { value: {}, nextIndex: index };
}
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
if (isArray) {
const values: unknown[] = [];
while (index < lines.length) {
const line = lines[index]!;
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
const remainder = line.content.slice(1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
values.push(nested.value);
index = nested.nextIndex;
continue;
}
values.push(parseYamlScalar(remainder));
}
return { value: values, nextIndex: index };
}
const record: Record<string, unknown> = {};
while (index < lines.length) {
const line = lines[index]!;
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel) {
index += 1;
continue;
}
const separatorIndex = line.content.indexOf(":");
if (separatorIndex <= 0) {
index += 1;
continue;
}
const key = line.content.slice(0, separatorIndex).trim();
const remainder = line.content.slice(separatorIndex + 1).trim();
index += 1;
if (remainder) {
record[key] = parseYamlScalar(remainder);
continue;
}
const nested = parseYamlBlock(lines, index, indentLevel + 2);
record[key] = nested.value;
index = nested.nextIndex;
}
return { value: record, nextIndex: index };
}
function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
const normalized = raw.replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) {
return { frontmatter: {}, body: normalized.trim() };
}
const closing = normalized.indexOf("\n---\n", 4);
if (closing < 0) {
return { frontmatter: {}, body: normalized.trim() };
}
return {
frontmatter: parseYamlFrontmatter(normalized.slice(4, closing).trim()),
body: normalized.slice(closing + 5).trim(),
};
function parseYamlDocument(raw: string): Record<string, unknown> {
return parseFrontmatterMarkdown(`---\n${raw.trim()}\n---\n`).frontmatter;
}
function renderSimpleMarkdown(frontmatter: Record<string, unknown>, body: string) {
@@ -784,7 +677,11 @@ async function readCatalogTeamSourceFiles(team: CatalogTeam): Promise<Record<str
/**
* Default safe adapter for catalog agents imported through the agent-safe path.
*/
const DEFAULT_SAFE_CATALOG_ADAPTER_TYPE = "claude_local";
const FALLBACK_SAFE_CATALOG_ADAPTER_TYPE = "claude_local";
function defaultSafeCatalogAdapterType() {
return process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE?.trim() || FALLBACK_SAFE_CATALOG_ADAPTER_TYPE;
}
/**
* Bundled catalog agents declare no adapter in their `AGENTS.md` frontmatter, so
@@ -799,11 +696,12 @@ const DEFAULT_SAFE_CATALOG_ADAPTER_TYPE = "claude_local";
function withSafeCatalogAdapterDefaults(
agentSlugs: string[],
callerOverrides: CompanyPortabilityImport["adapterOverrides"],
defaultAdapterType: string,
): Record<string, CompanyPortabilityAdapterOverride> {
const merged: Record<string, CompanyPortabilityAdapterOverride> = { ...(callerOverrides ?? {}) };
for (const slug of agentSlugs) {
if (merged[slug]) continue;
merged[slug] = { adapterType: DEFAULT_SAFE_CATALOG_ADAPTER_TYPE };
merged[slug] = { adapterType: defaultAdapterType };
}
return merged;
}
@@ -891,9 +789,9 @@ export function teamsCatalogService(db: Db) {
const files = await readCatalogTeamSourceFiles(team);
const existingExtension =
typeof files[".paperclip.yaml"] === "string"
? parseYamlFrontmatter(files[".paperclip.yaml"])
? parseYamlDocument(files[".paperclip.yaml"])
: {};
const generatedExtension = parseYamlFrontmatter(await renderCatalogProvenanceYaml(team, targetManager));
const generatedExtension = parseYamlDocument(await renderCatalogProvenanceYaml(team, targetManager));
files[".paperclip.yaml"] = renderYamlFile(mergePlainRecords(existingExtension, generatedExtension));
rewriteAgentCatalogSkillRefs(team, files);
@@ -995,11 +893,13 @@ export function teamsCatalogService(db: Db) {
throw unprocessable(`Catalog team source preparation failed: ${prepared.errors.join("; ")}`);
}
const defaultAdapterType = defaultSafeCatalogAdapterType();
const importInput: CompanyPortabilityImport = {
...buildPortabilityInput(companyId, prepared.source, options),
adapterOverrides: withSafeCatalogAdapterDefaults(
prepared.team.agentSlugs,
options.adapterOverrides,
defaultAdapterType,
),
secretValues: options.secretValues,
};
@@ -1018,10 +918,9 @@ export function teamsCatalogService(db: Db) {
...importPreview.warnings,
...(defaultedAdapterSlugs.length > 0
? [
`Catalog agents without explicit overrides (${defaultedAdapterSlugs.join(", ")}) default to ${DEFAULT_SAFE_CATALOG_ADAPTER_TYPE}. Pass adapterOverrides to use a different supported adapter.`,
`Catalog agents without explicit overrides (${defaultedAdapterSlugs.join(", ")}) default to ${defaultAdapterType}. Pass adapterOverrides or PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE to use a different supported adapter.`,
]
: []),
...await prepareSkillInstalls(companyId, prepared),
];
const result = await portability.importBundle(
importInput,
@@ -1031,6 +930,7 @@ export function teamsCatalogService(db: Db) {
sourceCompanyId: companyId,
},
);
warnings.push(...await prepareSkillInstalls(companyId, prepared));
result.warnings.push(...warnings);
await logCatalogEvent("company.team_catalog_installed", companyId, prepared.team, options.actor, {
warningCount: result.warnings.length,