From 022a0df61a297b2556feb18f1219d9ccf50ce47b Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 15:35:47 -0400 Subject: [PATCH 1/6] feat(skills): GitHub PAT support for private skill repos + delete by source - Add optional authToken to skill import for GitHub private repos - Store PAT as encrypted company secret (skill-pat:{skillId}) - Thread auth token through ghFetch, fetchText, fetchJson, and all GitHub resolution functions - Add PATCH /companies/:companyId/skills/:skillId/auth for managing PAT per skill - Add DELETE /companies/:companyId/skills/by-source for bulk deleting skills from a repo - Preserve sourceAuthSecretId across skill re-imports/updates - UI: Add PAT input field in import form for GitHub URLs - UI: Add SkillAuthSection with ShieldCheck icon for viewing/updating/removing PAT - UI: Add trash icon next to source label for delete-by-source Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/index.ts | 1 + .../shared/src/validators/company-skill.ts | 5 + packages/shared/src/validators/index.ts | 1 + .../__tests__/company-skills-routes.test.ts | 2 + server/src/routes/company-skills.ts | 67 ++++++- server/src/services/company-skills.ts | 154 +++++++++++++++-- server/src/services/github-fetch.ts | 8 +- ui/src/api/companySkills.ts | 17 +- ui/src/pages/CompanySkills.tsx | 163 ++++++++++++++++-- 9 files changed, 386 insertions(+), 32 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b125165..83e5ff79 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -557,6 +557,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 7f1df34b..26dcea66 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -66,6 +66,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({ diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index aca19625..2019f35c 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -44,6 +44,7 @@ export { companySkillDetailSchema, companySkillUpdateStatusSchema, companySkillImportSchema, + companySkillUpdateAuthSchema, companySkillProjectScanRequestSchema, companySkillProjectScanSkippedSchema, companySkillProjectScanConflictSchema, diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 6dbad659..16b4f692 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -89,6 +89,7 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", + undefined, ); }); @@ -266,6 +267,7 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", + undefined, ); }); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 9e91bf26..b1a5f7cf 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, { @@ -260,6 +262,36 @@ export function companySkillRoutes(db: Db) { }, ); + router.delete("/companies/:companyId/skills/by-source", async (req, res) => { + const companyId = req.params.companyId as string; + const sourceLocator = String(req.query.source ?? "").trim(); + if (!sourceLocator) { + res.status(400).json({ error: "source query parameter is required" }); + return; + } + await assertCanMutateCompanySkills(req, companyId); + const deleted = await svc.deleteBySource(companyId, sourceLocator); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skills_source_deleted", + entityType: "company", + entityId: companyId, + details: { + sourceLocator, + deletedCount: deleted.length, + deletedSlugs: deleted.map((s) => s.slug), + }, + }); + + res.json({ deleted }); + }); + router.delete("/companies/:companyId/skills/:skillId", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; @@ -318,5 +350,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 60fc06b4..c94b9ffa 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -471,20 +471,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}`); } @@ -492,16 +492,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) { @@ -538,7 +540,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 { @@ -549,8 +551,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType { const url = sourceUrl.trim(); const warnings: string[] = []; @@ -995,10 +998,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}`); }); @@ -1025,7 +1029,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)); @@ -1087,7 +1091,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); @@ -1459,6 +1463,22 @@ export function companySkillService(db: Db) { const projects = projectService(db); const secretsSvc = secretService(db); + /** Resolve the GitHub auth token from a skill's metadata, if stored. */ + 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()) { const stats = await fs.stat(skillsRoot).catch(() => null); @@ -1656,7 +1676,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, @@ -1700,8 +1721,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"); @@ -1818,7 +1840,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.`); @@ -2230,6 +2253,10 @@ export function companySkillService(db: Db) { const metadata = { ...(skill.metadata ?? {}), skillKey: skill.key, + // Preserve auth secret reference across re-imports/updates + ...(existing?.metadata && typeof (existing.metadata as Record).sourceAuthSecretId === "string" + ? { sourceAuthSecretId: (existing.metadata as Record).sourceAuthSecretId } + : {}), }; const values = { companyId, @@ -2265,7 +2292,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); @@ -2275,7 +2302,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], @@ -2302,6 +2329,35 @@ export function companySkillService(db: Db) { } } const imported = await upsertImportedSkills(companyId, filteredSkills); + + // Store the auth token as an encrypted company secret and link to imported skills + 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; + } + // Store the secret ID in skill metadata + const meta = (skill.metadata ?? {}) as Record; + meta.sourceAuthSecretId = secretId; + await db + .update(companySkills) + .set({ metadata: meta, updatedAt: new Date() }) + .where(eq(companySkills.id, skill.id)); + } + } + return { imported, warnings }; } @@ -2344,6 +2400,68 @@ export function companySkillService(db: Db) { return skill; } + async function updateSkillAuth( + companyId: string, + skillId: string, + authToken: string | null, + ): Promise { + const skill = await getById(skillId); + if (!skill || skill.companyId !== companyId) return null; + + const meta = (skill.metadata ?? {}) as Record; + const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null; + + if (authToken) { + // Set or update the PAT + const secretName = `skill-pat:${skill.id}`; + let secretId: string; + // Check metadata reference first, then fall back to name lookup + // (metadata ref may have been lost during a skill update/re-import) + 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 { + // Clear the PAT + delete meta.sourceAuthSecretId; + // Note: we don't delete the secret itself — it may be referenced in audit logs + } + + 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; + } + + async function deleteBySource(companyId: string, sourceLocator: string): Promise { + const rows = await db + .select() + .from(companySkills) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceLocator, sourceLocator))); + if (rows.length === 0) return []; + + const deleted: CompanySkill[] = []; + for (const row of rows) { + const result = await deleteSkill(companyId, row.id); + if (result) deleted.push(result); + } + return deleted; + } + return { list, listFull, @@ -2359,7 +2477,9 @@ export function companySkillService(db: Db) { updateFile, createLocalSkill, deleteSkill, + deleteBySource, 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..bbdb2e60 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -36,10 +36,23 @@ 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 }, + ), + remove: (companyId: string, skillId: string) => + api.delete( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, + ), + removeBySource: (companyId: string, sourceLocator: string) => + api.delete<{ deleted: CompanySkill[] }>( + `/companies/${encodeURIComponent(companyId)}/skills/by-source?source=${encodeURIComponent(sourceLocator)}`, ), scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) => api.post( diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index cc2d5605..5b1530ec 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type SVGProps } from "react"; import { Link, useNavigate, useParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + CompanySkill, CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, @@ -52,6 +53,7 @@ import { RefreshCw, Save, Search, + ShieldCheck, Trash2, } from "lucide-react"; @@ -487,6 +489,103 @@ function SkillList({ ); } +function SkillAuthSection({ + companyId, + skillId, + hasAuth, +}: { + companyId: string; + skillId: string; + hasAuth: boolean; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToast(); + 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, @@ -525,7 +624,7 @@ function SkillPane({ checkUpdatesPending: boolean; onInstallUpdate: () => void; installUpdatePending: boolean; - onDelete: () => void; + onDelete: (sourceLocator?: string | null) => void; deletePending: boolean; onSave: () => void; savePending: boolean; @@ -572,7 +671,7 @@ function SkillPane({ + {(detail.sourceType === "github" || detail.sourceType === "skills_sh") && ( + | null)?.sourceAuthSecretId)} + /> + )} {detail.sourceType === "github" && (
Pin @@ -762,6 +876,7 @@ export function CompanySkills() { const { pushToast } = useToast(); 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); @@ -886,8 +1001,17 @@ export function CompanySkills() { } } + function handleDeleteSkill(sourceLocator?: string | null) { + if (sourceLocator) { + deleteSkill.mutate(sourceLocator); + } else { + openDeleteDialog(); + } + } + 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 +1024,7 @@ export function CompanySkills() { pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] }); } setSource(""); + setImportAuthToken(""); }, onError: (error) => { pushToast({ @@ -1026,8 +1151,13 @@ export function CompanySkills() { }); const deleteSkill = useMutation({ - mutationFn: () => companySkillsApi.delete(selectedCompanyId!, deleteTargetSkillId!), - onSuccess: async (skill) => { + mutationFn: (sourceLocator?: string | null): Promise<{ deleted: CompanySkill[] }> => { + if (sourceLocator) { + return companySkillsApi.removeBySource(selectedCompanyId!, sourceLocator); + } + return companySkillsApi.remove(selectedCompanyId!, deleteTargetSkillId!).then((skill) => ({ deleted: [skill] })); + }, + onSuccess: async (result) => { closeDeleteDialog(false); setDisplayedDetail(null); setDisplayedFile(null); @@ -1048,10 +1178,10 @@ export function CompanySkills() { type: "active", }); navigate("/skills", { replace: true }); + const count = result.deleted.length; pushToast({ tone: "success", - title: "Skill removed", - body: `${skill.name} was removed from the company skill library.`, + title: `${count} skill${count === 1 ? "" : "s"} removed`, }); }, onError: (error) => { @@ -1073,7 +1203,8 @@ export function CompanySkills() { setEmptySourceHelpOpen(true); return; } - importSkill.mutate(trimmedSource); + const token = importAuthToken.trim() || undefined; + importSkill.mutate({ importSource: trimmedSource, authToken: token }); } return ( @@ -1115,7 +1246,7 @@ export function CompanySkills() {
+ {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} @@ -1284,7 +1427,7 @@ export function CompanySkills() { checkUpdatesPending={updateStatusQuery.isFetching} onInstallUpdate={() => installUpdate.mutate()} installUpdatePending={installUpdate.isPending} - onDelete={openDeleteDialog} + onDelete={handleDeleteSkill} deletePending={deleteSkill.isPending} onSave={() => saveFile.mutate()} savePending={saveFile.isPending} From 54c5e7cb41bb7357e856087650c0c7fe64f4589d Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 15:57:36 -0400 Subject: [PATCH 2/6] fix(skills): atomic deleteBySource + PAT secret cleanup on skill deletion - Pre-check all skills for agent usage before deleting any in deleteBySource to prevent partial/failed deletions - Delete (rotate to empty) the skill-pat: secret when a skill is deleted to prevent orphaned PAT secrets Co-Authored-By: Claude Opus 4.6 --- server/src/services/company-skills.ts | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index c94b9ffa..587adc68 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2397,6 +2397,17 @@ export function companySkillService(db: Db) { // Clean up materialized runtime files await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true }); + // Delete associated PAT secret if present + 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; } @@ -2454,6 +2465,28 @@ export function companySkillService(db: Db) { .where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceLocator, sourceLocator))); if (rows.length === 0) return []; + // Pre-check all skills for agent usage before deleting any (atomicity) + const skills = rows.map(toCompanySkill); + for (const skill of skills) { + const usedByAgents = await usage(companyId, skill.key); + if (usedByAgents.length > 0) { + const agentNames = usedByAgents.map((agent) => agent.name).sort((left, right) => left.localeCompare(right)); + throw unprocessable( + `Cannot delete skills from "${sourceLocator}" because skill "${skill.name}" is still used by ${agentNames.join(", ")}. Detach it from those agents first.`, + { + skillId: skill.id, + skillKey: skill.key, + usedByAgents: usedByAgents.map((agent) => ({ + id: agent.id, + name: agent.name, + urlKey: agent.urlKey, + adapterType: agent.adapterType, + })), + }, + ); + } + } + const deleted: CompanySkill[] = []; for (const row of rows) { const result = await deleteSkill(companyId, row.id); From 0b224f0864a4aa037856eee3b8900fa61951d06f Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 16:02:05 -0400 Subject: [PATCH 3/6] fix(ui): remove dead delete API method and add confirmation for delete-by-source - Remove duplicate `delete` method (identical to `remove`) - Route delete-by-source through confirmation dialog with source locator displayed and "Remove all from source" button Co-Authored-By: Claude Opus 4.6 --- ui/src/api/companySkills.ts | 4 --- ui/src/pages/CompanySkills.tsx | 57 +++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index bbdb2e60..f72c64c3 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -64,8 +64,4 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`, {}, ), - delete: (companyId: string, skillId: string) => - api.delete( - `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`, - ), }; diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 5b1530ec..d2124b03 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -890,6 +890,7 @@ export function CompanySkills() { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteTargetSkillId, setDeleteTargetSkillId] = useState(null); const [deleteTargetDetail, setDeleteTargetDetail] = useState(null); + const [deleteTargetSourceLocator, setDeleteTargetSourceLocator] = useState(null); const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]); const routeSkillId = parsedRoute.skillId; const selectedPath = parsedRoute.filePath; @@ -998,12 +999,16 @@ export function CompanySkills() { if (!open) { setDeleteTargetSkillId(null); setDeleteTargetDetail(null); + setDeleteTargetSourceLocator(null); } } function handleDeleteSkill(sourceLocator?: string | null) { if (sourceLocator) { - deleteSkill.mutate(sourceLocator); + setDeleteTargetSourceLocator(sourceLocator); + setDeleteTargetSkillId(null); + setDeleteTargetDetail(null); + setDeleteOpen(true); } else { openDeleteDialog(); } @@ -1212,30 +1217,40 @@ export function CompanySkills() {

- Remove skill + {deleteTargetSourceLocator ? "Remove skills from source" : "Remove skill"} - Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached. + {deleteTargetSourceLocator + ? `All skills imported from this source will be permanently removed from the company library.` + : "Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached."}
-

- {deleteTargetDetail - ? `You are about to remove ${deleteTargetDetail.name}.` - : "You are about to remove this skill."} -

- {deleteTargetDetail?.usedByAgents?.length ? ( -
- Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}. -
- ) : null} - {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( -

- Detach this skill from all agents to enable removal. + {deleteTargetSourceLocator ? ( +

+ {deleteTargetSourceLocator}

- ) : null} + ) : ( + <> +

+ {deleteTargetDetail + ? `You are about to remove ${deleteTargetDetail.name}.` + : "You are about to remove this skill."} +

+ {deleteTargetDetail?.usedByAgents?.length ? ( +
+ Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}. +
+ ) : null} + {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( +

+ Detach this skill from all agents to enable removal. +

+ ) : null} + + )}
- {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? ( + {(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 && !deleteTargetSourceLocator ? ( @@ -1246,10 +1261,10 @@ export function CompanySkills() { )} From ec4e94a6e79a6973c65e097171546f532c1e6edc Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 9 Apr 2026 16:03:33 -0400 Subject: [PATCH 4/6] fix: add companyId filter to metadata update + export CompanySkillUpdateAuth type - Scope metadata update WHERE clause to companyId for defence-in-depth - Add CompanySkillUpdateAuth inferred type export to match other schemas Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/validators/company-skill.ts | 1 + packages/shared/src/validators/index.ts | 1 + server/src/services/company-skills.ts | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 26dcea66..58b9165d 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -138,3 +138,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 2019f35c..d1bc3929 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -56,6 +56,7 @@ export { type CompanySkillProjectScan, type CompanySkillCreate, type CompanySkillFileUpdate, + type CompanySkillUpdateAuth, } from "./company-skill.js"; export { agentSkillStateSchema, diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 587adc68..6549fda8 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2354,7 +2354,7 @@ export function companySkillService(db: Db) { await db .update(companySkills) .set({ metadata: meta, updatedAt: new Date() }) - .where(eq(companySkills.id, skill.id)); + .where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId))); } } From edc77da0822e6ce19e634f31d0b00be146502f94 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 10 Apr 2026 17:31:36 -0400 Subject: [PATCH 5/6] fix(skills): delete secret row when PAT is cleared via updateSkillAuth When updateSkillAuth(null) is called, the underlying secret row was left orphaned. Now deletes the secret via secretsSvc.remove() before clearing sourceAuthSecretId from metadata. Co-Authored-By: Claude Opus 4.6 --- server/src/services/company-skills.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 6549fda8..b6c36db4 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2445,9 +2445,15 @@ export function companySkillService(db: Db) { } meta.sourceAuthSecretId = secretId; } else { - // Clear the PAT + // Clear the PAT — delete the secret row to avoid orphaned secrets + if (existingSecretId) { + try { + await secretsSvc.remove(existingSecretId); + } catch { + // Best-effort: don't fail the metadata update if secret deletion fails + } + } delete meta.sourceAuthSecretId; - // Note: we don't delete the secret itself — it may be referenced in audit logs } const [updated] = await db From 772130a5737881788c6610412f3a8f48cc10654d Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Fri, 10 Apr 2026 21:22:38 -0400 Subject: [PATCH 6/6] feat(docker): add jq to production stage alongside other tooling --- Dockerfile | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 36d5acab..2713e785 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,18 @@ ARG USER_UID=1000 ARG USER_GID=1000 WORKDIR /app COPY --chown=node:node --from=build /app /app -RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ +RUN apt-get update \ + && apt-get install -y --no-install-recommends jq nano vim \ + && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \ + && chmod +x /usr/local/bin/kubectl \ + && curl -fsSL https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz | tar -xzf - -C /tmp \ + && mv /tmp/kubeseal /usr/local/bin/kubeseal \ + && rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \ + && curl -LsSf https://astral.sh/uv/install.sh | sh \ + && mv /root/.local/bin/uv /usr/local/bin/uv \ + && mv /root/.local/bin/uvx /usr/local/bin/uvx \ + && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ && mkdir -p /paperclip \ && chown node:node /paperclip