diff --git a/app/package.json b/app/package.json index f741d78e53..31aa417742 100644 --- a/app/package.json +++ b/app/package.json @@ -20,6 +20,8 @@ "@copilotkit/a2ui-renderer": "1.77.0", "@copilotkit/react-core": "1.77.0", "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/plus-jakarta-sans": "5.3.0", + "@fontsource-variable/spline-sans-mono": "5.3.0", "@openbot/shared": "workspace:*", "@shadcn/react": "^0.3.0", "@tabler/icons-react": "^3.36.1", diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index 47e336b056..0d59ab7e78 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -64,6 +64,7 @@ import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; import { Channel } from "./channel"; import { ChannelPagination } from "./channel-pagination"; +import { IntelligenceRow } from "./intelligence-row"; const appLinkOptions = { to: "/" } satisfies LinkOptions; const adminLinkOptions = { to: "/admin" } satisfies LinkOptions; @@ -410,6 +411,8 @@ export function AppSidebar({ ...props }: React.ComponentProps) { Connections + {/* Inside OpenBot Desktop with a local Intelligence stack only; nothing otherwise. */} + + +
+ {dashboard.opening ? ( +
+ + {dashboard.opening ? "Opening…" : "Intelligence"} + +
+ {dashboard.failure ? ( +

+ {dashboard.failure} +

+ ) : null} +
+ ); +} diff --git a/app/src/components/layout/sidebar-shell.tsx b/app/src/components/layout/sidebar-shell.tsx index 177b61db78..7d01fc0eed 100644 --- a/app/src/components/layout/sidebar-shell.tsx +++ b/app/src/components/layout/sidebar-shell.tsx @@ -1,10 +1,21 @@ -import { type CSSProperties, type ReactNode, useEffect, useState } from "react"; +import { + type CSSProperties, + type ReactNode, + useEffect, + useMemo, + useState, +} from "react"; -import { SidebarProvider } from "@/components/ui/sidebar"; +import { + SidebarProvider, + SidebarResizeProvider, +} from "@/components/ui/sidebar"; import { applySidebarOpen, parseStoredSidebarOpen, + parseStoredSidebarWidth, SIDEBAR_STORAGE_KEY, + SIDEBAR_WIDTH_STORAGE_PREFIX, } from "@/lib/sidebar"; /** @@ -15,22 +26,46 @@ import { * `defaultOpen` to true). Driving it as controlled state is a dozen lines, and three copies of a * dozen lines is three chances for one shell to forget the preference the other two remember. * - * `width` is what actually differs between the shells. The app's roster earns 340px because its - * rows are two-line message previews; admin and settings hold short nav labels and earn 300px. + * `width` is what differs between the shells before anybody resizes one. The app's roster earns + * 340px because its rows are two-line message previews; admin and settings hold short nav labels and + * earn 300px. Dragging the sidebar's edge (`SidebarRail`) changes it, and each shell remembers its + * own under its `name`. */ export function SidebarShell({ children, className, - width, + name, + width: defaultWidth, }: { children: ReactNode; className?: string; + /** Whose width this is: each shell remembers its own, under this name. */ + name: string; + /** The width before anybody drags the sidebar's edge. */ width: string; }) { // Read before the first paint, so a shell somebody left collapsed never flashes open. const [open, setOpen] = useState(() => parseStoredSidebarOpen(window.localStorage.getItem(SIDEBAR_STORAGE_KEY)), ); + const widthKey = `${SIDEBAR_WIDTH_STORAGE_PREFIX}${name}`; + const [width, setWidth] = useState(() => + parseStoredSidebarWidth( + window.localStorage.getItem(widthKey), + defaultWidth, + ), + ); + // While the edge is dragged, the sidebar follows the pointer instead of easing after it. + const [resizing, setResizing] = useState(false); + const resize = useMemo( + () => ({ + setWidth: (pixels: number) => setWidth(`${pixels}px`), + commitWidth: (pixels: number) => + window.localStorage.setItem(widthKey, String(pixels)), + setResizing, + }), + [widthKey], + ); useEffect(() => { applySidebarOpen(open, { @@ -41,6 +76,7 @@ export function SidebarShell({ return ( - {children} + {children} ); } diff --git a/app/src/components/settings/settings-sidebar.tsx b/app/src/components/settings/settings-sidebar.tsx index a82d6f1e21..b597719b45 100644 --- a/app/src/components/settings/settings-sidebar.tsx +++ b/app/src/components/settings/settings-sidebar.tsx @@ -2,6 +2,7 @@ import { IconArrowLeft, IconBell, IconChecks, + IconDeviceDesktop, IconKey, IconLayoutGrid, IconSettings, @@ -18,6 +19,7 @@ import { SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar"; +import { desktopShell } from "@/lib/desktop/intelligence-dashboard"; const appLinkOptions = { to: "/" } satisfies LinkOptions; @@ -29,6 +31,8 @@ const ITEMS: { * everywhere else, so an entry stays lit on the pages beneath it. */ exact?: boolean; + /** Shown only inside OpenBot Desktop, which is what the page reads from. */ + desktopOnly?: boolean; icon: React.ComponentType<{ className?: string }>; linkOptions: LinkOptions; title: string; @@ -58,6 +62,13 @@ const ITEMS: { icon: IconKey, linkOptions: { to: "/settings/passwords" }, }, + { + /* What OpenBot runs here and the memory it takes, with Restart and Stop. */ + title: "This computer", + icon: IconDeviceDesktop, + desktopOnly: true, + linkOptions: { to: "/settings/computer" }, + }, { /* The same mark Admin gives UI Components. It is the same subject seen from the other side. */ title: "Components gallery", @@ -93,7 +104,9 @@ export function SettingsSidebar({ */} - {ITEMS.map((option) => ( + {ITEMS.filter( + (option) => !option.desktopOnly || desktopShell() !== null, + ).map((option) => ( ( diff --git a/app/src/components/ui/sidebar.tsx b/app/src/components/ui/sidebar.tsx index 9e927cf9aa..029763cd0b 100644 --- a/app/src/components/ui/sidebar.tsx +++ b/app/src/components/ui/sidebar.tsx @@ -1,13 +1,10 @@ "use client"; -import * as React from "react"; import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; +import { IconLayoutSidebar } from "@tabler/icons-react"; import { cva, type VariantProps } from "class-variance-authority"; - -import { useIsMobile } from "@/hooks/use-mobile"; -import { keyOf } from "@/lib/hotkeys/hotkeys"; -import { cn } from "@/lib/utils"; +import * as React from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; @@ -24,7 +21,10 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { IconLayoutSidebar } from "@tabler/icons-react"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { keyOf } from "@/lib/hotkeys/hotkeys"; +import { clampSidebarWidth, SIDEBAR_MIN_WIDTH } from "@/lib/sidebar"; +import { cn } from "@/lib/utils"; const SIDEBAR_WIDTH = "16rem"; const SIDEBAR_WIDTH_MOBILE = "18rem"; @@ -296,8 +296,76 @@ function SidebarTrigger({ ); } +/** + * How a shell lets its sidebar be resized: `setWidth` while the edge is dragged, `commitWidth` when it + * is let go. Provided by `SidebarShell`; without it the rail only toggles, as it always did. + */ +type SidebarResize = { + setWidth: (pixels: number) => void; + commitWidth: (pixels: number) => void; + setResizing: (resizing: boolean) => void; +}; + +const SidebarResizeContext = React.createContext(null); + +const SidebarResizeProvider = SidebarResizeContext.Provider; + +/** + * The narrowest the sidebar can be and still show its footer whole: its widest footer button's own + * content, plus the paddings around it. Measured from what is drawn, never from a rem constant, and + * never under `SIDEBAR_MIN_WIDTH`. A sidebar with no footer has only that floor. + */ +function footerWidth(panel: HTMLElement): number { + const footer = panel.querySelector( + '[data-slot="sidebar-footer"]', + ); + if (!footer) return SIDEBAR_MIN_WIDTH; + const around = + panel.getBoundingClientRect().width - footer.getBoundingClientRect().width; + const footerStyle = getComputedStyle(footer); + const footerPadding = + Number.parseFloat(footerStyle.paddingLeft) + + Number.parseFloat(footerStyle.paddingRight); + let widest = 0; + for (const button of footer.querySelectorAll( + '[data-sidebar="menu-button"]', + )) { + const style = getComputedStyle(button); + const gap = Number.parseFloat(style.columnGap) || 0; + const children = Array.from(button.children) as HTMLElement[]; + // `scrollWidth`, not the drawn width: a truncated label still reports what it needs. + const content = + children.reduce((sum, child) => sum + child.scrollWidth, 0) + + gap * Math.max(0, children.length - 1); + widest = Math.max( + widest, + content + + Number.parseFloat(style.paddingLeft) + + Number.parseFloat(style.paddingRight), + ); + } + return Math.max( + SIDEBAR_MIN_WIDTH, + Math.ceil(widest + footerPadding + around), + ); +} + +/** + * The sidebar's edge. Dragged, it resizes the sidebar between what its footer needs and the shell's + * maximum; clicked without moving, it hides or shows the sidebar as it always did. A press counts as + * a drag once it has moved 3px, so a click with a slight wobble still toggles. + */ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { - const { toggleSidebar } = useSidebar(); + const { toggleSidebar, state, isMobile } = useSidebar(); + const resize = React.useContext(SidebarResizeContext); + const drag = React.useRef<{ + startX: number; + startWidth: number; + min: number; + width: number; + moved: boolean; + } | null>(null); + const dragged = React.useRef(false); return ( + )} + + + )} + + + + + + ); +} + +function IntelligenceSection({ + measured, + readiness, + status, +}: { + /** Container memory has been read. */ + measured: boolean; + readiness: IntelligenceReadiness | undefined; + status: DesktopStatus; +}) { + const intelligence = useMutation(intelligenceMutationOptions(queryClient)); + const running = + status.intelligenceContainers.length > 0 && readiness !== "stopped"; + const memory = totalMemory(status.intelligenceContainers); + return ( + + + + + + + + + {running ? READINESS[readiness ?? "unknown"] : "Stopped"} + + + {intelligence.isPending + ? intelligence.variables === "start" + ? "Starting…" + : "Stopping…" + : intelligence.error + ? intelligence.error.message + : !running + ? "Not using memory while stopped." + : !measured + ? null + : memory !== null + ? `${formatBytes(memory)} in use` + : "Memory could not be measured."} + + + + {running ? ( + <> + + + + ) : ( + + )} + + + + + + ); +} + +function MachineSection({ + readiness, + status, +}: { + readiness: IntelligenceReadiness | undefined; + status: DesktopStatus; +}) { + const [copied, setCopied] = React.useState(false); + return ( + { + await navigator.clipboard.writeText(diagnostics(status, readiness)); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }} + size="sm" + variant="outline" + > + {copied ? "Copied" : "Copy diagnostics"} + + } + title="Details" + > + + + + + + + + {status.engine === "podman" ? "Podman" : "Docker"} + + + + + {status.engine ? "Running" : "Not running"} + + + + + + + + + + Installed in + + {status.root} + + + + + + + + + + Version {status.version} + + {status.build} + + + + + + ); +} + +/** Each container or process as a read-only row: its name, and its memory as the value. */ +function PartRows({ + icon: Icon, + parts, + separatorFirst = false, +}: { + icon: React.ComponentType<{ className?: string }>; + parts: DesktopPart[]; + separatorFirst?: boolean; +}) { + return parts.map((part, index) => ( + + {(separatorFirst || index > 0) && } + + + + + + {part.name} + + + + {part.memoryBytes === null ? "—" : formatBytes(part.memoryBytes)} + + + + + )); +} diff --git a/app/src/routes/_authed/settings/route.tsx b/app/src/routes/_authed/settings/route.tsx index 9a753bd732..4d5fef87bd 100644 --- a/app/src/routes/_authed/settings/route.tsx +++ b/app/src/routes/_authed/settings/route.tsx @@ -8,7 +8,7 @@ export const Route = createFileRoute("/_authed/settings")({ function RouteComponent() { return ( - +
diff --git a/app/src/styles.css b/app/src/styles.css index 12f7721466..b45cccb9f2 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -3,7 +3,8 @@ @import "shadcn/tailwind.css"; /* Composer chips/dropdown. Reads the shadcn tokens declared below, so it themes itself. */ @import "prompt-area/tailwind.css"; -@import "@fontsource-variable/inter"; +@import "@fontsource-variable/plus-jakarta-sans"; +@import "@fontsource-variable/spline-sans-mono"; /* * Streamdown draws a Bot's prose, and Tailwind only ships classes it can see used. Its markup lives @@ -14,77 +15,184 @@ @custom-variant dark (&:is(.dark *)); +/* + * Small light text on dark keeps macOS's own smoothing. The root's `antialiased` (grayscale) is right + * for dark text on light, but it turns off the slight thickening light-on-dark text gets, and small + * text in the dark theme read thin and wiry. + */ +.dark .antialiased { + -webkit-font-smoothing: auto; + -moz-osx-font-smoothing: auto; +} + :root { + /* + * The CopilotKit visual system (the CopilotCloud tokens the desktop setup window uses), mapped + * onto shadcn's names so every component takes it without being edited. Light is the theme's own: + * the lavender-gray ground, white cards, near-black actions, white-edged frosted sidebar, lilac + * focus. Dark is an implementation default on the brand's near-black #010507. + */ color-scheme: light; - --background: oklch(0.985 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.96 0 0); - --muted-foreground: oklch(0.5 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); + --background: #dedee9; + --foreground: #010507; + --card: #ffffff; + --card-foreground: #010507; + --popover: #ffffff; + --popover-foreground: #010507; + --primary: #010507; + --primary-foreground: #ffffff; + --secondary: #f0f0f4; + --secondary-foreground: #010507; + --muted: #e9e9ef; + --muted-foreground: #57575b; + --accent: rgba(255, 255, 255, 0.7); + --accent-foreground: #010507; --destructive: oklch(0.577 0.245 27.325); - --success: oklch(0.6 0.118 184.704); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + --success: #189370; + --border: #dbdbe5; + --input: #dbdbe5; + --ring: #bec2ff; + --chart-1: #bec2ff; + --chart-2: #85ecce; + --chart-3: #ffac4d; + --chart-4: #189370; + --chart-5: #57575b; --radius: 0.5rem; - --sidebar: oklch(0.97 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.5 0 0); - --sidebar-accent: oklch(0.95 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --sidebar: rgba(255, 255, 255, 0.5); + --sidebar-foreground: #010507; + --sidebar-primary: #010507; + --sidebar-primary-foreground: #57575b; + --sidebar-accent: rgba(255, 255, 255, 0.7); + --sidebar-accent-foreground: #010507; + --sidebar-border: #ffffff; + --sidebar-ring: #bec2ff; } .dark { color-scheme: dark; - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --success: oklch(0.704 0.14 182.503); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: #010507; + --foreground: #f7f7f9; + --card: #14151b; + --card-foreground: #f7f7f9; + --popover: #14151b; + --popover-foreground: #f7f7f9; + --primary: #f7f7f9; + --primary-foreground: #010507; + --secondary: rgba(255, 255, 255, 0.08); + --secondary-foreground: #f7f7f9; + --muted: rgba(255, 255, 255, 0.06); + --muted-foreground: #afafb7; + --accent: rgba(255, 255, 255, 0.08); + --accent-foreground: #f7f7f9; + --destructive: #fa5f67; + --success: #85ecce; + --border: rgba(255, 255, 255, 0.1); + --input: rgba(255, 255, 255, 0.12); + --ring: #bec2ff; + --chart-1: #bec2ff; + --chart-2: #85ecce; + --chart-3: #ffac4d; + --chart-4: #fff388; + --chart-5: #afafb7; + --sidebar: rgba(255, 255, 255, 0.04); + --sidebar-foreground: #f7f7f9; + --sidebar-primary: #bec2ff; + --sidebar-primary-foreground: #010507; + --sidebar-accent: rgba(255, 255, 255, 0.08); + --sidebar-accent-foreground: #f7f7f9; + --sidebar-border: rgba(255, 255, 255, 0.1); + --sidebar-ring: #bec2ff; +} + +/* + * The theme's ambient colour fields, behind everything, so the frosted sidebar has something to + * frost. Static radial gradients rather than blurred discs: a filtered layer repaints whenever + * anything above it changes, and a chat streams text constantly. + */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient( + circle 24vw at 87.7vw 16.3vw, + rgba(255, 172, 77, 0.14), + transparent + ), + radial-gradient( + circle 30vw at 67.7vw -4.2vw, + #c9c9da 15%, + rgba(201, 201, 218, 0) + ), + radial-gradient( + circle 30vw at 56.5vw 70vw, + #f3f3fc 15%, + rgba(243, 243, 252, 0) + ), + radial-gradient( + circle 24vw at 24.4vw 38.5vw, + rgba(255, 243, 136, 0.22), + transparent + ), + radial-gradient( + circle 24vw at 1.3vw 71.3vw, + rgba(255, 172, 77, 0.14), + transparent + ); +} + +.dark body::before { + background: + radial-gradient( + circle 24vw at 87.7vw 16.3vw, + rgba(190, 194, 255, 0.12), + transparent + ), + radial-gradient( + circle 30vw at 67.7vw -4.2vw, + rgba(190, 194, 255, 0.08), + transparent + ), + radial-gradient( + circle 24vw at 24.4vw 38.5vw, + rgba(133, 236, 206, 0.07), + transparent + ), + radial-gradient( + circle 24vw at 1.3vw 71.3vw, + rgba(255, 172, 77, 0.06), + transparent + ); +} + +/* + * OpenBot Desktop on macOS has no separate title bar over this page: the window's buttons sit over + * the top-left corner and the sidebar runs to the top edge, as in Mac apps with a sidebar. The + * desktop marks the page (`data-desktop-titlebar`) and makes the sidebar's header the drag handle; + * here the header makes room for the buttons above its own row. With the sidebar collapsed the + * buttons are over the main column instead, so that moves down. The main column is the shell's own + * `
`; the primitive's `SidebarInset`, which an earlier rule aimed at, is used by no shell. + * Outside the desktop nothing changes. + */ +:root[data-desktop-titlebar="overlay"] [data-slot="sidebar-header"] { + height: auto; + padding-top: 36px; +} + +:root[data-desktop-titlebar="overlay"] + [data-slot="sidebar-wrapper"]:has( + > [data-slot="sidebar"][data-state="collapsed"] + ) + > main { + padding-top: 28px; +} + +/* While the sidebar's edge is dragged, it follows the pointer rather than easing after it. */ +[data-slot="sidebar-wrapper"][data-resizing] [data-slot="sidebar-container"], +[data-slot="sidebar-wrapper"][data-resizing] [data-slot="sidebar-gap"] { + transition: none; } * { @@ -98,7 +206,10 @@ body { min-height: 100vh; background: var(--background); color: var(--foreground); - font-family: Inter, ui-sans-serif, system-ui, sans-serif; + font-family: + "Plus Jakarta Sans Variable", ui-sans-serif, system-ui, sans-serif; + /* Text with no weight of its own takes the theme's regular, which is 450 (see @theme). */ + font-weight: var(--font-weight-normal); } /* @@ -133,8 +244,20 @@ body { --text-2xl: 1.40625rem; /* 22.5px */ --text-3xl: 1.7578125rem; /* 28.125px */ --text-4xl: 2.109375rem; /* 33.75px */ + /* + * Plus Jakarta Sans, set one step heavier and less tightly than the Inter values these were. + * Regular and medium are 450 and 550 (it is a variable font, so neither tips into bold); at 400 on + * the dark ground small text read thin. Inter's tight tracking (-0.025em, -0.05em) ran this wider + * face's letters together in titles and previews, so tight is -0.01em and tighter -0.02em. + */ + --font-weight-normal: 450; + --font-weight-medium: 550; + --tracking-tight: -0.01em; + --tracking-tighter: -0.02em; --font-heading: var(--font-sans); - --font-sans: "Inter Variable", sans-serif; + --font-sans: "Plus Jakarta Sans Variable", sans-serif; + --font-mono: + "Spline Sans Mono Variable", ui-monospace, SFMono-Regular, monospace; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); diff --git a/app/tests/intelligence-row.test.tsx b/app/tests/intelligence-row.test.tsx new file mode 100644 index 0000000000..5aa2cb6253 --- /dev/null +++ b/app/tests/intelligence-row.test.tsx @@ -0,0 +1,184 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { IntelligenceRow } from "@/components/app-sidebar/intelligence-row"; +import { SidebarMenu, SidebarProvider } from "@/components/ui/sidebar"; +import { + type DashboardFailure, + dashboardFailureSaid, + readOffer, + type ShellIpc, +} from "@/lib/desktop/intelligence-dashboard"; + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +/** + * The Desktop shell as the page sees it: Tauri's injected IPC. `say` delivers a channel message the + * way Tauri does, `{ message, index }`; `refuse` makes `watch` reject, as it does for an origin the + * shell never allowed or a Desktop without the plugin. + */ +function fakeShell({ refuse = false } = {}) { + const calls: Array<{ command: string; args?: Record }> = []; + const callbacks = new Map void>(); + let index = 0; + let open: () => Promise = async () => null; + const shell: ShellIpc = { + transformCallback: (callback) => { + const id = callbacks.size + 1; + callbacks.set(id, callback); + return id; + }, + unregisterCallback: (id) => callbacks.delete(id), + invoke: async (command, args) => { + calls.push({ command, args }); + if (command.endsWith("|watch")) { + if (refuse) throw new Error("not allowed"); + return null; + } + return open(); + }, + }; + return { + shell, + calls, + say(message: unknown) { + const channel = [...callbacks.values()].at(-1); + act(() => channel?.({ message, index: index++ })); + }, + openWith(next: () => Promise) { + open = next; + }, + }; +} + +function draw(shell: ShellIpc | null) { + return render( + + + + + , + ); +} + +test("the row is not drawn in a plain browser", () => { + const view = draw(null); + expect(view.queryByRole("button", { name: /Intelligence/ })).toBeNull(); +}); + +test("the row is not drawn until Desktop says a local stack can be opened", async () => { + const desktop = fakeShell(); + const view = draw(desktop.shell); + await waitFor(() => + expect(desktop.calls.map((call) => call.command)).toEqual([ + "plugin:openbot-intelligence|watch", + ]), + ); + // The channel is handed over in Tauri's own form. + expect(desktop.calls[0]?.args).toEqual({ onOffer: "__CHANNEL__:1" }); + desktop.say({ state: "unavailable" }); + expect(view.queryByRole("button", { name: /Intelligence/ })).toBeNull(); + desktop.say({ state: "ready" }); + expect(view.getByRole("button", { name: "Intelligence" })).toBeTruthy(); + // Hosted Intelligence, or local mode turned off: gone again. + desktop.say({ state: "unavailable" }); + expect(view.queryByRole("button", { name: /Intelligence/ })).toBeNull(); +}); + +test("a shell that refuses this page draws no row", async () => { + const desktop = fakeShell({ refuse: true }); + const view = draw(desktop.shell); + await waitFor(() => expect(desktop.calls).toHaveLength(1)); + expect(view.queryByRole("button", { name: /Intelligence/ })).toBeNull(); +}); + +test("pressing the row spins and disables it at once, until the browser has the dashboard", async () => { + const desktop = fakeShell(); + const view = draw(desktop.shell); + await waitFor(() => expect(desktop.calls).toHaveLength(1)); + desktop.say({ state: "ready" }); + await userEvent.click(view.getByRole("button", { name: "Intelligence" })); + // Before the shell has said anything back. + const busy = view.getByRole("button", { name: "Opening…" }); + expect(busy.hasAttribute("disabled")).toBe(true); + expect(busy.getAttribute("aria-busy")).toBe("true"); + expect(desktop.calls.at(-1)?.command).toBe( + "plugin:openbot-intelligence|open", + ); + desktop.say({ state: "opening" }); + expect(view.getByRole("button", { name: "Opening…" })).toBeTruthy(); + desktop.say({ state: "ready" }); + const ready = view.getByRole("button", { name: "Intelligence" }); + expect(ready.hasAttribute("disabled")).toBe(false); + expect(view.queryByRole("status")).toBeNull(); +}); + +test("an opening started from Desktop's menu spins the row too", async () => { + const desktop = fakeShell(); + const view = draw(desktop.shell); + await waitFor(() => expect(desktop.calls).toHaveLength(1)); + desktop.say({ state: "opening" }); + expect( + view.getByRole("button", { name: "Opening…" }).hasAttribute("disabled"), + ).toBe(true); + expect(desktop.calls).toHaveLength(1); +}); + +test("a failure is said under the row in a short fixed sentence, and the row can be pressed again", async () => { + const desktop = fakeShell(); + const view = draw(desktop.shell); + await waitFor(() => expect(desktop.calls).toHaveLength(1)); + desktop.say({ state: "ready" }); + await userEvent.click(view.getByRole("button", { name: "Intelligence" })); + desktop.say({ state: "failed", reason: "not_responding" }); + expect(view.getByRole("status").textContent).toBe( + "Intelligence is not responding yet. Try again shortly.", + ); + expect( + view.getByRole("button", { name: "Intelligence" }).hasAttribute("disabled"), + ).toBe(false); + // Pressing again clears it. + await userEvent.click(view.getByRole("button", { name: "Intelligence" })); + expect(view.queryByRole("status")).toBeNull(); +}); + +test("a shell that cannot start the opening is said as a failure", async () => { + const desktop = fakeShell(); + desktop.openWith(async () => { + throw new Error("refused"); + }); + const view = draw(desktop.shell); + await waitFor(() => expect(desktop.calls).toHaveLength(1)); + desktop.say({ state: "ready" }); + await userEvent.click(view.getByRole("button", { name: "Intelligence" })); + await waitFor(() => + expect(view.getByRole("status").textContent).toBe( + "OpenBot could not open Intelligence. Try again.", + ), + ); +}); + +test("every failure category has its own sentence, and anything else is the general one", () => { + const reasons: DashboardFailure[] = [ + "not_set_up", + "not_responding", + "browser_failed", + "busy", + "engine_stopped", + "unsupported", + "timed_out", + "stopped", + "failed", + ]; + const said = reasons.map(dashboardFailureSaid); + expect(new Set(said).size).toBe(reasons.length); + for (const sentence of said) expect(sentence).toMatch(/^[A-Z].*\.$/); + expect( + readOffer({ state: "failed", reason: "https://leak.example" }), + ).toEqual({ state: "failed", reason: "failed" }); + expect(readOffer({ state: "open-everything" })).toBeNull(); + expect(readOffer("ready")).toBeNull(); +}); diff --git a/bun.lock b/bun.lock index d982d5df56..5b2443d462 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,8 @@ "@copilotkit/a2ui-renderer": "1.77.0", "@copilotkit/react-core": "1.77.0", "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/plus-jakarta-sans": "5.3.0", + "@fontsource-variable/spline-sans-mono": "5.3.0", "@openbot/shared": "workspace:*", "@shadcn/react": "^0.3.0", "@tabler/icons-react": "^3.36.1", @@ -448,6 +450,10 @@ "@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="], + "@fontsource-variable/plus-jakarta-sans": ["@fontsource-variable/plus-jakarta-sans@5.3.0", "", {}, "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw=="], + + "@fontsource-variable/spline-sans-mono": ["@fontsource-variable/spline-sans-mono@5.3.0", "", {}, "sha512-dH/qBj/MCE4ObiuCwlA9X1o3AftMO9dwfFgoRVYjy6VCPYkpWAlEtK/RjeyICgsR8rW763IAQARskVgShBY5JA=="], + "@graphql-tools/executor": ["@graphql-tools/executor@2.0.0", "", { "dependencies": { "@graphql-tools/utils": "^12.0.0", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.1.0", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-BjoqO5UfcV3BUqamGtHNm4ITBk040VOmWql/MrUefPo30x6t4v1+engfjgAjWikMsRPLU4ZC6e2QjICLucnOTA=="], "@graphql-tools/merge": ["@graphql-tools/merge@9.2.3", "", { "dependencies": { "@graphql-tools/utils": "^12.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-cKRoXqJGy2zSRBLvotQpkACbXlHAb0yuHLN0l0ypKGCuL3NnF0zofYalkBJqiBxxxpK0lr3s9uowKFHCKi1/ZQ=="], diff --git a/desktop/bun.lock b/desktop/bun.lock index ca0195321a..d91546ef3c 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -5,6 +5,8 @@ "": { "name": "openbot-desktop", "dependencies": { + "@fontsource-variable/plus-jakarta-sans": "5.3.0", + "@fontsource-variable/spline-sans-mono": "5.3.0", "@tauri-apps/api": "^2", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -116,6 +118,10 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@fontsource-variable/plus-jakarta-sans": ["@fontsource-variable/plus-jakarta-sans@5.3.0", "", {}, "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw=="], + + "@fontsource-variable/spline-sans-mono": ["@fontsource-variable/spline-sans-mono@5.3.0", "", {}, "sha512-dH/qBj/MCE4ObiuCwlA9X1o3AftMO9dwfFgoRVYjy6VCPYkpWAlEtK/RjeyICgsR8rW763IAQARskVgShBY5JA=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.14.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.14.0" } }, "sha512-Xn0kdkaA1eHOaFFopr+YQ37sPNTZ3JScpUMz3j4rIsn1Nneqoh94z79iW23Ej0Ytt99+LlFr4JrSDd6ow9c7MQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], diff --git a/desktop/package.json b/desktop/package.json index 216f1060ab..809b6866ff 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -9,6 +9,8 @@ "tauri": "tauri" }, "dependencies": { + "@fontsource-variable/plus-jakarta-sans": "5.3.0", + "@fontsource-variable/spline-sans-mono": "5.3.0", "@tauri-apps/api": "^2", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/desktop/public/copilotkit-logo-dark.svg b/desktop/public/copilotkit-logo-dark.svg new file mode 100644 index 0000000000..197202662f --- /dev/null +++ b/desktop/public/copilotkit-logo-dark.svg @@ -0,0 +1,48 @@ + + CopilotKit + + + + + + + + + + + + + + + + CopilotKit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/public/copilotkit-logo.svg b/desktop/public/copilotkit-logo.svg new file mode 100644 index 0000000000..e349b8a745 --- /dev/null +++ b/desktop/public/copilotkit-logo.svg @@ -0,0 +1,48 @@ + + CopilotKit + + + + + + + + + + + + + + + + CopilotKit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src-tauri/.gitignore b/desktop/src-tauri/.gitignore index 2f7896d1d1..7014b4deab 100644 --- a/desktop/src-tauri/.gitignore +++ b/desktop/src-tauri/.gitignore @@ -1 +1,3 @@ target/ +# The Intelligence CLI a test build carries (tauri.bundled-cli.conf.json); assembled, never committed. +cli-bundle/ diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 75485f07f2..969187dd58 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -343,6 +343,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -1806,6 +1812,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2193,6 +2212,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.3" @@ -2513,6 +2542,7 @@ dependencies = [ "base64 0.22.1", "flate2", "libc", + "objc2", "portable-pty", "rand 0.9.5", "reqwest 0.12.28", @@ -2872,6 +2902,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.41.0" @@ -3912,6 +3948,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "image", "jni", "libc", "log", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 78245f3b42..e5a09af0c7 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -15,9 +15,11 @@ path = "src/main.rs" [build-dependencies] tauri-build = { version = "2", features = [] } +# Pins a bundled Intelligence CLI to the bytes the app was built with. See build.rs. +sha2 = "0.10" [dependencies] -tauri = { version = "2", features = ["tray-icon"] } +tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri-plugin-shell = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -38,5 +40,15 @@ tauri-plugin-dialog = "2" [target.'cfg(unix)'.dependencies] libc = "0.2" +# The macOS quit OpenBot did not start (Dock, AppleScript, log out) routed through its own Quit. +# The version Tauri already uses. See `macos_quit` in main.rs. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" + [dev-dependencies] tauri = { version = "2", features = ["test"] } + +# Every local evaluation CLI call checks the pinned runtime and script against their digests, +# about 150 MB. Unoptimized, that hash alone took 4.9 s per call in a debug build; optimized, 0.3 s. +[profile.dev.package.sha2] +opt-level = 3 diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index f99ce6ebd6..7312e50898 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -30,5 +30,116 @@ fn main() { ); println!("cargo:rerun-if-env-changed=RUSTC"); println!("cargo:rerun-if-env-changed=PATH"); - tauri_build::build() + build_identity(); + bundled_cli(); + // The one way the OpenBot app's own page reaches the shell: an inline plugin whose commands get + // permissions of their own, granted at runtime to the exact owned app origin. App commands stay + // out of a remote page's reach. + tauri_build::try_build( + tauri_build::Attributes::new() + .plugin( + "openbot-intelligence", + tauri_build::InlinedPlugin::new().commands(&["watch", "open"]), + ) + // Settings → This computer: the Status window's facts and actions, from the app. + .plugin( + "openbot-desktop", + tauri_build::InlinedPlugin::new().commands(&[ + "appearance", + "status", + "restart", + "stop", + "intelligence", + ]), + ), + ) + .expect("the Tauri build failed") +} + +/** +What "About OpenBot" says about this build: the commit, whether the tree had uncommitted changes, +the branch, and when it was built. + +Several worktrees build this app on one machine, and the window looks the same from all of them, +so which one is running is otherwise a guess. Rerun on every source change, or the time and the +dirty mark would be those of whenever this script last happened to run. +*/ +fn build_identity() { + let git = |args: &[&str]| { + std::process::Command::new("git") + .args(args) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()) + }; + let commit = git(&["rev-parse", "--short", "HEAD"]) + .filter(|commit| !commit.is_empty()) + .unwrap_or_else(|| "unknown".into()); + let dirty = git(&["status", "--porcelain", "--untracked-files=no"]) + .is_some_and(|changes| !changes.is_empty()); + let branch = git(&["branch", "--show-current"]).unwrap_or_default(); + let built = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_secs()) + .unwrap_or_default(); + println!( + "cargo:rustc-env=OPENBOT_BUILD_COMMIT={commit}{}", + if dirty { "-dirty" } else { "" } + ); + println!("cargo:rustc-env=OPENBOT_BUILD_BRANCH={branch}"); + println!("cargo:rustc-env=OPENBOT_BUILD_TIME={built}"); + // A commit moves the branch's ref; staging changes the index. Both are under the git dir, which + // for a worktree is not `.git` in the checkout. + for path in ["HEAD", "index"] { + if let Some(path) = git(&["rev-parse", "--git-path", path]) { + println!("cargo:rerun-if-changed={path}"); + } + } + if let Some(reference) = git(&["symbolic-ref", "-q", "HEAD"]) { + if let Some(path) = git(&["rev-parse", "--git-path", &reference]) { + println!("cargo:rerun-if-changed={path}"); + } + } + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=../src"); +} + +/** +The Intelligence CLI a build carries inside the app, pinned to the bytes it was built with. + +Only with `OPENBOT_BUNDLED_CLI=1`, from `cli-bundle/` beside this file: `runtime/node` and +`cli/index.js` (and the CLI's `node_modules`), which `tauri.bundled-cli.conf.json` copies into the +app's resources. Their digests are compiled in, so the app runs exactly these files and nothing put +there afterwards. Every other build has no bundled CLI. See `local_eval::bundled_cli`. +*/ +fn bundled_cli() { + println!("cargo:rerun-if-env-changed=OPENBOT_BUNDLED_CLI"); + if std::env::var("OPENBOT_BUNDLED_CLI").as_deref() != Ok("1") { + return; + } + let root = std::path::Path::new("cli-bundle"); + // The target's, not `cfg!(windows)`: a build script is compiled for the machine building, so + // a Windows app built on a Mac would otherwise pin a `node` that is not there. + let windows = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows"); + let node = format!("node{}", if windows { ".exe" } else { "" }); + for (variable, path) in [ + ( + "OPENBOT_BUNDLED_CLI_PROGRAM_SHA256", + root.join("runtime").join(node), + ), + ( + "OPENBOT_BUNDLED_CLI_ENTRY_SHA256", + root.join("cli").join("index.js"), + ), + ] { + use sha2::Digest; + let mut file = std::fs::File::open(&path).unwrap_or_else(|error| { + panic!("OPENBOT_BUNDLED_CLI=1 needs {}: {error}", path.display()) + }); + let mut hasher = sha2::Sha256::new(); + std::io::copy(&mut file, &mut hasher).expect("the bundled CLI could not be read"); + println!("cargo:rustc-env={variable}={:x}", hasher.finalize()); + println!("cargo:rerun-if-changed={}", path.display()); + } } diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index d9f97e5a0a..0c58e9b193 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "What the setup window is allowed to do. Commands this app defines are always callable; everything under core: is not, and has to be granted here.", - "windows": ["main"], + "windows": ["main", "status"], "permissions": [ "core:event:default", { diff --git a/desktop/src-tauri/gen/schemas/acl-manifests.json b/desktop/src-tauri/gen/schemas/acl-manifests.json index 0c61428572..d836bd4543 100644 --- a/desktop/src-tauri/gen/schemas/acl-manifests.json +++ b/desktop/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"openbot-desktop":{"default_permission":null,"permissions":{"allow-appearance":{"identifier":"allow-appearance","description":"Enables the appearance command without any pre-configured scope.","commands":{"allow":["appearance"],"deny":[]}},"allow-intelligence":{"identifier":"allow-intelligence","description":"Enables the intelligence command without any pre-configured scope.","commands":{"allow":["intelligence"],"deny":[]}},"allow-restart":{"identifier":"allow-restart","description":"Enables the restart command without any pre-configured scope.","commands":{"allow":["restart"],"deny":[]}},"allow-status":{"identifier":"allow-status","description":"Enables the status command without any pre-configured scope.","commands":{"allow":["status"],"deny":[]}},"allow-stop":{"identifier":"allow-stop","description":"Enables the stop command without any pre-configured scope.","commands":{"allow":["stop"],"deny":[]}},"deny-appearance":{"identifier":"deny-appearance","description":"Denies the appearance command without any pre-configured scope.","commands":{"allow":[],"deny":["appearance"]}},"deny-intelligence":{"identifier":"deny-intelligence","description":"Denies the intelligence command without any pre-configured scope.","commands":{"allow":[],"deny":["intelligence"]}},"deny-restart":{"identifier":"deny-restart","description":"Denies the restart command without any pre-configured scope.","commands":{"allow":[],"deny":["restart"]}},"deny-status":{"identifier":"deny-status","description":"Denies the status command without any pre-configured scope.","commands":{"allow":[],"deny":["status"]}},"deny-stop":{"identifier":"deny-stop","description":"Denies the stop command without any pre-configured scope.","commands":{"allow":[],"deny":["stop"]}}},"permission_sets":{},"global_scope_schema":null},"openbot-intelligence":{"default_permission":null,"permissions":{"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-watch":{"identifier":"allow-watch","description":"Enables the watch command without any pre-configured scope.","commands":{"allow":["watch"],"deny":[]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-watch":{"identifier":"deny-watch","description":"Denies the watch command without any pre-configured scope.","commands":{"allow":[],"deny":["watch"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file diff --git a/desktop/src-tauri/gen/schemas/capabilities.json b/desktop/src-tauri/gen/schemas/capabilities.json index c5e5647c6a..dbb756f7ed 100644 --- a/desktop/src-tauri/gen/schemas/capabilities.json +++ b/desktop/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"What the setup window is allowed to do. Commands this app defines are always callable; everything under core: is not, and has to be granted here.","local":true,"windows":["main"],"permissions":["core:event:default",{"identifier":"opener:allow-open-url","allow":[{"url":"https://*"},{"url":"http://*"}]}]}} \ No newline at end of file +{"default":{"identifier":"default","description":"What the setup window is allowed to do. Commands this app defines are always callable; everything under core: is not, and has to be granted here.","local":true,"windows":["main","status"],"permissions":["core:event:default",{"identifier":"opener:allow-open-url","allow":[{"url":"https://*"},{"url":"http://*"}]}]}} \ No newline at end of file diff --git a/desktop/src-tauri/gen/schemas/desktop-schema.json b/desktop/src-tauri/gen/schemas/desktop-schema.json index 214b840f55..b613d9ddc9 100644 --- a/desktop/src-tauri/gen/schemas/desktop-schema.json +++ b/desktop/src-tauri/gen/schemas/desktop-schema.json @@ -2636,6 +2636,90 @@ "const": "dialog:deny-save", "markdownDescription": "Denies the save command without any pre-configured scope." }, + { + "description": "Enables the appearance command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:allow-appearance", + "markdownDescription": "Enables the appearance command without any pre-configured scope." + }, + { + "description": "Enables the intelligence command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:allow-intelligence", + "markdownDescription": "Enables the intelligence command without any pre-configured scope." + }, + { + "description": "Enables the restart command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:allow-restart", + "markdownDescription": "Enables the restart command without any pre-configured scope." + }, + { + "description": "Enables the status command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:allow-status", + "markdownDescription": "Enables the status command without any pre-configured scope." + }, + { + "description": "Enables the stop command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:allow-stop", + "markdownDescription": "Enables the stop command without any pre-configured scope." + }, + { + "description": "Denies the appearance command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:deny-appearance", + "markdownDescription": "Denies the appearance command without any pre-configured scope." + }, + { + "description": "Denies the intelligence command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:deny-intelligence", + "markdownDescription": "Denies the intelligence command without any pre-configured scope." + }, + { + "description": "Denies the restart command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:deny-restart", + "markdownDescription": "Denies the restart command without any pre-configured scope." + }, + { + "description": "Denies the status command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:deny-status", + "markdownDescription": "Denies the status command without any pre-configured scope." + }, + { + "description": "Denies the stop command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:deny-stop", + "markdownDescription": "Denies the stop command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the watch command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:allow-watch", + "markdownDescription": "Enables the watch command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the watch command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:deny-watch", + "markdownDescription": "Denies the watch command without any pre-configured scope." + }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/desktop/src-tauri/gen/schemas/macOS-schema.json b/desktop/src-tauri/gen/schemas/macOS-schema.json index 214b840f55..b613d9ddc9 100644 --- a/desktop/src-tauri/gen/schemas/macOS-schema.json +++ b/desktop/src-tauri/gen/schemas/macOS-schema.json @@ -2636,6 +2636,90 @@ "const": "dialog:deny-save", "markdownDescription": "Denies the save command without any pre-configured scope." }, + { + "description": "Enables the appearance command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:allow-appearance", + "markdownDescription": "Enables the appearance command without any pre-configured scope." + }, + { + "description": "Enables the intelligence command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:allow-intelligence", + "markdownDescription": "Enables the intelligence command without any pre-configured scope." + }, + { + "description": "Enables the restart command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:allow-restart", + "markdownDescription": "Enables the restart command without any pre-configured scope." + }, + { + "description": "Enables the status command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:allow-status", + "markdownDescription": "Enables the status command without any pre-configured scope." + }, + { + "description": "Enables the stop command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:allow-stop", + "markdownDescription": "Enables the stop command without any pre-configured scope." + }, + { + "description": "Denies the appearance command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:deny-appearance", + "markdownDescription": "Denies the appearance command without any pre-configured scope." + }, + { + "description": "Denies the intelligence command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:deny-intelligence", + "markdownDescription": "Denies the intelligence command without any pre-configured scope." + }, + { + "description": "Denies the restart command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:deny-restart", + "markdownDescription": "Denies the restart command without any pre-configured scope." + }, + { + "description": "Denies the status command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:deny-status", + "markdownDescription": "Denies the status command without any pre-configured scope." + }, + { + "description": "Denies the stop command without any pre-configured scope.", + "type": "string", + "const": "openbot-desktop:deny-stop", + "markdownDescription": "Denies the stop command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the watch command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:allow-watch", + "markdownDescription": "Enables the watch command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the watch command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:deny-watch", + "markdownDescription": "Denies the watch command without any pre-configured scope." + }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/desktop/src-tauri/icons/menu/about-dark.png b/desktop/src-tauri/icons/menu/about-dark.png new file mode 100644 index 0000000000..1d62ae7bef Binary files /dev/null and b/desktop/src-tauri/icons/menu/about-dark.png differ diff --git a/desktop/src-tauri/icons/menu/about-light.png b/desktop/src-tauri/icons/menu/about-light.png new file mode 100644 index 0000000000..491e47adcd Binary files /dev/null and b/desktop/src-tauri/icons/menu/about-light.png differ diff --git a/desktop/src-tauri/icons/menu/dashboard-dark.png b/desktop/src-tauri/icons/menu/dashboard-dark.png new file mode 100644 index 0000000000..1d27fa10be Binary files /dev/null and b/desktop/src-tauri/icons/menu/dashboard-dark.png differ diff --git a/desktop/src-tauri/icons/menu/dashboard-light.png b/desktop/src-tauri/icons/menu/dashboard-light.png new file mode 100644 index 0000000000..fb9a335929 Binary files /dev/null and b/desktop/src-tauri/icons/menu/dashboard-light.png differ diff --git a/desktop/src-tauri/icons/menu/draw.py b/desktop/src-tauri/icons/menu/draw.py new file mode 100644 index 0000000000..4cd5a41e08 --- /dev/null +++ b/desktop/src-tauri/icons/menu/draw.py @@ -0,0 +1,122 @@ +"""Draws the menu bar menu's icons: thin, geometric, one stroke weight, in the CopilotKit style. + +macOS does not treat these as template images, so each is drawn twice, in the light menu's ink and +the dark menu's, and the app picks by the system appearance. 36px squares: muda shows menu icons +18pt high, and these are for 2x screens. + +Run with `python3 draw.py` from this directory; it needs nothing beyond the standard library. +""" + +import math +import struct +import zlib + +SIZE = 36 +SAMPLES = 4 # per axis, for anti-aliasing +STROKE = 2.6 # px at 36px, about 1.3pt + + +def seg_dist(px, py, ax, ay, bx, by): + dx, dy = bx - ax, by - ay + t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy or 1))) + return math.hypot(px - (ax + t * dx), py - (ay + t * dy)) + + +def arc_dist(px, py, cx, cy, r, start, end): + """Distance to an arc from `start` to `end` radians, counterclockwise in screen terms.""" + angle = math.atan2(py - cy, px - cx) % (2 * math.pi) + s, e = start % (2 * math.pi), end % (2 * math.pi) + inside = s <= angle <= e if s <= e else angle >= s or angle <= e + if inside: + return abs(math.hypot(px - cx, py - cy) - r) + return min( + math.hypot(px - (cx + r * math.cos(a)), py - (cy + r * math.sin(a))) for a in (s, e) + ) + + +def rounded_rect_dist(px, py, x0, y0, x1, y1, r): + cx, cy = min(max(px, x0 + r), x1 - r), min(max(py, y0 + r), y1 - r) + return abs(math.hypot(px - cx, py - cy) - r) if (px < x0 + r or px > x1 - r) and (py < y0 + r or py > y1 - r) else min( + abs(px - x0) if y0 + r <= py <= y1 - r else 1e9, + abs(px - x1) if y0 + r <= py <= y1 - r else 1e9, + abs(py - y0) if x0 + r <= px <= x1 - r else 1e9, + abs(py - y1) if x0 + r <= px <= x1 - r else 1e9, + abs(math.hypot(px - cx, py - cy) - r), + ) + + +def dot_dist(px, py, cx, cy, r): + return max(0.0, math.hypot(px - cx, py - cy) - r) - STROKE / 2 + + +# Each icon: a function from a point to its distance from the drawing. +ICONS = { + # A window: rounded frame with a title bar line. + "open": lambda x, y: min( + rounded_rect_dist(x, y, 6, 8, 30, 28, 3.5), + seg_dist(x, y, 6, 13.5, 30, 13.5), + ), + # A dashboard: three bars of rising height on a baseline. + "dashboard": lambda x, y: min( + seg_dist(x, y, 9, 28, 9, 20), + seg_dist(x, y, 18, 28, 18, 13), + seg_dist(x, y, 27, 28, 27, 8), + ), + # Status: a pulse line. + "status": lambda x, y: min( + seg_dist(x, y, 5, 19, 12, 19), + seg_dist(x, y, 12, 19, 15.5, 10), + seg_dist(x, y, 15.5, 10, 20.5, 27), + seg_dist(x, y, 20.5, 27, 24, 19), + seg_dist(x, y, 24, 19, 31, 19), + ), + # About: an "i" in a ring. + "about": lambda x, y: min( + arc_dist(x, y, 18, 18, 12, 0, 2 * math.pi - 1e-6), + seg_dist(x, y, 18, 17, 18, 25), + dot_dist(x, y, 18, 12.3, 0), + ), + # Quit: the power symbol, an open ring with a stem. + "quit": lambda x, y: min( + arc_dist(x, y, 18, 19.5, 10, math.radians(-50), math.radians(230)), + seg_dist(x, y, 18, 6.5, 18, 17), + ), +} + + +def render(shape, ink): + rows = [] + for j in range(SIZE): + row = bytearray([0]) # filter: none + for i in range(SIZE): + covered = 0 + for sj in range(SAMPLES): + for si in range(SAMPLES): + x = i + (si + 0.5) / SAMPLES + y = j + (sj + 0.5) / SAMPLES + if shape(x, y) <= STROKE / 2: + covered += 1 + alpha = round(255 * covered / (SAMPLES * SAMPLES)) + row += bytes((*ink, alpha)) + rows.append(bytes(row)) + raw = b"".join(rows) + + def chunk(kind, data): + body = kind + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + header = struct.pack(">IIBBBBB", SIZE, SIZE, 8, 6, 0, 0, 0) + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + + +if __name__ == "__main__": + for name, shape in ICONS.items(): + for theme, ink in (("light", (0x01, 0x05, 0x07)), ("dark", (0xFF, 0xFF, 0xFF))): + with open(f"{name}-{theme}.png", "wb") as out: + out.write(render(shape, ink)) + print(f"{name}-{theme}.png") diff --git a/desktop/src-tauri/icons/menu/open-dark.png b/desktop/src-tauri/icons/menu/open-dark.png new file mode 100644 index 0000000000..f00561bacf Binary files /dev/null and b/desktop/src-tauri/icons/menu/open-dark.png differ diff --git a/desktop/src-tauri/icons/menu/open-light.png b/desktop/src-tauri/icons/menu/open-light.png new file mode 100644 index 0000000000..b9cd9dbdc8 Binary files /dev/null and b/desktop/src-tauri/icons/menu/open-light.png differ diff --git a/desktop/src-tauri/icons/menu/quit-dark.png b/desktop/src-tauri/icons/menu/quit-dark.png new file mode 100644 index 0000000000..4da5148c48 Binary files /dev/null and b/desktop/src-tauri/icons/menu/quit-dark.png differ diff --git a/desktop/src-tauri/icons/menu/quit-light.png b/desktop/src-tauri/icons/menu/quit-light.png new file mode 100644 index 0000000000..bf35811600 Binary files /dev/null and b/desktop/src-tauri/icons/menu/quit-light.png differ diff --git a/desktop/src-tauri/icons/menu/status-dark.png b/desktop/src-tauri/icons/menu/status-dark.png new file mode 100644 index 0000000000..7cd23fc32b Binary files /dev/null and b/desktop/src-tauri/icons/menu/status-dark.png differ diff --git a/desktop/src-tauri/icons/menu/status-light.png b/desktop/src-tauri/icons/menu/status-light.png new file mode 100644 index 0000000000..c2bd9c2a0b Binary files /dev/null and b/desktop/src-tauri/icons/menu/status-light.png differ diff --git a/desktop/src-tauri/src/ask.rs b/desktop/src-tauri/src/ask.rs index 37b66efbdd..9208672a77 100644 --- a/desktop/src-tauri/src/ask.rs +++ b/desktop/src-tauri/src/ask.rs @@ -168,6 +168,139 @@ pub fn ask_mastra( mastra_answer_in(&text).ok_or_else(|| Problem::plain(String::new())) } +/// The runtime agent id the server gives the Bot that setup picked. See `server/src/routing/routes.ts`. +pub const PICKED_HARNESS_AGENT: &str = "picked-harness"; + +/// How often the thread's events are read while the run is going. +const WATCH_EVERY: Duration = Duration::from_millis(400); + +/** +Put a question to a Bot through the OpenBot server's own runtime, so it is a thread in Intelligence. + +`ask` goes straight to the harness, which proves the credential but leaves nothing behind: the +harness keeps its thread in memory and Intelligence never hears of it, so "View in Intelligence" +opened a dashboard with no conversation in it. Asked here, the server mints the thread id, the +runtime records the run in Intelligence, and the dashboard shows the question and the answer. + +The runtime in Intelligence mode does not stream the run back on the request: it answers with a +token for the realtime gateway and runs on its own. Rather than a websocket client for one +question, this reads the thread's recorded events until the run finishes or fails. + +`server` is the server's own address, which a single-user owned deployment admits with no sign-in. +*/ +pub fn ask_through_server(server: &str, agent_id: &str, question: &str) -> Result { + let server = server.trim_end_matches('/'); + let client = reqwest::blocking::Client::builder() + .timeout(PATIENCE) + .build() + .map_err(|error| { + Problem::plain(format!("This machine cannot make web requests: {error}")) + })?; + let unreachable = |error: reqwest::Error| { + Problem::with("OpenBot could not reach its own server.", error.to_string()) + }; + + let minted = client + .post(format!("{server}/api/threads/mint")) + .send() + .map_err(unreachable)?; + let (status, text) = read_response(minted, "openbot-server", server)?; + let thread_id = status + .is_success() + .then(|| serde_json::from_str::(&text).ok()) + .flatten() + .and_then(|minted| minted.get("threadId")?.as_str().map(str::to_owned)) + .ok_or_else(|| { + Problem::with( + "OpenBot's server would not start a conversation.", + format!("HTTP {status}\n{text}"), + ) + })?; + + let started = client + .post(format!("{server}/api/copilotkit/agent/{agent_id}/run")) + .header("content-type", "application/json") + .json(&serde_json::json!({ + "threadId": thread_id, + "runId": format!("openbot-setup-{}", moment()), + "state": {}, + "messages": [{ "id": format!("openbot-setup-question-{}", moment()), "role": "user", "content": question }], + "tools": [], + "context": [], + "forwardedProps": {}, + })) + .send() + .map_err(unreachable)?; + let (status, text) = read_response(started, "openbot-server", server)?; + if !status.is_success() { + return Err(Problem::with( + "The Bot could not answer.", + format!("HTTP {status}\n{text}"), + )); + } + + let events = format!("{server}/api/copilotkit/threads/{thread_id}/events"); + let deadline = std::time::Instant::now() + PATIENCE; + while std::time::Instant::now() < deadline { + std::thread::sleep(WATCH_EVERY); + // Not recorded yet reads as an error or an empty list; both are "not finished". + let Ok(response) = client.get(&events).send() else { + continue; + }; + if !response.status().is_success() { + continue; + } + let Ok(body) = response.text() else { + continue; + }; + if let Some(outcome) = run_outcome(&body) { + return outcome; + } + } + Err(Problem::with( + "The Bot did not answer in time. Ask again.", + format!("thread {thread_id}\nno RUN_FINISHED or RUN_ERROR within {PATIENCE:?}"), + )) +} + +/** +How a run on a thread ended, out of the thread's recorded events, or None while it is going. + +The answer is the `TEXT_MESSAGE_CONTENT` deltas, as in `answer_in`. A run that finished with no text +is the same empty sentence `ask` returns, so the caller can fetch the harness's log for the cause. +*/ +pub fn run_outcome(body: &str) -> Option> { + let recorded: serde_json::Value = serde_json::from_str(body).ok()?; + let events = recorded.get("events")?.as_array()?; + let mut answer = String::new(); + for event in events { + match event.get("type").and_then(|kind| kind.as_str()) { + Some("TEXT_MESSAGE_CONTENT") => { + if let Some(delta) = event.get("delta").and_then(|delta| delta.as_str()) { + answer.push_str(delta); + } + } + Some("RUN_ERROR") => { + let said = event + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or_default(); + return Some(Err(why_nothing_came_back(said))); + } + Some("RUN_FINISHED") => { + let answer = answer.trim(); + return Some(if answer.is_empty() { + Err(Problem::plain(String::new())) + } else { + Ok(answer.to_owned()) + }); + } + _ => {} + } + } + None +} + fn read_response( response: reqwest::blocking::Response, kind: &str, @@ -597,6 +730,109 @@ mod tests { let _ = server.request(); } + fn json_response(status: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + } + + /// One connection per response, answered in order; each request line is sent back. + fn serve_in_order(responses: Vec) -> (String, std::sync::mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let url = format!("http://{}", listener.local_addr().expect("addr")); + let (sender, received) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + use std::io::{Read, Write}; + for response in responses { + let (mut stream, _) = listener.accept().expect("accept"); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut buffer).expect("read"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + let text = String::from_utf8_lossy(&request).to_string(); + let _ = sender.send(text.lines().next().unwrap_or_default().to_owned()); + stream.write_all(response.as_bytes()).expect("write"); + } + }); + (url, received) + } + + #[test] + fn a_question_asked_through_the_server_is_a_minted_thread_read_back_until_it_finishes() { + let thread = "55569917-dab5-8fe4-84e1-a85613418e1b"; + let (url, requests) = serve_in_order(vec![ + json_response("200 OK", &format!("{{\"threadId\":\"{thread}\"}}")), + json_response( + "200 OK", + &format!("{{\"threadId\":\"{thread}\",\"joinToken\":\"t\"}}"), + ), + // Not recorded yet, then still going, then finished. + json_response("404 Not Found", "{}"), + json_response( + "200 OK", + r#"{"events":[{"type":"RUN_STARTED"},{"type":"TEXT_MESSAGE_CONTENT","delta":"Three"}]}"#, + ), + json_response( + "200 OK", + r#"{"events":[{"type":"RUN_STARTED"},{"type":"TEXT_MESSAGE_CONTENT","delta":"Three"},{"type":"TEXT_MESSAGE_CONTENT","delta":" r's."},{"type":"RUN_FINISHED"}]}"#, + ), + ]); + + let answer = ask_through_server(&url, PICKED_HARNESS_AGENT, "How many r's?").unwrap(); + + assert_eq!(answer, "Three r's."); + let asked: Vec = requests.try_iter().collect(); + assert_eq!( + asked, + [ + "POST /api/threads/mint HTTP/1.1".to_owned(), + "POST /api/copilotkit/agent/picked-harness/run HTTP/1.1".to_owned(), + format!("GET /api/copilotkit/threads/{thread}/events HTTP/1.1"), + format!("GET /api/copilotkit/threads/{thread}/events HTTP/1.1"), + format!("GET /api/copilotkit/threads/{thread}/events HTTP/1.1"), + ] + ); + } + + #[test] + fn a_run_that_will_not_start_on_the_server_says_so_with_the_response_behind_it() { + let (url, _) = serve_in_order(vec![ + json_response( + "200 OK", + r#"{"threadId":"55569917-dab5-8fe4-84e1-a85613418e1b"}"#, + ), + json_response("404 Not Found", r#"{"error":"Agent not found"}"#), + ]); + let problem = ask_through_server(&url, "nobody", "hello").unwrap_err(); + assert_eq!(problem.said, "The Bot could not answer."); + assert!(problem.detail.unwrap().contains("Agent not found")); + } + + #[test] + fn run_outcome_reads_finished_failed_and_still_going_runs() { + assert!(run_outcome("{}").is_none()); + assert!(run_outcome(r#"{"events":[{"type":"RUN_STARTED"}]}"#).is_none()); + // Finished with no text: the empty sentence the caller fills from the harness's log. + let empty = run_outcome(r#"{"events":[{"type":"RUN_FINISHED"}]}"#).unwrap(); + assert_eq!(empty.unwrap_err().said, ""); + let refused = run_outcome( + r#"{"events":[{"type":"RUN_STARTED"},{"type":"RUN_ERROR","message":"Error code: 401 - invalid_api_key"}]}"#, + ) + .unwrap() + .unwrap_err(); + assert!( + refused.said.starts_with("That key was refused."), + "{}", + refused.said + ); + } + struct TestRequest { path: String, headers: Vec, diff --git a/desktop/src-tauri/src/deployment.rs b/desktop/src-tauri/src/deployment.rs index 563d8474e9..f4c80148ff 100644 --- a/desktop/src-tauri/src/deployment.rs +++ b/desktop/src-tauri/src/deployment.rs @@ -332,8 +332,25 @@ fn fetch_images(root: &Path, version: &str) -> Result<(), String> { /// Shared with `install.rs`, which fetches the engine's installers through it and then checks their /// digests. One client, one user agent, one set of TLS defaults. pub fn get(url: &str) -> Result, String> { - let response = reqwest::blocking::Client::builder() + get_waiting(url, IDLE_TIMEOUT) +} + +/// How long a download may go without receiving anything before it is given up. +const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/** +`get`, giving up only when nothing arrives for `idle`. + +READ AS A STREAM, NOT WITH `bytes()`. The blocking client's timeout (30 s by default) bounds each +read on a stream, but `bytes()` waits for the whole body within it, and reports running out of time +as "error decoding response body". So any download that took longer than 30 seconds failed, however +steadily it was arriving: Bun's 30 MB on a connection under about 1 MB/s, every time. +*/ +fn get_waiting(url: &str, idle: std::time::Duration) -> Result, String> { + use std::io::Read; + let mut response = reqwest::blocking::Client::builder() .user_agent("openbot-desktop") + .timeout(idle) .build() .map_err(|error| format!("could not prepare the download: {error}"))? .get(url) @@ -342,16 +359,64 @@ pub fn get(url: &str) -> Result, String> { if !response.status().is_success() { return Err(format!("{url} answered {}", response.status())); } + let mut body = Vec::new(); response - .bytes() - .map(|body| body.to_vec()) - .map_err(|error| format!("the download did not finish: {error}")) + .read_to_end(&mut body) + .map_err(|error| format!("the download did not finish: {error}"))?; + Ok(body) } #[cfg(test)] mod tests { use super::*; + /// A server that sends `chunks` pieces of `body`, `gap` apart, then closes. + fn dripping(chunks: usize, gap: std::time::Duration) -> (String, std::thread::JoinHandle<()>) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/file", listener.local_addr().unwrap()); + let served = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 1024]; + let _ = stream.read(&mut request); + let length = chunks * 4; + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-length: {length}\r\nconnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .unwrap(); + for _ in 0..chunks { + std::thread::sleep(gap); + if stream.write_all(b"data").is_err() { + return; + } + let _ = stream.flush(); + } + }); + (url, served) + } + + #[test] + fn a_download_that_keeps_arriving_finishes_however_long_it_takes() { + // Six pieces 300 ms apart take 1.8 s in all, longer than the 1 s allowed between pieces. + // `bytes()` gave up here with "error decoding response body"; a stream does not. + let (url, served) = dripping(6, std::time::Duration::from_millis(300)); + let body = get_waiting(&url, std::time::Duration::from_secs(1)).unwrap(); + assert_eq!(body, b"data".repeat(6)); + served.join().unwrap(); + } + + #[test] + fn a_download_that_stalls_is_given_up() { + let (url, served) = dripping(2, std::time::Duration::from_millis(1500)); + let error = get_waiting(&url, std::time::Duration::from_millis(500)).unwrap_err(); + assert!(error.starts_with("the download did not finish"), "{error}"); + served.join().unwrap(); + } + fn manifest(names: &[&str]) -> Images { Images { version: "v0.0.7".into(), diff --git a/desktop/src-tauri/src/desktop_page.rs b/desktop/src-tauri/src/desktop_page.rs new file mode 100644 index 0000000000..8f4357766c --- /dev/null +++ b/desktop/src-tauri/src/desktop_page.rs @@ -0,0 +1,93 @@ +//! What OpenBot's own Settings page ("This computer") may ask this desktop app. +//! +//! The same facts and actions as the Status window, reached from the app instead: what is running, +//! how much memory each part takes, Restart and Stop for OpenBot, and Start, Stop and status for +//! the local Intelligence stack. An inline plugin, so each command carries a permission of its own, +//! granted at runtime to the exact origin of the OpenBot this app started and to nothing else. + +use openbot_desktop_lib::{local_eval, local_eval_state, stack}; +use tauri::Manager; + +pub const PLUGIN: &str = "openbot-desktop"; + +/// The permissions a page needs for every command here. +pub const PERMISSIONS: [&str; 5] = [ + "openbot-desktop:allow-appearance", + "openbot-desktop:allow-status", + "openbot-desktop:allow-restart", + "openbot-desktop:allow-stop", + "openbot-desktop:allow-intelligence", +]; + +/** +The page's own light or dark, for the window: macOS draws a window's outline from the window's +appearance, and a dark one is a black line around a light page. + +Its own command rather than the window plugin's `set_theme`: granted to this origin, that call was +refused (the window stayed dark), and this one is ours to see. +*/ +#[tauri::command] +fn appearance(window: tauri::Window, dark: bool) -> Result<(), String> { + window + .set_theme(Some(if dark { + tauri::Theme::Dark + } else { + tauri::Theme::Light + })) + .map_err(|error| error.to_string())?; + crate::page_appearance(&window, dark); + Ok(()) +} + +/// `memory` false answers in milliseconds without container memory; see `read_desktop_status`. +#[tauri::command] +async fn status( + app: tauri::AppHandle, + memory: Option, +) -> Result { + crate::desktop_status(app, memory).await +} + +#[tauri::command] +fn restart(app: tauri::AppHandle) -> Result<(), String> { + crate::restart_openbot(app) +} + +/// Stops everything, as the menu's Stop did. The page asking goes with it; the window shows setup. +#[tauri::command] +fn stop(app: tauri::AppHandle) { + crate::stop_openbot_from_status(app); +} + +/// `local status`, `start` or `stop` for the stack recorded for this installation. +#[tauri::command] +async fn intelligence( + app: tauri::AppHandle, + action: local_eval::Action, +) -> Result { + let root = crate::cleanup_root(&app.state::(), &stack::default_root()); + let stack = local_eval_state::read(&root, None) + .ok_or(local_eval::Failure::Unavailable)? + .stack_id(); + // Named so it cannot be mistaken for the setup window's own calls in the CLI's log. + let attempt = format!( + "page-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_nanos()) + .unwrap_or_default() + ); + crate::run_local_eval(app, attempt, action, stack.as_str().to_owned()).await +} + +pub fn plugin() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new(PLUGIN) + .invoke_handler(tauri::generate_handler![ + appearance, + status, + restart, + stop, + intelligence + ]) + .build() +} diff --git a/desktop/src-tauri/src/desktop_status.rs b/desktop/src-tauri/src/desktop_status.rs new file mode 100644 index 0000000000..03c547d24d --- /dev/null +++ b/desktop/src-tauri/src/desktop_status.rs @@ -0,0 +1,144 @@ +//! What the Status window says about what OpenBot is running, and how much memory it takes. +//! +//! Containers are told apart by the names their owners give them: Compose names OpenBot's +//! `openbot--` (its project is `openbot`), and k3d names an Intelligence stack's +//! `k3d--`. Memory is Docker's own figure for each container, and the resident size +//! of OpenBot's host processes, so the total is what the machine is actually spending. + +use serde::Serialize; + +/// One container or process and its memory, as the window lists it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Part { + pub name: String, + pub memory_bytes: Option, +} + +/// Docker's `MemUsage` column ("123.4MiB / 7.66GiB"): the used half, in bytes. +pub fn memory_in_use(column: &str) -> Option { + let used = column.split('/').next()?.trim(); + let split = used.find(|c: char| c.is_ascii_alphabetic())?; + let (number, unit) = used.split_at(split); + let number: f64 = number.trim().parse().ok()?; + let scale: f64 = match unit.trim() { + "B" => 1.0, + "KiB" => 1024.0, + "MiB" => 1024.0 * 1024.0, + "GiB" => 1024.0 * 1024.0 * 1024.0, + "TiB" => 1024.0 * 1024.0 * 1024.0 * 1024.0, + "kB" | "KB" => 1e3, + "MB" => 1e6, + "GB" => 1e9, + _ => return None, + }; + (number.is_finite() && number >= 0.0).then(|| (number * scale).round() as u64) +} + +/// `docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}'`, split into OpenBot's containers +/// and those of the Intelligence stack `stack`. Anybody else's containers are left out. +pub fn containers(stats: &str, stack: Option<&str>) -> (Vec, Vec) { + let mut openbot = Vec::new(); + let mut intelligence = Vec::new(); + let intelligence_prefix = stack.map(|stack| format!("k3d-{stack}-")); + for line in stats.lines() { + let Some((name, usage)) = line.split_once('\t') else { + continue; + }; + let name = name.trim().trim_start_matches('/'); + let part = Part { + name: name.to_owned(), + memory_bytes: memory_in_use(usage), + }; + if name.starts_with("openbot-") { + openbot.push(part); + } else if intelligence_prefix + .as_deref() + .is_some_and(|prefix| name.starts_with(prefix)) + { + intelligence.push(part); + } + } + (openbot, intelligence) +} + +/// `ps -o pid=,rss= -p …`: each process's resident size, in bytes (ps reports kibibytes). +pub fn resident(ps: &str) -> Vec<(u32, u64)> { + ps.lines() + .filter_map(|line| { + let mut fields = line.split_whitespace(); + let pid = fields.next()?.parse().ok()?; + let kib: u64 = fields.next()?.parse().ok()?; + Some((pid, kib * 1024)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docker_memory_reads_the_used_half_in_either_unit_family() { + assert_eq!(memory_in_use("1.5GiB / 7.66GiB"), Some(1_610_612_736)); + assert_eq!(memory_in_use("123.4MiB / 7.66GiB"), Some(129_394_278)); + assert_eq!(memory_in_use("512KiB / 1GiB"), Some(524_288)); + assert_eq!(memory_in_use("0B / 0B"), Some(0)); + assert_eq!(memory_in_use("2.5MB / 1GB"), Some(2_500_000)); + assert_eq!(memory_in_use("--"), None); + assert_eq!(memory_in_use(""), None); + } + + #[test] + fn containers_are_openbots_or_the_stacks_and_nobody_elses() { + let stats = "openbot-postgres-1\t100MiB / 8GiB\n\ + k3d-local-abc-server-0\t1GiB / 8GiB\n\ + k3d-local-other-server-0\t2GiB / 8GiB\n\ + buildx_buildkit_x\t50MiB / 8GiB\n\ + /openbot-supervisor-1\t--\n"; + let (openbot, intelligence) = containers(stats, Some("local-abc")); + assert_eq!( + openbot, + vec![ + Part { + name: "openbot-postgres-1".into(), + memory_bytes: Some(104_857_600) + }, + Part { + name: "openbot-supervisor-1".into(), + memory_bytes: None + }, + ] + ); + assert_eq!( + intelligence, + vec![Part { + name: "k3d-local-abc-server-0".into(), + memory_bytes: Some(1_073_741_824) + }] + ); + assert!(containers(stats, None).1.is_empty()); + } + + #[test] + fn the_quick_listing_names_containers_without_measuring_them() { + let listing = "openbot-postgres-1\t\nk3d-local-abc-server-0\t\n"; + let (openbot, intelligence) = containers(listing, Some("local-abc")); + assert_eq!( + openbot, + vec![Part { + name: "openbot-postgres-1".into(), + memory_bytes: None + }] + ); + assert_eq!(intelligence[0].memory_bytes, None); + } + + #[test] + fn resident_sizes_are_kibibytes_per_pid() { + assert_eq!( + resident(" 123 2048\n 456 10\nnonsense\n"), + vec![(123, 2_097_152), (456, 10_240)] + ); + } +} diff --git a/desktop/src-tauri/src/docker_desktop.rs b/desktop/src-tauri/src/docker_desktop.rs new file mode 100644 index 0000000000..18879db9a6 --- /dev/null +++ b/desktop/src-tauri/src/docker_desktop.rs @@ -0,0 +1,602 @@ +//! Docker Desktop: started when it is installed, installed when it is not. +//! +//! Docker is the engine OpenBot runs on, and the only one local Intelligence accepts. A Mac or a +//! Windows machine with Docker Desktop installed but not running is not a machine without an engine: +//! starting it is the repair, not installing a second engine beside it. A machine without it gets +//! Docker's own installer. +//! +//! **Podman is never chosen for somebody.** It is for an organization that cannot use Docker +//! Desktop under its licence, and that is a decision a person makes, not a fallback the app takes +//! because Docker happened to be stopped. A Podman somebody already runs is still used (see +//! `install::install_engine_with`); one is never installed in Docker's place. +//! +//! **Docker's terms are accepted by the person, in Docker's own window.** The installer is run +//! without `--accept-license`, so the first start shows Docker's subscription agreement and its +//! privileged-helper prompt, and the wait below allows for somebody reading them. +//! +//! Linux is not handled here: Docker there is the distribution's Docker Engine, not Docker Desktop. + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; + +use crate::engine::{self, Engine}; +use crate::install::Download; +use crate::problem::Problem; + +/// The release whose installers are pinned below, and Docker's build number for it. +/// +/// Pinned for the reason every other download is: these files are executed. The digests come from +/// the `checksums.txt` Docker publishes beside each build, recorded here rather than fetched, since a +/// checksum from the server that served the file proves nothing. Moving the version means +/// re-recording all four. +pub const DOCKER_DESKTOP: &str = "4.96.3"; +const BUILD: &str = "244472"; + +/// How long a Docker Desktop that was already installed gets to start answering. +const START_PATIENCE: Duration = Duration::from_secs(180); +/// How long a first start gets: it shows Docker's terms and a helper prompt, which somebody reads. +const FIRST_START_PATIENCE: Duration = Duration::from_secs(15 * 60); +const POLL: Duration = Duration::from_secs(2); + +/// Docker Desktop's installer for this machine, or why there is not one. +/// +/// The machine's architecture, not OpenBot's: Windows on ARM runs an x64 OpenBot under emulation, +/// and Docker's x64 installer refuses that machine outright. +pub fn download() -> Result { + download_for(std::env::consts::OS, crate::windows::machine_arch()) +} + +fn download_for(os: &str, arch: &str) -> Result { + let (path, file, sha256) = + match (os, arch) { + ("macos", "aarch64") => ( + "mac/main/arm64", + "Docker.dmg", + "a3ac78b7de67af169ca58f33a4fa4c26007840870574c80c59a7ab0c7f68d954", + ), + ("macos", "x86_64") => ( + "mac/main/amd64", + "Docker.dmg", + "9d3409c689628d4f42972e4773a700817ff3526b1810a30a8de33fc1379e016b", + ), + ("windows", "x86_64") => ( + "win/main/amd64", + "Docker Desktop Installer.exe", + "3d8adad4f6c86583e0b00ea19a76197c51ccf5c83437c9ed775b739756452507", + ), + ("windows", "aarch64") => ( + "win/main/arm64", + "Docker Desktop Installer.exe", + "7e9d3b4d090527bdb89999ed87e0dc42e8b4b5c3bfdf3d252c8cc7d2fddeadb4", + ), + (os, arch) => return Err(Problem::with( + "OpenBot cannot install Docker Desktop on this kind of computer. Install Docker, \ + then start OpenBot again.", + format!("no Docker Desktop {DOCKER_DESKTOP} installer for {os} on {arch}"), + )), + }; + Ok(Download { + url: format!( + "https://desktop.docker.com/{path}/{BUILD}/{}", + file.replace(' ', "%20") + ), + sha256, + file, + }) +} + +/// Where Docker Desktop is installed on this machine, if it is. +pub fn installed_app() -> Option { + installed_app_in(&app_places()) +} + +fn installed_app_in(places: &[PathBuf]) -> Option { + places.iter().find(|place| place.exists()).cloned() +} + +fn app_places() -> Vec { + #[cfg(target_os = "macos")] + { + let mut places = vec![PathBuf::from("/Applications/Docker.app")]; + if let Some(home) = std::env::var_os("HOME") { + places.push(PathBuf::from(home).join("Applications/Docker.app")); + } + places + } + // Per user first, which is how OpenBot installs it; per machine is how most people already have it. + #[cfg(target_os = "windows")] + { + [ + std::env::var_os("LOCALAPPDATA").map(|local| { + PathBuf::from(local).join("Programs\\DockerDesktop\\Docker Desktop.exe") + }), + std::env::var_os("ProgramFiles") + .map(|files| PathBuf::from(files).join("Docker\\Docker\\Docker Desktop.exe")), + ] + .into_iter() + .flatten() + .collect() + } + #[cfg(target_os = "linux")] + { + Vec::new() + } +} + +/// Whether Docker answers now. +pub fn answers() -> bool { + engine::Address::new(Engine::Docker, None).responds() +} + +/// Downloads and installs Docker Desktop, saying how it is going. +type Install<'a> = &'a mut dyn FnMut(&mut dyn FnMut(Progress)) -> Result<(), Problem>; + +/// What `ensure` needs from the machine, so its decisions can be tested without one. +pub(crate) struct Machine<'a> { + pub installed: &'a dyn Fn() -> bool, + pub answers: &'a dyn Fn() -> bool, + pub install: Install<'a>, + pub open: &'a dyn Fn() -> Result<(), Problem>, + pub sleep: &'a dyn Fn(Duration), + pub now: &'a dyn Fn() -> Instant, +} + +/// One line for the setup screen's step row, with download progress when there is some. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Progress { + pub said: String, + pub downloaded: Option, +} + +impl Progress { + fn said(said: impl Into) -> Self { + Self { + said: said.into(), + downloaded: None, + } + } +} + +/// What `ensure` did, for the step row and for telemetry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ensured { + pub said: String, + /// True when Docker Desktop was installed by this call. + pub installed: bool, +} + +/// Docker answering, by starting it or installing and starting it. See the module comment. +pub fn ensure(cache: &Path, progress: &mut dyn FnMut(Progress)) -> Result { + let into = crate::acquire::download_dir(cache); + ensure_with( + &mut Machine { + installed: &|| installed_app().is_some(), + answers: &answers, + install: &mut |progress| install(&into, progress), + open: &open, + sleep: &std::thread::sleep, + now: &Instant::now, + }, + progress, + ) +} + +pub(crate) fn ensure_with( + machine: &mut Machine, + progress: &mut dyn FnMut(Progress), +) -> Result { + if (machine.answers)() { + return Ok(Ensured { + said: "Docker Desktop is running.".into(), + installed: false, + }); + } + let fresh = !(machine.installed)(); + if fresh { + (machine.install)(progress)?; + } + progress(Progress::said(if fresh { + "Starting Docker Desktop. Accept its terms in its own window when it asks." + } else { + "Starting Docker Desktop." + })); + (machine.open)()?; + + let patience = if fresh { + FIRST_START_PATIENCE + } else { + START_PATIENCE + }; + let started = (machine.now)(); + while (machine.now)().duration_since(started) < patience { + if (machine.answers)() { + return Ok(Ensured { + said: if fresh { + format!("Docker Desktop {DOCKER_DESKTOP} installed and running.") + } else { + "Docker Desktop is running.".into() + }, + installed: fresh, + }); + } + (machine.sleep)(POLL); + } + Err(Problem::with( + "Docker Desktop did not start. Open Docker Desktop, finish anything it asks for, then try \ + again.", + format!( + "Docker did not answer within {}s of opening Docker Desktop", + patience.as_secs() + ), + )) +} + +/// Open Docker Desktop the way the person would. +fn open() -> Result<(), Problem> { + let Some(app) = installed_app() else { + return Err(Problem::plain( + "OpenBot cannot find Docker Desktop. Install it, then try again.", + )); + }; + #[cfg(target_os = "macos")] + let spawned = crate::quiet::command("open").arg("-a").arg(&app).status(); + // Starting Docker Desktop's own executable is the whole job, so a started process counts as + // success and its exit is not waited for. The status is named: `Default::default()` here left + // the type to inference, which had nothing to go on, and failed to compile off macOS. + #[cfg(not(target_os = "macos"))] + let spawned = crate::quiet::command(&app) + .spawn() + .map(|_| std::process::ExitStatus::default()); + match spawned { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(Problem::with( + "OpenBot could not open Docker Desktop. Open it yourself, then try again.", + format!("opening {} ended with {status}", app.display()), + )), + Err(error) => Err(Problem::with( + "OpenBot could not open Docker Desktop. Open it yourself, then try again.", + format!("opening {}: {error}", app.display()), + )), + } +} + +/// Download Docker Desktop's installer and install it, without accepting its terms. +fn install(into: &Path, progress: &mut dyn FnMut(Progress)) -> Result<(), Problem> { + let download = download()?; + let installer = fetch_streamed(&download, into, progress)?; + progress(Progress::said("Installing Docker Desktop.")); + install_from(&installer, into) +} + +#[cfg(target_os = "macos")] +fn install_from(dmg: &Path, into: &Path) -> Result<(), Problem> { + let mount = into.join("docker-desktop-volume"); + let _ = std::fs::create_dir_all(&mount); + let attached = crate::quiet::command("hdiutil") + .args([ + "attach", + "-nobrowse", + "-readonly", + "-noautoopen", + "-mountpoint", + ]) + .arg(&mount) + .arg(dmg) + .output() + .map_err(|error| install_failed(format!("hdiutil attach: {error}")))?; + if !attached.status.success() { + return Err(install_failed(format!( + "hdiutil attach: {}", + crate::quiet::said(&attached.stderr) + ))); + } + // Copied as the person, which an administrator account can do into /Applications with no + // prompt. Docker's own first start then asks for its privileged helper. + let copied = crate::quiet::command("ditto") + .arg(mount.join("Docker.app")) + .arg("/Applications/Docker.app") + .output(); + let _ = crate::quiet::command("hdiutil") + .args(["detach", "-quiet"]) + .arg(&mount) + .output(); + match copied { + Ok(out) if out.status.success() => Ok(()), + Ok(out) => Err(install_failed(format!( + "ditto Docker.app /Applications: {}", + crate::quiet::said(&out.stderr) + ))), + Err(error) => Err(install_failed(format!("ditto: {error}"))), + } +} + +#[cfg(target_os = "windows")] +fn install_from(exe: &Path, _into: &Path) -> Result<(), Problem> { + /* + * Unattended, for this person only, on WSL 2. Run bare, Docker's installer opens its own + * Configuration window (per user or for everyone, WSL 2 or Hyper-V) and waits on it, and that + * window opened behind OpenBot's, so setup sat on "Preparing this computer" with nothing on + * screen to answer. `--user` installs to %LOCALAPPDATA%\Programs\DockerDesktop with no + * administrator prompt, the installer's own recommendation; WSL 2 is what the Windows setup + * screen has already made sure of. Still no `--accept-license`: Docker's agreement is the + * person's to accept, in Docker Desktop's first start. + */ + let status = crate::quiet::command(exe) + .args([ + "install", + "--quiet", + "--user", + "--backend=wsl-2", + "--no-windows-containers", + ]) + .status() + .map_err(|error| install_failed(format!("{}: {error}", exe.display())))?; + if status.success() { + Ok(()) + } else { + Err(install_failed(format!( + "{} install ended with {status}", + exe.display() + ))) + } +} + +#[cfg(target_os = "linux")] +fn install_from(_installer: &Path, _into: &Path) -> Result<(), Problem> { + Err(Problem::plain( + "On Linux, Docker comes from the distribution's own packages.", + )) +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn install_failed(detail: String) -> Problem { + Problem::with( + "OpenBot could not install Docker Desktop. Install it from docker.com, then try again.", + detail, + ) +} + +/// Fetch to `into`, hashing as it streams, and refuse anything whose digest is not the pinned one. +/// +/// Streamed rather than read whole like the smaller downloads: the installer is about 600 MB, and +/// the step row shows how much has arrived. A file already there with the right digest is kept. +fn fetch_streamed( + download: &Download, + into: &Path, + progress: &mut dyn FnMut(Progress), +) -> Result { + let path = into.join(download.file); + if file_digest(&path).as_deref() == Some(download.sha256) { + return Ok(path); + } + std::fs::create_dir_all(into).map_err(|error| unwritable(into, &error.to_string()))?; + + let unreachable = |error: String| { + Problem::with( + "OpenBot could not download Docker Desktop. Check the internet connection and try \ + again.", + format!("{}: {error}", download.url), + ) + }; + let mut response = reqwest::blocking::Client::builder() + .user_agent("openbot-desktop") + .build() + .map_err(|error| unreachable(error.to_string()))? + .get(&download.url) + .send() + .map_err(|error| unreachable(error.to_string()))?; + if !response.status().is_success() { + return Err(unreachable(format!("answered {}", response.status()))); + } + + let partial = into.join(format!("{}.part", download.file)); + let mut out = std::fs::File::create(&partial) + .map_err(|error| unwritable(&partial, &error.to_string()))?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0; 1 << 20]; + let mut received: u64 = 0; + let mut last_said = 0; + loop { + let read = response + .read(&mut buffer) + .map_err(|error| unreachable(format!("the download did not finish: {error}")))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + out.write_all(&buffer[..read]) + .map_err(|error| unwritable(&partial, &error.to_string()))?; + received += read as u64; + if received - last_said >= 8 << 20 { + last_said = received; + progress(Progress { + said: "Downloading Docker Desktop.".into(), + downloaded: Some(received), + }); + } + } + drop(out); + + let got = format!("{:x}", hasher.finalize()); + if got != download.sha256 { + let _ = std::fs::remove_file(&partial); + return Err(Problem::with( + "What OpenBot downloaded is not what it was expecting, so it has not been run. Try \ + again.", + format!( + "{} from {}: expected sha256 {}, got {got}", + download.file, download.url, download.sha256 + ), + )); + } + std::fs::rename(&partial, &path).map_err(|error| unwritable(&path, &error.to_string()))?; + Ok(path) +} + +fn file_digest(path: &Path) -> Option { + let mut file = std::fs::File::open(path).ok()?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher).ok()?; + Some(format!("{:x}", hasher.finalize())) +} + +fn unwritable(path: &Path, error: &str) -> Problem { + Problem::with( + "OpenBot could not save Docker Desktop's installer. Check there is free disk space and try \ + again.", + format!("{}: {error}", path.display()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::{Cell, RefCell}; + + /// A machine whose Docker answers after `answers_after` polls, counting what was done to it. + struct Fake { + installed: Cell, + polls: Cell, + answers_after: Option, + opened: Cell, + installs: Cell, + clock: Cell, + } + + impl Fake { + fn new(installed: bool, answers_after: Option) -> Self { + Self { + installed: Cell::new(installed), + polls: Cell::new(0), + answers_after, + opened: Cell::new(0), + installs: Cell::new(0), + clock: Cell::new(Instant::now()), + } + } + + fn run(&self) -> (Result, Vec) { + let said = RefCell::new(Vec::new()); + let answers = || { + let polls = self.polls.get(); + self.polls.set(polls + 1); + self.opened.get() > 0 && self.answers_after.is_some_and(|after| polls >= after) + }; + let result = ensure_with( + &mut Machine { + installed: &|| self.installed.get(), + answers: &answers, + install: &mut |progress| { + self.installs.set(self.installs.get() + 1); + progress(Progress { + said: "Downloading Docker Desktop.".into(), + downloaded: Some(8 << 20), + }); + self.installed.set(true); + Ok(()) + }, + open: &|| { + self.opened.set(self.opened.get() + 1); + Ok(()) + }, + sleep: &|step| self.clock.set(self.clock.get() + step), + now: &|| self.clock.get(), + }, + &mut |line| said.borrow_mut().push(line), + ); + (result, said.into_inner()) + } + } + + #[test] + fn an_answering_docker_is_used_as_it_is() { + let fake = Fake::new(true, Some(0)); + fake.opened.set(1); + let (result, said) = fake.run(); + assert!(!result.unwrap().installed); + assert_eq!(fake.opened.get(), 1, "nothing was opened again"); + assert_eq!(fake.installs.get(), 0); + assert!(said.is_empty()); + } + + #[test] + fn an_installed_docker_desktop_that_is_stopped_is_started_not_replaced() { + let fake = Fake::new(true, Some(3)); + let (result, said) = fake.run(); + let ensured = result.unwrap(); + assert!(!ensured.installed); + assert_eq!( + fake.installs.get(), + 0, + "an installed Docker is never installed again" + ); + assert_eq!(fake.opened.get(), 1); + assert_eq!(said, [Progress::said("Starting Docker Desktop.")]); + } + + #[test] + fn a_machine_without_docker_desktop_gets_it_installed_then_started() { + let fake = Fake::new(false, Some(2)); + let (result, said) = fake.run(); + let ensured = result.unwrap(); + assert!(ensured.installed); + assert_eq!(fake.installs.get(), 1); + assert_eq!(fake.opened.get(), 1); + assert_eq!(said[0].downloaded, Some(8 << 20)); + // The person accepts Docker's terms themselves, in Docker's window. + assert!(said[1].said.contains("Accept its terms in its own window")); + } + + #[test] + fn a_docker_desktop_that_never_answers_says_so_after_its_patience() { + let fake = Fake::new(true, None); + let (result, _) = fake.run(); + let problem = result.unwrap_err(); + assert!(problem.said.starts_with("Docker Desktop did not start.")); + assert!(problem.detail.unwrap().contains("180s")); + // A first start waits longer: it shows terms somebody reads. + let fresh = Fake::new(false, None); + let problem = fresh.run().0.unwrap_err(); + assert!(problem.detail.unwrap().contains("900s")); + } + + #[test] + fn the_installers_are_pinned_to_docker_s_published_digests() { + for (os, arch) in [ + ("macos", "aarch64"), + ("macos", "x86_64"), + ("windows", "x86_64"), + ("windows", "aarch64"), + ] { + let download = download_for(os, arch).unwrap(); + assert!( + download.url.starts_with("https://desktop.docker.com/"), + "{download:?}" + ); + assert!(download.url.contains(&format!("/{BUILD}/")), "{download:?}"); + assert_eq!(download.sha256.len(), 64); + assert!(download + .sha256 + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + } + assert!(download_for("windows", "x86_64") + .unwrap() + .url + .ends_with("Docker%20Desktop%20Installer.exe")); + assert!(download_for("linux", "x86_64").is_err()); + } + + #[test] + fn an_installed_app_is_found_in_any_of_its_places() { + let root = crate::test_support::temp_root("docker-desktop-places"); + let first = root.join("Applications/Docker.app"); + let second = root.join("home/Applications/Docker.app"); + assert_eq!(installed_app_in(&[first.clone(), second.clone()]), None); + std::fs::create_dir_all(&second).unwrap(); + assert_eq!(installed_app_in(&[first, second.clone()]), Some(second)); + } +} diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 2754f4970b..4f187f2d9a 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -372,6 +372,31 @@ fn command_at(engine: Engine, binary: &Path) -> Command { built } +/** +This process's PATH with Docker's own directories appended, for a program that looks `docker` up by +name, or None when there is no Docker here. + +Opened from Finder, the app has the system's minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`), which +does not reach Docker Desktop's `/usr/local/bin`. Appended, so a `docker` the person put first on +their PATH stays first; both the link and the directory it resolves to, as `command_at` does. +*/ +pub fn path_with_docker() -> Option { + let binary = program(Engine::Docker)?; + let mut entries: Vec = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect()) + .unwrap_or_default(); + let resolved = std::fs::canonicalize(&binary).ok(); + for directory in [binary.parent(), resolved.as_deref().and_then(Path::parent)] + .into_iter() + .flatten() + { + if !entries.iter().any(|entry| entry == directory) { + entries.push(directory.to_path_buf()); + } + } + std::env::join_paths(entries).ok() +} + /// This process's PATH with `first` in front of it. /// /// In front, so the provider OpenBot placed is the one found; appended, a broken `docker-compose` @@ -430,11 +455,19 @@ fn where_installers_put(engine: Engine) -> Option { .flatten() .collect() } + // Docker Desktop per user, as OpenBot installs it, then per machine. #[cfg(target_os = "windows")] - Engine::Docker => std::env::var_os("ProgramFiles") - .map(|files| PathBuf::from(files).join("Docker\\Docker\\resources\\bin\\docker.exe")) - .into_iter() - .collect(), + Engine::Docker => [ + std::env::var_os("LOCALAPPDATA").map(|local| { + PathBuf::from(local).join("Programs\\DockerDesktop\\resources\\bin\\docker.exe") + }), + std::env::var_os("ProgramFiles").map(|files| { + PathBuf::from(files).join("Docker\\Docker\\resources\\bin\\docker.exe") + }), + ] + .into_iter() + .flatten() + .collect(), #[cfg(target_os = "macos")] Engine::Podman => [ "/opt/podman/bin/podman", diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 2900d3a579..ed65a5b3ad 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -1,5 +1,11 @@ //! Installing an engine, instead of telling somebody to go and get one. //! +//! **On macOS and Windows the engine is Docker Desktop** (see `docker_desktop`): started when it is +//! installed, installed when it is not. Podman is never installed there for somebody; it is for an +//! organization that cannot use Docker under its licence, and that is their decision. The Podman +//! installers below are kept for that explicit path and for Linux, where Podman comes from the +//! distribution's packages. +//! //! The person this app is for does not have a package manager, has never heard of Podman, and is //! not going to read a release page. Every sentence that begins "install ..." is a step where an //! install stops for good, so the engine is fetched and installed here. @@ -61,7 +67,7 @@ pub struct Download { /// The Intel Mac case is real and not hypothetical: Podman 6.1.1 publishes `arm64` only. Answering /// with the arm64 package there would install something that cannot run, so it says so instead. pub fn podman_download() -> Result { - let (file, sha256) = match (std::env::consts::OS, std::env::consts::ARCH) { + let (file, sha256) = match (std::env::consts::OS, crate::windows::machine_arch()) { ("windows", "x86_64") => ( "podman-installer-windows-amd64.msi", "91d0e8ea0846c0151d531c88c329bb2729387231e4d1e42306a8e3ae9d09fc8a", @@ -107,7 +113,7 @@ pub fn podman_download() -> Result { /// One static binary on every platform, which is the whole reason this can be placed beside the /// engine rather than installed into the system. pub fn compose_download() -> Result { - let (file, sha256) = match (std::env::consts::OS, std::env::consts::ARCH) { + let (file, sha256) = match (std::env::consts::OS, crate::windows::machine_arch()) { ("windows", "x86_64") => ( "docker-compose-windows-x86_64.exe", "a3c0c73033eaede90210345d0cc2233edf4fab8fe0282a91dad8fd8436809d2f", @@ -262,7 +268,7 @@ fn bun_download(os: &str, arch: &str) -> Result { } fn install_bun(cache: &Path) -> Result { - let download = bun_download(std::env::consts::OS, std::env::consts::ARCH)?; + let download = bun_download(std::env::consts::OS, crate::windows::machine_arch())?; let entry = format!( "{}/bun{}", download.file.trim_end_matches(".zip"), @@ -369,35 +375,104 @@ pub(crate) fn verify_bun(binary: &Path) -> Result<(), Problem> { /// somebody presses Start. Answers with the sentence for the step row, or a failure in both /// registers. pub fn install_engine(cache: &Path) -> Result { - install_engine_observed(cache, |_| {}) + install_engine_observed(cache, |_, _| {}, &mut |_| {}) } -/// Observe an actual Podman installer invocation, excluding existing engines and Compose repair. +/// Observe an actual engine install (Docker Desktop, or Podman on Linux), excluding engines that +/// were already here and Compose repair; `progress` is each line for the step row. pub fn install_engine_observed( cache: &Path, - installed: impl FnMut(bool), + installed: impl FnMut(Engine, bool), + progress: &mut dyn FnMut(crate::docker_desktop::Progress), ) -> Result { - install_engine_with(cache, installed, install_podman, place_compose) + install_engine_with( + cache, + Found::here(), + installed, + |cache| crate::docker_desktop::ensure(cache, progress), + install_podman, + place_compose, + ) +} + +/// What engines this machine has, read once so the choice below can be tested on any machine. +#[derive(Debug, Clone, Copy)] +struct Found { + /// Docker Desktop is how this platform gets Docker: macOS and Windows, not Linux. + docker_desktop_platform: bool, + docker_answers: bool, + docker_app_installed: bool, + docker_cli_installed: bool, + podman_installed: bool, +} + +impl Found { + fn here() -> Self { + Self { + docker_desktop_platform: cfg!(any(target_os = "macos", target_os = "windows")), + docker_answers: engine::Address::new(Engine::Docker, None).responds(), + docker_app_installed: crate::docker_desktop::installed_app().is_some(), + docker_cli_installed: engine::program(Engine::Docker).is_some(), + podman_installed: engine::program(Engine::Podman).is_some(), + } + } } fn install_engine_with( cache: &Path, - mut installed: impl FnMut(bool), + found: Found, + mut installed: impl FnMut(Engine, bool), + ensure_docker: impl FnOnce(&Path) -> Result, install_podman: impl FnOnce(&Path) -> Result<(), Problem>, place_compose: impl FnOnce(&Path) -> Result, ) -> Result { let into = crate::acquire::download_dir(cache); + let docker_app_installed = found.docker_app_installed; - // engine_ready can start an installed Podman machine, but cannot start Docker. A stopped - // Docker CLI must not skip installing the Podman that preparation will then try to run. - if engine::program(Engine::Podman).is_some() - || engine::Address::new(Engine::Docker, None).responds() - { + if found.docker_answers { + return place_compose(&into); + } + + if found.docker_desktop_platform { + /* + * DOCKER IS THE ENGINE, AND PODMAN IS NEVER CHOSEN FOR SOMEBODY. + * + * An installed Docker Desktop that is not running is started; a machine with no engine at + * all gets Docker Desktop. This used to install Podman in both cases, behind an + * administrator prompt, which put a second engine on a machine that already had Docker and + * gave local Intelligence an engine it refuses. Podman is for an organization that cannot + * use Docker under its licence: a Podman somebody already has is theirs and is used, and + * one is never installed in Docker's place. See `docker_desktop`. + */ + if docker_app_installed || !found.podman_installed { + // A `docker` with no Docker Desktop behind it is OrbStack, Colima or similar, stopped. + // Installing Docker Desktop over it is not the repair; starting it is, and only they + // can. + if !docker_app_installed && found.docker_cli_installed { + return Err(Problem::with( + "Docker is installed but not running. Start it, then try again.", + "a docker command is installed, Docker Desktop is not, and docker does not \ + answer", + )); + } + let result = ensure_docker(cache); + if !docker_app_installed { + installed(Engine::Docker, result.is_ok()); + } + let ensured = result?; + place_compose(&into)?; + return Ok(ensured.said); + } + return place_compose(&into); + } + + // Linux: the distribution's own Podman, through its package manager. See the module comment. + if found.podman_installed { return place_compose(&into); } let result = install_podman(&into); - installed(result.is_ok()); + installed(Engine::Podman, result.is_ok()); result?; // Installed is not found. The MSI extends the *user's* PATH and this process was started with @@ -665,65 +740,139 @@ mod tests { use super::*; use crate::test_support::temp_root; + fn found(docker_answers: bool, docker_app: bool, docker_cli: bool, podman: bool) -> Found { + Found { + docker_desktop_platform: true, + docker_answers, + docker_app_installed: docker_app, + docker_cli_installed: docker_cli, + podman_installed: podman, + } + } + + fn ensured(installed: bool) -> crate::docker_desktop::Ensured { + crate::docker_desktop::Ensured { + said: if installed { "installed" } else { "started" }.into(), + installed, + } + } + #[test] - #[cfg(windows)] - fn a_stopped_docker_does_not_skip_the_podman_needed_for_setup() { - if crate::test_support::isolated_process( - "install::tests::a_stopped_docker_does_not_skip_the_podman_needed_for_setup", - ) { - return; + fn a_stopped_docker_desktop_is_started_and_podman_is_never_installed_for_it() { + let root = temp_root("stopped-docker-desktop"); + let mut observed = Vec::new(); + // Installed and stopped, with or without a Podman beside it: Docker is started. + for podman in [false, true] { + let said = install_engine_with( + &root, + found(false, true, true, podman), + |engine, ok| observed.push((engine, ok)), + |_| Ok(ensured(false)), + |_| panic!("Podman is never installed in Docker's place"), + |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!(said, "started"); } - let root = temp_root("stopped-docker-install"); - let bin = root.join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - let source = root.join("engine.rs"); - std::fs::write( - &source, - r#"fn main() { - if std::env::var_os("OPENBOT_TEST_DOCKER_RUNNING").is_none() { - std::process::exit(1); - } - println!("1.44"); - }"#, + assert!(observed.is_empty(), "starting is not an install"); + } + + #[test] + fn a_machine_with_no_engine_gets_docker_desktop_not_podman() { + let root = temp_root("no-engine"); + let mut observed = Vec::new(); + let said = install_engine_with( + &root, + found(false, false, false, false), + |engine, ok| observed.push((engine, ok)), + |_| Ok(ensured(true)), + |_| panic!("Podman is never installed in Docker's place"), + |_| Ok("Compose placed".into()), ) .unwrap(); - crate::test_support::compile_fixture(&source, &bin.join("docker.exe")); - std::env::set_var("PATH", &bin); - std::env::set_var("LOCALAPPDATA", root.join("Local")); - std::env::set_var("ProgramFiles", root.join("Program Files")); - std::env::remove_var("OPENBOT_TEST_DOCKER_RUNNING"); - - let found = engine::detect(); - assert_eq!(found.engine, Some(Engine::Docker)); - assert!(!found.responding); - assert!(engine::program(Engine::Podman).is_none()); + assert_eq!(said, "installed"); + assert_eq!(observed, [(Engine::Docker, true)]); + // A failed Docker Desktop install is reported as one, and nothing else is tried. let mut observed = Vec::new(); - let result = install_engine_with( + let problem = install_engine_with( &root, - |success| observed.push(success), - |_| { - // Installation supplies the engine that engine_ready will create/start. - std::fs::copy(bin.join("docker.exe"), bin.join("podman.exe")).unwrap(); - Ok(()) - }, + found(false, false, false, false), + |engine, ok| observed.push((engine, ok)), + |_| Err(Problem::plain("no")), + |_| panic!("no Podman fallback"), + |_| panic!("no Compose without an engine"), + ) + .unwrap_err(); + assert_eq!(problem.said, "no"); + assert_eq!(observed, [(Engine::Docker, false)]); + } + + #[test] + fn a_podman_somebody_already_has_is_theirs_and_is_used() { + let root = temp_root("existing-podman"); + let said = install_engine_with( + &root, + found(false, false, false, true), + |_, _| panic!("nothing is installed"), + |_| panic!("Docker Desktop is not installed over somebody's own Podman"), + |_| panic!("no install"), |_| Ok("Compose placed".into()), - ); - result.expect("setup should install its missing Podman"); - assert_eq!(observed, [true], "the Podman installer must run"); - assert!(engine::program(Engine::Podman).is_some()); - - // A responding Docker still supplies the engine; never install a replacement. - std::fs::remove_file(bin.join("podman.exe")).unwrap(); - std::env::set_var("OPENBOT_TEST_DOCKER_RUNNING", "1"); - assert!(engine::detect().responding); - let result = install_engine_with( + ) + .unwrap(); + assert_eq!(said, "Compose placed"); + } + + #[test] + fn a_stopped_docker_without_docker_desktop_is_left_for_its_owner_to_start() { + let root = temp_root("stopped-orbstack"); + let problem = install_engine_with( &root, - |_| panic!("no Podman installation should be observed"), - |_| panic!("responding Docker must be reused"), + found(false, false, true, false), + |_, _| panic!("nothing is installed"), + |_| panic!("Docker Desktop is not installed over another Docker"), + |_| panic!("no Podman fallback"), + |_| panic!("no Compose without an engine"), + ) + .unwrap_err(); + assert!(problem + .said + .starts_with("Docker is installed but not running.")); + } + + #[test] + fn an_answering_docker_is_used_as_it_is() { + let root = temp_root("answering-docker"); + let said = install_engine_with( + &root, + found(true, false, true, false), + |_, _| panic!("nothing is installed"), + |_| panic!("an answering Docker is not started again"), + |_| panic!("no install"), + |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!(said, "Compose placed"); + } + + #[test] + fn linux_keeps_its_distribution_podman() { + let root = temp_root("linux-engine"); + let mut observed = Vec::new(); + let linux = Found { + docker_desktop_platform: false, + ..found(false, false, false, false) + }; + let _ = install_engine_with( + &root, + linux, + |engine, ok| observed.push((engine, ok)), + |_| panic!("Linux does not get Docker Desktop"), + |_| Ok(()), |_| Ok("Compose placed".into()), ); - assert_eq!(result.unwrap(), "Compose placed"); - std::fs::remove_dir_all(root).unwrap(); + // Whether it then finds a podman program depends on the machine running the test: hosted + // Ubuntu runners ship one. What matters is that the engine installed was Podman. + assert_eq!(observed, [(Engine::Podman, true)]); } /// Every platform this app runs on has a Compose build, or the stack cannot be raised there. diff --git a/desktop/src-tauri/src/intelligence_bridge.rs b/desktop/src-tauri/src/intelligence_bridge.rs new file mode 100644 index 0000000000..63ccc816c5 --- /dev/null +++ b/desktop/src-tauri/src/intelligence_bridge.rs @@ -0,0 +1,308 @@ +/*! +The OpenBot app's own page asking the shell to open the local Intelligence dashboard. + +The app is served from a loopback port and loaded in the main window, so to Tauri it is a remote +origin, and a remote origin reaches no command of this app (Tauri refuses every app command to one +that no capability names). This is the one exception, kept as small as it can be: + +- an inline plugin, `openbot-intelligence`, with two commands: `watch`, which hands over a channel + for what the sidebar row should show, and `open`, which starts the same opening as the menu; +- a capability added at runtime for exactly the owned app's origin, `http://127.0.0.1:`, on + the main window only, granting those two commands and nothing else. No `core:event`, so the page + hears none of the setup window's events; +- what the page is told is an `Offer`: a state, and for a failure a fixed category. No address, + no CLI text, no stack id. +*/ + +use serde::Serialize; +use std::sync::Mutex; +use tauri::ipc::Channel; +use tauri::Manager; + +use super::{DashboardFailure, IntelligenceOpening, Shell}; + +pub const PLUGIN: &str = "openbot-intelligence"; + +/// What the app's sidebar row shows. A failure is told once, as it happens; a page that starts +/// watching afterwards is told the state that followed it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "state", content = "reason", rename_all = "lowercase")] +pub enum Offer { + /// Not local mode, or no stack recorded: the row is hidden. + Unavailable, + Ready, + Opening, + Failed(DashboardFailure), +} + +impl Offer { + /// What the row shows once the opening has said `status`. + pub fn after(status: &IntelligenceOpening) -> Offer { + match status { + IntelligenceOpening::Opening => Offer::Opening, + IntelligenceOpening::Opened => Offer::Ready, + IntelligenceOpening::Failed(failure) => Offer::Failed(*failure), + } + } +} + +#[derive(Default)] +pub struct Bridge { + /// The newest page's channel. Only the main window can watch, and a page that loads again + /// watches again, so one is kept. + watcher: Mutex>>, + /// The app origins already granted, so a navigation back does not add the same capability. + granted: Mutex>, + /// Set once the app was built with this plugin's permissions (`enable`). Tauri panics, holding + /// its permission lock, on a capability that names permissions it never compiled in. + enabled: std::sync::atomic::AtomicBool, +} + +/// Say the running app carries this plugin's permissions, so `allow` may grant them. Called from +/// the real app's setup; a test app built from a mock context never is. +pub fn enable(app: &tauri::AppHandle) { + app.state::() + .intelligence_bridge + .enabled + .store(true, std::sync::atomic::Ordering::SeqCst); +} + +/// The row's state now, from the same state the menu item uses. +pub fn current(app: &tauri::AppHandle) -> Offer { + let shell = app.state::(); + if shell + .intelligence_opening + .load(std::sync::atomic::Ordering::SeqCst) + { + Offer::Opening + } else if shell.intelligence_menu.lock().unwrap().is_some() { + Offer::Ready + } else { + Offer::Unavailable + } +} + +/// Tell the watching page. True when the main window shows a granted app page now and the channel +/// took the message, so a failure has somewhere on screen to be read. +pub fn tell(app: &tauri::AppHandle, offer: Offer) -> bool { + let bridge = &app.state::().intelligence_bridge; + let mut watcher = bridge.watcher.lock().unwrap(); + let Some(channel) = watcher.as_ref() else { + return false; + }; + if channel.send(offer).is_err() { + *watcher = None; + return false; + } + drop(watcher); + let showing = app + .get_webview_window("main") + .and_then(|window| window.url().ok()) + .map(|url| url.origin().ascii_serialization()); + showing.is_some_and(|origin| bridge.granted.lock().unwrap().contains(&origin)) +} + +/// The exact origin of an owned app address, or None for anything but `http` on a loopback IP. +pub fn app_origin(address: &str) -> Option { + let url = tauri::Url::parse(address).ok()?; + // An IP literal only: a name could resolve anywhere. + let loopback = url + .host_str()? + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + (url.scheme() == "http" && loopback && url.port().is_some()) + .then(|| url.origin().ascii_serialization()) +} + +/// Let the owned app at `address` use this plugin from the main window, once per origin. +/// +/// Called with the address the shell verified it owns, before navigating there. Anything that is +/// not a loopback `http` address with a port is refused. +pub fn allow(app: &tauri::AppHandle, address: &str) { + let Some(origin) = app_origin(address) else { + return; + }; + let shell = app.state::(); + if !shell + .intelligence_bridge + .enabled + .load(std::sync::atomic::Ordering::SeqCst) + { + return; + } + let mut granted = shell.intelligence_bridge.granted.lock().unwrap(); + if granted.contains(&origin) { + return; + } + let capability = tauri::ipc::CapabilityBuilder::new(format!("openbot-app-{}", granted.len())) + .remote(format!("{origin}/*")) + .local(false) + .window("main") + .permission(format!("{PLUGIN}:allow-watch")) + .permission(format!("{PLUGIN}:allow-open")); + match app.add_capability(capability) { + Ok(()) => granted.push(origin), + Err(error) => eprintln!("[intelligence] could not allow the app page: {error}"), + } +} + +/// Keep `on_offer` as the page's channel and tell it the state now. +#[tauri::command] +fn watch(app: tauri::AppHandle, on_offer: Channel) { + let now = current(&app); + if on_offer.send(now).is_ok() { + *app.state::() + .intelligence_bridge + .watcher + .lock() + .unwrap() = Some(on_offer); + } +} + +/// Start opening the dashboard, exactly as the menu does. How it goes arrives through `watch`. +#[tauri::command] +fn open(app: tauri::AppHandle) { + super::start_intelligence_opening(app); +} + +pub fn plugin() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new(PLUGIN) + .invoke_handler(tauri::generate_handler![watch, open]) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_a_loopback_ip_with_a_port_is_an_app_origin() { + assert_eq!( + app_origin("http://127.0.0.1:3010").as_deref(), + Some("http://127.0.0.1:3010") + ); + assert_eq!( + app_origin("http://[::1]:3010/channel/x").as_deref(), + Some("http://[::1]:3010") + ); + for refused in [ + "http://localhost:3010", + "https://127.0.0.1:3010", + "http://127.0.0.1", + "http://192.168.1.4:3010", + "http://openbot.example.com:3010", + "tauri://localhost", + "not a url", + ] { + assert_eq!(app_origin(refused), None, "{refused}"); + } + } + + fn app() -> ( + tauri::App, + tauri::WebviewWindow, + ) { + let app = tauri::test::mock_builder() + .plugin(plugin()) + .manage(Shell::default()) + .invoke_handler(tauri::generate_handler![crate::default_root]) + .build(tauri::generate_context!(test = true)) + .unwrap(); + enable(app.handle()); + let window = app.get_webview_window("main").unwrap_or_else(|| { + tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap() + }); + (app, window) + } + + fn ask( + window: &tauri::WebviewWindow, + from: &str, + cmd: &str, + ) -> Result { + tauri::test::get_ipc_response( + window, + tauri::webview::InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: from.parse().unwrap(), + body: tauri::ipc::InvokeBody::Json(serde_json::json!({})), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.into(), + }, + ) + .map(|body| body.deserialize().unwrap()) + } + + const OPEN: &str = "plugin:openbot-intelligence|open"; + + #[test] + fn the_owned_app_origin_reaches_these_commands_and_nothing_else() { + let (app, window) = app(); + let page = "http://127.0.0.1:3010/channel/existing"; + // Before the shell has verified and allowed the app, its page reaches nothing. + assert!(ask(&window, page, OPEN).is_err()); + + allow(app.handle(), "http://127.0.0.1:3010"); + assert_eq!(ask(&window, page, OPEN), Ok(serde_json::Value::Null)); + // Not the app's other commands, and not another port on the same address. + assert!(ask(&window, page, "default_root").is_err()); + assert!( + ask(&window, "tauri://localhost", "default_root").is_ok(), + "the setup page still reaches it, so the refusal above is the origin's" + ); + assert!(ask(&window, "http://127.0.0.1:3011/", OPEN).is_err()); + assert!(ask(&window, "http://localhost:3010/", OPEN).is_err()); + // Allowing again adds nothing new and changes nothing. + allow(app.handle(), "http://127.0.0.1:3010"); + assert_eq!( + app.state::() + .intelligence_bridge + .granted + .lock() + .unwrap() + .len(), + 1 + ); + // A refused address is never allowed. + allow(app.handle(), "http://localhost:3011"); + assert!(ask(&window, "http://localhost:3011/", OPEN).is_err()); + } + + #[test] + fn an_app_built_without_the_plugins_permissions_grants_nothing() { + let app = tauri::test::mock_builder() + .manage(Shell::default()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + // No panic, and nothing recorded as granted. + allow(app.handle(), "http://127.0.0.1:3010"); + assert!(app + .state::() + .intelligence_bridge + .granted + .lock() + .unwrap() + .is_empty()); + } + + #[test] + fn the_row_follows_the_menus_state() { + let (app, _window) = app(); + assert_eq!(current(app.handle()), Offer::Unavailable); + let shell = app.state::(); + *shell.intelligence_menu.lock().unwrap() = Some("/tmp/openbot".into()); + assert_eq!(current(app.handle()), Offer::Ready); + shell + .intelligence_opening + .store(true, std::sync::atomic::Ordering::SeqCst); + assert_eq!(current(app.handle()), Offer::Opening); + // With no page watching, nothing is told and a failure needs another place. + assert!(!tell(app.handle(), Offer::Failed(DashboardFailure::Busy))); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1a0a49877b..1024b315f1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,12 +3,15 @@ pub mod acquire; pub mod ask; pub mod deployment; pub mod deployment_release; +pub mod docker_desktop; pub mod engine; pub mod env; pub mod harness; pub mod host_access; pub mod install; pub mod intelligence; +pub mod local_eval; +pub mod local_eval_state; pub mod organization_auth; pub mod plan; pub mod preparation; diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs new file mode 100644 index 0000000000..dd8245991d --- /dev/null +++ b/desktop/src-tauri/src/local_eval.rs @@ -0,0 +1,3805 @@ +//! The Intelligence CLI's local evaluation commands, run as a bounded child process. +//! +//! THE CLI OWNS INTELLIGENCE. Its topology, sign-in, licence and readiness belong to it, so this +//! module never reads its credential files or probes its containers to fill a gap. What it does +//! own is the process boundary: which program runs, with which arguments, for how long, and which +//! of its words are allowed into the app. +//! +//! Three rules hold that boundary: +//! +//! - **Nothing unverified runs.** The CLI is pinned by SHA-256, like every other program this app +//! fetches (`install.rs`). There is no fallback to `PATH`, `npx` or a global install. +//! - **Arguments are fixed.** Only `status`, `start`, `stop` and `login`, a validated stack id and +//! `--json`, plus the constant `list`, `setup` and `connect` lists, as separate arguments with no shell. The CLI's `resumeCommand` strings are text for a +//! terminal user; they are dropped here and never executed. +//! - **Only allowlisted facts leave.** Raw stdout, stderr, error messages and recovery text never +//! cross IPC or reach a log. Failures are static categories with no payload. +//! +//! A command that *completed* is not Intelligence that is ready or licensed. Those two facts come +//! only from the additive v1 `readiness` and `license` fields of `local status`, read by exact +//! value. A CLI that does not publish them, or publishes something else, reads as `unknown`, and +//! unknown keeps chat closed. +//! +//! Source: Intelligence `apps/cli/src/commands/local.ts`, `services/local-manager.ts` and +//! `services/local-errors.ts` at 3935afbfe3cda88dfeabfd0250db08ff0ad51000; `local connect` and +//! `local login` (`commands/local-login.ts`) at 780313e4edf6fc5f038c3cf5e4612ac668446c45. + +use std::io::{Read, Seek}; +use std::path::PathBuf; +use std::process::{Child, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; + +/// Larger output than this is not a status report, and is not read further. +pub const MAX_OUTPUT: usize = 1024 * 1024; + +/// The CLI commands this app may run. Anything else is not representable. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Action { + Status, + Start, + Stop, +} + +impl Action { + fn as_str(self) -> &'static str { + match self { + Action::Status => "status", + Action::Start => "start", + Action::Stop => "stop", + } + } +} + +/// A stack id the CLI could have made: one leading letter or digit, then letters, digits, `_` +/// and `-`, at most 128 characters. Nothing that could read as a flag, a path or a second word. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StackId(String); + +impl StackId { + pub fn parse(value: &str) -> Result { + let bytes = value.as_bytes(); + let valid = !bytes.is_empty() + && bytes.len() <= 128 + && bytes[0].is_ascii_alphanumeric() + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'-'); + if valid { + Ok(Self(value.to_owned())) + } else { + Err(Failure::InvalidStack) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Why a CLI call produced no report. Static on purpose: none of these carries CLI output. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Failure { + /// This build has no pinned CLI to run. + Unavailable, + /// The CLI on disk is not the file that was pinned. + Unverified, + InvalidStack, + Spawn, + Timeout, + Cancelled, + OutputLimit, + InvalidJson, + UnsupportedVersion, + UnexpectedCommand, + InvalidStatus, + MissingStack, + UnsafeEndpoint, + /// The exit code and the reported status disagree, so neither is believed. + ContractMismatch, + /// An exit code `local` does not use: the CLI failed before it could report. + UnexpectedExit, + /// An accepted agreement version that is not one `terms` could have reported. + InvalidAcceptance, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Outcome { + Completed, + Blocked, + Failed, +} + +/// Whether Intelligence answers, by the CLI's own end-to-end check. Running pods are not this. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Readiness { + Ready, + Starting, + Stopped, + Degraded, + /// Not reported, not understood, or reported as unknown. Never treated as ready. + Unknown, +} + +/// The evaluation licence, as the CLI verified it. Never the licence itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Licence { + Valid, + Expired, + Missing, + Invalid, + OrganizationMismatch, + /// Not reported or not understood. Never treated as valid. + Unknown, +} + +/// The CLI's own reason for stopping, from its closed set. Only the reason survives; its summary, +/// instructions and resume command do not. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Reason { + AuthenticationRequired, + EulaAcceptanceRequired, + EulaUnavailable, + EulaDeclined, + ReleaseUnavailable, + FeatureDisabled, + UnsupportedPlatform, + UnsupportedEngine, + PortUnavailable, + DockerUnavailable, + UpdateRecoveryRequired, + IncompatibleRelease, + LocalCommandBusy, + LocalCommandFailed, + /// `connect` asked for its approval flag. Desktop always sends it, so this is a contract gap. + ConfirmationRequired, + /// `login`: the dashboard did not answer within the CLI's own wait. + DashboardUnavailable, + /// `login`: the CLI could not open the browser. Its fallback is a code approved in a terminal. + BrowserOpenFailed, + /// A reason this build does not know. Treated as blocking, never as success. + Unrecognized, +} + +impl Reason { + fn from_wire(reason: &str) -> Self { + match reason { + "authentication_required" => Reason::AuthenticationRequired, + "eula_acceptance_required" => Reason::EulaAcceptanceRequired, + "eula_unavailable" => Reason::EulaUnavailable, + "eula_declined" => Reason::EulaDeclined, + "release_unavailable" => Reason::ReleaseUnavailable, + "feature_disabled" => Reason::FeatureDisabled, + "unsupported_platform" => Reason::UnsupportedPlatform, + "unsupported_engine" => Reason::UnsupportedEngine, + "port_unavailable" => Reason::PortUnavailable, + "docker_unavailable" => Reason::DockerUnavailable, + "update_recovery_required" => Reason::UpdateRecoveryRequired, + "incompatible_release" => Reason::IncompatibleRelease, + "local_command_busy" => Reason::LocalCommandBusy, + "local_command_failed" => Reason::LocalCommandFailed, + "confirmation_required" => Reason::ConfirmationRequired, + "dashboard_unavailable" => Reason::DashboardUnavailable, + "browser_open_failed" => Reason::BrowserOpenFailed, + _ => Reason::Unrecognized, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Report { + pub outcome: Outcome, + pub stack: Option, + pub reason: Option, + pub readiness: Readiness, + pub licence: Licence, + /// When the licence expires, as a strict ISO-8601 instant, so the window can warn ahead of it. + pub licence_expires_at: Option, +} + +/// What `local connect` answered. The settings it wrote stay in the installation's `.env`; their +/// names and values are not part of this. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Connection { + pub outcome: Outcome, + pub stack: Option, + pub reason: Option, + /// The CLI found settings it wrote earlier changed since, and kept them. + pub conflicted: bool, +} + +/// What `local login` did. The single-use sign-in link it opens is a bearer credential, and the CLI +/// never prints it; of the rest, only the outcome and the reason are kept. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SignIn { + pub outcome: Outcome, + pub reason: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Stack { + pub id: String, + pub installed_release: String, + pub step: String, + pub urls: Urls, + pub has_failed_step: bool, + pub update_in_progress: bool, + pub observed: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Urls { + pub api: String, + pub dashboard: String, + pub gateway: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Observed { + pub running: bool, + pub services: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Service { + pub name: String, + pub phase: String, + pub ready: bool, +} + +/// The fixed argument list for one call. Built only from validated types. +pub fn arguments(action: Action, stack: &StackId) -> [&str; 5] { + [ + "local", + action.as_str(), + "--stack", + stack.as_str(), + "--json", + ] +} + +/// `local list` takes no stack: it is how a stack id is found in the first place. +pub const LIST_ARGUMENTS: [&str; 3] = ["local", "list", "--json"]; + +/** +`local setup` creates the stack for the folder it runs in, or resumes one it left unfinished. + +The model is deferred: OpenBot's own setup chooses the model, and asking twice would be two answers +to keep in step. + +`--accept-eula-version` is passed only with a version the person accepted in the window, after +reading the agreement `terms` reported (`EulaVersion`). Their click in OpenBot is their acceptance: +a product decision of 2026-10-22, recorded in the local evaluation policy. The CLI still refuses +an acceptance from a coding agent, and OpenBot's Bots never reach the CLI. +*/ +pub const SETUP_ARGUMENTS: [&str; 4] = ["local", "setup", "--defer-model", "--json"]; + +/// `SETUP_ARGUMENTS`, with the version the person accepted when they accepted one. +pub fn setup_arguments(accepted: Option<&EulaVersion>) -> Vec<&str> { + match accepted { + None => SETUP_ARGUMENTS.to_vec(), + Some(version) => vec![ + "local", + "setup", + "--defer-model", + "--accept-eula-version", + version.as_str(), + "--json", + ], + } +} + +/// The evaluation agreement version a person accepted, as `terms` reported it: an identifier such +/// as `developer-edition-2026-10-01-web`, never prose and never anything that reads as a flag. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EulaVersion(String); + +impl EulaVersion { + pub fn parse(value: &str) -> Result { + let version = + text(Some(&Value::String(value.to_owned()))).map_err(|_| Failure::InvalidAcceptance)?; + if version.starts_with('-') { + return Err(Failure::InvalidAcceptance); + } + Ok(Self(version)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// `copilotkit login --json`: the CopilotKit account sign-in local evaluation needs for its +/// licence. A device authorization grant; the CLI opens the approval page itself. +pub const ACCOUNT_SIGN_IN_ARGUMENTS: [&str; 2] = ["login", "--json"]; + +/// The evaluation agreement, read only: `onboard local-terms --json`. +pub const TERMS_ARGUMENTS: [&str; 3] = ["onboard", "local-terms", "--json"]; + +/** +`local connect` connects the folder it runs in, the installation `local setup` was run in, to that +folder's stack. + +Approved in the arguments because the person approved it in the window: the CLI's own +confirmation step only prints which settings it will write. It writes `INTELLIGENCE_API_URL`, +`INTELLIGENCE_GATEWAY_WS_URL`, `CPK_INTELLIGENCE_API_KEY`, `SL_ENABLED` and +`.copilotkit/project.json` into the folder. Interim, until the CLI issues OpenBot a credential of +its own (contract request 8). +*/ +pub const CONNECT_ARGUMENTS: [&str; 4] = ["local", "connect", "--approve-connection", "--json"]; + +/** +`local login` signs the browser in to the stack's dashboard with no code to approve. + +The CLI waits for the dashboard, mints a single-use link and opens the browser itself, so the app +opens nothing of its own. The browser lands on the dashboard's `/login?autoSignIn=1`; the command +takes no return path. +*/ +pub fn login_arguments(stack: &StackId) -> [&str; 5] { + ["local", "login", "--stack", stack.as_str(), "--json"] +} + +/** +Turn what the CLI printed, and how it exited, into the facts this app keeps. + +`local` exits 0 with `completed`, 2 with `blocked` and 1 with `failed`. Any other code means it +failed before it could report, so its output is not read. A status that disagrees with its exit +code is a contract this build does not understand, and neither half is believed. +*/ +pub fn normalize(action: Action, exit_code: i32, stdout: &[u8]) -> Result { + report(&format!("local {}", action.as_str()), exit_code, stdout) +} + +/// `local setup`, held to the same rules as `status`, `start` and `stop`. +pub fn normalize_setup(exit_code: i32, stdout: &[u8]) -> Result { + report("local setup", exit_code, stdout) +} + +fn report(command: &str, exit_code: i32, stdout: &[u8]) -> Result { + let wire = parse_envelope(command, exit_code, stdout)?; + let (outcome, wire) = (wire.0, &wire.1); + // Only a completed report states how things are now; a refusal's leftovers are not believed. + let (readiness, (licence, licence_expires_at)) = if outcome == Outcome::Completed { + ( + readiness(wire.get("readiness")), + licence(wire.get("license")), + ) + } else { + (Readiness::Unknown, (Licence::Unknown, None)) + }; + Ok(Report { + outcome, + stack: reported_stack(outcome, wire)?, + reason: reason(outcome, wire)?, + readiness, + licence, + licence_expires_at, + }) +} + +/// The stack a report names. A completed command always names one. +fn reported_stack(outcome: Outcome, wire: &Map) -> Result, Failure> { + match wire.get("stack") { + None | Some(Value::Null) if outcome == Outcome::Completed => Err(Failure::MissingStack), + None | Some(Value::Null) => Ok(None), + Some(saved) => Ok(Some(stack(saved)?)), + } +} + +/// `local connect`, held to the same rules. Its `conflicts` are setting names; only whether there +/// were any is kept. +pub fn normalize_connect(exit_code: i32, stdout: &[u8]) -> Result { + let wire = parse_envelope("local connect", exit_code, stdout)?; + let (outcome, wire) = (wire.0, &wire.1); + let conflicted = match wire.get("conflicts") { + None | Some(Value::Null) => false, + Some(Value::Array(conflicts)) => !conflicts.is_empty(), + Some(_) => return Err(Failure::InvalidStatus), + }; + Ok(Connection { + outcome, + stack: reported_stack(outcome, wire)?, + reason: reason(outcome, wire)?, + conflicted, + }) +} + +/** +`local login`, held to the same rules. A completed login counts only when it says it opened the +browser for the stack it was asked about; its summary, dashboard address and terminal commands are +not kept. +*/ +pub fn normalize_login(stack: &StackId, exit_code: i32, stdout: &[u8]) -> Result { + let wire = parse_envelope("local login", exit_code, stdout)?; + let (outcome, wire) = (wire.0, &wire.1); + if outcome == Outcome::Completed + && (wire.get("browserOpened") != Some(&Value::Bool(true)) + || wire.get("stackId").and_then(Value::as_str) != Some(stack.as_str())) + { + return Err(Failure::InvalidStatus); + } + Ok(SignIn { + outcome, + reason: reason(outcome, wire)?, + }) +} + +/// Whether the CopilotKit account sign-in finished. The approval page address and its code stay +/// in the CLI and the browser; only the outcome is kept. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountSignIn { + pub signed_in: bool, +} + +/** +`login --json`'s JSON Lines, ending `completed` or `failed`. + +The last `completed` or `failed` line decides, and it has to agree with the exit code. Lines before +it (`action_required`, with the approval page and code) are not kept. +*/ +pub fn normalize_account_sign_in(exit_code: i32, stdout: &[u8]) -> Result { + if stdout.len() > MAX_OUTPUT { + return Err(Failure::OutputLimit); + } + let text = std::str::from_utf8(stdout).map_err(|_| Failure::InvalidJson)?; + let last = text + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).map_err(|_| Failure::InvalidJson)) + .collect::, _>>()? + .into_iter() + .filter_map(|event| event.get("type").and_then(Value::as_str).map(str::to_owned)) + .rfind(|kind| kind == "completed" || kind == "failed") + .ok_or(Failure::InvalidStatus)?; + let signed_in = last == "completed"; + if signed_in != (exit_code == 0) { + return Err(Failure::ContractMismatch); + } + Ok(AccountSignIn { signed_in }) +} + +/// The evaluation agreement the licence needs, as the window shows it. No CLI prose is kept. +/// +/// The window reads the CLI's own status words (`signed-out`) and camelCase fields +/// (`organizationName`). A variant named in camelCase reaches it as `signedOut`, which it does not +/// know, and an unknown status is taken as nothing to accept. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde( + rename_all = "kebab-case", + rename_all_fields = "camelCase", + tag = "status" +)] +pub enum Terms { + Required { + accepted: bool, + version: String, + url: String, + organization_name: String, + }, + NotRequired, + Unavailable, + SignedOut, +} + +/// `onboard local-terms --json`: one object, exit 0. The address must be https; the organization +/// name is shown as text, so it is bounded and has no control characters. +pub fn normalize_terms(exit_code: i32, stdout: &[u8]) -> Result { + if exit_code != 0 { + return Err(Failure::UnexpectedExit); + } + if stdout.len() > MAX_OUTPUT { + return Err(Failure::OutputLimit); + } + let text_out = std::str::from_utf8(stdout).map_err(|_| Failure::InvalidJson)?; + let wire: Value = serde_json::from_str(text_out.trim()).map_err(|_| Failure::InvalidJson)?; + let wire = object(&wire)?; + if wire.get("command").and_then(Value::as_str) != Some("onboard local-terms") { + return Err(Failure::UnexpectedCommand); + } + match wire.get("status").and_then(Value::as_str) { + Some("not-required") => Ok(Terms::NotRequired), + Some("unavailable") => Ok(Terms::Unavailable), + Some("signed-out") => Ok(Terms::SignedOut), + Some("required") => { + let url = wire + .get("url") + .and_then(Value::as_str) + .filter(|url| { + url.len() <= 2048 + && reqwest::Url::parse(url).is_ok_and(|parsed| parsed.scheme() == "https") + }) + .ok_or(Failure::InvalidStatus)?; + let organization_name = wire + .get("organizationName") + .and_then(Value::as_str) + .filter(|name| { + !name.is_empty() + && name.chars().count() <= 200 + && !name.chars().any(char::is_control) + }) + .ok_or(Failure::InvalidStatus)?; + Ok(Terms::Required { + accepted: wire + .get("accepted") + .and_then(Value::as_bool) + .ok_or(Failure::InvalidStatus)?, + version: EulaVersion::parse( + wire.get("version").and_then(Value::as_str).unwrap_or(""), + ) + .map_err(|_| Failure::InvalidStatus)? + .0, + url: url.to_owned(), + organization_name: organization_name.to_owned(), + }) + } + _ => Err(Failure::InvalidStatus), + } +} + +/** +`readiness.state`, believed only as one of its exact wire values and with a strict `checkedAt`. + +Anything else, including a missing or malformed field, is `Unknown`. That is how a CLI that +predates the field reads, and it is not an error: the rest of the report still stands. The check +names in `checks` are not kept. +*/ +fn readiness(value: Option<&Value>) -> Readiness { + let Some(Value::Object(readiness)) = value else { + return Readiness::Unknown; + }; + if !readiness + .get("checkedAt") + .and_then(Value::as_str) + .is_some_and(instant) + { + return Readiness::Unknown; + } + match readiness.get("state").and_then(Value::as_str) { + Some("ready") => Readiness::Ready, + Some("starting") => Readiness::Starting, + Some("stopped") => Readiness::Stopped, + Some("degraded") => Readiness::Degraded, + _ => Readiness::Unknown, + } +} + +/** +`license.state` and `license.expiresAt`, by the same rule. `expiresAt` must be present, as `null` +or a strict instant; anything else makes the whole licence `Unknown`, so a valid state never +travels with an expiry nobody could read. The organization and renewal fields are not kept. +*/ +fn licence(value: Option<&Value>) -> (Licence, Option) { + const UNKNOWN: (Licence, Option) = (Licence::Unknown, None); + let Some(Value::Object(licence)) = value else { + return UNKNOWN; + }; + let expires_at = match licence.get("expiresAt") { + Some(Value::Null) => None, + Some(Value::String(at)) if instant(at) => Some(at.clone()), + _ => return UNKNOWN, + }; + let state = match licence.get("state").and_then(Value::as_str) { + Some("valid") => Licence::Valid, + Some("expired") => Licence::Expired, + Some("missing") => Licence::Missing, + Some("invalid") => Licence::Invalid, + Some("organization_mismatch") => Licence::OrganizationMismatch, + _ => return UNKNOWN, + }; + (state, expires_at) +} + +/** +An ISO-8601 instant in the one form a JavaScript CLI prints: `YYYY-MM-DDTHH:MM:SS`, an optional +fraction of one to nine digits, then `Z` or `±HH:MM`. Every field is range-checked against the +calendar. Nothing else is copied, so no prose or token can ride through as a date. +*/ +fn instant(text: &str) -> bool { + let bytes = text.as_bytes(); + let digits = |from: usize, to: usize| -> Option { + let part = bytes.get(from..to)?; + part.iter().all(u8::is_ascii_digit).then(|| { + part.iter() + .fold(0, |sum, byte| sum * 10 + u32::from(byte - b'0')) + }) + }; + let at = |index: usize, byte: u8| bytes.get(index) == Some(&byte); + let (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) = ( + digits(0, 4), + digits(5, 7), + digits(8, 10), + digits(11, 13), + digits(14, 16), + digits(17, 19), + ) else { + return false; + }; + if !(at(4, b'-') && at(7, b'-') && at(10, b'T') && at(13, b':') && at(16, b':')) { + return false; + } + let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let days = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return false, + }; + if !((1..=days).contains(&day) && hour < 24 && minute < 60 && second < 60) { + return false; + } + let mut rest = 19; + if at(rest, b'.') { + let fraction = bytes[rest + 1..] + .iter() + .take_while(|byte| byte.is_ascii_digit()) + .count(); + if !(1..=9).contains(&fraction) { + return false; + } + rest += 1 + fraction; + } + match &bytes[rest..] { + b"Z" => true, + [b'+' | b'-', ..] if bytes.len() == rest + 6 && at(rest + 3, b':') => { + digits(rest + 1, rest + 3).is_some_and(|hours| hours < 24) + && digits(rest + 4, rest + 6).is_some_and(|minutes| minutes < 60) + } + _ => false, + } +} + +/// The stacks the CLI has recorded on this machine, from `local list`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Listing { + pub outcome: Outcome, + pub stacks: Vec, + pub reason: Option, +} + +/// More recorded stacks than this is not a listing this app can present. +const MAX_STACKS: usize = 64; + +/// `local list`: every recorded stack, each held to the same rules as a status report. One stack +/// that fails them fails the listing, rather than leaving a partial one. The app folders each +/// stack is connected to are paths on this machine, and are not kept. +pub fn normalize_list(exit_code: i32, stdout: &[u8]) -> Result { + let wire = parse_envelope("local list", exit_code, stdout)?; + let (outcome, wire) = (wire.0, &wire.1); + let stacks = match (outcome, wire.get("stacks")) { + (Outcome::Completed, Some(Value::Array(stacks))) if stacks.len() <= MAX_STACKS => { + stacks.iter().map(stack).collect::>()? + } + (Outcome::Completed, _) => return Err(Failure::InvalidStatus), + _ => Vec::new(), + }; + Ok(Listing { + outcome, + stacks, + reason: reason(outcome, wire)?, + }) +} + +/// Only the CLI's reason for stopping, never its summary, instructions or resume command. +fn reason(outcome: Outcome, wire: &Map) -> Result, Failure> { + match (outcome, wire.get("nextAction")) { + (Outcome::Completed, _) | (_, None | Some(Value::Null)) => Ok(None), + (_, Some(next)) => match object(next)?.get("reason") { + Some(Value::String(reason)) => Ok(Some(Reason::from_wire(reason))), + None | Some(Value::Null) => Ok(None), + Some(_) => Err(Failure::InvalidStatus), + }, + } +} + +/// The version-1 envelope every `local` command shares: version, command, and a status that +/// agrees with the exit code. +fn parse_envelope( + command: &str, + exit_code: i32, + stdout: &[u8], +) -> Result<(Outcome, Map), Failure> { + let expected = match exit_code { + 0 => Outcome::Completed, + 1 => Outcome::Failed, + 2 => Outcome::Blocked, + _ => return Err(Failure::UnexpectedExit), + }; + if stdout.len() > MAX_OUTPUT { + return Err(Failure::OutputLimit); + } + let text = std::str::from_utf8(stdout).map_err(|_| Failure::InvalidJson)?; + let wire: Value = serde_json::from_str(text).map_err(|_| Failure::InvalidJson)?; + let Value::Object(wire) = wire else { + return Err(Failure::InvalidStatus); + }; + if wire.get("version").and_then(Value::as_u64) != Some(1) { + return Err(Failure::UnsupportedVersion); + } + if wire.get("command").and_then(Value::as_str) != Some(command) { + return Err(Failure::UnexpectedCommand); + } + let outcome = match wire.get("status").and_then(Value::as_str) { + Some("completed") => Outcome::Completed, + Some("blocked") => Outcome::Blocked, + Some("failed") => Outcome::Failed, + _ => return Err(Failure::InvalidStatus), + }; + if outcome != expected { + return Err(Failure::ContractMismatch); + } + Ok((outcome, wire)) +} + +fn object(value: &Value) -> Result<&Map, Failure> { + value.as_object().ok_or(Failure::InvalidStatus) +} + +/// A release, step, service name or phase: an identifier, never prose. Anything longer or +/// with other characters is refused, so a message or a token cannot ride through in one. +fn text(value: Option<&Value>) -> Result { + match value { + Some(Value::String(text)) + if !text.is_empty() + && text.len() <= 128 + && text.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'+') + }) => + { + Ok(text.clone()) + } + _ => Err(Failure::InvalidStatus), + } +} + +/// More services than any release runs is not an observation. +const MAX_SERVICES: usize = 256; + +fn stack(value: &Value) -> Result { + let saved = object(value)?; + let id = match saved.get("id") { + Some(Value::String(id)) => StackId::parse(id)?, + _ => return Err(Failure::InvalidStack), + }; + let urls = object(saved.get("urls").unwrap_or(&Value::Null))?; + let has_failed_step = match saved.get("failedStep") { + None | Some(Value::Null) => false, + Some(Value::String(step)) => !step.is_empty(), + Some(_) => return Err(Failure::InvalidStatus), + }; + let update_in_progress = match saved.get("update") { + None | Some(Value::Null) => false, + Some(update) => { + object(update)?; + true + } + }; + Ok(Stack { + id: id.0, + installed_release: text(saved.get("installedRelease"))?, + step: text(saved.get("step"))?, + urls: Urls { + api: endpoint(urls.get("api"), Endpoint::Api)?, + dashboard: endpoint(urls.get("dashboard"), Endpoint::Dashboard)?, + gateway: endpoint(urls.get("gateway"), Endpoint::Gateway)?, + }, + has_failed_step, + update_in_progress, + observed: observation(saved.get("observed"))?, + }) +} + +fn observation(value: Option<&Value>) -> Result, Failure> { + let observed = match value { + None | Some(Value::Null) => return Ok(None), + Some(observed) => object(observed)?, + }; + let (Some(Value::Bool(running)), Some(Value::Array(services))) = + (observed.get("running"), observed.get("services")) + else { + return Err(Failure::InvalidStatus); + }; + if services.len() > MAX_SERVICES { + return Err(Failure::InvalidStatus); + } + let services = services + .iter() + .map(|service| { + let service = object(service)?; + let Some(Value::Bool(ready)) = service.get("ready") else { + return Err(Failure::InvalidStatus); + }; + Ok(Service { + name: text(service.get("name"))?, + phase: text(service.get("phase"))?, + ready: *ready, + }) + }) + .collect::>()?; + Ok(Some(Observed { + running: *running, + services, + })) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Endpoint { + Api, + Dashboard, + Gateway, +} + +/** +A loopback endpoint in exactly the form the CLI writes: `scheme://host:port`, optionally `/`. + +Stricter than a URL parser on purpose. No userinfo, path, query or fragment; lowercase only; the +host is `localhost`, a dotted-decimal `127.x.y.z`, `[::1]`, or for the dashboard alone one DNS +label under `.localhost`, which browsers resolve to loopback (RFC 6761). `localhost.evil.test` +and `a.b.localhost` are refused. +*/ +fn endpoint(value: Option<&Value>, kind: Endpoint) -> Result { + let Some(Value::String(url)) = value else { + return Err(Failure::UnsafeEndpoint); + }; + let scheme = if kind == Endpoint::Gateway { + "ws://" + } else { + "http://" + }; + let rest = url.strip_prefix(scheme).ok_or(Failure::UnsafeEndpoint)?; + let rest = rest.strip_suffix('/').unwrap_or(rest); + let (host, port) = rest.rsplit_once(':').ok_or(Failure::UnsafeEndpoint)?; + let port_valid = !port.is_empty() + && port.len() <= 5 + && port.bytes().all(|byte| byte.is_ascii_digit()) + && port + .parse::() + .is_ok_and(|port| (1..=65535).contains(&port)); + let host_valid = host == "localhost" + || host == "[::1]" + || loopback_ipv4(host) + || (kind == Endpoint::Dashboard && dashboard_host(host)); + if port_valid && host_valid { + Ok(url.clone()) + } else { + Err(Failure::UnsafeEndpoint) + } +} + +fn loopback_ipv4(host: &str) -> bool { + let octets: Vec<&str> = host.split('.').collect(); + octets.len() == 4 + && octets[0] == "127" + && octets.iter().all(|octet| { + !octet.is_empty() + && octet.len() <= 3 + && octet.bytes().all(|byte| byte.is_ascii_digit()) + && (octet.len() == 1 || !octet.starts_with('0')) + && octet.parse::().is_ok_and(|value| value <= 255) + }) +} + +/// The CLI's own rule: `^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.localhost$`. +fn dashboard_host(host: &str) -> bool { + let Some(label) = host.strip_suffix(".localhost") else { + return false; + }; + let bytes = label.as_bytes(); + let allowed = |byte: &u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); + !bytes.is_empty() + && bytes.len() <= 63 + && allowed(&bytes[0]) + && allowed(&bytes[bytes.len() - 1]) + && bytes.iter().all(|byte| allowed(byte) || *byte == b'-') +} + +/// A file and the digest it has to have. +#[derive(Clone, Debug)] +pub struct Pin { + pub path: PathBuf, + pub sha256: String, +} + +/** +The CLI, checked against its pins. + +`entry` exists because the CLI is published today as an npm package (`copilotkit`, `bin: +index.js`) rather than a single executable. Whether the release ships a native build or a script +run by a pinned runtime is still to be agreed with the CLI owners, so both shapes are verified the +same way and neither is assumed. A script's own imports are not covered by its pin; that is part of +the same agreement. + +The files are checked when this is built and run by path afterwards, so they belong in a directory +only this app writes to. +*/ +#[derive(Clone, Debug)] +pub struct PinnedCli { + program: PathBuf, + entry: Option, + sha256: String, +} + +impl PinnedCli { + pub fn verify(program: &Pin, entry: Option<&Pin>) -> Result { + Ok(Self { + program: verified(program)?, + entry: entry.map(verified).transpose()?, + sha256: entry.unwrap_or(program).sha256.clone(), + }) + } + + /// Which CLI this is, as a local evaluation record keeps it: the script's digest when a + /// runtime runs one, since the runtime runs many. + pub fn sha256(&self) -> &str { + &self.sha256 + } +} + +/// A regular file, not a link to one, whose bytes hash to the pin. Hashed in chunks: a pinned +/// runtime can be a hundred megabytes. +fn verified(pin: &Pin) -> Result { + let metadata = std::fs::symlink_metadata(&pin.path).map_err(|_| Failure::Unverified)?; + if !metadata.file_type().is_file() { + return Err(Failure::Unverified); + } + let mut file = std::fs::File::open(&pin.path).map_err(|_| Failure::Unverified)?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher).map_err(|_| Failure::Unverified)?; + let digest: String = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + if digest == pin.sha256 { + Ok(pin.path.clone()) + } else { + Err(Failure::Unverified) + } +} + +/// How long a call may take and how much it may say. +/// +/// The per-action timeouts are initial bounds, not measurements: `start` waits for a k3d cluster, +/// which has not been timed on a clean machine yet. +#[derive(Clone, Copy, Debug)] +pub struct Limits { + pub timeout: Duration, + /// Between asking the process tree to stop and forcing it. + pub grace: Duration, + pub output: usize, +} + +impl Limits { + pub fn for_action(action: Action) -> Self { + let timeout = match action { + Action::Status => Duration::from_secs(120), + Action::Start => Duration::from_secs(15 * 60), + Action::Stop => Duration::from_secs(10 * 60), + }; + Self { + timeout, + grace: Duration::from_secs(5), + output: MAX_OUTPUT, + } + } + + /// `local setup` downloads a release and creates the cluster. 88 seconds on a warm machine + /// with a fast connection; a first pull of every image is what this allows for. + pub fn for_setup() -> Self { + Self { + timeout: Duration::from_secs(30 * 60), + ..Self::for_action(Action::Start) + } + } + + /// The CopilotKit account sign-in waits on a person approving it in their browser. + pub fn for_account_sign_in() -> Self { + Self { + timeout: Duration::from_secs(15 * 60), + ..Self::for_action(Action::Status) + } + } + + /// `stop` while OpenBot quits. Somebody is waiting for the app to close, so the stop gets two + /// minutes rather than ten; a stack that has not stopped by then is reported, not waited on. + pub fn for_quit() -> Self { + Self { + timeout: Duration::from_secs(120), + ..Self::for_action(Action::Stop) + } + } +} + +/// Asks a running call to stop. Cloning shares the request. +#[derive(Clone, Debug, Default)] +pub struct Cancel(Arc); + +impl Cancel { + pub fn cancel(&self) { + self.0.store(true, Ordering::SeqCst); + } + + fn requested(&self) -> bool { + self.0.load(Ordering::SeqCst) + } +} + +/// The CLI this build runs, if it has one. +/// +/// None yet: the CLI's release artifact and its digest are not agreed, so there is nothing to pin. +/// Saying so is the honest answer; finding something on `PATH` would run whatever is there. +/// +/// A debug build also runs a CLI the developer pinned (`developer_cli`), so the flow can be tried +/// against a real release before one is pinned here. A release build never reads those settings. +pub fn installed() -> Result { + #[cfg(debug_assertions)] + if let Some(cli) = developer_cli() { + return cli; + } + if let Some(cli) = bundled_cli() { + return cli; + } + Err(Failure::Unavailable) +} + +/** +A CLI this build carries inside the app: a pinned Node runtime and the CLI it runs, in the app's +resources, with the digests `build.rs` recorded when the app was built. + +Present only in a build made with `OPENBOT_BUNDLED_CLI=1` (see `build.rs`); every other build has +none and answers as before. The files are checked against those digests on every call, like any +pin, so an app whose resources were changed after it was built runs nothing. +*/ +fn bundled_cli() -> Option> { + let program_sha256 = option_env!("OPENBOT_BUNDLED_CLI_PROGRAM_SHA256")?; + let entry_sha256 = option_env!("OPENBOT_BUNDLED_CLI_ENTRY_SHA256")?; + let root = bundled_cli_root()?; + Some(PinnedCli::verify( + &Pin { + path: root + .join("runtime") + .join(format!("node{}", std::env::consts::EXE_SUFFIX)), + sha256: program_sha256.to_owned(), + }, + Some(&Pin { + path: root.join("cli").join("index.js"), + sha256: entry_sha256.to_owned(), + }), + )) +} + +/// Where the bundled CLI is: `Contents/Resources/cli-bundle` in a Mac app, beside the program +/// elsewhere. +fn bundled_cli_root() -> Option { + let program = std::env::current_exe().ok()?; + let directory = program.parent()?; + if cfg!(target_os = "macos") { + Some(directory.parent()?.join("Resources").join("cli-bundle")) + } else { + Some(directory.join("cli-bundle")) + } +} + +/// The program a developer pinned: a native CLI, or the runtime that runs `DEVELOPER_ENTRY`. +#[cfg(debug_assertions)] +const DEVELOPER_PROGRAM: &str = "OPENBOT_DEV_LOCAL_EVAL_PROGRAM"; +#[cfg(debug_assertions)] +const DEVELOPER_PROGRAM_SHA256: &str = "OPENBOT_DEV_LOCAL_EVAL_PROGRAM_SHA256"; +/// The script the program runs, for a CLI published as one, like `copilotkit`'s `index.js`. +#[cfg(debug_assertions)] +const DEVELOPER_ENTRY: &str = "OPENBOT_DEV_LOCAL_EVAL_ENTRY"; +#[cfg(debug_assertions)] +const DEVELOPER_ENTRY_SHA256: &str = "OPENBOT_DEV_LOCAL_EVAL_ENTRY_SHA256"; + +/** +Debug builds only: a CLI named by path *and* digest, checked exactly like a built-in pin. + +A path without its digest is refused, not trusted, so the boundary is the same one a release has: +the developer chose the bytes and said so. None when no program is named. +*/ +#[cfg(debug_assertions)] +fn developer_cli() -> Option> { + let pin = |path: std::ffi::OsString, digest: &str| -> Result { + Ok(Pin { + path: PathBuf::from(path), + sha256: std::env::var(digest).map_err(|_| Failure::Unverified)?, + }) + }; + let program = std::env::var_os(DEVELOPER_PROGRAM)?; + Some((|| { + let program = pin(program, DEVELOPER_PROGRAM_SHA256)?; + let entry = std::env::var_os(DEVELOPER_ENTRY) + .map(|entry| pin(entry, DEVELOPER_ENTRY_SHA256)) + .transpose()?; + PinnedCli::verify(&program, entry.as_ref()) + })()) +} + +/// What the CLI inherits. Everything else, `NODE_OPTIONS` and `NODE_PATH` included, is dropped: +/// either could load code the pin never saw. The CLI needs a home for its configuration, `PATH` +/// for Docker and k3d, a temporary directory, the proxy settings of a managed network, and the +/// person's telemetry choice. +const INHERITED: &[&str] = &[ + "PATH", + "HOME", + "USER", + "LOGNAME", + "TMPDIR", + "LANG", + "LC_ALL", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + // The CLI's telemetry opt-outs (`services/consent/gate.ts`). Dropping them would turn + // telemetry back on for somebody who turned it off. + "DO_NOT_TRACK", + "COPILOTKIT_TELEMETRY_DISABLED", + "SystemRoot", + "SystemDrive", + "windir", + "ComSpec", + "PATHEXT", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "APPDATA", + "LOCALAPPDATA", + "ProgramData", + "ProgramFiles", + "ProgramFiles(x86)", + "TEMP", + "TMP", +]; + +/** +Run one CLI call to completion, within its limits. + +stdout goes to a private file, not a pipe. The call is finished when the CLI exits; anything it +left running with that file open cannot hold the call, and a normally exiting CLI keeps whatever it +deliberately detached. A deadline, a cancellation or too much output stops the whole process tree, +not only the CLI: it starts Docker and k3d, and leaving those running after a timeout is how a +retry collides with its own leftovers. +*/ +pub fn run( + cli: &PinnedCli, + action: Action, + stack: &StackId, + limits: Limits, + cancel: &Cancel, +) -> Result { + let (code, bytes) = execute(cli, &arguments(action, stack), None, limits, cancel)?; + normalize(action, code, &bytes) +} + +/// `local setup` in `installation`, the folder the stack is created for. +pub fn setup( + cli: &PinnedCli, + installation: &std::path::Path, + accepted: Option<&EulaVersion>, + limits: Limits, + cancel: &Cancel, +) -> Result { + let (code, bytes) = execute( + cli, + &setup_arguments(accepted), + Some(installation), + limits, + cancel, + )?; + normalize_setup(code, &bytes) +} + +/// `login --json`: the CopilotKit account sign-in. +pub fn account_sign_in( + cli: &PinnedCli, + limits: Limits, + cancel: &Cancel, +) -> Result { + let (code, bytes) = execute(cli, &ACCOUNT_SIGN_IN_ARGUMENTS, None, limits, cancel)?; + normalize_account_sign_in(code, &bytes) +} + +/// `onboard local-terms --json`, within the limits of `status`. +pub fn terms(cli: &PinnedCli, limits: Limits, cancel: &Cancel) -> Result { + let (code, bytes) = execute(cli, &TERMS_ARGUMENTS, None, limits, cancel)?; + normalize_terms(code, &bytes) +} + +/// `local connect` in `installation`, the folder `local setup` created the stack for. +pub fn connect( + cli: &PinnedCli, + installation: &std::path::Path, + limits: Limits, + cancel: &Cancel, +) -> Result { + let (code, bytes) = execute(cli, &CONNECT_ARGUMENTS, Some(installation), limits, cancel)?; + normalize_connect(code, &bytes) +} + +/// `local login` for `stack`. +pub fn login( + cli: &PinnedCli, + stack: &StackId, + limits: Limits, + cancel: &Cancel, +) -> Result { + let (code, bytes) = execute(cli, &login_arguments(stack), None, limits, cancel)?; + normalize_login(stack, code, &bytes) +} + +/// `local list`, within the same limits as `status`. +pub fn list(cli: &PinnedCli, limits: Limits, cancel: &Cancel) -> Result { + let (code, bytes) = execute(cli, &LIST_ARGUMENTS, None, limits, cancel)?; + normalize_list(code, &bytes) +} + +/// The exit code and stdout of one bounded CLI process. Arguments come only from `arguments`, +/// `login_arguments`, `LIST_ARGUMENTS`, `setup_arguments`, `CONNECT_ARGUMENTS`, +/// `ACCOUNT_SIGN_IN_ARGUMENTS` and `TERMS_ARGUMENTS`; only setup and connect run somewhere other +/// than inherited. +fn execute( + cli: &PinnedCli, + arguments: &[&str], + folder: Option<&std::path::Path>, + limits: Limits, + cancel: &Cancel, +) -> Result<(i32, Vec), Failure> { + let output = Output::create()?; + let mut command = crate::quiet::command(&cli.program); + if let Some(entry) = &cli.entry { + command.arg(entry); + } + command.env_clear(); + for name in INHERITED { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + // The CLI looks `docker` up by name. Opened from Finder, this app has the system's minimal + // PATH, which does not reach Docker Desktop's `/usr/local/bin`; the CLI gets Docker's own + // directory added. + if let Some(path) = crate::engine::path_with_docker() { + command.env("PATH", path); + } + if let Some(folder) = folder { + command.current_dir(folder); + } + command + .args(arguments) + .stdin(Stdio::null()) + .stdout(output.file.try_clone().map_err(|_| Failure::Spawn)?) + // stderr can carry anything a dependency prints. It is never read, so it is not kept. + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = command.spawn().map_err(|_| Failure::Spawn)?; + // Until this call has reaped the tree, by its own end or by `stop_tree`. + let _running = Running::start(); + // Only the child holds the file for writing now. + drop(command); + + let deadline = Instant::now() + limits.timeout; + loop { + if exited(&mut child)? { + let code = child.wait().map_err(|_| Failure::Spawn)?.code(); + let bytes = output.read(limits.output)?; + // A signal-terminated CLI has no code and no report. + return Ok((code.ok_or(Failure::UnexpectedExit)?, bytes)); + } + if output.len() > limits.output as u64 { + return Err(stop_tree(&mut child, limits.grace, Failure::OutputLimit)); + } + if cancel.requested() { + return Err(stop_tree(&mut child, limits.grace, Failure::Cancelled)); + } + if Instant::now() >= deadline { + return Err(stop_tree(&mut child, limits.grace, Failure::Timeout)); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +/// The owner-only file the CLI writes its report to, removed when the call ends. +struct Output { + path: PathBuf, + file: std::fs::File, +} + +impl Output { + fn create() -> Result { + let path = std::env::temp_dir().join(format!( + "openbot-local-eval-{}-{:016x}.json", + std::process::id(), + rand::random::() + )); + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path).map_err(|_| Failure::Spawn)?; + Ok(Self { path, file }) + } + + fn len(&self) -> u64 { + self.file + .metadata() + .map(|metadata| metadata.len()) + .unwrap_or(0) + } + + fn read(&self, cap: usize) -> Result, Failure> { + // The CLI wrote through a clone of this handle, which shares its offset. + (&self.file) + .seek(std::io::SeekFrom::Start(0)) + .map_err(|_| Failure::InvalidJson)?; + let mut bytes = Vec::new(); + (&self.file) + .take(cap as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| Failure::InvalidJson)?; + if bytes.len() > cap { + return Err(Failure::OutputLimit); + } + Ok(bytes) + } +} + +impl Drop for Output { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/** +Whether the CLI has exited, without reaping it. + +The CLI's process id is also its process group id. While it is unreaped, neither can be given to +another process, so `stop_tree` can only ever signal the group this call created. +*/ +#[cfg(unix)] +fn exited(child: &mut Child) -> Result { + // SAFETY: an all-zero siginfo_t is a valid out-parameter for waitid. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: waits on this call's own child; WNOWAIT leaves it unreaped. + let result = unsafe { + libc::waitid( + libc::P_PID, + child.id() as libc::id_t, + &mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result != 0 { + return Err(Failure::Spawn); + } + // SAFETY: waitid filled `info`; with WNOHANG and no state change the pid stays zero. + Ok(unsafe { info.si_pid() } != 0) +} + +#[cfg(not(unix))] +fn exited(child: &mut Child) -> Result { + child + .try_wait() + .map(|status| status.is_some()) + .map_err(|_| Failure::Spawn) +} + +/** +Stop the CLI and everything it started, then reap it. Returns `failure` for the caller. + +Unix: the CLI leads its own process group, which is asked to stop, given `grace`, then killed. The +CLI is reaped only after the last signal, so the group id cannot have been reused. Windows: +`taskkill /T /F` on the CLI's tree, which reaches what the CLI still parents; a Job Object would +also reach orphans, and is not used yet. +*/ +fn stop_tree(child: &mut Child, grace: Duration, failure: Failure) -> Failure { + #[cfg(unix)] + { + let group = child.id() as libc::pid_t; + // SAFETY: signalling the unreaped group this call created; no memory is shared. + unsafe { libc::killpg(group, libc::SIGTERM) }; + let until = Instant::now() + grace; + while Instant::now() < until && !matches!(exited(child), Ok(true)) { + std::thread::sleep(Duration::from_millis(20)); + } + // SAFETY: as above. The leader is still unreaped, so the id is still this group's. + unsafe { libc::killpg(group, libc::SIGKILL) }; + } + #[cfg(windows)] + { + let _ = grace; + let _ = crate::quiet::command("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + let _ = child.kill(); + let _ = child.wait(); + failure +} + +/// Calls in progress by attempt id, so the window can stop the one it means. +/// +/// A cancel can arrive before its call has registered: the window sends both, and nothing orders +/// them. So a cancel for an unknown attempt is kept, and the call finds itself already cancelled. +static IN_FLIGHT: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// At most this many attempts are remembered, so cancels for calls that never come cannot grow +/// the list without bound. +const REMEMBERED: usize = 64; + +fn in_flight() -> std::sync::MutexGuard<'static, Vec<(String, Cancel)>> { + IN_FLIGHT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// How many Stops are shutting the stack down right now. +static STOPPING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Set once OpenBot is quitting, and never cleared: nothing the window asks for after that should +/// start. +static QUITTING: AtomicBool = AtomicBool::new(false); + +fn closed() -> bool { + QUITTING.load(Ordering::SeqCst) || STOPPING.load(Ordering::SeqCst) > 0 +} + +/// A Stop in progress. Calls are refused until it is dropped. +#[must_use = "calls are allowed again as soon as this is dropped"] +pub struct Closing(()); + +impl Drop for Closing { + fn drop(&mut self) { + STOPPING.fetch_sub(1, Ordering::SeqCst); + } +} + +/** +Refuse new calls and cancel the running ones, until the returned guard is dropped. + +Cancelling once is not enough. A status or start the window sends while Stop is taking OpenBot's +containers down would otherwise take its turn ahead of the shutdown's own stop, or start after it +and leave the stack running. So every call registered while closing is cancelled before it begins, +except the shutdown's own (`stop_for_shutdown`). +*/ +pub fn closing_for_stop() -> Closing { + STOPPING.fetch_add(1, Ordering::SeqCst); + cancel_all(); + Closing(()) +} + +/// As `closing_for_stop`, for good. Quitting also cancels a Stop's own stop, so Quit never waits +/// behind it; Quit runs its own. +pub fn close_for_quit() { + QUITTING.store(true, Ordering::SeqCst); + cancel_all(); + // Cancelling only asks: each call stops its CLI's process tree on its own thread, and the CLI + // forwards SIGTERM to its children rather than exiting, so the tree goes at SIGKILL, after the + // grace. An app that exits before then leaves it running with nobody to stop it. + wait_until_stopped(Duration::from_secs(10)); +} + +/// CLI process trees started and not yet stopped or finished, across every call. +static RUNNING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Counts one CLI process tree as running for as long as it is held. +struct Running; + +impl Running { + fn start() -> Self { + RUNNING.fetch_add(1, Ordering::SeqCst); + Self + } +} + +impl Drop for Running { + fn drop(&mut self) { + RUNNING.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Waits, at most `patience`, until no CLI process tree is running. True when none is. +fn wait_until_stopped(patience: Duration) -> bool { + let until = Instant::now() + patience; + while RUNNING.load(Ordering::SeqCst) > 0 { + if Instant::now() >= until { + return false; + } + std::thread::sleep(Duration::from_millis(20)); + } + true +} + +/// An attempt's place in `IN_FLIGHT`, removed however the call ends. +struct Attempt(Cancel); + +impl Attempt { + /// Read under the same lock `cancel_all` takes, so a call registering as closing begins is + /// either refused here or cancelled there. + fn register(id: &str) -> Self { + Self::enter(id, true) + } + + fn register_for_shutdown(id: &str) -> Self { + Self::enter(id, false) + } + + fn enter(id: &str, refused_while_closing: bool) -> Self { + let mut held = in_flight(); + let cancel = match held.iter().position(|(attempt, _)| attempt == id) { + Some(index) => held.remove(index).1, + None => Cancel::default(), + }; + if refused_while_closing && closed() { + cancel.cancel(); + } + held.push((id.to_owned(), cancel.clone())); + Self(cancel) + } +} + +impl Drop for Attempt { + fn drop(&mut self) { + in_flight().retain(|(_, cancel)| !Arc::ptr_eq(&cancel.0, &self.0 .0)); + } +} + +/// Held by the one call that is running. The CLI refuses a second `local` command while one is in +/// progress (`local_command_busy`), so a start racing a stop would fail one of them for nothing. +static CALLS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/** +Run `call` once every earlier call has finished, with what is left of `limits.timeout`. + +The wait counts against the deadline, because the person waiting does not care which half of the +time went where. A cancel is honoured while waiting, and checked again once the turn comes, so a +call cancelled in the queue never starts a process. +*/ +fn one_at_a_time( + cancel: &Cancel, + limits: Limits, + call: impl FnOnce(Limits) -> Result, +) -> Result { + let deadline = Instant::now() + limits.timeout; + let _turn = loop { + if cancel.requested() { + return Err(Failure::Cancelled); + } + match CALLS.try_lock() { + Ok(turn) => break turn, + // A call that panicked held nothing the next one depends on. + Err(std::sync::TryLockError::Poisoned(poisoned)) => break poisoned.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => {} + } + if Instant::now() >= deadline { + return Err(Failure::Timeout); + } + std::thread::sleep(Duration::from_millis(20)); + }; + if cancel.requested() { + return Err(Failure::Cancelled); + } + let timeout = deadline.saturating_duration_since(Instant::now()); + if timeout.is_zero() { + return Err(Failure::Timeout); + } + call(Limits { timeout, ..limits }) +} + +/// Run against the CLI this build has installed, with the default limits for `action`. +pub fn run_installed(attempt: &str, action: Action, stack: &StackId) -> Result { + run_installed_with(attempt, action, stack, Limits::for_action(action)) +} + +/// Run against the installed CLI within `limits`, after any call already running has finished. +pub fn run_installed_with( + attempt: &str, + action: Action, + stack: &StackId, + limits: Limits, +) -> Result { + run_attempt(Attempt::register(attempt), action, stack, limits) +} + +/// `stop` for Stop or Quit itself: the one call let through while closing. A later Quit still +/// cancels it. +pub fn stop_for_shutdown( + attempt: &str, + stack: &StackId, + limits: Limits, +) -> Result { + run_attempt( + Attempt::register_for_shutdown(attempt), + Action::Stop, + stack, + limits, + ) +} + +fn run_attempt( + attempt: Attempt, + action: Action, + stack: &StackId, + limits: Limits, +) -> Result { + one_at_a_time(&attempt.0, limits, |limits| { + let cli = installed()?; + run(&cli, action, stack, limits, &attempt.0) + }) +} + +/// `local setup` in `installation` against the installed CLI, in its turn like every other call. +/// The digest of the CLI that ran comes back with the report, so the caller records which one. +pub fn setup_installed( + attempt: &str, + installation: &std::path::Path, + accepted: Option<&EulaVersion>, +) -> Result<(Report, String), Failure> { + let attempt = Attempt::register(attempt); + one_at_a_time(&attempt.0, Limits::for_setup(), |limits| { + let cli = installed()?; + let report = setup(&cli, installation, accepted, limits, &attempt.0)?; + Ok((report, cli.sha256().to_owned())) + }) +} + +/// The CopilotKit account sign-in against the installed CLI. +pub fn account_sign_in_installed(attempt: &str) -> Result { + let attempt = Attempt::register(attempt); + one_at_a_time(&attempt.0, Limits::for_account_sign_in(), |limits| { + let cli = installed()?; + account_sign_in(&cli, limits, &attempt.0) + }) +} + +/// The evaluation agreement against the installed CLI. +pub fn terms_installed(attempt: &str) -> Result { + let attempt = Attempt::register(attempt); + one_at_a_time(&attempt.0, Limits::for_action(Action::Status), |limits| { + let cli = installed()?; + terms(&cli, limits, &attempt.0) + }) +} + +/// `local connect` in `installation` against the installed CLI, within the limits of `status`. +pub fn connect_installed( + attempt: &str, + installation: &std::path::Path, +) -> Result { + let attempt = Attempt::register(attempt); + one_at_a_time(&attempt.0, Limits::for_action(Action::Status), |limits| { + let cli = installed()?; + connect(&cli, installation, limits, &attempt.0) + }) +} + +/// `local login` against the installed CLI, within the limits of `status`: the CLI waits up to a +/// minute for the dashboard before it opens anything. +pub fn login_installed(attempt: &str, stack: &StackId) -> Result { + let attempt = Attempt::register(attempt); + one_at_a_time(&attempt.0, Limits::for_action(Action::Status), |limits| { + let cli = installed()?; + login(&cli, stack, limits, &attempt.0) + }) +} + +/// `local list` against the installed CLI. +pub fn list_installed(attempt: &str) -> Result { + let attempt = Attempt::register(attempt); + one_at_a_time(&attempt.0, Limits::for_action(Action::Status), |limits| { + let cli = installed()?; + list(&cli, limits, &attempt.0) + }) +} + +/// Stop one call, whether or not it has started yet. +pub fn cancel(attempt: &str) { + let mut held = in_flight(); + match held.iter().find(|(id, _)| id == attempt) { + Some((_, cancel)) => cancel.cancel(), + None => { + if held.len() >= REMEMBERED { + held.remove(0); + } + let cancel = Cancel::default(); + cancel.cancel(); + held.push((attempt.to_owned(), cancel)); + } + } +} + +/// Stop every call in progress, before Stop or Quit asks the CLI to stop. Each stops its own +/// process tree, and a call still waiting for its turn never starts. +pub fn cancel_all() { + for (_, cancel) in in_flight().iter() { + cancel.cancel(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{compile_fixture, temp_root}; + use serde_json::json; + use std::path::Path; + use std::sync::OnceLock; + + const SECRET: &str = "synthetic-secret-marker"; + + // Mirrors local.ts and local-manager.ts at Intelligence 3935afbf, with synthetic identifiers. + fn envelope() -> Value { + json!({ + "version": 1, + "command": "local status", + "status": "completed", + "stack": { + "id": "local-abc123", + "installedRelease": "0.15.0", + "step": "ready", + "urls": { + "api": "http://localhost:8082", + "dashboard": "http://intelligence.localhost:8080", + "gateway": "ws://[::1]:4401" + }, + "failedStep": null, + "update": null, + "observed": { + "running": true, + "services": [{ "name": "cpki-app-api-test", "phase": "Running", "ready": true }] + } + }, + "auth": { "running": true }, + "nextAction": null, + "error": null, + "diagnostics": [] + }) + } + + // As 4.29.0 printed it, with the agreement version shortened. + fn setup_blocked(reason: &str) -> String { + json!({ + "version": 1, + "command": "local setup", + "status": "blocked", + "error": null, + "nextAction": { + "reason": reason, + "summary": "Ask the developer to run `copilotkit local accept-terms`.", + "instructions": ["Ask the developer to run `copilotkit local accept-terms`."], + "resumeCommand": null, + "url": null + }, + "diagnostics": [] + }) + .to_string() + } + + fn status(input: &Value) -> Result { + normalize(Action::Status, 0, input.to_string().as_bytes()) + } + + fn exposes_secret(report: &Report) -> bool { + serde_json::to_string(report).unwrap().contains(SECRET) + } + + #[test] + fn a_completed_command_with_ready_pods_is_not_licensed_readiness() { + let report = status(&envelope()).unwrap(); + assert_eq!(report.outcome, Outcome::Completed); + assert_eq!(report.readiness, Readiness::Unknown); + assert_eq!(report.licence, Licence::Unknown); + let stack = report.stack.unwrap(); + assert_eq!(stack.id, "local-abc123"); + assert!(stack.observed.unwrap().services[0].ready); + } + + #[test] + fn stopped_unready_and_interrupted_stacks_are_kept_as_observed() { + for running in [false, true] { + let mut input = envelope(); + input["stack"]["observed"] = json!({ + "running": running, + "services": [{ "name": "cpki-app-api-test", "phase": "Running", "ready": false }] + }); + input["stack"]["failedStep"] = json!("services-start"); + input["stack"]["update"] = json!({ "state": "migrated", "recoveryCommand": SECRET }); + let report = status(&input).unwrap(); + let stack = report.stack.clone().unwrap(); + assert!(stack.has_failed_step); + assert!(stack.update_in_progress); + let observed = stack.observed.unwrap(); + assert_eq!(observed.running, running); + assert!(!observed.services[0].ready); + assert_eq!(report.readiness, Readiness::Unknown); + assert!(!exposes_secret(&report)); + } + } + + #[test] + fn empty_or_missing_observations_never_imply_readiness() { + let mut input = envelope(); + input["stack"]["observed"] = json!({ "running": true, "services": [] }); + assert_eq!(status(&input).unwrap().readiness, Readiness::Unknown); + input["stack"].as_object_mut().unwrap().remove("observed"); + assert_eq!(status(&input).unwrap().stack.unwrap().observed, None); + } + + #[test] + fn blocked_and_failed_keep_only_the_reason() { + for (code, outcome, reason, expected) in [ + ( + 2, + "blocked", + "authentication_required", + Reason::AuthenticationRequired, + ), + ( + 1, + "failed", + "local_command_failed", + Reason::LocalCommandFailed, + ), + ] { + let input = json!({ + "version": 1, + "command": "local status", + "status": outcome, + "error": { "message": SECRET }, + "nextAction": { + "reason": reason, + "summary": SECRET, + "instructions": [SECRET], + "resumeCommand": format!("echo {SECRET}") + } + }); + let report = normalize(Action::Status, code, input.to_string().as_bytes()).unwrap(); + assert_eq!(report.reason, Some(expected)); + assert_eq!(report.stack, None); + assert!(!exposes_secret(&report)); + } + } + + #[test] + fn unknown_fields_and_unsupported_licence_claims_are_discarded() { + let mut input = envelope(); + input["credentials"] = json!(SECRET); + input["stack"]["apiKey"] = json!(SECRET); + input["stack"]["licence"] = json!({ "valid": true }); + input["stack"]["readiness"] = json!("ready"); + input["readiness"] = json!("ready"); + let report = status(&input).unwrap(); + assert_eq!(report.licence, Licence::Unknown); + assert_eq!(report.readiness, Readiness::Unknown); + assert!(!exposes_secret(&report)); + } + + // The additive v1 fields the CLI publishes on `local status`, with synthetic organization data. + fn canonical(readiness: &str, licence: &str, expires_at: Value) -> Value { + let mut input = envelope(); + input["readiness"] = json!({ + "state": readiness, + "checkedAt": "2026-10-21T18:04:05.123Z", + "checks": ["api", SECRET] + }); + input["license"] = json!({ + "state": licence, + "expiresAt": expires_at, + "organizationId": SECRET, + "organizationName": SECRET, + "renewal": "none" + }); + input + } + + #[test] + fn each_readiness_state_is_read() { + for (wire, expected) in [ + ("ready", Readiness::Ready), + ("starting", Readiness::Starting), + ("stopped", Readiness::Stopped), + ("degraded", Readiness::Degraded), + ("unknown", Readiness::Unknown), + ] { + let report = status(&canonical(wire, "valid", Value::Null)).unwrap(); + assert_eq!(report.readiness, expected, "{wire}"); + assert!(!exposes_secret(&report)); + } + } + + #[test] + fn each_licence_state_is_read() { + for (wire, expected) in [ + ("valid", Licence::Valid), + ("expired", Licence::Expired), + ("missing", Licence::Missing), + ("invalid", Licence::Invalid), + ("organization_mismatch", Licence::OrganizationMismatch), + ] { + let report = status(&canonical("ready", wire, Value::Null)).unwrap(); + assert_eq!(report.licence, expected, "{wire}"); + assert_eq!(report.licence_expires_at, None); + assert!(!exposes_secret(&report)); + } + } + + #[test] + fn a_licence_expiring_in_four_days_keeps_only_its_instant() { + let report = status(&canonical( + "ready", + "valid", + json!("2026-10-25T18:04:05.000Z"), + )) + .unwrap(); + assert_eq!(report.readiness, Readiness::Ready); + assert_eq!(report.licence, Licence::Valid); + assert_eq!( + report.licence_expires_at.as_deref(), + Some("2026-10-25T18:04:05.000Z") + ); + // Organization, renewal and check names are not kept. + let serialized = serde_json::to_value(&report).unwrap(); + assert_eq!(serialized["readiness"], "ready"); + assert_eq!(serialized["licence"], "valid"); + assert_eq!(serialized["licenceExpiresAt"], "2026-10-25T18:04:05.000Z"); + assert!(!exposes_secret(&report)); + } + + #[test] + fn ready_pods_with_an_api_that_is_not_ready_are_not_ready() { + // Every observed service is ready; the CLI's own end-to-end check is what says it is not. + let report = status(&canonical("starting", "valid", Value::Null)).unwrap(); + assert!(report.stack.unwrap().observed.unwrap().services[0].ready); + assert_eq!(report.readiness, Readiness::Starting); + } + + #[test] + fn unknown_canonical_values_are_unknown() { + let report = status(&canonical("green", "revoked", Value::Null)).unwrap(); + assert_eq!(report.readiness, Readiness::Unknown); + assert_eq!(report.licence, Licence::Unknown); + // Wire spellings only: the window's own spelling is not a CLI value. + let report = status(&canonical("Ready", "organization-mismatch", Value::Null)).unwrap(); + assert_eq!(report.readiness, Readiness::Unknown); + assert_eq!(report.licence, Licence::Unknown); + } + + #[test] + fn malformed_readiness_is_unknown_and_never_fails_the_report() { + let good = canonical("ready", "valid", Value::Null); + for broken in [ + json!(null), + json!("ready"), + json!(["ready"]), + json!({ "checkedAt": "2026-10-21T18:04:05Z" }), + json!({ "state": true, "checkedAt": "2026-10-21T18:04:05Z" }), + json!({ "state": "ready" }), + json!({ "state": "ready", "checkedAt": null }), + json!({ "state": "ready", "checkedAt": "yesterday" }), + ] { + let mut input = good.clone(); + input["readiness"] = broken.clone(); + let report = status(&input).unwrap(); + assert_eq!(report.readiness, Readiness::Unknown, "{broken}"); + assert_eq!(report.licence, Licence::Valid, "{broken}"); + } + let mut input = good; + input.as_object_mut().unwrap().remove("readiness"); + assert_eq!(status(&input).unwrap().readiness, Readiness::Unknown); + } + + #[test] + fn malformed_licence_is_unknown_with_no_expiry() { + let good = canonical("ready", "valid", json!("2026-10-25T18:04:05Z")); + let mut cases = vec![ + json!(null), + json!("valid"), + json!({ "expiresAt": null }), + json!({ "state": 1, "expiresAt": null }), + // `expiresAt` is ISO-8601 or null, and present. + json!({ "state": "valid" }), + json!({ "state": "valid", "expiresAt": 1792000000 }), + ]; + for expires in [ + SECRET, + "2026-10-25", + "2026-10-25 18:04:05Z", + "2026-10-25t18:04:05z", + "2026-13-25T18:04:05Z", + "2026-02-30T18:04:05Z", + "2026-10-25T24:00:00Z", + "2026-10-25T18:60:05Z", + "2026-10-25T18:04:05", + "2026-10-25T18:04:05.Z", + "2026-10-25T18:04:05.1234567890Z", + "2026-10-25T18:04:05+2400", + "+02026-10-25T18:04:05Z", + " 2026-10-25T18:04:05Z", + ] { + cases.push(json!({ "state": "valid", "expiresAt": expires })); + } + for broken in cases { + let mut input = good.clone(); + input["license"] = broken.clone(); + let report = status(&input).unwrap(); + assert_eq!(report.licence, Licence::Unknown, "{broken}"); + assert_eq!(report.licence_expires_at, None, "{broken}"); + assert_eq!(report.readiness, Readiness::Ready, "{broken}"); + assert!(!exposes_secret(&report)); + } + let mut input = good; + input.as_object_mut().unwrap().remove("license"); + assert_eq!(status(&input).unwrap().licence, Licence::Unknown); + } + + #[test] + fn strict_instants_with_offsets_and_fractions_are_kept() { + for instant in [ + "2026-10-25T18:04:05Z", + "2026-10-25T18:04:05.1Z", + "2026-10-25T18:04:05.123456789Z", + "2026-10-25T18:04:05+02:00", + "2026-10-25T18:04:05.5-07:30", + "2028-02-29T00:00:00Z", + ] { + let report = status(&canonical("ready", "valid", json!(instant))).unwrap(); + assert_eq!(report.licence_expires_at.as_deref(), Some(instant)); + } + // Not a leap year. + let report = status(&canonical("ready", "valid", json!("2026-02-29T00:00:00Z"))).unwrap(); + assert_eq!(report.licence, Licence::Unknown); + } + + #[test] + fn readiness_and_licence_are_read_only_from_a_completed_report() { + let mut input = canonical("ready", "valid", Value::Null); + input["status"] = json!("blocked"); + input["nextAction"] = json!({ "reason": "docker_unavailable" }); + let report = normalize(Action::Status, 2, input.to_string().as_bytes()).unwrap(); + assert_eq!(report.readiness, Readiness::Unknown); + assert_eq!(report.licence, Licence::Unknown); + } + + #[test] + fn unsupported_versions_wrong_commands_and_missing_stacks_are_refused() { + let mut input = envelope(); + input["version"] = json!(2); + assert_eq!(status(&input), Err(Failure::UnsupportedVersion)); + let mut input = envelope(); + input["command"] = json!("local delete"); + assert_eq!(status(&input), Err(Failure::UnexpectedCommand)); + assert_eq!( + normalize(Action::Start, 0, envelope().to_string().as_bytes()), + Err(Failure::UnexpectedCommand) + ); + let mut input = envelope(); + input.as_object_mut().unwrap().remove("stack"); + assert_eq!(status(&input), Err(Failure::MissingStack)); + } + + #[test] + fn malformed_and_oversized_output_fail_without_its_contents() { + assert_eq!( + normalize(Action::Status, 0, SECRET.as_bytes()), + Err(Failure::InvalidJson) + ); + assert_eq!( + normalize(Action::Status, 0, &[0xff, 0xfe]), + Err(Failure::InvalidJson) + ); + assert_eq!( + normalize(Action::Status, 0, " ".repeat(MAX_OUTPUT + 1).as_bytes()), + Err(Failure::OutputLimit) + ); + let mut input = envelope(); + input["status"] = json!("ready"); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + let mut input = envelope(); + input["stack"] = json!({ "id": "local-test" }); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + // Failures are static categories, so nothing from the input can be in one. + assert_eq!( + serde_json::to_string(&Failure::InvalidJson).unwrap(), + "\"invalid-json\"" + ); + } + + #[test] + fn unsafe_api_endpoints_are_refused() { + for url in [ + "http://example.com:8082", + "http://localhost.evil.test:8082", + "http://user:synthetic-secret-marker@localhost:8082", + "http://localhost:8082?key=synthetic-secret-marker", + "http://localhost:8082/#secret", + "http://localhost:8082/api", + "file:///tmp/sock", + "https://localhost:8082", + "http://intelligence.localhost:8082", + "http://LOCALHOST:8082", + "http://localhost", + "http://localhost:0", + "http://localhost:65536", + "http://localhost:+80", + "http://127.1:8082", + "http://127.0.0.01:8082", + "http://127.0.0.256:8082", + "http://[::2]:8082", + ] { + let mut input = envelope(); + input["stack"]["urls"]["api"] = json!(url); + assert_eq!(status(&input), Err(Failure::UnsafeEndpoint), "{url}"); + } + } + + #[test] + fn explicit_loopback_hosts_and_the_cli_dashboard_host_are_accepted() { + for host in ["localhost", "127.0.0.1", "127.255.0.9", "[::1]"] { + let mut input = envelope(); + input["stack"]["urls"]["api"] = json!(format!("http://{host}:8082")); + input["stack"]["urls"]["gateway"] = json!(format!("ws://{host}:4401/")); + let urls = status(&input).unwrap().stack.unwrap().urls; + assert_eq!(urls.api, format!("http://{host}:8082")); + } + let urls = status(&envelope()).unwrap().stack.unwrap().urls; + assert_eq!(urls.dashboard, "http://intelligence.localhost:8080"); + + let mut input = envelope(); + input["stack"]["urls"]["gateway"] = json!("http://localhost:4401"); + assert_eq!(status(&input), Err(Failure::UnsafeEndpoint)); + for dashboard in [ + "http://a.b.localhost:8080", + "http://-a.localhost:8080", + "http://.localhost:8080", + ] { + let mut input = envelope(); + input["stack"]["urls"]["dashboard"] = json!(dashboard); + assert_eq!(status(&input), Err(Failure::UnsafeEndpoint), "{dashboard}"); + } + } + + #[test] + fn malformed_observations_are_refused_rather_than_partly_believed() { + let mut input = envelope(); + input["stack"]["observed"] = json!({ "running": "true", "services": [] }); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + input["stack"]["observed"] = json!({ + "running": true, + "services": [{ "name": "pod", "phase": "Running", "ready": "true" }] + }); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + } + + #[test] + fn arguments_are_fixed() { + let stack = StackId::parse("local-abc123").unwrap(); + for (action, name) in [ + (Action::Status, "status"), + (Action::Start, "start"), + (Action::Stop, "stop"), + ] { + assert_eq!( + arguments(action, &stack), + ["local", name, "--stack", "local-abc123", "--json"] + ); + } + // An action outside the allowlist cannot arrive from the window either. + assert!(serde_json::from_value::(json!("delete")).is_err()); + } + + #[test] + fn setup_defers_the_model_and_never_accepts_the_agreement() { + assert_eq!( + SETUP_ARGUMENTS, + ["local", "setup", "--defer-model", "--json"] + ); + assert!(!SETUP_ARGUMENTS + .iter() + .any(|argument| argument.contains("eula") || argument.contains("accept"))); + } + + #[test] + fn setup_runs_in_the_installation_folder() { + let fixture = Fixture::new( + "local-eval-setup", + &format!("2\n{}", setup_blocked("eula_acceptance_required")), + ); + let installation = fixture.root.join("installation"); + std::fs::create_dir_all(&installation).unwrap(); + let report = setup( + &fixture.cli(), + &installation, + None, + Limits::for_setup(), + &Cancel::default(), + ) + .unwrap(); + assert_eq!(report.outcome, Outcome::Blocked); + assert_eq!(report.reason, Some(Reason::EulaAcceptanceRequired)); + assert_eq!( + fixture.argv().unwrap(), + "local\nsetup\n--defer-model\n--json" + ); + assert_eq!(fixture.cwd(), installation.canonicalize().unwrap()); + } + + // As `local connect` prints it at Intelligence 780313e4: no key, only the stack it connected. + fn connected(status: &str, conflicts: Value, next: Value) -> String { + let mut input = envelope(); + input["command"] = json!("local connect"); + input["status"] = json!(status); + input["conflicts"] = conflicts; + input["nextAction"] = next; + input.as_object_mut().unwrap().remove("auth"); + input.to_string() + } + + #[test] + fn connect_approves_only_the_documented_connection() { + assert_eq!( + CONNECT_ARGUMENTS, + ["local", "connect", "--approve-connection", "--json"] + ); + assert!(!CONNECT_ARGUMENTS + .iter() + .any(|argument| argument.contains("eula") || argument.contains("terms"))); + } + + #[test] + fn connect_runs_in_the_installation_folder() { + let fixture = Fixture::new( + "local-eval-connect", + &format!("0\n{}", connected("completed", json!([]), Value::Null)), + ); + let installation = fixture.root.join("installation"); + std::fs::create_dir_all(&installation).unwrap(); + let connection = connect( + &fixture.cli(), + &installation, + Limits::for_action(Action::Status), + &Cancel::default(), + ) + .unwrap(); + assert_eq!(connection.outcome, Outcome::Completed); + assert!(!connection.conflicted); + assert_eq!(connection.stack.unwrap().id, "local-abc123"); + assert_eq!( + fixture.argv().unwrap(), + "local\nconnect\n--approve-connection\n--json" + ); + assert_eq!(fixture.cwd(), installation.canonicalize().unwrap()); + } + + #[test] + fn connect_answers_keep_only_their_reason_and_whether_settings_conflicted() { + let confirmation = normalize_connect( + 2, + connected( + "blocked", + Value::Null, + json!({ + "reason": "confirmation_required", + "summary": SECRET, + "instructions": [SECRET], + "resumeCommand": "copilotkit local connect --approve-connection" + }), + ) + .as_bytes(), + ) + .unwrap(); + assert_eq!(confirmation.outcome, Outcome::Blocked); + assert_eq!(confirmation.reason, Some(Reason::ConfirmationRequired)); + assert!(!confirmation.conflicted); + + let conflict = normalize_connect( + 2, + connected( + "blocked", + json!(["CPK_INTELLIGENCE_API_KEY", SECRET]), + Value::Null, + ) + .as_bytes(), + ) + .unwrap(); + assert_eq!(conflict.outcome, Outcome::Blocked); + assert_eq!(conflict.reason, None); + assert!(conflict.conflicted); + + let failed = normalize_connect( + 1, + json!({ + "version": 1, + "command": "local connect", + "status": "failed", + "error": { "code": "LOCAL_COMMAND_FAILED", "message": SECRET }, + "nextAction": { "reason": "local_command_failed", "resumeCommand": SECRET } + }) + .to_string() + .as_bytes(), + ) + .unwrap(); + assert_eq!(failed.reason, Some(Reason::LocalCommandFailed)); + assert_eq!(failed.stack, None); + + for answer in [confirmation, conflict, failed] { + assert!(!serde_json::to_string(&answer).unwrap().contains(SECRET)); + } + } + + #[test] + fn a_connect_report_is_held_to_the_same_rules() { + // A completed connection names the stack it connected. + let mut input: Value = + serde_json::from_str(&connected("completed", json!([]), Value::Null)).unwrap(); + input.as_object_mut().unwrap().remove("stack"); + assert_eq!( + normalize_connect(0, input.to_string().as_bytes()), + Err(Failure::MissingStack) + ); + // Conflicts are a list of setting names, or absent. + assert_eq!( + normalize_connect( + 2, + connected("blocked", json!(SECRET), Value::Null).as_bytes() + ), + Err(Failure::InvalidStatus) + ); + // Another command's report, or a completion that exits as blocked, is not a connection. + assert_eq!( + normalize_connect(0, envelope().to_string().as_bytes()), + Err(Failure::UnexpectedCommand) + ); + assert_eq!( + normalize_connect(2, connected("completed", json!([]), Value::Null).as_bytes()), + Err(Failure::ContractMismatch) + ); + assert_eq!( + normalize_connect(0, connected("completed", json!([]), Value::Null).as_bytes()) + .unwrap() + .stack + .unwrap() + .urls + .api, + "http://localhost:8082" + ); + } + + #[test] + fn connect_waits_its_turn_and_is_refused_once_cancelled() { + let installation = temp_root("local-eval-connect-installed"); + std::fs::create_dir_all(&installation).unwrap(); + assert_eq!( + connect_installed("connect-no-cli", &installation), + Err(Failure::Unavailable) + ); + cancel("connect-cancelled"); + assert_eq!( + connect_installed("connect-cancelled", &installation), + Err(Failure::Cancelled) + ); + std::fs::remove_dir_all(installation).unwrap(); + } + + // As `local login --json` prints it at Intelligence 780313e4 (`local-login.ts`), with the + // bearer-free fields it really has: no handoff link is ever in it. + fn logged_in(status: &str, next: Value) -> String { + let mut input = json!({ + "version": 1, + "command": "local login", + "status": status, + "error": null, + "nextAction": next, + "diagnostics": [] + }); + if status == "completed" { + input["browserOpened"] = json!(true); + input["stackId"] = json!("local-abc123"); + } + input.to_string() + } + + fn login_blocked(reason: &str) -> Value { + json!({ + "reason": reason, + "summary": format!("Open http://cpk.localhost:8080/login {SECRET}"), + "instructions": ["http://cpk.localhost:8080/login", "copilotkit local auth pending --stack local-abc123"], + "resumeCommand": "copilotkit local login --stack local-abc123", + "url": "http://cpk.localhost:8080/login", + "linkLabel": "Open dashboard" + }) + } + + #[test] + fn login_arguments_are_fixed() { + let stack = StackId::parse("local-abc123").unwrap(); + assert_eq!( + login_arguments(&stack), + ["local", "login", "--stack", "local-abc123", "--json"] + ); + } + + #[test] + fn login_runs_the_fixed_arguments_and_keeps_only_that_it_opened() { + let fixture = Fixture::new( + "local-eval-login", + &format!("0\n{}", logged_in("completed", Value::Null)), + ); + let stack = StackId::parse("local-abc123").unwrap(); + let signed = login( + &fixture.cli(), + &stack, + Limits::for_action(Action::Status), + &Cancel::default(), + ) + .unwrap(); + assert_eq!( + signed, + SignIn { + outcome: Outcome::Completed, + reason: None + } + ); + assert_eq!( + fixture.argv().unwrap(), + "local\nlogin\n--stack\nlocal-abc123\n--json" + ); + } + + #[test] + fn login_answers_keep_only_their_reason() { + let stack = StackId::parse("local-abc123").unwrap(); + for (wire, reason) in [ + ("dashboard_unavailable", Reason::DashboardUnavailable), + ("browser_open_failed", Reason::BrowserOpenFailed), + ("local_command_busy", Reason::LocalCommandBusy), + ("docker_unavailable", Reason::DockerUnavailable), + ("something_new", Reason::Unrecognized), + ] { + let signed = normalize_login( + &stack, + 2, + logged_in("blocked", login_blocked(wire)).as_bytes(), + ) + .unwrap(); + assert_eq!( + signed, + SignIn { + outcome: Outcome::Blocked, + reason: Some(reason) + } + ); + let said = serde_json::to_string(&signed).unwrap(); + for leaked in [SECRET, "cpk.localhost", "auth pending", "copilotkit"] { + assert!(!said.contains(leaked), "{said} carried {leaked}"); + } + } + let failed = normalize_login( + &stack, + 1, + logged_in("failed", login_blocked("local_command_failed")).as_bytes(), + ) + .unwrap(); + assert_eq!(failed.outcome, Outcome::Failed); + assert_eq!(failed.reason, Some(Reason::LocalCommandFailed)); + } + + #[test] + fn a_login_report_is_held_to_the_same_rules() { + let stack = StackId::parse("local-abc123").unwrap(); + // Opened only when the CLI says it opened the browser, for this stack. + let mut unopened: Value = + serde_json::from_str(&logged_in("completed", Value::Null)).unwrap(); + unopened["browserOpened"] = json!(false); + let mut other: Value = serde_json::from_str(&logged_in("completed", Value::Null)).unwrap(); + other["stackId"] = json!("local-other"); + let mut missing: Value = + serde_json::from_str(&logged_in("completed", Value::Null)).unwrap(); + missing.as_object_mut().unwrap().remove("browserOpened"); + for input in [unopened, other, missing] { + assert_eq!( + normalize_login(&stack, 0, input.to_string().as_bytes()), + Err(Failure::InvalidStatus) + ); + } + // Another command's report, a status the exit code contradicts, and the CLI's text output. + assert_eq!( + normalize_login(&stack, 0, envelope().to_string().as_bytes()), + Err(Failure::UnexpectedCommand) + ); + assert_eq!( + normalize_login( + &stack, + 0, + logged_in("blocked", login_blocked("x")).as_bytes() + ), + Err(Failure::ContractMismatch) + ); + assert_eq!( + normalize_login( + &stack, + 0, + b"Opened local sign-in in your browser. No code approval is needed; if the dashboard shows Sign In, choose it.\n" + ), + Err(Failure::InvalidJson) + ); + assert_eq!( + normalize_login(&stack, 9, b""), + Err(Failure::UnexpectedExit) + ); + } + + #[test] + fn login_waits_its_turn_and_is_refused_once_cancelled() { + let stack = StackId::parse("local-abc123").unwrap(); + assert_eq!( + login_installed("login-no-cli", &stack), + Err(Failure::Unavailable) + ); + cancel("login-cancelled"); + assert_eq!( + login_installed("login-cancelled", &stack), + Err(Failure::Cancelled) + ); + } + + #[test] + fn a_pinned_cli_is_known_by_the_digest_of_what_it_runs() { + let fixture = Fixture::new("local-eval-cli-digest", "0\n"); + assert_eq!(fixture.cli().sha256(), sha256(&fixture.program)); + let entry = fixture.root.join("index.js"); + std::fs::write(&entry, "// pinned entry").unwrap(); + let entry_pin = Pin { + path: entry.clone(), + sha256: sha256(&entry), + }; + let cli = PinnedCli::verify(&fixture.pin(), Some(&entry_pin)).unwrap(); + // A runtime runs many scripts; the script is what identifies the CLI. + assert_eq!(cli.sha256(), sha256(&entry)); + } + + #[test] + fn setup_waits_its_turn_and_is_refused_once_cancelled() { + let installation = temp_root("local-eval-setup-installed"); + std::fs::create_dir_all(&installation).unwrap(); + assert_eq!( + setup_installed("setup-no-cli", &installation, None).map(|(report, _)| report), + Err(Failure::Unavailable) + ); + cancel("setup-cancelled"); + assert_eq!( + setup_installed("setup-cancelled", &installation, None).map(|(report, _)| report), + Err(Failure::Cancelled) + ); + std::fs::remove_dir_all(installation).unwrap(); + } + + #[test] + fn a_setup_report_is_held_to_the_same_rules() { + let blocked = + normalize_setup(2, setup_blocked("eula_acceptance_required").as_bytes()).unwrap(); + assert_eq!(blocked.reason, Some(Reason::EulaAcceptanceRequired)); + assert!(!serde_json::to_string(&blocked) + .unwrap() + .contains("accept-terms")); + // Another command's report is not a setup report. + assert_eq!( + normalize_setup(0, envelope().to_string().as_bytes()), + Err(Failure::UnexpectedCommand) + ); + } + + #[test] + fn invalid_stack_ids_are_refused_in_arguments_and_reports() { + let long = "a".repeat(129); + for id in [ + "", + "--json", + "a b", + "a/b", + "a\nb", + "a;id", + "a$(id)", + "_a", + long.as_str(), + ] { + assert_eq!(StackId::parse(id), Err(Failure::InvalidStack), "{id:?}"); + let mut input = envelope(); + input["stack"]["id"] = json!(id); + assert_eq!(status(&input), Err(Failure::InvalidStack), "{id:?}"); + } + assert!(StackId::parse(&"a".repeat(128)).is_ok()); + } + + #[test] + fn start_stop_and_busy_envelopes_are_understood() { + for action in [Action::Start, Action::Stop] { + let mut input = envelope(); + input["command"] = json!(format!("local {}", action.as_str())); + input["stack"].as_object_mut().unwrap().remove("observed"); + let report = normalize(action, 0, input.to_string().as_bytes()).unwrap(); + assert_eq!(report.stack.unwrap().observed, None); + + let busy = json!({ + "version": 1, + "command": format!("local {}", action.as_str()), + "status": "blocked", + "error": null, + "nextAction": { + "reason": "local_command_busy", + "resumeCommand": format!("copilotkit local {} --stack local-abc123 --json", action.as_str()) + }, + "diagnostics": [] + }); + let report = normalize(action, 2, busy.to_string().as_bytes()).unwrap(); + assert_eq!(report.reason, Some(Reason::LocalCommandBusy)); + } + let unknown = json!({ + "version": 1, "command": "local start", "status": "blocked", + "nextAction": { "reason": "something_new" } + }); + let report = normalize(Action::Start, 2, unknown.to_string().as_bytes()).unwrap(); + assert_eq!(report.reason, Some(Reason::Unrecognized)); + } + + #[test] + fn exit_codes_and_statuses_must_agree() { + let completed = envelope().to_string(); + assert_eq!( + normalize(Action::Status, 2, completed.as_bytes()), + Err(Failure::ContractMismatch) + ); + let blocked = json!({ "version": 1, "command": "local status", "status": "blocked" }); + assert_eq!( + normalize(Action::Status, 0, blocked.to_string().as_bytes()), + Err(Failure::ContractMismatch) + ); + for code in [3, 75, 127, -1] { + assert_eq!( + normalize(Action::Status, code, completed.as_bytes()), + Err(Failure::UnexpectedExit), + "{code}" + ); + } + } + + #[test] + fn this_build_has_no_cli_to_run() { + assert!(matches!(installed(), Err(Failure::Unavailable))); + let stack = StackId::parse("local-abc123").unwrap(); + assert_eq!( + run_installed("no-cli-attempt", Action::Status, &stack), + Err(Failure::Unavailable) + ); + // The attempt is gone however the call ended. + assert!(!in_flight().iter().any(|(id, _)| id == "no-cli-attempt")); + } + + #[test] + fn a_debug_build_runs_only_the_cli_a_developer_pinned() { + // Sets process environment, which other tests read. + if crate::test_support::isolated_process( + "local_eval::tests::a_debug_build_runs_only_the_cli_a_developer_pinned", + ) { + return; + } + let fixture = Fixture::new("local-eval-developer-pin", &format!("0\n{}", envelope())); + let stack = StackId::parse("local-abc123").unwrap(); + // A path alone is not a pin, and neither is a digest of something else. + std::env::set_var(DEVELOPER_PROGRAM, &fixture.program); + assert!(matches!(installed(), Err(Failure::Unverified))); + std::env::set_var(DEVELOPER_PROGRAM_SHA256, "0".repeat(64)); + assert!(matches!(installed(), Err(Failure::Unverified))); + assert_eq!(fixture.argv(), None, "an unverified CLI ran"); + + std::env::set_var(DEVELOPER_PROGRAM_SHA256, sha256(&fixture.program)); + let report = run_installed("developer-pin", Action::Status, &stack).unwrap(); + assert_eq!(report.outcome, Outcome::Completed); + assert_eq!( + fixture.argv().unwrap(), + "local\nstatus\n--stack\nlocal-abc123\n--json" + ); + + let entry = fixture.root.join("index.js"); + std::fs::write(&entry, "// pinned entry").unwrap(); + std::env::set_var(DEVELOPER_ENTRY, &entry); + assert!(matches!(installed(), Err(Failure::Unverified))); + std::env::set_var(DEVELOPER_ENTRY_SHA256, sha256(&entry)); + run_installed("developer-pin-entry", Action::Status, &stack).unwrap(); + assert_eq!( + fixture.argv().unwrap(), + format!( + "{}\nlocal\nstatus\n--stack\nlocal-abc123\n--json", + entry.display() + ) + ); + } + + #[test] + fn a_cancel_that_arrives_first_is_kept_for_its_call() { + cancel("early-attempt"); + let attempt = Attempt::register("early-attempt"); + assert!(attempt.0.requested()); + drop(attempt); + assert!(!in_flight().iter().any(|(id, _)| id == "early-attempt")); + } + + #[test] + fn cancelling_one_attempt_leaves_the_others_running() { + let start = Attempt::register("start-attempt"); + let status = Attempt::register("status-attempt"); + cancel("status-attempt"); + assert!(status.0.requested()); + assert!(!start.0.requested()); + } + + /// Take the turn every call through `one_at_a_time` waits for, as a running call would. + fn hold_turn() -> std::sync::MutexGuard<'static, ()> { + CALLS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + #[test] + fn quitting_allows_a_stop_two_minutes() { + let quit = Limits::for_quit(); + assert_eq!(quit.timeout, Duration::from_secs(120)); + assert_eq!(quit.grace, Limits::for_action(Action::Stop).grace); + assert_eq!(quit.output, MAX_OUTPUT); + let stack = StackId::parse("local-abc123").unwrap(); + assert_eq!( + run_installed_with("quit-no-cli", Action::Stop, &stack, quit), + Err(Failure::Unavailable) + ); + } + + #[test] + fn calls_run_one_at_a_time() { + let active = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let most = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls: Vec<_> = (0..4) + .map(|_| { + let (active, most) = (active.clone(), most.clone()); + std::thread::spawn(move || { + one_at_a_time( + &Cancel::default(), + Limits::for_action(Action::Status), + |_| { + let now = active.fetch_add(1, Ordering::SeqCst) + 1; + most.fetch_max(now, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(50)); + active.fetch_sub(1, Ordering::SeqCst); + Ok(()) + }, + ) + }) + }) + .collect(); + for call in calls { + assert_eq!(call.join().unwrap(), Ok(())); + } + assert_eq!(most.load(Ordering::SeqCst), 1); + } + + #[test] + fn a_call_cancelled_while_waiting_for_its_turn_never_runs() { + let turn = hold_turn(); + let cancel = Cancel::default(); + let ran = Arc::new(AtomicBool::new(false)); + let waiting = { + let (cancel, ran) = (cancel.clone(), ran.clone()); + std::thread::spawn(move || { + one_at_a_time(&cancel, Limits::for_action(Action::Stop), |_| { + ran.store(true, Ordering::SeqCst); + Ok(()) + }) + }) + }; + std::thread::sleep(Duration::from_millis(100)); + cancel.cancel(); + // Released only after the cancel, so a call that missed it would run and fail the test + // rather than hang it. + std::thread::sleep(Duration::from_millis(100)); + drop(turn); + assert_eq!(waiting.join().unwrap(), Err(Failure::Cancelled)); + assert!(!ran.load(Ordering::SeqCst)); + } + + #[test] + fn a_call_that_waits_past_its_deadline_never_runs() { + let turn = hold_turn(); + let ran = AtomicBool::new(false); + let result = one_at_a_time( + &Cancel::default(), + short(Duration::from_millis(100)), + |_| { + ran.store(true, Ordering::SeqCst); + Ok(()) + }, + ); + drop(turn); + assert_eq!(result, Err(Failure::Timeout)); + assert!(!ran.load(Ordering::SeqCst)); + } + + #[test] + fn waiting_for_a_turn_counts_against_the_deadline() { + let turn = hold_turn(); + let (asking, asked) = std::sync::mpsc::channel(); + let waiting = std::thread::spawn(move || { + asking.send(()).unwrap(); + one_at_a_time( + &Cancel::default(), + short(Duration::from_secs(10)), + |limits| Ok(limits.timeout), + ) + }); + asked.recv().unwrap(); + std::thread::sleep(Duration::from_millis(200)); + drop(turn); + let left = waiting.join().unwrap().unwrap(); + assert!(left <= Duration::from_millis(9_900), "{left:?}"); + } + + #[test] + fn a_cancelled_attempt_does_not_run_the_cli() { + let fixture = Fixture::new("local-eval-precancelled", &format!("0\n{}", envelope())); + let cancelled = Cancel::default(); + cancelled.cancel(); + let result = one_at_a_time(&cancelled, short(Duration::from_secs(30)), |limits| { + fixture.run(Action::Status, limits, &cancelled) + }); + assert_eq!(result, Err(Failure::Cancelled)); + assert_eq!(fixture.argv(), None, "the fake CLI ran"); + // Through the public entry point, the cancel is seen before looking for a CLI at all. + let stack = StackId::parse("local-abc123").unwrap(); + cancel("precancelled-attempt"); + assert_eq!( + run_installed("precancelled-attempt", Action::Start, &stack), + Err(Failure::Cancelled) + ); + } + + #[test] + fn calls_are_refused_while_a_stop_is_closing_and_allowed_after() { + // Closing is process-wide, and would refuse other tests' calls. + if crate::test_support::isolated_process( + "local_eval::tests::calls_are_refused_while_a_stop_is_closing_and_allowed_after", + ) { + return; + } + let stack = StackId::parse("local-abc123").unwrap(); + let running = Attempt::register("running-before-stop"); + let closing = closing_for_stop(); + assert!(running.0.requested(), "a call already running is cancelled"); + assert_eq!( + run_installed("start-during-stop", Action::Start, &stack), + Err(Failure::Cancelled) + ); + assert_eq!(list_installed("list-during-stop"), Err(Failure::Cancelled)); + // The shutdown's own stop is the one call allowed through; this build then has no CLI. + assert_eq!( + stop_for_shutdown("the-shutdown", &stack, Limits::for_quit()), + Err(Failure::Unavailable) + ); + let second = closing_for_stop(); + drop(closing); + assert_eq!( + run_installed("status-during-second-stop", Action::Status, &stack), + Err(Failure::Cancelled), + "one Stop finishing does not reopen for another still running" + ); + drop(second); + assert_eq!( + run_installed("status-after-stop", Action::Status, &stack), + Err(Failure::Unavailable) + ); + } + + #[test] + fn quitting_closes_for_good_and_cancels_a_stop_in_progress() { + if crate::test_support::isolated_process( + "local_eval::tests::quitting_closes_for_good_and_cancels_a_stop_in_progress", + ) { + return; + } + let stack = StackId::parse("local-abc123").unwrap(); + let stopping = closing_for_stop(); + let stops = Attempt::register_for_shutdown("stop-in-progress"); + close_for_quit(); + assert!( + stops.0.requested(), + "Quit does not wait behind Stop's own stop" + ); + drop(stopping); + drop(closing_for_stop()); + assert_eq!( + run_installed("status-after-quit", Action::Status, &stack), + Err(Failure::Cancelled) + ); + assert_eq!( + stop_for_shutdown("quit-shutdown", &stack, Limits::for_quit()), + Err(Failure::Unavailable) + ); + } + + #[test] + #[cfg(unix)] + fn quitting_waits_until_a_cli_that_ignores_sigterm_is_gone() { + // Quit used to only ask: the app could exit before the CLI's SIGKILL, and the CLI then ran + // on with nobody to stop it, holding its stack's lock. + if crate::test_support::isolated_process( + "local_eval::tests::quitting_waits_until_a_cli_that_ignores_sigterm_is_gone", + ) { + return; + } + let fixture = Fixture::new("local-eval-quit-stubborn", "stubborn"); + let cli = fixture.cli(); + let attempt = Attempt::register("stubborn-call"); + let cancel = attempt.0.clone(); + let call = std::thread::spawn(move || { + run( + &cli, + Action::Status, + &StackId::parse("local-abc123").unwrap(), + Limits { + timeout: Duration::from_secs(60), + grace: Duration::from_secs(1), + output: MAX_OUTPUT, + }, + &cancel, + ) + }); + Fixture::wait_for_grandchild(&fixture.root); + let pid: libc::pid_t = std::fs::read_to_string(fixture.root.join("pid")) + .unwrap() + .trim() + .parse() + .unwrap(); + let alive = |pid: libc::pid_t| unsafe { libc::kill(pid, 0) } == 0; + assert!(alive(pid)); + + close_for_quit(); + + // Gone when Quit goes on, not some time after. + assert!(!alive(pid), "the CLI outlived close_for_quit"); + let grandchild = fixture.grandchild(); + let until = Instant::now() + Duration::from_secs(2); + while alive(grandchild) && Instant::now() < until { + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + !alive(grandchild), + "the CLI's child outlived close_for_quit" + ); + assert_eq!(call.join().unwrap(), Err(Failure::Cancelled)); + drop(attempt); + } + + #[test] + fn remembered_cancels_are_bounded() { + // Evicting the oldest remembered cancel would race with tests that rely on one. + if crate::test_support::isolated_process( + "local_eval::tests::remembered_cancels_are_bounded", + ) { + return; + } + for index in 0..(REMEMBERED * 2) { + cancel(&format!("never-runs-{index}")); + } + assert!(in_flight().len() <= REMEMBERED + 8); + } + + #[test] + fn identifiers_cannot_carry_prose_and_observations_are_bounded() { + for step in ["services start", "a\nb", "ready;", &"a".repeat(129)] { + let mut input = envelope(); + input["stack"]["step"] = json!(step); + assert_eq!(status(&input), Err(Failure::InvalidStatus), "{step:?}"); + } + let mut input = envelope(); + input["stack"]["observed"]["services"][0]["phase"] = json!("Error: token=abc"); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + let service = json!({ "name": "pod", "phase": "Running", "ready": true }); + input = envelope(); + input["stack"]["observed"]["services"] = json!(vec![service; MAX_SERVICES + 1]); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + input = envelope(); + input["stack"]["installedRelease"] = json!("0.15.0-rc.1+build.7"); + assert!(status(&input).is_ok()); + } + + #[test] + fn every_failure_category_is_known_to_the_window() { + let window = include_str!("../../src/localEvalCli.ts"); + let all = [ + Failure::Unavailable, + Failure::Unverified, + Failure::InvalidStack, + Failure::Spawn, + Failure::Timeout, + Failure::Cancelled, + Failure::OutputLimit, + Failure::InvalidJson, + Failure::UnsupportedVersion, + Failure::UnexpectedCommand, + Failure::InvalidStatus, + Failure::MissingStack, + Failure::UnsafeEndpoint, + Failure::ContractMismatch, + Failure::UnexpectedExit, + Failure::InvalidAcceptance, + ]; + // Adding a variant without listing it above stops compiling here. + for failure in all { + match failure { + Failure::Unavailable + | Failure::Unverified + | Failure::InvalidStack + | Failure::Spawn + | Failure::Timeout + | Failure::Cancelled + | Failure::OutputLimit + | Failure::InvalidJson + | Failure::UnsupportedVersion + | Failure::UnexpectedCommand + | Failure::InvalidStatus + | Failure::MissingStack + | Failure::UnsafeEndpoint + | Failure::ContractMismatch + | Failure::UnexpectedExit + | Failure::InvalidAcceptance => {} + } + let name = serde_json::to_string(&failure).unwrap(); + assert!(window.contains(&name), "localEvalCli.ts is missing {name}"); + } + } + + #[test] + fn every_reason_readiness_and_licence_is_known_to_the_window() { + let window = include_str!("../../src/localEvalCli.ts"); + let reasons = [ + "authentication_required", + "eula_acceptance_required", + "eula_unavailable", + "eula_declined", + "release_unavailable", + "feature_disabled", + "unsupported_platform", + "unsupported_engine", + "port_unavailable", + "docker_unavailable", + "update_recovery_required", + "incompatible_release", + "local_command_busy", + "local_command_failed", + "confirmation_required", + "something_new", + ] + .map(Reason::from_wire) + .map(|reason| serde_json::to_string(&reason).unwrap()); + let readiness = [ + Readiness::Ready, + Readiness::Starting, + Readiness::Stopped, + Readiness::Degraded, + Readiness::Unknown, + ] + .map(|state| serde_json::to_string(&state).unwrap()); + let licence = [ + Licence::Valid, + Licence::Expired, + Licence::Missing, + Licence::Invalid, + Licence::OrganizationMismatch, + Licence::Unknown, + ] + .map(|state| serde_json::to_string(&state).unwrap()); + for name in reasons.iter().chain(&readiness).chain(&licence) { + assert!( + window.contains(name.as_str()), + "localEvalCli.ts is missing {name}" + ); + } + } + + // A fake CLI. It records its arguments, then does what `scenario` (beside it) says: an exit + // code and a body to print, or `sleep`, `hold` or `flood`. `sleep` and `hold` start a + // grandchild that sleeps, holding stdout, so tree cleanup can be observed. `--warm` only + // exits: macOS checks a newly written executable on its first run, and that delay belongs + // before a test's deadline starts, not inside it. + const FAKE_CLI: &str = r#" +use std::io::Write; +fn main() { + let exe = std::env::current_exe().unwrap(); + let dir = exe.parent().unwrap().to_path_buf(); + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("--warm") => return, + Some("--grandchild") => { + std::thread::sleep(std::time::Duration::from_secs(60)); + return; + } + _ => {} + } + std::fs::write(dir.join("argv"), args.join("\n")).unwrap(); + std::fs::write(dir.join("cwd"), std::env::current_dir().unwrap().display().to_string()).unwrap(); + let inherited = |name: &str| format!("{name}={}", std::env::var(name).is_ok()); + std::fs::write(dir.join("env"), [inherited("NODE_OPTIONS"), inherited("HOME"), inherited("DO_NOT_TRACK")].join("\n")).unwrap(); + let scenario = std::fs::read_to_string(dir.join("scenario")).unwrap(); + let (mode, body) = scenario.split_once('\n').unwrap_or((scenario.as_str(), "")); + match mode { + "sleep" | "hold" => { + let grandchild = std::process::Command::new(&exe).arg("--grandchild").spawn().unwrap(); + std::fs::write(dir.join("grandchild.tmp"), grandchild.id().to_string()).unwrap(); + std::fs::rename(dir.join("grandchild.tmp"), dir.join("grandchild")).unwrap(); + if mode == "hold" { + print!("{body}"); + std::io::stdout().flush().unwrap(); + return; + } + std::thread::sleep(std::time::Duration::from_secs(60)); + } + // Like the real CLI, which forwards SIGTERM to its children instead of exiting: only + // SIGKILL stops it. + "stubborn" => { + #[cfg(unix)] + { + extern "C" { + fn signal(signum: i32, handler: usize) -> usize; + } + // SAFETY: SIGTERM (15) set to SIG_IGN (1) before any other thread exists. + unsafe { signal(15, 1) }; + } + std::fs::write(dir.join("pid"), std::process::id().to_string()).unwrap(); + let grandchild = std::process::Command::new(&exe).arg("--grandchild").spawn().unwrap(); + std::fs::write(dir.join("grandchild.tmp"), grandchild.id().to_string()).unwrap(); + std::fs::rename(dir.join("grandchild.tmp"), dir.join("grandchild")).unwrap(); + std::thread::sleep(std::time::Duration::from_secs(60)); + } + "flood" => { + let chunk = vec![b' '; 65536]; + let mut out = std::io::stdout(); + loop { + if out.write_all(&chunk).is_err() { + std::process::exit(0); + } + } + } + code => { + eprint!("synthetic-secret-marker"); + print!("{body}"); + std::io::stdout().flush().unwrap(); + std::process::exit(code.parse().unwrap()); + } + } +} +"#; + + fn compiled() -> &'static Path { + static BINARY: OnceLock = OnceLock::new(); + BINARY.get_or_init(|| { + let root = temp_root("local-eval-cli-build"); + std::fs::create_dir_all(&root).unwrap(); + let source = root.join("cli.rs"); + std::fs::write(&source, FAKE_CLI).unwrap(); + let binary = root.join(format!("cli{}", std::env::consts::EXE_SUFFIX)); + compile_fixture(&source, &binary); + binary + }) + } + + fn sha256(path: &Path) -> String { + Sha256::digest(std::fs::read(path).unwrap()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + + struct Fixture { + root: PathBuf, + program: PathBuf, + } + + impl Fixture { + fn new(label: &str, scenario: &str) -> Self { + let root = temp_root(label); + std::fs::create_dir_all(&root).unwrap(); + let program = root.join(format!("cli{}", std::env::consts::EXE_SUFFIX)); + std::fs::copy(compiled(), &program).unwrap(); + std::fs::write(root.join("scenario"), scenario).unwrap(); + let warm = std::process::Command::new(&program).arg("--warm").status(); + assert!(warm.unwrap().success()); + Self { root, program } + } + + fn pin(&self) -> Pin { + Pin { + path: self.program.clone(), + sha256: sha256(&self.program), + } + } + + fn cli(&self) -> PinnedCli { + PinnedCli::verify(&self.pin(), None).unwrap() + } + + fn argv(&self) -> Option { + std::fs::read_to_string(self.root.join("argv")).ok() + } + + /// The folder the program ran in, resolved: Windows reports the temporary folder by its + /// short 8.3 name (`RUNNER~1`) and `canonicalize` by its long one, with a `\\?\` prefix. + fn cwd(&self) -> PathBuf { + PathBuf::from(std::fs::read_to_string(self.root.join("cwd")).unwrap()) + .canonicalize() + .unwrap() + } + + fn run(&self, action: Action, limits: Limits, cancel: &Cancel) -> Result { + run( + &self.cli(), + action, + &StackId::parse("local-abc123").unwrap(), + limits, + cancel, + ) + } + + /// The fake has started the grandchild it will hold the call open with. + fn wait_for_grandchild(root: &Path) { + let until = Instant::now() + Duration::from_secs(30); + while !root.join("grandchild").exists() { + assert!( + Instant::now() < until, + "the fake never started its grandchild" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[cfg(unix)] + fn grandchild(&self) -> libc::pid_t { + std::fs::read_to_string(self.root.join("grandchild")) + .expect("the fake started its grandchild before the call ended") + .parse() + .unwrap() + } + + #[cfg(unix)] + fn grandchild_stops(&self) -> bool { + let pid = self.grandchild(); + let until = Instant::now() + Duration::from_secs(5); + while Instant::now() < until { + // SAFETY: signal 0 only checks existence. + if unsafe { libc::kill(pid, 0) } != 0 { + return true; + } + std::thread::sleep(Duration::from_millis(20)); + } + false + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + fn short(timeout: Duration) -> Limits { + Limits { + timeout, + grace: Duration::from_millis(500), + output: MAX_OUTPUT, + } + } + + #[test] + fn runs_the_fixed_arguments_and_keeps_only_the_report() { + let fixture = Fixture::new("local-eval-status", &format!("0\n{}", envelope())); + let report = fixture + .run( + Action::Status, + Limits::for_action(Action::Status), + &Cancel::default(), + ) + .unwrap(); + assert_eq!(report.outcome, Outcome::Completed); + assert_eq!( + fixture.argv().unwrap(), + "local\nstatus\n--stack\nlocal-abc123\n--json" + ); + // The fake writes the marker to stderr; stderr is never read. + assert!(!exposes_secret(&report)); + } + + #[test] + fn a_blocked_call_never_runs_its_recovery_command() { + let fixture = Fixture::new("local-eval-blocked", ""); + let executed = fixture.root.join("executed"); + let body = json!({ + "version": 1, + "command": "local start", + "status": "blocked", + "error": null, + "nextAction": { + "reason": "authentication_required", + "summary": SECRET, + "resumeCommand": format!("touch {}", executed.display()) + } + }); + std::fs::write(fixture.root.join("scenario"), format!("2\n{body}")).unwrap(); + let report = fixture + .run( + Action::Start, + Limits::for_action(Action::Start), + &Cancel::default(), + ) + .unwrap(); + assert_eq!(report.outcome, Outcome::Blocked); + assert_eq!(report.reason, Some(Reason::AuthenticationRequired)); + assert!(!executed.exists()); + assert!(!exposes_secret(&report)); + } + + #[test] + fn exit_codes_local_does_not_use_are_not_believed() { + let fixture = Fixture::new("local-eval-exit", &format!("3\n{}", envelope())); + assert_eq!( + fixture.run( + Action::Status, + Limits::for_action(Action::Status), + &Cancel::default() + ), + Err(Failure::UnexpectedExit) + ); + } + + #[test] + fn a_cli_that_does_not_match_its_pin_is_never_run() { + let fixture = Fixture::new("local-eval-unverified", &format!("0\n{}", envelope())); + let mut pin = fixture.pin(); + pin.sha256 = "0".repeat(64); + assert!(matches!( + PinnedCli::verify(&pin, None), + Err(Failure::Unverified) + )); + let missing = Pin { + path: fixture.root.join("absent"), + sha256: "0".repeat(64), + }; + assert!(matches!( + PinnedCli::verify(&missing, None), + Err(Failure::Unverified) + )); + // An entry script is pinned the same way. + assert!(matches!( + PinnedCli::verify(&fixture.pin(), Some(&missing)), + Err(Failure::Unverified) + )); + assert_eq!(fixture.argv(), None); + } + + #[cfg(unix)] + #[test] + fn a_link_to_the_pinned_file_is_not_the_pinned_file() { + let fixture = Fixture::new("local-eval-link", "0\n"); + let link = fixture.root.join("linked"); + std::os::unix::fs::symlink(&fixture.program, &link).unwrap(); + let pin = Pin { + path: link, + sha256: sha256(&fixture.program), + }; + assert!(matches!( + PinnedCli::verify(&pin, None), + Err(Failure::Unverified) + )); + } + + #[test] + fn a_verified_entry_script_is_the_first_argument() { + let fixture = Fixture::new("local-eval-entry", &format!("0\n{}", envelope())); + let entry = fixture.root.join("index.js"); + std::fs::write(&entry, "// pinned entry").unwrap(); + let entry_pin = Pin { + path: entry.clone(), + sha256: sha256(&entry), + }; + let cli = PinnedCli::verify(&fixture.pin(), Some(&entry_pin)).unwrap(); + run( + &cli, + Action::Status, + &StackId::parse("local-abc123").unwrap(), + Limits::for_action(Action::Status), + &Cancel::default(), + ) + .unwrap(); + assert_eq!( + fixture.argv().unwrap(), + format!( + "{}\nlocal\nstatus\n--stack\nlocal-abc123\n--json", + entry.display() + ) + ); + } + + #[test] + fn a_call_past_its_deadline_stops_its_whole_tree() { + let fixture = Fixture::new("local-eval-timeout", "sleep"); + let started = Instant::now(); + assert_eq!( + fixture.run( + Action::Start, + short(Duration::from_secs(5)), + &Cancel::default() + ), + Err(Failure::Timeout) + ); + assert!(started.elapsed() < Duration::from_secs(15)); + #[cfg(unix)] + assert!( + fixture.grandchild_stops(), + "grandchild outlived the timeout" + ); + } + + #[test] + fn a_cancelled_call_stops_its_whole_tree() { + let fixture = Fixture::new("local-eval-cancel", "sleep"); + let cancel = Cancel::default(); + let later = cancel.clone(); + let root = fixture.root.clone(); + std::thread::spawn(move || { + Fixture::wait_for_grandchild(&root); + later.cancel(); + }); + let started = Instant::now(); + assert_eq!( + fixture.run(Action::Start, short(Duration::from_secs(60)), &cancel), + Err(Failure::Cancelled) + ); + assert!(started.elapsed() < Duration::from_secs(10)); + #[cfg(unix)] + assert!( + fixture.grandchild_stops(), + "grandchild outlived cancellation" + ); + } + + #[test] + fn too_much_output_stops_the_call() { + let fixture = Fixture::new("local-eval-flood", "flood"); + let started = Instant::now(); + assert_eq!( + fixture.run( + Action::Status, + short(Duration::from_secs(60)), + &Cancel::default() + ), + Err(Failure::OutputLimit) + ); + assert!(started.elapsed() < Duration::from_secs(10)); + } + + #[test] + fn a_cli_that_exits_is_finished_even_if_something_keeps_its_output_open() { + let fixture = Fixture::new("local-eval-hold", &format!("hold\n{}", envelope())); + let started = Instant::now(); + let report = fixture + .run( + Action::Status, + short(Duration::from_secs(30)), + &Cancel::default(), + ) + .unwrap(); + assert_eq!(report.outcome, Outcome::Completed); + assert!(started.elapsed() < Duration::from_secs(10)); + #[cfg(unix)] + { + // A CLI that exited normally keeps what it left running; this test cleans it up. + let pid = fixture.grandchild(); + // SAFETY: signal 0 checks existence; SIGKILL stops the fixture's own grandchild. + assert_eq!(unsafe { libc::kill(pid, 0) }, 0, "the holder was stopped"); + unsafe { libc::kill(pid, libc::SIGKILL) }; + } + } + + #[test] + fn the_cli_does_not_inherit_code_loading_settings() { + if crate::test_support::isolated_process( + "local_eval::tests::the_cli_does_not_inherit_code_loading_settings", + ) { + return; + } + std::env::set_var("NODE_OPTIONS", "--require=/tmp/synthetic-secret-marker.js"); + std::env::set_var("DO_NOT_TRACK", "1"); + let fixture = Fixture::new("local-eval-env", &format!("0\n{}", envelope())); + fixture + .run( + Action::Status, + Limits::for_action(Action::Status), + &Cancel::default(), + ) + .unwrap(); + let env = std::fs::read_to_string(fixture.root.join("env")).unwrap(); + assert!(env.contains("NODE_OPTIONS=false"), "{env}"); + assert!(env.contains("DO_NOT_TRACK=true"), "{env}"); + assert_eq!( + env.contains("HOME=true"), + std::env::var_os("HOME").is_some(), + "{env}" + ); + } + + fn listing(stacks: Value) -> Value { + json!({ + "version": 1, + "command": "local list", + "status": "completed", + "stacks": stacks, + "nextAction": null, + "error": null, + "diagnostics": [] + }) + } + + fn listed_stack(id: &str) -> Value { + let mut stack = envelope()["stack"].clone(); + stack["id"] = json!(id); + stack.as_object_mut().unwrap().remove("observed"); + stack["appPaths"] = + json!([{ "path": format!("/Users/someone/{SECRET}"), "missing": false }]); + stack + } + + #[test] + fn a_listing_keeps_each_stack_and_none_of_its_app_folders() { + let input = listing(json!([ + listed_stack("local-one"), + listed_stack("local-two") + ])); + let listed = normalize_list(0, input.to_string().as_bytes()).unwrap(); + let ids: Vec<_> = listed + .stacks + .iter() + .map(|stack| stack.id.as_str()) + .collect(); + assert_eq!(ids, ["local-one", "local-two"]); + assert!(!serde_json::to_string(&listed).unwrap().contains(SECRET)); + let empty = normalize_list(0, listing(json!([])).to_string().as_bytes()).unwrap(); + assert!(empty.stacks.is_empty()); + } + + #[test] + fn a_listing_fails_whole_rather_than_partly() { + let mut bad = listed_stack("local-two"); + bad["urls"]["api"] = json!("http://example.com:8082"); + let input = listing(json!([listed_stack("local-one"), bad])); + assert_eq!( + normalize_list(0, input.to_string().as_bytes()), + Err(Failure::UnsafeEndpoint) + ); + let mut input = listing(json!([])); + input.as_object_mut().unwrap().remove("stacks"); + assert_eq!( + normalize_list(0, input.to_string().as_bytes()), + Err(Failure::InvalidStatus) + ); + let many = vec![listed_stack("local-one"); MAX_STACKS + 1]; + assert_eq!( + normalize_list(0, listing(json!(many)).to_string().as_bytes()), + Err(Failure::InvalidStatus) + ); + assert_eq!( + normalize_list(0, envelope().to_string().as_bytes()), + Err(Failure::UnexpectedCommand) + ); + assert_eq!( + normalize_list(2, listing(json!([])).to_string().as_bytes()), + Err(Failure::ContractMismatch) + ); + let failed = json!({ + "version": 1, "command": "local list", "status": "failed", + "error": { "message": SECRET }, + "nextAction": { "reason": "local_command_failed", "resumeCommand": SECRET } + }); + let listed = normalize_list(1, failed.to_string().as_bytes()).unwrap(); + assert_eq!(listed.reason, Some(Reason::LocalCommandFailed)); + assert!(listed.stacks.is_empty()); + assert!(!serde_json::to_string(&listed).unwrap().contains(SECRET)); + } + + #[test] + fn listing_runs_without_a_stack() { + let body = listing(json!([listed_stack("local-one")])); + let fixture = Fixture::new("local-eval-list", &format!("0\n{body}")); + let listed = list( + &fixture.cli(), + Limits::for_action(Action::Status), + &Cancel::default(), + ) + .unwrap(); + assert_eq!(listed.stacks[0].id, "local-one"); + assert_eq!(fixture.argv().unwrap(), "local\nlist\n--json"); + assert_eq!(list_installed("list-no-cli"), Err(Failure::Unavailable)); + } + + #[test] + fn an_accepted_agreement_version_is_an_identifier_and_reaches_setup_only_when_given() { + assert_eq!(setup_arguments(None), SETUP_ARGUMENTS.to_vec()); + let version = EulaVersion::parse("developer-edition-2026-10-01-web").unwrap(); + assert_eq!( + setup_arguments(Some(&version)), + [ + "local", + "setup", + "--defer-model", + "--accept-eula-version", + "developer-edition-2026-10-01-web", + "--json" + ] + ); + for refused in [ + "", + "--json", + "-x", + "v1 --stack other", + "v1\n", + "a/b", + &"v".repeat(129), + ] { + assert_eq!( + EulaVersion::parse(refused), + Err(Failure::InvalidAcceptance), + "{refused:?}" + ); + } + } + + #[test] + fn account_sign_in_believes_the_last_outcome_line_and_its_exit_code() { + let completed = concat!( + "{\"type\":\"action_required\",\"authentication_url\":\"https://x\",\"user_code\":\"ABCD\",\"browser_opened\":true}\n", + "{\"type\":\"completed\"}\n" + ); + assert_eq!( + normalize_account_sign_in(0, completed.as_bytes()), + Ok(AccountSignIn { signed_in: true }) + ); + assert_eq!( + normalize_account_sign_in( + 0, + b"{\"already_authenticated\":true,\"type\":\"completed\"}\n" + ), + Ok(AccountSignIn { signed_in: true }) + ); + assert_eq!( + normalize_account_sign_in(1, b"{\"type\":\"failed\",\"error\":\"denied\"}\n"), + Ok(AccountSignIn { signed_in: false }) + ); + // An outcome that disagrees with how it exited is not believed. + assert_eq!( + normalize_account_sign_in(1, b"{\"type\":\"completed\"}\n"), + Err(Failure::ContractMismatch) + ); + assert_eq!( + normalize_account_sign_in(0, b"{\"type\":\"action_required\"}\n"), + Err(Failure::InvalidStatus) + ); + assert_eq!( + normalize_account_sign_in(0, b"not json\n"), + Err(Failure::InvalidJson) + ); + } + + #[test] + fn terms_keep_the_agreement_and_nothing_the_cli_says_around_it() { + let required = r#"{"command":"onboard local-terms","status":"required","accepted":false,"version":"developer-edition-2026-10-01-web","url":"https://copilotkit.ai/legal/developer-edition","organizationId":"org_1","organizationName":"CopilotKit🪁","next":"Ask the developer to accept."}"# + .as_bytes(); + assert_eq!( + normalize_terms(0, required), + Ok(Terms::Required { + accepted: false, + version: "developer-edition-2026-10-01-web".into(), + url: "https://copilotkit.ai/legal/developer-edition".into(), + organization_name: "CopilotKit🪁".into(), + }) + ); + for status in ["not-required", "unavailable", "signed-out"] { + let wire = format!(r#"{{"command":"onboard local-terms","status":"{status}"}}"#); + assert!(normalize_terms(0, wire.as_bytes()).is_ok(), "{status}"); + } + for (wire, failure) in [ + ( + r#"{"command":"onboard local-terms","status":"required","accepted":false,"version":"v1","url":"http://copilotkit.ai/x","organizationName":"o"}"#, + Failure::InvalidStatus, + ), + ( + r#"{"command":"onboard local-terms","status":"required","accepted":false,"version":"--json","url":"https://copilotkit.ai/x","organizationName":"o"}"#, + Failure::InvalidStatus, + ), + ( + r#"{"command":"onboard local-terms","status":"required","accepted":false,"version":"v1","url":"https://copilotkit.ai/x","organizationName":"a\nb"}"#, + Failure::InvalidStatus, + ), + ( + r#"{"command":"local setup","status":"required"}"#, + Failure::UnexpectedCommand, + ), + ] { + assert_eq!(normalize_terms(0, wire.as_bytes()), Err(failure), "{wire}"); + } + assert_eq!(normalize_terms(1, b"{}"), Err(Failure::UnexpectedExit)); + } + + #[test] + fn the_window_reads_terms_in_the_clis_own_words() { + for (terms, status) in [ + (Terms::SignedOut, "signed-out"), + (Terms::NotRequired, "not-required"), + (Terms::Unavailable, "unavailable"), + ] { + assert_eq!( + serde_json::to_value(terms).unwrap(), + serde_json::json!({ "status": status }) + ); + } + assert_eq!( + serde_json::to_value(Terms::Required { + accepted: false, + version: "v1".into(), + url: "https://copilotkit.ai/x".into(), + organization_name: "o".into(), + }) + .unwrap(), + serde_json::json!({ + "status": "required", + "accepted": false, + "version": "v1", + "url": "https://copilotkit.ai/x", + "organizationName": "o", + }) + ); + } + + #[test] + fn a_build_without_a_bundled_cli_finds_none() { + // This test build was not made with OPENBOT_BUNDLED_CLI=1. + assert!(option_env!("OPENBOT_BUNDLED_CLI_PROGRAM_SHA256").is_none()); + assert!(bundled_cli().is_none()); + assert!(bundled_cli_root().is_some()); + } +} diff --git a/desktop/src-tauri/src/local_eval_state.rs b/desktop/src-tauri/src/local_eval_state.rs new file mode 100644 index 0000000000..50204709ab --- /dev/null +++ b/desktop/src-tauri/src/local_eval_state.rs @@ -0,0 +1,333 @@ +//! Where the local evaluation has got to, kept beside the installation. +//! +//! A checkpoint, not a credential: the stack id, who created the stack, the CLI digest it was +//! recorded with, the furthest phase reached, and whether OpenBot holds a key for the stack. Nothing here proves the stack is running or the +//! person is signed in; the CLI is asked for that every time. A missing, unreadable or unknown +//! record reads as no record, exactly like the installation's own checkpoints. +//! +//! Phases advance one at a time and never backwards, in the agreed order: installed, app opened, +//! signed in, provider validated. Activation is deliberately not a phase yet. It requires the CLI +//! to report readiness and licence, which version 1 cannot, so it cannot be recorded truthfully. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::local_eval::StackId; +use crate::problem::Problem; + +pub const FILE: &str = ".openbot-local-eval.json"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Phase { + Installed, + AppOpened, + SignedIn, + ProviderValidated, +} + +impl Phase { + fn next(self) -> Option { + match self { + Phase::Installed => Some(Phase::AppOpened), + Phase::AppOpened => Some(Phase::SignedIn), + Phase::SignedIn => Some(Phase::ProviderValidated), + Phase::ProviderValidated => None, + } + } +} + +/// Whether the stack exists only for OpenBot. +/// +/// A stack OpenBot created starts and stops with OpenBot. One it found already recorded may serve +/// other apps too, so OpenBot never stops it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Ownership { + Created, + Shared, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Record { + version: u8, + stack: String, + ownership: Ownership, + cli_sha256: String, + phase: Phase, + /// `local connect` ran and its key went into OpenBot's credential store. Absent until then, so + /// a record that was never connected keeps the fields it always had. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + connected: bool, +} + +/// What the window and the stop path may know. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct State { + pub stack: String, + pub ownership: Ownership, + pub phase: Phase, + pub connected: bool, +} + +impl State { + pub fn stack_id(&self) -> StackId { + // `read` and `begin` only ever hold an id that parsed. + StackId::parse(&self.stack).expect("a recorded stack id was validated") + } + + /// Whether stopping OpenBot also stops this stack. + pub fn tied_to_openbot(&self) -> bool { + self.ownership == Ownership::Created + } +} + +fn digest_valid(digest: &str) -> bool { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn load(root: &Path) -> Option { + let record: Record = serde_json::from_slice(&std::fs::read(root.join(FILE)).ok()?).ok()?; + (record.version == 1 + && StackId::parse(&record.stack).is_ok() + && digest_valid(&record.cli_sha256)) + .then_some(record) +} + +/** +The recorded state, or none. + +`cli_sha256` is the digest of the CLI this build would run, when it has one. A record made with a +different CLI keeps its stack and ownership but goes back to `installed`: what the earlier CLI +reported says nothing about this one. +*/ +pub fn read(root: &Path, cli_sha256: Option<&str>) -> Option { + let record = load(root)?; + let phase = match cli_sha256 { + Some(current) if current != record.cli_sha256 => Phase::Installed, + _ => record.phase, + }; + Some(State { + stack: record.stack, + ownership: record.ownership, + phase, + connected: record.connected, + }) +} + +fn problem(detail: String) -> Problem { + Problem::with( + "OpenBot could not save its local evaluation progress. Check that the installation folder \ + is writable, then try again.", + detail, + ) +} + +fn write(root: &Path, record: &Record) -> Result<(), Problem> { + crate::preparation::write_checkpoint(root, FILE, record).map_err(problem) +} + +/// Record a stack at `installed`, replacing any earlier record. Setting the same stack up again +/// resumes it, so its connection is kept; any other stack starts unconnected. +pub fn begin( + root: &Path, + stack: &StackId, + ownership: Ownership, + cli_sha256: &str, +) -> Result { + if !digest_valid(cli_sha256) { + return Err(Problem::plain("OpenBot refused an invalid CLI digest.")); + } + let record = Record { + version: 1, + stack: stack.as_str().to_owned(), + ownership, + cli_sha256: cli_sha256.to_owned(), + phase: Phase::Installed, + connected: load(root) + .is_some_and(|earlier| earlier.stack == stack.as_str() && earlier.connected), + }; + write(root, &record)?; + Ok(read(root, Some(cli_sha256)).expect("the record just written reads back")) +} + +/// Move to `to`, which must be the current phase or the one after it. +pub fn advance(root: &Path, cli_sha256: &str, to: Phase) -> Result { + let current = read(root, Some(cli_sha256)) + .ok_or_else(|| Problem::plain("OpenBot has no local evaluation progress to continue."))?; + if to != current.phase && current.phase.next() != Some(to) { + return Err(Problem::plain( + "OpenBot refused to skip or repeat a local evaluation step.", + )); + } + let mut record = load(root).expect("read found the record"); + record.phase = to; + record.cli_sha256 = cli_sha256.to_owned(); + write(root, &record)?; + Ok(read(root, Some(cli_sha256)).expect("the record just written reads back")) +} + +/// Record that OpenBot now holds the key `local connect` gave it for `stack`, the recorded one. +pub fn mark_connected(root: &Path, stack: &StackId) -> Result { + let mut record = load(root) + .filter(|record| record.stack == stack.as_str()) + .ok_or_else(|| Problem::plain("OpenBot has no record of that local Intelligence stack."))?; + record.connected = true; + write(root, &record)?; + Ok(read(root, None).expect("the record just written reads back")) +} + +pub fn clear(root: &Path) -> Result<(), Problem> { + match std::fs::remove_file(root.join(FILE)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(problem(error.to_string())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::temp_root; + + const PIN: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + const OTHER: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + + fn root(label: &str) -> std::path::PathBuf { + let root = temp_root(label); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn stack() -> StackId { + StackId::parse("local-abc123").unwrap() + } + + #[test] + fn missing_malformed_unknown_and_wrong_version_records_read_as_none() { + let root = root("local-eval-state-unknown"); + assert_eq!(read(&root, None), None); + for contents in [ + "not json".to_owned(), + format!(r#"{{"version":2,"stack":"local-abc123","ownership":"created","cliSha256":"{PIN}","phase":"installed"}}"#), + format!(r#"{{"version":1,"stack":"local-abc123","ownership":"created","cliSha256":"{PIN}","phase":"installed","token":"x"}}"#), + format!(r#"{{"version":1,"stack":"--json","ownership":"created","cliSha256":"{PIN}","phase":"installed"}}"#), + format!(r#"{{"version":1,"stack":"local-abc123","ownership":"created","cliSha256":"{PIN}","phase":"activated"}}"#), + r#"{"version":1,"stack":"local-abc123","ownership":"created","cliSha256":"short","phase":"installed"}"#.to_owned(), + ] { + std::fs::write(root.join(FILE), contents.as_bytes()).unwrap(); + assert_eq!(read(&root, None), None, "{contents}"); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn phases_advance_one_at_a_time_and_survive_a_restart() { + let root = root("local-eval-state-advance"); + let state = begin(&root, &stack(), Ownership::Created, PIN).unwrap(); + assert_eq!(state.phase, Phase::Installed); + assert!( + advance(&root, PIN, Phase::SignedIn).is_err(), + "skipped a phase" + ); + for phase in [Phase::AppOpened, Phase::SignedIn, Phase::ProviderValidated] { + assert_eq!(advance(&root, PIN, phase).unwrap().phase, phase); + // Repeating the current phase is harmless; reopening the app does it. + assert_eq!(advance(&root, PIN, phase).unwrap().phase, phase); + } + assert!( + advance(&root, PIN, Phase::AppOpened).is_err(), + "went backwards" + ); + // A restart is a fresh read of the same file. + let state = read(&root, Some(PIN)).unwrap(); + assert_eq!(state.phase, Phase::ProviderValidated); + assert_eq!(state.stack_id(), stack()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn a_different_cli_keeps_the_stack_but_restarts_the_phases() { + let root = root("local-eval-state-pin"); + begin(&root, &stack(), Ownership::Created, PIN).unwrap(); + advance(&root, PIN, Phase::AppOpened).unwrap(); + let state = read(&root, Some(OTHER)).unwrap(); + assert_eq!(state.phase, Phase::Installed); + assert_eq!(state.stack, "local-abc123"); + // Without a CLI to compare against, the record is reported as it was written. + assert_eq!(read(&root, None).unwrap().phase, Phase::AppOpened); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn only_a_stack_openbot_created_is_tied_to_it() { + let root = root("local-eval-state-owner"); + assert!(begin(&root, &stack(), Ownership::Created, PIN) + .unwrap() + .tied_to_openbot()); + assert!(!begin(&root, &stack(), Ownership::Shared, PIN) + .unwrap() + .tied_to_openbot()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn a_connection_is_kept_for_its_own_stack_only() { + let root = root("local-eval-state-connected"); + assert!(mark_connected(&root, &stack()).is_err(), "nothing recorded"); + assert!( + !begin(&root, &stack(), Ownership::Created, PIN) + .unwrap() + .connected + ); + let other = StackId::parse("local-other").unwrap(); + assert!(mark_connected(&root, &other).is_err(), "another stack"); + assert!(mark_connected(&root, &stack()).unwrap().connected); + assert!(read(&root, None).unwrap().connected); + // Setting the same stack up again resumes it, and its connection with it. + assert!( + begin(&root, &stack(), Ownership::Created, PIN) + .unwrap() + .connected + ); + assert!(advance(&root, PIN, Phase::AppOpened).unwrap().connected); + // A different stack starts unconnected. + assert!( + !begin(&root, &other, Ownership::Created, PIN) + .unwrap() + .connected + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn the_record_holds_only_its_own_fields() { + let root = root("local-eval-state-fields"); + begin(&root, &stack(), Ownership::Shared, PIN).unwrap(); + let written: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.join(FILE)).unwrap()).unwrap(); + let mut keys: Vec<_> = written.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + assert_eq!( + keys, + ["cliSha256", "ownership", "phase", "stack", "version"] + ); + assert!(begin( + &root, + &stack(), + Ownership::Shared, + "synthetic-secret-marker" + ) + .is_err()); + clear(&root).unwrap(); + clear(&root).unwrap(); + assert_eq!(read(&root, None), None); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3877d8b906..9f6c20f903 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -6,18 +6,25 @@ use std::sync::Mutex; mod desktop_connection; mod desktop_host_access; +mod desktop_page; +mod desktop_status; mod desktop_telemetry; +mod intelligence_bridge; #[cfg(test)] mod test_support; use openbot_desktop_lib::{ acquire, deployment, deployment_release, engine, env as openbot_env, harness, host_access, - install, preparation, problem::Problem, provider, pull_metrics, quiet, stack, supervise, - telemetry, tray, windows as win, + install, local_eval, local_eval_state, preparation, problem::Problem, provider, pull_metrics, + quiet, stack, supervise, telemetry, tray, windows as win, }; const QUIT_CLEANUP_NOTICE_FILE: &str = ".openbot-quit-cleanup-notice"; +/// Kept apart from the quit cleanup notice, whose presence alone means OpenBot needs recovery. +const LOCAL_EVAL_STOP_NOTICE_FILE: &str = ".openbot-local-eval-stop-notice"; +/// How an Intelligence failure starts, so Stop and Quit can tell it from OpenBot's own. +const INTELLIGENCE_STOP_FAILED: &str = "Intelligence stop failed: "; const QUIT_MENU_ACCELERATOR: &str = "CmdOrCtrl+KeyQ"; const QUIT_CLEANUP_NOTICE_LIMIT: usize = 16 * 1024; use serde::{Deserialize, Serialize}; @@ -89,6 +96,15 @@ struct Shell { /// The configured setup destination, resolved using Tauri's build mode and platform. /// WebView2's current URL can still be about:blank during startup; it is never a setup source. setup_url: Mutex>, + /// The installation whose Intelligence dashboard the menus open, while the window is in local + /// mode with a recorded stack. None hides the item. + intelligence_menu: Mutex>, + /// The menu's "Open Intelligence dashboard" is running; its item is disabled until it ends. + intelligence_opening: std::sync::Arc, + /// Dashboard addresses and this session's sign-ins, so a later opening needs no CLI call. + dashboards: Dashboards, + /// The OpenBot app page's sidebar row, and the app origins allowed to reach it. + intelligence_bridge: intelligence_bridge::Bridge, } /// The deployment whose Compose up may have created containers, including a partial failure. @@ -199,7 +215,7 @@ impl Drop for StartAttempt<'_> { } } -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, Debug, PartialEq)] struct Progress { step: String, ok: bool, @@ -247,6 +263,10 @@ struct AlreadyConfigured { #[serde(skip_serializing_if = "Option::is_none")] installation: Option, auto_start: bool, + /// Where the local evaluation has got to, if one was ever begun here. The checkpoint only: + /// whether its stack is running is the CLI's to say. + #[serde(skip_serializing_if = "Option::is_none")] + local_eval: Option, } struct ReadyRespondingEngine { @@ -393,7 +413,15 @@ fn detect_engine(app: tauri::AppHandle) -> engine::EngineS fn windows_blocker( app: tauri::AppHandle, ) -> Result, Problem> { - let result = win::blocker(); + // The rest of the machine is ready: ask WSL whether it can start a VM at all, unless Docker + // already answers, which proves it can. + let result = win::blocker().map(|found| { + found.or_else(|| { + (!openbot_desktop_lib::docker_desktop::answers()) + .then(win::nested_virtualization_blocker) + .flatten() + }) + }); if cfg!(target_os = "windows") { use telemetry::WindowsStageOutcome as Outcome; let outcome = match &result { @@ -405,6 +433,9 @@ fn windows_blocker( Outcome::VirtualMachinePlatformDisabled } Ok(Some(win::Blocker::VirtualizationDisabled)) => Outcome::VirtualizationDisabled, + Ok(Some(win::Blocker::NestedVirtualizationUnavailable)) => { + Outcome::NestedVirtualizationUnavailable + } Ok(Some(win::Blocker::NotAdministrator)) => Outcome::NotAdministrator, Err(_) => Outcome::CheckFailed, }; @@ -430,6 +461,60 @@ fn windows_blocker_instruction(blocker: win::Blocker) -> String { blocker.instruction().to_string() } +/// Clear a Windows blocker by running its fix, behind Windows's own administrator prompt. +/// +/// A fix that needs a restart is recorded against this boot, so the next launch knows whether the +/// restart has happened rather than trusting a feature that reads as on but is not yet running. +#[tauri::command] +async fn windows_fix(app: tauri::AppHandle, blocker: win::Blocker) -> Result { + let failed = |detail: String| { + Problem::with( + "OpenBot could not set up Windows Subsystem for Linux. Try again, or run the commands \ + yourself.", + detail, + ) + }; + let fix = blocker.fix().ok_or_else(|| { + Problem::plain("OpenBot cannot change this setting. Follow the steps on this screen.") + })?; + let data = app + .path() + .app_local_data_dir() + .map_err(|error| failed(error.to_string()))?; + tauri::async_runtime::spawn_blocking(move || { + let fixed = win::run_fix(fix, &data)?; + if fixed == win::Fixed::RestartNeeded { + win::record_restart_needed(&data, &win::boot()?) + .map_err(|error| failed(error.to_string()))?; + } + Ok(fixed) + }) + .await + .map_err(|error| failed(error.to_string()))? +} + +/// Whether a fix made earlier is still waiting for Windows to restart. +#[tauri::command] +async fn windows_restart_pending(app: tauri::AppHandle) -> Result { + if !cfg!(target_os = "windows") { + return Ok(false); + } + let Ok(data) = app.path().app_local_data_dir() else { + return Ok(false); + }; + tauri::async_runtime::spawn_blocking(move || Ok(win::restart_pending(&data, &win::boot()?))) + .await + .map_err(|error| { + Problem::with("OpenBot could not check Windows setup.", error.to_string()) + })? +} + +/// Restart Windows to finish a fix, with OpenBot set to open again afterwards. +#[tauri::command] +fn windows_restart() -> Result<(), Problem> { + win::restart() +} + /// Bring the engine up: create the machine if it is missing, start it, then prove it answers. /// /// Reported step by step rather than as one result, because these take minutes and a window with @@ -658,20 +743,36 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result telemetry::Engine::Docker, + engine::Engine::Podman => telemetry::Engine::Podman, + }, + outcome: if success { + telemetry::EngineInstallOutcome::Success + } else { + telemetry::EngineInstallOutcome::Failure + }, }, - }, - ); - }) + ); + }, + // Starting or installing Docker Desktop takes minutes; each line says where it is. + &mut |progress| { + report_running( + &progress_app, + "install-engine", + progress.said, + progress.downloaded, + ) + }, + ) }) .await .map_err(|error| { @@ -1256,7 +1357,8 @@ async fn start_stack( // converts to the plain half, so a path without its own sentence reads as it always did. ) -> Result<(), openbot_desktop_lib::problem::Problem> { let root = stack::root_from(&root); - start_stack_inner( + let menus = app.clone(); + let started = start_stack_inner( app, root, api_url, @@ -1266,7 +1368,10 @@ async fn start_stack( harness, organization_auth_url, ) - .await + .await; + // The menu bar says whether OpenBot is running. + refresh_menus(&menus); + started } #[allow( @@ -1655,9 +1760,11 @@ async fn stop_stack( ) -> Result<(), String> { // Inventory, held-child cleanup and Compose all block. Keep the complete shutdown off both // Tauri's event loop and its async workers, and resolve IPC only when shutdown has finished. - tauri::async_runtime::spawn_blocking(move || stop_everything(&app, &stack::root_from(&root))) - .await - .map_err(|error| format!("the shutdown did not run: {error}"))? + tauri::async_runtime::spawn_blocking(move || { + stop_everything(&app, &stack::root_from(&root)).into_result() + }) + .await + .map_err(|error| format!("the shutdown did not run: {error}"))? } #[cfg(test)] @@ -1671,20 +1778,219 @@ fn shutdown_root(shell: &Shell, fallback_root: &Path) -> PathBuf { root } -/// Take the whole stack down: the host processes, anything left over, and the containers. +/// Take the whole stack down: the host processes, anything left over, the containers, and the +/// local Intelligence stack if OpenBot created it. /// /// One implementation, because there are three ways to ask for it (the button, the menu bar, and /// quitting) and a person who used one of them and got a different amount of stopping would be /// right to call that a bug. -fn stop_everything( - app: &tauri::AppHandle, - fallback_root: &Path, -) -> Result<(), String> { - let shell = app.state::(); - let root = root_for_stop(&shell, fallback_root); - stop_everything_with(&shell, &root, stack::stop_processes_under, |root| { - down_owned_containers(&shell, root) +fn stop_everything(app: &tauri::AppHandle, fallback_root: &Path) -> Stopped { + stop_everything_for(&app.state::(), fallback_root) +} + +/// Stop as the button and the menu bar run it. +fn stop_everything_for(shell: &Shell, fallback_root: &Path) -> Stopped { + let root = root_for_stop(shell, fallback_root); + stop_everything_then( + shell, + &root, + stack::stop_processes_under, + |root| down_owned_containers(shell, root), + intelligence_step(Shutdown::Stop), + ) +} + +/// Quit's cleanup as the exit handler runs it, telling `progress` as each part starts and ends. +fn quit_cleanup

(shell: &Shell, progress: P) -> Stopped +where + P: FnMut(QuitStep, QuitStepState), +{ + exit_cleanup_reporting( + shell, + &stack::default_root(), + stack::stop_processes_under, + |root| down_containers_on_quit(shell, root), + intelligence_step(Shutdown::Quit), + progress, + ) +} + +/** +Quit with the Shutting down screen in front: the window comes back and shows each part of the +cleanup as it runs, then the app exits as it always has. + +The window only listens. Nothing here waits for it: with no window, or a page that never loads, +the same cleanup runs in the same order and the exit follows. +*/ +fn quit_cleanup_shown(app: &tauri::AppHandle) -> Vec { + show_shutting_down(app); + desktop_telemetry::shutdown(app); + quit_cleanup(&app.state::(), |step, state| { + report_quit(app, step, state) }) + .lines() +} + +/// The window event that carries the Shutting down screen's rows, the whole list each time. +const QUIT_PROGRESS_EVENT: &str = "quit:progress"; + +/// The setup page's fragment that opens it on the Shutting down screen alone. +const SHUTTING_DOWN_FRAGMENT: &str = "shutting-down"; + +/// A row on the Shutting down screen. Fixed ids and fixed words: nothing the CLI said reaches it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QuitStep { + /// Host processes, Compose down, and on Linux the Podman API. + OpenBot, + /// The local Intelligence stack OpenBot created. + Intelligence, +} + +impl QuitStep { + fn id(self) -> &'static str { + match self { + QuitStep::OpenBot => "stop-openbot", + QuitStep::Intelligence => "stop-intelligence", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QuitStepState { + Running, + Complete, + Failed, +} + +impl QuitStepState { + fn of(result: &Result) -> Self { + if result.is_ok() { + QuitStepState::Complete + } else { + QuitStepState::Failed + } + } +} + +/// What the Shutting down screen shows, kept so a page that loads late can catch up. +/// +/// `seq` only rises, so the page keeps whichever copy is newest, whether it came as an event or +/// as the answer to `quit_status`. +#[derive(Serialize, Clone, Debug, Default, PartialEq)] +struct QuitStatus { + seq: u64, + steps: Vec, +} + +impl QuitStatus { + fn begin(&mut self) { + self.seq += 1; + self.steps.clear(); + } + + fn record(&mut self, step: QuitStep, state: QuitStepState) { + self.seq += 1; + let row = Progress { + step: step.id().into(), + ok: state != QuitStepState::Failed, + detail: String::new(), + running: state == QuitStepState::Running, + download_bytes: None, + }; + match self.steps.iter_mut().find(|known| known.step == row.step) { + Some(known) => *known = row, + None => self.steps.push(row), + } + } +} + +/// Bring the window back on the Shutting down screen. +/// +/// The setup page already open listens for `quit:progress` and switches by itself; reloading it +/// would only run its launch checks again. Anything else, OpenBot itself or a page that never +/// loaded, is sent to the setup page's Shutting down screen, which asks `quit_status` for what it +/// missed. +fn show_shutting_down(app: &tauri::AppHandle) { + app.state::().quit.status.lock().unwrap().begin(); + let Some(window) = app.get_webview_window("main") else { + return; + }; + if let Ok(mut setup) = setup_destination(app) { + let showing_setup = window + .url() + .is_ok_and(|current| same_window_app(¤t, &setup)); + if !showing_setup { + setup.set_fragment(Some(SHUTTING_DOWN_FRAGMENT)); + let _ = window.navigate(setup); + } + } + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); +} + +fn report_quit(app: &tauri::AppHandle, step: QuitStep, state: QuitStepState) { + let status = { + let shell = app.state::(); + let mut status = shell.quit.status.lock().unwrap(); + status.record(step, state); + status.clone() + }; + let _ = app.emit(QUIT_PROGRESS_EVENT, status); +} + +/// The Shutting down screen's rows so far, or None when no Quit has begun. +#[tauri::command] +fn quit_status(app: tauri::AppHandle) -> Option { + let status = app.state::().quit.status.lock().unwrap().clone(); + (status.seq > 0).then_some(status) +} + +/// What a Stop or Quit could not do, kept apart: only OpenBot's own failures mean OpenBot needs +/// recovery. A local Intelligence stack that would not stop is said, but it is not OpenBot's +/// processes or containers left behind, and must not hold OpenBot's next start. +#[derive(Debug, Default, PartialEq, Eq)] +struct Stopped { + openbot: Vec, + /// The whole line, starting with `INTELLIGENCE_STOP_FAILED`. + intelligence: Option, +} + +impl Stopped { + /// Every failure, OpenBot's first. + fn lines(self) -> Vec { + self.openbot.into_iter().chain(self.intelligence).collect() + } + + fn into_result(self) -> Result<(), String> { + let lines = self.lines(); + if lines.is_empty() { + Ok(()) + } else { + Err(lines.join("\n")) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Shutdown { + Stop, + Quit, +} + +/// How long the CLI may take to stop the stack. Quit's is shorter: somebody is waiting for the +/// app to close. +fn intelligence_limits(kind: Shutdown) -> local_eval::Limits { + match kind { + Shutdown::Stop => local_eval::Limits::for_action(local_eval::Action::Stop), + Shutdown::Quit => local_eval::Limits::for_quit(), + } +} + +/// The Intelligence step both production paths run, chosen here so they cannot drift apart. +fn intelligence_step(kind: Shutdown) -> impl FnOnce(&Path) -> Result<(), String> { + let limits = intelligence_limits(kind); + move |root: &Path| stop_local_eval(root, limits) } fn root_for_stop(shell: &Shell, fallback_root: &Path) -> PathBuf { @@ -1693,6 +1999,7 @@ fn root_for_stop(shell: &Shell, fallback_root: &Path) -> PathBuf { root } +#[cfg(test)] fn stop_everything_with( shell: &Shell, fallback_root: &Path, @@ -1703,6 +2010,31 @@ where C: FnOnce(&Path) -> Result, D: FnOnce(&Path) -> Result<(), String>, { + stop_everything_then(shell, fallback_root, cleanup, down, |_| Ok(())).into_result() +} + +/** +Stop, then `intelligence` last, once OpenBot's own processes and containers have had their turn, +and whether or not they stopped. Its failure is kept beside theirs, never instead of them. + +Local evaluation closes first, before any lock: calls in flight are cancelled and new ones refused +until this returns, so neither a running `start` nor one the window sends now can hold the stop up +or outlive it. The startup lock is released before the Intelligence step, which can take minutes, +so a Quit arriving meanwhile is not held behind it. +*/ +fn stop_everything_then( + shell: &Shell, + fallback_root: &Path, + cleanup: C, + down: D, + intelligence: I, +) -> Stopped +where + C: FnOnce(&Path) -> Result, + D: FnOnce(&Path) -> Result<(), String>, + I: FnOnce(&Path) -> Result<(), String>, +{ + let _closing = local_eval::closing_for_stop(); cancel_self_hosted_sign_in(shell); shell .stopped_in_session @@ -1713,7 +2045,7 @@ where shell .generation .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let _startup = shell.startup.lock().unwrap(); + let startup = shell.startup.lock().unwrap(); let root = cleanup_root(shell, fallback_root); let mut failures = Vec::new(); if let Err(problem) = cleanup_host_state(shell, &root, cleanup) { @@ -1724,12 +2056,91 @@ where failures.push(format!("Compose down failed: {problem}")); } + // Recovery is about what OpenBot runs, so only OpenBot's own failures keep it. if failures.is_empty() { clear_recovery_required(shell, &root); - Ok(()) - } else { - Err(failures.join("\n")) } + drop(startup); + Stopped { + openbot: failures, + intelligence: intelligence(&root) + .err() + .map(|problem| format!("{INTELLIGENCE_STOP_FAILED}{problem}")), + } +} + +/// The production Intelligence step for Stop and Quit, within `limits`. +fn stop_local_eval(root: &Path, limits: local_eval::Limits) -> Result<(), String> { + #[cfg(test)] + INTELLIGENCE_TIMEOUT.with(|used| used.set(Some(limits.timeout))); + let attempt = format!("openbot-shutdown-{:016x}", rand::random::()); + stop_local_eval_with(root, local_eval::cancel_all, |stack| { + local_eval::stop_for_shutdown(&attempt, stack, limits) + }) +} + +#[cfg(test)] +thread_local! { + /// The timeout the last `stop_local_eval` on this thread was given, so a test can tell which + /// limits a production path chose without a CLI to time. + static INTELLIGENCE_TIMEOUT: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +fn last_intelligence_timeout() -> Option { + INTELLIGENCE_TIMEOUT.with(std::cell::Cell::take) +} + +/** +Ask the CLI to stop the local Intelligence stack, if and only if OpenBot created it. + +No record, or a stack that was already there when OpenBot found it, is left exactly as it is: it +may be serving other apps, and stopping OpenBot is not a reason to stop them. Docker and the +stack's containers are never touched here; only the CLI stops what the CLI started. + +Calls already in flight are cancelled first, whoever owns the stack: cancelling ends only this +app's own CLI processes, never the stack, and a `start` left running could otherwise hold the stop +behind it, finish after it and leave the stack running, or outlive the app as an orphan. + +Every way this can fail is said in fixed words. There is no CLI output to repeat, and none would +be repeated if there were. +*/ +fn stop_local_eval_with(root: &Path, cancel_all: A, stop: S) -> Result<(), String> +where + A: FnOnce(), + S: FnOnce(&local_eval::StackId) -> Result, +{ + cancel_all(); + let Some(state) = local_eval_state::read(root, None).filter(|state| state.tied_to_openbot()) + else { + return Ok(()); + }; + let not_stopped = |why: String| { + format!("the Intelligence CLI did not stop the local Intelligence stack ({why})") + }; + match stop(&state.stack_id()) { + Ok(report) => match report.outcome { + local_eval::Outcome::Completed => Ok(()), + outcome => Err(not_stopped(match report.reason { + Some(reason) => format!("{}: {}", wire_name(outcome), wire_name(reason)), + None => wire_name(outcome), + })), + }, + Err(local_eval::Failure::Unavailable) => Err( + "this build has no Intelligence CLI to stop the local Intelligence stack OpenBot created" + .into(), + ), + Err(failure) => Err(not_stopped(wire_name(failure))), + } +} + +/// A category's name as the window knows it, `local-command-busy` rather than `LocalCommandBusy`. +fn wire_name(category: impl Serialize) -> String { + serde_json::to_value(category) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_default() } /// Called under the startup lock by both Stop and Quit. Host cleanup can clear its own root @@ -2164,7 +2575,57 @@ fn quit_cleanup_failure_summary(failure: QuitCleanupFailure) -> &'static str { } } +fn is_intelligence_quit_failure(line: &str) -> bool { + line.strip_prefix("[exit] cleanup failed: ") + .is_some_and(|failure| failure.starts_with(INTELLIGENCE_STOP_FAILED)) +} + +/// OpenBot's own failures go to the notice that means recovery; an Intelligence one goes to its +/// own, which only says so. fn write_quit_cleanup_notice(root: &Path, lines: &[String]) -> Result<(), String> { + let (intelligence, openbot): (Vec<&String>, Vec<&String>) = lines + .iter() + .partition(|line| is_intelligence_quit_failure(line)); + write_openbot_quit_cleanup_notice(root, &openbot)?; + if intelligence.is_empty() { + return Ok(()); + } + let path = root.join(LOCAL_EVAL_STOP_NOTICE_FILE); + std::fs::create_dir_all(root) + .and_then(|()| std::fs::write(&path, br#"{"version":1}"#)) + .map_err(|error| { + format!( + "{}: could not write the Intelligence shutdown notice: {error}", + path.display() + ) + }) +} + +/** +That the last Quit could not stop the local Intelligence stack, said once. + +Apart from `last_failure` on purpose. The window treats that as an interrupted run and holds its +automatic start, and OpenBot's own processes are not what was left running. Fixed words: the +notice records only that it happened. +*/ +fn take_local_eval_stop_notice(root: &Path) -> Option { + std::fs::remove_file(root.join(LOCAL_EVAL_STOP_NOTICE_FILE)).ok()?; + Some(Problem::with( + "When OpenBot last quit, it could not stop the local Intelligence stack it started.", + "It may still be running. Stopping OpenBot again asks it to stop.", + )) +} + +/// What `take_local_eval_stop_notice` says, for the window. +#[tauri::command] +fn last_local_eval_stop_failure( + app: tauri::AppHandle, +) -> Option { + let shell = app.state::(); + take_local_eval_stop_notice(&cleanup_root(&shell, &stack::default_root())) +} + +fn write_openbot_quit_cleanup_notice(root: &Path, lines: &[&String]) -> Result<(), String> { if lines.is_empty() { return Ok(()); } @@ -2266,18 +2727,69 @@ fn recovery_required_or_pending_quit_notice(shell: &Shell, root: &Path) -> bool true } +#[cfg(test)] fn exit_cleanup_with(shell: &Shell, fallback_root: &Path, cleanup: C, down: D) -> Vec where C: FnOnce(&Path) -> Result, D: FnOnce(&Path) -> Result<(), String>, { + exit_cleanup_then(shell, fallback_root, cleanup, down, |_| Ok(())).lines() +} + +/// `exit_cleanup_reporting` with nobody watching. +#[cfg(test)] +fn exit_cleanup_then( + shell: &Shell, + fallback_root: &Path, + cleanup: C, + down: D, + intelligence: I, +) -> Stopped +where + C: FnOnce(&Path) -> Result, + D: FnOnce(&Path) -> Result<(), String>, + I: FnOnce(&Path) -> Result<(), String>, +{ + exit_cleanup_reporting(shell, fallback_root, cleanup, down, intelligence, |_, _| {}) +} + +/** +Quit's cleanup, then `intelligence` last, as in `stop_everything_then`. + +Local evaluation closes for good here, which also cancels a Stop's own CLI stop, so Quit never +waits behind one. The order is host processes, Compose down, on Linux the Podman API this session +started, and only then Intelligence, with no lock held. So Quit's Intelligence cap starts after all +of that: 120 seconds for the CLI, its turn included, plus up to five seconds of grace to stop the +CLI's process tree if it overruns. + +`progress` hears OpenBot's part start and end, then Intelligence's. Intelligence has a row only +when a stack OpenBot created is recorded, which is when its step stops anything; it is asked either +way, as before. A failure always gets its row, so the screen never says everything stopped when +something did not. +*/ +fn exit_cleanup_reporting( + shell: &Shell, + fallback_root: &Path, + cleanup: C, + down: D, + intelligence: I, + mut progress: P, +) -> Stopped +where + C: FnOnce(&Path) -> Result, + D: FnOnce(&Path) -> Result<(), String>, + I: FnOnce(&Path) -> Result<(), String>, + P: FnMut(QuitStep, QuitStepState), +{ + local_eval::close_for_quit(); + progress(QuitStep::OpenBot, QuitStepState::Running); shell .start_generation .fetch_add(1, std::sync::atomic::Ordering::SeqCst); shell .generation .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let _startup = shell.startup.lock().unwrap(); + let startup = shell.startup.lock().unwrap(); let root = cleanup_root(shell, fallback_root); let mut failures = Vec::new(); if let Err(problem) = cleanup_host_state(shell, &root, cleanup) { @@ -2296,7 +2808,34 @@ where if failures.is_empty() { clear_recovery_required(shell, &root); } - failures + drop(startup); + progress( + QuitStep::OpenBot, + if failures.is_empty() { + QuitStepState::Complete + } else { + QuitStepState::Failed + }, + ); + let created = created_stack_recorded(&root); + if created { + progress(QuitStep::Intelligence, QuitStepState::Running); + } + let stopped = intelligence(&root); + if created || stopped.is_err() { + progress(QuitStep::Intelligence, QuitStepState::of(&stopped)); + } + Stopped { + openbot: failures, + intelligence: stopped + .err() + .map(|problem| format!("{INTELLIGENCE_STOP_FAILED}{problem}")), + } +} + +/// A local Intelligence stack OpenBot created is recorded under `root`, so Stop and Quit stop it. +fn created_stack_recorded(root: &Path) -> bool { + local_eval_state::read(root, None).is_some_and(|state| state.tied_to_openbot()) } fn report_exit_cleanup_failures(failures: Vec, sink: F) -> Result<(), String> @@ -2317,6 +2856,8 @@ where #[derive(Default)] struct QuitState { phase: Mutex, + /// The Shutting down screen's rows. + status: Mutex, } #[derive(Default)] @@ -2459,6 +3000,8 @@ fn show_openbot_on( ) .connection(openbot_desktop_lib::problem::Connection::Organization)); } + // Only the owned app itself; an organization's OpenBot is not this machine's page. + intelligence_bridge::allow(&app, &url); url } else { openbot_desktop_lib::organization_auth::session_destination(&root, &authority, &url)? @@ -2567,32 +3110,574 @@ fn show_setup(app: tauri::AppHandle) -> Result<(), String> .map_err(|error| format!("could not go back to setup: {error}")) } -/// Both pages are single-page apps. Keep their route and in-memory state on restore. -/// Compare the authority explicitly: tauri:// has an opaque URL origin. -fn same_window_app(current: &tauri::Url, destination: &tauri::Url) -> bool { - destination.host_str().is_some() - && current.scheme() == destination.scheme() - && current.host_str() == destination.host_str() - && current.port_or_known_default() == destination.port_or_known_default() +/// The setup screens' background in light and in dark, which the macOS title bar takes while they +/// show. The same values as `--surface-main` in styles.css. +#[cfg(any(target_os = "macos", target_os = "windows"))] +const SETUP_BACKGROUND: tauri::window::Color = tauri::window::Color(0xde, 0xde, 0xe9, 0xff); +#[cfg(any(target_os = "macos", target_os = "windows"))] +const SETUP_BACKGROUND_DARK: tauri::window::Color = tauri::window::Color(0x01, 0x05, 0x07, 0xff); + +/// Whether `url` is the setup page rather than OpenBot. +fn on_setup_page(app: &tauri::AppHandle, url: &tauri::Url) -> bool { + let setup = app.state::().setup_url.lock().unwrap().clone(); + setup + .and_then(|setup| setup.parse::().ok()) + .is_some_and(|setup| same_window_app(&setup, url)) } -fn show_setup_and_focus(app: tauri::AppHandle) -> Result<(), String> { - show_setup(app.clone())?; - let window = app - .get_webview_window("main") - .ok_or("the OpenBot window is not there")?; - window - .show() - .map_err(|error| format!("could not show setup: {error}"))?; - window - .unminimize() - .map_err(|error| format!("could not unminimize setup: {error}"))?; - window - .set_focus() - .map_err(|error| format!("could not focus setup: {error}")) +/** +The appearance the setup screens use: the system's (a product choice, 2026-10-23). Both themes +exist; the page follows the window's appearance through `prefers-color-scheme`, so `Some(Dark)` or +`Some(Light)` here pins one instead. +*/ +const SETUP_THEME: Option = None; + +/// What the window is showing, as far as its chrome is concerned. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WindowPage { + Setup, + /// OpenBot served from this machine: its sidebar takes the title bar's place. + OwnedApp, + /// OpenBot served from this machine, from a release whose styles make no room for the window + /// buttons: the system's title bar, still in the app's own light or dark. + LocalApp, + /// Anything else, such as an organization's OpenBot: the system's title bar. + Other, +} + +fn window_page(app: &tauri::AppHandle, url: &tauri::Url) -> WindowPage { + if on_setup_page(app, url) { + WindowPage::Setup + } else if url.scheme() == "http" + && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")) + { + if app_makes_room_for_window_buttons(&cleanup_root( + &app.state::(), + &stack::default_root(), + )) { + WindowPage::OwnedApp + } else { + WindowPage::LocalApp + } + } else { + WindowPage::Other + } } -/// Is a deployment this app manages already running? +/** +Whether the OpenBot installed under `root` pads its sidebar for the window's buttons. + +The overlay title bar needs the page's help: without the room its styles make, the buttons sit on +the sidebar's name. Releases before that change (v0.2.0 among them) do not have it, so they keep the +system's title bar. Read from the installed source the app is built from, which is what the page +is. +*/ +fn app_makes_room_for_window_buttons(root: &Path) -> bool { + std::fs::read_to_string(root.join("app/src/styles.css")) + .is_ok_and(|styles| styles.contains("data-desktop-titlebar")) +} + +/** +The window's appearance and title bar for the page it shows. + +Setup: the setup screens' theme, a transparent title bar over their ground, the title shown. The +window starts this way (in `setup`), so the first paint already matches. + +OpenBot from this machine: one surface, as Mac apps with a sidebar have it. The title bar becomes an +overlay, its title text is hidden (the sidebar carries the name), and the page is marked so its +sidebar makes room for the window buttons and works as the drag handle (`OVERLAY_TITLE_BAR_PAGE`). + +Anything else gets the system's bar back, since a page this app did not style cannot make room. +*/ +fn match_title_bar(window: &tauri::Window, page: WindowPage) { + // OpenBot's own page sets the window's appearance to its own light or dark (`APP_THEME_PAGE`); + // setting it here as well would undo that on every theme event. + match page { + WindowPage::Setup => { + let _ = window.set_theme(SETUP_THEME); + } + WindowPage::OwnedApp | WindowPage::LocalApp => {} + WindowPage::Other => { + let _ = window.set_theme(None); + } + } + #[cfg(target_os = "macos")] + { + let _ = window.set_title_bar_style(match page { + WindowPage::Setup => tauri::TitleBarStyle::Transparent, + WindowPage::OwnedApp => tauri::TitleBarStyle::Overlay, + WindowPage::LocalApp | WindowPage::Other => tauri::TitleBarStyle::Visible, + }); + let ground = match SETUP_THEME.map_or_else(|| window.theme().ok(), Some) { + Some(tauri::Theme::Dark) => SETUP_BACKGROUND_DARK, + _ => SETUP_BACKGROUND, + }; + let _ = window.set_background_color((page == WindowPage::Setup).then_some(ground)); + show_window_title(window, page != WindowPage::OwnedApp); + } + // OpenBot's own page tints the bar to its own light or dark through `appearance`. + #[cfg(target_os = "windows")] + match page { + WindowPage::Setup => { + let dark = + SETUP_THEME.map_or_else(|| window.theme().ok(), Some) == Some(tauri::Theme::Dark); + tint_title_bar(window, Some(dark)); + } + WindowPage::OwnedApp => {} + WindowPage::LocalApp | WindowPage::Other => tint_title_bar(window, None), + } +} + +/// OpenBot's own page reporting its light or dark (`APP_THEME_PAGE`). On Windows the title bar +/// takes the page's ground, when the page is one whose ground that is: an OpenBot from before the +/// reskin keeps Windows's own bar, in the page's light or dark. +pub(crate) fn page_appearance(window: &tauri::Window, dark: bool) { + #[cfg(target_os = "windows")] + { + let app = window.app_handle(); + let owned = app + .get_webview_window(window.label()) + .and_then(|own| own.url().ok()) + .is_some_and(|url| window_page(app, &url) == WindowPage::OwnedApp); + if owned { + tint_title_bar(window, Some(dark)); + } + } + #[cfg(not(target_os = "windows"))] + let _ = (window, dark); +} + +/** +Windows 11's title bar in the ground of the page under it, or Windows's own colours for `None`. + +`Some(dark)` is the setup screens' ground, which OpenBot's own pages share (`--background` in the +app's styles): the bar, its text and the window's border become one surface with the page instead +of a white band across its top. Windows 10 has no such attributes and keeps its bar; the calls fail +there and are ignored. +*/ +#[cfg(target_os = "windows")] +fn tint_title_bar(window: &tauri::Window, dark: Option) { + #[link(name = "dwmapi")] + extern "system" { + fn DwmSetWindowAttribute( + window: *mut std::ffi::c_void, + attribute: u32, + value: *const std::ffi::c_void, + size: u32, + ) -> i32; + } + const BORDER_COLOR: u32 = 34; + const CAPTION_COLOR: u32 = 35; + const TEXT_COLOR: u32 = 36; + /// `DWMWA_COLOR_DEFAULT`: give the attribute back to Windows. + const DEFAULT: u32 = 0xFFFF_FFFF; + // COLORREF is 0x00BBGGRR. + let colorref = |tauri::window::Color(r, g, b, _): tauri::window::Color| { + u32::from(r) | u32::from(g) << 8 | u32::from(b) << 16 + }; + let (caption, text) = match dark { + Some(true) => (colorref(SETUP_BACKGROUND_DARK), 0x00F9_F7F7), + Some(false) => (colorref(SETUP_BACKGROUND), colorref(SETUP_BACKGROUND_DARK)), + None => (DEFAULT, DEFAULT), + }; + let Ok(hwnd) = window.hwnd() else { + return; + }; + let hwnd = hwnd.0; + for (attribute, value) in [ + (CAPTION_COLOR, caption), + (BORDER_COLOR, caption), + (TEXT_COLOR, text), + ] { + // SAFETY: `hwnd` is this window's live handle, and `value` is a u32 that outlives the call, + // which is the size and type these three attributes take. + unsafe { + DwmSetWindowAttribute( + hwnd, + attribute, + (&value as *const u32).cast(), + std::mem::size_of::() as u32, + ); + } + } +} + +/// The title text in the title bar. AppKit, so on the main thread. +#[cfg(target_os = "macos")] +fn show_window_title(window: &tauri::Window, shown: bool) { + let target = window.clone(); + let _ = window.run_on_main_thread(move || { + let Ok(ns_window) = target.ns_window() else { + return; + }; + // NSWindowTitleVisible = 0, NSWindowTitleHidden = 1. + let visibility: isize = if shown { 0 } else { 1 }; + // SAFETY: `ns_window` is this window's live NSWindow, used on the main thread. + unsafe { + let ns_window = ns_window.cast::(); + let _: () = objc2::msg_send![ns_window, setTitleVisibility: visibility]; + } + }); +} + +/** +Marks OpenBot's page for the overlay title bar: its styles pad the sidebar's header for the window +buttons, and the header becomes the drag handle, as does the strip the main column keeps above +itself while the sidebar is hidden. The sidebar renders after the page loads and again +on navigation, so new headers are marked as they appear, at most once a frame. +*/ +const OVERLAY_TITLE_BAR_PAGE: &str = r#"(() => { + document.documentElement.dataset.desktopTitlebar = "overlay"; + const DRAG = "data-tauri-drag-region"; + const mark = () => { + // Bare attributes: only a press on the element itself drags, never on its contents. + for (const header of document.querySelectorAll(`[data-slot="sidebar-header"]:not([${DRAG}])`)) { + header.setAttribute(DRAG, ""); + } + // The main column only while the sidebar is hidden, when its top strip is the one handle left. + // With the sidebar showing, a press in its empty margins must not move the window. + for (const main of document.querySelectorAll('[data-slot="sidebar-wrapper"] > main')) { + const hidden = main.parentElement.querySelector( + ':scope > [data-slot="sidebar"][data-state="collapsed"]', + ); + if (hidden) main.setAttribute(DRAG, ""); + else main.removeAttribute(DRAG); + } + }; + let queued = false; + new MutationObserver(() => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { queued = false; mark(); }); + }).observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["data-state"], + }); + mark(); +})();"#; + +/** +Keeps the window's appearance on OpenBot's own light or dark, which it chooses itself (Settings) +rather than following the system. + +MEASURED: with the Mac in dark and OpenBot set to light, the window kept the dark appearance, and +macOS drew its dark-window outline, a black line, around a light window. The page sets the window's +theme to its own whenever its `dark` class changes, through `desktop_page::appearance`. +*/ +const APP_THEME_PAGE: &str = r#"(() => { + const internals = window.__TAURI_INTERNALS__; + if (!internals || typeof internals.invoke !== "function") return; + let applied = null; + const sync = () => { + const theme = document.documentElement.classList.contains("dark") ? "dark" : "light"; + if (theme === applied) return; + applied = theme; + internals + .invoke("plugin:openbot-desktop|appearance", { dark: theme === "dark" }) + .catch(() => {}); + }; + new MutationObserver(sync).observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + sync(); +})();"#; + +/// Lets OpenBot's own page move and zoom the window from its drag handle, set the window's +/// appearance to its own theme, and use Settings → This computer (`desktop_page`), and nothing more. +fn allow_window_drag(app: &tauri::AppHandle, url: &tauri::Url) { + static GRANTED: Mutex> = Mutex::new(Vec::new()); + let origin = url.origin().ascii_serialization(); + let mut granted = GRANTED.lock().unwrap(); + if granted.contains(&origin) { + return; + } + let mut capability = + tauri::ipc::CapabilityBuilder::new(format!("openbot-app-window-{}", granted.len())) + .remote(format!("{origin}/*")) + .local(false) + .window("main") + .permission("core:window:allow-start-dragging") + .permission("core:window:allow-internal-toggle-maximize"); + for permission in desktop_page::PERMISSIONS { + capability = capability.permission(permission); + } + match app.add_capability(capability) { + Ok(()) => granted.push(origin), + Err(error) => eprintln!("[window] could not let the app page drag the window: {error}"), + } +} + +/// Where the Status window's page is: the setup page, at `#status`. +const STATUS_FRAGMENT: &str = "status"; + +/** +The Status window: what OpenBot is running, what it costs, and Restart and Stop for each part. + +Its own window rather than a page of the main one, so looking at it never takes OpenBot off the +screen. Closing it hides it, as the main window does; it reads everything again when shown. +*/ +fn show_status_window(app: &tauri::AppHandle) { + if show_status_in_app(app) { + return; + } + if let Some(window) = app.get_webview_window("status") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + return; + } + let Ok(mut url) = setup_destination(app) else { + return; + }; + url.set_fragment(Some(STATUS_FRAGMENT)); + let page = if matches!(url.scheme(), "http" | "https") { + tauri::WebviewUrl::External(url) + } else { + tauri::WebviewUrl::CustomProtocol(url) + }; + let built = tauri::WebviewWindowBuilder::new(app, "status", page) + .title("OpenBot Status") + .inner_size(640.0, 720.0) + .min_inner_size(520.0, 480.0) + .build(); + if let Err(error) = built { + eprintln!("[status] could not open the Status window: {error}"); + } +} + +/// Where OpenBot's Settings → This computer lives, in releases that have it. +const COMPUTER_SETTINGS_PATH: &str = "/settings/computer"; + +/** +Status in OpenBot itself, Settings → This computer, when the OpenBot running here has that page: +the main window goes there. True when it did. Otherwise, with OpenBot stopped or from a release +without the page, the Status window does the job. +*/ +fn show_status_in_app(app: &tauri::AppHandle) -> bool { + let root = cleanup_root(&app.state::(), &stack::default_root()); + if !root + .join("app/src/routes/_authed/settings/computer.tsx") + .is_file() + { + return false; + } + let Some(mut url) = ports_for_shell(app) + .ok() + .and_then(|ports| owned_app_url(&root, &ports)) + .and_then(|url| url.parse::().ok()) + else { + return false; + }; + let Some(window) = app.get_webview_window("main") else { + return false; + }; + url.set_path(COMPUTER_SETTINGS_PATH); + intelligence_bridge::allow(app, url.as_str()); + if window.navigate(url).is_err() { + return false; + } + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + true +} + +/// What the Status window shows. Read fresh each time; nothing here is kept. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DesktopStatus { + /// OpenBot answers on its own address, whoever started it. + openbot_running: bool, + /// This session of the desktop app holds OpenBot's processes, so its supervisor can restart + /// them. False for an OpenBot left running by an earlier session that ended without its Quit. + openbot_held: bool, + processes: Vec, + containers: Vec, + /// The local Intelligence stack recorded for this installation, if any. + intelligence_stack: Option, + intelligence_containers: Vec, + /// "docker" or "podman", answering now; None when nothing answers. + engine: Option<&'static str>, + root: String, + app_port: Option, + version: &'static str, + build: String, +} + +#[tauri::command] +async fn desktop_status( + app: tauri::AppHandle, + memory: Option, +) -> Result { + let memory = memory.unwrap_or(true); + tauri::async_runtime::spawn_blocking(move || read_desktop_status(&app, memory)) + .await + .map_err(|error| format!("the status could not be read: {error}")) +} + +/** +`memory` false lists the containers without measuring them, in milliseconds: `docker stats` waits +about two seconds to sample, which left the page blank that long. The page asks without it first, so +its rows appear at once, and with it after, filling the figures in. +*/ +fn read_desktop_status( + app: &tauri::AppHandle, + memory: bool, +) -> DesktopStatus { + let shell = app.state::(); + let root = cleanup_root(&shell, &stack::default_root()); + let pids: Vec<(&'static str, u32)> = shell + .children + .lock() + .unwrap() + .iter() + .map(|(name, child)| (*name, child.id())) + .collect(); + let processes = resident_parts(&pids); + let stack_id = + local_eval_state::read(&root, None).map(|state| state.stack_id().as_str().to_owned()); + let found = engine::detect(); + let running = found.engine.filter(|_| found.responding); + let (containers, intelligence_containers) = match running { + Some(running) => { + let mut stats = quiet::command( + engine::program(running).unwrap_or_else(|| PathBuf::from(running.binary())), + ); + if memory { + stats.args([ + "stats", + "--no-stream", + "--format", + "{{.Name}}\t{{.MemUsage}}", + ]); + } else { + // The same two columns, the second empty, so one parser reads both. + stats.args(["ps", "--format", "{{.Names}}\t"]); + } + if let Some(path) = engine::path_with_docker() { + stats.env("PATH", path); + } + let stats = stats + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) + .unwrap_or_default(); + desktop_status::containers(&stats, stack_id.as_deref()) + } + None => (Vec::new(), Vec::new()), + }; + let ports = ports_for_shell(app).ok(); + let answering = ports + .as_ref() + .is_some_and(|ports| owned_app_url(&root, ports).is_some()); + DesktopStatus { + openbot_running: !pids.is_empty() || answering, + openbot_held: !pids.is_empty(), + processes, + containers, + intelligence_stack: stack_id, + intelligence_containers, + engine: running.map(|engine| engine.binary()), + root: root.display().to_string(), + app_port: ports.map(|ports| ports.app), + version: env!("CARGO_PKG_VERSION"), + build: build_description( + env!("OPENBOT_BUILD_COMMIT"), + env!("OPENBOT_BUILD_BRANCH"), + env!("OPENBOT_BUILD_TIME").parse().unwrap_or_default(), + ), + } +} + +/// Each host process by name, with its resident memory where `ps` could read it. +fn resident_parts(pids: &[(&'static str, u32)]) -> Vec { + let sizes = if pids.is_empty() { + Vec::new() + } else { + let list = pids + .iter() + .map(|(_, pid)| pid.to_string()) + .collect::>() + .join(","); + quiet::command("ps") + .args(["-o", "pid=,rss=", "-p", &list]) + .output() + .ok() + .map(|output| desktop_status::resident(&String::from_utf8_lossy(&output.stdout))) + .unwrap_or_default() + }; + pids.iter() + .map(|(name, pid)| desktop_status::Part { + name: (*name).to_owned(), + memory_bytes: sizes + .iter() + .find(|(found, _)| found == pid) + .map(|(_, bytes)| *bytes), + }) + .collect() +} + +/** +Restart OpenBot's host processes: each is ended, and the supervisor that keeps them alive starts it +again, as it would after a crash. The containers, its database among them, keep running, so nothing +is lost and the restart takes seconds. +*/ +#[tauri::command] +fn restart_openbot(app: tauri::AppHandle) -> Result<(), String> { + let shell = app.state::(); + let mut children = shell.children.lock().unwrap(); + if children.is_empty() { + return Err("OpenBot is not running.".into()); + } + for (name, child) in children.iter_mut() { + // SAFETY: the pid is this app's own child, held and not yet reaped, so it cannot be reused. + #[cfg(unix)] + let ended = unsafe { libc::kill(child.id() as i32, libc::SIGTERM) } == 0; + #[cfg(not(unix))] + let ended = child.kill().is_ok(); + if !ended { + eprintln!("[status] could not restart {name}"); + } + } + Ok(()) +} + +/// Stop, as the menu used to offer it: everything, Intelligence included if OpenBot started it. +#[tauri::command] +fn stop_openbot_from_status(app: tauri::AppHandle) { + stop_from_menu(app); +} + +/// Both pages are single-page apps. Keep their route and in-memory state on restore. +/// Compare the authority explicitly: tauri:// has an opaque URL origin. +fn same_window_app(current: &tauri::Url, destination: &tauri::Url) -> bool { + destination.host_str().is_some() + && current.scheme() == destination.scheme() + && current.host_str() == destination.host_str() + && current.port_or_known_default() == destination.port_or_known_default() +} + +fn show_setup_and_focus(app: tauri::AppHandle) -> Result<(), String> { + show_setup(app.clone())?; + let window = app + .get_webview_window("main") + .ok_or("the OpenBot window is not there")?; + window + .show() + .map_err(|error| format!("could not show setup: {error}"))?; + window + .unminimize() + .map_err(|error| format!("could not unminimize setup: {error}"))?; + window + .set_focus() + .map_err(|error| format!("could not focus setup: {error}")) +} + +/// Is a deployment this app manages already running? /// /// The shell keeps what it started in memory, so closing the window and opening it again forgets a /// stack that is still up. Without asking, the second launch offers to set up something already @@ -2715,7 +3800,23 @@ async fn ask_the_bot_inner(root: PathBuf, question: String) -> Result Result, Problem> { @@ -2733,10 +3834,21 @@ fn ask_saved_settings(root: &Path) -> Result, +) -> Result { + ask_the_bot_via(root, question, settings, None).await +} + +async fn ask_the_bot_via( + root: PathBuf, + question: String, + settings: std::collections::BTreeMap, + server: Option, ) -> Result { // The picked harness if there is one, and the Bot that ships with OpenBot if there is not. // Both speak AG-UI at the same address shape, so this screen does not care which it got. @@ -2779,14 +3891,25 @@ async fn ask_the_bot_with_settings( question }; + // A picked Bot is the server's `picked-harness`, asked through the runtime so the question and + // its answer are a thread in Intelligence. The Bot that ships with OpenBot is asked directly. + let through = server.filter(|_| picked_endpoint.is_some()); let asked = tauri::async_runtime::spawn_blocking(move || { - match openbot_desktop_lib::ask::ask_harness( - &endpoint, - &token, - &question, - kind.as_deref(), - agent_id.as_deref(), - ) { + let answered = match &through { + Some(server) => openbot_desktop_lib::ask::ask_through_server( + server, + openbot_desktop_lib::ask::PICKED_HARNESS_AGENT, + &question, + ), + None => openbot_desktop_lib::ask::ask_harness( + &endpoint, + &token, + &question, + kind.as_deref(), + agent_id.as_deref(), + ), + }; + match answered { Ok(answer) => Ok(answer), // An empty sentence carries no cause. Local logs can explain an installed Bot; // they cannot explain a BYO endpoint, even when an old local harness still has logs. @@ -2880,6 +4003,7 @@ fn already_configured_for_root(root: String) -> AlreadyConfigured { launch: preparation::launch(&root), installation: preparation::installation(&root), auto_start: true, + local_eval: local_eval_state::read(&root, None), saved: SavedConfiguration { intelligence_api_key: hint( Category::Intelligence, @@ -3334,692 +4458,2010 @@ fn cancel_model_oauth(attempt_id: String) { openbot_desktop_lib::provider_oauth::cancel(&attempt_id); } -/// `bun` from PATH, or the places an installer puts it when PATH has not been reloaded. -fn which_bun() -> Option { - if quiet::command("bun") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - { - if let Some(path) = std::env::var_os("PATH").and_then(|path| { - std::env::split_paths(&path) - .map(|directory| directory.join(if cfg!(windows) { "bun.exe" } else { "bun" })) - .find(|path| path.is_file()) - }) { - return Some(path); - } - } - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .ok()?; - let candidates = [ - PathBuf::from(&home).join(".bun/bin/bun"), - PathBuf::from(&home).join(".bun/bin/bun.exe"), - ]; - candidates.into_iter().find(|path| path.exists()) +/// One Intelligence CLI call for the local evaluation. Returns only the normalized report or a +/// static failure category; the CLI's own output never reaches the window. `attempt` names the +/// call so `cancel_local_eval` can stop it, including before it has started. +#[tauri::command] +async fn run_local_eval( + app: tauri::AppHandle, + attempt: String, + action: local_eval::Action, + stack: String, +) -> Result { + let stack = local_eval::StackId::parse(&stack)?; + let report = tauri::async_runtime::spawn_blocking(move || { + local_eval::run_installed(&attempt, action, &stack) + }) + .await + .map_err(|_| local_eval::Failure::Spawn)??; + learn_dashboards(&app, report.stack.iter()); + Ok(report) } -/// Watch the three host processes and start one again when it dies. -/// -/// The policy is in `supervise.rs`; this is the loop that applies it. It ends when the stack is -/// stopped, which is what clearing the root means, so stopping does not race a restart. -fn publish_connection_failure( - app: &tauri::AppHandle, - root: &Path, - generation: u64, - connection: openbot_desktop_lib::problem::Connection, -) -> Result { +/// Keep the dashboard address of every stack a CLI report described, for `Dashboards`. +fn learn_dashboards<'a>( + app: &tauri::AppHandle, + stacks: impl Iterator, +) { let shell = app.state::(); - let _startup = shell.startup.lock().unwrap(); - if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation - || shell.root.lock().unwrap().as_deref() != Some(root) - { - return Ok(false); - } - if recovery_required(&shell, root) { - return Ok(true); - } - let said = match connection { - openbot_desktop_lib::problem::Connection::Model => "Your AI connection was refused. Refresh it to continue using your existing OpenBot.", - openbot_desktop_lib::problem::Connection::Intelligence => "Your CopilotKit connection was refused. Refresh it to continue using your existing OpenBot.", - openbot_desktop_lib::problem::Connection::Organization => "Your organization sign-in needs to be refreshed to continue using your existing OpenBot.", - }; - mark_recovery_required(&shell, root, generation); - if let Some(recovery) = shell.recovery_required.lock().unwrap().as_mut() { - recovery.connection = Some(connection.clone()); + for stack in stacks { + shell.dashboards.learn(stack); } - *shell.last_failure.lock().unwrap() = Some(Problem::plain(said).connection(connection)); - show_setup_and_focus(app.clone())?; - Ok(true) } -#[allow( - clippy::too_many_arguments, - reason = "Retain selected ports alongside the supervised run's identity and credentials." -)] -fn supervise_host_processes( - app: tauri::AppHandle, - root: PathBuf, - logs: PathBuf, - bun: PathBuf, - // Carried rather than fetched again on each restart. A restart happens when something is - // already wrong, and a credential prompt at that moment is the worst time to ask for one. - secrets: stack::Secrets, - generation: u64, - ports: openbot_env::Ports, -) -> std::thread::JoinHandle<()> { - std::thread::spawn(move || { - eprintln!( - "[watch] supervising {} host processes", - stack::HOST_PROCESSES.len() - ); - let mut watches: Vec = stack::HOST_PROCESSES - .iter() - .map(|process| supervise::Watch::new(process.name)) - .collect(); - let connection_client = desktop_connection::client() - .map_err(|error| { - eprintln!("[watch] {error}"); - }) - .ok(); - let mut connection_notice_sent = false; - let mut connection_poll_failed = false; +/// The stacks the CLI has recorded, so a stack id comes from the CLI rather than being typed. +/// Why a local evaluation setup produced nothing to show: the CLI's own static category, or a +/// stack that was set up but could not be recorded. The second is not a CLI category, so it is +/// not one: the window tells them apart by shape. +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum SetupError { + Failure(local_eval::Failure), + Unrecorded(Problem), +} - loop { - std::thread::sleep(std::time::Duration::from_secs(2)); - let shell = app.state::(); - // Not this run's any more, or no run at all. - if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation - || shell.root.lock().unwrap().is_none() - { - return; - } +/** +`local setup` for this installation, and the stack it made recorded as OpenBot's. - if connection_notice_sent && !recovery_required(&shell, &root) { - connection_notice_sent = false; - } - if !connection_notice_sent { - if let (Some(client), Some(token)) = ( - &connection_client, - secrets.get("OPENBOT_DESKTOP_HOST_TOKEN"), - ) { - match desktop_connection::poll(client, ports.server, token) { - Ok(Some(connection)) => { - match publish_connection_failure(&app, &root, generation, connection) { - Ok(published) => connection_notice_sent = published, - Err(error) => { - eprintln!("[watch] could not show connection refresh: {error}") - } - } - } - Ok(None) => connection_poll_failed = false, - Err(error) => { - if !connection_poll_failed { - eprintln!("[watch] {error}"); - } - connection_poll_failed = true; - } - } - } - } +Blocked is a normal answer here, not an error: the first one is the evaluation agreement, which the +person accepts in their own terminal before the window asks again. Only a completed setup is +recorded, and only then does Stop or Quit stop that stack. +*/ +#[tauri::command] +async fn set_up_local_eval( + app: tauri::AppHandle, + attempt: String, + root: String, + accepted_eula_version: Option, +) -> Result { + let root = stack::root_from(&root); + // Only a version the person accepted in the window, after `local_eval_terms` showed it. + let accepted = accepted_eula_version + .as_deref() + .map(local_eval::EulaVersion::parse) + .transpose() + .map_err(SetupError::Failure)?; + let report = tauri::async_runtime::spawn_blocking(move || { + // A local first run starts setup while OpenBot is still installing into this folder, which + // may not exist yet; the CLI runs in it. + std::fs::create_dir_all(&root) + .map_err(|_| SetupError::Failure(local_eval::Failure::Spawn))?; + set_up_local_eval_at(&root, |folder| { + local_eval::setup_installed(&attempt, folder, accepted.as_ref()) + }) + }) + .await + .map_err(|_| SetupError::Failure(local_eval::Failure::Spawn))??; + learn_dashboards(&app, report.stack.iter()); + Ok(report) +} - // Which ones have died. Collected rather than acted on under the lock, because a - // restart waits, and waiting while holding the children is how Stop would block on a - // backoff nobody asked it to sit through. - let dead: Vec<&'static str> = { - let mut children = shell.children.lock().unwrap(); - let mut dead = Vec::new(); - for (name, child) in children.iter_mut() { - if let Ok(Some(_)) = child.try_wait() { - dead.push(*name); - } - } - dead - }; +/** +Sign in to CopilotKit, which local evaluation needs to issue its licence: `login --json`. - if !dead.is_empty() { - eprintln!("[watch] dead: {dead:?}"); - } - for name in dead { - let Some(watch) = watches.iter_mut().find(|watch| watch.name == name) else { - continue; - }; - if !watch.should_restart(std::time::Instant::now()) { - // A restore already probing may finish first; publish recovery and its setup - // navigation together after it, so that late probe cannot undo this transition. - let _startup = shell.startup.lock().unwrap(); - // Let go of it. A dead child left in the list is found dead again two seconds - // later, and forever after: the count climbs past what actually happened, the - // window is sent back to the setup screen on a loop, and the giving up that was - // supposed to stop a hot laptop becomes one. - { - let mut children = shell.children.lock().unwrap(); - if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation - || shell.root.lock().unwrap().as_deref() != Some(root.as_path()) - { - return; - } - children.retain(|(held, _)| *held != name); - } +The CLI opens the approval page in the browser and waits for it; this returns once the person has +approved it there, or it failed. `attempt` lets `cancel_local_eval` stop the wait. +*/ +#[tauri::command] +async fn sign_in_to_copilotkit( + attempt: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || local_eval::account_sign_in_installed(&attempt)) + .await + .map_err(|_| local_eval::Failure::Spawn)? +} - let reason = watch.gave_up(); - mark_recovery_required(&shell, &root, generation); - report(&app, name, false, reason.clone()); - /* - * Both registers here too. `gave_up` names the process and quotes the tail of - * its log, which is the developer half; the person needs to know a piece of - * OpenBot stopped and that starting again is the thing to try. - */ - *shell.last_failure.lock().unwrap() = - Some(openbot_desktop_lib::problem::Problem::with( - format!( - "Part of OpenBot ({name}) stopped and could not be started again. \ - Try starting OpenBot once more." - ), - reason, - )); - // Back to the setup screen. By now the window is showing OpenBot, and OpenBot - // is not running: leaving it there is a window that lies. - let _ = show_setup(app.clone()); - continue; - } - report( - &app, - name, - false, - format!("{name} stopped. Starting it again."), - ); - std::thread::sleep(supervise::backoff(watch.restarts - 1)); +/// The evaluation agreement local setup needs accepted, for the window to show before Accept. +#[tauri::command] +async fn local_eval_terms(attempt: String) -> Result { + tauri::async_runtime::spawn_blocking(move || local_eval::terms_installed(&attempt)) + .await + .map_err(|_| local_eval::Failure::Spawn)? +} - // Asked again after the backoff: a stop, or another start, may have happened while - // this was waiting, and starting a process into either is how an orphan is made. - if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation - || shell.root.lock().unwrap().is_none() - { - return; - } - let Some(process) = stack::HOST_PROCESSES - .iter() - .find(|process| process.name == name) - else { - continue; - }; - match restart_host_process_with(&shell, &root, name, generation, || { - stack::spawn_host_process(process, &root, &logs, &bun, &secrets) - }) { - Ok(true) => report(&app, name, true, "started again"), - Ok(false) => return, - Err(problem) => { - report(&app, name, false, problem.said.clone()); - *shell.last_failure.lock().unwrap() = Some(problem); - } - } - } - } +/** +Run `setup` in `root` and record what it made. + +A stack set up here was made for OpenBot, so it starts and stops with it. The one exception is a +record that already calls the same stack shared: setting up again resumes that stack, it does not +make OpenBot its owner. +*/ +fn set_up_local_eval_at(root: &Path, setup: S) -> Result +where + S: FnOnce(&Path) -> Result<(local_eval::Report, String), local_eval::Failure>, +{ + let (report, cli_sha256) = setup(root).map_err(SetupError::Failure)?; + let Some(stack) = report + .stack + .as_ref() + .filter(|_| report.outcome == local_eval::Outcome::Completed) + else { + return Ok(report); + }; + let stack = local_eval::StackId::parse(&stack.id).map_err(SetupError::Failure)?; + let ownership = match local_eval_state::read(root, None) { + Some(state) if state.stack_id() == stack => state.ownership, + _ => local_eval_state::Ownership::Created, + }; + local_eval_state::begin(root, &stack, ownership, &cli_sha256) + .map_err(SetupError::Unrecorded)?; + Ok(report) +} + +/// Why connecting OpenBot to local Intelligence produced nothing to show: the CLI's own static +/// category, or a connection OpenBot would not take up. Told apart by shape, like `SetupError`. +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum ConnectError { + Failure(local_eval::Failure), + Unconnected(Problem), +} + +/** +`local connect` for this installation, after the person chose to connect OpenBot to it. + +Interim, until the CLI issues OpenBot a credential of its own: the key `connect` writes into the +installation's `.env` is moved into the credential store as OpenBot's Intelligence key, the same +place a pasted key goes, and Start takes it from there. Blocked and conflicted answers are returned +as they are, with nothing taken. +*/ +#[tauri::command] +async fn connect_local_eval( + app: tauri::AppHandle, + attempt: String, + root: String, +) -> Result { + let root = stack::root_from(&root); + let connection = tauri::async_runtime::spawn_blocking(move || { + connect_local_eval_at( + &root, + |folder| local_eval::connect_installed(&attempt, folder), + openbot_desktop_lib::vault::remember, + openbot_desktop_lib::vault::recall, + ) }) + .await + .map_err(|_| ConnectError::Failure(local_eval::Failure::Spawn))??; + learn_dashboards(&app, connection.stack.iter()); + Ok(connection) } -/// The same lock covers generation validation, launch, publication, and owned cleanup on every -/// platform. Stop can retire during spawn, but cannot finish before receiving that child handle. -fn restart_host_process_with( - shell: &Shell, +/// Where `local connect` puts the key for an app's runtime. OpenBot's server reads it as +/// `INTELLIGENCE_API_KEY`. +const LOCAL_EVAL_CONNECTED_KEY: &str = "CPK_INTELLIGENCE_API_KEY"; + +/** +Connect the recorded stack in `root`, and take up what the CLI wrote. + +Only the stack this installation recorded is connected, and only its own addresses are believed: +the `.env` must hold exactly the API and gateway addresses the CLI reported for it. The key then +goes to the credential store and leaves the file, which keeps only settings (see `vault`). The +CLI connects a folder once, so connecting again finds no key in the file; that is the key already +kept, provided this stack was the one connected. +*/ +fn connect_local_eval_at( root: &Path, - name: &'static str, - generation: u64, - spawn: F, -) -> Result + connect: C, + mut remember: R, + mut recall: K, +) -> Result where - F: FnOnce() -> std::io::Result, + C: FnOnce(&Path) -> Result, + R: FnMut(&Path, &str, &str) -> Result<(), Problem>, + K: FnMut(&Path, &str) -> Result, Problem>, { - let mut children = shell.children.lock().unwrap(); - if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation - || shell.root.lock().unwrap().as_deref() != Some(root) + let unconnected = |said: &str| ConnectError::Unconnected(Problem::plain(said)); + let recorded = local_eval_state::read(root, None) + .ok_or_else(|| unconnected("Set up local Intelligence before connecting OpenBot to it."))?; + let connection = connect(root).map_err(ConnectError::Failure)?; + if connection.outcome != local_eval::Outcome::Completed || connection.conflicted { + return Ok(connection); + } + let Some(stack) = connection + .stack + .as_ref() + .filter(|stack| stack.id == recorded.stack) + else { + return Err(unconnected( + "The Intelligence CLI connected a different stack. OpenBot did not use it.", + )); + }; + let env_file = root.join(".env"); + let written = openbot_env::read_already_set( + &env_file, + &[ + "INTELLIGENCE_API_URL", + "INTELLIGENCE_GATEWAY_WS_URL", + LOCAL_EVAL_CONNECTED_KEY, + ], + ) + .map_err(|error| { + ConnectError::Unconnected(Problem::with( + "OpenBot could not read the connection the Intelligence CLI wrote.", + format!("{}: {}", env_file.display(), error.kind()), + )) + })?; + let addresses = [ + ("INTELLIGENCE_API_URL", &stack.urls.api), + ("INTELLIGENCE_GATEWAY_WS_URL", &stack.urls.gateway), + ]; + if addresses + .iter() + .any(|(name, reported)| written.get(*name) != Some(*reported)) { - return Ok(false); + return Err(unconnected( + "The Intelligence CLI's connection settings do not match its stack. OpenBot did not use them.", + )); } - let child = spawn().map_err(|error| { - Problem::with( - format!("OpenBot could not restart {name}."), - error.to_string(), - ) - })?; - #[cfg(unix)] - stack::replace_host_process(root, &mut children, name, child)?; - #[cfg(not(unix))] - stack::replace_windows_host_process_with( - root, - &mut children, - name, - child, - Path::new("powershell"), - )?; - Ok(shell.generation.load(std::sync::atomic::Ordering::SeqCst) == generation) + match written + .get(LOCAL_EVAL_CONNECTED_KEY) + .map(|key| key.trim()) + .filter(|key| !key.is_empty()) + { + Some(key) => { + remember(root, "INTELLIGENCE_API_KEY", key).map_err(ConnectError::Unconnected)?; + let settings = addresses + .iter() + .map(|(name, value)| ((*name).to_owned(), (*value).clone())) + .collect(); + let purge = [(LOCAL_EVAL_CONNECTED_KEY.to_owned(), String::new())].into(); + openbot_env::write(&env_file, &settings, &purge).map_err(|error| { + ConnectError::Unconnected(Problem::with( + "OpenBot kept the local Intelligence key but could not remove it from its settings file.", + format!("{}: {}", env_file.display(), error.kind()), + )) + })?; + } + None => { + let kept = recorded.connected + && recall(root, "INTELLIGENCE_API_KEY") + .map_err(ConnectError::Unconnected)? + .is_some_and(|key| !key.trim().is_empty()); + if !kept { + return Err(unconnected( + "The Intelligence CLI connected, but OpenBot has no key for this stack.", + )); + } + } + } + let stack_id = local_eval::StackId::parse(&stack.id).map_err(ConnectError::Failure)?; + local_eval_state::mark_connected(root, &stack_id).map_err(ConnectError::Unconnected)?; + Ok(connection) } -/// Point the window at OpenBot if it is up, and at the setup screen if it is not. -/// -/// Used by the tray and by a second launch, both of which happen at moments when the caller has no -/// idea which of the two the person should be looking at. -fn show_whichever_applies(app: &tauri::AppHandle) { - match ports_for_shell(app) { - Ok(ports) => restore_window_on(app, &ports), - Err(problem) => { - *app.state::().last_failure.lock().unwrap() = Some(problem); - if let Err(error) = show_setup_and_focus(app.clone()) { - eprintln!("{error}"); +#[tauri::command] +async fn list_local_eval( + app: tauri::AppHandle, + attempt: String, +) -> Result { + let listing = + tauri::async_runtime::spawn_blocking(move || local_eval::list_installed(&attempt)) + .await + .map_err(|_| local_eval::Failure::Spawn)??; + learn_dashboards(&app, listing.stacks.iter()); + Ok(listing) +} + +#[tauri::command] +fn cancel_local_eval(attempt: String) { + local_eval::cancel(&attempt); +} + +/** +Open the recorded local stack's Intelligence dashboard, signed in, with no code to approve. + +`local login` opens the browser itself, so nothing here opens a second tab; once it has signed this +stack in during this session the dashboard's address is opened directly (see `Dashboards`). The +window gets nothing back but success or a fixed sentence; `attempt` lets `cancel_local_eval` stop +the call. +*/ +#[tauri::command] +async fn open_local_intelligence( + app: tauri::AppHandle, + attempt: String, + root: String, +) -> Result<(), Problem> { + let root = stack::root_from(&root); + tauri::async_runtime::spawn_blocking(move || { + open_dashboard_from(&app, &root, &attempt).map_err(|failure| failure.problem()) + }) + .await + .map_err(|_| DashboardFailure::Failed.problem())? +} + +/// Why the dashboard did not open: a fixed category, which is all the OpenBot app's page is told, +/// and one fixed sentence for each, which is all the setup window and the menu say. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DashboardFailure { + NotSetUp, + NotResponding, + BrowserFailed, + Busy, + EngineStopped, + Unsupported, + TimedOut, + Stopped, + Failed, +} + +impl DashboardFailure { + fn said(self) -> &'static str { + match self { + DashboardFailure::NotSetUp => "Set up local Intelligence before opening its dashboard.", + DashboardFailure::NotResponding => { + "The Intelligence dashboard is not responding yet. Try again when Intelligence is ready." + } + DashboardFailure::BrowserFailed => "Your browser did not open. Try again.", + DashboardFailure::Busy => { + "Another Intelligence command is still running. Try again when it finishes." + } + DashboardFailure::EngineStopped => "The software Intelligence runs on is not running.", + DashboardFailure::Unsupported => { + "This version of OpenBot cannot open the Intelligence dashboard." } + DashboardFailure::TimedOut => { + "The Intelligence dashboard took too long to open. Try again." + } + DashboardFailure::Stopped => "Opening the Intelligence dashboard was stopped.", + DashboardFailure::Failed => "OpenBot could not open the Intelligence dashboard. Try again.", } } + + fn problem(self) -> Problem { + Problem::plain(self.said()) + } } -fn restore_window_on(app: &tauri::AppHandle, ports: &openbot_env::Ports) { - let Some(window) = app.get_webview_window("main") else { - return; - }; - let shell = app.state::(); - let _startup = shell.startup.lock().unwrap(); - let root = cleanup_root(&shell, &stack::default_root()); - // Restore has the same deployment ownership requirement as the setup page's passive probe. - // A successful app-port response alone may belong to another installation or application. - let destination = if let Some(url) = (!recovery_required_or_pending_quit_notice(&shell, &root)) - .then(|| owned_app_url(&root, ports)) - .flatten() - { - url.parse().ok() - } else { - setup_destination(app).ok() - }; - if let Some(destination) = destination { - let already_showing = window - .url() - .is_ok_and(|current| same_window_app(¤t, &destination)); - if !already_showing { - let _ = window.navigate(destination); +/// `open_dashboard_at` with its sentence, as the setup window is told. +#[cfg(test)] +fn open_local_intelligence_at(root: &Path, login: L) -> Result<(), Problem> +where + L: FnOnce(&local_eval::StackId) -> Result, +{ + open_dashboard_at(root, login).map_err(DashboardFailure::problem) +} + +/// `sign_in` for the stack recorded in `root`, and nothing for a root with no record. +fn open_dashboard_at(root: &Path, sign_in: L) -> Result<(), DashboardFailure> +where + L: FnOnce(&local_eval::StackId) -> Result, +{ + use local_eval::{Failure, Outcome, Reason}; + let recorded = local_eval_state::read(root, None).ok_or(DashboardFailure::NotSetUp)?; + Err(match sign_in(&recorded.stack_id()) { + Ok(signed) if signed.outcome == Outcome::Completed => return Ok(()), + Ok(signed) => match signed.reason { + Some(Reason::DashboardUnavailable) => DashboardFailure::NotResponding, + Some(Reason::BrowserOpenFailed) => DashboardFailure::BrowserFailed, + Some(Reason::LocalCommandBusy) => DashboardFailure::Busy, + Some(Reason::DockerUnavailable) => DashboardFailure::EngineStopped, + _ => DashboardFailure::Failed, + }, + Err(Failure::Unavailable) => DashboardFailure::Unsupported, + Err(Failure::Timeout) => DashboardFailure::TimedOut, + Err(Failure::Cancelled) => DashboardFailure::Stopped, + Err(_) => DashboardFailure::Failed, + }) +} + +/** +The dashboard addresses the CLI has reported this session, and which stacks `local login` has +signed in since OpenBot started. + +`local login` costs a CLI call: about 0.3 s to check the pinned CLI's digests and 0.25 s for it to +start, before it waits for the dashboard, mints a single-use link and hands that to the browser. +The browser keeps the session it was given, so once a stack has been signed in during this session, +"Open Intelligence dashboard" opens its address directly and at once. OpenBot does not read the +CLI's credential files or the browser to decide; the first opening in each session signs in again, +and a dashboard whose session ended shows its own Sign In. + +Addresses come only from CLI reports, whose dashboard address was checked to be a loopback one when +the report was read. A stack whose address changes is signed in again: the browser's session +belongs to the old address. +*/ +#[derive(Default)] +struct Dashboards { + known: Mutex>, +} + +#[derive(Default)] +struct KnownDashboard { + address: Option, + signed_in: bool, +} + +impl Dashboards { + fn learn(&self, stack: &local_eval::Stack) { + let mut known = self.known.lock().unwrap(); + let entry = known.entry(stack.id.clone()).or_default(); + if entry + .address + .as_ref() + .is_some_and(|address| *address != stack.urls.dashboard) + { + entry.signed_in = false; } + entry.address = Some(stack.urls.dashboard.clone()); + } + + fn signed_in(&self, stack: &local_eval::StackId) -> Option { + let known = self.known.lock().unwrap(); + let entry = known.get(stack.as_str())?; + entry.signed_in.then(|| entry.address.clone()).flatten() + } + + fn mark_signed_in(&self, stack: &local_eval::StackId) { + self.known + .lock() + .unwrap() + .entry(stack.as_str().to_owned()) + .or_default() + .signed_in = true; } - let _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); } -fn schedule_second_instance_restore( - context: T, - restore: F, -) -> std::io::Result> +/** +Open `stack`'s dashboard: straight to its address once this session has signed it in, otherwise +through `login`, remembering a completed sign-in. + +`open` hands an address to the browser. Its failure is the CLI's own category for the same thing. +*/ +fn open_or_sign_in( + dashboards: &Dashboards, + stack: &local_eval::StackId, + login: L, + open: O, +) -> Result where - T: Send + 'static, - F: FnOnce(T) + Send + 'static, + L: FnOnce(&local_eval::StackId) -> Result, + O: FnOnce(&str) -> Result<(), ()>, { - std::thread::Builder::new() - .name("openbot-second-instance-restore".into()) - .spawn(move || restore(context)) + use local_eval::{Outcome, Reason, SignIn}; + if let Some(address) = dashboards.signed_in(stack) { + return Ok(match open(&address) { + Ok(()) => SignIn { + outcome: Outcome::Completed, + reason: None, + }, + Err(()) => SignIn { + outcome: Outcome::Failed, + reason: Some(Reason::BrowserOpenFailed), + }, + }); + } + let signed = login(stack)?; + if signed.outcome == Outcome::Completed { + dashboards.mark_signed_in(stack); + } + Ok(signed) } -fn restore_after_second_instance(app: &tauri::AppHandle) { - let app = app.clone(); - let reporting_app = app.clone(); - if let Err(error) = schedule_second_instance_restore(app, |app| { - show_whichever_applies(&app); - }) { - eprintln!("[single-instance] restore scheduling failed: {error}"); - report( - &reporting_app, - "open", - false, - format!("OpenBot could not show the existing window: {error}"), - ); +/// The real `open_dashboard_at`: the installed CLI, and the browser through the opener plugin. +fn open_dashboard_from( + app: &tauri::AppHandle, + root: &Path, + attempt: &str, +) -> Result<(), DashboardFailure> { + open_dashboard_at(root, |stack| { + open_or_sign_in( + &app.state::().dashboards, + stack, + |stack| local_eval::login_installed(attempt, stack), + |address| { + use tauri_plugin_opener::OpenerExt; + app.opener().open_url(address, None::<&str>).map_err(|_| ()) + }, + ) + }) +} + +/// The root whose dashboard the menu opens: only one in local mode with a recorded stack. +fn intelligence_menu_root(root: &Path, offered: bool) -> Option { + (offered && local_eval_state::read(root, None).is_some()).then(|| root.to_path_buf()) +} + +/** +The window says whether it is in local mode for `root`, and the menus follow. + +The window decides local mode, as it does for its own link; the menus add "Open Intelligence +dashboard" only while it says so and a stack is recorded there. Sync, so the menus are rebuilt on +the main thread. +*/ +#[tauri::command] +fn offer_local_intelligence(app: tauri::AppHandle, root: String, offered: bool) { + let next = intelligence_menu_root(&stack::root_from(&root), offered); + let shell = app.state::(); + let mut current = shell.intelligence_menu.lock().unwrap(); + if *current == next { + return; + } + let show = next.is_some(); + *current = next; + drop(current); + if let Err(error) = install_menus(&app, show) { + eprintln!("[menu] could not update the menus: {error}"); } + intelligence_bridge::tell(&app, intelligence_bridge::current(&app)); } -fn publish_quit_notice_failure(app: tauri::AppHandle, error: String) { - let problem = Problem::with("OpenBot could not record a shutdown problem.", error); +/// The menu's "Open Intelligence dashboard". The CLI can take many seconds the first time, so the +/// window comes forward without leaving what it shows, where the setup page or the app's sidebar +/// says the dashboard is opening. +fn open_intelligence_from_menu(app: tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + start_intelligence_opening(app); +} + +/** +Open the dashboard of the stack the menus offer, from the menu or the app's sidebar alike. + +Answered at once, before any CLI call: the menu item is disabled, a setup page shows the words +with a spinner, and the app's sidebar row spins (`intelligence_bridge`). How it went is said in the +same places. A failure that has none of them on screen is said in a dialog. +*/ +fn start_intelligence_opening(app: tauri::AppHandle) { let shell = app.state::(); - { - let _startup = shell.startup.lock().unwrap(); - let root = cleanup_root(&shell, &stack::default_root()); - let generation = shell.generation.load(std::sync::atomic::Ordering::SeqCst); - mark_recovery_required(&shell, &root, generation); - *shell.last_failure.lock().unwrap() = Some(problem); + let Some(root) = shell.intelligence_menu.lock().unwrap().clone() else { + // Nothing to open any more: a row that thought otherwise is told, so it does not spin. + intelligence_bridge::tell(&app, intelligence_bridge::current(&app)); + return; + }; + let opening = std::sync::Arc::clone(&shell.intelligence_opening); + let status_app = app.clone(); + open_intelligence_reporting( + opening, + move |status| show_intelligence_opening(&status_app, status), + move || { + let attempt = format!("menu-open-intelligence-{:016x}", rand::random::()); + open_dashboard_from(&app, &root, &attempt) + }, + |job| { + std::thread::Builder::new() + .name("openbot-open-intelligence".into()) + .spawn(job) + .map(|_| ()) + }, + ); +} + +/// What the menu's dashboard opening says, with fixed words: as `quit:progress`, nothing the CLI +/// printed reaches the window. +#[derive(Clone, Debug, PartialEq, Eq)] +enum IntelligenceOpening { + Opening, + Opened, + Failed(DashboardFailure), +} + +/// `{"state": "opening" | "opened" | "failed", "said": …}`, the words always included. +impl Serialize for IntelligenceOpening { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut status = serializer.serialize_struct("IntelligenceOpening", 2)?; + status.serialize_field( + "state", + match self { + IntelligenceOpening::Opening => "opening", + IntelligenceOpening::Opened => "opened", + IntelligenceOpening::Failed(_) => "failed", + }, + )?; + status.serialize_field("said", self.said())?; + status.end() } - let _ = show_setup_and_focus(app); } -fn stop_from_menu(app: tauri::AppHandle) { - std::thread::spawn(move || { - let root = default_root(); - let root_path = PathBuf::from(&root); - eprintln!("[menu] stopping the stack under {root}"); - match stop_everything(&app, &root_path) { - Ok(()) => { - eprintln!("[menu] stopped"); - report(&app, "stopped", true, "OpenBot has been stopped"); - } - // Said rather than swallowed. A menu item that fails silently is worse than one - // that is not there: the person believes the stack is down and it is not. - Err(detail) => { - eprintln!("[menu] stop failed: {detail}"); - let problem = Problem::with( - "OpenBot could not finish stopping. Try Stop OpenBot again.", - detail, - ); - let shell = app.state::(); - { - let _startup = shell.startup.lock().unwrap(); - let root = cleanup_root(&shell, &root_path); - let generation = shell.generation.load(std::sync::atomic::Ordering::SeqCst); - mark_recovery_required(&shell, &root, generation); - *shell.last_failure.lock().unwrap() = Some(problem.clone()); - } - report(&app, "stopped", false, problem.said); - } +impl IntelligenceOpening { + fn said(&self) -> &str { + match self { + IntelligenceOpening::Opening => "Opening the Intelligence dashboard…", + IntelligenceOpening::Opened => "Opened in your browser.", + IntelligenceOpening::Failed(failure) => failure.said(), } - let _ = show_setup(app.clone()); + } +} + +const INTELLIGENCE_OPENING_EVENT: &str = "intelligence:opening"; + +/** +Say `Opening` now, run `open` through `spawn`, then say how it went. + +Once at a time: a click that arrives while one runs, from a menu built before the item was +disabled, does nothing. `opening` is clear again before the outcome is said, so the menus rebuilt +for it offer the item again. +*/ +fn open_intelligence_reporting( + opening: std::sync::Arc, + status: S, + open: O, + spawn: W, +) where + S: Fn(IntelligenceOpening) + Clone + Send + 'static, + O: FnOnce() -> Result<(), DashboardFailure> + Send + 'static, + W: FnOnce(Box) -> std::io::Result<()>, +{ + use std::sync::atomic::Ordering; + if opening.swap(true, Ordering::SeqCst) { + return; + } + status(IntelligenceOpening::Opening); + let finishing = std::sync::Arc::clone(&opening); + let finished = status.clone(); + let job = Box::new(move || { + let outcome = match open() { + Ok(()) => IntelligenceOpening::Opened, + Err(failure) => IntelligenceOpening::Failed(failure), + }; + finishing.store(false, Ordering::SeqCst); + finished(outcome); }); + if spawn(job).is_err() { + opening.store(false, Ordering::SeqCst); + status(IntelligenceOpening::Failed(DashboardFailure::Failed)); + } } -/// What each of the three items does, wherever it was chosen from. -/// -/// The tray and the window menu carry the same items, so they share one function: two copies would -/// be two chances for Stop to mean something different depending on where somebody clicked. -fn chose(app: &tauri::AppHandle, item: &str) { - match item { - "open" => show_whichever_applies(app), - // Stop without quitting: the stack is what costs something to leave running, and somebody - // who wants it stopped does not necessarily want the application gone. - "stop" => stop_from_menu(app.clone()), - // Exit rather than hide: quitting is a decision to stop, and the exit handler is what stops - // the processes with it. - "quit" => { - app.exit(0); +const OPENING_INTELLIGENCE_ITEM: &str = "Opening Intelligence dashboard…"; + +/// The menu's "Open Intelligence dashboard" item: its words, and whether it can be chosen. +fn intelligence_menu_item(opening: bool) -> (&'static str, bool) { + if opening { + (OPENING_INTELLIGENCE_ITEM, false) + } else { + ("Open Intelligence dashboard", true) + } +} + +/// Tell the setup page and the app's sidebar, then put the menus in step. Nothing is written in +/// the menu bar: a status title there widened the item behind the camera notch and, on macOS, +/// clearing the title never took it away. A failure with no page on screen to say it gets a dialog. +/// Menus belong to the main thread. +fn show_intelligence_opening( + app: &tauri::AppHandle, + status: IntelligenceOpening, +) { + let _ = app.emit(INTELLIGENCE_OPENING_EVENT, &status); + let told = intelligence_bridge::tell(app, intelligence_bridge::Offer::after(&status)); + if let IntelligenceOpening::Failed(failure) = &status { + if !told && !showing_setup(app) { + use tauri_plugin_dialog::DialogExt; + app.dialog() + .message(failure.said()) + .title("Open Intelligence dashboard") + .show(|_| {}); } - _ => {} } + refresh_menus(app); } -fn quit_menu_accelerator() -> Option<&'static str> { - Some(QUIT_MENU_ACCELERATOR) +/// Room left between a grown or clamped window and the edge of its monitor's work area. +const SCREEN_MARGIN: f64 = 24.0; + +/** +How tall the window's content area should become, in logical pixels, or None to leave it. + +Grows to `wanted` and never shrinks: a window that jumped smaller under somebody's pointer when a +row collapsed would be worse than one with a little room to spare. `tallest` is the most the content +area may have on this monitor; past it the page scrolls, as it always could. +*/ +fn grown_height(current: f64, wanted: f64, tallest: f64) -> Option { + let target = wanted.ceil().min(tallest.floor()); + (target > current.ceil()).then_some(target) +} + +/// The window's monitor work area in logical pixels, and the height of its title bar and frame. +fn screen_room( + window: &tauri::WebviewWindow, +) -> Option<(tauri::LogicalRect, f64)> { + let scale = window.scale_factor().ok()?; + let monitor = window.current_monitor().ok().flatten()?; + let area = monitor.work_area(); + let frame = window.outer_size().ok()?.height as f64 - window.inner_size().ok()?.height as f64; + Some(( + tauri::LogicalRect { + position: area.position.to_logical(scale), + size: area.size.to_logical(scale), + }, + frame / scale, + )) } -fn main() { - tauri::Builder::default() - // A second launch is somebody looking for the window they already have, not a request for a - // second stack. Without this both copies bind the same ports and the loser reports a - // failure that belongs to the winner. - .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - restore_after_second_instance(app); - })) - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) - .manage(Shell::default()) - .invoke_handler(tauri::generate_handler![ - record_setup_event, - detect_engine, - windows_blocker, - windows_blocker_instruction, - prepare_engine, - prepare_installation, - start_stack, - reset_leftover_database, - stop_stack, - show_openbot, - show_setup, - already_running, - last_failure, - default_root, - selected_root, - harnesses, - providers, - already_configured, - begin_claude_sign_in, - finish_claude_sign_in, - begin_chatgpt_sign_in, - begin_model_oauth, - finish_model_oauth, - cancel_model_oauth, - finish_chatgpt_sign_in, - begin_self_hosted_intelligence_sign_in, - finish_self_hosted_intelligence_sign_in, - cancel_self_hosted_intelligence_sign_in, - begin_intelligence_sign_in, - finish_intelligence_sign_in, - intelligence_key_for, - create_intelligence_project, - begin_organization_sign_in, - finish_organization_sign_in, - cancel_organization_sign_in, - ask_the_bot, - ]) - // A packaged application is not a browser tab. Left alone, WebView2 answers a right-click - // with Back, Refresh, Save as and Print: Back walks the window out of OpenBot with nothing - // to walk it home, and Save as offers to write the page to disk as `Webpage, complete`. - // macOS never showed this because Tauri suppresses it there in release builds; Windows has - // no such setting, and Tauri has no configuration option for it either, so the page is - // asked to refuse. Every navigation, because the window navigates to OpenBot and back. - .on_page_load(|window, _| { - let _ = window - .eval("document.addEventListener('contextmenu', e => e.preventDefault(), true)"); - }) - // Closing the window hides it. A tray application whose window is destroyed on close has a - // menu item that points at nothing: `get_webview_window` returns None from then on, and the - // only way back is to quit and start again, with a stack still running that nothing on - // screen can reach. - .on_window_event(|window, event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event { - api.prevent_close(); - let _ = window.hide(); - } - }) - .setup(|app| { - desktop_telemetry::initialize(app.handle()); - if let Some(root) = app - .path() - .app_config_dir() - .ok() - .and_then(|config| preparation::selected_root(&config)) - { - remember_selected_root(&app.state::(), &root); - } - // Where the Compose provider OpenBot installs itself lives, told once so every engine - // command can put it on the child's PATH. Before anything asks for an engine. - engine::tools_live_in(engine::tools_dir_under(&acquire::download_dir( - &stack::default_root(), - ))); - - remember_setup_url(app.handle())?; - - // The status menu lets somebody open the window, stop the stack, or quit the app. - use tauri::menu::{Menu, MenuItem}; - use tauri::tray::TrayIconBuilder; - - let open = MenuItem::with_id(app, "open", "Open OpenBot", true, None::<&str>)?; - let stop = MenuItem::with_id(app, "stop", "Stop OpenBot", true, None::<&str>)?; - let quit = MenuItem::with_id(app, "quit", "Quit", true, quit_menu_accelerator())?; - let menu = Menu::with_items(app, &[&open, &stop, &quit])?; +/// Resize the content area to `height` logical pixels, moving the window up if its bottom would +/// leave the work area. +fn set_window_height( + window: &tauri::WebviewWindow, + area: &tauri::LogicalRect, + frame: f64, + height: f64, +) { + let Ok(scale) = window.scale_factor() else { + return; + }; + let Ok(inner) = window.inner_size() else { + return; + }; + let width = inner.to_logical::(scale).width; + let _ = window.set_size(tauri::LogicalSize::new(width, height)); + if let Ok(position) = window.outer_position() { + let position = position.to_logical::(scale); + let bottom = area.position.y + area.size.height - SCREEN_MARGIN; + if position.y + frame + height > bottom { + let top = (bottom - frame - height).max(area.position.y); + let _ = window.set_position(tauri::LogicalPosition::new(position.x, top)); + } + } +} - TrayIconBuilder::with_id("openbot") - .icon(tray::icon()) - .icon_as_template(false) - .tooltip("OpenBot") - .menu(&menu) - .build(app)?; +/** +Make the setup window tall enough for the screen it is showing, within its monitor. - // The same three items on the window itself, because the tray cannot be relied on and - // Stop lives nowhere else. - // - // Linux needs a tray host to draw the icon, and Windows can place it in overflow. - // The tray library restores the Windows icon after Explorer restarts, but the window - // menu still provides access when the tray is unavailable or hard to find. - // Its own items, not the tray's: a menu item belongs to one menu, and the two menus - // outlive each other. The ids match so both arrive at the same function. - use tauri::menu::Submenu; - let window_open = MenuItem::with_id(app, "open", "Open OpenBot", true, None::<&str>)?; - let window_stop = MenuItem::with_id(app, "stop", "Stop OpenBot", true, None::<&str>)?; - let window_quit = - MenuItem::with_id(app, "quit", "Quit", true, quit_menu_accelerator())?; - // A submenu, because a top-level entry in a menu bar has to be one to open at all. - let openbot = Submenu::with_items( - app, - "OpenBot", - true, - &[&window_open, &window_stop, &window_quit], - )?; - /* - * AN EDIT MENU, WITHOUT WHICH COMMAND-V DOES NOTHING. - * - * MEASURED, on the screen that asks for a paste. macOS routes the clipboard shortcuts - * through the menu bar, so a window with no Edit menu has no Paste, and a webview text - * field silently ignores the keystroke. Typing worked and pasting did not, on the one - * screen whose own instruction is "paste the code it shows you". Every person signing - * in to a Claude plan would have reached that field, pressed the shortcut everybody - * knows, and had nothing happen. - * - * Predefined items rather than our own: these carry the standard shortcuts and the - * standard behaviour, which is the whole point of them being where a person expects. - */ - use tauri::menu::PredefinedMenuItem; - let edit = Submenu::with_items( - app, - "Edit", - true, - &[ - &PredefinedMenuItem::undo(app, None)?, - &PredefinedMenuItem::redo(app, None)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::cut(app, None)?, - &PredefinedMenuItem::copy(app, None)?, - &PredefinedMenuItem::paste(app, None)?, - &PredefinedMenuItem::select_all(app, None)?, - ], - )?; - app.set_menu(Menu::with_items(app, &[&openbot, &edit])?)?; - app.on_menu_event(|app, event| chose(app, event.id().as_ref())); - Ok(()) - }) - .build(tauri::generate_context!()) - .expect("the OpenBot window could not be created") - .run(|app, event| { - match event { - #[cfg(target_os = "macos")] - tauri::RunEvent::Reopen { .. } => { - show_whichever_applies(app); - } - tauri::RunEvent::ExitRequested { api, code, .. } => { - // Exit runs on the event-loop thread. Waiting there for Windows process - // inventory or Compose made Quit show "Not Responding". Keep the loop alive - // until cleanup finishes, then allow its final exit without repeating work. - let cleaning_app = app.clone(); - let notice_app = app.clone(); - let notice_failure_app = app.clone(); - let exiting_app = app.clone(); - if let Err(error) = request_quit_with( - std::sync::Arc::clone(&app.state::().quit), - code, - || api.prevent_exit(), - move || { - desktop_telemetry::shutdown(&cleaning_app); - let shell = cleaning_app.state::(); - exit_cleanup_with( - &shell, - &stack::default_root(), - stack::stop_processes_under, - |root| down_containers_on_quit(&shell, root), - ) - }, - QuitDiagnostics { - sink: move |failures: Vec| { - for failure in &failures { - eprintln!("{failure}"); - } - let shell = notice_app.state::(); - let root = cleanup_root(&shell, &stack::default_root()); - write_quit_cleanup_notice(&root, &failures) - }, - failed: move |error: String| { - publish_quit_notice_failure(notice_failure_app.clone(), error) - }, - }, - move |code| exiting_app.exit(code), - |work| { - std::thread::Builder::new() - .name("openbot-quit-cleanup".into()) - .spawn(work) - .map(|_| ()) - }, - ) { - eprintln!("[exit] could not start cleanup: {error}"); - report( - app, - "quit", - false, - "OpenBot could not start shutting down. Try Quit again.", - ); - } - } - _ => {} - } - }); +The setup page measures its own content and asks; it is not granted any `core:window` permission, +so this is the only way it can change the window, and only ever larger. A maximized or full-screen +window is left as the person made it. +*/ +#[tauri::command] +fn fit_setup_window(window: tauri::WebviewWindow, content_height: f64) { + if !content_height.is_finite() + || window.is_maximized().unwrap_or(true) + || window.is_fullscreen().unwrap_or(true) + { + return; + } + let (Some((area, frame)), Ok(scale), Ok(inner)) = ( + screen_room(&window), + window.scale_factor(), + window.inner_size(), + ) else { + return; + }; + let current = inner.to_logical::(scale).height; + let tallest = area.size.height - frame - 2.0 * SCREEN_MARGIN; + if let Some(height) = grown_height(current, content_height, tallest) { + set_window_height(&window, &area, frame, height); + } } -#[cfg(test)] -mod tests { - use super::*; - use std::io::{Read, Write}; +/// On launch: a window taller than its monitor's work area is brought within it. +fn fit_main_window_to_screen(app: &tauri::AppHandle) { + let Some(window) = app.get_webview_window("main") else { + return; + }; + let (Some((area, frame)), Ok(scale), Ok(inner)) = ( + screen_room(&window), + window.scale_factor(), + window.inner_size(), + ) else { + return; + }; + let tallest = area.size.height - frame - 2.0 * SCREEN_MARGIN; + if inner.to_logical::(scale).height > tallest { + set_window_height(&window, &area, frame, tallest.floor()); + } +} - include!("stop_ipc_tests.rs"); +/// Whether the main window shows the setup page, whose own overlay says the menu's outcome. +fn showing_setup(app: &tauri::AppHandle) -> bool { + let Some(window) = app.get_webview_window("main") else { + return false; + }; + let Ok(setup) = setup_destination(app) else { + return false; + }; + window + .url() + .is_ok_and(|current| same_window_app(¤t, &setup)) +} - #[test] - fn learning_default_is_written_only_for_the_provisioned_connection() { - use std::collections::BTreeMap; - let root = temp_root("learning-default-env"); - std::fs::create_dir_all(&root).unwrap(); - let proof = openbot_desktop_lib::intelligence::ProvisionedConnection { - api_key: "runtime-key".into(), - api_url: "https://api.intelligence.example".into(), - learning_container_id: "openbot".into(), - }; - for (api, key, existing, verified, expected) in [ - ("https://api.intelligence.example", "runtime-key", None, true, Some("openbot")), - ("https://api.intelligence.example", "runtime-key", Some(""), true, Some("openbot")), - ("https://api.intelligence.example/", "runtime-key", Some(""), true, Some("openbot")), - ("https://other.example", "runtime-key", Some(""), true, None), - ("https://api.intelligence.example", "other-key", Some(""), true, None), - ("https://api.intelligence.example", "runtime-key", Some(""), false, None), - ("https://api.intelligence.example", "runtime-key", Some("CPK_INTELLIGENCE_LEARNING_CONTAINER_ID=custom\nCPK_INTELLIGENCE_SKILLS_REVISION=7\n"), true, Some("custom")), - ("https://api.intelligence.example", "runtime-key", Some("export \tCPK_INTELLIGENCE_LEARNING_CONTAINER_ID = custom\n"), true, Some("custom")), - ] { - if let Some(existing) = existing { - std::fs::write(root.join(".env"), existing).unwrap(); - } - let mut settings = BTreeMap::from([ - ("INTELLIGENCE_API_URL".into(), api.into()), - ("INTELLIGENCE_API_KEY".into(), key.into()), - ]); - seed_learning_default(&root, &mut settings, verified.then_some(&proof)).unwrap(); - openbot_env::write(&root.join(".env"), &settings, &BTreeMap::new()).unwrap(); - let read = openbot_env::read_already_set(&root.join(".env"), &["CPK_INTELLIGENCE_LEARNING_CONTAINER_ID", "CPK_INTELLIGENCE_SKILLS_REVISION"]).unwrap(); - if let Some(exported) = existing.filter(|text| text.starts_with("export")) { - // The shared setup reader does not parse export syntax; the file must keep the - // custom assignment with no bare assignment appended to override it. - assert!(!read.contains_key("CPK_INTELLIGENCE_LEARNING_CONTAINER_ID")); - assert!(std::fs::read_to_string(root.join(".env")).unwrap().contains(exported)); - continue; - } - assert_eq!(read.get("CPK_INTELLIGENCE_LEARNING_CONTAINER_ID").map(String::as_str), expected); - if expected == Some("custom") { - assert_eq!(read.get("CPK_INTELLIGENCE_SKILLS_REVISION").map(String::as_str), Some("7")); - } +/// `bun` from PATH, or the places an installer puts it when PATH has not been reloaded. +fn which_bun() -> Option { + if quiet::command("bun") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + if let Some(path) = std::env::var_os("PATH").and_then(|path| { + std::env::split_paths(&path) + .map(|directory| directory.join(if cfg!(windows) { "bun.exe" } else { "bun" })) + .find(|path| path.is_file()) + }) { + return Some(path); } - std::fs::remove_dir_all(root).unwrap(); } + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .ok()?; + let candidates = [ + PathBuf::from(&home).join(".bun/bin/bun"), + PathBuf::from(&home).join(".bun/bin/bun.exe"), + ]; + candidates.into_iter().find(|path| path.exists()) +} - #[test] - fn quit_menu_uses_the_standard_quit_shortcut() { - assert_eq!(quit_menu_accelerator(), Some("CmdOrCtrl+KeyQ")); +/// Watch the three host processes and start one again when it dies. +/// +/// The policy is in `supervise.rs`; this is the loop that applies it. It ends when the stack is +/// stopped, which is what clearing the root means, so stopping does not race a restart. +fn publish_connection_failure( + app: &tauri::AppHandle, + root: &Path, + generation: u64, + connection: openbot_desktop_lib::problem::Connection, +) -> Result { + let shell = app.state::(); + let _startup = shell.startup.lock().unwrap(); + if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation + || shell.root.lock().unwrap().as_deref() != Some(root) + { + return Ok(false); + } + if recovery_required(&shell, root) { + return Ok(true); + } + let said = match connection { + openbot_desktop_lib::problem::Connection::Model => "Your AI connection was refused. Refresh it to continue using your existing OpenBot.", + openbot_desktop_lib::problem::Connection::Intelligence => "Your CopilotKit connection was refused. Refresh it to continue using your existing OpenBot.", + openbot_desktop_lib::problem::Connection::Organization => "Your organization sign-in needs to be refreshed to continue using your existing OpenBot.", + }; + mark_recovery_required(&shell, root, generation); + if let Some(recovery) = shell.recovery_required.lock().unwrap().as_mut() { + recovery.connection = Some(connection.clone()); } + *shell.last_failure.lock().unwrap() = Some(Problem::plain(said).connection(connection)); + show_setup_and_focus(app.clone())?; + Ok(true) +} - #[test] +#[allow( + clippy::too_many_arguments, + reason = "Retain selected ports alongside the supervised run's identity and credentials." +)] +fn supervise_host_processes( + app: tauri::AppHandle, + root: PathBuf, + logs: PathBuf, + bun: PathBuf, + // Carried rather than fetched again on each restart. A restart happens when something is + // already wrong, and a credential prompt at that moment is the worst time to ask for one. + secrets: stack::Secrets, + generation: u64, + ports: openbot_env::Ports, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + eprintln!( + "[watch] supervising {} host processes", + stack::HOST_PROCESSES.len() + ); + let mut watches: Vec = stack::HOST_PROCESSES + .iter() + .map(|process| supervise::Watch::new(process.name)) + .collect(); + let connection_client = desktop_connection::client() + .map_err(|error| { + eprintln!("[watch] {error}"); + }) + .ok(); + let mut connection_notice_sent = false; + let mut connection_poll_failed = false; + + loop { + std::thread::sleep(std::time::Duration::from_secs(2)); + let shell = app.state::(); + // Not this run's any more, or no run at all. + if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation + || shell.root.lock().unwrap().is_none() + { + return; + } + + if connection_notice_sent && !recovery_required(&shell, &root) { + connection_notice_sent = false; + } + if !connection_notice_sent { + if let (Some(client), Some(token)) = ( + &connection_client, + secrets.get("OPENBOT_DESKTOP_HOST_TOKEN"), + ) { + match desktop_connection::poll(client, ports.server, token) { + Ok(Some(connection)) => { + match publish_connection_failure(&app, &root, generation, connection) { + Ok(published) => connection_notice_sent = published, + Err(error) => { + eprintln!("[watch] could not show connection refresh: {error}") + } + } + } + Ok(None) => connection_poll_failed = false, + Err(error) => { + if !connection_poll_failed { + eprintln!("[watch] {error}"); + } + connection_poll_failed = true; + } + } + } + } + + // Which ones have died. Collected rather than acted on under the lock, because a + // restart waits, and waiting while holding the children is how Stop would block on a + // backoff nobody asked it to sit through. + let dead: Vec<&'static str> = { + let mut children = shell.children.lock().unwrap(); + let mut dead = Vec::new(); + for (name, child) in children.iter_mut() { + if let Ok(Some(_)) = child.try_wait() { + dead.push(*name); + } + } + dead + }; + + if !dead.is_empty() { + eprintln!("[watch] dead: {dead:?}"); + } + for name in dead { + let Some(watch) = watches.iter_mut().find(|watch| watch.name == name) else { + continue; + }; + if !watch.should_restart(std::time::Instant::now()) { + // A restore already probing may finish first; publish recovery and its setup + // navigation together after it, so that late probe cannot undo this transition. + let _startup = shell.startup.lock().unwrap(); + // Let go of it. A dead child left in the list is found dead again two seconds + // later, and forever after: the count climbs past what actually happened, the + // window is sent back to the setup screen on a loop, and the giving up that was + // supposed to stop a hot laptop becomes one. + { + let mut children = shell.children.lock().unwrap(); + if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation + || shell.root.lock().unwrap().as_deref() != Some(root.as_path()) + { + return; + } + children.retain(|(held, _)| *held != name); + } + + let reason = watch.gave_up(); + mark_recovery_required(&shell, &root, generation); + report(&app, name, false, reason.clone()); + /* + * Both registers here too. `gave_up` names the process and quotes the tail of + * its log, which is the developer half; the person needs to know a piece of + * OpenBot stopped and that starting again is the thing to try. + */ + *shell.last_failure.lock().unwrap() = + Some(openbot_desktop_lib::problem::Problem::with( + format!( + "Part of OpenBot ({name}) stopped and could not be started again. \ + Try starting OpenBot once more." + ), + reason, + )); + // Back to the setup screen. By now the window is showing OpenBot, and OpenBot + // is not running: leaving it there is a window that lies. + let _ = show_setup(app.clone()); + continue; + } + report( + &app, + name, + false, + format!("{name} stopped. Starting it again."), + ); + std::thread::sleep(supervise::backoff(watch.restarts - 1)); + + // Asked again after the backoff: a stop, or another start, may have happened while + // this was waiting, and starting a process into either is how an orphan is made. + if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation + || shell.root.lock().unwrap().is_none() + { + return; + } + let Some(process) = stack::HOST_PROCESSES + .iter() + .find(|process| process.name == name) + else { + continue; + }; + match restart_host_process_with(&shell, &root, name, generation, || { + stack::spawn_host_process(process, &root, &logs, &bun, &secrets) + }) { + Ok(true) => report(&app, name, true, "started again"), + Ok(false) => return, + Err(problem) => { + report(&app, name, false, problem.said.clone()); + *shell.last_failure.lock().unwrap() = Some(problem); + } + } + } + } + }) +} + +/// The same lock covers generation validation, launch, publication, and owned cleanup on every +/// platform. Stop can retire during spawn, but cannot finish before receiving that child handle. +fn restart_host_process_with( + shell: &Shell, + root: &Path, + name: &'static str, + generation: u64, + spawn: F, +) -> Result +where + F: FnOnce() -> std::io::Result, +{ + let mut children = shell.children.lock().unwrap(); + if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation + || shell.root.lock().unwrap().as_deref() != Some(root) + { + return Ok(false); + } + let child = spawn().map_err(|error| { + Problem::with( + format!("OpenBot could not restart {name}."), + error.to_string(), + ) + })?; + #[cfg(unix)] + stack::replace_host_process(root, &mut children, name, child)?; + #[cfg(not(unix))] + stack::replace_windows_host_process_with( + root, + &mut children, + name, + child, + Path::new("powershell"), + )?; + Ok(shell.generation.load(std::sync::atomic::Ordering::SeqCst) == generation) +} + +/// Point the window at OpenBot if it is up, and at the setup screen if it is not. +/// +/// Used by the tray and by a second launch, both of which happen at moments when the caller has no +/// idea which of the two the person should be looking at. +fn show_whichever_applies(app: &tauri::AppHandle) { + match ports_for_shell(app) { + Ok(ports) => restore_window_on(app, &ports), + Err(problem) => { + *app.state::().last_failure.lock().unwrap() = Some(problem); + if let Err(error) = show_setup_and_focus(app.clone()) { + eprintln!("{error}"); + } + } + } +} + +fn restore_window_on(app: &tauri::AppHandle, ports: &openbot_env::Ports) { + let Some(window) = app.get_webview_window("main") else { + return; + }; + let shell = app.state::(); + let _startup = shell.startup.lock().unwrap(); + let root = cleanup_root(&shell, &stack::default_root()); + // Restore has the same deployment ownership requirement as the setup page's passive probe. + // A successful app-port response alone may belong to another installation or application. + let destination = if let Some(url) = (!recovery_required_or_pending_quit_notice(&shell, &root)) + .then(|| owned_app_url(&root, ports)) + .flatten() + { + intelligence_bridge::allow(app, &url); + url.parse().ok() + } else { + setup_destination(app).ok() + }; + if let Some(destination) = destination { + let already_showing = window + .url() + .is_ok_and(|current| same_window_app(¤t, &destination)); + if !already_showing { + let _ = window.navigate(destination); + } + } + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); +} + +fn schedule_second_instance_restore( + context: T, + restore: F, +) -> std::io::Result> +where + T: Send + 'static, + F: FnOnce(T) + Send + 'static, +{ + std::thread::Builder::new() + .name("openbot-second-instance-restore".into()) + .spawn(move || restore(context)) +} + +fn restore_after_second_instance(app: &tauri::AppHandle) { + let app = app.clone(); + let reporting_app = app.clone(); + if let Err(error) = schedule_second_instance_restore(app, |app| { + show_whichever_applies(&app); + }) { + eprintln!("[single-instance] restore scheduling failed: {error}"); + report( + &reporting_app, + "open", + false, + format!("OpenBot could not show the existing window: {error}"), + ); + } +} + +fn publish_quit_notice_failure(app: tauri::AppHandle, error: String) { + let problem = Problem::with("OpenBot could not record a shutdown problem.", error); + let shell = app.state::(); + { + let _startup = shell.startup.lock().unwrap(); + let root = cleanup_root(&shell, &stack::default_root()); + let generation = shell.generation.load(std::sync::atomic::Ordering::SeqCst); + mark_recovery_required(&shell, &root, generation); + *shell.last_failure.lock().unwrap() = Some(problem); + } + let _ = show_setup_and_focus(app); +} + +fn stop_from_menu(app: tauri::AppHandle) { + std::thread::spawn(move || { + let root = default_root(); + let root_path = PathBuf::from(&root); + eprintln!("[menu] stopping the stack under {root}"); + let stopped = stop_everything(&app, &root_path); + refresh_menus(&app); + match settle_menu_stop(&app.state::(), &root_path, stopped) { + None => { + eprintln!("[menu] stopped"); + report(&app, "stopped", true, "OpenBot has been stopped"); + } + // Said rather than swallowed. A menu item that fails silently is worse than one + // that is not there: the person believes the stack is down and it is not. + Some(problem) => { + eprintln!( + "[menu] stop failed: {}", + problem.detail.as_deref().unwrap_or_default() + ); + report(&app, "stopped", false, problem.said); + } + } + let _ = show_setup(app.clone()); + }); +} + +/// Keep a menu Stop's failure for the setup screen, and mark recovery only when OpenBot's own +/// processes or containers may still be running. None when everything stopped. +fn settle_menu_stop(shell: &Shell, fallback_root: &Path, stopped: Stopped) -> Option { + let openbot_failed = !stopped.openbot.is_empty(); + let problem = if openbot_failed { + Problem::with( + "OpenBot could not finish stopping. Try Stop OpenBot again.", + stopped.lines().join("\n"), + ) + } else { + Problem::with( + "OpenBot stopped, but could not stop the local Intelligence stack it started.", + stopped.intelligence?, + ) + }; + let _startup = shell.startup.lock().unwrap(); + if openbot_failed { + let root = cleanup_root(shell, fallback_root); + let generation = shell.generation.load(std::sync::atomic::Ordering::SeqCst); + mark_recovery_required(shell, &root, generation); + } + *shell.last_failure.lock().unwrap() = Some(problem.clone()); + Some(problem) +} + +/// What each of the three items does, wherever it was chosen from. +/// +/// The tray and the window menu carry the same items, so they share one function: two copies would +/// be two chances for Stop to mean something different depending on where somebody clicked. +fn chose(app: &tauri::AppHandle, item: &str) { + match item { + "open" => show_whichever_applies(app), + // Stop without quitting: the stack is what costs something to leave running, and somebody + // who wants it stopped does not necessarily want the application gone. + "stop" => stop_from_menu(app.clone()), + "status" => show_status_window(app), + "open-intelligence" => open_intelligence_from_menu(app.clone()), + // Exit rather than hide: quitting is a decision to stop, and the exit handler is what stops + // the processes with it. + "quit" => { + app.exit(0); + } + _ => {} + } +} + +/** +A quit macOS starts, not OpenBot: Dock > Quit, an AppleScript `quit`, logging out or shutting down. + +Tauri on macOS answers those only in `applicationWillTerminate:`, which ends the event loop at once: +`RunEvent::ExitRequested`, where OpenBot's own Quit stops its CLI calls, its containers and the +Intelligence stack it made, never runs. A local setup quit that way kept running with nobody to +stop it, and the next setup was refused as busy. + +So the app delegate also answers `applicationShouldTerminate:`: it cancels macOS's termination +and asks for OpenBot's own Quit (`app.exit`), whose cleanup then exits. Tauri ends the loop with +`stop:` and `process::exit`, never `terminate:`, so this is not asked again on the way out. A +logout waits for nothing here: macOS reports that OpenBot cancelled it, and logging out again +after OpenBot has closed goes through. +*/ +#[cfg(target_os = "macos")] +mod macos_quit { + use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; + use std::sync::OnceLock; + + static APP: OnceLock = OnceLock::new(); + + /// `NSApplicationTerminateReply`. + const TERMINATE_CANCEL: usize = 0; + const TERMINATE_NOW: usize = 1; + + extern "C-unwind" fn should_terminate(_: *mut AnyObject, _: Sel, _: *mut AnyObject) -> usize { + match APP.get() { + Some(app) => { + app.exit(0); + TERMINATE_CANCEL + } + None => TERMINATE_NOW, + } + } + + /// Adds `applicationShouldTerminate:` to the delegate Tauri installed. Called once, in setup. + pub fn install(app: &tauri::AppHandle) { + if APP.set(app.clone()).is_err() { + return; + } + // SAFETY: on the main thread during setup, after Tauri set the application's delegate. The + // method is added to the delegate's own class, with the signature AppKit calls it with: + // NSUInteger (Q) on self (@), _cmd (:), sender (@). + unsafe { + let application: *mut AnyObject = + objc2::msg_send![objc2::class!(NSApplication), sharedApplication]; + if application.is_null() { + return; + } + let delegate: *mut AnyObject = objc2::msg_send![application, delegate]; + if delegate.is_null() { + return; + } + let class = objc2::ffi::object_getClass(delegate) as *mut AnyClass; + let imp: Imp = std::mem::transmute::< + extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize, + Imp, + >(should_terminate); + objc2::ffi::class_addMethod( + class, + objc2::sel!(applicationShouldTerminate:), + imp, + c"Q@:@".as_ptr(), + ); + } + } +} + +/** +What About OpenBot says: the version, and which build this is. + +The build is the commit it was built from, marked `-dirty` when the tree had uncommitted changes, +and when; see `build_identity` in build.rs. macOS shows the version and the build on one line and +the credits under them; Windows and Linux show the comments. +*/ +fn about_openbot() -> tauri::menu::AboutMetadata<'static> { + let built = build_description( + env!("OPENBOT_BUILD_COMMIT"), + env!("OPENBOT_BUILD_BRANCH"), + env!("OPENBOT_BUILD_TIME").parse().unwrap_or_default(), + ); + tauri::menu::AboutMetadata { + name: Some("OpenBot".into()), + short_version: Some(env!("CARGO_PKG_VERSION").into()), + version: Some(env!("OPENBOT_BUILD_COMMIT").into()), + credits: Some(built.clone()), + comments: Some(built), + ..Default::default() + } +} + +/// "Built 2026-10-22 06:40 UTC from 397d98a on dustywusty/local-eval-test-question". +fn build_description(commit: &str, branch: &str, built: u64) -> String { + let on = if branch.is_empty() { + String::new() + } else { + format!(" on {branch}") + }; + format!("Built {} from {commit}{on}", utc_minute(built)) +} + +/// Seconds since 1970 as "YYYY-MM-DD HH:MM UTC", with no date library for one line. +fn utc_minute(seconds: u64) -> String { + let days = (seconds / 86_400) as i64; + let minute_of_day = seconds % 86_400 / 60; + // Howard Hinnant's days_from_civil, inverted. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let day_of_era = z - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_index = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_index + 2) / 5 + 1; + let month = if month_index < 10 { + month_index + 3 + } else { + month_index - 9 + }; + let year = year_of_era + era * 400 + i64::from(month <= 2); + format!( + "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC", + minute_of_day / 60, + minute_of_day % 60 + ) +} + +/** +The tray menu and the window's menu bar, with "Open Intelligence dashboard" when `intelligence`. + +Built again whenever that changes, because a menu item cannot be hidden, only removed. +*/ +fn install_menus( + app: &tauri::AppHandle, + intelligence: bool, +) -> tauri::Result<()> { + use tauri::menu::{IconMenuItem, Menu, MenuItem, NativeIcon, PredefinedMenuItem, Submenu}; + + // The menu bar menu follows the system's appearance; its icons are drawn for each. + let dark = system_is_dark(app); + let running = !app.state::().children.lock().unwrap().is_empty(); + let dashboard = || { + intelligence_menu_item( + app.state::() + .intelligence_opening + .load(std::sync::atomic::Ordering::SeqCst), + ) + }; + + /* + * The menu bar's menu, laid out as Mac menu bar apps have theirs: what is running, with a + * status dot, then the things to do, each with an icon, then About, then Quit. + * + * No line carries the app's name. The icon it opens from already says whose menu it is, the + * name is a brand that builds change (OpenBot, Soar, a company's own), and a verb in front of + * a product name reads badly for some of them: never "Open Soar" (a product decision of + * 2026-10-23). + */ + let tray_entries = || -> tauri::Result>> { + let icon = |name: &str| menu_icon(name, dark); + let mut entries = vec![ + MenuEntry::Icon(IconMenuItem::with_id_and_native_icon( + app, + "state", + if running { "Running" } else { "Not running" }, + false, + Some(if running { + NativeIcon::StatusAvailable + } else { + NativeIcon::StatusNone + }), + None::<&str>, + )?), + MenuEntry::Native(PredefinedMenuItem::separator(app)?), + MenuEntry::Icon(IconMenuItem::with_id( + app, + "open", + "Show Window", + true, + icon("open"), + None::<&str>, + )?), + ]; + if intelligence { + let (label, enabled) = dashboard(); + entries.push(MenuEntry::Icon(IconMenuItem::with_id( + app, + "open-intelligence", + label, + enabled, + icon("dashboard"), + None::<&str>, + )?)); + } + entries.push(MenuEntry::Icon(IconMenuItem::with_id( + app, + "status", + "Status…", + true, + icon("status"), + None::<&str>, + )?)); + entries.push(MenuEntry::Native(PredefinedMenuItem::separator(app)?)); + entries.push(MenuEntry::Native(PredefinedMenuItem::about( + app, + Some("About"), + Some(about_openbot()), + )?)); + entries.push(MenuEntry::Native(PredefinedMenuItem::separator(app)?)); + entries.push(MenuEntry::Icon(IconMenuItem::with_id( + app, + "quit", + "Quit", + true, + icon("quit"), + quit_menu_accelerator(), + )?)); + Ok(entries) + }; + + /* + * The application menu keeps the plain Mac order, About first and no icons, which is how every + * app's own menu reads. The same ids, so both arrive at the same function. + */ + let window_entries = || -> tauri::Result>> { + let mut entries = vec![ + MenuEntry::Native(PredefinedMenuItem::about( + app, + Some("About OpenBot"), + Some(about_openbot()), + )?), + MenuEntry::Native(PredefinedMenuItem::separator(app)?), + MenuEntry::Plain(MenuItem::with_id( + app, + "open", + "Show Window", + true, + None::<&str>, + )?), + ]; + if intelligence { + let (label, enabled) = dashboard(); + entries.push(MenuEntry::Plain(MenuItem::with_id( + app, + "open-intelligence", + label, + enabled, + None::<&str>, + )?)); + } + entries.push(MenuEntry::Plain(MenuItem::with_id( + app, + "status", + "Status…", + true, + None::<&str>, + )?)); + entries.push(MenuEntry::Plain(MenuItem::with_id( + app, + "quit", + "Quit", + true, + quit_menu_accelerator(), + )?)); + Ok(entries) + }; + + let tray = tray_entries()?; + if let Some(icon) = app.tray_by_id("openbot") { + icon.set_menu(Some(Menu::with_items(app, &menu_refs(&tray))?))?; + #[cfg(target_os = "macos")] + show_menu_images(&icon, dark); + } + + /* + * No menu bar on Windows. Windows draws it as a white strip of its own that no colour reaches, + * between a title bar tinted to the page and the page itself. Nothing is lost: the Edit menu is + * there for macOS, which routes Command-V through the menu bar, while WebView2 handles Ctrl+V + * itself; and the OpenBot menu's items are in the tray and in Settings → This computer. + */ + if cfg!(target_os = "windows") { + return Ok(()); + } + + // The same items on the window itself, because the tray cannot be relied on. + // + // Linux needs a tray host to draw the icon, and Windows can place it in overflow. + // The tray library restores the Windows icon after Explorer restarts, but the window + // menu still provides access when the tray is unavailable or hard to find. + // Its own items, not the tray's: a menu item belongs to one menu, and the two menus + // outlive each other. + let window = window_entries()?; + // A submenu, because a top-level entry in a menu bar has to be one to open at all. + let openbot = Submenu::with_items(app, "OpenBot", true, &menu_refs(&window))?; + /* + * AN EDIT MENU, WITHOUT WHICH COMMAND-V DOES NOTHING. + * + * MEASURED, on the screen that asks for a paste. macOS routes the clipboard shortcuts + * through the menu bar, so a window with no Edit menu has no Paste, and a webview text + * field silently ignores the keystroke. Typing worked and pasting did not, on the one + * screen whose own instruction is "paste the code it shows you". Every person signing + * in to a Claude plan would have reached that field, pressed the shortcut everybody + * knows, and had nothing happen. + * + * Predefined items rather than our own: these carry the standard shortcuts and the + * standard behaviour, which is the whole point of them being where a person expects. + */ + let edit = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?; + app.set_menu(Menu::with_items(app, &[&openbot, &edit])?)?; + Ok(()) +} + +/** +Whether the system, rather than any one window, is in dark: the menu bar follows the system, and the +main window follows OpenBot's own theme once it shows OpenBot. +*/ +fn system_is_dark(app: &tauri::AppHandle) -> bool { + #[cfg(target_os = "macos")] + { + let _ = app; + quiet::command("defaults") + .args(["read", "-g", "AppleInterfaceStyle"]) + .output() + .is_ok_and(|output| String::from_utf8_lossy(&output.stdout).trim() == "Dark") + } + #[cfg(not(target_os = "macos"))] + { + app.get_webview_window("main") + .and_then(|window| window.theme().ok()) + == Some(tauri::Theme::Dark) + } +} + +/// One item of a menu: ours with an icon, ours without, or one of the system's own. +enum MenuEntry { + Icon(tauri::menu::IconMenuItem), + Plain(tauri::menu::MenuItem), + Native(tauri::menu::PredefinedMenuItem), +} + +fn menu_refs(entries: &[MenuEntry]) -> Vec<&dyn tauri::menu::IsMenuItem> { + entries + .iter() + .map(|entry| match entry { + MenuEntry::Icon(item) => item as &dyn tauri::menu::IsMenuItem, + MenuEntry::Plain(item) => item, + MenuEntry::Native(item) => item, + }) + .collect() +} + +/** +A menu bar icon, in the ink of the menu it is drawn on. + +macOS does not treat these as template images, so a black icon would vanish in a dark menu. Each is +drawn twice by `icons/menu/draw.py`, and the menus are rebuilt when the appearance changes. +*/ +fn menu_icon(name: &str, dark: bool) -> Option> { + tauri::image::Image::from_bytes(menu_icon_png(name, dark)?).ok() +} + +/// The PNG behind `menu_icon`. +fn menu_icon_png(name: &str, dark: bool) -> Option<&'static [u8]> { + Some(match (name, dark) { + ("open", false) => include_bytes!("../icons/menu/open-light.png"), + ("open", true) => include_bytes!("../icons/menu/open-dark.png"), + ("dashboard", false) => include_bytes!("../icons/menu/dashboard-light.png"), + ("dashboard", true) => include_bytes!("../icons/menu/dashboard-dark.png"), + ("status", false) => include_bytes!("../icons/menu/status-light.png"), + ("status", true) => include_bytes!("../icons/menu/status-dark.png"), + ("quit", false) => include_bytes!("../icons/menu/quit-light.png"), + ("quit", true) => include_bytes!("../icons/menu/quit-dark.png"), + ("about", false) => include_bytes!("../icons/menu/about-light.png"), + ("about", true) => include_bytes!("../icons/menu/about-dark.png"), + _ => return None, + }) +} + +/** +Lets the menu bar menu draw its icons and status dot on macOS 27 and later. + +MEASURED: built with the macOS 27 SDK, every image in this menu was hidden, Apple's own status +dot and SF Symbols included, in a bundled test app as much as here. macOS 27 added +`NSMenuItem.preferredImageVisibility`, and its default lets AppKit hide images; an item has to ask +for `NSMenuItemImageVisibilityVisible` (1). Docker's menu still showed icons only because it is +built with an older SDK. Asked only where the selector exists, so earlier macOS is untouched. + +About is the system's own item, which takes no icon through the menu API, so its icon is set here. +*/ +#[cfg(target_os = "macos")] +fn show_menu_images(tray: &tauri::tray::TrayIcon, dark: bool) { + use objc2::encode::{Encode, Encoding}; + use objc2::runtime::AnyObject; + + #[repr(C)] + struct Size { + width: f64, + height: f64, + } + // SAFETY: the same layout as CGSize, two doubles. + unsafe impl Encode for Size { + const ENCODING: Encoding = Encoding::Struct("CGSize", &[f64::ENCODING, f64::ENCODING]); + } + + let Ok(Some(status_item)) = tray.with_inner_tray_icon(|inner| { + inner + .ns_status_item() + .map(|item| objc2::rc::Retained::as_ptr(&item) as usize) + }) else { + return; + }; + // SAFETY: the status item is retained by the tray for the app's lifetime; this runs on the + // main thread, as menus are built there; and the setter is sent only where it is implemented. + unsafe { + let status_item = status_item as *mut AnyObject; + let menu: *mut AnyObject = objc2::msg_send![status_item, menu]; + if menu.is_null() { + return; + } + let visibility = objc2::sel!(setPreferredImageVisibility:); + let count: isize = objc2::msg_send![menu, numberOfItems]; + for index in 0..count { + let item: *mut AnyObject = objc2::msg_send![menu, itemAtIndex: index]; + if item.is_null() { + continue; + } + let title: *mut AnyObject = objc2::msg_send![item, title]; + let utf8: *const std::ffi::c_char = if title.is_null() { + std::ptr::null() + } else { + objc2::msg_send![title, UTF8String] + }; + let is_about = !utf8.is_null() && std::ffi::CStr::from_ptr(utf8) == c"About"; + if let Some(png) = is_about.then(|| menu_icon_png("about", dark)).flatten() { + let data: *mut AnyObject = objc2::msg_send![ + objc2::class!(NSData), + dataWithBytes: png.as_ptr().cast::(), + length: png.len() + ]; + let image: *mut AnyObject = objc2::msg_send![objc2::class!(NSImage), alloc]; + let image: *mut AnyObject = objc2::msg_send![image, initWithData: data]; + if !image.is_null() { + let _: () = + objc2::msg_send![image, setSize: Size { width: 18.0, height: 18.0 }]; + let _: () = objc2::msg_send![item, setImage: image]; + // `setImage:` keeps its own reference; this one came from `alloc`. + let _: () = objc2::msg_send![image, release]; + } + } + let implemented: bool = objc2::msg_send![item, respondsToSelector: visibility]; + if implemented { + // NSMenuItemImageVisibilityVisible + let _: () = objc2::msg_send![item, setPreferredImageVisibility: 1isize]; + } + } + } +} + +/// Rebuild both menus on the main thread, for a new state, appearance or dashboard offer. +fn refresh_menus(app: &tauri::AppHandle) { + // The mock runtime runs "main thread" work on a worker, where AppKit menus panic. The menus + // are not what the tests that start and stop a stack are about. + if cfg!(test) { + return; + } + let menus_app = app.clone(); + let _ = app.run_on_main_thread(move || { + let offered = menus_app + .state::() + .intelligence_menu + .lock() + .unwrap() + .is_some(); + if let Err(error) = install_menus(&menus_app, offered) { + eprintln!("[menu] could not update the menus: {error}"); + } + }); +} + +fn quit_menu_accelerator() -> Option<&'static str> { + Some(QUIT_MENU_ACCELERATOR) +} + +fn main() { + tauri::Builder::default() + // A second launch is somebody looking for the window they already have, not a request for a + // second stack. Without this both copies bind the same ports and the loser reports a + // failure that belongs to the winner. + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + restore_after_second_instance(app); + })) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(intelligence_bridge::plugin()) + .plugin(desktop_page::plugin()) + .manage(Shell::default()) + .invoke_handler(tauri::generate_handler![ + record_setup_event, + fit_setup_window, + detect_engine, + windows_blocker, + windows_blocker_instruction, + windows_fix, + windows_restart_pending, + windows_restart, + prepare_engine, + prepare_installation, + start_stack, + reset_leftover_database, + stop_stack, + show_openbot, + show_setup, + already_running, + last_failure, + default_root, + selected_root, + harnesses, + providers, + already_configured, + begin_claude_sign_in, + finish_claude_sign_in, + begin_chatgpt_sign_in, + begin_model_oauth, + finish_model_oauth, + cancel_model_oauth, + finish_chatgpt_sign_in, + begin_self_hosted_intelligence_sign_in, + finish_self_hosted_intelligence_sign_in, + cancel_self_hosted_intelligence_sign_in, + begin_intelligence_sign_in, + finish_intelligence_sign_in, + intelligence_key_for, + create_intelligence_project, + begin_organization_sign_in, + finish_organization_sign_in, + cancel_organization_sign_in, + run_local_eval, + list_local_eval, + cancel_local_eval, + open_local_intelligence, + offer_local_intelligence, + set_up_local_eval, + sign_in_to_copilotkit, + local_eval_terms, + connect_local_eval, + last_local_eval_stop_failure, + ask_the_bot, + quit_status, + desktop_status, + restart_openbot, + stop_openbot_from_status, + ]) + // A packaged application is not a browser tab. Left alone, WebView2 answers a right-click + // with Back, Refresh, Save as and Print: Back walks the window out of OpenBot with nothing + // to walk it home, and Save as offers to write the page to disk as `Webpage, complete`. + // macOS never showed this because Tauri suppresses it there in release builds; Windows has + // no such setting, and Tauri has no configuration option for it either, so the page is + // asked to refuse. Every navigation, because the window navigates to OpenBot and back. + .on_page_load(|window, payload| { + let _ = window + .eval("document.addEventListener('contextmenu', e => e.preventDefault(), true)"); + let page = window_page(window.app_handle(), payload.url()); + match_title_bar(&window.window(), page); + if matches!(page, WindowPage::OwnedApp | WindowPage::LocalApp) + && payload.event() == tauri::webview::PageLoadEvent::Finished + { + allow_window_drag(window.app_handle(), payload.url()); + // macOS draws the window's outline from it; Windows tints the title bar with it. + if cfg!(any(target_os = "macos", target_os = "windows")) { + let _ = window.eval(APP_THEME_PAGE); + } + if cfg!(target_os = "macos") && page == WindowPage::OwnedApp { + let _ = window.eval(OVERLAY_TITLE_BAR_PAGE); + } + } + }) + // Closing the window hides it. A tray application whose window is destroyed on close has a + // menu item that points at nothing: `get_webview_window` returns None from then on, and the + // only way back is to quit and start again, with a stack still running that nothing on + // screen can reach. + .on_window_event(|window, event| match event { + tauri::WindowEvent::CloseRequested { api, .. } => { + api.prevent_close(); + let _ = window.hide(); + } + // The setup screens follow the system's light and dark, and so does their title bar. + tauri::WindowEvent::ThemeChanged(_) => { + let app = window.app_handle(); + // The menu bar's icons are drawn for each appearance. + if window.label() == "main" { + refresh_menus(app); + } + if let Some(url) = app + .get_webview_window(window.label()) + .and_then(|own| own.url().ok()) + { + match_title_bar(window, window_page(app, &url)); + } + } + _ => {} + }) + .setup(|app| { + desktop_telemetry::initialize(app.handle()); + if let Some(root) = app + .path() + .app_config_dir() + .ok() + .and_then(|config| preparation::selected_root(&config)) + { + remember_selected_root(&app.state::(), &root); + } + // Where the Compose provider OpenBot installs itself lives, told once so every engine + // command can put it on the child's PATH. Before anything asks for an engine. + engine::tools_live_in(engine::tools_dir_under(&acquire::download_dir( + &stack::default_root(), + ))); + + remember_setup_url(app.handle())?; + if let Some(main) = app.get_webview_window("main") { + match_title_bar(&main.as_ref().window(), WindowPage::Setup); + } + fit_main_window_to_screen(app.handle()); + // Built by `generate_context!`, so the bridge plugin's permissions are compiled in. + intelligence_bridge::enable(app.handle()); + + // The status menu lets somebody open the window, stop the stack, or quit the app. + tauri::tray::TrayIconBuilder::with_id("openbot") + .icon(tray::icon()) + .icon_as_template(false) + .tooltip("OpenBot") + .build(app)?; + install_menus(app.handle(), false)?; + app.on_menu_event(|app, event| chose(app, event.id().as_ref())); + #[cfg(target_os = "macos")] + macos_quit::install(app.handle()); + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("the OpenBot window could not be created") + .run(|app, event| { + match event { + #[cfg(target_os = "macos")] + tauri::RunEvent::Reopen { .. } => { + show_whichever_applies(app); + } + tauri::RunEvent::ExitRequested { api, code, .. } => { + // Exit runs on the event-loop thread. Waiting there for Windows process + // inventory or Compose made Quit show "Not Responding". Keep the loop alive + // until cleanup finishes, then allow its final exit without repeating work. + let cleaning_app = app.clone(); + let notice_app = app.clone(); + let notice_failure_app = app.clone(); + let exiting_app = app.clone(); + if let Err(error) = request_quit_with( + std::sync::Arc::clone(&app.state::().quit), + code, + || api.prevent_exit(), + move || quit_cleanup_shown(&cleaning_app), + QuitDiagnostics { + sink: move |failures: Vec| { + for failure in &failures { + eprintln!("{failure}"); + } + let shell = notice_app.state::(); + let root = cleanup_root(&shell, &stack::default_root()); + write_quit_cleanup_notice(&root, &failures) + }, + failed: move |error: String| { + publish_quit_notice_failure(notice_failure_app.clone(), error) + }, + }, + move |code| exiting_app.exit(code), + |work| { + std::thread::Builder::new() + .name("openbot-quit-cleanup".into()) + .spawn(work) + .map(|_| ()) + }, + ) { + eprintln!("[exit] could not start cleanup: {error}"); + report( + app, + "quit", + false, + "OpenBot could not start shutting down. Try Quit again.", + ); + } + } + _ => {} + } + }); +} + +#[cfg(test)] +mod tests { + #[test] + fn only_an_app_that_makes_room_gets_the_overlay_title_bar() { + let root = test_support::temp_root("titlebar-room"); + assert!(!app_makes_room_for_window_buttons(&root)); + std::fs::create_dir_all(root.join("app/src")).unwrap(); + std::fs::write(root.join("app/src/styles.css"), "body {}").unwrap(); + assert!(!app_makes_room_for_window_buttons(&root)); + std::fs::write( + root.join("app/src/styles.css"), + ":root[data-desktop-titlebar=\"overlay\"] [data-slot=\"sidebar-header\"] {}", + ) + .unwrap(); + assert!(app_makes_room_for_window_buttons(&root)); + let _ = std::fs::remove_dir_all(&root); + } + + use super::*; + use std::io::{Read, Write}; + + include!("stop_ipc_tests.rs"); + + #[test] + fn learning_default_is_written_only_for_the_provisioned_connection() { + use std::collections::BTreeMap; + let root = temp_root("learning-default-env"); + std::fs::create_dir_all(&root).unwrap(); + let proof = openbot_desktop_lib::intelligence::ProvisionedConnection { + api_key: "runtime-key".into(), + api_url: "https://api.intelligence.example".into(), + learning_container_id: "openbot".into(), + }; + for (api, key, existing, verified, expected) in [ + ("https://api.intelligence.example", "runtime-key", None, true, Some("openbot")), + ("https://api.intelligence.example", "runtime-key", Some(""), true, Some("openbot")), + ("https://api.intelligence.example/", "runtime-key", Some(""), true, Some("openbot")), + ("https://other.example", "runtime-key", Some(""), true, None), + ("https://api.intelligence.example", "other-key", Some(""), true, None), + ("https://api.intelligence.example", "runtime-key", Some(""), false, None), + ("https://api.intelligence.example", "runtime-key", Some("CPK_INTELLIGENCE_LEARNING_CONTAINER_ID=custom\nCPK_INTELLIGENCE_SKILLS_REVISION=7\n"), true, Some("custom")), + ("https://api.intelligence.example", "runtime-key", Some("export \tCPK_INTELLIGENCE_LEARNING_CONTAINER_ID = custom\n"), true, Some("custom")), + ] { + if let Some(existing) = existing { + std::fs::write(root.join(".env"), existing).unwrap(); + } + let mut settings = BTreeMap::from([ + ("INTELLIGENCE_API_URL".into(), api.into()), + ("INTELLIGENCE_API_KEY".into(), key.into()), + ]); + seed_learning_default(&root, &mut settings, verified.then_some(&proof)).unwrap(); + openbot_env::write(&root.join(".env"), &settings, &BTreeMap::new()).unwrap(); + let read = openbot_env::read_already_set(&root.join(".env"), &["CPK_INTELLIGENCE_LEARNING_CONTAINER_ID", "CPK_INTELLIGENCE_SKILLS_REVISION"]).unwrap(); + if let Some(exported) = existing.filter(|text| text.starts_with("export")) { + // The shared setup reader does not parse export syntax; the file must keep the + // custom assignment with no bare assignment appended to override it. + assert!(!read.contains_key("CPK_INTELLIGENCE_LEARNING_CONTAINER_ID")); + assert!(std::fs::read_to_string(root.join(".env")).unwrap().contains(exported)); + continue; + } + assert_eq!(read.get("CPK_INTELLIGENCE_LEARNING_CONTAINER_ID").map(String::as_str), expected); + if expected == Some("custom") { + assert_eq!(read.get("CPK_INTELLIGENCE_SKILLS_REVISION").map(String::as_str), Some("7")); + } + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn quit_menu_uses_the_standard_quit_shortcut() { + assert_eq!(quit_menu_accelerator(), Some("CmdOrCtrl+KeyQ")); + } + + #[test] fn responsive_quit_returns_while_cleanup_is_blocked_then_exits_in_order() { use std::sync::{mpsc, Arc}; let state = Arc::new(QuitState::default()); @@ -4057,2087 +6499,3775 @@ mod tests { }, |work| std::thread::Builder::new().spawn(work).map(|_| ()), ); - returned.send(result).unwrap(); + returned.send(result).unwrap(); + }) + }; + cleanup_started + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + let returned_before_cleanup = + handler_returned.recv_timeout(std::time::Duration::from_millis(200)); + let exit_before_cleanup = exit_requested.try_recv(); + // Always release and join before asserting, including against the synchronous regression. + release.send(()).unwrap(); + dispatch.join().unwrap(); + let exit_code = exit_requested + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + assert!( + returned_before_cleanup.is_ok(), + "Quit's event handler blocked waiting for cleanup" + ); + returned_before_cleanup.unwrap().unwrap(); + assert!(matches!( + exit_before_cleanup, + Err(mpsc::TryRecvError::Empty) + )); + assert_eq!(exit_code, 37); + assert_eq!( + *events.lock().unwrap(), + ["prevent", "cleanup-started", "cleanup-finished", "exit"] + ); + request_quit_with( + state, + Some(37), + || panic!("completed Quit must allow its final exit request"), + || panic!("final exit must not repeat cleanup"), + QuitDiagnostics { + sink: |_: Vec| panic!("final exit must not repeat diagnostics"), + failed: |_: String| panic!("final exit must not fail diagnostics"), + }, + |_| panic!("final exit must not request another exit"), + |_| panic!("final exit must not launch another worker"), + ) + .unwrap(); + } + + #[test] + fn responsive_quit_coalesces_duplicates_and_preserves_the_first_exit_code() { + use std::sync::Arc; + let state = Arc::new(QuitState::default()); + let work = std::cell::RefCell::new(None); + let prevented = std::cell::Cell::new(0); + let (exiting, exited) = std::sync::mpsc::channel(); + request_quit_with( + state.clone(), + Some(23), + || prevented.set(prevented.get() + 1), + Vec::new, + QuitDiagnostics { + sink: |_: Vec| panic!("no cleanup failure"), + failed: |_: String| panic!("successful cleanup must not fail diagnostics"), + }, + move |code| exiting.send(code).unwrap(), + |task| { + *work.borrow_mut() = Some(task); + Ok(()) + }, + ) + .unwrap(); + request_quit_with( + state.clone(), + Some(0), + || prevented.set(prevented.get() + 1), + || panic!("duplicate Quit must not run cleanup"), + QuitDiagnostics { + sink: |_: Vec| panic!("duplicate Quit must not report"), + failed: |_: String| panic!("duplicate Quit must not fail diagnostics"), + }, + |_| panic!("duplicate Quit must not replace the saved exit code"), + |_| panic!("duplicate Quit must not launch another worker"), + ) + .unwrap(); + assert_eq!(prevented.get(), 2); + assert!(matches!( + exited.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + work.into_inner().expect("one cleanup worker")(); + assert_eq!(exited.recv().unwrap(), 23); + request_quit_with( + state, + None, + || prevented.set(prevented.get() + 1), + || panic!("a late Quit must not repeat cleanup"), + QuitDiagnostics { + sink: |_: Vec| panic!("a late Quit must not report"), + failed: |_: String| panic!("a late Quit must not fail diagnostics"), + }, + |_| panic!("a late Quit must not replace the saved exit code"), + |_| panic!("a late Quit must not launch another worker"), + ) + .unwrap(); + assert_eq!(prevented.get(), 3); + } + + #[test] + fn responsive_quit_reports_cleanup_failures_before_requesting_exit() { + use std::sync::Arc; + let state = Arc::new(QuitState::default()); + let events = Arc::new(Mutex::new(Vec::new())); + let diagnostics = events.clone(); + let exiting = events.clone(); + let work = std::cell::RefCell::new(None); + request_quit_with( + state, + None, + || {}, + || { + vec![ + "host cleanup refused".into(), + "Compose down failed: synthetic".into(), + ] + }, + QuitDiagnostics { + sink: move |lines: Vec| { + diagnostics.lock().unwrap().extend(lines); + Ok(()) + }, + failed: |_: String| panic!("diagnostics should be recorded"), + }, + move |code| exiting.lock().unwrap().push(format!("exit:{code}")), + |task| { + *work.borrow_mut() = Some(task); + Ok(()) + }, + ) + .unwrap(); + assert!( + events.lock().unwrap().is_empty(), + "cleanup ran on the requesting thread" + ); + work.into_inner().expect("cleanup worker")(); + assert_eq!( + *events.lock().unwrap(), + [ + "[exit] cleanup failed: host cleanup refused", + "[exit] cleanup failed: Compose down failed: synthetic", + "exit:0" + ] + ); + } + + #[test] + fn responsive_quit_launch_failure_preserves_the_app_and_allows_retry() { + use std::sync::Arc; + let state = Arc::new(QuitState::default()); + let prevented = std::cell::Cell::new(0); + let result = request_quit_with( + state.clone(), + Some(7), + || prevented.set(prevented.get() + 1), + || panic!("failed launch must not run cleanup"), + QuitDiagnostics { + sink: |_: Vec| panic!("failed launch must not report cleanup errors"), + failed: |_: String| panic!("failed launch must not fail diagnostics"), + }, + |_| panic!("failed launch must not exit"), + |_| Err(std::io::Error::other("synthetic worker launch failure")), + ); + assert_eq!( + result.unwrap_err().to_string(), + "synthetic worker launch failure" + ); + assert_eq!(prevented.get(), 1); + let work = std::cell::RefCell::new(None); + let (exiting, exited) = std::sync::mpsc::channel(); + request_quit_with( + state, + Some(9), + || prevented.set(prevented.get() + 1), + Vec::new, + QuitDiagnostics { + sink: |_: Vec| panic!("retry cleanup succeeded"), + failed: |_: String| panic!("retry cleanup must not fail diagnostics"), + }, + move |code| exiting.send(code).unwrap(), + |task| { + *work.borrow_mut() = Some(task); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(prevented.get(), 2); + assert!(matches!( + exited.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + work.into_inner().expect("retry launches a worker")(); + assert_eq!(exited.recv().unwrap(), 9); + } + + #[test] + fn quit_cleanup_notice_is_known_safe_bounded_and_consumed_once() { + let root = temp_root("openbot-quit-cleanup-notice"); + let lines = vec![ + "[exit] cleanup failed: Compose down failed: /Users/alice/OpenBot/docker-compose.yml refused token=secret".to_string(), + "[exit] cleanup failed: C:\\Users\\alice\\OpenBot\\owned.exe OAuth password".to_string(), + ]; + + write_quit_cleanup_notice(&root, &lines).unwrap(); + let first = read_quit_cleanup_notice(&root) + .unwrap() + .expect("notice should be present"); + let detail = first.detail.unwrap(); + assert_eq!(first.said, "OpenBot had trouble shutting down last time."); + assert!(detail.contains("containers stopped"), "{detail}"); + assert!(detail.contains("app processes stopped"), "{detail}"); + assert!(!detail.contains("Compose down failed"), "{detail}"); + assert!(!detail.contains("/Users/alice"), "{detail}"); + assert!(!detail.contains("C:\\Users\\alice"), "{detail}"); + assert!(!detail.contains("token=secret"), "{detail}"); + assert!(!detail.contains("OAuth"), "{detail}"); + assert!(read_quit_cleanup_notice(&root).unwrap().is_none()); + } + + #[test] + fn diagnostic_sink_failure_keeps_quit_from_completing_exit() { + use std::sync::Arc; + let state = Arc::new(QuitState::default()); + let work = std::cell::RefCell::new(None); + let failures = Arc::new(Mutex::new(Vec::new())); + let captured = failures.clone(); + let exited = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let first_exit = exited.clone(); + request_quit_with( + state.clone(), + None, + || {}, + || vec!["raw cleanup failure".into()], + QuitDiagnostics { + sink: |_: Vec| Err("notice file is unavailable".into()), + failed: move |error: String| captured.lock().unwrap().push(error), + }, + move |_| { + first_exit.store(true, std::sync::atomic::Ordering::SeqCst); + }, + |task| { + *work.borrow_mut() = Some(task); + Ok(()) + }, + ) + .unwrap(); + + work.into_inner().expect("cleanup worker")(); + assert!( + !exited.load(std::sync::atomic::Ordering::SeqCst), + "Quit exited after losing its notice sink" + ); + let reported = failures.lock().unwrap().join("\n"); + assert!( + reported.contains("notice file is unavailable"), + "{reported}" + ); + assert!( + reported.contains("[exit] cleanup failed: raw cleanup failure"), + "{reported}" + ); + let second_exit = exited.clone(); + request_quit_with( + state, + None, + || {}, + Vec::new, + QuitDiagnostics { + sink: |_: Vec| Ok(()), + failed: |_: String| panic!("diagnostics now succeed"), + }, + move |_| { + second_exit.store(true, std::sync::atomic::Ordering::SeqCst); + }, + |task| { + task(); + Ok(()) + }, + ) + .unwrap(); + assert!( + exited.load(std::sync::atomic::Ordering::SeqCst), + "Quit did not retry after diagnostic failure" + ); + } + + #[test] + fn ask_transport_regressions_do_not_load_from_the_vault() { + let source = include_str!("main.rs"); + let test = source + .split("\n fn ask_the_bot_uses_native_mastra_for_a_picked_mastra_harness()") + .nth(1) + .expect("ID12 regression") + .split("struct TestRequest") + .next() + .expect("ID12 regression body"); + + assert!( + !test.contains("ask_the_bot("), + "ID12 must test dispatch with resolved settings instead of loading vault-backed settings" + ); + } + + #[test] + fn runtime_connection_recovery_retains_installation_and_rejects_stale_run_notifications() { + let root = temp_root("runtime-connection-recovery"); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(root.clone()); + remember_selected_root(&shell, &root); + *shell.setup_url.lock().unwrap() = Some("http://tauri.localhost/recovery".into()); + shell + .generation + .store(7, std::sync::atomic::Ordering::SeqCst); + let app = tauri::test::mock_builder() + .manage(shell) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap(); + window + .navigate("http://127.0.0.1:3010/channel/existing".parse().unwrap()) + .unwrap(); + let connection = openbot_desktop_lib::problem::Connection::Model; + assert!(!publish_connection_failure(app.handle(), &root, 6, connection.clone()).unwrap()); + assert!(!publish_connection_failure( + app.handle(), + &root.join("another"), + 7, + connection.clone() + ) + .unwrap()); + assert_eq!(window.url().unwrap().path(), "/channel/existing"); + assert!(publish_connection_failure(app.handle(), &root, 7, connection).unwrap()); + let shell = app.state::(); + assert_eq!(shell.root.lock().unwrap().as_ref(), Some(&root)); + assert_eq!(shell.selected_root.lock().unwrap().as_ref(), Some(&root)); + assert!(recovery_required(&shell, &root)); + assert_eq!( + window.url().unwrap().as_str(), + "http://tauri.localhost/recovery" + ); + assert_eq!( + last_failure(app.handle().clone()).unwrap().connection, + Some(openbot_desktop_lib::problem::Connection::Model) + ); + assert!(publish_connection_failure( + app.handle(), + &root, + 7, + openbot_desktop_lib::problem::Connection::Intelligence + ) + .unwrap()); + assert!( + last_failure(app.handle().clone()).is_none(), + "one refresh per run; a second signal cannot replace the pending screen" + ); + } + + #[test] + fn stop_retains_completed_installation_and_disables_only_automatic_start() { + let root = temp_root("stopped-completed-installation"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join(preparation::FILE), "{}").unwrap(); + preparation::record_launch( + &root, + Some(&harness::HarnessChoice { + id: "mastra".into(), + agent_url: None, + }), + ) + .unwrap(); + let app = tauri::test::mock_builder() + .manage(Shell::default()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + app.state::() + .stopped_in_session + .store(true, std::sync::atomic::Ordering::SeqCst); + let configured = serde_json::to_value(already_configured( + app.handle().clone(), + root.to_string_lossy().into_owned(), + )) + .unwrap(); + std::fs::remove_dir_all(root).unwrap(); + assert_eq!(configured["launch"]["harness"]["id"], "mastra"); + assert_eq!(configured["autoStart"], false); + } + + #[test] + fn public_already_configured_reads_only_passive_files() { + let root = temp_root("public-passive-boundary"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join(".env"), + "INTELLIGENCE_API_URL=https://synthetic.example\n", + ) + .unwrap(); + for metadata in [ + None, + Some("malformed"), + Some(r#"{"version":9,"categories":["intelligence"],"model":null}"#), + Some( + r#"{"version":1,"categories":["intelligence","claude-plan"],"model":"claude-plan"}"#, + ), + ] { + if let Some(metadata) = metadata { + std::fs::write(root.join(openbot_desktop_lib::saved_intent::FILE), metadata) + .unwrap(); + } + for legacy in ["", "INTELLIGENCE_API_KEY=synthetic-cpk\nOPENAI_API_KEY=synthetic-openai\nANTHROPIC_API_KEY=synthetic-anthropic\nCLAUDE_CODE_OAUTH_TOKEN=synthetic-claude\n"] { + std::fs::write(root.join(".env"), format!("INTELLIGENCE_API_URL=https://synthetic.example\n{legacy}")).unwrap(); + let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + assert_eq!(configured.values["INTELLIGENCE_API_URL"], "https://synthetic.example"); + assert!(!configured.values.contains_key("CLAUDE_CODE_OAUTH_TOKEN")); + } + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn launch_never_downloads_missing_images() { + if test_support::isolated_process("tests::launch_never_downloads_missing_images") { + return; + } + let _path = SerializedPath::set_only_with("docker", "installation-boundary"); + let root = temp_root("launch-without-installation"); + std::fs::create_dir_all(&root).unwrap(); + let address = engine::Address::new(engine::Engine::Docker, None); + let secrets = stack::Secrets::new(); + let up = stack::up(&address, &root, false, stack::BundledBots::none(), &secrets); + let migrate = stack::migrate(&address, &root, &secrets); + std::fs::remove_dir_all(root).unwrap(); + assert!(up.is_ok(), "{up:?}"); + assert!(migrate.is_ok(), "{migrate:?}"); + } + + #[test] + fn deployment_ready_preserves_the_installed_release() { + let root = temp_root("deployment-ready-pinned"); + write_installed_deployment(&root); + deployment::record(&root, "v0.0.7").unwrap(); + let app = tauri::test::mock_builder() + .manage(Shell::default()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + + tauri::async_runtime::block_on(deployment_ready(app.handle(), &root)).unwrap(); + + assert_eq!(deployment::installed(&root).unwrap().version, "v0.0.7"); + assert_eq!( + std::fs::read_to_string(root.join("docker-compose.yml")).unwrap(), + "services: {}\n" + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn plan_sign_in_boundary_uses_selected_root_for_reference_without_acquisition() { + let default = temp_root("signin-default-root"); + let selected = temp_root("signin-selected-root"); + std::fs::create_dir_all(&default).unwrap(); + std::fs::create_dir_all(&selected).unwrap(); + std::fs::write(default.join("manifest.json"), "poisoned-default").unwrap(); + let reference_root = std::cell::RefCell::new(None); + + let image = sign_in_reference( + &selected, + openbot_desktop_lib::plan::CHATGPT_SIGN_IN_IMAGE, + |root, published| { + *reference_root.borrow_mut() = Some((root.to_path_buf(), published.to_string())); + Ok(format!("{}@{}", published, root.display())) + }, + ) + .unwrap(); + + assert_eq!( + reference_root.into_inner(), + Some(( + selected.clone(), + openbot_desktop_lib::plan::CHATGPT_SIGN_IN_IMAGE.to_string() + )) + ); + assert!(image.contains(&selected.to_string_lossy().to_string())); + assert!(!image.contains(&default.to_string_lossy().to_string())); + assert_eq!( + std::fs::read_to_string(default.join("manifest.json")).unwrap(), + "poisoned-default" + ); + let _ = std::fs::remove_dir_all(default); + let _ = std::fs::remove_dir_all(selected); + } + + #[test] + fn command_roots_trim_paste_padding_and_preserve_interior_spaces() { + let root = temp_root("openbot-command-root My Files"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("settings-marker"), "this deployment").unwrap(); + for typed in [ + root.display().to_string(), + format!(" \n{}\t ", root.display()), + ] { + let work_root = stack::root_from(&typed); + assert_eq!( + std::fs::read_to_string(work_root.join("settings-marker")).unwrap(), + "this deployment" + ); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn credential_restore_commands_are_not_registered() { + let source = include_str!("main.rs"); + let handlers = source + .split("tauri::generate_handler![") + .nth(1) + .expect("handler list exists") + .split("])") + .next() + .expect("handler list closes"); + for command in [ + ["reco", "ver", "_credential"].concat(), + ["cancel", "_credential", "_reco", "very"].concat(), + ] { + assert!( + !handlers.contains(&command), + "{command} is still registered" + ); + } + } + + #[test] + fn already_configured_trims_pasted_root_and_preserves_interior_spaces() { + let root = temp_root("openbot-pasted-root My Files"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join(".env"), + "INTELLIGENCE_API_URL=https://trim.example.test\n", + ) + .unwrap(); + let typed = format!(" \n{}\t ", root.display()); + let configured = already_configured_for_root(typed); + let normal = already_configured_for_root(root.to_string_lossy().into_owned()); + assert_eq!(configured.values, normal.values); + assert_eq!( + configured.values.get("INTELLIGENCE_API_URL"), + Some(&"https://trim.example.test".to_string()) + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn already_configured_returns_file_values_and_saved_indicators() { + let root = temp_root("openbot-already-configured"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join(".env"), + "INTELLIGENCE_API_KEY=file-cpk\nOPENAI_API_KEY=file-openai\nOPENAI_BASE_URL=https://models.example/v1\n", + ) + .unwrap(); + std::fs::create_dir_all(root.join(".langchain")).unwrap(); + std::fs::write( + root.join(openbot_env::CHATGPT_STORE_FILE), + "{\"refresh_token\":\"stored\"}\n", + ) + .unwrap(); + + let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + + assert_eq!( + configured.values.get("INTELLIGENCE_API_KEY"), + Some(&"file-cpk".to_string()) + ); + assert_eq!( + configured.values.get("OPENAI_API_KEY"), + Some(&"file-openai".to_string()) + ); + assert_eq!(configured.saved.intelligence_api_key, Some(true)); + assert_eq!(configured.saved.model_api_keys.openai, Some(true)); + assert_eq!(configured.saved.model_sessions.openai, Some(true)); + assert_eq!(configured.saved.model_sessions.anthropic, None); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn already_configured_reports_legacy_anthropic_plan_without_returning_token() { + let root = temp_root("openbot-already-configured-anthropic-session"); + std::fs::create_dir_all(&root).unwrap(); + + std::fs::write( + root.join(".env"), + "CLAUDE_CODE_OAUTH_TOKEN=synthetic-legacy-plan\n", + ) + .unwrap(); + let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + + assert_eq!(configured.saved.model_sessions.anthropic, Some(true)); + assert!(!configured.values.contains_key("CLAUDE_CODE_OAUTH_TOKEN")); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn second_instance_restore_runs_blocking_probe_outside_the_async_listener() { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 512]; + let _ = stream.read(&mut request).unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .unwrap(); + }); + let (sent, received) = std::sync::mpsc::channel(); + + let scheduled = tauri::async_runtime::block_on(async move { + tauri::async_runtime::spawn(async move { + schedule_second_instance_restore(port, move |port| { + sent.send(stack::app_url(port).is_some()).unwrap(); + }) + .unwrap() + .join() + .unwrap(); + }) + .await + }); + + assert!( + scheduled.is_ok(), + "the async single-instance listener must not panic while scheduling restore" + ); + assert!(received.recv().unwrap()); + server.join().unwrap(); + } + + #[test] + fn passive_metadata_and_legacy_hints_are_root_and_provider_scoped() { + let root = temp_root("public-intent-cases"); + std::fs::create_dir_all(&root).unwrap(); + for input in [ + None, + Some("bad json"), + Some(r#"{"version":42,"categories":["intelligence"],"model":null}"#), + ] { + if let Some(input) = input { + std::fs::write(root.join(openbot_desktop_lib::saved_intent::FILE), input).unwrap(); + } + let unknown = already_configured_for_root(root.to_string_lossy().into_owned()); + assert_eq!(unknown.saved.intelligence_api_key, None); + assert_eq!(unknown.saved.model_sessions.anthropic, None); + } + std::fs::write( + root.join(openbot_desktop_lib::saved_intent::FILE), + r#"{"version":1,"categories":["intelligence","claude-plan"],"model":"claude-plan"}"#, + ) + .unwrap(); + let recorded = already_configured_for_root(root.to_string_lossy().into_owned()); + assert_eq!(recorded.saved.intelligence_api_key, Some(true)); + assert_eq!(recorded.saved.model_sessions.anthropic, Some(true)); + assert_eq!(recorded.saved.model_api_keys.anthropic, None); + assert_eq!(recorded.saved.model_sessions.openai, None); + assert!(recorded.values.is_empty()); + let fresh = already_configured_for_root( + temp_root("different-public-root") + .to_string_lossy() + .into_owned(), + ); + assert_eq!(fresh.saved.model_sessions.anthropic, None); + std::fs::write( + root.join(".env"), + "ANTHROPIC_API_KEY=synthetic-legacy-anthropic\n", + ) + .unwrap(); + let legacy = already_configured_for_root(root.to_string_lossy().into_owned()); + assert_eq!(legacy.saved.model_api_keys.anthropic, Some(true)); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn saved_selection_refusal_never_falls_back_to_a_different_provider_or_billing_mode() { + let root = temp_root("explicit-saved-refusal"); + for (provider, login, expected) in [ + ("openai", "api-key", "OPENAI_API_KEY"), + ("anthropic", "api-key", "ANTHROPIC_API_KEY"), + ("anthropic", "plan", "CLAUDE_CODE_OAUTH_TOKEN"), + ] { + for denied in [false, true] { + let mut calls = Vec::new(); + let choice = ChosenModel { + provider: provider.into(), + login: login.into(), + api_key: Some("synthetic-unselected-billable-key".into()), + base_url: None, + container_base_url: None, + model: None, + token: None, + saved: Some(true), + }; + let result = start_stack_credential_with(&root, choice, |_, key| { + calls.push(key.to_string()); + if denied { + Err(Problem::plain("synthetic access denied")) + } else { + Ok(String::new()) + } + }); + let problem = result.expect_err("selected credential is unavailable"); + assert!(!problem.said.is_empty()); + assert_eq!( + problem.connection, + Some(openbot_desktop_lib::problem::Connection::Model) + ); + if denied { + assert_eq!(problem.said, "synthetic access denied"); + } + assert_eq!(calls, [expected]); + } + } + for denied in [false, true] { + let result = intelligence_key_for_start(&root, String::new(), |_, key| { + assert_eq!(key, "INTELLIGENCE_API_KEY"); + if denied { + Err(Problem::plain("synthetic access denied")) + } else { + Ok(String::new()) + } + }); + assert_eq!( + result.unwrap_err().connection, + Some(openbot_desktop_lib::problem::Connection::Intelligence) + ); + } + // A missing or unreadable ChatGPT file is an action error; no API-key resolver is called. + std::fs::create_dir_all(&root).unwrap(); + for unreadable in [false, true] { + if unreadable { + std::fs::create_dir_all(root.join(openbot_env::CHATGPT_STORE_FILE)).unwrap(); + } + let choice = ChosenModel { + provider: "openai".into(), + login: "plan".into(), + api_key: Some("synthetic-unselected-key".into()), + base_url: None, + container_base_url: None, + model: None, + token: None, + saved: Some(true), + }; + assert!(start_stack_credential_with(&root, choice, |_, _| panic!( + "plan must not fall back to an API key" + )) + .is_err()); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn oauth_start_reads_private_session_without_contacting_unstarted_proxy() { + let root = temp_root("oauth-start"); + std::fs::create_dir_all(root.join(".openbot")).unwrap(); + for provider in ["google", "xai"] { + let path = root.join(openbot_desktop_lib::provider_oauth::FILE); + let record = serde_json::json!({"version":1,"sessionId":"synthetic-session","provider":provider,"clientId":"synthetic-client","accessToken":"synthetic-access","refreshToken":"synthetic-refresh","expiresAt":1,"scope":"synthetic","proxyToken":"synthetic-proxy"}); + std::fs::write(&path, serde_json::to_vec(&record).unwrap()).unwrap(); + let choice = ChosenModel { + provider: provider.into(), + login: "oauth".into(), + api_key: None, + base_url: None, + container_base_url: None, + model: Some("chosen-model".into()), + token: None, + saved: Some(true), + }; + let credential = start_stack_credential_with(&root, choice, |_, _| { + panic!("OAuth must not resolve an API key") }) - }; - cleanup_started - .recv_timeout(std::time::Duration::from_secs(5)) .unwrap(); - let returned_before_cleanup = - handler_returned.recv_timeout(std::time::Duration::from_millis(200)); - let exit_before_cleanup = exit_requested.try_recv(); - // Always release and join before asserting, including against the synchronous regression. - release.send(()).unwrap(); - dispatch.join().unwrap(); - let exit_code = exit_requested - .recv_timeout(std::time::Duration::from_secs(5)) + assert_eq!( + credential, + openbot_env::ModelCredential::ProviderOAuth { + provider: provider.into(), + path: path.to_string_lossy().into_owned(), + proxy_token: "synthetic-proxy".into(), + model: "chosen-model".into() + } + ); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn start_and_ask_resolve_saved_secrets_from_the_selected_root() { + let root_a = temp_root("selected-saved-root-a"); + let root_b = temp_root("selected-saved-root-b"); + for (root, label) in [(&root_a, "a"), (&root_b, "b")] { + std::fs::create_dir_all(root).unwrap(); + std::fs::write( + root.join(".env"), + format!("MANAGED_AGENT_AG_UI_URL=https://agent-{label}.example\n"), + ) .unwrap(); - assert!( - returned_before_cleanup.is_ok(), - "Quit's event handler blocked waiting for cleanup" + openbot_desktop_lib::vault::remember( + root, + "OPENAI_API_KEY", + &format!("openai-{label}"), + ) + .unwrap(); + openbot_desktop_lib::vault::remember( + root, + "MANAGED_AGENT_TOKEN", + &format!("agent-{label}"), + ) + .unwrap(); + } + + let credential = start_stack_credential_with( + &root_b, + ChosenModel { + provider: "openai".into(), + login: "api-key".into(), + api_key: None, + base_url: None, + container_base_url: None, + model: None, + token: None, + saved: Some(true), + }, + saved_secret, + ) + .unwrap(); + assert_eq!( + credential, + openbot_env::ModelCredential::OpenAi { + api_key: "openai-b".into() + } ); - returned_before_cleanup.unwrap().unwrap(); - assert!(matches!( - exit_before_cleanup, - Err(mpsc::TryRecvError::Empty) - )); - assert_eq!(exit_code, 37); + + let settings = ask_saved_settings(&root_b).unwrap(); assert_eq!( - *events.lock().unwrap(), - ["prevent", "cleanup-started", "cleanup-finished", "exit"] + settings.get("MANAGED_AGENT_AG_UI_URL").map(String::as_str), + Some("https://agent-b.example") ); - request_quit_with( - state, - Some(37), - || panic!("completed Quit must allow its final exit request"), - || panic!("final exit must not repeat cleanup"), - QuitDiagnostics { - sink: |_: Vec| panic!("final exit must not repeat diagnostics"), - failed: |_: String| panic!("final exit must not fail diagnostics"), + assert_eq!( + settings.get("MANAGED_AGENT_TOKEN").map(String::as_str), + Some("agent-b") + ); + + std::fs::remove_dir_all(root_a).unwrap(); + std::fs::remove_dir_all(root_b).unwrap(); + } + + #[test] + fn saved_api_key_start_reports_unreadable_env_before_store_resolution() { + let root = temp_root("start-unreadable-env"); + std::fs::create_dir_all(root.join(".env")).unwrap(); + + let problem = start_stack_credential( + &root, + ChosenModel { + provider: "openai".into(), + login: "api-key".into(), + api_key: None, + base_url: None, + container_base_url: None, + model: None, + token: None, + saved: Some(true), }, - |_| panic!("final exit must not request another exit"), - |_| panic!("final exit must not launch another worker"), + ) + .expect_err("unreadable .env must stop saved-key resolution"); + + assert_eq!(problem.said, "OpenBot could not read its settings."); + assert!( + problem + .detail + .as_deref() + .is_some_and(|detail| detail.contains(root.join(".env").to_string_lossy().as_ref())), + "{problem:?}" + ); + assert!(root.join(".env").is_dir()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn ask_reports_unreadable_env_before_store_resolution_or_http() { + let root = temp_root("ask-unreadable-env"); + std::fs::create_dir_all(root.join(".env")).unwrap(); + + let problem = + tauri::async_runtime::block_on(ask_the_bot_inner(root.clone(), "hello".into())) + .expect_err("unreadable .env must stop Ask before transport"); + + assert_eq!(problem.said, "OpenBot could not read its settings."); + assert!( + problem + .detail + .as_deref() + .is_some_and(|detail| detail.contains(root.join(".env").to_string_lossy().as_ref())), + "{problem:?}" + ); + assert!(root.join(".env").is_dir()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn existing_installation_rejects_unusable_original_encryption_keys() { + for marker in ["database", "model"] { + let root = temp_root(&format!("unusable-existing-encryption-key-{marker}")); + std::fs::create_dir_all(&root).unwrap(); + if marker == "database" { + std::fs::write( + root.join(".env"), + "DATABASE_URL=postgres://synthetic-local\n", + ) + .unwrap(); + } else { + std::fs::write( + root.join(openbot_desktop_lib::saved_intent::FILE), + r#"{"version":1,"categories":[],"model":"open-ai-api-key"}"#, + ) + .unwrap(); + assert!(openbot_desktop_lib::saved_intent::SavedIntent::read(&root) + .model + .is_some()); + } + for original in [ + None, + Some(""), + Some(" "), + Some("not-base64"), + Some("c2hvcnQ="), + Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="), + ] { + let secrets = original + .map(|value| { + std::collections::BTreeMap::from([( + "KEY_ENCRYPTION_KEY".into(), + value.into(), + )]) + }) + .unwrap_or_default(); + assert!( + require_existing_encryption_key(&root, &secrets, || { + panic!("configured roots already require their original key") + }) + .is_err(), + "{marker}: {original:?}" + ); + } + std::fs::remove_dir_all(root).unwrap(); + } + } + + #[test] + fn configured_root_without_original_key_is_rejected_but_fresh_root_is_allowed() { + let root = temp_root("valid-existing-encryption-key"); + std::fs::create_dir_all(&root).unwrap(); + assert!( + require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Ok( + false + )) + .is_ok() + ); + assert!( + require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Ok(true)) + .is_err() + ); + let unavailable = Problem::plain("selected engine unavailable"); + assert_eq!( + require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Err( + unavailable.clone() + )), + Err(unavailable) + ); + std::fs::write( + root.join(".env"), + "DATABASE_URL=postgres://synthetic-local\n", ) .unwrap(); + let original = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; + let secrets = + std::collections::BTreeMap::from([("KEY_ENCRYPTION_KEY".into(), original.into())]); + assert!(require_existing_encryption_key(&root, &secrets, || { + panic!("normal resume with the original key needs no volume probe") + }) + .is_ok()); + assert_eq!(secrets["KEY_ENCRYPTION_KEY"], original); + std::fs::remove_dir_all(root).unwrap(); } #[test] - fn responsive_quit_coalesces_duplicates_and_preserves_the_first_exit_code() { - use std::sync::Arc; - let state = Arc::new(QuitState::default()); - let work = std::cell::RefCell::new(None); - let prevented = std::cell::Cell::new(0); - let (exiting, exited) = std::sync::mpsc::channel(); - request_quit_with( - state.clone(), - Some(23), - || prevented.set(prevented.get() + 1), - Vec::new, - QuitDiagnostics { - sink: |_: Vec| panic!("no cleanup failure"), - failed: |_: String| panic!("successful cleanup must not fail diagnostics"), - }, - move |code| exiting.send(code).unwrap(), - |task| { - *work.borrow_mut() = Some(task); - Ok(()) - }, - ) - .unwrap(); - request_quit_with( - state.clone(), - Some(0), - || prevented.set(prevented.get() + 1), - || panic!("duplicate Quit must not run cleanup"), - QuitDiagnostics { - sink: |_: Vec| panic!("duplicate Quit must not report"), - failed: |_: String| panic!("duplicate Quit must not fail diagnostics"), - }, - |_| panic!("duplicate Quit must not replace the saved exit code"), - |_| panic!("duplicate Quit must not launch another worker"), + fn leftover_database_recovery_is_offered_only_for_a_proven_fresh_root() { + let root = temp_root("leftover-database-offer"); + std::fs::create_dir_all(&root).unwrap(); + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(true), + || Ok(Some("openbot_postgres-data".into())), ) - .unwrap(); - assert_eq!(prevented.get(), 2); - assert!(matches!( - exited.try_recv(), - Err(std::sync::mpsc::TryRecvError::Empty) - )); - work.into_inner().expect("one cleanup worker")(); - assert_eq!(exited.recv().unwrap(), 23); - request_quit_with( - state, - None, - || prevented.set(prevented.get() + 1), - || panic!("a late Quit must not repeat cleanup"), - QuitDiagnostics { - sink: |_: Vec| panic!("a late Quit must not report"), - failed: |_: String| panic!("a late Quit must not fail diagnostics"), - }, - |_| panic!("a late Quit must not replace the saved exit code"), - |_| panic!("a late Quit must not launch another worker"), + .unwrap_err(); + assert_eq!( + serde_json::to_value(error).unwrap()["database_reset"], + "openbot_postgres-data" + ); + for (file, content) in [ + (".env", "DATABASE_URL=postgres://fixture\n"), + ( + openbot_desktop_lib::saved_intent::FILE, + r#"{"version":1,"categories":[],"model":"open-ai-api-key"}"#, + ), + ( + openbot_desktop_lib::saved_intent::FILE, + "invalid-settings-secret", + ), + ( + openbot_desktop_lib::saved_intent::FILE, + r#"{"version":99,"categories":[],"model":null}"#, + ), + ] { + std::fs::write(root.join(file), content).unwrap(); + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(true), + || panic!("unknown or configured roots must not offer deletion"), + ) + .unwrap_err(); + assert!(serde_json::to_value(error) + .unwrap() + .get("database_reset") + .is_none()); + std::fs::remove_file(root.join(file)).unwrap(); + } + let secrets = stack::Secrets::from([( + "KEY_ENCRYPTION_KEY".into(), + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".into(), + )]); + assert!(require_existing_encryption_key_with_recovery( + &root, + &secrets, + || panic!("original key needs no probe"), + || panic!("original key needs no reset") ) - .unwrap(); - assert_eq!(prevented.get(), 3); + .is_ok()); + std::fs::remove_dir_all(root).unwrap(); } #[test] - fn responsive_quit_reports_cleanup_failures_before_requesting_exit() { - use std::sync::Arc; - let state = Arc::new(QuitState::default()); - let events = Arc::new(Mutex::new(Vec::new())); - let diagnostics = events.clone(); - let exiting = events.clone(); - let work = std::cell::RefCell::new(None); - request_quit_with( - state, - None, - || {}, - || { - vec![ - "host cleanup refused".into(), - "Compose down failed: synthetic".into(), - ] - }, - QuitDiagnostics { - sink: move |lines: Vec| { - diagnostics.lock().unwrap().extend(lines); - Ok(()) - }, - failed: |_: String| panic!("diagnostics should be recorded"), - }, - move |code| exiting.lock().unwrap().push(format!("exit:{code}")), - |task| { - *work.borrow_mut() = Some(task); - Ok(()) - }, + fn leftover_database_reset_rechecks_confirmation_configuration_key_and_ownership() { + let root = temp_root("leftover-database-command"); + std::fs::create_dir_all(&root).unwrap(); + let shell = Shell::default(); + let offer = || { + *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { + root: root.clone(), + volume: "openbot_postgres-data".into(), + address: engine::Address::new( + engine::Engine::Podman, + Some("original-machine".into()), + ), + }); + }; + offer(); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + false, + || panic!("unconfirmed must not access credentials"), + |_, _| panic!("unconfirmed must not delete") + ) + .is_err()); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || Ok(stack::Secrets::new()), + |_, _| Ok(()) + ) + .is_ok()); + offer(); + for (file, content) in [ + (".env", "DATABASE_URL=postgres://fixture\n"), + ( + openbot_desktop_lib::saved_intent::FILE, + r#"{"version":1,"categories":[],"model":"open-ai-api-key"}"#, + ), + ( + openbot_desktop_lib::saved_intent::FILE, + "invalid-settings-secret", + ), + ] { + std::fs::write(root.join(file), content).unwrap(); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || Ok(stack::Secrets::new()), + |_, _| panic!("configured or unknown root must not delete") + ) + .is_err()); + std::fs::remove_file(root.join(file)).unwrap(); + } + std::fs::create_dir(root.join(".env")).unwrap(); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || Ok(stack::Secrets::new()), + |_, _| panic!("unreadable settings must not delete") + ) + .is_err()); + std::fs::remove_dir(root.join(".env")).unwrap(); + let secrets = stack::Secrets::from([( + "KEY_ENCRYPTION_KEY".into(), + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".into(), + )]); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || Ok(secrets), + |_, _| panic!("restored key must prevent deletion") + ) + .is_err()); + *shell.root.lock().unwrap() = Some(root.clone()); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || panic!("owned hosts must refuse before credential access"), + |_, _| panic!("owned hosts must prevent deletion") + ) + .is_err()); + *shell.root.lock().unwrap() = None; + *shell.containers.lock().unwrap() = Some(ContainerDeployment { + root: root.clone(), + address: engine::Address::new(engine::Engine::Podman, Some("fixture".into())), + }); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || panic!("owned containers must refuse"), + |_, _| panic!("owned containers must prevent deletion") + ) + .is_err()); + *shell.containers.lock().unwrap() = None; + let attempt = StartAttempt::begin(&shell).unwrap(); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || panic!("concurrent startup must refuse"), + |_, _| panic!("concurrent startup must prevent deletion") ) - .unwrap(); - assert!( - events.lock().unwrap().is_empty(), - "cleanup ran on the requesting thread" - ); - work.into_inner().expect("cleanup worker")(); - assert_eq!( - *events.lock().unwrap(), - [ - "[exit] cleanup failed: host cleanup refused", - "[exit] cleanup failed: Compose down failed: synthetic", - "exit:0" - ] - ); - } - - #[test] - fn responsive_quit_launch_failure_preserves_the_app_and_allows_retry() { - use std::sync::Arc; - let state = Arc::new(QuitState::default()); - let prevented = std::cell::Cell::new(0); - let result = request_quit_with( - state.clone(), - Some(7), - || prevented.set(prevented.get() + 1), - || panic!("failed launch must not run cleanup"), - QuitDiagnostics { - sink: |_: Vec| panic!("failed launch must not report cleanup errors"), - failed: |_: String| panic!("failed launch must not fail diagnostics"), - }, - |_| panic!("failed launch must not exit"), - |_| Err(std::io::Error::other("synthetic worker launch failure")), - ); - assert_eq!( - result.unwrap_err().to_string(), - "synthetic worker launch failure" - ); - assert_eq!(prevented.get(), 1); - let work = std::cell::RefCell::new(None); - let (exiting, exited) = std::sync::mpsc::channel(); - request_quit_with( - state, - Some(9), - || prevented.set(prevented.get() + 1), - Vec::new, - QuitDiagnostics { - sink: |_: Vec| panic!("retry cleanup succeeded"), - failed: |_: String| panic!("retry cleanup must not fail diagnostics"), - }, - move |code| exiting.send(code).unwrap(), - |task| { - *work.borrow_mut() = Some(task); + .is_err()); + drop(attempt); + assert!(reset_leftover_database_with( + &shell, + &root, + "openbot_postgres-data", + true, + || Ok(stack::Secrets::new()), + |_, _| { + assert!( + shell.startup.try_lock().is_err(), + "deletion must retain the startup lock" + ); + assert!( + StartAttempt::begin(&shell).is_err(), + "startup must remain excluded during deletion" + ); Ok(()) - }, + } ) - .unwrap(); - assert_eq!(prevented.get(), 2); - assert!(matches!( - exited.try_recv(), - Err(std::sync::mpsc::TryRecvError::Empty) - )); - work.into_inner().expect("retry launches a worker")(); - assert_eq!(exited.recv().unwrap(), 9); - } - - #[test] - fn quit_cleanup_notice_is_known_safe_bounded_and_consumed_once() { - let root = temp_root("openbot-quit-cleanup-notice"); - let lines = vec![ - "[exit] cleanup failed: Compose down failed: /Users/alice/OpenBot/docker-compose.yml refused token=secret".to_string(), - "[exit] cleanup failed: C:\\Users\\alice\\OpenBot\\owned.exe OAuth password".to_string(), - ]; - - write_quit_cleanup_notice(&root, &lines).unwrap(); - let first = read_quit_cleanup_notice(&root) - .unwrap() - .expect("notice should be present"); - let detail = first.detail.unwrap(); - assert_eq!(first.said, "OpenBot had trouble shutting down last time."); - assert!(detail.contains("containers stopped"), "{detail}"); - assert!(detail.contains("app processes stopped"), "{detail}"); - assert!(!detail.contains("Compose down failed"), "{detail}"); - assert!(!detail.contains("/Users/alice"), "{detail}"); - assert!(!detail.contains("C:\\Users\\alice"), "{detail}"); - assert!(!detail.contains("token=secret"), "{detail}"); - assert!(!detail.contains("OAuth"), "{detail}"); - assert!(read_quit_cleanup_notice(&root).unwrap().is_none()); + .is_ok()); + std::fs::remove_dir_all(root).unwrap(); } #[test] - fn diagnostic_sink_failure_keeps_quit_from_completing_exit() { - use std::sync::Arc; - let state = Arc::new(QuitState::default()); - let work = std::cell::RefCell::new(None); - let failures = Arc::new(Mutex::new(Vec::new())); - let captured = failures.clone(); - let exited = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let first_exit = exited.clone(); - request_quit_with( - state.clone(), - None, - || {}, - || vec!["raw cleanup failure".into()], - QuitDiagnostics { - sink: |_: Vec| Err("notice file is unavailable".into()), - failed: move |error: String| captured.lock().unwrap().push(error), - }, - move |_| { - first_exit.store(true, std::sync::atomic::Ordering::SeqCst); - }, - |task| { - *work.borrow_mut() = Some(task); - Ok(()) - }, - ) - .unwrap(); - - work.into_inner().expect("cleanup worker")(); - assert!( - !exited.load(std::sync::atomic::Ordering::SeqCst), - "Quit exited after losing its notice sink" - ); - let reported = failures.lock().unwrap().join("\n"); - assert!( - reported.contains("notice file is unavailable"), - "{reported}" - ); - assert!( - reported.contains("[exit] cleanup failed: raw cleanup failure"), - "{reported}" - ); - let second_exit = exited.clone(); - request_quit_with( - state, - None, - || {}, - Vec::new, - QuitDiagnostics { - sink: |_: Vec| Ok(()), - failed: |_: String| panic!("diagnostics now succeed"), - }, - move |_| { - second_exit.store(true, std::sync::atomic::Ordering::SeqCst); - }, - |task| { - task(); - Ok(()) - }, + fn leftover_database_reset_keeps_offered_runtime_and_rejects_stale_offers() { + if crate::test_support::isolated_process( + "tests::leftover_database_reset_keeps_offered_runtime_and_rejects_stale_offers", + ) { + return; + } + let root = temp_root("leftover-database-affinity"); + std::fs::create_dir_all(&root).unwrap(); + let shell = Shell::default(); + let original = + engine::Address::new(engine::Engine::Podman, Some("original-machine".into())); + let volume = "openbot_postgres-data"; + assert!(reset_leftover_database_with( + &shell, + &root, + volume, + true, + || panic!("a missing offer must refuse before reading credentials"), + |_, _| panic!("a missing offer must not remove anything") ) - .unwrap(); - assert!( - exited.load(std::sync::atomic::Ordering::SeqCst), - "Quit did not retry after diagnostic failure" + .is_err()); + *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { + root: root.clone(), + volume: volume.into(), + address: original.clone(), + }); + for (selected_root, selected_volume) in [ + (&root, "other-volume"), + (&root.join("another-root"), volume), + ] { + assert!(reset_leftover_database_with( + &shell, + selected_root, + selected_volume, + true, + || panic!("a mismatched offer must refuse before reading credentials"), + |_, _| panic!("a mismatched offer must not remove anything") + ) + .is_err()); + } + // Ambient choices may change while the confirmation is open. Both a new Docker endpoint + // and a new Podman default remain irrelevant to the already pinned offer. + std::env::set_var("DOCKER_HOST", "unix:///another-engine.sock"); + std::env::set_var("CONTAINER_CONNECTION", "replacement-machine"); + let unavailable = Problem::plain("the originally offered engine is unavailable"); + assert_eq!( + reset_leftover_database_with( + &shell, + &root, + volume, + true, + || Ok(stack::Secrets::new()), + |address, _| { + assert_eq!(address, &original); + Err(unavailable.clone()) + } + ), + Err(unavailable) ); - } - - #[test] - fn ask_transport_regressions_do_not_load_from_the_vault() { - let source = include_str!("main.rs"); - let test = source - .split("\n fn ask_the_bot_uses_native_mastra_for_a_picked_mastra_harness()") - .nth(1) - .expect("ID12 regression") - .split("struct TestRequest") - .next() - .expect("ID12 regression body"); - + reset_leftover_database_with( + &shell, + &root, + volume, + true, + || Ok(stack::Secrets::new()), + |address, _| { + assert_eq!(address, &original); + let command = address.command(); + let arguments: Vec<_> = command.get_args().collect(); + assert_eq!(arguments, ["--connection", "original-machine"]); + Ok(()) + }, + ) + .unwrap(); assert!( - !test.contains("ask_the_bot("), - "ID12 must test dispatch with resolved settings instead of loading vault-backed settings" + shell.leftover_database.lock().unwrap().is_none(), + "successful reset consumes the offer" ); + std::fs::remove_dir_all(root).unwrap(); } + /// Real engine boundary: all root metadata can disappear while a named volume survives. + /// Owns one isolated project and empty database; never touches an existing deployment. #[test] - fn runtime_connection_recovery_retains_installation_and_rejects_stale_run_notifications() { - let root = temp_root("runtime-connection-recovery"); - let shell = Shell::default(); - *shell.root.lock().unwrap() = Some(root.clone()); - remember_selected_root(&shell, &root); - *shell.setup_url.lock().unwrap() = Some("http://tauri.localhost/recovery".into()); - shell - .generation - .store(7, std::sync::atomic::Ordering::SeqCst); - let app = tauri::test::mock_builder() - .manage(shell) - .build(tauri::test::mock_context(tauri::test::noop_assets())) + #[ignore = "creates an isolated database volume and starts Postgres on the explicitly selected engine"] + fn surviving_postgres_volume_blocks_fresh_root_without_key() { + let root = temp_root("encryption-key-volume-reinstall"); + std::fs::create_dir_all(&root).unwrap(); + let project = format!( + "openbot-key-reinstall-fixture-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let volume = format!("{project}_postgres-data"); + std::fs::write( + root.join("docker-compose.yml"), + format!( + "name: {project}\nservices:\n postgres:\n image: docker.io/pgvector/pgvector:pg17\n environment:\n POSTGRES_PASSWORD: isolated-fixture-only\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [CMD, pg_isready, -U, postgres]\n interval: 1s\n timeout: 5s\n retries: 45\nvolumes:\n postgres-data:\n" + ), + ) + .unwrap(); + let selected = match std::env::var("OPENBOT_TEST_ENGINE").as_deref() { + Ok("podman") => engine::Engine::Podman, + Ok("docker") | Err(_) => engine::Engine::Docker, + Ok(other) => panic!("unknown explicit test engine: {other}"), + }; + let address = engine::Address::new(selected, std::env::var("OPENBOT_TEST_CONNECTION").ok()) + .pin() + .expect("explicit selected test runtime"); + let created = address + .command() + .args([ + "volume", + "create", + "--label", + "ai.copilotkit.openbot.fixture=key-reinstall", + "--label", + &format!("com.docker.compose.project={project}"), + "--label", + "com.docker.compose.volume=postgres-data", + &volume, + ]) + .output() + .expect("create isolated volume"); + assert!(created.status.success(), "fixture volume creation failed"); + + // Always clean up this fixture, including when an assertion inside the workflow fails. + let workflow = std::panic::catch_unwind(|| { + let secrets = stack::Secrets::new(); + let guarded = require_existing_encryption_key_with_recovery( + &root, + &secrets, + || stack::postgres_volume_exists(&address, &root, &secrets), + || stack::leftover_database_volume(&address, &root, &secrets), + ) + .unwrap_err(); + assert_eq!(guarded.database_reset.as_deref(), Some(volume.as_str())); + let shell = Shell::default(); + *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { + root: root.clone(), + volume: volume.clone(), + address: address.clone(), + }); + assert!(reset_leftover_database_with( + &shell, + &root, + &volume, + false, + || Ok(secrets.clone()), + |address, secrets| stack::reset_leftover_database(address, &root, secrets, &volume) + ) + .is_err()); + assert!(stack::postgres_volume_exists(&address, &root, &secrets).unwrap()); + assert!(!root.join(".secrets/KEY_ENCRYPTION_KEY.secret").exists()); + assert!(!root.join(".env").exists()); + reset_leftover_database_with( + &shell, + &root, + &volume, + true, + || Ok(secrets.clone()), + |address, secrets| stack::reset_leftover_database(address, &root, secrets, &volume), + ) .unwrap(); - let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) - .build() + require_existing_encryption_key(&root, &secrets, || { + stack::postgres_volume_exists(&address, &root, &secrets) + }) .unwrap(); - window - .navigate("http://127.0.0.1:3010/channel/existing".parse().unwrap()) + let started = address + .command() + .current_dir(&root) + .args([ + "compose", + "up", + "--detach", + "--wait", + "--wait-timeout", + "60", + "postgres", + ]) + .output() + .unwrap(); + assert!( + started.status.success(), + "isolated Postgres must start after recovery: {}", + String::from_utf8_lossy(&started.stderr) + ); + assert!( + stack::reset_leftover_database(&address, &root, &secrets, &volume).is_err(), + "the real engine must refuse an attached database volume" + ); + let ready = address + .command() + .current_dir(&root) + .args([ + "compose", + "exec", + "-T", + "postgres", + "pg_isready", + "-U", + "postgres", + ]) + .output() + .unwrap(); + assert!( + ready.status.success(), + "Postgres must remain ready after the refused attached-volume reset" + ); + }); + let stopped = address + .command() + .current_dir(&root) + .args(["compose", "down"]) + .output() .unwrap(); - let connection = openbot_desktop_lib::problem::Connection::Model; - assert!(!publish_connection_failure(app.handle(), &root, 6, connection.clone()).unwrap()); - assert!(!publish_connection_failure( - app.handle(), - &root.join("another"), - 7, - connection.clone() - ) - .unwrap()); - assert_eq!(window.url().unwrap().path(), "/channel/existing"); - assert!(publish_connection_failure(app.handle(), &root, 7, connection).unwrap()); - let shell = app.state::(); - assert_eq!(shell.root.lock().unwrap().as_ref(), Some(&root)); - assert_eq!(shell.selected_root.lock().unwrap().as_ref(), Some(&root)); - assert!(recovery_required(&shell, &root)); - assert_eq!( - window.url().unwrap().as_str(), - "http://tauri.localhost/recovery" - ); - assert_eq!( - last_failure(app.handle().clone()).unwrap().connection, - Some(openbot_desktop_lib::problem::Connection::Model) + let removed = address + .command() + .args(["volume", "rm", &volume]) + .output() + .unwrap(); + std::fs::remove_dir_all(&root).unwrap(); + assert!( + stopped.status.success(), + "stop only the isolated fixture project" ); - assert!(publish_connection_failure( - app.handle(), - &root, - 7, - openbot_desktop_lib::problem::Connection::Intelligence - ) - .unwrap()); assert!( - last_failure(app.handle().clone()).is_none(), - "one refresh per run; a second signal cannot replace the pending screen" + removed.status.success(), + "remove only the fixture-owned volume" ); + if let Err(panic) = workflow { + std::panic::resume_unwind(panic); + } } #[test] - fn stop_retains_completed_installation_and_disables_only_automatic_start() { - let root = temp_root("stopped-completed-installation"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write(root.join(preparation::FILE), "{}").unwrap(); - preparation::record_launch( - &root, - Some(&harness::HarnessChoice { - id: "mastra".into(), - agent_url: None, - }), - ) - .unwrap(); - let app = tauri::test::mock_builder() - .manage(Shell::default()) - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .unwrap(); - app.state::() - .stopped_in_session - .store(true, std::sync::atomic::Ordering::SeqCst); - let configured = serde_json::to_value(already_configured( - app.handle().clone(), - root.to_string_lossy().into_owned(), - )) - .unwrap(); - std::fs::remove_dir_all(root).unwrap(); - assert_eq!(configured["launch"]["harness"]["id"], "mastra"); - assert_eq!(configured["autoStart"], false); + fn saved_api_key_selection_without_a_saved_key_is_rejected_before_starting_services() { + for (provider, expected) in [ + ( + "openai", + "That saved OpenAI API key is no longer available.", + ), + ( + "anthropic", + "That saved Anthropic API key is no longer available.", + ), + ] { + let root = temp_root(&format!("openbot-missing-saved-{provider}")); + std::fs::create_dir_all(&root).unwrap(); + let mut trace = Vec::new(); + + let result = start_stack_credential_with( + &root, + ChosenModel { + provider: provider.to_string(), + login: "api-key".to_string(), + api_key: None, + base_url: None, + container_base_url: None, + model: None, + token: None, + saved: Some(true), + }, + |_, key| { + trace.push(format!("saved-secret:{key}")); + Ok(String::new()) + }, + ); + if result.is_ok() { + trace.push("external-start-boundary".to_string()); + } + let problem = result.expect_err("missing saved key should stop before compose"); + + println!( + "DTA-004 missing provider={provider} error={} trace={trace:?}", + problem.said + ); + assert_eq!(problem.said, expected); + assert_eq!(trace, [format!("saved-secret:{}", saved_api_key(provider))]); + let _ = std::fs::remove_dir_all(root); + } } #[test] - fn public_already_configured_reads_only_passive_files() { - let root = temp_root("public-passive-boundary"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write( - root.join(".env"), - "INTELLIGENCE_API_URL=https://synthetic.example\n", - ) - .unwrap(); - for metadata in [ - None, - Some("malformed"), - Some(r#"{"version":9,"categories":["intelligence"],"model":null}"#), - Some( - r#"{"version":1,"categories":["intelligence","claude-plan"],"model":"claude-plan"}"#, - ), + fn saved_api_key_selection_uses_the_saved_key_when_it_still_exists() { + for (provider, expected_key) in [ + ("openai", "sk-openai-still-saved"), + ("anthropic", "sk-ant-still-saved"), ] { - if let Some(metadata) = metadata { - std::fs::write(root.join(openbot_desktop_lib::saved_intent::FILE), metadata) - .unwrap(); - } - for legacy in ["", "INTELLIGENCE_API_KEY=synthetic-cpk\nOPENAI_API_KEY=synthetic-openai\nANTHROPIC_API_KEY=synthetic-anthropic\nCLAUDE_CODE_OAUTH_TOKEN=synthetic-claude\n"] { - std::fs::write(root.join(".env"), format!("INTELLIGENCE_API_URL=https://synthetic.example\n{legacy}")).unwrap(); - let configured = already_configured_for_root(root.to_string_lossy().into_owned()); - assert_eq!(configured.values["INTELLIGENCE_API_URL"], "https://synthetic.example"); - assert!(!configured.values.contains_key("CLAUDE_CODE_OAUTH_TOKEN")); + let root = temp_root(&format!("openbot-present-saved-{provider}")); + std::fs::create_dir_all(&root).unwrap(); + + let credential = start_stack_credential_with( + &root, + ChosenModel { + provider: provider.to_string(), + login: "api-key".to_string(), + api_key: None, + base_url: None, + container_base_url: None, + model: None, + token: None, + saved: Some(true), + }, + |_, key| { + assert_eq!(key, saved_api_key(provider)); + Ok(expected_key.to_string()) + }, + ) + .expect("saved key should be accepted"); + + match credential { + openbot_env::ModelCredential::OpenAi { api_key } + | openbot_env::ModelCredential::Anthropic { api_key } => { + assert_eq!(api_key, expected_key); + println!( + "DTA-004 present provider={provider} saved_key_len={}", + api_key.len() + ); + } + other => panic!("unexpected credential: {other:?}"), } + let _ = std::fs::remove_dir_all(root); } - std::fs::remove_dir_all(root).unwrap(); } - #[test] - fn launch_never_downloads_missing_images() { - if test_support::isolated_process("tests::launch_never_downloads_missing_images") { - return; + fn saved_api_key(provider: &str) -> &'static str { + match provider { + "openai" => "OPENAI_API_KEY", + "anthropic" => "ANTHROPIC_API_KEY", + other => panic!("unexpected provider: {other}"), } - let _path = SerializedPath::set_only_with("docker", "installation-boundary"); - let root = temp_root("launch-without-installation"); - std::fs::create_dir_all(&root).unwrap(); - let address = engine::Address::new(engine::Engine::Docker, None); - let secrets = stack::Secrets::new(); - let up = stack::up(&address, &root, false, stack::BundledBots::none(), &secrets); - let migrate = stack::migrate(&address, &root, &secrets); - std::fs::remove_dir_all(root).unwrap(); - assert!(up.is_ok(), "{up:?}"); - assert!(migrate.is_ok(), "{migrate:?}"); } - #[test] - fn deployment_ready_preserves_the_installed_release() { - let root = temp_root("deployment-ready-pinned"); - write_installed_deployment(&root); - deployment::record(&root, "v0.0.7").unwrap(); - let app = tauri::test::mock_builder() - .manage(Shell::default()) - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .unwrap(); + fn compatible_choice(base_url: Option<&str>, model: Option<&str>) -> ChosenModel { + ChosenModel { + provider: "openai-compatible".into(), + login: "endpoint".into(), + api_key: None, + base_url: base_url.map(String::from), + container_base_url: None, + model: model.map(String::from), + token: None, + saved: None, + } + } - tauri::async_runtime::block_on(deployment_ready(app.handle(), &root)).unwrap(); + fn persist_endpoint_fixture(root: &Path, credential: &openbot_env::ModelCredential) { + let settings = openbot_env::compose( + &openbot_env::Intelligence { + api_url: "https://api.example.test".into(), + gateway_ws_url: "wss://api.example.test".into(), + api_key: "synthetic-intelligence".into(), + }, + &openbot_env::Model { + credential: credential.clone(), + }, + &engine::EngineStatus { + engine: None, + address: None, + responding: false, + engine_socket: None, + detail: "synthetic".into(), + }, + &openbot_env::Ports::default(), + &[], + None, + &Default::default(), + ); + let (public, secrets) = openbot_desktop_lib::vault::split(settings); + openbot_desktop_lib::saved_intent::persist_configuration( + root, &public, &secrets, &secrets, credential, + ) + .unwrap(); + } - assert_eq!(deployment::installed(&root).unwrap().version, "v0.0.7"); + #[test] + fn saved_compatible_endpoint_roundtrips_public_settings_and_scoped_key() { + let root = temp_root("compatible-roundtrip"); + std::fs::create_dir_all(&root).unwrap(); + let mut chosen = compatible_choice(Some("https://models.example/v1"), Some("local-model")); + chosen.api_key = Some("synthetic-endpoint-key".into()); + let credential = chosen.into_credential(&root).unwrap(); + persist_endpoint_fixture(&root, &credential); + let configured = already_configured_for_root(root.to_string_lossy().into_owned()); assert_eq!( - std::fs::read_to_string(root.join("docker-compose.yml")).unwrap(), - "services: {}\n" + configured.values.get("BOT_MODEL").map(String::as_str), + Some("local-model") + ); + assert_eq!(configured.saved.model_api_keys.compatible, Some(true)); + assert_eq!(configured.saved.model_api_keys.openai, None); + assert!(!serde_json::to_string(&configured) + .unwrap() + .contains("synthetic-endpoint-key")); + let mut reopened = compatible_choice( + configured.values.get("OPENAI_BASE_URL").map(String::as_str), + configured.values.get("BOT_MODEL").map(String::as_str), ); + reopened.saved = Some(true); + assert_eq!(reopened.into_credential(&root).unwrap(), credential); + + for url in [ + "https://other.example/v1", + "https://models.example/v2", + "https://models.example:8443/v1", + ] { + let mut changed = compatible_choice(Some(url), Some("local-model")); + changed.saved = Some(true); + assert!(changed + .into_credential_with(&root, |_, _| panic!( + "different endpoint must not read a credential" + )) + .is_err()); + } + let other = root.join("other-root"); + let mut changed_root = + compatible_choice(Some("https://models.example/v1"), Some("local-model")); + changed_root.saved = Some(true); + assert!(changed_root + .into_credential_with(&other, |_, _| panic!( + "different root must not read a credential" + )) + .is_err()); std::fs::remove_dir_all(root).unwrap(); } #[test] - fn plan_sign_in_boundary_uses_selected_root_for_reference_without_acquisition() { - let default = temp_root("signin-default-root"); - let selected = temp_root("signin-selected-root"); - std::fs::create_dir_all(&default).unwrap(); - std::fs::create_dir_all(&selected).unwrap(); - std::fs::write(default.join("manifest.json"), "poisoned-default").unwrap(); - let reference_root = std::cell::RefCell::new(None); - - let image = sign_in_reference( - &selected, - openbot_desktop_lib::plan::CHATGPT_SIGN_IN_IMAGE, - |root, published| { - *reference_root.borrow_mut() = Some((root.to_path_buf(), published.to_string())); - Ok(format!("{}@{}", published, root.display())) - }, - ) - .unwrap(); + fn stale_endpoint_hint_cannot_relabel_another_endpoints_stored_key() { + let root = temp_root("compatible-stale-record"); + std::fs::create_dir_all(&root).unwrap(); + let credential = openbot_env::ModelCredential::Compatible { + base_url: "https://models.example/v1".into(), + container_base_url: None, + api_key: "synthetic-old-key".into(), + model: "model".into(), + }; + persist_endpoint_fixture(&root, &credential); + let mut choice = compatible_choice(Some("https://models.example/v1"), Some("model")); + choice.saved = Some(true); + let mut reads = 0; + let error = choice + .into_credential_with(&root, |_, key| { + reads += 1; + assert_eq!( + key, + openbot_desktop_lib::saved_intent::COMPATIBLE_CREDENTIAL + ); + Ok( + r#"{"base_url":"https://other.example/v1","api_key":"synthetic-other-key"}"# + .into(), + ) + }) + .unwrap_err(); + assert_eq!(reads, 1); + assert!(!error.said.contains("synthetic-other-key")); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] + fn compatible_endpoint_accepts_trimmed_container_url_and_rejects_invalid_one() { + let mut choice = + compatible_choice(Some(" http://127.0.0.1:11434/v1 "), Some(" qwen3-vl:2b ")); + choice.container_base_url = Some(" http://ollama:11434/v1 ".into()); + let credential = + start_stack_credential(Path::new("synthetic-unused-compatible-root"), choice) + .expect("a valid container endpoint may be stored with the compatible credential"); + let openbot_env::ModelCredential::Compatible { + base_url, + container_base_url, + model, + .. + } = credential + else { + panic!("the endpoint must retain its compatible credential"); + }; + assert_eq!(base_url, "http://127.0.0.1:11434/v1"); assert_eq!( - reference_root.into_inner(), - Some(( - selected.clone(), - openbot_desktop_lib::plan::CHATGPT_SIGN_IN_IMAGE.to_string() - )) + container_base_url.as_deref(), + Some("http://ollama:11434/v1") ); - assert!(image.contains(&selected.to_string_lossy().to_string())); - assert!(!image.contains(&default.to_string_lossy().to_string())); + assert_eq!(model, "qwen3-vl:2b"); + + let mut invalid = compatible_choice(Some("http://127.0.0.1:11434/v1"), Some("qwen3-vl:2b")); + invalid.container_base_url = Some("ollama:11434/v1".into()); + let problem = + start_stack_credential(Path::new("synthetic-unused-compatible-root"), invalid) + .expect_err("a container endpoint URL must be an absolute HTTP(S) URL"); assert_eq!( - std::fs::read_to_string(default.join("manifest.json")).unwrap(), - "poisoned-default" + problem.said, + "Enter a valid http:// or https:// address for the container model endpoint." ); - let _ = std::fs::remove_dir_all(default); - let _ = std::fs::remove_dir_all(selected); } #[test] - fn command_roots_trim_paste_padding_and_preserve_interior_spaces() { - let root = temp_root("openbot-command-root My Files"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write(root.join("settings-marker"), "this deployment").unwrap(); - for typed in [ - root.display().to_string(), - format!(" \n{}\t ", root.display()), + fn compatible_endpoint_rejects_missing_or_invalid_http_url() { + for base_url in [ + None, + Some(""), + Some(" \t\n "), + Some("ftp://localhost/v1"), + Some("file:///tmp/model"), + Some("httpx://localhost/v1"), + Some("localhost:11434/v1"), + Some("http://"), + Some("https://?query"), + Some("http://[invalid]/v1"), ] { - let work_root = stack::root_from(&typed); + let problem = start_stack_credential( + Path::new("synthetic-unused-compatible-root"), + compatible_choice(base_url, Some("local-model")), + ) + .expect_err("a missing or invalid endpoint URL must stop setup"); assert_eq!( - std::fs::read_to_string(work_root.join("settings-marker")).unwrap(), - "this deployment" + problem.said, "Enter a valid http:// or https:// address for your model endpoint.", + "base_url={base_url:?}" ); } - std::fs::remove_dir_all(root).unwrap(); } #[test] - fn credential_restore_commands_are_not_registered() { - let source = include_str!("main.rs"); - let handlers = source - .split("tauri::generate_handler![") - .nth(1) - .expect("handler list exists") - .split("])") - .next() - .expect("handler list closes"); - for command in [ - ["reco", "ver", "_credential"].concat(), - ["cancel", "_credential", "_reco", "very"].concat(), - ] { - assert!( - !handlers.contains(&command), - "{command} is still registered" - ); + fn compatible_endpoint_rejects_missing_or_blank_model() { + for model in [None, Some(""), Some(" \t\n ")] { + let problem = start_stack_credential( + Path::new("synthetic-unused-compatible-root"), + compatible_choice(Some("http://127.0.0.1:11434/v1"), model), + ) + .expect_err("a missing model name must stop setup"); + assert_eq!(problem.said, "Enter the model name your endpoint serves."); } } #[test] - fn already_configured_trims_pasted_root_and_preserves_interior_spaces() { - let root = temp_root("openbot-pasted-root My Files"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write( - root.join(".env"), - "INTELLIGENCE_API_URL=https://trim.example.test\n", - ) - .unwrap(); - let typed = format!(" \n{}\t ", root.display()); - let configured = already_configured_for_root(typed); - let normal = already_configured_for_root(root.to_string_lossy().into_owned()); - assert_eq!(configured.values, normal.values); - assert_eq!( - configured.values.get("INTELLIGENCE_API_URL"), - Some(&"https://trim.example.test".to_string()) - ); - std::fs::remove_dir_all(root).unwrap(); + fn compatible_endpoint_accepts_trimmed_http_urls_and_optional_keys() { + for base_url in [ + "http://127.0.0.1:11434/v1", + "https://models.example.invalid/v1", + ] { + for api_key in [None, Some(" \t "), Some(" synthetic-endpoint-key ")] { + let mut choice = + compatible_choice(Some(&format!(" {base_url} ")), Some(" local-model ")); + choice.api_key = api_key.map(String::from); + let credential = + start_stack_credential(Path::new("synthetic-unused-compatible-root"), choice) + .expect("a valid endpoint may run without an API key"); + let openbot_env::ModelCredential::Compatible { + base_url: actual_url, + api_key: actual_key, + model, + .. + } = credential + else { + panic!("the endpoint must retain its compatible credential"); + }; + assert_eq!(actual_url, base_url); + assert_eq!(model, "local-model"); + assert_eq!(actual_key, api_key.unwrap_or_default().trim()); + } + } } #[test] - fn already_configured_returns_file_values_and_saved_indicators() { - let root = temp_root("openbot-already-configured"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write( - root.join(".env"), - "INTELLIGENCE_API_KEY=file-cpk\nOPENAI_API_KEY=file-openai\nOPENAI_BASE_URL=https://models.example/v1\n", - ) - .unwrap(); - std::fs::create_dir_all(root.join(".langchain")).unwrap(); - std::fs::write( - root.join(openbot_env::CHATGPT_STORE_FILE), - "{\"refresh_token\":\"stored\"}\n", + fn linux_first_install_redetects_native_podman_without_a_virtual_machine() { + let missing = engine::EngineStatus { + engine: None, + address: None, + responding: false, + engine_socket: None, + detail: "No container engine yet.".into(), + }; + let trace = std::cell::RefCell::new(Vec::new()); + let ready = ready_responding_engine_after_compose_repair( + missing, + true, + || { + trace.borrow_mut().push("install"); + Ok("Podman and Compose installed".into()) + }, + || { + trace.borrow_mut().push("detect"); + engine::EngineStatus { + engine: Some(engine::Engine::Podman), + address: Some(engine::Address::new(engine::Engine::Podman, None)), + responding: true, + engine_socket: None, + detail: "native Podman answers".into(), + } + }, + |_| { + trace.borrow_mut().push("compose"); + true + }, ) - .unwrap(); - - let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + .unwrap() + .expect("Linux must return its newly installed native engine, not request a VM"); + assert_eq!(ready.address.engine, engine::Engine::Podman); + assert!(ready.address.connection.is_none()); + assert_eq!(*trace.borrow(), ["install", "detect", "compose"]); + } - assert_eq!( - configured.values.get("INTELLIGENCE_API_KEY"), - Some(&"file-cpk".to_string()) + #[test] + fn linux_failed_native_engine_is_an_error_instead_of_a_request_for_qemu() { + let stopped = engine::EngineStatus { + engine: Some(engine::Engine::Podman), + address: None, + responding: false, + engine_socket: None, + detail: "podman native operation failed".into(), + }; + let result = ready_responding_engine_after_compose_repair( + stopped.clone(), + true, + || Ok("installed".into()), + || stopped.clone(), + |_| panic!("a failed engine cannot run Compose"), + ); + assert!( + result.is_err(), + "Ok(None) would enter the virtual-machine path" ); assert_eq!( - configured.values.get("OPENAI_API_KEY"), - Some(&"file-openai".to_string()) + result.err().unwrap().detail.as_deref(), + Some("podman native operation failed") ); - assert_eq!(configured.saved.intelligence_api_key, Some(true)); - assert_eq!(configured.saved.model_api_keys.openai, Some(true)); - assert_eq!(configured.saved.model_sessions.openai, Some(true)); - assert_eq!(configured.saved.model_sessions.anthropic, None); - let _ = std::fs::remove_dir_all(root); } #[test] - fn already_configured_reports_legacy_anthropic_plan_without_returning_token() { - let root = temp_root("openbot-already-configured-anthropic-session"); - std::fs::create_dir_all(&root).unwrap(); + fn responding_engine_without_compose_installs_then_redetects_before_returning() { + let before = engine::EngineStatus { + engine: Some(engine::Engine::Podman), + address: Some(engine::Address::new(engine::Engine::Podman, None)), + responding: true, + engine_socket: None, + detail: "podman is answering.".into(), + }; + let after = engine::EngineStatus { + engine: Some(engine::Engine::Podman), + address: Some(engine::Address::new( + engine::Engine::Podman, + Some("openbot".into()), + )), + responding: true, + engine_socket: None, + detail: "podman is answering on openbot.".into(), + }; + let trace = std::cell::RefCell::new(Vec::new()); + let mut compose_checks = 0; - std::fs::write( - root.join(".env"), - "CLAUDE_CODE_OAUTH_TOKEN=synthetic-legacy-plan\n", + let ready = ready_responding_engine_after_compose_repair( + before, + false, + || { + trace.borrow_mut().push("install-engine".to_string()); + Ok("Compose installed.".into()) + }, + || { + trace.borrow_mut().push("re-detect".to_string()); + after.clone() + }, + |_| { + compose_checks += 1; + compose_checks > 1 + }, ) - .unwrap(); - let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + .expect("missing Compose should be repaired") + .expect("responding engine should be returned"); - assert_eq!(configured.saved.model_sessions.anthropic, Some(true)); - assert!(!configured.values.contains_key("CLAUDE_CODE_OAUTH_TOKEN")); - let _ = std::fs::remove_dir_all(root); + assert_eq!(&*trace.borrow(), &["install-engine", "re-detect"]); + assert_eq!(ready.installed.as_deref(), Some("Compose installed.")); + assert_eq!(ready.address.connection.as_deref(), Some("openbot")); } #[test] - fn second_instance_restore_runs_blocking_probe_outside_the_async_listener() { - let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let port = listener.local_addr().unwrap().port(); - let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut request = [0_u8; 512]; - let _ = stream.read(&mut request).unwrap(); - stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") - .unwrap(); - }); - let (sent, received) = std::sync::mpsc::channel(); - - let scheduled = tauri::async_runtime::block_on(async move { - tauri::async_runtime::spawn(async move { - schedule_second_instance_restore(port, move |port| { - sent.send(stack::app_url(port).is_some()).unwrap(); - }) - .unwrap() - .join() - .unwrap(); - }) - .await - }); - + fn disposable_provider_fixture_repairs_missing_compose_at_process_boundary() { + if crate::test_support::isolated_process( + "tests::disposable_provider_fixture_repairs_missing_compose_at_process_boundary", + ) { + return; + } + let path = SerializedPath::set_only_with("podman", "podman"); + let address = engine::Address::new(engine::Engine::Podman, None); + assert!(address.responds(), "fake podman must answer before repair"); assert!( - scheduled.is_ok(), - "the async single-instance listener must not panic while scheduling restore" + !address.composes(), + "fake podman must start without a compose provider" ); - assert!(received.recv().unwrap()); - server.join().unwrap(); - } + let mut installed = false; + let mut detections = 0; - #[test] - fn passive_metadata_and_legacy_hints_are_root_and_provider_scoped() { - let root = temp_root("public-intent-cases"); - std::fs::create_dir_all(&root).unwrap(); - for input in [ - None, - Some("bad json"), - Some(r#"{"version":42,"categories":["intelligence"],"model":null}"#), - ] { - if let Some(input) = input { - std::fs::write(root.join(openbot_desktop_lib::saved_intent::FILE), input).unwrap(); - } - let unknown = already_configured_for_root(root.to_string_lossy().into_owned()); - assert_eq!(unknown.saved.intelligence_api_key, None); - assert_eq!(unknown.saved.model_sessions.anthropic, None); - } - std::fs::write( - root.join(openbot_desktop_lib::saved_intent::FILE), - r#"{"version":1,"categories":["intelligence","claude-plan"],"model":"claude-plan"}"#, + let ready = ready_responding_engine_after_compose_repair( + engine::EngineStatus { + engine: Some(engine::Engine::Podman), + address: Some(address.clone()), + responding: address.responds(), + engine_socket: None, + detail: "podman is answering.".into(), + }, + false, + || { + path.write_binary(install::compose_provider_name(), "compose-provider"); + installed = true; + Ok("Compose installed into disposable PATH.".into()) + }, + || { + detections += 1; + engine::EngineStatus { + engine: Some(engine::Engine::Podman), + address: Some(address.clone()), + responding: address.responds(), + engine_socket: None, + detail: "podman is answering after disposable provider install.".into(), + } + }, + engine::Address::composes, ) - .unwrap(); - let recorded = already_configured_for_root(root.to_string_lossy().into_owned()); - assert_eq!(recorded.saved.intelligence_api_key, Some(true)); - assert_eq!(recorded.saved.model_sessions.anthropic, Some(true)); - assert_eq!(recorded.saved.model_api_keys.anthropic, None); - assert_eq!(recorded.saved.model_sessions.openai, None); - assert!(recorded.values.is_empty()); - let fresh = already_configured_for_root( - temp_root("different-public-root") - .to_string_lossy() - .into_owned(), + .expect("disposable provider should repair Compose") + .expect("responding fake podman should be ready"); + + assert!(installed, "install path must run before readiness returns"); + assert_eq!(detections, 1, "readiness must re-detect after install"); + assert_eq!(ready.address.engine, engine::Engine::Podman); + assert!( + ready.address.composes(), + "the later start_stack compose gate should now pass" + ); + println!( + "DTA-007 functional proof: installed={installed} detections={detections} composes={}", + ready.address.composes() ); - assert_eq!(fresh.saved.model_sessions.anthropic, None); - std::fs::write( - root.join(".env"), - "ANTHROPIC_API_KEY=synthetic-legacy-anthropic\n", - ) - .unwrap(); - let legacy = already_configured_for_root(root.to_string_lossy().into_owned()); - assert_eq!(legacy.saved.model_api_keys.anthropic, Some(true)); - std::fs::remove_dir_all(root).unwrap(); } #[test] - fn saved_selection_refusal_never_falls_back_to_a_different_provider_or_billing_mode() { - let root = temp_root("explicit-saved-refusal"); - for (provider, login, expected) in [ - ("openai", "api-key", "OPENAI_API_KEY"), - ("anthropic", "api-key", "ANTHROPIC_API_KEY"), - ("anthropic", "plan", "CLAUDE_CODE_OAUTH_TOKEN"), - ] { - for denied in [false, true] { - let mut calls = Vec::new(); - let choice = ChosenModel { - provider: provider.into(), - login: login.into(), - api_key: Some("synthetic-unselected-billable-key".into()), - base_url: None, - container_base_url: None, - model: None, - token: None, - saved: Some(true), - }; - let result = start_stack_credential_with(&root, choice, |_, key| { - calls.push(key.to_string()); - if denied { - Err(Problem::plain("synthetic access denied")) - } else { - Ok(String::new()) - } - }); - let problem = result.expect_err("selected credential is unavailable"); - assert!(!problem.said.is_empty()); - assert_eq!( - problem.connection, - Some(openbot_desktop_lib::problem::Connection::Model) - ); - if denied { - assert_eq!(problem.said, "synthetic access denied"); - } - assert_eq!(calls, [expected]); - } - } - for denied in [false, true] { - let result = intelligence_key_for_start(&root, String::new(), |_, key| { - assert_eq!(key, "INTELLIGENCE_API_KEY"); - if denied { - Err(Problem::plain("synthetic access denied")) - } else { - Ok(String::new()) - } - }); - assert_eq!( - result.unwrap_err().connection, - Some(openbot_desktop_lib::problem::Connection::Intelligence) - ); - } - // A missing or unreadable ChatGPT file is an action error; no API-key resolver is called. - std::fs::create_dir_all(&root).unwrap(); - for unreadable in [false, true] { - if unreadable { - std::fs::create_dir_all(root.join(openbot_env::CHATGPT_STORE_FILE)).unwrap(); - } - let choice = ChosenModel { - provider: "openai".into(), - login: "plan".into(), - api_key: Some("synthetic-unselected-key".into()), - base_url: None, - container_base_url: None, - model: None, - token: None, - saved: Some(true), - }; - assert!(start_stack_credential_with(&root, choice, |_, _| panic!( - "plan must not fall back to an API key" - )) - .is_err()); + fn stop_shutdown_uses_the_active_root_at_the_external_command_boundary() { + if crate::test_support::isolated_process( + "tests::stop_shutdown_uses_the_active_root_at_the_external_command_boundary", + ) { + return; } - std::fs::remove_dir_all(root).unwrap(); - } + let _path = SerializedPath::set(); + let active = temp_root("openbot-active-stop-root"); + let fallback = temp_root("openbot-default-stop-root"); + std::fs::create_dir_all(&active).unwrap(); + std::fs::write(active.join("docker-compose.yml"), "services: {}\n").unwrap(); + std::fs::create_dir_all(&fallback).unwrap(); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + let selected = shutdown_root(&shell, &fallback); - #[test] - fn oauth_start_reads_private_session_without_contacting_unstarted_proxy() { - let root = temp_root("oauth-start"); - std::fs::create_dir_all(root.join(".openbot")).unwrap(); - for provider in ["google", "xai"] { - let path = root.join(openbot_desktop_lib::provider_oauth::FILE); - let record = serde_json::json!({"version":1,"sessionId":"synthetic-session","provider":provider,"clientId":"synthetic-client","accessToken":"synthetic-access","refreshToken":"synthetic-refresh","expiresAt":1,"scope":"synthetic","proxyToken":"synthetic-proxy"}); - std::fs::write(&path, serde_json::to_vec(&record).unwrap()).unwrap(); - let choice = ChosenModel { - provider: provider.into(), - login: "oauth".into(), - api_key: None, - base_url: None, - container_base_url: None, - model: Some("chosen-model".into()), - token: None, - saved: Some(true), - }; - let credential = start_stack_credential_with(&root, choice, |_, _| { - panic!("OAuth must not resolve an API key") - }) - .unwrap(); - assert_eq!( - credential, - openbot_env::ModelCredential::ProviderOAuth { - provider: provider.into(), - path: path.to_string_lossy().into_owned(), - proxy_token: "synthetic-proxy".into(), - model: "chosen-model".into() - } - ); - } - std::fs::remove_dir_all(root).unwrap(); + let record = temp_root("openbot-stop-record").join("commands.log"); + let engine = fake_engine(&record); + stack::down(&engine, &selected).expect("fake compose down"); + + assert_compose_down_ran_under(&record, &active); + assert!(shell.root.lock().unwrap().is_none()); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); } #[test] - fn start_and_ask_resolve_saved_secrets_from_the_selected_root() { - let root_a = temp_root("selected-saved-root-a"); - let root_b = temp_root("selected-saved-root-b"); - for (root, label) in [(&root_a, "a"), (&root_b, "b")] { - std::fs::create_dir_all(root).unwrap(); - std::fs::write( - root.join(".env"), - format!("MANAGED_AGENT_AG_UI_URL=https://agent-{label}.example\n"), - ) - .unwrap(); - openbot_desktop_lib::vault::remember( - root, - "OPENAI_API_KEY", - &format!("openai-{label}"), - ) - .unwrap(); - openbot_desktop_lib::vault::remember( - root, - "MANAGED_AGENT_TOKEN", - &format!("agent-{label}"), - ) - .unwrap(); + fn quit_shutdown_uses_the_active_root_at_the_external_command_boundary() { + if crate::test_support::isolated_process( + "tests::quit_shutdown_uses_the_active_root_at_the_external_command_boundary", + ) { + return; } + let _path = SerializedPath::set(); + let active = temp_root("openbot-active-quit-root"); + let fallback = temp_root("openbot-default-quit-root"); + std::fs::create_dir_all(&active).unwrap(); + std::fs::write(active.join("docker-compose.yml"), "services: {}\n").unwrap(); + std::fs::create_dir_all(&fallback).unwrap(); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + let selected = shutdown_root(&shell, &fallback); - let credential = start_stack_credential_with( - &root_b, - ChosenModel { - provider: "openai".into(), - login: "api-key".into(), - api_key: None, - base_url: None, - container_base_url: None, - model: None, - token: None, - saved: Some(true), + let record = temp_root("openbot-quit-record").join("commands.log"); + let engine = fake_engine(&record); + stack::down(&engine, &selected).expect("fake compose down"); + + assert_compose_down_ran_under(&record, &active); + assert!(shell.root.lock().unwrap().is_none()); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); + } + + #[test] + fn stop_reports_cleanup_and_down_failures_after_using_the_active_root() { + let active = temp_root("openbot-active-stop-failures"); + let fallback = temp_root("openbot-default-stop-failures"); + std::fs::create_dir_all(&active).unwrap(); + std::fs::write(active.join("docker-compose.yml"), "services: {}\n").unwrap(); + std::fs::create_dir_all(&fallback).unwrap(); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + let phases = std::cell::RefCell::new(Vec::new()); + + let problem = stop_everything_with( + &shell, + &fallback, + |root| { + phases + .borrow_mut() + .push(format!("cleanup:{}", root.display())); + Err(Problem::with( + "OpenBot could not inspect or stop its host processes.", + "lsof exited with status 2", + )) + }, + |root| { + phases.borrow_mut().push(format!("down:{}", root.display())); + Err("compose refused".to_string()) }, - saved_secret, ) - .unwrap(); - assert_eq!( - credential, - openbot_env::ModelCredential::OpenAi { - api_key: "openai-b".into() - } - ); + .expect_err("Stop must surface both cleanup and Compose failures"); - let settings = ask_saved_settings(&root_b).unwrap(); assert_eq!( - settings.get("MANAGED_AGENT_AG_UI_URL").map(String::as_str), - Some("https://agent-b.example") + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()) + ] ); - assert_eq!( - settings.get("MANAGED_AGENT_TOKEN").map(String::as_str), - Some("agent-b") + assert!( + problem.contains("OpenBot could not inspect or stop its host processes."), + "{problem}" ); - - std::fs::remove_dir_all(root_a).unwrap(); - std::fs::remove_dir_all(root_b).unwrap(); + assert!(problem.contains("lsof exited with status 2"), "{problem}"); + assert!( + problem.contains("Compose down failed: compose refused"), + "{problem}" + ); + assert_eq!(shell.root.lock().unwrap().as_ref(), Some(&active)); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); } #[test] - fn saved_api_key_start_reports_unreadable_env_before_store_resolution() { - let root = temp_root("start-unreadable-env"); - std::fs::create_dir_all(root.join(".env")).unwrap(); + fn exit_cleanup_body_records_cleanup_and_down_failures_after_using_the_active_root() { + let active = temp_root("openbot-active-exit-failures"); + let fallback = temp_root("openbot-default-exit-failures"); + std::fs::create_dir_all(&active).unwrap(); + std::fs::write(active.join("docker-compose.yml"), "services: {}\n").unwrap(); + std::fs::create_dir_all(&fallback).unwrap(); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + let phases = std::cell::RefCell::new(Vec::new()); - let problem = start_stack_credential( - &root, - ChosenModel { - provider: "openai".into(), - login: "api-key".into(), - api_key: None, - base_url: None, - container_base_url: None, - model: None, - token: None, - saved: Some(true), + let failures = exit_cleanup_with( + &shell, + &fallback, + |root| { + phases + .borrow_mut() + .push(format!("cleanup:{}", root.display())); + Err(Problem::with( + "OpenBot could not inspect or stop its host processes.", + "taskkill exited with status 5", + )) }, - ) - .expect_err("unreadable .env must stop saved-key resolution"); + |root| { + phases.borrow_mut().push(format!("down:{}", root.display())); + Err("compose down refused".to_string()) + }, + ); - assert_eq!(problem.said, "OpenBot could not read its settings."); + assert_eq!( + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()) + ] + ); + assert_eq!(failures.len(), 2, "{failures:?}"); assert!( - problem - .detail - .as_deref() - .is_some_and(|detail| detail.contains(root.join(".env").to_string_lossy().as_ref())), - "{problem:?}" + failures[0].contains("taskkill exited with status 5"), + "{failures:?}" ); - assert!(root.join(".env").is_dir()); - std::fs::remove_dir_all(root).unwrap(); + assert!( + failures[1].contains("Compose down failed: compose down refused"), + "{failures:?}" + ); + assert_eq!(shell.root.lock().unwrap().as_ref(), Some(&active)); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); } - #[test] - fn ask_reports_unreadable_env_before_store_resolution_or_http() { - let root = temp_root("ask-unreadable-env"); - std::fs::create_dir_all(root.join(".env")).unwrap(); + const LOCAL_EVAL_PIN: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; - let problem = - tauri::async_runtime::block_on(ask_the_bot_inner(root.clone(), "hello".into())) - .expect_err("unreadable .env must stop Ask before transport"); + fn local_eval_root(label: &str, ownership: Option) -> PathBuf { + let root = temp_root(label); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("docker-compose.yml"), "services: {}\n").unwrap(); + if let Some(ownership) = ownership { + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + local_eval_state::begin(&root, &stack, ownership, LOCAL_EVAL_PIN).unwrap(); + } + root + } - assert_eq!(problem.said, "OpenBot could not read its settings."); - assert!( - problem - .detail - .as_deref() - .is_some_and(|detail| detail.contains(root.join(".env").to_string_lossy().as_ref())), - "{problem:?}" - ); - assert!(root.join(".env").is_dir()); - std::fs::remove_dir_all(root).unwrap(); + fn local_eval_report( + outcome: local_eval::Outcome, + reason: Option, + ) -> local_eval::Report { + local_eval::Report { + outcome, + stack: None, + reason, + readiness: local_eval::Readiness::Unknown, + licence: local_eval::Licence::Unknown, + licence_expires_at: None, + } + } + + fn signed_in( + outcome: local_eval::Outcome, + reason: Option, + ) -> Result { + Ok(local_eval::SignIn { outcome, reason }) } #[test] - fn existing_installation_rejects_unusable_original_encryption_keys() { - for marker in ["database", "model"] { - let root = temp_root(&format!("unusable-existing-encryption-key-{marker}")); - std::fs::create_dir_all(&root).unwrap(); - if marker == "database" { - std::fs::write( - root.join(".env"), - "DATABASE_URL=postgres://synthetic-local\n", - ) - .unwrap(); - } else { - std::fs::write( - root.join(openbot_desktop_lib::saved_intent::FILE), - r#"{"version":1,"categories":[],"model":"open-ai-api-key"}"#, - ) - .unwrap(); - assert!(openbot_desktop_lib::saved_intent::SavedIntent::read(&root) - .model - .is_some()); - } - for original in [ - None, - Some(""), - Some(" "), - Some("not-base64"), - Some("c2hvcnQ="), - Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="), - ] { - let secrets = original - .map(|value| { - std::collections::BTreeMap::from([( - "KEY_ENCRYPTION_KEY".into(), - value.into(), - )]) - }) - .unwrap_or_default(); - assert!( - require_existing_encryption_key(&root, &secrets, || { - panic!("configured roots already require their original key") - }) - .is_err(), - "{marker}: {original:?}" - ); - } - std::fs::remove_dir_all(root).unwrap(); - } + fn opening_intelligence_needs_a_recorded_stack_and_never_asks_the_cli_without_one() { + let root = local_eval_root("openbot-open-intelligence-unrecorded", None); + let problem = + open_local_intelligence_at(&root, |_| panic!("login ran without a stack")).unwrap_err(); + assert_eq!( + problem, + Problem::plain("Set up local Intelligence before opening its dashboard.") + ); + let _ = std::fs::remove_dir_all(root); } #[test] - fn configured_root_without_original_key_is_rejected_but_fresh_root_is_allowed() { - let root = temp_root("valid-existing-encryption-key"); - std::fs::create_dir_all(&root).unwrap(); - assert!( - require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Ok( - false - )) - .is_ok() - ); - assert!( - require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Ok(true)) - .is_err() - ); - let unavailable = Problem::plain("selected engine unavailable"); - assert_eq!( - require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Err( - unavailable.clone() - )), - Err(unavailable) - ); - std::fs::write( - root.join(".env"), - "DATABASE_URL=postgres://synthetic-local\n", - ) - .unwrap(); - let original = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; - let secrets = - std::collections::BTreeMap::from([("KEY_ENCRYPTION_KEY".into(), original.into())]); - assert!(require_existing_encryption_key(&root, &secrets, || { - panic!("normal resume with the original key needs no volume probe") + fn opening_intelligence_signs_in_the_recorded_stack() { + let root = local_eval_root( + "openbot-open-intelligence-recorded", + Some(local_eval_state::Ownership::Shared), + ); + let mut asked = None; + open_local_intelligence_at(&root, |stack| { + asked = Some(stack.as_str().to_owned()); + signed_in(local_eval::Outcome::Completed, None) }) - .is_ok()); - assert_eq!(secrets["KEY_ENCRYPTION_KEY"], original); - std::fs::remove_dir_all(root).unwrap(); + .unwrap(); + assert_eq!(asked.as_deref(), Some("local-abc123")); + let _ = std::fs::remove_dir_all(root); } #[test] - fn leftover_database_recovery_is_offered_only_for_a_proven_fresh_root() { - let root = temp_root("leftover-database-offer"); - std::fs::create_dir_all(&root).unwrap(); - let error = require_existing_encryption_key_with_recovery( - &root, - &stack::Secrets::new(), - || Ok(true), - || Ok(Some("openbot_postgres-data".into())), - ) - .unwrap_err(); - assert_eq!( - serde_json::to_value(error).unwrap()["database_reset"], - "openbot_postgres-data" - ); - for (file, content) in [ - (".env", "DATABASE_URL=postgres://fixture\n"), - ( - openbot_desktop_lib::saved_intent::FILE, - r#"{"version":1,"categories":[],"model":"open-ai-api-key"}"#, - ), + fn opening_intelligence_fails_with_fixed_sentences_only() { + use local_eval::{Failure, Outcome, Reason}; + let root = local_eval_root( + "openbot-open-intelligence-failures", + Some(local_eval_state::Ownership::Created), + ); + let retry = "OpenBot could not open the Intelligence dashboard. Try again."; + for (result, said) in [ ( - openbot_desktop_lib::saved_intent::FILE, - "invalid-settings-secret", + signed_in(Outcome::Blocked, Some(Reason::DashboardUnavailable)), + "The Intelligence dashboard is not responding yet. Try again when Intelligence is ready.", ), ( - openbot_desktop_lib::saved_intent::FILE, - r#"{"version":99,"categories":[],"model":null}"#, + signed_in(Outcome::Blocked, Some(Reason::BrowserOpenFailed)), + "Your browser did not open. Try again.", ), - ] { - std::fs::write(root.join(file), content).unwrap(); - let error = require_existing_encryption_key_with_recovery( - &root, - &stack::Secrets::new(), - || Ok(true), - || panic!("unknown or configured roots must not offer deletion"), - ) - .unwrap_err(); - assert!(serde_json::to_value(error) - .unwrap() - .get("database_reset") - .is_none()); - std::fs::remove_file(root.join(file)).unwrap(); - } - let secrets = stack::Secrets::from([( - "KEY_ENCRYPTION_KEY".into(), - "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".into(), - )]); - assert!(require_existing_encryption_key_with_recovery( - &root, - &secrets, - || panic!("original key needs no probe"), - || panic!("original key needs no reset") - ) - .is_ok()); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn leftover_database_reset_rechecks_confirmation_configuration_key_and_ownership() { - let root = temp_root("leftover-database-command"); - std::fs::create_dir_all(&root).unwrap(); - let shell = Shell::default(); - let offer = || { - *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { - root: root.clone(), - volume: "openbot_postgres-data".into(), - address: engine::Address::new( - engine::Engine::Podman, - Some("original-machine".into()), - ), - }); - }; - offer(); - assert!(reset_leftover_database_with( - &shell, - &root, - "openbot_postgres-data", - false, - || panic!("unconfirmed must not access credentials"), - |_, _| panic!("unconfirmed must not delete") - ) - .is_err()); - assert!(reset_leftover_database_with( - &shell, - &root, - "openbot_postgres-data", - true, - || Ok(stack::Secrets::new()), - |_, _| Ok(()) - ) - .is_ok()); - offer(); - for (file, content) in [ - (".env", "DATABASE_URL=postgres://fixture\n"), ( - openbot_desktop_lib::saved_intent::FILE, - r#"{"version":1,"categories":[],"model":"open-ai-api-key"}"#, + signed_in(Outcome::Blocked, Some(Reason::LocalCommandBusy)), + "Another Intelligence command is still running. Try again when it finishes.", ), ( - openbot_desktop_lib::saved_intent::FILE, - "invalid-settings-secret", - ), - ] { - std::fs::write(root.join(file), content).unwrap(); - assert!(reset_leftover_database_with( - &shell, - &root, - "openbot_postgres-data", - true, - || Ok(stack::Secrets::new()), - |_, _| panic!("configured or unknown root must not delete") - ) - .is_err()); - std::fs::remove_file(root.join(file)).unwrap(); - } - std::fs::create_dir(root.join(".env")).unwrap(); - assert!(reset_leftover_database_with( - &shell, - &root, - "openbot_postgres-data", - true, - || Ok(stack::Secrets::new()), - |_, _| panic!("unreadable settings must not delete") - ) - .is_err()); - std::fs::remove_dir(root.join(".env")).unwrap(); - let secrets = stack::Secrets::from([( - "KEY_ENCRYPTION_KEY".into(), - "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".into(), - )]); - assert!(reset_leftover_database_with( - &shell, - &root, - "openbot_postgres-data", - true, - || Ok(secrets), - |_, _| panic!("restored key must prevent deletion") - ) - .is_err()); - *shell.root.lock().unwrap() = Some(root.clone()); - assert!(reset_leftover_database_with( - &shell, - &root, - "openbot_postgres-data", - true, - || panic!("owned hosts must refuse before credential access"), - |_, _| panic!("owned hosts must prevent deletion") - ) - .is_err()); - *shell.root.lock().unwrap() = None; - *shell.containers.lock().unwrap() = Some(ContainerDeployment { - root: root.clone(), - address: engine::Address::new(engine::Engine::Podman, Some("fixture".into())), - }); - assert!(reset_leftover_database_with( - &shell, - &root, - "openbot_postgres-data", - true, - || panic!("owned containers must refuse"), - |_, _| panic!("owned containers must prevent deletion") - ) - .is_err()); - *shell.containers.lock().unwrap() = None; - let attempt = StartAttempt::begin(&shell).unwrap(); - assert!(reset_leftover_database_with( - &shell, - &root, - "openbot_postgres-data", - true, - || panic!("concurrent startup must refuse"), - |_, _| panic!("concurrent startup must prevent deletion") - ) - .is_err()); - drop(attempt); - assert!(reset_leftover_database_with( - &shell, - &root, - "openbot_postgres-data", - true, - || Ok(stack::Secrets::new()), - |_, _| { - assert!( - shell.startup.try_lock().is_err(), - "deletion must retain the startup lock" - ); - assert!( - StartAttempt::begin(&shell).is_err(), - "startup must remain excluded during deletion" - ); - Ok(()) - } - ) - .is_ok()); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn leftover_database_reset_keeps_offered_runtime_and_rejects_stale_offers() { - if crate::test_support::isolated_process( - "tests::leftover_database_reset_keeps_offered_runtime_and_rejects_stale_offers", - ) { - return; - } - let root = temp_root("leftover-database-affinity"); - std::fs::create_dir_all(&root).unwrap(); - let shell = Shell::default(); - let original = - engine::Address::new(engine::Engine::Podman, Some("original-machine".into())); - let volume = "openbot_postgres-data"; - assert!(reset_leftover_database_with( - &shell, - &root, - volume, - true, - || panic!("a missing offer must refuse before reading credentials"), - |_, _| panic!("a missing offer must not remove anything") - ) - .is_err()); - *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { - root: root.clone(), - volume: volume.into(), - address: original.clone(), - }); - for (selected_root, selected_volume) in [ - (&root, "other-volume"), - (&root.join("another-root"), volume), + signed_in(Outcome::Blocked, Some(Reason::DockerUnavailable)), + "The software Intelligence runs on is not running.", + ), + (signed_in(Outcome::Blocked, Some(Reason::Unrecognized)), retry), + (signed_in(Outcome::Blocked, None), retry), + (signed_in(Outcome::Failed, Some(Reason::LocalCommandFailed)), retry), + ( + Err(Failure::Unavailable), + "This version of OpenBot cannot open the Intelligence dashboard.", + ), + ( + Err(Failure::Timeout), + "The Intelligence dashboard took too long to open. Try again.", + ), + ( + Err(Failure::Cancelled), + "Opening the Intelligence dashboard was stopped.", + ), + (Err(Failure::InvalidJson), retry), + (Err(Failure::Unverified), retry), ] { - assert!(reset_leftover_database_with( - &shell, - selected_root, - selected_volume, - true, - || panic!("a mismatched offer must refuse before reading credentials"), - |_, _| panic!("a mismatched offer must not remove anything") - ) - .is_err()); + let problem = open_local_intelligence_at(&root, |_| result).unwrap_err(); + // Nothing but the sentence: no detail, so no CLI text can ride along. + assert_eq!(problem, Problem::plain(said)); } - // Ambient choices may change while the confirmation is open. Both a new Docker endpoint - // and a new Podman default remain irrelevant to the already pinned offer. - std::env::set_var("DOCKER_HOST", "unix:///another-engine.sock"); - std::env::set_var("CONTAINER_CONNECTION", "replacement-machine"); - let unavailable = Problem::plain("the originally offered engine is unavailable"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn the_intelligence_menu_item_is_offered_only_for_a_recorded_local_stack() { + let recorded = local_eval_root( + "openbot-intelligence-menu-recorded", + Some(local_eval_state::Ownership::Created), + ); + let unrecorded = local_eval_root("openbot-intelligence-menu-unrecorded", None); assert_eq!( - reset_leftover_database_with( - &shell, - &root, - volume, - true, - || Ok(stack::Secrets::new()), - |address, _| { - assert_eq!(address, &original); - Err(unavailable.clone()) - } - ), - Err(unavailable) + intelligence_menu_root(&recorded, true), + Some(recorded.clone()) ); - reset_leftover_database_with( - &shell, - &root, - volume, - true, - || Ok(stack::Secrets::new()), - |address, _| { - assert_eq!(address, &original); - let command = address.command(); - let arguments: Vec<_> = command.get_args().collect(); - assert_eq!(arguments, ["--connection", "original-machine"]); + // Hosted mode, or local mode with nothing recorded: no item. + assert_eq!(intelligence_menu_root(&recorded, false), None); + assert_eq!(intelligence_menu_root(&unrecorded, true), None); + let _ = std::fs::remove_dir_all(recorded); + let _ = std::fs::remove_dir_all(unrecorded); + } + + #[test] + fn the_intelligence_menu_item_says_it_is_opening_and_cannot_be_chosen_twice() { + assert_eq!( + intelligence_menu_item(false), + ("Open Intelligence dashboard", true) + ); + assert_eq!( + intelligence_menu_item(true), + ("Opening Intelligence dashboard…", false) + ); + } + + #[test] + fn opening_the_dashboard_from_the_menu_says_so_before_the_cli_runs_then_how_it_went() { + use std::sync::{Arc, Mutex}; + for (result, last) in [ + (Ok(()), IntelligenceOpening::Opened), + ( + Err(DashboardFailure::BrowserFailed), + IntelligenceOpening::Failed(DashboardFailure::BrowserFailed), + ), + ] { + let opening = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let said = Arc::new(Mutex::new(Vec::new())); + let work = std::cell::RefCell::new(None); + let ran = Arc::new(Mutex::new(false)); + let status = { + let said = Arc::clone(&said); + move |status: IntelligenceOpening| said.lock().unwrap().push(status) + }; + let open = { + let ran = Arc::clone(&ran); + move || { + *ran.lock().unwrap() = true; + result + } + }; + open_intelligence_reporting(Arc::clone(&opening), status.clone(), open, |job| { + *work.borrow_mut() = Some(job); Ok(()) - }, - ) - .unwrap(); - assert!( - shell.leftover_database.lock().unwrap().is_none(), - "successful reset consumes the offer" + }); + // Said on the click itself, before the CLI call has even started. + assert_eq!(*said.lock().unwrap(), [IntelligenceOpening::Opening]); + assert!(!*ran.lock().unwrap()); + assert!(opening.load(std::sync::atomic::Ordering::SeqCst)); + // A second click while it runs starts nothing and says nothing new. + open_intelligence_reporting( + Arc::clone(&opening), + status.clone(), + || panic!("a second opening ran while the first was running"), + |_| panic!("a second opening was scheduled"), + ); + assert_eq!(said.lock().unwrap().len(), 1); + + (work.borrow_mut().take().unwrap())(); + assert_eq!( + *said.lock().unwrap(), + [IntelligenceOpening::Opening, last.clone()] + ); + assert!(!opening.load(std::sync::atomic::Ordering::SeqCst)); + } + assert_eq!( + serde_json::to_value(IntelligenceOpening::Opening).unwrap(), + serde_json::json!({"state": "opening", "said": "Opening the Intelligence dashboard…"}) + ); + assert_eq!( + serde_json::to_value(IntelligenceOpening::Opened).unwrap(), + serde_json::json!({"state": "opened", "said": "Opened in your browser."}) + ); + assert_eq!( + serde_json::to_value(IntelligenceOpening::Failed(DashboardFailure::BrowserFailed)) + .unwrap(), + serde_json::json!({"state": "failed", "said": "Your browser did not open. Try again."}) ); - std::fs::remove_dir_all(root).unwrap(); } - /// Real engine boundary: all root metadata can disappear while a named volume survives. - /// Owns one isolated project and empty database; never touches an existing deployment. #[test] - #[ignore = "creates an isolated database volume and starts Postgres on the explicitly selected engine"] - fn surviving_postgres_volume_blocks_fresh_root_without_key() { - let root = temp_root("encryption-key-volume-reinstall"); - std::fs::create_dir_all(&root).unwrap(); - let project = format!( - "openbot-key-reinstall-fixture-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() + fn a_dashboard_opening_that_could_not_be_scheduled_is_not_left_disabled() { + use std::sync::{Arc, Mutex}; + let opening = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let said = Arc::new(Mutex::new(Vec::new())); + let status = { + let said = Arc::clone(&said); + move |status: IntelligenceOpening| said.lock().unwrap().push(status) + }; + open_intelligence_reporting( + Arc::clone(&opening), + status, + || Ok(()), + |_| Err(std::io::Error::other("no threads left")), ); - let volume = format!("{project}_postgres-data"); - std::fs::write( - root.join("docker-compose.yml"), - format!( - "name: {project}\nservices:\n postgres:\n image: docker.io/pgvector/pgvector:pg17\n environment:\n POSTGRES_PASSWORD: isolated-fixture-only\n volumes:\n - postgres-data:/var/lib/postgresql/data\n healthcheck:\n test: [CMD, pg_isready, -U, postgres]\n interval: 1s\n timeout: 5s\n retries: 45\nvolumes:\n postgres-data:\n" - ), + assert_eq!( + *said.lock().unwrap(), + [ + IntelligenceOpening::Opening, + IntelligenceOpening::Failed(DashboardFailure::Failed) + ] + ); + assert!(!opening.load(std::sync::atomic::Ordering::SeqCst)); + } + + fn reported_stack(id: &str, dashboard: &str) -> local_eval::Stack { + local_eval::Stack { + id: id.into(), + installed_release: "0.13.14".into(), + step: "ready".into(), + urls: local_eval::Urls { + api: "http://127.0.0.1:4201".into(), + dashboard: dashboard.into(), + gateway: "ws://127.0.0.1:4401".into(), + }, + has_failed_step: false, + update_in_progress: false, + observed: None, + } + } + + #[test] + fn the_dashboard_signs_in_once_per_session_then_opens_its_address_with_no_cli_call() { + use local_eval::{Outcome, Reason}; + use std::cell::RefCell; + let dashboards = Dashboards::default(); + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + let opened = RefCell::new(Vec::new()); + let open = |address: &str| { + opened.borrow_mut().push(address.to_owned()); + Ok(()) + }; + dashboards.learn(&reported_stack( + "local-abc123", + "http://intelligence.localhost:8080", + )); + + // A sign-in that did not complete is not remembered. + let signed = open_or_sign_in( + &dashboards, + &stack, + |_| signed_in(Outcome::Blocked, Some(Reason::DashboardUnavailable)), + open, ) .unwrap(); - let selected = match std::env::var("OPENBOT_TEST_ENGINE").as_deref() { - Ok("podman") => engine::Engine::Podman, - Ok("docker") | Err(_) => engine::Engine::Docker, - Ok(other) => panic!("unknown explicit test engine: {other}"), + assert_eq!(signed.outcome, Outcome::Blocked); + // The first completed opening in this session goes through `local login`. + let logins = RefCell::new(0); + let login = |_: &local_eval::StackId| { + *logins.borrow_mut() += 1; + signed_in(Outcome::Completed, None) }; - let address = engine::Address::new(selected, std::env::var("OPENBOT_TEST_CONNECTION").ok()) - .pin() - .expect("explicit selected test runtime"); - let created = address - .command() - .args([ - "volume", - "create", - "--label", - "ai.copilotkit.openbot.fixture=key-reinstall", - "--label", - &format!("com.docker.compose.project={project}"), - "--label", - "com.docker.compose.volume=postgres-data", - &volume, - ]) - .output() - .expect("create isolated volume"); - assert!(created.status.success(), "fixture volume creation failed"); - - // Always clean up this fixture, including when an assertion inside the workflow fails. - let workflow = std::panic::catch_unwind(|| { - let secrets = stack::Secrets::new(); - let guarded = require_existing_encryption_key_with_recovery( - &root, - &secrets, - || stack::postgres_volume_exists(&address, &root, &secrets), - || stack::leftover_database_volume(&address, &root, &secrets), - ) - .unwrap_err(); - assert_eq!(guarded.database_reset.as_deref(), Some(volume.as_str())); - let shell = Shell::default(); - *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { - root: root.clone(), - volume: volume.clone(), - address: address.clone(), - }); - assert!(reset_leftover_database_with( - &shell, - &root, - &volume, - false, - || Ok(secrets.clone()), - |address, secrets| stack::reset_leftover_database(address, &root, secrets, &volume) - ) - .is_err()); - assert!(stack::postgres_volume_exists(&address, &root, &secrets).unwrap()); - assert!(!root.join(".secrets/KEY_ENCRYPTION_KEY.secret").exists()); - assert!(!root.join(".env").exists()); - reset_leftover_database_with( - &shell, - &root, - &volume, - true, - || Ok(secrets.clone()), - |address, secrets| stack::reset_leftover_database(address, &root, secrets, &volume), + open_or_sign_in(&dashboards, &stack, login, open).unwrap(); + assert_eq!(*logins.borrow(), 1); + assert!( + opened.borrow().is_empty(), + "local login opens the browser itself" + ); + // After that: the address, at once, and the CLI is never asked. + for _ in 0..2 { + let signed = open_or_sign_in( + &dashboards, + &stack, + |_| panic!("the CLI was asked again"), + open, ) .unwrap(); - require_existing_encryption_key(&root, &secrets, || { - stack::postgres_volume_exists(&address, &root, &secrets) - }) - .unwrap(); - let started = address - .command() - .current_dir(&root) - .args([ - "compose", - "up", - "--detach", - "--wait", - "--wait-timeout", - "60", - "postgres", - ]) - .output() - .unwrap(); - assert!( - started.status.success(), - "isolated Postgres must start after recovery: {}", - String::from_utf8_lossy(&started.stderr) - ); - assert!( - stack::reset_leftover_database(&address, &root, &secrets, &volume).is_err(), - "the real engine must refuse an attached database volume" - ); - let ready = address - .command() - .current_dir(&root) - .args([ - "compose", - "exec", - "-T", - "postgres", - "pg_isready", - "-U", - "postgres", - ]) - .output() - .unwrap(); - assert!( - ready.status.success(), - "Postgres must remain ready after the refused attached-volume reset" - ); - }); - let stopped = address - .command() - .current_dir(&root) - .args(["compose", "down"]) - .output() - .unwrap(); - let removed = address - .command() - .args(["volume", "rm", &volume]) - .output() - .unwrap(); - std::fs::remove_dir_all(&root).unwrap(); - assert!( - stopped.status.success(), - "stop only the isolated fixture project" + assert_eq!(signed.outcome, Outcome::Completed); + } + assert_eq!( + *opened.borrow(), + [ + "http://intelligence.localhost:8080", + "http://intelligence.localhost:8080" + ] ); - assert!( - removed.status.success(), - "remove only the fixture-owned volume" + // A browser that would not open is the CLI's own category for it. + let failed = + open_or_sign_in(&dashboards, &stack, |_| panic!("no CLI"), |_| Err(())).unwrap(); + assert_eq!( + (failed.outcome, failed.reason), + (Outcome::Failed, Some(Reason::BrowserOpenFailed)) ); - if let Err(panic) = workflow { - std::panic::resume_unwind(panic); + // A new address is a new origin with no session: signed in again. + dashboards.learn(&reported_stack( + "local-abc123", + "http://intelligence.localhost:8081", + )); + open_or_sign_in(&dashboards, &stack, login, open).unwrap(); + assert_eq!(*logins.borrow(), 2); + open_or_sign_in(&dashboards, &stack, |_| panic!("no CLI"), open).unwrap(); + assert_eq!( + opened.borrow().last().map(String::as_str), + Some("http://intelligence.localhost:8081") + ); + } + + #[test] + fn a_signed_in_stack_with_no_reported_address_signs_in_again() { + let dashboards = Dashboards::default(); + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + let logins = std::cell::Cell::new(0); + for _ in 0..2 { + open_or_sign_in( + &dashboards, + &stack, + |_| { + logins.set(logins.get() + 1); + signed_in(local_eval::Outcome::Completed, None) + }, + |_| panic!("no address was ever reported"), + ) + .unwrap(); } + assert_eq!(logins.get(), 2); + // Learning the address keeps the sign-in: the browser holds the session for it. + dashboards.learn(&reported_stack( + "local-abc123", + "http://intelligence.localhost:8080", + )); + assert_eq!( + dashboards.signed_in(&stack).as_deref(), + Some("http://intelligence.localhost:8080") + ); + // Another stack's sign-in is its own. + let other = local_eval::StackId::parse("local-def456").unwrap(); + assert_eq!(dashboards.signed_in(&other), None); } #[test] - fn saved_api_key_selection_without_a_saved_key_is_rejected_before_starting_services() { - for (provider, expected) in [ - ( - "openai", - "That saved OpenAI API key is no longer available.", - ), - ( - "anthropic", - "That saved Anthropic API key is no longer available.", - ), + fn dashboard_failures_reach_the_app_page_as_categories_only() { + for (failure, category) in [ + (DashboardFailure::NotSetUp, "not_set_up"), + (DashboardFailure::NotResponding, "not_responding"), + (DashboardFailure::BrowserFailed, "browser_failed"), + (DashboardFailure::Busy, "busy"), + (DashboardFailure::EngineStopped, "engine_stopped"), + (DashboardFailure::Unsupported, "unsupported"), + (DashboardFailure::TimedOut, "timed_out"), + (DashboardFailure::Stopped, "stopped"), + (DashboardFailure::Failed, "failed"), ] { - let root = temp_root(&format!("openbot-missing-saved-{provider}")); - std::fs::create_dir_all(&root).unwrap(); - let mut trace = Vec::new(); - - let result = start_stack_credential_with( - &root, - ChosenModel { - provider: provider.to_string(), - login: "api-key".to_string(), - api_key: None, - base_url: None, - container_base_url: None, - model: None, - token: None, - saved: Some(true), - }, - |_, key| { - trace.push(format!("saved-secret:{key}")); - Ok(String::new()) - }, + assert_eq!( + serde_json::to_value(intelligence_bridge::Offer::Failed(failure)).unwrap(), + serde_json::json!({"state": "failed", "reason": category}) ); - if result.is_ok() { - trace.push("external-start-boundary".to_string()); - } - let problem = result.expect_err("missing saved key should stop before compose"); - - println!( - "DTA-004 missing provider={provider} error={} trace={trace:?}", - problem.said + } + for (offer, state) in [ + (intelligence_bridge::Offer::Unavailable, "unavailable"), + (intelligence_bridge::Offer::Ready, "ready"), + (intelligence_bridge::Offer::Opening, "opening"), + ] { + assert_eq!( + serde_json::to_value(offer).unwrap(), + serde_json::json!({ "state": state }) ); - assert_eq!(problem.said, expected); - assert_eq!(trace, [format!("saved-secret:{}", saved_api_key(provider))]); - let _ = std::fs::remove_dir_all(root); } + assert_eq!( + intelligence_bridge::Offer::after(&IntelligenceOpening::Opening), + intelligence_bridge::Offer::Opening + ); + assert_eq!( + intelligence_bridge::Offer::after(&IntelligenceOpening::Opened), + intelligence_bridge::Offer::Ready + ); + assert_eq!( + intelligence_bridge::Offer::after(&IntelligenceOpening::Failed(DashboardFailure::Busy)), + intelligence_bridge::Offer::Failed(DashboardFailure::Busy) + ); } #[test] - fn saved_api_key_selection_uses_the_saved_key_when_it_still_exists() { - for (provider, expected_key) in [ - ("openai", "sk-openai-still-saved"), - ("anthropic", "sk-ant-still-saved"), + fn the_setup_window_only_grows_and_never_past_its_screen() { + // Content taller than the window: grown to fit. + assert_eq!(grown_height(640.0, 812.4, 900.0), Some(813.0)); + // Taller than the screen allows: grown to the most it may have, and the page scrolls. + assert_eq!(grown_height(640.0, 1400.0, 900.5), Some(900.0)); + // Content that fits, or a window already as tall as it can be: left alone, never shrunk. + assert_eq!(grown_height(800.0, 620.0, 900.0), None); + assert_eq!(grown_height(900.0, 1400.0, 900.0), None); + assert_eq!(grown_height(799.6, 800.0, 900.0), None); + } + + #[test] + fn about_openbot_names_the_commit_branch_and_build_time() { + assert_eq!(utc_minute(0), "1970-01-01 00:00 UTC"); + assert_eq!(utc_minute(951_782_400), "2000-02-29 00:00 UTC"); + assert_eq!(utc_minute(1_792_647_660), "2026-10-22 05:41 UTC"); + assert_eq!( + build_description("397d98a-dirty", "dustywusty/x", 1_792_647_660), + "Built 2026-10-22 05:41 UTC from 397d98a-dirty on dustywusty/x" + ); + assert_eq!( + build_description("397d98a", "", 0), + "Built 1970-01-01 00:00 UTC from 397d98a" + ); + let about = about_openbot(); + assert_eq!( + about.short_version.as_deref(), + Some(env!("CARGO_PKG_VERSION")) + ); + assert_eq!(about.version.as_deref(), Some(env!("OPENBOT_BUILD_COMMIT"))); + assert!(about.credits.unwrap().starts_with("Built ")); + } + + #[test] + fn nothing_is_ever_written_in_the_menu_bar() { + // The status title widened the tray item behind the camera notch, and on macOS + // clearing the title never took it away. The tray keeps its icon, tooltip and menu only. + let source = include_str!("main.rs"); + let call = ["set_", "title("].concat(); + assert!(!source.contains(&call), "the tray title is set again"); + } + + #[test] + fn stop_asks_intelligence_last_even_after_compose_down_failed() { + let active = local_eval_root("openbot-stop-intelligence-order", None); + let fallback = temp_root("openbot-default-stop-intelligence-order"); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + let phases = std::cell::RefCell::new(Vec::new()); + + let stopped = stop_everything_then( + &shell, + &fallback, + |root| { + phases + .borrow_mut() + .push(format!("cleanup:{}", root.display())); + Ok(0) + }, + |root| { + phases.borrow_mut().push(format!("down:{}", root.display())); + Err("compose refused".to_string()) + }, + |root| { + phases + .borrow_mut() + .push(format!("intelligence:{}", root.display())); + Err("the CLI timed out".to_string()) + }, + ); + + assert_eq!( + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()), + format!("intelligence:{}", active.display()), + ] + ); + assert_eq!( + stopped, + Stopped { + openbot: vec!["Compose down failed: compose refused".into()], + intelligence: Some("Intelligence stop failed: the CLI timed out".into()), + } + ); + assert_eq!( + stopped.into_result(), + Err("Compose down failed: compose refused\n\ + Intelligence stop failed: the CLI timed out" + .into()) + ); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); + } + + #[test] + fn quit_asks_intelligence_last_even_after_compose_down_failed() { + let active = local_eval_root("openbot-exit-intelligence-order", None); + let fallback = temp_root("openbot-default-exit-intelligence-order"); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + let phases = std::cell::RefCell::new(Vec::new()); + + let failures = exit_cleanup_then( + &shell, + &fallback, + |root| { + phases + .borrow_mut() + .push(format!("cleanup:{}", root.display())); + Ok(0) + }, + |root| { + phases.borrow_mut().push(format!("down:{}", root.display())); + Err("compose down refused".to_string()) + }, + |root| { + phases + .borrow_mut() + .push(format!("intelligence:{}", root.display())); + Err("the CLI timed out".to_string()) + }, + ); + + assert_eq!( + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()), + format!("intelligence:{}", active.display()), + ] + ); + assert_eq!( + failures, + Stopped { + openbot: vec!["Compose down failed: compose down refused".into()], + intelligence: Some("Intelligence stop failed: the CLI timed out".into()), + } + ); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); + } + + #[test] + fn quit_shows_stopping_openbot_then_intelligence_only_for_a_stack_openbot_created() { + for ownership in [ + Some(local_eval_state::Ownership::Created), + Some(local_eval_state::Ownership::Shared), + None, ] { - let root = temp_root(&format!("openbot-present-saved-{provider}")); - std::fs::create_dir_all(&root).unwrap(); + let root = local_eval_root("openbot-quit-progress-order", ownership); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(root.clone()); + let phases = std::cell::RefCell::new(Vec::new()); - let credential = start_stack_credential_with( + let stopped = exit_cleanup_reporting( + &shell, &root, - ChosenModel { - provider: provider.to_string(), - login: "api-key".to_string(), - api_key: None, - base_url: None, - container_base_url: None, - model: None, - token: None, - saved: Some(true), + |_| { + phases.borrow_mut().push("cleanup".to_string()); + Ok(0) }, - |_, key| { - assert_eq!(key, saved_api_key(provider)); - Ok(expected_key.to_string()) + |_| { + phases.borrow_mut().push("down".to_string()); + Ok(()) }, - ) - .expect("saved key should be accepted"); + |_| { + phases.borrow_mut().push("intelligence".to_string()); + Ok(()) + }, + |step, state| { + phases.borrow_mut().push(format!("{}:{state:?}", step.id())); + }, + ); - match credential { - openbot_env::ModelCredential::OpenAi { api_key } - | openbot_env::ModelCredential::Anthropic { api_key } => { - assert_eq!(api_key, expected_key); - println!( - "DTA-004 present provider={provider} saved_key_len={}", - api_key.len() - ); - } - other => panic!("unexpected credential: {other:?}"), + assert_eq!(stopped, Stopped::default(), "{ownership:?}"); + let mut expected = vec![ + "stop-openbot:Running", + "cleanup", + "down", + "stop-openbot:Complete", + ]; + if ownership == Some(local_eval_state::Ownership::Created) { + expected.extend([ + "stop-intelligence:Running", + "intelligence", + "stop-intelligence:Complete", + ]); + } else { + // Still asked, so calls in flight are cancelled; there is no stack to show. + expected.push("intelligence"); } + assert_eq!(phases.into_inner(), expected, "{ownership:?}"); let _ = std::fs::remove_dir_all(root); } } - fn saved_api_key(provider: &str) -> &'static str { - match provider { - "openai" => "OPENAI_API_KEY", - "anthropic" => "ANTHROPIC_API_KEY", - other => panic!("unexpected provider: {other}"), - } - } + #[test] + fn quit_marks_failed_rows_and_still_records_each_failure_in_the_notice() { + let root = local_eval_root( + "openbot-quit-progress-failures", + Some(local_eval_state::Ownership::Created), + ); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(root.clone()); + let phases = std::cell::RefCell::new(Vec::new()); - fn compatible_choice(base_url: Option<&str>, model: Option<&str>) -> ChosenModel { - ChosenModel { - provider: "openai-compatible".into(), - login: "endpoint".into(), - api_key: None, - base_url: base_url.map(String::from), - container_base_url: None, - model: model.map(String::from), - token: None, - saved: None, - } + let stopped = exit_cleanup_reporting( + &shell, + &root, + |_| Ok(0), + |_| Err("compose down refused".to_string()), + |_| Err("the CLI timed out".to_string()), + |step, state| { + phases.borrow_mut().push(format!("{}:{state:?}", step.id())); + }, + ); + + assert_eq!( + phases.into_inner(), + [ + "stop-openbot:Running", + "stop-openbot:Failed", + "stop-intelligence:Running", + "stop-intelligence:Failed", + ] + ); + // Unchanged by the screen: the two failures are kept apart. + assert_eq!( + stopped, + Stopped { + openbot: vec!["Compose down failed: compose down refused".into()], + intelligence: Some("Intelligence stop failed: the CLI timed out".into()), + } + ); + report_exit_cleanup_failures(stopped.lines(), |lines| { + write_quit_cleanup_notice(&root, &lines) + }) + .unwrap(); + let openbot = read_quit_cleanup_notice(&root).unwrap().unwrap(); + assert_eq!(openbot.said, "OpenBot had trouble shutting down last time."); + assert!(openbot + .detail + .as_deref() + .unwrap() + .contains("containers stopped")); + assert!(take_local_eval_stop_notice(&root).is_some()); + let _ = std::fs::remove_dir_all(root); } - fn persist_endpoint_fixture(root: &Path, credential: &openbot_env::ModelCredential) { - let settings = openbot_env::compose( - &openbot_env::Intelligence { - api_url: "https://api.example.test".into(), - gateway_ws_url: "wss://api.example.test".into(), - api_key: "synthetic-intelligence".into(), - }, - &openbot_env::Model { - credential: credential.clone(), - }, - &engine::EngineStatus { - engine: None, - address: None, - responding: false, - engine_socket: None, - detail: "synthetic".into(), + #[test] + fn a_failure_with_no_stack_recorded_still_gets_a_failed_row() { + let root = local_eval_root("openbot-quit-progress-untied-failure", None); + let shell = Shell::default(); + let phases = std::cell::RefCell::new(Vec::new()); + exit_cleanup_reporting( + &shell, + &root, + |_| Ok(0), + |_| Ok(()), + |_| Err("the CLI timed out".to_string()), + |step, state| { + phases.borrow_mut().push(format!("{}:{state:?}", step.id())); }, - &openbot_env::Ports::default(), - &[], - None, - &Default::default(), ); - let (public, secrets) = openbot_desktop_lib::vault::split(settings); - openbot_desktop_lib::saved_intent::persist_configuration( - root, &public, &secrets, &secrets, credential, - ) - .unwrap(); + // The screen never says everything stopped when something did not. + assert_eq!( + phases.into_inner(), + [ + "stop-openbot:Running", + "stop-openbot:Complete", + "stop-intelligence:Failed", + ] + ); + let _ = std::fs::remove_dir_all(root); } #[test] - fn saved_compatible_endpoint_roundtrips_public_settings_and_scoped_key() { - let root = temp_root("compatible-roundtrip"); - std::fs::create_dir_all(&root).unwrap(); - let mut chosen = compatible_choice(Some("https://models.example/v1"), Some("local-model")); - chosen.api_key = Some("synthetic-endpoint-key".into()); - let credential = chosen.into_credential(&root).unwrap(); - persist_endpoint_fixture(&root, &credential); - let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + fn quit_status_keeps_one_row_per_step_with_fixed_words_and_a_rising_sequence() { + let mut status = QuitStatus::default(); + status.begin(); + let started = status.seq; + status.record(QuitStep::OpenBot, QuitStepState::Running); + status.record(QuitStep::OpenBot, QuitStepState::Failed); + status.record(QuitStep::Intelligence, QuitStepState::Running); + assert!(status.seq > started); assert_eq!( - configured.values.get("BOT_MODEL").map(String::as_str), - Some("local-model") + serde_json::to_value(&status).unwrap()["steps"], + serde_json::json!([ + {"step": "stop-openbot", "ok": false, "detail": "", "running": false}, + {"step": "stop-intelligence", "ok": true, "detail": "", "running": true}, + ]) ); - assert_eq!(configured.saved.model_api_keys.compatible, Some(true)); - assert_eq!(configured.saved.model_api_keys.openai, None); - assert!(!serde_json::to_string(&configured) - .unwrap() - .contains("synthetic-endpoint-key")); - let mut reopened = compatible_choice( - configured.values.get("OPENAI_BASE_URL").map(String::as_str), - configured.values.get("BOT_MODEL").map(String::as_str), + let seq = status.seq; + status.begin(); + assert!( + status.seq > seq, + "a second Quit never looks older than the first" ); - reopened.saved = Some(true); - assert_eq!(reopened.into_credential(&root).unwrap(), credential); - - for url in [ - "https://other.example/v1", - "https://models.example/v2", - "https://models.example:8443/v1", - ] { - let mut changed = compatible_choice(Some(url), Some("local-model")); - changed.saved = Some(true); - assert!(changed - .into_credential_with(&root, |_, _| panic!( - "different endpoint must not read a credential" - )) - .is_err()); - } - let other = root.join("other-root"); - let mut changed_root = - compatible_choice(Some("https://models.example/v1"), Some("local-model")); - changed_root.saved = Some(true); - assert!(changed_root - .into_credential_with(&other, |_, _| panic!( - "different root must not read a credential" - )) - .is_err()); - std::fs::remove_dir_all(root).unwrap(); + assert!(status.steps.is_empty()); } #[test] - fn stale_endpoint_hint_cannot_relabel_another_endpoints_stored_key() { - let root = temp_root("compatible-stale-record"); - std::fs::create_dir_all(&root).unwrap(); - let credential = openbot_env::ModelCredential::Compatible { - base_url: "https://models.example/v1".into(), - container_base_url: None, - api_key: "synthetic-old-key".into(), - model: "model".into(), - }; - persist_endpoint_fixture(&root, &credential); - let mut choice = compatible_choice(Some("https://models.example/v1"), Some("model")); - choice.saved = Some(true); - let mut reads = 0; - let error = choice - .into_credential_with(&root, |_, key| { - reads += 1; + fn quit_puts_the_shutting_down_screen_in_front_and_exits_with_or_without_a_window() { + use std::sync::{Arc, Mutex}; + use tauri::Listener; + // Quit closes local evaluation for the rest of the process. + if test_support::isolated_process( + "tests::quit_puts_the_shutting_down_screen_in_front_and_exits_with_or_without_a_window", + ) { + return; + } + for (window_at, listening) in [ + (None, false), + (Some("http://127.0.0.1:3010/channel/existing"), true), + (Some("http://tauri.localhost/"), true), + (Some("http://127.0.0.1:3010/"), false), + ] { + let root = local_eval_root( + "openbot-quit-shutting-down", + Some(local_eval_state::Ownership::Created), + ); + // Nothing for Compose to take down; this build has no CLI, so Intelligence fails. + std::fs::remove_file(root.join("docker-compose.yml")).unwrap(); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(root.clone()); + *shell.setup_url.lock().unwrap() = Some("http://tauri.localhost/".into()); + let quit = Arc::clone(&shell.quit); + let app = tauri::test::mock_builder() + .manage(shell) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let window = window_at.map(|url| { + let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap(); + window.navigate(url.parse().unwrap()).unwrap(); + window.hide().unwrap(); + window + }); + let seen = Arc::new(Mutex::new(Vec::new())); + if listening { + let seen = Arc::clone(&seen); + app.listen_any(QUIT_PROGRESS_EVENT, move |event| { + let status: serde_json::Value = serde_json::from_str(event.payload()).unwrap(); + let rows = status["steps"] + .as_array() + .unwrap() + .iter() + .map(|row| { + format!( + "{}:{}", + row["step"].as_str().unwrap(), + if row["running"] == true { + "running" + } else if row["ok"] == true { + "complete" + } else { + "failed" + } + ) + }) + .collect::>() + .join(","); + seen.lock().unwrap().push(rows); + }); + } + let exited = Arc::new(Mutex::new(None)); + let cleaning = app.handle().clone(); + let noticed = root.clone(); + let exiting = Arc::clone(&exited); + request_quit_with( + quit, + Some(0), + || {}, + move || quit_cleanup_shown(&cleaning), + QuitDiagnostics { + sink: move |lines: Vec| write_quit_cleanup_notice(¬iced, &lines), + failed: |error: String| panic!("the notice was not written: {error}"), + }, + move |code| *exiting.lock().unwrap() = Some(code), + |work| { + work(); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(*exited.lock().unwrap(), Some(0), "{window_at:?}"); + // The Intelligence failure is in its own notice, as before the screen existed, and + // OpenBot's is not written for it. + assert!(!quit_cleanup_notice_path(&root).exists(), "{window_at:?}"); + assert!( + take_local_eval_stop_notice(&root).is_some(), + "{window_at:?}" + ); + if listening { assert_eq!( - key, - openbot_desktop_lib::saved_intent::COMPATIBLE_CREDENTIAL + *seen.lock().unwrap(), + [ + "stop-openbot:running", + "stop-openbot:complete", + "stop-openbot:complete,stop-intelligence:running", + "stop-openbot:complete,stop-intelligence:failed", + ], + "{window_at:?}" ); - Ok( - r#"{"base_url":"https://other.example/v1","api_key":"synthetic-other-key"}"# - .into(), - ) - }) - .unwrap_err(); - assert_eq!(reads, 1); - assert!(!error.said.contains("synthetic-other-key")); - std::fs::remove_dir_all(root).unwrap(); + } + if let Some(window) = window { + assert!(window.is_visible().unwrap(), "{window_at:?}"); + let expected = if window_at == Some("http://tauri.localhost/") { + // Already the setup page: its own listener shows the screen, no reload. + "http://tauri.localhost/" + } else { + "http://tauri.localhost/#shutting-down" + }; + assert_eq!(window.url().unwrap().as_str(), expected, "{window_at:?}"); + } + let status = quit_status(app.handle().clone()).expect("the page can catch up"); + assert_eq!(status.steps.len(), 2); + let _ = std::fs::remove_dir_all(root); + } } #[test] - fn compatible_endpoint_accepts_trimmed_container_url_and_rejects_invalid_one() { - let mut choice = - compatible_choice(Some(" http://127.0.0.1:11434/v1 "), Some(" qwen3-vl:2b ")); - choice.container_base_url = Some(" http://ollama:11434/v1 ".into()); - let credential = - start_stack_credential(Path::new("synthetic-unused-compatible-root"), choice) - .expect("a valid container endpoint may be stored with the compatible credential"); - let openbot_env::ModelCredential::Compatible { - base_url, - container_base_url, - model, - .. - } = credential - else { - panic!("the endpoint must retain its compatible credential"); - }; - assert_eq!(base_url, "http://127.0.0.1:11434/v1"); + fn an_intelligence_failure_is_reported_but_does_not_keep_openbot_in_recovery() { + let active = local_eval_root("openbot-stop-intelligence-recovery", None); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + mark_recovery_required(&shell, &active, 0); + + let stopped = stop_everything_then( + &shell, + &active, + |_| Ok(0), + |_| Ok(()), + |_| Err("the CLI timed out".to_string()), + ); + + assert!(stopped.openbot.is_empty(), "{stopped:?}"); assert_eq!( - container_base_url.as_deref(), - Some("http://ollama:11434/v1") + stopped.into_result(), + Err("Intelligence stop failed: the CLI timed out".into()), + "an Intelligence failure is not a silent success" ); - assert_eq!(model, "qwen3-vl:2b"); + assert!(!recovery_required(&shell, &active)); - let mut invalid = compatible_choice(Some("http://127.0.0.1:11434/v1"), Some("qwen3-vl:2b")); - invalid.container_base_url = Some("ollama:11434/v1".into()); - let problem = - start_stack_credential(Path::new("synthetic-unused-compatible-root"), invalid) - .expect_err("a container endpoint URL must be an absolute HTTP(S) URL"); + *shell.root.lock().unwrap() = Some(active.clone()); + mark_recovery_required( + &shell, + &active, + shell.generation.load(std::sync::atomic::Ordering::SeqCst), + ); + let failures = exit_cleanup_then( + &shell, + &active, + |_| Ok(0), + |_| Ok(()), + |_| Err("the CLI timed out".to_string()), + ); assert_eq!( - problem.said, - "Enter a valid http:// or https:// address for the container model endpoint." + failures.lines(), + ["Intelligence stop failed: the CLI timed out"] ); + assert!(!recovery_required(&shell, &active)); + let _ = std::fs::remove_dir_all(active); } #[test] - fn compatible_endpoint_rejects_missing_or_invalid_http_url() { - for base_url in [ - None, - Some(""), - Some(" \t\n "), - Some("ftp://localhost/v1"), - Some("file:///tmp/model"), - Some("httpx://localhost/v1"), - Some("localhost:11434/v1"), - Some("http://"), - Some("https://?query"), - Some("http://[invalid]/v1"), - ] { - let problem = start_stack_credential( - Path::new("synthetic-unused-compatible-root"), - compatible_choice(base_url, Some("local-model")), - ) - .expect_err("a missing or invalid endpoint URL must stop setup"); + fn a_stack_openbot_did_not_create_is_never_stopped() { + for ownership in [None, Some(local_eval_state::Ownership::Shared)] { + let root = local_eval_root("openbot-local-eval-untied", ownership); + let phases = std::cell::RefCell::new(Vec::new()); + + let result = stop_local_eval_with( + &root, + || phases.borrow_mut().push("cancel-all".to_string()), + |stack| { + phases.borrow_mut().push(format!("stop:{}", stack.as_str())); + Ok(local_eval_report(local_eval::Outcome::Completed, None)) + }, + ); + + assert_eq!(result, Ok(()), "{ownership:?}"); + // Its own calls are cancelled; the stack is not stopped. + assert_eq!(phases.into_inner(), ["cancel-all"], "{ownership:?}"); + // The production adapter too: had it tried, this build's missing CLI would have failed. assert_eq!( - problem.said, "Enter a valid http:// or https:// address for your model endpoint.", - "base_url={base_url:?}" + stop_local_eval(&root, local_eval::Limits::for_quit()), + Ok(()), + "{ownership:?}" ); + let _ = std::fs::remove_dir_all(root); } } #[test] - fn compatible_endpoint_rejects_missing_or_blank_model() { - for model in [None, Some(""), Some(" \t\n ")] { - let problem = start_stack_credential( - Path::new("synthetic-unused-compatible-root"), - compatible_choice(Some("http://127.0.0.1:11434/v1"), model), - ) - .expect_err("a missing model name must stop setup"); - assert_eq!(problem.said, "Enter the model name your endpoint serves."); - } + fn a_stack_openbot_created_is_stopped_after_cancelling_calls_in_flight() { + let root = local_eval_root( + "openbot-local-eval-created", + Some(local_eval_state::Ownership::Created), + ); + let phases = std::cell::RefCell::new(Vec::new()); + + let result = stop_local_eval_with( + &root, + || phases.borrow_mut().push("cancel-all".to_string()), + |stack| { + phases.borrow_mut().push(format!("stop:{}", stack.as_str())); + Ok(local_eval_report(local_eval::Outcome::Completed, None)) + }, + ); + + assert_eq!(result, Ok(())); + assert_eq!(phases.into_inner(), ["cancel-all", "stop:local-abc123"]); + let _ = std::fs::remove_dir_all(root); } #[test] - fn compatible_endpoint_accepts_trimmed_http_urls_and_optional_keys() { - for base_url in [ - "http://127.0.0.1:11434/v1", - "https://models.example.invalid/v1", - ] { - for api_key in [None, Some(" \t "), Some(" synthetic-endpoint-key ")] { - let mut choice = - compatible_choice(Some(&format!(" {base_url} ")), Some(" local-model ")); - choice.api_key = api_key.map(String::from); - let credential = - start_stack_credential(Path::new("synthetic-unused-compatible-root"), choice) - .expect("a valid endpoint may run without an API key"); - let openbot_env::ModelCredential::Compatible { - base_url: actual_url, - api_key: actual_key, - model, - .. - } = credential - else { - panic!("the endpoint must retain its compatible credential"); - }; - assert_eq!(actual_url, base_url); - assert_eq!(model, "local-model"); - assert_eq!(actual_key, api_key.unwrap_or_default().trim()); - } - } + fn every_way_a_stop_can_fail_is_reported_in_fixed_words() { + let root = local_eval_root( + "openbot-local-eval-stop-failures", + Some(local_eval_state::Ownership::Created), + ); + let failed = |result: Result| { + stop_local_eval_with(&root, || {}, move |_| result) + .expect_err("a stop that did not complete is reported") + }; + + assert_eq!( + failed(Err(local_eval::Failure::Unavailable)), + "this build has no Intelligence CLI to stop the local Intelligence stack OpenBot created" + ); + assert_eq!( + failed(Err(local_eval::Failure::Timeout)), + "the Intelligence CLI did not stop the local Intelligence stack (timeout)" + ); + assert_eq!( + failed(Ok(local_eval_report( + local_eval::Outcome::Blocked, + Some(local_eval::Reason::LocalCommandBusy) + ))), + "the Intelligence CLI did not stop the local Intelligence stack (blocked: local-command-busy)" + ); + assert_eq!( + failed(Ok(local_eval_report(local_eval::Outcome::Failed, None))), + "the Intelligence CLI did not stop the local Intelligence stack (failed)" + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn quit_reports_a_created_stack_this_build_cannot_stop() { + let root = local_eval_root( + "openbot-local-eval-quit-no-cli", + Some(local_eval_state::Ownership::Created), + ); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(root.clone()); + + let failures = exit_cleanup_then( + &shell, + &root, + |_| Ok(0), + |_| Err("compose down refused".to_string()), + |root| stop_local_eval(root, local_eval::Limits::for_quit()), + ); + + assert_eq!( + failures.lines(), + [ + "Compose down failed: compose down refused", + "Intelligence stop failed: this build has no Intelligence CLI to stop the local \ + Intelligence stack OpenBot created" + ] + ); + let _ = std::fs::remove_dir_all(root); + } + + const NO_CLI_LINE: &str = "Intelligence stop failed: this build has no Intelligence CLI to \ + stop the local Intelligence stack OpenBot created"; + + #[test] + fn an_intelligence_failure_on_quit_is_shown_next_launch_without_recovery() { + let root = local_eval_root("openbot-quit-intelligence-notice", None); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(root.clone()); + let failures = exit_cleanup_then( + &shell, + &root, + |_| Ok(0), + |_| Ok(()), + |_| Err("the CLI timed out".to_string()), + ); + // The quit sink, exactly as production wires it. + report_exit_cleanup_failures(failures.lines(), |lines| { + write_quit_cleanup_notice(&root, &lines) + }) + .unwrap(); + + // Next launch: nothing about OpenBot's own processes, and nothing holding its start. + let next = Shell::default(); + assert!(!quit_cleanup_notice_path(&root).exists()); + assert!(!recovery_required_or_pending_quit_notice(&next, &root)); + assert!(!recovery_required(&next, &root)); + assert_eq!(read_quit_cleanup_notice(&root).unwrap(), None); + let problem = + take_local_eval_stop_notice(&root).expect("the Intelligence failure is still shown"); + assert!(problem.said.contains("Intelligence"), "{problem:?}"); + assert!(!problem.said.contains("app processes"), "{problem:?}"); + assert_eq!(take_local_eval_stop_notice(&root), None, "shown once"); + let _ = std::fs::remove_dir_all(root); } #[test] - fn linux_first_install_redetects_native_podman_without_a_virtual_machine() { - let missing = engine::EngineStatus { - engine: None, - address: None, - responding: false, - engine_socket: None, - detail: "No container engine yet.".into(), - }; - let trace = std::cell::RefCell::new(Vec::new()); - let ready = ready_responding_engine_after_compose_repair( - missing, - true, - || { - trace.borrow_mut().push("install"); - Ok("Podman and Compose installed".into()) - }, - || { - trace.borrow_mut().push("detect"); - engine::EngineStatus { - engine: Some(engine::Engine::Podman), - address: Some(engine::Address::new(engine::Engine::Podman, None)), - responding: true, - engine_socket: None, - detail: "native Podman answers".into(), - } - }, - |_| { - trace.borrow_mut().push("compose"); - true - }, + fn a_quit_notice_keeps_openbot_and_intelligence_failures_apart() { + let root = local_eval_root("openbot-quit-mixed-notice", None); + write_quit_cleanup_notice( + &root, + &[ + "[exit] cleanup failed: Compose down failed: refused".to_string(), + format!("[exit] cleanup failed: {NO_CLI_LINE}"), + ], ) - .unwrap() - .expect("Linux must return its newly installed native engine, not request a VM"); - assert_eq!(ready.address.engine, engine::Engine::Podman); - assert!(ready.address.connection.is_none()); - assert_eq!(*trace.borrow(), ["install", "detect", "compose"]); + .unwrap(); + + let next = Shell::default(); + assert!(recovery_required_or_pending_quit_notice(&next, &root)); + let openbot = read_quit_cleanup_notice(&root).unwrap().unwrap(); + assert_eq!( + openbot.detail.as_deref(), + Some("OpenBot could not confirm all containers stopped.") + ); + assert!(take_local_eval_stop_notice(&root).is_some()); + let _ = std::fs::remove_dir_all(root); } #[test] - fn linux_failed_native_engine_is_an_error_instead_of_a_request_for_qemu() { - let stopped = engine::EngineStatus { - engine: Some(engine::Engine::Podman), - address: None, - responding: false, - engine_socket: None, - detail: "podman native operation failed".into(), + fn menu_stop_marks_recovery_only_for_openbot_failures() { + let root = local_eval_root("openbot-menu-stop-intelligence", None); + let shell = Shell::default(); + + let intelligence_only = Stopped { + openbot: Vec::new(), + intelligence: Some(NO_CLI_LINE.into()), }; - let result = ready_responding_engine_after_compose_repair( - stopped.clone(), - true, - || Ok("installed".into()), - || stopped.clone(), - |_| panic!("a failed engine cannot run Compose"), + let problem = settle_menu_stop(&shell, &root, intelligence_only) + .expect("an Intelligence failure is still said"); + assert!(problem.said.contains("Intelligence"), "{problem:?}"); + assert_eq!(problem.detail.as_deref(), Some(NO_CLI_LINE)); + assert!(!recovery_required(&shell, &root)); + assert_eq!(shell.last_failure.lock().unwrap().as_ref(), Some(&problem)); + + let both = Stopped { + openbot: vec!["Compose down failed: refused".into()], + intelligence: Some(NO_CLI_LINE.into()), + }; + let problem = settle_menu_stop(&shell, &root, both).unwrap(); + assert_eq!( + problem.said, + "OpenBot could not finish stopping. Try Stop OpenBot again." ); - assert!( - result.is_err(), - "Ok(None) would enter the virtual-machine path" + assert_eq!( + problem.detail.as_deref(), + Some(format!("Compose down failed: refused\n{NO_CLI_LINE}").as_str()) ); + assert!(recovery_required(&shell, &root)); + assert_eq!( - result.err().unwrap().detail.as_deref(), - Some("podman native operation failed") + settle_menu_stop(&Shell::default(), &root, Stopped::default()), + None ); + let _ = std::fs::remove_dir_all(root); } #[test] - fn responding_engine_without_compose_installs_then_redetects_before_returning() { - let before = engine::EngineStatus { - engine: Some(engine::Engine::Podman), - address: Some(engine::Address::new(engine::Engine::Podman, None)), - responding: true, - engine_socket: None, - detail: "podman is answering.".into(), + fn quit_does_not_wait_behind_a_stop_asking_intelligence_to_stop() { + let root = local_eval_root("openbot-quit-behind-stop", None); + let shell = std::sync::Arc::new(Shell::default()); + *shell.root.lock().unwrap() = Some(root.clone()); + let (asking, asked) = std::sync::mpsc::channel(); + let (release, released) = std::sync::mpsc::channel::<()>(); + let stopping = { + let (shell, root) = (shell.clone(), root.clone()); + std::thread::spawn(move || { + stop_everything_then( + &shell, + &root, + |_| Ok(0), + |_| Ok(()), + |_| { + asking.send(()).unwrap(); + released.recv().unwrap(); + Ok(()) + }, + ) + }) }; - let after = engine::EngineStatus { - engine: Some(engine::Engine::Podman), - address: Some(engine::Address::new( - engine::Engine::Podman, - Some("openbot".into()), - )), - responding: true, - engine_socket: None, - detail: "podman is answering on openbot.".into(), + asked.recv().unwrap(); + let quitting = { + let (shell, root) = (shell.clone(), root.clone()); + std::thread::spawn(move || { + exit_cleanup_then(&shell, &root, |_| Ok(0), |_| Ok(()), |_| Ok(())) + }) }; - let trace = std::cell::RefCell::new(Vec::new()); - let mut compose_checks = 0; + let until = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !quitting.is_finished() { + assert!( + std::time::Instant::now() < until, + "Quit waited behind Stop's Intelligence step" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + release.send(()).unwrap(); + assert_eq!(quitting.join().unwrap(), Stopped::default()); + assert_eq!(stopping.join().unwrap(), Stopped::default()); + let _ = std::fs::remove_dir_all(root); + } - let ready = ready_responding_engine_after_compose_repair( - before, - false, - || { - trace.borrow_mut().push("install-engine".to_string()); - Ok("Compose installed.".into()) + #[test] + fn stop_closes_local_eval_before_anything_else_and_reopens_after() { + // Closing is process-wide, and Quit's never reopens. + if test_support::isolated_process( + "tests::stop_closes_local_eval_before_anything_else_and_reopens_after", + ) { + return; + } + let root = local_eval_root("openbot-stop-closes-local-eval", None); + let shell = Shell::default(); + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + let refused = |when: &str| { + assert_eq!( + local_eval::run_installed(when, local_eval::Action::Start, &stack), + Err(local_eval::Failure::Cancelled), + "{when}" + ); + }; + let stopped = stop_everything_then( + &shell, + &root, + |_| { + refused("during-cleanup"); + Ok(0) }, - || { - trace.borrow_mut().push("re-detect".to_string()); - after.clone() + |_| Ok(()), + |_| { + refused("during-intelligence"); + Ok(()) }, + ); + assert_eq!(stopped, Stopped::default()); + assert_eq!( + local_eval::run_installed("after-stop", local_eval::Action::Status, &stack), + Err(local_eval::Failure::Unavailable) + ); + + exit_cleanup_then( + &shell, + &root, |_| { - compose_checks += 1; - compose_checks > 1 + refused("during-quit-cleanup"); + Ok(0) }, - ) - .expect("missing Compose should be repaired") - .expect("responding engine should be returned"); + |_| Ok(()), + |_| Ok(()), + ); + refused("after-quit"); + let _ = std::fs::remove_dir_all(root); + } - assert_eq!(&*trace.borrow(), &["install-engine", "re-detect"]); - assert_eq!(ready.installed.as_deref(), Some("Compose installed.")); - assert_eq!(ready.address.connection.as_deref(), Some("openbot")); + #[test] + fn stop_and_quit_choose_their_own_limits_for_the_real_adapter() { + assert_eq!( + intelligence_limits(Shutdown::Stop).timeout, + local_eval::Limits::for_action(local_eval::Action::Stop).timeout + ); + assert_eq!( + intelligence_limits(Shutdown::Quit).timeout, + local_eval::Limits::for_quit().timeout + ); + let root = local_eval_root( + "openbot-intelligence-step", + Some(local_eval_state::Ownership::Created), + ); + for kind in [Shutdown::Stop, Shutdown::Quit] { + assert_eq!( + intelligence_step(kind)(&root), + Err(NO_CLI_LINE + .strip_prefix(INTELLIGENCE_STOP_FAILED) + .unwrap() + .to_owned()), + "{kind:?}" + ); + assert_eq!( + last_intelligence_timeout(), + Some(intelligence_limits(kind).timeout), + "{kind:?}" + ); + } + let _ = std::fs::remove_dir_all(root); } #[test] - fn disposable_provider_fixture_repairs_missing_compose_at_process_boundary() { - if crate::test_support::isolated_process( - "tests::disposable_provider_fixture_repairs_missing_compose_at_process_boundary", + fn production_stop_and_quit_ask_intelligence_with_their_own_limits() { + // Quit closes local evaluation for the rest of the process. + if test_support::isolated_process( + "tests::production_stop_and_quit_ask_intelligence_with_their_own_limits", ) { return; } - let path = SerializedPath::set_only_with("podman", "podman"); - let address = engine::Address::new(engine::Engine::Podman, None); - assert!(address.responds(), "fake podman must answer before repair"); - assert!( - !address.composes(), - "fake podman must start without a compose provider" + let root = local_eval_root( + "openbot-production-intelligence-wiring", + Some(local_eval_state::Ownership::Created), ); - let mut installed = false; - let mut detections = 0; + std::fs::remove_file(root.join("docker-compose.yml")).unwrap(); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(root.clone()); - let ready = ready_responding_engine_after_compose_repair( - engine::EngineStatus { - engine: Some(engine::Engine::Podman), - address: Some(address.clone()), - responding: address.responds(), - engine_socket: None, - detail: "podman is answering.".into(), - }, - false, - || { - path.write_binary(install::compose_provider_name(), "compose-provider"); - installed = true; - Ok("Compose installed into disposable PATH.".into()) - }, - || { - detections += 1; - engine::EngineStatus { - engine: Some(engine::Engine::Podman), - address: Some(address.clone()), - responding: address.responds(), - engine_socket: None, - detail: "podman is answering after disposable provider install.".into(), - } - }, - engine::Address::composes, - ) - .expect("disposable provider should repair Compose") - .expect("responding fake podman should be ready"); + let stopped = stop_everything_for(&shell, &root); + assert_eq!( + stopped, + Stopped { + openbot: Vec::new(), + intelligence: Some(NO_CLI_LINE.into()), + } + ); + assert_eq!( + last_intelligence_timeout(), + Some(local_eval::Limits::for_action(local_eval::Action::Stop).timeout) + ); - assert!(installed, "install path must run before readiness returns"); - assert_eq!(detections, 1, "readiness must re-detect after install"); - assert_eq!(ready.address.engine, engine::Engine::Podman); - assert!( - ready.address.composes(), - "the later start_stack compose gate should now pass" + *shell.root.lock().unwrap() = Some(root.clone()); + let quit = quit_cleanup(&shell, |_, _| {}); + assert_eq!(quit.lines(), [NO_CLI_LINE]); + assert_eq!( + last_intelligence_timeout(), + Some(local_eval::Limits::for_quit().timeout) ); - println!( - "DTA-007 functional proof: installed={installed} detections={detections} composes={}", - ready.address.composes() + let _ = std::fs::remove_dir_all(root); + } + + fn local_eval_completed(stack: &str) -> local_eval::Report { + let listing = serde_json::json!({ + "version": 1, + "command": "local list", + "status": "completed", + "stacks": [{ + "id": stack, + "installedRelease": "0.13.14", + "step": "ready", + "urls": { + "api": "http://localhost:64770", + "dashboard": "http://localhost:64771", + "gateway": "ws://localhost:64772" + } + }] + }); + let listed = local_eval::normalize_list(0, listing.to_string().as_bytes()).unwrap(); + local_eval::Report { + stack: listed.stacks.into_iter().next(), + ..local_eval_report(local_eval::Outcome::Completed, None) + } + } + + #[test] + fn a_completed_setup_records_the_stack_as_openbots() { + let root = local_eval_root("openbot-local-eval-setup-created", None); + let report = set_up_local_eval_at(&root, |folder| { + assert_eq!(folder, root.as_path(), "setup runs in the installation"); + Ok(( + local_eval_completed("local-67db320bed3f3e893a4f8122"), + LOCAL_EVAL_PIN.to_owned(), + )) + }) + .unwrap(); + assert_eq!(report.outcome, local_eval::Outcome::Completed); + let state = local_eval_state::read(&root, Some(LOCAL_EVAL_PIN)).unwrap(); + assert_eq!(state.stack, "local-67db320bed3f3e893a4f8122"); + assert!(state.tied_to_openbot()); + assert_eq!(state.phase, local_eval_state::Phase::Installed); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn a_setup_that_did_not_complete_records_nothing() { + let root = local_eval_root("openbot-local-eval-setup-blocked", None); + let blocked = local_eval_report( + local_eval::Outcome::Blocked, + Some(local_eval::Reason::EulaAcceptanceRequired), + ); + let report = + set_up_local_eval_at(&root, |_| Ok((blocked, LOCAL_EVAL_PIN.to_owned()))).unwrap(); + assert_eq!( + report.reason, + Some(local_eval::Reason::EulaAcceptanceRequired) ); + assert_eq!(local_eval_state::read(&root, None), None); + assert!(matches!( + set_up_local_eval_at(&root, |_| Err(local_eval::Failure::Timeout)), + Err(SetupError::Failure(local_eval::Failure::Timeout)) + )); + assert_eq!(local_eval_state::read(&root, None), None); + let _ = std::fs::remove_dir_all(root); } #[test] - fn stop_shutdown_uses_the_active_root_at_the_external_command_boundary() { - if crate::test_support::isolated_process( - "tests::stop_shutdown_uses_the_active_root_at_the_external_command_boundary", - ) { - return; - } - let _path = SerializedPath::set(); - let active = temp_root("openbot-active-stop-root"); - let fallback = temp_root("openbot-default-stop-root"); - std::fs::create_dir_all(&active).unwrap(); - std::fs::write(active.join("docker-compose.yml"), "services: {}\n").unwrap(); - std::fs::create_dir_all(&fallback).unwrap(); - let shell = Shell::default(); - *shell.root.lock().unwrap() = Some(active.clone()); - let selected = shutdown_root(&shell, &fallback); - - let record = temp_root("openbot-stop-record").join("commands.log"); - let engine = fake_engine(&record); - stack::down(&engine, &selected).expect("fake compose down"); - - assert_compose_down_ran_under(&record, &active); - assert!(shell.root.lock().unwrap().is_none()); - let _ = std::fs::remove_dir_all(active); - let _ = std::fs::remove_dir_all(fallback); + fn setting_up_again_keeps_a_stack_openbot_found_shared() { + let root = local_eval_root( + "openbot-local-eval-setup-shared", + Some(local_eval_state::Ownership::Shared), + ); + set_up_local_eval_at(&root, |_| { + Ok(( + local_eval_completed("local-abc123"), + LOCAL_EVAL_PIN.to_owned(), + )) + }) + .unwrap(); + assert!(!local_eval_state::read(&root, None) + .unwrap() + .tied_to_openbot()); + let _ = std::fs::remove_dir_all(root); } #[test] - fn quit_shutdown_uses_the_active_root_at_the_external_command_boundary() { - if crate::test_support::isolated_process( - "tests::quit_shutdown_uses_the_active_root_at_the_external_command_boundary", - ) { - return; - } - let _path = SerializedPath::set(); - let active = temp_root("openbot-active-quit-root"); - let fallback = temp_root("openbot-default-quit-root"); - std::fs::create_dir_all(&active).unwrap(); - std::fs::write(active.join("docker-compose.yml"), "services: {}\n").unwrap(); - std::fs::create_dir_all(&fallback).unwrap(); - let shell = Shell::default(); - *shell.root.lock().unwrap() = Some(active.clone()); - let selected = shutdown_root(&shell, &fallback); + fn a_stack_set_up_but_not_recorded_is_said() { + let parent = temp_root("openbot-local-eval-setup-unrecorded"); + std::fs::create_dir_all(&parent).unwrap(); + // A file where the installation folder should be: nothing can be written inside it. + let root = parent.join("not-a-folder"); + std::fs::write(&root, "").unwrap(); + let error = set_up_local_eval_at(&root, |_| { + Ok(( + local_eval_completed("local-abc123"), + LOCAL_EVAL_PIN.to_owned(), + )) + }) + .expect_err("an unrecorded stack would never be stopped with OpenBot"); + let SetupError::Unrecorded(problem) = error else { + panic!("{error:?}"); + }; + assert!(problem.said.contains("local evaluation"), "{problem:?}"); + let _ = std::fs::remove_dir_all(parent); + } - let record = temp_root("openbot-quit-record").join("commands.log"); - let engine = fake_engine(&record); - stack::down(&engine, &selected).expect("fake compose down"); + const LOCAL_KEY: &str = "cpk-synthetic-local-key"; - assert_compose_down_ran_under(&record, &active); - assert!(shell.root.lock().unwrap().is_none()); - let _ = std::fs::remove_dir_all(active); - let _ = std::fs::remove_dir_all(fallback); + /// What `local connect` leaves in an installation folder, after a line OpenBot wrote earlier. + fn local_eval_connected_env(root: &Path) { + std::fs::write( + root.join(".env"), + format!( + "SERVER_PORT=3001\nINTELLIGENCE_API_URL=http://localhost:64770\nINTELLIGENCE_GATEWAY_WS_URL=ws://localhost:64772\nCPK_INTELLIGENCE_API_KEY={LOCAL_KEY}\nSL_ENABLED=true\n" + ), + ) + .unwrap(); } - #[test] - fn stop_reports_cleanup_and_down_failures_after_using_the_active_root() { - let active = temp_root("openbot-active-stop-failures"); - let fallback = temp_root("openbot-default-stop-failures"); - std::fs::create_dir_all(&active).unwrap(); - std::fs::write(active.join("docker-compose.yml"), "services: {}\n").unwrap(); - std::fs::create_dir_all(&fallback).unwrap(); - let shell = Shell::default(); - *shell.root.lock().unwrap() = Some(active.clone()); - let phases = std::cell::RefCell::new(Vec::new()); + fn local_eval_connection( + outcome: local_eval::Outcome, + stack: &str, + reason: Option, + conflicted: bool, + ) -> local_eval::Connection { + local_eval::Connection { + outcome, + stack: local_eval_completed(stack).stack, + reason, + conflicted, + } + } - let problem = stop_everything_with( - &shell, - &fallback, - |root| { - phases - .borrow_mut() - .push(format!("cleanup:{}", root.display())); - Err(Problem::with( - "OpenBot could not inspect or stop its host processes.", - "lsof exited with status 2", - )) + type FakeVault = std::cell::RefCell>; + + fn connect_with_vault( + root: &Path, + vault: &FakeVault, + connection: Result, + ) -> Result { + connect_local_eval_at( + root, + |folder| { + assert_eq!(folder, root, "connect runs in the installation"); + connection }, - |root| { - phases.borrow_mut().push(format!("down:{}", root.display())); - Err("compose refused".to_string()) + |_, name, value| { + vault.borrow_mut().insert(name.into(), value.into()); + Ok(()) }, + |_, name| Ok(vault.borrow().get(name).cloned()), ) - .expect_err("Stop must surface both cleanup and Compose failures"); + } + #[test] + fn a_completed_connection_moves_its_key_into_the_vault_and_out_of_the_file() { + let root = local_eval_root( + "openbot-local-eval-connect-vault", + Some(local_eval_state::Ownership::Created), + ); + local_eval_connected_env(&root); + let vault = FakeVault::default(); + let connection = connect_with_vault( + &root, + &vault, + Ok(local_eval_connection( + local_eval::Outcome::Completed, + "local-abc123", + None, + false, + )), + ) + .unwrap(); + assert_eq!(connection.outcome, local_eval::Outcome::Completed); + // OpenBot's server reads INTELLIGENCE_API_KEY; the CLI names it for an app's runtime. assert_eq!( - phases.into_inner(), - vec![ - format!("cleanup:{}", active.display()), - format!("down:{}", active.display()) + vault + .borrow() + .get("INTELLIGENCE_API_KEY") + .map(String::as_str), + Some(LOCAL_KEY) + ); + assert_eq!(vault.borrow().len(), 1); + let env = std::fs::read_to_string(root.join(".env")).unwrap(); + assert!(!env.contains(LOCAL_KEY), "{env}"); + assert!(!env.contains("CPK_INTELLIGENCE_API_KEY"), "{env}"); + for line in [ + "INTELLIGENCE_API_URL=http://localhost:64770", + "INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:64772", + "SL_ENABLED=true", + "SERVER_PORT=3001", + ] { + assert_eq!( + env.lines().filter(|found| *found == line).count(), + 1, + "{env}" + ); + } + assert!(local_eval_state::read(&root, None).unwrap().connected); + assert!(!serde_json::to_string(&connection) + .unwrap() + .contains(LOCAL_KEY)); + + // Start then takes it from the vault exactly like a key pasted earlier. + let key = intelligence_key_for_start(&root, String::new(), |_, name| { + Ok(vault.borrow().get(name).cloned().unwrap_or_default()) + }) + .unwrap(); + assert_eq!(key, LOCAL_KEY); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn starting_after_a_connection_neither_clobbers_nor_duplicates_it() { + let root = local_eval_root( + "openbot-local-eval-connect-start-env", + Some(local_eval_state::Ownership::Created), + ); + local_eval_connected_env(&root); + let vault = FakeVault::default(); + connect_with_vault( + &root, + &vault, + Ok(local_eval_connection( + local_eval::Outcome::Completed, + "local-abc123", + None, + false, + )), + ) + .unwrap(); + // Start's own write: the same addresses as settings, its credentials purged to the vault. + let (settings, secrets) = openbot_desktop_lib::vault::split( + [ + ("INTELLIGENCE_API_URL", "http://localhost:64770"), + ("INTELLIGENCE_GATEWAY_WS_URL", "ws://localhost:64772"), + ("INTELLIGENCE_API_KEY", LOCAL_KEY), + ("SERVER_PORT", "3001"), ] + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect(), ); - assert!( - problem.contains("OpenBot could not inspect or stop its host processes."), - "{problem}" - ); - assert!(problem.contains("lsof exited with status 2"), "{problem}"); - assert!( - problem.contains("Compose down failed: compose refused"), - "{problem}" - ); - assert_eq!(shell.root.lock().unwrap().as_ref(), Some(&active)); - let _ = std::fs::remove_dir_all(active); - let _ = std::fs::remove_dir_all(fallback); + openbot_env::write(&root.join(".env"), &settings, &secrets).unwrap(); + let env = std::fs::read_to_string(root.join(".env")).unwrap(); + for key in [ + "INTELLIGENCE_API_URL=", + "INTELLIGENCE_GATEWAY_WS_URL=", + "SL_ENABLED=", + "SERVER_PORT=", + ] { + assert_eq!( + env.lines().filter(|line| line.starts_with(key)).count(), + 1, + "{env}" + ); + } + assert!(!env.contains(LOCAL_KEY), "{env}"); + let _ = std::fs::remove_dir_all(root); } #[test] - fn exit_cleanup_body_records_cleanup_and_down_failures_after_using_the_active_root() { - let active = temp_root("openbot-active-exit-failures"); - let fallback = temp_root("openbot-default-exit-failures"); - std::fs::create_dir_all(&active).unwrap(); - std::fs::write(active.join("docker-compose.yml"), "services: {}\n").unwrap(); - std::fs::create_dir_all(&fallback).unwrap(); - let shell = Shell::default(); - *shell.root.lock().unwrap() = Some(active.clone()); - let phases = std::cell::RefCell::new(Vec::new()); + fn a_connection_that_is_not_complete_takes_nothing() { + for (connection, label) in [ + ( + local_eval_connection( + local_eval::Outcome::Blocked, + "local-abc123", + Some(local_eval::Reason::ConfirmationRequired), + false, + ), + "confirmation", + ), + ( + local_eval_connection(local_eval::Outcome::Blocked, "local-abc123", None, true), + "conflict", + ), + ( + local_eval::Connection { + stack: None, + ..local_eval_connection( + local_eval::Outcome::Failed, + "local-abc123", + Some(local_eval::Reason::LocalCommandFailed), + false, + ) + }, + "failed", + ), + ] { + let root = local_eval_root( + &format!("openbot-local-eval-connect-{label}"), + Some(local_eval_state::Ownership::Created), + ); + local_eval_connected_env(&root); + let before = std::fs::read_to_string(root.join(".env")).unwrap(); + let vault = FakeVault::default(); + let answer = connect_with_vault(&root, &vault, Ok(connection.clone())).unwrap(); + assert_eq!(answer, connection, "{label}"); + assert!(vault.borrow().is_empty(), "{label}"); + assert_eq!(std::fs::read_to_string(root.join(".env")).unwrap(), before); + assert!(!local_eval_state::read(&root, None).unwrap().connected); + let _ = std::fs::remove_dir_all(root); + } + } - let failures = exit_cleanup_with( - &shell, - &fallback, - |root| { - phases - .borrow_mut() - .push(format!("cleanup:{}", root.display())); - Err(Problem::with( - "OpenBot could not inspect or stop its host processes.", - "taskkill exited with status 5", - )) - }, - |root| { - phases.borrow_mut().push(format!("down:{}", root.display())); - Err("compose down refused".to_string()) - }, + #[test] + fn a_connection_is_taken_only_for_the_recorded_stack_and_its_own_addresses() { + let completed = |stack: &str| { + Ok(local_eval_connection( + local_eval::Outcome::Completed, + stack, + None, + false, + )) + }; + // No recorded stack: the CLI is not asked at all. + let root = local_eval_root("openbot-local-eval-connect-unrecorded", None); + let vault = FakeVault::default(); + let error = connect_local_eval_at( + &root, + |_| panic!("connect ran without a recorded stack"), + |_, _, _| panic!("stored a key"), + |_, _| Ok(None), + ) + .unwrap_err(); + assert!(matches!(error, ConnectError::Unconnected(_)), "{error:?}"); + let _ = std::fs::remove_dir_all(root); + + // Another stack, or addresses that are not the stack's own: nothing is stored. + for (label, stack, env) in [ + ("other-stack", "local-other", None), + ( + "other-address", + "local-abc123", + Some(format!("INTELLIGENCE_API_URL=http://localhost:9999\nINTELLIGENCE_GATEWAY_WS_URL=ws://localhost:64772\nCPK_INTELLIGENCE_API_KEY={LOCAL_KEY}\n")), + ), + ( + "no-address", + "local-abc123", + Some(format!("CPK_INTELLIGENCE_API_KEY={LOCAL_KEY}\n")), + ), + ] { + let root = local_eval_root( + &format!("openbot-local-eval-connect-{label}"), + Some(local_eval_state::Ownership::Created), + ); + match env { + Some(env) => std::fs::write(root.join(".env"), env).unwrap(), + None => local_eval_connected_env(&root), + } + let error = connect_with_vault(&root, &vault, completed(stack)).unwrap_err(); + let ConnectError::Unconnected(problem) = error else { + panic!("{label}: {error:?}"); + }; + assert!(!format!("{problem:?}").contains(LOCAL_KEY), "{label}"); + assert!(vault.borrow().is_empty(), "{label}"); + assert!(!local_eval_state::read(&root, None).unwrap().connected); + let _ = std::fs::remove_dir_all(root); + } + + // A CLI failure is its own static category. + let root = local_eval_root( + "openbot-local-eval-connect-timeout", + Some(local_eval_state::Ownership::Created), ); + assert!(matches!( + connect_with_vault(&root, &vault, Err(local_eval::Failure::Timeout)), + Err(ConnectError::Failure(local_eval::Failure::Timeout)) + )); + let _ = std::fs::remove_dir_all(root); + } - assert_eq!( - phases.into_inner(), - vec![ - format!("cleanup:{}", active.display()), - format!("down:{}", active.display()) - ] + #[test] + fn connecting_again_uses_the_key_already_kept() { + let root = local_eval_root( + "openbot-local-eval-connect-again", + Some(local_eval_state::Ownership::Created), ); - assert_eq!(failures.len(), 2, "{failures:?}"); - assert!( - failures[0].contains("taskkill exited with status 5"), - "{failures:?}" + // The CLI's second connect changes nothing, and the file no longer holds the key. + std::fs::write( + root.join(".env"), + "INTELLIGENCE_API_URL=http://localhost:64770\nINTELLIGENCE_GATEWAY_WS_URL=ws://localhost:64772\n", + ) + .unwrap(); + let completed = || { + Ok(local_eval_connection( + local_eval::Outcome::Completed, + "local-abc123", + None, + false, + )) + }; + let vault = FakeVault::default(); + // Never connected: a key from somewhere else is not this stack's. + vault + .borrow_mut() + .insert("INTELLIGENCE_API_KEY".into(), "hosted-key".into()); + assert!(matches!( + connect_with_vault(&root, &vault, completed()), + Err(ConnectError::Unconnected(_)) + )); + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + local_eval_state::mark_connected(&root, &stack).unwrap(); + vault.borrow_mut().clear(); + assert!(matches!( + connect_with_vault(&root, &vault, completed()), + Err(ConnectError::Unconnected(_)) + )); + vault + .borrow_mut() + .insert("INTELLIGENCE_API_KEY".into(), LOCAL_KEY.into()); + assert_eq!( + connect_with_vault(&root, &vault, completed()) + .unwrap() + .outcome, + local_eval::Outcome::Completed ); - assert!( - failures[1].contains("Compose down failed: compose down refused"), - "{failures:?}" + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn already_configured_shows_the_local_evaluation_only_when_there_is_one() { + let root = local_eval_root("openbot-already-configured-local-eval", None); + let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + let json = serde_json::to_value(&configured).unwrap(); + assert!(json.get("localEval").is_none(), "{json}"); + + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + local_eval_state::begin( + &root, + &stack, + local_eval_state::Ownership::Created, + LOCAL_EVAL_PIN, + ) + .unwrap(); + let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + let json = serde_json::to_value(&configured).unwrap(); + assert_eq!( + json["localEval"], + serde_json::json!({ + "stack": "local-abc123", + "ownership": "created", + "phase": "installed", + "connected": false + }) ); - assert_eq!(shell.root.lock().unwrap().as_ref(), Some(&active)); - let _ = std::fs::remove_dir_all(active); - let _ = std::fs::remove_dir_all(fallback); + let _ = std::fs::remove_dir_all(root); } #[test] diff --git a/desktop/src-tauri/src/preparation.rs b/desktop/src-tauri/src/preparation.rs index 0747dae969..502af65741 100644 --- a/desktop/src-tauri/src/preparation.rs +++ b/desktop/src-tauri/src/preparation.rs @@ -12,6 +12,17 @@ pub const FILE: &str = ".openbot-prepared.json"; const DEPENDENCIES: &str = ".openbot-dependencies.json"; const LAUNCH: &str = ".openbot-launched.json"; const PACKAGES: [&str; 6] = ["", "server", "app", "worker", "shared", "toolkit"]; +/// Packages a release has only since #784. v0.2.0, still the latest release when this desktop was +/// built, has `shared` as plain source and no `toolkit`; requiring their manifests failed every new +/// install with "shared/package.json: No such file or directory". +const LATER_PACKAGES: [&str; 2] = ["shared", "toolkit"]; + +/// The packages this release actually has: every one in `PACKAGES`, less a later one it predates. +fn packages(root: &Path) -> impl Iterator + '_ { + PACKAGES.into_iter().filter(move |package| { + !LATER_PACKAGES.contains(package) || root.join(package).join("package.json").is_file() + }) +} #[derive(Serialize, Deserialize)] struct Dependencies { @@ -124,7 +135,7 @@ fn dependency_fingerprint(root: &Path) -> Result { hash_files( root, std::iter::once(PathBuf::from("bun.lock")) - .chain(PACKAGES.map(|path| Path::new(path).join("package.json"))), + .chain(packages(root).map(|path| Path::new(path).join("package.json"))), ) } @@ -141,17 +152,27 @@ fn fingerprint(root: &Path) -> Result { } fn write_record(root: &Path, name: &str, value: &impl Serialize) -> Result<(), Problem> { + write_checkpoint(root, name, value).map_err(required) +} + +/// Replace a checkpoint file through a temporary file. The error is the underlying cause, for the +/// caller to put beside its own sentence. +pub(crate) fn write_checkpoint( + root: &Path, + name: &str, + value: &impl Serialize, +) -> Result<(), String> { let target = root.join(name); let temporary = root.join(format!("{name}.tmp")); - let bytes = serde_json::to_vec(value).map_err(|e| required(e.to_string()))?; - std::fs::write(&temporary, bytes).map_err(|e| required(e.to_string()))?; + let bytes = serde_json::to_vec(value).map_err(|e| e.to_string())?; + std::fs::write(&temporary, bytes).map_err(|e| e.to_string())?; // Windows rename does not replace an existing file. A missing checkpoint is safe on interruption. match std::fs::remove_file(&target) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(required(e.to_string())), + Err(e) => return Err(e.to_string()), } - std::fs::rename(temporary, target).map_err(|e| required(e.to_string())) + std::fs::rename(temporary, target).map_err(|e| e.to_string()) } pub fn invalidate(root: &Path) -> Result<(), Problem> { @@ -165,7 +186,7 @@ pub fn invalidate(root: &Path) -> Result<(), Problem> { /// Record resolved package manifests, not just the existence of a partial node_modules directory. fn package_files(root: &Path) -> Result, Problem> { let mut files = BTreeSet::new(); - for package in PACKAGES { + for package in packages(root) { let directory = root.join(package); let manifest: serde_json::Value = serde_json::from_slice( &std::fs::read(directory.join("package.json")).map_err(|e| required(e.to_string()))?, @@ -354,6 +375,33 @@ pub fn image_settings( mod tests { use super::*; + #[test] + fn a_release_from_before_the_toolkit_needs_no_shared_or_toolkit_manifest() { + let root = crate::test_support::temp_root("preparation-v020-layout"); + for package in ["", "server", "app", "worker"] { + std::fs::create_dir_all(root.join(package)).unwrap(); + std::fs::write(root.join(package).join("package.json"), "{}").unwrap(); + } + // v0.2.0: `shared` is source with no manifest, and there is no `toolkit`. + std::fs::create_dir_all(root.join("shared")).unwrap(); + assert_eq!( + packages(&root).collect::>(), + ["", "server", "app", "worker"] + ); + std::fs::write(root.join("bun.lock"), "lock").unwrap(); + assert!(dependency_fingerprint(&root).is_ok()); + assert!(package_files(&root).is_ok()); + // A release that has them is held to them. + for package in ["shared", "toolkit"] { + std::fs::create_dir_all(root.join(package)).unwrap(); + std::fs::write(root.join(package).join("package.json"), "{}").unwrap(); + } + assert_eq!(packages(&root).count(), PACKAGES.len()); + // The packages every release has stay required. + std::fs::remove_file(root.join("server").join("package.json")).unwrap(); + assert!(dependency_fingerprint(&root).is_err()); + } + struct Fixture { root: PathBuf, bun: PathBuf, diff --git a/desktop/src-tauri/src/telemetry.rs b/desktop/src-tauri/src/telemetry.rs index 3a6d9b2fd8..282d244d87 100644 --- a/desktop/src-tauri/src/telemetry.rs +++ b/desktop/src-tauri/src/telemetry.rs @@ -687,6 +687,8 @@ pub enum Step { Welcome, Harness, Install, + /// The CopilotKit sign-in and evaluation agreement of a local first run. + Account, Model, Connect, Ask, @@ -761,6 +763,7 @@ pub enum WindowsStageOutcome { WslNoKernel, VirtualMachinePlatformDisabled, VirtualizationDisabled, + NestedVirtualizationUnavailable, NotAdministrator, CheckFailed, } @@ -804,7 +807,7 @@ pub fn schema_json() -> serde_json::Value { "model_chosen": { "provider": ["openai", "anthropic", "compatible", "none"], "credential_path": ["subscription", "api_key", "none"], "custom_base_url": "bool" }, "engine_detected": { "engine": ["docker", "podman", "none"], "responding": "bool" }, "engine_installed": { "engine": ["docker", "podman", "none"], "outcome": ["success", "failure"] }, - "windows_stage": { "outcome": ["ready", "wsl_absent", "wsl_one", "wsl_no_kernel", "virtual_machine_platform_disabled", "virtualization_disabled", "not_administrator", "check_failed"] }, + "windows_stage": { "outcome": ["ready", "wsl_absent", "wsl_one", "wsl_no_kernel", "virtual_machine_platform_disabled", "virtualization_disabled", "nested_virtualization_unavailable", "not_administrator", "check_failed"] }, "image_pull": { "outcome": ["success", "failure"], "duration_ms": "u64", "bytes": "option_u64" }, "setup_failed": { "step": ["welcome", "harness", "install", "model", "connect", "ask"], "error_class": ["engine_unavailable", "engine_install_failed", "image_pull_failed", "invalid_configuration", "network_unavailable", "permission_denied", "unknown"] }, "activated": {}, diff --git a/desktop/src-tauri/src/windows.rs b/desktop/src-tauri/src/windows.rs index d3886f3858..84306a30d4 100644 --- a/desktop/src-tauri/src/windows.rs +++ b/desktop/src-tauri/src/windows.rs @@ -54,6 +54,9 @@ pub enum Blocker { VirtualMachinePlatformDisabled, /// Virtualization is off in firmware. Only the person, in their BIOS, can fix this. VirtualizationDisabled, + /// This Windows is itself a virtual machine whose host does not pass virtualization through, + /// so WSL 2 cannot start one. Only the host can fix this. + NestedVirtualizationUnavailable, /// The account cannot elevate. NotAdministrator, } @@ -97,6 +100,12 @@ impl Blocker { there, which OpenBot cannot do: restart, open the firmware settings, and enable \ Intel VT-x or AMD-V." } + Blocker::NestedVirtualizationUnavailable => { + "This Windows runs in a virtual machine whose host does not pass virtualization \ + through to it, so Windows Subsystem for Linux cannot start. Turn on nested \ + virtualization for this virtual machine in its host, then start it and OpenBot \ + again." + } Blocker::NotAdministrator => { "Setting up Windows Subsystem for Linux needs administrator rights. Ask an \ administrator to open Windows Terminal or PowerShell as an administrator and run \ @@ -108,13 +117,9 @@ impl Blocker { } } - /// Whether the shell can clear this itself. Two of the four are ours; two are not. - /// Whether OpenBot could fix this itself, one day. - /// - /// Nothing acts on this yet. `wsl --install` needs elevation and a restart, and the resumable - /// state machine that would carry somebody across that reboot is designed and not built, so - /// every blocker screen currently tells a person what to run. Kept because the two halves are - /// genuinely different: WSL is installable and a firmware setting is not. + /// Whether this account can clear it without anybody else. A standard account can still use + /// `fix`, with an administrator typing their password into the prompt; a firmware setting + /// cannot be fixed from Windows at all. pub fn ours_to_fix(self) -> bool { matches!( self, @@ -437,11 +442,20 @@ fn blocker_with( return Ok(Some(Blocker::VirtualizationDisabled)); } + /* + * Whether this account can approve an administrator prompt, not whether this process already + * runs elevated. Under UAC an administrator's apps run with a filtered token in which + * Administrators (S-1-5-32-544) is a deny-only group, so `IsInRole(Administrator)` answered + * false for every administrator who opened OpenBot normally, and they were all told to go and + * find one. The group is in the token's claims either way: as a group SID once elevated, as a + * deny-only SID before. A standard account has neither. Matched by SID, so it is the same in + * every language. + */ let administrator = "Windows administrator rights (powershell)"; let elevated = probe_bool(administrator, &probe_text(administrator, run("powershell", &[ "-NoProfile", "-NonInteractive", "-Command", "$ErrorActionPreference = 'Stop'; \ - ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)", + [bool]([Security.Principal.WindowsIdentity]::GetCurrent().Claims | Where-Object { $_.Value -eq 'S-1-5-32-544' })", ]))?)?; let wsl_feature = "the WSL feature state (powershell)"; @@ -553,6 +567,389 @@ pub fn blocker() -> Result, Problem> { Ok(None) } +/** +Whether WSL 2 can start a virtual machine here, asked of WSL itself. + +Every other check reads the same on a PC running Hyper-V and in a virtual machine whose host passes +no virtualization through: a hypervisor is present and the firmware flag reads off. Measured in +Windows 11 on ARM under VMware Fusion: every check passed, Docker Desktop was installed, and it +then stopped on "Nested virtualization is not available" while OpenBot waited for it. So this +starts WSL's own system distribution, which exists with no Linux distribution installed, and asks +whether Hyper-V refused to create its VM. + +Only that refusal is a blocker. Any other failure (an older WSL without `--system`, say) is not +evidence about virtualization, and is left for Docker to report. +*/ +#[cfg(target_os = "windows")] +pub fn nested_virtualization_blocker() -> Option { + let output = crate::quiet::command("wsl.exe") + .args(["--system", "--exec", "true"]) + .output() + .ok()?; + let said = format!( + "{}\n{}", + decode_probe_text(&output.stdout).unwrap_or_default(), + decode_probe_text(&output.stderr).unwrap_or_default() + ); + (!output.status.success() && hyper_v_refused(&said)) + .then_some(Blocker::NestedVirtualizationUnavailable) +} + +#[cfg(not(target_os = "windows"))] +pub fn nested_virtualization_blocker() -> Option { + None +} + +/// WSL's words for "Hyper-V cannot create a virtual machine here": the HCS error, by name or code. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn hyper_v_refused(said: &str) -> bool { + said.contains("HCS_E_HYPERV_NOT_INSTALLED") || said.contains("0x80370102") +} + +/// The machine's own architecture, in `std::env::consts::ARCH`'s words, for choosing what to +/// download and run. +/// +/// Not this program's: Windows on ARM runs an x64 OpenBot under emulation, where `ARCH` says +/// `x86_64` on an ARM64 machine. Docker's x64 installer refuses that machine, and an x64 Bun or +/// Compose would run emulated when a native one exists. Windows says what the machine is through +/// `IsWow64Process2` (Windows 10 1709 and later); anything it cannot answer falls back to `ARCH`. +pub fn machine_arch() -> &'static str { + #[cfg(target_os = "windows")] + if let Some(arch) = windows_machine_arch() { + return arch; + } + std::env::consts::ARCH +} + +#[cfg(target_os = "windows")] +fn windows_machine_arch() -> Option<&'static str> { + #[link(name = "kernel32")] + extern "system" { + fn GetCurrentProcess() -> *mut std::ffi::c_void; + fn IsWow64Process2( + process: *mut std::ffi::c_void, + process_machine: *mut u16, + native_machine: *mut u16, + ) -> i32; + } + let (mut process, mut native) = (0u16, 0u16); + // SAFETY: the pseudo-handle for this process is always valid, and both out-pointers are to + // locals that outlive the call. + if unsafe { IsWow64Process2(GetCurrentProcess(), &mut process, &mut native) } == 0 { + return None; + } + machine_name(native) +} + +/// `IMAGE_FILE_MACHINE_*` to `ARCH`'s names, for the two machines Windows 11 runs on. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn machine_name(machine: u16) -> Option<&'static str> { + match machine { + 0xAA64 => Some("aarch64"), + 0x8664 => Some("x86_64"), + _ => None, + } +} + +/// What OpenBot runs to clear a blocker: a PowerShell script, behind one administrator prompt when +/// it changes the machine, and whether Windows has to restart before the change is real. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Fix { + pub script: &'static str, + pub elevated: bool, + pub restart: bool, +} + +/// Turn on WSL 2 and nothing else: no Linux distribution, which `wsl --install` would add and then +/// open after the restart asking for a UNIX username nobody needs. +/// +/// Both Windows features through DISM every time, because that works on every build and is a no-op +/// for one already on; `wsl --install` alone can find WSL present and leave Virtual Machine +/// Platform off. DISM exits 3010 for "done, restart needed", which is success here. Then the +/// current WSL and its kernel, best effort: the inbox `wsl.exe` of older builds refuses +/// `--no-distribution` (measured on Server 2022), and a kernel still missing after the restart is +/// its own blocker, with its own fix. +const ENABLE_WSL: &str = r#"$ErrorActionPreference = 'Continue' +foreach ($feature in 'Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform') { + & dism.exe /online /enable-feature /featurename:$feature /all /norestart /quiet | Out-Null + if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 3010) { + throw "dism.exe could not turn on $feature (exit $LASTEXITCODE)" + } +} +& wsl.exe --install --no-distribution | Out-Null +exit 0"#; + +/// The WSL kernel, from GitHub where this WSL knows how, because the Store is often what a managed +/// machine has switched off; the Store otherwise. +const UPDATE_WSL: &str = r#"$ErrorActionPreference = 'Continue' +& wsl.exe --update --web-download | Out-Null +if ($LASTEXITCODE -eq 0) { exit 0 } +& wsl.exe --update | Out-Null +if ($LASTEXITCODE -ne 0) { throw "wsl.exe --update failed (exit $LASTEXITCODE)" } +exit 0"#; + +/// A per-account setting, so it runs as the person rather than as whoever approved a prompt. +const WSL_VERSION_TWO: &str = r#"$ErrorActionPreference = 'Continue' +& wsl.exe --set-default-version 2 | Out-Null +if ($LASTEXITCODE -ne 0) { throw "wsl.exe --set-default-version 2 failed (exit $LASTEXITCODE)" } +exit 0"#; + +impl Blocker { + /// How OpenBot clears this itself, or None when it cannot: firmware is not Windows's to change. + /// + /// A standard account gets the same fix as an administrator. Windows asks for an + /// administrator's password instead of a yes, and the features it turns on are the machine's, + /// so they are on for this account too. + pub fn fix(self) -> Option { + match self { + Blocker::WslAbsent + | Blocker::VirtualMachinePlatformDisabled + | Blocker::NotAdministrator => Some(Fix { + script: ENABLE_WSL, + elevated: true, + restart: true, + }), + Blocker::WslNoKernel => Some(Fix { + script: UPDATE_WSL, + elevated: true, + restart: true, + }), + Blocker::WslOne => Some(Fix { + script: WSL_VERSION_TWO, + elevated: false, + restart: false, + }), + Blocker::VirtualizationDisabled | Blocker::NestedVirtualizationUnavailable => None, + } + } +} + +/// The exit code that means the person said no to the administrator prompt: `ERROR_CANCELLED`. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const DECLINED: i32 = 1223; + +/// The script as PowerShell's `-EncodedCommand` takes it: UTF-16LE, base64. Nothing in it is +/// quoted twice, which is what passing a script through `Start-Process` otherwise comes to. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn encoded(script: &str) -> String { + use base64::Engine; + let bytes: Vec = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +/// Run `script` elevated: Windows shows its prompt, the script runs hidden, and its error, if it +/// has one, comes back through `log` because an elevated process cannot write to our pipes. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn elevated_launcher(script: &str, log: &Path) -> String { + let log = log.display().to_string().replace('\'', "''"); + let inner = format!( + "try {{\n{script}\n}} catch {{ $_ | Out-String | Set-Content -LiteralPath '{log}' -Encoding UTF8; exit 1 }}" + ); + format!( + r#"$ErrorActionPreference = 'Stop' +try {{ + $process = Start-Process -FilePath powershell.exe -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand','{}' + exit $process.ExitCode +}} catch {{ + $error_ = $_.Exception + while ($error_) {{ if ($error_.NativeErrorCode -eq {DECLINED}) {{ exit {DECLINED} }}; $error_ = $error_.InnerException }} + throw +}}"#, + encoded(&inner) + ) +} + +/// What came of running a fix, before any restart. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Fixed { + /// Done; Windows has to restart before it counts. + RestartNeeded, + /// Done, and nothing else needed. + Done, +} + +/// Turn the outcome of running a fix into what the screen says. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn fixed( + fix: Fix, + status: Option, + log: Option, + stderr: &str, +) -> Result { + match status { + Some(0) => Ok(if fix.restart { + Fixed::RestartNeeded + } else { + Fixed::Done + }), + Some(DECLINED) => Err(Problem::plain( + "Windows did not get permission to make the change. Choose Set up Windows again and \ + approve the prompt, or run the commands yourself.", + )), + status => Err(Problem::with( + "OpenBot could not set up Windows Subsystem for Linux. Try again, or run the commands \ + yourself.", + log.filter(|log| !log.trim().is_empty()) + .unwrap_or_else(|| format!("exit {status:?}: {}", stderr.trim())), + )), + } +} + +/// Run a blocker's fix and say what came of it. `scratch` holds the elevated script's error log. +#[cfg(target_os = "windows")] +pub fn run_fix(fix: Fix, scratch: &Path) -> Result { + std::fs::create_dir_all(scratch).map_err(|error| { + Problem::with( + "OpenBot could not set up Windows Subsystem for Linux.", + error.to_string(), + ) + })?; + let log = scratch.join("windows-fix-error.txt"); + let _ = std::fs::remove_file(&log); + let script = if fix.elevated { + elevated_launcher(fix.script, &log) + } else { + fix.script.to_string() + }; + let output = crate::quiet::command("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + ]) + .arg(encoded(&script)) + .output() + .map_err(|error| { + Problem::with( + "OpenBot could not set up Windows Subsystem for Linux.", + error.to_string(), + ) + })?; + fixed( + fix, + output.status.code(), + std::fs::read_to_string(&log).ok(), + &String::from_utf8_lossy(&output.stderr), + ) +} + +#[cfg(not(target_os = "windows"))] +pub fn run_fix(_fix: Fix, _scratch: &Path) -> Result { + Err(Problem::plain( + "Only Windows needs Windows Subsystem for Linux.", + )) +} + +/// Where a fix that needs a restart is remembered, with the boot it was made in. +pub fn restart_path(data_dir: &Path) -> PathBuf { + data_dir.join("windows-restart.json") +} + +#[derive(Serialize, Deserialize)] +struct AwaitingRestart { + boot: String, +} + +/// Remember that Windows has to restart, against this boot. +pub fn record_restart_needed(data_dir: &Path, boot: &str) -> std::io::Result<()> { + std::fs::create_dir_all(data_dir)?; + std::fs::write( + restart_path(data_dir), + serde_json::to_string(&AwaitingRestart { + boot: boot.to_string(), + }) + .unwrap_or_default(), + ) +} + +/// Whether a fix is still waiting for its restart: recorded in the boot that is running now. +/// +/// Checked before the blocker is, because a feature switched on reads as on straight away while +/// WSL cannot use it until Windows restarts; going on then would fail later, in Docker. A record +/// from an earlier boot means the restart happened, so it is removed. +pub fn restart_pending(data_dir: &Path, boot: &str) -> bool { + let path = restart_path(data_dir); + let Some(recorded) = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + else { + let _ = std::fs::remove_file(&path); + return false; + }; + if recorded.boot == boot { + return true; + } + let _ = std::fs::remove_file(&path); + false +} + +/// When Windows last started, to tell one boot from the next. +#[cfg(target_os = "windows")] +pub fn boot() -> Result { + probe_text( + "when Windows last started (powershell)", + crate::quiet::command("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('yyyyMMddHHmmss')", + ]) + .output(), + ) + .map(|text| text.trim().to_string()) +} + +#[cfg(not(target_os = "windows"))] +pub fn boot() -> Result { + Ok(String::new()) +} + +/// Restart Windows now, with OpenBot set to open once when the person signs back in. +#[cfg(target_os = "windows")] +pub fn restart() -> Result<(), Problem> { + let failed = |detail: String| { + Problem::with( + "OpenBot could not restart Windows. Restart it from the Start menu.", + detail, + ) + }; + let program = std::env::current_exe().map_err(|error| failed(error.to_string()))?; + // Best effort: without it the person opens OpenBot themselves, as the screen already says. + let _ = crate::quiet::command("reg.exe") + .args([ + "add", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnce", + "/v", + "OpenBot", + "/t", + "REG_SZ", + "/d", + &format!("\"{}\"", program.display()), + "/f", + ]) + .output(); + let output = crate::quiet::command("shutdown.exe") + .args(["/r", "/t", "0"]) + .output() + .map_err(|error| failed(error.to_string()))?; + if output.status.success() { + Ok(()) + } else { + Err(failed( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )) + } +} + +#[cfg(not(target_os = "windows"))] +pub fn restart() -> Result<(), Problem> { + Err(Problem::plain("Only Windows restarts from here.")) +} + #[cfg(test)] mod tests { use super::*; @@ -574,7 +971,7 @@ mod tests { assert_eq!(&args[..3], ["-NoProfile", "-NonInteractive", "-Command"]); match probe { 0 => assert!(args[3].contains("Get-CimInstance Win32_ComputerSystem")), - 1 => assert!(args[3].contains("WindowsBuiltInRole]::Administrator")), + 1 => assert!(args[3].contains("S-1-5-32-544")), 2 => assert_eq!( args[3], optional_feature_probe_command("Microsoft-Windows-Subsystem-Linux") @@ -621,7 +1018,7 @@ mod tests { )); } else if command.contains("Win32_ComputerSystem") { PROBE_OUTPUTS[0] - } else if command.contains("WindowsBuiltInRole]::Administrator") { + } else if command.contains("S-1-5-32-544") { "False" } else if command.contains("Win32_OptionalFeature") { "1" @@ -1526,6 +1923,140 @@ mod tests { } } + #[test] + fn only_hyper_v_refusing_a_vm_is_missing_nested_virtualization() { + assert!(hyper_v_refused( + "Please enable the Virtual Machine Platform Windows feature and ensure virtualization \ + is enabled in the BIOS.\nError code: \ + Wsl/Service/CreateInstance/CreateVm/HCS/HCS_E_HYPERV_NOT_INSTALLED" + )); + assert!(hyper_v_refused( + "WslRegisterDistribution failed with error: 0x80370102" + )); + assert!(!hyper_v_refused("Invalid command line option: --system")); + assert!(!hyper_v_refused("")); + assert_eq!(Blocker::NestedVirtualizationUnavailable.fix(), None); + assert!(!Blocker::NestedVirtualizationUnavailable.ours_to_fix()); + } + + #[test] + fn the_machine_is_named_as_arch_names_it() { + assert_eq!(machine_name(0xAA64), Some("aarch64")); + assert_eq!(machine_name(0x8664), Some("x86_64")); + assert_eq!( + machine_name(0x014C), + None, + "32-bit x86 is not a machine OpenBot runs on" + ); + if !cfg!(target_os = "windows") { + assert_eq!(machine_arch(), std::env::consts::ARCH); + } + } + + #[test] + fn every_blocker_but_firmware_has_a_fix_and_only_the_account_setting_skips_the_prompt() { + for blocker in [ + Blocker::WslAbsent, + Blocker::WslNoKernel, + Blocker::VirtualMachinePlatformDisabled, + Blocker::NotAdministrator, + ] { + let fix = blocker.fix().unwrap(); + assert!(fix.elevated && fix.restart, "{blocker:?}"); + } + let fix = Blocker::WslOne.fix().unwrap(); + assert!(!fix.elevated && !fix.restart); + assert_eq!(Blocker::VirtualizationDisabled.fix(), None); + } + + #[test] + fn enabling_wsl_turns_on_both_features_and_never_installs_a_distribution() { + assert!(ENABLE_WSL.contains("--install --no-distribution")); + assert!(!ENABLE_WSL.contains("wsl.exe --install\n")); + assert!(ENABLE_WSL.contains("Microsoft-Windows-Subsystem-Linux")); + assert!(ENABLE_WSL.contains("VirtualMachinePlatform")); + assert!( + ENABLE_WSL.contains("3010"), + "a restart-needed DISM exit is success" + ); + } + + #[test] + fn the_encoded_script_is_utf16le_base64() { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded("exit 0")) + .unwrap(); + assert_eq!(bytes, b"e\0x\0i\0t\0 \x000\0"); + } + + #[test] + fn the_elevated_launcher_asks_through_runas_and_reports_a_refusal() { + let launcher = elevated_launcher("exit 0", Path::new(r"C:\Users\o'brien\fix.txt")); + assert!(launcher.contains("-Verb RunAs")); + assert!(launcher.contains("-Wait -PassThru")); + assert!(launcher.contains("exit 1223")); + // The log path is inside the encoded inner script, quoted for PowerShell there. + let encoded_inner = launcher + .split("'-EncodedCommand','") + .nth(1) + .and_then(|rest| rest.split('\'').next()) + .unwrap(); + use base64::Engine; + let inner = base64::engine::general_purpose::STANDARD + .decode(encoded_inner) + .unwrap(); + let inner: Vec = inner + .chunks(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + let inner = String::from_utf16(&inner).unwrap(); + assert!(inner.contains(r"'C:\Users\o''brien\fix.txt'"), "{inner}"); + } + + #[test] + fn a_fix_says_restart_done_declined_or_failed_with_its_log() { + let enable = Blocker::WslAbsent.fix().unwrap(); + let version = Blocker::WslOne.fix().unwrap(); + assert_eq!(fixed(enable, Some(0), None, ""), Ok(Fixed::RestartNeeded)); + assert_eq!(fixed(version, Some(0), None, ""), Ok(Fixed::Done)); + let declined = fixed(enable, Some(DECLINED), None, "").unwrap_err(); + assert!(declined.said.contains("did not get permission")); + let failed = fixed( + enable, + Some(1), + Some("dism.exe could not turn on X".into()), + "", + ) + .unwrap_err(); + assert_eq!( + failed.detail.as_deref(), + Some("dism.exe could not turn on X") + ); + let failed = fixed(enable, Some(1), None, "boom").unwrap_err(); + assert!(failed.detail.unwrap().contains("boom")); + } + + #[test] + fn a_restart_is_pending_only_within_the_boot_that_recorded_it() { + let dir = temp_root("winrestart"); + assert!(!restart_pending(&dir, "20261026120000")); + record_restart_needed(&dir, "20261026120000").unwrap(); + assert!(restart_pending(&dir, "20261026120000")); + assert!( + restart_pending(&dir, "20261026120000"), + "asking does not clear it" + ); + assert!( + !restart_pending(&dir, "20261026130500"), + "a new boot is the restart" + ); + assert!(!restart_path(&dir).exists(), "and the record is gone"); + std::fs::write(restart_path(&dir), "{ not json").unwrap(); + assert!(!restart_pending(&dir, "20261026130500")); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn the_step_survives_the_restart_that_ends_the_process() { let dir = temp_root("winstate"); diff --git a/desktop/src-tauri/tauri.bundled-cli.conf.json b/desktop/src-tauri/tauri.bundled-cli.conf.json new file mode 100644 index 0000000000..323a315d40 --- /dev/null +++ b/desktop/src-tauri/tauri.bundled-cli.conf.json @@ -0,0 +1,7 @@ +{ + "bundle": { + "resources": { + "cli-bundle/": "cli-bundle/" + } + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index a3d81e35f4..0037ea1453 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -13,11 +13,12 @@ "windows": [ { "title": "OpenBot", - "width": 900, - "height": 640, + "width": 1000, + "height": 800, "minWidth": 720, "minHeight": 560, - "resizable": true + "resizable": true, + "titleBarStyle": "Transparent" } ], "security": { "csp": null } diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 98e42fe0ef..c4d0f5caf6 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -32,6 +32,10 @@ let invokeHandler: Invoke = async () => { const progressListeners = new Set< (event: { payload: ProgressEvent }) => void >(); +type QuitEvent = { seq: number; steps: ProgressEvent[] }; +const quitListeners = new Set<(event: { payload: QuitEvent }) => void>(); +type OpeningEvent = { state: "opening" | "opened" | "failed"; said: string }; +const openingListeners = new Set<(event: { payload: OpeningEvent }) => void>(); mock.module("@tauri-apps/api/core", () => ({ invoke: (command: string, args?: unknown) => { @@ -46,7 +50,17 @@ mock.module("@tauri-apps/api/event", () => ({ listener: (event: { payload: ProgressEvent }) => void, ) => { if (name === "setup:progress") progressListeners.add(listener); - return () => progressListeners.delete(listener); + const quit = listener as unknown as (event: { payload: QuitEvent }) => void; + if (name === "quit:progress") quitListeners.add(quit); + const opening = listener as unknown as (event: { + payload: OpeningEvent; + }) => void; + if (name === "intelligence:opening") openingListeners.add(opening); + return () => { + progressListeners.delete(listener); + quitListeners.delete(quit); + openingListeners.delete(opening); + }; }, })); @@ -55,12 +69,19 @@ mock.module("./Mark", () => ({ })); const { App } = await import("./App"); +const { PRESET_QUESTIONS } = await import("./Ask"); +const { ShuttingDownWindow, isShuttingDownPage } = await import( + "./ShuttingDown" +); beforeAll(() => GlobalRegistrator.register()); afterEach(() => { - invokeCalls = []; + // Unmounting can still call (a running local step is cancelled), so clear the record after it. cleanup(); + invokeCalls = []; progressListeners.clear(); + quitListeners.clear(); + openingListeners.clear(); }); afterAll(() => GlobalRegistrator.unregister()); @@ -134,6 +155,55 @@ async function beginPendingInstallation() { }; } +async function emitQuit(payload: QuitEvent) { + await act(async () => { + for (const listener of quitListeners) listener({ payload }); + }); +} + +/** One centred column, as every setup screen: the heading, its lede and rows in one sheet. */ +function expectStandardSheet(heading: HTMLElement) { + const sheet = heading.closest(".sheet"); + expect(sheet).not.toBeNull(); + expect(sheet?.classList.contains("connection-sheet")).toBe(true); + expect(sheet?.parentElement?.tagName).toBe("MAIN"); + // Nothing beside the sheet: the grid would give each child its own row and spread them apart. + expect(sheet?.parentElement?.children).toHaveLength(1); + expect(sheet?.querySelector(".lede, [role=status]")).not.toBeNull(); +} + +const running = (step: string) => ({ + step, + ok: true, + detail: "", + running: true, +}); +const complete = (step: string) => ({ + step, + ok: true, + detail: "", + running: false, +}); +const failed = (step: string) => ({ + step, + ok: false, + detail: "", + running: false, +}); + +/** A status line that is still working: its words, with the setup rows' spinner beside them. */ +function expectWorking(view: ReturnType, said: string) { + const status = view + .getAllByRole("status") + .find((line) => line.textContent === said); + expect(status?.textContent).toBe(said); + expect(status?.querySelector(".step-spinner")).not.toBeNull(); +} + +function rowStatus(view: ReturnType, name: string) { + return within(view.getByRole("listitem", { name })); +} + async function emitProgress(payload: ProgressEvent) { await act(async () => { for (const listener of progressListeners) listener({ payload }); @@ -296,78 +366,1453 @@ test.each([ }, ); -test("installation completes before either sign-in is available", async () => { - setupRootConfiguration("/tmp/install-before-signin", async () => - emptyConfiguration(), +test("installation completes before either sign-in is available", async () => { + setupRootConfiguration("/tmp/install-before-signin", async () => + emptyConfiguration(), + ); + const preparation = deferred(); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "prepare_installation") return preparation.promise; + return previous(command, args); + }; + const view = await renderApp(); + await enterInstallation(view); + expect(view.getByRole("heading", { name: "Install OpenBot" })).toBeTruthy(); + expect(view.getByLabelText("Where OpenBot lives")).toHaveProperty( + "value", + "/tmp/install-before-signin", + ); + expect( + view.queryByRole("button", { name: /Sign in|Continue to sign in/i }), + ).toBeNull(); + await userEvent.click(view.getByRole("button", { name: "Install OpenBot" })); + expect(installationCalls()).toEqual([ + { + command: "prepare_installation", + args: { + root: "/tmp/install-before-signin", + harness: { id: "langgraph" }, + }, + }, + ]); + expect(view.getByLabelText("Where OpenBot lives")).toHaveProperty( + "disabled", + true, + ); + expect(view.getByRole("button", { name: "Back" })).toHaveProperty( + "disabled", + true, + ); + expect(view.queryByRole("heading", { name: "Connect your AI" })).toBeNull(); + expect( + invokeCalls.some((call) => + /sign_in|start_stack|^providers$/.test(call.command), + ), + ).toBe(false); + await act(async () => { + for (const listener of progressListeners) + listener({ + payload: { + step: "dependencies", + ok: true, + detail: "Local runtime installed.", + }, + }); + }); + expect(view.getByText("Local runtime installed.")).toBeTruthy(); + expect( + view.queryByRole("button", { name: "Continue to sign in" }), + ).toBeNull(); + await act(async () => preparation.resolve()); + expect( + view.getByRole("heading", { name: "Installation complete" }), + ).toBeTruthy(); + expect( + view + .getByRole("button", { name: "Repair installation" }) + .closest("details"), + ).toHaveProperty("open", false); + expect(view.queryByRole("heading", { name: "Connect your AI" })).toBeNull(); + await userEvent.click( + view.getByRole("button", { name: "Continue to sign in" }), + ); + expect(view.getByRole("heading", { name: "Connect your AI" })).toBeTruthy(); +}); + +async function reachConnect(view: Awaited>) { + await enterInstallation(view); + await completeInstallation(view); + await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await userEvent.type(view.getByLabelText("OpenAI API key"), "model-test-key"); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await view.findByRole("heading", { name: "Connect to CopilotKit" }); +} + +const localListing = { outcome: "completed", stacks: [], reason: null }; + +test("local Intelligence is not offered when this build has no CLI", async () => { + setupRootConfiguration("/tmp/local-eval-unavailable", async () => + emptyConfiguration(), + ); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "list_local_eval") throw "unavailable"; + return previous(command, args); + }; + const view = await renderApp(); + await reachConnect(view); + expect(view.queryByText("Run Intelligence on this computer")).toBeNull(); + expect(view.queryByRole("radio", { name: /Intelligence/ })).toBeNull(); + expect(view.queryByText("Choose where Intelligence runs")).toBeNull(); + expect( + view.getByRole("button", { name: "Sign in to CopilotKit" }), + ).toBeTruthy(); + expect( + invokeCalls.some((call) => call.command === "begin_intelligence_sign_in"), + ).toBe(false); + expect(invokeCalls.some((call) => call.command === "set_up_local_eval")).toBe( + false, + ); +}); + +function localStatus(readiness: string, licence: string) { + return { + outcome: "completed", + stack: { + id: "local-abc123", + installedRelease: "0.15.0", + step: "ready", + urls: { + api: "http://localhost:64770", + dashboard: "http://cpk.localhost:64771", + gateway: "ws://localhost:64772", + }, + hasFailedStep: false, + updateInProgress: false, + observed: null, + }, + reason: null, + readiness, + licence, + licenceExpiresAt: null, + }; +} + +const localConnection = { + outcome: "completed", + stack: localStatus("unknown", "unknown").stack, + reason: null, + conflicted: false, +}; + +/** This build has a CLI; each local command answers from `local`. */ +/** The agreement as the CLI reports it for a person who has already accepted it. */ +const ACCEPTED_TERMS = { + status: "required", + accepted: true, + version: "developer-edition-2026-10-01-web", + url: "https://copilotkit.ai/legal/developer-edition", + organizationName: "Acme", +}; + +function withLocalCli(local: Record) { + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "list_local_eval") return localListing; + if (command === "cancel_local_eval") return null; + if (command === "show_openbot") return null; + // Signed in and agreed already, unless a test says otherwise: the account step passes. + if (command === "local_eval_terms" && !("local_eval_terms" in local)) + return ACCEPTED_TERMS; + if (command in local) { + const answer = local[command]; + return typeof answer === "function" ? answer(args) : answer; + } + return previous(command, args); + }; +} + +function localSequence() { + return invokeCalls + .map((call) => call.command) + .filter((command) => + [ + "set_up_local_eval", + "connect_local_eval", + "run_local_eval", + "start_stack", + "show_openbot", + ].includes(command), + ); +} + +/** Commands that set up, connect or run local Intelligence, or stop a call. */ +function localCliCalls() { + return invokeCalls.filter((call) => + [ + "set_up_local_eval", + "connect_local_eval", + "run_local_eval", + "cancel_local_eval", + ].includes(call.command), + ); +} + +const LOCAL_CHOICE = "Run Intelligence on this computer"; +const HOSTED_CHOICE = "Use CopilotKit's hosted Intelligence"; +const HOSTED_LINK = "Use CopilotKit's hosted service instead"; + +/** Ask the question already in the Try OpenBot box, and wait for the answer. */ +async function askTheTest( + view: Awaited>, + answer = "I am working.", +) { + await userEvent.click(await view.findByRole("button", { name: "Ask" })); + return view.findByText(answer); +} + +function choice(view: Awaited>, name: string) { + return view.getByRole("radio", { name: new RegExp(name) }); +} + +/** The first screen's Install, which with a CLI installs OpenBot and local Intelligence. */ +async function installLocal(view: Awaited>) { + await userEvent.click(view.getByRole("button", { name: "Install" })); +} + +/** Words about the software underneath, which no local screen says. */ +// Docker is not here: its installer and its agreement show windows of their own, so the screen +// says what it is waiting for by name. See `plainInstallSteps`. +const JARGON = [ + /\bCLI\b/, + /Podman/i, + /Container engine/i, + /Engine machine/i, + /k3d/i, +]; + +function expectPlain(view: Awaited>) { + const text = view.container.textContent ?? ""; + for (const word of JARGON) expect(text).not.toMatch(word); +} + +/** The section the screen is in, as the section list marks it. */ +function stepLabel(view: Awaited>) { + return view.container.querySelector('[aria-current="step"]')?.textContent; +} + +function buttons(view: Awaited>) { + return view + .getAllByRole("button") + .map((button) => button.textContent) + .sort(); +} + +async function chooseOpenAi(view: Awaited>) { + await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await userEvent.type(view.getByLabelText("OpenAI API key"), "model-test-key"); + await userEvent.click(view.getByRole("button", { name: "Continue" })); +} + +test("with a CLI, the first screen is Install, local by default with nothing to choose, and without one it is unchanged", async () => { + setupRootConfiguration("/tmp/local-eval-welcome", async () => + emptyConfiguration(), + ); + withLocalCli({}); + let view = await renderApp(); + expect(view.getByRole("heading", { name: "Set up OpenBot" })).toBeTruthy(); + expect( + view.getByText( + "Installs OpenBot and CopilotKit Intelligence on this computer. The first setup takes about 10 minutes.", + ), + ).toBeTruthy(); + expect(buttons(view)).toEqual(["Install"]); + expect(view.queryByRole("radio")).toBeNull(); + expectPlain(view); + expect(localCliCalls()).toEqual([]); + cleanup(); + invokeCalls = []; + + setupRootConfiguration("/tmp/local-eval-welcome-none", async () => + emptyConfiguration(), + ); + view = await renderApp(); + expect( + view.getByRole("heading", { + name: "Your own AI coworkers, on this computer.", + }), + ).toBeTruthy(); + expect(buttons(view)).toEqual(["Set up OpenBot"]); + await userEvent.click(view.getByRole("button", { name: "Set up OpenBot" })); + expect(stepLabel(view)).toBe("Bot"); +}); + +test("Install prepares the default Bot in the default folder, with no Bot or folder screen", async () => { + setupRootConfiguration("/tmp/local-eval-install", async () => + emptyConfiguration(), + ); + const preparation = deferred(); + withLocalCli({ prepare_installation: () => preparation.promise }); + const view = await renderApp(); + await installLocal(view); + expect(installationCalls()).toEqual([ + { + command: "prepare_installation", + args: { root: "/tmp/local-eval-install", harness: { id: "langgraph" } }, + }, + ]); + expect( + view.getByRole("heading", { name: "Installing OpenBot" }), + ).toBeTruthy(); + expect(stepLabel(view)).toBe("Install"); + expect(view.queryByLabelText("Where OpenBot lives")).toBeNull(); + expect(view.queryByText(/Pick a Bot|Which Bot/i)).toBeNull(); + expect(invokeCalls.some((call) => call.command === "harnesses")).toBe(false); + // Nothing to press, and no way out while it installs. + expect(view.queryAllByRole("button")).toEqual([]); + // Intelligence waits for the engine the install starts: nothing local runs yet. + expect(localCliCalls()).toEqual([]); + // Native rows are summarised in plain words, with what the computer is doing now. + await emitProgress({ + step: "install-engine", + ok: true, + running: true, + detail: "Downloading Docker Desktop.", + downloadBytes: 48_000_000, + }); + const preparing = within( + view.getByRole("listitem", { name: "Preparing this computer" }), + ); + expect(preparing.getByText(/Downloading Docker Desktop\./)).toBeTruthy(); + expect(preparing.getByText(/48\.0 MB downloaded/)).toBeTruthy(); + await emitProgress({ + step: "install-engine", + ok: true, + running: true, + detail: + "Starting Docker Desktop. Accept its terms in its own window when it asks.", + }); + expect( + preparing.getByText(/Accept its terms in its own window/), + ).toBeTruthy(); + expect(preparing.queryByText(/downloaded/)).toBeNull(); + await emitProgress({ + step: "install-engine", + ok: true, + detail: "Docker Desktop 4.96.3 installed and running.", + }); + await emitProgress({ + step: "engine", + ok: true, + detail: "The container engine is answering.", + }); + await emitProgress({ + step: "create-machine", + ok: true, + detail: "Preparing the engine machine.", + }); + await emitProgress({ + step: "images", + ok: true, + running: true, + detail: "Downloading local software (2/4).", + downloadBytes: 2_400_000, + }); + expect( + within( + view.getByRole("listitem", { name: "Preparing this computer" }), + ).getByText("Complete"), + ).toBeTruthy(); + const installing = within( + view.getByRole("listitem", { name: "Installing OpenBot" }), + ); + expect(installing.getByText("In progress")).toBeTruthy(); + expect(installing.getByText(/2.4 MB downloaded/)).toBeTruthy(); + expectPlain(view); + expect( + setupEvents().filter( + (event) => + (event as { event: { kind: string } }).event.kind === "harness_chosen", + ), + ).toEqual([{ event: { kind: "harness_chosen", harness: "langgraph" } }]); + // Once the engine answers, Intelligence sets itself up alongside the install, once. + await waitFor(() => + expect( + localCliCalls().filter((call) => call.command === "set_up_local_eval"), + ).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ root: "/tmp/local-eval-install" }), + }), + ]), + ); +}); + +test("a signed-out first run signs in to CopilotKit and accepts the agreement as its own step, before choosing an AI", async () => { + setupRootConfiguration("/tmp/local-eval-account", async () => + emptyConfiguration(), + ); + let signedIn = false; + withLocalCli({ + local_eval_terms: () => + signedIn + ? { ...ACCEPTED_TERMS, accepted: false } + : { status: "signed-out" }, + sign_in_to_copilotkit: () => { + signedIn = true; + return { signedIn: true }; + }, + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + }); + const view = await renderApp(); + await installLocal(view); + // The first step. OpenBot needs no account, so it installs behind it, exactly once; nothing + // of Intelligence's runs before the agreement. + await view.findByRole("heading", { name: "Sign in to CopilotKit" }); + expect(stepLabel(view)).toBe("Account"); + await waitFor(() => + expect(installationCalls().map((call) => call.command)).toEqual([ + "prepare_installation", + ]), + ); + expect(localSequence()).toEqual([]); + await userEvent.click( + view.getByRole("button", { name: "Sign in to CopilotKit" }), + ); + await view.findByRole("heading", { name: "Accept the evaluation agreement" }); + expect( + view.getByText( + "To run Intelligence on this computer, accept the CopilotKit Intelligence evaluation agreement for Acme.", + ), + ).toBeTruthy(); + expect(view.getByRole("link", { name: "Read the agreement" })).toBeTruthy(); + // Nothing licensed runs before Accept. + expect(localSequence()).toEqual([]); + await userEvent.click(view.getByRole("button", { name: "Accept" })); + await view.findByRole("heading", { name: "Connect your AI" }); + expect(stepLabel(view)).toBe("AI"); + expect(view.queryByText(/needs your CopilotKit account/)).toBeNull(); + // Leaving the account step does not install a second time. + expect( + installationCalls().filter( + (call) => call.command === "prepare_installation", + ), + ).toHaveLength(1); + await waitFor(() => expect(localSequence()).toContain("set_up_local_eval")); + expect( + invokeCalls.find((call) => call.command === "set_up_local_eval")?.args, + ).toMatchObject({ + root: "/tmp/local-eval-account", + acceptedEulaVersion: "developer-edition-2026-10-01-web", + }); + expectPlain(view); +}); + +test("a first run already signed in with the agreement accepted passes the account step by itself", async () => { + setupRootConfiguration("/tmp/local-eval-account-done", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + }); + const view = await renderApp(); + await installLocal(view); + await view.findByRole("heading", { name: "Connect your AI" }); + expect( + invokeCalls.some((call) => call.command === "sign_in_to_copilotkit"), + ).toBe(false); + await waitFor(() => expect(localSequence()).toContain("set_up_local_eval")); + expect( + invokeCalls.find((call) => call.command === "set_up_local_eval")?.args, + ).toMatchObject({ acceptedEulaVersion: null }); +}); + +test("the local sequence starts when installation completes and survives the provider screen", async () => { + setupRootConfiguration("/tmp/local-eval-overlap", async () => + emptyConfiguration(), + ); + const preparation = deferred(); + const setUp = deferred(); + withLocalCli({ + prepare_installation: () => preparation.promise, + set_up_local_eval: () => setUp.promise, + connect_local_eval: () => new Promise(() => {}), + }); + const view = await renderApp(); + await installLocal(view); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(localCliCalls()).toEqual([]); + await act(async () => preparation.resolve()); + await view.findByRole("heading", { name: "Connect your AI" }); + expect(stepLabel(view)).toBe("AI"); + await waitFor(() => expect(localSequence()).toEqual(["set_up_local_eval"])); + expect( + invokeCalls.find((call) => call.command === "set_up_local_eval")?.args, + ).toMatchObject({ root: "/tmp/local-eval-overlap" }); + // Working behind this screen says nothing on it: its progress has its own step. + expect(view.queryByText(/Setting up Intelligence/)).toBeNull(); + expect(view.queryByRole("alert", { name: "Intelligence" })).toBeNull(); + // A local first run has nothing behind this screen to go back to. + expect(view.queryByRole("button", { name: "Back" })).toBeNull(); + await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await act(async () => setUp.resolve(localStatus("starting", "valid"))); + await waitFor(() => + expect(localSequence()).toEqual([ + "set_up_local_eval", + "connect_local_eval", + ]), + ); + expect(view.queryByText(/Connecting OpenBot/)).toBeNull(); + expect(invokeCalls.some((call) => call.command === "cancel_local_eval")).toBe( + false, + ); + expectPlain(view); +}); + +test("Continue during setup shows Getting ready, which starts, tests and opens OpenBot by itself", async () => { + setupRootConfiguration("/tmp/local-eval-auto", async () => + emptyConfiguration(), + ); + const setUp = deferred(); + withLocalCli({ + set_up_local_eval: () => setUp.promise, + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + }); + const view = await renderApp(); + await installLocal(view); + await chooseOpenAi(view); + await view.findByRole("heading", { name: "Getting ready" }); + expect(stepLabel(view)).toBe("Start"); + const row = view.getByRole("listitem", { name: "Setting up Intelligence" }); + expect(row.textContent).toContain("In progress"); + expectPlain(view); + await act(async () => setUp.resolve(localStatus("starting", "valid"))); + await view.findByRole("heading", { name: "Try OpenBot" }); + expect(stepLabel(view)).toBe("Start"); + expect(localSequence()).toEqual([ + "set_up_local_eval", + "connect_local_eval", + "run_local_eval", + "start_stack", + ]); + expect( + invokeCalls.find((call) => call.command === "run_local_eval")?.args, + ).toMatchObject({ action: "status", stack: "local-abc123" }); + // The local stack's own addresses, and no key from the window: Start takes the vaulted one. + expect(getStartStackPayload()).toMatchObject({ + root: "/tmp/local-eval-auto", + apiUrl: "http://localhost:64770", + gatewayWsUrl: "ws://localhost:64772", + apiKey: "", + }); + // One of the questions is already in the box, nothing is asked until Ask, Another question + // steps through the rest in order and back round, and a typed question replaces it. + const question = view.getByLabelText("Your question") as HTMLInputElement; + const first = PRESET_QUESTIONS.indexOf( + question.value as (typeof PRESET_QUESTIONS)[number], + ); + expect(first).toBeGreaterThanOrEqual(0); + expect(invokeCalls.some((call) => call.command === "ask_the_bot")).toBe( + false, + ); + for (let step = 1; step <= PRESET_QUESTIONS.length; step++) { + await userEvent.click( + view.getByRole("button", { name: "Another question" }), + ); + expect(question.value).toBe( + PRESET_QUESTIONS[(first + step) % PRESET_QUESTIONS.length], + ); + } + await userEvent.type(question, "Who are you?", { + initialSelectionStart: 0, + initialSelectionEnd: question.value.length, + }); + await userEvent.click(view.getByRole("button", { name: "Ask" })); + await view.findByText("I am working."); + const asked = invokeCalls.filter((call) => call.command === "ask_the_bot"); + expect(asked).toEqual([ + { + command: "ask_the_bot", + args: { root: "/tmp/local-eval-auto", question: "Who are you?" }, + }, + ]); + // Asking again stays open after an answer. + expect(view.getByRole("button", { name: "Ask again" })).toBeTruthy(); + expectPlain(view); + await userEvent.click( + view.getByRole("button", { name: "Start using OpenBot" }), + ); + await waitFor(() => expect(localSequence()).toContain("show_openbot")); + expect( + invokeCalls.filter((call) => call.command === "start_stack"), + ).toHaveLength(1); +}); + +function intelligenceCalls() { + return invokeCalls.filter((call) => + ["offer_local_intelligence", "open_local_intelligence"].includes( + call.command, + ), + ); +} + +test("a local Try OpenBot screen links to the Intelligence dashboard through the native sign-in", async () => { + setupRootConfiguration("/tmp/local-eval-view", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + offer_local_intelligence: null, + open_local_intelligence: () => opening.promise, + }); + const opening = deferred(); + const view = await renderApp(); + await installLocal(view); + await chooseOpenAi(view); + await askTheTest(view); + // The menus offer the dashboard once local mode has a recorded stack. + expect(intelligenceCalls()).toEqual([ + { + command: "offer_local_intelligence", + args: { root: "/tmp/local-eval-view", offered: true }, + }, + ]); + await userEvent.click( + view.getByRole("button", { name: "View in Intelligence" }), + ); + // Said on the click, while the CLI call is still running. + expectWorking(view, "Opening the Intelligence dashboard…"); + await act(async () => opening.resolve(null)); + expect(view.getByRole("status").textContent).toBe("Opened in your browser."); + expect(view.queryByText("Opening the Intelligence dashboard…")).toBeNull(); + const opened = intelligenceCalls().filter( + (call) => call.command === "open_local_intelligence", + ); + expect(opened).toEqual([ + { + command: "open_local_intelligence", + args: { attempt: expect.any(String), root: "/tmp/local-eval-view" }, + }, + ]); + // The sign-in opens the browser itself; the window stays on its screen. + expect(localSequence()).not.toContain("show_openbot"); + expect(view.getByRole("heading", { name: "Try OpenBot" })).toBeTruthy(); + expectPlain(view); +}); + +test("a local Try OpenBot step says it is asking, with a spinner, until the answer arrives", async () => { + setupRootConfiguration("/tmp/local-eval-asking", async () => + emptyConfiguration(), + ); + const answer = deferred(); + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: () => answer.promise, + offer_local_intelligence: null, + }); + const view = await renderApp(); + await installLocal(view); + await chooseOpenAi(view); + await view.findByRole("heading", { name: "Try OpenBot" }); + await userEvent.click(view.getByRole("button", { name: "Ask" })); + await waitFor(() => expectWorking(view, "Asking…")); + await act(async () => answer.resolve("I am working.")); + expect(view.queryByText("Asking…")).toBeNull(); +}); + +test("View in Intelligence says the native sentence when the dashboard does not open", async () => { + setupRootConfiguration("/tmp/local-eval-view-failed", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + offer_local_intelligence: null, + open_local_intelligence: () => + Promise.reject({ + said: "Your browser did not open. Try again.", + detail: null, + }), + }); + const view = await renderApp(); + await installLocal(view); + await chooseOpenAi(view); + await askTheTest(view); + await userEvent.click( + view.getByRole("button", { name: "View in Intelligence" }), + ); + expect( + await view.findByText("Your browser did not open. Try again."), + ).toBeTruthy(); + // The test result and its way forward are unchanged. + expect(view.getByText("I am working.")).toBeTruthy(); + expect( + view.getByRole("button", { name: "Start using OpenBot" }), + ).toBeTruthy(); + expectPlain(view); +}); + +test("Continue after Intelligence is already ready starts OpenBot with the AI just chosen", async () => { + setupRootConfiguration("/tmp/local-eval-ready-first", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + }); + const view = await renderApp(); + await installLocal(view); + await view.findByRole("heading", { name: "Connect your AI" }); + // Intelligence finishes while the person is still choosing. + await waitFor(() => + expect(localSequence()).toEqual([ + "set_up_local_eval", + "connect_local_eval", + "run_local_eval", + ]), + ); + await act(async () => new Promise((resolve) => setTimeout(resolve, 100))); + await chooseOpenAi(view); + await view.findByRole("heading", { name: "Try OpenBot" }); + expect(getStartStackPayload()).toMatchObject({ + model: { provider: "openai", login: "api-key", apiKey: "model-test-key" }, + }); +}); + +test("a Claude plan signed in on the provider screen reaches Start with its token", async () => { + setupRootConfiguration("/tmp/local-eval-plan", async () => + emptyConfiguration(), + ); + withLocalCli({ + providers: [ + { + id: "anthropic", + name: "Anthropic", + summary: "Sign in with a Claude plan.", + logins: ["plan", "api-key"], + mark: null, + caution: null, + }, + ], + begin_claude_sign_in: "https://claude.example/authorize", + finish_claude_sign_in: "synthetic-plan-token", + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + }); + const view = await renderApp(); + await installLocal(view); + await userEvent.click(await view.findByRole("radio", { name: /Anthropic/ })); + await userEvent.click( + await view.findByRole("button", { name: "Sign in with Anthropic" }), + ); + await userEvent.type( + await view.findByLabelText("Code from your browser"), + "synthetic-code", + ); + // Intelligence is ready and waiting before the sign-in finishes, as on a real machine. The + // finished sign-in then moves on by itself, from an effect rather than a click, and Start must + // still get the model it chose (it once got null: Start ran before the choice had rendered). + await waitFor(() => expect(localSequence()).toContain("run_local_eval")); + await act(async () => new Promise((resolve) => setTimeout(resolve, 100))); + await userEvent.click( + view.getByRole("button", { name: "Finish signing in" }), + ); + // A tick for a second, then on by itself. + expect((await view.findByText(/✓/)).closest("p")?.textContent).toContain( + "Signed in to Anthropic.", + ); + await view.findByRole("heading", { name: "Try OpenBot" }, { timeout: 4000 }); + expect(getStartStackPayload()).toMatchObject({ + model: { + provider: "anthropic", + login: "plan", + token: "synthetic-plan-token", + }, + }); +}); + +test("a development double mount runs each local step and asks the question once, on Ask", async () => { + setupRootConfiguration("/tmp/local-eval-strict", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + }); + const view = await renderApp(true); + await installLocal(view); + await chooseOpenAi(view); + await askTheTest(view); + expect(localSequence()).toEqual([ + "set_up_local_eval", + "connect_local_eval", + "run_local_eval", + "start_stack", + ]); + expect( + invokeCalls.filter((call) => call.command === "ask_the_bot"), + ).toHaveLength(1); +}); + +test("Continue after Intelligence is ready starts OpenBot with the chosen model", async () => { + setupRootConfiguration("/tmp/local-eval-ready-first", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: localStatus("ready", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + }); + const view = await renderApp(); + await installLocal(view); + await view.findByRole("heading", { name: "Connect your AI" }); + await waitFor(() => expect(localSequence()).toContain("run_local_eval")); + expect(view.queryByRole("alert", { name: "Intelligence" })).toBeNull(); + expect(localSequence()).not.toContain("start_stack"); + await chooseOpenAi(view); + await askTheTest(view); + expect(getStartStackPayload()).toMatchObject({ + model: { provider: "openai" }, + }); +}); + +test("while a CLI step runs only Cancel is offered, and Cancel stops the call and says so", async () => { + setupRootConfiguration("/tmp/local-eval-cancel", async () => + emptyConfiguration(), + ); + const pending = deferred(); + withLocalCli({ + set_up_local_eval: () => pending.promise, + cancel_local_eval: () => { + pending.reject("cancelled"); + return null; + }, + begin_intelligence_sign_in: "https://signin.example/fixture", + finish_intelligence_sign_in: () => new Promise(() => {}), + }); + const view = await renderApp(); + await installLocal(view); + await chooseOpenAi(view); + await view.findByRole("heading", { name: "Getting ready" }); + expect(buttons(view)).toEqual(["Cancel"]); + expect(view.queryByText("Installation options")).toBeNull(); + await userEvent.click(view.getByRole("button", { name: "Cancel" })); + const setup = invokeCalls.find( + (call) => call.command === "set_up_local_eval", + ); + expect( + invokeCalls.find((call) => call.command === "cancel_local_eval")?.args, + ).toEqual({ + attempt: (setup?.args as { attempt: string } | undefined)?.attempt, + }); + const row = view.getByRole("listitem", { name: "Setting up Intelligence" }); + await waitFor(() => + expect(row.textContent).toContain( + "Stopped at your request before it finished.", + ), + ); + // Once nothing runs, leaving is offered. + expect(buttons(view)).toEqual(["Back", "Retry", HOSTED_LINK]); + await userEvent.click(view.getByRole("button", { name: HOSTED_LINK })); + await view.findByRole("heading", { name: "Connect to CopilotKit" }); + expect( + invokeCalls.filter((call) => call.command === "set_up_local_eval"), + ).toHaveLength(1); +}); + +test("a first local run whose model cannot answer offers Change the model, as hosted does", async () => { + setupRootConfiguration("/tmp/local-eval-ask-failure", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: localStatus("ready", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + stop_stack: null, + ask_the_bot: () => { + throw { said: "The model could not answer.", detail: "401" }; + }, + }); + const view = await renderApp(); + await installLocal(view); + await chooseOpenAi(view); + await userEvent.click(await view.findByRole("button", { name: "Ask" })); + await view.findByText("The model could not answer."); + expect(localSequence()).not.toContain("show_openbot"); + expect( + view.queryByRole("button", { name: "Start using OpenBot" }), + ).toBeNull(); + await userEvent.click(view.getByRole("button", { name: "Change the model" })); + await view.findByRole("heading", { name: "Connect your AI" }); + expect(invokeCalls.some((call) => call.command === "stop_stack")).toBe(true); + // The rows start over: what ran before was for the OpenBot that stopped. + await chooseOpenAi(view); + await waitFor(() => + expect( + invokeCalls.filter((call) => call.command === "start_stack"), + ).toHaveLength(2), + ); +}); + +test("a blocked setup stops at its row in plain words and Retry resumes there", async () => { + setupRootConfiguration("/tmp/local-eval-blocked", async () => + emptyConfiguration(), + ); + let setups = 0; + withLocalCli({ + set_up_local_eval: () => + ++setups === 1 + ? { + ...localStatus("unknown", "unknown"), + outcome: "blocked", + stack: null, + reason: "docker-unavailable", + } + : localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + }); + const view = await renderApp(); + await installLocal(view); + await view.findByRole("heading", { name: "Connect your AI" }); + // A stop interrupts the screen in front at once, in a card with its reason and Retry. + const alert = await view.findByRole("alert", { name: "Intelligence" }); + expect(alert.textContent).toContain("Intelligence stopped"); + expect(alert.textContent).toContain( + "Intelligence cannot start until the software it runs on is running.", + ); + await chooseOpenAi(view); + await view.findByRole("heading", { name: "Getting ready" }); + // Continue does not retry a blocked step: Retry does. + expect(setups).toBe(1); + const row = view.getByRole("listitem", { name: "Setting up Intelligence" }); + expect(row.textContent).toContain( + "Intelligence cannot start until the software it runs on is running.", + ); + expectPlain(view); + expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( + false, + ); + await userEvent.click(view.getByRole("button", { name: "Retry" })); + await askTheTest(view); + expect(setups).toBe(2); +}); + +test("an installation from before the first-screen choice still asks at step 4", async () => { + setupRootConfiguration("/tmp/local-eval-choice", async () => ({ + ...savedOpenAiConfiguration(), + saved: { + ...savedOpenAiConfiguration().saved, + intelligenceApiKey: false, + model: "open-ai-api-key", + }, + installation: { harness: { id: "mastra" } }, + })); + withLocalCli({ + set_up_local_eval: localStatus("ready", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + }); + const view = await renderApp(); + await view.findByRole("heading", { name: "Choose where Intelligence runs" }); + expect(choice(view, LOCAL_CHOICE)).toHaveProperty("checked", true); + expect(choice(view, HOSTED_CHOICE)).toHaveProperty("checked", false); + expect( + view.getByText("For evaluation. The first setup takes about 10 minutes."), + ).toBeTruthy(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(localCliCalls()).toEqual([]); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await askTheTest(view); + expect(localSequence()).toEqual([ + "set_up_local_eval", + "connect_local_eval", + "run_local_eval", + "start_stack", + ]); +}); + +test("a hosted connection chosen earlier preselects hosted at step 4", async () => { + for (const [name, configuration] of [ + [ + "url", + { + ...emptyConfiguration(), + saved: { + ...savedOpenAiConfiguration().saved, + model: "open-ai-api-key", + }, + values: { INTELLIGENCE_API_URL: "https://intelligence.example" }, + installation: { harness: { id: "mastra" } }, + }, + ], + [ + "key", + { + ...savedOpenAiConfiguration(), + saved: { + ...savedOpenAiConfiguration().saved, + model: "open-ai-api-key", + }, + installation: { harness: { id: "mastra" } }, + }, + ], + ] as const) { + setupRootConfiguration( + `/tmp/local-eval-hosted-chosen-${name}`, + async () => configuration, + ); + withLocalCli({}); + const view = await renderApp(); + await view.findByRole("heading", { + name: "Choose where Intelligence runs", + }); + expect(choice(view, HOSTED_CHOICE)).toHaveProperty("checked", true); + expect(choice(view, LOCAL_CHOICE)).toHaveProperty("checked", false); + // A saved connection is checked at Start; Continue does not sign in again. + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await view.findByRole("heading", { name: "Connect to CopilotKit" }); + expect( + invokeCalls.some((call) => call.command === "begin_intelligence_sign_in"), + ).toBe(false); + expect(localCliCalls()).toEqual([]); + cleanup(); + invokeCalls = []; + } +}); + +test("a recorded, connected stack on step 4 skips the choice and starts", async () => { + setupRootConfiguration("/tmp/local-eval-recorded", async () => ({ + ...savedOpenAiConfiguration(), + saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, + installation: { harness: { id: "mastra" } }, + localEval: { + stack: "local-abc123", + ownership: "created", + phase: "installed", + connected: true, + }, + })); + withLocalCli({ + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + }); + const view = await renderApp(); + // Never launched, so this is a first run: it tests the model before handing over. + await askTheTest(view); + expect( + view.queryByRole("radio", { name: new RegExp(LOCAL_CHOICE) }), + ).toBeNull(); + expect(localSequence()).toEqual(["run_local_eval", "start_stack"]); +}); + +test("relaunch says it is starting while it starts, and never offers a CopilotKit connection", async () => { + setupConnectedLocalStack( + "/tmp/local-eval-relaunch-heading", + localStatus("ready", "valid"), + ); + const status = deferred(); + const previous = invokeHandler; + invokeHandler = async (command, args) => + command === "run_local_eval" ? status.promise : previous(command, args); + const view = await renderApp(); + await view.findByRole("heading", { name: "Starting OpenBot" }); + expect( + view.queryByRole("heading", { name: "OpenBot is stopped" }), + ).toBeNull(); + expect(buttons(view)).toEqual(["Cancel"]); + expectPlain(view); + await act(async () => status.resolve(localStatus("ready", "valid"))); + await view.findByRole("heading", { name: "OpenBot is running" }); + await userEvent.click(view.getByRole("button", { name: "Stop OpenBot" })); + await view.findByRole("heading", { name: "OpenBot is stopped" }); + expect( + view.queryByRole("button", { name: "Change CopilotKit connection" }), + ).toBeNull(); + expect( + view.getByRole("button", { name: "Change AI connection" }), + ).toBeTruthy(); + expectPlain(view); +}); + +test("Quit shows Shutting down with each part's row as it starts and ends", async () => { + setupConnectedLocalStack( + "/tmp/local-eval-quit-screen", + localStatus("ready", "valid"), + ); + const view = await renderApp(); + await view.findByRole("heading", { name: "OpenBot is running" }); + const callsBefore = invokeCalls.length; + + await emitQuit({ seq: 2, steps: [running("stop-openbot")] }); + const heading = view.getByRole("heading", { name: "Shutting down OpenBot" }); + expectStandardSheet(heading); + expect( + rowStatus(view, "Stopping OpenBot").getByText("In progress"), + ).toBeTruthy(); + expect( + view.queryByRole("listitem", { name: "Stopping Intelligence" }), + ).toBeNull(); + // Nothing to press: the app exits by itself. + expect(view.queryAllByRole("button")).toHaveLength(0); + + await emitQuit({ + seq: 4, + steps: [complete("stop-openbot"), running("stop-intelligence")], + }); + expect( + rowStatus(view, "Stopping OpenBot").getByText("Complete"), + ).toBeTruthy(); + expect( + rowStatus(view, "Stopping Intelligence").getByText("In progress"), + ).toBeTruthy(); + // An older copy arriving late never undoes a newer one. + await emitQuit({ seq: 3, steps: [complete("stop-openbot")] }); + expect( + rowStatus(view, "Stopping Intelligence").getByText("In progress"), + ).toBeTruthy(); + + await emitQuit({ + seq: 5, + steps: [complete("stop-openbot"), failed("stop-intelligence")], + }); + expect( + rowStatus(view, "Stopping Intelligence").getByText("Failed"), + ).toBeTruthy(); + expect(view.getByRole("status").textContent).toBe( + "Part of it could not be stopped. OpenBot says what happened the next time it opens.", + ); + // The window only shows; it never asks anything of the native side while it quits. + expect(invokeCalls.slice(callsBefore)).toEqual([]); +}); + +test("a window sent to the Shutting down page catches up, then follows the rows", async () => { + expect(isShuttingDownPage("#shutting-down")).toBe(true); + expect(isShuttingDownPage("")).toBe(false); + invokeHandler = async (command) => { + if (command === "quit_status") + return { + seq: 3, + steps: [complete("stop-openbot"), running("stop-intelligence")], + }; + throw new Error(`unexpected ${command}`); + }; + let view!: ReturnType; + await act(async () => { + view = render(); + }); + const heading = await view.findByRole("heading", { + name: "Shutting down OpenBot", + }); + expectStandardSheet(heading); + await waitFor(() => + expect( + rowStatus(view, "Stopping OpenBot").getByText("Complete"), + ).toBeTruthy(), ); - const preparation = deferred(); + expect(view.getByRole("status").textContent).toBe( + "OpenBot is stopping what it started on this computer, then it closes.", + ); + await emitQuit({ + seq: 4, + steps: [complete("stop-openbot"), complete("stop-intelligence")], + }); + expect( + rowStatus(view, "Stopping Intelligence").getByText("Complete"), + ).toBeTruthy(); + // Only the catch-up call; nothing that starts or stops anything. + expect(invokeCalls.map((call) => call.command)).toEqual(["quit_status"]); +}); + +test("Stop OpenBot and Show OpenBot say what they are doing at once", async () => { + setupConnectedLocalStack( + "/tmp/local-eval-stop-status", + localStatus("ready", "valid"), + ); + const stopping = deferred(); + const showing = deferred(); const previous = invokeHandler; + let shows = 0; invokeHandler = async (command, args) => { - if (command === "prepare_installation") return preparation.promise; + if (command === "stop_stack") return stopping.promise; + // The first show is the relaunch's own handover. + if (command === "show_openbot" && shows++ > 0) return showing.promise; return previous(command, args); }; const view = await renderApp(); - await enterInstallation(view); - expect(view.getByRole("heading", { name: "Install OpenBot" })).toBeTruthy(); - expect(view.getByLabelText("Where OpenBot lives")).toHaveProperty( - "value", - "/tmp/install-before-signin", - ); - expect( - view.queryByRole("button", { name: /Sign in|Continue to sign in/i }), - ).toBeNull(); - await userEvent.click(view.getByRole("button", { name: "Install OpenBot" })); - expect(installationCalls()).toEqual([ - { - command: "prepare_installation", - args: { - root: "/tmp/install-before-signin", - harness: { id: "langgraph" }, - }, - }, - ]); - expect(view.getByLabelText("Where OpenBot lives")).toHaveProperty( + await view.findByRole("heading", { name: "OpenBot is running" }); + + await userEvent.click(view.getByRole("button", { name: "Show OpenBot" })); + expectWorking(view, "Loading OpenBot…"); + await act(async () => showing.resolve(null)); + expect(view.queryByText("Loading OpenBot…")).toBeNull(); + + await userEvent.click(view.getByRole("button", { name: "Stop OpenBot" })); + expectWorking(view, "Stopping OpenBot…"); + expect(view.getByRole("button", { name: "Stop OpenBot" })).toHaveProperty( "disabled", true, ); - expect(view.getByRole("button", { name: "Back" })).toHaveProperty( - "disabled", - true, + await act(async () => stopping.resolve(null)); + await view.findByRole("heading", { name: "OpenBot is stopped" }); + expect(view.queryByText("Stopping OpenBot…")).toBeNull(); +}); + +test("the menu's dashboard opening shows on a setup screen with a spinner, then how it went", async () => { + setupConnectedLocalStack( + "/tmp/local-eval-menu-opening", + localStatus("ready", "valid"), ); - expect(view.queryByRole("heading", { name: "Connect your AI" })).toBeNull(); - expect( - invokeCalls.some((call) => - /sign_in|start_stack|^providers$/.test(call.command), - ), - ).toBe(false); - await act(async () => { - for (const listener of progressListeners) - listener({ - payload: { - step: "dependencies", - ok: true, - detail: "Local runtime installed.", - }, - }); + const view = await renderApp(); + await view.findByRole("heading", { name: "OpenBot is running" }); + const emitOpening = async (payload: OpeningEvent) => { + await act(async () => { + for (const listener of openingListeners) listener({ payload }); + }); + }; + + await emitOpening({ + state: "opening", + said: "Opening the Intelligence dashboard…", }); - expect(view.getByText("Local runtime installed.")).toBeTruthy(); - expect( - view.queryByRole("button", { name: "Continue to sign in" }), - ).toBeNull(); - await act(async () => preparation.resolve()); + expectWorking(view, "Opening the Intelligence dashboard…"); + // The screen under it is unchanged. expect( - view.getByRole("heading", { name: "Installation complete" }), + view.getByRole("heading", { name: "OpenBot is running" }), ).toBeTruthy(); + + await emitOpening({ state: "opened", said: "Opened in your browser." }); + const opened = view + .getAllByRole("status") + .find((line) => line.textContent === "Opened in your browser."); + expect(opened?.querySelector(".step-spinner")).toBeNull(); + + await emitOpening({ + state: "failed", + said: "Your browser did not open. Try again.", + }); expect( view - .getByRole("button", { name: "Repair installation" }) - .closest("details"), - ).toHaveProperty("open", false); - expect(view.queryByRole("heading", { name: "Connect your AI" })).toBeNull(); + .getAllByRole("status") + .some( + (line) => line.textContent === "Your browser did not open. Try again.", + ), + ).toBe(true); + expect(view.queryByText("Opened in your browser.")).toBeNull(); +}); + +test("choosing a CopilotKit project says it is connecting until the key arrives", async () => { + const root = "/tmp/project-pick-status"; + setupRootConfiguration(root, async () => ({ + ...savedOpenAiConfiguration(), + saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, + launch: { harness: { id: "mastra" } }, + })); + const key = deferred(); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "start_stack") + throw { + said: "That saved CopilotKit connection is no longer available. Sign in again or enter a project key.", + connection: "intelligence", + }; + if (command === "begin_intelligence_sign_in") + return "https://signin.example/fixture"; + if (command === "finish_intelligence_sign_in") + return [{ id: "fixture", name: "Existing project" }]; + if (command === "intelligence_key_for") return key.promise; + return previous(command, args); + }; + const view = await renderApp(); await userEvent.click( - view.getByRole("button", { name: "Continue to sign in" }), + await view.findByRole("button", { name: "Sign in to CopilotKit again" }), ); - expect(view.getByRole("heading", { name: "Connect your AI" })).toBeTruthy(); + await userEvent.click( + await view.findByRole("button", { name: "Existing project" }), + ); + expectWorking(view, "Connecting OpenBot to Existing project…"); + await act(async () => key.resolve("refreshed-intelligence-key")); + expect( + view.queryByText("Connecting OpenBot to Existing project…"), + ).toBeNull(); +}); + +test("relaunch's Starting and Opening screens use the standard setup column", async () => { + setupConnectedLocalStack( + "/tmp/local-eval-relaunch-layout", + localStatus("ready", "valid"), + ); + const status = deferred(); + const previous = invokeHandler; + invokeHandler = async (command, args) => + command === "run_local_eval" ? status.promise : previous(command, args); + const view = await renderApp(); + const starting = await view.findByRole("heading", { + name: "Starting OpenBot", + }); + expectStandardSheet(starting); + const sheet = starting.closest(".sheet") as HTMLElement; + expect( + await within(sheet).findByRole("list", { name: "Setup progress" }), + ).toBeTruthy(); + expect(within(sheet).getByRole("button", { name: "Cancel" })).toBeTruthy(); + await act(async () => status.resolve(localStatus("ready", "valid"))); + await view.findByRole("heading", { name: "OpenBot is running" }); + await userEvent.click(view.getByRole("button", { name: "Stop OpenBot" })); + expectStandardSheet( + await view.findByRole("heading", { name: "OpenBot is stopped" }), + ); + cleanup(); + + const reopened = "/tmp/reopen-layout"; + const configuration = deferred>(); + setupRootConfiguration(reopened, async () => configuration.promise); + const reopening = invokeHandler; + invokeHandler = async (command, args) => + command === "selected_root" ? reopened : reopening(command, args); + const opening = await renderApp(); + expectStandardSheet( + opening.getByRole("heading", { name: "Getting OpenBot ready" }), + ); + await act(async () => configuration.resolve(savedOpenAiConfiguration())); +}); + +function setupConnectedLocalStack( + root: string, + status: unknown, + overrides: Record = {}, +) { + setupRootConfiguration(root, async () => ({ + ...savedOpenAiConfiguration(), + saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, + launch: { harness: { id: "mastra" } }, + localEval: { + stack: "local-abc123", + ownership: "created", + phase: "installed", + connected: true, + }, + ...overrides, + })); + withLocalCli({ + set_up_local_eval: status, + connect_local_eval: localConnection, + run_local_eval: status, + stop_stack: null, + }); +} + +test("relaunch starts a connected local stack that reports ready and valid", async () => { + setupConnectedLocalStack( + "/tmp/local-eval-ready", + localStatus("ready", "valid"), + ); + const view = await renderApp(); + await view.findByRole("heading", { name: "OpenBot is running" }); + // A recorded, connected stack goes straight to waiting. + expect(localSequence()).toEqual([ + "run_local_eval", + "start_stack", + "show_openbot", + ]); + expect(getStartStackPayload()).toMatchObject({ + root: "/tmp/local-eval-ready", + apiUrl: "http://localhost:64770", + gatewayWsUrl: "ws://localhost:64772", + apiKey: "", + }); + // Stopped by choice: nothing starts again on its own in this session. + await userEvent.click(view.getByRole("button", { name: "Stop OpenBot" })); + await view.findByRole("heading", { name: "OpenBot is stopped" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect( + invokeCalls.filter((call) => call.command === "start_stack"), + ).toHaveLength(1); + expect(view.getByRole("button", { name: "Start OpenBot" })).toBeTruthy(); +}); + +test("relaunch never starts a local stack that is not exactly ready and valid", async () => { + for (const [readiness, licence, said] of [ + [ + "unknown", + "unknown", + "Intelligence started. Its licence and readiness are not reported yet.", + ], + ["ready", "expired", "The Intelligence evaluation licence has expired."], + ["degraded", "valid", "Intelligence is running but not healthy."], + ]) { + setupConnectedLocalStack( + `/tmp/local-eval-${readiness}-${licence}`, + localStatus(readiness, licence), + ); + const view = await renderApp(); + await view.findByText(said); + expect(view.getByRole("button", { name: "Retry" })).toBeTruthy(); + expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( + false, + ); + cleanup(); + invokeCalls = []; + } +}); + +test("relaunch waits for Start when auto-start is off, interrupted, or the stack is not connected", async () => { + for (const [name, overrides, failure] of [ + ["auto-start-off", { autoStart: false }, null], + [ + "not-connected", + { + localEval: { + stack: "local-abc123", + ownership: "created", + phase: "installed", + }, + }, + null, + ], + ["interrupted", {}, { said: "OpenBot stopped unexpectedly." }], + ] as const) { + setupConnectedLocalStack( + `/tmp/local-eval-${name}`, + localStatus("ready", "valid"), + overrides, + ); + if (failure) { + const previous = invokeHandler; + invokeHandler = async (command, args) => + command === "last_failure" ? failure : previous(command, args); + } + const view = await renderApp(); + await view.findByRole("heading", { name: "OpenBot is stopped" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(localSequence()).toEqual([]); + await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); + await waitFor(() => expect(localSequence()).toContain("start_stack")); + cleanup(); + invokeCalls = []; + } }); test("CopilotKit Back preserves the selected AI and connection settings", async () => { @@ -518,7 +1963,12 @@ test("provider sign-in retries and Back reuse the completed local installation", await userEvent.click( view.getByRole("button", { name: "Sign in with OpenAI" }), ); - await view.findByText(/Signed in to OpenAI/); + // Signed in, so on to the next step by itself, after a second showing the tick. + await view.findByRole( + "heading", + { name: "Connect to CopilotKit" }, + { timeout: 4000 }, + ); expect(signIns).toBe(2); expect(installationCalls()).toHaveLength(1); }); @@ -926,7 +2376,9 @@ test("reopening a retained root waits for its saved setup without flashing the w return previous(command, args); }; const view = await renderApp(); - expect(view.getByRole("heading", { name: "Opening OpenBot" })).toBeTruthy(); + expect( + view.getByRole("heading", { name: "Getting OpenBot ready" }), + ).toBeTruthy(); expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); await act(async () => configuration.resolve(savedOpenAiConfiguration())); // A retained folder with no successful launch still needs the setup screens. @@ -959,6 +2411,69 @@ function useInterruptedShutdownSetup() { return { root, notice }; } +test("a local Intelligence stack left running at Quit is shown without holding auto-start", async () => { + useInterruptedShutdownSetup(); + const notice = { + said: "OpenBot could not stop the local Intelligence stack it started when it last quit.", + detail: "Intelligence stop failed: the CLI timed out", + }; + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "last_failure") return null; + if (command === "last_local_eval_stop_failure") return notice; + return previous(command, args); + }; + const view = await renderApp(); + await waitFor(() => + expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( + true, + ), + ); + expect(view.getByRole("alert").textContent).toContain(notice.said); +}); + +test("Stop that fails only for the local Intelligence stack still stops OpenBot", async () => { + useInterruptedShutdownSetup(); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "last_failure") return null; + if (command === "stop_stack") + throw "Intelligence stop failed: the CLI timed out"; + return previous(command, args); + }; + const view = await renderApp(); + await userEvent.click( + await view.findByRole("button", { name: "Stop OpenBot" }), + ); + await waitFor(() => + expect(view.queryByRole("button", { name: "Stop OpenBot" })).toBeNull(), + ); + expect(view.getByRole("alert").textContent).toContain( + "OpenBot stopped, but could not stop the local Intelligence stack it started.", + ); +}); + +test("Stop that fails for OpenBot itself keeps OpenBot running", async () => { + useInterruptedShutdownSetup(); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "last_failure") return null; + if (command === "stop_stack") + throw "Compose down failed: busy\nIntelligence stop failed: the CLI timed out"; + return previous(command, args); + }; + const view = await renderApp(); + await userEvent.click( + await view.findByRole("button", { name: "Stop OpenBot" }), + ); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + "Compose down failed: busy", + ), + ); + expect(view.getByRole("button", { name: "Stop OpenBot" })).toBeTruthy(); +}); + test("an interrupted shutdown restores saved setup for explicit recovery", async () => { const { root, notice } = useInterruptedShutdownSetup(); const view = await renderApp(); @@ -1063,7 +2578,7 @@ test.each(["supervisor", "windows"])( if (blocker === "windows" && command === "windows_blocker") return "wsl-absent"; if (command === "windows_blocker_instruction") - return "Install WSL before continuing."; + return "Install WSL: run `wsl --install`."; return previous(command, args); }; const view = await renderApp(); @@ -1080,7 +2595,7 @@ test.each(["supervisor", "windows"])( view.getByText( blocker === "supervisor" ? "The supervisor stopped retrying." - : "Install WSL before continuing.", + : "wsl --install", ), ).toBeTruthy(); }, @@ -1438,20 +2953,43 @@ test("a failed Windows blocker instruction is visible instead of an empty blocke expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); }); +test("a restart still owed from a Windows fix comes before any check or setup", async () => { + setupRootConfiguration("/tmp/openbot-windows-restart-test", async () => + emptyConfiguration(), + ); + const setupHandler = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "windows_restart_pending") return true; + return setupHandler(command, args); + }; + const view = await renderApp(); + expect( + await view.findByRole("heading", { name: "Restart Windows to finish" }), + ).toBeTruthy(); + expect(invokeCalls.some((call) => call.command === "windows_blocker")).toBe( + false, + ); + expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); +}); + test("a successfully detected missing WSL feature keeps its setup instruction", async () => { setupRootConfiguration("/tmp/openbot-windows-detection-test", async () => emptyConfiguration(), ); const setupHandler = invokeHandler; const instruction = - "Run wsl --install, restart Windows, and start OpenBot again."; + "Run `wsl --install`, restart Windows, and start OpenBot again."; invokeHandler = async (command, args) => { if (command === "windows_blocker") return "wsl-absent"; if (command === "windows_blocker_instruction") return instruction; return setupHandler(command, args); }; const view = await renderApp(); - expect(await view.findByText(instruction)).toBeTruthy(); + expect(await view.findByText("wsl --install")).toBeTruthy(); + expect( + view.getByRole("heading", { name: "Turn on Windows Subsystem for Linux" }), + ).toBeTruthy(); + expect(view.getByRole("button", { name: "Copy" })).toBeTruthy(); expect(view.queryByRole("alert")).toBeNull(); expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); }); @@ -1473,11 +3011,13 @@ test("disabled Virtual Machine Platform displays its feature-specific fix and bl return setupHandler(command, args); }; const view = await renderApp(); - expect(await view.findByText(instruction)).toBeTruthy(); expect( - view.getByRole("heading", { - name: "Virtual Machine Platform is switched off", - }), + await view.findByText( + "dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart", + ), + ).toBeTruthy(); + expect( + view.getByRole("heading", { name: "Turn on Virtual Machine Platform" }), ).toBeTruthy(); expect(view.queryByRole("alert")).toBeNull(); expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); @@ -1789,9 +3329,10 @@ test("Change the model after an Ask failure stops the stack and reaches the prov if (command === "ask_the_bot") { throw { said: "The model could not answer.", detail: "401" }; } - if (command === "stop_stack") return null; + if (command === "stop_stack") return stopping.promise; throw new Error(`unexpected command ${command}`); }; + const stopping = deferred(); const view = await renderApp(); @@ -1814,6 +3355,13 @@ test("Change the model after an Ask failure stops the stack and reaches the prov await userEvent.click( await view.findByRole("button", { name: "Change the model" }), ); + // Stopping takes a while; it is said at once, and nothing else can be pressed meanwhile. + expectWorking(view, "Stopping OpenBot…"); + expect(view.getByRole("button", { name: "Change the model" })).toHaveProperty( + "disabled", + true, + ); + await act(async () => stopping.resolve(null)); await waitFor(() => expect(invokeCalls.some((call) => call.command === "stop_stack")).toBe( @@ -2875,16 +4423,20 @@ for (const provider of [ ); } } - await view.findByText( - new RegExp( - session === "saved" - ? `A saved ${provider.name} sign-in will be checked` - : `Signed in to ${provider.name}`, - ), - ); - await userEvent.click(view.getByRole("button", { name: "Continue" })); + // A fresh sign-in moves on by itself; a saved one is only offered, so it waits for Continue. + if (session === "saved") { + await view.findByText( + new RegExp(`A saved ${provider.name} sign-in will be checked`), + ); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + } await userEvent.click( - await view.findByRole("button", { name: "Start OpenBot" }), + await view.findByRole( + "button", + { name: "Start OpenBot" }, + // A fresh sign-in shows its tick for a second before it moves on. + { timeout: 4000 }, + ), ); const payload = getStartStackPayload(); @@ -3142,6 +4694,30 @@ test("Start credential failures do not expose a restore action", async () => { ).toEqual([]); }); +test("a hosted Ask screen has no Intelligence dashboard link and leaves the menus alone", async () => { + useCompatibleEndpointSetup({}); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "ask_the_bot") return "42"; + return previous(command, args); + }; + const view = await enterCompatibleEndpoint("https://models.example/v1"); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); + await userEvent.click(await view.findByRole("button", { name: "Ask" })); + await view.findByText("42"); + expect( + view.queryByRole("button", { name: "View in Intelligence" }), + ).toBeNull(); + expect( + invokeCalls.filter((call) => + ["offer_local_intelligence", "open_local_intelligence"].includes( + call.command, + ), + ), + ).toEqual([]); +}); + test("the Enter that finishes a composed character does not ask the Bot", async () => { useCompatibleEndpointSetup({}); const previous = invokeHandler; diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index ad54068c62..b77187867c 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -2,13 +2,30 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useRef, useState } from "react"; import { Ask } from "./Ask"; +import { CopilotKitAccount } from "./CopilotKitAccount"; import { DatabaseReset } from "./DatabaseReset"; +import { useWindowFitsContent } from "./fitWindow"; import { DEFAULT_HARNESS, type HarnessChoice, HarnessPicker, } from "./HarnessPicker"; import { isHttpEndpointUrl } from "./http-endpoint-url"; +import { IntelligenceOpening } from "./IntelligenceOpening"; +import { + IntelligenceChoice, + type IntelligenceMode, + LocalIntelligence, + LocalStatus, + useLocalSequence, +} from "./LocalIntelligence"; +import { + intelligenceStopProblem, + listLocalEval, + offerLocalIntelligence, + openLocalIntelligence, + splitStopFailure, +} from "./localEvalCli"; import { OrganizationSignIn } from "./OrganizationSignIn"; import { asProblem, Failure, type Problem } from "./Problem"; import { @@ -17,6 +34,14 @@ import { ProviderPicker, recordedModel, } from "./ProviderPicker"; +import { Sections } from "./Sections"; +import { + type Progress, + plainInstallSteps, + SetupProgress, + StatusLine, +} from "./SetupProgress"; +import { ShuttingDown, useQuitStatus } from "./ShuttingDown"; import { harnessChoiceEvent, modelChoiceEvent, @@ -24,6 +49,7 @@ import { type SetupStep, } from "./telemetry"; import { Welcome } from "./Welcome"; +import { type Blocker, WindowsBlocker, WindowsRestart } from "./WindowsBlocker"; type EngineStatus = { engine: "docker" | "podman" | null; @@ -32,27 +58,20 @@ type EngineStatus = { detail: string; }; -type Blocker = - | "wsl-absent" - | "wsl-one" - | "virtual-machine-platform-disabled" - | "virtualization-disabled" - | "not-administrator"; - -type Progress = { - step: string; - ok: boolean; - detail: string; - running?: boolean; - downloadBytes?: number; -}; - type AlreadyConfigured = { values: Record; saved: NonNullable; launch?: { harness: HarnessChoice | null } | null; installation?: { harness: HarnessChoice | null } | null; autoStart?: boolean; + /** The local evaluation's checkpoint, when this installation has one. */ + localEval?: { + stack: string; + ownership: "created" | "shared"; + phase: string; + /** OpenBot holds the key `local connect` gave it for this stack. */ + connected?: boolean; + }; }; function installationKeyFor(root: string, harness: HarnessChoice | null) { @@ -67,22 +86,41 @@ const MANAGED_INTELLIGENCE_API_URL = "https://api.intelligence.copilotkit.ai"; const MANAGED_INTELLIGENCE_GATEWAY_WS_URL = "wss://realtime.intelligence.copilotkit.ai"; -/** - * What the last screen offers to ask, mirroring `ask::SUGGESTED`. - * - * Two copies of one sentence, and a test in `ask.rs` pins what it has to contain. The window needs - * it before it calls anything, and the Rust side needs it for the case where somebody clears the - * field, so neither can be the only one that has it. - */ -const SUGGESTED_QUESTION = "What is 17 times 23?"; - export function App() { + useWindowFitsContent(); + return ( + <> + + + + ); +} + +function Setup() { const [engine, setEngine] = useState(null); const [blocker, setBlocker] = useState(null); + // A Windows fix made earlier that only counts once Windows has restarted, and it has not. + const [restartPending, setRestartPending] = useState(false); const [blockerFailure, setBlockerFailure] = useState(null); const [instruction, setInstruction] = useState(""); const [root, setRoot] = useState(""); const [reuseIntelligence, setReuseIntelligence] = useState(false); + // Offered only when this build has an Intelligence CLI to run; a local stack is its own mode, + // not another setup step. + const [localAvailable, setLocalAvailable] = useState(false); + // The CLI answered either way, so the first screen knows which choice it offers. + const [localChecked, setLocalChecked] = useState(false); + // Null until somebody presses Continue on the choice, or a recorded local stack implies it. + // Held per root in memory, so returning to step 4 does not ask again. + const [intelligenceChoice, setIntelligenceMode] = + useState(null); + // The choice screen's selection, before Continue commits it. + const [pickedIntelligence, setPickedIntelligence] = + useState(null); + const [localStack, setLocalStack] = useState(null); + const [localConnected, setLocalConnected] = useState(false); + // A relaunch found a connected stack it may start, as a hosted launch resumes. + const [localAutoStart, setLocalAutoStart] = useState(false); const [apiKey, setApiKey] = useState(""); const [organizationAuthorityUrl, setOrganizationAuthorityUrl] = useState< string | null @@ -99,8 +137,32 @@ export function App() { id: DEFAULT_HARNESS, }); const [model, setModel] = useState(null); + /* + * The model as of the latest choice, without waiting for a render. + * + * Local Intelligence starts OpenBot from its own sequence, a tick after the go-ahead. A choice + * made by a click has rendered by then; one made from an effect (a sign-in that finished and moved + * on by itself) may not have, and Start ran with the model from before it: null. + */ + const chosenModel = useRef(model); + // Follows every other change to the model, once committed; a choice sets it at once (below). + useEffect(() => { + chosenModel.current = model; + }, [model]); /** Model credentials a previous run already wrote, so the provider screen arrives filled in. */ const [alreadyHeld, setAlreadyHeld] = useState({}); + // A hosted connection somebody chose earlier: a saved key, or their own server's address. + const hostedChosen = + Boolean(alreadyHeld.saved?.intelligenceApiKey) || + Boolean(alreadyHeld.INTELLIGENCE_API_URL); + // Before a choice, the preselection: local only when the CLI answered and nothing hosted is saved. + const intelligenceMode = + intelligenceChoice ?? + (localAvailable && !hostedChosen ? "local" : "hosted"); + // Step 4 asks first, and runs nothing until Continue. Only when the first screen did not ask. + const choosingIntelligence = localAvailable && intelligenceChoice === null; + // Local mode: three steps, and nothing a person reads about the software underneath. + const localFlow = localAvailable && intelligenceChoice === "local"; const [projects, setProjects] = useState< { id: string; name: string }[] | null >(null); @@ -164,6 +226,13 @@ export function App() { * out at all. */ const [signInUrl, setSignInUrl] = useState(null); + // The project whose key is being made, named while it is. + const [connectingProject, setConnectingProject] = useState( + null, + ); + // Running-screen actions that wait on the native side, said while they do. + const [stopping, setStopping] = useState(false); + const [showing, setShowing] = useState(false); async function signInToCopilotKit() { await cancelSelfHostedSignIn(); @@ -188,6 +257,9 @@ export function App() { async function pickProject(id: string) { setSigningIn(true); + setConnectingProject( + projects?.find((project) => project.id === id)?.name ?? null, + ); setFailure(null); const selfHosted = intelligenceSignInKind === "self-hosted"; const attempt = selfHostedAttempt.current; @@ -209,6 +281,7 @@ export function App() { } finally { if (!selfHosted || selfHostedAttempt.current === attempt) { setSigningIn(false); + setConnectingProject(null); if (selfHosted) { selfHostedActiveRef.current = false; setSelfHostedActive(false); @@ -238,6 +311,26 @@ export function App() { } } + function chooseIntelligence(mode: IntelligenceMode | null) { + setIntelligenceMode(mode); + } + + function continueWithIntelligence() { + const mode = pickedIntelligence ?? intelligenceMode; + chooseIntelligence(mode); + // Continue with local is the go-ahead. + if (mode === "local" && modelCanStart()) local.start(); + // Hosted begins at the sign-in, unless a connection is already saved to be checked at Start. + if ( + mode === "hosted" && + !hostedChosen && + !apiKey.trim() && + !reuseIntelligence + ) { + void signInToCopilotKit(); + } + } + const [step, setStep] = useState("welcome"); const [apiUrl, setApiUrl] = useState(MANAGED_INTELLIGENCE_API_URL); const [wsUrl, setWsUrl] = useState(MANAGED_INTELLIGENCE_GATEWAY_WS_URL); @@ -246,6 +339,8 @@ export function App() { const [resuming, setResuming] = useState(false); const [checkingResume, setCheckingResume] = useState(true); const savedLaunch = useRef(null); + // An interrupted run needs an explicit Start, local Intelligence included. + const interruptedAtLaunch = useRef(false); const [refreshIntelligence, setRefreshIntelligence] = useState(false); const [preparation, setPreparation] = useState<{ key: string; @@ -254,6 +349,23 @@ export function App() { const installationKey = installationKeyFor(root, harness); const installationReady = preparation?.key === installationKey && preparation.status === "complete"; + /* + * A local first run sets Intelligence up alongside the OpenBot install, not after it: from the + * moment the install reports the container engine ready (Intelligence runs on the same Docker, + * and the install is what starts or installs it), or at once when the install is already done. + * Queued by the CopilotKit account step, which runs first. + */ + const intelligenceQueued = useRef(false); + const engineReady = steps.some( + (row) => row.step === "engine" && row.ok && !row.running, + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: runs when the engine or install changes. + useEffect(() => { + if (!intelligenceQueued.current) return; + if (!engineReady && !installationReady) return; + intelligenceQueued.current = false; + local.prepare(); + }, [engineReady, installationReady]); const returningToInstallation = savedLaunch.current === installationKey; const [running, setRunning] = useState(false); const visibleSetupStep = @@ -295,7 +407,35 @@ export function App() { } if (problem.connection === "organization") setRefreshOrganization(true); }, []); - const displayedFailure = failure ?? recoveryFailure; + // A local Intelligence stack OpenBot could not stop. Shown, but it never holds a start. + const [intelligenceNotice, setIntelligenceNotice] = useState( + null, + ); + const displayedFailure = failure ?? recoveryFailure ?? intelligenceNotice; + // Held here, above the screens, so changing screens never cancels it. + const local = useLocalSequence({ + root, + stack: localStack, + connected: localConnected, + onStart: (startable) => start(chosenModel.current, startable), + onRecorded: (stack, connected) => { + setLocalStack(stack); + if (connected) setLocalConnected(true); + }, + }); + // Local mode with a recorded stack: the dashboard can be opened, from here and from the menus. + const intelligenceOffered = localFlow && localStack !== null; + // What the menus were last told. Starts hidden, so a hosted run never calls. + const offeredToMenus = useRef({ root: "", offered: false }); + useEffect(() => { + const last = offeredToMenus.current; + if (last.offered === intelligenceOffered && last.root === root) return; + if (!last.offered && !intelligenceOffered) return; + offeredToMenus.current = { root, offered: intelligenceOffered }; + offerLocalIntelligence(root, intelligenceOffered).catch(() => {}); + }, [root, intelligenceOffered]); + // Stop OpenBot was chosen in this window: nothing local starts again on its own. + const stoppedByChoice = useRef(false); const credentialContext = useRef([ root, model, @@ -334,6 +474,11 @@ export function App() { setOrganizationAuthorityUrl(null); setApiKey(""); setReuseIntelligence(false); + setIntelligenceMode(null); + setPickedIntelligence(null); + setLocalStack(null); + setLocalConnected(false); + setLocalAutoStart(false); setApiUrl(MANAGED_INTELLIGENCE_API_URL); setWsUrl(MANAGED_INTELLIGENCE_GATEWAY_WS_URL); setAlreadyHeld({}); @@ -358,6 +503,11 @@ export function App() { if (values.INTELLIGENCE_GATEWAY_WS_URL) setWsUrl(values.INTELLIGENCE_GATEWAY_WS_URL); setAlreadyHeld({ ...values, saved }); + if (configured.localEval) { + setLocalStack(configured.localEval.stack); + setLocalConnected(configured.localEval.connected === true); + setIntelligenceMode("local"); + } setOrganizationAuthorityUrl( values.OPENBOT_ORGANIZATION_AUTH_URL ?? null, ); @@ -376,8 +526,15 @@ export function App() { invoke("detect_engine") .then(setEngine) .catch(() => undefined); - const windowsReady = invoke("windows_blocker") - .then(async (found) => { + // A restart still owed comes first: a feature switched on reads as on before it runs. + const windowsReady = invoke("windows_restart_pending") + .catch(() => false) + .then(async (pending) => { + if (pending) { + setRestartPending(true); + return false; + } + const found = await invoke("windows_blocker"); setBlocker(found); if (found) { setInstruction( @@ -392,10 +549,28 @@ export function App() { setBlockerFailure(asProblem(error)); return false; }); + // A build without a pinned CLI answers `unavailable`, and local Intelligence is not offered. + listLocalEval() + .listing.then(() => { + if (active) setLocalAvailable(true); + }) + .catch(() => {}) + .finally(() => { + if (active) setLocalChecked(true); + }); + // Read once and kept apart from `last_failure`, which would hold auto-start. + invoke("last_local_eval_stop_failure") + .then((found) => { + if (active && found) setIntelligenceNotice(found); + }) + .catch(() => {}); // A supervisor that stopped retrying must stay stopped when it returns to this window. const interruptedRun = invoke("last_failure") .then((found) => { - if (found) setRecoveryFailure(found); + if (found) { + interruptedAtLaunch.current = true; + setRecoveryFailure(found); + } return found; }) .catch(() => null); @@ -454,6 +629,11 @@ export function App() { const resumedHarness = configured.launch.harness; // An interrupted run needs an explicit Start, with its saved setup still available. if (interrupted || configured.autoStart === false) return; + // Local Intelligence must report ready and licensed first, which only its screen asks. + if (configured.localEval) { + if (configured.localEval.connected) setLocalAutoStart(true); + return; + } setBusy(true); setResuming(true); try { @@ -516,8 +696,10 @@ export function App() { }; }, [loadConfiguredRoot, recoverFromStart]); - async function install() { - if (busy || !root.trim()) return; + /** True when the installation completed. */ + async function install(nextHarness = harness): Promise { + if (busy || !root.trim()) return false; + const installationKey = installationKeyFor(root, nextHarness); setBusy(true); setFailure(null); setSteps([ @@ -530,23 +712,66 @@ export function App() { ]); setPreparation({ key: installationKey, status: "preparing" }); try { - await invoke("prepare_installation", { root: root.trim(), harness }); + await invoke("prepare_installation", { + root: root.trim(), + harness: nextHarness, + }); setSteps((current) => settleProgress(current)); setPreparation({ key: installationKey, status: "complete" }); + return true; } catch (error) { const problem = asProblem(error); setSteps((current) => settleProgress(current, problem)); setPreparation({ key: installationKey, status: "failed" }); setFailure(problem); + return false; } finally { setBusy(false); } } - async function start(nextModel = model) { + /* + * Local mode installs on the first screen's click, with the default Bot in the default folder: + * one installation per computer. It needs no CopilotKit account, so it runs while the person + * signs in; only Intelligence waits for the agreement. Once it completes, Intelligence sets up + * behind the AI provider screen rather than after it. + */ + const localInstall = useRef | null>(null); + function beginLocalInstall(): Promise { + if (localInstall.current) return localInstall.current; + const choice = { id: DEFAULT_HARNESS }; + setHarness(choice); + const started = install(choice).then((ok) => { + // A failed install is started again by the next Install, not remembered. + if (!ok) localInstall.current = null; + return ok; + }); + localInstall.current = started; + return started; + } + + /** Shows the install until it completes, then the AI provider screen. */ + async function installLocally() { + setStep("install"); + if (await beginLocalInstall()) { + setSteps([]); + setStep("model"); + } + } + + /** True when OpenBot started. */ + async function start( + nextModel = model, + local: { apiUrl: string; gatewayWsUrl: string } | null = null, + ): Promise { if (!installationReady) { setStep("install"); - return; + return false; + } + // Local Intelligence starts only from a stack its CLI reports ready and licensed. + if (intelligenceMode === "local" && !local) { + setStep("connect"); + return false; } setBusy(true); setFailure(null); @@ -554,9 +779,10 @@ export function App() { try { await invoke("start_stack", { root: root.trim(), - apiUrl, - gatewayWsUrl: wsUrl, - apiKey, + apiUrl: local?.apiUrl ?? apiUrl, + gatewayWsUrl: local?.gatewayWsUrl ?? wsUrl, + // Empty for a local stack: Start takes the key `local connect` gave OpenBot from the vault. + apiKey: local ? "" : apiKey, // The whole answer from the model screen, so the Rust side decides which keys that // implies. Sending a bare key here is what made `ANTHROPIC_API_KEY` and a plan token // expressible at the same time. @@ -571,7 +797,8 @@ export function App() { setSteps((current) => settleProgress(current)); setRunning(true); setRecoveryFailure(null); - // Refreshing credentials does not turn an existing installation into a first run. + // Refreshing credentials does not turn an existing installation into a first run. A first + // local run asks its question too: Intelligence reporting ready says nothing about the model. if ( savedLaunch.current === installationKey || organizationAuthorityUrl?.trim() @@ -580,7 +807,7 @@ export function App() { setStep("connect"); setRefreshIntelligence(false); await invoke("show_openbot"); - return; + return true; } /* * One screen short of the handover, on purpose. @@ -592,8 +819,10 @@ export function App() { * question, and the handover waits for an answer to come back. */ setStep("ask"); + return true; } catch (error) { recoverFromStart(error); + return false; } finally { setBusy(false); invoke("detect_engine") @@ -639,18 +868,51 @@ export function App() { return model.login === "plan" || model.login === "api-key"; } - async function stop() { - setBusy(true); + /** True when OpenBot stopped, even if the local Intelligence stack it started did not. */ + async function stopStack(): Promise { try { await invoke("stop_stack", { root }); - setRunning(false); - setRecoveryFailure(null); - setStep("connect"); - setRefreshIntelligence(false); + setIntelligenceNotice(null); + return true; } catch (error) { + const stopped = splitStopFailure(error); + if (stopped.openBot === null && stopped.intelligence !== null) { + setIntelligenceNotice(intelligenceStopProblem(stopped.intelligence)); + return true; + } setFailure(asProblem(error)); + return false; + } + } + + async function stop() { + setBusy(true); + setStopping(true); + // Stopped by choice: nothing starts again on its own in this session. + setLocalAutoStart(false); + stoppedByChoice.current = true; + try { + if (await stopStack()) { + local.reset(); + setRunning(false); + setRecoveryFailure(null); + setStep("connect"); + setRefreshIntelligence(false); + } } finally { setBusy(false); + setStopping(false); + } + } + + async function showOpenBot() { + setShowing(true); + try { + await invoke("show_openbot"); + } catch (error) { + recoverFromStart(error); + } finally { + setShowing(false); } } @@ -658,17 +920,42 @@ export function App() { setBusy(true); setFailure(null); try { - await invoke("stop_stack", { root }); - setRunning(false); - setRecoveryFailure(null); - setStep("model"); - } catch (error) { - setFailure(asProblem(error)); + if (await stopStack()) { + local.reset(); + setRunning(false); + setRecoveryFailure(null); + setStep("model"); + } } finally { setBusy(false); } } + /* + * Starts local Intelligence without a click where a hosted setup would: a recorded stack on a + * first run that was not interrupted, or a connected one on relaunch with auto-start on. Never + * after Stop OpenBot. + */ + const localAuto = + !checkingResume && + !running && + !stoppedByChoice.current && + step === "connect" && + intelligenceMode === "local" && + !choosingIntelligence && + modelCanStart() && + (returningToInstallation + ? !refreshIntelligence && localAutoStart + : !interruptedAtLaunch.current); + // biome-ignore lint/correctness/useExhaustiveDependencies: when it turns on. + useEffect(() => { + if (localAuto) local.start(); + }, [localAuto]); + + // Quit outranks every screen: the app is closing, whatever was on it. + const quitting = useQuitStatus(false); + if (quitting) return ; + // Nothing else on this screen can be done until the machine allows it, so nothing else is shown. if (blockerFailure) { return ( @@ -679,28 +966,34 @@ export function App() { ); } + if (restartPending) return ; + if (blocker) { return ( -

-

OpenBot needs one thing first

-
-

{titleFor(blocker)}

-

{instruction}

-
-
+ setRestartPending(true)} + /> ); } - if (resuming || checkingResume) { + if ( + resuming || + checkingResume || + (!running && step === "welcome" && !localChecked) + ) { return (
-

{resuming ? "Starting OpenBot" : "Opening OpenBot"}

-

- {resuming - ? "Opening your saved setup…" - : "Checking your saved setup…"} -

- +
+

{resuming ? "Starting OpenBot" : "Getting OpenBot ready"}

+

+ {resuming + ? "Loading your saved setup…" + : "Checking your saved setup…"} +

+ +
); } @@ -742,7 +1035,23 @@ export function App() { if (!running && step === "welcome") { return (
- setStep("harness")} /> + setStep("harness")} + local={ + localAvailable + ? { + onInstall: () => { + chooseIntelligence("local"); + // The default Bot, recorded as its Continue would. + recordSetupEvent(harnessChoiceEvent(DEFAULT_HARNESS)); + // The account is the first screen; OpenBot installs behind it meanwhile. + setStep("account"); + void beginLocalInstall(); + }, + } + : undefined + } + /> {displayedFailure && }
); @@ -784,59 +1093,75 @@ export function App() { return (
-

Step 2 of 4

+

- {installationReady ? "Installation complete" : "Install OpenBot"} + {installationReady + ? "Installation complete" + : localFlow + ? "Installing OpenBot" + : "Install OpenBot"}

{installationReady ? "OpenBot’s local software is ready. Next, connect your AI and CopilotKit accounts." - : "Install the software OpenBot needs on this computer. This can take a few minutes. You’ll sign in after installation finishes."} + : localFlow + ? "OpenBot is installing what it needs on this computer. This can take a few minutes." + : "Install the software OpenBot needs on this computer. This can take a few minutes. You’ll sign in after installation finishes."}

- {!installationReady && engine?.responding && ( + {!localFlow && !installationReady && engine?.responding && (

Using {engine.engine === "docker" ? "Docker" : "Podman"} for local services.

)} -
- - { - configuredRunRef.current += 1; - setRoot(event.target.value); - setModel(null); - setPreparation(null); - setSteps([]); - clearRootScopedSavedState(); - }} - onBlur={(event) => loadConfiguredRoot(event.target.value)} - spellCheck={false} - /> -
- + {!localFlow && ( +
+ + { + configuredRunRef.current += 1; + setRoot(event.target.value); + setModel(null); + setPreparation(null); + setSteps([]); + clearRootScopedSavedState(); + }} + onBlur={(event) => loadConfiguredRoot(event.target.value)} + spellCheck={false} + /> +
+ )} + + {/* Intelligence sets itself up alongside, once the install has Docker answering. */} + {localFlow && } {displayedFailure && } - {busy &&

Installing local software…

} - {installationReady && ( + {busy && !localFlow && ( +

Installing local software…

+ )} + {installationReady && !localFlow && (
Installation options -
)}
- + {!(localFlow && busy) && ( + + )} {installationReady ? ( - ) : ( + ) : localFlow && busy ? null : (
); } + const localStarting = intelligenceMode === "local" && local.running; + if (!running && returningToInstallation && !refreshIntelligence) { return (
-

OpenBot is stopped

-

- Your installation and saved connections are ready to reopen. -

- - {displayedFailure && } - - - - {displayedFailure && ( - - )} -
- Installation options - -
+
+

{localStarting ? "Starting OpenBot" : "OpenBot is stopped"}

+

+ {localStarting + ? "Starting Intelligence and OpenBot on this computer." + : "Your installation and saved connections are ready to reopen."} +

+ {intelligenceMode === "local" && ( + + )} + {intelligenceMode !== "local" && } + {displayedFailure && } + {/* While a local step runs, its Cancel is the only control. */} + {(intelligenceMode === "hosted" || !localStarting) && ( +
+ {intelligenceMode === "hosted" && ( + + )} + {!localStarting && ( + <> + + {intelligenceMode !== "local" && ( + + )} + {displayedFailure && ( + + )} + + )} +
+ )} + {!localStarting && ( +
+ Installation options + +
+ )} +
+
+ ); + } + + /* + * Local Intelligence on step 4: its rows, and OpenBot starts by itself once the CLI reports it + * ready and valid. While a CLI call runs, Cancel is the only control; leaving is offered only + * once nothing runs. + */ + if (!running && !choosingIntelligence && intelligenceMode === "local") { + return ( +
+
+ {displayedFailure?.database_reset && ( + + )} + {!returningToInstallation && } +

Getting ready

+

+ OpenBot starts as soon as Intelligence is ready. This can take a few + minutes. +

+ + {displayedFailure && } + {!local.running && !busy && ( +
+ + {localAvailable && ( + + )} +
+ )} +
); } @@ -992,24 +1448,35 @@ export function App() { )} {!running && !returningToInstallation && ( -

Step 4 of 4

+ )}

{running && !displayedFailure ? "OpenBot is running" - : returningToInstallation - ? "Refresh your CopilotKit connection" - : "Connect to CopilotKit"} + : choosingIntelligence && !running + ? "Choose where Intelligence runs" + : returningToInstallation + ? "Refresh your CopilotKit connection" + : "Connect to CopilotKit"}

{running && !displayedFailure ? "The stack is up. OpenBot is in this window; the menu bar has it too, and stops it." - : returningToInstallation - ? "Update this connection to reopen your existing OpenBot." - : "Local installation is complete. Connect CopilotKit, then start OpenBot."} + : choosingIntelligence && !running + ? "OpenBot keeps your conversations in CopilotKit Intelligence. Nothing is set up until you continue." + : returningToInstallation + ? "Update this connection to reopen your existing OpenBot." + : "Local installation is complete. Connect CopilotKit, then start OpenBot."}

- {!running && ( + {!running && choosingIntelligence && ( + + )} + + {!running && !choosingIntelligence && intelligenceMode === "hosted" && (
{/* Sign in on the main path; paste behind the disclosure. @@ -1063,7 +1530,7 @@ export function App() {

{signInUrl}

-

Waiting for you to approve it…

+ Waiting for you to approve it… ) : projects ? ( <> @@ -1082,6 +1549,11 @@ export function App() { ))}
+ {connectingProject && ( + + Connecting OpenBot to {connectingProject}… + + )} {projects.length === 0 && ( <>

@@ -1162,6 +1634,12 @@ export function App() { )} + {/* Managed sign-in only: the self-hosted one says its own line below. */} + {signingIn && intelligenceSignInKind === "managed" && ( + + Opening CopilotKit sign-in in your browser… + + )} )} {/* @@ -1255,6 +1733,16 @@ export function App() { /> + {localAvailable && ( + + )} )} @@ -1279,6 +1767,8 @@ export function App() { )} + {stopping && Stopping OpenBot…} + {showing && Loading OpenBot…}

{!running && ( @@ -1319,11 +1806,15 @@ export function App() { type="button" className="quiet" onClick={stop} - disabled={busy} + disabled={busy || showing} > Stop OpenBot + ) : choosingIntelligence ? ( + ) : ( +
+ {asking && Asking…} {failure && } - {answer !== null && ( + {asked && (

Your Bot said

{answer}

)} + {asked && onViewIntelligence && ( + <> + + {opening && ( + Opening the Intelligence dashboard… + )} + {opened && ( + Opened in your browser. + )} + {viewFailure &&

{viewFailure.said}

} + + )} + {leavingSaid}
- {answer === null ? ( - ) : ( - )} {/* @@ -114,15 +231,13 @@ export function Ask({ )} - {answer !== null && ( + {asked && ( + )} + {agreement && ( + + )} + {seen.at === "failed" && ( + + )} +
+ + ); +} diff --git a/desktop/src/HarnessPicker.tsx b/desktop/src/HarnessPicker.tsx index fd26dece4c..3f0bb9fe05 100644 --- a/desktop/src/HarnessPicker.tsx +++ b/desktop/src/HarnessPicker.tsx @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; import { isHttpEndpointUrl } from "./http-endpoint-url"; import { Mark } from "./Mark"; +import { Sections } from "./Sections"; export type Harness = { id: string; @@ -86,7 +87,7 @@ export function HarnessPicker({ return (
-

Step 1 of 4

+

Your first Bot

{/* Written to the person who has to act, not about the situation. diff --git a/desktop/src/IntelligenceOpening.tsx b/desktop/src/IntelligenceOpening.tsx new file mode 100644 index 0000000000..c71317bb5d --- /dev/null +++ b/desktop/src/IntelligenceOpening.tsx @@ -0,0 +1,40 @@ +import { listen } from "@tauri-apps/api/event"; +import { useEffect, useState } from "react"; +import { StatusLine } from "./SetupProgress"; + +/** What the menu's "Open Intelligence dashboard" says, in fixed native words. */ +type Opening = { state: "opening" | "opened" | "failed"; said: string }; + +/** How long how it went stays on screen. */ +const SHOWN_FOR_MS = 6_000; + +/** + * The menu's dashboard opening, over whichever setup screen is showing. + * + * The menu can be chosen while the window shows anything, so this sits apart from every screen + * rather than in one of them, and never moves what is under it. + */ +export function IntelligenceOpening() { + const [opening, setOpening] = useState(null); + useEffect(() => { + const stop = listen("intelligence:opening", (event) => + setOpening(event.payload), + ); + return () => { + stop.then((unlisten) => unlisten()); + }; + }, []); + useEffect(() => { + if (!opening || opening.state === "opening") return; + const timer = setTimeout(() => setOpening(null), SHOWN_FOR_MS); + return () => clearTimeout(timer); + }, [opening]); + if (!opening) return null; + return ( +
+ + {opening.said} + +
+ ); +} diff --git a/desktop/src/LocalIntelligence.test.tsx b/desktop/src/LocalIntelligence.test.tsx new file mode 100644 index 0000000000..c593c174f6 --- /dev/null +++ b/desktop/src/LocalIntelligence.test.tsx @@ -0,0 +1,651 @@ +import { afterAll, afterEach, beforeAll, expect, mock, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, render, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useEffect } from "react"; +import type { LocalConnection, LocalReport } from "./localEvalCli"; + +type Invoke = (command: string, args?: unknown) => Promise; + +let invokeCalls: Array<{ command: string; args?: unknown }> = []; +let invokeHandler: Invoke = async () => { + throw new Error("invoke handler was not installed"); +}; + +mock.module("@tauri-apps/api/core", () => ({ + invoke: (command: string, args?: unknown) => { + invokeCalls.push({ command, args }); + return invokeHandler(command, args); + }, +})); + +const { LocalIntelligence, LocalStatus, useLocalSequence } = await import( + "./LocalIntelligence" +); + +beforeAll(() => GlobalRegistrator.register()); +afterEach(() => { + // Unmounting cancels a running step, so clear the record after it. + cleanup(); + invokeCalls = []; +}); +afterAll(() => GlobalRegistrator.unregister()); + +function report(overrides: Partial = {}): LocalReport { + return { + outcome: "completed", + stack: { + id: "local-abc123", + installedRelease: "0.13.14", + step: "ready", + urls: { + api: "http://localhost:64770", + dashboard: "http://cpk.localhost:64771", + gateway: "ws://localhost:64772", + }, + hasFailedStep: false, + updateInProgress: false, + observed: null, + }, + reason: null, + readiness: "unknown", + licence: "unknown", + licenceExpiresAt: null, + ...overrides, + }; +} + +const ready = report({ readiness: "ready", licence: "valid" }); +const starting = report({ readiness: "starting", licence: "valid" }); + +function connection(overrides: Partial = {}): LocalConnection { + return { + outcome: "completed", + stack: report().stack, + reason: null, + conflicted: false, + ...overrides, + }; +} + +const startableUrls = { + apiUrl: "http://localhost:64770", + gatewayWsUrl: "ws://localhost:64772", +}; + +const blockedOnTerms = report({ + outcome: "blocked", + stack: null, + reason: "eula-acceptance-required", +}); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +type Startable = { apiUrl: string; gatewayWsUrl: string }; + +type Props = { + stack?: string | null; + connected?: boolean; + /** Gives the go-ahead on arrival. Without it, Start OpenBot does. */ + auto?: boolean; + /** Runs up to ready on arrival, without the go-ahead, under its status line. */ + prepare?: boolean; + onStart?: (startable: Startable) => Promise; + pollDelays?: number[]; +}; + +/** The window's part: it holds the sequence, and a screen shows it. */ +function Holder(props: Props) { + const sequence = useLocalSequence({ + root: "/tmp/openbot", + stack: props.stack ?? null, + connected: props.connected ?? false, + onStart: props.onStart ?? (async () => true), + pollDelays: props.pollDelays ?? [0, 0, 0], + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: once, on arrival. + useEffect(() => { + if (props.prepare) sequence.prepare(); + else if (props.auto ?? true) sequence.start(); + }, []); + return ( + <> + {props.prepare && } + + + ); +} + +function view(props: Props = {}) { + return render(); +} + +/** Answers each command from its own queue; the last answer repeats. */ +function answers(queues: Record): Invoke { + return async (command) => { + const queue = queues[command]; + if (!queue) throw new Error(`unexpected ${command}`); + const next = queue.length > 1 ? queue.shift() : queue[0]; + if (next instanceof Error) throw next; + return next; + }; +} + +function statusCalls() { + return invokeCalls.filter( + (call) => + call.command === "run_local_eval" && + (call.args as { action: string }).action === "status", + ); +} + +function commands() { + return invokeCalls.map((call) => + call.command === "run_local_eval" + ? `${call.command}:${(call.args as { action: string }).action}` + : call.command, + ); +} + +function row(screen: ReturnType, name: string) { + return screen.getByRole("listitem", { name }); +} + +const SETUP = "Setting up Intelligence"; +const CONNECT = "Connecting OpenBot"; +const WAIT = "Waiting for Intelligence"; +const START = "Starting OpenBot"; + +test("the whole sequence runs by itself: set up, connect, wait for ready and valid, start", async () => { + invokeHandler = answers({ + set_up_local_eval: [report()], + connect_local_eval: [connection()], + run_local_eval: [starting, starting, ready], + }); + const started: Startable[] = []; + const screen = view({ + onStart: async (startable) => { + started.push(startable); + return true; + }, + }); + await waitFor(() => expect(started).toEqual([startableUrls])); + expect(commands()).toEqual([ + "set_up_local_eval", + "connect_local_eval", + "run_local_eval:status", + "run_local_eval:status", + "run_local_eval:status", + ]); + expect( + invokeCalls.find((call) => call.command === "set_up_local_eval")?.args, + ).toMatchObject({ root: "/tmp/openbot" }); + expect( + invokeCalls.find((call) => call.command === "connect_local_eval")?.args, + ).toMatchObject({ root: "/tmp/openbot" }); + for (const name of [SETUP, CONNECT, WAIT, START]) { + expect(row(screen, name).textContent).toContain("Complete"); + } + // Nothing to press: the installation is the consent. + expect(screen.queryByRole("button", { name: /Connect/ })).toBeNull(); +}); + +test("a recorded, connected stack skips straight to waiting", async () => { + invokeHandler = answers({ run_local_eval: [ready] }); + const started: Startable[] = []; + view({ + stack: "local-abc123", + connected: true, + onStart: async (startable) => { + started.push(startable); + return true; + }, + }); + await waitFor(() => expect(started).toEqual([startableUrls])); + expect(commands()).toEqual(["run_local_eval:status"]); + expect(statusCalls()[0]?.args).toMatchObject({ stack: "local-abc123" }); +}); + +const blockedOnSignIn = report({ + outcome: "blocked", + stack: null, + reason: "authentication-required", +}); + +const requiredTerms = { + status: "required", + accepted: false, + version: "developer-edition-2026-10-01-web", + url: "https://copilotkit.ai/legal/developer-edition", + organizationName: "Acme", +}; + +test("setup that needs a CopilotKit sign-in offers it in the window, then carries on by itself", async () => { + invokeHandler = answers({ + // Blocked on the sign-in, then set up once it is done. + set_up_local_eval: [blockedOnSignIn, report()], + sign_in_to_copilotkit: [{ signedIn: true }], + connect_local_eval: [connection()], + run_local_eval: [ready], + cancel_local_eval: [null], + }); + const screen = view(); + await userEvent.click( + await screen.findByRole("button", { name: "Sign in to CopilotKit" }), + ); + await waitFor(() => + expect(row(screen, START).textContent).toContain("Complete"), + ); + expect(commands().filter((c) => c === "sign_in_to_copilotkit")).toHaveLength( + 1, + ); + expect(commands().filter((c) => c === "set_up_local_eval")).toHaveLength(2); +}); + +test("a CopilotKit sign-in that does not finish says so and can be tried again", async () => { + invokeHandler = answers({ + set_up_local_eval: [blockedOnSignIn], + sign_in_to_copilotkit: [{ signedIn: false }], + cancel_local_eval: [null], + }); + const screen = view(); + await userEvent.click( + await screen.findByRole("button", { name: "Sign in to CopilotKit" }), + ); + await screen.findByText("Sign-in did not finish. Try again."); + expect( + screen.getByRole("button", { name: "Sign in to CopilotKit" }), + ).toBeTruthy(); + expect(commands().filter((c) => c === "set_up_local_eval")).toHaveLength(1); +}); + +test("setup that needs the agreement shows it, and only Accept sets up again with that version", async () => { + invokeHandler = answers({ + set_up_local_eval: [blockedOnTerms, report()], + local_eval_terms: [requiredTerms], + connect_local_eval: [connection()], + run_local_eval: [ready], + cancel_local_eval: [null], + }); + const screen = view(); + await screen.findByText( + "To run Intelligence on this computer, accept the CopilotKit Intelligence evaluation agreement for Acme.", + ); + expect(screen.getByRole("link", { name: "Read the agreement" })).toBeTruthy(); + // Nothing is accepted until the person clicks Accept. + expect( + invokeCalls.filter((call) => call.command === "set_up_local_eval"), + ).toEqual([ + { + command: "set_up_local_eval", + args: expect.objectContaining({ acceptedEulaVersion: null }), + }, + ]); + await userEvent.click(screen.getByRole("button", { name: "Accept" })); + await waitFor(() => + expect(row(screen, START).textContent).toContain("Complete"), + ); + const setups = invokeCalls.filter( + (call) => call.command === "set_up_local_eval", + ); + expect( + setups.map( + (call) => + (call.args as { acceptedEulaVersion: string | null }) + .acceptedEulaVersion, + ), + ).toEqual([null, "developer-edition-2026-10-01-web"]); +}); + +test("an acceptance setup does not take is said once, not offered again", async () => { + invokeHandler = answers({ + set_up_local_eval: [blockedOnTerms, blockedOnTerms], + local_eval_terms: [requiredTerms, requiredTerms], + cancel_local_eval: [null], + }); + const screen = view(); + await userEvent.click(await screen.findByRole("button", { name: "Accept" })); + await screen.findByText(/Intelligence did not take the acceptance/); + expect(screen.queryByRole("button", { name: "Accept" })).toBeNull(); + expect( + invokeCalls.filter((call) => call.command === "set_up_local_eval"), + ).toHaveLength(2); +}); + +test("no build sends the person to a terminal", async () => { + for (const [blocked, terms] of [ + [blockedOnTerms, requiredTerms], + [blockedOnSignIn, requiredTerms], + ] as const) { + invokeHandler = answers({ + set_up_local_eval: [blocked], + local_eval_terms: [terms], + cancel_local_eval: [null], + }); + const screen = view(); + await screen.findByRole("button", { + name: blocked === blockedOnTerms ? "Accept" : "Sign in to CopilotKit", + }); + expect(screen.queryByText(/copilotkit |Terminal/)).toBeNull(); + cleanup(); + invokeCalls = []; + } +}); + +test("a blocked or conflicted connection stops at its row, and Retry resumes at Connect", async () => { + for (const [answer, said] of [ + [ + connection({ outcome: "blocked", reason: "confirmation-required" }), + "Intelligence asked for a confirmation OpenBot cannot give.", + ], + [ + connection({ outcome: "blocked", conflicted: true }), + "Settings Intelligence wrote for OpenBot were changed since. OpenBot did not connect.", + ], + ] as const) { + invokeHandler = answers({ + set_up_local_eval: [report()], + connect_local_eval: [answer], + }); + const started: Startable[] = []; + const screen = view({ + onStart: async (startable) => { + started.push(startable); + return true; + }, + }); + await waitFor(() => + expect(row(screen, CONNECT).textContent).toContain(said), + ); + expect(statusCalls()).toHaveLength(0); + invokeHandler = answers({ + connect_local_eval: [connection()], + run_local_eval: [ready], + }); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(started).toEqual([startableUrls])); + expect(commands().filter((c) => c === "set_up_local_eval")).toHaveLength(1); + cleanup(); + invokeCalls = []; + } +}); + +test("readiness or a licence that is not exactly ready and valid never starts", async () => { + for (const [answer, said] of [ + [ + report(), + "Intelligence started. Its licence and readiness are not reported yet.", + ], + [ + report({ readiness: "ready", licence: "expired" }), + "The Intelligence evaluation licence has expired.", + ], + [ + report({ readiness: "degraded", licence: "valid" }), + "Intelligence is running but not healthy.", + ], + ] as const) { + invokeHandler = answers({ run_local_eval: [answer] }); + const started: Startable[] = []; + const screen = view({ + stack: "local-abc123", + connected: true, + onStart: async (startable) => { + started.push(startable); + return true; + }, + }); + await waitFor(() => expect(row(screen, WAIT).textContent).toContain(said)); + // Unknown is not polled: an older CLI never reports more. + expect(statusCalls()).toHaveLength(1); + expect(started).toEqual([]); + expect(screen.queryByRole("listitem", { name: START })).toBeNull(); + invokeHandler = answers({ run_local_eval: [ready] }); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(started).toEqual([startableUrls])); + expect(commands()).toEqual([ + "run_local_eval:status", + "run_local_eval:status", + ]); + cleanup(); + invokeCalls = []; + } +}); + +test("polling is bounded, and says so when Intelligence never became ready", async () => { + invokeHandler = answers({ run_local_eval: [starting] }); + const screen = view({ + stack: "local-abc123", + connected: true, + pollDelays: [0, 0], + }); + await screen.findByText("Intelligence did not report ready in time."); + expect(statusCalls()).toHaveLength(3); + expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy(); +}); + +test("Cancel stops the running CLI call by its own attempt, and Retry resumes there", async () => { + const pending = deferred(); + invokeHandler = async (command) => { + if (command === "set_up_local_eval") return pending.promise; + if (command === "cancel_local_eval") { + pending.reject("cancelled"); + return null; + } + throw new Error(`unexpected ${command}`); + }; + const screen = view(); + await userEvent.click(await screen.findByRole("button", { name: "Cancel" })); + const setup = invokeCalls.find( + (call) => call.command === "set_up_local_eval", + ); + expect( + invokeCalls.find((call) => call.command === "cancel_local_eval")?.args, + ).toEqual({ + attempt: (setup?.args as { attempt: string } | undefined)?.attempt, + }); + await waitFor(() => + expect(row(screen, SETUP).textContent).toContain( + "Stopped at your request before it finished.", + ), + ); + invokeHandler = answers({ set_up_local_eval: [blockedOnTerms] }); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => + expect(commands().filter((c) => c === "set_up_local_eval")).toHaveLength(2), + ); +}); + +test("polling stops when cancelled", async () => { + invokeHandler = async (command) => { + if (command === "run_local_eval") return starting; + throw new Error(`unexpected ${command}`); + }; + const screen = view({ + stack: "local-abc123", + connected: true, + pollDelays: [60_000], + }); + await waitFor(() => expect(statusCalls()).toHaveLength(1)); + await userEvent.click(await screen.findByRole("button", { name: "Cancel" })); + await screen.findByRole("button", { name: "Retry" }); + expect(statusCalls()).toHaveLength(1); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); +}); + +test("a stopped stack is started, then polled", async () => { + const stopped = report({ readiness: "stopped", licence: "valid" }); + invokeHandler = async (command, args) => { + const action = (args as { action?: string }).action; + if (command === "run_local_eval" && action === "start") return report(); + if (command === "run_local_eval") { + return invokeCalls.some( + (call) => (call.args as { action?: string }).action === "start", + ) + ? ready + : stopped; + } + throw new Error(`unexpected ${command}`); + }; + const started: Startable[] = []; + view({ + stack: "local-abc123", + connected: true, + onStart: async (startable) => { + started.push(startable); + return true; + }, + }); + await waitFor(() => expect(started).toEqual([startableUrls])); + expect(commands()).toEqual([ + "run_local_eval:status", + "run_local_eval:start", + "run_local_eval:status", + ]); +}); + +test("a start that fails stops at its row, and Retry starts again without asking the CLI", async () => { + invokeHandler = answers({ run_local_eval: [ready] }); + let starts = 0; + const screen = view({ + stack: "local-abc123", + connected: true, + onStart: async () => ++starts > 1, + }); + await waitFor(() => + expect(row(screen, START).textContent).toContain("OpenBot did not start."), + ); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(starts).toBe(2)); + expect(statusCalls()).toHaveLength(1); +}); + +test("without auto, nothing runs until Start OpenBot is chosen", async () => { + invokeHandler = answers({ run_local_eval: [ready] }); + const started: Startable[] = []; + const screen = view({ + stack: "local-abc123", + connected: true, + auto: false, + onStart: async (startable) => { + started.push(startable); + return true; + }, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(invokeCalls).toEqual([]); + await userEvent.click(screen.getByRole("button", { name: "Start OpenBot" })); + await waitFor(() => expect(started).toEqual([startableUrls])); +}); + +test("prepare runs to ready and waits for the go-ahead, which starts at once", async () => { + invokeHandler = answers({ + set_up_local_eval: [report()], + connect_local_eval: [connection()], + run_local_eval: [ready], + }); + const started: Startable[] = []; + const screen = view({ + prepare: true, + onStart: async (startable) => { + started.push(startable); + return true; + }, + }); + await waitFor(() => + expect(row(screen, WAIT).textContent).toContain("Complete"), + ); + expect(screen.queryByRole("alert", { name: "Intelligence" })).toBeNull(); + expect(screen.queryByRole("listitem", { name: START })).toBeNull(); + expect(started).toEqual([]); + // No Start OpenBot once it has run: the go-ahead comes from the screen in front. + expect(screen.queryByRole("button")).toBeNull(); + cleanup(); + invokeCalls = []; +}); + +test("the screen in front hears nothing while Intelligence works, and a stop interrupts it", async () => { + invokeHandler = answers({ + set_up_local_eval: [new Promise(() => {})], + cancel_local_eval: [null], + }); + const working = view({ prepare: true }); + await waitFor(() => + expect(row(working, SETUP).textContent).toContain("In progress"), + ); + expect(working.queryByRole("alert", { name: "Intelligence" })).toBeNull(); + cleanup(); + invokeCalls = []; + + invokeHandler = answers({ + set_up_local_eval: [ + report({ outcome: "blocked", stack: null, reason: "port-unavailable" }), + ], + cancel_local_eval: [null], + }); + const failed = view({ prepare: true }); + const stopped = await failed.findByRole("alert", { name: "Intelligence" }); + expect(stopped.textContent).toContain("Intelligence stopped"); + expect(stopped.textContent).toContain("A port Intelligence needs is in use."); + expect(within(stopped).getByRole("button", { name: "Retry" })).toBeTruthy(); + cleanup(); + invokeCalls = []; + + invokeHandler = answers({ + set_up_local_eval: [blockedOnTerms], + local_eval_terms: [requiredTerms], + cancel_local_eval: [null], + }); + const waiting = view({ prepare: true }); + const needs = await waiting.findByRole("alert", { name: "Intelligence" }); + expect(needs.textContent).toContain("Intelligence needs you"); + expect( + await within(needs).findByRole("button", { name: "Accept" }), + ).toBeTruthy(); +}); + +test("the running screen offers no way to the hosted service", async () => { + invokeHandler = answers({ set_up_local_eval: [blockedOnTerms] }); + const screen = view(); + await screen.findByRole("button", { name: "Retry" }); + expect(screen.queryByRole("button", { name: /hosted/ })).toBeNull(); +}); + +test("failures arrive as categories, and a stack that could not be recorded as a problem", async () => { + invokeHandler = async () => { + throw "timeout"; + }; + const screen = view(); + await waitFor(() => + expect(row(screen, SETUP).textContent).toContain( + "Intelligence could not start.", + ), + ); + invokeHandler = async () => { + throw { + said: "OpenBot could not save its local evaluation progress.", + detail: "synthetic", + }; + }; + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + expect((await screen.findByRole("alert")).textContent).toContain( + "OpenBot could not save its local evaluation progress.", + ); +}); + +test("an answer OpenBot does not recognise is never echoed", async () => { + invokeHandler = async () => { + throw "synthetic-secret-marker"; + }; + const screen = view(); + await screen.findByRole("alert"); + expect(document.body.textContent).not.toContain("synthetic-secret-marker"); +}); diff --git a/desktop/src/LocalIntelligence.tsx b/desktop/src/LocalIntelligence.tsx new file mode 100644 index 0000000000..0e5d4351d0 --- /dev/null +++ b/desktop/src/LocalIntelligence.tsx @@ -0,0 +1,725 @@ +import { useEffect, useRef, useState } from "react"; +import { ExternalLink } from "./ExternalLink"; +import { + cancelLocalEval, + chatGate, + connectLocalEval, + type LocalConnection, + LocalEvalError, + type LocalFailure, + type LocalReason, + type LocalReport, + type LocalTerms, + localEvalTerms, + runLocalEval, + setUpLocalEval, + signInToCopilotKit, +} from "./localEvalCli"; +import { localReadiness } from "./localReadiness"; +import { asProblem, Failure, type Problem } from "./Problem"; +import { type Progress, SetupProgress, StatusLine } from "./SetupProgress"; + +type Call = { attempt: string; answer: Promise }; + +/** What Start OpenBot is given for a local stack: its addresses. The key is in the vault. */ +export type LocalStartable = { apiUrl: string; gatewayWsUrl: string }; + +/** + * Between status checks while Intelligence reports that it is starting: about three minutes in + * all, then the person is told and can check again. + */ +const POLL_DELAYS: number[] = [2_000, 4_000, 8_000, ...Array(12).fill(15_000)]; + +const unreadable: Problem = { + said: "OpenBot did not understand Intelligence's answer.", +}; + +type Phase = "setup" | "connect" | "wait" | "start"; + +const PHASES: [Phase, string][] = [ + ["setup", "Setting up Intelligence"], + ["connect", "Connecting OpenBot"], + ["wait", "Waiting for Intelligence"], + ["start", "Starting OpenBot"], +]; + +/** The readiness sentence for one CLI answer or failure category. */ +function said(intelligence: LocalReport | LocalFailure): string { + return localReadiness({ + runtime: "unknown", + intelligence, + openBot: "unknown", + provider: "unvalidated", + }).intelligence.said; +} + +function connectionSaid(answered: LocalConnection): string { + // A conflict has no reason of its own, so it is said in this window's words. + if (answered.conflicted) { + return "Settings Intelligence wrote for OpenBot were changed since. OpenBot did not connect."; + } + return said({ + outcome: answered.outcome, + stack: answered.stack, + reason: answered.reason, + readiness: "unknown", + licence: "unknown", + licenceExpiresAt: null, + }); +} + +function isReady(report: LocalReport): boolean { + return chatGate({ + intelligence: report, + openBot: "connected", + provider: "validated", + }).open; +} + +type State = { + at: Phase | null; + running: boolean; + /** When the current row began, so its elapsed time survives a change of screen. */ + since: number | null; + blocked: { said: string; reason: LocalReason | null } | null; + problem: Problem | null; + /** + * The agreement version setup was given and still answered "acceptance required" to. The CLI + * refuses an acceptance it believes a coding agent passed on, so offering the same Accept again + * would only loop. + */ + refused: string | null; +}; + +const idle: State = { + at: null, + running: false, + since: null, + blocked: null, + problem: null, + refused: null, +}; + +export type LocalSequence = State & { + /** Set up, connected and reported ready and valid, and waiting for the go-ahead to start. */ + ready: boolean; + /** Stopped at its row with a reason; Retry resumes there. */ + stopped: boolean; + steps: Progress[]; + /** Runs set up, connect and wait, and stops before Start. */ + prepare: () => void; + /** The go-ahead: runs what is left and starts OpenBot once Intelligence is ready. */ + start: () => void; + retry: () => void; + /** The person accepted this agreement version in the window: set up again with it. */ + acceptTerms: (version: string) => void; + /** Stops the running CLI call and says so. */ + cancel: () => void; + /** Forgets the rows, as after Stop OpenBot. */ + reset: () => void; +}; + +/** + * Intelligence on this computer, run by the Intelligence CLI, then OpenBot started against it. + * + * Held by the window rather than by a screen, so moving between screens never cancels it. Four + * steps, each idempotent, run in order without a button: set up, connect (the installation is the + * consent, and the native side keeps the key the CLI wrote), wait until the CLI itself reports + * readiness "ready" and licence "valid", then start OpenBot. `prepare` runs the first three while + * the person chooses an AI provider; Start waits for `start`. A recorded, connected stack starts + * at waiting. A step that is blocked or fails stops there with its reason, and Retry resumes at + * that step. A CLI that does not report ready and valid never reaches Start. + */ +export function useLocalSequence({ + root, + stack, + connected = false, + onStart, + onRecorded = () => {}, + pollDelays = POLL_DELAYS, +}: { + root: string; + stack: string | null; + /** OpenBot already holds the key `local connect` gave it for this stack. */ + connected?: boolean; + /** Starts OpenBot against the stack; true when it started. */ + onStart: (startable: LocalStartable) => Promise; + /** A stack was set up, or connected. */ + onRecorded?: (stack: string, connected: boolean) => void; + pollDelays?: number[]; +}): LocalSequence { + const [state, setState] = useState(idle); + const now = useRef(state); + const latest = useRef({ root, stack, connected, onStart, onRecorded }); + latest.current = { root, stack, connected, onStart, onRecorded }; + // Bumped to stop a run: by Cancel, by leaving, and by any newer run. + const generation = useRef(0); + const attempt = useRef(null); + const wake = useRef<(() => void) | null>(null); + const queued = useRef | null>(null); + const startable = useRef(null); + // Start OpenBot once Intelligence is ready. Spent when it starts. + const go = useRef(false); + // The agreement version the person accepted, carried to the next setup and spent once it works. + const accepted = useRef(null); + + function update(patch: Partial) { + now.current = { ...now.current, ...patch }; + setState(now.current); + } + + function halt() { + generation.current += 1; + wake.current?.(); + if (queued.current !== null) clearTimeout(queued.current); + queued.current = null; + const inFlight = attempt.current; + attempt.current = null; + if (inFlight) void cancelLocalEval(inFlight); + } + + // Closing the window stops whatever is running, the CLI call included. + // biome-ignore lint/correctness/useExhaustiveDependencies: once, on leaving. + useEffect(() => halt, []); + + /** Runs one call; a failure category or a problem stops the step. */ + async function call(started: Call, token: number): Promise { + attempt.current = started.attempt; + try { + const value = await started.answer; + return generation.current === token ? value : null; + } catch (error) { + if (generation.current !== token) return null; + if (error instanceof LocalEvalError) { + if (error.category === "unrecognized") update({ problem: unreadable }); + else update({ blocked: { said: said(error.category), reason: null } }); + } else { + update({ problem: asProblem(error) }); + } + return null; + } finally { + if (attempt.current === started.attempt) attempt.current = null; + } + } + + function pause(ms: number, token: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + wake.current = null; + resolve(generation.current === token); + }, ms); + wake.current = () => { + clearTimeout(timer); + wake.current = null; + resolve(false); + }; + }); + } + + /** + * Status until the CLI reports ready and valid, or anything that waiting will not change. + * Only "starting" is waited on: unknown is what an older CLI always says. A stopped stack is + * started once, as its own readiness row offers. + */ + async function waitForReady( + id: string, + token: number, + ): Promise { + const report = (action: "status" | "start") => { + const started = runLocalEval(action, id); + return call({ attempt: started.attempt, answer: started.report }, token); + }; + const stuck = (found: LocalReport) => { + if (generation.current === token) { + update({ blocked: { said: said(found), reason: found.reason } }); + } + return null; + }; + let startedIntelligence = false; + for (let tries = 0; ; tries += 1) { + const found = await report("status"); + if (!found) return null; + if (isReady(found)) { + if (!found.stack) { + update({ blocked: { said: said("missing-stack"), reason: null } }); + return null; + } + return { + apiUrl: found.stack.urls.api, + gatewayWsUrl: found.stack.urls.gateway, + }; + } + const row = localReadiness({ + runtime: "unknown", + intelligence: found, + openBot: "unknown", + provider: "unvalidated", + }).intelligence; + if (row.action === "start-intelligence" && !startedIntelligence) { + startedIntelligence = true; + const started = await report("start"); + if (!started) return null; + if (started.outcome !== "completed") return stuck(started); + continue; + } + if (found.readiness !== "starting" || row.state === "blocked") { + return stuck(found); + } + if (tries >= pollDelays.length) { + update({ + blocked: { + said: "Intelligence did not report ready in time.", + reason: null, + }, + }); + return null; + } + if (!(await pause(pollDelays[tries], token))) return null; + } + } + + async function run(from: Phase): Promise { + halt(); + const token = generation.current; + update({ blocked: null, problem: null }); + const { root } = latest.current; + let id = latest.current.stack; + const stop = (reason?: { said: string; reason?: LocalReason | null }) => { + if (generation.current !== token) return; + update({ + running: false, + ...(reason + ? { blocked: { said: reason.said, reason: reason.reason ?? null } } + : {}), + }); + }; + for (const [phase] of PHASES.slice(PHASES.findIndex(([p]) => p === from))) { + if (generation.current !== token) return; + // Ready, and waiting for the go-ahead. + if (phase === "start" && !go.current) return stop(); + update({ at: phase, running: true, since: Date.now() }); + if (phase === "setup") { + const carried = accepted.current; + const started = setUpLocalEval(root, carried); + const report = await call( + { attempt: started.attempt, answer: started.report }, + token, + ); + if (!report) return stop(); + update({ + refused: + report.reason === "eula-acceptance-required" ? carried : null, + }); + if (report.outcome !== "completed") { + return stop({ said: said(report), reason: report.reason }); + } + if (!report.stack) return stop({ said: said("missing-stack") }); + accepted.current = null; + id = report.stack.id; + latest.current.onRecorded(id, false); + } + if (phase === "connect") { + const started = connectLocalEval(root); + const answered = await call( + { attempt: started.attempt, answer: started.connection }, + token, + ); + if (!answered) return stop(); + if (answered.outcome !== "completed" || answered.conflicted) { + return stop({ + said: connectionSaid(answered), + reason: answered.reason, + }); + } + id = answered.stack?.id ?? id; + if (!id) return stop({ said: said("missing-stack") }); + latest.current.onRecorded(id, true); + } + if (phase === "wait") { + if (!id) return stop({ said: said("missing-stack") }); + const found = await waitForReady(id, token); + if (!found) return stop(); + startable.current = found; + } + if (phase === "start") { + const target = startable.current; + if (!target) return run("wait"); + if (!(await latest.current.onStart(target))) { + return stop({ said: "OpenBot did not start." }); + } + go.current = false; + } + } + stop(); + } + + const first = (): Phase => + latest.current.stack && latest.current.connected ? "wait" : "setup"; + const stopped = + !state.running && + state.at !== null && + (state.blocked !== null || state.problem !== null); + const ready = !state.running && state.at === "wait" && !stopped; + + function prepare() { + if (now.current.running) return; + void run(first()); + } + + function start() { + go.current = true; + const { at, running, blocked, problem } = now.current; + // A running sequence starts OpenBot when it gets there; a stopped one waits for Retry. + if (running || blocked || problem) return; + const from: Phase = at === "wait" ? "start" : first(); + // Deferred a tick: the screen that gave the go-ahead has rendered what it chose by then, and a + // development double mount runs the CLI once. + update({ running: true }); + queued.current = setTimeout(() => { + queued.current = null; + void run(from); + }, 0); + } + + function retry() { + const { at } = now.current; + if (at) void run(at); + } + + function acceptTerms(version: string) { + accepted.current = version; + retry(); + } + + function cancel() { + halt(); + // Before its first row, nothing ran: there is nothing to retry. + update( + now.current.at === null + ? { running: false } + : { + running: false, + blocked: { said: said("cancelled"), reason: null }, + }, + ); + } + + function reset() { + halt(); + go.current = false; + startable.current = null; + update(idle); + } + + const reached = state.at + ? PHASES.findIndex(([phase]) => phase === state.at) + : -1; + const steps: Progress[] = PHASES.slice(0, reached + 1).map( + ([, label], index) => + index < reached + ? { step: label, ok: true, detail: "" } + : state.running + ? { + step: label, + ok: true, + running: true, + detail: "", + startedAt: state.since ?? undefined, + } + : stopped + ? { step: label, ok: false, detail: state.blocked?.said ?? "" } + : { step: label, ok: true, detail: "" }, + ); + + return { + ...state, + ready, + stopped, + steps, + prepare, + start, + retry, + acceptTerms, + cancel, + reset, + }; +} + +/** + * The local sequence's rows and its one control at a time: Cancel while a CLI call runs, Retry + * once it stopped, and Start OpenBot when nothing has run yet and `offerStart` is set. + */ +export function LocalIntelligence({ + sequence, + offerStart = false, +}: { + sequence: LocalSequence; + offerStart?: boolean; +}) { + const { at, running, stopped, problem } = sequence; + + return ( +
+ + + {stopped && problem && } +
+ {running && at !== "start" && ( + + )} + {stopped && ( + + )} + {offerStart && !running && at === null && ( + + )} +
+
+ ); +} + +/** + * Intelligence on a screen that is about something else: nothing while it works, and a card that + * cannot be missed when it stops. The person is installing OpenBot or choosing an AI here; a second + * progress line competed with that, and its full progress has its own step later. A stop is shown + * at once, because finding out only at that later step would waste the minutes since: one they + * clear themselves (signing in, accepting the agreement) with its action, anything else with Retry. + */ +export function LocalStatus({ sequence }: { sequence: LocalSequence }) { + if (!sequence.stopped) return null; + const needs = needsPerson(sequence); + return ( +
+

{needs ? "Intelligence needs you" : "Intelligence stopped"}

+ {needs ? ( + + ) : ( + <> +

{sequence.blocked?.said ?? sequence.problem?.said}

+
+ +
+ + )} +
+ ); +} + +/** + * What the person does when local setup stops for them, in the window where it stopped: sign in to + * CopilotKit, or read and accept the evaluation agreement. Setup then carries on by itself. + * + * The agreement is the person's to accept. They read it at its own address and click Accept here; + * that click is passed to the CLI as the version they accepted (a product decision of 2026-10-22). + * Nothing accepts it without the click. + */ +export function LocalNeeds({ sequence }: { sequence: LocalSequence }) { + if (!needsPerson(sequence)) return null; + const reason = sequence.blocked?.reason; + if (reason === "authentication-required") + return ; + if (reason === "eula-acceptance-required") + return ( + + ); + return null; +} + +function needsPerson(sequence: LocalSequence): boolean { + const reason = sequence.stopped ? sequence.blocked?.reason : null; + return ( + reason === "authentication-required" || + reason === "eula-acceptance-required" + ); +} + +function SignInToCopilotKit({ onSignedIn }: { onSignedIn: () => void }) { + const [waiting, setWaiting] = useState(false); + const [failed, setFailed] = useState(null); + const attempt = useRef(null); + // Leaving stops the wait for the browser. + useEffect( + () => () => { + if (attempt.current) cancelLocalEval(attempt.current).catch(() => {}); + }, + [], + ); + + async function signIn() { + setWaiting(true); + setFailed(null); + const started = signInToCopilotKit(); + attempt.current = started.attempt; + try { + if (await started.signedIn) onSignedIn(); + else setFailed("Sign-in did not finish. Try again."); + } catch (error) { + setFailed( + error instanceof LocalEvalError && error.category !== "unrecognized" + ? said(error.category) + : "Sign-in did not finish. Try again.", + ); + } finally { + attempt.current = null; + setWaiting(false); + } + } + + return ( +
+

Intelligence on this computer needs your CopilotKit account.

+ {waiting ? ( + Finish signing in in your browser… + ) : ( +
+ +
+ )} + {failed &&

{failed}

} +
+ ); +} + +function AcceptTerms({ + refused, + onAccept, + onRetry, +}: { + /** The version setup was already given and did not take. */ + refused: string | null; + onAccept: (version: string) => void; + onRetry: () => void; +}) { + const [terms, setTerms] = useState(null); + const [failed, setFailed] = useState(false); + useEffect(() => { + let current = true; + const started = localEvalTerms(); + started.terms.then( + (read) => current && setTerms(read), + () => current && setFailed(true), + ); + return () => { + current = false; + cancelLocalEval(started.attempt).catch(() => {}); + }; + }, []); + + if (failed || (terms && terms.status !== "required")) + return ( +
+

+ The evaluation agreement could not be shown. Try again. +

+
+ +
+
+ ); + if (terms?.status !== "required") + return Getting the evaluation agreement…; + if (terms.version === refused) + return ( +
+

+ Intelligence did not take the acceptance from OpenBot. If a coding + agent or a script opened OpenBot, quit it, open it yourself, and + accept again. +

+
+ +
+
+ ); + return ( +
+

+ To run Intelligence on this computer, accept the CopilotKit Intelligence + evaluation agreement for {terms.organizationName}. +

+

+ Read the agreement ( + {terms.version}) +

+
+ +
+
+ ); +} + +export type IntelligenceMode = "local" | "hosted"; + +/** + * Where Intelligence runs, asked before anything is set up. + * + * Local setup downloads several gigabytes, so it waits for this answer rather than starting on + * arrival and offering hosted as an interruption. Continue, which belongs to the screen, commits it. + */ +export function IntelligenceChoice({ + chosen, + onChoose, +}: { + /** Null until somebody picks one, so neither is assumed. */ + chosen: IntelligenceMode | null; + onChoose: (mode: IntelligenceMode) => void; +}) { + const rows: [IntelligenceMode, string, string][] = [ + [ + "local", + "Run Intelligence on this computer", + "For evaluation. The first setup takes about 10 minutes.", + ], + [ + "hosted", + "Use CopilotKit's hosted Intelligence", + "Signs in to your CopilotKit account.", + ], + ]; + return ( +
+ Where Intelligence runs + {rows.map(([mode, name, summary]) => ( + + ))} +
+ ); +} diff --git a/desktop/src/OrganizationSignIn.tsx b/desktop/src/OrganizationSignIn.tsx index 507a415ab3..71a9280bea 100644 --- a/desktop/src/OrganizationSignIn.tsx +++ b/desktop/src/OrganizationSignIn.tsx @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { useEffect, useRef, useState } from "react"; import { asProblem, Failure } from "./Problem"; +import { StatusLine } from "./SetupProgress"; export function OrganizationSignIn({ root, @@ -72,9 +73,9 @@ export function OrganizationSignIn({ ))} {busy && ( -

+ Complete sign-in in your browser, then return to OpenBot. -

+ )} {signInUrl && (

diff --git a/desktop/src/ProviderPicker.test.tsx b/desktop/src/ProviderPicker.test.tsx index a976856de7..b9af4a0c27 100644 --- a/desktop/src/ProviderPicker.test.tsx +++ b/desktop/src/ProviderPicker.test.tsx @@ -1,6 +1,12 @@ import { afterAll, afterEach, beforeAll, expect, mock, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; -import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { HeldConfiguration, @@ -152,6 +158,15 @@ async function renderPickerWithHeld( return view; } +/** Chooses a provider row, going back to the whole list first when one is already chosen. */ +async function pick(view: ReturnType, name: RegExp) { + const change = view.queryByRole("button", { + name: "Choose a different provider", + }); + if (change) await userEvent.click(change); + await userEvent.click(await view.findByRole("radio", { name })); +} + test("a completed plan sign-in enables and submits only its issuing provider", async () => { const choices: unknown[] = []; invokeHandler = async (command, args) => { @@ -166,21 +181,52 @@ test("a completed plan sign-in enables and submits only its issuing provider", a const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await pick(view, /OpenAI/); await userEvent.click( view.getByRole("button", { name: "Sign in with OpenAI" }), ); await waitFor(() => expect(view.getByText(/Signed in to OpenAI/)).toBeTruthy(), ); - await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); + // The finished sign-in moves on by itself, with its own provider's token. + await waitFor( + () => + expect(choices).toEqual([ + { provider: "openai", login: "plan", token: "chatgpt-token" }, + ]), + { timeout: 4000 }, + ); + await pick(view, /Anthropic/); expect(view.queryByText(/Signed in to Anthropic/)).toBeNull(); const continueButton = view.getByRole("button", { name: "Continue" }); expect(continueButton).toHaveProperty("disabled", true); await userEvent.click(continueButton); + expect(choices).toHaveLength(1); +}); + +test("a finished sign-in shows its tick for a second, and choosing another row then cancels the move", async () => { + const choices: unknown[] = []; + invokeHandler = async (command) => { + if (command === "providers") return providers; + if (command === "begin_chatgpt_sign_in") return "https://chatgpt.test"; + if (command === "finish_chatgpt_sign_in") return "chatgpt-token"; + throw new Error(`unexpected command ${command}`); + }; + const view = await renderPicker((choice) => choices.push(choice)); + await pick(view, /OpenAI/); + await userEvent.click( + view.getByRole("button", { name: "Sign in with OpenAI" }), + ); + const tick = await view.findByText(/✓/); + expect(tick.closest("p")?.textContent).toContain("Signed in to OpenAI."); + expect(choices).toEqual([]); + // Another row inside the second: nothing moves on. + await pick(view, /Anthropic/); + await act(async () => new Promise((resolve) => setTimeout(resolve, 1300))); expect(choices).toEqual([]); + expect(view.queryByText(/✓/)).toBeNull(); }); test("a pending plan sign-in completion is ignored after switching provider rows", async () => { @@ -197,7 +243,7 @@ test("a pending plan sign-in completion is ignored after switching provider rows const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await pick(view, /OpenAI/); await userEvent.click( view.getByRole("button", { name: "Sign in with OpenAI" }), ); @@ -206,7 +252,11 @@ test("a pending plan sign-in completion is ignored after switching provider rows invokeCalls.some((call) => call.command === "finish_chatgpt_sign_in"), ).toBe(true), ); - await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); + // The wait for the browser is said with a spinner, as every setup row is. + const waiting = view.getByRole("status"); + expect(waiting.textContent).toBe("Waiting for you to approve it…"); + expect(waiting.querySelector(".step-spinner")).not.toBeNull(); + await pick(view, /Anthropic/); await act(async () => { chatgpt.resolve("chatgpt-token"); @@ -241,7 +291,7 @@ test("a saved provider-scoped plan session enables continue without exposing a t (choice) => choices.push(choice), ); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await pick(view, /OpenAI/); expect(view.getByText(/A saved OpenAI sign-in will be checked/)).toBeTruthy(); await userEvent.click(view.getByRole("button", { name: "Continue" })); @@ -277,9 +327,7 @@ for (const provider of providers) { (choice) => choices.push(choice), ); - await userEvent.click( - await view.findByRole("radio", { name: new RegExp(provider.name) }), - ); + await pick(view, new RegExp(provider.name)); await userEvent.click(view.getByRole("tab", { name: "Use an API key" })); await userEvent.type( view.getByLabelText(`${provider.name} API key`), @@ -310,7 +358,14 @@ for (const provider of providers) { ), ); expect(view.queryByLabelText(`${provider.name} API key`)).toBeNull(); - await userEvent.click(view.getByRole("button", { name: "Continue" })); + // A fresh sign-in moves on by itself; a saved one is only offered, so it waits for Continue. + if (session === "saved") { + await userEvent.click(view.getByRole("button", { name: "Continue" })); + } else { + await waitFor(() => expect(choices).toHaveLength(1), { + timeout: 4000, + }); + } expect(choices).toHaveLength(1); expect(choices[0]).not.toHaveProperty("apiKey"); @@ -329,9 +384,7 @@ for (const provider of providers) { throw new Error(`unexpected command ${command}`); }; const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: new RegExp(provider.name) }), - ); + await pick(view, new RegExp(provider.name)); await userEvent.click(view.getByRole("tab", { name: "Use an API key" })); await userEvent.type( view.getByLabelText(`${provider.name} API key`), @@ -369,9 +422,7 @@ test("a compatible endpoint does not inherit a saved OpenAI API key", async () = (choice) => choices.push(choice), ); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type( view.getByLabelText("Base URL"), "https://models.example/v1", @@ -409,9 +460,7 @@ test("a compatible endpoint submits the key typed into its endpoint key field", (choice) => choices.push(choice), ); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type( view.getByLabelText("Base URL"), "https://models.example/v1", @@ -441,9 +490,7 @@ test("a compatible endpoint carries an optional container-only URL", async () => }; const view = await renderPickerWithHeld({}, (choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type( view.getByLabelText("Base URL"), "http://127.0.0.1:11434/v1", @@ -474,9 +521,7 @@ test("a compatible endpoint uses the host URL for containers when no override is }; const view = await renderPickerWithHeld({}, (choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type( view.getByLabelText("Base URL"), "https://models.example/v1", @@ -507,9 +552,7 @@ test("a compatible endpoint refuses an invalid container-only URL", async () => }; const view = await renderPickerWithHeld({}, (choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type( view.getByLabelText("Base URL"), "http://127.0.0.1:11434/v1", @@ -540,9 +583,7 @@ test.each([ }; const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type(view.getByLabelText("Base URL"), baseUrl); await userEvent.type(view.getByLabelText("Model name"), "local-model"); @@ -564,9 +605,7 @@ test("a compatible endpoint accepts local http and external https URLs", async ( "https://models.example/v1", ]) { const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type(view.getByLabelText("Base URL"), baseUrl); await userEvent.type(view.getByLabelText("Model name"), "local-model"); @@ -601,9 +640,7 @@ for (const provider of providers) { throw new Error(`unexpected protected command ${command}`); }; const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: new RegExp(provider.name) }), - ); + await pick(view, new RegExp(provider.name)); if (login === "api-key") await userEvent.click( view.getByRole("tab", { name: "Use an API key" }), @@ -628,9 +665,7 @@ for (const provider of providers) { await userEvent.click(view.getByRole("button", { name: "Continue" })); expect(choices).toEqual([{ provider: provider.id, login, saved: true }]); const other = providers.find((item) => item.id !== provider.id)!; - await userEvent.click( - view.getByRole("radio", { name: new RegExp(other.name) }), - ); + await pick(view, new RegExp(other.name)); expect(view.getByRole("button", { name: "Continue" })).toHaveProperty( "disabled", true, @@ -881,7 +916,8 @@ for (const provider of cloudProviders) { view.getByRole("button", { name: `Sign in with ${provider.name}` }), ); await view.findByText(`Signed in to ${provider.name}.`); - await user.click(view.getByRole("button", { name: "Continue" })); + // Signed in with a model already named: on by itself, no Continue. + await waitFor(() => expect(choices).toHaveLength(1), { timeout: 4000 }); expect(choices).toEqual([ { provider: provider.id, @@ -1061,9 +1097,7 @@ for (const provider of cloudProviders) { const other = cloudProviders.find( (candidate) => candidate.id !== provider.id, )!; - await userEvent.click( - view.getByRole("radio", { name: new RegExp(other.name) }), - ); + await pick(view, new RegExp(other.name)); expect(view.getByLabelText(`${other.name} API key`)).toHaveProperty( "value", "", @@ -1087,3 +1121,28 @@ for (const provider of cloudProviders) { }); }); } + +test("a chosen provider stands alone, and Change or Escape shows them all again", async () => { + invokeHandler = async (command) => { + if (command === "providers") return providers; + return null; + }; + const view = await renderPicker(); + const everyRow = (await view.findAllByRole("radio")).length; + expect(everyRow).toBeGreaterThan(1); + + await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); + expect(view.getAllByRole("radio")).toHaveLength(1); + await userEvent.click( + view.getByRole("button", { name: "Choose a different provider" }), + ); + expect(view.getAllByRole("radio")).toHaveLength(everyRow); + expect( + view.queryByRole("button", { name: "Choose a different provider" }), + ).toBeNull(); + + await userEvent.click(view.getByRole("radio", { name: /OpenAI$|^OpenAI/ })); + expect(view.getAllByRole("radio")).toHaveLength(1); + fireEvent.keyDown(window, { key: "Escape" }); + expect(view.getAllByRole("radio")).toHaveLength(everyRow); +}); diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index c4cd3eb914..40ad6c32ca 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -1,13 +1,18 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { useEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { ExternalLink } from "./ExternalLink"; import { isHttpEndpointUrl } from "./http-endpoint-url"; import { Mark } from "./Mark"; import { asProblem, InlineFailure, type Problem } from "./Problem"; +import { Sections } from "./Sections"; +import { StatusLine } from "./SetupProgress"; export type Login = "plan" | "api-key" | "endpoint" | "oauth"; +/** How long "✓ Signed in" shows before the screen moves on by itself. */ +export const CONFIRMED_FOR_MS = 1000; + export type Provider = { id: string; name: string; @@ -144,6 +149,8 @@ export function ProviderPicker({ onBack, returning = false, busy: starting = false, + local = false, + status, }: { chosen: ModelChoice | null; /** @@ -155,9 +162,14 @@ export function ProviderPicker({ held: HeldConfiguration; root: string; onChoose: (choice: ModelChoice) => void; - onBack: () => void; + /** Without it there is no Back, as while local Intelligence sets up behind this screen. */ + onBack?: () => void; returning?: boolean; busy?: boolean; + /** A local first run: its sections are Account, Install, AI and Start. */ + local?: boolean; + /** One line about work running behind this screen. */ + status?: ReactNode; }) { const initialChoice = chosen ?? recordedModel(held); const [reuse, setReuse] = useState( @@ -212,6 +224,8 @@ export function ProviderPicker({ const [failure, setFailure] = useState(null); const openRef = useRef(open); const signInRunRef = useRef(0); + // A sign-in that just finished on this screen moves on by itself once the screen is ready. + const continueAfterSignIn = useRef(false); const oauthAttempt = useRef(null); const [oauthCode, setOauthCode] = useState(null); const [oauthSignedIn, setOauthSignedIn] = useState( @@ -269,6 +283,7 @@ export function ProviderPicker({ attemptId: authorization.attemptId, }); if (current()) { + continueAfterSignIn.current = true; setOauthSignedIn(providerId); setSignInUrl(null); setOauthCode(null); @@ -337,6 +352,7 @@ export function ProviderPicker({ if (providerId !== "anthropic") { const nextToken = await invoke("finish_chatgpt_sign_in"); if (stillCurrent()) { + continueAfterSignIn.current = true; setTokens((previous) => ({ ...previous, [providerId]: nextToken })); setSignInUrl(null); } @@ -367,6 +383,7 @@ export function ProviderPicker({ // Held, not shown. It goes on to `start_stack` the same way a typed key does. const nextToken = await invoke("finish_claude_sign_in", { code }); if (stillCurrent()) { + continueAfterSignIn.current = true; setTokens((previous) => ({ ...previous, [providerId]: nextToken })); setSignInUrl(null); setCode(""); @@ -470,18 +487,79 @@ export function ProviderPicker({ ...(trimmedModel ? { model: trimmedModel } : {}), }); } + /* + * Signed in, so on to the next step: the sign-in was the thing this screen was waiting for, and + * a Continue after "Signed in" is a click that only confirms what just happened. A sign-in that + * leaves something else to fill in (an OAuth row's model) waits for Continue as before. + * + * Not at once, though: a tick beside "Signed in" for a second first, so the screen says it worked + * before it changes. Choosing another row in that second cancels the move. + */ + const [confirmed, setConfirmed] = useState(null); + const continueLatest = useRef(continueWithChoice); + continueLatest.current = continueWithChoice; + // biome-ignore lint/correctness/useExhaustiveDependencies: runs when readiness or a sign-in changes. + useEffect(() => { + if (!continueAfterSignIn.current || busy) return; + // Used up on the first look after the sign-in either way, so a field filled in later never + // moves the screen on from under somebody still typing. + continueAfterSignIn.current = false; + if (ready && !starting && row) setConfirmed(row.id); + }, [ready, busy, starting, tokens, oauthSignedIn]); + useEffect(() => { + if (!confirmed) return; + if (confirmed !== open) { + setConfirmed(null); + return; + } + const timer = setTimeout(() => { + setConfirmed(null); + continueLatest.current(); + }, CONFIRMED_FOR_MS); + return () => clearTimeout(timer); + }, [confirmed, open]); + + /** Back to every provider: the chosen one's sign-in, if any, stops. */ + function chooseAnother() { + if (oauthAttempt.current) void cancelOAuth(); + signInRunRef.current += 1; + setOpen(null); + setFailure(null); + setSignInUrl(null); + setCode(""); + setBusy(false); + setProgress(null); + setLogin(null); + } + const chooseAnotherLatest = useRef(chooseAnother); + chooseAnotherLatest.current = chooseAnother; + // Escape goes back too, except while typing, where it would throw away a half-typed key. + const anyChosen = row !== null; + useEffect(() => { + if (!anyChosen) return; + const onKey = (event: KeyboardEvent) => { + const typing = + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement; + if (event.key === "Escape" && !typing) chooseAnotherLatest.current(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [anyChosen]); return (

- {!returning &&

Step 3 of 4

} + {!returning && }

{returning ? "Refresh your AI connection" : "Connect your AI"}

Connect your provider with a supported plan or an API key.

+ {status}
Model provider - {rows.map((r) => ( + {/* Once one is chosen it stands alone, with Change to see them all again. */} + {(row ? [row] : rows).map((r) => ( ))} + {row && ( + + )}
{row && ( @@ -576,12 +664,15 @@ export function ProviderPicker({ : "Authorize OpenBot to use models available to your xAI account."}

{oauthSignedIn === row.id ? ( -

Signed in to {row.name}.

+

+ {confirmed === row.id && } + Signed in to {row.name}. +

) : signInUrl ? ( <> -

+ Waiting for you to approve sign-in in your browser. -

+ {oauthCode && (

Verification code: {oauthCode} @@ -601,7 +692,9 @@ export function ProviderPicker({ {row.name} )} - {busy && !signInUrl &&

Preparing sign-in…

} + {busy && !signInUrl && ( + Preparing sign-in… + )}

+ {token && confirmed === row.id && } {token ? `Signed in to ${row.name}.` : `A saved ${row.name} sign-in will be checked when you start.`}{" "} @@ -677,11 +771,10 @@ export function ProviderPicker({ > {busy ? "Checking…" : "Finish signing in"} + {busy && Checking the code…} ) : ( -

- Waiting for you to approve it… -

+ Waiting for you to approve it… )} ) : ( @@ -709,10 +802,8 @@ export function ProviderPicker({ )}
- {busy && progress && ( -

- {progress} -

+ {busy && ( + {progress ?? "Preparing sign-in…"} )} ))} @@ -863,14 +954,16 @@ export function ProviderPicker({ )}
- + {onBack && ( + + )} + )} + +
+ )} + + + {status.intelligenceStack && ( +
+
+

Intelligence

+ + {intelligenceRunning + ? READINESS_SAID[readiness ?? "unknown"] + : "Stopped"} + {intelligenceRunning && + intelligenceMemory !== null && + ` · ${formatBytes(intelligenceMemory)}`} + +
+ +
+ {intelligenceRunning ? ( + <> + + + + ) : ( + + )} +
+ {status.openbotRunning && ( +

+ OpenBot uses Intelligence while it runs. Stopping it frees + memory until you start it again here. +

+ )} +
+ )} + +
+
+

{status.engine === "podman" ? "Podman" : "Docker"}

+ + {status.engine ? "Running" : "Not running"} + +
+
+ + {busy && {BUSY_SAID[busy]}} + {said &&

{said}

} + +
+
Installed in
+
{status.root}
+
Version
+
+ {status.version} · {status.build} +
+ {status.intelligenceStack && ( + <> +
Intelligence stack
+
{status.intelligenceStack}
+ + )} +
+ +
+ + +
+ + )} +
+
+ ); +} + +/** Each container or process and its memory, small, under its part's heading. */ +function PartList({ parts }: { parts: Part[] }) { + if (parts.length === 0) return null; + return ( +
    + {parts.map((part) => ( +
  • + {part.name} + + {part.memoryBytes === null ? "—" : formatBytes(part.memoryBytes)} + +
  • + ))} +
+ ); +} diff --git a/desktop/src/Welcome.tsx b/desktop/src/Welcome.tsx index 054c1d344c..49af74a3cd 100644 --- a/desktop/src/Welcome.tsx +++ b/desktop/src/Welcome.tsx @@ -5,7 +5,37 @@ * to care yet: they need to know what this is, that it will not ask them for anything technical, * and where the button is. Everything else waits for a screen that needs it. */ -export function Welcome({ onStart }: { onStart: () => void }) { +export function Welcome({ + onStart, + local, +}: { + onStart: () => void; + /** + * When this build can run Intelligence on this computer, Install sets up both here. Local is the + * default with nothing to choose: one fewer decision before anything is installed. + */ + local?: { onInstall: () => void }; +}) { + if (local) { + return ( +
+
+ +

Set up OpenBot

+

+ Installs OpenBot and CopilotKit Intelligence on this computer. The + first setup takes about 10 minutes. +

+
+ +
+
+ ); + } return (
diff --git a/desktop/src/WindowsBlocker.test.tsx b/desktop/src/WindowsBlocker.test.tsx new file mode 100644 index 0000000000..cd7cb8dfa3 --- /dev/null +++ b/desktop/src/WindowsBlocker.test.tsx @@ -0,0 +1,189 @@ +import { afterAll, afterEach, beforeAll, expect, mock, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; + +let invokeCalls: Array<{ command: string; args?: unknown }> = []; +let answer: (command: string) => Promise = async () => null; + +mock.module("@tauri-apps/api/core", () => ({ + invoke: (command: string, args?: unknown) => { + invokeCalls.push({ command, args }); + return answer(command); + }, +})); + +const { commandsIn, WindowsBlocker, WindowsRestart } = await import( + "./WindowsBlocker" +); + +beforeAll(() => GlobalRegistrator.register()); +afterEach(() => { + cleanup(); + invokeCalls = []; + answer = async () => null; +}); +afterAll(() => GlobalRegistrator.unregister()); + +const ADMINISTRATOR_INSTRUCTION = + "Setting up Windows Subsystem for Linux needs administrator rights. Ask an administrator to open Windows Terminal or PowerShell as an administrator and run `dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart`, `dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart`, and `wsl --install`. Restart Windows, then start OpenBot again in your own account."; + +const noRestart = () => { + throw new Error("no restart expected"); +}; + +test("the commands are read from the native instruction, in order", () => { + expect(commandsIn(ADMINISTRATOR_INSTRUCTION)).toEqual([ + "dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart", + "dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart", + "wsl --install", + ]); +}); + +test("Set up Windows runs the fix for this blocker and hands over to the restart", async () => { + let restartNeeded = false; + answer = async () => "restart-needed"; + const view = render( + { + restartNeeded = true; + }} + />, + ); + expect(view.getByText(/Windows will ask for permission/)).toBeTruthy(); + fireEvent.click(view.getByRole("button", { name: "Set up Windows" })); + await waitFor(() => expect(restartNeeded).toBe(true)); + expect(invokeCalls).toEqual([ + { command: "windows_fix", args: { blocker: "wsl-absent" } }, + ]); +}); + +test("a fix with no restart checks the machine again", async () => { + let cleared = false; + answer = async () => "done"; + const view = render( + { + cleared = true; + }} + />, + ); + fireEvent.click(view.getByRole("button", { name: "Switch to WSL 2" })); + await waitFor(() => expect(cleared).toBe(true)); +}); + +test("a declined prompt is said, and the button and the commands stay", async () => { + answer = async () => { + throw { + said: "Windows did not get permission to make the change. Choose Set up Windows again and approve the prompt, or run the commands yourself.", + }; + }; + const view = render( + , + ); + fireEvent.click(view.getByRole("button", { name: "Set up Windows" })); + expect( + (await view.findByRole("alert")).textContent?.includes( + "did not get permission", + ), + ).toBe(true); + const button = view.getByRole("button", { + name: "Set up Windows", + }) as HTMLButtonElement; + expect(button.disabled).toBe(false); + expect(view.getByText("Or run the commands yourself")).toBeTruthy(); + expect(view.getByText("wsl --install")).toBeTruthy(); +}); + +test("a standard account can still set up, with an administrator's password, and Copy copies every command", async () => { + const written: string[] = []; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: async (text: string) => void written.push(text) }, + }); + const view = render( + , + ); + expect( + view.getByRole("heading", { + name: "An administrator needs to turn on WSL", + }), + ).toBeTruthy(); + expect(view.getByText(/administrator's name and password/)).toBeTruthy(); + expect(view.getByRole("button", { name: "Set up Windows" })).toBeTruthy(); + expect(view.getByText(/Ask an administrator/)).toBeTruthy(); + fireEvent.click(view.getByRole("button", { name: "Copy" })); + await waitFor(() => expect(view.getByRole("button", { name: "Copied" }))); + expect(written).toEqual([commandsIn(ADMINISTRATOR_INSTRUCTION).join("\n")]); +}); + +test("firmware virtualization has steps and nothing to run", () => { + const view = render( + , + ); + expect( + view.getByRole("heading", { name: "Turn on virtualization" }), + ).toBeTruthy(); + expect(view.queryByRole("button", { name: "Copy" })).toBeNull(); + expect(view.queryByRole("button", { name: "Set up Windows" })).toBeNull(); + expect(view.getByText(/nested virtualization/)).toBeTruthy(); +}); + +test("a virtual machine without nested virtualization is told what to change on its host", () => { + const view = render( + , + ); + expect( + view.getByRole("heading", { name: "Turn on nested virtualization" }), + ).toBeTruthy(); + expect(view.getByText(/Shut down this virtual machine/)).toBeTruthy(); + expect(view.getByText(/ExposeVirtualizationExtensions/)).toBeTruthy(); + expect(view.getByText(/Apple silicon cannot do this/)).toBeTruthy(); + expect(view.queryByRole("button", { name: "Set up Windows" })).toBeNull(); + expect(view.queryByText(/firmware settings/)).toBeNull(); +}); + +test("Restart now restarts Windows, and a refusal is said with the button back", async () => { + answer = async () => { + throw { + said: "OpenBot could not restart Windows. Restart it from the Start menu.", + }; + }; + const view = render(); + expect( + view.getByRole("heading", { name: "Restart Windows to finish" }), + ).toBeTruthy(); + fireEvent.click(view.getByRole("button", { name: "Restart now" })); + expect( + (await view.findByRole("alert")).textContent?.includes( + "could not restart Windows", + ), + ).toBe(true); + expect(invokeCalls).toEqual([ + { command: "windows_restart", args: undefined }, + ]); + const button = view.getByRole("button", { + name: "Restart now", + }) as HTMLButtonElement; + expect(button.disabled).toBe(false); +}); diff --git a/desktop/src/WindowsBlocker.tsx b/desktop/src/WindowsBlocker.tsx new file mode 100644 index 0000000000..f60093bdf8 --- /dev/null +++ b/desktop/src/WindowsBlocker.tsx @@ -0,0 +1,299 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useState } from "react"; +import { asProblem, Failure, type Problem } from "./Problem"; +import { StatusLine } from "./SetupProgress"; + +export type Blocker = + | "wsl-absent" + | "wsl-one" + | "wsl-no-kernel" + | "virtual-machine-platform-disabled" + | "virtualization-disabled" + | "nested-virtualization-unavailable" + | "not-administrator"; + +/** What running a blocker's fix came to: Windows restarts before it counts, or it already does. */ +type Fixed = "restart-needed" | "done"; + +const WHY_WSL = + "OpenBot runs its services in Linux containers. On Windows, those need Windows Subsystem for Linux (WSL 2)."; + +/** + * What each blocker is called, why it matters, and how OpenBot clears it. + * + * `fix` is the button: what it says, and what Windows will ask on the way. Firmware has none, + * because nothing running in Windows can change it. + */ +const SCREENS: Record< + Blocker, + { + title: string; + why: string; + fix?: { action: string; asks: string }; + administrator?: "you" | "someone-else"; + } +> = { + "wsl-absent": { + title: "Turn on Windows Subsystem for Linux", + why: `${WHY_WSL} It's a one-time setup.`, + fix: { + action: "Set up Windows", + asks: "Windows will ask for permission, then needs one restart.", + }, + administrator: "you", + }, + "wsl-one": { + title: "Switch Windows Subsystem for Linux to version 2", + why: "WSL is installed but set to version 1. OpenBot's containers need version 2.", + fix: { action: "Switch to WSL 2", asks: "This takes a few seconds." }, + administrator: "you", + }, + "wsl-no-kernel": { + title: "Finish installing Windows Subsystem for Linux", + why: "WSL is turned on, but its Linux kernel is missing, so nothing can run in it yet.", + fix: { + action: "Finish setting up WSL", + asks: "Windows will ask for permission, then needs one restart.", + }, + administrator: "you", + }, + "virtual-machine-platform-disabled": { + title: "Turn on Virtual Machine Platform", + why: "WSL 2 needs this Windows feature, and it is turned off.", + fix: { + action: "Set up Windows", + asks: "Windows will ask for permission, then needs one restart.", + }, + administrator: "you", + }, + "not-administrator": { + title: "An administrator needs to turn on WSL", + why: `${WHY_WSL} Turning it on needs administrator rights, which this account does not have.`, + fix: { + action: "Set up Windows", + asks: "Windows will ask for an administrator's name and password, then needs one restart.", + }, + administrator: "someone-else", + }, + "virtualization-disabled": { + title: "Turn on virtualization", + why: "WSL 2 runs a small virtual machine, and virtualization is turned off in this computer's firmware. OpenBot cannot change that setting.", + }, + "nested-virtualization-unavailable": { + title: "Turn on nested virtualization", + why: "This Windows runs in a virtual machine, and its host does not pass virtualization through to it. WSL 2 needs it to run, and nothing inside this virtual machine can turn it on.", + }, +}; + +/** The commands the native instruction names, in order: each one sits in backticks there. */ +export function commandsIn(instruction: string): string[] { + return [...instruction.matchAll(/`([^`]+)`/g)].map((match) => match[1]); +} + +/** + * The screen for a Windows machine that cannot run OpenBot yet, and what to do about it. + * + * One button does it: OpenBot runs the commands behind Windows's own administrator prompt, then + * hands over to the restart. The commands are still here, under the button, for somebody who would + * rather run them, or whose machine will not let OpenBot. They come from the native side's + * instruction, which is what its tests hold to, in a block that copies whole, because nobody + * should have to retype `dism.exe /online /enable-feature …` from a sentence. + */ +export function WindowsBlocker({ + blocker, + instruction, + onRestartNeeded, + onCleared = () => window.location.reload(), +}: { + blocker: Blocker; + /** The native instruction; only the commands in it are shown. */ + instruction: string; + /** The fix ran and Windows has to restart before it counts. */ + onRestartNeeded: () => void; + /** The fix ran and nothing else is needed: check the machine again. */ + onCleared?: () => void; +}) { + const screen = SCREENS[blocker]; + const commands = commandsIn(instruction); + const [fixing, setFixing] = useState(false); + const [failure, setFailure] = useState(null); + + async function fix() { + setFixing(true); + setFailure(null); + try { + const fixed = await invoke("windows_fix", { blocker }); + if (fixed === "restart-needed") onRestartNeeded(); + else onCleared(); + } catch (error) { + setFailure(asProblem(error)); + } finally { + setFixing(false); + } + } + + const steps = screen.administrator ? ( +
    +
  1. + {screen.administrator === "someone-else" + ? "Ask an administrator to open PowerShell as an administrator: " + : "Open PowerShell as an administrator: "} + right-click the Start button and choose{" "} + Terminal (Admin). +
  2. + {commands.length > 0 && ( +
  3. + {screen.administrator === "someone-else" + ? "Have them paste these commands and press Enter:" + : "Paste these commands and press Enter:"} + +
  4. + )} +
  5. + Restart Windows, then open OpenBot again + {screen.administrator === "someone-else" + ? " from your own account." + : "."} +
  6. +
+ ) : blocker === "nested-virtualization-unavailable" ? ( +
    +
  1. Shut down this virtual machine.
  2. +
  3. + In the virtual machine app on the host, turn on nested virtualization + for it. In Hyper-V, run{" "} + + Set-VMProcessor -VMName <name> -ExposeVirtualizationExtensions + $true + + ; in VMware, Enable hypervisor applications; in + Parallels, Enable nested virtualization. In the cloud, + choose a size that supports it. +
  4. +
  5. Start the virtual machine, then open OpenBot again.
  6. +
+ ) : ( +
    +
  1. + Restart and open the firmware settings (UEFI or BIOS). The key is + usually F2, F10, F12 or Delete while the computer starts. +
  2. +
  3. + Turn on Intel VT-x (Intel Virtualization Technology) or{" "} + AMD-V (SVM Mode), then save and restart. +
  4. +
  5. Start OpenBot again.
  6. +
+ ); + + return ( +
+
+

Before OpenBot can start

+

{screen.title}

+

{screen.why}

+ {screen.fix ? ( + <> +

{screen.fix.asks}

+ {fixing && ( + + Setting up Windows. Approve the Windows prompt if it asks; this + can take a few minutes. + + )} + {failure && } +
+ +
+
+ Or run the commands yourself + {steps} +
+ + ) : ( + <> + {steps} +

+ {blocker === "nested-virtualization-unavailable" + ? "Windows on a Mac with Apple silicon cannot do this in any virtual machine app. Use a Windows PC, or an x64 cloud virtual machine whose size supports nested virtualization." + : "In a virtual machine, turn on nested virtualization for it in the host's settings instead."} +

+ + )} +
+
+ ); +} + +/** + * After a fix that only counts once Windows restarts: say so, and offer to do it. + * + * Also what OpenBot opens on while that restart is still owed, so a feature that reads as on but is + * not running yet never lets setup carry on into a Docker that cannot start. + */ +export function WindowsRestart() { + const [restarting, setRestarting] = useState(false); + const [failure, setFailure] = useState(null); + return ( +
+
+

Before OpenBot can start

+

Restart Windows to finish

+

+ Windows Subsystem for Linux is set up. Windows needs to restart before + OpenBot can use it. OpenBot opens again after you sign back in. +

+ {restarting && Restarting Windows…} + {failure && } +
+ +
+

+ Save your work in other apps first. To restart later, use the Start + menu, then open OpenBot. +

+
+
+ ); +} + +/** Commands to run, one per line, copied as a whole: PowerShell runs a pasted block line by line. */ +function Command({ lines }: { lines: string[] }) { + const [copied, setCopied] = useState(false); + const text = lines.join("\n"); + return ( +
+
+        {text}
+      
+ +
+ ); +} diff --git a/desktop/src/fitWindow.ts b/desktop/src/fitWindow.ts new file mode 100644 index 0000000000..15bd5478e9 --- /dev/null +++ b/desktop/src/fitWindow.ts @@ -0,0 +1,35 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect } from "react"; + +/** + * Ask the shell for a taller window whenever the step on screen is taller than the window. + * + * Some steps grow as they are used (a provider's sign-in opening under its row, an answer arriving), + * and the window is sized for the common case. The shell decides how much it may grow on this + * monitor and never shrinks it; see `fit_setup_window`. Past what the screen allows, the page + * scrolls as before. + */ +export function useWindowFitsContent() { + useEffect(() => { + if (typeof ResizeObserver === "undefined") return; + let frame = 0; + const measure = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const wanted = document.documentElement.scrollHeight; + if (wanted > window.innerHeight) { + void invoke("fit_setup_window", { contentHeight: wanted }).catch( + () => {}, + ); + } + }); + }; + const observer = new ResizeObserver(measure); + observer.observe(document.body); + measure(); + return () => { + observer.disconnect(); + cancelAnimationFrame(frame); + }; + }, []); +} diff --git a/desktop/src/localEvalCli.test.ts b/desktop/src/localEvalCli.test.ts new file mode 100644 index 0000000000..b84e5469c5 --- /dev/null +++ b/desktop/src/localEvalCli.test.ts @@ -0,0 +1,307 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import type { ChatGateInput, LocalReport } from "./localEvalCli"; + +// Wire-format cases live with the parser in src-tauri/src/local_eval.rs. These cover the window's +// side: the command it invokes, how failures arrive, and the chat gate. + +type Invoke = (command: string, args?: unknown) => Promise; + +let invokeCalls: Array<{ command: string; args?: unknown }> = []; +let invokeHandler: Invoke = async () => { + throw new Error("invoke handler was not installed"); +}; + +mock.module("@tauri-apps/api/core", () => ({ + invoke: (command: string, args?: unknown) => { + invokeCalls.push({ command, args }); + return invokeHandler(command, args); + }, +})); + +const { + cancelLocalEval, + chatGate, + connectLocalEval, + licenceExpiresSoon, + localLicences, + localReadinesses, + intelligenceStopFailed, + intelligenceStopProblem, + listLocalEval, + LocalEvalError, + runLocalEval, + splitStopFailure, +} = await import("./localEvalCli"); + +afterEach(() => { + invokeCalls = []; +}); + +function report(overrides: Partial = {}): LocalReport { + return { + outcome: "completed", + stack: { + id: "local-abc123", + installedRelease: "0.15.0", + step: "ready", + urls: { + api: "http://localhost:8082", + dashboard: "http://intelligence.localhost:8080", + gateway: "ws://localhost:4401", + }, + hasFailedStep: false, + updateInProgress: false, + observed: { + running: true, + services: [ + { name: "cpki-app-api-test", phase: "Running", ready: true }, + ], + }, + }, + reason: null, + readiness: "unknown", + licence: "unknown", + licenceExpiresAt: null, + ...overrides, + }; +} + +test("runs one native command named by its attempt", async () => { + invokeHandler = async () => report(); + const { attempt, report: pending } = runLocalEval("status", "local-abc123"); + expect((await pending).outcome).toBe("completed"); + expect(invokeCalls).toEqual([ + { + command: "run_local_eval", + args: { attempt, action: "status", stack: "local-abc123" }, + }, + ]); +}); + +test("gives each call its own attempt", () => { + invokeHandler = async () => report(); + const first = runLocalEval("status", "local-abc123"); + const second = runLocalEval("status", "local-abc123"); + expect(first.attempt).not.toBe(second.attempt); +}); + +test("passes known failure categories through", async () => { + invokeHandler = async () => { + throw "unavailable"; + }; + const error = await runLocalEval("start", "local-abc123").report.catch( + (e) => e, + ); + expect(error).toBeInstanceOf(LocalEvalError); + expect(error.category).toBe("unavailable"); +}); + +test("never echoes an unrecognized rejection", async () => { + invokeHandler = async () => { + throw "synthetic-secret-marker"; + }; + const error = await runLocalEval("status", "local-abc123").report.catch( + (e) => e, + ); + expect(error.category).toBe("unrecognized"); + expect(String(error.message)).not.toContain("synthetic-secret-marker"); +}); + +test("lists recorded stacks without a stack argument", async () => { + invokeHandler = async () => ({ + outcome: "completed", + stacks: [report().stack], + reason: null, + }); + const { attempt, listing } = listLocalEval(); + expect((await listing).stacks[0].id).toBe("local-abc123"); + expect(invokeCalls).toEqual([ + { command: "list_local_eval", args: { attempt } }, + ]); + invokeHandler = async () => { + throw "timeout"; + }; + const error = await listLocalEval().listing.catch((e) => e); + expect(error.category).toBe("timeout"); +}); + +test("cancels the attempt it names", async () => { + invokeHandler = async () => undefined; + await cancelLocalEval("attempt-1"); + expect(invokeCalls).toEqual([ + { command: "cancel_local_eval", args: { attempt: "attempt-1" } }, + ]); +}); + +test("connects this installation as its own cancellable attempt", async () => { + invokeHandler = async () => ({ + outcome: "completed", + stack: report().stack, + reason: null, + conflicted: false, + }); + const { attempt, connection } = connectLocalEval("/tmp/openbot"); + expect((await connection).outcome).toBe("completed"); + expect(invokeCalls).toEqual([ + { + command: "connect_local_eval", + args: { attempt, root: "/tmp/openbot" }, + }, + ]); +}); + +test("a connection OpenBot would not take up arrives as a Problem; CLI failures as categories", async () => { + invokeHandler = async () => { + throw { said: "The Intelligence CLI connected a different stack." }; + }; + const problem = await connectLocalEval("/tmp/openbot").connection.catch( + (e) => e, + ); + expect(problem).toEqual({ + said: "The Intelligence CLI connected a different stack.", + }); + invokeHandler = async () => { + throw "timeout"; + }; + const error = await connectLocalEval("/tmp/openbot").connection.catch( + (e) => e, + ); + expect(error).toBeInstanceOf(LocalEvalError); + expect(error.category).toBe("timeout"); +}); + +test("a licence warns within five days of its expiry, and not before", () => { + const now = Date.parse("2026-10-21T18:00:00Z"); + const expiring = (at: string | null) => report({ licenceExpiresAt: at }); + expect(licenceExpiresSoon(expiring("2026-10-25T18:00:00Z"), now)).toBe(true); + expect(licenceExpiresSoon(expiring("2026-10-26T18:00:00Z"), now)).toBe(true); + expect(licenceExpiresSoon(expiring("2026-10-26T18:00:01Z"), now)).toBe(false); + expect(licenceExpiresSoon(expiring(null), now)).toBe(false); + expect(licenceExpiresSoon(expiring("not a date"), now)).toBe(false); +}); + +test("the readiness and licence lists match the native enums", async () => { + const native = await Bun.file( + new URL("../src-tauri/src/local_eval.rs", import.meta.url), + ).text(); + const variants = (name: string) => + [ + ...(native + .match(new RegExp(`pub enum ${name} \\{([^}]*)\\}`))?.[1] + .matchAll(/^\s{4}([A-Z][A-Za-z]*),/gm) ?? []), + ].map(([, variant]) => + variant.replace(/[A-Z]/g, (c, i) => (i ? "-" : "") + c.toLowerCase()), + ); + expect([...localReadinesses]).toEqual(variants("Readiness")); + expect([...localLicences]).toEqual(variants("Licence")); +}); + +const good: ChatGateInput = { + intelligence: report(), + openBot: "connected", + provider: "validated", +}; + +test("a v1 report with every service ready does not open chat", () => { + expect(chatGate(good)).toEqual({ open: false, blockers: ["intelligence"] }); +}); + +test("unknown, failed and missing Intelligence states all block", () => { + for (const intelligence of [ + null, + "unavailable" as const, + "timeout" as const, + report({ outcome: "blocked", reason: "authentication-required" }), + report({ outcome: "failed", stack: null }), + ]) { + expect(chatGate({ ...good, intelligence }).blockers).toContain( + "intelligence", + ); + } +}); + +test("of every readiness and licence pair, only ready and valid open chat", () => { + for (const readiness of localReadinesses) + for (const licence of localLicences) { + const open = chatGate({ + ...good, + intelligence: report({ readiness, licence }), + }).open; + expect(open).toBe(readiness === "ready" && licence === "valid"); + } + // Not on a report that did not complete. + expect( + chatGate({ + ...good, + intelligence: report({ + outcome: "blocked", + readiness: "ready", + licence: "valid", + }), + }).open, + ).toBe(false); +}); + +test("future readiness and licence values open chat only when exactly good", () => { + const future = (readiness: string, licence: string) => + ({ ...report(), readiness, licence }) as unknown as LocalReport; + expect(chatGate({ ...good, intelligence: future("ready", "valid") })).toEqual( + { open: true, blockers: [] }, + ); + for (const [readiness, licence] of [ + ["not-ready", "valid"], + ["ready", "expired"], + ["ready", "unknown"], + ["unknown", "valid"], + ]) { + expect( + chatGate({ ...good, intelligence: future(readiness, licence) }).open, + ).toBe(false); + } +}); + +test("OpenBot and provider states block independently", () => { + const ready = { ...report(), readiness: "ready", licence: "valid" }; + const intelligence = ready as unknown as LocalReport; + expect( + chatGate({ intelligence, openBot: "unknown", provider: "invalid" }), + ).toEqual({ open: false, blockers: ["openbot", "provider"] }); + expect( + chatGate({ intelligence, openBot: "not-connected", provider: "validated" }) + .blockers, + ).toEqual(["openbot"]); + expect( + chatGate({ intelligence, openBot: "connected", provider: "unvalidated" }) + .blockers, + ).toEqual(["provider"]); +}); + +test("the Stop failure prefix is the one the native side writes", async () => { + const native = await Bun.file( + new URL("../src-tauri/src/main.rs", import.meta.url), + ).text(); + expect(native).toContain( + `const INTELLIGENCE_STOP_FAILED: &str = "${intelligenceStopFailed}";`, + ); +}); + +test("a Stop failure is split into OpenBot's part and Intelligence's part", () => { + const intelligence = `${intelligenceStopFailed}the CLI timed out`; + expect(splitStopFailure(intelligence)).toEqual({ + openBot: null, + intelligence, + }); + expect( + splitStopFailure(`Compose down failed: busy\n${intelligence}`), + ).toEqual({ openBot: "Compose down failed: busy", intelligence }); + expect(splitStopFailure("Compose down failed: busy")).toEqual({ + openBot: "Compose down failed: busy", + intelligence: null, + }); + // Anything that is not the native line format is OpenBot's own failure. + expect(splitStopFailure({ said: "x" }).openBot).not.toBeNull(); + expect(intelligenceStopProblem(intelligence)).toEqual({ + said: "OpenBot stopped, but could not stop the local Intelligence stack it started.", + detail: intelligence, + }); +}); diff --git a/desktop/src/localEvalCli.ts b/desktop/src/localEvalCli.ts new file mode 100644 index 0000000000..c43895cd22 --- /dev/null +++ b/desktop/src/localEvalCli.ts @@ -0,0 +1,373 @@ +/** + * The window's view of the local evaluation CLI boundary in src-tauri/src/local_eval.rs. + * + * The native layer runs the pinned CLI, normalizes its version-1 JSON and returns only allowlisted + * facts or a static failure category. Raw CLI output never reaches the window. Runtime and licence + * decisions stay in the CLI: readiness and licence are its own states, read by exact value. + */ +import { invoke } from "@tauri-apps/api/core"; +import type { Problem } from "./Problem"; + +export type LocalAction = "status" | "start" | "stop"; +export type LocalOutcome = "completed" | "blocked" | "failed"; + +/** Static categories from `local_eval::Failure`. None carries CLI output. */ +export const localFailures = [ + "unavailable", + "unverified", + "invalid-stack", + "spawn", + "timeout", + "cancelled", + "output-limit", + "invalid-json", + "unsupported-version", + "unexpected-command", + "invalid-status", + "missing-stack", + "unsafe-endpoint", + "contract-mismatch", + "unexpected-exit", + "invalid-acceptance", +] as const; +export type LocalFailure = (typeof localFailures)[number]; + +/** The CLI's own blocker reasons, from `local_eval::Reason`. */ +export type LocalReason = + | "authentication-required" + | "eula-acceptance-required" + | "eula-unavailable" + | "eula-declined" + | "release-unavailable" + | "feature-disabled" + | "unsupported-platform" + | "unsupported-engine" + | "port-unavailable" + | "docker-unavailable" + | "update-recovery-required" + | "incompatible-release" + | "local-command-busy" + | "local-command-failed" + | "confirmation-required" + // `local login` only; the window reads these as a fixed sentence from `open_local_intelligence`. + | "dashboard-unavailable" + | "browser-open-failed" + | "unrecognized"; + +/** `local_eval::Readiness`: the CLI's own end-to-end check. Running pods are not this. */ +export const localReadinesses = [ + "ready", + "starting", + "stopped", + "degraded", + "unknown", +] as const; +export type LocalReadiness = (typeof localReadinesses)[number]; + +/** `local_eval::Licence`, as the CLI verified it. */ +export const localLicences = [ + "valid", + "expired", + "missing", + "invalid", + "organization-mismatch", + "unknown", +] as const; +export type LocalLicence = (typeof localLicences)[number]; + +export type LocalReport = { + outcome: LocalOutcome; + stack: null | { + id: string; + installedRelease: string; + step: string; + urls: { api: string; dashboard: string; gateway: string }; + hasFailedStep: boolean; + updateInProgress: boolean; + observed: null | { + running: boolean; + services: { name: string; phase: string; ready: boolean }[]; + }; + }; + reason: LocalReason | null; + // Unknown when the CLI does not publish the fact, or publishes something this build cannot read. + readiness: LocalReadiness; + licence: LocalLicence; + /** A strict ISO-8601 instant, or null. */ + licenceExpiresAt: string | null; +}; + +/** What `local connect` answered. The key it wrote went to the credential store, not here. */ +export type LocalConnection = { + outcome: LocalOutcome; + stack: LocalReport["stack"]; + reason: LocalReason | null; + /** Settings the CLI wrote earlier were changed since, and it kept them. */ + conflicted: boolean; +}; + +/** The stacks the CLI has recorded. App folders are not included. */ +export type LocalListing = { + outcome: LocalOutcome; + stacks: NonNullable[]; + reason: LocalReason | null; +}; + +export class LocalEvalError extends Error { + constructor(readonly category: LocalFailure | "unrecognized") { + super(category); + this.name = "LocalEvalError"; + } +} + +function failure(rejection: unknown): LocalEvalError { + // Only a known static category is passed on. Anything else is not echoed. + const known = localFailures.find((category) => category === rejection); + return new LocalEvalError(known ?? "unrecognized"); +} + +/** + * Starts one CLI call. `attempt` names it for `cancelLocalEval`, which works even before the + * native side has started the call. + */ +export function runLocalEval( + action: LocalAction, + stack: string, +): { attempt: string; report: Promise } { + const attempt = crypto.randomUUID(); + const report = invoke("run_local_eval", { + attempt, + action, + stack, + }).catch((rejection: unknown) => { + throw failure(rejection); + }); + return { attempt, report }; +} + +/** Lists recorded stacks, so a stack id comes from the CLI. Cancel it like any other call. */ +export function listLocalEval(): { + attempt: string; + listing: Promise; +} { + const attempt = crypto.randomUUID(); + const listing = invoke("list_local_eval", { attempt }).catch( + (rejection: unknown) => { + throw failure(rejection); + }, + ); + return { attempt, listing }; +} + +/** + * `local setup` for this installation. A stack it completes is recorded as OpenBot's, so it + * starts and stops with OpenBot. Blocked is an answer, not an error: the first is usually the + * evaluation terms. A stack that was set up but could not be recorded rejects with a `Problem`. + */ +export function setUpLocalEval( + root: string, + /** The agreement version the person accepted in this window, once they have. */ + acceptedEulaVersion: string | null = null, +): { + attempt: string; + report: Promise; +} { + const attempt = crypto.randomUUID(); + const report = invoke("set_up_local_eval", { + attempt, + root, + acceptedEulaVersion, + }).catch((rejection: unknown) => { + if (rejection && typeof rejection === "object" && "said" in rejection) { + throw rejection as Problem; + } + throw failure(rejection); + }); + return { attempt, report }; +} + +/** + * Signs in to CopilotKit, which local Intelligence needs for its evaluation licence. The CLI opens + * the approval page in the browser; this settles once the person approved it there (true) or it + * did not finish (false). + */ +export function signInToCopilotKit(): { + attempt: string; + signedIn: Promise; +} { + const attempt = crypto.randomUUID(); + const signedIn = invoke<{ signedIn: boolean }>("sign_in_to_copilotkit", { + attempt, + }).then( + (answer) => answer.signedIn, + (rejection: unknown) => { + throw failure(rejection); + }, + ); + return { attempt, signedIn }; +} + +/** The evaluation agreement, as the CLI reports it: what the person reads before Accept. */ +export type LocalTerms = + | { + status: "required"; + accepted: boolean; + version: string; + url: string; + organizationName: string; + } + | { status: "not-required" | "unavailable" | "signed-out" }; + +export function localEvalTerms(): { + attempt: string; + terms: Promise; +} { + const attempt = crypto.randomUUID(); + const terms = invoke("local_eval_terms", { attempt }).catch( + (rejection: unknown) => { + throw failure(rejection); + }, + ); + return { attempt, terms }; +} + +/** + * `local connect` for this installation, after the person chose to connect OpenBot. The native + * side keeps the key the CLI wrote as OpenBot's Intelligence key. A connection it would not take + * up rejects with a `Problem`. + */ +export function connectLocalEval(root: string): { + attempt: string; + connection: Promise; +} { + const attempt = crypto.randomUUID(); + const connection = invoke("connect_local_eval", { + attempt, + root, + }).catch((rejection: unknown) => { + if (rejection && typeof rejection === "object" && "said" in rejection) { + throw rejection as Problem; + } + throw failure(rejection); + }); + return { attempt, connection }; +} + +/** + * Signs the browser in to the recorded local stack's Intelligence dashboard with no code to + * approve. The CLI opens the browser itself, so the window opens nothing. Rejects only with a + * `Problem` whose sentence is fixed on the native side. + */ +export function openLocalIntelligence(root: string): { + attempt: string; + opened: Promise; +} { + const attempt = crypto.randomUUID(); + const opened = invoke("open_local_intelligence", { + attempt, + root, + }).catch((rejection: unknown) => { + if (rejection && typeof rejection === "object" && "said" in rejection) { + throw rejection as Problem; + } + throw { + said: "OpenBot could not open the Intelligence dashboard. Try again.", + } satisfies Problem; + }); + return { attempt, opened }; +} + +/** Whether the menus offer "Open Intelligence dashboard" for `root`. */ +export function offerLocalIntelligence( + root: string, + offered: boolean, +): Promise { + return invoke("offer_local_intelligence", { root, offered }); +} + +/** Stops one call; it then rejects with `cancelled`. */ +export function cancelLocalEval(attempt: string): Promise { + return invoke("cancel_local_eval", { attempt }); +} + +export type ChatBlocker = "intelligence" | "openbot" | "provider"; + +export type ChatGateInput = { + /** The latest status call: a report, a failure category, or nothing yet. */ + intelligence: LocalReport | LocalFailure | null; + openBot: "connected" | "not-connected" | "unknown"; + provider: "validated" | "invalid" | "unvalidated"; +}; + +/** + * Chat opens only when all three independent facts are known good. Unknown is blocked, never + * guessed ready: a CLI that does not report readiness and licence cannot open it. + */ +export function chatGate(state: ChatGateInput): { + open: boolean; + blockers: ChatBlocker[]; +} { + const blockers: ChatBlocker[] = []; + if (!intelligenceReady(state.intelligence)) blockers.push("intelligence"); + if (state.openBot !== "connected") blockers.push("openbot"); + if (state.provider !== "validated") blockers.push("provider"); + return { open: blockers.length === 0, blockers }; +} + +function intelligenceReady(report: ChatGateInput["intelligence"]): boolean { + if (report === null || typeof report === "string") return false; + return ( + report.outcome === "completed" && + canonical(report.readiness, "ready") && + canonical(report.licence, "valid") + ); +} + +// Positive match only: a future value that is not exactly the good one still blocks. +function canonical(value: string, good: string): boolean { + return value === good; +} + +const WARN_BEFORE_EXPIRY_MS = 5 * 24 * 60 * 60 * 1000; + +/** The licence expires within five days of `now`. Desktop warns; it never renews on its own. */ +export function licenceExpiresSoon( + report: LocalReport, + now: number = Date.now(), +): boolean { + if (report.licenceExpiresAt === null) return false; + const expires = Date.parse(report.licenceExpiresAt); + return !Number.isNaN(expires) && expires - now <= WARN_BEFORE_EXPIRY_MS; +} + +/** How main.rs starts an Intelligence stop failure (`INTELLIGENCE_STOP_FAILED`). */ +export const intelligenceStopFailed = "Intelligence stop failed: "; + +/** + * Splits a Stop failure into OpenBot's own lines and the local Intelligence stack's. OpenBot has + * stopped when only the second is present. Anything not in the native line format is OpenBot's. + */ +export function splitStopFailure(thrown: unknown): { + openBot: string | null; + intelligence: string | null; +} { + if (typeof thrown !== "string") { + return { openBot: String(thrown), intelligence: null }; + } + const lines = thrown.split("\n"); + const own = lines.filter((line) => !line.startsWith(intelligenceStopFailed)); + const intelligence = lines.filter((line) => + line.startsWith(intelligenceStopFailed), + ); + return { + openBot: own.length ? own.join("\n") : null, + intelligence: intelligence.length ? intelligence.join("\n") : null, + }; +} + +export function intelligenceStopProblem(line: string): Problem { + return { + said: "OpenBot stopped, but could not stop the local Intelligence stack it started.", + detail: line, + }; +} diff --git a/desktop/src/localReadiness.test.ts b/desktop/src/localReadiness.test.ts new file mode 100644 index 0000000000..04b36ba0f3 --- /dev/null +++ b/desktop/src/localReadiness.test.ts @@ -0,0 +1,415 @@ +import { expect, test } from "bun:test"; +import { + type ChatGateInput, + chatGate, + type LocalFailure, + type LocalReason, + type LocalReport, + localFailures, +} from "./localEvalCli"; +import { localReadiness, type ReadinessInput } from "./localReadiness"; + +const reasons: LocalReason[] = [ + "authentication-required", + "eula-acceptance-required", + "eula-unavailable", + "eula-declined", + "release-unavailable", + "feature-disabled", + "unsupported-platform", + "unsupported-engine", + "port-unavailable", + "docker-unavailable", + "update-recovery-required", + "incompatible-release", + "local-command-busy", + "local-command-failed", + "confirmation-required", + "dashboard-unavailable", + "browser-open-failed", + "unrecognized", +]; + +function report(overrides: Partial = {}): LocalReport { + return { + outcome: "completed", + stack: null, + reason: null, + readiness: "unknown", + licence: "unknown", + licenceExpiresAt: null, + ...overrides, + }; +} + +function future(readiness: string, licence: string): LocalReport { + return { ...report(), readiness, licence } as unknown as LocalReport; +} + +const base: ReadinessInput = { + runtime: "running", + intelligence: report(), + openBot: "connected", + provider: "validated", +}; + +function hasText(said: string, slug: string): boolean { + return said.toLowerCase().includes(slug); +} + +test("every reason gives a plain, non-empty sentence without its own name", () => { + for (const reason of reasons) { + const rows = localReadiness({ + ...base, + intelligence: report({ outcome: "blocked", reason }), + }); + for (const row of Object.values(rows)) { + expect(row.said.length).toBeGreaterThan(0); + expect(row.said).not.toContain("!"); + expect(hasText(row.said, reason)).toBe(false); + } + expect(rows.intelligence.state).toBe("blocked"); + } +}); + +test("every failure gives a plain, non-empty sentence without its own name", () => { + for (const failure of localFailures) { + const rows = localReadiness({ ...base, intelligence: failure }); + expect(rows.intelligence.said.length).toBeGreaterThan(0); + expect(rows.intelligence.said).not.toContain("!"); + expect(hasText(rows.intelligence.said, failure)).toBe(false); + expect(rows.intelligence.state).toBe("blocked"); + } +}); + +test("the reason and failure lists in this test match the source", () => { + expect(new Set(reasons).size).toBe(18); + expect(localFailures.length).toBe(16); +}); + +test("Docker reasons block the runtime when the probe has no answer", () => { + const rows = localReadiness({ + ...base, + runtime: "unknown", + intelligence: report({ outcome: "blocked", reason: "docker-unavailable" }), + }); + expect(rows.runtime).toEqual({ + state: "blocked", + said: "Docker Desktop is not running.", + action: "retry", + }); + expect(rows.intelligence.state).toBe("blocked"); + const engine = localReadiness({ + ...base, + runtime: "unknown", + intelligence: report({ outcome: "blocked", reason: "unsupported-engine" }), + }); + expect(engine.runtime.said).toBe("This Docker setup is not supported."); + expect(engine.runtime.action).toBeUndefined(); +}); + +test("a running probe is not overridden by the CLI", () => { + const rows = localReadiness({ + ...base, + intelligence: report({ outcome: "blocked", reason: "docker-unavailable" }), + }); + expect(rows.runtime.state).toBe("ready"); +}); + +test("runtime probe maps directly", () => { + expect(localReadiness({ ...base, runtime: "running" }).runtime.state).toBe( + "ready", + ); + expect(localReadiness({ ...base, runtime: "not-running" }).runtime).toEqual({ + state: "blocked", + said: "Docker Desktop is not running.", + action: "retry", + }); + expect(localReadiness({ ...base, runtime: "unknown" }).runtime.state).toBe( + "unknown", + ); +}); + +test("specific wording and actions", () => { + const said = (intelligence: ReadinessInput["intelligence"]) => + localReadiness({ ...base, intelligence }).intelligence; + expect( + said(report({ outcome: "blocked", reason: "port-unavailable" })), + ).toEqual({ + state: "blocked", + said: "A port Intelligence needs is in use.", + action: "retry", + }); + expect( + said(report({ outcome: "blocked", reason: "authentication-required" })), + ).toEqual({ + state: "blocked", + said: "Sign in to CopilotKit.", + action: "sign-in", + }); + expect(said("cancelled").action).toBe("retry"); + expect(said("cancelled").said).toBe( + "Stopped at your request before it finished.", + ); + expect( + said(report({ outcome: "blocked", reason: "local-command-busy" })).said, + ).toBe("Another Intelligence command is still running."); + expect(said("timeout")).toEqual({ + state: "blocked", + said: "Intelligence could not start.", + action: "restart-intelligence", + }); + expect(said("unavailable").action).toBeUndefined(); + expect(said("unverified").action).toBe("repair"); + expect(said("invalid-json")).toEqual({ + state: "blocked", + said: "OpenBot did not understand Intelligence's reply.", + }); +}); + +test("a blocked or failed report with no reason still blocks", () => { + for (const outcome of ["blocked", "failed"] as const) { + const row = localReadiness({ + ...base, + intelligence: report({ outcome, reason: null }), + }).intelligence; + expect(row.state).toBe("blocked"); + expect(row.action).toBe("restart-intelligence"); + } +}); + +test("a v1 report is unknown, never ready", () => { + const row = localReadiness(base).intelligence; + expect(row).toEqual({ + state: "unknown", + said: "Intelligence started. Its licence and readiness are not reported yet.", + }); + expect( + localReadiness({ ...base, intelligence: null }).intelligence.state, + ).toBe("unknown"); +}); + +test("no v1 input ever gives an Intelligence row of ready", () => { + const inputs: ReadinessInput["intelligence"][] = [ + null, + ...localFailures, + ...reasons.map((reason) => report({ outcome: "blocked", reason })), + ...reasons.map((reason) => report({ outcome: "completed", reason })), + report(), + report({ outcome: "failed" }), + report({ outcome: "blocked" }), + ]; + for (const intelligence of inputs) { + expect( + localReadiness({ ...base, intelligence }).intelligence.state, + ).not.toBe("ready"); + } +}); + +test("only exactly ready and valid on a completed report is ready", () => { + expect( + localReadiness({ ...base, intelligence: future("ready", "valid") }) + .intelligence.state, + ).toBe("ready"); + for (const [readiness, licence] of [ + ["not-ready", "valid"], + ["ready", "expired"], + ["ready", "unknown"], + ["unknown", "valid"], + ["Ready", "valid"], + ]) { + expect( + localReadiness({ ...base, intelligence: future(readiness, licence) }) + .intelligence.state, + ).not.toBe("ready"); + } + const failed = { + ...future("ready", "valid"), + outcome: "failed", + } as LocalReport; + expect( + localReadiness({ ...base, intelligence: failed }).intelligence.state, + ).not.toBe("ready"); +}); + +test("OpenBot and provider rows", () => { + expect(localReadiness({ ...base, openBot: "connected" }).openBot.state).toBe( + "ready", + ); + expect( + localReadiness({ ...base, openBot: "not-connected" }).openBot, + ).toMatchObject({ state: "blocked", action: "restart-openbot" }); + expect(localReadiness({ ...base, openBot: "unknown" }).openBot.state).toBe( + "unknown", + ); + expect( + localReadiness({ ...base, provider: "validated" }).provider.state, + ).toBe("ready"); + expect( + localReadiness({ ...base, provider: "invalid" }).provider, + ).toMatchObject({ + state: "blocked", + said: "The AI provider rejected the key.", + }); + expect( + localReadiness({ ...base, provider: "unvalidated" }).provider.state, + ).toBe("unknown"); +}); + +test("rows are independent", () => { + const before = localReadiness(base); + const changed = (input: ReadinessInput) => { + const after = localReadiness(input); + return (Object.keys(after) as (keyof typeof after)[]).filter( + (key) => JSON.stringify(after[key]) !== JSON.stringify(before[key]), + ); + }; + expect(changed({ ...base, runtime: "not-running" })).toEqual(["runtime"]); + expect(changed({ ...base, openBot: "not-connected" })).toEqual(["openBot"]); + expect(changed({ ...base, provider: "invalid" })).toEqual(["provider"]); + expect(changed({ ...base, intelligence: "timeout" })).toEqual([ + "intelligence", + ]); + expect( + changed({ + ...base, + intelligence: report({ outcome: "blocked", reason: "port-unavailable" }), + }), + ).toEqual(["intelligence"]); +}); + +// chatGate does not take the runtime. So the direction that holds is: if every row is ready, chat +// is open; and if chat is open, the Intelligence, OpenBot and provider rows are ready (the runtime +// row may still be unknown or blocked). +test("agrees with chatGate", () => { + const intelligences: ChatGateInput["intelligence"][] = [ + null, + "timeout", + report(), + report({ outcome: "blocked", reason: "authentication-required" }), + future("ready", "valid"), + future("ready", "expired"), + ]; + const runtimes: ReadinessInput["runtime"][] = [ + "running", + "not-running", + "unknown", + ]; + const openBots: ChatGateInput["openBot"][] = [ + "connected", + "not-connected", + "unknown", + ]; + const providers: ChatGateInput["provider"][] = [ + "validated", + "invalid", + "unvalidated", + ]; + let allReady = 0; + for (const intelligence of intelligences) + for (const runtime of runtimes) + for (const openBot of openBots) + for (const provider of providers) { + const rows = localReadiness({ + runtime, + intelligence, + openBot, + provider, + }); + const open = chatGate({ intelligence, openBot, provider }).open; + const every = Object.values(rows).every((r) => r.state === "ready"); + const three = [rows.intelligence, rows.openBot, rows.provider].every( + (r) => r.state === "ready", + ); + if (every) { + allReady += 1; + expect(open).toBe(true); + } + expect(three).toBe(open); + } + expect(allReady).toBe(1); +}); + +test("types: the failure and reason inputs are the upstream ones", () => { + const failure: LocalFailure = "spawn"; + expect( + localReadiness({ ...base, intelligence: failure }).intelligence.said, + ).toBe("Intelligence could not start."); +}); + +test("each readiness state of a valid licence has its own row", () => { + const row = (readiness: LocalReport["readiness"]) => + localReadiness({ + ...base, + intelligence: report({ readiness, licence: "valid" }), + }).intelligence; + expect(row("ready")).toEqual({ + state: "ready", + said: "Intelligence is ready.", + }); + expect(row("starting")).toEqual({ + state: "unknown", + said: "Intelligence is starting.", + }); + expect(row("stopped")).toEqual({ + state: "blocked", + said: "Intelligence is stopped.", + action: "start-intelligence", + }); + expect(row("degraded")).toEqual({ + state: "blocked", + said: "Intelligence is running but not healthy.", + action: "restart-intelligence", + }); + expect(row("unknown")).toEqual({ + state: "unknown", + said: "Intelligence started. Its licence and readiness are not reported yet.", + }); +}); + +test("a licence that is not valid blocks a ready Intelligence", () => { + const row = (licence: LocalReport["licence"]) => + localReadiness({ + ...base, + intelligence: report({ readiness: "ready", licence }), + }).intelligence; + expect(row("expired")).toEqual({ + state: "blocked", + said: "The Intelligence evaluation licence has expired.", + }); + expect(row("missing")).toEqual({ + state: "blocked", + said: "Intelligence has no evaluation licence.", + }); + expect(row("invalid")).toEqual({ + state: "blocked", + said: "The Intelligence evaluation licence is not valid.", + }); + expect(row("organization-mismatch")).toEqual({ + state: "blocked", + said: "The Intelligence licence belongs to a different organization.", + }); + expect(row("unknown").state).toBe("unknown"); +}); + +test("a licence expiring in four days is ready with a warning", () => { + const now = Date.parse("2026-10-21T18:00:00Z"); + const expiring = (licenceExpiresAt: string) => + localReadiness({ + ...base, + now, + intelligence: report({ + readiness: "ready", + licence: "valid", + licenceExpiresAt, + }), + }).intelligence; + expect(expiring("2026-10-25T18:00:00.000Z")).toEqual({ + state: "ready", + said: "Intelligence is ready. Its evaluation licence expires within 5 days.", + }); + expect(expiring("2026-10-27T18:00:00.000Z")).toEqual({ + state: "ready", + said: "Intelligence is ready.", + }); +}); diff --git a/desktop/src/localReadiness.ts b/desktop/src/localReadiness.ts new file mode 100644 index 0000000000..55cbf00cab --- /dev/null +++ b/desktop/src/localReadiness.ts @@ -0,0 +1,247 @@ +/** + * What the window can honestly say about each part of the local setup. + * + * Four rows, one per independent fact: the container runtime, Intelligence, OpenBot's connection + * to it, and the AI provider key. Every sentence here is static. Nothing from the CLI is shown, + * because the native boundary passes on only enumerated categories. + * + * Unknown is never shown as ready. The Intelligence row is ready only when the CLI itself reports + * readiness "ready" and licence "valid"; a CLI that reports neither leaves it unknown. + */ +import { + type ChatGateInput, + chatGate, + type LocalFailure, + type LocalLicence, + type LocalReason, + licenceExpiresSoon, +} from "./localEvalCli"; + +export type ReadinessState = "ready" | "blocked" | "unknown"; +export type ReadinessAction = + | "retry" + | "start-intelligence" + | "restart-intelligence" + | "restart-openbot" + | "sign-in" + | "repair"; + +export type ReadinessRow = { + state: ReadinessState; + said: string; + action?: ReadinessAction; +}; + +export type ReadinessInput = { + /** Docker Desktop, as probed by the window. */ + runtime: "running" | "not-running" | "unknown"; + /** The latest status call: a report, a failure category, or nothing yet. */ + intelligence: ChatGateInput["intelligence"]; + openBot: ChatGateInput["openBot"]; + provider: ChatGateInput["provider"]; + /** The clock the licence expiry warning is measured against. */ + now?: number; +}; + +export type Readiness = { + runtime: ReadinessRow; + intelligence: ReadinessRow; + openBot: ReadinessRow; + provider: ReadinessRow; +}; + +function blocked(said: string, action?: ReadinessAction): ReadinessRow { + return action + ? { state: "blocked", said, action } + : { state: "blocked", said }; +} + +const unavailableHere = + "Intelligence is not available on this computer or release."; +const contract = "OpenBot did not understand Intelligence's reply."; +const couldNotStart = blocked( + "Intelligence could not start.", + "restart-intelligence", +); +const busy = blocked("Another Intelligence command is still running.", "retry"); + +// Compiler-enforced: a new upstream reason or failure breaks the build here. +const reasonRows: Record = { + "authentication-required": blocked("Sign in to CopilotKit.", "sign-in"), + // Acceptance is an explicit step of its own; retrying start cannot satisfy it. + "eula-acceptance-required": blocked( + "The Intelligence terms have not been accepted.", + ), + "eula-unavailable": blocked( + "The Intelligence terms could not be shown.", + "retry", + ), + "eula-declined": blocked("The Intelligence terms were declined."), + "release-unavailable": blocked(unavailableHere), + "feature-disabled": blocked(unavailableHere), + "unsupported-platform": blocked(unavailableHere), + "incompatible-release": blocked(unavailableHere), + "update-recovery-required": blocked(unavailableHere), + // The runtime row carries the Docker detail. Here Intelligence is simply blocked behind it. + "unsupported-engine": blocked( + "Intelligence cannot run on this computer's current setup.", + ), + "docker-unavailable": blocked( + "Intelligence cannot start until the software it runs on is running.", + "retry", + ), + "port-unavailable": blocked("A port Intelligence needs is in use.", "retry"), + "local-command-busy": busy, + "local-command-failed": couldNotStart, + // OpenBot always approves the connection it asks for, so this is the CLI asking for more. + "confirmation-required": blocked( + "Intelligence asked for a confirmation OpenBot cannot give.", + ), + // Reasons only `local login` gives; a status that gave one is still blocked, never ready. + "dashboard-unavailable": blocked( + "The Intelligence dashboard is not responding yet.", + "retry", + ), + "browser-open-failed": blocked("Your browser did not open.", "retry"), + unrecognized: couldNotStart, +}; + +// A licence the CLI verified as anything but valid blocks, however ready Intelligence is. +const licenceRows: Record< + Exclude, + ReadinessRow +> = { + expired: blocked("The Intelligence evaluation licence has expired."), + missing: blocked("Intelligence has no evaluation licence."), + invalid: blocked("The Intelligence evaluation licence is not valid."), + "organization-mismatch": blocked( + "The Intelligence licence belongs to a different organization.", + ), +}; + +const failureRows: Record = { + unavailable: blocked( + "This version of OpenBot cannot set up Intelligence on this computer.", + ), + unverified: blocked( + "The Intelligence software failed its integrity check.", + "repair", + ), + // Cancelled is the person's own call stopping, not another command holding the stack. + cancelled: blocked("Stopped at your request before it finished.", "retry"), + spawn: couldNotStart, + timeout: couldNotStart, + "unexpected-exit": couldNotStart, + "invalid-stack": blocked(contract), + "output-limit": blocked(contract), + "invalid-json": blocked(contract), + "unsupported-version": blocked(contract), + "unexpected-command": blocked(contract), + "invalid-status": blocked(contract), + "missing-stack": blocked(contract), + "unsafe-endpoint": blocked(contract), + "contract-mismatch": blocked(contract), + "invalid-acceptance": blocked( + "The evaluation agreement could not be accepted.", + "retry", + ), +}; + +function intelligenceRow( + intelligence: ReadinessInput["intelligence"], + now: number, +): ReadinessRow { + if (intelligence === null) { + return { + state: "unknown", + said: "Intelligence has not been checked yet.", + }; + } + if (typeof intelligence === "string") return failureRows[intelligence]; + // Same positive match as the chat gate: only an exactly good report is ready. + const good = chatGate({ + intelligence, + openBot: "connected", + provider: "validated", + }).open; + if (good) { + return { + state: "ready", + said: licenceExpiresSoon(intelligence, now) + ? "Intelligence is ready. Its evaluation licence expires within 5 days." + : "Intelligence is ready.", + }; + } + if (intelligence.reason !== null) return reasonRows[intelligence.reason]; + if (intelligence.outcome !== "completed") return couldNotStart; + const licence = intelligence.licence; + if (licence !== "valid" && licence !== "unknown") return licenceRows[licence]; + if (intelligence.readiness === "starting") { + return { state: "unknown", said: "Intelligence is starting." }; + } + if (intelligence.readiness === "stopped") { + return blocked("Intelligence is stopped.", "start-intelligence"); + } + if (intelligence.readiness === "degraded") { + return blocked( + "Intelligence is running but not healthy.", + "restart-intelligence", + ); + } + return { + state: "unknown", + said: "Intelligence started. Its licence and readiness are not reported yet.", + }; +} + +/** + * The CLI's Docker reasons are evidence about the runtime, used only when the window's own probe + * has no answer. A probe that says Docker is running is not overridden. + */ +function runtimeRow( + runtime: ReadinessInput["runtime"], + intelligence: ReadinessInput["intelligence"], +): ReadinessRow { + if (runtime === "running") { + return { state: "ready", said: "Docker Desktop is running." }; + } + const notRunning = blocked("Docker Desktop is not running.", "retry"); + if (runtime === "not-running") return notRunning; + const reason = + intelligence !== null && typeof intelligence !== "string" + ? intelligence.reason + : null; + if (reason === "docker-unavailable") return notRunning; + if (reason === "unsupported-engine") { + return blocked("This Docker setup is not supported."); + } + return { state: "unknown", said: "Docker Desktop has not been checked." }; +} + +export function localReadiness(input: ReadinessInput): Readiness { + return { + runtime: runtimeRow(input.runtime, input.intelligence), + intelligence: intelligenceRow(input.intelligence, input.now ?? Date.now()), + openBot: + input.openBot === "connected" + ? { state: "ready", said: "OpenBot is connected to Intelligence." } + : input.openBot === "not-connected" + ? blocked( + "OpenBot is not connected to Intelligence.", + "restart-openbot", + ) + : { + state: "unknown", + said: "OpenBot's connection has not been checked.", + }, + provider: + input.provider === "validated" + ? { state: "ready", said: "The AI provider key is valid." } + : input.provider === "invalid" + ? blocked("The AI provider rejected the key.") + : { + state: "unknown", + said: "The AI provider key has not been checked.", + }, + }; +} diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index b536b4b047..824c578ae7 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -1,14 +1,27 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { isShuttingDownPage, ShuttingDownWindow } from "./ShuttingDown"; +import { isStatusPage, StatusWindow } from "./StatusWindow"; +import "@fontsource-variable/plus-jakarta-sans"; +import "@fontsource-variable/spline-sans-mono"; import "./styles.css"; const root = document.getElementById("root"); if (!root) throw new Error("index.html has no #root for the window to render into."); +// Sent here by Quit: only the Shutting down screen, so none of setup's launch checks run again. createRoot(root).render( - + {/* The six blurred colour fields every screen sits on; see `.backdrop` in styles.css. */} +