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
This commit is contained in:
2026-05-01 07:41:48 -04:00
parent 3494e84a29
commit 3dfb859676
8 changed files with 312 additions and 24 deletions
+1
View File
@@ -812,6 +812,7 @@ export {
companySkillDetailSchema,
companySkillUpdateStatusSchema,
companySkillImportSchema,
companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema,
companySkillProjectScanConflictSchema,
@@ -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<typeof companySkillImportSchema>;
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
export type CompanySkillUpdateAuth = z.infer<typeof companySkillUpdateAuthSchema>;
+2
View File
@@ -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,
+36 -1
View File
@@ -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;
}
+131 -17
View File
@@ -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<string, un
};
}
async function fetchText(url: string) {
const response = await ghFetch(url);
async function fetchText(url: string, authToken?: string) {
const response = await ghFetch(url, undefined, authToken);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
async function fetchJson<T>(url: string): Promise<T> {
async function fetchJson<T>(url: string, authToken?: string): Promise<T> {
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<T>(url: string): Promise<T> {
}
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<typeof parseGitHubSourceUrl>) {
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>, 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<typeof parseGitHubSourc
const trackingRef = parsed.explicitRef
? parsed.ref
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase);
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase);
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase, authToken);
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase, authToken);
return { pinnedRef, trackingRef };
}
@@ -1050,6 +1053,7 @@ async function readUrlSkillImports(
companyId: string,
sourceUrl: string,
requestedSkillSlug: string | null = null,
authToken?: string,
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
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<string, unknown> | null },
): Promise<string | undefined> {
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<string, unknown>).sourceAuthSecretId === "string"
? { sourceAuthSecretId: (existing.metadata as Record<string, unknown>).sourceAuthSecretId }
: {}),
};
const values = {
companyId,
@@ -2375,7 +2402,7 @@ export function companySkillService(db: Db) {
return out;
}
async function importFromSource(companyId: string, source: string): Promise<CompanySkillImportResult> {
async function importFromSource(companyId: string, source: string, authToken?: string): Promise<CompanySkillImportResult> {
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<string, unknown>;
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<string, unknown> | 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<CompanySkill | null> {
const skill = await getById(companyId, skillId);
if (!skill) return null;
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
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,
+6 -2
View File
@@ -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<Response> {
export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise<Response> {
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`);
}
+7 -2
View File
@@ -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<CompanySkillImportResult>(
`/companies/${encodeURIComponent(companyId)}/skills/import`,
{ source },
{ source, ...(authToken ? { authToken } : {}) },
),
updateAuth: (companyId: string, skillId: string, authToken: string | null) =>
api.patch<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`,
{ authToken },
),
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
api.post<CompanySkillProjectScanResult>(
+123 -2
View File
@@ -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 (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Auth</span>
{!editing ? (
<>
{hasAuth ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(true)}
>
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
PAT configured
</Button>
<button
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
onClick={() => updateAuth.mutate(null)}
disabled={updateAuth.isPending}
title="Remove PAT"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(true)}
>
Add PAT
</Button>
)}
</>
) : (
<>
<input
type="password"
value={token}
onChange={(e) => 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
/>
<Button
size="sm"
onClick={() => updateAuth.mutate(token.trim())}
disabled={!token.trim() || updateAuth.isPending}
>
{updateAuth.isPending ? "Saving..." : "Save"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setEditing(false); setToken(""); }}
>
Cancel
</Button>
</>
)}
</div>
);
}
function SkillPane({
loading,
detail,
@@ -614,6 +712,13 @@ function SkillPane({
)}
</span>
</div>
{(detail.sourceType === "github" || detail.sourceType === "skills_sh") && (
<SkillAuthSection
companyId={detail.companyId}
skillId={detail.id}
hasAuth={Boolean((detail.metadata as Record<string, unknown> | null)?.sourceAuthSecretId)}
/>
)}
{detail.sourceType === "github" && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
@@ -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<string | null>(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 ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Add"}
</Button>
</div>
{source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && (
<div className="mt-1 flex items-center gap-2 border-b border-border pb-2">
<input
type="password"
value={importAuthToken}
onChange={(event) => 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"
/>
</div>
)}
{scanStatusMessage && (
<p className="mt-3 text-xs text-muted-foreground">
{scanStatusMessage}