From 3dfb85967699f8f8de4de6659d40b51a9412e4a3 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 1 May 2026 07:41:48 -0400 Subject: [PATCH 1/7] feat(skills): GitHub PAT support for private skill repos - Add optional authToken to skill import for GitHub private repos - Store PAT as encrypted company secret (skill-pat:{skillId}) - Thread auth token through ghFetch and GitHub resolution helpers - Add PATCH /companies/:companyId/skills/:skillId/auth for managing PAT per skill - Preserve sourceAuthSecretId across skill re-imports/updates - Delete PAT secret on PAT clear and on skill deletion to prevent orphans - UI: Add PAT input field in import form for GitHub URLs - UI: Add SkillAuthSection with ShieldCheck icon for viewing/updating/removing PAT --- packages/shared/src/index.ts | 1 + .../shared/src/validators/company-skill.ts | 6 + packages/shared/src/validators/index.ts | 2 + server/src/routes/company-skills.ts | 37 ++++- server/src/services/company-skills.ts | 148 ++++++++++++++++-- server/src/services/github-fetch.ts | 8 +- ui/src/api/companySkills.ts | 9 +- ui/src/pages/CompanySkills.tsx | 125 ++++++++++++++- 8 files changed, 312 insertions(+), 24 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 72d8ca73..cf8b199e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -812,6 +812,7 @@ export { companySkillDetailSchema, companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index eb1df5ce..4cfb701c 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -68,6 +68,11 @@ export const companySkillUpdateStatusSchema = z.object({ export const companySkillImportSchema = z.object({ source: z.string().min(1), + authToken: z.string().min(1).optional(), +}); + +export const companySkillUpdateAuthSchema = z.object({ + authToken: z.string().min(1).nullable(), }); export const companySkillProjectScanRequestSchema = z.object({ @@ -135,3 +140,4 @@ export type CompanySkillImport = z.infer; export type CompanySkillProjectScan = z.infer; export type CompanySkillCreate = z.infer; export type CompanySkillFileUpdate = z.infer; +export type CompanySkillUpdateAuth = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index e9141631..fc37a3c5 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -63,6 +63,7 @@ export { companySkillDetailSchema, companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, @@ -74,6 +75,7 @@ export { type CompanySkillProjectScan, type CompanySkillCreate, type CompanySkillFileUpdate, + type CompanySkillUpdateAuth, } from "./company-skill.js"; export { agentSkillStateSchema, diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 9e91bf26..402ffcbc 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -4,6 +4,7 @@ import { companySkillCreateSchema, companySkillFileUpdateSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, } from "@paperclipai/shared"; import { trackSkillImported } from "@paperclipai/shared/telemetry"; @@ -194,7 +195,8 @@ export function companySkillRoutes(db: Db) { const companyId = req.params.companyId as string; await assertCanMutateCompanySkills(req, companyId); const source = String(req.body.source ?? ""); - const result = await svc.importFromSource(companyId, source); + const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined; + const result = await svc.importFromSource(companyId, source, authToken || undefined); const actor = getActorInfo(req); await logActivity(db, { @@ -318,5 +320,38 @@ export function companySkillRoutes(db: Db) { res.json(result); }); + router.patch( + "/companies/:companyId/skills/:skillId/auth", + validate(companySkillUpdateAuthSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanMutateCompanySkills(req, companyId); + const authToken = req.body.authToken as string | null; + const result = await svc.updateSkillAuth(companyId, skillId, authToken); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed", + entityType: "company_skill", + entityId: result.id, + details: { + slug: result.slug, + }, + }); + + res.json(result); + }, + ); + return router; } diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 0f5af2db..f29944ac 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -32,6 +32,7 @@ import { notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; +import { secretService } from "./secrets.js"; type CompanySkillRow = typeof companySkills.$inferSelect; type CompanySkillListDbRow = Pick< @@ -540,20 +541,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record(url: string): Promise { +async function fetchJson(url: string, authToken?: string): Promise { const response = await ghFetch(url, { headers: { accept: "application/vnd.github+json", }, - }); + }, authToken); if (!response.ok) { throw unprocessable(`Failed to fetch ${url}: ${response.status}`); } @@ -561,16 +562,18 @@ async function fetchJson(url: string): Promise { } -async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) { +async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string, authToken?: string) { const response = await fetchJson<{ default_branch?: string }>( `${apiBase}/repos/${owner}/${repo}`, + authToken, ); return asString(response.default_branch) ?? "main"; } -async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) { +async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string, authToken?: string) { const response = await fetchJson<{ sha?: string }>( `${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`, + authToken, ); const sha = asString(response.sha); if (!sha) { @@ -607,7 +610,7 @@ function parseGitHubSourceUrl(rawUrl: string) { return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef }; } -async function resolveGitHubPinnedRef(parsed: ReturnType) { +async function resolveGitHubPinnedRef(parsed: ReturnType, authToken?: string) { const apiBase = gitHubApiBase(parsed.hostname); if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) { return { @@ -618,8 +621,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType { const url = sourceUrl.trim(); const warnings: string[] = []; @@ -1064,10 +1068,11 @@ async function readUrlSkillImports( if (looksLikeRepoUrl) { const parsed = parseGitHubSourceUrl(url); const apiBase = gitHubApiBase(parsed.hostname); - const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed); + const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed, authToken); let ref = pinnedRef; const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>( `${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`, + authToken, ).catch(() => { throw unprocessable(`Failed to read GitHub tree for ${url}`); }); @@ -1094,7 +1099,7 @@ async function readUrlSkillImports( const skills: ImportedSkill[] = []; for (const relativeSkillPath of skillPaths) { const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath; - const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath)); + const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath), authToken); const parsedMarkdown = parseFrontmatterMarkdown(markdown); const skillDir = path.posix.dirname(relativeSkillPath); const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir)); @@ -1156,7 +1161,7 @@ async function readUrlSkillImports( } if (url.startsWith("http://") || url.startsWith("https://")) { - const markdown = await fetchText(url); + const markdown = await fetchText(url, authToken); const parsedMarkdown = parseFrontmatterMarkdown(markdown); const urlObj = new URL(url); const fileName = path.posix.basename(urlObj.pathname); @@ -1548,6 +1553,22 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: export function companySkillService(db: Db) { const agents = agentService(db); const projects = projectService(db); + const secretsSvc = secretService(db); + + async function resolveSkillAuthToken( + companyId: string, + skill: { metadata: Record | null }, + ): Promise { + const meta = skill.metadata; + if (!meta) return undefined; + const secretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId.trim() : ""; + if (!secretId) return undefined; + try { + return await secretsSvc.resolveSecretValue(companyId, secretId, "latest"); + } catch { + return undefined; + } + } async function ensureBundledSkills(companyId: string) { for (const skillsRoot of resolveBundledSkillsRoot()) { @@ -1766,7 +1787,8 @@ export function companySkillService(db: Db) { const hostname = asString(metadata.hostname) || "github.com"; const apiBase = gitHubApiBase(hostname); - const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase); + const authToken = await resolveSkillAuthToken(companyId, skill); + const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase, authToken); return { supported: true, reason: null, @@ -1810,8 +1832,9 @@ export function companySkillService(db: Db) { if (!owner || !repo) { throw unprocessable("Skill source metadata is incomplete."); } + const authToken = await resolveSkillAuthToken(companyId, skill); const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath)); - content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath)); + content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath), authToken); } else if (skill.sourceType === "url") { if (normalizedPath !== "SKILL.md") { throw notFound("This skill source only exposes SKILL.md"); @@ -1928,7 +1951,8 @@ export function companySkillService(db: Db) { throw unprocessable("Skill source locator is missing."); } - const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug); + const authToken = await resolveSkillAuthToken(companyId, skill); + const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug, authToken); const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null; if (!matching) { throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`); @@ -2340,6 +2364,9 @@ export function companySkillService(db: Db) { const metadata = { ...(skill.metadata ?? {}), skillKey: skill.key, + ...(existing?.metadata && typeof (existing.metadata as Record).sourceAuthSecretId === "string" + ? { sourceAuthSecretId: (existing.metadata as Record).sourceAuthSecretId } + : {}), }; const values = { companyId, @@ -2375,7 +2402,7 @@ export function companySkillService(db: Db) { return out; } - async function importFromSource(companyId: string, source: string): Promise { + async function importFromSource(companyId: string, source: string, authToken?: string): Promise { await ensureSkillInventoryCurrent(companyId); const parsed = parseSkillImportSourceInput(source); const local = !/^https?:\/\//i.test(parsed.resolvedSource); @@ -2385,7 +2412,7 @@ export function companySkillService(db: Db) { .filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug), warnings: parsed.warnings, } - : await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug) + : await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken) .then((result) => ({ skills: result.skills, warnings: [...parsed.warnings, ...result.warnings], @@ -2412,6 +2439,33 @@ export function companySkillService(db: Db) { } } const imported = await upsertImportedSkills(companyId, filteredSkills); + + if (authToken && imported.length > 0) { + for (const skill of imported) { + const secretName = `skill-pat:${skill.id}`; + let secretId: string; + const existing = await secretsSvc.getByName(companyId, secretName); + if (existing) { + await secretsSvc.rotate(existing.id, { value: authToken }); + secretId = existing.id; + } else { + const created = await secretsSvc.create(companyId, { + name: secretName, + provider: "local_encrypted", + value: authToken, + description: `GitHub PAT for skill ${skill.slug}`, + }); + secretId = created.id; + } + const meta = (skill.metadata ?? {}) as Record; + meta.sourceAuthSecretId = secretId; + await db + .update(companySkills) + .set({ metadata: meta, updatedAt: new Date() }) + .where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId))); + } + } + return { imported, warnings }; } @@ -2451,9 +2505,68 @@ export function companySkillService(db: Db) { // Clean up materialized runtime files await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true }); + const meta = skill.metadata as Record | null; + const secretId = typeof meta?.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null; + if (secretId) { + try { + await secretsSvc.remove(secretId); + } catch { + // Best-effort: don't fail the skill deletion if secret cleanup fails + } + } + return skill; } + async function updateSkillAuth( + companyId: string, + skillId: string, + authToken: string | null, + ): Promise { + const skill = await getById(companyId, skillId); + if (!skill) return null; + + const meta = (skill.metadata ?? {}) as Record; + const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null; + + if (authToken) { + const secretName = `skill-pat:${skill.id}`; + let secretId: string; + const existingSecret = existingSecretId + ? await secretsSvc.getById(existingSecretId) + : await secretsSvc.getByName(companyId, secretName); + if (existingSecret) { + await secretsSvc.rotate(existingSecret.id, { value: authToken }); + secretId = existingSecret.id; + } else { + const created = await secretsSvc.create(companyId, { + name: secretName, + provider: "local_encrypted", + value: authToken, + description: `GitHub PAT for skill ${skill.slug}`, + }); + secretId = created.id; + } + meta.sourceAuthSecretId = secretId; + } else { + if (existingSecretId) { + try { + await secretsSvc.remove(existingSecretId); + } catch { + // Best-effort: don't fail the metadata update if secret deletion fails + } + } + delete meta.sourceAuthSecretId; + } + + const [updated] = await db + .update(companySkills) + .set({ metadata: meta, updatedAt: new Date() }) + .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))) + .returning(); + return updated ? toCompanySkill(updated) : null; + } + return { list, listFull, @@ -2470,6 +2583,7 @@ export function companySkillService(db: Db) { createLocalSkill, deleteSkill, importFromSource, + updateSkillAuth, scanProjectWorkspaces, importPackageFiles, installUpdate, diff --git a/server/src/services/github-fetch.ts b/server/src/services/github-fetch.ts index 787ae0ef..e8f8aee5 100644 --- a/server/src/services/github-fetch.ts +++ b/server/src/services/github-fetch.ts @@ -16,9 +16,13 @@ export function resolveRawGitHubUrl(hostname: string, owner: string, repo: strin : `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`; } -export async function ghFetch(url: string, init?: RequestInit): Promise { +export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise { + const headers = new Headers(init?.headers); + if (authToken) { + headers.set("Authorization", `Bearer ${authToken}`); + } try { - return await fetch(url, init); + return await fetch(url, { ...init, headers }); } catch { throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`); } diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index 7377b2fa..1ce25503 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -36,10 +36,15 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills`, payload, ), - importFromSource: (companyId: string, source: string) => + importFromSource: (companyId: string, source: string, authToken?: string) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/import`, - { source }, + { source, ...(authToken ? { authToken } : {}) }, + ), + updateAuth: (companyId: string, skillId: string, authToken: string | null) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`, + { authToken }, ), scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) => api.post( diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 87f6e811..289a6b51 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -52,6 +52,7 @@ import { RefreshCw, Save, Search, + ShieldCheck, Trash2, } from "lucide-react"; @@ -487,6 +488,103 @@ function SkillList({ ); } +function SkillAuthSection({ + companyId, + skillId, + hasAuth, +}: { + companyId: string; + skillId: string; + hasAuth: boolean; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + const [editing, setEditing] = useState(false); + const [token, setToken] = useState(""); + + const updateAuth = useMutation({ + mutationFn: (authToken: string | null) => + companySkillsApi.updateAuth(companyId, skillId, authToken), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) }); + setEditing(false); + setToken(""); + pushToast({ tone: "success", title: "Auth updated" }); + }, + onError: (error) => { + pushToast({ + tone: "error", + title: "Failed to update auth", + body: error instanceof Error ? error.message : "Unknown error", + }); + }, + }); + + return ( +
+ Auth + {!editing ? ( + <> + {hasAuth ? ( + <> + + + + ) : ( + + )} + + ) : ( + <> + setToken(e.target.value)} + placeholder="GitHub Personal Access Token" + className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50" + autoComplete="off" + autoFocus + /> + + + + )} +
+ ); +} + function SkillPane({ loading, detail, @@ -614,6 +712,13 @@ function SkillPane({ )} + {(detail.sourceType === "github" || detail.sourceType === "skills_sh") && ( + | null)?.sourceAuthSecretId)} + /> + )} {detail.sourceType === "github" && (
Pin @@ -762,6 +867,7 @@ export function CompanySkills() { const { pushToast } = useToastActions(); const [skillFilter, setSkillFilter] = useState(""); const [source, setSource] = useState(""); + const [importAuthToken, setImportAuthToken] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false); const [expandedSkillId, setExpandedSkillId] = useState(null); @@ -887,7 +993,8 @@ export function CompanySkills() { } const importSkill = useMutation({ - mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource), + mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) => + companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken), onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }); if (result.imported[0]) navigate(skillRoute(result.imported[0].id)); @@ -900,6 +1007,7 @@ export function CompanySkills() { pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] }); } setSource(""); + setImportAuthToken(""); }, onError: (error) => { pushToast({ @@ -1073,7 +1181,8 @@ export function CompanySkills() { setEmptySourceHelpOpen(true); return; } - importSkill.mutate(trimmedSource); + const token = importAuthToken.trim() || undefined; + importSkill.mutate({ importSource: trimmedSource, authToken: token }); } return ( @@ -1220,6 +1329,18 @@ export function CompanySkills() { {importSkill.isPending ? : "Add"}
+ {source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && ( +
+ setImportAuthToken(event.target.value)} + placeholder="GitHub PAT (optional, for private repos)" + className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + autoComplete="off" + /> +
+ )} {scanStatusMessage && (

{scanStatusMessage} From d1d592d793111272e2f87550c12b5dea5948345a Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 1 May 2026 07:41:57 -0400 Subject: [PATCH 2/7] fix(security): use manual redirects when PAT is attached Token-free requests follow redirects normally to support renamed/transferred GitHub repos. Manual redirect policy is only needed when a PAT is attached, to prevent the bearer token from being forwarded to attacker-controlled redirect targets. --- server/src/services/github-fetch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/services/github-fetch.ts b/server/src/services/github-fetch.ts index e8f8aee5..c279ace5 100644 --- a/server/src/services/github-fetch.ts +++ b/server/src/services/github-fetch.ts @@ -22,7 +22,7 @@ export async function ghFetch(url: string, init?: RequestInit, authToken?: strin headers.set("Authorization", `Bearer ${authToken}`); } try { - return await fetch(url, { ...init, headers }); + return await fetch(url, { ...init, headers, redirect: authToken ? "manual" : "follow" }); } catch { throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`); } From 7b12d907cc785e9d855ee9e2e07a59de04f2c52c Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 1 May 2026 07:43:02 -0400 Subject: [PATCH 3/7] feat(skills): scan re-scans existing GitHub/sks_sh sources for new skills When the project workspace scan runs, also iterate the source locators of all accepted GitHub and sks_sh skills, re-fetch each source, and upsert any skills that have appeared since the last import. Per-source failures are collected as warnings instead of aborting the whole scan. --- server/src/services/company-skills.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index f29944ac..790dd02d 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2127,6 +2127,28 @@ export function companySkillService(db: Db) { } } + const sourceLocators = new Set(); + for (const skill of acceptedSkills) { + if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") continue; + const locator = skill.sourceLocator ?? ""; + if (locator) sourceLocators.add(locator); + } + for (const sourceLocator of sourceLocators) { + try { + const result = await readUrlSkillImports(companyId, sourceLocator, null); + for (const nextSkill of result.skills) { + if (acceptedSkills.some((s) => s.slug === nextSkill.slug)) continue; + const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0]; + if (persisted) { + imported.push(persisted); + upsertAcceptedSkill(persisted); + } + } + } catch { + warnings.push(`Could not re-scan source ${sourceLocator} — skipping.`); + } + } + return { scannedProjects: scannedProjectIds.size, scannedWorkspaces: scanTargets.length, From 9e30b72b27271e5343456f06419cc181f041b07b Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 1 May 2026 07:58:51 -0400 Subject: [PATCH 4/7] test(skills): cover PAT import, skill auth, and scan-projects routes - importFromSource is invoked with the PAT when one is supplied in the body - PATCH /skills/:id/auth updates and clears tokens, with matching activity log entries - POST /skills/scan-projects is reachable to agents that hold canCreateAgents - Update existing import-permission assertion to include the new authToken arg --- .../__tests__/company-skills-routes.test.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index d18bc1f4..86ebc9da 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -14,6 +14,8 @@ const mockAccessService = vi.hoisted(() => ({ const mockCompanySkillService = vi.hoisted(() => ({ importFromSource: vi.fn(), deleteSkill: vi.fn(), + updateSkillAuth: vi.fn(), + scanProjectWorkspaces: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn()); @@ -97,6 +99,15 @@ describe("company skill mutation permissions", () => { slug: "find-skills", name: "Find Skills", }); + mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({ + scannedProjects: 1, + scannedWorkspaces: 2, + discovered: [], + imported: [], + updated: [], + conflicts: [], + warnings: [], + }); mockLogActivity.mockResolvedValue(undefined); mockAccessService.canUser.mockResolvedValue(true); mockAccessService.hasPermission.mockResolvedValue(false); @@ -294,9 +305,120 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", + undefined, ); }); + it("passes a PAT through skill import requests", async () => { + const res = await request(await createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .post("/api/companies/company-1/skills/import") + .send({ + source: "https://github.com/vercel-labs/agent-browser", + authToken: "ghp_private_token", + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( + "company-1", + "https://github.com/vercel-labs/agent-browser", + "ghp_private_token", + ); + }); + + it("updates a skill auth token", async () => { + mockCompanySkillService.updateSkillAuth.mockResolvedValue({ + id: "skill-1", + slug: "find-skills", + }); + + const res = await request(await createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .patch("/api/companies/company-1/skills/skill-1/auth") + .send({ authToken: "ghp_private_token" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockCompanySkillService.updateSkillAuth).toHaveBeenCalledWith( + "company-1", + "skill-1", + "ghp_private_token", + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + companyId: "company-1", + action: "company.skill_auth_updated", + entityType: "company_skill", + entityId: "skill-1", + details: { slug: "find-skills" }, + }), + ); + }); + + it("clears a skill auth token", async () => { + mockCompanySkillService.updateSkillAuth.mockResolvedValue({ + id: "skill-1", + slug: "find-skills", + }); + + const res = await request(await createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .patch("/api/companies/company-1/skills/skill-1/auth") + .send({ authToken: null }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockCompanySkillService.updateSkillAuth).toHaveBeenCalledWith( + "company-1", + "skill-1", + null, + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + companyId: "company-1", + action: "company.skill_auth_removed", + entityType: "company_skill", + entityId: "skill-1", + details: { slug: "find-skills" }, + }), + ); + }); + + it("allows agents with canCreateAgents to scan project workspaces", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + permissions: { canCreateAgents: true }, + }); + + const res = await request(await createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + runId: "run-1", + })) + .post("/api/companies/company-1/skills/scan-projects") + .send({}); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenCalledWith("company-1", {}); + }); + it("returns a blocking error when attempting to delete a skill still used by agents", async () => { const { unprocessable } = await import("../errors.js"); mockCompanySkillService.deleteSkill.mockImplementationOnce(async () => { From e8579d5c66b3af82ff06bc5cb5598798e8cca54e Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 1 May 2026 08:18:50 -0400 Subject: [PATCH 5/7] =?UTF-8?q?feat(import-export):=20complete=20company?= =?UTF-8?q?=20portability=20=E2=80=94=20secrets=20export/import=20and=20en?= =?UTF-8?q?v=20round-tripping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in secret export/import: secret values are resolved (and optionally decrypted) into the portability manifest, and re-created with conflict handling on import. Fixes env round-tripping so both secret_ref and plain bindings survive export/import cycles. --- packages/shared/src/index.ts | 1 + .../shared/src/types/company-portability.ts | 16 +- packages/shared/src/types/index.ts | 1 + .../src/validators/company-portability.ts | 11 + .../src/__tests__/company-portability.test.ts | 217 +++++++++++++- server/src/services/company-portability.ts | 275 ++++++++++++++++-- ui/src/pages/CompanyExport.tsx | 60 ++++ ui/src/pages/CompanyImport.tsx | 19 ++ 8 files changed, 579 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 72d8ca73..9a2420b0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -473,6 +473,7 @@ export type { CompanyPortabilityImportRequest, CompanyPortabilityImportResult, CompanyPortabilityExportRequest, + CompanyPortabilitySecretEntry, EnvBinding, AgentEnvConfig, CompanySecret, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 1b30713c..ea5e7b1c 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -1,4 +1,4 @@ -import type { AgentEnvConfig } from "./secrets.js"; +import type { AgentEnvConfig, SecretProvider } from "./secrets.js"; import type { RoutineVariable } from "./routine.js"; export interface CompanyPortabilityInclude { @@ -18,6 +18,10 @@ export interface CompanyPortabilityEnvInput { requirement: "required" | "optional"; defaultValue: string | null; portability: "portable" | "system_dependent"; + secretName?: string | null; + secretProvider?: string | null; + /** Binding type — stored in extension.inputs.env but not in the manifest type itself */ + type?: "secret_ref" | "plain"; } export type CompanyPortabilityFileEntry = @@ -166,6 +170,15 @@ export interface CompanyPortabilityManifest { projects: CompanyPortabilityProjectManifestEntry[]; issues: CompanyPortabilityIssueManifestEntry[]; envInputs: CompanyPortabilityEnvInput[]; + secrets?: CompanyPortabilitySecretEntry[]; +} + +export interface CompanyPortabilitySecretEntry { + name: string; + provider: SecretProvider; + description: string | null; + latestVersion: number; + currentValue: string; } export interface CompanyPortabilityExportResult { @@ -317,4 +330,5 @@ export interface CompanyPortabilityExportRequest { selectedFiles?: string[]; expandReferencedSkills?: boolean; sidebarOrder?: Partial; + includeSecrets?: boolean; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a574f854..7328c561 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -304,6 +304,7 @@ export type { CompanyPortabilityImportRequest, CompanyPortabilityImportResult, CompanyPortabilityExportRequest, + CompanyPortabilitySecretEntry, } from "./company-portability.js"; export type { JsonSchema, diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index ec9df1c2..e25d17c1 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -21,6 +21,9 @@ export const portabilityEnvInputSchema = z.object({ requirement: z.enum(["required", "optional"]), defaultValue: z.string().nullable(), portability: z.enum(["portable", "system_dependent"]), + secretName: z.string().min(1).nullable().optional(), + secretProvider: z.string().min(1).nullable().optional(), + type: z.enum(["secret_ref", "plain"]).optional(), }); export const portabilityFileEntrySchema = z.union([ @@ -175,6 +178,13 @@ export const portabilityManifestSchema = z.object({ projects: z.array(portabilityProjectManifestEntrySchema).default([]), issues: z.array(portabilityIssueManifestEntrySchema).default([]), envInputs: z.array(portabilityEnvInputSchema).default([]), + secrets: z.array(z.object({ + name: z.string().min(1), + provider: z.string().min(1), + description: z.string().nullable(), + latestVersion: z.number().int().nonnegative(), + currentValue: z.string(), + })).optional(), }); export const portabilitySourceSchema = z.discriminatedUnion("type", [ @@ -217,6 +227,7 @@ export const companyPortabilityExportSchema = z.object({ selectedFiles: z.array(z.string().min(1)).optional(), expandReferencedSkills: z.boolean().optional(), sidebarOrder: portabilitySidebarOrderSchema.partial().optional(), + includeSecrets: z.boolean().optional(), }); export type CompanyPortabilityExport = z.infer; diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 01794385..267d88c0 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -14,6 +14,7 @@ const companySvc = { const agentSvc = { list: vi.fn(), + getById: vi.fn(), create: vi.fn(), update: vi.fn(), }; @@ -27,6 +28,7 @@ const accessSvc = { const projectSvc = { list: vi.fn(), + getById: vi.fn(), create: vi.fn(), update: vi.fn(), createWorkspace: vi.fn(), @@ -62,6 +64,26 @@ const assetSvc = { const secretSvc = { normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ config, secretKeys: new Set() })), + normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: unknown) => env as Record), + getById: vi.fn(async (id: string) => { + if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" }; + if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" }; + return null; + }), + resolveSecretValue: vi.fn(async (_companyId: string, secretId: string, _version: "latest") => { + if (secretId === "secret-1") return "sk-ant-secret-xxx"; + if (secretId === "secret-2") return "ghp_secretxxx"; + throw new Error("Secret not found"); + }), + create: vi.fn(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({ + id: `new-secret-${input.name}`, + companyId, + name: input.name, + provider: input.provider, + description: input.description ?? null, + latestVersion: 1, + })), + getByName: vi.fn(async (_companyId: string, name: string) => null), }; const agentInstructionsSvc = { @@ -448,7 +470,6 @@ describe("company portability", () => { expect(extension).not.toContain("instructionsFilePath"); expect(extension).not.toContain("command:"); expect(extension).not.toContain("secretId"); - expect(extension).not.toContain('type: "secret_ref"'); expect(extension).toContain("inputs:"); expect(extension).toContain("ANTHROPIC_API_KEY:"); expect(extension).toContain('requirement: "optional"'); @@ -1199,6 +1220,9 @@ describe("company portability", () => { requirement: "optional", defaultValue: "", portability: "portable", + secretName: "anthropic-api-key", + secretProvider: "local_encrypted", + type: "secret_ref", }, { key: "GH_TOKEN", @@ -1209,6 +1233,9 @@ describe("company portability", () => { requirement: "optional", defaultValue: "", portability: "portable", + secretName: "gh-token", + secretProvider: "local_encrypted", + type: "secret_ref", }, ]); }); @@ -1332,6 +1359,9 @@ describe("company portability", () => { requirement: "optional", defaultValue: "", portability: "portable", + secretName: null, + secretProvider: null, + type: "plain", }); }); @@ -2646,6 +2676,191 @@ describe("company portability", () => { })); }); + describe("secret env vars", () => { + beforeEach(() => { + // Reset create/getByName to ensure clean state per test + secretSvc.create.mockReset(); + secretSvc.getByName.mockReset(); + secretSvc.getById.mockImplementation(async (id: string) => { + if (id === "secret-1") return { id: "secret-1", name: "anthropic-api-key", provider: "local_encrypted" }; + if (id === "secret-2") return { id: "secret-2", name: "gh-token", provider: "local_encrypted" }; + return null; + }); + secretSvc.resolveSecretValue.mockImplementation(async (_companyId: string, secretId: string) => { + if (secretId === "secret-1") return "sk-ant-secret-xxx"; + if (secretId === "secret-2") return "ghp_secretxxx"; + throw new Error("Secret not found"); + }); + secretSvc.create.mockImplementation(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => ({ + id: `new-secret-${input.name}`, + companyId, + name: input.name, + provider: input.provider, + description: input.description ?? null, + latestVersion: 1, + })); + secretSvc.getByName.mockResolvedValue(null); + }); + + it("exports secret env var metadata with secretName and secretProvider", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { agents: true, company: false, projects: false, issues: false, skills: false }, + agents: ["claudecoder"], + }); + const secretInput = exported.manifest.envInputs.find( + (e: any) => e.key === "ANTHROPIC_API_KEY" && e.kind === "secret", + ); + expect(secretInput).toBeDefined(); + expect(secretInput.secretName).toBe("anthropic-api-key"); + expect(secretInput.secretProvider).toBe("local_encrypted"); + }); + + it("exports secret values to manifest when includeSecrets is true", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { agents: true, company: false, projects: false, issues: false, skills: false }, + agents: ["claudecoder"], + includeSecrets: true, + }); + expect(exported.manifest.secrets).toBeDefined(); + expect(exported.manifest.secrets).toContainEqual(expect.objectContaining({ + name: "anthropic-api-key", + provider: "local_encrypted", + currentValue: "sk-ant-secret-xxx", + })); + }); + + it("omits secrets section when includeSecrets is false", async () => { + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { agents: true, company: false, projects: false, issues: false, skills: false }, + agents: ["claudecoder"], + includeSecrets: false, + }); + expect(exported.manifest.secrets).toBeUndefined(); + }); + + it("writes placeholder when resolveSecretValue throws (cross-instance decryption failure)", async () => { + secretSvc.resolveSecretValue.mockImplementation(async () => { + throw new Error("Decryption failed: missing master key"); + }); + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { agents: true, company: false, projects: false, issues: false, skills: false }, + agents: ["claudecoder"], + includeSecrets: true, + }); + const secretEntry = exported.manifest.secrets?.find((s: any) => s.name === "anthropic-api-key"); + expect(secretEntry?.currentValue).toBe(""); + expect(exported.warnings).toContainEqual(expect.stringContaining("could not be decrypted during export")); + }); + + it("imports secrets and remaps secret_ref bindings to new secret IDs", async () => { + const portability = companyPortabilityService({} as any); + agentSvc.create.mockImplementation(async (companyId: string, patch: Record) => ({ + id: "new-agent-1", + companyId, + ...patch, + })); + agentSvc.update.mockImplementation(async (id: string, patch: Record) => patch as any); + agentSvc.getById.mockImplementation(async (id: string) => { + if (id === "new-agent-1") { + return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } }; + } + return null; + }); + const exported = await portability.exportBundle("company-1", { + include: { agents: true, company: false, projects: false, issues: false, skills: false }, + agents: ["claudecoder"], + includeSecrets: true, + }); + const imported = await portability.importBundle({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { agents: true, company: false, projects: false, issues: false, skills: false }, + target: { mode: "existing_company", companyId: "company-imported" }, + agents: ["claudecoder"], + collisionStrategy: "rename", + }, "user-1"); + expect(secretSvc.create).toHaveBeenCalled(); + expect(agentSvc.update).toHaveBeenCalledWith( + "new-agent-1", + expect.any(Object), + ); + }); + + it("reuses existing secret on conflict during import", async () => { + secretSvc.getByName.mockImplementation(async (_companyId: string, name: string) => { + if (name === "anthropic-api-key") return { id: "existing-secret-1", name, provider: "local_encrypted" }; + return null; + }); + const portability = companyPortabilityService({} as any); + agentSvc.create.mockImplementation(async (companyId: string, patch: Record) => ({ + id: "new-agent-1", + companyId, + ...patch, + })); + agentSvc.update.mockImplementation(async (id: string, patch: Record) => patch as any); + agentSvc.getById.mockImplementation(async (id: string) => { + if (id === "new-agent-1") { + return { id: "new-agent-1", adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "placeholder-secret" } } } }; + } + return null; + }); + const exported = await portability.exportBundle("company-1", { + include: { agents: true, company: false, projects: false, issues: false, skills: false }, + agents: ["claudecoder"], + includeSecrets: true, + }); + await portability.importBundle({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { agents: true, company: false, projects: false, issues: false, skills: false }, + target: { mode: "existing_company", companyId: "company-imported" }, + agents: ["claudecoder"], + collisionStrategy: "rename", + }, "user-1"); + expect(agentSvc.update).toHaveBeenCalled(); + }); + + it("exports plain env vars faithfully", async () => { + agentSvc.list.mockResolvedValue([{ + id: "agent-1", + name: "TestAgent", + status: "idle", + role: "agent", + title: null, + icon: null, + reportsTo: null, + capabilities: null, + adapterType: "process", + adapterConfig: { + env: { + PLAIN_VAR: { type: "plain", value: "plain-value" }, + ANOTHER_VAR: { type: "plain", value: "another-value" }, + }, + }, + runtimeConfig: {}, + permissions: {}, + budgetMonthlyCents: 0, + metadata: null, + }]); + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { + include: { agents: true, company: false, projects: false, issues: false, skills: false }, + agents: ["testagent"], + }); + const plainInputs = exported.manifest.envInputs.filter((e: any) => e.kind === "plain"); + expect(plainInputs).toContainEqual(expect.objectContaining({ + key: "PLAIN_VAR", + defaultValue: "plain-value", + })); + expect(plainInputs).toContainEqual(expect.objectContaining({ + key: "ANOTHER_VAR", + defaultValue: "another-value", + })); + }); + }); + it("nameOverrides applied after collision detection do not re-validate uniqueness", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 88b66f6d..b7e186a2 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -26,9 +26,11 @@ import type { CompanyPortabilityIssueManifestEntry, CompanyPortabilitySidebarOrder, CompanyPortabilitySkillManifestEntry, + CompanyPortabilitySecretEntry, CompanySkill, AgentEnvConfig, RoutineVariable, + SecretProvider, } from "@paperclipai/shared"; import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, @@ -50,7 +52,7 @@ import { } from "@paperclipai/adapter-utils/server-utils"; import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; import { findServerAdapter } from "../adapters/index.js"; -import { forbidden, notFound, unprocessable } from "../errors.js"; +import { forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import type { StorageService } from "../storage/types.js"; import { accessService } from "./access.js"; @@ -399,7 +401,7 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null { return parsed.success ? parsed.data : null; } -function extractPortableScopedEnvInputs( +async function extractPortableScopedEnvInputs( scope: { label: string; warningPrefix: string; @@ -408,7 +410,11 @@ function extractPortableScopedEnvInputs( }, envValue: unknown, warnings: string[], -): CompanyPortabilityEnvInput[] { + secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise }, + secretEntries: CompanyPortabilitySecretEntry[], + includeSecrets: boolean, + companyId: string, +): Promise { if (!isPlainRecord(envValue)) return []; const env = envValue as Record; const inputs: CompanyPortabilityEnvInput[] = []; @@ -420,6 +426,7 @@ function extractPortableScopedEnvInputs( } if (isPlainRecord(binding) && binding.type === "secret_ref") { + const secret = await secrets.getById(String(binding.secretId)); inputs.push({ key, description: `Provide ${key} for ${scope.label}`, @@ -429,7 +436,33 @@ function extractPortableScopedEnvInputs( requirement: "optional", defaultValue: "", portability: "portable", + secretName: secret?.name ?? null, + secretProvider: secret?.provider ?? null, }); + if (includeSecrets && secret && binding.secretId) { + const alreadyExported = secretEntries.some((e) => e.name === secret.name); + if (!alreadyExported) { + try { + const resolvedValue = await secrets.resolveSecretValue(companyId, String(binding.secretId), "latest"); + secretEntries.push({ + name: secret.name, + provider: secret.provider as SecretProvider, + description: secret.description, + latestVersion: secret.latestVersion, + currentValue: resolvedValue, + }); + } catch { + secretEntries.push({ + name: secret.name, + provider: secret.provider as SecretProvider, + description: secret.description, + latestVersion: secret.latestVersion, + currentValue: ``, + }); + warnings.push(`Secret "${secret.name}" could not be decrypted during export. Placeholder written.`); + } + } + } continue; } @@ -439,9 +472,6 @@ function extractPortableScopedEnvInputs( const portability = defaultValue && isAbsoluteCommand(defaultValue) ? "system_dependent" : "portable"; - if (portability === "system_dependent") { - warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`); - } inputs.push({ key, description: `Optional default for ${key} on ${scope.label}`, @@ -457,9 +487,6 @@ function extractPortableScopedEnvInputs( if (typeof binding === "string") { const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable"; - if (portability === "system_dependent") { - warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`); - } inputs.push({ key, description: `Optional default for ${key} on ${scope.label}`, @@ -567,11 +594,14 @@ type AgentLike = { }; type EnvInputRecord = { + type?: "secret_ref" | "plain"; kind: "secret" | "plain"; requirement: "required" | "optional"; default?: string | null; description?: string | null; portability?: "portable" | "system_dependent"; + secretName?: string | null; + secretProvider?: string | null; }; const COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS: Record = { @@ -1623,11 +1653,15 @@ function isAbsoluteCommand(value: string) { return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value); } -function extractPortableEnvInputs( +async function extractPortableEnvInputs( agentSlug: string, envValue: unknown, warnings: string[], -): CompanyPortabilityEnvInput[] { + secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise }, + secretEntries: CompanyPortabilitySecretEntry[], + includeSecrets: boolean, + companyId: string, +): Promise { return extractPortableScopedEnvInputs( { label: `agent ${agentSlug}`, @@ -1637,14 +1671,22 @@ function extractPortableEnvInputs( }, envValue, warnings, + secrets, + secretEntries, + includeSecrets, + companyId, ); } -function extractPortableProjectEnvInputs( +async function extractPortableProjectEnvInputs( projectSlug: string, envValue: unknown, warnings: string[], -): CompanyPortabilityEnvInput[] { + secrets: { getById: (id: string) => Promise<{ name: string; provider: string; description: string | null; latestVersion: number } | null>; resolveSecretValue: (companyId: string, secretId: string, version: "latest") => Promise }, + secretEntries: CompanyPortabilitySecretEntry[], + includeSecrets: boolean, + companyId: string, +): Promise { return extractPortableScopedEnvInputs( { label: `project ${projectSlug}`, @@ -1654,6 +1696,10 @@ function extractPortableProjectEnvInputs( }, envValue, warnings, + secrets, + secretEntries, + includeSecrets, + companyId, ); } @@ -2258,6 +2304,13 @@ function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) { if (input.defaultValue !== null) entry.default = input.defaultValue; if (input.description) entry.description = input.description; if (input.portability === "system_dependent") entry.portability = "system_dependent"; + if (input.secretName) { + entry.secretName = input.secretName; + entry.type = "secret_ref"; + } else { + entry.type = "plain"; + } + if (input.secretProvider) entry.secretProvider = input.secretProvider; env[input.key] = entry; } return env; @@ -2302,6 +2355,9 @@ function readAgentEnvInputs( requirement: record.requirement === "required" ? "required" : "optional", defaultValue: typeof record.default === "string" ? record.default : null, portability: record.portability === "system_dependent" ? "system_dependent" : "portable", + secretName: record.secretName ?? null, + secretProvider: record.secretProvider ?? null, + type: record.type, }]; }); } @@ -2326,6 +2382,9 @@ function readProjectEnvInputs( requirement: record.requirement === "required" ? "required" : "optional", defaultValue: typeof record.default === "string" ? record.default : null, portability: record.portability === "system_dependent" ? "system_dependent" : "portable", + secretName: record.secretName ?? null, + secretProvider: record.secretProvider ?? null, + type: record.type, }]; }); } @@ -2372,6 +2431,7 @@ function buildManifestFromPackageFiles( const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {}; const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {}; const paperclipRoutines = isPlainRecord(paperclipExtension.routines) ? paperclipExtension.routines : {}; + const paperclipSecrets = Array.isArray(paperclipExtension.secrets) ? paperclipExtension.secrets : []; const companyName = asString(companyFrontmatter.name) ?? opts?.sourceLabel?.companyName @@ -2455,6 +2515,7 @@ function buildManifestFromPackageFiles( projects: [], issues: [], envInputs: [], + secrets: paperclipSecrets.length > 0 ? paperclipSecrets : undefined, }; const warnings: string[] = []; @@ -2969,7 +3030,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const files: Record = {}; const warnings: string[] = []; const envInputs: CompanyPortabilityManifest["envInputs"] = []; + const secretEntries: CompanyPortabilitySecretEntry[] = []; const requestedSidebarOrder = normalizePortableSidebarOrder(input.sidebarOrder); + const includeSecrets = input.includeSecrets === true; const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package"; let companyLogoPath: string | null = null; @@ -3249,10 +3312,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { warnings.push(...exportedInstructions.warnings); const envInputsStart = envInputs.length; - const exportedEnvInputs = extractPortableEnvInputs( + const exportedEnvInputs = await extractPortableEnvInputs( slug, (agent.adapterConfig as Record).env, warnings, + secrets, + secretEntries, + includeSecrets, + companyId, ); envInputs.push(...exportedEnvInputs); const adapterDefaultRules = ADAPTER_DEFAULT_RULES_BY_TYPE[agent.adapterType] ?? []; @@ -3329,7 +3396,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const slug = projectSlugById.get(project.id)!; const projectPath = `projects/${slug}/PROJECT.md`; const envInputsStart = envInputs.length; - const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings); + const exportedEnvInputs = await extractPortableProjectEnvInputs(slug, project.env, warnings, secrets, secretEntries, includeSecrets, companyId); envInputs.push(...exportedEnvInputs); const projectEnvInputs = dedupeEnvInputs( envInputs @@ -3534,8 +3601,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { skills: resolved.manifest.skills.length > 0, }; resolved.manifest.envInputs = dedupeEnvInputs(envInputs); + if (includeSecrets) { + resolved.manifest.secrets = secretEntries.length > 0 ? secretEntries : undefined; + } resolved.warnings.unshift(...warnings); + // Rebuild the YAML file to include secrets so files stay in sync with manifest + // Only include secrets - other fields should come from the original YAML structure + if (includeSecrets && resolved.manifest.secrets) { + // Parse existing YAML and add secrets to it + const existingYaml = parseYamlFile(readPortableTextFile(finalFiles, paperclipExtensionPath) ?? "") ?? {}; + existingYaml.secrets = resolved.manifest.secrets; + finalFiles[paperclipExtensionPath] = buildYamlFile(existingYaml, { preserveEmptyStrings: true }); + } + return { rootPath, manifest: resolved.manifest, @@ -4093,6 +4172,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const resultAgents: CompanyPortabilityImportResult["agents"] = []; const resultProjects: CompanyPortabilityImportResult["projects"] = []; const importedSlugToAgentId = new Map(); + const secretNameToId = new Map(); const existingSlugToAgentId = new Map(); const agentStatusById = new Map(); const existingAgents = await agents.list(targetCompany.id); @@ -4124,6 +4204,35 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } } + // Create secrets in target company and build name->id map + for (const secretEntry of sourceManifest.secrets ?? []) { + if (secretEntry.currentValue.startsWith(" agent.slug === planAgent.slug); @@ -4180,6 +4289,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { desiredSkills, mode, ); + + // Reconstruct adapterConfig.env from manifest.envInputs for this agent + const agentEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.agentSlug === manifestAgent.slug); + if (agentEnvInputs.length > 0) { + const env: Record = {}; + for (const ei of agentEnvInputs) { + if (ei.kind === "secret" && ei.secretName) { + const newSecretId = secretNameToId.get(ei.secretName); + if (newSecretId) { + env[ei.key] = { type: "secret_ref", secretId: newSecretId }; + } else { + warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`); + } + } else if (ei.kind === "secret" && !ei.secretName) { + warnings.push(`Env key "${ei.key}" for agent ${manifestAgent.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`); + } else if (ei.kind === "plain" && ei.defaultValue !== null) { + env[ei.key] = { type: "plain", value: ei.defaultValue }; + } + } + if (Object.keys(env).length > 0) { + normalizedAdapter.adapterConfig.env = await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, env as any, { strictMode: strictSecretsMode }); + } + } + const patch = { name: planAgent.plannedName, role: manifestAgent.role, @@ -4230,10 +4363,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { continue; } - const createdStatus = "idle"; let created = await agents.create(targetCompany.id, { ...patch, - status: createdStatus, + status: "idle", }); await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); await access.setPrincipalPermission( @@ -4253,7 +4385,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } catch (err) { warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`); } - agentStatusById.set(created.id, created.status ?? createdStatus); + agentStatusById.set(created.id, created.status ?? "idle"); importedSlugToAgentId.set(planAgent.slug, created.id); existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id); resultAgents.push({ @@ -4302,6 +4434,26 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { ?? null : null; const projectWorkspaceIdByKey = new Map(); + // Build project env from manifest.envInputs filtered by this project + const projectEnvInputs = (sourceManifest.envInputs ?? []).filter((e) => e.projectSlug === planProject.slug); + const reconstructedProjectEnv: Record = {}; + for (const ei of projectEnvInputs) { + if (ei.kind === "secret" && ei.secretName) { + const newSecretId = secretNameToId.get(ei.secretName); + if (newSecretId) { + reconstructedProjectEnv[ei.key] = { type: "secret_ref", secretId: newSecretId }; + } else { + warnings.push(`Env key "${ei.key}" for project ${planProject.slug} references secret "${ei.secretName}" which was not included in this package. Re-add manually.`); + } + } else if (ei.kind === "secret" && !ei.secretName) { + warnings.push(`Env key "${ei.key}" for project ${planProject.slug} could not be reconstructed (sensitive binding without secret reference). Re-add manually.`); + } else if (ei.kind === "plain" && ei.defaultValue !== null) { + reconstructedProjectEnv[ei.key] = { type: "plain", value: ei.defaultValue }; + } + } + const projectEnvConfig = Object.keys(reconstructedProjectEnv).length > 0 + ? await secrets.normalizeEnvBindingsForPersistence(targetCompany.id, reconstructedProjectEnv as any, { strictMode: strictSecretsMode }) + : null; const projectPatch = { name: planProject.plannedName, description: manifestProject.description, @@ -4311,7 +4463,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any) ? manifestProject.status as typeof PROJECT_STATUSES[number] : "backlog", - env: manifestProject.env, + env: projectEnvConfig ?? undefined, executionWorkspacePolicy: stripPortableProjectExecutionWorkspaceRefs(manifestProject.executionWorkspacePolicy), }; @@ -4390,6 +4542,91 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } } + // Remap secret_ref bindings in imported agent/project records to target company secret IDs + for (const envInput of sourceManifest.envInputs ?? []) { + if (envInput.kind !== "secret" || !envInput.secretName) continue; + const newSecretId = secretNameToId.get(envInput.secretName); + if (!newSecretId) { + // secret wasn't created (decryption failure or error) — it's already a placeholder in the env + continue; + } + if (envInput.agentSlug) { + const agentId = importedSlugToAgentId.get(envInput.agentSlug); + if (agentId) { + const agent = await agents.getById(agentId); + if (agent) { + const adapterConfig = agent.adapterConfig as Record; + const env = adapterConfig.env as Record | undefined; + let mutated = false; + if (env && typeof env[envInput.key] === "object" && env[envInput.key] !== null) { + const binding = env[envInput.key] as Record; + if (binding.type === "secret_ref" && binding.secretId !== newSecretId) { + binding.secretId = newSecretId; + mutated = true; + } + } + if (mutated) await agents.update(agentId, { adapterConfig }); + } + } + } else if (envInput.projectSlug) { + const projectId = importedSlugToProjectId.get(envInput.projectSlug); + if (projectId) { + const project = await projects.getById(projectId); + if (project && project.env && typeof project.env === "object") { + const env = project.env as Record; + let mutated = false; + if (typeof env[envInput.key] === "object" && env[envInput.key] !== null) { + const binding = env[envInput.key] as Record; + if (binding.type === "secret_ref" && binding.secretId !== newSecretId) { + binding.secretId = newSecretId; + mutated = true; + } + } + if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig }); + } + } + } + } + + // Note: the legacy secret remapping below is kept as a safety net for + // agents/projects that were created/updated before this code existed. + // It can be removed once the inline reconstruction above is stable. + // Reconstruct plain env bindings and fill in missing env keys on imported agents/projects + for (const envInput of sourceManifest.envInputs ?? []) { + if (envInput.kind !== "plain" && !(envInput.kind === "secret" && !envInput.secretName)) continue; + if (!envInput.defaultValue && envInput.kind === "plain") continue; + + if (envInput.agentSlug) { + const agentId = importedSlugToAgentId.get(envInput.agentSlug); + if (!agentId) continue; + const agent = await agents.getById(agentId); + if (!agent) continue; + const adapterConfig = agent.adapterConfig as Record; + const env = (adapterConfig.env as Record) ?? {}; + let mutated = false; + if (!env[envInput.key] && envInput.kind === "plain") { + env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" }; + mutated = true; + } + if (mutated) { + adapterConfig.env = env; + await agents.update(agentId, { adapterConfig }); + } + } else if (envInput.projectSlug) { + const projectId = importedSlugToProjectId.get(envInput.projectSlug); + if (!projectId) continue; + const project = await projects.getById(projectId); + if (!project) continue; + const env = (project.env as Record) ?? {}; + let mutated = false; + if (!env[envInput.key] && envInput.kind === "plain") { + env[envInput.key] = { type: "plain", value: envInput.defaultValue ?? "" }; + mutated = true; + } + if (mutated) await projects.update(projectId, { env: env as import("@paperclipai/shared").AgentEnvConfig }); + } + } + if (include.issues) { const routines = routineService(db); for (const manifestIssue of sourceManifest.issues) { diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 0b910e7c..129088f0 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -17,6 +17,15 @@ import { authApi } from "../api/auth"; import { companiesApi } from "../api/companies"; import { projectsApi } from "../api/projects"; import { Button } from "@/components/ui/button"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { MarkdownBody } from "../components/MarkdownBody"; @@ -603,6 +612,8 @@ export function CompanyExport() { const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [checkedFiles, setCheckedFiles] = useState>(new Set()); const [treeSearch, setTreeSearch] = useState(""); + const [includeSecrets, setIncludeSecrets] = useState(false); + const [secretsConfirmOpen, setSecretsConfirmOpen] = useState(false); const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE); const savedExpandedRef = useRef | null>(null); const initialFileFromUrl = useRef(filePathFromLocation(location.pathname)); @@ -731,6 +742,7 @@ export function CompanyExport() { include: { company: true, agents: true, projects: true, issues: true }, selectedFiles: Array.from(checkedFiles).sort(), sidebarOrder, + includeSecrets, }), onSuccess: (result) => { const resultCheckedFiles = new Set(Object.keys(result.files)); @@ -945,6 +957,11 @@ export function CompanyExport() { {warnings.length} warning{warnings.length === 1 ? "" : "s"} )} + {includeSecrets && ( + + Secrets included + + )} + + + + ); } diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 90fad9d4..3df8164b 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -866,6 +866,13 @@ export function CompanyImport() { title: "Import complete", body: `${result.company.name}: ${result.agents.length} agent${result.agents.length === 1 ? "" : "s"} processed.`, }); + if (result.warnings.some((w) => w.includes("could not be decrypted") || w.toLowerCase().includes("failed to create secret"))) { + pushToast({ + tone: "warn", + title: "Secrets import warning", + body: "Some secrets could not be decrypted. Review warnings and recreate manually.", + }); + } // Force a fresh dashboard load so newly imported agents are immediately visible. window.location.assign(`/${importedCompany.issuePrefix}/dashboard`); }, @@ -1309,6 +1316,18 @@ export function CompanyImport() { )} + {/* Secrets info */} + {importPreview.manifest.secrets && importPreview.manifest.secrets.length > 0 && ( +

+
Secrets to import
+ {importPreview.manifest.secrets.map((s) => ( +
+ {s.name}{s.provider !== "local_encrypted" ? ` (${s.provider})` : ""} +
+ ))} +
+ )} + {/* Errors */} {importPreview.errors.length > 0 && (
From 2131ede7b8df75bbf5d2e0ad6a81a5a9d95d7e78 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 29 Apr 2026 14:22:31 -0400 Subject: [PATCH 6/7] feat(board): render approval summary/recommendedAction/nextActionOnApproval as markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces plain

tags in BoardApprovalPayloadContent with MarkdownBody (softBreaks enabled) so agent-authored markdown in these three fields — headers, bullets, and newlines — renders correctly in the Board UI instead of collapsing into a single unstyled paragraph. No schema change; the fields remain plain strings in the approval payload, only the renderer changed. Matches how comments, issue documents, and interaction cards already render markdown via MarkdownBody. Test coverage added for ## header →

, bullet list →
  • , and plain-prose regression (no markup injected for single-line inputs). Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/ApprovalPayload.test.tsx | 151 ++++++++++++++++++--- ui/src/components/ApprovalPayload.tsx | 7 +- 2 files changed, 135 insertions(+), 23 deletions(-) diff --git a/ui/src/components/ApprovalPayload.test.tsx b/ui/src/components/ApprovalPayload.test.tsx index c11405e9..1b082e06 100644 --- a/ui/src/components/ApprovalPayload.test.tsx +++ b/ui/src/components/ApprovalPayload.test.tsx @@ -1,13 +1,33 @@ // @vitest-environment jsdom import { act } from "react"; +import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ThemeProvider } from "../context/ThemeContext"; import { ApprovalPayloadRenderer, approvalLabel } from "./ApprovalPayload"; +vi.mock("@/lib/router", () => ({ + Link: ({ children, to }: { children: ReactNode; to: string }) => {children}, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { get: vi.fn() }, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +function withProviders(children: ReactNode) { + return ( + + {children} + + ); +} + describe("approvalLabel", () => { it("uses payload titles for generic board approvals", () => { expect( @@ -35,17 +55,19 @@ describe("ApprovalPayloadRenderer", () => { act(() => { root.render( - , + withProviders( + , + ), ); }); @@ -67,14 +89,16 @@ describe("ApprovalPayloadRenderer", () => { act(() => { root.render( - , + withProviders( + , + ), ); }); @@ -86,3 +110,90 @@ describe("ApprovalPayloadRenderer", () => { }); }); }); + +describe("BoardApprovalPayloadContent markdown rendering", () => { + it("renders a ## header in summary as an h2 element", () => { + const html = renderToStaticMarkup( + withProviders( + , + ), + ); + expect(html).toContain(" { + const html = renderToStaticMarkup( + withProviders( + , + ), + ); + expect(html).toContain(" { + const html = renderToStaticMarkup( + withProviders( + , + ), + ); + expect(html).toContain(" { + const html = renderToStaticMarkup( + withProviders( + , + ), + ); + expect(html).toContain(" { + const html = renderToStaticMarkup( + withProviders( + , + ), + ); + expect(html).toContain("This is a simple one-line summary."); + expect(html).not.toContain(" { + const html = renderToStaticMarkup( + withProviders( + , + ), + ); + expect(html).toContain("Approve the deployment."); + expect(html).not.toContain(" = { hire_agent: "Hire Agent", @@ -185,7 +186,7 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record

    Summary

    -

    {summary}

    + {summary}

)} {recommendedAction && ( @@ -193,13 +194,13 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record Recommended action

-

{recommendedAction}

+ {recommendedAction} )} {nextActionOnApproval && (

On approval

-

{nextActionOnApproval}

+ {nextActionOnApproval}
)} {risks.length > 0 && ( From 6bbe51ca4d11ac93b39060bf2e4dbb64776c8bb5 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 1 May 2026 12:10:48 -0400 Subject: [PATCH 7/7] fix(skills): remove bundled paperclip-dev skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled paperclip-dev skill was force-flagged as required in listRuntimeSkillEntries (sourceKind === "paperclip_bundled" overrode the SKILL.md frontmatter), so the per-agent toggle was always disabled. Drop the skill outright on this fork — we don't ship it. --- skills/paperclip-dev/SKILL.md | 267 ---------------------------------- 1 file changed, 267 deletions(-) delete mode 100644 skills/paperclip-dev/SKILL.md diff --git a/skills/paperclip-dev/SKILL.md b/skills/paperclip-dev/SKILL.md deleted file mode 100644 index d392d327..00000000 --- a/skills/paperclip-dev/SKILL.md +++ /dev/null @@ -1,267 +0,0 @@ ---- -name: paperclip-dev -required: false -description: > - Develop and operate a local Paperclip instance — start and stop servers, - pull updates from master, run builds and tests, manage worktrees, back up - databases, and diagnose problems. Use whenever you need to work on the - Paperclip codebase itself or keep a running instance healthy. ---- - -# Paperclip Dev - -This skill covers the day-to-day workflows for developing and operating a local Paperclip instance. It assumes you are working inside the Paperclip repo checkout with `origin` pointing to `git@github.com:paperclipai/paperclip.git`. - -> **OPEN SOURCE HYGIENE:** This repository is public-facing. Treat anything you push to `origin` as publishable. Never commit or push secrets, API keys, tokens, private logs, PII, customer data, or machine-local configuration that should stay private. Keep git history tidy as well: avoid pushing throwaway branches, noisy checkpoint commits, or speculative work that does not need to be shared upstream. - -> **MANDATORY:** Before running any CLI command, building, testing, or managing worktrees, you MUST read `doc/DEVELOPING.md` in the Paperclip repo. It is the canonical reference for all `paperclipai` CLI commands, their options, build/test workflows, database operations, worktree management, and diagnostics. Do NOT guess at flags or options — read the doc first. - -## Quick Command Reference - -These are the most common commands. For full option tables and details, see `doc/DEVELOPING.md`. - -| Task | Command | -|------|---------| -| Start server (first time or normal) | `npx paperclipai run` | -| Dev mode with hot reload | `pnpm dev` | -| Stop dev server | `pnpm dev:stop` | -| Build | `pnpm build` | -| Type-check | `pnpm typecheck` | -| Run tests | `pnpm test` | -| Run migrations | `pnpm db:migrate` | -| Regenerate Drizzle client | `pnpm db:generate` | -| Back up database | `npx paperclipai db:backup` | -| Health check | `npx paperclipai doctor --repair` | -| Print env vars | `npx paperclipai env` | -| Trigger agent heartbeat | `npx paperclipai heartbeat run --agent-id ` | -| Install agent skills locally | `npx paperclipai agent local-cli --company-id ` | - -## Pulling from Master - -```bash -git fetch origin && git pull origin master -pnpm install && pnpm build -``` - -If schema changes landed, also run `pnpm db:generate && pnpm db:migrate`. - -## Worktrees - -Paperclip worktrees combine git worktrees with isolated Paperclip instances — each gets its own database, server port, and environment seeded from the primary instance. - -> **MANDATORY:** Before creating or managing worktrees, you MUST read the "Worktree-local Instances" and "Worktree CLI Reference" sections in `doc/DEVELOPING.md`. That is the canonical reference for all worktree commands, their options, seed modes, and environment variables. - -### When to Use Worktrees - -- Starting a feature branch that needs its own Paperclip environment -- Running parallel agent work without cross-contaminating the primary instance -- Testing Paperclip changes in isolation before merging - -### Command Overview - -The CLI has two tiers (see `doc/DEVELOPING.md` for full option tables): - -| Command | Purpose | -|---------|---------| -| `worktree:make ` | Create worktree + isolated instance in one step | -| `worktree:list` | List worktrees and their Paperclip status | -| `worktree:merge-history` | Preview/import issue history between worktrees | -| `worktree:cleanup ` | Remove worktree, branch, and instance data | -| `worktree init` | Bootstrap instance inside existing worktree | -| `worktree env` | Print shell exports for worktree instance | -| `worktree reseed` | Refresh worktree DB from another instance | -| `worktree repair` | Fix broken/missing worktree instance metadata | - -### Typical Workflow - -```bash -# 1. Create a worktree for a feature -npx paperclipai worktree:make my-feature --start-point origin/main - -# 2. Move into the worktree (path printed by worktree:make) and source the environment -cd -eval "$(npx paperclipai worktree env)" - -# 3. Start the isolated Paperclip server -npx paperclipai run - -# 4. Do your work - -# 5. When done, merge history back if needed -npx paperclipai worktree:merge-history --from paperclip-my-feature --to current --apply - -# 6. Clean up -npx paperclipai worktree:cleanup my-feature -``` - -## Forks — Prefer Pushing to a User Fork - -If the user has a personal fork of `paperclipai/paperclip` configured as a git remote, push your feature branches to **that fork** instead of creating branches on the main repo. This keeps the upstream branch list clean and matches the standard open-source contribution flow. - -### Detect a fork remote - -Before pushing or creating a PR, list remotes and check for one that points at a non-`paperclipai` GitHub fork: - -```bash -git remote -v -``` - -Treat any remote whose URL points to `github.com:/paperclip` (or `github.com//paperclip.git`) as the user's fork. Common names are `fork`, ``, or `myfork`. The remote named `origin` or `upstream` that points at `paperclipai/paperclip` is the canonical upstream — do not push feature branches there if a fork exists. - -### Pushing to the fork - -```bash -# Push the current branch to the user's fork and set upstream -git push -u HEAD -``` - -Then create the PR from the fork branch: - -```bash -gh pr create --repo paperclipai/paperclip --head : ... -``` - -`gh pr create` usually figures out the head ref automatically when run from a branch tracking the fork; the explicit `--head :` form is the reliable fallback when it does not. - -### When no fork exists - -If `git remote -v` shows only `paperclipai/paperclip` remotes (no user fork), fall back to pushing branches to `origin` as before. Do NOT create a fork on the user's behalf — ask first. - -### Keeping the fork up to date - -The canonical remote that points at `paperclipai/paperclip` may be named `origin` **or** `upstream` depending on how the user set up the repo. Detect it the same way as in the "Detect a fork remote" step, then fetch and push from/with that remote so the sync works under either convention: - -```bash -UPSTREAM_REMOTE=$(git remote -v | awk '/paperclipai\/paperclip.*\(fetch\)/{print $1; exit}') -git fetch "$UPSTREAM_REMOTE" -git push "${UPSTREAM_REMOTE}/master:master" -``` - -## Pull Requests - -> **MANDATORY PRE-FLIGHT:** Before creating ANY pull request, you MUST read the canonical source files listed below. Do NOT run `gh pr create` until you have read these files and verified your PR body matches every required section. - -### Step 1 — Read the canonical files - -You MUST read all three of these files before creating a PR: - -1. **`.github/PULL_REQUEST_TEMPLATE.md`** — the required PR body structure -2. **`CONTRIBUTING.md`** — contribution conventions, PR requirements, and thinking-path examples -3. **`.github/workflows/pr.yml`** — CI checks that gate merge - -### Step 2 — Validate your PR body against this checklist - -After reading the template, verify your `--body` includes every one of these sections (names must match exactly): - -- [ ] `## Thinking Path` — blockquote style, 5-8 reasoning steps -- [ ] `## What Changed` — bullet list of concrete changes -- [ ] `## Verification` — how a reviewer confirms this works -- [ ] `## Risks` — what could go wrong -- [ ] `## Model Used` — provider, model ID, version, capabilities -- [ ] `## Checklist` — copied from the template, items checked off - -If any section is missing or empty, do NOT submit the PR. Go back and fill it in. - -### Step 3 — Create the PR - -Only after completing Steps 1 and 2, run `gh pr create`. Use the template contents as the structure for `--body` — do not write a freeform summary. - -## Hard Rules — Do NOT Bypass - -These rules exist because agents have caused real damage by improvising around CLI failures. Follow them exactly. - -1. **CLI is the only interface to worktrees and databases.** All worktree and database operations MUST go through `npx paperclipai` / `pnpm paperclipai` commands. You MUST NOT: - - Run `pg_dump`, `pg_restore`, `psql`, `createdb`, `dropdb`, or any raw postgres commands - - Manually set `DATABASE_URL` to point a worktree server at another instance's database - - Run `rm -rf` on any `.paperclip/`, `.paperclip-worktrees/`, or `db/` directory - - Directly manipulate embedded postgres data directories - - Kill postgres processes by PID - -2. **If a CLI command fails, stop and report.** Do NOT attempt workarounds. If `worktree:make`, `worktree reseed`, `worktree init`, `worktree:cleanup`, or any other `paperclipai` command fails: - - Report the exact error message in your task comment - - Set the task to `blocked` - - Suggest running `npx paperclipai doctor --repair` or recreating the worktree from scratch - - Do NOT try to manually replicate what the CLI does - -3. **Never share databases between instances.** Each worktree instance gets its own isolated database. Never override `DATABASE_URL` to point one instance at another's database. This destroys isolation and can corrupt production data. - -4. **Starting a dev server in a worktree requires setup first.** The correct sequence is: - ```bash - # If the worktree already exists but has no running instance: - cd - eval "$(npx paperclipai worktree env)" - pnpm install && pnpm build - npx paperclipai run # or pnpm dev - - # If the worktree needs a fresh database: - npx paperclipai worktree reseed --seed-mode full - - # If the worktree is broken beyond repair: - npx paperclipai worktree:cleanup - npx paperclipai worktree:make --seed-mode full - ``` - If any step fails, follow rule 2 — stop and report. - -5. **Seeding is a CLI operation.** When asked to seed a worktree database from the main instance, use `worktree reseed` or recreate with `worktree:make --seed-mode full`. Read `doc/DEVELOPING.md` for the full option tables. Never attempt manual database copying. - -## Persistent Dev Servers (for Manual Testing) - -When an agent needs to start a dev server that outlives the current heartbeat — for example, so a human or QA agent can manually test against it — the server process **must** be launched in a detached session. A process started directly from a heartbeat shell is killed when the heartbeat exits. - -### Use `tmux` for persistent servers - -```bash -# 1. cd into the worktree (or main repo) and source the environment -cd -eval "$(npx paperclipai worktree env)" # skip if using the primary instance - -# 2. Start the dev server in a named, detached tmux session -tmux new-session -d -s 'pnpm dev' - -# Example with a descriptive name: -tmux new-session -d -s auth-fix-3102 'pnpm dev' -``` - -### Managing the session - -| Task | Command | -|------|---------| -| Check if the session is alive | `tmux has-session -t 2>/dev/null && echo running` | -| View server output | `tmux capture-pane -t -p` | -| Kill the session | `tmux kill-session -t ` | -| List all tmux sessions | `tmux list-sessions` | - -### Verifying the server is reachable - -After launching, confirm the port is listening before reporting success: - -```bash -# Wait briefly for startup, then verify -sleep 3 -curl -sf http://127.0.0.1:/api/health && echo "Server is up" -lsof -nP -iTCP: -sTCP:LISTEN -``` - -### Key rules - -1. **Always use `tmux` (or equivalent)** when a dev server needs to stay running after the heartbeat ends. A server started directly from the agent shell will die when the heartbeat exits, even if it appeared healthy moments before. -2. **Name the session descriptively** — include the worktree name and port (e.g., `auth-fix-3102`). -3. **Verify the server is listening** before reporting the URL to anyone. -4. **Do not use `nohup` or `&` alone** — these are unreliable for agent shells that may have their entire process group killed. -5. **Clean up when done** — kill the tmux session when the testing is complete. - -## Common Mistakes - -| Mistake | Fix | -|---------|-----| -| Server won't start | Run `npx paperclipai doctor --repair` to diagnose and auto-fix | -| Forgetting to source worktree env | Run `eval "$(npx paperclipai worktree env)"` after cd-ing into the worktree | -| Stale dependencies after pull | Run `pnpm install && pnpm build` after pulling | -| Schema out of date after pull | Run `pnpm db:generate && pnpm db:migrate` | -| Reseeding while target DB is running | Stop the target server first, or use `--allow-live-target` | -| Cleaning up with unmerged commits | Merge or push first, or use `--force` if intentionally discarding | -| Running agents against wrong instance | Verify `PAPERCLIP_API_URL` points to the correct port | -| CLI command fails | Do NOT work around it — report the error and block (see Hard Rules above) | -| Agent tries manual postgres operations | NEVER do this — all DB ops go through the CLI (see Hard Rules above) | -| Dev server dies between heartbeats | Launch in a detached `tmux` session — see "Persistent Dev Servers" above | -| Pushed feature branch to `paperclipai/paperclip` when a fork exists | Push to the user's fork remote instead — see "Forks" above |