diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index 27fe657b18..48efe593e8 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -46,6 +46,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"; import { useSignOut } from "./use-sign-out"; const appLinkOptions = { to: "/" } satisfies LinkOptions; @@ -349,6 +350,8 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ))} {/* Your own browser, with the Recorder in it. Drawn only where OpenBot can run one for you. */} + {/* 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 53e5d9bec3..e3a377dfe3 100644 --- a/app/src/components/settings/settings-sidebar.tsx +++ b/app/src/components/settings/settings-sidebar.tsx @@ -3,6 +3,7 @@ import { IconBell, IconBrowser, IconChecks, + IconDeviceDesktop, IconKey, IconLayoutGrid, IconSchool, @@ -20,6 +21,7 @@ import { SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar"; +import { desktopShell } from "@/lib/desktop/intelligence-dashboard"; const appLinkOptions = { to: "/" } satisfies LinkOptions; @@ -31,6 +33,8 @@ export const SETTINGS_SIDEBAR_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; @@ -72,6 +76,13 @@ export const SETTINGS_SIDEBAR_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", @@ -107,7 +118,9 @@ export function SettingsSidebar({ */} - {SETTINGS_SIDEBAR_ITEMS.map((option) => ( + {SETTINGS_SIDEBAR_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 8adf9c0b53..108aa1d264 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -14,6 +14,34 @@ */ @source "../node_modules/streamdown/dist/*.js"; +/* + * 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; +} + /* * A tool line that is still running. * 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/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/acquire.rs b/desktop/src-tauri/src/acquire.rs index 9bcbb94b4c..37c623fc94 100644 --- a/desktop/src-tauri/src/acquire.rs +++ b/desktop/src-tauri/src/acquire.rs @@ -9,6 +9,14 @@ //! needs elevation. So the two halves run in different contexts, and the elevated half is the only //! part that may be handed to a helper. See `windows.rs`. Fetching and installing Podman itself is //! `install.rs`. +//! +//! **The machine is rootful.** Local Intelligence runs a k3d cluster, and the Intelligence CLI +//! accepts Podman only in rootful mode, because k3d's node needs privileges a rootless engine +//! cannot grant. A rootful machine is the same VM with three things changed: its default +//! connection is `-root`, `/var/run/docker.sock` inside it is the rootful socket, and the API +//! socket it forwards to this computer is the rootful one. OpenBot's own containers are addressed +//! through `-root` as well (see `connection_for`), so OpenBot and Intelligence share one +//! engine and the supervisor's mount of `/var/run/docker.sock` is the engine it was started by. use crate::quiet::said as command_said; use std::net::Ipv4Addr; @@ -80,7 +88,135 @@ impl StepOutcome { pub const MACHINE: &str = "openbot"; const USER_MODE_NETWORKING_FLAG: &str = "--user-mode-networking=true"; +const ROOTFUL_FLAG: &str = "--rootful"; const HOST_GATEWAY_CONFIG: &str = ".config/containers/containers.conf.d/90-openbot-host.conf"; +/// Where a rootful engine reads the same setting. Root's own `~/.config` is not read by rootful +/// Podman, and the API service a rootful machine runs is the system one, not a user unit. +const ROOTFUL_HOST_GATEWAY_CONFIG: &str = "/etc/containers/containers.conf.d/90-openbot-host.conf"; + +/// The connection that reaches a machine's engine in the mode the machine runs in. +/// +/// `podman machine init` makes two connections for every machine, whatever its mode: ``, +/// which logs in as the machine's user and reaches the rootless engine, and `-root`, which +/// reaches the rootful one. The machine's own mode decides only which of the two is the default, +/// which socket `/var/run/docker.sock` is inside it, and which API it forwards to this computer. +/// Addressing a rootful machine by its bare name would put OpenBot's containers in the rootless +/// engine beside a rootful one, with the supervisor mounting a socket it cannot open. The one +/// exception is an OpenBot that already lives there (see `connection_keeping_openbot`). +pub fn connection_for(machine: &str, rootful: bool) -> String { + if rootful { + format!("{machine}-root") + } else { + machine.to_string() + } +} + +/// The connection to address a running machine through: the one for its mode, unless OpenBot's +/// database is only in the other one's storage. +/// +/// The two engines in one machine keep separate containers and volumes. Releases up to 0.2.0 +/// reached every machine by its bare name, so an OpenBot that adopted somebody's rootful machine, +/// or whose own machine was switched to rootful since, has its database in the rootless engine's +/// storage. Moving it to `-root` would open it on an empty database with the old one +/// unreachable beside it, so it stays where it is. Only a rootful machine is asked: a rootless one +/// was always reached by its bare name. +fn connection_keeping_openbot( + machine: &str, + rootful: bool, + holds_openbot: impl Fn(&str) -> bool, +) -> String { + let own = connection_for(machine, rootful); + if rootful && !holds_openbot(&own) && holds_openbot(machine) { + return machine.to_string(); + } + own +} + +/// Whether OpenBot's database volume is in the engine behind `connection`. +/// +/// By Compose's own label for the volume rather than by its full name, which carries the +/// deployment folder's name. Any project's `postgres-data` counts: a false yes keeps the +/// connection a release up to 0.2.0 would have used, and a false no is the one that loses data. +fn holds_openbot(connection: &str) -> bool { + podman(&[ + "--connection", + connection, + "volume", + "ls", + "--quiet", + "--filter", + "label=com.docker.compose.volume=postgres-data", + ]) + .is_ok_and(|listing| !listing.is_empty()) +} + +/// The machine a connection belongs to, by the naming `connection_for` describes. +fn machine_of(connection: &str) -> &str { + connection.strip_suffix("-root").unwrap_or(connection) +} + +/// What `podman machine inspect` says about one machine that matters here. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct MachineFacts { + /// `None` when inspect did not say, which no supported Podman does. + pub rootful: Option, + /// The engine API this machine forwards to this computer: a Unix socket on macOS, a named pipe + /// on Windows, as a `unix://` or `npipe://` address. + pub api: Option, +} + +fn machine_facts(listing: &str, machine: &str) -> Result { + let machines: Vec = serde_json::from_str(listing) + .map_err(|error| format!("could not read podman machine inspect JSON: {error}"))?; + let found = machines + .iter() + .find(|entry| entry.get("Name").and_then(Value::as_str) == Some(machine)) + .ok_or_else(|| format!("podman machine inspect did not return {machine}"))?; + let path = |key: &str| { + found + .pointer(&format!("/ConnectionInfo/{key}/Path")) + .and_then(Value::as_str) + .filter(|path| !path.is_empty()) + }; + // The pipe first: Windows reports a pipe, and a socket path beside it is not one this + // computer can open. + let api = path("PodmanPipe") + .map(pipe_address) + .or_else(|| path("PodmanSocket").map(|socket| format!("unix://{socket}"))); + Ok(MachineFacts { + rootful: found.get("Rootful").and_then(Value::as_bool), + api, + }) +} + +/// `\\.\pipe\podman-openbot` as the `npipe:////./pipe/podman-openbot` that Docker clients, k3d and +/// the Intelligence CLI read. +fn pipe_address(pipe: &str) -> String { + format!("npipe://{}", pipe.replace('\\', "/")) +} + +/// What the machine behind a Podman connection is, or why that could not be found. +/// +/// A connection that is not a machine's (a remote host somebody added) answers with an error, and +/// the caller then leaves the engine to find its own way. +pub fn machine_behind(connection: &str) -> Result { + let machine = machine_of(connection); + machine_facts(&podman(&["machine", "inspect", machine])?, machine) +} + +/// The connection for a machine found by name, in the mode it runs in, or where its OpenBot +/// already is (see `connection_keeping_openbot`). +/// +/// A machine that cannot be inspected keeps its bare name, which is how it was addressed before +/// machines were rootful. +pub fn connection_of_machine(machine: &str) -> String { + let rootful = podman(&["machine", "inspect", machine]) + .and_then(|listing| machine_facts(&listing, machine)) + .ok() + .and_then(|facts| facts.rootful) + .unwrap_or(false); + connection_keeping_openbot(machine, rootful, holds_openbot) +} fn podman(args: &[&str]) -> Result { podman_with(args, || { @@ -148,6 +284,18 @@ pub fn machine_exists() -> Result { /// No `--provider`: `applehv` has been the default on Apple silicon since Podman 6.1, and pinning it /// asks for what you already get. The libkrun bind-mount trouble that the pin was written for /// belonged to 5.7, where libkrun was the default. +/// +/// `--rootful`, for local Intelligence: see the module comment. +/// +/// AN EXISTING MACHINE IS NEVER SWITCHED TO ROOTFUL, OURS INCLUDED. `podman machine set --rootful` +/// would do it, on a stopped machine, which is the person's to stop. A rootless machine keeps its +/// containers and volumes in the rootless engine's storage, and the rootful engine in the same VM +/// does not see them. Releases up to 0.2.0 created this machine rootless, so "ours" is not +/// "empty". A rootless machine is left as it is and OpenBot keeps running on it; local +/// Intelligence then declines to start there with a sentence that names the switch (see +/// `local_eval::Failure::RootlessPodman`). After the switch OpenBot stays in the rootless storage +/// its database is in (see `connection_keeping_openbot`), and Intelligence runs on the rootful +/// engine of the same machine. pub fn create_machine(cpus: u32, memory_mib: u32, disk_gib: u32) -> StepOutcome { create_machine_with(cpus, memory_mib, disk_gib, machine_exists, podman) } @@ -190,6 +338,7 @@ fn create_machine_args( memory_mib.to_string(), "--disk-size".to_string(), disk_gib.to_string(), + ROOTFUL_FLAG.to_string(), ]; if target_is_windows { args.push(USER_MODE_NETWORKING_FLAG.to_string()); @@ -201,14 +350,19 @@ fn create_machine_args( struct MachineNetworking { state: String, user_mode: bool, + /// Decides where the host-gateway setting is written. Absent reads as rootless, which is what + /// every machine was before this app created them rootful. + rootful: bool, } +/// Answers with the machine's networking on Windows, where the gateway is configured after start, +/// and with nothing elsewhere. fn prepare_user_mode_networking_before_start( run: &mut impl FnMut(&[&str]) -> Result, target_is_windows: bool, -) -> Result<(), String> { +) -> Result, String> { if !target_is_windows { - return Ok(()); + return Ok(None); } let listing = run(&["machine", "inspect", MACHINE])?; @@ -221,7 +375,7 @@ fn prepare_user_mode_networking_before_start( if networking.state.eq_ignore_ascii_case("stopped") && !networking.user_mode { run(&["machine", "set", USER_MODE_NETWORKING_FLAG, MACHINE])?; } - Ok(()) + Ok(Some(networking)) } fn owned_machine_networking(listing: &str) -> Result { @@ -242,12 +396,21 @@ fn owned_machine_networking(listing: &str) -> Result .ok_or_else(|| { format!("podman machine inspect did not include {MACHINE} user-mode networking") })?; - Ok(MachineNetworking { state, user_mode }) + let rootful = machine + .get("Rootful") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(MachineNetworking { + state, + user_mode, + rootful, + }) } fn configure_host_gateway_after_start( run: &mut impl FnMut(&[&str]) -> Result, target_is_windows: bool, + rootful: bool, ) -> Result<(), String> { if !target_is_windows { return Ok(()); @@ -262,7 +425,11 @@ fn configure_host_gateway_after_start( "host.containers.internal", ])?; let ip = first_valid_host_gateway_ip(&resolved)?; - let script = host_gateway_config_script(ip); + let script = if rootful { + rootful_host_gateway_config_script(ip) + } else { + host_gateway_config_script(ip) + }; run(&["machine", "ssh", MACHINE, &script]).map(|_| ()) } @@ -281,7 +448,7 @@ fn configure_owned_windows_podman_for_compose( "{MACHINE} is running without Podman user-mode networking. Stop the OpenBot engine machine and start OpenBot again so host callbacks can be configured." )); } - configure_host_gateway_after_start(&mut run, true) + configure_host_gateway_after_start(&mut run, true, networking.rootful) } /// Run before Compose even when an existing engine skipped the install/start steps. @@ -292,7 +459,10 @@ pub fn prepare_for_compose(address: &Address) -> Result<(), String> { fn owned_windows_podman_address(address: &Address, target_is_windows: bool) -> bool { target_is_windows && address.engine == Engine::Podman - && address.connection.as_deref() == Some(MACHINE) + && address + .connection + .as_deref() + .is_some_and(|connection| machine_of(connection) == MACHINE) } fn first_valid_host_gateway_ip(raw: &str) -> Result { @@ -331,13 +501,36 @@ fn host_gateway_config_script(ip: Ipv4Addr) -> String { ) } +/// The same setting for a rootful machine: in the system's configuration, and the system's API +/// service restarted. +/// +/// Through `sudo`, which is a no-op for root and passwordless for the machine's own user, so the +/// script does not depend on which of the two `podman machine ssh` logs in as. +fn rootful_host_gateway_config_script(ip: Ipv4Addr) -> String { + format!( + "set -e; \ + target=\"{ROOTFUL_HOST_GATEWAY_CONFIG}\"; \ + tmp=\"$(mktemp)\"; \ + cat > \"$tmp\" <<'EOF'\n[containers]\nhost_containers_internal_ip=\"{ip}\"\nEOF\n\ + if sudo test -f \"$target\" && sudo cmp -s \"$tmp\" \"$target\"; then \ + rm \"$tmp\"; \ + else \ + sudo mkdir -p \"$(dirname \"$target\")\"; \ + sudo install -m 0644 \"$tmp\" \"$target\"; \ + rm \"$tmp\"; \ + sudo systemctl try-restart podman.service; \ + fi" + ) +} + fn start_machine_with( mut run: impl FnMut(&[&str]) -> Result, target_is_windows: bool, ) -> StepOutcome { - if let Err(error) = prepare_user_mode_networking_before_start(&mut run, target_is_windows) { - return StepOutcome::stopped(Step::StartMachine, &error); - } + let rootful = match prepare_user_mode_networking_before_start(&mut run, target_is_windows) { + Ok(networking) => networking.is_some_and(|networking| networking.rootful), + Err(error) => return StepOutcome::stopped(Step::StartMachine, &error), + }; let started = match run(&["machine", "start", MACHINE]) { Ok(_) => format!("{MACHINE} started."), Err(error) if error.contains("already running") => { @@ -345,7 +538,7 @@ fn start_machine_with( } Err(error) => return StepOutcome::stopped(Step::StartMachine, &error), }; - if let Err(error) = configure_host_gateway_after_start(&mut run, target_is_windows) { + if let Err(error) = configure_host_gateway_after_start(&mut run, target_is_windows, rootful) { return StepOutcome::stopped(Step::StartMachine, &error); } StepOutcome::went(Step::StartMachine, started) @@ -380,9 +573,28 @@ fn explain_machine_error(error: &str) -> String { /// How to reach the machine this app just started. /// /// Always by name. The default connection belongs to whoever set it, and after `machine init` it is -/// usually still pointing somewhere else. +/// usually still pointing somewhere else. The rootful connection for the rootful machine this app +/// creates; the bare name for one an earlier release created rootless (see `create_machine`), and +/// for one switched to rootful since whose database is still in the rootless storage. pub fn address() -> Address { - Address::new(Engine::Podman, Some(MACHINE.to_string())) + address_with(|| podman(&["machine", "inspect", MACHINE]), holds_openbot) +} + +fn address_with( + inspect: impl FnOnce() -> Result, + holds_openbot: impl Fn(&str) -> bool, +) -> Address { + // Unknown reads as rootful: that is how this app creates the machine, and a machine that cannot + // be inspected is not going to answer on either connection. + let rootful = inspect() + .and_then(|listing| machine_facts(&listing, MACHINE)) + .ok() + .and_then(|facts| facts.rootful) + .unwrap_or(true); + Address::new( + Engine::Podman, + Some(connection_keeping_openbot(MACHINE, rootful, holds_openbot)), + ) } /// The gate before Compose is touched. @@ -679,11 +891,129 @@ mod tests { "8192", "--disk-size", "64", + "--rootful", "--user-mode-networking=true" ] ); } + /// Local Intelligence's k3d needs a rootful engine, on macOS as on Windows. + #[test] + fn the_machine_is_created_rootful_on_every_platform() { + for windows in [false, true] { + let args = create_machine_args(4, 6144, 60, windows); + assert!(args.iter().any(|arg| arg == "--rootful"), "{args:?}"); + assert_eq!(&args[..3], ["machine", "init", "openbot"]); + } + assert_eq!( + create_machine_args(4, 6144, 60, false), + [ + "machine", + "init", + "openbot", + "--cpus", + "4", + "--memory", + "6144", + "--disk-size", + "60", + "--rootful" + ] + ); + } + + /// Nothing switches a machine that already exists, ours included: its OpenBot lives in the + /// rootless engine's storage. Creating is skipped, and nothing else is run. + #[test] + fn an_existing_rootless_machine_is_not_switched_to_rootful() { + let listing = r#"[{"Name":"openbot","Default":true,"Running":false}]"#; + let result = create_machine_with( + 4, + 6144, + 60, + || machine_exists_with(|| Ok(listing.into())), + |args| panic!("nothing runs against an existing machine: {args:?}"), + ); + assert!(result.ok, "{result:?}"); + + let mut calls = Vec::>::new(); + let inspect = serde_json::json!([{ + "Name": "openbot", + "State": "stopped", + "UserModeNetworking": true, + "Rootful": false + }]) + .to_string(); + let started = start_machine_with( + |args| { + calls.push(args.iter().map(|arg| (*arg).to_string()).collect()); + match args { + ["machine", "inspect", "openbot"] => Ok(inspect.clone()), + ["machine", "start", "openbot"] => Ok(String::new()), + ["machine", "ssh", "openbot", "getent", ..] => { + Ok("192.168.127.254 STREAM host.containers.internal\n".into()) + } + ["machine", "ssh", "openbot", _] => Ok(String::new()), + _ => Err(format!("unexpected args: {args:?}")), + } + }, + true, + ); + assert!(started.ok, "{started:?}"); + assert!( + !calls + .iter() + .any(|call| call.iter().any(|arg| arg == "--rootful")), + "{calls:?}" + ); + // Its gateway goes where its rootless engine reads it. + assert!(calls + .last() + .unwrap() + .last() + .unwrap() + .contains(HOST_GATEWAY_CONFIG)); + } + + #[test] + fn a_rootful_windows_machine_gets_the_gateway_in_the_system_configuration() { + let mut calls = Vec::>::new(); + let inspect = serde_json::json!([{ + "Name": "openbot", + "State": "stopped", + "UserModeNetworking": true, + "Rootful": true + }]) + .to_string(); + let started = start_machine_with( + |args| { + calls.push(args.iter().map(|arg| (*arg).to_string()).collect()); + match args { + ["machine", "inspect", "openbot"] => Ok(inspect.clone()), + ["machine", "start", "openbot"] => Ok(String::new()), + ["machine", "ssh", "openbot", "getent", ..] => { + Ok("192.168.127.254 STREAM host.containers.internal\n".into()) + } + ["machine", "ssh", "openbot", _] => Ok(String::new()), + _ => Err(format!("unexpected args: {args:?}")), + } + }, + true, + ); + assert!(started.ok, "{started:?}"); + let script = calls.last().unwrap().last().unwrap().clone(); + assert_eq!( + script, + rootful_host_gateway_config_script("192.168.127.254".parse().unwrap()) + ); + assert!(script.contains(ROOTFUL_HOST_GATEWAY_CONFIG), "{script}"); + assert!( + script.contains("sudo systemctl try-restart podman.service"), + "{script}" + ); + assert!(!script.contains("--user"), "{script}"); + } + #[test] fn existing_stopped_windows_machine_is_configured_before_start() { let mut calls = Vec::>::new(); @@ -974,9 +1304,124 @@ mod tests { #[test] fn the_machine_this_app_starts_is_addressed_by_name_not_by_the_default_connection() { - let addressed = address(); - assert_eq!(addressed.connection.as_deref(), Some(MACHINE)); + let inspect = |rootful: bool| { + serde_json::json!([{ "Name": "openbot", "Rootful": rootful }]).to_string() + }; + let nowhere = |_: &str| false; + // Rootful, as this app creates it: its root connection, where the rootful engine is. + let addressed = address_with(|| Ok(inspect(true)), nowhere); + assert_eq!(addressed.connection.as_deref(), Some("openbot-root")); assert_eq!(addressed.engine, Engine::Podman); + // Rootless, as releases up to 0.2.0 created it: its bare name, where its OpenBot is. + let addressed = address_with(|| Ok(inspect(false)), nowhere); + assert_eq!(addressed.connection.as_deref(), Some(MACHINE)); + // Not inspectable: as this app creates it. + let addressed = address_with(|| Err("no such machine".into()), nowhere); + assert_eq!(addressed.connection.as_deref(), Some("openbot-root")); + // Switched to rootful since, with its database still in the rootless storage: kept there. + let addressed = address_with(|| Ok(inspect(true)), |connection| connection == MACHINE); + assert_eq!(addressed.connection.as_deref(), Some(MACHINE)); + } + + /// An OpenBot is never moved away from its database by the machine's mode. + #[test] + fn a_rootful_machine_keeps_an_openbot_whose_database_is_in_rootless_storage() { + let in_rootless: fn(&str) -> bool = |connection| connection == "podman-machine-default"; + let in_rootful: fn(&str) -> bool = |connection| connection == "podman-machine-default-root"; + let in_both: fn(&str) -> bool = |_| true; + let in_neither: fn(&str) -> bool = |_| false; + let machine = "podman-machine-default"; + assert_eq!( + connection_keeping_openbot(machine, true, in_rootless), + "podman-machine-default" + ); + // Wherever else it is, the machine's own mode wins. + for holds in [in_rootful, in_both, in_neither] { + assert_eq!( + connection_keeping_openbot(machine, true, holds), + "podman-machine-default-root" + ); + } + // A rootless machine was always reached by its bare name, and nothing is asked. + assert_eq!( + connection_keeping_openbot(machine, false, |_: &str| panic!("not asked")), + "podman-machine-default" + ); + } + + #[test] + fn a_connection_names_its_machine_and_its_mode() { + assert_eq!(connection_for("openbot", true), "openbot-root"); + assert_eq!(connection_for("openbot", false), "openbot"); + assert_eq!(machine_of("openbot-root"), "openbot"); + assert_eq!(machine_of("openbot"), "openbot"); + assert_eq!( + machine_of("podman-machine-default-root"), + "podman-machine-default" + ); + } + + #[test] + fn machine_facts_give_the_mode_and_the_api_this_computer_can_open() { + let mac = serde_json::json!([{ + "Name": "openbot", + "Rootful": true, + "ConnectionInfo": { + "PodmanSocket": { "Path": "/var/folders/xy/T/podman/openbot-api.sock" }, + "PodmanPipe": null + } + }]) + .to_string(); + assert_eq!( + machine_facts(&mac, "openbot").unwrap(), + MachineFacts { + rootful: Some(true), + api: Some("unix:///var/folders/xy/T/podman/openbot-api.sock".into()), + } + ); + let windows = serde_json::json!([{ + "Name": "openbot", + "Rootful": false, + "ConnectionInfo": { + "PodmanSocket": { "Path": "C:\\Users\\me\\podman.sock" }, + "PodmanPipe": { "Path": "\\\\.\\pipe\\podman-openbot" } + } + }]) + .to_string(); + assert_eq!( + machine_facts(&windows, "openbot").unwrap(), + MachineFacts { + rootful: Some(false), + api: Some("npipe:////./pipe/podman-openbot".into()), + } + ); + let bare = serde_json::json!([{ "Name": "openbot" }]).to_string(); + assert_eq!( + machine_facts(&bare, "openbot").unwrap(), + MachineFacts::default() + ); + assert!(machine_facts(&bare, "somebody-else").is_err()); + assert!(machine_facts("not json", "openbot").is_err()); + } + + #[test] + fn the_root_connection_of_our_windows_machine_is_still_ours() { + assert!(owned_windows_podman_address( + &Address::new(Engine::Podman, Some("openbot-root".into())), + true + )); + assert!(owned_windows_podman_address( + &Address::new(Engine::Podman, Some("openbot".into())), + true + )); + assert!(!owned_windows_podman_address( + &Address::new(Engine::Podman, Some("somebody-else-root".into())), + true + )); + assert!(!owned_windows_podman_address( + &Address::new(Engine::Podman, Some("openbot-root".into())), + false + )); } #[test] 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 9d030d0ea4..380270bbe8 100644 --- a/desktop/src-tauri/src/deployment.rs +++ b/desktop/src-tauri/src/deployment.rs @@ -171,11 +171,59 @@ pub fn needs_fetch(root: &Path, wanted: &str) -> bool { } match installed(root) { // A deployment laid out by an app that copied less is missing something this one runs. - Some(found) => found.version != wanted || found.layout != layout(), + Some(found) => { + found.version != wanted || found.layout != layout() || predates_containers(root) + } None => true, } } +/// Whether the deployment on disk is a release from before OpenBot's server and worker were +/// Compose services. +/// +/// Such a release ran both as Bun processes on the host, and this app no longer starts those. Its +/// Compose file has no `server` or `worker` to raise, and its server image has no worker in it, so +/// keeping its pin would leave an installation that used to start unable to. Answered from the +/// Compose file because that is what is missing; an unreadable or absent file is a damaged +/// deployment, not an old one, and the ordinary repair of the pinned version covers it. +pub fn predates_containers(root: &Path) -> bool { + let Ok(text) = std::fs::read_to_string(root.join("docker-compose.yml")) else { + return false; + }; + let services = compose_services(&text); + !(services.contains(&"server") && services.contains(&"worker")) +} + +/// The service names in a Compose file, read line by line. +/// +/// Not a YAML parser, and not meant to be one: it reads the shape `docker-compose.yml` in this +/// repository has always had, a top-level `services:` with each service two spaces in. That is the +/// only file this is ever pointed at, and it is enough to tell whether a service is declared. +fn compose_services(text: &str) -> Vec<&str> { + let mut in_services = false; + let mut services = Vec::new(); + for line in text.lines() { + let trimmed = line.trim_start(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if !line.starts_with(' ') { + in_services = line.starts_with("services:"); + continue; + } + let Some(entry) = line.strip_prefix(" ").filter(|_| in_services) else { + continue; + }; + if entry.starts_with(' ') { + continue; + } + if let Some((name, _)) = entry.split_once(':') { + services.push(name.trim().trim_matches(|c| c == '"' || c == '\'')); + } + } + services +} + /// What this app lays out, in a fixed order, as recorded in the stamp. fn layout() -> Vec { let mut entries: Vec = REQUIRED @@ -428,6 +476,49 @@ mod tests { served.join().unwrap(); } + #[test] + fn this_repositorys_compose_file_runs_the_server_and_worker_as_services() { + let text = include_str!("../../../docker-compose.yml"); + let services = compose_services(text); + for name in ["postgres", "supervisor", "server", "worker"] { + assert!(services.contains(&name), "{name} not in {services:?}"); + } + } + + #[test] + fn a_release_whose_server_ran_on_the_host_is_fetched_again_at_its_own_version() { + let root = crate::test_support::temp_root("deployment-host-server"); + std::fs::create_dir_all(&root).unwrap(); + // The shape of every release before the server moved into Compose: services, none of them + // OpenBot's own, and one commented-out line that must not count. + std::fs::write( + root.join("docker-compose.yml"), + "services:\n postgres:\n image: postgres\n # server: on the host\n supervisor:\n \ + environment:\n server: x\nvolumes:\n worker:\n", + ) + .unwrap(); + std::fs::write(images_path(&root), "{}").unwrap(); + record(&root, "v0.0.15").unwrap(); + assert!(predates_containers(&root)); + assert!(needs_fetch(&root, "v0.0.15")); + + std::fs::write( + root.join("docker-compose.yml"), + "services:\n postgres: {}\n server:\n profiles: [\"openbot\"]\n worker:\n \ + profiles: [\"openbot\"]\n", + ) + .unwrap(); + assert!(!predates_containers(&root)); + assert!(!needs_fetch(&root, "v0.0.15")); + + std::fs::remove_file(root.join("docker-compose.yml")).unwrap(); + assert!( + !predates_containers(&root), + "a missing file is damage, not age" + ); + std::fs::remove_dir_all(root).unwrap(); + } + fn manifest(names: &[&str]) -> Images { Images { version: "v0.0.7".into(), diff --git a/desktop/src-tauri/src/deployment_release.rs b/desktop/src-tauri/src/deployment_release.rs index 14a51f97b9..856578e2f0 100644 --- a/desktop/src-tauri/src/deployment_release.rs +++ b/desktop/src-tauri/src/deployment_release.rs @@ -10,6 +10,10 @@ const LATEST_RELEASE: &str = "https://api.github.com/repos/CopilotKit/OpenBot/re /// Only new deployments consult GitHub. The fetcher records the exact tag after the source and /// image manifest have both downloaded successfully; restarts and repairs retain that pin. +/// +/// The one exception is a pin this app cannot run: a release from before the server and worker +/// were Compose services. Repairing it at its own version lays out the same Compose file again, so +/// that installation is moved to the latest release instead. See `deployment::predates_containers`. /// Uses blocking HTTP, so callers in an async runtime must use a blocking task. pub fn resolve_version(root: &Path) -> Result { resolve_version_with(root, || { @@ -24,8 +28,8 @@ fn resolve_version_with( latest: impl FnOnce() -> Result, ) -> Result { match deployment::installed(root) { - Some(installed) => Ok(installed.version), - None => latest(), + Some(installed) if !deployment::predates_containers(root) => Ok(installed.version), + _ => latest(), } } @@ -75,6 +79,22 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn a_pinned_release_whose_server_ran_on_the_host_is_upgraded_to_latest() { + let root = scratch("release-host-server"); + std::fs::write( + root.join("docker-compose.yml"), + "services:\n postgres: {}\n supervisor: {}\n", + ) + .unwrap(); + std::fs::write(deployment::images_path(&root), "{}").unwrap(); + deployment::record(&root, "v0.0.15").unwrap(); + let version = resolve_version_with(&root, || Ok("v0.0.16".into())).unwrap(); + assert_eq!(version, "v0.0.16"); + assert!(deployment::needs_fetch(&root, &version)); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn a_missing_manifest_repairs_the_pinned_version() { let root = scratch("release-repair"); diff --git a/desktop/src-tauri/src/desktop_page.rs b/desktop/src-tauri/src/desktop_page.rs new file mode 100644 index 0000000000..7b544ef459 --- /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] +async fn restart(app: tauri::AppHandle) -> Result<(), String> { + crate::restart_openbot(app).await +} + +/// 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..b6d3b57d97 --- /dev/null +++ b/desktop/src-tauri/src/desktop_status.rs @@ -0,0 +1,124 @@ +//! 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, OpenBot's own server +//! and worker among them, 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) +} + +#[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); + } +} diff --git a/desktop/src-tauri/src/docker_desktop.rs b/desktop/src-tauri/src/docker_desktop.rs index ebd4049f8e..cad9ad35ae 100644 --- a/desktop/src-tauri/src/docker_desktop.rs +++ b/desktop/src-tauri/src/docker_desktop.rs @@ -41,12 +41,19 @@ pub(crate) fn app_places() -> Vec { } places } + // Per user and per machine: Docker's installer offers both. #[cfg(target_os = "windows")] { - std::env::var_os("ProgramFiles") - .map(|files| PathBuf::from(files).join("Docker\\Docker\\Docker Desktop.exe")) - .into_iter() - .collect() + [ + 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")] { diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 2108ce1913..39c334b63b 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -4,8 +4,9 @@ //! documentation, and the three differ more than they look: //! //! - **macOS.** `podman machine` puts a Linux VM behind a socket. Inside that VM -//! `/var/run/docker.sock` is already a symlink to the rootless socket, so Compose's mount needs no -//! help and `ENGINE_SOCKET` stays unset. `applehv` is the default on Apple silicon as of Podman +//! `/var/run/docker.sock` is already a symlink to the socket of the mode the machine runs in +//! (rootful for the machine OpenBot creates, see `acquire`), so Compose's mount needs no help and +//! `ENGINE_SOCKET` stays unset. `applehv` is the default on Apple silicon as of Podman //! 6.1, so no `--provider` is passed. //! - **Linux.** Podman is native and rootless and there is no VM. `/var/run/docker.sock` is either //! absent or, with `podman-docker` installed, a symlink to the *rootful* socket, which is not the @@ -279,6 +280,9 @@ pub struct EngineStatus { /// Somebody who already has a machine up is handed it rather than made to wait while a duplicate /// boots beside it. Ours is preferred among running machines only so that repeat launches settle on /// the same one. +/// +/// Answers with the connection for the machine's own mode (see `acquire::connection_for`): a rootful +/// machine is reached through `-root`, where its rootful engine is. fn running_machine(preferred: &str) -> Option { let output = tool(Engine::Podman) .args(["machine", "list", "--format", "json"]) @@ -292,7 +296,7 @@ fn running_machine(preferred: &str) -> Option { running() .find(|machine| machine.name == preferred) .or_else(|| running().next()) - .map(|machine| machine.name.clone()) + .map(|machine| crate::acquire::connection_of_machine(&machine.name)) } #[derive(Deserialize)] @@ -372,6 +376,47 @@ 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 { + path_with_engines(&[Engine::Docker]) +} + +/// `path_with_docker`, for each of `engines` that is here, or None when none is. +/// +/// The Intelligence CLI runs `podman` by name as it runs `docker`, and a Podman OpenBot installed +/// in this session is not on this process's PATH (see the PATH rule above). +pub fn path_with_engines(engines: &[Engine]) -> Option { + let binaries: Vec = engines + .iter() + .filter_map(|engine| program(*engine)) + .collect(); + if binaries.is_empty() { + return None; + } + let mut entries: Vec = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect()) + .unwrap_or_default(); + for binary in &binaries { + 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` @@ -436,11 +481,19 @@ fn installer_places(engine: Engine) -> Vec { .flatten() .collect() } + // Docker Desktop per user, then per machine, the places `docker_desktop` looks for the app. #[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", @@ -496,7 +549,8 @@ pub fn rootless_socket() -> Option { #[cfg(not(target_os = "linux"))] pub fn rootless_socket() -> Option { // macOS and Windows run the engine in a virtual machine, and inside it `/var/run/docker.sock` - // is already the rootless socket. Compose mounts that path, so there is nothing to override. + // is already the socket of the machine's own mode. Compose mounts that path, so there is + // nothing to override. None } @@ -844,6 +898,24 @@ fn main() { } } +/** +Where a container under rootless Podman reaches this machine's loopback. + +Rootless Podman puts its containers in a network namespace of the person's own, and by default +nothing in it leads back to the host's `127.0.0.1`: slirp4netns runs with host loopback disabled +and pasta with `--no-map-gw`. The server is a container now, and what it calls on this machine +listens only on loopback: the person's browser, deliberately, and a local Intelligence. Measured +on Ubuntu 24.04 (Podman 4.9.3, slirp4netns) and Fedora 44 (Podman 5.8.9, pasta): a container on a +Compose network reached neither, at any address, until [`local_api::HOST_LOOPBACK_CONFIG`] was in +place, and then reached both at this address, which is slirp4netns's fixed one and which pasta is +told to map. + +Docker Desktop and the Podman machines on macOS and Windows already forward `host.docker.internal` +to the host's loopback, so this is for rootless Podman on Linux alone. Compose is pointed at it +through `OPENBOT_HOST_GATEWAY` (see `env::compose`). +*/ +pub const ROOTLESS_HOST_LOOPBACK: &str = "10.0.2.2"; + /// A native Podman API service belongs to the desktop session only when this session spawned it. /// Existing user/systemd services are probed and reused, never stopped or reconfigured. #[cfg(any(target_os = "linux", all(test, unix)))] @@ -861,6 +933,81 @@ pub mod local_api { pub struct Service { child: Option, socket: Option<(PathBuf, u64, u64)>, + /// Where [`allow_host_loopback`](Service::allow_host_loopback) last wrote, so a Start can + /// ask whether the reset it calls for is still owed. + config_home: Option, + } + + /// Podman's own drop-in directory, under the person's configuration home. + pub const HOST_LOOPBACK_CONFIG: &str = "containers/containers.conf.d/90-openbot-host.conf"; + + /** + Present from the moment [`HOST_LOOPBACK_CONFIG`] changes until a Start has taken OpenBot's + stack down, so the containers after it build a new rootless network namespace. + + A file rather than a flag in memory, so the reset is still owed after a Start whose teardown + failed, and after the app quit between writing the drop-in and starting. Beside Podman's + configuration rather than in `containers.conf.d`, where Podman reads drop-ins. + */ + pub const HOST_LOOPBACK_PENDING: &str = "containers/openbot-host-loopback.pending"; + + /** + The drop-in that opens the rootless network namespace to this machine's loopback at + [`super::ROOTLESS_HOST_LOOPBACK`], for both of the programs rootless Podman builds it with. + Only the one in use reads its own key. + + Appended rather than set, so options the person gave either program in their own files are + kept: a later drop-in's list otherwise replaces an earlier one's. Both Podman 4.9 and 5.8 + parse `{append=true}`. + + Nothing here renames `host.containers.internal` or `host.docker.internal` for the person's + other containers. OpenBot's own services are pointed at this address by Compose, and anything + else would have to ask for it by number. + */ + pub fn host_loopback_config() -> String { + let address = super::ROOTLESS_HOST_LOOPBACK; + format!( + "# Written by OpenBot Desktop: lets its containers reach services this machine runs on\n\ + # 127.0.0.1, such as the person's browser and a local Intelligence, at {address}.\n\ + [engine]\n\ + network_cmd_options = [\"allow_host_loopback=true\", {{append=true}}]\n\ + \n\ + [network]\n\ + pasta_options = [\"--map-host-loopback\", \"{address}\", {{append=true}}]\n" + ) + } + + /// Write [`host_loopback_config`] under `config_home`, saying whether anything changed. + /// Replaced whole, through a sibling, so Podman never reads half of it. A change marks the + /// reset owed ([`HOST_LOOPBACK_PENDING`]) first, so no drop-in is ever in place without it. + pub fn write_host_loopback(config_home: &Path) -> Result { + let path = config_home.join(HOST_LOOPBACK_CONFIG); + let wanted = host_loopback_config(); + match std::fs::read_to_string(&path) { + Ok(current) if current == wanted => return Ok(false), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("{}: {error}", path.display())), + } + let directory = path.parent().expect("the drop-in has a directory"); + std::fs::create_dir_all(directory) + .map_err(|error| format!("{}: {error}", directory.display()))?; + let pending = config_home.join(HOST_LOOPBACK_PENDING); + std::fs::write(&pending, "").map_err(|error| format!("{}: {error}", pending.display()))?; + let partial = path.with_extension("conf.partial"); + std::fs::write(&partial, wanted) + .and_then(|()| std::fs::rename(&partial, &path)) + .map_err(|error| format!("{}: {error}", path.display()))?; + Ok(true) + } + + /// Where the person's configuration lives, as Podman looks for it. + #[cfg(target_os = "linux")] + fn config_home() -> Option { + std::env::var_os("XDG_CONFIG_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) } fn problem(detail: impl Into) -> Problem { @@ -927,6 +1074,9 @@ pub mod local_api { }); let socket = runtime.join("podman/podman.sock"); let log = root.join("logs/podman-api.log"); + let config_home = + config_home().ok_or_else(|| problem("Neither XDG_CONFIG_HOME nor HOME is set."))?; + self.allow_host_loopback(&config_home)?; self.ensure_with(&socket, &log, || { let mut command = address.command(); command @@ -1027,6 +1177,48 @@ pub mod local_api { })) } + /// Write the host loopback drop-in before any container needs it. A service this session + /// started read its configuration when it started, so a change stops it, and the + /// `ensure` that follows starts it again. A service somebody else runs is left alone. + pub(super) fn allow_host_loopback(&mut self, config_home: &Path) -> Result<(), Problem> { + self.config_home = Some(config_home.to_path_buf()); + if write_host_loopback(config_home).map_err(problem)? { + self.stop()?; + } + Ok(()) + } + + /** + Whether a Start still owes the host loopback configuration a teardown of OpenBot's stack. + + Podman reads it only when the rootless network namespace is created, and that namespace + lives while any container on a Compose network does, so a stack still running from before + keeps the old one. Measured: written while a container held the namespace, nothing + changed; once the last one stopped, the next container reached the host. + + Owed until [`acknowledge_host_loopback_reset`](Self::acknowledge_host_loopback_reset), + which a Start calls only once the teardown succeeded. + */ + pub fn host_loopback_reset_pending(&self) -> bool { + self.config_home + .as_ref() + .is_some_and(|home| home.join(HOST_LOOPBACK_PENDING).exists()) + } + + /// The teardown [`host_loopback_reset_pending`](Self::host_loopback_reset_pending) asked + /// for is done. + pub fn acknowledge_host_loopback_reset(&self) -> Result<(), Problem> { + let Some(home) = self.config_home.as_ref() else { + return Ok(()); + }; + let pending = home.join(HOST_LOOPBACK_PENDING); + match std::fs::remove_file(&pending) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(problem(format!("{}: {error}", pending.display()))), + } + } + pub fn stop(&mut self) -> Result<(), Problem> { if let Some(child) = self.child.as_mut() { if child @@ -1136,6 +1328,108 @@ mod local_api_tests { } } + /// The drop-in opens host loopback for both programs, at the one address Compose is given, and + /// appends to what the person configured rather than replacing it. + #[test] + fn the_host_loopback_drop_in_names_both_programs_and_keeps_the_persons_options() { + let config = super::local_api::host_loopback_config(); + let address = super::ROOTLESS_HOST_LOOPBACK; + assert!(config.contains( + "[engine]\nnetwork_cmd_options = [\"allow_host_loopback=true\", {append=true}]\n" + )); + assert!(config.contains(&format!( + "[network]\npasta_options = [\"--map-host-loopback\", \"{address}\", {{append=true}}]\n" + ))); + // slirp4netns serves the host's loopback at its gateway, which is fixed. + assert_eq!(address, "10.0.2.2"); + assert!(!config.contains("host_containers_internal_ip")); + } + + #[test] + fn the_host_loopback_drop_in_is_written_once_and_restored_when_changed() { + let home = temp_root("host-loopback-drop-in"); + let path = home.join(super::local_api::HOST_LOOPBACK_CONFIG); + let theirs = path.with_file_name("10-person.conf"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &theirs, + "[network]\npasta_options = [\"--mtu\", \"1400\"]\n", + ) + .unwrap(); + + let pending = home.join(super::local_api::HOST_LOOPBACK_PENDING); + assert!(super::local_api::write_host_loopback(&home).unwrap()); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + super::local_api::host_loopback_config() + ); + assert!(pending.exists(), "a new drop-in owes a reset"); + std::fs::remove_file(&pending).unwrap(); + assert!(!super::local_api::write_host_loopback(&home).unwrap()); + assert!(!pending.exists(), "an unchanged drop-in owes nothing"); + + std::fs::write(&path, "[network]\n").unwrap(); + assert!(super::local_api::write_host_loopback(&home).unwrap()); + assert!(pending.exists(), "a changed drop-in owes a reset"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + super::local_api::host_loopback_config() + ); + assert_eq!( + std::fs::read_to_string(&theirs).unwrap(), + "[network]\npasta_options = [\"--mtu\", \"1400\"]\n", + "the person's own drop-in is not touched" + ); + assert!(!path.with_extension("conf.partial").exists()); + std::fs::remove_dir_all(home).unwrap(); + } + + /// A change restarts the service this session owns, because it read its configuration when it + /// started, and owes a Start a teardown until one acknowledges it. No change leaves it running. + #[test] + fn a_host_loopback_change_restarts_the_owned_api_and_owes_a_reset_until_acknowledged() { + let root = temp_root("host-loopback-restart"); + std::fs::create_dir_all(&root).unwrap(); + let home = root.join("config"); + let socket = root.join("api.sock"); + let log = root.join("api.log"); + let mut owned = Service::default(); + owned + .ensure_with(&socket, &log, || fixture(&socket)) + .unwrap(); + let first = owned.child_id().unwrap(); + + owned.allow_host_loopback(&home).unwrap(); + assert_eq!( + unsafe { libc::kill(first as i32, 0) }, + -1, + "the owned service that predates the change is stopped" + ); + owned + .ensure_with(&socket, &log, || fixture(&socket)) + .unwrap(); + let second = owned.child_id().unwrap(); + assert_ne!(first, second); + assert!(owned.host_loopback_reset_pending()); + // A Start whose teardown failed acknowledges nothing, so asking again still says owed. + assert!(owned.host_loopback_reset_pending()); + + // Nor is it forgotten with the app: a later session, with the drop-in already in place + // and nothing written, still owes it. + owned.allow_host_loopback(&home).unwrap(); + assert_eq!(owned.child_id(), Some(second), "no change, no restart"); + let mut later = Service::default(); + later.allow_host_loopback(&home).unwrap(); + assert!(later.host_loopback_reset_pending()); + + later.acknowledge_host_loopback_reset().unwrap(); + assert!(!later.host_loopback_reset_pending()); + assert!(!owned.host_loopback_reset_pending()); + later.acknowledge_host_loopback_reset().unwrap(); + owned.stop().unwrap(); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn linux_api_starts_a_real_socket_and_reaps_only_its_own_child() { let root = temp_root("linux-api"); diff --git a/desktop/src-tauri/src/env.rs b/desktop/src-tauri/src/env.rs index d6848b56a1..db5f2d593d 100644 --- a/desktop/src-tauri/src/env.rs +++ b/desktop/src-tauri/src/env.rs @@ -137,6 +137,8 @@ impl Ports { /// Hold the probes until every port is chosen so allocations cannot collide with each other. /// Existing containers from this exact Compose project are reusable, never foreign listeners. /// `browser` asks for a port for the person's browser, kept from the last run where it is free. + /// The app and the API are among them: both are host ports of the one `server` container, so + /// a running OpenBot keeps its own ports across a Start rather than being moved off them. pub fn available( self, ours: &std::collections::HashSet, @@ -175,8 +177,8 @@ impl Ports { })) }; Ok(Self { - app: choose(self.app, false)?, - server: choose(self.server, false)?, + app: choose(self.app, true)?, + server: choose(self.server, true)?, postgres: choose(self.postgres, true)?, computer: choose(self.computer, true)?, bot: choose(self.bot, true)?, @@ -226,6 +228,37 @@ fn bind_loopbacks(port: u16) -> std::io::Result> { Ok(held) } +/** +A loopback address as a container on this machine reaches it, or empty if it is not loopback. + +`localhost`, `*.localhost`, `127.x.y.z` and `[::1]` become `host.docker.internal`, keeping the +scheme, port and path. Anything else is already reachable from a container as written, and empty is +how `docker-compose.yml` is told to use it unchanged. +*/ +pub fn container_address(address: &str) -> String { + let address = address.trim(); + let Ok(mut url) = reqwest::Url::parse(address) else { + return String::new(); + }; + let host = url.host_str().unwrap_or_default(); + let loopback = host == "localhost" + || host.ends_with(".localhost") + || host + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + if !loopback || url.set_host(Some("host.docker.internal")).is_err() { + return String::new(); + } + // The parser writes an empty path as `/`; given without one, it is returned without one. + let moved = url.to_string(); + match moved.strip_suffix('/') { + Some(bare) if url.path() == "/" && !address.ends_with('/') => bare.to_string(), + _ => moved, + } +} + #[cfg(test)] mod port_tests { use super::*; @@ -319,19 +352,17 @@ mod port_tests { } #[test] - fn own_published_container_ports_are_reused_but_do_not_authorize_host_port_reuse() { + fn own_published_container_ports_are_reused_but_a_foreign_listener_is_not() { let owned = TcpListener::bind("127.0.0.1:0").unwrap(); let port = owned.local_addr().unwrap().port(); let preferred = Ports { postgres: port, - server: port, ..Ports::default() }; let chosen = preferred .available(&HashSet::from([port]), None, false) .unwrap(); assert_eq!(chosen.postgres, port); - assert_ne!(chosen.server, port); assert_ne!( preferred .available(&HashSet::new(), None, false) @@ -341,6 +372,28 @@ mod port_tests { ); } + #[test] + fn the_app_and_api_ports_our_server_container_publishes_are_kept() { + // The one `server` container publishes both, so both are held while it runs. + let app = TcpListener::bind("127.0.0.1:0").unwrap(); + let server = TcpListener::bind("127.0.0.1:0").unwrap(); + let preferred = Ports { + app: app.local_addr().unwrap().port(), + server: server.local_addr().unwrap().port(), + ..Ports::default() + }; + let ours = HashSet::from([preferred.app, preferred.server]); + let chosen = preferred.available(&ours, None, false).unwrap(); + assert_eq!( + (chosen.app, chosen.server), + (preferred.app, preferred.server) + ); + // Held by somebody else, they are moved, as they always were. + let moved = preferred.available(&HashSet::new(), None, false).unwrap(); + assert_ne!(moved.app, preferred.app); + assert_ne!(moved.server, preferred.server); + } + #[test] fn invalid_saved_ports_fail_instead_of_probing_an_unrelated_default() { let root = crate::test_support::temp_root("invalid-local-ports"); @@ -596,6 +649,23 @@ pub fn compose( "INTELLIGENCE_GATEWAY_WS_URL".into(), intelligence.gateway_ws_url.trim().to_string(), ); + /* + * The same two addresses as the server's container reaches them. + * + * Local Intelligence answers on this machine's loopback, which inside a container is the + * container. `host.docker.internal` is the machine. Only the server is moved: the browser opens + * the gateway too, from the address in `/info`, and the browser is on the host, so + * `docker-compose.yml` hands it the address above unchanged. Empty for an Intelligence somewhere + * else, which the container reaches at the address it was given. + */ + env.insert( + "INTELLIGENCE_CONTAINER_API_URL".into(), + container_address(&intelligence.api_url), + ); + env.insert( + "INTELLIGENCE_CONTAINER_GATEWAY_WS_URL".into(), + container_address(&intelligence.gateway_ws_url), + ); // Explicit desktop selection must override a CLI key left in the checkout's .env. // Supply both names for consumers that still read the legacy variable directly. for key in ["CPK_INTELLIGENCE_API_KEY", "INTELLIGENCE_API_KEY"] { @@ -685,6 +755,9 @@ pub fn compose( } .into(), ); + // Empty unless a harness runs here, so the address a remote Bot was given is the one used and a + // harness picked on an earlier start does not leave its container address behind. + env.insert("PICKED_HARNESS_CONTAINER_URL".into(), String::new()); if let Some(picked) = harness { match picked { PickedHarness::Installed { @@ -700,15 +773,20 @@ pub fn compose( env.insert("PICKED_HARNESS_PORT".into(), port.to_string()); env.insert("PICKED_HARNESS_NAME".into(), name.clone()); let run_path = run_path.trim(); + let run_path = if run_path.is_empty() || run_path.starts_with('/') { + run_path.to_string() + } else { + format!("/{run_path}") + }; env.insert( "PICKED_HARNESS_URL".into(), - if run_path.is_empty() { - format!("http://127.0.0.1:{host_port}") - } else if run_path.starts_with('/') { - format!("http://127.0.0.1:{host_port}{run_path}") - } else { - format!("http://127.0.0.1:{host_port}/{run_path}") - }, + format!("http://127.0.0.1:{host_port}{run_path}"), + ); + // The server is a container on the harness's network, so it dials the service by + // name on the image's own port, not the host port published for everything else. + env.insert( + "PICKED_HARNESS_CONTAINER_URL".into(), + format!("http://agent-harness:{port}{run_path}"), ); /* * The kind, as the package spells it. @@ -757,10 +835,13 @@ pub fn compose( * keeps a line it does not own, so Start purges the value an earlier run wrote. */ if let Some(port) = ports.browser { + let address = format!("http://127.0.0.1:{port}"); + // The server is a container, and this is the host's loopback. See `container_address`. env.insert( - crate::local_browser::BROWSER_URL_ENV.into(), - format!("http://127.0.0.1:{port}"), + BROWSER_CONTAINER_URL_ENV.into(), + container_address(&address), ); + env.insert(crate::local_browser::BROWSER_URL_ENV.into(), address); } // The worker refuses to start without this, by design: it is a fact about where this process @@ -772,12 +853,11 @@ pub fn compose( ); env.extend(ports.settings()); + // The server is a container on the Bots' network now, so they reach it by name and on its own + // port, whichever host port this start published it on. env.insert( "OPENBOT_TOOL_URL".into(), - format!( - "http://host.docker.internal:{}/api/agent-tools/call", - ports.server - ), + "http://server:3001/api/agent-tools/call".into(), ); // The whole deployment is on this machine, so the server must be allowed to talk to it. @@ -819,6 +899,12 @@ pub fn compose( // Only rootless Podman on Linux needs this; see engine.rs. if let Some(socket) = &engine.engine_socket { env.insert("ENGINE_SOCKET".into(), socket.clone()); + // And only there is `host-gateway` not this machine's loopback, which is where the + // person's browser and a local Intelligence listen. See `engine::ROOTLESS_HOST_LOOPBACK`. + env.insert( + HOST_GATEWAY_ENV.into(), + crate::engine::ROOTLESS_HOST_LOOPBACK.into(), + ); } env @@ -969,14 +1055,24 @@ pub const MINTED: [&str; 6] = [ /// earlier run wrote outlives the answer that implied it: `BOT_MODEL` once asked OpenAI for a model /// an earlier endpoint had named, and `OPENBOT_BROWSER_URL` would send the person to a browser that /// no longer runs. -pub const REMOVED_WHEN_UNSET: [&str; 5] = [ +pub const REMOVED_WHEN_UNSET: [&str; 7] = [ "BOT_PROVIDER", "BOT_MODEL", "AGENT_BOT_MODEL", crate::local_browser::BROWSER_URL_ENV, + BROWSER_CONTAINER_URL_ENV, crate::local_browser::BROWSER_ABSENT_ENV, + // A machine that moves from rootless Podman to Docker must go back to `host-gateway`. + HOST_GATEWAY_ENV, ]; +/// What `docker-compose.yml` maps `host.docker.internal` to, in place of `host-gateway`. +pub const HOST_GATEWAY_ENV: &str = "OPENBOT_HOST_GATEWAY"; + +/// [`crate::local_browser::BROWSER_URL_ENV`] as the server's container reaches it, which +/// `docker-compose.yml` prefers for the `server` service. +pub const BROWSER_CONTAINER_URL_ENV: &str = "OPENBOT_BROWSER_CONTAINER_URL"; + /// Tell the server why this machine runs no browser of the person's own (`BROWSER_ABSENT_ENV`), so /// the app can say it to them in a sentence of their own rather than leave it in Desktop's log. /// Nothing where a browser runs, and [`REMOVED_WHEN_UNSET`] then takes out what an earlier Start @@ -1833,6 +1929,32 @@ HTTPS_PROXY=http://proxy:8080 ); } + /// Rootless Podman alone sends `host.docker.internal` to the address its namespace maps to this + /// machine's loopback. Everywhere else `host-gateway` already gets there, and a value left from + /// rootless Podman would send Docker's containers to an address nothing answers on. + #[test] + fn host_docker_internal_is_moved_only_under_rootless_podman() { + let compose_with = |socket: Option<&str>| { + compose( + &intelligence(), + &Model::default(), + &engine_status(socket), + &Ports::default(), + &pinned(), + None, + &BTreeMap::new(), + ) + }; + assert!(!compose_with(None).contains_key(HOST_GATEWAY_ENV)); + assert_eq!( + compose_with(Some("/run/user/501/podman/podman.sock")) + .get(HOST_GATEWAY_ENV) + .map(String::as_str), + Some(crate::engine::ROOTLESS_HOST_LOOPBACK) + ); + assert!(REMOVED_WHEN_UNSET.contains(&HOST_GATEWAY_ENV)); + } + #[test] fn no_model_choice_does_not_advertise_an_unselected_bundled_service() { let env = compose( @@ -2135,8 +2257,8 @@ HTTPS_PROXY=http://proxy:8080 ); assert_eq!( value_of(&written, "OPENBOT_TOOL_URL"), - "http://host.docker.internal:3999/api/agent-tools/call", - "container tools must call the selected API port" + "http://server:3001/api/agent-tools/call", + "Bots call the server container by name, on its own port, whatever the host port" ); } @@ -2168,6 +2290,99 @@ HTTPS_PROXY=http://proxy:8080 settings["PICKED_HARNESS_URL"], "http://127.0.0.1:52106/ag-ui" ); + // The server container dials the service by name, on the image's port. + assert_eq!( + settings["PICKED_HARNESS_CONTAINER_URL"], + "http://agent-harness:4206/ag-ui" + ); + } + + #[test] + fn a_remote_harness_and_no_harness_leave_no_container_address() { + let remote = compose( + &intelligence(), + &Model::default(), + &engine_status(None), + &Ports::default(), + &pinned(), + Some(&PickedHarness::RemoteAgUi { + url: "https://agents.example/run".into(), + name: "Remote".into(), + remote_agent_id: String::new(), + }), + &BTreeMap::new(), + ); + assert_eq!(remote["PICKED_HARNESS_URL"], "https://agents.example/run"); + assert_eq!(remote["PICKED_HARNESS_CONTAINER_URL"], ""); + + let none = compose( + &intelligence(), + &Model::default(), + &engine_status(None), + &Ports::default(), + &pinned(), + None, + &BTreeMap::new(), + ); + assert_eq!(none["PICKED_HARNESS_CONTAINER_URL"], ""); + } + + /// Local Intelligence answers on loopback; the server's container reaches it through the host. + #[test] + fn local_intelligence_gets_a_container_address_and_hosted_does_not() { + for (given, expected) in [ + ( + "http://localhost:18082", + "http://host.docker.internal:18082", + ), + ("ws://localhost:14401", "ws://host.docker.internal:14401"), + ("ws://localhost:14401/", "ws://host.docker.internal:14401/"), + ( + "http://127.0.0.1:8082/api", + "http://host.docker.internal:8082/api", + ), + ("http://[::1]:8082", "http://host.docker.internal:8082"), + ( + "http://cpk.localhost:8080", + "http://host.docker.internal:8080", + ), + ( + " http://localhost:18082 ", + "http://host.docker.internal:18082", + ), + ("https://api.intelligence.copilotkit.ai", ""), + ("wss://realtime.intelligence.copilotkit.ai", ""), + ("http://localhost.evil.test:8082", ""), + ("not a url", ""), + ("", ""), + ] { + assert_eq!(container_address(given), expected, "{given}"); + } + + let env = compose( + &Intelligence { + api_url: "http://localhost:18082".into(), + gateway_ws_url: "ws://localhost:14401".into(), + api_key: "synthetic".into(), + }, + &Model::default(), + &engine_status(None), + &Ports::default(), + &pinned(), + None, + &BTreeMap::new(), + ); + // The host's own addresses stay as given: the browser reads the gateway's from these. + assert_eq!(env["INTELLIGENCE_API_URL"], "http://localhost:18082"); + assert_eq!(env["INTELLIGENCE_GATEWAY_WS_URL"], "ws://localhost:14401"); + assert_eq!( + env["INTELLIGENCE_CONTAINER_API_URL"], + "http://host.docker.internal:18082" + ); + assert_eq!( + env["INTELLIGENCE_CONTAINER_GATEWAY_WS_URL"], + "ws://host.docker.internal:14401" + ); } } diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index a63b5c6b43..596789fec5 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -71,7 +71,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", @@ -117,7 +117,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", @@ -272,7 +272,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"), @@ -397,17 +397,34 @@ pub fn install_engine_observed( installed: impl FnMut(Engine, bool), progress: &mut dyn FnMut(String), ) -> Result { + // Only one of the two runs, so they share the step row's lines one at a time. + let progress = std::cell::RefCell::new(progress); install_engine_with( cache, Found::here(saved), saved, installed, - || crate::docker_desktop::start(progress), - install_podman, + || crate::docker_desktop::start(&mut **progress.borrow_mut()), + |into| { + (progress.borrow_mut())(podman_install_said(std::env::consts::OS).into()); + install_podman(into) + }, place_compose, ) } +/// The step row's line while Podman installs, naming the prompt the platform raises for it. +/// +/// macOS asks for the person's password, because the package writes to `/opt/podman`. Windows +/// asks for nothing here: the MSI installs for this account alone. Linux asks through `pkexec`. +fn podman_install_said(os: &str) -> &'static str { + match os { + "macos" => "Installing Podman. Enter your Mac password when macOS asks for it.", + "linux" => "Installing Podman. Enter your password when your system asks for it.", + _ => "Installing Podman.", + } +} + /// What engines this machine has, read once so the choice below can be tested on any machine. /// /// Nothing here asks what product is behind a `docker` command. A Docker that answers is used, a @@ -1719,4 +1736,12 @@ esac assert!(quoted.contains("Application Support"), "{quoted}"); assert!(!quoted.contains("\\\""), "{quoted}"); } + + /// The prompt is named where there is one, and none is promised where there is not. + #[test] + fn the_podman_install_line_names_the_prompt_each_platform_raises() { + assert!(podman_install_said("macos").contains("password")); + assert!(podman_install_said("linux").contains("password")); + assert_eq!(podman_install_said("windows"), "Installing Podman."); + } } diff --git a/desktop/src-tauri/src/intelligence_bridge.rs b/desktop/src-tauri/src/intelligence_bridge.rs new file mode 100644 index 0000000000..a822bb08cb --- /dev/null +++ b/desktop/src-tauri/src/intelligence_bridge.rs @@ -0,0 +1,314 @@ +/*! +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"; + /// The setup page's own origin, which Tauri serves differently on Windows. + const SETUP_PAGE: &str = if cfg!(any(windows, target_os = "android")) { + "http://tauri.localhost" + } else { + "tauri://localhost" + }; + + #[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, SETUP_PAGE, "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 df554af8f0..76d0589419 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -11,6 +11,8 @@ pub mod host_access; pub mod install; pub mod intelligence; pub mod local_browser; +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..cee298daf2 --- /dev/null +++ b/desktop/src-tauri/src/local_eval.rs @@ -0,0 +1,4163 @@ +//! 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, + /// OpenBot runs on a rootless Podman machine, and local Intelligence needs rootful Podman. + /// Refused here, before `setup` or `start`, so the window can name the switch; the CLI would + /// refuse it too, as an engine it does not support. See `acquire::create_machine` for why the + /// switch is the person's to make. + RootlessPodman, +} + +#[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. An hour, not + /// half of one: a fresh Windows VM ran out the 30 minutes this used to allow while it was also + /// installing OpenBot's packages, and a timeout throws away a setup that was getting there. + pub fn for_setup() -> Self { + Self { + timeout: Duration::from_secs(60 * 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", +]; + +/* + * ONE ENGINE FOR OPENBOT AND INTELLIGENCE. + * + * Left to itself the CLI picks Docker when Docker answers and Podman otherwise. That is not always + * the engine OpenBot is running on: Docker Desktop started after OpenBot settled on Podman, or a + * Podman machine of the person's own that is running beside ours. Two engines on one computer is + * two VMs' worth of memory, and an Intelligence that OpenBot's containers reach through a host + * port of a different VM. So every CLI process is told the engine OpenBot uses, by the variable + * the CLI reads for exactly this (`COPILOTKIT_LOCAL_ENGINE`), and for Podman the API of the + * machine OpenBot uses: the CLI would otherwise take the first running machine, and it looks + * `podman` up on a PATH that, in the session OpenBot installed Podman, does not have it. + * + * Recorded by the app when it knows its engine (`use_engine`), and read for every call, because + * the machine's mode can change between calls and asking costs one `podman machine inspect`. + */ +static ENGINE: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Run the CLI on the engine OpenBot runs on. +pub fn use_engine(address: &crate::engine::Address) { + *ENGINE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(address.clone()); +} + +/// Whether the app has said which engine OpenBot runs on. +pub fn engine_known() -> bool { + ENGINE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_some() +} + +/// What the CLI is told about its engine. Addresses and a name; nothing here is a secret. +#[derive(Clone, Debug, PartialEq, Eq)] +struct CliEngine { + engine: crate::engine::Engine, + /// The Podman API OpenBot's machine forwards to this computer, when it could be found. + api: Option, + /// A Podman machine running rootless, which local Intelligence does not run on. + rootless: bool, +} + +impl CliEngine { + fn of(address: &crate::engine::Address) -> Self { + Self::of_with(address, crate::acquire::machine_behind) + } + + /// The machine's mode and API, not the connection's. + /// + /// A rootful machine whose OpenBot is still in its rootless storage keeps OpenBot on the + /// machine's bare connection (see `acquire::connection_keeping_openbot`). Intelligence then + /// runs on that same machine's rootful engine, the one its forwarded API reaches: one VM, so + /// none of the cost of two engines above, and the only engine there Intelligence runs on. + /// Only a rootless machine has no rootful engine to offer, and is declined. + fn of_with( + address: &crate::engine::Address, + machine_behind: impl FnOnce(&str) -> Result, + ) -> Self { + use crate::engine::Engine; + let named_only = || Self { + engine: address.engine, + api: None, + rootless: false, + }; + // Linux runs OpenBot's containers without local Intelligence for now, and its Podman has + // no machine: the CLI finds the rootful socket itself, or declines a rootless one. + if address.engine == Engine::Docker || cfg!(target_os = "linux") { + return named_only(); + } + let connection = address + .connection + .clone() + .or_else(|| address.pin().ok().and_then(|pinned| pinned.connection)); + match connection.map(|connection| machine_behind(&connection)) { + Some(Ok(facts)) => Self { + engine: Engine::Podman, + api: facts.api, + rootless: facts.rootful == Some(false), + }, + // Not a machine this computer can inspect: the CLI finds its own way, and judges it. + _ => named_only(), + } + } +} + +fn recorded_engine() -> Option { + let address = ENGINE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone()?; + Some(CliEngine::of(&address)) +} + +/// The variables that name the engine, and those the CLI must not inherit beside them. +/// +/// For Podman, `CONTAINER_HOST` is what the CLI and the Podman CLI read and `DOCKER_HOST` is what +/// k3d reads, so both name the machine's API. An inherited `DOCKER_HOST` or `DOCKER_CONTEXT` names +/// a Docker, and is dropped rather than handed to k3d beside a Podman. For Docker nothing else is +/// set: the CLI finds Docker as Docker's own CLI does, through what it inherits. +fn engine_environment(engine: Option<&CliEngine>) -> Vec<(&'static str, Option)> { + use crate::engine::Engine; + let Some(engine) = engine else { + return Vec::new(); + }; + let mut environment = vec![( + "COPILOTKIT_LOCAL_ENGINE", + Some(engine.engine.binary().to_string()), + )]; + if engine.engine == Engine::Podman { + environment.push(("CONTAINER_HOST", engine.api.clone())); + environment.push(("DOCKER_HOST", engine.api.clone())); + environment.push(("DOCKER_CONTEXT", None)); + } + environment +} + +/// Decline the calls that create or start Intelligence's cluster on a rootless Podman machine. +/// Everything else runs: `status`, `stop` and the account calls work on any engine. +fn refuse_rootless(arguments: &[&str], engine: Option<&CliEngine>) -> Result<(), Failure> { + let runs_workloads = matches!(arguments, ["local", "setup", ..] | ["local", "start", ..]); + if runs_workloads && engine.is_some_and(|engine| engine.rootless) { + return Err(Failure::RootlessPodman); + } + Ok(()) +} + +/// The CLI process for one call: its program, its environment and its folder, not yet started. +fn command_for( + cli: &PinnedCli, + arguments: &[&str], + folder: Option<&std::path::Path>, + engine: Option<&CliEngine>, +) -> std::process::Command { + 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` and `podman` up by name. Opened from Finder, this app has the system's + // minimal PATH, which reaches neither Docker Desktop's `/usr/local/bin` nor `/opt/podman/bin`; + // the CLI gets the engine's own directory added. + let engines: &[crate::engine::Engine] = match engine.map(|engine| engine.engine) { + Some(crate::engine::Engine::Podman) => { + &[crate::engine::Engine::Docker, crate::engine::Engine::Podman] + } + _ => &[crate::engine::Engine::Docker], + }; + if let Some(path) = crate::engine::path_with_engines(engines) { + command.env("PATH", path); + } + for (name, value) in engine_environment(engine) { + match value { + Some(value) => command.env(name, value), + None => command.env_remove(name), + }; + } + if let Some(folder) = folder { + command.current_dir(folder); + } + command.args(arguments); + command +} + +/** +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 engine = recorded_engine(); + refuse_rootless(arguments, engine.as_ref())?; + let output = Output::create()?; + let mut command = command_for(cli, arguments, folder, engine.as_ref()); + command + .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()); + } + + fn engine_variables( + engine: Option<&CliEngine>, + ) -> std::collections::BTreeMap> { + let cli = PinnedCli { + program: PathBuf::from("copilotkit"), + entry: None, + sha256: String::new(), + }; + command_for(&cli, &["local", "status", "--json"], None, engine) + .get_envs() + .map(|(name, value)| { + ( + name.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect() + } + + const ENGINE_VARIABLES: [&str; 4] = [ + "COPILOTKIT_LOCAL_ENGINE", + "CONTAINER_HOST", + "DOCKER_HOST", + "DOCKER_CONTEXT", + ]; + + #[test] + fn the_cli_is_told_the_engine_openbot_runs_on() { + use crate::engine::Engine; + let docker = CliEngine { + engine: Engine::Docker, + api: None, + rootless: false, + }; + let variables = engine_variables(Some(&docker)); + assert_eq!( + variables.get("COPILOTKIT_LOCAL_ENGINE"), + Some(&Some("docker".to_string())) + ); + // Docker is found as Docker's own CLI finds it: nothing is set or taken away for it. + for name in ["CONTAINER_HOST", "DOCKER_HOST", "DOCKER_CONTEXT"] { + assert!(!variables.contains_key(name), "{name} in {variables:?}"); + } + + let api = "unix:///var/folders/xy/T/podman/openbot-api.sock"; + let podman = CliEngine { + engine: Engine::Podman, + api: Some(api.into()), + rootless: false, + }; + let variables = engine_variables(Some(&podman)); + assert_eq!( + variables.get("COPILOTKIT_LOCAL_ENGINE"), + Some(&Some("podman".to_string())) + ); + assert_eq!( + variables.get("CONTAINER_HOST"), + Some(&Some(api.to_string())) + ); + assert_eq!(variables.get("DOCKER_HOST"), Some(&Some(api.to_string()))); + // An inherited Docker context names a Docker, and is not handed to k3d beside a Podman. + assert!( + engine_environment(Some(&podman)).contains(&("DOCKER_CONTEXT", None)), + "a Podman call must drop DOCKER_CONTEXT" + ); + assert!(!matches!(variables.get("DOCKER_CONTEXT"), Some(Some(_)))); + + // A Podman whose API could not be found is still named, and inherits no Docker address. + let unfound = CliEngine { + api: None, + ..podman + }; + let variables = engine_variables(Some(&unfound)); + assert_eq!( + variables.get("COPILOTKIT_LOCAL_ENGINE"), + Some(&Some("podman".to_string())) + ); + assert_eq!( + engine_environment(Some(&unfound))[1..], + [ + ("CONTAINER_HOST", None), + ("DOCKER_HOST", None), + ("DOCKER_CONTEXT", None) + ] + ); + for name in ["CONTAINER_HOST", "DOCKER_HOST", "DOCKER_CONTEXT"] { + assert!( + !matches!(variables.get(name), Some(Some(_))), + "{name} in {variables:?}" + ); + } + + // Before the app knows its engine the CLI chooses for itself, as it did before. + let variables = engine_variables(None); + for name in ENGINE_VARIABLES { + assert!(!variables.contains_key(name), "{name} in {variables:?}"); + } + } + + /// The engine adds names and addresses, never a secret: what the CLI receives is the inherited + /// allowlist and the engine's own variables, and nothing else. + #[test] + fn naming_the_engine_adds_nothing_secret() { + use crate::engine::Engine; + for engine in [ + None, + Some(CliEngine { + engine: Engine::Docker, + api: None, + rootless: false, + }), + Some(CliEngine { + engine: Engine::Podman, + api: Some("npipe:////./pipe/podman-openbot".into()), + rootless: false, + }), + ] { + for name in engine_variables(engine.as_ref()).keys() { + assert!( + INHERITED.contains(&name.as_str()) || ENGINE_VARIABLES.contains(&name.as_str()), + "{name} is neither inherited nor the engine's" + ); + } + } + } + + /// OpenBot on the bare connection of a rootful machine: Intelligence goes to the same machine's + /// rootful engine. OpenBot on a rootless machine, by either connection: declined. + #[cfg(not(target_os = "linux"))] + #[test] + fn intelligence_runs_on_the_rootful_engine_of_the_machine_openbot_is_on() { + use crate::acquire::MachineFacts; + use crate::engine::{Address, Engine}; + let api = "unix:///tmp/podman/openbot-api.sock"; + let machine = |rootful| { + move |name: &str| { + assert_eq!(name.strip_suffix("-root").unwrap_or(name), "openbot"); + Ok(MachineFacts { + rootful: Some(rootful), + api: Some(api.into()), + }) + } + }; + for connection in ["openbot", "openbot-root"] { + let address = Address::new(Engine::Podman, Some(connection.into())); + assert_eq!( + CliEngine::of_with(&address, machine(true)), + CliEngine { + engine: Engine::Podman, + api: Some(api.into()), + rootless: false, + }, + "{connection}" + ); + assert!( + CliEngine::of_with(&address, machine(false)).rootless, + "{connection}" + ); + } + } + + #[test] + fn a_rootless_podman_declines_setup_and_start_and_nothing_else() { + use crate::engine::Engine; + let rootless = CliEngine { + engine: Engine::Podman, + api: Some("unix:///tmp/podman.sock".into()), + rootless: true, + }; + let rootful = CliEngine { + rootless: false, + ..rootless.clone() + }; + let stack = StackId::parse("local-abc123").unwrap(); + for arguments in [ + SETUP_ARGUMENTS.to_vec(), + arguments(Action::Start, &stack).to_vec(), + ] { + assert_eq!( + refuse_rootless(&arguments, Some(&rootless)), + Err(Failure::RootlessPodman) + ); + assert_eq!(refuse_rootless(&arguments, Some(&rootful)), Ok(())); + assert_eq!(refuse_rootless(&arguments, None), Ok(())); + } + for arguments in [ + arguments(Action::Status, &stack).to_vec(), + arguments(Action::Stop, &stack).to_vec(), + LIST_ARGUMENTS.to_vec(), + CONNECT_ARGUMENTS.to_vec(), + login_arguments(&stack).to_vec(), + ACCOUNT_SIGN_IN_ARGUMENTS.to_vec(), + TERMS_ARGUMENTS.to_vec(), + ] { + assert_eq!(refuse_rootless(&arguments, Some(&rootless)), 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, + Failure::RootlessPodman, + ]; + // 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 + | Failure::RootlessPodman => {} + } + 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/macos-podman-ports.yml b/desktop/src-tauri/src/macos-podman-ports.yml index 20b0ae6804..f3f8a86f52 100644 --- a/desktop/src-tauri/src/macos-podman-ports.yml +++ b/desktop/src-tauri/src/macos-podman-ports.yml @@ -20,3 +20,7 @@ services: agent-harness: ports: !override - "127.0.0.1:${PICKED_HARNESS_PORT:-4202}:${PICKED_HARNESS_PORT:-4202}" + server: + ports: !override + - "127.0.0.1:${APP_PORT:-3010}:3001" + - "127.0.0.1:${SERVER_PORT:-3001}:3001" diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index b7cc830b88..3c76b57a47 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -6,20 +6,27 @@ 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, local_browser, preparation, + install, local_browser, local_eval, local_eval_state, preparation, problem::{LeftoverReset, Problem}, provider, pull_metrics, quiet, recorder_external, recorder_host, recorder_install, 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}; @@ -30,22 +37,23 @@ use tauri::{Emitter, Manager}; struct Shell { /// Session-only local folder authority; shutdown retires it before stopping the API server. host_access: Mutex>, - /// Named, because a restart policy that cannot say which process died cannot start it again. - children: Mutex>, + /// The person's browser, the one host process a Start runs (see `local_browser`). Held so Stop + /// can let Chrome close its windows and profile before anything is forced. + browser: Mutex>, /// Native Linux engine API owned by this desktop session, kept through Stop/Start. #[cfg(target_os = "linux")] podman_api: Mutex, /// Which run is the current one. /// - /// Stopping and starting again inside two seconds would otherwise leave the previous watcher - /// alive beside the new one, both answering the same death, and a process restarted twice is - /// one process and one orphan holding a port. + /// Stopping and starting again inside two seconds would otherwise leave the previous run's + /// connection watcher alive beside the new one, both sending the window back to setup for the + /// same refusal, and a recovery marked by the old run would outlive the run that cleared it. generation: std::sync::atomic::AtomicU64, /// Cancellation of pending Start is independent of the hosts still serving the previous run. start_generation: std::sync::atomic::AtomicU64, /// Stop serializes with synchronous startup side effects, never with an async wait. startup: Mutex<()>, - /// A cancelled attempt must finish returning its unpublished children before another starts. + /// A cancelled attempt must finish unwinding before another starts. starting: std::sync::atomic::AtomicBool, /// An explicit Stop must survive setup navigation; a new app session may resume again. stopped_in_session: std::sync::atomic::AtomicBool, @@ -91,6 +99,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. @@ -205,7 +222,7 @@ impl Drop for StartAttempt<'_> { } } -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, Debug, PartialEq)] struct Progress { step: String, ok: bool, @@ -253,6 +270,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, } /// The engine the install step left answering, if it left one. @@ -461,7 +482,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 { @@ -473,6 +502,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, }; @@ -498,6 +530,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 @@ -565,34 +651,16 @@ async fn prepare_installation( if let Some(problem) = stack::deployment_problem(&root) { return Err(Problem::from(problem)); } - report_running( - &handle, - "dependencies", - "Preparing the app's dependencies.", - None, - ); - preparation::install_dependencies_if_needed(&root, || { - report_running( - &handle, - "dependencies", - "Preparing the local runtime.", - None, - ); - let bun = install::ensure_bun(&root, which_bun())?; - report_running( - &handle, - "dependencies", - "Installing the app's packages.", - None, - ); - Ok(bun) - })?; - report( - &handle, - "dependencies", - true, - "The app's dependencies are installed.", - ); + /* + * NO BUN, NO `bun install`, NO APP BUILD. + * + * The server, the app and the worker run from the published server image, so installing + * OpenBot is pulling images and nothing else. This step used to download Bun and install + * the app's packages here, on every machine, and on a fresh Windows machine that took longer + * than everything else in the install together: Defender scans each of the thousands of + * small files it writes. The one thing on the host that still needs them, the self-hosted + * Intelligence sign-in helper, prepares them itself the first time somebody uses it. + */ report_running( &handle, "images", @@ -731,6 +799,7 @@ async fn engine_ready( ) })??; } + local_eval::use_engine(&ready.address); report(app, "engine", true, ready.detail); return Ok(ready.address); } @@ -775,7 +844,7 @@ async fn engine_ready( }, ); }, - // Starting Docker Desktop can take minutes; each line says where it is. + // Installing Podman or starting Docker Desktop can take minutes; each line says where it is. &mut |said| report_running(&progress_app, "install-engine", said, None), ) }) @@ -802,6 +871,7 @@ async fn engine_ready( let after = engine::detect_saved(Some(chosen)); if let Some(address) = answering_after_install(&after, engine::Address::composes)? { desktop_telemetry::observe_engine(app, &after); + local_eval::use_engine(&address); report(app, "engine", true, after.detail); return Ok(address); } @@ -809,7 +879,19 @@ async fn engine_ready( // One at a time, and each only if the last one worked. Written as a loop over an array once, // which ran all three before the first was checked: a failed `machine init` was still followed // by `machine start`. - report_running(app, "create-machine", "Preparing the engine machine.", None); + report_running( + app, + "create-machine", + if cfg!(target_os = "windows") { + // Podman's own installer for WSL, when WSL is missing: its dialog, then Windows' own + // administrator prompt, then a restart. Said before it happens rather than after. + "Preparing the engine machine. If Windows Subsystem for Linux is missing, Podman \ + offers to install it. Windows then asks for administrator approval and a restart." + } else { + "Preparing the engine machine." + }, + None, + ); let created = acquire::create_machine(4, 6144, 60); report(app, "create-machine", created.ok, created.said.clone()); if !created.ok { @@ -847,6 +929,7 @@ async fn engine_ready( ready.detail, ) })?; + local_eval::use_engine(&address); report(app, "engine", true, "The container engine is answering."); Ok(address) } @@ -866,6 +949,21 @@ fn ensure_linux_podman_api(shell: &Shell, address: &engine::Address) -> Result<( .ensure(&address.pin()?, &stack::default_root()) } +/// Who the server container runs as on Linux, given who owns the deployment folder. +/// +/// Rootful Docker keeps the host's numbers across a bind mount, so the owner's own uid and gid are +/// what reads the owner-only sign-in folder. Rootless Podman does not: it runs every container in a +/// user namespace where root is the person who started it and any other uid is one of their +/// subordinate ids, so the owner's uid inside the container is a stranger on the host and cannot +/// open `.openbot`. Root inside that namespace is the owner, with no more reach than the owner has. +#[cfg(any(target_os = "linux", test))] +fn linux_server_user(engine: engine::Engine, uid: u32, gid: u32) -> String { + match engine { + engine::Engine::Podman => "0:0".into(), + engine::Engine::Docker => format!("{uid}:{gid}"), + } +} + /// Install the latest published deployment on first use, then keep its recorded version. /// /// Local preparation fetches the manifest needed by both launch and plan sign-in images. @@ -903,6 +1001,19 @@ async fn deployment_ready( error, ) })?; + // Only reachable when the latest release itself still ran the server on the host, which is an + // app published ahead of the release it needs. Saying so beats the Compose error that follows. + if deployment::predates_containers(root) { + let detail = format!( + "OpenBot {version} runs its server on the host; this app runs it in a container." + ); + report(app, "deployment", false, detail.clone()); + return Err(Problem::with( + "This OpenBot app needs a newer OpenBot release than the latest one. Try again \ + once it is published.", + detail, + )); + } report( app, "deployment", @@ -1371,7 +1482,7 @@ fn reset_leftover_database_with( let _startup = attempt.lock_current()?; if shell.containers.lock().unwrap().is_some() || shell.root.lock().unwrap().is_some() - || !shell.children.lock().unwrap().is_empty() + || shell.browser.lock().unwrap().is_some() { return Err(Problem::plain("OpenBot still owns running services. Choose Stop OpenBot before resetting leftover data.")); } @@ -1446,7 +1557,7 @@ async fn reset_leftover_database( ) } -/// Write the `.env`, raise the containers, migrate, then start the three host processes. +/// Write the `.env`, raise the containers, migrate, then wait for the server container to answer. #[tauri::command] #[allow( clippy::too_many_arguments, @@ -1466,7 +1577,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, @@ -1476,7 +1588,10 @@ async fn start_stack( harness, organization_auth_url, ) - .await + .await; + // The menu bar says whether OpenBot is running. + refresh_menus(&menus); + started } #[allow( @@ -1631,7 +1746,15 @@ async fn start_stack_inner( attempt.require_current()?; } - let (logs, bun, mut secrets, ports, local_browser) = { + // Minted before Compose, because the server is a container now and Compose is how it gets its + // environment. Never persisted: it goes into `secrets` only after they are written to the + // credential store, and the Compose file names it for the `server` service alone, so model + // workers, Bots and the worker cannot impersonate the native approval transport. + use base64::Engine as _; + let host_token = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()); + + let (found, secrets, ports, local_browser) = { let _startup = attempt.lock_current()?; // Belt and braces: a fetch that reported success and left something out is still not a @@ -1655,12 +1778,41 @@ async fn start_stack_inner( return Err(status.detail.into()); }; let found = found.pin()?; + // Intelligence runs on the engine OpenBot is about to run on. See `local_eval::use_engine`. + local_eval::use_engine(&found); #[cfg(target_os = "linux")] ensure_linux_podman_api(&shell, &found)?; + /* + * A rootless network namespace that predates the host loopback configuration keeps the old + * options for as long as any container holds it, and an OpenBot left running by an earlier + * version does. Taken down once, here, so the containers about to start build a new one, + * and acknowledged only once that worked, so "Try Start again" tries the teardown again. + * See `engine::local_api::Service::host_loopback_reset_pending`. + */ + #[cfg(target_os = "linux")] + if shell + .podman_api + .lock() + .unwrap() + .host_loopback_reset_pending() + { + stack::down(&found, &root).map_err(|error| { + Problem::with( + "OpenBot could not restart its containers with their new network settings. \ + Try Start again.", + error, + ) + })?; + shell + .podman_api + .lock() + .unwrap() + .acknowledge_host_loopback_reset()?; + } #[cfg(target_os = "linux")] let status = found.status(); acquire::prepare_for_compose(&found)?; - let bun = preparation::require(&root, Some(&requested_harness), &found)?; + preparation::require(&root, Some(&requested_harness), &found)?; // An installation from before engines were saved is pinned to the one its images were // just found on. Best effort: Start does not fail over a hint for next time. if saved.is_none() { @@ -1692,7 +1844,14 @@ async fn start_stack_inner( *shell.leftover_database.lock().unwrap() = leftover_offer(problem, &root, &found); })?; - // Only this deployment's recorded hosts are reclaimed; its existing containers are reusable. + /* + * Only this deployment's recorded hosts are reclaimed; its existing containers are reusable. + * + * THIS IS ALSO THE UPGRADE. An installation from before the server was a container still + * has its Bun server, app and worker on the host, recorded in `host-pids.json` and holding + * the very ports the `server` container is about to publish. Stopping them here, and waiting + * for their sockets to close, is what lets that `up` bind them. + */ let previous_ports = openbot_env::Ports::read(&root).map_err(|error| { Problem::with("OpenBot could not read its local ports.", error.to_string()) })?; @@ -1758,6 +1917,41 @@ async fn start_stack_inner( settings.insert("OPENBOT_ORGANIZATION_AUTH_URL".into(), authority); } openbot_env::explain_browser_absence(&mut settings, browser_absent); + /* + * Each Bot's computer on the network the server container is on. + * + * The supervisor publishes a computer on a loopback port of the host unless this names a + * network to attach it to instead, and a loopback port on the host is not one the server + * container can open. `default` is the network the server shares with the Bots, under the + * project name Compose is about to give it. See `stack::project_name`. + */ + settings.insert( + "COMPUTER_NETWORK".into(), + format!("{}_default", stack::project_name(&root)), + ); + /* + * On Linux the server runs as the person, not as the image's user. + * + * The model sign-in file is theirs and readable by nobody else, and a bind mount on Linux + * keeps the host's numbers, so the image's 1001 could not read it. The deployment folder is + * the one this app made for them, so its owner is who that is, with no call out to `id`. + * Docker Desktop on macOS and Windows maps ownership itself, and there the default stays. + * Rootless Podman is the exception on Linux; see `linux_server_user`. + */ + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::MetadataExt as _; + let owner = std::fs::metadata(&root).map_err(|error| { + Problem::with( + "OpenBot could not read its installation folder.", + format!("{}: {error}", root.display()), + ) + })?; + settings.insert( + "OPENBOT_SERVER_USER".into(), + linux_server_user(found.engine, owner.uid(), owner.gid()), + ); + } seed_learning_default( &root, &mut settings, @@ -1797,13 +1991,32 @@ async fn start_stack_inner( report(&app, "env", true, "settings written, credentials stored"); // Compose gives inherited environment precedence over .env. Pin this run's chosen ports. secrets.extend(ports.settings()); - for key in ["PICKED_HARNESS_PORT", "OPENBOT_TOOL_URL"] { + for key in [ + "PICKED_HARNESS_PORT", + "OPENBOT_TOOL_URL", + "COMPUTER_NETWORK", + "OPENBOT_SERVER_USER", + ] { if let Some(value) = settings.get(key) { secrets.insert(key.into(), value.clone()); } } - // Set before Bun imports the runtime, and retained for supervised restarts. + // Handed to Compose with the rest; a service receives only what the Compose file names. secrets.extend(desktop_telemetry::runtime_env(&app)); + // After the credential store has been written, so the token never is. See where it is + // minted above. + secrets.insert("OPENBOT_DESKTOP_HOST_TOKEN".into(), host_token.clone()); + + // Compose's bind mount of the sign-in folder does not create it, because Docker would make + // it as root on Linux and this app could then not write a sign-in into it. So it is made + // here, as the person, before anything asks for it. + let sign_in_folder = root.join(".openbot"); + std::fs::create_dir_all(&sign_in_folder).map_err(|error| { + Problem::with( + "OpenBot could not prepare its sign-in folder.", + format!("{}: {error}", sign_in_folder.display()), + ) + })?; // Installation already verified these images. Start only raises the local containers; // its no-pull policy sends missing assets back to the installation step. @@ -1843,53 +2056,102 @@ async fn start_stack_inner( report(&app, "services", false, detail); })?; - // Before spawning: if these are still held, whatever answers later is not ours. - let mut hosts = vec![("API server", ports.server), ("app", ports.app)]; - if let Some(port) = ports.browser { - hosts.push(("browser", port)); - } - if let Some(problem) = stack::port_already_taken(&hosts) { - report(&app, "ports", false, problem.clone()); - return Err(problem.into()); - } - - let logs = root.join(".logs"); - (logs, bun, secrets, ports, local_browser) + (found, secrets, ports, local_browser) }; - // Never persisted or passed to Compose. Only the server process receives this credential; - // model workers and frontend processes cannot impersonate the native approval transport. - use base64::Engine as _; - let host_token = - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()); - secrets.insert("OPENBOT_DESKTOP_HOST_TOKEN".into(), host_token.clone()); - - let logs_for_wait = logs.clone(); - let generation = start_host_processes( - &attempt, - &root, - &logs, - &bun, - &secrets, - local_browser.as_ref(), - |name| report(&app, name, true, "started"), - move |started| { - stack::wait_until_answering( - started, - &logs_for_wait, - &stack::Ready { - api: ports.server, - app: ports.app, - }, - std::time::Duration::from_secs(180), - ) - }, - ) + /* + * The server container answering, on both of the host ports it was given. + * + * No port check before this, as there was before spawning host processes: these ports are now + * published by this deployment's own `server` container, which `Ports::available` already + * counts as ours, and anybody else on them was avoided when the ports were chosen. Docker's + * restart policy keeps it up from here, so there is nothing to supervise after it answers. + */ + report_running( + &app, + "answering", + "Starting OpenBot. This can take a few minutes the first time.", + None, + ); + let ticket = attempt.generation; + let waiting = app.clone(); + let ready = stack::Ready { + api: ports.server, + app: ports.app, + }; + let answered = tauri::async_runtime::spawn_blocking(move || { + stack::wait_until_healthy(&ready, std::time::Duration::from_secs(180), || { + waiting + .state::() + .start_generation + .load(std::sync::atomic::Ordering::SeqCst) + == ticket + }) + }) .await - .inspect_err(|problem| report(&app, "answering", false, problem.said.clone()))?; + .unwrap_or_else(|error| Err(format!("the wait did not run: {error}"))); + // A Stop during the wait is the reason it ended, whatever the wait itself says. + attempt.require_current()?; + if let Err(error) = answered { + // What the server said while it was not answering, from Compose's log, with every + // credential this run handed out taken out of it. + let log = stack::service_log(&found, &root, "server", 40); + let detail = if log.is_empty() { + "The server container wrote nothing to its log.".to_string() + } else { + format!("Last from the server container (up to 40 lines):\n{log}") + }; + let problem = stack::startup_problem(Problem::with(error, detail), &secrets); + report(&app, "answering", false, problem.said.clone()); + return Err(problem); + } + /* + * The person's own browser, the one host process left (see `local_browser`). + * + * After the server answers: the server reaches it only when the person opens their browser, so + * it is not on the way to the window answering. Bun and the computer's packages are prepared the + * first time a Start has a browser to run, and reused after that, as the Intelligence sign-in + * helper prepares them. Installing OpenBot no longer does. + */ + let browser_bun = match local_browser.as_ref() { + Some(_) => { + report_running(&app, "browser", "Preparing your browser.", None); + let prepare_root = root.clone(); + let prepared = tauri::async_runtime::spawn_blocking(move || { + preparation::install_dependencies_if_needed(&prepare_root, || { + install::ensure_bun(&prepare_root, which_bun()) + }) + }) + .await + .unwrap_or_else(|error| { + Err(Problem::with( + "OpenBot could not prepare your browser. Try Start again.", + error.to_string(), + )) + }); + attempt.require_current()?; + Some( + prepared + .inspect_err(|problem| report(&app, "browser", false, problem.said.clone()))?, + ) + } + None => None, + }; // Stop must not finish between accepting readiness and reporting a successful Start. let _startup = attempt.lock_current()?; - // Only a stack that answered successfully acquires a restart policy. + if let (Some(browser), Some(bun)) = (local_browser.as_ref(), browser_bun.as_ref()) { + start_local_browser(&shell, &root, bun, &secrets, &ports, browser) + .inspect_err(|problem| report(&app, "browser", false, problem.said.clone()))?; + report( + &app, + "browser", + true, + format!("{} is ready.", browser.product.name()), + ); + } + *shell.root.lock().unwrap() = Some(root.clone()); + clear_recovery_required(&shell, &root); + let generation = shell.generation.load(std::sync::atomic::Ordering::SeqCst); let address = shell .containers .lock() @@ -1899,7 +2161,7 @@ async fn start_stack_inner( .ok_or_else(|| Problem::plain("The local container runtime is unavailable."))?; let config = host_access::HostAccessConfig::new( format!("http://127.0.0.1:{}", ports.server), - host_token, + host_token.clone(), address, deployment::reference(&root, "agent-computer")?, vec![root.clone()], @@ -1918,18 +2180,12 @@ async fn start_stack_inner( ) })?; preparation::save_selected_root(&config, &root)?; - supervise_host_processes( - app.clone(), - root, - logs, - bun, - secrets, - generation, - ports, - local_browser, - ); + if let (Some(browser), Some(bun)) = (local_browser, browser_bun) { + supervise_local_browser(app.clone(), root.clone(), bun, secrets, browser, generation); + } + watch_connections(app.clone(), root, host_token, generation, ports.server); - report(&app, "answering", true, "the API and the app are answering"); + report(&app, "answering", true, "OpenBot is ready."); Ok(()) } @@ -1946,9 +2202,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)] @@ -1962,20 +2220,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: any host processes an installation from before the server was a +/// container left running, 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 { @@ -1984,6 +2441,7 @@ fn root_for_stop(shell: &Shell, fallback_root: &Path) -> PathBuf { root } +#[cfg(test)] fn stop_everything_with( shell: &Shell, fallback_root: &Path, @@ -1994,6 +2452,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 @@ -2004,7 +2487,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) { @@ -2015,14 +2498,93 @@ 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 /// first; Compose must still use the deployment captured before up, retaining it on any error. fn down_containers_with(shell: &Shell, fallback_root: &Path, down: D) -> Result<(), String> @@ -2068,102 +2630,16 @@ fn down_containers_on_quit(shell: &Shell, root: &Path) -> Result<(), String> { down_owned_containers(shell, root) } -/// Reclaim held replacements before consulting durable inventory. Keep the handles and pidfile -/// if any phase fails, so the next Stop or Start can retry with the same ownership evidence. -fn cleanup_host_children( - root: &Path, - children: &mut Vec<(&'static str, std::process::Child)>, - cleanup: C, -) -> Result -where - C: FnOnce(&Path) -> Result, -{ - let held = stack::stop_host_children(root, children)?; - stop_held_process_handles(children)?; - let recorded = cleanup(root)?; - Ok(held + recorded) -} - -fn stop_held_process_handles( - children: &mut Vec<(&'static str, std::process::Child)>, -) -> Result<(), Problem> { - for (name, child) in children.iter_mut() { - let failure = |error| { - Problem::with( - "OpenBot could not stop one of its host processes.", - format!("could not finish stopping held {name}: {error}"), - ) - }; - if child.try_wait().map_err(failure)?.is_none() { - child.kill().map_err(failure)?; - } - child.wait().map_err(failure)?; - } - children.clear(); - Ok(()) -} - -fn cleanup_after_host_recording_failure( - shell: &Shell, - root: &Path, - children: &mut Vec<(&'static str, std::process::Child)>, - recording: Problem, - cleanup: C, - force_handles: F, -) -> Result -where - C: FnOnce(&Path, &mut Vec<(&'static str, std::process::Child)>) -> Result, - F: FnOnce(&mut Vec<(&'static str, std::process::Child)>) -> Result<(), Problem>, -{ - shell - .generation - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let cleanup = cleanup(root, children); - let failure = match cleanup { - Ok(_) => { - *shell.root.lock().unwrap() = None; - return Err(recording); - } - Err(cleanup) => cleanup, - }; - let forced = force_handles(children); - let mut detail = recording.detail.unwrap_or_default(); - if !detail.is_empty() { - detail.push('\n'); - } - detail.push_str(&problem_detail(failure)); - match forced { - Ok(()) => { - *shell.root.lock().unwrap() = None; - } - Err(forced) => { - detail.push('\n'); - detail.push_str(&problem_detail(forced)); - } - } - Err(Problem::with(recording.said, detail)) -} - -#[cfg(test)] -fn retire_host_processes(shell: &Shell, root: &Path, cleanup: C) -> Result -where - C: FnOnce(&Path) -> Result, -{ - // Invalidate before waiting for a restart that already owns the lock. That restart either - // observes retirement before spawning, or publishes its handle before cleanup can proceed. - shell - .generation - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let _startup = shell.startup.lock().unwrap(); - cleanup_host_state(shell, root, cleanup) -} - +/// Stop what this session holds and then what the deployment's records name. The records are +/// how an installation from before the server was a container still has Bun processes on the +/// host to stop; a current one has none, and the cleanup finds nothing. Keep failed cleanup +/// owned for a later Stop. fn cleanup_host_state(shell: &Shell, root: &Path, cleanup: C) -> Result where C: FnOnce(&Path) -> Result, { // Also runs for startup replacement and failure recovery, so a retired authorization session - // cannot leave a job running beside a new server. Keep failed cleanup owned for a later Stop. + // cannot leave a job running beside a new server. let host_access_result = { let mut slot = shell.host_access.lock().unwrap(); let result = slot.as_ref().map_or(Ok(()), |broker| { @@ -2174,14 +2650,18 @@ where } result }; - let mut children = shell.children.lock().unwrap(); let selected = shell .root .lock() .unwrap() .clone() .unwrap_or_else(|| root.to_path_buf()); - let result = cleanup_host_children(&selected, &mut children, cleanup); + // Closed while it is still held, before `cleanup` ends whatever was recorded. + let result = match (close_held_browser(shell, &selected), cleanup(&selected)) { + (Ok(()), result) => result, + (Err(problem), Ok(_)) => Err(problem), + (Err(_), Err(problem)) => Err(problem), + }; let result = match (host_access_result, result) { (Ok(()), result) => result, (Err(error), Ok(_)) => Err(Problem::with( @@ -2199,176 +2679,6 @@ where result } -/// The initial host launch, including ownership handoff on every outcome. -#[allow( - clippy::too_many_arguments, - reason = "The launch carries what every host process needs and what this start decided about the person's browser." -)] -async fn start_host_processes( - attempt: &StartAttempt<'_>, - root: &Path, - logs: &Path, - bun: &Path, - secrets: &stack::Secrets, - local_browser: Option<&local_browser::LocalBrowser>, - mut report_started: R, - wait: W, -) -> Result -where - R: FnMut(&'static str), - W: FnOnce(&mut Vec<(&'static str, std::process::Child)>) -> Result<(), String> + Send + 'static, -{ - let started = { - let _startup = attempt.lock_current()?; - let mut started = Vec::new(); - for process in stack::host_processes(local_browser.is_some()) { - if attempt.require_current().is_err() { - return finish_host_start_locked(attempt, root, started, Ok(())); - } - let child = - match stack::spawn_host_process(process, root, logs, bun, secrets, local_browser) { - Ok(child) => child, - Err(error) => { - return finish_host_start_locked( - attempt, - root, - started, - Err(format!("could not start {}: {error}", process.name)), - ); - } - }; - started.push((process.name, child)); - report_started(process.name); - } - // Stop and Quit need durable ownership while readiness is still waiting, especially on - // Windows where a deployment directory alone cannot authorize terminating a process. - if let Err(recording) = stack::record_host_processes( - root, - &started - .iter() - .map(|(name, child)| (*name, child.id())) - .collect::>(), - ) { - let shell = attempt.shell; - let mut children = shell.children.lock().unwrap(); - children.extend(started); - *shell.root.lock().unwrap() = Some(root.to_path_buf()); - return cleanup_after_host_recording_failure( - shell, - root, - &mut children, - recording, - |root, children| cleanup_host_children(root, children, stack::stop_processes_under), - stop_held_process_handles, - ); - } - if attempt.require_current().is_err() { - return finish_host_start_locked(attempt, root, started, Ok(())); - } - started - }; - // A failed blocking task must not drop the only handles either. The caller retains the - // vector while readiness borrows it; even a panic returns every child to the same cleanup. - let owned = std::sync::Arc::new(Mutex::new(started)); - let waiting = std::sync::Arc::clone(&owned); - let outcome = tauri::async_runtime::spawn_blocking(move || { - let mut started = waiting.lock().unwrap(); - wait(&mut started) - }) - .await - .unwrap_or_else(|error| Err(format!("the wait did not run: {error}"))); - let started = std::mem::take( - &mut *owned - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner), - ); - let readiness_failure = outcome.as_ref().err().cloned(); - finish_host_start(attempt, root, started, outcome).map_err(|problem| { - // Preserve cancellation and lifecycle failures. Only the actual readiness error gets - // the startup headline; cleanup details remain attached and are redacted with it. - if readiness_failure.as_deref() == Some(problem.said.as_str()) { - stack::startup_problem(problem, secrets) - } else { - problem - } - }) -} - -fn finish_host_start( - attempt: &StartAttempt<'_>, - root: &Path, - started: Vec<(&'static str, std::process::Child)>, - outcome: Result<(), String>, -) -> Result { - let _startup = attempt.shell.startup.lock().unwrap(); - finish_host_start_locked(attempt, root, started, outcome) -} - -fn finish_host_start_locked( - attempt: &StartAttempt<'_>, - root: &Path, - mut started: Vec<(&'static str, std::process::Child)>, - outcome: Result<(), String>, -) -> Result { - let shell = attempt.shell; - if let Err(cancelled) = attempt.require_current() { - // These handles were never published. Stop may already have finished, so this attempt - // must reap them itself. A concurrent initial Start is excluded until its ticket drops. - let cleaned = cleanup_host_children(root, &mut started, stack::stop_processes_under); - return match cleaned { - Ok(_) => Err(cancelled), - Err(cleanup) => { - let mut detail = problem_detail(cleanup); - if let Err(forced) = stop_held_process_handles(&mut started) { - detail.push('\n'); - detail.push_str(&problem_detail(forced)); - shell.children.lock().unwrap().extend(started); - *shell.root.lock().unwrap() = Some(root.to_path_buf()); - } - Err(Problem::with(cancelled.said, detail)) - } - }; - } - let mut children = shell.children.lock().unwrap(); - children.extend(started); - *shell.root.lock().unwrap() = Some(root.to_path_buf()); - if let Err(original) = outcome { - shell - .generation - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - // The initial failure is the reason Start failed, even if its cleanup also needs help. - return match cleanup_host_children(root, &mut children, stack::stop_processes_under) { - Ok(_) => { - *shell.root.lock().unwrap() = None; - Err(original.into()) - } - Err(cleanup) => Err(Problem::with(original, problem_detail(cleanup))), - }; - } - // Keep handles only while the recording failure is being cleaned up. Reporting Start failure - // while leaving the just-spawned host processes alive would recreate the orphan this ownership - // record exists to prevent. - if let Err(recording) = stack::record_host_processes( - root, - &children - .iter() - .map(|(name, child)| (*name, child.id())) - .collect::>(), - ) { - return cleanup_after_host_recording_failure( - shell, - root, - &mut children, - recording, - |root, children| cleanup_host_children(root, children, stack::stop_processes_under), - stop_held_process_handles, - ); - } - attempt.require_current()?; - clear_recovery_required(shell, root); - Ok(shell.generation.load(std::sync::atomic::Ordering::SeqCst)) -} - fn require_no_exited_compose_services( found: &engine::Address, root: &Path, @@ -2410,9 +2720,9 @@ where C: FnOnce(&Path) -> Result, { attempt.require_current()?; - // Preflight can fail while the previous run still owns live hosts. Retire that watcher only - // when replacement actually begins reclaiming them, before taking the children lock, so a - // restart already in progress hands its child to this cleanup and never adopts the new run. + // Preflight can fail while the previous run is still serving. Retire its connection watcher + // only when replacement actually begins, so a refusal it finds now cannot be published + // against the run that replaces it. attempt .shell .generation @@ -2461,7 +2771,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(()); } @@ -2563,18 +2923,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) { @@ -2593,7 +3004,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> @@ -2614,6 +3052,8 @@ where #[derive(Default)] struct QuitState { phase: Mutex, + /// The Shutting down screen's rows. + status: Mutex, } #[derive(Default)] @@ -2742,7 +3182,7 @@ fn show_openbot_on( if recovery_required_or_pending_quit_notice(&shell, &root) && !organization_recovery { return Err("Part of OpenBot needs recovery. Try starting OpenBot once more.".into()); } - let url = owned_app_url(&root, ports).ok_or_else(|| { + let url = owned_app_url(&shell, &root, ports).ok_or_else(|| { format!("OpenBot could not verify its app on port {port} belongs to this installation. Try starting OpenBot again.") })?; let authority = @@ -2756,6 +3196,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)? @@ -2864,90 +3306,656 @@ 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: OpenBot's own light or dark, the last time its page reported +one (`page_appearance`), and the system's until then. Both themes exist; the page follows the +window's appearance through `prefers-color-scheme`, so `Some(Dark)` or `Some(Light)` pins one. + +OpenBot's, not the system's: somebody who set OpenBot to light on a Mac in dark mode saw setup in +dark, then OpenBot in light, as if they were two apps. +*/ +fn setup_theme(app: &tauri::AppHandle) -> Option { + let record = app.path().app_config_dir().ok()?.join(APPEARANCE_FILE); + theme_from_record(std::fs::read_to_string(record).ok().as_deref()) } -/// 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 -/// running, and the port check then reports OpenBot as a foreign process holding its own port. -/// -/// Asked of the deployment rather than of a file: a stamp says a deployment was installed, and only -/// an answer on the port says one is running now. -fn server_capabilities_answer(port: u16) -> bool { - reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(2)) - .build() - .ok() - .and_then(|client| { - client - .get(format!("http://127.0.0.1:{port}/api/capabilities")) - .send() - .ok() - }) - .map(|response| response.status().is_success()) - .unwrap_or(false) +/// Where OpenBot's last light or dark is kept, in the app's own configuration folder. +const APPEARANCE_FILE: &str = "appearance.json"; + +/// The theme a saved record names, or None for no record or one that cannot be read. +fn theme_from_record(record: Option<&str>) -> Option { + let record: serde_json::Value = serde_json::from_str(record?).ok()?; + match record.get("dark")?.as_bool()? { + true => Some(tauri::Theme::Dark), + false => Some(tauri::Theme::Light), + } } -fn already_running_on(root: &Path, port: u16, owns_server: F) -> bool -where - F: FnOnce(&Path, u16) -> Result, -{ - if deployment::installed(root).is_none() { - return false; +/// Keep OpenBot's light or dark for the next setup screen. Best effort: a record that cannot be +/// written only means setup follows the system next time. +fn remember_appearance(app: &tauri::AppHandle, dark: bool) { + let Ok(directory) = app.path().app_config_dir() else { + return; + }; + let record = directory.join(APPEARANCE_FILE); + let wanted = serde_json::json!({ "dark": dark }).to_string(); + if std::fs::read_to_string(&record).ok().as_deref() == Some(wanted.as_str()) { + return; } - server_capabilities_answer(port) && owns_server(root, port).unwrap_or(false) + let _ = std::fs::create_dir_all(&directory).and_then(|()| std::fs::write(&record, wanted)); } -/// Off the event-loop thread: installation holds startup for the whole download, and waiting for it -/// there froze the window until the download finished. -#[tauri::command] -async fn already_running(app: tauri::AppHandle, root: String) -> bool { - tauri::async_runtime::spawn_blocking(move || already_running_now(&app, &root)) - .await - .unwrap_or(false) +/// 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 already_running_now(app: &tauri::AppHandle, root: &str) -> bool { - let root = stack::root_from(root); - let shell = app.state::(); - let _startup = shell.startup.lock().unwrap(); - !recovery_required_or_pending_quit_notice(&shell, &root) - && openbot_env::Ports::read(&root).is_ok_and(|ports| already_running_at(&root, &ports)) +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 + } +} + +/** +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(window.app_handle())); + } + 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(window.app_handle()).or_else(|| window.theme().ok()) { + 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(window.app_handle()).or_else(|| window.theme().ok()) + == 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) { + remember_appearance(window.app_handle(), dark); + #[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}"), + } } -fn already_running_at(root: &Path, ports: &openbot_env::Ports) -> bool { - owned_app_url(root, ports).is_some() +/// 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}"); + } } -/// Neither an owned API nor an answering app port alone authorizes showing a deployment. -fn owned_app_url(root: &Path, ports: &openbot_env::Ports) -> Option { - if !already_running_on(root, ports.server, stack::recorded_server_owns_port) - || !stack::recorded_process_owns_port(root, "app", ports.app).unwrap_or(false) +/// 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(&app.state::(), &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 started the OpenBot that is running, so it can restart it. + /// False for an OpenBot left running by an earlier session that ended without its Quit. + openbot_held: bool, + /// Always empty now: OpenBot's server and worker are containers and are listed with them. + /// Kept so a Status window from before that still reads the shape it expects. + 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 held = shell.root.lock().unwrap().is_some() && shell.containers.lock().unwrap().is_some(); + 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(&shell, &root, ports).is_some()); + DesktopStatus { + openbot_running: held || answering, + openbot_held: held, + processes: Vec::new(), + 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(), + ), + } +} + +/** +Restart OpenBot itself: the server and the worker containers, through Compose. The other +containers, its database among them, keep running, so nothing is lost and the restart takes seconds. +Only an OpenBot this session started, whose engine it knows. +*/ +#[tauri::command] +async fn restart_openbot(app: tauri::AppHandle) -> Result<(), String> { + // Compose takes seconds to restart a container. Off Tauri's threads, as Stop is. + tauri::async_runtime::spawn_blocking(move || restart_openbot_now(&app)) + .await + .map_err(|error| format!("the restart did not run: {error}"))? +} + +fn restart_openbot_now(app: &tauri::AppHandle) -> Result<(), String> { + let shell = app.state::(); + let root = shell.root.lock().unwrap().clone(); + let address = shell + .containers + .lock() + .unwrap() + .as_ref() + .filter(|owned| Some(&owned.root) == root.as_ref()) + .map(|owned| owned.address.clone()); + let (Some(root), Some(address)) = (root, address) else { + return Err("OpenBot is not running.".into()); + }; + stack::restart_openbot(&address, &root) + .map_err(|error| format!("OpenBot could not restart: {error}")) +} + +/// 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 +/// running, and the port check then reports OpenBot as a foreign process holding its own port. +/// +/// Asked of the deployment rather than of a file: a stamp says a deployment was installed, and only +/// an answer on the port says one is running now. +fn server_capabilities_answer(port: u16) -> bool { + reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + .ok() + .and_then(|client| { + client + .get(format!("http://127.0.0.1:{port}/api/capabilities")) + .send() + .ok() + }) + .map(|response| response.status().is_success()) + .unwrap_or(false) +} + +fn already_running_on(root: &Path, port: u16, owns_server: F) -> bool +where + F: FnOnce(&Path, u16) -> Result, +{ + if deployment::installed(root).is_none() { + return false; + } + server_capabilities_answer(port) && owns_server(root, port).unwrap_or(false) +} + +/// Off the event-loop thread: installation holds startup for the whole download, and waiting for it +/// there froze the window until the download finished. +#[tauri::command] +async fn already_running(app: tauri::AppHandle, root: String) -> bool { + tauri::async_runtime::spawn_blocking(move || already_running_now(&app, &root)) + .await + .unwrap_or(false) +} + +fn already_running_now(app: &tauri::AppHandle, root: &str) -> bool { + let root = stack::root_from(root); + let shell = app.state::(); + let _startup = shell.startup.lock().unwrap(); + !recovery_required_or_pending_quit_notice(&shell, &root) + && openbot_env::Ports::read(&root) + .is_ok_and(|ports| already_running_at(&shell, &root, &ports)) +} + +fn already_running_at(shell: &Shell, root: &Path, ports: &openbot_env::Ports) -> bool { + owned_app_url(shell, root, ports).is_some() +} + +/// Neither an owned API nor an answering app port alone authorizes showing a deployment: this +/// root's own `server` container has to be the one publishing both. +fn owned_app_url(shell: &Shell, root: &Path, ports: &openbot_env::Ports) -> Option { + let owned = shell + .containers + .lock() + .unwrap() + .as_ref() + .filter(|owned| owned.root == root) + .map(|owned| owned.address.clone()); + app_url_published_by(owned.as_ref(), root, ports) +} + +/// [`owned_app_url`] without a window: the Recorder's native host is a process of its own, started +/// by the browser, so it holds no engine and asks whichever one answers. +fn app_url_published_by( + owned: Option<&engine::Address>, + root: &Path, + ports: &openbot_env::Ports, +) -> Option { + if !already_running_on(root, ports.server, |root, server| { + server_container_publishes(owned, root, &[server, ports.app]) + }) { return None; } stack::app_url(ports.app) @@ -3065,12 +4073,44 @@ fn recorder_host_environment() -> recorder_host::HostEnvironment { app_url: Box::new(move || { let root = root.as_deref()?; let ports = openbot_env::Ports::read(root).ok()?; - owned_app_url(root, &ports) + app_url_published_by(None, root, &ports) }), organization_cookie, } } +/// Whether `root`'s `server` container publishes `ports`, asked of the engine this session raised +/// it on when there is one, and otherwise of whichever engine answers: an OpenBot left running by +/// an earlier session is still this deployment's, and its restart policy kept it up. +#[cfg(not(test))] +fn server_container_publishes( + owned: Option<&engine::Address>, + root: &Path, + ports: &[u16], +) -> Result { + let address = match owned { + Some(address) => address.clone(), + None => { + let status = engine::detect(); + match status.address.filter(|_| status.responding) { + Some(address) => address, + None => return Ok(false), + } + } + }; + stack::server_publishes(&address, root, ports) +} + +/// Tests never ask a real engine. They say which roots' server containers publish which ports. +#[cfg(test)] +fn server_container_publishes( + _owned: Option<&engine::Address>, + root: &Path, + ports: &[u16], +) -> Result { + Ok(tests::published_by_server(root, ports)) +} + /// What stopped the stack, if anything did, and forget it once it has been read. /// /// Cleared on reading so a failure from an hour ago does not greet somebody who has since fixed it. @@ -3152,7 +4192,25 @@ async fn ask_the_bot_inner(root: PathBuf, question: String) -> Result Result, Problem> { @@ -3170,10 +4228,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. @@ -3216,14 +4285,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. @@ -3317,6 +4397,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, @@ -3494,7 +4575,12 @@ async fn begin_self_hosted_intelligence_sign_in( .self_hosted_sign_in_generation .load(std::sync::atomic::Ordering::SeqCst); tauri::async_runtime::spawn_blocking(move || { - let bun = preparation::dependencies_ready(&root)?; + // The helper is a Bun script run from the downloaded source, and the only thing on the host + // that still needs Bun and the source's packages. Installing OpenBot no longer prepares + // them, so the first sign-in does, once; later ones reuse what it recorded. + let bun = preparation::install_dependencies_if_needed(&root, || { + install::ensure_bun(&root, which_bun()) + })?; let shell = app.state::(); let signing = { let mut slot = shell.self_hosted_intelligence.lock().unwrap(); @@ -3771,3446 +4857,6518 @@ 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 root = { + let shell = app.state::(); + let selected = shell.selected_root.lock().unwrap().clone(); + selected.unwrap_or_else(stack::default_root) + }; + let report = tauri::async_runtime::spawn_blocking(move || { + know_cli_engine(&root); + 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 { - 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); +/** +The engine for the Intelligence CLI when nothing this run has named one yet. + +A relaunched OpenBot can ask Intelligence for its status before Start has pinned an engine. The +answer is the one Start would reach with no containers of its own: the engine `root` was set up on +if it saved one (see `preparation::saved_engine`), otherwise whatever `engine::detect` finds +answering. Blocking, so only from a blocking thread. +*/ +fn know_cli_engine(root: &Path) { + if local_eval::engine_known() { + return; } - if recovery_required(&shell, root) { - return Ok(true); + let found = engine::detect_saved(preparation::saved_engine(root)); + if let Some(address) = found.address.filter(|_| found.responding) { + local_eval::use_engine(&address); } - 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()); +} + +/// 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::(); + 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, - // The person's browser this start runs, carried so a restart starts the same one. - local_browser: Option, -) -> std::thread::JoinHandle<()> { - std::thread::spawn(move || { - eprintln!( - "[watch] supervising {} host processes", - stack::host_processes(local_browser.is_some()).count() - ); - 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))?; + know_cli_engine(&root); + 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, - local_browser.as_ref(), - ) - }) { - 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::(); - // Reopen and the menus call this on the event-loop thread, and installation holds startup for - // the whole download. Waiting for it there froze the window until the download finished. A - // busy lock means setup is at work and the window is already on its screen, so show it as it - // is and leave which page it belongs on to the next restore. - let startup = match shell.startup.try_lock() { - Ok(guard) => Some(guard), - Err(std::sync::TryLockError::WouldBlock) => None, - Err(std::sync::TryLockError::Poisoned(error)) => panic!("{error}"), - }; - // 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 = startup.as_ref().and_then(|_| { - let root = cleanup_root(&shell, &stack::default_root()); - 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); - } - } - let _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); +/// `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) } -fn schedule_second_instance_restore( - context: T, - restore: F, -) -> std::io::Result> +/// `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 - T: Send + 'static, - F: FnOnce(T) + Send + 'static, + L: FnOnce(&local_eval::StackId) -> Result, { - std::thread::Builder::new() - .name("openbot-second-instance-restore".into()) - .spawn(move || restore(context)) + 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, + }) } -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 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>, } -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); +#[derive(Default)] +struct KnownDashboard { + address: Option, + signed_in: bool, } -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 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; } - let _ = show_setup(app.clone()); - }); + 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; + } } -/// 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); - } - _ => {} +/** +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 + L: FnOnce(&local_eval::StackId) -> Result, + O: FnOnce(&str) -> Result<(), ()>, +{ + 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 quit_menu_accelerator() -> Option<&'static str> { - Some(QUIT_MENU_ACCELERATOR) +/// 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 Recorder host install's real side effects on this computer. -struct RecorderInstallWriter; +/// 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()) +} -impl recorder_install::InstallWriter for RecorderInstallWriter { - fn write_file(&mut self, path: &Path, contents: &str) -> std::io::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, contents) +/** +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 read_file(&self, path: &Path) -> Option { - std::fs::read_to_string(path).ok() +/// 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); +} - fn remove_file(&mut self, path: &Path) -> std::io::Result<()> { - match std::fs::remove_file(path) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - done => done, - } +/** +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 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() } +} - // Output is captured, never inherited: `reg.exe` prints a success line, and the install command's - // own output is only the manifest paths. - fn run(&mut self, argv: &[String]) -> std::io::Result<()> { - // The registry is the developer's own, whatever folder a test points the install at. - if cfg!(debug_assertions) { - return Err(std::io::Error::other( - "a debug build never writes the Windows registry", - )); +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 (program, arguments) = argv - .split_first() - .ok_or_else(|| std::io::Error::other("an empty command"))?; - let output = quiet::command(program) - .args(arguments) - .stdin(std::process::Stdio::null()) - .output()?; - if output.status.success() { - return Ok(()); - } - Err(std::io::Error::other(format!( - "{program} exited with {}: {}", - output.status, - quiet::said(&output.stderr) - ))) } +} - fn exists(&self, path: &Path) -> bool { - path.exists() +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)); } } -fn recorder_os() -> recorder_install::Os { - if cfg!(windows) { - recorder_install::Os::Windows - } else if cfg!(target_os = "macos") { - recorder_install::Os::Mac +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 { - recorder_install::Os::Linux + ("Open Intelligence dashboard", true) } } -/// The person's home folder, read as `stack.rs` reads it. -fn recorder_home() -> Option { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .filter(|home| !home.is_empty()) - .map(PathBuf::from) +/// 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); } -/// The variable a debug build's Recorder host install reads its home folder from. -const RECORDER_HOST_TEST_HOME: &str = "OPENBOT_RECORDER_HOST_TEST_HOME"; +/// Room left between a grown or clamped window and the edge of its monitor's work area. +const SCREEN_MARGIN: f64 = 24.0; -fn non_empty_var(name: &str) -> Option { - std::env::var_os(name).filter(|value| !value.is_empty()) +/** +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, + )) } -/// Lets each browser's Recorder find this app (one host manifest per browser present for this user), and registers the -/// Recorder from its stores with this person's Chrome and Edge once it is published there (`recorder_external`). -/// Returns the files in place. -/// -/// A release build writes into this person's own browser folders. A debug build is a developer's binary, rebuilt on -/// every change, and must never be registered as the host in the developer's own browsers, so it writes only under -/// the folder `OPENBOT_RECORDER_HOST_TEST_HOME` names (a test's), takes every other folder from there too, never -/// writes the Windows registry, and without the variable writes nothing. -/// -/// One round at a time: the launch's rounds and a Start's may overlap, and both read and write Desktop's note of what -/// it registered. -fn install_recorder_host_for_this_user() -> Result, Problem> { - static ONE_ROUND: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _round = ONE_ROUND - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let current_exe = std::env::current_exe().map_err(|error| { - Problem::with( - "OpenBot Desktop could not set up the Recorder.", - error.to_string(), - ) - })?; - let stable = recorder_install::stable_host_path( - recorder_os(), - ¤t_exe, - std::env::var_os("APPIMAGE").as_deref().map(Path::new), - )?; - let (home, xdg_config_home, chrome_config_home, local_app_data) = if cfg!(debug_assertions) { - let home = non_empty_var(RECORDER_HOST_TEST_HOME) - .map(PathBuf::from) - .ok_or_else(|| { - Problem::plain(format!( - "A debug build sets up the Recorder only under {RECORDER_HOST_TEST_HOME}, never in your own browsers." - )) - })?; - (home, None, None, None) - } else { - let home = recorder_home() - .ok_or_else(|| Problem::plain("OpenBot Desktop could not find your home folder."))?; - ( - home, - non_empty_var("XDG_CONFIG_HOME").map(PathBuf::from), - non_empty_var("CHROME_CONFIG_HOME").map(PathBuf::from), - non_empty_var("LOCALAPPDATA").map(PathBuf::from), - ) +/// 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 dirs = recorder_install::UserDirs { - home: &home, - xdg_config_home: xdg_config_home.as_deref(), - chrome_config_home: chrome_config_home.as_deref(), - local_app_data: local_app_data.as_deref(), + let Ok(inner) = window.inner_size() else { + return; }; - // Both run, whatever became of the other: a browser that cannot take the Recorder's entry still gets its host, - // and the other way round. - let hosts = recorder_install::install(&stable, dirs, recorder_os(), &mut RecorderInstallWriter); - let entries = recorder_external::register( - dirs, - recorder_os(), - recorder_install::store_ids(), - &mut RecorderInstallWriter, - ); - let mut in_place = hosts?; - in_place.extend(entries?); - Ok(in_place) + 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)); + } + } } -/// The install at launch, on its own thread so setup never waits on it, and again every minute where a browser -/// installed since needs its own manifest. Never in a debug build (see `install_recorder_host_for_this_user`). -#[cfg(not(debug_assertions))] -fn keep_recorder_host_installed() -> Option> { - Some(recorder_install::keep_recorder_host_installed( - install_recorder_host_for_this_user, - recorder_install::recheck_interval(recorder_os()), - )) +/** +Make the setup window tall enough for the screen it is showing, within its monitor. + +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(debug_assertions)] -fn keep_recorder_host_installed() -> Option> { - None +/// 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()); + } } -/// The same install once more at every Start, on its own thread so the Start never waits on it: a version that brought -/// a store id registers the Recorder then, on Windows too, where the launch's round is the only other. Never in a debug -/// build. -#[cfg(not(debug_assertions))] -fn install_recorder_host_at_start() -> Option> { - Some(recorder_install::keep_recorder_host_installed( - install_recorder_host_for_this_user, - None, - )) +/// 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)) } -#[cfg(debug_assertions)] -fn install_recorder_host_at_start() -> Option> { - None +/// `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()) } -fn main() { - let args: Vec = std::env::args_os().collect(); - // Chrome, Edge or Chromium starting the Recorder's native messaging host: answer it and exit, before - // anything of the app (the single-instance plugin would forward this launch to a running app). - if let Some(origin) = recorder_host::caller_origin(&args) { - std::process::exit(recorder_host::run(&origin, recorder_host_environment())); +/// Mark this run as needing its connection refreshed, and send the window to setup to do it. +/// +/// False when the run it was found for is no longer the current one, which a Stop or a new Start +/// in the meantime makes it. +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); } - // The same install the app's setup runs, as a command, so it can be tested and run by hand. - if args.get(1).is_some_and(|a| a == "--install-recorder-host") { - std::process::exit(match install_recorder_host_for_this_user() { - Ok(paths) => { - for p in paths { - println!("{}", p.display()); - } - 0 - } - Err(problem) => { - eprintln!("{}", problem.said); - if let Some(detail) = problem.detail { - eprintln!("{detail}"); - } - 1 - } - }); + if recovery_required(&shell, root) { + return Ok(true); } - 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())?; - - // On its own thread: setup never waits on it, and a failure is logged. - keep_recorder_host_installed(); + 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) +} - // The status menu lets somebody open the window, stop the stack, or quit the app. - use tauri::menu::{Menu, MenuItem}; - use tauri::tray::TrayIconBuilder; +/// Start the person's browser and hand it to this window and to the record a later window ends it +/// from. Called under the startup lock, so a Stop cannot finish between the spawn and the handoff. +fn start_local_browser( + shell: &Shell, + root: &Path, + bun: &Path, + secrets: &stack::Secrets, + ports: &openbot_env::Ports, + browser: &local_browser::LocalBrowser, +) -> Result<(), Problem> { + let port = ports.browser.ok_or_else(|| { + Problem::plain("OpenBot did not choose a port for your browser. Try Start again.") + })?; + if let Some(problem) = stack::port_already_taken(&[("browser", port)]) { + return Err(problem.into()); + } + let child = spawn_local_browser(root, bun, secrets, browser)?; + hold_local_browser(shell, root, child) +} - 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])?; - - TrayIconBuilder::with_id("openbot") - .icon(tray::icon()) - .icon_as_template(false) - .tooltip("OpenBot") - .menu(&menu) - .build(app)?; - - // 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.", - ); - } - } - _ => {} - } - }); +fn spawn_local_browser( + root: &Path, + bun: &Path, + secrets: &stack::Secrets, + browser: &local_browser::LocalBrowser, +) -> Result { + stack::spawn_host_process( + stack::local_browser_process(), + root, + &root.join(".logs"), + bun, + secrets, + Some(browser), + ) + .map_err(|error| { + Problem::with( + "OpenBot could not start your browser. Try Start again.", + error.to_string(), + ) + }) } -#[cfg(test)] -mod tests { - use super::*; - use std::io::{Read, Write}; - - include!("stop_ipc_tests.rs"); - - // The release half (it starts the install thread) is the app's own launch; a test runs a debug build. - #[cfg(debug_assertions)] - #[test] - fn a_debug_build_never_starts_the_recorder_host_install_at_launch() { - assert!(keep_recorder_host_installed().is_none()); - assert!(install_recorder_host_at_start().is_none()); +/// Record it, then hold it. One that cannot be recorded is ended, rather than left running where no +/// later window could find it. +fn hold_local_browser( + shell: &Shell, + root: &Path, + child: std::process::Child, +) -> Result<(), Problem> { + if let Err(problem) = stack::record_host_processes(root, &[(stack::LOCAL_BROWSER, child.id())]) + { + let mut held = [(stack::LOCAL_BROWSER, child)]; + if stack::stop_host_children(root, &mut held).is_err() { + let [(_, mut child)] = held; + let _ = child.kill(); + let _ = child.wait(); + } + return Err(problem); } + *shell.browser.lock().unwrap() = Some(child); + Ok(()) +} - // The registration's real side effects, in a folder standing in for a person's home: the entries written once - // published, left alone while unchanged, and taken back out, note and all, when the ids empty. Windows writes the - // registry, which a debug build refuses, so this is proved where the entries are files. - #[cfg(unix)] - #[test] - fn the_store_registration_writes_and_takes_out_real_files() { - let home = temp_root("recorder-store-registration"); - let os = recorder_os(); - let dirs = recorder_install::UserDirs::at(&home); - let edge = recorder_install::data_directory(os, recorder_install::Browser::Edge, dirs); - std::fs::create_dir_all(&edge).unwrap(); - let edge_id = "ponmlkjihgfedcbaponmlkjihgfedcba"; - let published = recorder_install::StoreIds { - chrome: Some("abcdefghijklmnopabcdefghijklmnop".into()), - edge: Some(edge_id.into()), - }; - // Another application's entry beside the Recorder's. - let folder = edge.join("External Extensions"); - std::fs::create_dir_all(&folder).unwrap(); - let theirs = folder.join("hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh.json"); - std::fs::write( - &theirs, - recorder_external::entry_contents(recorder_install::Browser::Edge), - ) - .unwrap(); - - let placed = - recorder_external::register(dirs, os, &published, &mut RecorderInstallWriter).unwrap(); - let ours = folder.join(format!("{edge_id}.json")); - assert_eq!(placed, vec![ours.clone()]); - assert_eq!( - serde_json::from_str::(&std::fs::read_to_string(&ours).unwrap()) - .unwrap(), - serde_json::json!({ - "external_update_url": "https://edge.microsoft.com/extensionwebstorebase/v1/crx" - }) - ); - // Chrome is not installed in this home: nothing of Chrome's was made. - assert!( - !recorder_install::data_directory(os, recorder_install::Browser::Chrome, dirs).exists() - ); - let note = recorder_external::record_location(os, dirs); - assert!(note.is_file()); - let modified = std::fs::metadata(&ours).unwrap().modified().unwrap(); - recorder_external::register(dirs, os, &published, &mut RecorderInstallWriter).unwrap(); - assert_eq!( - std::fs::metadata(&ours).unwrap().modified().unwrap(), - modified - ); - - recorder_external::register( - dirs, - os, - &recorder_install::StoreIds::default(), - &mut RecorderInstallWriter, - ) - .unwrap(); - assert!(!ours.exists()); - assert!(!note.exists()); - assert!(theirs.is_file()); - // Taking out what is already gone is not a failure. - recorder_external::register( - dirs, - os, - &recorder_install::StoreIds::default(), - &mut RecorderInstallWriter, - ) - .unwrap(); - let _ = std::fs::remove_dir_all(&home); +/// The browser this window holds, closed so Chrome closes its windows and profile before anything +/// is forced (see `stack::stop_host_children`). Kept on failure, so another Stop can try again. +fn close_held_browser(shell: &Shell, root: &Path) -> Result<(), Problem> { + let mut slot = shell.browser.lock().unwrap(); + let Some(child) = slot.take() else { + return Ok(()); + }; + let mut held = [(stack::LOCAL_BROWSER, child)]; + match stack::stop_host_children(root, &mut held) { + Ok(_) => Ok(()), + Err(problem) => { + let [(_, child)] = held; + *slot = Some(child); + Err(problem) + } } +} - #[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(), +/** +The person's browser, started again when it dies, as the host-process supervisor once did for all +four. It ends with the run it was started for, like [`watch_connections`]. + +Giving up is said, with the tail of its log, and OpenBot is left running: it runs without the +browser, and sending the window back to setup over it would stop everything else too. +*/ +fn supervise_local_browser( + app: tauri::AppHandle, + root: PathBuf, + bun: PathBuf, + secrets: stack::Secrets, + browser: local_browser::LocalBrowser, + generation: u64, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let mut watch = supervise::Watch::new(stack::LOCAL_BROWSER); + let current = |shell: &Shell| { + shell.generation.load(std::sync::atomic::Ordering::SeqCst) == generation + && shell.root.lock().unwrap().as_deref() == Some(root.as_path()) }; - 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(); + loop { + std::thread::sleep(std::time::Duration::from_secs(2)); + let shell = app.state::(); + if !current(&shell) { + return; } - 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)); + let exited = match shell.browser.lock().unwrap().as_mut() { + // Stop took it. + None => return, + Some(child) => matches!(child.try_wait(), Ok(Some(_))), + }; + if !exited { 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")); + if !watch.should_restart(std::time::Instant::now()) { + *shell.browser.lock().unwrap() = None; + report( + &app, + stack::LOCAL_BROWSER, + false, + format!( + "{}\n{}", + watch.gave_up(), + stack::tail_of(&root.join(".logs"), stack::LOCAL_BROWSER) + ), + ); + return; + } + report( + &app, + stack::LOCAL_BROWSER, + false, + "Your browser stopped. Starting it again.", + ); + std::thread::sleep(supervise::backoff(watch.restarts - 1)); + + // Asked again under the lock: a Stop, or another Start, may have happened meanwhile, + // and starting a browser into either is how an orphan is made. + let _startup = shell.startup.lock().unwrap(); + if !current(&shell) { + return; + } + match spawn_local_browser(&root, &bun, &secrets, &browser) + .and_then(|child| hold_local_browser(&shell, &root, child)) + { + Ok(()) => report(&app, stack::LOCAL_BROWSER, true, "started again"), + Err(problem) => report(&app, stack::LOCAL_BROWSER, false, problem.said), } } - std::fs::remove_dir_all(root).unwrap(); - } + }) +} - #[test] - fn quit_menu_uses_the_standard_quit_shortcut() { +/** +Watch a running OpenBot for a connection it reports refused, and send the window back to setup to +refresh it. + +What is left of the host-process supervisor. Docker's restart policy keeps the server and the worker +up now, so nothing here starts anything. The server still knows, as only it can, when the model, the +Intelligence or the organization sign-in it was given has been refused, and it tells the desktop +through the token minted for this run. The watcher ends with the run it was started for: a Stop or +another Start moves the generation on, and clearing the root says there is no run at all. +*/ +fn watch_connections( + app: tauri::AppHandle, + root: PathBuf, + // Carried rather than fetched again: it was minted for this run and is kept nowhere else. + token: String, + generation: u64, + server_port: u16, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let Ok(client) = desktop_connection::client().map_err(|error| { + eprintln!("[watch] {error}"); + }) else { + return; + }; + let mut notice_sent = false; + let mut 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 notice_sent && !recovery_required(&shell, &root) { + notice_sent = false; + } + if notice_sent { + continue; + } + match desktop_connection::poll(&client, server_port, &token) { + Ok(Some(connection)) => { + match publish_connection_failure(&app, &root, generation, connection) { + Ok(published) => notice_sent = published, + Err(error) => { + eprintln!("[watch] could not show connection refresh: {error}") + } + } + } + Ok(None) => poll_failed = false, + Err(error) => { + if !poll_failed { + eprintln!("[watch] {error}"); + } + poll_failed = true; + } + } + } + }) +} + +/// 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::(); + // Reopen and the menus call this on the event-loop thread, and installation holds startup for + // the whole download. Waiting for it there froze the window until the download finished. A + // busy lock means setup is at work and the window is already on its screen, so show it as it + // is and leave which page it belongs on to the next restore. + let startup = match shell.startup.try_lock() { + Ok(guard) => Some(guard), + Err(std::sync::TryLockError::WouldBlock) => None, + Err(std::sync::TryLockError::Poisoned(error)) => panic!("{error}"), + }; + // 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 = startup.as_ref().and_then(|_| { + let root = cleanup_root(&shell, &stack::default_root()); + if let Some(url) = (!recovery_required_or_pending_quit_notice(&shell, &root)) + .then(|| owned_app_url(&shell, &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::().root.lock().unwrap().is_some(); + 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) +} + +/// The Recorder host install's real side effects on this computer. +struct RecorderInstallWriter; + +impl recorder_install::InstallWriter for RecorderInstallWriter { + fn write_file(&mut self, path: &Path, contents: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, contents) + } + + fn read_file(&self, path: &Path) -> Option { + std::fs::read_to_string(path).ok() + } + + fn remove_file(&mut self, path: &Path) -> std::io::Result<()> { + match std::fs::remove_file(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + done => done, + } + } + + // Output is captured, never inherited: `reg.exe` prints a success line, and the install command's + // own output is only the manifest paths. + fn run(&mut self, argv: &[String]) -> std::io::Result<()> { + // The registry is the developer's own, whatever folder a test points the install at. + if cfg!(debug_assertions) { + return Err(std::io::Error::other( + "a debug build never writes the Windows registry", + )); + } + let (program, arguments) = argv + .split_first() + .ok_or_else(|| std::io::Error::other("an empty command"))?; + let output = quiet::command(program) + .args(arguments) + .stdin(std::process::Stdio::null()) + .output()?; + if output.status.success() { + return Ok(()); + } + Err(std::io::Error::other(format!( + "{program} exited with {}: {}", + output.status, + quiet::said(&output.stderr) + ))) + } + + fn exists(&self, path: &Path) -> bool { + path.exists() + } +} + +fn recorder_os() -> recorder_install::Os { + if cfg!(windows) { + recorder_install::Os::Windows + } else if cfg!(target_os = "macos") { + recorder_install::Os::Mac + } else { + recorder_install::Os::Linux + } +} + +/// The person's home folder, read as `stack.rs` reads it. +fn recorder_home() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .filter(|home| !home.is_empty()) + .map(PathBuf::from) +} + +/// The variable a debug build's Recorder host install reads its home folder from. +const RECORDER_HOST_TEST_HOME: &str = "OPENBOT_RECORDER_HOST_TEST_HOME"; + +fn non_empty_var(name: &str) -> Option { + std::env::var_os(name).filter(|value| !value.is_empty()) +} + +/// Lets each browser's Recorder find this app (one host manifest per browser present for this user), and registers the +/// Recorder from its stores with this person's Chrome and Edge once it is published there (`recorder_external`). +/// Returns the files in place. +/// +/// A release build writes into this person's own browser folders. A debug build is a developer's binary, rebuilt on +/// every change, and must never be registered as the host in the developer's own browsers, so it writes only under +/// the folder `OPENBOT_RECORDER_HOST_TEST_HOME` names (a test's), takes every other folder from there too, never +/// writes the Windows registry, and without the variable writes nothing. +/// +/// One round at a time: the launch's rounds and a Start's may overlap, and both read and write Desktop's note of what +/// it registered. +fn install_recorder_host_for_this_user() -> Result, Problem> { + static ONE_ROUND: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _round = ONE_ROUND + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let current_exe = std::env::current_exe().map_err(|error| { + Problem::with( + "OpenBot Desktop could not set up the Recorder.", + error.to_string(), + ) + })?; + let stable = recorder_install::stable_host_path( + recorder_os(), + ¤t_exe, + std::env::var_os("APPIMAGE").as_deref().map(Path::new), + )?; + let (home, xdg_config_home, chrome_config_home, local_app_data) = if cfg!(debug_assertions) { + let home = non_empty_var(RECORDER_HOST_TEST_HOME) + .map(PathBuf::from) + .ok_or_else(|| { + Problem::plain(format!( + "A debug build sets up the Recorder only under {RECORDER_HOST_TEST_HOME}, never in your own browsers." + )) + })?; + (home, None, None, None) + } else { + let home = recorder_home() + .ok_or_else(|| Problem::plain("OpenBot Desktop could not find your home folder."))?; + ( + home, + non_empty_var("XDG_CONFIG_HOME").map(PathBuf::from), + non_empty_var("CHROME_CONFIG_HOME").map(PathBuf::from), + non_empty_var("LOCALAPPDATA").map(PathBuf::from), + ) + }; + let dirs = recorder_install::UserDirs { + home: &home, + xdg_config_home: xdg_config_home.as_deref(), + chrome_config_home: chrome_config_home.as_deref(), + local_app_data: local_app_data.as_deref(), + }; + // Both run, whatever became of the other: a browser that cannot take the Recorder's entry still gets its host, + // and the other way round. + let hosts = recorder_install::install(&stable, dirs, recorder_os(), &mut RecorderInstallWriter); + let entries = recorder_external::register( + dirs, + recorder_os(), + recorder_install::store_ids(), + &mut RecorderInstallWriter, + ); + let mut in_place = hosts?; + in_place.extend(entries?); + Ok(in_place) +} + +/// The install at launch, on its own thread so setup never waits on it, and again every minute where a browser +/// installed since needs its own manifest. Never in a debug build (see `install_recorder_host_for_this_user`). +#[cfg(not(debug_assertions))] +fn keep_recorder_host_installed() -> Option> { + Some(recorder_install::keep_recorder_host_installed( + install_recorder_host_for_this_user, + recorder_install::recheck_interval(recorder_os()), + )) +} + +#[cfg(debug_assertions)] +fn keep_recorder_host_installed() -> Option> { + None +} + +/// The same install once more at every Start, on its own thread so the Start never waits on it: a version that brought +/// a store id registers the Recorder then, on Windows too, where the launch's round is the only other. Never in a debug +/// build. +#[cfg(not(debug_assertions))] +fn install_recorder_host_at_start() -> Option> { + Some(recorder_install::keep_recorder_host_installed( + install_recorder_host_for_this_user, + None, + )) +} + +#[cfg(debug_assertions)] +fn install_recorder_host_at_start() -> Option> { + None +} + +fn main() { + let args: Vec = std::env::args_os().collect(); + // Chrome, Edge or Chromium starting the Recorder's native messaging host: answer it and exit, before + // anything of the app (the single-instance plugin would forward this launch to a running app). + if let Some(origin) = recorder_host::caller_origin(&args) { + std::process::exit(recorder_host::run(&origin, recorder_host_environment())); + } + // The same install the app's setup runs, as a command, so it can be tested and run by hand. + if args.get(1).is_some_and(|a| a == "--install-recorder-host") { + std::process::exit(match install_recorder_host_for_this_user() { + Ok(paths) => { + for p in paths { + println!("{}", p.display()); + } + 0 + } + Err(problem) => { + eprintln!("{}", problem.said); + if let Some(detail) = problem.detail { + eprintln!("{detail}"); + } + 1 + } + }); + } + 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()); + + // On its own thread: setup never waits on it, and a failure is logged. + keep_recorder_host_installed(); + + // 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::*; + + /// Setup opens in OpenBot's own light or dark once its page has reported one, and in the + /// system's before that or when the record cannot be read. + #[test] + fn setup_takes_openbots_last_light_or_dark() { + assert_eq!( + theme_from_record(Some(r#"{"dark":false}"#)), + Some(tauri::Theme::Light) + ); + assert_eq!( + theme_from_record(Some(r#"{"dark":true}"#)), + Some(tauri::Theme::Dark) + ); + for unreadable in [ + None, + Some(""), + Some("not json"), + Some("{}"), + Some(r#"{"dark":"yes"}"#), + ] { + assert_eq!(theme_from_record(unreadable), None, "{unreadable:?}"); + } + // What `remember_appearance` writes is what this reads. + let written = serde_json::json!({ "dark": false }).to_string(); + assert_eq!(theme_from_record(Some(&written)), Some(tauri::Theme::Light)); + } + use std::io::{Read, Write}; + + include!("stop_ipc_tests.rs"); + + // The release half (it starts the install thread) is the app's own launch; a test runs a debug build. + #[cfg(debug_assertions)] + #[test] + fn a_debug_build_never_starts_the_recorder_host_install_at_launch() { + assert!(keep_recorder_host_installed().is_none()); + assert!(install_recorder_host_at_start().is_none()); + } + + // The registration's real side effects, in a folder standing in for a person's home: the entries written once + // published, left alone while unchanged, and taken back out, note and all, when the ids empty. Windows writes the + // registry, which a debug build refuses, so this is proved where the entries are files. + #[cfg(unix)] + #[test] + fn the_store_registration_writes_and_takes_out_real_files() { + let home = temp_root("recorder-store-registration"); + let os = recorder_os(); + let dirs = recorder_install::UserDirs::at(&home); + let edge = recorder_install::data_directory(os, recorder_install::Browser::Edge, dirs); + std::fs::create_dir_all(&edge).unwrap(); + let edge_id = "ponmlkjihgfedcbaponmlkjihgfedcba"; + let published = recorder_install::StoreIds { + chrome: Some("abcdefghijklmnopabcdefghijklmnop".into()), + edge: Some(edge_id.into()), + }; + // Another application's entry beside the Recorder's. + let folder = edge.join("External Extensions"); + std::fs::create_dir_all(&folder).unwrap(); + let theirs = folder.join("hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh.json"); + std::fs::write( + &theirs, + recorder_external::entry_contents(recorder_install::Browser::Edge), + ) + .unwrap(); + + let placed = + recorder_external::register(dirs, os, &published, &mut RecorderInstallWriter).unwrap(); + let ours = folder.join(format!("{edge_id}.json")); + assert_eq!(placed, vec![ours.clone()]); + assert_eq!( + serde_json::from_str::(&std::fs::read_to_string(&ours).unwrap()) + .unwrap(), + serde_json::json!({ + "external_update_url": "https://edge.microsoft.com/extensionwebstorebase/v1/crx" + }) + ); + // Chrome is not installed in this home: nothing of Chrome's was made. + assert!( + !recorder_install::data_directory(os, recorder_install::Browser::Chrome, dirs).exists() + ); + let note = recorder_external::record_location(os, dirs); + assert!(note.is_file()); + let modified = std::fs::metadata(&ours).unwrap().modified().unwrap(); + recorder_external::register(dirs, os, &published, &mut RecorderInstallWriter).unwrap(); + assert_eq!( + std::fs::metadata(&ours).unwrap().modified().unwrap(), + modified + ); + + recorder_external::register( + dirs, + os, + &recorder_install::StoreIds::default(), + &mut RecorderInstallWriter, + ) + .unwrap(); + assert!(!ours.exists()); + assert!(!note.exists()); + assert!(theirs.is_file()); + // Taking out what is already gone is not a failure. + recorder_external::register( + dirs, + os, + &recorder_install::StoreIds::default(), + &mut RecorderInstallWriter, + ) + .unwrap(); + let _ = std::fs::remove_dir_all(&home); + } + + #[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()); - let (entered, cleanup_started) = mpsc::channel(); - let (release, released) = mpsc::channel(); - let (returned, handler_returned) = mpsc::channel(); - let (exiting, exit_requested) = mpsc::channel(); - let events = Arc::new(Mutex::new(Vec::new())); - let dispatch = { - let state = state.clone(); - let before = events.clone(); - let during = events.clone(); - let after = events.clone(); - std::thread::spawn(move || { - let result = request_quit_with( - state, - Some(37), - move || before.lock().unwrap().push("prevent"), - move || { - during.lock().unwrap().push("cleanup-started"); - entered.send(()).unwrap(); - released.recv().unwrap(); - during.lock().unwrap().push("cleanup-finished"); - Vec::new() - }, - QuitDiagnostics { - sink: |_: Vec| { - panic!("successful cleanup must not report a failure") - }, - failed: |_: String| panic!("successful cleanup must not fail diagnostics"), - }, - move |code| { - after.lock().unwrap().push("exit"); - exiting.send(code).unwrap(); - }, - |work| std::thread::Builder::new().spawn(work).map(|_| ()), - ); - 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(); + fn responsive_quit_returns_while_cleanup_is_blocked_then_exits_in_order() { + use std::sync::{mpsc, Arc}; + let state = Arc::new(QuitState::default()); + let (entered, cleanup_started) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let (returned, handler_returned) = mpsc::channel(); + let (exiting, exit_requested) = mpsc::channel(); + let events = Arc::new(Mutex::new(Vec::new())); + let dispatch = { + let state = state.clone(); + let before = events.clone(); + let during = events.clone(); + let after = events.clone(); + std::thread::spawn(move || { + let result = request_quit_with( + state, + Some(37), + move || before.lock().unwrap().push("prevent"), + move || { + during.lock().unwrap().push("cleanup-started"); + entered.send(()).unwrap(); + released.recv().unwrap(); + during.lock().unwrap().push("cleanup-finished"); + Vec::new() + }, + QuitDiagnostics { + sink: |_: Vec| { + panic!("successful cleanup must not report a failure") + }, + failed: |_: String| panic!("successful cleanup must not fail diagnostics"), + }, + move |code| { + after.lock().unwrap().push("exit"); + exiting.send(code).unwrap(); + }, + |work| std::thread::Builder::new().spawn(work).map(|_| ()), + ); + 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 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_now(app.handle()).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_now(app.handle()).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 the_linux_server_runs_as_the_owner_under_docker_and_as_namespace_root_under_podman() { + assert_eq!( + linux_server_user(engine::Engine::Docker, 1000, 1000), + "1000:1000" + ); + assert_eq!(linux_server_user(engine::Engine::Podman, 1000, 1000), "0:0"); + } + + #[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(), + INSTALLED_COMPOSE + ); + 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 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") + }) + .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(); + } + + #[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(); + } + + 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() + } + ); + + 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") + ); + 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), + }, + ) + .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!( - returned_before_cleanup.is_ok(), - "Quit's event handler blocked waiting for cleanup" + 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 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(database_leftovers(&[])), + ) + .unwrap_err(); + let offered = serde_json::to_value(error).unwrap(); + assert_eq!(offered["database_reset"], "openbot_postgres-data"); + assert_eq!( + offered["leftover_reset"], + serde_json::json!(["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(); + let refused = serde_json::to_value(error).unwrap(); + assert!(refused.get("database_reset").is_none()); + assert!(refused.get("leftover_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(); + } + + fn database_leftovers(others: &[&str]) -> stack::LeftoverVolumes { + let mut volumes: Vec = others.iter().map(|name| name.to_string()).collect(); + volumes.push("openbot_postgres-data".into()); + volumes.sort(); + stack::LeftoverVolumes { + database: Some("openbot_postgres-data".into()), + database_exists: true, + volumes, + ..Default::default() + } + } + + fn leftovers_without_database(volumes: &[&str]) -> stack::LeftoverVolumes { + stack::LeftoverVolumes { + database: None, + database_exists: false, + volumes: volumes.iter().map(|name| name.to_string()).collect(), + ..Default::default() + } + } + + const BOT_LEFTOVERS: [&str; 3] = [ + "openbot-profile-general-assistant", + "openbot-workspace-general-assistant", + "openbot_agent-profiles", + ]; + + /// Deleting the folder leaves Bots' files and sign-ins in the engine even with no database. + #[test] + fn a_fresh_root_refuses_to_reattach_any_leftover_volume() { + let root = temp_root("leftover-volumes-offer"); + std::fs::create_dir_all(&root).unwrap(); + + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Ok(leftovers_without_database(&BOT_LEFTOVERS)), + ) + .unwrap_err(); + assert!( + error.said.contains("previous installation"), + "{}", + error.said + ); + let offered = serde_json::to_value(error).unwrap(); + assert!(offered.get("database_reset").is_none()); + assert_eq!(offered["leftover_reset"], serde_json::json!(BOT_LEFTOVERS)); + + // Nothing left behind starts exactly as before. + assert!(require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Ok(stack::LeftoverVolumes::default()), + ) + .is_ok()); + + // A probe that fails is not permission to reattach whatever is there. + assert!(require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Err(Problem::plain("inventory failed")), + ) + .is_err()); + + // A database joins the same confirmation as everything else it would be reset with. + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(true), + || Ok(database_leftovers(&BOT_LEFTOVERS)), + ) + .unwrap_err(); + assert_eq!( + error.database_reset.as_deref(), + Some("openbot_postgres-data") + ); + assert_eq!( + error.leftover_volumes(), + database_leftovers(&BOT_LEFTOVERS).volumes + ); + + // A database OpenBot cannot verify keeps the existing refusal, with nothing offered. + let mut unverified = leftovers_without_database(&BOT_LEFTOVERS); + unverified.database_exists = true; + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(true), + || Ok(unverified), + ) + .unwrap_err(); + assert_eq!(error.database_reset, None); + assert!(error.leftover_volumes().is_empty()); + assert!(error + .said + .contains("OpenBot will not replace the key automatically")); + + // Stopped containers that hold the volumes join the confirmation; running ones block it. + let mut held = leftovers_without_database(&BOT_LEFTOVERS); + held.containers = vec![stack::LeftoverContainer { + name: "openbot-computer-general-assistant".into(), + id: "c-computer".into(), + }]; + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Ok(held.clone()), + ) + .unwrap_err(); + assert_eq!(error.leftover_volumes(), BOT_LEFTOVERS); + assert_eq!( + error.leftover_containers(), + ["openbot-computer-general-assistant"] ); - 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"] + serde_json::to_value(&error).unwrap()["leftover_containers"], + serde_json::json!(["openbot-computer-general-assistant"]) ); - 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"), + held.running = vec!["openbot-agent-computer-1".into()]; + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Ok(held), ) - .unwrap(); - } + .unwrap_err(); + assert!(error.leftover_volumes().is_empty() && error.leftover_containers().is_empty()); + assert!( + error.said.contains("openbot-agent-computer-1") && error.said.contains("still running"), + "{}", + error.said + ); - #[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(()) - }, + // Unknown root metadata refuses Start but never offers deletion. + std::fs::write( + root.join(openbot_desktop_lib::saved_intent::FILE), + "invalid-settings-secret", ) .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"), + let error = require_existing_encryption_key_with_recovery( + &root, + &stack::Secrets::new(), + || Ok(false), + || Ok(leftovers_without_database(&BOT_LEFTOVERS)), ) - .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!(error.leftover_volumes().is_empty()); + assert!(!format!("{error:?}").contains("invalid-settings-secret")); + std::fs::remove_file(root.join(openbot_desktop_lib::saved_intent::FILE)).unwrap(); + + // A returning installation with its key keeps its data and is never probed. + let secrets = stack::Secrets::from([( + "KEY_ENCRYPTION_KEY".into(), + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".into(), + )]); + std::fs::write(root.join(".env"), "DATABASE_URL=postgres://fixture\n").unwrap(); + assert!(require_existing_encryption_key_with_recovery( + &root, + &secrets, + || panic!("original key needs no probe"), + || panic!("original key keeps its volumes") ) - .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(()) - }, - ) - .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" - ] - ); + fn a_refused_start_offers_exactly_the_containers_and_volumes_it_named() { + let root = temp_root("leftover-offer"); + let address = engine::Address::new(engine::Engine::Docker, None); + assert!(leftover_offer(&Problem::plain("refused"), &root, &address).is_none()); + let mut problem = Problem::plain("refused"); + problem.leftover = Some(Box::new(LeftoverReset { + volumes: BOT_LEFTOVERS.map(String::from).to_vec(), + containers: vec!["openbot-computer-general-assistant".into()], + })); + let offer = leftover_offer(&problem, &root, &address).unwrap(); + assert_eq!(offer.root, root); + assert_eq!(offer.volumes, BOT_LEFTOVERS); + assert_eq!(offer.containers, ["openbot-computer-general-assistant"]); + assert_eq!(offer.address, address); } #[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); + fn leftover_reset_is_bound_to_the_exact_offered_set() { + let root = temp_root("leftover-volumes-command"); + std::fs::create_dir_all(&root).unwrap(); + let shell = Shell::default(); + let offered: Vec = BOT_LEFTOVERS.map(String::from).to_vec(); + *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { + root: root.clone(), + volumes: offered.clone(), + containers: vec!["openbot-computer-general-assistant".into()], + address: engine::Address::new(engine::Engine::Docker, None), + }); + let mut superset = offered.clone(); + superset.push("other-profile-knowledge".into()); + let containers = ["openbot-computer-general-assistant".to_string()]; + for (stale, stale_containers) in [ + (offered[..2].to_vec(), &containers[..]), + (superset, &containers[..]), + (Vec::new(), &containers[..]), + (offered.clone(), &[][..]), + ( + offered.clone(), + &[containers[0].clone(), "other-computer-knowledge".into()][..], + ), + ] { + assert!(reset_leftover_database_with( + &shell, + &root, + &stale, + stale_containers, + true, + || panic!("a changed set must refuse before reading credentials"), + |_, _| panic!("a changed set must not remove anything") + ) + .is_err()); + } + let mut reordered = offered.clone(); + reordered.reverse(); + reset_leftover_database_with( + &shell, + &root, + &reordered, + &containers, + true, + || Ok(stack::Secrets::new()), + |offer, _| { + assert_eq!(offer.volumes, offered, "removal gets the confirmed volumes"); + assert_eq!(offer.containers, containers, "and the confirmed containers"); 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()); + assert!(shell.leftover_database.lock().unwrap().is_none()); + 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); + 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(), + volumes: vec!["openbot_postgres-data".into()], + containers: Vec::new(), + address: engine::Address::new( + engine::Engine::Podman, + Some("original-machine".into()), + ), + }); + }; + offer(); + assert!(reset_leftover_database_with( + &shell, + &root, + &["openbot_postgres-data".to_string()], + &[], + false, + || panic!("unconfirmed must not access credentials"), + |_, _| panic!("unconfirmed must not delete") + ) + .is_err()); + assert!(reset_leftover_database_with( + &shell, + &root, + &["openbot_postgres-data".to_string()], + &[], + 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".to_string()], + &[], + 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".to_string()], + &[], + 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".to_string()], + &[], + 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".to_string()], + &[], + 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".to_string()], + &[], + 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".to_string()], + &[], + 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".to_string()], + &[], + 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(); + .is_ok()); + std::fs::remove_dir_all(root).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}" + #[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".to_string()][..]; + 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(), + volumes: volume.to_vec(), + containers: Vec::new(), + address: original.clone(), + }); + for (selected_root, selected_volume) in [ + (&root, &["other-volume".to_string()][..]), + (&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()), + |offer, _| { + assert_eq!(offer.address, original); + Err(unavailable.clone()) + } + ), + Err(unavailable) ); - 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(); + reset_leftover_database_with( + &shell, + &root, + volume, + &[], + true, + || Ok(stack::Secrets::new()), + |offer, _| { + assert_eq!(offer.address, original); + let command = offer.address.command(); + let arguments: Vec<_> = command.get_args().collect(); + assert_eq!(arguments, ["--connection", "original-machine"]); Ok(()) }, ) .unwrap(); assert!( - exited.load(std::sync::atomic::Ordering::SeqCst), - "Quit did not retry after diagnostic failure" + 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_volumes(&address, &root, &secrets), + ) + .unwrap_err(); + assert_eq!(guarded.database_reset.as_deref(), Some(volume.as_str())); + let volumes = [volume.clone()]; + assert_eq!(guarded.leftover_volumes(), volumes); + let shell = Shell::default(); + *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { + root: root.clone(), + volumes: volumes.to_vec(), + containers: Vec::new(), + address: address.clone(), + }); + assert!(reset_leftover_database_with( + &shell, + &root, + &volumes, + &[], + false, + || Ok(secrets.clone()), + |offer, secrets| stack::reset_leftover_volumes( + &offer.address, + &root, + secrets, + &offer.volumes, + &offer.containers + ) + ) + .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, + &volumes, + &[], + true, + || Ok(secrets.clone()), + |offer, secrets| { + stack::reset_leftover_volumes( + &offer.address, + &root, + secrets, + &offer.volumes, + &offer.containers, + ) + }, + ) .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_volumes(&address, &root, &secrets, &volumes, &[]).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_now(app.handle()).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_now(app.handle()).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); - } - - #[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_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.", ), ] { - 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-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()); } - } - std::fs::remove_dir_all(root).unwrap(); - } + let problem = result.expect_err("missing saved key should stop before compose"); - #[test] - fn launch_never_downloads_missing_images() { - if test_support::isolated_process("tests::launch_never_downloads_missing_images") { - return; + 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); } - 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(); + 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"), + ] { + let root = temp_root(&format!("openbot-present-saved-{provider}")); + std::fs::create_dir_all(&root).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); - } + 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"); - #[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" - ); + 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 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", + fn saved_api_key(provider: &str) -> &'static str { + match provider { + "openai" => "OPENAI_API_KEY", + "anthropic" => "ANTHROPIC_API_KEY", + other => panic!("unexpected provider: {other}"), + } + } + + 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, + } + } + + 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(); - 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"); + fn saved_compatible_endpoint_roundtrips_public_settings_and_scoped_key() { + let root = temp_root("compatible-roundtrip"); 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 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!( - configured.values.get("INTELLIGENCE_API_KEY"), - Some(&"file-cpk".to_string()) + configured.values.get("BOT_MODEL").map(String::as_str), + Some("local-model") ); - assert_eq!( - configured.values.get("OPENAI_API_KEY"), - Some(&"file-openai".to_string()) + 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), ); - 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()); + reopened.saved = Some(true); + assert_eq!(reopened.into_credential(&root).unwrap(), credential); - 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); + 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 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(); + 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(), + ) }) - .await - }); - - assert!( - scheduled.is_ok(), - "the async single-instance listener must not panic while scheduling restore" - ); - assert!(received.recv().unwrap()); - server.join().unwrap(); + .unwrap_err(); + assert_eq!(reads, 1); + assert!(!error.said.contains("synthetic-other-key")); + std::fs::remove_dir_all(root).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(), + 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!( + container_base_url.as_deref(), + Some("http://ollama:11434/v1") + ); + 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!( + problem.said, + "Enter a valid http:// or https:// address for the container model endpoint." ); - 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"), + 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"), ] { - 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()) - } - }); + 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!( - result.unwrap_err().connection, - Some(openbot_desktop_lib::problem::Connection::Intelligence) + problem.said, "Enter a valid http:// or https:// address for your model endpoint.", + "base_url={base_url:?}" ); } - // 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") - }) - .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() - } - ); + 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."); } - 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(); - 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 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()); + } } + } - 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), + #[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 }, - saved_secret, ) - .unwrap(); + .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"]); + } + + #[test] + fn a_docker_desktop_started_by_the_install_step_skips_the_podman_machine() { + let docker = engine::EngineStatus { + engine: Some(engine::Engine::Docker), + address: Some(engine::Address::new(engine::Engine::Docker, None)), + responding: true, + engine_socket: None, + detail: "docker is answering.".into(), + }; + let address = answering_after_install(&docker, |_| true) + .unwrap() + .expect("an answering Docker needs no Podman machine"); + assert_eq!(address.engine, engine::Engine::Docker); + + let problem = answering_after_install(&docker, |_| false).unwrap_err(); assert_eq!( - credential, - openbot_env::ModelCredential::OpenAi { - api_key: "openai-b".into() - } + problem.said, + acquire::missing_compose(engine::Engine::Docker.binary()) ); + } - 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") + #[test] + fn a_podman_without_its_machine_still_gets_the_machine_steps() { + let podman = engine::EngineStatus { + engine: Some(engine::Engine::Podman), + address: None, + responding: false, + engine_socket: None, + detail: "podman is installed but not answering. Start it and try again.".into(), + }; + assert!( + answering_after_install(&podman, |_| panic!("nothing answers to run Compose")) + .unwrap() + .is_none() ); + } + + #[test] + fn start_brings_up_the_engine_this_installation_runs_on() { + use engine::Engine::{Docker, Podman}; + use std::cell::RefCell; + // (saved, in a machine, answers, our machine exists) -> what was done + let run = |saved, in_machine, answers, exists| { + let done = RefCell::new(Vec::new()); + wake_saved_engine( + saved, + in_machine, + || answers, + || { + done.borrow_mut().push("open Docker Desktop"); + Ok(()) + }, + || exists, + || { + done.borrow_mut().push("start our machine"); + Ok(()) + }, + ) + .unwrap(); + done.into_inner() + }; + let none: [&str; 0] = []; + // Answering: nothing is done, whatever was saved. + for saved in [Some(Docker), Some(Podman), None] { + assert_eq!(run(saved, true, true, true), none, "{saved:?}"); + } + // A saved Docker opens Docker Desktop, never a Podman machine. assert_eq!( - settings.get("MANAGED_AGENT_TOKEN").map(String::as_str), - Some("agent-b") + run(Some(Docker), true, false, true), + ["open Docker Desktop"] ); - - std::fs::remove_dir_all(root_a).unwrap(); - std::fs::remove_dir_all(root_b).unwrap(); + // A saved Podman after a restart: OpenBot's machine is started. + assert_eq!(run(Some(Podman), true, false, true), ["start our machine"]); + // An installation from before engines were saved, with nothing answering: the same. + assert_eq!(run(None, true, false, true), ["start our machine"]); + // No machine of ours: none is created and nobody else's is started. + assert_eq!(run(Some(Podman), true, false, false), none); + assert_eq!(run(None, true, false, false), none); + // Linux runs Podman natively: there is no machine to start. + assert_eq!(run(Some(Podman), false, false, true), none); } #[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), - }, + fn a_machine_that_fails_to_start_stops_start_with_its_own_words() { + let problem = wake_saved_engine( + Some(engine::Engine::Podman), + true, + || false, + || panic!("a Podman installation never opens Docker Desktop"), + || true, + || Err(Problem::plain("machine did not start")), ) - .expect_err("unreadable .env must stop saved-key resolution"); + .unwrap_err(); + assert_eq!(problem.said, "machine did not start"); + } - assert_eq!(problem.said, "OpenBot could not read its settings."); + #[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!( - problem - .detail - .as_deref() - .is_some_and(|detail| detail.contains(root.join(".env").to_string_lossy().as_ref())), - "{problem:?}" + result.is_err(), + "Ok(None) would enter the virtual-machine path" + ); + assert_eq!( + result.err().unwrap().detail.as_deref(), + Some("podman native operation failed") ); - 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(); + 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; - let problem = - tauri::async_runtime::block_on(ask_the_bot_inner(root.clone(), "hello".into())) - .expect_err("unreadable .env must stop Ask before transport"); + 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 + }, + ) + .expect("missing Compose should be repaired") + .expect("responding engine should be returned"); - 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(); + 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 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 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; } - } - - #[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(); + 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!( - require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Ok( - false - )) - .is_ok() + !address.composes(), + "fake podman must start without a compose provider" ); + let mut installed = false; + let mut detections = 0; + + 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"); + + 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!( - require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Ok(true)) - .is_err() + ready.address.composes(), + "the later start_stack compose gate should now pass" ); - let unavailable = Problem::plain("selected engine unavailable"); - assert_eq!( - require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Err( - unavailable.clone() - )), - Err(unavailable) + println!( + "DTA-007 functional proof: installed={installed} detections={detections} composes={}", + ready.address.composes() ); - 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 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(database_leftovers(&[])), - ) - .unwrap_err(); - let offered = serde_json::to_value(error).unwrap(); - assert_eq!(offered["database_reset"], "openbot_postgres-data"); - assert_eq!( - offered["leftover_reset"], - serde_json::json!(["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(); - let refused = serde_json::to_value(error).unwrap(); - assert!(refused.get("database_reset").is_none()); - assert!(refused.get("leftover_reset").is_none()); - std::fs::remove_file(root.join(file)).unwrap(); + 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 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(); - } + 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); - fn database_leftovers(others: &[&str]) -> stack::LeftoverVolumes { - let mut volumes: Vec = others.iter().map(|name| name.to_string()).collect(); - volumes.push("openbot_postgres-data".into()); - volumes.sort(); - stack::LeftoverVolumes { - database: Some("openbot_postgres-data".into()), - database_exists: true, - volumes, - ..Default::default() - } - } + let record = temp_root("openbot-stop-record").join("commands.log"); + let engine = fake_engine(&record); + stack::down(&engine, &selected).expect("fake compose down"); - fn leftovers_without_database(volumes: &[&str]) -> stack::LeftoverVolumes { - stack::LeftoverVolumes { - database: None, - database_exists: false, - volumes: volumes.iter().map(|name| name.to_string()).collect(), - ..Default::default() - } + 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); } - const BOT_LEFTOVERS: [&str; 3] = [ - "openbot-profile-general-assistant", - "openbot-workspace-general-assistant", - "openbot_agent-profiles", - ]; - - /// Deleting the folder leaves Bots' files and sign-ins in the engine even with no database. #[test] - fn a_fresh_root_refuses_to_reattach_any_leftover_volume() { - let root = temp_root("leftover-volumes-offer"); - std::fs::create_dir_all(&root).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 error = require_existing_encryption_key_with_recovery( - &root, - &stack::Secrets::new(), - || Ok(false), - || Ok(leftovers_without_database(&BOT_LEFTOVERS)), - ) - .unwrap_err(); - assert!( - error.said.contains("previous installation"), - "{}", - error.said - ); - let offered = serde_json::to_value(error).unwrap(); - assert!(offered.get("database_reset").is_none()); - assert_eq!(offered["leftover_reset"], serde_json::json!(BOT_LEFTOVERS)); + let record = temp_root("openbot-quit-record").join("commands.log"); + let engine = fake_engine(&record); + stack::down(&engine, &selected).expect("fake compose down"); - // Nothing left behind starts exactly as before. - assert!(require_existing_encryption_key_with_recovery( - &root, - &stack::Secrets::new(), - || Ok(false), - || Ok(stack::LeftoverVolumes::default()), - ) - .is_ok()); + 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); + } - // A probe that fails is not permission to reattach whatever is there. - assert!(require_existing_encryption_key_with_recovery( - &root, - &stack::Secrets::new(), - || Ok(false), - || Err(Problem::plain("inventory failed")), - ) - .is_err()); + #[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()); - // A database joins the same confirmation as everything else it would be reset with. - let error = require_existing_encryption_key_with_recovery( - &root, - &stack::Secrets::new(), - || Ok(true), - || Ok(database_leftovers(&BOT_LEFTOVERS)), + 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()) + }, ) - .unwrap_err(); + .expect_err("Stop must surface both cleanup and Compose failures"); + assert_eq!( - error.database_reset.as_deref(), - Some("openbot_postgres-data") + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()) + ] ); - assert_eq!( - error.leftover_volumes(), - database_leftovers(&BOT_LEFTOVERS).volumes + 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); + } - // A database OpenBot cannot verify keeps the existing refusal, with nothing offered. - let mut unverified = leftovers_without_database(&BOT_LEFTOVERS); - unverified.database_exists = true; - let error = require_existing_encryption_key_with_recovery( - &root, - &stack::Secrets::new(), - || Ok(true), - || Ok(unverified), - ) - .unwrap_err(); - assert_eq!(error.database_reset, None); - assert!(error.leftover_volumes().is_empty()); - assert!(error - .said - .contains("OpenBot will not replace the key automatically")); + #[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()); - // Stopped containers that hold the volumes join the confirmation; running ones block it. - let mut held = leftovers_without_database(&BOT_LEFTOVERS); - held.containers = vec![stack::LeftoverContainer { - name: "openbot-computer-general-assistant".into(), - id: "c-computer".into(), - }]; - let error = require_existing_encryption_key_with_recovery( - &root, - &stack::Secrets::new(), - || Ok(false), - || Ok(held.clone()), - ) - .unwrap_err(); - assert_eq!(error.leftover_volumes(), BOT_LEFTOVERS); - assert_eq!( - error.leftover_containers(), - ["openbot-computer-general-assistant"] + 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()) + }, ); + assert_eq!( - serde_json::to_value(&error).unwrap()["leftover_containers"], - serde_json::json!(["openbot-computer-general-assistant"]) + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()) + ] ); - held.running = vec!["openbot-agent-computer-1".into()]; - let error = require_existing_encryption_key_with_recovery( - &root, - &stack::Secrets::new(), - || Ok(false), - || Ok(held), - ) - .unwrap_err(); - assert!(error.leftover_volumes().is_empty() && error.leftover_containers().is_empty()); + assert_eq!(failures.len(), 2, "{failures:?}"); assert!( - error.said.contains("openbot-agent-computer-1") && error.said.contains("still running"), - "{}", - error.said + failures[0].contains("taskkill exited with status 5"), + "{failures:?}" ); - - // Unknown root metadata refuses Start but never offers deletion. - std::fs::write( - root.join(openbot_desktop_lib::saved_intent::FILE), - "invalid-settings-secret", - ) - .unwrap(); - let error = require_existing_encryption_key_with_recovery( - &root, - &stack::Secrets::new(), - || Ok(false), - || Ok(leftovers_without_database(&BOT_LEFTOVERS)), - ) - .unwrap_err(); - assert!(error.leftover_volumes().is_empty()); - assert!(!format!("{error:?}").contains("invalid-settings-secret")); - std::fs::remove_file(root.join(openbot_desktop_lib::saved_intent::FILE)).unwrap(); - - // A returning installation with its key keeps its data and is never probed. - let secrets = stack::Secrets::from([( - "KEY_ENCRYPTION_KEY".into(), - "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=".into(), - )]); - std::fs::write(root.join(".env"), "DATABASE_URL=postgres://fixture\n").unwrap(); - assert!(require_existing_encryption_key_with_recovery( - &root, - &secrets, - || panic!("original key needs no probe"), - || panic!("original key keeps its volumes") - ) - .is_ok()); - 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 a_refused_start_offers_exactly_the_containers_and_volumes_it_named() { - let root = temp_root("leftover-offer"); - let address = engine::Address::new(engine::Engine::Docker, None); - assert!(leftover_offer(&Problem::plain("refused"), &root, &address).is_none()); - let mut problem = Problem::plain("refused"); - problem.leftover = Some(Box::new(LeftoverReset { - volumes: BOT_LEFTOVERS.map(String::from).to_vec(), - containers: vec!["openbot-computer-general-assistant".into()], - })); - let offer = leftover_offer(&problem, &root, &address).unwrap(); - assert_eq!(offer.root, root); - assert_eq!(offer.volumes, BOT_LEFTOVERS); - assert_eq!(offer.containers, ["openbot-computer-general-assistant"]); - assert_eq!(offer.address, address); - } + const LOCAL_EVAL_PIN: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; - #[test] - fn leftover_reset_is_bound_to_the_exact_offered_set() { - let root = temp_root("leftover-volumes-command"); + fn local_eval_root(label: &str, ownership: Option) -> PathBuf { + let root = temp_root(label); std::fs::create_dir_all(&root).unwrap(); - let shell = Shell::default(); - let offered: Vec = BOT_LEFTOVERS.map(String::from).to_vec(); - *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { - root: root.clone(), - volumes: offered.clone(), - containers: vec!["openbot-computer-general-assistant".into()], - address: engine::Address::new(engine::Engine::Docker, None), - }); - let mut superset = offered.clone(); - superset.push("other-profile-knowledge".into()); - let containers = ["openbot-computer-general-assistant".to_string()]; - for (stale, stale_containers) in [ - (offered[..2].to_vec(), &containers[..]), - (superset, &containers[..]), - (Vec::new(), &containers[..]), - (offered.clone(), &[][..]), - ( - offered.clone(), - &[containers[0].clone(), "other-computer-knowledge".into()][..], - ), - ] { - assert!(reset_leftover_database_with( - &shell, - &root, - &stale, - stale_containers, - true, - || panic!("a changed set must refuse before reading credentials"), - |_, _| panic!("a changed set must not remove anything") - ) - .is_err()); + 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(); } - let mut reordered = offered.clone(); - reordered.reverse(); - reset_leftover_database_with( - &shell, - &root, - &reordered, - &containers, - true, - || Ok(stack::Secrets::new()), - |offer, _| { - assert_eq!(offer.volumes, offered, "removal gets the confirmed volumes"); - assert_eq!(offer.containers, containers, "and the confirmed containers"); - Ok(()) - }, - ) - .unwrap(); - assert!(shell.leftover_database.lock().unwrap().is_none()); - std::fs::remove_dir_all(root).unwrap(); + root } - #[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(), - volumes: vec!["openbot_postgres-data".into()], - containers: Vec::new(), - address: engine::Address::new( - engine::Engine::Podman, - Some("original-machine".into()), - ), - }); - }; - offer(); - assert!(reset_leftover_database_with( - &shell, - &root, - &["openbot_postgres-data".to_string()], - &[], - false, - || panic!("unconfirmed must not access credentials"), - |_, _| panic!("unconfirmed must not delete") - ) - .is_err()); - assert!(reset_leftover_database_with( - &shell, - &root, - &["openbot_postgres-data".to_string()], - &[], - true, - || Ok(stack::Secrets::new()), - |_, _| Ok(()) - ) - .is_ok()); - offer(); - for (file, content) in [ - (".env", "DATABASE_URL=postgres://fixture\n"), + 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 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 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) + }) + .unwrap(); + assert_eq!(asked.as_deref(), Some("local-abc123")); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + 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, - r#"{"version":1,"categories":[],"model":"open-ai-api-key"}"#, + 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, - "invalid-settings-secret", + signed_in(Outcome::Blocked, Some(Reason::BrowserOpenFailed)), + "Your browser did not open. Try again.", + ), + ( + signed_in(Outcome::Blocked, Some(Reason::LocalCommandBusy)), + "Another Intelligence command is still running. Try again when it finishes.", + ), + ( + 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), ] { - std::fs::write(root.join(file), content).unwrap(); - assert!(reset_leftover_database_with( - &shell, - &root, - &["openbot_postgres-data".to_string()], - &[], - true, - || Ok(stack::Secrets::new()), - |_, _| panic!("configured or unknown root must not delete") - ) - .is_err()); - std::fs::remove_file(root.join(file)).unwrap(); + 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)); } - std::fs::create_dir(root.join(".env")).unwrap(); - assert!(reset_leftover_database_with( - &shell, - &root, - &["openbot_postgres-data".to_string()], - &[], - 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".to_string()], - &[], - 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".to_string()], - &[], - 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".to_string()], - &[], - 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".to_string()], - &[], - 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".to_string()], - &[], - 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(); + let _ = std::fs::remove_dir_all(root); } #[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".to_string()][..]; - 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(), - volumes: volume.to_vec(), - containers: Vec::new(), - address: original.clone(), - }); - for (selected_root, selected_volume) in [ - (&root, &["other-volume".to_string()][..]), - (&root.join("another-root"), volume), + 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!( + intelligence_menu_root(&recorded, true), + Some(recorded.clone()) + ); + // 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), + ), ] { - 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 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(()) + }); + // 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)); } - // 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()), - |offer, _| { - assert_eq!(offer.address, original); - Err(unavailable.clone()) - } - ), - Err(unavailable) + serde_json::to_value(IntelligenceOpening::Opening).unwrap(), + serde_json::json!({"state": "opening", "said": "Opening the Intelligence dashboard…"}) ); - reset_leftover_database_with( - &shell, - &root, - volume, - &[], - true, - || Ok(stack::Secrets::new()), - |offer, _| { - assert_eq!(offer.address, original); - let command = offer.address.command(); - let arguments: Vec<_> = command.get_args().collect(); - assert_eq!(arguments, ["--connection", "original-machine"]); - Ok(()) - }, - ) - .unwrap(); - assert!( - shell.leftover_database.lock().unwrap().is_none(), - "successful reset consumes the offer" + 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() - ); - 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" - ), + 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")), + ); + 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_volumes(&address, &root, &secrets), - ) - .unwrap_err(); - assert_eq!(guarded.database_reset.as_deref(), Some(volume.as_str())); - let volumes = [volume.clone()]; - assert_eq!(guarded.leftover_volumes(), volumes); - let shell = Shell::default(); - *shell.leftover_database.lock().unwrap() = Some(LeftoverDatabase { - root: root.clone(), - volumes: volumes.to_vec(), - containers: Vec::new(), - address: address.clone(), - }); - assert!(reset_leftover_database_with( - &shell, - &root, - &volumes, - &[], - false, - || Ok(secrets.clone()), - |offer, secrets| stack::reset_leftover_volumes( - &offer.address, - &root, - secrets, - &offer.volumes, - &offer.containers - ) + 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, ) - .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, - &volumes, - &[], - true, - || Ok(secrets.clone()), - |offer, secrets| { - stack::reset_leftover_volumes( - &offer.address, - &root, - secrets, - &offer.volumes, - &offer.containers, - ) + .unwrap(); + assert_eq!(signed.outcome, Outcome::Completed); + } + assert_eq!( + *opened.borrow(), + [ + "http://intelligence.localhost:8080", + "http://intelligence.localhost:8080" + ] + ); + // 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)) + ); + // 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(); - 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_volumes(&address, &root, &secrets, &volumes, &[]).is_err(), - "the real engine must refuse an attached database volume" + } + 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 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"), + ] { + assert_eq!( + serde_json::to_value(intelligence_bridge::Offer::Failed(failure)).unwrap(), + serde_json::json!({"state": "failed", "reason": category}) ); - 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" + } + 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 }) ); - }); - 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!( + 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 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!( - removed.status.success(), - "remove only the fixture-owned volume" + + assert_eq!( + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()), + format!("intelligence:{}", active.display()), + ] ); - if let Err(panic) = workflow { - std::panic::resume_unwind(panic); - } + 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 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 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-missing-saved-{provider}")); - std::fs::create_dir_all(&root).unwrap(); - let mut trace = Vec::new(); + 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 result = 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| { - trace.push(format!("saved-secret:{key}")); - Ok(String::new()) + |_| { + phases.borrow_mut().push("down".to_string()); + Ok(()) }, - ); - 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 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"), - ] { - 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), + |_| { + phases.borrow_mut().push("intelligence".to_string()); + Ok(()) }, - |_, key| { - assert_eq!(key, saved_api_key(provider)); - Ok(expected_key.to_string()) + |step, state| { + phases.borrow_mut().push(format!("{}:{state:?}", step.id())); }, - ) - .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:?}"), + 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}"), - } - } - - 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, - } - } + #[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 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(), + 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())); }, - &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(); - } - #[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!( - configured.values.get("BOT_MODEL").map(String::as_str), - Some("local-model") + phases.into_inner(), + [ + "stop-openbot:Running", + "stop-openbot:Failed", + "stop-intelligence:Running", + "stop-intelligence:Failed", + ] ); - 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), + // 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()), + } ); - 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(); + 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); } #[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; - 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(); + 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())); + }, + ); + // 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 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 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!( - container_base_url.as_deref(), - Some("http://ollama:11434/v1") + 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!(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!( - problem.said, - "Enter a valid http:// or https:// address for the container model endpoint." + let seq = status.seq; + status.begin(); + assert!( + status.seq > seq, + "a second Quit never looks older than the first" ); + assert!(status.steps.is_empty()); } #[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"), + 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 problem = start_stack_credential( - Path::new("synthetic-unused-compatible-root"), - compatible_choice(base_url, Some("local-model")), + 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(()) + }, ) - .expect_err("a missing or invalid endpoint URL must stop setup"); - assert_eq!( - problem.said, "Enter a valid http:// or https:// address for your model endpoint.", - "base_url={base_url:?}" + .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!( + *seen.lock().unwrap(), + [ + "stop-openbot:running", + "stop-openbot:complete", + "stop-openbot:complete,stop-intelligence:running", + "stop-openbot:complete,stop-intelligence:failed", + ], + "{window_at:?}" + ); + } + 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_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 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!( + stopped.into_result(), + Err("Intelligence stop failed: the CLI timed out".into()), + "an Intelligence failure is not a silent success" + ); + assert!(!recovery_required(&shell, &active)); + + *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!( + 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_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 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!( + stop_local_eval(&root, local_eval::Limits::for_quit()), + Ok(()), + "{ownership:?}" + ); + 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_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)) }, - ) - .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!(result, Ok(())); + assert_eq!(phases.into_inner(), ["cancel-all", "stop:local-abc123"]); + let _ = std::fs::remove_dir_all(root); } #[test] - fn a_docker_desktop_started_by_the_install_step_skips_the_podman_machine() { - let docker = engine::EngineStatus { - engine: Some(engine::Engine::Docker), - address: Some(engine::Address::new(engine::Engine::Docker, None)), - responding: true, - engine_socket: None, - detail: "docker is answering.".into(), + 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") }; - let address = answering_after_install(&docker, |_| true) - .unwrap() - .expect("an answering Docker needs no Podman machine"); - assert_eq!(address.engine, engine::Engine::Docker); - let problem = answering_after_install(&docker, |_| false).unwrap_err(); assert_eq!( - problem.said, - acquire::missing_compose(engine::Engine::Docker.binary()) + 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 a_podman_without_its_machine_still_gets_the_machine_steps() { - let podman = engine::EngineStatus { - engine: Some(engine::Engine::Podman), - address: None, - responding: false, - engine_socket: None, - detail: "podman is installed but not answering. Start it and try again.".into(), - }; - assert!( - answering_after_install(&podman, |_| panic!("nothing answers to run Compose")) - .unwrap() - .is_none() + 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()), ); - } - #[test] - fn start_brings_up_the_engine_this_installation_runs_on() { - use engine::Engine::{Docker, Podman}; - use std::cell::RefCell; - // (saved, in a machine, answers, our machine exists) -> what was done - let run = |saved, in_machine, answers, exists| { - let done = RefCell::new(Vec::new()); - wake_saved_engine( - saved, - in_machine, - || answers, - || { - done.borrow_mut().push("open Docker Desktop"); - Ok(()) - }, - || exists, - || { - done.borrow_mut().push("start our machine"); - Ok(()) - }, - ) - .unwrap(); - done.into_inner() - }; - let none: [&str; 0] = []; - // Answering: nothing is done, whatever was saved. - for saved in [Some(Docker), Some(Podman), None] { - assert_eq!(run(saved, true, true, true), none, "{saved:?}"); - } - // A saved Docker opens Docker Desktop, never a Podman machine. assert_eq!( - run(Some(Docker), true, false, true), - ["open Docker Desktop"] + 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" + ] ); - // A saved Podman after a restart: OpenBot's machine is started. - assert_eq!(run(Some(Podman), true, false, true), ["start our machine"]); - // An installation from before engines were saved, with nothing answering: the same. - assert_eq!(run(None, true, false, true), ["start our machine"]); - // No machine of ours: none is created and nobody else's is started. - assert_eq!(run(Some(Podman), true, false, false), none); - assert_eq!(run(None, true, false, false), none); - // Linux runs Podman natively: there is no machine to start. - assert_eq!(run(Some(Podman), false, false, true), none); + 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 a_machine_that_fails_to_start_stops_start_with_its_own_words() { - let problem = wake_saved_engine( - Some(engine::Engine::Podman), - true, - || false, - || panic!("a Podman installation never opens Docker Desktop"), - || true, - || Err(Problem::plain("machine did not start")), + 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 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_err(); - assert_eq!(problem.said, "machine did not start"); + .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 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 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); + 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-stop-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 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; + 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 _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 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()); + type FakeVault = std::cell::RefCell>; - 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", - )) + 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] @@ -7451,7 +11609,7 @@ fn main() { ["compose","-f","docker-compose.yml","config","--format","json"] => println!("{{\"services\":{{\"supervisor\":{{\"environment\":{{\"COMPUTER_NAMESPACE\":\"fixture\"}}}}}}}}"), ["compose","-f","docker-compose.yml","stop","supervisor"] => (), ["ps","--quiet","--filter",_,"--filter",_] => (), - ["compose","-f","docker-compose.yml","--profile","harness","down"] => { + ["compose","-f","docker-compose.yml","--profile","harness","--profile","openbot","down"] => { if cwd.join("fail-down").exists() { eprintln!("synthetic down refusal");std::process::exit(73); } if fs::read_to_string(cwd.join("fixture-containers-running")).ok().as_deref()==Some(&identity) { fs::remove_file(cwd.join("fixture-containers-running")).unwrap(); @@ -7909,6 +12067,8 @@ fn main() { "docker-compose.yml", "--profile", "harness", + "--profile", + "openbot", "down", ], ); @@ -8371,33 +12531,33 @@ fn main() { let mut choice = serde_json::json!({"id":"byo-url", "agentUrl":remote}); let (expected_up, expected_image) = match case { "remote" | "remote-stale-image" => ( - "compose up -d --no-build --pull never postgres supervisor agent-computer agent-bot agent-langgraph", + "compose up -d --no-build --pull never postgres supervisor agent-computer server worker agent-bot agent-langgraph", None, ), "anthropic-api" => { model = serde_json::json!({"provider":"anthropic", "login":"api-key", "apiKey":"synthetic-anthropic-key"}); choice = serde_json::json!({"id":"langgraph"}); - ("compose --profile harness up -d --no-build --pull never postgres supervisor agent-computer agent-langgraph agent-harness", Some("agent-langgraph-agui")) + ("compose --profile harness up -d --no-build --pull never postgres supervisor agent-computer server worker agent-langgraph agent-harness", Some("agent-langgraph-agui")) } "compatible" => { model = serde_json::json!({"provider":"openai-compatible", "login":"endpoint", "baseUrl":"http://127.0.0.1:11434/v1", "model":"synthetic-model", "apiKey":""}); - ("compose up -d --no-build --pull never postgres supervisor agent-computer agent-bot agent-langgraph", None) + ("compose up -d --no-build --pull never postgres supervisor agent-computer server worker agent-bot agent-langgraph", None) } "installed" => { choice = serde_json::json!({"id":"langgraph"}); - ("compose --profile harness up -d --no-build --pull never postgres supervisor agent-computer agent-bot agent-langgraph agent-harness", Some("agent-langgraph-agui")) + ("compose --profile harness up -d --no-build --pull never postgres supervisor agent-computer server worker agent-bot agent-langgraph agent-harness", Some("agent-langgraph-agui")) } "none" => { choice = serde_json::Value::Null; - ("compose up -d --no-build --pull never postgres supervisor agent-computer agent-bot agent-langgraph", None) + ("compose up -d --no-build --pull never postgres supervisor agent-computer server worker agent-bot agent-langgraph", None) } "chatgpt-plan" => { model = serde_json::json!({"provider":"openai", "login":"plan", "token":"{\"refresh_token\":\"synthetic-plan\"}"}); - ("compose --profile harness up -d --no-build --pull never postgres supervisor agent-computer agent-harness", Some("agent-langgraph-agui")) + ("compose --profile harness up -d --no-build --pull never postgres supervisor agent-computer server worker agent-harness", Some("agent-langgraph-agui")) } "claude-plan" => { model = serde_json::json!({"provider":"anthropic", "login":"plan", "token":"synthetic-claude-plan"}); - ("compose --profile harness up -d --no-build --pull never postgres supervisor agent-computer agent-harness", Some("agent-claude-sdk")) + ("compose --profile harness up -d --no-build --pull never postgres supervisor agent-computer server worker agent-harness", Some("agent-claude-sdk")) } _ => panic!("unknown test case"), }; @@ -8476,8 +12636,26 @@ fn main() { vec![expected_up], "case={case}, actual Start IPC commands:\n{commands}" ); - assert!(commands.contains("\tcompose run --rm --pull never migrate\n")); - assert!(!root.join(".logs").exists(), "no host runtime was launched"); + assert!(commands.contains("\tcompose run --rm --pull never migrate\n")); + assert!(!root.join(".logs").exists(), "no host runtime was launched"); + // The server container's own credential reaches Compose for `up`, and only Compose. + assert_eq!( + std::fs::read_to_string(root.join("host-token-at-up")).ok(), + Some("43".to_string()), + "case={case}: Compose must be handed the host token before it starts the server" + ); + assert!(!std::fs::read_to_string(root.join(".env")) + .unwrap() + .contains("OPENBOT_DESKTOP_HOST_TOKEN")); + // Compose's bind mount does not create the sign-in folder, so Start must have. + assert!(root.join(".openbot").is_dir()); + // Each Bot's computer goes on the network the server container is on. + assert_eq!( + openbot_env::read_already_set(&root.join(".env"), &["COMPUTER_NETWORK"]) + .unwrap() + .get("COMPUTER_NETWORK"), + Some(&format!("{}_default", stack::project_name(&root))) + ); let settings = openbot_env::read_already_set( &root.join(".env"), &[ @@ -8685,7 +12863,7 @@ fn main() { "{commands}" ); assert!(commands.contains("\tcompose version\n"), "{commands}"); - assert!(commands.contains("\tcompose up -d --no-build --pull never postgres supervisor agent-computer agent-bot agent-langgraph\n"), "{commands}"); + assert!(commands.contains("\tcompose up -d --no-build --pull never postgres supervisor agent-computer server worker agent-bot agent-langgraph\n"), "{commands}"); assert!( commands.contains("\tcompose run --rm --pull never migrate\n"), "{commands}" @@ -8752,13 +12930,13 @@ fn main() { let commands = std::fs::read_to_string(&record).expect("command record"); assert!( commands.contains( - "\tcompose up -d --no-build --pull never postgres supervisor agent-computer agent-langgraph\n" + "\tcompose up -d --no-build --pull never postgres supervisor agent-computer server worker agent-langgraph\n" ), "{commands}" ); assert!( !commands - .contains("compose up -d --no-build --pull never postgres supervisor agent-computer agent-bot"), + .contains("compose up -d --no-build --pull never postgres supervisor agent-computer server worker agent-bot"), "Anthropic Start must not target the OpenAI-only agent-bot: {commands}" ); assert_eq!(problem.said, "Part of OpenBot stopped during startup."); @@ -9249,6 +13427,9 @@ fn main() { let _ = std::fs::remove_dir_all(record.parent().expect("record parent")); } + /// A Compose file from a release that runs the server and worker as services. + const INSTALLED_COMPOSE: &str = "services:\n server: {}\n worker: {}\n"; + fn write_installed_deployment(root: &Path) { const DEPLOYMENT_VERSION: &str = "v0.0.8"; std::fs::create_dir_all(root.join("server")).unwrap(); @@ -9257,7 +13438,7 @@ fn main() { std::fs::create_dir_all(root.join("shared")).unwrap(); std::fs::create_dir_all(root.join("toolkit")).unwrap(); std::fs::create_dir_all(root.join("extension")).unwrap(); - std::fs::write(root.join("docker-compose.yml"), "services: {}\n").unwrap(); + std::fs::write(root.join("docker-compose.yml"), INSTALLED_COMPOSE).unwrap(); std::fs::write( root.join("app/package.json"), r#"{"scripts":{"serve":"vite preview"}}"#, @@ -9367,663 +13548,116 @@ fn main() { let path = lines .next() .and_then(|line| line.split_whitespace().nth(1)) - .expect("path") - .to_string(); - let headers = lines - .filter(|line| !line.trim().is_empty()) - .map(|line| line.to_ascii_lowercase()) - .collect(); - let body = - String::from_utf8_lossy(&request[header_end..header_end + content_length]) - .to_string(); - sender - .send(TestRequest { - path, - headers, - body, - }) - .expect("send request"); - stream - .write_all(response.as_bytes()) - .expect("write response"); - }); - Self { - url, - received, - done: Some(done), - } - } - - fn request(mut self) -> TestRequest { - let request = self.received.recv().expect("request"); - self.done.take().expect("thread").join().expect("join"); - request - } - } - - fn test_server_port(server: &TestServer) -> u16 { - server - .url - .strip_prefix("http://127.0.0.1:") - .expect("loopback url") - .parse() - .expect("port") - } - - #[test] - fn already_running_requires_selected_root_ownership_for_loopback_answer() { - let root_a = temp_root("already-running-root-a"); - let root_b = temp_root("already-running-root-b"); - write_installed_deployment(&root_a); - write_installed_deployment(&root_b); - - let server_a = TestServer::new("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"); - let port_a = test_server_port(&server_a); - assert!( - !already_running_on(&root_a, port_a, |root, port| { - assert_eq!(root, root_a.as_path()); - assert_eq!(port, port_a); - Ok(false) - }), - "an answering shared port without selected-root ownership must not auto-adopt root A" - ); - assert_eq!(server_a.request().path, "/api/capabilities"); - - let server_b = TestServer::new("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"); - let port_b = test_server_port(&server_b); - assert!(already_running_on(&root_b, port_b, |root, port| { - assert_eq!(root, root_b.as_path()); - assert_eq!(port, port_b); - Ok(true) - })); - assert_eq!(server_b.request().path, "/api/capabilities"); - - std::fs::remove_dir_all(root_a).unwrap(); - std::fs::remove_dir_all(root_b).unwrap(); - } - - #[test] - fn already_running_returns_false_when_ownership_is_unproven() { - let root = temp_root("already-running-unproven-root"); - write_installed_deployment(&root); - let server = TestServer::new("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"); - let port = test_server_port(&server); - assert!(!already_running_on(&root, port, |_, _| { - Err(Problem::with("ownership unavailable", "synthetic failure")) - })); - assert_eq!(server.request().path, "/api/capabilities"); - std::fs::remove_dir_all(root).unwrap(); - } - - #[cfg(unix)] - struct InitialHostFixture { - base: PathBuf, - root: PathBuf, - bun: PathBuf, - pids: std::cell::RefCell>, - } - - #[cfg(unix)] - impl InitialHostFixture { - fn new(mode: &str) -> Self { - use std::os::unix::fs::PermissionsExt; - let base = temp_root("initial-host-launch"); - let root = base.join("deployment"); - std::fs::create_dir_all(&root).unwrap(); - for process in stack::host_processes(false) { - if mode == "first-fails" - || ((mode == "second-fails" || mode == "cleanup-refuses") - && process.name != "server") - { - break; - } - std::fs::create_dir(root.join(process.cwd)).unwrap(); - } - let bun = base.join("bun"); - // The production spawn boundary supplies cwd/argv/log files. Only the executable is - // synthetic: one direct child with no network, engine, or credential access. - std::fs::write( - &bun, - "#!/bin/sh\nprintf '%s' \"$$\" > child.pid\nexec /bin/sleep 60\n", - ) - .unwrap(); - std::fs::set_permissions(&bun, std::fs::Permissions::from_mode(0o700)).unwrap(); - Self { - base, - root, - bun, - pids: std::cell::RefCell::new(Vec::new()), - } - } - - fn observe(&self, name: &str) { - let path = self.root.join(name).join("child.pid"); - let until = std::time::Instant::now() + std::time::Duration::from_secs(5); - let pid = loop { - if let Ok(text) = std::fs::read_to_string(&path) { - if let Ok(pid) = text.parse::() { - break pid; - } - } - assert!(std::time::Instant::now() < until, "child did not start"); - std::thread::sleep(std::time::Duration::from_millis(5)); - }; - self.pids.borrow_mut().push(pid); - } - - fn alive(&self) -> Vec { - self.pids - .borrow() - .iter() - .copied() - .filter(|pid| unsafe { libc::kill(*pid as i32, 0) } == 0) - .collect() - } - } - - #[cfg(unix)] - impl Drop for InitialHostFixture { - fn drop(&mut self) { - // Even an old-code regression failure must not orphan the fixture. waitpid first - // proves this is still our direct, unreaped child; ECHILD never authorizes a signal. - for pid in self.pids.get_mut() { - if unsafe { libc::waitpid(*pid as i32, std::ptr::null_mut(), libc::WNOHANG) } == 0 { - unsafe { - libc::kill(*pid as i32, libc::SIGKILL); - libc::waitpid(*pid as i32, std::ptr::null_mut(), 0); - } - } - } - if self.base.is_file() { - std::fs::remove_file(&self.base).unwrap(); - } else { - std::fs::remove_dir_all(&self.base).unwrap(); - } - } - } - - #[cfg(unix)] - struct SupervisionFixture { - host: InitialHostFixture, - app: tauri::App, - watcher: Option>, - } - - #[cfg(unix)] - impl Drop for SupervisionFixture { - fn drop(&mut self) { - let shell = self.app.state::(); - shell - .generation - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - stop_held_process_handles( - &mut shell - .children - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner), - ) - .unwrap(); - *shell - .root - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; - if let Some(watcher) = self.watcher.take() { - watcher.join().unwrap(); - } - eprintln!( - "{}", - serde_json::json!({"supervisionCleanup": self.host.root, - "heldChildren": shell.children.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len(), "watcherJoined": true}) - ); - } - } - - #[cfg(unix)] - fn failed_retry_keeps_survivor_supervised(failed_role: &str) { - let host = InitialHostFixture::new("success"); - std::fs::write( - &host.bun, - "#!/bin/sh\nprintf '%s' \"$$\" > child.pid\nif [ -f fail ]; then exit 71; fi\nexec /bin/sleep 300\n", - ).unwrap(); - let app = tauri::test::mock_builder() - .manage(Shell::default()) - .invoke_handler(tauri::generate_handler![start_stack]) - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .unwrap(); - let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) - .build() - .unwrap(); - let mut fixture = SupervisionFixture { - host, - app, - watcher: None, - }; - let shell = fixture.app.state::(); - let setup = "tauri://localhost/failed-retry-setup"; - *shell.setup_url.lock().unwrap() = Some(setup.into()); - let attempt = StartAttempt::begin(&shell).unwrap(); - let generation = tauri::async_runtime::block_on(start_host_processes( - &attempt, - &fixture.host.root, - &fixture.host.root.join(".logs"), - &fixture.host.bun, - &stack::Secrets::new(), - None, - |name| fixture.host.observe(name), - |_| Ok(()), - )) - .unwrap(); - drop(attempt); - fixture.watcher = Some(supervise_host_processes( - fixture.app.handle().clone(), - fixture.host.root.clone(), - fixture.host.root.join(".logs"), - fixture.host.bun.clone(), - stack::Secrets::new(), - generation, - openbot_env::Ports::default(), - None, - )); - // The actual watcher exhausts its actual budget and backoffs after this role fails. - std::fs::write(fixture.host.root.join(failed_role).join("fail"), "").unwrap(); - { - let mut children = shell.children.lock().unwrap(); - children - .iter_mut() - .find(|(name, _)| *name == failed_role) - .unwrap() - .1 - .kill() - .unwrap(); - } - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(70); - loop { - if shell - .last_failure - .lock() - .unwrap() - .as_ref() - .is_some_and(|p| p.said.contains("could not be started again")) - && window.url().unwrap().as_str() == setup - { - break; - } - assert!( - std::time::Instant::now() < deadline, - "watcher did not produce recovery setup" - ); - std::thread::sleep(std::time::Duration::from_millis(20)); - } - let survivor_pid = { - let mut children = shell.children.lock().unwrap(); - assert!(!children.iter().any(|(name, _)| *name == failed_role)); - let (_, survivor) = children - .iter_mut() - .find(|(name, _)| *name == "worker") - .unwrap(); - assert!(survivor.try_wait().unwrap().is_none()); - survivor.id() - }; - // Deliberate invalid input rejects before saved-secret/deployment/engine access. This is - // the generated production Start handler, after recovery has exposed ordinary Start. - let problem = tauri::test::get_ipc_response(&window, tauri::webview::InvokeRequest { - cmd: "start_stack".into(), callback: tauri::ipc::CallbackFn(0), error: tauri::ipc::CallbackFn(1), - url: "tauri://localhost".parse().unwrap(), - body: tauri::ipc::InvokeBody::Json(serde_json::json!({ - "root": fixture.host.root, "apiUrl": "https://intelligence.example.test", - "gatewayWsUrl": "wss://gateway.example.test", "apiKey": "synthetic-unused-key", - "model": {"provider": "synthetic-invalid-provider", "login": "api-key"}, "harness": null, - })), - headers: Default::default(), invoke_key: tauri::test::INVOKE_KEY.into(), - }).expect_err("credential preflight must reject the synthetic provider"); - assert!(problem["said"] - .as_str() - .unwrap() - .contains("synthetic-invalid-provider")); - assert_eq!( - shell.root.lock().unwrap().as_ref(), - Some(&fixture.host.root) - ); - assert!(!shell.starting.load(std::sync::atomic::Ordering::SeqCst)); - { - let mut children = shell.children.lock().unwrap(); - let (_, survivor) = children - .iter_mut() - .find(|(name, _)| *name == "worker") - .unwrap(); - assert_eq!(survivor.id(), survivor_pid); - assert!( - survivor.try_wait().unwrap().is_none(), - "preflight unexpectedly reclaimed survivor" - ); - survivor.kill().unwrap(); - } - // A subsequent real child death must still cause the existing watcher to restart it. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8); - let replacement = loop { - let replacement = shell - .children - .lock() - .unwrap() - .iter_mut() - .find(|(name, child)| *name == "worker" && child.id() != survivor_pid) - .and_then(|(_, child)| child.try_wait().unwrap().is_none().then_some(child.id())); - if replacement.is_some() - || fixture.watcher.as_ref().unwrap().is_finished() - || std::time::Instant::now() >= deadline - { - break replacement; - } - std::thread::sleep(std::time::Duration::from_millis(20)); - }; - eprintln!( - "{}", - serde_json::json!({ - "failedRole": failed_role, "recoveryUrl": window.url().unwrap().as_str(), - "ipcError": problem, "survivorPid": survivor_pid, "replacementPid": replacement, - "watcherRetired": fixture.watcher.as_ref().unwrap().is_finished(), - "generationBefore": generation, - "generationAfterPreflight": shell.generation.load(std::sync::atomic::Ordering::SeqCst), - }) - ); - assert!( - replacement.is_some(), - "failed retry preflight abandoned the surviving host's watcher" - ); - } - - #[cfg(unix)] - #[test] - fn worker_exhaustion_does_not_adopt_healthy_survivors() { - if crate::test_support::isolated_process( - "tests::worker_exhaustion_does_not_adopt_healthy_survivors", - ) { - return; - } - let host = InitialHostFixture::new("success"); - write_installed_deployment(&host.root); - let source = host.base.join("worker-recovery-host.rs"); - std::fs::write(&source, r#" -use std::{fs,io::{Read,Write},net::TcpListener,time::Duration}; -fn main() { - let cwd=std::env::current_dir().unwrap(); - let role=cwd.file_name().unwrap().to_str().unwrap(); - fs::write("child.pid",std::process::id().to_string()).unwrap(); - let mut starts=fs::OpenOptions::new().create(true).append(true).open("starts.log").unwrap(); - writeln!(starts,"{}",std::process::id()).unwrap(); - if role=="worker" { - loop { - if cwd.join("fail").exists() { std::process::exit(71); } - std::thread::sleep(Duration::from_millis(20)); - } - } - let port=match role { "server"=>3001,"app"=>3010,_=>panic!("unexpected role") }; - let listener=TcpListener::bind(("127.0.0.1",port)).unwrap(); - for stream in listener.incoming() { - let mut stream=stream.unwrap(); - stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); - let mut request=[0;2048]; - if stream.read(&mut request).unwrap_or(0)>0 { - let _=stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"); - } - } -} -"#).unwrap(); - crate::test_support::compile_fixture(&source, &host.bun); - let app = tauri::test::mock_builder() - .manage(Shell::default()) - .invoke_handler(tauri::generate_handler![ - already_running, - show_openbot, - last_failure - ]) - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .unwrap(); - let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) - .build() - .unwrap(); - let mut fixture = SupervisionFixture { - host, - app, - watcher: None, - }; - let shell = fixture.app.state::(); - let setup = "tauri://localhost/worker-recovery-setup"; - *shell.setup_url.lock().unwrap() = Some(setup.into()); - let attempt = StartAttempt::begin(&shell).unwrap(); - let logs = fixture.host.root.join(".logs"); - let wait_logs = logs.clone(); - let generation = tauri::async_runtime::block_on(start_host_processes( - &attempt, - &fixture.host.root, - &logs, - &fixture.host.bun, - &stack::Secrets::new(), - None, - |name| fixture.host.observe(name), - move |children| { - stack::wait_until_answering( - children, - &wait_logs, - &stack::Ready { - api: 3001, - app: 3010, - }, - std::time::Duration::from_secs(10), - ) - }, - )) - .unwrap(); - drop(attempt); - fixture.watcher = Some(supervise_host_processes( - fixture.app.handle().clone(), - fixture.host.root.clone(), - logs, - fixture.host.bun.clone(), - stack::Secrets::new(), - generation, - openbot_env::Ports::default(), - None, - )); - std::fs::write(fixture.host.root.join("worker/fail"), "").unwrap(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(70); - loop { - if shell - .last_failure - .lock() - .unwrap() - .as_ref() - .is_some_and(|p| { - p.said.contains("(worker)") && p.said.contains("could not be started again") - }) - && window.url().unwrap().as_str() == setup - { - break; + .expect("path") + .to_string(); + let headers = lines + .filter(|line| !line.trim().is_empty()) + .map(|line| line.to_ascii_lowercase()) + .collect(); + let body = + String::from_utf8_lossy(&request[header_end..header_end + content_length]) + .to_string(); + sender + .send(TestRequest { + path, + headers, + body, + }) + .expect("send request"); + stream + .write_all(response.as_bytes()) + .expect("write response"); + }); + Self { + url, + received, + done: Some(done), } - assert!( - std::time::Instant::now() < deadline, - "worker did not exhaust actual restart budget" - ); - std::thread::sleep(std::time::Duration::from_millis(20)); } - assert!(server_capabilities_answer(3001)); - assert!(stack::recorded_server_owns_port(&fixture.host.root, 3001).unwrap()); - assert!(stack::recorded_process_owns_port(&fixture.host.root, "app", 3010).unwrap()); - let invoke = |command: &str, body: serde_json::Value| { - tauri::test::get_ipc_response( - &window, - tauri::webview::InvokeRequest { - cmd: command.into(), - callback: tauri::ipc::CallbackFn(0), - error: tauri::ipc::CallbackFn(1), - url: "tauri://localhost".parse().unwrap(), - body: tauri::ipc::InvokeBody::Json(body), - headers: Default::default(), - invoke_key: tauri::test::INVOKE_KEY.into(), - }, - ) - .map(|response| response.deserialize::().unwrap()) - }; - let result = invoke( - "already_running", - serde_json::json!({"root":fixture.host.root}), - ) - .unwrap() - .as_bool() - .unwrap(); - let children = shell.children.lock().unwrap(); - let survivors: Vec<_> = children - .iter() - .map(|(name, child)| (*name, child.id())) - .collect(); - drop(children); - let starts = std::fs::read_to_string(fixture.host.root.join("worker/starts.log")).unwrap(); - let failure = shell.last_failure.lock().unwrap().clone(); - println!( - "WORKER_RECOVERY_PROOF={}", - serde_json::json!({ - "root":fixture.host.root,"base":fixture.host.base,"generation":generation, - "setupUrl":window.url().unwrap().as_str(),"alreadyRunning":result, - "survivors":survivors,"workerStarts":starts,"failure":failure, - }) - ); - assert_eq!(starts.lines().count(), supervise::MAX_RESTARTS as usize + 1); + + fn request(mut self) -> TestRequest { + let request = self.received.recv().expect("request"); + self.done.take().expect("thread").join().expect("join"); + request + } + } + + fn test_server_port(server: &TestServer) -> u16 { + server + .url + .strip_prefix("http://127.0.0.1:") + .expect("loopback url") + .parse() + .expect("port") + } + + #[test] + fn already_running_requires_selected_root_ownership_for_loopback_answer() { + let root_a = temp_root("already-running-root-a"); + let root_b = temp_root("already-running-root-b"); + write_installed_deployment(&root_a); + write_installed_deployment(&root_b); + + let server_a = TestServer::new("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"); + let port_a = test_server_port(&server_a); assert!( - !result, - "exhausted worker was automatically adopted through generated already_running IPC" - ); - let notification = invoke("last_failure", serde_json::json!({})).unwrap(); - assert!(notification["said"].as_str().unwrap().contains("(worker)")); - assert!(invoke("last_failure", serde_json::json!({})) - .unwrap() - .is_null()); - assert!(recovery_required(&shell, &fixture.host.root)); - let show_error = invoke("show_openbot", serde_json::json!({})).unwrap_err(); - assert!(show_error["said"] - .as_str() - .unwrap() - .contains("needs recovery")); - restore_window_on(fixture.app.handle(), &openbot_env::Ports::default()); - assert_eq!(window.url().unwrap().as_str(), setup); - println!( - "WORKER_RECOVERY_COMMANDS={}", - serde_json::json!({ - "notification":notification,"generatedShowError":show_error, - "recoveryAfterNoticeRead":true,"restoreUrl":window.url().unwrap().as_str(), - }) + !already_running_on(&root_a, port_a, |root, port| { + assert_eq!(root, root_a.as_path()); + assert_eq!(port, port_a); + Ok(false) + }), + "an answering shared port without selected-root ownership must not auto-adopt root A" ); + assert_eq!(server_a.request().path, "/api/capabilities"); + + let server_b = TestServer::new("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"); + let port_b = test_server_port(&server_b); + assert!(already_running_on(&root_b, port_b, |root, port| { + assert_eq!(root, root_b.as_path()); + assert_eq!(port, port_b); + Ok(true) + })); + assert_eq!(server_b.request().path, "/api/capabilities"); + + std::fs::remove_dir_all(root_a).unwrap(); + std::fs::remove_dir_all(root_b).unwrap(); } - #[cfg(unix)] #[test] - fn recovery_transient_worker_restart_does_not_require_recovery() { - let host = InitialHostFixture::new("success"); - let app = tauri::test::mock_builder() - .manage(Shell::default()) - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .unwrap(); - let mut fixture = SupervisionFixture { - host, - app, - watcher: None, - }; - let shell = fixture.app.state::(); - let attempt = StartAttempt::begin(&shell).unwrap(); - let generation = tauri::async_runtime::block_on(start_host_processes( - &attempt, - &fixture.host.root, - &fixture.host.root.join(".logs"), - &fixture.host.bun, - &stack::Secrets::new(), - None, - |name| fixture.host.observe(name), - |_| Ok(()), - )) - .unwrap(); - drop(attempt); - fixture.watcher = Some(supervise_host_processes( - fixture.app.handle().clone(), - fixture.host.root.clone(), - fixture.host.root.join(".logs"), - fixture.host.bun.clone(), - stack::Secrets::new(), - generation, - openbot_env::Ports::default(), - None, - )); - let original = { - let mut children = shell.children.lock().unwrap(); - let (_, worker) = children - .iter_mut() - .find(|(name, _)| *name == "worker") - .unwrap(); - worker.kill().unwrap(); - worker.id() - }; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8); - let replacement = loop { - let replacement = shell - .children - .lock() - .unwrap() - .iter_mut() - .find(|(name, child)| *name == "worker" && child.id() != original) - .and_then(|(_, child)| child.try_wait().unwrap().is_none().then_some(child.id())); - if let Some(pid) = replacement { - break pid; - } - assert!( - std::time::Instant::now() < deadline, - "transient worker was not restarted" - ); - std::thread::sleep(std::time::Duration::from_millis(10)); - }; - assert!(!recovery_required(&shell, &fixture.host.root)); - assert!(shell.last_failure.lock().unwrap().is_none()); - println!( - "WORKER_TRANSIENT_PROOF={}", - serde_json::json!({"original":original,"replacement":replacement,"generation":generation,"recoveryRequired":false}) - ); + fn already_running_returns_false_when_ownership_is_unproven() { + let root = temp_root("already-running-unproven-root"); + write_installed_deployment(&root); + let server = TestServer::new("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"); + let port = test_server_port(&server); + assert!(!already_running_on(&root, port, |_, _| { + Err(Problem::with("ownership unavailable", "synthetic failure")) + })); + assert_eq!(server.request().path, "/api/capabilities"); + std::fs::remove_dir_all(root).unwrap(); } - #[cfg(unix)] #[test] - fn recovery_ready_start_and_completed_stop_resolve_condition() { - let host = InitialHostFixture::new("success"); + fn recovery_is_resolved_only_by_a_completed_stop() { + let root = temp_root("recovery-completed-stop"); let shell = Shell::default(); - mark_recovery_required(&shell, &host.root, 0); - *shell.last_failure.lock().unwrap() = Some(Problem::plain("worker exhausted")); - assert!(recovery_required(&shell, &host.root)); - assert!(!recovery_required(&shell, &host.base.join("other"))); - let attempt = StartAttempt::begin(&shell).unwrap(); - tauri::async_runtime::block_on(start_host_processes( - &attempt, - &host.root, - &host.root.join(".logs"), - &host.bun, - &stack::Secrets::new(), - None, - |name| host.observe(name), - |_| Ok(()), - )) - .unwrap(); - drop(attempt); - assert!(!recovery_required(&shell, &host.root)); - assert!(shell.last_failure.lock().unwrap().is_none()); - mark_recovery_required(&shell, &host.root, 0); - assert!( - stop_everything_with(&shell, &host.root, stack::stop_processes_under, |_| Err( - "synthetic container cleanup refusal".into() - )) - .is_err() - ); + mark_recovery_required(&shell, &root, 0); + *shell.last_failure.lock().unwrap() = Some(Problem::plain("server refused")); + assert!(recovery_required(&shell, &root)); + assert!(!recovery_required(&shell, &root.join("other"))); + assert!(stop_everything_with( + &shell, + &root, + |_| Ok(0), + |_| Err("synthetic container cleanup refusal".into()) + ) + .is_err()); assert!( - recovery_required(&shell, &host.root), + recovery_required(&shell, &root), "failed Stop cannot resolve recovery" ); - stop_everything_with(&shell, &host.root, stack::stop_processes_under, |_| Ok(())).unwrap(); - assert!(!recovery_required(&shell, &host.root)); - assert!(host.alive().is_empty()); + stop_everything_with(&shell, &root, |_| Ok(0), |_| Ok(())).unwrap(); + assert!(!recovery_required(&shell, &root)); + assert!(shell.last_failure.lock().unwrap().is_none()); } #[test] @@ -10083,7 +13717,7 @@ fn main() { ); assert!(recovery_required(&shell, &f.owned)); assert!( - owned_app_url(&f.owned, &f.ports).is_some(), + owned_app_url(&shell, &f.owned, &f.ports).is_some(), "survivors must still answer" ); assert!(show_openbot_on(app.handle().clone(), &f.ports).is_err()); @@ -10113,7 +13747,7 @@ fn main() { let shell = app.state::(); let window = app.get_webview_window("main").unwrap(); assert!( - owned_app_url(&f.owned, &f.ports).is_some(), + owned_app_url(&shell, &f.owned, &f.ports).is_some(), "survivors must still answer before restore" ); assert!(!recovery_required(&shell, &f.owned)); @@ -10169,257 +13803,61 @@ fn main() { } #[test] - fn recovery_publication_wins_over_restore_already_probing() { - let f = RestoreFixture::new(); - let app = f.app(&f.owned, "tauri://localhost/recovery"); - std::fs::write(f.owned.join("pause-response"), "").unwrap(); - let restoring = app.handle().clone(); - let ports = openbot_env::Ports { - server: f.ports.server, - app: f.ports.app, - ..Default::default() - }; - let restore = std::thread::spawn(move || restore_window_on(&restoring, &ports)); - let until = std::time::Instant::now() + std::time::Duration::from_secs(5); - while !f.owned.join("response-entered").exists() { - assert!( - std::time::Instant::now() < until, - "restore did not reach owned responder" - ); - std::thread::sleep(std::time::Duration::from_millis(5)); - } - // The actual probe holds startup until its navigation completes. Exhaustion publication - // uses that same lock, so it must follow the stale probe and leave setup as the destination. - assert!(app.state::().startup.try_lock().is_err()); - let recovering = app.handle().clone(); - let root = f.owned.clone(); - let recovery = std::thread::spawn(move || { - let shell = recovering.state::(); - let _startup = shell.startup.lock().unwrap(); - mark_recovery_required(&shell, &root, 0); - show_setup(recovering.clone()).unwrap(); - }); - std::fs::remove_file(f.owned.join("pause-response")).unwrap(); - restore.join().unwrap(); - recovery.join().unwrap(); - assert_eq!( - app.get_webview_window("main") - .unwrap() - .url() - .unwrap() - .as_str(), - "tauri://localhost/recovery" - ); - assert!(recovery_required(&app.state::(), &f.owned)); - } - - #[cfg(unix)] - #[test] - fn failed_retry_after_server_exhaustion_keeps_survivor_supervised() { - failed_retry_keeps_survivor_supervised("server"); - } - - #[cfg(unix)] - #[test] - fn failed_retry_after_app_exhaustion_keeps_survivor_supervised() { - failed_retry_keeps_survivor_supervised("app"); - } - - #[cfg(unix)] - fn initial_host_case(mode: &'static str) { - let fixture = InitialHostFixture::new(mode); - let shell = Shell::default(); - let attempt = StartAttempt::begin(&shell).unwrap(); - let result = tauri::async_runtime::block_on(start_host_processes( - &attempt, - &fixture.root, - &fixture.root.join(".logs"), - &fixture.bun, - &stack::Secrets::new(), - None, - |name| { - fixture.observe(name); - if mode == "cleanup-refuses" { - // A real filesystem failure prevents both the second spawn and verification - // of the first child's deployment. No cleanup failure is mocked away. - std::fs::remove_dir_all(&fixture.base).unwrap(); - std::fs::write(&fixture.base, b"blocked fixture parent").unwrap(); - } - }, - move |_| match mode { - "wait-fails" => Err("synthetic readiness failure".into()), - "wait-panics" => panic!("synthetic readiness task panic"), - "success" => Ok(()), - _ => panic!("readiness must not run after a spawn failure"), - }, - )); - let alive = fixture.alive(); - eprintln!( - "{}", - serde_json::json!({ - "initialHostCase": mode, - "spawned": fixture.pids.borrow().len(), - "aliveAfterStart": alive, - "heldAfterStart": shell.children.lock().unwrap().len(), - "error": result.as_ref().err().map(|problem| &problem.said), - }) - ); - if mode == "success" { - assert!(result.is_ok(), "{result:?}"); - assert_eq!(alive.len(), 3); - assert_eq!(stack::recorded_host_pids(&fixture.root).unwrap().len(), 3); - } else { - let problem = result.unwrap_err(); - let expected = match mode { - "first-fails" => "could not start server:", - "second-fails" | "cleanup-refuses" => "could not start app:", - "wait-fails" => "synthetic readiness failure", - "wait-panics" => "the wait did not run:", - _ => unreachable!(), - }; - if matches!(mode, "wait-fails" | "wait-panics") { - assert!( - problem.said.contains("could not finish starting"), - "{problem:?}" - ); - assert!( - problem - .detail - .as_deref() - .unwrap_or_default() - .starts_with(expected), - "{problem:?}" - ); - } else { - assert!(problem.said.starts_with(expected), "{problem:?}"); - } - if mode == "cleanup-refuses" { - assert_eq!(alive.len(), 1); - assert_eq!(shell.children.lock().unwrap().len(), 1); - assert_eq!(shell.root.lock().unwrap().as_ref(), Some(&fixture.root)); - assert!( - problem.detail.is_some(), - "cleanup failure must remain visible" - ); - std::fs::remove_file(&fixture.base).unwrap(); - std::fs::create_dir_all(&fixture.root).unwrap(); - } else { - assert!( - alive.is_empty(), - "failed Start left owned children alive: {alive:?}" - ); - assert!(shell.children.lock().unwrap().is_empty()); - assert!(shell.root.lock().unwrap().is_none()); - } - } - if mode == "success" || mode == "cleanup-refuses" { - retire_host_processes(&shell, &fixture.root, stack::stop_processes_under).unwrap(); - assert!(fixture.alive().is_empty()); - assert!(shell.children.lock().unwrap().is_empty()); - assert!(shell.root.lock().unwrap().is_none()); - } - } - - #[cfg(unix)] - #[test] - fn initial_start_stopped_during_readiness_cannot_publish_and_cleans_real_children() { - use std::sync::{atomic::Ordering::SeqCst, Arc, Barrier}; - let fixture = InitialHostFixture::new("success"); - let shell = Arc::new(Shell::default()); - let entered = Arc::new(Barrier::new(2)); - let release = Arc::new(Barrier::new(2)); - let starter = { - let (shell, root, bun, entered, release) = ( - shell.clone(), - fixture.root.clone(), - fixture.bun.clone(), - entered.clone(), - release.clone(), - ); - std::thread::spawn(move || { - let attempt = StartAttempt::begin(&shell).unwrap(); - tauri::async_runtime::block_on(start_host_processes( - &attempt, - &root, - &root.join(".logs"), - &bun, - &stack::Secrets::new(), - None, - |_| {}, - move |_| { - entered.wait(); - release.wait(); - Ok(()) - }, - )) - }) + fn recovery_publication_wins_over_restore_already_probing() { + let f = RestoreFixture::new(); + let app = f.app(&f.owned, "tauri://localhost/recovery"); + std::fs::write(f.owned.join("pause-response"), "").unwrap(); + let restoring = app.handle().clone(); + let ports = openbot_env::Ports { + server: f.ports.server, + app: f.ports.app, + ..Default::default() }; - entered.wait(); - for process in stack::host_processes(false) { - fixture.observe(process.cwd); + let restore = std::thread::spawn(move || restore_window_on(&restoring, &ports)); + let until = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !f.owned.join("response-entered").exists() { + assert!( + std::time::Instant::now() < until, + "restore did not reach owned responder" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); } + // The actual probe holds startup until its navigation completes. Exhaustion publication + // uses that same lock, so it must follow the stale probe and leave setup as the destination. + assert!(app.state::().startup.try_lock().is_err()); + let recovering = app.handle().clone(); + let root = f.owned.clone(); + let recovery = std::thread::spawn(move || { + let shell = recovering.state::(); + let _startup = shell.startup.lock().unwrap(); + mark_recovery_required(&shell, &root, 0); + show_setup(recovering.clone()).unwrap(); + }); + std::fs::remove_file(f.owned.join("pause-response")).unwrap(); + restore.join().unwrap(); + recovery.join().unwrap(); assert_eq!( - stack::recorded_host_pids(&fixture.root).unwrap().len(), - stack::host_processes(false).count() - ); - stop_everything_with(&shell, &fixture.root, stack::stop_processes_under, |_| { - Ok(()) - }) - .unwrap(); - let stopped_generation = shell.generation.load(SeqCst); - assert!(shell.children.lock().unwrap().is_empty()); - assert!(shell.root.lock().unwrap().is_none()); - release.wait(); - let result = starter.join().unwrap(); - let published = shell.children.lock().unwrap().len(); - let active_root = shell.root.lock().unwrap().clone(); - let completed_generation = shell.generation.load(SeqCst); - let alive = fixture.alive(); - // The old-code run must also leave no fixture processes behind. - retire_host_processes(&shell, &fixture.root, stack::stop_processes_under).unwrap(); - eprintln!( - "{}", - serde_json::json!({ - "stoppedGeneration": stopped_generation, - "completedGeneration": completed_generation, - "publishedAfterStop": published, - "aliveAfterStartFinished": alive, - "result": result.as_ref().err().map(|problem| &problem.said), - }) - ); - assert!( - result.is_err(), - "Start succeeded after Stop completed: {result:?}" - ); - assert_eq!(completed_generation, stopped_generation); - assert_eq!(published, 0); - assert!(active_root.is_none()); - assert!( - alive.is_empty(), - "cancelled Start leaked its children: {alive:?}" + app.get_webview_window("main") + .unwrap() + .url() + .unwrap() + .as_str(), + "tauri://localhost/recovery" ); + assert!(recovery_required(&app.state::(), &f.owned)); } #[test] - fn initial_start_cancelled_during_preparation_never_launches_hosts() { + fn initial_start_cancelled_during_preparation_cannot_take_the_startup_lock() { let shell = Shell::default(); let attempt = StartAttempt::begin(&shell).unwrap(); // Deployment/download or dependency preparation returns after Stop has completed. stop_everything_with(&shell, Path::new("unused-root"), |_| Ok(0), |_| Ok(())).unwrap(); - let problem = tauri::async_runtime::block_on(start_host_processes( - &attempt, - Path::new("unused-root"), - Path::new("unused-logs"), - Path::new("must-not-be-launched"), - &stack::Secrets::new(), - None, - |_| panic!("cancelled preparation launched a host"), - |_| panic!("cancelled preparation reached readiness"), - )) - .unwrap_err(); + let problem = attempt + .lock_current() + .expect_err("a cancelled Start resumed a side effect"); assert_eq!(problem.said, StartAttempt::cancelled().said); assert!(shell.root.lock().unwrap().is_none()); - assert!(shell.children.lock().unwrap().is_empty()); assert!( StartAttempt::begin(&shell).is_err(), "cancelled attempt is still unwinding" @@ -10487,283 +13925,6 @@ fn main() { } } - #[cfg(unix)] - #[test] - fn initial_host_second_spawn_failure_cleans_the_real_first_child() { - initial_host_case("second-fails"); - } - - #[cfg(unix)] - #[test] - fn initial_host_first_spawn_failure_never_waits_or_publishes() { - initial_host_case("first-fails"); - } - - #[cfg(unix)] - #[test] - fn initial_host_success_records_then_stops_all_children() { - initial_host_case("success"); - } - - #[cfg(unix)] - #[test] - fn initial_host_readiness_failure_preserves_error_and_cleans_children() { - initial_host_case("wait-fails"); - } - - #[cfg(unix)] - #[test] - fn initial_host_wait_panic_keeps_children_available_for_cleanup() { - initial_host_case("wait-panics"); - } - - #[cfg(unix)] - #[test] - fn initial_host_cleanup_refusal_keeps_original_error_and_stop_ownership() { - initial_host_case("cleanup-refuses"); - } - - #[cfg(unix)] - #[test] - fn failed_host_recording_retires_children_and_preserves_recording_failure() { - let root = temp_root("failed-recording-lifecycle"); - std::fs::create_dir_all(&root).unwrap(); - let worker = std::process::Command::new("/bin/sleep") - .arg("60") - .current_dir(&root) - .spawn() - .unwrap(); - let children = vec![("bogus", worker)]; - let shell = Shell::default(); - let attempt = StartAttempt::begin(&shell).unwrap(); - let result = finish_host_start(&attempt, &root, children, Ok(())).unwrap_err(); - assert_eq!( - result.said, - "OpenBot could not verify its host process ownership." - ); - assert!( - result - .detail - .as_deref() - .is_some_and(|detail| detail.contains("invalid host launch bogus")), - "{result:?}" - ); - assert!(shell.children.lock().unwrap().is_empty()); - assert!(shell.root.lock().unwrap().is_none()); - assert_eq!( - shell.generation.load(std::sync::atomic::Ordering::SeqCst), - 1 - ); - assert!( - !restart_host_process_with(&shell, &root, "worker", 0, || panic!( - "failed recording attempt restarted" - )) - .unwrap() - ); - std::fs::remove_dir_all(root).unwrap(); - } - - #[cfg(unix)] - #[test] - fn failed_host_recording_keeps_root_and_handles_when_forced_cleanup_fails() { - let root = temp_root("failed-recording-stubborn-child"); - std::fs::create_dir_all(&root).unwrap(); - let child = std::process::Command::new("/bin/sleep") - .arg("60") - .current_dir(&root) - .spawn() - .unwrap(); - let pid = child.id(); - let mut children = vec![("server", child)]; - let shell = Shell::default(); - *shell.root.lock().unwrap() = Some(root.clone()); - let result = cleanup_after_host_recording_failure( - &shell, - &root, - &mut children, - Problem::with("recording failed", "recording detail"), - |_, _| { - Err(Problem::with( - "normal cleanup failed", - "normal cleanup detail", - )) - }, - |_| { - Err(Problem::with( - "forced cleanup failed", - "forced cleanup detail", - )) - }, - ) - .unwrap_err(); - - assert_eq!(result.said, "recording failed"); - let detail = result.detail.as_deref().unwrap_or_default(); - assert!(detail.contains("recording detail"), "{detail}"); - assert!(detail.contains("normal cleanup detail"), "{detail}"); - assert!(detail.contains("forced cleanup detail"), "{detail}"); - assert_eq!(shell.root.lock().unwrap().as_deref(), Some(root.as_path())); - assert_eq!(children.len(), 1); - assert_eq!(children[0].1.id(), pid); - for (_, child) in children.iter_mut() { - let _ = child.kill(); - let _ = child.wait(); - } - std::fs::remove_dir_all(root).unwrap(); - } - - #[cfg(unix)] - #[test] - fn failed_host_recording_clears_root_and_handles_when_forced_cleanup_succeeds() { - let root = temp_root("failed-recording-forced-cleanup"); - std::fs::create_dir_all(&root).unwrap(); - let child = std::process::Command::new("/bin/sleep") - .arg("60") - .current_dir(&root) - .spawn() - .unwrap(); - let mut children = vec![("server", child)]; - let shell = Shell::default(); - *shell.root.lock().unwrap() = Some(root.clone()); - let result = cleanup_after_host_recording_failure( - &shell, - &root, - &mut children, - Problem::with("recording failed", "recording detail"), - |_, _| { - Err(Problem::with( - "normal cleanup failed", - "normal cleanup detail", - )) - }, - |children| { - for (_, child) in children.iter_mut() { - let _ = child.kill(); - let _ = child.wait(); - } - children.clear(); - Ok(()) - }, - ) - .unwrap_err(); - - assert_eq!(result.said, "recording failed"); - let detail = result.detail.as_deref().unwrap_or_default(); - assert!(detail.contains("recording detail"), "{detail}"); - assert!(detail.contains("normal cleanup detail"), "{detail}"); - assert!(!detail.contains("forced cleanup"), "{detail}"); - assert!(shell.root.lock().unwrap().is_none()); - assert!(children.is_empty()); - std::fs::remove_dir_all(root).unwrap(); - } - - #[cfg(unix)] - #[test] - fn failed_readiness_retires_children_and_preserves_original_failure() { - let root = temp_root("failed-readiness-lifecycle"); - std::fs::create_dir_all(&root).unwrap(); - let mut failed = std::process::Command::new("/bin/sh") - .args(["-c", "exit 71"]) - .spawn() - .unwrap(); - failed.wait().unwrap(); - let worker = std::process::Command::new("/bin/sleep") - .arg("60") - .current_dir(&root) - .spawn() - .unwrap(); - let mut children = vec![("server", failed), ("worker", worker)]; - let original = stack::wait_until_answering( - &mut children, - &root, - &stack::Ready { api: 0, app: 0 }, - std::time::Duration::from_secs(1), - ) - .unwrap_err(); - let shell = Shell::default(); - let attempt = StartAttempt::begin(&shell).unwrap(); - let result = - finish_host_start(&attempt, &root, children, Err(original.clone())).unwrap_err(); - assert_eq!(result.said, original); - assert!(result.detail.is_none()); - assert!(shell.children.lock().unwrap().is_empty()); - assert!(shell.root.lock().unwrap().is_none()); - assert_eq!( - shell.generation.load(std::sync::atomic::Ordering::SeqCst), - 1 - ); - assert!( - !restart_host_process_with(&shell, &root, "server", 0, || panic!( - "failed attempt restarted" - )) - .unwrap() - ); - std::fs::remove_dir_all(root).unwrap(); - } - - #[cfg(unix)] - #[test] - fn stop_serializes_with_a_restart_already_inside_spawn() { - use std::sync::{atomic::Ordering::SeqCst, Arc, Barrier}; - let root = temp_root("restart-stop-barrier"); - std::fs::create_dir_all(&root).unwrap(); - let shell = Arc::new(Shell::default()); - *shell.root.lock().unwrap() = Some(root.clone()); - shell.generation.store(1, SeqCst); - let entered = Arc::new(Barrier::new(2)); - let release = Arc::new(Barrier::new(2)); - let restart = { - let (shell, root, entered, release) = ( - shell.clone(), - root.clone(), - entered.clone(), - release.clone(), - ); - std::thread::spawn(move || { - restart_host_process_with(&shell, &root, "server", 1, || { - entered.wait(); - release.wait(); - std::process::Command::new("/bin/sleep") - .arg("60") - .current_dir(&root) - .spawn() - }) - }) - }; - entered.wait(); - let (sent, completed) = std::sync::mpsc::channel(); - let stop = { - let (shell, root) = (shell.clone(), root.clone()); - std::thread::spawn(move || { - let result = - stop_everything_with(&shell, &root, stack::stop_processes_under, |_| Ok(())); - sent.send(()).unwrap(); - result - }) - }; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while shell.generation.load(SeqCst) == 1 { - assert!(std::time::Instant::now() < deadline); - std::thread::yield_now(); - } - assert!(matches!( - completed.try_recv(), - Err(std::sync::mpsc::TryRecvError::Empty) - )); - release.wait(); - assert!(!restart.join().unwrap().unwrap()); - stop.join().unwrap().unwrap(); - assert!(shell.children.lock().unwrap().is_empty()); - assert!(shell.root.lock().unwrap().is_none()); - assert!( - !restart_host_process_with(&shell, &root, "server", 1, || panic!( - "retired restart spawned" - )) - .unwrap() - ); - std::fs::remove_dir_all(root).unwrap(); - } - #[test] fn failed_retry_cleanup_retires_generation_and_retains_selected_root() { let app = tauri::test::mock_builder() @@ -10806,12 +13967,31 @@ fn main() { 5 ); assert_eq!(shell.root.lock().unwrap().as_ref(), Some(&root)); - assert!( - !restart_host_process_with(&shell, &root, "server", 4, || panic!( - "old generation resumed" - )) - .unwrap() - ); + } + + /// Which roots' `server` containers publish which host ports, as far as the code under test + /// can tell. Keyed by root, and every test root is its own temporary folder, so tests running + /// side by side cannot see each other's. + static PUBLISHED_BY_SERVER: Mutex)>> = Mutex::new(Vec::new()); + + pub(super) fn published_by_server(root: &Path, ports: &[u16]) -> bool { + PUBLISHED_BY_SERVER + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .any(|(published, by)| { + published == root && !ports.is_empty() && ports.iter().all(|port| by.contains(port)) + }) + } + + fn publish_from_server(root: &Path, ports: &[u16]) { + let mut published = PUBLISHED_BY_SERVER + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + published.retain(|(published, _)| published != root); + if !ports.is_empty() { + published.push((root.to_path_buf(), ports.to_vec())); + } } fn temp_root(name: &str) -> PathBuf { @@ -10937,30 +14117,26 @@ fn main() { assert!( lines.lines().any(|line| line == format!( - "{}\tcompose -f docker-compose.yml --profile harness down", + "{}\tcompose -f docker-compose.yml --profile harness --profile openbot down", root.display() )), "{lines}" ); } - /// Real loopback responder in a separate process, so root/PID ownership checks use the same - /// OS inventory as production. The Tauri mock replaces only the window, never the HTTP/PID path. + /// Real loopback responders in separate processes, so the HTTP probes are the production ones. + /// The Tauri mock replaces only the window, and `publish_from_server` only the engine's word on + /// which root's `server` container publishes the two ports. struct RestoreFixture { base: PathBuf, selected: PathBuf, owned: PathBuf, child: std::process::Child, app_child: std::process::Child, - app_descendant_pid: Option, ports: openbot_env::Ports, } impl RestoreFixture { fn new() -> Self { - Self::with_app_descendant(false) - } - - fn with_app_descendant(descendant: bool) -> Self { let base = temp_root("restore-owned-loopback"); let selected = base.join("selected"); let owned = base.join("owned"); @@ -10987,19 +14163,6 @@ fn serve(listener: TcpListener) { } } fn main() { - let args: Vec = std::env::args().collect(); - if args.get(1).map(String::as_str) == Some("--parent") { - let mut child = std::process::Command::new(std::env::current_exe().unwrap()) - .arg("--record-pid").arg(&args[2]).stdin(std::process::Stdio::null()).spawn().unwrap(); - let mut stop = [0; 1]; - let _ = std::io::stdin().read(&mut stop); - let _ = child.kill(); - let _ = child.wait(); - return; - } - if args.get(1).map(String::as_str) == Some("--record-pid") { - std::fs::write(&args[2], std::process::id().to_string()).unwrap(); - } let api = TcpListener::bind("127.0.0.1:0").unwrap(); let app = TcpListener::bind("127.0.0.1:0").unwrap(); println!("{} {}", api.local_addr().unwrap().port(), app.local_addr().unwrap().port()); @@ -11029,13 +14192,7 @@ fn main() { .split_whitespace() .map(|n| n.parse().unwrap()) .collect(); - let mut app_command = std::process::Command::new(&binary); - let descendant_file = base.join("app-listener.pid"); - if descendant { - app_command.arg("--parent").arg(&descendant_file); - } - let mut app_child = app_command - .stdin(std::process::Stdio::piped()) + let mut app_child = std::process::Command::new(&binary) .current_dir(&owned) .stdout(std::process::Stdio::piped()) .spawn() @@ -11050,33 +14207,19 @@ fn main() { .split_whitespace() .map(|n| n.parse().unwrap()) .collect(); - let app_descendant_pid = descendant.then(|| { - std::fs::read_to_string(descendant_file) - .unwrap() - .parse() - .unwrap() - }); let fixture = Self { base, selected, owned, child, app_child, - app_descendant_pid, ports: openbot_env::Ports { server: numbers[0], app: app_numbers[1], ..Default::default() }, }; - stack::record_host_processes( - &fixture.owned, - &[ - ("server", fixture.child.id()), - ("app", fixture.app_child.id()), - ], - ) - .unwrap(); + publish_from_server(&fixture.owned, &[fixture.ports.server, fixture.ports.app]); fixture } @@ -11107,13 +14250,9 @@ fn main() { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); - if self.app_descendant_pid.is_some() { - // Dropping the pipe asks the fixture launcher to kill and reap its own child. - drop(self.app_child.stdin.take()); - } else { - let _ = self.app_child.kill(); - } + let _ = self.app_child.kill(); let _ = self.app_child.wait(); + publish_from_server(&self.owned, &[]); let _ = std::fs::remove_dir_all(&self.base); } } @@ -11439,8 +14578,8 @@ fn main() { let f = RestoreFixture::new(); assert!(server_capabilities_answer(f.ports.server)); assert!(stack::app_url(f.ports.app).is_some()); - assert!(!stack::recorded_server_owns_port(&f.selected, f.ports.server).unwrap()); - assert!(stack::recorded_server_owns_port(&f.owned, f.ports.server).unwrap()); + assert!(!published_by_server(&f.selected, &[f.ports.server])); + assert!(published_by_server(&f.owned, &[f.ports.server])); for setup in ["tauri://localhost/", "http://tauri.localhost/"] { let app = f.app(&f.selected, setup); restore_window_on(app.handle(), &f.ports); @@ -11481,7 +14620,7 @@ fn main() { app.state::().selected_root.lock().unwrap().as_ref(), Some(&f.owned) ); - assert!(stack::recorded_server_owns_port(&f.owned, f.ports.server).unwrap()); + assert!(published_by_server(&f.owned, &[f.ports.server])); } #[test] @@ -11560,7 +14699,8 @@ fn main() { #[test] fn restore_window_unproven_identity_shows_setup_without_losing_selected_root() { let f = RestoreFixture::new(); - std::fs::write(f.owned.join(".logs/host-pids.json"), "not-json").unwrap(); + // Answering on both ports, but the engine does not say this root's server publishes them. + publish_from_server(&f.owned, &[]); let app = f.app(&f.owned, "tauri://localhost/"); restore_window_on(app.handle(), &f.ports); assert_eq!( @@ -11583,16 +14723,16 @@ fn main() { #[test] fn app_adoption_and_restore_refuse_foreign_app_with_owned_api_still_running() { let f = RestoreFixture::new(); - stack::record_host_processes(&f.owned, &[("server", f.child.id())]).unwrap(); - stack::record_host_processes(&f.selected, &[("app", f.app_child.id())]).unwrap(); + // This root's server publishes the API port; something else has the app port. + publish_from_server(&f.owned, &[f.ports.server]); assert!(already_running_on( &f.owned, f.ports.server, - stack::recorded_server_owns_port + |root, port| { server_container_publishes(None, root, &[port]) } )); assert!(stack::app_url(f.ports.app).is_some()); - let initial_adoption = already_running_at(&f.owned, &f.ports); let app = f.app(&f.owned, "tauri://localhost/"); + let initial_adoption = already_running_at(&app.state::(), &f.owned, &f.ports); let shown = show_openbot_on(app.handle().clone(), &f.ports); restore_window_on(app.handle(), &f.ports); let destination = app.get_webview_window("main").unwrap().url().unwrap(); @@ -11601,25 +14741,23 @@ fn main() { } #[test] - fn app_adoption_and_restore_allow_owned_app_direct_and_launcher_descendant() { - for descendant in [false, true] { - let f = RestoreFixture::with_app_descendant(descendant); - assert_ne!(f.child.id(), f.app_child.id()); - if descendant { - assert_ne!(f.app_descendant_pid.unwrap(), f.app_child.id()); - } - assert!(already_running_at(&f.owned, &f.ports)); - let app = f.app(&f.owned, "tauri://localhost/"); - show_openbot_on(app.handle().clone(), &f.ports).unwrap(); - restore_window_on(app.handle(), &f.ports); - assert_eq!( - app.get_webview_window("main") - .unwrap() - .url() - .unwrap() - .as_str(), - format!("http://127.0.0.1:{}/", f.ports.app) - ); - } + fn app_adoption_and_restore_allow_the_app_its_own_server_container_publishes() { + let f = RestoreFixture::new(); + let app = f.app(&f.owned, "tauri://localhost/"); + assert!(already_running_at( + &app.state::(), + &f.owned, + &f.ports + )); + show_openbot_on(app.handle().clone(), &f.ports).unwrap(); + restore_window_on(app.handle(), &f.ports); + assert_eq!( + app.get_webview_window("main") + .unwrap() + .url() + .unwrap() + .as_str(), + format!("http://127.0.0.1:{}/", f.ports.app) + ); } } diff --git a/desktop/src-tauri/src/preparation.rs b/desktop/src-tauri/src/preparation.rs index 364699140a..6dee81c47e 100644 --- a/desktop/src-tauri/src/preparation.rs +++ b/desktop/src-tauri/src/preparation.rs @@ -216,17 +216,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> { @@ -465,11 +475,16 @@ fn complete_with( } /// `selection=None` is the auth check, which has no authority to change the Bot selection. +/// +/// No runtime and no packages: OpenBot's server and worker are containers now, so what Start needs +/// from the installation is the deployment it laid out and the images it downloaded. Installation +/// still records the host packages, for the self-hosted Intelligence sign-in that runs on the host, +/// and that sign-in checks them itself. pub fn require( root: &Path, selection: Option<&Option>, address: &Address, -) -> Result { +) -> Result<(), Problem> { require_with(root, selection, |images| require_images(address, images)) } @@ -477,7 +492,7 @@ fn require_with( root: &Path, selection: Option<&Option>, verify: impl FnOnce(&[String]) -> Result<(), Problem>, -) -> Result { +) -> Result<(), Problem> { let record: Prepared = serde_json::from_slice( &std::fs::read(root.join(FILE)).map_err(|e| required(e.to_string()))?, ) @@ -492,6 +507,12 @@ fn require_with( if let Some(problem) = stack::deployment_problem(root) { return Err(required(problem)); } + // Install moves this one to the latest release; Start cannot. See `deployment_release`. + if deployment::predates_containers(root) { + return Err(required( + "The deployment is a release from before OpenBot ran its server in a container.", + )); + } // A deployment laid out by an app that copied less still matches its own fingerprint. Install // lays it out again; Start cannot. if deployment::installed(root) @@ -501,9 +522,7 @@ fn require_with( "The deployment was laid out by an earlier OpenBot and is missing files this version runs.", )); } - let bun = dependencies_ready(root)?; - verify(&record.images)?; - Ok(bun) + verify(&record.images) } /// Public Compose overrides suffice to identify and download software before authentication. @@ -524,6 +543,9 @@ pub fn image_settings( mod tests { use super::*; + /// A Compose file from a release that runs the server and worker as services. + const COMPOSE: &str = "services:\n server: {}\n worker: {}\n"; + #[test] fn the_saved_engine_outlives_a_new_preparation() { let root = crate::test_support::temp_root("preparation-saved-engine"); @@ -594,7 +616,7 @@ mod tests { ) .unwrap(); std::fs::write(root.join("bun.lock"), "synthetic-lock").unwrap(); - std::fs::write(root.join("docker-compose.yml"), "services: {}\n").unwrap(); + std::fs::write(root.join("docker-compose.yml"), COMPOSE).unwrap(); std::fs::write(root.join("container-images.json"), "{}").unwrap(); deployment::record(&root, "v-test").unwrap(); let source = root.join("runtime.rs"); @@ -723,7 +745,23 @@ fn main() { } #[test] - fn changed_bot_lockfile_or_deleted_package_requires_installation() { + fn a_release_whose_server_ran_on_the_host_must_be_installed_again_before_start() { + let f = Fixture::new(); + std::fs::write( + f.root.join("docker-compose.yml"), + "services:\n postgres: {}\n supervisor: {}\n", + ) + .unwrap(); + f.prepared(None); + let problem = require_with(&f.root, Some(&None), |_| Ok(())).unwrap_err(); + assert!( + format!("{problem:?}").contains("before OpenBot ran its server in a container"), + "{problem:?}" + ); + } + + #[test] + fn changed_bot_requires_installation_but_host_packages_do_not() { let f = Fixture::new(); let chosen = Some(HarnessChoice { id: "langgraph".into(), @@ -735,11 +773,18 @@ fn main() { "wrong selection must stop first" )) .is_err()); + // Start runs containers, so the host's packages and runtime are not its business. std::fs::remove_file(f.root.join("node_modules/synthetic-package/package.json")).unwrap(); - assert!(require_with(&f.root, Some(&chosen), |_| Ok(())).is_err()); - f.dependencies(); - f.prepared(chosen.as_ref()); - std::fs::write(f.root.join("bun.lock"), "changed-lock").unwrap(); + std::fs::write(f.root.join("runtime-version"), "1.2.15").unwrap(); + require_with(&f.root, Some(&chosen), |_| Ok(())).unwrap(); + // The images still are. + assert!(require_with(&f.root, Some(&chosen), |_| Err(required("missing image"))).is_err()); + // And the deployment Compose runs. + std::fs::write( + f.root.join("docker-compose.yml"), + format!("{COMPOSE} x: {{}}\n"), + ) + .unwrap(); assert!(require_with(&f.root, Some(&chosen), |_| Ok(())).is_err()); } @@ -810,11 +855,14 @@ fn main() { .expect_err("Start must not run a browser with no Recorder to load"); assert!(gone.said.contains("Finish installing"), "{gone:?}"); - // A changed lockfile for the computer is a changed installation, as the root's is. + // A changed lockfile for the computer is a changed installation, as the root's is. Start no + // longer refuses over it: the browser's next Start installs again rather than running the + // old packages (`install_dependencies_if_needed`). place_recorder_with(&f.root, COMPUTER, &mut image.engine()).unwrap(); complete_with(&f.root, None, images(), |_| Ok(())).unwrap(); + assert!(dependencies_ready(&f.root).is_ok()); std::fs::write(f.root.join("agent-computer/bun.lock"), "changed-lock").unwrap(); - assert!(require_with(&f.root, None, |_| Ok(())).is_err()); + assert!(dependencies_ready(&f.root).is_err()); } /// Releases before the browser have no Recorder to put in place and no computer to install for. diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index 038a0a3383..ac395b87d1 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -1,18 +1,16 @@ -//! Raising the stack: the Compose services, then the three processes that are not containers. +//! Raising the stack: the Compose services, and the one process that is not a container. //! -//! `docker-compose.yml` has no `app`, `server` or `worker` service, and the root `Dockerfile` leaves -//! out the supervisor because it needs a socket no serverless platform grants. So the shape is not a -//! choice: containers for postgres, the supervisor, `agent-computer`, the Bots and a one-shot -//! `migrate`, and three host processes for the rest. `scripts/start.sh` does exactly this for a -//! developer. This does it for somebody who double-clicked. +//! Compose runs all of OpenBot: postgres, the supervisor, `agent-computer`, the Bots, a one-shot +//! `migrate`, and the published image's `server` and `worker` under the `openbot` profile. Docker's +//! restart policy keeps them up. //! -//! A fourth host process runs where this machine can run it: the person's own browser, the local -//! Chrome computer `scripts/start-local-chrome-computer.ts` starts for a developer. See -//! `local_browser` for when, and why it is not a container. +//! One host process runs where this machine can run it: the person's own browser, the local Chrome +//! computer `scripts/start-local-chrome-computer.ts` starts for a developer. It opens a window on +//! their desktop, so it cannot be a container; see `local_browser`. The shell is its restart +//! policy. //! -//! The shell also becomes the restart policy those three do not have. `worker/src/index.ts` names -//! the gap itself: "this process has no restart policy watching it; it is somebody's laptop, left -//! running". +//! [`HOST_PROCESSES`] still names the server, the app and the worker, which earlier versions ran on +//! the host, so a Start or a Stop can find and end what one of those left running. use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -26,7 +24,24 @@ use crate::problem::Problem; /// The services Compose owns. `migrate` is deliberately absent: it is run once, to completion, /// rather than raised, and treating it as a long-lived service makes it look like a crash loop. -const SERVICES: [&str; 3] = ["postgres", "supervisor", "agent-computer"]; +/// +/// `server` and `worker` are OpenBot itself, from the published server image: the API, the app it +/// serves, and the routines sweep. They used to be Bun processes this app started on the host after +/// downloading Bun and running `bun install`, which on a fresh Windows machine took longer than +/// everything else in the install put together. Now they are containers like the rest, and the +/// restart policy that keeps them up is Docker's. +const SERVICES: [&str; 5] = [ + "postgres", + "supervisor", + "agent-computer", + "server", + "worker", +]; + +/// The Compose profile `server` and `worker` sit behind, so `scripts/start.sh`, whose server is a +/// host process, never starts a second one. Named on every command rather than only on `up`, so +/// `down` stops them and `config` describes them. +const OPENBOT_PROFILE: &str = "openbot"; /** The Bots that ship with OpenBot, which only run on an API key. @@ -141,6 +156,14 @@ pub const HOST_PROCESSES: [HostProcess; 4] = [ /// The host process that runs the person's own browser. pub const LOCAL_BROWSER: &str = "browser"; +/// The person's browser, the one host process a Start still runs. +pub fn local_browser_process() -> &'static HostProcess { + HOST_PROCESSES + .iter() + .find(|process| process.name == LOCAL_BROWSER) + .expect("HOST_PROCESSES names the person's browser") +} + /// The host processes one start runs, in order: the server, the app and the worker always, and the /// person's browser where this machine runs one. pub fn host_processes(local_browser: bool) -> impl Iterator { @@ -1273,9 +1296,20 @@ pub fn down(engine: &Address, root: &Path) -> Result<(), String> { * * Named unconditionally rather than only when a harness was picked: this has to stop what an * earlier run started, and whether that run picked one is not something a Stop can know. + * + * `openbot` for the same reason: `server` and `worker` sit behind it, and a Stop that left them + * running would leave the app answering a window that says OpenBot is stopped. */ let output = compose_command(engine, root, &Secrets::new()) - .args(["-f", "docker-compose.yml", "--profile", "harness", "down"]) + .args([ + "-f", + "docker-compose.yml", + "--profile", + "harness", + "--profile", + OPENBOT_PROFILE, + "down", + ]) .output() .map_err(|error| format!("could not stop the stack: {error}"))?; @@ -1687,6 +1721,46 @@ fn configure_local_browser( Ok(()) } +/// The last build error can precede Bun's wrapper stack, version, and exit message. +/// Bound both the file read and displayed lines while preserving that useful context. +pub fn tail_of(logs: &Path, name: &str) -> String { + use std::io::{Read, Seek, SeekFrom}; + const MAX_BYTES: u64 = 8 * 1024; + let read = || -> std::io::Result> { + let mut file = std::fs::File::open(logs.join(format!("{name}.log")))?; + let offset = file.metadata()?.len().saturating_sub(MAX_BYTES); + file.seek(SeekFrom::Start(offset))?; + let mut bytes = Vec::new(); + file.take(MAX_BYTES).read_to_end(&mut bytes)?; + // Do not show a truncated first line, which may include part of a credential. + if offset > 0 { + let first_line = bytes.iter().position(|byte| *byte == b'\n'); + bytes.drain(..first_line.map_or(bytes.len(), |index| index + 1)); + } + Ok(bytes) + }; + let bytes = match read() { + Ok(bytes) => bytes, + Err(error) => return format!("Could not read {name}.log: {error}"), + }; + let text = String::from_utf8_lossy(&bytes); + let tail: Vec<&str> = text + .lines() + .filter(|line| !line.trim().is_empty()) + .rev() + .take(40) + .collect(); + if tail.is_empty() { + return format!("No complete lines were available in the tail of {name}.log."); + } + let mut lines = tail; + lines.reverse(); + format!( + "Last from {name}.log (up to 40 lines, 8 KiB):\n{}", + lines.join("\n") + ) +} + /// Keep a replacement handle even if refreshing durable ownership fails. The caller must /// report success only after this Result succeeds; Stop still has the handle on failure. #[cfg(unix)] @@ -3292,6 +3366,93 @@ pub fn ports_we_already_publish(engine: &Address, root: &Path) -> std::collectio published_in(&listing) } +/** +Whether this deployment's own `server` container publishes every one of `ports` on the host. + +THIS IS WHAT OWNERSHIP MEANS NOW. The server and the app used to be host processes, and a port +belonged to this deployment when a process recorded in its `host-pids.json` was listening on it. +They are one container now, and the listener on the host is the engine's port forwarder, which no +pid file names. What does name it is Compose: this project's `server` row lists the host ports it +was given. Something else answering on the same port, another installation's server or a stranger, +is not in that row. + +Every service rather than `server` alone, read row by row: naming a service on `ps` makes Compose +resolve it, and `server` sits behind a profile this command would otherwise have to repeat. +*/ +pub fn server_publishes(engine: &Address, root: &Path, ports: &[u16]) -> Result { + let operation = format!("{} compose ps", engine.engine.binary()); + let output = compose_command(engine, root, &Secrets::new()) + .args(["ps", "--format", "{{.Service}}\t{{.Ports}}"]) + .output() + .map_err(|error| { + Problem::with( + "OpenBot could not inspect its Compose services.", + format!("could not run {operation}: {error}"), + ) + })?; + if !output.status.success() { + return Err(Problem::with( + "OpenBot could not inspect its Compose services.", + format!("{operation}: {}", command_said(&output.stderr)), + )); + } + Ok(server_publishes_in( + &String::from_utf8_lossy(&output.stdout), + ports, + )) +} + +/// `server_publishes` over a `{{.Service}}\t{{.Ports}}` listing. Pure, for the reason +/// `published_in` is. +pub fn server_publishes_in(listing: &str, ports: &[u16]) -> bool { + let published: std::collections::HashSet = listing + .lines() + .filter_map(|row| row.split_once('\t')) + .filter(|(service, _)| service.trim() == "server") + .flat_map(|(_, mappings)| published_in(mappings)) + .collect(); + !ports.is_empty() && ports.iter().all(|port| published.contains(port)) +} + +/** +Restart OpenBot itself, the server and the worker, and leave everything else running. + +What the Status window's Restart did to the host processes, now done by the engine that runs them: +the database and the Bots stay up, so nothing is lost and it takes seconds. The profile is named +because both services sit behind it, and Compose refuses to restart a service it cannot see. +*/ +pub fn restart_openbot(engine: &Address, root: &Path) -> Result<(), String> { + let output = compose_command(engine, root, &Secrets::new()) + .args(["--profile", OPENBOT_PROFILE, "restart", "server", "worker"]) + .output() + .map_err(|error| format!("could not restart OpenBot: {error}"))?; + if output.status.success() { + return Ok(()); + } + Err(command_said(&output.stderr)) +} + +/** +The name Compose gives this deployment's project, and so the prefix of its networks. + +The desktop runs Compose from the deployment folder with no `-p` and the file names no `name:`, so +the project is the folder's own name as Compose normalises it: lower case, with everything but +`a-z`, `0-9`, `_` and `-` dropped, then any leading `_` or `-` trimmed. Worked out here rather than +asked of Compose, because the answer is wanted before `up`, to tell the supervisor which network to +put each Bot's computer on, and it has to match what `up` is about to create. +*/ +pub fn project_name(root: &Path) -> String { + let base = root + .file_name() + .map(|name| name.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + base.chars() + .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_' || *c == '-') + .collect::() + .trim_start_matches(['_', '-']) + .to_string() +} + /** The published ports in a `compose ps` listing. @@ -3378,21 +3539,11 @@ pub fn port_already_taken_except( None } -/// Wait until the API answers, or say why it never did. -/// -/// Spawning is not starting. Each of these three can exit in the first second for a reason that has -/// nothing to do with the others, and a shell that reports "running" because it called `spawn` -/// three times is telling somebody the stack is up while nothing is listening. That is worse than -/// an error, because the next thing they do is open a page that will not load and go looking for -/// the fault in the wrong place. -/// -/// So: watch the child, and watch the port. Whichever fails first is what gets reported, with the -/// tail of the log that explains it. -/// The two things that have to answer before anybody is told the stack is up. +/// The two addresses that have to answer before anybody is told the stack is up. /// /// The API alone is not enough. The window navigates to the app, so a person told "running" who -/// then gets a blank window has been told something that is not true, and the API was answering the -/// whole time. +/// then gets a blank window has been told something that is not true. Both are the one `server` +/// container now, published on two host ports, and either mapping can be the one that is missing. pub struct Ready { pub api: u16, pub app: u16, @@ -3435,33 +3586,28 @@ pub fn app_url(port: u16) -> Option { answering_at(port, "/") } -/// Wait until the stack is genuinely usable, or say which part is not. +/// Wait until the server container answers its health check on both host ports, or say which +/// one never did. /// -/// Watches the children as well as the ports, because three processes that died leave a port -/// unanswered for the same length of time as three that are still starting, and only one of those -/// is worth waiting out. -pub fn wait_until_answering( - children: &mut [(&'static str, std::process::Child)], - logs: &Path, +/// `compose up` returns once it has asked for the container, not once the server inside it is +/// listening, and a server still running its first start leaves the port unanswered for the same +/// length of time as one that is crash-looping under its restart policy. Only an answer settles it. +/// +/// `keep_going` is asked between polls, so a Stop pressed during the wait ends it there rather than +/// after the full patience. What a stopped wait returns does not matter: the caller asks its own +/// ticket first, and the cancellation is what it reports. +pub fn wait_until_healthy( ready: &Ready, patience: std::time::Duration, + keep_going: impl Fn() -> bool, ) -> Result<(), String> { let deadline = std::time::Instant::now() + patience; let mut api_up = false; - while std::time::Instant::now() < deadline { - for (name, child) in children.iter_mut() { - if let Ok(Some(status)) = child.try_wait() { - return Err(format!( - "{name} stopped straight away ({status}). {}", - tail_of(logs, name) - )); - } - } - + while std::time::Instant::now() < deadline && keep_going() { // An earlier API success is no longer sufficient when the app becomes ready later. - api_up = answering_at(ready.api, "/api/capabilities").is_some(); - if api_up && app_url(ready.app).is_some() { + api_up = answering_at(ready.api, "/health").is_some(); + if api_up && answering_at(ready.app, "/health").is_some() { return Ok(()); } @@ -3470,56 +3616,11 @@ pub fn wait_until_answering( if api_up { return Err(format!( - "the API is answering, but the app is not answering on port {}. {}", - ready.app, - tail_of(logs, "app") + "the API is answering, but the app is not answering on port {}.", + ready.app )); } - Err(format!( - "the API is not answering on port {}. {}", - ready.api, - tail_of(logs, "server") - )) -} - -/// The last build error can precede Bun's wrapper stack, version, and exit message. -/// Bound both the file read and displayed lines while preserving that useful context. -fn tail_of(logs: &Path, name: &str) -> String { - use std::io::{Read, Seek, SeekFrom}; - const MAX_BYTES: u64 = 8 * 1024; - let read = || -> std::io::Result> { - let mut file = std::fs::File::open(logs.join(format!("{name}.log")))?; - let offset = file.metadata()?.len().saturating_sub(MAX_BYTES); - file.seek(SeekFrom::Start(offset))?; - let mut bytes = Vec::new(); - file.take(MAX_BYTES).read_to_end(&mut bytes)?; - // Do not show a truncated first line, which may include part of a credential. - if offset > 0 { - let first_line = bytes.iter().position(|byte| *byte == b'\n'); - bytes.drain(..first_line.map_or(bytes.len(), |index| index + 1)); - } - Ok(bytes) - }; - let bytes = match read() { - Ok(bytes) => bytes, - Err(error) => return format!("Could not read {name}.log: {error}"), - }; - let text = String::from_utf8_lossy(&bytes); - let tail: Vec<&str> = text - .lines() - .filter(|line| !line.trim().is_empty()) - .rev() - .take(40) - .collect(); - if tail.is_empty() { - return format!("No complete lines were available in the tail of {name}.log."); - } - let mut lines = tail; - lines.reverse(); - format!( - "Last from {name}.log (up to 40 lines, 8 KiB):\n{}", - lines.join("\n") - ) + Err(format!("the API is not answering on port {}.", ready.api)) } /// Keep a short startup headline and useful local diagnostics, with credentials removed. @@ -3662,29 +3763,6 @@ mod tests { use super::*; use crate::test_support::temp_root; - #[test] - fn startup_log_tail_retains_build_error_before_wrapper_without_loading_whole_log() { - let root = temp_root("startup-build-diagnostic"); - std::fs::create_dir_all(&root).unwrap(); - let mut log = "old output must be omitted\n".repeat(1_000); - log.push_str("error during build: Could not resolve imported module\n"); - log.push_str(&" at synthetic build frame\n".repeat(20)); - log.push_str(" at run (app/scripts/serve-or-build.ts:36:11)\n"); - log.push_str("Bun v1.2.15 (macOS arm64)\nerror: script serve exited with code 1\n"); - std::fs::write(root.join("app.log"), log).unwrap(); - - let detail = tail_of(&root, "app"); - assert!( - detail.contains("Could not resolve imported module"), - "{detail}" - ); - assert!(detail.contains("serve-or-build.ts:36:11"), "{detail}"); - assert!(!detail.contains(&"old output must be omitted\n".repeat(20))); - assert!(detail.len() < 8_500, "unbounded startup diagnostic"); - assert!(detail.lines().count() <= 42, "too many startup log lines"); - std::fs::remove_dir_all(root).unwrap(); - } - #[test] fn startup_problem_keeps_build_and_cleanup_details_but_removes_credentials() { let secrets = Secrets::from([ @@ -6190,7 +6268,8 @@ fn main() { } } } - Some("compose") if actual == ["compose", "--profile", "harness", "down"] => { + Some("compose") + if actual == ["compose", "--profile", "harness", "--profile", "openbot", "down"] => { if race { std::fs::write(root.join("supervisor.stopped"), "").unwrap(); std::fs::write(root.join("stack.down"), "").unwrap(); @@ -8077,6 +8156,29 @@ fn main() { assert!(picked.contains(&"agent-harness")); } + /// Stop has to name both profiles, or what sits behind them keeps running: the one Bot the + /// person picked, and OpenBot's own server and worker. + #[test] + fn stopping_names_the_harness_and_openbot_profiles() { + let source = include_str!("stack.rs"); + let down = &source[source.find("pub fn down(").unwrap()..]; + let down = &down[..down.find("\n}\n").unwrap()]; + assert_eq!( + down.matches(r#""--profile","#).count(), + 2, + "compose down without a profile leaves that profile's services running" + ); + assert!( + down.contains(r#""harness","#), + "agent-harness keeps running" + ); + assert!( + down.contains("OPENBOT_PROFILE,"), + "server and worker keep running" + ); + assert_eq!(OPENBOT_PROFILE, "openbot"); + } + #[test] fn the_published_ports_are_read_off_a_real_listing() { // Verbatim from `compose ps --format '{{.Ports}}'` against a running deployment. @@ -8432,7 +8534,14 @@ fn main() { let services = up(&podman, &root, true, BundledBots::none(), &Secrets::new()).unwrap(); assert_eq!( services, - ["postgres", "supervisor", "agent-computer", "agent-harness"] + [ + "postgres", + "supervisor", + "agent-computer", + "server", + "worker", + "agent-harness" + ] ); migrate(&podman, &root, &Secrets::new()).unwrap(); let log = std::fs::read_to_string(&record).unwrap(); @@ -8586,7 +8695,9 @@ fn main() { .unwrap(); let snapshot = log.find("ps --quiet").unwrap(); let computers = log.find("stop current late restarted").unwrap(); - let teardown = log.find("--profile harness down").unwrap(); + let teardown = log + .find("--profile harness --profile openbot down") + .unwrap(); assert!( config < supervisor && supervisor < snapshot @@ -8665,7 +8776,7 @@ fn main() { ); assert!( log.contains(&format!( - "{prefix}compose -f docker-compose.yml --profile harness down" + "{prefix}compose -f docker-compose.yml --profile harness --profile openbot down" )), "{log}" ); @@ -8883,7 +8994,7 @@ fn main() { } fn wait(&mut self, patience: std::time::Duration) -> Result<(), String> { - wait_until_answering(&mut self.children, &self.root, &self.ready, patience) + wait_until_healthy(&self.ready, patience, || true) } fn requests(&self, name: &str) -> String { @@ -8921,31 +9032,30 @@ fn main() { .children .iter_mut() .all(|(_, child)| child.try_wait().unwrap().is_none()); - let api_now = answering_at(fixture.ready.api, "/api/capabilities"); - let app_now = app_url(fixture.ready.app); + let api_now = answering_at(fixture.ready.api, "/health"); + let app_now = answering_at(fixture.ready.app, "/health"); eprintln!("current API readiness: result={result:?}; children_alive={live}; api_requests={api_requests:?}; api_now={api_now:?}; app_now={app_now:?}"); drop(fixture); assert!(live && api_now.is_none() && app_now.is_some()); let error = result.expect_err("a historical API success cannot satisfy readiness"); assert!(api_requests.lines().count() >= 2, "{api_requests}"); assert!(error.contains("the API is not answering"), "{error}"); - assert!(error.contains("synthetic server diagnostic"), "{error}"); } #[test] - fn readiness_accepts_currently_healthy_api_and_app() { + fn readiness_asks_the_server_health_check_on_both_ports() { let mut fixture = ReadinessFixture::new("200", "200"); let result = fixture.wait(std::time::Duration::from_secs(2)); let api_requests = fixture.requests("server"); let app_requests = fixture.requests("app"); drop(fixture); assert!(result.is_ok(), "{result:?}"); - assert!(api_requests.contains("200 GET /api/capabilities HTTP/1.1")); - assert!(app_requests.contains("200 GET / HTTP/1.1")); + assert!(api_requests.contains("200 GET /health HTTP/1.1")); + assert!(app_requests.contains("200 GET /health HTTP/1.1")); } #[test] - fn readiness_timeout_names_the_currently_unavailable_service() { + fn readiness_timeout_names_the_currently_unavailable_port() { for (api, app, missing) in [("503", "200", "server"), ("200", "503", "app")] { let mut fixture = ReadinessFixture::new(api, app); let result = fixture.wait(std::time::Duration::from_millis(100)); @@ -8961,26 +9071,52 @@ fn main() { error.contains(&format!("the {service} is not answering on port {port}")), "{error}" ); - assert!( - error.contains(&format!("synthetic {missing} diagnostic")), - "{error}" - ); } } #[test] - fn readiness_reports_child_exit_without_waiting_for_timeout() { - let mut fixture = ReadinessFixture::new("exit", "200"); + fn readiness_ends_at_once_when_the_start_is_stopped() { + // Port 1 needs privilege to bind, so nothing here will ever answer it. + let ready = Ready { api: 1, app: 1 }; let started = std::time::Instant::now(); - let result = fixture.wait(std::time::Duration::from_secs(30)); - let elapsed = started.elapsed(); - let status = fixture.children[0].1.try_wait().unwrap().unwrap(); - drop(fixture); - let error = result.unwrap_err(); - assert!(elapsed < std::time::Duration::from_secs(5), "{elapsed:?}"); - assert_eq!(status.code(), Some(17)); - assert!(error.contains("server stopped straight away"), "{error}"); - assert!(error.contains("synthetic server diagnostic"), "{error}"); + let result = wait_until_healthy(&ready, std::time::Duration::from_secs(30), || false); + assert!(result.is_err()); + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "a stopped Start sat out the readiness wait" + ); + } + + #[test] + fn project_name_is_the_folder_name_as_compose_normalises_it() { + assert_eq!(project_name(Path::new("/Users/someone/OpenBot")), "openbot"); + assert_eq!( + project_name(Path::new("/home/someone/My OpenBot!")), + "myopenbot" + ); + assert_eq!(project_name(Path::new("/srv/_x")), "x"); + assert_eq!(project_name(Path::new("/srv/-_open.bot_2-")), "openbot_2-"); + assert_eq!( + project_name(Path::new("C:/Users/someone/ÖpenBot")), + "penbot" + ); + } + + #[test] + fn only_the_server_row_can_own_the_app_and_api_ports() { + let listing = "postgres\t127.0.0.1:5432->5432/tcp\n\ + server\t127.0.0.1:3010->3001/tcp, [::1]:3010->3001/tcp, 127.0.0.1:3001->3001/tcp, [::1]:3001->3001/tcp\n\ + worker\t\n"; + assert!(server_publishes_in(listing, &[3001, 3010])); + assert!(server_publishes_in(listing, &[3001])); + // Published by the project, but not by the server: still not the app. + assert!(!server_publishes_in(listing, &[3001, 5432])); + assert!(!server_publishes_in(listing, &[3002])); + assert!(!server_publishes_in("", &[3001])); + assert!( + !server_publishes_in(listing, &[]), + "nothing asked is nothing proven" + ); } #[test] diff --git a/desktop/src-tauri/src/stop_ipc_tests.rs b/desktop/src-tauri/src/stop_ipc_tests.rs index e66a747645..017d6a9793 100644 --- a/desktop/src-tauri/src/stop_ipc_tests.rs +++ b/desktop/src-tauri/src/stop_ipc_tests.rs @@ -185,7 +185,8 @@ mod stop_ipc { let fixture = Fixture::new(); let compose = fixture.compose_barrier(); let shell = fixture.app.state::(); - let cleanup = shell.children.lock().unwrap(); + // The first lock host cleanup takes; held here, it keeps that cleanup waiting. + let cleanup = shell.host_access.lock().unwrap(); let dispatch = fixture.dispatch(); wait_until(|| shell.generation.load(SeqCst) == 1); // Always release both barriers before asserting against the old synchronous command. @@ -278,19 +279,10 @@ mod stop_ipc { pending, "Stop finished while Start still held the side-effect lock" ); - let error = tauri::async_runtime::block_on(start_host_processes( - &attempt, - &fixture.root, - &fixture.root.join(".logs"), - Path::new("must-not-launch"), - &stack::Secrets::new(), - None, - |_| panic!("cancelled Start published a child"), - |_| panic!("cancelled Start reached readiness"), - )) - .unwrap_err(); + let error = attempt + .lock_current() + .expect_err("a cancelled Start resumed a side effect"); assert_eq!(error.said, StartAttempt::cancelled().said); - assert!(shell.children.lock().unwrap().is_empty()); assert!(shell.root.lock().unwrap().is_none()); } @@ -394,7 +386,7 @@ mod stop_ipc { let shell = fixture.app.state::(); // A poisoned cleanup lock is a real panic boundary; it must reject IPC, not abandon it. std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _cleanup = shell.children.lock().unwrap(); + let _cleanup = shell.host_access.lock().unwrap(); panic!("synthetic cleanup lock poisoning"); })) .expect_err("poison the cleanup lock"); 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 598a8e0c8a..50b172ed34 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-tauri/tests/fixtures/engine.rs b/desktop/src-tauri/tests/fixtures/engine.rs index 4f8bf8c90e..ab78c91875 100644 --- a/desktop/src-tauri/tests/fixtures/engine.rs +++ b/desktop/src-tauri/tests/fixtures/engine.rs @@ -94,7 +94,7 @@ fn main() { if SCENARIO == "stop-ipc" && (joined.starts_with("ps ") || joined.contains("stop supervisor")) { return; } - if SCENARIO == "stop-ipc" && joined == "compose -f docker-compose.yml --profile harness down" { + if SCENARIO == "stop-ipc" && joined == "compose -f docker-compose.yml --profile harness --profile openbot down" { let mut barrier = std::net::TcpStream::connect( std::fs::read_to_string("stop-barrier-address") .unwrap() @@ -137,7 +137,14 @@ fn main() { value if value.starts_with("compose up -d --no-build --pull never ") || value - .starts_with("compose --profile harness up -d --no-build --pull never ") => {} + .starts_with("compose --profile harness up -d --no-build --pull never ") => + { + // Whether Compose was handed the desktop's host token, by its length only: the + // token itself is never written anywhere. + if let Some(token) = std::env::var_os("OPENBOT_DESKTOP_HOST_TOKEN") { + std::fs::write("host-token-at-up", token.len().to_string()).unwrap(); + } + } "compose run --rm --pull never migrate" => { if SCENARIO == "harness" { eprintln!("synthetic migration barrier"); diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 0623c05db1..27770e1d4c 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,1465 @@ 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 and Podman are not here: they are the engine a person may already know, and their prompts +// and agreements show windows of their own, so a screen names them when it waits on one. See +// `plainInstallSteps`. +const JARGON = [/\bCLI\b/, /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", HOSTED_LINK]); + 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("with a CLI, the first screen also offers the hosted service, which sets up without the CLI", async () => { + setupRootConfiguration("/tmp/local-eval-welcome-hosted", async () => + emptyConfiguration(), + ); + withLocalCli({}); + const view = await renderApp(); + await userEvent.click(view.getByRole("button", { name: HOSTED_LINK })); + // The hosted setup a build without local Intelligence starts: the Bot first. + expect(stepLabel(view)).toBe("Bot"); + expect(view.queryByText("Choose where Intelligence runs")).toBeNull(); + expect(installationCalls()).toEqual([]); + expect(localCliCalls()).toEqual([]); +}); + +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(/Downloading local software \(2\/4\)\./), + ).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 +1975,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 +2388,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 +2423,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 +2590,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 +2607,7 @@ test.each(["supervisor", "windows"])( view.getByText( blocker === "supervisor" ? "The supervisor stopped retrying." - : "Install WSL before continuing.", + : "wsl --install", ), ).toBeTruthy(); }, @@ -1485,20 +3012,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(); }); @@ -1520,11 +3070,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(); @@ -1836,9 +3388,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(); @@ -1861,6 +3414,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( @@ -2922,16 +4482,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(); @@ -3189,6 +4753,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 b97e86de49..2dcab86f6f 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -2,6 +2,8 @@ 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 { useWindowFitsContent } from "./fitWindow"; import { StartFresh } from "./StartFresh"; import { DEFAULT_HARNESS, @@ -9,6 +11,21 @@ import { 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,11 @@ 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; // An older shell offered only the database. The list is exactly what a reset may remove. const leftoverVolumes = displayedFailure?.leftover_reset?.length ? displayedFailure.leftover_reset @@ -303,6 +419,30 @@ export function App() { ? [displayedFailure.database_reset] : []; const leftoverContainers = displayedFailure?.leftover_containers ?? []; + // 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, @@ -341,6 +481,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({}); @@ -365,6 +510,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, ); @@ -383,8 +533,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( @@ -399,10 +556,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); @@ -461,6 +636,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 { @@ -523,8 +703,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([ @@ -537,23 +719,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); @@ -561,9 +786,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. @@ -578,7 +804,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() @@ -587,7 +814,7 @@ export function App() { setStep("connect"); setRefreshIntelligence(false); await invoke("show_openbot"); - return; + return true; } /* * One screen short of the handover, on purpose. @@ -599,8 +826,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") @@ -651,18 +880,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); } } @@ -670,17 +932,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 ( @@ -691,28 +978,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…"} +

