Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/features/agents/lib/agentZipImport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { zipSync } from "fflate";
import { describe, expect, it } from "vitest";
import { MAX_PERSONA_IMPORT_BYTES } from "./personaImport";
import {
type AgentZipImportError,
extractAgentFileFromZip,
isAgentZipFileName,
} from "./agentZipImport";

describe("agent ZIP import", () => {
it("extracts a portable agent image", () => {
const bytes = new Uint8Array([1, 2, 3]);
const archive = zipSync({ "reviewer.agent.png": bytes });

expect(extractAgentFileFromZip(archive)).toEqual({
name: "reviewer.agent.png",
bytes,
});
});

it("extracts persona markdown", () => {
const bytes = new TextEncoder().encode("---\nname: reviewer\n---\nReview.");
const archive = zipSync({ "reviewer.persona.md": bytes });

const extracted = extractAgentFileFromZip(archive);
expect(extracted.name).toBe("reviewer.persona.md");
expect(Array.from(extracted.bytes)).toEqual(Array.from(bytes));
});

it("ignores macOS metadata", () => {
const archive = zipSync({
"__MACOSX/._reviewer.agent.png": new Uint8Array([9]),
"folder/reviewer.agent.png": new Uint8Array([1]),
});

expect(extractAgentFileFromZip(archive).name).toBe("reviewer.agent.png");
});

it("rejects duplicate supported paths before extraction collapses them", () => {
const archive = zipSync({
"one.md": new Uint8Array([1]),
"two.md": new Uint8Array([2]),
});
const duplicatePathArchive = new Uint8Array(archive);
const originalName = new TextEncoder().encode("two.md");
const duplicateName = new TextEncoder().encode("one.md");
for (
let offset = 0;
offset <= duplicatePathArchive.length - originalName.length;
offset += 1
) {
if (
originalName.every(
(byte, index) => duplicatePathArchive[offset + index] === byte,
)
) {
duplicatePathArchive.set(duplicateName, offset);
}
}

expect(() => extractAgentFileFromZip(duplicatePathArchive)).toThrow(
expect.objectContaining<Partial<AgentZipImportError>>({
code: "multipleAgents",
}),
);
});

it("rejects ambiguous archives with a typed error", () => {
const archive = zipSync({
"one.persona.md": new Uint8Array([1]),
"two.json": new Uint8Array([2]),
});

expect(() => extractAgentFileFromZip(archive)).toThrow(
expect.objectContaining<Partial<AgentZipImportError>>({
code: "multipleAgents",
}),
);
});

it("enforces the direct-import limit on nested text agents", () => {
const archive = zipSync({
"large.persona.md": new Uint8Array(MAX_PERSONA_IMPORT_BYTES + 1),
});

expect(() => extractAgentFileFromZip(archive)).toThrow(
expect.objectContaining<Partial<AgentZipImportError>>({
code: "tooLarge",
maxBytes: MAX_PERSONA_IMPORT_BYTES,
}),
);
});

it("reports malformed archives with a typed error", () => {
expect(() => extractAgentFileFromZip(new Uint8Array([1, 2, 3]))).toThrow(
expect.objectContaining<Partial<AgentZipImportError>>({
code: "invalid",
}),
);
});

it("recognizes ZIP filenames case-insensitively", () => {
expect(isAgentZipFileName("Reviewer.Agent.ZIP")).toBe(true);
});
});
176 changes: 176 additions & 0 deletions src/features/agents/lib/agentZipImport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { unzipSync } from "fflate";
import { MAX_SNAPSHOT_PNG_BYTES } from "@/features/agents/agent-snapshot";
import { MAX_PERSONA_IMPORT_BYTES } from "@/features/agents/lib/personaImport";

const MAX_ARCHIVE_ENTRIES = 32;

export type AgentZipImportErrorCode =
| "invalid"
| "tooManyFiles"
| "tooLarge"
| "missingAgent"
| "multipleAgents";

export class AgentZipImportError extends Error {
constructor(
public readonly code: AgentZipImportErrorCode,
public readonly maxBytes?: number,
) {
super(code);
this.name = "AgentZipImportError";
}
}

export interface ExtractedAgentFile {
bytes: Uint8Array;
name: string;
}

export function isAgentZipFileName(fileName: string): boolean {
return fileName.trim().toLowerCase().endsWith(".zip");
}

function isAgentImageFileName(fileName: string): boolean {
return fileName.toLowerCase().endsWith(".png");
}

function isSupportedAgentFileName(fileName: string): boolean {
const lowerName = fileName.toLowerCase();
return (
isAgentImageFileName(lowerName) ||
lowerName.endsWith(".md") ||
lowerName.endsWith(".json")
);
}

