diff --git a/apps/api/src/bot-import.test.ts b/apps/api/src/bot-import.test.ts new file mode 100644 index 0000000..ae6bd2d --- /dev/null +++ b/apps/api/src/bot-import.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js"; + +describe("safeImportPath", () => { + it("normalizes separators and dot segments", () => { + expect(safeImportPath("notes\\./result.txt")).toBe("notes/result.txt"); + expect(safeImportPath("MEMORY.md")).toBe("MEMORY.md"); + }); + + it("rejects paths that could leave the bot home", () => { + expect(safeImportPath("../secrets")).toBeNull(); + expect(safeImportPath("notes/../../x")).toBeNull(); + expect(safeImportPath("/etc/passwd")).toBeNull(); + expect(safeImportPath("C:\\Windows")).toBeNull(); + expect(safeImportPath("./")).toBeNull(); + }); +}); + +describe("relativeImportedFiles", () => { + it("drops the old Team Computer workspace prefix", () => { + expect( + relativeImportedFiles([ + { path: "bots/old-id/notes/result.txt", content: "a" }, + { path: "bots/old-id/README.md", content: "b" }, + ]), + ).toEqual([ + { path: "notes/result.txt", content: "a" }, + { path: "README.md", content: "b" }, + ]); + }); + + it("keeps Private Computer paths as they are", () => { + const files = [ + { path: "notes/result.txt", content: "a" }, + { path: "bots/other/file.txt", content: "b" }, + ]; + expect(relativeImportedFiles(files)).toEqual(files); + }); + + it("keeps paths when files span several workspaces", () => { + const files = [ + { path: "bots/a/x.txt", content: "a" }, + { path: "bots/b/y.txt", content: "b" }, + ]; + expect(relativeImportedFiles(files)).toEqual(files); + }); + + it("refuses the whole set when one path is unsafe", () => { + expect( + relativeImportedFiles([ + { path: "ok.txt", content: "a" }, + { path: "../escape.txt", content: "b" }, + ]), + ).toBeNull(); + }); + + it("accepts an export without files", () => { + expect(relativeImportedFiles([])).toEqual([]); + }); +}); + +describe("importableRoutines", () => { + it("leaves out one-shot schedules", () => { + const routines = [ + { name: "daily", crons: ["0 9 * * *"] }, + { name: "once", crons: ["@once"] }, + { name: "webhook", crons: [] }, + ]; + expect(importableRoutines(routines).map((routine) => routine.name)).toEqual([ + "daily", + "webhook", + ]); + }); +}); diff --git a/apps/api/src/bot-import.ts b/apps/api/src/bot-import.ts new file mode 100644 index 0000000..5979738 --- /dev/null +++ b/apps/api/src/bot-import.ts @@ -0,0 +1,34 @@ +import { isOneShotRoutineCrons } from "@rakazo/core"; + +/** A relative, forward-slash path without `.`/`..` segments, or null when it could escape. */ +export function safeImportPath(raw: string): string | null { + const normalized = raw.replace(/\\/g, "/"); + if (normalized.startsWith("/") || /^[a-z]:/i.test(normalized)) return null; + const segments = normalized.split("/").filter((segment) => segment !== "" && segment !== "."); + if (segments.length === 0 || segments.includes("..")) return null; + return segments.join("/"); +} + +/** + * Exports of bots on a Team Computer carry their workspace prefix (`bots//…`). + * The prefix names the old bot, so it is dropped and the importer writes under the new one. + */ +export function relativeImportedFiles( + files: T[], +): Array | null { + const safe: Array = []; + for (const file of files) { + const path = safeImportPath(file.path); + if (!path) return null; + safe.push({ ...file, path }); + } + const first = safe[0]?.path.split("/"); + const workspace = first && first.length > 2 && first[0] === "bots" ? `bots/${first[1]}/` : null; + if (!workspace || !safe.every((file) => file.path.startsWith(workspace))) return safe; + return safe.map((file) => ({ ...file, path: file.path.slice(workspace.length) })); +} + +/** One-shot schedules belong to the chat that set them, so they are not carried over. */ +export function importableRoutines(routines: T[]): T[] { + return routines.filter((routine) => !isOneShotRoutineCrons(routine.crons)); +} diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 845ffbc..4a5154a 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -169,6 +169,7 @@ import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-sec import { createAgentSkillsService } from "./agent-skills.js"; import { aiConsentStatus, allowAiConsent } from "./ai-consent.js"; import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js"; +import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js"; import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js"; import { createCoachService } from "./coach.js"; import { @@ -1264,6 +1265,87 @@ export function createRouter(deps: RouterDeps) { } return duplicate; }), + import: authed.bots.import.handler(async ({ context, input }) => { + const files = relativeImportedFiles(input.files); + const memory = input.memory.flatMap((doc) => { + const path = safeImportPath(doc.path); + return path ? [{ path, content: doc.content }] : []; + }); + if (!files || memory.length !== input.memory.length) { + throw new ORPCError("BAD_REQUEST", { message: "This export has an unsafe file path." }); + } + const routines = importableRoutines(input.routines); + for (const routine of routines) { + if (hasMixedOneShotSchedule(routine.crons)) { + throw new ORPCError("BAD_REQUEST", { + message: "A one-time schedule can't be combined with other schedules.", + }); + } + if (routine.crons.length > 0) nextRoutineDate(routine.crons, routine.timezone); + } + const created = await repos + .createBot(context.actor, { ...input.bot, notifyOnFinish: true }) + .catch((error: unknown) => { + throw mapSpaceLifecycleError(error); + }); + const bot = await repos.getBot(context.actor, created.id); + const importContext = { + operationId: "import", + traceId: "import", + spaceId: context.actor.spaceId, + userId: context.actor.userId, + signal: new AbortController().signal, + }; + try { + if (!bot.computer) throw new IsolationError(); + for (const doc of memory) { + await deps.memory.commit( + { scope: "bot", botId: bot.id, path: doc.path, content: doc.content }, + importContext, + ); + } + const prefix = bot.computer.scope === "team" ? teamBotWorkspaceDirectory(bot.id) : ""; + for (const file of files) { + await deps.home.writeFile( + bot.computer.homeKey, + prefix ? `${prefix}/${file.path}` : file.path, + file.content, + importContext, + ); + } + // Imported schedules start paused: the user decides when this bot may act on its own. + if (routines.length) { + await deps.prisma.routine.createMany({ + data: routines.map((routine) => ({ + spaceId: context.actor.spaceId, + botId: bot.id, + userId: context.actor.userId, + name: routine.name, + prompt: routine.prompt, + crons: routine.crons, + timezone: routine.timezone, + active: false, + })), + }); + } + } catch (error) { + await destroyBot( + { + prisma: deps.prisma, + sandbox: deps.sandbox, + home: deps.home, + jobs: deps.jobs, + artifacts: deps.artifacts, + dataDir: deps.dataDir, + }, + bot, + { ...importContext, operationId: "destroy", traceId: "destroy" }, + { deleteMemories: true }, + ).catch(() => undefined); + throw error; + } + return created; + }), reorder: authed.bots.reorder.handler(async ({ context, input }) => { await repos.reorderBots(context.actor, input.botIds); return { ok: true as const }; diff --git a/apps/web/e2e/bot-crud.spec.ts b/apps/web/e2e/bot-crud.spec.ts index 0780649..3c9d46a 100644 --- a/apps/web/e2e/bot-crud.spec.ts +++ b/apps/web/e2e/bot-crud.spec.ts @@ -179,3 +179,66 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) = await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); await captureScreenshot(page, testInfo, "31-deleted-bot-fallback"); }); + +test("a bot export restores as a new bot", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `bot-import-${stamp}@rakazo.test`, "password12", "Bot Import"); + await completeOnboarding(page); + await page.goto("/app"); + await page.waitForURL(/\/app\/[^/]+$/); + + await openNewBot(page); + const form = page.getByTestId("create-bot-form"); + await expect(form.getByRole("button", { name: "Import bot" })).toBeVisible(); + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await expect(page.locator("html")).toHaveAttribute("data-theme", colorScheme); + await captureScreenshot(page, testInfo, `27d-create-bot-import-${colorScheme}`); + } + + const fileInput = form.getByTestId("import-bot-file"); + await fileInput.setInputFiles({ + name: "broken.json", + mimeType: "application/json", + buffer: Buffer.from("{"), + }); + await expect(page.getByTestId("create-bot-error")).toHaveText("Could not import this file"); + + await fileInput.setInputFiles({ + name: "archivist-export.json", + mimeType: "application/json", + buffer: Buffer.from( + JSON.stringify({ + version: 1, + exportedAt: new Date().toISOString(), + bot: { + name: "Archivist", + title: "Keeps the records", + description: "Files and finds documents.", + instructions: "Name every file by date.", + }, + memory: [{ path: "MEMORY.md", content: "# Archive\n\nPrefers ISO dates." }], + routines: [ + { + name: "Weekly sweep", + prompt: "Tidy the archive", + crons: ["0 8 * * 1"], + timezone: "UTC", + }, + ], + files: [{ path: "notes/index.md", content: "imported" }], + history: [], + }), + ), + }); + await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed"); + const botList = page.locator("aside").first(); + await expect(botList.getByRole("button", { name: /^Archivist/ })).toBeVisible(); + await page.locator("main").getByRole("button", { name: "Archivist", exact: true }).click(); + await expect(page.locator("label:has-text('Title') input")).toHaveValue("Keeps the records"); + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await expect(page.locator("html")).toHaveAttribute("data-theme", colorScheme); + await captureScreenshot(page, testInfo, `27e-imported-bot-${colorScheme}`); + } +}); diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index c52404a..4373ebe 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -1508,6 +1508,10 @@ msgstr "Konnte nicht ausgeblendet werden" msgid "Could not enable speaking" msgstr "Vorlesen konnte nicht aktiviert werden" +#: src/pages/shell/bot-panel.tsx +msgid "Could not import this file" +msgstr "Diese Datei konnte nicht importiert werden" + #: src/pages/PluginsOverlay.tsx msgid "Could not install connector" msgstr "Connector konnte nicht installiert werden" @@ -2556,6 +2560,10 @@ msgstr "Bild-Upload-Bereich" msgid "Image workflow" msgstr "Bild-Workflow" +#: src/pages/shell/bot-panel.tsx +msgid "Import bot" +msgstr "Bot importieren" + #: src/pages/PluginsOverlay.tsx msgid "Import OpenAPI JSON" msgstr "OpenAPI-JSON importieren" diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index d9c2f14..6fada7e 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -1508,6 +1508,10 @@ msgstr "Could not dismiss" msgid "Could not enable speaking" msgstr "Could not enable speaking" +#: src/pages/shell/bot-panel.tsx +msgid "Could not import this file" +msgstr "Could not import this file" + #: src/pages/PluginsOverlay.tsx msgid "Could not install connector" msgstr "Could not install connector" @@ -2556,6 +2560,10 @@ msgstr "Image upload area" msgid "Image workflow" msgstr "Image workflow" +#: src/pages/shell/bot-panel.tsx +msgid "Import bot" +msgstr "Import bot" + #: src/pages/PluginsOverlay.tsx msgid "Import OpenAPI JSON" msgstr "Import OpenAPI JSON" diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index 7c98792..52a19d2 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -1508,6 +1508,10 @@ msgstr "" msgid "Could not enable speaking" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Could not import this file" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Could not install connector" msgstr "No se pudo instalar el conector" @@ -2556,6 +2560,10 @@ msgstr "Área de carga de imagen" msgid "Image workflow" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Import bot" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Import OpenAPI JSON" msgstr "Importar JSON de OpenAPI" diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index 6ebeba1..9b23d13 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -1508,6 +1508,10 @@ msgstr "" msgid "Could not enable speaking" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Could not import this file" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Could not install connector" msgstr "कनेक्टर इंस्टॉल नहीं हो सका" @@ -2556,6 +2560,10 @@ msgstr "छवि अपलोड क्षेत्र" msgid "Image workflow" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Import bot" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Import OpenAPI JSON" msgstr "OpenAPI JSON इम्पोर्ट करें" diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index c225d4e..6c2e14e 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -1508,6 +1508,10 @@ msgstr "" msgid "Could not enable speaking" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Could not import this file" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Could not install connector" msgstr "도구 연결을 추가하지 못했습니다." @@ -2556,6 +2560,10 @@ msgstr "이미지 업로드 영역" msgid "Image workflow" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Import bot" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Import OpenAPI JSON" msgstr "OpenAPI JSON 가져오기" diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index 42033c3..1e37b68 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -1508,6 +1508,10 @@ msgstr "" msgid "Could not enable speaking" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Could not import this file" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Could not install connector" msgstr "Não foi possível instalar o conector" @@ -2556,6 +2560,10 @@ msgstr "Área de envio de imagem" msgid "Image workflow" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Import bot" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Import OpenAPI JSON" msgstr "Importar OpenAPI JSON" diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index 935a14d..ebc3f3c 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -1508,6 +1508,10 @@ msgstr "Не удалось отклонить" msgid "Could not enable speaking" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Could not import this file" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Could not install connector" msgstr "Не удалось установить коннектор" @@ -2556,6 +2560,10 @@ msgstr "Область загрузки изображения" msgid "Image workflow" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Import bot" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Import OpenAPI JSON" msgstr "Импортировать OpenAPI JSON" diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index 08316ed..7dc8438 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -1508,6 +1508,10 @@ msgstr "" msgid "Could not enable speaking" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Could not import this file" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Could not install connector" msgstr "Bağlayıcı kurulamadı" @@ -2556,6 +2560,10 @@ msgstr "Görüntü yükleme alanı" msgid "Image workflow" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Import bot" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Import OpenAPI JSON" msgstr "OpenAPI JSON içe aktar" diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index 2a4dcd3..bb72f74 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -1508,6 +1508,10 @@ msgstr "" msgid "Could not enable speaking" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Could not import this file" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Could not install connector" msgstr "无法安装连接器" @@ -2556,6 +2560,10 @@ msgstr "图片上传区域" msgid "Image workflow" msgstr "" +#: src/pages/shell/bot-panel.tsx +msgid "Import bot" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Import OpenAPI JSON" msgstr "导入 OpenAPI JSON" diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index a0c998d..5afd237 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -27,6 +27,7 @@ import type { Connection, ConnectionCatalogItem, Group, + ImportBotInput, MainConversationRef, Me, ProductEvent, @@ -3107,6 +3108,16 @@ export function ShellPage() { setSettingsOpen(true); } + async function importBot(manifest: unknown) { + const bot = await rpc.bots.import(manifest as ImportBotInput); + setBots((current) => + current.some((item) => item.id === bot.id) ? current : [bot, ...current], + ); + navigate(`/app/${bot.id}`); + setPanel(null); + await refreshBots().catch(() => undefined); + } + async function createBot(input: { name: string; title: string; @@ -4961,6 +4972,7 @@ export function ShellPage() { setPanel(null)} onCreate={(input) => createBot(input)} + onImport={(manifest) => importBot(manifest)} /> ) : null} {/* Centered Create Bot Modal */} @@ -4992,6 +5004,10 @@ export function ShellPage() { onCreate={async (input) => { await createBot(input); }} + onImport={async (manifest) => { + await importBot(manifest); + setShowCreateBotModal(false); + }} /> , diff --git a/apps/web/src/pages/shell/bot-panel.tsx b/apps/web/src/pages/shell/bot-panel.tsx index 5120807..05ad050 100644 --- a/apps/web/src/pages/shell/bot-panel.tsx +++ b/apps/web/src/pages/shell/bot-panel.tsx @@ -30,7 +30,7 @@ import { Textarea, Toggle, } from "@rakazo/ui-web"; -import { X } from "lucide-react"; +import { Upload, X } from "lucide-react"; import { Suspense, useEffect, useId, useRef, useState } from "react"; import { connectedModelOptions } from "../../lib/connected-models"; import { lazyWithRecovery } from "../../lib/lazy-recovery"; @@ -87,6 +87,7 @@ function ComputerModePicker({ export function CreateBotForm({ onCreate, + onImport, onCancel, }: { onCreate: (input: { @@ -95,10 +96,13 @@ export function CreateBotForm({ description: string; computerMode: ComputerMode; }) => Promise; + /** Restores a bot from the JSON that the bot settings' Export downloads. */ + onImport: (manifest: unknown) => Promise; onCancel: () => void; }) { const { t } = useLingui(); const ids = useId(); + const importInputRef = useRef(null); const [name, setName] = useState(""); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); @@ -124,15 +128,51 @@ export function CreateBotForm({ } } + async function handleImport(file: File) { + if (submitting) return; + setError(null); + setSubmitting(true); + try { + await onImport(JSON.parse(await file.text())); + } catch { + setError(t`Could not import this file`); + } finally { + setSubmitting(false); + } + } + return (
New bot - +
+ + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void handleImport(file); + }} + /> + +
{error ? (

; +const ImportPath = z.string().trim().min(1).max(1024); + +/** + * An export manifest restored as a new bot. History is not replayed: the bot starts + * its own conversation, so extra keys such as `history` are accepted and dropped. + */ +export const ImportBotInput = z.object({ + version: z.literal(1), + bot: CreateBotInput.pick({ name: true, title: true, description: true, instructions: true }), + memory: z.array(z.object({ path: ImportPath, content: z.string() })).max(500), + routines: z + .array( + z.object({ + name: z.string().trim().min(1).max(80), + prompt: z.string().min(1), + crons: z.array(z.string().min(1)).max(20), + timezone: z.string().min(1).max(100), + }), + ) + .max(100), + files: z.array(z.object({ path: ImportPath, content: z.string() })).max(5000), +}); +export type ImportBotInput = z.infer; + /** Categories of durable knowledge about the user. */ export const USER_FACT_CATEGORIES = [ "identity", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 281286a..cf14c28 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -50,6 +50,7 @@ import { ExternalConversationPolicySchema, GroupDetailSchema, GroupSchema, + ImportBotInput, IntegrationCatalogResultSchema, MainConversationRefSchema, McpServerConfigInput, @@ -340,6 +341,8 @@ export const appContract = { /** The user's main bot, created with its main conversation in the home space when missing. */ ensureMain: oc.output(BotSchema), duplicate: oc.input(botId).output(BotSchema), + /** A new bot restored from an `export.bot` manifest. */ + import: oc.input(ImportBotInput).output(BotSchema), reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })), update: oc.input(UpdateBotInput).output(BotSchema), /** Builtin tool names this bot's allowlist can choose from. */ diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index 3ab5e55..da71092 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -1613,6 +1613,68 @@ describeJourneys("required product journeys", () => { expect(rawJson).not.toMatch(/browserProfile|ciphertext|sessionCookie/i); }); + it("9b: an export restores as a new bot with memory, files and paused routines", async () => { + const cookie = await signup(app, `import-j-${stamp}@rakazo.test`, "Import"); + const source = await rpc(app, cookie, "bots/create", { + name: "Scout", + title: "Researcher", + description: "Finds sources", + instructions: "Cite everything", + notifyOnFinish: true, + }); + await sendAndWait( + app, + cookie, + source.id, + "write a file in your home called notes/result.txt that says import-ok", + ); + await rpc(app, cookie, "routines/create", { + botId: source.id, + name: "Morning brief", + prompt: "Summarize the news", + crons: ["0 9 * * *"], + timezone: "UTC", + active: true, + }); + const manifest = await rpc<{ + memory: Array<{ path: string; content: string }>; + files: Array<{ path: string; content: string }>; + }>(app, cookie, "export/bot", { botId: source.id }); + expect(manifest.files.some((file) => file.content.includes("import-ok"))).toBe(true); + + const imported = await rpc(app, cookie, "bots/import", manifest); + expect(imported.id).not.toBe(source.id); + expect(imported).toMatchObject({ + name: "Scout", + title: "Researcher", + description: "Finds sources", + instructions: "Cite everything", + }); + const roundTrip = await rpc<{ + memory: Array<{ path: string; content: string }>; + files: Array<{ path: string; content: string }>; + }>(app, cookie, "export/bot", { botId: imported.id }); + expect(roundTrip.files.map((file) => file.content)).toContain( + manifest.files.find((file) => file.content.includes("import-ok"))?.content, + ); + expect(roundTrip.files.every((file) => !file.path.includes(source.id))).toBe(true); + for (const doc of manifest.memory) { + expect(roundTrip.memory).toContainEqual(doc); + } + const routines = await prisma.routine.findMany({ where: { botId: imported.id } }); + expect(routines).toHaveLength(1); + expect(routines[0]).toMatchObject({ name: "Morning brief", active: false, nextRunAt: null }); + + const other = await signup(app, `import-other-j-${stamp}@rakazo.test`, "Other"); + const unsafe = await raw(app, other, "bots/import", { + ...manifest, + files: [{ path: "../escape.txt", content: "x" }], + }); + expect(unsafe.status).toBe(400); + const otherBots = await rpc(app, other, "bots/list"); + expect(otherBots.some((bot) => bot.name === "Scout")).toBe(false); + }); + it("10: bots can be archived safely and deleted with or without their memories", async () => { const ada = await signup(app, `delete-j-${stamp}@rakazo.test`, "Delete Ada"); const bob = await signup(app, `delete-bob-j-${stamp}@rakazo.test`, "Delete Bob");