From 96051e6132f36275fb0c618a9b12fb2e645e38af Mon Sep 17 00:00:00 2001 From: cynfria Date: Tue, 18 Aug 2026 10:54:31 -0700 Subject: [PATCH 1/9] feat: import agents from zip files --- .../agents/lib/agentZipImport.test.ts | 76 ++++++++++++ src/features/agents/lib/agentZipImport.ts | 115 ++++++++++++++++++ src/features/agents/ui/AgentImportDialog.tsx | 2 +- src/features/agents/ui/AgentsView.tsx | 78 ++++++++++-- .../ui/__tests__/AgentsView.entry.test.tsx | 67 ++++++++++ src/shared/i18n/locales/en/agents.json | 12 +- src/shared/i18n/locales/es/agents.json | 12 +- 7 files changed, 344 insertions(+), 18 deletions(-) create mode 100644 src/features/agents/lib/agentZipImport.test.ts create mode 100644 src/features/agents/lib/agentZipImport.ts diff --git a/src/features/agents/lib/agentZipImport.test.ts b/src/features/agents/lib/agentZipImport.test.ts new file mode 100644 index 00000000..8b13ee0d --- /dev/null +++ b/src/features/agents/lib/agentZipImport.test.ts @@ -0,0 +1,76 @@ +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 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..f8b29298 --- /dev/null +++ b/src/features/agents/lib/agentZipImport.ts @@ -0,0 +1,115 @@ +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 function extractAgentFileFromZip( + archiveBytes: Uint8Array, +): ExtractedAgentFile { + let entryCount = 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) ?? ""; + if ( + !entry.name.endsWith("/") && + !entry.name.split("/").includes("__MACOSX") && + !name.startsWith(".") && + isSupportedAgentFileName(name) && + 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/ui/AgentImportDialog.tsx b/src/features/agents/ui/AgentImportDialog.tsx index b1dfa570..e8c498cd 100644 --- a/src/features/agents/ui/AgentImportDialog.tsx +++ b/src/features/agents/ui/AgentImportDialog.tsx @@ -222,7 +222,7 @@ export function AgentImportDialog({ diff --git a/src/features/agents/ui/AgentsView.tsx b/src/features/agents/ui/AgentsView.tsx index 530cc5d2..d6524443 100644 --- a/src/features/agents/ui/AgentsView.tsx +++ b/src/features/agents/ui/AgentsView.tsx @@ -42,6 +42,7 @@ import type { Persona } from "@/shared/types/agents"; import { formatAgentError, formatImportSuccessMessage, + formatPersonaImportFileSize, validatePersonaImportFile, } from "@/features/agents/lib/personaImport"; import { isEmptyAgentsGallerySimulated } from "@/features/agents/lib/emptyGallerySimulation"; @@ -55,6 +56,12 @@ import { runAgentViewTransition } from "@/features/agents/lib/agentViewTransitio import { deleteDraftAgentSession } from "@/features/agents/lib/agentBuilderSession"; import type { AppNavigationUpdateOptions } from "@/app/types/appNavigation"; import { isSafePngAvatarDataUrl } from "@/shared/lib/avatarUrl"; +import { + AgentZipImportError, + type ExtractedAgentFile, + extractAgentFileFromZip, + isAgentZipFileName, +} from "@/features/agents/lib/agentZipImport"; function decodeImportFileBytes(fileBytes: Uint8Array): string { try { @@ -77,6 +84,18 @@ function isAgentImageFileName(fileName: string): boolean { return fileName.toLowerCase().endsWith(".png"); } +function formatAgentZipImportError( + error: AgentZipImportError, + t: (key: string, options?: Record) => string, +): string { + if (error.code === "tooLarge" && error.maxBytes) { + return t("view.importTooLarge", { + maxSize: formatPersonaImportFileSize(error.maxBytes), + }); + } + return t(`zipImport.${error.code}`); +} + function exportFilenameFromPath( path: string, fallbackFilename: string, @@ -413,6 +432,19 @@ export function AgentsView({ const validateImportFile = useCallback( (file: Pick) => { + if (isAgentZipFileName(file.name)) { + if (file.size > MAX_SNAPSHOT_PNG_BYTES) { + return t("view.importTooLarge", { maxSize: "10 MB" }); + } + if ( + file.type && + file.type !== "application/zip" && + file.type !== "application/x-zip-compressed" + ) { + return t("view.importInvalidMimeType"); + } + return null; + } if (isAgentImageFileName(file.name)) { if (file.size > MAX_SNAPSHOT_PNG_BYTES) { return t("imageImport.tooLarge"); @@ -457,16 +489,26 @@ export function AgentsView({ preview?: { snapshot?: ReturnType }, ) => { try { - if (isAgentImageFileName(fileName)) { + const extracted = isAgentZipFileName(fileName) + ? extractAgentFileFromZip(fileBytes) + : { bytes: fileBytes, name: fileName }; + if (isAgentImageFileName(extracted.name)) { setImageImport({ - snapshot: preview?.snapshot ?? decodeAgentImage(fileBytes), - bytes: fileBytes, + snapshot: preview?.snapshot ?? decodeAgentImage(extracted.bytes), + bytes: extracted.bytes, }); return; } - await handleImportContents(decodeImportFileBytes(fileBytes), fileName); + await handleImportContents( + decodeImportFileBytes(extracted.bytes), + extracted.name, + ); } catch (err) { - toast.error(formatAgentError(err, t("view.importFailed"))); + toast.error( + err instanceof AgentZipImportError + ? formatAgentZipImportError(err, t) + : formatAgentError(err, t("view.importFailed")), + ); } }, [handleImportContents, t], @@ -480,16 +522,27 @@ export function AgentsView({ onOpenChange={setImportDialogOpen} onImportFile={handleImportFileBytes} prepareImport={(bytes, name) => { - if (isAgentImageFileName(name)) { - const snapshot = decodeAgentImage(bytes); - const { width, height } = getPngDimensions(bytes); + let extracted: ExtractedAgentFile; + try { + extracted = isAgentZipFileName(name) + ? extractAgentFileFromZip(bytes) + : { bytes, name }; + } catch (error) { + if (error instanceof AgentZipImportError) { + throw new Error(formatAgentZipImportError(error, t)); + } + throw error; + } + if (isAgentImageFileName(extracted.name)) { + const snapshot = decodeAgentImage(extracted.bytes); + const { width, height } = getPngDimensions(extracted.bytes); return { displayName: snapshot.profile?.displayName ?? snapshot.definition.name ?? "Imported agent", systemPrompt: snapshot.definition.systemPrompt ?? "", - identity: name, + identity: extracted.name, avatar: typeof snapshot.profile?.avatarDataUrl === "string" && isSafePngAvatarDataUrl(snapshot.profile.avatarDataUrl) @@ -498,13 +551,16 @@ export function AgentsView({ snapshot, cardAspectRatio: width / height, cardImageUrl: URL.createObjectURL( - new Blob([new Uint8Array(bytes).buffer], { + new Blob([new Uint8Array(extracted.bytes).buffer], { type: "image/png", }), ), }; } - return previewPersonaImport(decodeImportFileBytes(bytes), name); + return previewPersonaImport( + decodeImportFileBytes(extracted.bytes), + extracted.name, + ); }} validateImportFile={validateImportFile} onImportError={handleImportError} diff --git a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx index f6bc0c46..91c6600f 100644 --- a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx +++ b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx @@ -10,6 +10,7 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { zipSync } from "fflate"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { toast } from "sonner"; @@ -369,6 +370,72 @@ describe("AgentsView entry points", () => { expect(screen.getByText("You are a test agent.")).toBeInTheDocument(); }); + it("previews a portable agent image imported from a ZIP", async () => { + const fixtureBytes = readFileSync( + resolve( + process.cwd(), + "src/features/agents/agent-snapshot/fixtures/buzz-v1-config-only.agent.png", + ), + ); + const archive = zipSync({ "shared.agent.png": fixtureBytes }); + const file = new File([archive], "shared.agent.zip", { + type: "application/zip", + }); + Object.defineProperty(file, "arrayBuffer", { + configurable: true, + value: vi.fn().mockResolvedValue(archive.buffer), + }); + render(); + await userEvent.click( + screen.getByRole("button", { name: "gallery.importViaImage" }), + ); + const input = document.querySelector( + 'input[type="file"][accept*="application/zip"]', + ); + expect(input).not.toBeNull(); + + fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); + + expect( + await screen.findByRole("img", { name: "importDialog.previewAlt" }), + ).toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: "importDialog.import" }), + ); + expect( + await screen.findByRole("heading", { name: "imageImport.description" }), + ).toBeInTheDocument(); + expect(screen.getByDisplayValue("Test Agent Display")).toBeInTheDocument(); + }); + + it("shows a localized error for an ambiguous agent ZIP", async () => { + const archive = zipSync({ + "one.persona.md": new TextEncoder().encode("one"), + "two.json": new TextEncoder().encode("{}"), + }); + const file = new File([archive], "agents.zip", { + type: "application/zip", + }); + Object.defineProperty(file, "arrayBuffer", { + configurable: true, + value: vi.fn().mockResolvedValue(archive.buffer), + }); + render(); + await userEvent.click( + screen.getByRole("button", { name: "gallery.importViaImage" }), + ); + const input = document.querySelector( + 'input[type="file"][accept*="application/zip"]', + ); + expect(input).not.toBeNull(); + + fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("zipImport.multipleAgents"), + ); + }); + it("reports malformed PNG imports instead of rejecting silently", async () => { const file = new File([new Uint8Array([1, 2, 3])], "broken.png", { type: "image/png", diff --git a/src/shared/i18n/locales/en/agents.json b/src/shared/i18n/locales/en/agents.json index f3225137..6389f583 100644 --- a/src/shared/i18n/locales/en/agents.json +++ b/src/shared/i18n/locales/en/agents.json @@ -225,8 +225,8 @@ "deleted": "\"{{name}}\" deleted.", "exportFailed": "Failed to export agent.", "exportedTo": "Exported to {{filename}}", - "importInvalidExtension": "Unsupported file type. Choose a .persona.md or .json file.", - "importInvalidMimeType": "Unsupported file type. Choose a persona markdown or JSON file.", + "importInvalidExtension": "Unsupported file type. Choose an agent ZIP, PNG, .persona.md, or JSON file.", + "importInvalidMimeType": "Unsupported file type. Choose an agent ZIP, PNG, persona markdown, or JSON file.", "importTooLarge": "Import file must be {{maxSize}} or smaller.", "importFailed": "Failed to import agent.", "imported_one": "Imported {{count}} agent.", @@ -263,9 +263,15 @@ "tooLarge": "Agent image must be 10 MB or smaller.", "pngOnly": "Agent image must be a PNG file." }, + "zipImport": { + "invalid": "The agent ZIP is invalid or damaged.", + "tooManyFiles": "The agent ZIP contains too many files.", + "missingAgent": "The ZIP doesn’t contain a supported agent file.", + "multipleAgents": "The ZIP must contain exactly one agent file." + }, "importDialog": { "title": "Import an agent", - "description": "Import an agent with an image, .md, or JSON file.", + "description": "Import an agent with a ZIP, image, .md, or JSON file.", "dropTitle": "Drag and drop an agent file here", "openFinder": "Open Finder", "previewAlt": "Preview of {{name}}", diff --git a/src/shared/i18n/locales/es/agents.json b/src/shared/i18n/locales/es/agents.json index 94c6ae95..4efe1a40 100644 --- a/src/shared/i18n/locales/es/agents.json +++ b/src/shared/i18n/locales/es/agents.json @@ -225,8 +225,8 @@ "deleted": "Se eliminó \"{{name}}\".", "exportFailed": "No se pudo exportar el agente.", "exportedTo": "Exportado a {{filename}}", - "importInvalidExtension": "Tipo de archivo no compatible. Elige un archivo .persona.md o .json.", - "importInvalidMimeType": "Tipo de archivo no compatible. Elige un archivo de persona markdown o JSON.", + "importInvalidExtension": "Tipo de archivo no compatible. Elige un archivo ZIP, PNG, .persona.md o JSON de agente.", + "importInvalidMimeType": "Tipo de archivo no compatible. Elige un archivo ZIP, PNG, markdown de persona o JSON de agente.", "importTooLarge": "El archivo importado debe ser de {{maxSize}} o menos.", "importFailed": "No se pudo importar el agente.", "imported_one": "Se importó {{count}} agente.", @@ -263,9 +263,15 @@ "tooLarge": "La imagen del agente debe ser de 10 MB o menos.", "pngOnly": "La imagen del agente debe ser un archivo PNG." }, + "zipImport": { + "invalid": "El archivo ZIP del agente no es válido o está dañado.", + "tooManyFiles": "El archivo ZIP del agente contiene demasiados archivos.", + "missingAgent": "El archivo ZIP no contiene un archivo de agente compatible.", + "multipleAgents": "El archivo ZIP debe contener exactamente un archivo de agente." + }, "importDialog": { "title": "Importar un agente", - "description": "Importa un agente con una imagen o un archivo .md o JSON.", + "description": "Importa un agente con un archivo ZIP, una imagen o un archivo .md o JSON.", "dropTitle": "Arrastra y suelta aquí un archivo de agente", "openFinder": "Abrir Finder", "previewAlt": "Vista previa de {{name}}", From c57341889319eb8fbb594c76bdde36b4b65fb046 Mon Sep 17 00:00:00 2001 From: cynfria Date: Tue, 18 Aug 2026 11:41:41 -0700 Subject: [PATCH 2/9] fix: clear stale agent import previews --- src/features/agents/ui/AgentImportDialog.tsx | 5 +- .../ui/__tests__/AgentImportDialog.test.tsx | 61 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/features/agents/ui/AgentImportDialog.tsx b/src/features/agents/ui/AgentImportDialog.tsx index e8c498cd..807dd158 100644 --- a/src/features/agents/ui/AgentImportDialog.tsx +++ b/src/features/agents/ui/AgentImportDialog.tsx @@ -133,7 +133,10 @@ export function AgentImportDialog({ onImportError(error instanceof Error ? error.message : String(error)); } }, - validateFile: validateImportFile, + validateFile: (file) => { + setPrepared(null); + return validateImportFile(file); + }, onImportError, maxBytes: maxImportBytes, fileTooLargeMessage: importTooLargeMessage, diff --git a/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx b/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx index 5c2b3ba8..c325ba57 100644 --- a/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx +++ b/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx @@ -20,7 +20,68 @@ vi.mock("motion/react", async (importOriginal) => { return { ...actual, useReducedMotion: () => false }; }); +function importDialogProps(overrides: Record = {}) { + return { + open: true, + onOpenChange: vi.fn(), + onImportFile: vi.fn(), + prepareImport: () => ({ + displayName: "Reviewer", + systemPrompt: "Review carefully.", + identity: "agent.agent.png", + }), + validateImportFile: () => null, + onImportError: vi.fn(), + maxImportBytes: 1024, + importTooLargeMessage: "Too large", + ...overrides, + }; +} + describe("AgentImportDialog", () => { + it("clears a prepared import when a replacement file is rejected", async () => { + const firstBytes = Uint8Array.from([1, 2, 3]); + const firstFile = new File([firstBytes], "agent.agent.png", { + type: "image/png", + }); + Object.defineProperty(firstFile, "arrayBuffer", { + configurable: true, + value: vi.fn().mockResolvedValue(firstBytes.buffer), + }); + const rejectedFile = new File([new Uint8Array([9])], "broken.zip", { + type: "application/zip", + }); + const onImportError = vi.fn(); + render( + + file.name === "broken.zip" ? "Invalid ZIP" : null, + onImportError, + })} + />, + ); + const input = + document.querySelector('input[type="file"]'); + + fireEvent.change(input as HTMLInputElement, { + target: { files: [firstFile] }, + }); + expect( + await screen.findByRole("button", { name: "importDialog.import" }), + ).toBeInTheDocument(); + + fireEvent.change(input as HTMLInputElement, { + target: { files: [rejectedFile] }, + }); + + expect(onImportError).toHaveBeenCalledWith("Invalid ZIP"); + expect( + screen.queryByRole("button", { name: "importDialog.import" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Reviewer")).not.toBeInTheDocument(); + }); + it("tilts the rendered import card toward the pointer and resets", async () => { vi.spyOn(window, "matchMedia").mockReturnValue({ matches: false, From 264bb59fc07df4404590dfb97b7d99ab07dd66e3 Mon Sep 17 00:00:00 2001 From: cynfria Date: Tue, 18 Aug 2026 11:42:08 -0700 Subject: [PATCH 3/9] fix: reject duplicate agent zip entries --- .../agents/lib/agentZipImport.test.ts | 29 +++++++++++++++++++ src/features/agents/lib/agentZipImport.ts | 16 ++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/features/agents/lib/agentZipImport.test.ts b/src/features/agents/lib/agentZipImport.test.ts index 8b13ee0d..d855950a 100644 --- a/src/features/agents/lib/agentZipImport.test.ts +++ b/src/features/agents/lib/agentZipImport.test.ts @@ -36,6 +36,35 @@ describe("agent ZIP import", () => { 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]), diff --git a/src/features/agents/lib/agentZipImport.ts b/src/features/agents/lib/agentZipImport.ts index f8b29298..698f1e97 100644 --- a/src/features/agents/lib/agentZipImport.ts +++ b/src/features/agents/lib/agentZipImport.ts @@ -60,6 +60,7 @@ export function extractAgentFileFromZip( archiveBytes: Uint8Array, ): ExtractedAgentFile { let entryCount = 0; + let supportedEntryCount = 0; let totalUncompressedBytes = 0; let archive: Record; try { @@ -74,14 +75,19 @@ export function extractAgentFileFromZip( throw new AgentZipImportError("tooLarge", MAX_SNAPSHOT_PNG_BYTES); } const name = entry.name.split("/").at(-1) ?? ""; - if ( + const isSupportedEntry = !entry.name.endsWith("/") && !entry.name.split("/").includes("__MACOSX") && !name.startsWith(".") && - isSupportedAgentFileName(name) && - entry.originalSize > maxAgentFileBytes(name) - ) { - throw new AgentZipImportError("tooLarge", maxAgentFileBytes(name)); + 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("/"); }, From 4cfca9ebfe2f6e7b997272520512697fbda223d3 Mon Sep 17 00:00:00 2001 From: cynfria Date: Tue, 18 Aug 2026 12:11:53 -0700 Subject: [PATCH 4/9] fix: extract agent zips off the renderer thread --- src/features/agents/lib/agentZipImport.ts | 55 ++++++++++++++++++ .../agents/lib/agentZipImport.worker.ts | 15 +++++ src/features/agents/ui/AgentImportDialog.tsx | 44 +++++++++++--- src/features/agents/ui/AgentsView.tsx | 18 +++--- .../ui/__tests__/AgentImportDialog.test.tsx | 58 ++++++++++++++++++- .../ui/__tests__/AgentsView.entry.test.tsx | 12 ++++ 6 files changed, 183 insertions(+), 19 deletions(-) create mode 100644 src/features/agents/lib/agentZipImport.worker.ts diff --git a/src/features/agents/lib/agentZipImport.ts b/src/features/agents/lib/agentZipImport.ts index 698f1e97..745d2c1d 100644 --- a/src/features/agents/lib/agentZipImport.ts +++ b/src/features/agents/lib/agentZipImport.ts @@ -56,6 +56,61 @@ function validateExtractedSize(fileName: string, size: number): void { } } +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 { 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 807dd158..2db9444a 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 { useEffect, useRef, useState } from "react"; import { IconPhotoPlus, IconUpload } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; @@ -45,7 +45,14 @@ interface AgentImportDialogProps { prepareImport: ( fileBytes: Uint8Array, fileName: string, - ) => AgentImportPreview; + signal: AbortSignal, + ) => + | AgentImportPreview + | Promise<{ + bytes: Uint8Array; + name: string; + preview: AgentImportPreview; + }>; validateImportFile: ( file: Pick, ) => string | null; @@ -69,6 +76,7 @@ export function AgentImportDialog({ const [importAccentColor, setImportAccentColor] = useState( null, ); + const preparationRef = useRef(null); const [prepared, setPrepared] = useState<{ bytes: Uint8Array; name: string; @@ -76,9 +84,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); @@ -123,17 +141,29 @@ export function AgentImportDialog({ handleFileChange, openFilePicker, } = useFileImportZone({ - onImportFile: (bytes, name) => { + onImportFile: async (bytes, name) => { + const controller = new AbortController(); + preparationRef.current = controller; try { - const preview = prepareImport(bytes, name); + const result = await prepareImport(bytes, name, controller.signal); + if (controller.signal.aborted) return; // The cleanup effect keyed by cardImageUrl revokes the previous URL // exactly once when this prepared preview replaces it. - setPrepared({ bytes, name, preview }); + setPrepared( + "preview" in result ? result : { bytes, name, preview: result }, + ); } catch (error) { - onImportError(error instanceof Error ? error.message : String(error)); + if (!controller.signal.aborted) { + onImportError(error instanceof Error ? error.message : String(error)); + } + } finally { + if (preparationRef.current === controller) { + preparationRef.current = null; + } } }, validateFile: (file) => { + preparationRef.current?.abort(); setPrepared(null); return validateImportFile(file); }, diff --git a/src/features/agents/ui/AgentsView.tsx b/src/features/agents/ui/AgentsView.tsx index d6524443..11c13202 100644 --- a/src/features/agents/ui/AgentsView.tsx +++ b/src/features/agents/ui/AgentsView.tsx @@ -60,6 +60,7 @@ import { AgentZipImportError, type ExtractedAgentFile, extractAgentFileFromZip, + extractAgentFileFromZipInWorker, isAgentZipFileName, } from "@/features/agents/lib/agentZipImport"; @@ -436,13 +437,6 @@ export function AgentsView({ if (file.size > MAX_SNAPSHOT_PNG_BYTES) { return t("view.importTooLarge", { maxSize: "10 MB" }); } - if ( - file.type && - file.type !== "application/zip" && - file.type !== "application/x-zip-compressed" - ) { - return t("view.importInvalidMimeType"); - } return null; } if (isAgentImageFileName(file.name)) { @@ -521,11 +515,11 @@ export function AgentsView({ open onOpenChange={setImportDialogOpen} onImportFile={handleImportFileBytes} - prepareImport={(bytes, name) => { + prepareImport={async (bytes, name, signal) => { let extracted: ExtractedAgentFile; try { extracted = isAgentZipFileName(name) - ? extractAgentFileFromZip(bytes) + ? await extractAgentFileFromZipInWorker(bytes, signal) : { bytes, name }; } catch (error) { if (error instanceof AgentZipImportError) { @@ -536,7 +530,7 @@ export function AgentsView({ if (isAgentImageFileName(extracted.name)) { const snapshot = decodeAgentImage(extracted.bytes); const { width, height } = getPngDimensions(extracted.bytes); - return { + const preview = { displayName: snapshot.profile?.displayName ?? snapshot.definition.name ?? @@ -556,11 +550,13 @@ export function AgentsView({ }), ), }; + return { ...extracted, preview }; } - return previewPersonaImport( + const preview = previewPersonaImport( decodeImportFileBytes(extracted.bytes), extracted.name, ); + return { ...extracted, preview }; }} validateImportFile={validateImportFile} onImportError={handleImportError} diff --git a/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx b/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx index c325ba57..8fb0e770 100644 --- a/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx +++ b/src/features/agents/ui/__tests__/AgentImportDialog.test.tsx @@ -6,7 +6,18 @@ import { waitFor, } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { AgentImportDialog } from "../AgentImportDialog"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} +import { + AgentImportDialog, + type AgentImportPreview, +} from "../AgentImportDialog"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -82,6 +93,51 @@ describe("AgentImportDialog", () => { expect(screen.queryByText("Reviewer")).not.toBeInTheDocument(); }); + it("cancels in-flight preparation when the dialog closes", async () => { + const preparation = deferred<{ + bytes: Uint8Array; + name: string; + preview: AgentImportPreview; + }>(); + const bytes = Uint8Array.from([1]); + const file = new File([bytes], "agent.zip", { type: "application/zip" }); + Object.defineProperty(file, "arrayBuffer", { + configurable: true, + value: vi.fn().mockResolvedValue(bytes.buffer), + }); + let preparationSignal: AbortSignal | undefined; + const props = importDialogProps({ + prepareImport: ( + _bytes: Uint8Array, + _name: string, + signal: AbortSignal, + ) => { + preparationSignal = signal; + return preparation.promise; + }, + }); + const { rerender } = render(); + const input = + document.querySelector('input[type="file"]'); + fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); + await waitFor(() => expect(preparationSignal).toBeDefined()); + + rerender(); + expect(preparationSignal?.aborted).toBe(true); + + preparation.resolve({ + bytes, + name: "stale.md", + preview: { + displayName: "Stale", + systemPrompt: "Stale", + identity: "stale.md", + }, + }); + await Promise.resolve(); + expect(screen.queryByText("Stale")).not.toBeInTheDocument(); + }); + it("tilts the rendered import card toward the pointer and resets", async () => { vi.spyOn(window, "matchMedia").mockReturnValue({ matches: false, diff --git a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx index 91c6600f..7a521abb 100644 --- a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx +++ b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx @@ -45,6 +45,18 @@ const mockDraftSource = vi.hoisted(() => ({ }, })); +vi.mock("@/features/agents/lib/agentZipImport", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/features/agents/lib/agentZipImport") + >(); + return { + ...actual, + extractAgentFileFromZipInWorker: async (bytes: Uint8Array) => + actual.extractAgentFileFromZip(bytes), + }; +}); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string, options?: Record) => From 64b3b4215cd706056324bbc5f1d11e56b907a41a Mon Sep 17 00:00:00 2001 From: cynfria Date: Tue, 18 Aug 2026 12:38:57 -0700 Subject: [PATCH 5/9] fix: use worker extraction for gallery zip drops --- src/features/agents/ui/AgentsView.tsx | 5 +-- .../ui/__tests__/AgentsView.entry.test.tsx | 34 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/features/agents/ui/AgentsView.tsx b/src/features/agents/ui/AgentsView.tsx index 11c13202..8cc70586 100644 --- a/src/features/agents/ui/AgentsView.tsx +++ b/src/features/agents/ui/AgentsView.tsx @@ -59,7 +59,6 @@ import { isSafePngAvatarDataUrl } from "@/shared/lib/avatarUrl"; import { AgentZipImportError, type ExtractedAgentFile, - extractAgentFileFromZip, extractAgentFileFromZipInWorker, isAgentZipFileName, } from "@/features/agents/lib/agentZipImport"; @@ -483,8 +482,10 @@ export function AgentsView({ preview?: { snapshot?: ReturnType }, ) => { try { + // A .zip name only reaches this handler from the gallery drop zone; + // the import dialog always passes the extracted inner file. const extracted = isAgentZipFileName(fileName) - ? extractAgentFileFromZip(fileBytes) + ? await extractAgentFileFromZipInWorker(fileBytes) : { bytes: fileBytes, name: fileName }; if (isAgentImageFileName(extracted.name)) { setImageImport({ diff --git a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx index 7a521abb..dd40d5cd 100644 --- a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx +++ b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx @@ -45,15 +45,19 @@ const mockDraftSource = vi.hoisted(() => ({ }, })); +const mockExtractInWorker = vi.hoisted(() => vi.fn()); + vi.mock("@/features/agents/lib/agentZipImport", async (importOriginal) => { const actual = await importOriginal< typeof import("@/features/agents/lib/agentZipImport") >(); + mockExtractInWorker.mockImplementation(async (bytes: Uint8Array) => + actual.extractAgentFileFromZip(bytes), + ); return { ...actual, - extractAgentFileFromZipInWorker: async (bytes: Uint8Array) => - actual.extractAgentFileFromZip(bytes), + extractAgentFileFromZipInWorker: mockExtractInWorker, }; }); @@ -420,6 +424,32 @@ describe("AgentsView entry points", () => { expect(screen.getByDisplayValue("Test Agent Display")).toBeInTheDocument(); }); + it("routes gallery ZIP drops through worker-backed extraction", async () => { + vi.mocked(importPersonas).mockResolvedValue([]); + const archive = zipSync({ + "reviewer.persona.md": new TextEncoder().encode("---\n---\nReview."), + }); + const file = new File([archive], "reviewer.zip", { + type: "application/zip", + }); + Object.defineProperty(file, "arrayBuffer", { + configurable: true, + value: vi.fn().mockResolvedValue(archive.buffer), + }); + const { container } = render(); + const dropZone = container.querySelector(".\\@container") as HTMLElement; + + fireEvent.drop(dropZone, { dataTransfer: { files: [file] } }); + + await waitFor(() => expect(mockExtractInWorker).toHaveBeenCalled()); + await waitFor(() => + expect(importPersonas).toHaveBeenCalledWith( + expect.stringContaining("Review."), + "reviewer.persona.md", + ), + ); + }); + it("shows a localized error for an ambiguous agent ZIP", async () => { const archive = zipSync({ "one.persona.md": new TextEncoder().encode("one"), From d96e24dc1c3ca414147e4adebe7b2a6e0d3f86ce Mon Sep 17 00:00:00 2001 From: cynfria Date: Tue, 18 Aug 2026 12:38:57 -0700 Subject: [PATCH 6/9] fix: show pending state while preparing imports --- src/features/agents/ui/AgentImportDialog.tsx | 12 +++++++++- .../ui/__tests__/AgentImportDialog.test.tsx | 24 ++++++++++++------- src/shared/i18n/locales/en/agents.json | 1 + src/shared/i18n/locales/es/agents.json | 1 + 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/features/agents/ui/AgentImportDialog.tsx b/src/features/agents/ui/AgentImportDialog.tsx index 2db9444a..4a85a89f 100644 --- a/src/features/agents/ui/AgentImportDialog.tsx +++ b/src/features/agents/ui/AgentImportDialog.tsx @@ -77,6 +77,7 @@ export function AgentImportDialog({ null, ); const preparationRef = useRef(null); + const [preparing, setPreparing] = useState(false); const [prepared, setPrepared] = useState<{ bytes: Uint8Array; name: string; @@ -144,6 +145,7 @@ export function AgentImportDialog({ onImportFile: async (bytes, name) => { const controller = new AbortController(); preparationRef.current = controller; + setPreparing(true); try { const result = await prepareImport(bytes, name, controller.signal); if (controller.signal.aborted) return; @@ -159,6 +161,7 @@ export function AgentImportDialog({ } finally { if (preparationRef.current === controller) { preparationRef.current = null; + setPreparing(false); } } }, @@ -232,6 +235,8 @@ export function AgentImportDialog({ ) : (