From 79652da520464643b224da1a280f2c7a121ee4b9 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 20 Mar 2026 08:55:10 -0500 Subject: [PATCH] Address remaining Greptile portability feedback --- .../src/__tests__/company-portability.test.ts | 57 +++++++++++++++++++ server/src/__tests__/company-skills.test.ts | 39 +++++++++++++ server/src/services/company-portability.ts | 3 + server/src/services/company-skills.ts | 36 +++++++++--- ui/src/pages/CompanyImport.tsx | 5 +- 5 files changed, 131 insertions(+), 9 deletions(-) diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 7e4499ed..81d3d4eb 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -623,6 +623,63 @@ describe("company portability", () => { ]); }); + it("treats no-separator auth and api key env names as secrets during export", async () => { + const portability = companyPortabilityService({} as any); + + agentSvc.list.mockResolvedValue([ + { + id: "agent-1", + name: "ClaudeCoder", + status: "idle", + role: "engineer", + title: "Software Engineer", + icon: "code", + reportsTo: null, + capabilities: "Writes code", + adapterType: "claude_local", + adapterConfig: { + promptTemplate: "You are ClaudeCoder.", + env: { + APIKEY: { + type: "plain", + value: "sk-plain-api", + }, + GITHUBAUTH: { + type: "plain", + value: "gh-auth-token", + }, + PRIVATEKEY: { + type: "plain", + value: "private-key-value", + }, + }, + }, + runtimeConfig: {}, + budgetMonthlyCents: 0, + permissions: {}, + metadata: null, + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + const extension = asTextFile(exported.files[".paperclip.yaml"]); + expect(extension).toContain("APIKEY:"); + expect(extension).toContain("GITHUBAUTH:"); + expect(extension).toContain("PRIVATEKEY:"); + expect(extension).not.toContain("sk-plain-api"); + expect(extension).not.toContain("gh-auth-token"); + expect(extension).not.toContain("private-key-value"); + expect(extension).toContain('kind: "secret"'); + }); + it("imports packaged skills and restores desired skill refs on agents", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts index 77fd072e..17da7804 100644 --- a/server/src/__tests__/company-skills.test.ts +++ b/server/src/__tests__/company-skills.test.ts @@ -138,6 +138,45 @@ describe("project workspace skill discovery", () => { expect(imported.fileInventory.map((entry) => entry.kind)).toContain("script"); expect(imported.metadata?.sourceKind).toBe("project_scan"); }); + + it("parses inline object array items in skill frontmatter metadata", async () => { + const workspace = await makeTempDir("paperclip-inline-skill-yaml-"); + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile( + path.join(workspace, "SKILL.md"), + [ + "---", + "name: Inline Metadata Skill", + "metadata:", + " sources:", + " - kind: github-dir", + " repo: paperclipai/paperclip", + " path: skills/paperclip", + "---", + "", + "# Inline Metadata Skill", + "", + ].join("\n"), + "utf8", + ); + + const imported = await readLocalSkillImportFromDirectory( + "33333333-3333-4333-8333-333333333333", + workspace, + { inventoryMode: "full" }, + ); + + expect(imported.metadata).toMatchObject({ + sourceKind: "local_path", + sources: [ + { + kind: "github-dir", + repo: "paperclipai/paperclip", + path: "skills/paperclip", + }, + ], + }); + }); }); describe("missing local skill reconciliation", () => { diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index c0bb8a54..58f73d30 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -351,10 +351,12 @@ function isSensitiveEnvKey(key: string) { normalized === "token" || normalized.endsWith("_token") || normalized.endsWith("-token") || + normalized.includes("apikey") || normalized.includes("api_key") || normalized.includes("api-key") || normalized.includes("access_token") || normalized.includes("access-token") || + normalized.includes("auth") || normalized.includes("auth_token") || normalized.includes("auth-token") || normalized.includes("authorization") || @@ -364,6 +366,7 @@ function isSensitiveEnvKey(key: string) { normalized.includes("password") || normalized.includes("credential") || normalized.includes("jwt") || + normalized.includes("privatekey") || normalized.includes("private_key") || normalized.includes("private-key") || normalized.includes("cookie") || diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index c927e3b7..19aeab04 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -377,6 +377,28 @@ function parseYamlBlock( index = nested.nextIndex; continue; } + const inlineObjectSeparator = remainder.indexOf(":"); + if ( + inlineObjectSeparator > 0 && + !remainder.startsWith("\"") && + !remainder.startsWith("{") && + !remainder.startsWith("[") + ) { + const key = remainder.slice(0, inlineObjectSeparator).trim(); + const rawValue = remainder.slice(inlineObjectSeparator + 1).trim(); + const nextObject: Record = { + [key]: parseYamlScalar(rawValue), + }; + if (index < lines.length && lines[index]!.indent > indentLevel) { + const nested = parseYamlBlock(lines, index, indentLevel + 2); + if (isPlainRecord(nested.value)) { + Object.assign(nextObject, nested.value); + } + index = nested.nextIndex; + } + values.push(nextObject); + continue; + } values.push(parseYamlScalar(remainder)); } return { value: values, nextIndex: index }; @@ -804,12 +826,11 @@ export async function readLocalSkillImportFromDirectory( const markdown = await fs.readFile(skillFilePath, "utf8"); const parsed = parseFrontmatterMarkdown(markdown); const slug = deriveImportedSkillSlug(parsed.frontmatter, path.basename(resolvedSkillDir)); - const skillKey = readCanonicalSkillKey( - parsed.frontmatter, - isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null, - ); + const parsedMetadata = isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null; + const skillKey = readCanonicalSkillKey(parsed.frontmatter, parsedMetadata); const metadata = { ...(skillKey ? { skillKey } : {}), + ...(parsedMetadata ?? {}), sourceKind: "local_path", ...(options?.metadata ?? {}), }; @@ -877,12 +898,11 @@ async function readLocalSkillImports(companyId: string, sourcePath: string): Pro const markdown = await fs.readFile(resolvedPath, "utf8"); const parsed = parseFrontmatterMarkdown(markdown); const slug = deriveImportedSkillSlug(parsed.frontmatter, path.basename(path.dirname(resolvedPath))); - const skillKey = readCanonicalSkillKey( - parsed.frontmatter, - isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null, - ); + const parsedMetadata = isPlainRecord(parsed.frontmatter.metadata) ? parsed.frontmatter.metadata : null; + const skillKey = readCanonicalSkillKey(parsed.frontmatter, parsedMetadata); const metadata = { ...(skillKey ? { skillKey } : {}), + ...(parsedMetadata ?? {}), sourceKind: "local_path", }; const inventory: CompanySkillFileInventoryEntry[] = [ diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 10b00266..44d1d9a3 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -655,6 +655,9 @@ export function CompanyImport() { return ceo?.adapterType ?? "claude_local"; }, [companyAgents]); + const localZipHelpText = + "Upload a .zip exported directly from Paperclip. Re-zipped archives created by Finder, Explorer, or other zip tools may not import correctly."; + useEffect(() => { setBreadcrumbs([ { label: "Org Chart", href: "/org" }, @@ -1093,7 +1096,7 @@ export function CompanyImport() { {!localPackage && (

- Upload a `.zip` exported from Paperclip that contains COMPANY.md and the related package files. + {localZipHelpText}

)}