+ +
); } @@ -754,7 +1047,28 @@ 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(); + }, + // The hosted setup, as a build without local Intelligence starts it. + onHosted: () => { + chooseIntelligence("hosted"); + setStep("harness"); + }, + } + : undefined + } + /> {displayedFailure && }
); @@ -796,59 +1110,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 ( +
+
+ {leftoverVolumes.length > 0 && ( + + )} + {!returningToInstallation && } +

Getting ready

+

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

+ + {/* Leftover data is a choice, offered above, not a failure to report here. */} + {displayedFailure && leftoverVolumes.length === 0 && ( + + )} + {!local.running && !busy && ( +
+ + {localAvailable && ( + + )} +
+ )} +
); } @@ -1006,24 +1472,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. @@ -1077,7 +1554,7 @@ export function App() {

{signInUrl}

-

Waiting for you to approve it…

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

@@ -1176,6 +1658,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… + + )} )} {/* @@ -1269,6 +1757,16 @@ export function App() { /> + {localAvailable && ( + + )} )} @@ -1296,6 +1794,8 @@ export function App() { )} + {stopping && Stopping OpenBot…} + {showing && Loading OpenBot…}

{!running && ( @@ -1336,11 +1833,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..5882de451a --- /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 took too long to 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..7921a63f9d 100644 --- a/desktop/src/Welcome.tsx +++ b/desktop/src/Welcome.tsx @@ -5,7 +5,41 @@ * 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, and Install is the one button that asks for attention. Hosted is a quiet second button + * for somebody who already knows local is not for them, so they need not fail at it first. + */ + local?: { onInstall: () => void; onHosted: () => 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..60623d9045 --- /dev/null +++ b/desktop/src/localEvalCli.ts @@ -0,0 +1,374 @@ +/** + * 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", + "rootless-podman", +] 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..5c465ae0b8 --- /dev/null +++ b/desktop/src/localReadiness.test.ts @@ -0,0 +1,418 @@ +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(17); +}); + +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: "The container engine 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 container engine 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: "The container engine 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 took too long to start, so OpenBot stopped waiting. What it already downloaded is kept, so trying again picks up from there.", + action: "restart-intelligence", + }); + expect(said("unexpected-exit").said).toBe("Intelligence could not start."); + 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..3f62cbdd5d --- /dev/null +++ b/desktop/src/localReadiness.ts @@ -0,0 +1,263 @@ +/** + * 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 = { + /** The container engine, Docker or Podman, 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, + // Ran out of time, which on a slow disk or connection is not the same as failing: the cluster and + // the images it fetched are still there for the retry. Seen at exactly 30 minutes on a Windows VM + // that was installing OpenBot's packages at the same time. + timeout: blocked( + "Intelligence took too long to start, so OpenBot stopped waiting. What it already downloaded is kept, so trying again picks up from there.", + "restart-intelligence", + ), + "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", + ), + // OpenBot runs on a Podman machine without root, and Intelligence needs root. Switching it is + // the person's call. OpenBot stays in the storage its database is in, and Intelligence runs on + // the machine's rootful engine. + "rootless-podman": blocked( + "Intelligence needs Podman in rootful mode. Stop your Podman machine, run podman machine set --rootful, then start it again.", + "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: "The container engine is running." }; + } + const notRunning = blocked("The container engine 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 container engine setup is not supported."); + } + return { + state: "unknown", + said: "The container engine 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. */} +