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 <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-04 11:38:59 +02:00 committed by GitHub
parent 6135bd8d63
commit 77559a6284
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 474 additions and 4 deletions

View File

@ -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",
]);
});
});

View File

@ -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/<old id>/…`).
* The prefix names the old bot, so it is dropped and the importer writes under the new one.
*/
export function relativeImportedFiles<T extends { path: string }>(
files: T[],
): Array<T & { path: string }> | null {
const safe: Array<T & { path: string }> = [];
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<T extends { crons: string[] }>(routines: T[]): T[] {
return routines.filter((routine) => !isOneShotRoutineCrons(routine.crons));
}

View File

@ -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 };

View File

@ -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}`);
}
});

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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 इम्पोर्ट करें"

View File

@ -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 가져오기"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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() {
<CreateBotForm
onCancel={() => 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);
}}
/>
</div>
</div>,

View File

@ -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<void>;
/** Restores a bot from the JSON that the bot settings' Export downloads. */
onImport: (manifest: unknown) => Promise<void>;
onCancel: () => void;
}) {
const { t } = useLingui();
const ids = useId();
const importInputRef = useRef<HTMLInputElement>(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 (
<div data-testid="create-bot-form">
<div className="mb-4 flex items-center justify-between">
<span className="text-ui-body text-muted-foreground">
<Trans>New bot</Trans>
</span>
<Button variant="ghost" size="icon-sm" aria-label={t`Cancel new bot`} onClick={onCancel}>
<X strokeWidth={1.8} className="size-icon-md" />
</Button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Import bot`}
disabled={submitting}
onClick={() => importInputRef.current?.click()}
>
<Upload strokeWidth={1.8} className="size-icon-md" />
</Button>
<input
ref={importInputRef}
type="file"
accept="application/json,.json"
hidden
data-testid="import-bot-file"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void handleImport(file);
}}
/>
<Button variant="ghost" size="icon-sm" aria-label={t`Cancel new bot`} onClick={onCancel}>
<X strokeWidth={1.8} className="size-icon-md" />
</Button>
</div>
</div>
{error ? (
<p

View File

@ -1308,6 +1308,30 @@ export const ExportManifestSchema = z.object({
});
export type ExportManifest = z.infer<typeof ExportManifestSchema>;
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<typeof ImportBotInput>;
/** Categories of durable knowledge about the user. */
export const USER_FACT_CATEGORIES = [
"identity",

View File

@ -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. */

View File

@ -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<Bot>(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<Bot>(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<Bot[]>(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");