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:
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user