feat(FN-3896): support companies.sh repo subpaths in company imports
- Add secure archive subPath handling to company package parsing, including traversal/absolute-path validation - Parse GitHub website tree URLs from companies.sh metadata to derive preferred branch and nested subPath - Wire dashboard company import flow to download the correct branch archive and parse nested manifests with clear API errors - Add parser and route tests for subPath success/failure cases and include changeset for @runfusion/fusion Fusion-Task-Id: FN-3896
This commit is contained in:
@@ -112,6 +112,48 @@ export function registerAgentImportExportRoutes(ctx: ApiRoutesContext): void {
|
||||
return /^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$/.test(slug) || /^[a-z0-9]$/.test(slug);
|
||||
}
|
||||
|
||||
function parseCompanyWebsiteSubPath(
|
||||
website: string | undefined,
|
||||
expectedRepo: string,
|
||||
): { branch?: string; subPath?: string } | null {
|
||||
if (typeof website !== "string" || website.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repoMatch = expectedRepo.match(/^([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_.-]+)$/);
|
||||
if (!repoMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(website);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.hostname.toLowerCase() !== "github.com") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
if (segments.length < 5 || segments[2] !== "tree") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, expectedOwner, expectedRepoName] = repoMatch;
|
||||
const [owner, repoName, , branch, ...subPathParts] = segments;
|
||||
if (owner !== expectedOwner || repoName !== expectedRepoName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subPath = subPathParts.join("/").trim();
|
||||
return {
|
||||
branch: typeof branch === "string" && branch.length > 0 ? branch : undefined,
|
||||
subPath: subPath.length > 0 ? subPath : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/agents/companies
|
||||
* Browse companies from companies.sh catalog.
|
||||
@@ -496,7 +538,7 @@ async function persistImportedSkills(
|
||||
// Note: The per-company endpoint (/api/companies/:slug) returns HTML (SPA),
|
||||
// so we fetch the full list and filter by slug.
|
||||
const companyApiUrl = "https://companies.sh/api/companies";
|
||||
let companyInfo: { name: string; repo?: string; tagline?: string } | null = null;
|
||||
let companyInfo: { name: string; repo?: string; tagline?: string; website?: string } | null = null;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
@@ -538,8 +580,9 @@ async function persistImportedSkills(
|
||||
const name = typeof match.name === "string" ? match.name : importCompanySlug;
|
||||
const repo = typeof match.repo === "string" ? match.repo : undefined;
|
||||
const tagline = typeof match.tagline === "string" ? match.tagline : undefined;
|
||||
const website = typeof match.website === "string" ? match.website : undefined;
|
||||
|
||||
companyInfo = { name, repo, tagline };
|
||||
companyInfo = { name, repo, tagline, website };
|
||||
} catch (fetchErr) {
|
||||
const message = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
||||
if (fetchErr instanceof ApiError) throw fetchErr;
|
||||
@@ -563,8 +606,10 @@ async function persistImportedSkills(
|
||||
}
|
||||
|
||||
const [, repoOwner, repoName] = repoMatch;
|
||||
// Use GitHub's archive API to get the default branch archive
|
||||
const archiveUrl = `https://github.com/${repoOwner}/${repoName}/archive/refs/heads/main.tar.gz`;
|
||||
const websiteInfo = parseCompanyWebsiteSubPath(companyInfo.website, `${repoOwner}/${repoName}`);
|
||||
const preferredBranch = websiteInfo?.branch?.trim() || "main";
|
||||
const subPath = websiteInfo?.subPath;
|
||||
const archiveUrl = `https://github.com/${repoOwner}/${repoName}/archive/refs/heads/${preferredBranch}.tar.gz`;
|
||||
|
||||
// Download and extract to temp directory
|
||||
let tempDir: string | null = null;
|
||||
@@ -616,7 +661,14 @@ async function persistImportedSkills(
|
||||
);
|
||||
|
||||
// Parse the downloaded archive directly to avoid requiring shell tar tools.
|
||||
pkg = await parseCompanyArchive(archivePath);
|
||||
try {
|
||||
pkg = await parseCompanyArchive(archivePath, subPath ? { subPath } : undefined);
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof Error && parseErr.name === "AgentCompaniesParseError" && subPath) {
|
||||
throw badRequest(`Failed to import companies.sh slug "${importCompanySlug}" from subpath "${subPath}": ${parseErr.message}`);
|
||||
}
|
||||
throw parseErr;
|
||||
}
|
||||
|
||||
// Override company info if available from API
|
||||
if (companyInfo) {
|
||||
|
||||
Reference in New Issue
Block a user