feat: restore a bot from its export

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.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sbaz56zNFZBJ1whrpasMMs
This commit is contained in:
Claude 2026-10-04 07:04:34 +00:00
parent 59f4a1dfca
commit ac2ac2713e
No known key found for this signature in database
11 changed files with 418 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

@ -161,6 +161,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 {
executionBlocksUserTakeover,
@ -1177,6 +1178,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

@ -665,6 +665,14 @@ msgstr "Server nicht erreichbar."
msgid "Cancel"
msgstr "Abbrechen"
#: src/pages/shell/bot-panel.tsx
msgid "Import bot"
msgstr "Bot importieren"
#: src/pages/shell/bot-panel.tsx
msgid "Could not import this file"
msgstr "Diese Datei konnte nicht importiert werden"
#: src/pages/shell/bot-panel.tsx:123
msgid "Cancel new bot"
msgstr "Neuen Bot abbrechen"

View File

@ -665,6 +665,14 @@ msgstr "Can't reach the server."
msgid "Cancel"
msgstr "Cancel"
#: src/pages/shell/bot-panel.tsx
msgid "Import bot"
msgstr "Import bot"
#: src/pages/shell/bot-panel.tsx
msgid "Could not import this file"
msgstr "Could not import this file"
#: src/pages/shell/bot-panel.tsx:123
msgid "Cancel new bot"
msgstr "Cancel new bot"

View File

@ -27,6 +27,7 @@ import type {
Connection,
ConnectionCatalogItem,
Group,
ImportBotInput,
MainConversationRef,
Me,
ProductEvent,
@ -3101,6 +3102,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;
@ -4949,6 +4960,7 @@ export function ShellPage() {
<CreateBotForm
onCancel={() => setPanel(null)}
onCreate={(input) => createBot(input)}
onImport={(manifest) => importBot(manifest)}
/>
) : null}
{/* Centered Create Bot Modal */}
@ -4980,6 +4992,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

@ -1302,6 +1302,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

@ -34,6 +34,7 @@ import {
ExternalConversationPolicySchema,
GroupDetailSchema,
GroupSchema,
ImportBotInput,
IntegrationCatalogResultSchema,
MainConversationRefSchema,
McpServerConfigInput,
@ -279,6 +280,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

@ -1609,6 +1609,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");