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:
Fusion
2026-05-09 15:56:04 -07:00
committed by gsxdsm
parent 00c580d388
commit 89acfd0466
5 changed files with 276 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix agent-company imports from companies.sh monorepos by honoring the catalog subdirectory path (for example `paperclipai/companies/gstack`) instead of parsing the alphabetically first sibling package.

View File

@@ -502,6 +502,64 @@ name: Nested Archive CEO
expect(pkg.company?.name).toBe("Flat Zip Co");
});
it("uses subPath to select the correct company from a monorepo archive", async () => {
const root = createTempDir();
const archivePath = join(root, "mono.zip");
createZipFromEntries(archivePath, [
{ path: "companies-main/aeon/COMPANY.md", content: `---\nname: Aeon\n---` },
{ path: "companies-main/gstack/COMPANY.md", content: `---\nname: GStack\n---` },
{ path: "companies-main/gstack/agents/ceo/AGENTS.md", content: `---\nname: GStack CEO\n---` },
]);
const pkg = await parseCompanyArchive(archivePath, { subPath: "gstack" });
expect(pkg.company?.name).toBe("GStack");
expect(pkg.company?.name).not.toBe("Aeon");
expect(pkg.agents[0]?.name).toBe("GStack CEO");
});
it("throws when subPath does not contain COMPANY.md", async () => {
const root = createTempDir();
const archivePath = join(root, "missing-subpath.zip");
createZipFromEntries(archivePath, [
{ path: "companies-main/aeon/COMPANY.md", content: `---\nname: Aeon\n---` },
]);
await expect(parseCompanyArchive(archivePath, { subPath: "gstack" })).rejects.toThrow(
AgentCompaniesParseError,
);
});
it("rejects invalid subPath values", async () => {
const root = createTempDir();
const archivePath = join(root, "invalid-subpath.zip");
createZipFromEntries(archivePath, [
{ path: "companies-main/gstack/COMPANY.md", content: `---\nname: GStack\n---` },
]);
await expect(parseCompanyArchive(archivePath, { subPath: "../etc" })).rejects.toThrow(
AgentCompaniesParseError,
);
await expect(parseCompanyArchive(archivePath, { subPath: "/abs" })).rejects.toThrow(
AgentCompaniesParseError,
);
});
it("preserves legacy extraction behavior when subPath is omitted", async () => {
const root = createTempDir();
const archivePath = join(root, "legacy-behavior.zip");
createZipFromEntries(archivePath, [
{ path: "companies-main/aeon/COMPANY.md", content: `---\nname: Aeon\n---` },
{ path: "companies-main/gstack/COMPANY.md", content: `---\nname: GStack\n---` },
]);
const pkg = await parseCompanyArchive(archivePath);
expect(pkg.company?.name).toBe("Aeon");
});
it("throws for unsupported archive extension", async () => {
const root = createTempDir();
const archivePath = join(root, "company.rar");

View File

@@ -6,7 +6,7 @@
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { isAbsolute, join, normalize, resolve } from "node:path";
import extractZip from "extract-zip";
import { parse as parseYaml } from "yaml";
@@ -488,7 +488,42 @@ async function extractTarArchive(archivePath: string, outputDir: string): Promis
await execFileAsync("tar", ["xzf", archivePath, "-C", outputDir]);
}
export async function parseCompanyArchive(archivePath: string): Promise<AgentCompaniesPackage> {
function sanitizeCompanySubPath(subPath: string): string {
const trimmed = subPath.trim();
if (trimmed.length === 0) {
throw new AgentCompaniesParseError("subPath must not be empty");
}
if (trimmed.includes("\\")) {
throw new AgentCompaniesParseError(`Invalid subPath "${subPath}": backslashes are not allowed`);
}
if (isAbsolute(trimmed)) {
throw new AgentCompaniesParseError(`Invalid subPath "${subPath}": absolute paths are not allowed`);
}
const normalized = normalize(trimmed).replace(/^\/+/, "");
if (normalized === "" || normalized === "." || normalized.split("/").some((segment) => segment === "..")) {
throw new AgentCompaniesParseError(`Invalid subPath "${subPath}": path traversal is not allowed`);
}
return normalized;
}
function findArchiveWrapperRoot(tempDir: string): string {
const directories = readdirSync(tempDir, { withFileTypes: true }).filter((entry) =>
entry.isDirectory(),
);
if (directories.length === 1) {
return join(tempDir, directories[0].name);
}
return tempDir;
}
export async function parseCompanyArchive(
archivePath: string,
options?: { subPath?: string },
): Promise<AgentCompaniesPackage> {
const resolvedArchivePath = resolve(archivePath);
const tempDir = mkdtempSync(join(tmpdir(), "agent-companies-"));
@@ -503,6 +538,19 @@ export async function parseCompanyArchive(archivePath: string): Promise<AgentCom
);
}
if (typeof options?.subPath === "string") {
const wrapperRoot = findArchiveWrapperRoot(tempDir);
const sanitizedSubPath = sanitizeCompanySubPath(options.subPath);
const candidateRoot = join(wrapperRoot, sanitizedSubPath);
const companyManifestPath = join(candidateRoot, "COMPANY.md");
if (!existsSync(companyManifestPath)) {
throw new AgentCompaniesParseError(
`Company manifest not found at archive subPath "${sanitizedSubPath}" (expected ${companyManifestPath})`,
);
}
return parseCompanyDirectory(candidateRoot);
}
return parseCompanyDirectory(resolveExtractionRoot(tempDir));
} catch (error) {
if (error instanceof AgentCompaniesParseError) {

View File

@@ -576,7 +576,7 @@ describe("POST /api/agents/import", () => {
const url = String(input);
if (url === "https://companies.sh/api/companies") {
return Promise.resolve(new Response(
JSON.stringify({ items: [{ slug: "acme-ai", name: "Acme AI", repo: "acme/reviewers" }] }),
JSON.stringify({ items: [{ slug: "acme-ai", name: "Acme AI", repo: "acme/reviewers", website: "https://github.com/acme/reviewers/tree/main" }] }),
{
status: 200,
headers: { "content-type": "application/json" },
@@ -618,6 +618,109 @@ describe("POST /api/agents/import", () => {
expect(body.skills).toEqual([{ name: "review", description: "Review implementation details" }]);
});
it("passes website-derived subPath for monorepo company imports", async () => {
const archiveBuffer = createGitHubArchiveBuffer("gstack");
globalThis.fetch = vi.fn().mockImplementation((input: unknown) => {
const url = String(input);
if (url === "https://companies.sh/api/companies") {
return Promise.resolve(new Response(
JSON.stringify({
items: [
{ slug: "gstack", name: "GStack", repo: "paperclipai/companies", website: "https://github.com/paperclipai/companies/tree/main/gstack" },
{ slug: "aeon", name: "Aeon", repo: "paperclipai/companies", website: "https://github.com/paperclipai/companies/tree/main/aeon" },
],
}),
{ status: 200, headers: { "content-type": "application/json" } },
));
}
if (url === "https://github.com/paperclipai/companies/archive/refs/heads/main.tar.gz") {
return Promise.resolve(new Response(archiveBuffer, {
status: 200,
headers: { "content-type": "application/gzip" },
}));
}
return Promise.reject(new Error(`Unexpected fetch URL: ${url}`));
});
mockParseCompanyArchive.mockImplementation(async (_archivePath: string, options?: { subPath?: string }) => {
expect(options).toEqual({ subPath: "gstack" });
if (options?.subPath === "gstack") {
return {
company: { name: "GStack", slug: "gstack" },
agents: [{ name: "GStack Agent" }],
teams: [],
projects: [],
tasks: [],
skills: [],
};
}
return {
company: { name: "Aeon", slug: "aeon" },
agents: [{ name: "Aeon Agent" }],
teams: [],
projects: [],
tasks: [],
skills: [],
};
});
const response = await postImport(app, {
importSource: "companies.sh",
companySlug: "gstack",
dryRun: true,
});
expect(response.status, JSON.stringify(response.body)).toBe(200);
const body = response.body as any;
expect(body.companyName).toBe("GStack");
expect(body.companySlug).toBe("gstack");
});
it("omits subPath when website is absent or invalid", async () => {
const archiveBuffer = createGitHubArchiveBuffer("acme-ai");
globalThis.fetch = vi.fn().mockImplementation((input: unknown) => {
const url = String(input);
if (url === "https://companies.sh/api/companies") {
return Promise.resolve(new Response(
JSON.stringify({ items: [{ slug: "acme-ai", name: "Acme AI", repo: "acme/reviewers", website: "https://example.com/not-github" }] }),
{ status: 200, headers: { "content-type": "application/json" } },
));
}
if (url === "https://github.com/acme/reviewers/archive/refs/heads/main.tar.gz") {
return Promise.resolve(new Response(archiveBuffer, {
status: 200,
headers: { "content-type": "application/gzip" },
}));
}
return Promise.reject(new Error(`Unexpected fetch URL: ${url}`));
});
mockParseCompanyArchive.mockResolvedValue({
company: { name: "Acme AI", slug: "acme-ai" },
agents: [{ name: "Reviewer", skills: ["review"] }],
teams: [],
projects: [],
tasks: [],
skills: [],
});
const response = await postImport(app, {
importSource: "companies.sh",
companySlug: "acme-ai",
dryRun: true,
});
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(mockParseCompanyArchive).toHaveBeenCalledWith(expect.any(String), undefined);
});
it("returns companies.sh live import with skill import result", async () => {
const archiveBuffer = createGitHubArchiveBuffer("acme-ai");
@@ -625,7 +728,7 @@ describe("POST /api/agents/import", () => {
const url = String(input);
if (url === "https://companies.sh/api/companies") {
return Promise.resolve(new Response(
JSON.stringify({ items: [{ slug: "acme-ai", name: "Acme AI", repo: "acme/reviewers" }] }),
JSON.stringify({ items: [{ slug: "acme-ai", name: "Acme AI", repo: "acme/reviewers", website: "https://github.com/acme/reviewers/tree/main" }] }),
{
status: 200,
headers: { "content-type": "application/json" },
@@ -689,7 +792,7 @@ describe("POST /api/agents/import", () => {
const url = String(input);
if (url === "https://companies.sh/api/companies") {
return Promise.resolve(new Response(
JSON.stringify({ items: [{ slug: "acme-ai", name: "Acme AI", repo: "acme/reviewers" }] }),
JSON.stringify({ items: [{ slug: "acme-ai", name: "Acme AI", repo: "acme/reviewers", website: "https://github.com/acme/reviewers/tree/main" }] }),
{
status: 200,
headers: { "content-type": "application/json" },

View File

@@ -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) {