function maxAgentFileBytes(fileName: string): number {
return isAgentImageFileName(fileName)
? MAX_SNAPSHOT_PNG_BYTES
: MAX_PERSONA_IMPORT_BYTES;
}

function validateExtractedSize(fileName: string, size: number): void {
const maxBytes = maxAgentFileBytes(fileName);
if (size > maxBytes) {
throw new AgentZipImportError("tooLarge", maxBytes);
}
}

export const AGENT_ZIP_IMPORT_TIMEOUT_MS = 15_000;

export function extractAgentFileFromZipInWorker(
archiveBytes: Uint8Array,
signal?: AbortSignal,
timeoutMs = AGENT_ZIP_IMPORT_TIMEOUT_MS,
): Promise<ExtractedAgentFile> {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL("./agentZipImport.worker.ts", import.meta.url),
{ type: "module" },
);
let settled = false;
const finish = (operation: () => void) => {
if (settled) return;
settled = true;
window.clearTimeout(timeout);
signal?.removeEventListener("abort", handleAbort);
worker.terminate();
operation();
};
const handleAbort = () =>
finish(() => reject(new DOMException("Aborted", "AbortError")));
const timeout = window.setTimeout(
() => finish(() => reject(new AgentZipImportError("invalid"))),
timeoutMs,
);
worker.onmessage = (
event: MessageEvent<
| ExtractedAgentFile
| {
error: { code: AgentZipImportErrorCode; maxBytes?: number };
}
>,
) => {
if ("error" in event.data) {
const { code, maxBytes } = event.data.error;
finish(() => reject(new AgentZipImportError(code, maxBytes)));
} else {
const extracted = event.data;
finish(() => resolve(extracted));
}
};
worker.onerror = () =>
finish(() => reject(new AgentZipImportError("invalid")));
if (signal?.aborted) {
handleAbort();
return;
}
signal?.addEventListener("abort", handleAbort, { once: true });
const workerBytes = new Uint8Array(archiveBytes);
worker.postMessage({ archiveBytes: workerBytes }, [workerBytes.buffer]);
});
}

export function extractAgentFileFromZip(
archiveBytes: Uint8Array,
): ExtractedAgentFile {
let entryCount = 0;
let supportedEntryCount = 0;
let totalUncompressedBytes = 0;
let archive: Record<string, Uint8Array>;
try {
archive = unzipSync(archiveBytes, {
Comment thread
cynfria marked this conversation as resolved.
filter(entry) {
entryCount += 1;
if (entryCount > MAX_ARCHIVE_ENTRIES) {
throw new AgentZipImportError("tooManyFiles");
}
totalUncompressedBytes += entry.originalSize;
if (totalUncompressedBytes > MAX_SNAPSHOT_PNG_BYTES) {
throw new AgentZipImportError("tooLarge", MAX_SNAPSHOT_PNG_BYTES);
}
const name = entry.name.split("/").at(-1) ?? "";
const isSupportedEntry =
!entry.name.endsWith("/") &&
!entry.name.split("/").includes("__MACOSX") &&
!name.startsWith(".") &&
isSupportedAgentFileName(name);
if (isSupportedEntry) {
supportedEntryCount += 1;
if (supportedEntryCount > 1) {
throw new AgentZipImportError("multipleAgents");
}
if (entry.originalSize > maxAgentFileBytes(name)) {
throw new AgentZipImportError("tooLarge", maxAgentFileBytes(name));
}
}
return !entry.name.endsWith("/");
},
});
} catch (error) {
if (error instanceof AgentZipImportError) throw error;
throw new AgentZipImportError("invalid");
}

const candidates = Object.entries(archive).filter(([path]) => {
Comment thread
cynfria marked this conversation as resolved.
const parts = path.split("/");
const name = parts.at(-1) ?? "";
return (
!parts.includes("__MACOSX") &&
!name.startsWith(".") &&
isSupportedAgentFileName(name)
);
});

if (candidates.length === 0) {
throw new AgentZipImportError("missingAgent");
}
if (candidates.length > 1) {
throw new AgentZipImportError("multipleAgents");
}

const [path, bytes] = candidates[0];
const name = path.split("/").at(-1) ?? path;
validateExtractedSize(name, bytes.length);
return { bytes, name };
}
15 changes: 15 additions & 0 deletions src/features/agents/lib/agentZipImport.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { AgentZipImportError, extractAgentFileFromZip } from "./agentZipImport";

self.onmessage = ({ data }: MessageEvent<{ archiveBytes: Uint8Array }>) => {
try {
const extracted = extractAgentFileFromZip(data.archiveBytes);
self.postMessage(extracted, { transfer: [extracted.bytes.buffer] });
} catch (error) {
self.postMessage({
error:
error instanceof AgentZipImportError
? { code: error.code, maxBytes: error.maxBytes }
: { code: "invalid" },
});
}
};
Loading