diff --git a/src/features/agents/lib/agentZipImport.test.ts b/src/features/agents/lib/agentZipImport.test.ts new file mode 100644 index 00000000..d855950a --- /dev/null +++ b/src/features/agents/lib/agentZipImport.test.ts @@ -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>({ + 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>({ + 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>({ + 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>({ + code: "invalid", + }), + ); + }); + + it("recognizes ZIP filenames case-insensitively", () => { + expect(isAgentZipFileName("Reviewer.Agent.ZIP")).toBe(true); + }); +}); diff --git a/src/features/agents/lib/agentZipImport.ts b/src/features/agents/lib/agentZipImport.ts new file mode 100644 index 00000000..745d2c1d --- /dev/null +++ b/src/features/agents/lib/agentZipImport.ts @@ -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 { + 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; + try { + archive = unzipSync(archiveBytes, { + 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]) => { + 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 }; +} diff --git a/src/features/agents/lib/agentZipImport.worker.ts b/src/features/agents/lib/agentZipImport.worker.ts new file mode 100644 index 00000000..dd1a80ce --- /dev/null +++ b/src/features/agents/lib/agentZipImport.worker.ts @@ -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" }, + }); + } +}; diff --git a/src/features/agents/ui/AgentImportDialog.tsx b/src/features/agents/ui/AgentImportDialog.tsx index b1dfa570..f89bbd1e 100644 --- a/src/features/agents/ui/AgentImportDialog.tsx +++ b/src/features/agents/ui/AgentImportDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { IconPhotoPlus, IconUpload } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; @@ -37,6 +37,12 @@ export interface AgentImportPreview extends PersonaImportPreview { interface AgentImportDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + /** + * A file already validated and read by another import surface (the gallery + * drop zone). The dialog prepares it through the same flow as picker files + * so every entry point shares one preview/confirmation owner. + */ + initialFile?: { bytes: Uint8Array; name: string } | null; onImportFile: ( fileBytes: Uint8Array, fileName: string, @@ -45,7 +51,14 @@ interface AgentImportDialogProps { prepareImport: ( fileBytes: Uint8Array, fileName: string, - ) => AgentImportPreview; + signal: AbortSignal, + ) => + | AgentImportPreview + | Promise<{ + bytes: Uint8Array; + name: string; + preview: AgentImportPreview; + }>; validateImportFile: ( file: Pick, ) => string | null; @@ -57,6 +70,7 @@ interface AgentImportDialogProps { export function AgentImportDialog({ open, onOpenChange, + initialFile, onImportFile, prepareImport, validateImportFile, @@ -69,6 +83,8 @@ export function AgentImportDialog({ const [importAccentColor, setImportAccentColor] = useState( null, ); + const preparationRef = useRef(null); + const [preparing, setPreparing] = useState(false); const [prepared, setPrepared] = useState<{ bytes: Uint8Array; name: string; @@ -76,9 +92,19 @@ export function AgentImportDialog({ } | null>(null); useEffect(() => { - if (!open) setPrepared(null); + if (!open) { + preparationRef.current?.abort(); + setPrepared(null); + } }, [open]); + useEffect( + () => () => { + preparationRef.current?.abort(); + }, + [], + ); + useEffect(() => { if (!prepared?.preview.cardImageUrl) { setImportAccentColor(null); @@ -116,6 +142,71 @@ export function AgentImportDialog({ [prepared?.preview.cardImageUrl], ); + const startPreparation = useCallback( + (bytes: Uint8Array, name: string): AbortController => { + preparationRef.current?.abort(); + const controller = new AbortController(); + preparationRef.current = controller; + setPreparing(true); + void (async () => { + try { + const result = await prepareImport(bytes, name, controller.signal); + if (controller.signal.aborted) { + // A discarded result never reaches prepared state, so the cleanup + // effect will not revoke its preview URL; dispose of it here. + const staleUrl = ("preview" in result ? result.preview : result) + .cardImageUrl; + if (staleUrl) URL.revokeObjectURL(staleUrl); + return; + } + // The cleanup effect keyed by cardImageUrl revokes the previous URL + // exactly once when this prepared preview replaces it. + setPrepared( + "preview" in result ? result : { bytes, name, preview: result }, + ); + } catch (error) { + if (!controller.signal.aborted) { + onImportError( + error instanceof Error ? error.message : String(error), + ); + } + } finally { + if (preparationRef.current === controller) { + preparationRef.current = null; + setPreparing(false); + } + } + })(); + return controller; + }, + [onImportError, prepareImport], + ); + + const consumedInitialFileRef = useRef(null); + useEffect(() => { + if (!open) { + consumedInitialFileRef.current = null; + return; + } + if (!initialFile || consumedInitialFileRef.current === initialFile) { + return; + } + consumedInitialFileRef.current = initialFile; + const controller = startPreparation(initialFile.bytes, initialFile.name); + return () => { + // Lifecycle cleanup (StrictMode replay, unmount) aborts this attempt. + // If it is still the active preparation, un-consume the file so a + // replayed effect can restart it; a picker selection that replaced + // this attempt keeps ownership and the marker stays consumed. + if (preparationRef.current === controller) { + controller.abort(); + preparationRef.current = null; + setPreparing(false); + consumedInitialFileRef.current = null; + } + }; + }, [open, initialFile, startPreparation]); + const { fileInputRef, isDragOver, @@ -123,17 +214,12 @@ export function AgentImportDialog({ handleFileChange, openFilePicker, } = useFileImportZone({ - onImportFile: (bytes, name) => { - try { - const preview = prepareImport(bytes, name); - // The cleanup effect keyed by cardImageUrl revokes the previous URL - // exactly once when this prepared preview replaces it. - setPrepared({ bytes, name, preview }); - } catch (error) { - onImportError(error instanceof Error ? error.message : String(error)); - } + onImportFile: startPreparation, + validateFile: (file) => { + preparationRef.current?.abort(); + setPrepared(null); + return validateImportFile(file); }, - validateFile: validateImportFile, onImportError, maxBytes: maxImportBytes, fileTooLargeMessage: importTooLargeMessage, @@ -199,6 +285,8 @@ export function AgentImportDialog({ ) : (