From 77559a62841a0af95e74d7b00119ecd2dc607a81 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:38:59 +0200 Subject: [PATCH] feat: restore a bot from its export (#128) Bot settings could export a bot as JSON, but nothing could read that file back, so an export was not a way to recover or move a bot. bots.import creates a new bot from an export manifest: profile and instructions, bot memory, home files and routines. Team Computer paths are rewritten to the new bot's workspace, unsafe paths reject the whole import, imported routines start paused and one-shot schedules are left out. History is not replayed; the bot starts its own conversation. A failed import removes the half-created bot. The New bot panel gets an import button that takes the exported file. Claude-Session: https://claude.ai/code/session_01Sbaz56zNFZBJ1whrpasMMs Co-authored-by: Claude --- apps/api/src/bot-import.test.ts | 74 +++++++++++++++++++++++ apps/api/src/bot-import.ts | 34 +++++++++++ apps/api/src/router.ts | 82 ++++++++++++++++++++++++++ apps/web/e2e/bot-crud.spec.ts | 63 ++++++++++++++++++++ apps/web/src/locales/de/messages.po | 8 +++ apps/web/src/locales/en/messages.po | 8 +++ apps/web/src/locales/es/messages.po | 8 +++ apps/web/src/locales/hi/messages.po | 8 +++ apps/web/src/locales/ko/messages.po | 8 +++ apps/web/src/locales/pt-BR/messages.po | 8 +++ apps/web/src/locales/ru/messages.po | 8 +++ apps/web/src/locales/tr/messages.po | 8 +++ apps/web/src/locales/zh-CN/messages.po | 8 +++ apps/web/src/pages/Shell.tsx | 16 +++++ apps/web/src/pages/shell/bot-panel.tsx | 48 +++++++++++++-- packages/contracts/src/domain.ts | 24 ++++++++ packages/contracts/src/rpc.ts | 3 + packages/testkit/src/journeys.test.ts | 62 +++++++++++++++++++ 18 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/bot-import.test.ts create mode 100644 apps/api/src/bot-import.ts 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 79ad40f..92f826c 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -168,6 +168,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 { @@ -1253,6 +1254,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 234a25f..bb5c497 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -1511,6 +1511,10 @@ msgstr "{name} konnte nicht heruntergeladen werden. Versuche es erneut." 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" @@ -2538,6 +2542,10 @@ msgstr "Ignorieren" msgid "Image upload area" msgstr "Bild-Upload-Bereich" +#: 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 b030f40..cd9bf13 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -1511,6 +1511,10 @@ msgstr "Could not download {name}. Try again." 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" @@ -2538,6 +2542,10 @@ msgstr "Ignore" msgid "Image upload area" msgstr "Image upload area" +#: 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 4adafbc..15128d8 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -1511,6 +1511,10 @@ msgstr "No se pudo descargar {name}. Inténtalo de nuevo." 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" @@ -2538,6 +2542,10 @@ msgstr "" msgid "Image upload area" msgstr "Área de carga de imagen" +#: 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 e2f2e54..a4af6f8 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -1511,6 +1511,10 @@ msgstr "{name} डाउनलोड नहीं हो सका। पुन 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 "कनेक्टर इंस्टॉल नहीं हो सका" @@ -2538,6 +2542,10 @@ msgstr "" msgid "Image upload area" 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 e7e2a0e..b577a71 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -1511,6 +1511,10 @@ msgstr "{name}을 다운로드하지 못했습니다. 다시 시도해 주세요 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 "도구 연결을 추가하지 못했습니다." @@ -2538,6 +2542,10 @@ msgstr "" msgid "Image upload area" 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 e3392d4..82b07cb 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -1511,6 +1511,10 @@ msgstr "Não foi possível baixar o {name}. Tente novamente." 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" @@ -2538,6 +2542,10 @@ msgstr "" msgid "Image upload area" msgstr "Área de envio de imagem" +#: 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 cd98928..0fb2203 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -1511,6 +1511,10 @@ msgstr "Не удалось загрузить {name}. Попробуйте ещ 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 "Не удалось установить коннектор" @@ -2538,6 +2542,10 @@ msgstr "игнорировать" msgid "Image upload area" 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 088dc15..1dced15 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -1511,6 +1511,10 @@ msgstr "{name} indirilemedi. Tekrar dene." 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ı" @@ -2538,6 +2542,10 @@ msgstr "" msgid "Image upload area" msgstr "Görüntü yükleme alanı" +#: 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 f0276e0..62a46dc 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -1511,6 +1511,10 @@ msgstr "无法下载 {name}。请重试。" 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 "无法安装连接器" @@ -2538,6 +2542,10 @@ msgstr "" msgid "Image upload area" 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 a07547f..2d9f9d0 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, @@ -3104,6 +3105,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; @@ -4959,6 +4970,7 @@ export function ShellPage() { setPanel(null)} onCreate={(input) => createBot(input)} + onImport={(manifest) => importBot(manifest)} /> ) : null} {/* Centered Create Bot Modal */} @@ -4990,6 +5002,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 ab43522..ed2062d 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, @@ -333,6 +334,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");