Merge the final media branch (with #128) into claude/phase5-shell-split

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-04 10:59:54 +00:00
commit f9b1a40663
No known key found for this signature in database
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

@ -169,6 +169,7 @@ import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-sec
import { createAgentSkillsService } from "./agent-skills.js"; import { createAgentSkillsService } from "./agent-skills.js";
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js"; import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js"; import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js"; import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
import { createCoachService } from "./coach.js"; import { createCoachService } from "./coach.js";
import { import {
@ -1264,6 +1265,87 @@ export function createRouter(deps: RouterDeps) {
} }
return duplicate; 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 }) => { reorder: authed.bots.reorder.handler(async ({ context, input }) => {
await repos.reorderBots(context.actor, input.botIds); await repos.reorderBots(context.actor, input.botIds);
return { ok: true as const }; 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 expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await captureScreenshot(page, testInfo, "31-deleted-bot-fallback"); 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

@ -1509,6 +1509,10 @@ msgstr "Konnte nicht ausgeblendet werden"
msgid "Could not enable speaking" msgid "Could not enable speaking"
msgstr "Vorlesen konnte nicht aktiviert werden" 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 #: src/pages/PluginsOverlay.tsx
msgid "Could not install connector" msgid "Could not install connector"
msgstr "Connector konnte nicht installiert werden" msgstr "Connector konnte nicht installiert werden"
@ -2558,6 +2562,10 @@ msgstr "Bild-Upload-Bereich"
msgid "Image workflow" msgid "Image workflow"
msgstr "Bild-Workflow" msgstr "Bild-Workflow"
#: src/pages/shell/bot-panel.tsx
msgid "Import bot"
msgstr "Bot importieren"
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Import OpenAPI JSON" msgid "Import OpenAPI JSON"
msgstr "OpenAPI-JSON importieren" msgstr "OpenAPI-JSON importieren"

View File

@ -1509,6 +1509,10 @@ msgstr "Could not dismiss"
msgid "Could not enable speaking" msgid "Could not enable speaking"
msgstr "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 #: src/pages/PluginsOverlay.tsx
msgid "Could not install connector" msgid "Could not install connector"
msgstr "Could not install connector" msgstr "Could not install connector"
@ -2558,6 +2562,10 @@ msgstr "Image upload area"
msgid "Image workflow" msgid "Image workflow"
msgstr "Image workflow" msgstr "Image workflow"
#: src/pages/shell/bot-panel.tsx
msgid "Import bot"
msgstr "Import bot"
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Import OpenAPI JSON" msgid "Import OpenAPI JSON"
msgstr "Import OpenAPI JSON" msgstr "Import OpenAPI JSON"

View File

@ -1509,6 +1509,10 @@ msgstr ""
msgid "Could not enable speaking" msgid "Could not enable speaking"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Could not import this file"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Could not install connector" msgid "Could not install connector"
msgstr "No se pudo instalar el conector" msgstr "No se pudo instalar el conector"
@ -2558,6 +2562,10 @@ msgstr "Área de carga de imagen"
msgid "Image workflow" msgid "Image workflow"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Import bot"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Import OpenAPI JSON" msgid "Import OpenAPI JSON"
msgstr "Importar JSON de OpenAPI" msgstr "Importar JSON de OpenAPI"

View File

@ -1509,6 +1509,10 @@ msgstr ""
msgid "Could not enable speaking" msgid "Could not enable speaking"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Could not import this file"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Could not install connector" msgid "Could not install connector"
msgstr "कनेक्टर इंस्टॉल नहीं हो सका" msgstr "कनेक्टर इंस्टॉल नहीं हो सका"
@ -2558,6 +2562,10 @@ msgstr "छवि अपलोड क्षेत्र"
msgid "Image workflow" msgid "Image workflow"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Import bot"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Import OpenAPI JSON" msgid "Import OpenAPI JSON"
msgstr "OpenAPI JSON इम्पोर्ट करें" msgstr "OpenAPI JSON इम्पोर्ट करें"

View File

@ -1509,6 +1509,10 @@ msgstr ""
msgid "Could not enable speaking" msgid "Could not enable speaking"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Could not import this file"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Could not install connector" msgid "Could not install connector"
msgstr "도구 연결을 추가하지 못했습니다." msgstr "도구 연결을 추가하지 못했습니다."
@ -2558,6 +2562,10 @@ msgstr "이미지 업로드 영역"
msgid "Image workflow" msgid "Image workflow"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Import bot"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Import OpenAPI JSON" msgid "Import OpenAPI JSON"
msgstr "OpenAPI JSON 가져오기" msgstr "OpenAPI JSON 가져오기"

View File

@ -1509,6 +1509,10 @@ msgstr ""
msgid "Could not enable speaking" msgid "Could not enable speaking"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Could not import this file"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Could not install connector" msgid "Could not install connector"
msgstr "Não foi possível instalar o conector" msgstr "Não foi possível instalar o conector"
@ -2558,6 +2562,10 @@ msgstr "Área de envio de imagem"
msgid "Image workflow" msgid "Image workflow"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Import bot"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Import OpenAPI JSON" msgid "Import OpenAPI JSON"
msgstr "Importar OpenAPI JSON" msgstr "Importar OpenAPI JSON"

View File

@ -1509,6 +1509,10 @@ msgstr "Не удалось отклонить"
msgid "Could not enable speaking" msgid "Could not enable speaking"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Could not import this file"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Could not install connector" msgid "Could not install connector"
msgstr "Не удалось установить коннектор" msgstr "Не удалось установить коннектор"
@ -2558,6 +2562,10 @@ msgstr "Область загрузки изображения"
msgid "Image workflow" msgid "Image workflow"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Import bot"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Import OpenAPI JSON" msgid "Import OpenAPI JSON"
msgstr "Импортировать OpenAPI JSON" msgstr "Импортировать OpenAPI JSON"

View File

@ -1509,6 +1509,10 @@ msgstr ""
msgid "Could not enable speaking" msgid "Could not enable speaking"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Could not import this file"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Could not install connector" msgid "Could not install connector"
msgstr "Bağlayıcı kurulamadı" msgstr "Bağlayıcı kurulamadı"
@ -2558,6 +2562,10 @@ msgstr "Görüntü yükleme alanı"
msgid "Image workflow" msgid "Image workflow"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Import bot"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Import OpenAPI JSON" msgid "Import OpenAPI JSON"
msgstr "OpenAPI JSON içe aktar" msgstr "OpenAPI JSON içe aktar"

View File

@ -1509,6 +1509,10 @@ msgstr ""
msgid "Could not enable speaking" msgid "Could not enable speaking"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Could not import this file"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Could not install connector" msgid "Could not install connector"
msgstr "无法安装连接器" msgstr "无法安装连接器"
@ -2558,6 +2562,10 @@ msgstr "图片上传区域"
msgid "Image workflow" msgid "Image workflow"
msgstr "" msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Import bot"
msgstr ""
#: src/pages/PluginsOverlay.tsx #: src/pages/PluginsOverlay.tsx
msgid "Import OpenAPI JSON" msgid "Import OpenAPI JSON"
msgstr "导入 OpenAPI JSON" msgstr "导入 OpenAPI JSON"

View File

@ -13,6 +13,7 @@ import type {
ComputerReleaseReason, ComputerReleaseReason,
ComputerStatus, ComputerStatus,
Group, Group,
ImportBotInput,
MainConversationRef, MainConversationRef,
Me, Me,
Routine, Routine,
@ -2361,6 +2362,16 @@ export function ShellPage() {
setSettingsOpen(true); 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: { async function createBot(input: {
name: string; name: string;
title: string; title: string;
@ -3530,6 +3541,7 @@ export function ShellPage() {
<CreateBotForm <CreateBotForm
onCancel={() => setPanel(null)} onCancel={() => setPanel(null)}
onCreate={(input) => createBot(input)} onCreate={(input) => createBot(input)}
onImport={(manifest) => importBot(manifest)}
/> />
) : null} ) : null}
{/* Centered Create Bot Modal */} {/* Centered Create Bot Modal */}
@ -3561,6 +3573,10 @@ export function ShellPage() {
onCreate={async (input) => { onCreate={async (input) => {
await createBot(input); await createBot(input);
}} }}
onImport={async (manifest) => {
await importBot(manifest);
setShowCreateBotModal(false);
}}
/> />
</div> </div>
</div>, </div>,

View File

@ -30,7 +30,7 @@ import {
Textarea, Textarea,
Toggle, Toggle,
} from "@rakazo/ui-web"; } from "@rakazo/ui-web";
import { X } from "lucide-react"; import { Upload, X } from "lucide-react";
import { Suspense, useEffect, useId, useRef, useState } from "react"; import { Suspense, useEffect, useId, useRef, useState } from "react";
import { connectedModelOptions } from "../../lib/connected-models"; import { connectedModelOptions } from "../../lib/connected-models";
import { lazyWithRecovery } from "../../lib/lazy-recovery"; import { lazyWithRecovery } from "../../lib/lazy-recovery";
@ -87,6 +87,7 @@ function ComputerModePicker({
export function CreateBotForm({ export function CreateBotForm({
onCreate, onCreate,
onImport,
onCancel, onCancel,
}: { }: {
onCreate: (input: { onCreate: (input: {
@ -95,10 +96,13 @@ export function CreateBotForm({
description: string; description: string;
computerMode: ComputerMode; computerMode: ComputerMode;
}) => Promise<void>; }) => Promise<void>;
/** Restores a bot from the JSON that the bot settings' Export downloads. */
onImport: (manifest: unknown) => Promise<void>;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const ids = useId(); const ids = useId();
const importInputRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@ -124,16 +128,52 @@ 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 ( return (
<div data-testid="create-bot-form"> <div data-testid="create-bot-form">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<span className="text-ui-body text-muted-foreground"> <span className="text-ui-body text-muted-foreground">
<Trans>New bot</Trans> <Trans>New bot</Trans>
</span> </span>
<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}> <Button variant="ghost" size="icon-sm" aria-label={t`Cancel new bot`} onClick={onCancel}>
<X strokeWidth={1.8} className="size-icon-md" /> <X strokeWidth={1.8} className="size-icon-md" />
</Button> </Button>
</div> </div>
</div>
{error ? ( {error ? (
<p <p
role="alert" role="alert"

View File

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

View File

@ -50,6 +50,7 @@ import {
ExternalConversationPolicySchema, ExternalConversationPolicySchema,
GroupDetailSchema, GroupDetailSchema,
GroupSchema, GroupSchema,
ImportBotInput,
IntegrationCatalogResultSchema, IntegrationCatalogResultSchema,
MainConversationRefSchema, MainConversationRefSchema,
McpServerConfigInput, McpServerConfigInput,
@ -340,6 +341,8 @@ export const appContract = {
/** The user's main bot, created with its main conversation in the home space when missing. */ /** The user's main bot, created with its main conversation in the home space when missing. */
ensureMain: oc.output(BotSchema), ensureMain: oc.output(BotSchema),
duplicate: oc.input(botId).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) })), reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })),
update: oc.input(UpdateBotInput).output(BotSchema), update: oc.input(UpdateBotInput).output(BotSchema),
/** Builtin tool names this bot's allowlist can choose from. */ /** 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); 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 () => { 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 ada = await signup(app, `delete-j-${stamp}@rakazo.test`, "Delete Ada");
const bob = await signup(app, `delete-bob-j-${stamp}@rakazo.test`, "Delete Bob"); const bob = await signup(app, `delete-bob-j-${stamp}@rakazo.test`, "Delete Bob");