diff --git a/.github/workflows/desktop-macos-signing.yml b/.github/workflows/desktop-macos-signing.yml index 7e75d275fe..c9954d94f0 100644 --- a/.github/workflows/desktop-macos-signing.yml +++ b/.github/workflows/desktop-macos-signing.yml @@ -96,13 +96,15 @@ jobs: # What a release signs and attaches: a candidate pinned to its own images with no updater, # a stable release made Latest only once its signed Mac and Windows installers are both on # it, every signed installer under its fixed download name too, and downloads.copilotkit.ai - # serving only the Latest stable release's signed installers. + # serving only the Latest stable release's signed installers. And every signed build carries + # the Intelligence CLI, its Mac binaries signed for notarization: an app without it cannot set + # up Intelligence, and the updater would put it on every Mac. - name: Signed release regressions run: >- bun test scripts/signed-build.test.ts scripts/publish-signed-mac.test.ts scripts/publish-signed-windows.test.ts scripts/signing-workflows.test.ts ../scripts/release-latest.test.ts ../scripts/release-download-names.test.ts - ../scripts/downloads-sync.test.ts + ../scripts/downloads-sync.test.ts scripts/release-cli-bundle.test.ts working-directory: desktop - name: Importing the Developer ID intermediate twice succeeds # The first real signing run stopped here: the .p12 already carried the intermediate, and a @@ -351,6 +353,9 @@ jobs: echo '::error::The app is ad-hoc signed.' exit 1 fi + + # The CLI is in the app, and every Mach-O in it is ours, hardened and timestamped. + desktop/scripts/verify-cli-bundle.sh "$app" developer-id # Named by architecture because both runners produce `OpenBot.app.tar.gz`, and latest.json # points at one URL per platform. A pinned build made no updater archive. - name: Collect the artifacts diff --git a/.github/workflows/desktop-signing.yml b/.github/workflows/desktop-signing.yml index 05cd822a29..162a3dc3b5 100644 --- a/.github/workflows/desktop-signing.yml +++ b/.github/workflows/desktop-signing.yml @@ -181,6 +181,15 @@ jobs: if ($installers.Count -ne 1) { throw 'Expected exactly one NSIS installer.' } & 7z e $installers[0].FullName '-odesktop/signed-app' '-r' '-y' 'openbot-desktop.exe' if ($LASTEXITCODE -ne 0) { throw 'Could not extract signed app from NSIS installer.' } + # The CLI is in what people install, not only in the build folder. + - name: Verify the installer carries the Intelligence CLI + run: | + $installers = @(Get-ChildItem 'desktop/src-tauri/target/release/bundle/nsis/*-setup.exe' -File) + $listing = & 7z l $installers[0].FullName + if ($LASTEXITCODE -ne 0) { throw 'Could not list the NSIS installer.' } + foreach ($file in @('cli-bundle\runtime\node.exe', 'cli-bundle\cli\index.js')) { + if (-not ($listing | Select-String -SimpleMatch $file)) { throw "The installer has no $file." } + } - name: Verify publisher, trust, and timestamp on both executables run: | $build = Get-Content desktop/build-version.json -Raw | ConvertFrom-Json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d36951ecd..992de1a74b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +- OpenBot Desktop now runs OpenBot for one person on one computer with CopilotKit Intelligence on + the same computer, set up from the app alone: Account, Install, AI, Start. The Intelligence CLI + and its Node runtime ship inside the app, pinned by SHA-256. **Before upgrading:** the Desktop app + no longer connects to a hosted or self-hosted Intelligence, has no organization sign-in and no + Bot picker. An installation an earlier app connected to a hosted Intelligence starts at the first + screen and keeps its finished install, its database and its Bots; its conversations stay in that + hosted Intelligence. +- OpenBot Desktop runs every new installation as Compose project `openbot-desktop`, never a source + checkout's. An installation an earlier app made keeps its own project, and with it its database + and its Bots' computers, even when Stop or Quit took its containers down. If another OpenBot on + the computer, such as a source checkout called `openbot`, is running that project, Install says so + and waits for it to stop rather than starting with an empty database. Nothing is deleted either + way. +- Settings → This computer, inside OpenBot Desktop, shows what runs, the memory each part takes, and + Restart and Stop for OpenBot and the local Intelligence. Only OpenBot's own page gets those + controls: a page on another port of this computer that opens in the window gets none of them. +- The Windows installer and uninstaller stop the bundled CLI's background service before they + replace or remove it, including when the installation folder has an apostrophe in its name. +- The Helm chart now installs CopilotKit Intelligence with OpenBot by default, so one `helm install` is the whole product: Intelligence 0.16.0-rc.2 in self-hosted mode on its own bundled PostgreSQL and Redis, with every credential it needs generated and kept across upgrades, OpenBot's project and runtime key created inside it by Intelligence's own bootstrap from a key the chart generates, with nobody signing in, and Automatic Learning and Trajectories on. The licence is given once, as `secrets.licenseToken`, and reaches both; Learning's agent runs on `secrets.modelApiKey`. The server carries the browser's socket to Intelligence's gateway on its own address, so a single `kubectl port-forward` reaches everything. The chart README's vanilla install is the three steps. **Before upgrading:** a release pointed at managed Intelligence, or at one of its own, needs `--set intelligence.enabled=false`; the chart refuses the upgrade until it is set rather than install a second, empty Intelligence. - The desktop installers are served from `downloads.copilotkit.ai`, the address the website links to. `https://downloads.copilotkit.ai/latest/OpenBot-mac-arm64.dmg`, `OpenBot-mac-x64.dmg` and `OpenBot-windows-x64-setup.exe` give the signed installers of the Latest stable release, never a @@ -98,10 +117,12 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. its last message, now also as plain text. A name the model writes in Markdown is stored as plain text, and an answer too long to be a name is not stored as one. Migration `0077_channel_opening` adds `channels.opening`. -- The sign-in page the Desktop app opens now explains OpenBot setup — you are setting up a - CopilotKit account for the personal edition of OpenBot, and your data stays on your computer — - instead of the generic CopilotKit pitch. The app adds `mode=openbot` to the sign-in URL it opens; - sign-in itself is unchanged, and an older hosted page ignores the mode. +- The sign-in page the Desktop app opens explains OpenBot setup (you are setting up a CopilotKit + account for the personal edition of OpenBot, and your data stays on your computer) instead of the + generic CopilotKit pitch. The app signs in through the bundled Intelligence CLI and asks it for + that page with `copilotkit login --mode openbot` whenever the CLI's own `login --help` lists the + flag. A CLI from before the flag signs you in exactly as before, with the generic page. Sign-in + itself is unchanged either way. - Choosing Anthropic in the Desktop setup screen, or setting `BOT_PROVIDER=anthropic` without a `BOT_MODEL`, now runs `claude-sonnet-5-5` instead of `claude-sonnet-4-5`, which Anthropic retires on 2026-11-30. A deployment that pins `BOT_MODEL` is unaffected. - Code and markdown in a Bot's interface are drawn the way a reply draws them: highlighted in the light and dark themes, with the same copy and download buttons, tables and Doc links. They were plain monospace before, and nearly unreadable in dark mode. - The Computer panel's Activity lists every command the Bot runs and every file it reads, saves or lists, with what it printed, whether the step ran at once, after you allowed it, or before the page was opened. It had stayed empty for anything that went through approval. A step that was not done shows why in your words ("You declined it."), not the sentence the Bot is told, and the line in the conversation says the same. A command you decline on the Bot's own approval card is listed too. A step you did yourself shows as done by you. A step shows the time it ran only when the page saw it run, never the time the page was opened. Resetting a computer from Admin clears the Activity of every Bot that ran on it, and a reload or another tab no longer brings it back. diff --git a/app/src/components/layout/sidebar-shell.tsx b/app/src/components/layout/sidebar-shell.tsx index e96b7399c6..f8c0fa227f 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"; import { cn } from "@/lib/utils"; @@ -23,22 +34,46 @@ const SIDEBAR_GUTTER = "[&_[data-sidebar=menu-button]]:px-2.5"; * `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, { @@ -49,6 +84,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 f237309b36..fa04873603 100644 --- a/app/src/components/settings/settings-sidebar.tsx +++ b/app/src/components/settings/settings-sidebar.tsx @@ -27,6 +27,7 @@ import { SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar"; +import { desktopShell } from "@/lib/desktop/shell"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries"; import { mayOpenSetup, setupChecklistQueryOptions } from "@/lib/setup/queries"; @@ -41,6 +42,8 @@ type SettingsSidebarItem = { * 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; @@ -92,6 +95,13 @@ export const SETTINGS_SIDEBAR_ITEMS: SettingsSidebarItem[] = [ 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/this-computer" }, + }, { /* The same mark Admin gives UI Components. It is the same subject seen from the other side. */ title: "Components gallery", @@ -195,7 +205,11 @@ export function SettingsSidebar({ {items - .filter((option) => !option.setup || canSetUp) + .filter( + (option) => + (!option.setup || canSetUp) && + (!option.desktopOnly || desktopShell() !== null), + ) .map((option) => ( 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/styles.css b/app/src/styles.css index 62f38b13f8..ab08ef6773 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -16,6 +16,34 @@ /* Its code plugin, which Streamdown's docs say to scan the same way. */ @source "../node_modules/@streamdown/code/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/desktop/bun.lock b/desktop/bun.lock index 94535e0f4c..2b2191e2cf 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -5,6 +5,7 @@ "": { "name": "openbot-desktop", "dependencies": { + "@fontsource-variable/inter": "5.3.0", "@tauri-apps/api": "^2.12.4", "@tauri-apps/plugin-process": "^2.4.0", "@tauri-apps/plugin-updater": "^2.14.1", @@ -118,6 +119,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="], + "@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 a289b1131e..af7d3feafe 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -9,6 +9,7 @@ "tauri": "tauri" }, "dependencies": { + "@fontsource-variable/inter": "5.3.0", "@tauri-apps/api": "^2.12.4", "@tauri-apps/plugin-process": "^2.4.0", "@tauri-apps/plugin-updater": "^2.14.1", 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/scripts/cli-bundle.test.ts b/desktop/scripts/cli-bundle.test.ts index f6fa30b06a..336bd9f38e 100644 --- a/desktop/scripts/cli-bundle.test.ts +++ b/desktop/scripts/cli-bundle.test.ts @@ -11,7 +11,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { bundleCli, downloadNode, @@ -304,7 +304,7 @@ function fakePackRun(pkg: { return { code: 0, stdout: out, stderr: "" }; } if (cmd[0] === "tar") { - const dir = join(cmd[cmd.indexOf("-C") + 1], "package"); + const dir = join(resolve(cwd, cmd[cmd.indexOf("-C") + 1]), "package"); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "package.json"), @@ -342,6 +342,25 @@ function packDirs() { return { workDir: join(root, "work"), cliDir: join(root, "cli") }; } +/** + * Windows: the build step runs in Git's bash, whose GNU tar reads `D:\\a\\...\\copilotkit.tgz` as the + * host `D` and fails ("Cannot connect to D: resolve failed"). The tarball is named relative to the + * work folder it is unpacked in, which every tar reads as a file. + */ +test("unpacks the CLI by paths relative to its work folder", () => { + const { workDir, cliDir } = packDirs(); + mkdirSync(workDir, { recursive: true }); + const { run, calls } = fakePackRun({ + name: "copilotkit", + version: "4.33.1", + bin: { copilotkit: "index.js" }, + }); + packCli({ kind: "npm", spec: "copilotkit@latest" }, workDir, cliDir, run); + const tar = calls.find((call) => call.cmd[0] === "tar"); + expect(tar?.cmd).toEqual(["tar", "-xzf", "copilotkit-4.33.1.tgz", "-C", "."]); + expect(tar?.cwd).toBe(workDir); +}); + test("packs a preview and reports the package's own name and version", () => { const { workDir, cliDir } = packDirs(); mkdirSync(workDir, { recursive: true }); diff --git a/desktop/scripts/cli-bundle.ts b/desktop/scripts/cli-bundle.ts index f59a32ff12..5cd4591c82 100644 --- a/desktop/scripts/cli-bundle.ts +++ b/desktop/scripts/cli-bundle.ts @@ -230,7 +230,9 @@ export function packCli( runChecked( run, - ["tar", "-xzf", join(workDir, filename), "-C", workDir], + // Relative to the work folder: Git's GNU tar, which a bash step on Windows runs, reads + // `D:\...` as a remote host. + ["tar", "-xzf", filename, "-C", "."], workDir, ); const pkgDir = join(workDir, "package"); diff --git a/desktop/scripts/release-cli-bundle.test.ts b/desktop/scripts/release-cli-bundle.test.ts new file mode 100644 index 0000000000..4a4d332fab --- /dev/null +++ b/desktop/scripts/release-cli-bundle.test.ts @@ -0,0 +1,243 @@ +/** + * Release builds carry the Intelligence CLI. + * + * The standalone app sets up Intelligence only through the CLI it bundles; a build without it + * cannot get past Account. The signed builds are the ones people install, and the Mac one is what + * the in-place updater puts on every Mac, so both signing workflows are held to bundling it here, + * and the scripts that sign and check the Mac bundle are run on a stand-in. + */ +import { afterEach, expect, test } from "bun:test"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { CLI_OVERLAY, planSignedBuild, type Runner } from "./signed-build"; + +const repository = resolve(import.meta.dir, "..", ".."); +const scripts = import.meta.dir; +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function workflow(name: string): string { + return readFileSync(join(repository, ".github", "workflows", name), "utf8"); +} + +/** The text of the step called `name`, up to the next step. */ +function step(text: string, name: string): string { + const start = text.indexOf(`- name: ${name}\n`); + expect(start, `no step "${name}"`).toBeGreaterThan(-1); + const rest = text.slice(start + 1); + const next = rest.search(/\n {6}- (name|uses):/); + return next === -1 ? rest : rest.slice(0, next); +} + +test("the bundle overlay puts the CLI in the app's resources", () => { + const overlay = JSON.parse( + readFileSync( + join(repository, "desktop/src-tauri/tauri.bundled-cli.conf.json"), + "utf8", + ), + ); + expect(overlay.bundle.resources).toEqual({ "cli-bundle/": "cli-bundle/" }); +}); + +test("the signed Mac build goes through signed-build.ts and checks the CLI it carries", () => { + const text = workflow("desktop-macos-signing.yml"); + expect(step(text, "Build, sign and notarize")).toContain( + 'bun desktop/scripts/signed-build.ts darwin "$ARCH"', + ); + expect( + step(text, "Verify the signature, Gatekeeper and the notarization ticket"), + ).toContain('desktop/scripts/verify-cli-bundle.sh "$app" developer-id'); +}); + +test("the signed Windows build goes through signed-build.ts and checks the installer has the CLI", () => { + const text = workflow("desktop-signing.yml"); + expect(step(text, "Build and sign")).toContain("signed-build.ts win32 x64"); + const verify = step( + text, + "Verify the installer carries the Intelligence CLI", + ); + expect(verify).toContain("cli-bundle\\runtime\\node.exe"); + expect(verify).toContain("cli-bundle\\cli\\index.js"); +}); + +/** A desktop/ folder signed-build.ts can plan for, with the bundled CLI's overlay. */ +function signedDesktop(): string { + const dir = mkdtempSync(join(tmpdir(), "openbot-signed-cli-")); + directories.push(dir); + mkdirSync(join(dir, "src-tauri")); + for (const file of [ + "src-tauri/tauri.macos-signing.conf.json", + "src-tauri/tauri.windows-signing.conf.json", + "src-tauri/tauri.bundled-cli.conf.json", + ]) { + copyFileSync(join(repository, "desktop", file), join(dir, file)); + } + writeFileSync( + join(dir, "build-version.json"), + JSON.stringify({ + version: "0.3.0", + sourceSha: "a".repeat(40), + channel: "release", + }), + ); + return dir; +} + +function plannedCommands( + platform: "darwin" | "win32", + signingIdentity?: string, +) { + const dir = signedDesktop(); + const calls: string[][] = []; + const run: Runner = (cmd) => { + calls.push(cmd); + return { code: 0, stdout: "", stderr: "" }; + }; + const plan = planSignedBuild({ + desktop: dir, + platform, + arch: platform === "darwin" ? "arm64" : "x64", + overlay: + platform === "darwin" + ? "src-tauri/tauri.macos-signing.conf.json" + : "src-tauri/tauri.windows-signing.conf.json", + repository: "CopilotKit/OpenBot", + workDir: join(dir, "work"), + bun: "bun", + run, + signingIdentity, + }); + return { plan, calls: calls.map((call) => call.join(" ")) }; +} + +test("a signed Mac build bundles the CLI, then signs its binaries before the build pins them", () => { + const { plan, calls } = plannedCommands( + "darwin", + "Developer ID Application: Example (TEAM123456)", + ); + expect(calls).toHaveLength(2); + expect(calls[0]).toContain("scripts/cli-bundle.ts darwin arm64"); + expect(calls[1]).toContain("scripts/sign-cli-bundle.sh"); + expect(calls[1]).toContain("Developer ID Application: Example (TEAM123456)"); + expect(calls[1]).toContain("cli-bundle.entitlements.plist"); + expect(plan.configs).toContain(CLI_OVERLAY); + expect(plan.env.OPENBOT_BUNDLED_CLI).toBe("1"); +}); + +test("a signed Windows build bundles the CLI and signs nothing in it itself", () => { + const { plan, calls } = plannedCommands("win32"); + expect(calls).toEqual([ + expect.stringContaining("scripts/cli-bundle.ts win32 x64"), + ]); + expect(plan.configs).toContain(CLI_OVERLAY); + expect(plan.env.OPENBOT_BUNDLED_CLI).toBe("1"); +}); + +/** A stand-in OpenBot.app: a signed main program and, unless `withCli` is false, a CLI bundle. */ +function standInApp(withCli = true): string { + const root = mkdtempSync(join(tmpdir(), "openbot-cli-bundle-")); + directories.push(root); + const app = join(root, "OpenBot.app"); + const macos = join(app, "Contents", "MacOS"); + mkdirSync(macos, { recursive: true }); + copyFileSync("/usr/bin/true", join(macos, "openbot-desktop")); + if (withCli) { + const bundle = join(app, "Contents", "Resources", "cli-bundle"); + mkdirSync(join(bundle, "runtime"), { recursive: true }); + mkdirSync(join(bundle, "cli", "node_modules", "addon"), { + recursive: true, + }); + copyFileSync("/usr/bin/true", join(bundle, "runtime", "node")); + // A native module npm installed, signed by somebody else. + copyFileSync( + "/usr/bin/true", + join(bundle, "cli", "node_modules", "addon", "addon.node"), + ); + writeFileSync(join(bundle, "cli", "index.js"), "console.log('cli');\n"); + } + return app; +} + +function run(command: string[]) { + const result = Bun.spawnSync(command, { stdout: "pipe", stderr: "pipe" }); + return { + code: result.exitCode, + output: `${result.stdout.toString()}${result.stderr.toString()}`, + }; +} + +const onMac = process.platform === "darwin"; + +test.if(onMac)("an app with no CLI fails the check", () => { + const app = standInApp(false); + const checked = run([join(scripts, "verify-cli-bundle.sh"), app, "adhoc"]); + expect(checked.code).not.toBe(0); + expect(checked.output).toContain("has no bundled CLI"); +}); + +test.if(onMac)( + "every Mach-O in the bundle is signed hardened, and the runtime without get-task-allow", + () => { + const app = standInApp(); + const bundle = join(app, "Contents", "Resources", "cli-bundle"); + // As Node ships: get-task-allow, which notarization refuses. + const debuggable = join(bundle, "debuggable.plist"); + writeFileSync( + debuggable, + '\n\ncom.apple.security.get-task-allow\n', + ); + const node = join(bundle, "runtime", "node"); + expect( + run([ + "codesign", + "--force", + "--sign", + "-", + "--options", + "runtime", + "--entitlements", + debuggable, + node, + ]).code, + ).toBe(0); + rmSync(debuggable); + const before = run([join(scripts, "verify-cli-bundle.sh"), app, "adhoc"]); + expect(before.code).not.toBe(0); + + const signed = run([ + join(scripts, "sign-cli-bundle.sh"), + bundle, + "-", + join(repository, "desktop/src-tauri/cli-bundle.entitlements.plist"), + ]); + expect(signed.code, signed.output).toBe(0); + expect(signed.output).toContain("1 other Mach-O files"); + const after = run([join(scripts, "verify-cli-bundle.sh"), app, "adhoc"]); + expect(after.code, after.output).toBe(0); + expect(after.output).toContain("2 Mach-O files"); + // The runtime keeps what V8 needs under the hardened runtime, and nothing more. + const entitlements = run([ + "codesign", + "-d", + "--entitlements", + "-", + "--xml", + node, + ]); + expect(entitlements.output).toContain("com.apple.security.cs.allow-jit"); + expect(entitlements.output).not.toContain("get-task-allow"); + }, +); diff --git a/desktop/scripts/sign-cli-bundle.sh b/desktop/scripts/sign-cli-bundle.sh new file mode 100755 index 0000000000..5094340e91 --- /dev/null +++ b/desktop/scripts/sign-cli-bundle.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Signs every Mach-O file in the Intelligence CLI bundle a Mac build carries +# (`tauri.bundled-cli.conf.json`), before `tauri build` pins the runtime's digest (build.rs). +# +# Tauri signs the app's own binary and its frameworks, not its resources, and notarization refuses +# any Mach-O in the app that is not signed with a Developer ID Application certificate, with the +# hardened runtime and a secure timestamp. The bundle has such files of its own: Node, which ships +# with `get-task-allow`, and native modules npm installs signed for the Mac App Store. Each is +# signed again here with this build's identity. Node gets the entitlements V8 needs under the +# hardened runtime (cli-bundle.entitlements.plist) and nothing else. +# +# Usage: sign-cli-bundle.sh +# An identity of `-` signs ad hoc, with no timestamp, for a local build. +set -euo pipefail + +bundle="${1:?the cli-bundle folder}" +identity="${2:?the signing identity, or - for ad hoc}" +entitlements="${3:?the entitlements for the runtime}" + +node="$bundle/runtime/node" +test -f "$node" || { echo "No runtime at $node" >&2; exit 1; } +timestamp=--timestamp +if [ "$identity" = "-" ]; then + timestamp=--timestamp=none +fi + +others=0 +while IFS= read -r -d '' file; do + [ "$file" = "$node" ] && continue + case "$(file -b "$file")" in + *Mach-O*) + codesign --force --sign "$identity" --options runtime "$timestamp" "$file" + others=$((others + 1)) + ;; + esac +done < <(find "$bundle" -type f -print0) + +codesign --force --sign "$identity" --options runtime "$timestamp" \ + --entitlements "$entitlements" "$node" +codesign --verify --strict "$node" +echo "Signed the runtime and $others other Mach-O files in $bundle." diff --git a/desktop/scripts/signed-build.ts b/desktop/scripts/signed-build.ts index 098476b542..77148cda0b 100644 --- a/desktop/scripts/signed-build.ts +++ b/desktop/scripts/signed-build.ts @@ -104,6 +104,8 @@ export function planSignedBuild(options: { workDir: string; bun: string; run: Runner; + /** The Developer ID a Mac build signs with. The bundled CLI's binaries are signed with it. */ + signingIdentity?: string; }): Plan { const { desktop, platform, arch, overlay, repository, workDir, run } = options; @@ -188,6 +190,22 @@ export function planSignedBuild(options: { [options.bun, join(desktop, "scripts", "cli-bundle.ts"), platform, arch], desktop, ); + // Notarization refuses any Mach-O in the app that is not signed with a Developer ID, hardened + // and timestamped, and Tauri signs none of its resources: Node ships with get-task-allow, and + // npm installs native modules signed for the Mac App Store. Signed here, before the build pins + // the runtime's digest, since signing rewrites the file. + if (platform === "darwin" && options.signingIdentity) { + runChecked( + run, + [ + join(desktop, "scripts", "sign-cli-bundle.sh"), + join(desktop, "src-tauri", "cli-bundle"), + options.signingIdentity, + join(desktop, "src-tauri", "cli-bundle.entitlements.plist"), + ], + desktop, + ); + } plan.configs.push(CLI_OVERLAY); plan.env.OPENBOT_BUNDLED_CLI = "1"; } @@ -252,6 +270,8 @@ function main(argv: string[]) { workDir: join(process.env.RUNNER_TEMP ?? tmpdir(), "signed-build"), bun, run: realRun, + signingIdentity: + platform === "darwin" ? (process.env.APPLE_SIGNING_IDENTITY ?? "") : "", }); console.log( plan.pinned diff --git a/desktop/scripts/verify-cli-bundle.sh b/desktop/scripts/verify-cli-bundle.sh new file mode 100755 index 0000000000..426be07637 --- /dev/null +++ b/desktop/scripts/verify-cli-bundle.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Checks that a built OpenBot.app carries the Intelligence CLI, and that every Mach-O file in it is +# signed the way notarization requires: with a Developer ID Application certificate of the app's own +# team, the hardened runtime, a secure timestamp and no `get-task-allow`. See sign-cli-bundle.sh. +# +# Usage: verify-cli-bundle.sh developer-id|adhoc +# `adhoc` checks a local build, which has no timestamp and no team to compare. +set -euo pipefail + +app="${1:?the .app}" +mode="${2:?developer-id or adhoc}" +bundle="$app/Contents/Resources/cli-bundle" +team="$(codesign -dv --verbose=4 "$app" 2>&1 | sed -n 's/^TeamIdentifier=//p')" || team="" +if [ "$mode" = developer-id ] && { [ -z "$team" ] || [ "$team" = "not set" ]; }; then + echo "::error::$app has no team to compare the CLI's signatures with." >&2 + exit 1 +fi + +for required in runtime/node cli/index.js; do + if [ ! -f "$bundle/$required" ]; then + echo "::error::$app has no bundled CLI: $required is missing." >&2 + exit 1 + fi +done + +checked=0 +while IFS= read -r -d '' file; do + case "$(file -b "$file")" in + *Mach-O*) ;; + *) continue ;; + esac + name="${file#"$bundle"/}" + codesign --verify --strict "$file" + details="$(codesign -dv --verbose=4 "$file" 2>&1)" + if ! grep -q 'flags=.*runtime' <<<"$details"; then + echo "::error::$name does not have the hardened runtime." >&2 + exit 1 + fi + if [ "$mode" = developer-id ]; then + grep -q '^Authority=Developer ID Application: ' <<<"$details" && + grep -qx "TeamIdentifier=$team" <<<"$details" || { + echo "::error::$name is not signed with the app's own Developer ID ($team)." >&2 + exit 1 + } + grep -q '^Timestamp=' <<<"$details" || { + echo "::error::$name has no secure timestamp." >&2 + exit 1 + } + fi + if codesign -d --entitlements - --xml "$file" 2>/dev/null | grep -q 'get-task-allow'; then + echo "::error::$name requests get-task-allow, which notarization refuses." >&2 + exit 1 + fi + checked=$((checked + 1)) +done < <(find "$bundle" -type f -print0) + +echo "The app carries the CLI; $checked Mach-O files in it are signed for notarization." diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 3f637e665a..2d9caed03b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -349,6 +349,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" @@ -1800,6 +1806,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" @@ -2222,6 +2241,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.20.0" @@ -2573,6 +2602,7 @@ dependencies = [ "base64 0.22.1", "flate2", "libc", + "objc2", "portable-pty", "rand 0.9.5", "reqwest 0.12.28", @@ -2941,6 +2971,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" @@ -4100,6 +4136,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "image", "jni 0.21.1", "libc", "log", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 55bddc3709..4bb31e316b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -15,6 +15,8 @@ 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" # Reads the pinned deployment manifest an internal or candidate build carries. See `build.rs`. serde_json = "1" @@ -23,7 +25,7 @@ default = [] shell-updater = [] [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" @@ -51,5 +53,15 @@ tauri-plugin-process = "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 2309f4c3fc..5ba585acda 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -30,8 +30,119 @@ fn main() { ); println!("cargo:rerun-if-env-changed=RUSTC"); println!("cargo:rerun-if-env-changed=PATH"); + build_identity(); + bundled_cli(); pin_deployment(); - tauri_build::build() + // 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()); + } } /// Compile in the deployment named by `OPENBOT_PINNED_DEPLOYMENT`, when there is one. diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 0eb9a813af..1676420311 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/cli-bundle.entitlements.plist b/desktop/src-tauri/cli-bundle.entitlements.plist new file mode 100644 index 0000000000..a3375d70f8 --- /dev/null +++ b/desktop/src-tauri/cli-bundle.entitlements.plist @@ -0,0 +1,14 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + diff --git a/desktop/src-tauri/gen/schemas/acl-manifests.json b/desktop/src-tauri/gen/schemas/acl-manifests.json index 3b8d8859b4..20e8eab6df 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-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"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-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"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-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"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-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"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"}},"process":{"default_permission":{"identifier":"default","description":"This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n","permissions":["allow-exit","allow-restart"]},"permissions":{"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-restart":{"identifier":"allow-restart","description":"Enables the restart command without any pre-configured scope.","commands":{"allow":["restart"],"deny":[]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-restart":{"identifier":"deny-restart","description":"Denies the restart command without any pre-configured scope.","commands":{"allow":[],"deny":["restart"]}}},"permission_sets":{},"global_scope_schema":null},"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"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ 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-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"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-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"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-fullscreen-on-monitor":{"identifier":"allow-set-fullscreen-on-monitor","description":"Enables the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":["set_fullscreen_on_monitor"],"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-fullscreen-on-monitor":{"identifier":"deny-set-fullscreen-on-monitor","description":"Denies the set_fullscreen_on_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen_on_monitor"]}},"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"}},"process":{"default_permission":{"identifier":"default","description":"This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n","permissions":["allow-exit","allow-restart"]},"permissions":{"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-restart":{"identifier":"allow-restart","description":"Enables the restart command without any pre-configured scope.","commands":{"allow":["restart"],"deny":[]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-restart":{"identifier":"deny-restart","description":"Denies the restart command without any pre-configured scope.","commands":{"allow":[],"deny":["restart"]}}},"permission_sets":{},"global_scope_schema":null},"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"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ 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 d8349ab8cf..e52c7576ca 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://*"}]},"updater:default","process:allow-restart"]}} \ 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://*"}]},"updater:default","process:allow-restart"]}} \ 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 60fc9642ab..6253b00d77 100644 --- a/desktop/src-tauri/gen/schemas/desktop-schema.json +++ b/desktop/src-tauri/gen/schemas/desktop-schema.json @@ -2660,6 +2660,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 60fc9642ab..6253b00d77 100644 --- a/desktop/src-tauri/gen/schemas/macOS-schema.json +++ b/desktop/src-tauri/gen/schemas/macOS-schema.json @@ -2660,6 +2660,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 deleted file mode 100644 index 37b66efbdd..0000000000 --- a/desktop/src-tauri/src/ask.rs +++ /dev/null @@ -1,685 +0,0 @@ -/*! -The last screen: a real question, put to the Bot that was just set up. - -WHY THE INSTALL DOES NOT END AT "SAVED". Everything before this proves that things started, which -is not the same as proving the configuration works. A wrong key, an expired plan, a model name the -provider does not serve: all of them produce a stack that comes up clean and a Bot that cannot -answer, and the person finds out later, in the product, with no idea which of the choices they made -was the wrong one. So the wizard ends by asking a question and showing the answer. A configuration -that has not answered is not a finished install. - -AND THIS SCREEN OWNS THE WORST MESSAGE IN THE PRODUCT. Measured on a deliberately invalid key: the -stream opens, says `RUN_STARTED`, says `STEP_STARTED`, and then simply stops. No error event, no -reason, nothing to show. The whole of `OpenAIAuthenticationError: Error code: 401` went to the -container's log, which is where the framework's own handler put it. Passing that experience through -would leave somebody staring at a screen that stopped, so a run that produces no text is treated as -a failure here, the sentence is OpenBot's own, and the container's log is fetched to fill the -developer half, because otherwise there is no developer half at all. -*/ - -use std::time::Duration; - -use crate::problem::Problem; - -/// The header the server puts its token in, and the one every harness checks. One spelling, here, -/// because a second copy of it is a 401 nobody can explain. -pub const AGENT_TOKEN_HEADER: &str = "x-openbot-agent-token"; - -/// Long enough for a cold model, short enough that a hung run is not mistaken for a slow one. -const PATIENCE: Duration = Duration::from_secs(90); - -/// What the screen asks when the person has not typed anything of their own. -/// -/// A question with one checkable answer, on purpose. "Tell me about yourself" is answered -/// convincingly by a Bot whose tools are all broken, and the point of this screen is proof. -pub const SUGGESTED: &str = "What is 17 times 23?"; - -/** -Put a question to the harness and return what it said. - -Straight to the harness rather than through the server, because what this screen proves is the -credential and the Bot behind it. The server's own path is proved by the step before it, which -writes the Bot's row; adding a session and a login to this would test the parts that are already -green and hide the part that is not. -*/ -pub fn ask(endpoint: &str, token: &str, question: &str) -> Result { - let client = reqwest::blocking::Client::builder() - .timeout(PATIENCE) - .build() - .map_err(|error| { - Problem::plain(format!("This machine cannot make web requests: {error}")) - })?; - - let body = serde_json::json!({ - // New every time. The harness keeps a thread in memory, and reusing an id would ask the - // question into a conversation that already has an answer in it. - "threadId": format!("openbot-setup-{}", moment()), - "runId": format!("openbot-run-{}", moment()), - "state": {}, - "messages": [{ "id": "m1", "role": "user", "content": question }], - "tools": [], - "context": [], - "forwardedProps": {}, - }); - - let response = client - .post(endpoint) - .header("content-type", "application/json") - .header(AGENT_TOKEN_HEADER, token) - .json(&body) - .send() - .map_err(|error| { - Problem::with( - "OpenBot could not reach the Bot it just set up.", - error.to_string(), - ) - })?; - - let (status, text) = read_response(response, "remote-ag-ui", endpoint)?; - if !status.is_success() { - // 401 here is this deployment's own token, not the person's model credential, and saying - // "check your API key" would send them to fix the wrong thing. - return Err(Problem::with( - if status == reqwest::StatusCode::UNAUTHORIZED { - "The Bot refused OpenBot's own request. Stop OpenBot and start it again." - } else { - "The Bot could not answer." - }, - format!("HTTP {status}\n{text}"), - )); - } - - match answer_in(&text) { - Some(answer) => Ok(answer), - // Deliberately not a sentence here. The caller has the deployment and can fetch the log - // that holds the actual cause; see `why_nothing_came_back`. - None => Err(Problem::plain(String::new())), - } -} - -pub fn ask_harness( - endpoint: &str, - token: &str, - question: &str, - kind: Option<&str>, - agent_id: Option<&str>, -) -> Result { - if kind.map(str::trim) == Some("remote-mastra") { - return ask_mastra(endpoint, token, question, agent_id.unwrap_or_default()); - } - ask(endpoint, token, question) -} - -/// Ask a native Mastra server through its own agent stream endpoint. -pub fn ask_mastra( - endpoint: &str, - token: &str, - question: &str, - agent_id: &str, -) -> Result { - let agent_id = agent_id.trim(); - if agent_id.is_empty() { - return Err(Problem::plain( - "OpenBot cannot find the Mastra Bot it just set up. Stop OpenBot and start it again.", - )); - } - - let client = reqwest::blocking::Client::builder() - .timeout(PATIENCE) - .build() - .map_err(|error| { - Problem::plain(format!("This machine cannot make web requests: {error}")) - })?; - let url = mastra_stream_url(endpoint, agent_id)?; - let stream_endpoint = url.as_str().to_string(); - let body = serde_json::json!({ - "threadId": format!("openbot-setup-{}", moment()), - "resourceId": "openbot-setup", - "messages": [{ "role": "user", "content": question }], - "clientTools": {}, - "requestContext": { "ag-ui": { "context": [] } }, - }); - - let response = client - .post(url) - .header("content-type", "application/json") - .header(AGENT_TOKEN_HEADER, token) - .json(&body) - .send() - .map_err(|error| { - Problem::with( - "OpenBot could not reach the Bot it just set up.", - error.to_string(), - ) - })?; - - let (status, text) = read_response(response, "remote-mastra", &stream_endpoint)?; - if !status.is_success() { - return Err(Problem::with( - if status == reqwest::StatusCode::UNAUTHORIZED { - "The Bot refused OpenBot's own request. Stop OpenBot and start it again." - } else { - "The Bot could not answer." - }, - format!("HTTP {status}\n{text}"), - )); - } - - mastra_answer_in(&text).ok_or_else(|| Problem::plain(String::new())) -} - -fn read_response( - response: reqwest::blocking::Response, - kind: &str, - endpoint: &str, -) -> Result<(reqwest::StatusCode, String), Problem> { - let status = response.status(); - response.text().map(|text| (status, text)).map_err(|error| { - Problem::with( - if status.is_success() { - "The Bot started answering and then stopped. Its own record of what happened is below." - } else if status == reqwest::StatusCode::UNAUTHORIZED { - "The Bot refused OpenBot's own request. Stop OpenBot and start it again." - } else { - "The Bot could not answer." - }, - format!("kind {kind}\nendpoint {endpoint}\nHTTP {status}\nbody read error: {error}"), - ) - }) -} - -/** -The answer, out of an AG-UI stream. - -`TEXT_MESSAGE_CONTENT` carries the text a person sees, one delta per event, and everything else on -the wire is either the framework's own trace or protocol bookkeeping. Measured against a live run -rather than read off the spec: the same stream also carries the whole answer inside `RAW` events, -and collecting those instead would double every reply. -*/ -pub fn answer_in(body: &str) -> Option { - let mut answer = String::new(); - for line in body.lines() { - let Some(data) = line.trim().strip_prefix("data:") else { - continue; - }; - let Ok(event) = serde_json::from_str::(data.trim()) else { - continue; - }; - if event.get("type").and_then(|t| t.as_str()) != Some("TEXT_MESSAGE_CONTENT") { - continue; - } - if let Some(delta) = event.get("delta").and_then(|d| d.as_str()) { - answer.push_str(delta); - } - } - let answer = answer.trim().to_string(); - (!answer.is_empty()).then_some(answer) -} - -fn mastra_stream_url(endpoint: &str, agent_id: &str) -> Result { - let mut url = reqwest::Url::parse(endpoint.trim()).map_err(|error| { - Problem::with( - "OpenBot cannot find the Bot it just set up. Stop OpenBot and start it again.", - error.to_string(), - ) - })?; - url.path_segments_mut() - .map_err(|_| { - Problem::plain( - "OpenBot cannot find the Bot it just set up. Stop OpenBot and start it again.", - ) - })? - .clear() - .extend(["api", "agents", agent_id, "stream"]); - Ok(url) -} - -/// The visible answer out of Mastra's native stream. -pub fn mastra_answer_in(body: &str) -> Option { - let mut answer = String::new(); - for line in body.lines() { - let Some(data) = line.trim().strip_prefix("data:") else { - continue; - }; - let Ok(event) = serde_json::from_str::(data.trim()) else { - continue; - }; - if event.get("type").and_then(|t| t.as_str()) != Some("text-delta") { - continue; - } - if let Some(delta) = event - .get("payload") - .and_then(|payload| payload.get("text")) - .and_then(|text| text.as_str()) - { - answer.push_str(delta); - } - } - let answer = answer.trim().to_string(); - (!answer.is_empty()).then_some(answer) -} - -/** -Why a run produced no text, said plainly, with the log kept behind it. - -The cause is only ever in the harness's log, so this takes the log rather than the stream. The -sentences name the choice the person made that is wrong, because that is the only thing they can -act on: a key they pasted, a plan that has lapsed, a model name nobody serves. -*/ -pub fn why_nothing_came_back(log: &str) -> Problem { - let lower = log.to_lowercase(); - - // The likeliest failure in the whole product, and the reason this screen exists. - if lower.contains("authenticationerror") - || lower.contains("incorrect api key") - || lower.contains("invalid_api_key") - || lower.contains("401") - { - return Problem::with( - "That key was refused. Go back and connect the model again, either by signing in or with a different key.", - log, - ); - } - if lower.contains("insufficient_quota") || lower.contains("exceeded your current quota") { - return Problem::with( - "That account has no credit left with the model provider, so the Bot cannot answer yet.", - log, - ); - } - if lower.contains("rate limit") || lower.contains("429") { - return Problem::with( - "The model provider is asking OpenBot to slow down. Wait a minute and ask again.", - log, - ); - } - // A model name is only ever wrong on the compatible row, which is the one that asks for one. - if lower.contains("model_not_found") - || lower.contains("does not exist or you do not have access") - || lower.contains("unknown model") - { - return Problem::with( - "That account cannot use the model that was chosen. Go back and choose another one.", - log, - ); - } - if lower.contains("connection") || lower.contains("timed out") || lower.contains("timeout") { - return Problem::with( - "The Bot could not reach the model provider. Check this machine's internet connection and ask again.", - log, - ); - } - Problem::with( - "The Bot started answering and then stopped. Its own record of what happened is below.", - log, - ) -} - -/// Enough to tell two runs apart, which is all an id on a throwaway thread has to do. -fn moment() -> u128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|since| since.as_nanos()) - .unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Captured from a live run, trimmed. The shape is the contract this screen depends on. - const REAL_STREAM: &str = concat!( - "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n", - "data: {\"type\":\"RAW\",\"event\":{\"data\":{\"chunk\":{\"content\":\"391\"}}}}\n\n", - "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"a\",\"role\":\"assistant\"}\n\n", - "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"a\",\"delta\":\"3\"}\n\n", - "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"a\",\"delta\":\"91\"}\n\n", - "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"a\"}\n\n", - ); - - #[test] - fn the_answer_is_the_deltas_joined() { - assert_eq!(answer_in(REAL_STREAM).as_deref(), Some("391")); - } - - /// The same answer rides inside `RAW` as well. Counting those would say "391391". - #[test] - fn the_framework_trace_is_not_counted_as_the_answer() { - assert_eq!(answer_in(REAL_STREAM).as_deref(), Some("391")); - assert!( - REAL_STREAM.contains("\"type\":\"RAW\""), - "the fixture must carry the trap" - ); - } - - /// Measured: a rejected key ends the stream after STEP_STARTED and says nothing at all. - #[test] - fn a_stream_that_stops_is_not_an_empty_answer() { - let stopped = concat!( - "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n", - "data: {\"type\":\"STEP_STARTED\",\"stepName\":\"answer\"}\n\n", - ); - assert_eq!(answer_in(stopped), None); - } - - #[test] - fn junk_on_the_wire_does_not_stop_the_answer_being_read() { - let messy = concat!( - "data: not json at all\n", - ": a comment\n", - "\n", - "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"delta\":\"fine\"}\n", - ); - assert_eq!(answer_in(messy).as_deref(), Some("fine")); - } - - /// The sentence names the choice to change, and the log is kept rather than shown as the point. - #[test] - fn a_refused_key_is_named_as_a_key_and_not_as_a_stack_trace() { - let log = "langchain_openai.chat_models.base.OpenAIAuthenticationError: Error code: 401"; - let problem = why_nothing_came_back(log); - assert!(problem.said.contains("refused"), "{}", problem.said); - assert!( - !problem.said.contains("401"), - "the sentence leaked the trace" - ); - assert_eq!(problem.detail.as_deref(), Some(log)); - } - - /// Each cause the person can act on gets its own sentence, and no two are the same. - #[test] - fn the_causes_worth_telling_apart_are_told_apart() { - let said = |log: &str| why_nothing_came_back(log).said; - let quota = said("Error code: 429 - insufficient_quota"); - let model = said("The model `gpt-9` does not exist or you do not have access to it"); - let offline = said("Connection error while reaching the provider"); - let unknown = said("Traceback (most recent call last): RuntimeError: something else"); - let all = ["a, &model, &offline, &unknown]; - for (i, one) in all.iter().enumerate() { - for other in all.iter().skip(i + 1) { - assert_ne!(one, other, "two causes share a sentence"); - } - } - } - - /// Every sentence is for the person: no jargon, and always something to do next. - #[test] - fn no_sentence_asks_anybody_to_read_a_log() { - for log in [ - "401 unauthorized", - "insufficient_quota", - "rate limit", - "model_not_found", - "connection refused", - "something nobody has seen", - ] { - let said = why_nothing_came_back(log).said; - let lower = said.to_lowercase(); - for forbidden in ["stack trace", "see the logs", "server logs", "traceback"] { - assert!(!lower.contains(forbidden), "{said}"); - } - assert!(said.ends_with('.'), "{said}"); - } - } - - /** - The whole path against a running harness, which is the only thing that proves the wire format. - - Ignored, because it needs a harness and a real model credential and neither belongs in CI. Run - it against one by hand: - - ```text - OPENBOT_ASK_ENDPOINT=http://127.0.0.1:4288/ OPENBOT_ASK_TOKEN=... \ - cargo test --lib live_harness -- --ignored --nocapture - ``` - - Kept rather than deleted after it passed: the fixtures above are transcriptions, and a vendor - who changes the events they emit breaks this and nothing else. - */ - #[test] - #[ignore = "needs a running harness and a real model credential"] - fn live_harness_answers_the_suggested_question() { - let endpoint = std::env::var("OPENBOT_ASK_ENDPOINT").expect("OPENBOT_ASK_ENDPOINT"); - let token = std::env::var("OPENBOT_ASK_TOKEN").expect("OPENBOT_ASK_TOKEN"); - let answer = ask(&endpoint, &token, SUGGESTED).expect("the harness did not answer"); - println!("the Bot said: {answer}"); - assert!( - answer.contains("391"), - "answered {answer:?}, which is not 17 x 23" - ); - } - - /// The same path with a credential the provider refuses, which is the failure this screen owns. - #[test] - #[ignore = "needs a running harness holding a deliberately invalid credential"] - fn live_harness_that_cannot_answer_produces_a_sentence_not_a_silence() { - let endpoint = std::env::var("OPENBOT_ASK_BAD_ENDPOINT").expect("OPENBOT_ASK_BAD_ENDPOINT"); - let token = std::env::var("OPENBOT_ASK_TOKEN").expect("OPENBOT_ASK_TOKEN"); - let problem = ask(&endpoint, &token, SUGGESTED).expect_err("it answered on a bad key"); - // An empty sentence is this module saying the reason is in the log, not in the stream. - assert!(problem.said.is_empty(), "got {problem:?}"); - } - - /// The default question has one right answer, which is the only reason it proves anything. - #[test] - fn the_suggested_question_is_checkable() { - assert!(SUGGESTED.contains("17") && SUGGESTED.contains("23")); - } - - #[test] - fn mastra_harness_uses_native_agent_stream() { - let server = TestServer::new( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n\ - data: {\"type\":\"text-delta\",\"payload\":{\"text\":\"thirty \"}}\n\n\ - data: {\"type\":\"text-delta\",\"payload\":{\"text\":\"nine\"}}\n\n\ - data: {\"type\":\"finish\",\"payload\":{\"stepResult\":{\"reason\":\"stop\"}}}\n\n", - ); - - let answer = ask_harness( - &server.url, - "managed-token", - "What is 20 plus 19?", - Some("remote-mastra"), - Some("openbot"), - ) - .expect("native Mastra answer"); - - let request = server.request(); - assert_eq!(request.path, "/api/agents/openbot/stream"); - assert!( - request - .headers - .iter() - .any(|line| line == "x-openbot-agent-token: managed-token"), - "{:?}", - request.headers - ); - let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body"); - assert_eq!( - body.pointer("/messages/0/content").and_then(|v| v.as_str()), - Some("What is 20 plus 19?") - ); - assert_eq!(answer, "thirty nine"); - } - - #[test] - fn ag_ui_harness_still_uses_the_ag_ui_request() { - let server = TestServer::new( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n\ - data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"delta\":\"ag-ui ok\"}\n\n", - ); - - let answer = ask_harness( - &server.url, - "managed-token", - "hello", - Some("remote-ag-ui"), - Some("openbot"), - ) - .expect("AG-UI answer"); - - let request = server.request(); - assert_eq!(request.path, "/"); - let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body"); - assert!( - body.get("threadId").is_some(), - "AG-UI request body was not sent: {body}" - ); - assert_eq!(answer, "ag-ui ok"); - } - - #[test] - fn ag_ui_body_read_errors_keep_the_endpoint_status_and_kind() { - let body = "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"delta\":\"part"; - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() + 64 - ); - let server = TestServer::new(response); - - let problem = ask_harness( - &server.url, - "managed-token", - "hello", - Some("remote-ag-ui"), - Some("openbot"), - ) - .expect_err("a truncated AG-UI response must not become an empty answer"); - - assert!( - problem - .said - .contains("The Bot started answering and then stopped"), - "{}", - problem.said - ); - let detail = problem.detail.as_deref().expect("body read detail"); - assert!(detail.contains("kind remote-ag-ui"), "{detail}"); - assert!(detail.contains(&server.url), "{detail}"); - assert!(detail.contains("HTTP 200 OK"), "{detail}"); - assert!( - detail.contains("body") || detail.contains("error"), - "{detail}" - ); - let _ = server.request(); - } - - #[test] - fn mastra_body_read_errors_keep_the_endpoint_status_and_kind() { - let body = "data: {\"type\":\"text-delta\",\"payload\":{\"text\":\"part"; - let response = format!( - "HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() + 64 - ); - let server = TestServer::new(response); - - let problem = ask_harness( - &server.url, - "managed-token", - "hello", - Some("remote-mastra"), - Some("openbot"), - ) - .expect_err("a truncated Mastra response must keep the transport error"); - - assert!( - problem.said.contains("The Bot could not answer"), - "{}", - problem.said - ); - let detail = problem.detail.as_deref().expect("body read detail"); - assert!(detail.contains("kind remote-mastra"), "{detail}"); - assert!(detail.contains("/api/agents/openbot/stream"), "{detail}"); - assert!(detail.contains("HTTP 502 Bad Gateway"), "{detail}"); - assert!( - detail.contains("body") || detail.contains("error"), - "{detail}" - ); - let _ = server.request(); - } - - struct TestRequest { - path: String, - headers: Vec, - body: String, - } - - struct TestServer { - url: String, - received: std::sync::mpsc::Receiver, - done: Option>, - } - - impl TestServer { - fn new(response: impl Into) -> Self { - let response = response.into(); - 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(); - let done = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - use std::io::{Read, Write}; - let mut request = Vec::new(); - let mut buffer = [0; 1024]; - loop { - let read = stream.read(&mut buffer).expect("read"); - request.extend_from_slice(&buffer[..read]); - if request.windows(4).any(|window| window == b"\r\n\r\n") { - break; - } - } - let header_end = request - .windows(4) - .position(|window| window == b"\r\n\r\n") - .expect("headers") - + 4; - let headers = String::from_utf8_lossy(&request[..header_end]).to_string(); - let content_length = headers - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::().expect("content length")) - }) - .unwrap_or(0); - while request.len() < header_end + content_length { - let read = stream.read(&mut buffer).expect("read body"); - request.extend_from_slice(&buffer[..read]); - } - let mut lines = headers.lines(); - 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 - } - } -} diff --git a/desktop/src-tauri/src/composio.rs b/desktop/src-tauri/src/composio.rs index d249fc66a5..e938f4615c 100644 --- a/desktop/src-tauri/src/composio.rs +++ b/desktop/src-tauri/src/composio.rs @@ -156,12 +156,13 @@ pub enum Provisioned { New(ProvisionedKey), } -/// Ask Ops for this installation's project key. Only the installation identity is written. -pub fn provision( - root: &Path, - session: &crate::intelligence::OpsSession, -) -> Result { - provision_at(root, session.bearer(), crate::intelligence::OPS_API) +/// Where CopilotKit Ops answers for an installation's project connection. +const OPS_API: &str = "https://api.operations.copilotkit.ai"; + +/// Ask Ops for this installation's project key, with a CopilotKit Ops session's bearer token. Only +/// the installation identity is written. +pub fn provision(root: &Path, bearer: &str) -> Result { + provision_at(root, bearer, OPS_API) } fn provision_at(root: &Path, session: &str, origin: &str) -> Result { diff --git a/desktop/src-tauri/src/composio_ipc_tests.rs b/desktop/src-tauri/src/composio_ipc_tests.rs deleted file mode 100644 index 5aaad564ae..0000000000 --- a/desktop/src-tauri/src/composio_ipc_tests.rs +++ /dev/null @@ -1,429 +0,0 @@ -// The real generated IPC handler must reject a changed root before browser/network work. -mod composio_ipc { - use super::*; - - #[test] - fn finish_sign_in_rejects_a_different_installation_without_provisioning() { - if crate::test_support::isolated_process("tests::composio_ipc::finish_sign_in_rejects_a_different_installation_without_provisioning") { return; } - let root = temp_root("composio-ipc-root"); - let other = root.join("other"); - let app = tauri::test::mock_builder() - .manage(Shell::default()) - .invoke_handler(tauri::generate_handler![finish_intelligence_sign_in]) - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .unwrap(); - let (signing, _) = - openbot_desktop_lib::intelligence::SigningInToIntelligence::begin().unwrap(); - *app.state::() - .signing_in_to_intelligence - .lock() - .unwrap() = Some((root.clone(), 0, signing)); - let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) - .build() - .unwrap(); - let result = tauri::test::get_ipc_response( - &window, - tauri::webview::InvokeRequest { - cmd: "finish_intelligence_sign_in".into(), - callback: tauri::ipc::CallbackFn(0), - error: tauri::ipc::CallbackFn(1), - url: if cfg!(any(windows, target_os = "android")) { - "http://tauri.localhost" - } else { - "tauri://localhost" - } - .parse() - .unwrap(), - body: tauri::ipc::InvokeBody::Json(serde_json::json!({ "root": other })), - headers: Default::default(), - invoke_key: tauri::test::INVOKE_KEY.into(), - }, - ); - let error = result.expect_err("a different root cannot finish this sign-in"); - assert_eq!( - error["said"], - "Where OpenBot lives changed. Sign in again for this folder." - ); - assert!(app - .state::() - .intelligence_credential - .lock() - .unwrap() - .is_none()); - assert!(!root.exists()); - } - - fn signed_in(credential: &str, session: &str) -> openbot_desktop_lib::intelligence::SignedIn { - openbot_desktop_lib::intelligence::SignedIn { - credential: credential.into(), - projects: Vec::new(), - session: openbot_desktop_lib::intelligence::OpsSession::new(session.into()), - } - } - - fn ipc( - window: &tauri::WebviewWindow, - cmd: &str, - body: serde_json::Value, - ) -> Result { - tauri::test::get_ipc_response( - window, - tauri::webview::InvokeRequest { - cmd: cmd.into(), - callback: tauri::ipc::CallbackFn(0), - error: tauri::ipc::CallbackFn(1), - url: if cfg!(any(windows, target_os = "android")) { - "http://tauri.localhost" - } else { - "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()) - } - - /** - AN ABANDONED SIGN-IN KEEPS NOTHING. - - Composio provisioning ran inside the browser sign-in, before the newer-attempt check. Pressing - "Sign in again" and then finishing the older tab under another account saved that account's - key, and the newer attempt saw a key and skipped provisioning. Now the check comes first, and - again before a provisioned key is saved, on the real protected store. - */ - #[test] - fn an_abandoned_sign_in_keeps_no_credential_and_saves_no_app_key() { - if crate::test_support::isolated_process("tests::composio_ipc::an_abandoned_sign_in_keeps_no_credential_and_saves_no_app_key") { return; } - for name in ["COMPOSIO_API_KEY", "ARCADE_API_KEY", "OPENBOT_BROKER_PROVIDER"] { - std::env::remove_var(name); - } - let root = temp_root("composio-abandoned-sign-in"); - std::fs::create_dir_all(&root).unwrap(); - let shell = Shell::default(); - let older = supersede_managed_sign_in(&shell); - let newer = supersede_managed_sign_in(&shell); - publish_intelligence_sign_in( - &shell, - newer, - root.clone(), - signed_in("synthetic-current-product", "synthetic-current-session"), - ) - .unwrap(); - let error = publish_intelligence_sign_in( - &shell, - older, - root.clone(), - signed_in("synthetic-stale-product", "synthetic-stale-session"), - ) - .unwrap_err(); - assert!(!serde_json::to_string(&error).unwrap().contains("synthetic-")); - let (_, credential, _) = managed_sign_in_for(&shell, &root).unwrap(); - assert_eq!(credential, "synthetic-current-product"); - - // A newer sign-in starts while this one's apps are being provisioned. - let error = connect_apps(&shell, &root, |_, _| { - supersede_managed_sign_in(&shell); - Ok(openbot_desktop_lib::composio::Provisioned::New( - openbot_desktop_lib::composio::ProvisionedKey::new("synthetic-stale-key".into()), - )) - }) - .unwrap_err(); - assert_eq!( - error.said, - "A newer sign-in was started. Finish that sign-in instead." - ); - let brokers = openbot_desktop_lib::composio::resolve(&root).unwrap(); - assert_eq!(brokers.composio_source(), None); - assert!(!brokers.runtime().contains_key("COMPOSIO_API_KEY")); - std::fs::remove_dir_all(root).unwrap(); - } - - /// Apps are a follow-on step that can only add a key. A failure keeps the sign-in usable, and - /// a retry with the same held session saves the key for this folder only. - #[test] - fn an_apps_failure_keeps_the_sign_in_and_a_retry_adds_the_key() { - if crate::test_support::isolated_process("tests::composio_ipc::an_apps_failure_keeps_the_sign_in_and_a_retry_adds_the_key") { return; } - for name in ["COMPOSIO_API_KEY", "ARCADE_API_KEY", "OPENBOT_BROKER_PROVIDER"] { - std::env::remove_var(name); - } - let root = temp_root("composio-apps-retry"); - std::fs::create_dir_all(&root).unwrap(); - let shell = Shell::default(); - let generation = supersede_managed_sign_in(&shell); - publish_intelligence_sign_in( - &shell, - generation, - root.clone(), - signed_in("synthetic-product", "synthetic-session"), - ) - .unwrap(); - let unavailable = Problem::plain("Apps are not provisioned here yet."); - let error = connect_apps(&shell, &root, |_, _| Err(unavailable.clone())).unwrap_err(); - assert_eq!(error.said, unavailable.said); - let (_, credential, _) = managed_sign_in_for(&shell, &root).unwrap(); - assert_eq!(credential, "synthetic-product"); - assert_eq!( - openbot_desktop_lib::composio::resolve(&root) - .unwrap() - .composio_source(), - None - ); - - connect_apps(&shell, &root, |_, _| { - Ok(openbot_desktop_lib::composio::Provisioned::New( - openbot_desktop_lib::composio::ProvisionedKey::new("synthetic-app-key".into()), - )) - }) - .unwrap(); - let brokers = openbot_desktop_lib::composio::resolve(&root).unwrap(); - assert_eq!( - brokers.composio_source(), - Some(openbot_desktop_lib::composio::KeySource::Provisioned) - ); - assert_eq!(brokers.runtime()["COMPOSIO_API_KEY"], "synthetic-app-key"); - std::fs::remove_dir_all(root).unwrap(); - } - - /** - A SIGN-IN BELONGS TO THE FOLDER IT WAS STARTED FOR. - - Changing the folder after signing in and then picking a project made the other folder's key, - and that folder started without its apps. Every command that uses the held sign-in now names - the folder, through the real generated handlers, and a different one is refused before any - request is made. - */ - #[test] - fn a_sign_in_for_one_folder_cannot_make_keys_or_apps_for_another() { - let root = temp_root("composio-ipc-bound-root"); - let other = root.join("other"); - let app = tauri::test::mock_builder() - .manage(Shell::default()) - .invoke_handler(tauri::generate_handler![ - intelligence_key_for, - connect_intelligence_apps - ]) - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .unwrap(); - { - let shell = app.state::(); - let generation = supersede_managed_sign_in(&shell); - publish_intelligence_sign_in( - &shell, - generation, - root.clone(), - signed_in("synthetic-product", "synthetic-session"), - ) - .unwrap(); - } - let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) - .build() - .unwrap(); - for (cmd, body) in [ - ( - "intelligence_key_for", - serde_json::json!({ "root": other, "project": "7" }), - ), - ( - "connect_intelligence_apps", - serde_json::json!({ "root": other }), - ), - ] { - let error = ipc(&window, cmd, body).expect_err("another folder is refused"); - assert_eq!( - error["said"], - "Where OpenBot lives changed. Sign in again for this folder.", - "{cmd}" - ); - } - assert!(app.state::().intelligence_learning.lock().unwrap().is_none()); - assert!(!root.exists()); - } - - /** - THE START HAND-OFF, AS START RUNS IT. - - Both cases go through `persist_start_environment`, the function Start calls, into a real Bun - server and its supervised replacement. A key the person put in `.env` reaches the server over a - provisioned one and stays in `.env` line for line. A provisioned key reaches the server and is - never written to `.env`. - */ - #[cfg(unix)] - #[test] - fn saved_broker_key_reaches_fresh_server_and_actual_supervised_replacement() { - if crate::test_support::isolated_process("tests::composio_ipc::saved_broker_key_reaches_fresh_server_and_actual_supervised_replacement") { return; } - for supplied in [false, true] { - broker_start_and_restart(supplied); - } - } - - #[cfg(unix)] - fn broker_start_and_restart(supplied: bool) { - for name in [ - "COMPOSIO_API_KEY", - "ARCADE_API_KEY", - "OPENBOT_BROKER_PROVIDER", - ] { - std::env::set_var(name, " "); - } - std::env::remove_var("OPENBOT_INSTALLATION_ID"); - let host = InitialHostFixture::new("success"); - // Real Bun loads the dotenv while inherited blank values remain in its parent. - let bun = - PathBuf::from(std::env::var_os("OPENBOT_TEST_BUN").unwrap_or_else(|| "bun".into())); - let (provider, expected) = if supplied { - ("arcade", "synthetic-person-key") - } else { - ("composio", "synthetic-native-key") - }; - let script = format!( - r#" -import {{ writeFileSync }} from 'node:fs'; -import {{ basename }} from 'node:path'; -const role = basename(process.cwd()); -const parent = process.ppid; -writeFileSync('child.pid', String(process.pid)); -if (role === 'server') {{ - const checks = {{ composio: process.env.COMPOSIO_API_KEY === '{expected}', arcade: process.env.ARCADE_API_KEY === 'synthetic-arcade-key', provider: process.env.OPENBOT_BROKER_PROVIDER === '{provider}' }}; - writeFileSync('broker.checks', JSON.stringify(checks)); - if (!Object.values(checks).every(Boolean)) throw new Error('incorrect native broker environment'); - writeFileSync('broker.receipt', String(process.pid)); -}} else if (process.env.COMPOSIO_API_KEY === 'synthetic-native-key') throw new Error('the provisioned key reached another host role'); -setInterval(() => {{ if (process.ppid !== parent) process.exit(0); }}, 50); -"# - ); - for process in stack::host_processes(false) { - let path = host - .root - .join(process.cwd) - .join(if process.script.is_empty() { - "fixture.ts" - } else { - process.script - }); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, &script).unwrap(); - } - std::fs::write( - host.root.join("app/package.json"), - r#"{"scripts":{"serve":"bun fixture.ts"}}"#, - ) - .unwrap(); - let path = host.root.join(".env"); - let supplied_line = "COMPOSIO_API_KEY=\"synthetic-person-key\" # supplied"; - let broker_lines = format!( - "{}ARCADE_API_KEY='synthetic-arcade-key'\nOPENBOT_BROKER_PROVIDER=`{provider}` # selected\n", - if supplied { - format!("{supplied_line}\n") - } else { - String::new() - } - ); - std::fs::write(&path, &broker_lines).unwrap(); - // Provisioned in both cases. With a supplied key it must lose. - openbot_desktop_lib::composio::save( - &host.root, - openbot_desktop_lib::composio::ProvisionedKey::new("synthetic-native-key".into()), - ) - .unwrap(); - let (_, secrets) = persist_start_environment( - &host.root, - std::collections::BTreeMap::from([("APP_PORT".to_string(), "52110".to_string())]), - &openbot_env::ModelCredential::None, - ) - .unwrap(); - let written = std::fs::read_to_string(&path).unwrap(); - for line in broker_lines.lines() { - assert!(written.lines().any(|kept| kept == line), "{line} left .env: {written}"); - } - assert!(!written.contains("synthetic-native-key")); - 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"), - &bun, - &secrets, - None, - |name| fixture.host.observe(name), - |_| Ok(()), - )) - .unwrap_or_else(|problem| { - panic!( - "broker startup failed: {problem:?}; checks: {:?}", - std::fs::read_to_string(fixture.host.root.join("server/broker.checks")) - ) - }); - drop(attempt); - fixture.watcher = Some(supervise_host_processes( - fixture.app.handle().clone(), - fixture.host.root.clone(), - fixture.host.root.join(".logs"), - bun, - secrets, - generation, - openbot_env::Ports::default(), - None, - )); - let receipt = fixture.host.root.join("server/broker.receipt"); - let original = { - let mut children = shell.children.lock().unwrap(); - let (_, server) = children - .iter_mut() - .find(|(name, _)| *name == "server") - .unwrap(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while !receipt.exists() { - assert!( - std::time::Instant::now() < deadline, - "initial server did not receive resolved broker settings: {:?}", - std::fs::read_to_string(fixture.host.root.join("server/broker.checks")) - ); - std::thread::sleep(std::time::Duration::from_millis(10)); - } - assert_eq!( - std::fs::read_to_string(&receipt).unwrap(), - server.id().to_string() - ); - server.kill().unwrap(); - server.id() - }; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8); - loop { - let replacement = shell - .children - .lock() - .unwrap() - .iter_mut() - .find(|(name, child)| *name == "server" && child.id() != original) - .and_then(|(_, child)| child.try_wait().unwrap().is_none().then_some(child.id())); - if let Some(pid) = replacement { - if std::fs::read_to_string(&receipt).ok().as_deref() == Some(&pid.to_string()) { - break; - } - } - assert!( - std::time::Instant::now() < deadline, - "replacement did not receive resolved broker settings" - ); - std::thread::sleep(std::time::Duration::from_millis(10)); - } - let written = std::fs::read_to_string(&path).unwrap(); - assert!(!written.contains("synthetic-native-key")); - assert_eq!(written.contains(supplied_line), supplied); - assert!(shell.last_failure.lock().unwrap().is_none()); - } -} diff --git a/desktop/src-tauri/src/deployment.rs b/desktop/src-tauri/src/deployment.rs index 21b48b83b6..e31b3f6ea3 100644 --- a/desktop/src-tauri/src/deployment.rs +++ b/desktop/src-tauri/src/deployment.rs @@ -223,11 +223,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 @@ -298,7 +346,11 @@ pub type Download<'a> = &'a dyn Fn(&str) -> Result, String>; pub fn fetch_with(root: &Path, version: &str, get: Download) -> Result<(), String> { crate::updates::require_no_interruption(root)?; let policy = crate::updates::distribution(root)?; - if let Some(installed) = installed(root) { + // A release from before the server and worker were Compose services cannot run in this app, + // so `deployment_release` moves it to the latest release. That move is not a runtime update: + // it is the whole layout again, as for a new deployment. + let installed = installed(root).filter(|_| !predates_containers(root)); + if let Some(installed) = installed { if installed.version != version { return Err( "Use the explicit runtime update controls to change an installed release".into(), @@ -478,42 +530,98 @@ fn fetch_images(root: &Path, version: &str, get: Download) -> Result<(), String> .map_err(|error| format!("could not write {IMAGES}: {error}")) } -/// Fetch a URL into memory. -/// -/// Shared with `install.rs`, which fetches the engine's installers through it and then checks their -/// digests. One client, one user agent, one set of TLS defaults. +/** +Fetch a URL into memory, trying again when the connection fails. + +Shared with `install.rs`, which fetches the engine's installers through it and then checks their +digests. One client, one user agent, one set of TLS defaults. + +TRIED UP TO THREE TIMES. One connection cut part-way through Bun's download used to fail the whole +Start ("the download did not finish: error decoding response body"), though the same download +finished in under three seconds a minute later. So a failure that says nothing about the file is +tried again after a short wait: the server could not be reached, the body did not all arrive, or the +server answered 5xx or 429. Any other answer, a 404 in particular, means the file is not there, and +is reported at once: the release lookups depend on seeing that promptly. A second attempt downloads +the whole file again, and `install.rs` still checks the digest of the copy that is kept. +*/ pub fn get(url: &str) -> Result, String> { - get_waiting(url, IDLE_TIMEOUT) + get_with(url, IDLE_TIMEOUT, &RETRY_DELAYS) } /// How long a download may go without receiving anything before it is given up. const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +/// The waits before the second and third attempts. +const RETRY_DELAYS: [std::time::Duration; 2] = [ + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(3), +]; + +/// `get`, with one attempt more than there are `delays`, waiting each delay in turn before trying +/// again. The last error says how many attempts were made, so Technical details shows it tried again. +fn get_with( + url: &str, + idle: std::time::Duration, + delays: &[std::time::Duration], +) -> Result, String> { + let mut attempts = 1; + loop { + match get_waiting(url, idle) { + Ok(body) => return Ok(body), + Err(Failed::ForGood(error)) => return Err(error), + Err(Failed::ForNow(error)) => match delays.get(attempts - 1) { + Some(delay) => { + std::thread::sleep(*delay); + attempts += 1; + } + None if attempts == 1 => return Err(error), + None => return Err(format!("{error} ({attempts} attempts)")), + }, + } + } +} + +/// Why one attempt at a download failed, and so whether another could succeed. +enum Failed { + /// The connection or the server failed; the same request may work in a moment. + ForNow(String), + /// The server answered that it will not give the file. + ForGood(String), +} + /** -`get`, giving up only when nothing arrives for `idle`. +One attempt at `get`, giving up only when nothing arrives for `idle`. READ AS A STREAM, NOT WITH `bytes()`. The blocking client's timeout (30 s by default) bounds each read on a stream, but `bytes()` waits for the whole body within it, and reports running out of time as "error decoding response body". So any download that took longer than 30 seconds failed, however steadily it was arriving: Bun's 30 MB on a connection under about 1 MB/s, every time. */ -fn get_waiting(url: &str, idle: std::time::Duration) -> Result, String> { +fn get_waiting(url: &str, idle: std::time::Duration) -> Result, Failed> { use std::io::Read; let mut response = reqwest::blocking::Client::builder() .user_agent("openbot-desktop") .timeout(idle) .build() - .map_err(|error| format!("could not prepare the download: {error}"))? + .map_err(|error| Failed::ForGood(format!("could not prepare the download: {error}")))? .get(url) .send() - .map_err(|error| format!("could not reach {url}: {error}"))?; - if !response.status().is_success() { - return Err(format!("{url} answered {}", response.status())); + .map_err(|error| Failed::ForNow(format!("could not reach {url}: {error}")))?; + let status = response.status(); + if !status.is_success() { + let error = format!("{url} answered {status}"); + return Err( + if status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS { + Failed::ForNow(error) + } else { + Failed::ForGood(error) + }, + ); } let mut body = Vec::new(); response .read_to_end(&mut body) - .map_err(|error| format!("the download did not finish: {error}"))?; + .map_err(|error| Failed::ForNow(format!("the download did not finish: {error}")))?; Ok(body) } @@ -555,7 +663,7 @@ mod tests { // Six pieces 300 ms apart take 1.8 s in all, longer than the 1 s allowed between pieces. // `bytes()` gave up here with "error decoding response body"; a stream does not. let (url, served) = dripping(6, std::time::Duration::from_millis(300)); - let body = get_waiting(&url, std::time::Duration::from_secs(1)).unwrap(); + let body = get_with(&url, std::time::Duration::from_secs(1), &[]).unwrap(); assert_eq!(body, b"data".repeat(6)); served.join().unwrap(); } @@ -563,11 +671,147 @@ mod tests { #[test] fn a_download_that_stalls_is_given_up() { let (url, served) = dripping(2, std::time::Duration::from_millis(1500)); - let error = get_waiting(&url, std::time::Duration::from_millis(500)).unwrap_err(); + let error = get_with(&url, std::time::Duration::from_millis(500), &[]).unwrap_err(); assert!(error.starts_with("the download did not finish"), "{error}"); served.join().unwrap(); } + /// What one connection to `scripted` gets. + #[derive(Clone, Copy)] + enum Reply { + /// The whole body. + Whole, + /// Half the promised body, then the connection is closed. + Cut, + /// This status and nothing else. + Status(u16), + } + + /// A server that answers its connections with `replies` in order, repeating the last one, and + /// counts them. It is left running; the test process ends it. + fn scripted(replies: Vec) -> (String, std::sync::Arc) { + use std::io::{Read, Write}; + use std::sync::atomic::Ordering; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/file", listener.local_addr().unwrap()); + let count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counted = count.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { return }; + let index = counted.fetch_add(1, Ordering::SeqCst); + let reply = replies[index.min(replies.len() - 1)]; + let mut request = [0u8; 1024]; + let _ = stream.read(&mut request); + let (status, sent) = match reply { + Reply::Whole => (200, &b"datadata"[..]), + Reply::Cut => (200, &b"data"[..]), + Reply::Status(code) => (code, &b"datadata"[..]), + }; + let _ = stream.write_all( + format!( + "HTTP/1.1 {status} X\r\ncontent-length: 8\r\nconnection: close\r\n\r\n" + ) + .as_bytes(), + ); + let _ = stream.write_all(sent); + let _ = stream.flush(); + } + }); + (url, count) + } + + const NO_WAIT: [std::time::Duration; 2] = [std::time::Duration::ZERO; 2]; + + fn get_quickly(url: &str) -> Result, String> { + get_with(url, std::time::Duration::from_secs(5), &NO_WAIT) + } + + fn connections(count: &std::sync::atomic::AtomicUsize) -> usize { + count.load(std::sync::atomic::Ordering::SeqCst) + } + + #[test] + fn a_download_cut_off_once_is_tried_again() { + let (url, count) = scripted(vec![Reply::Cut, Reply::Whole]); + assert_eq!(get_quickly(&url).unwrap(), b"datadata"); + assert_eq!(connections(&count), 2); + } + + #[test] + fn a_download_cut_off_every_time_is_given_up_after_three_attempts() { + let (url, count) = scripted(vec![Reply::Cut]); + let error = get_quickly(&url).unwrap_err(); + assert!(error.starts_with("the download did not finish"), "{error}"); + assert!(error.ends_with("(3 attempts)"), "{error}"); + assert_eq!(connections(&count), 3); + } + + #[test] + fn a_missing_file_is_reported_at_once() { + let (url, count) = scripted(vec![Reply::Status(404), Reply::Whole]); + let error = get_quickly(&url).unwrap_err(); + assert_eq!(error, format!("{url} answered 404 Not Found")); + assert_eq!(connections(&count), 1); + } + + #[test] + fn a_server_error_is_tried_again() { + let (url, count) = scripted(vec![Reply::Status(503), Reply::Whole]); + assert_eq!(get_quickly(&url).unwrap(), b"datadata"); + assert_eq!(connections(&count), 2); + } + + #[test] + fn being_asked_to_slow_down_is_tried_again() { + let (url, count) = scripted(vec![Reply::Status(429), Reply::Whole]); + assert_eq!(get_quickly(&url).unwrap(), b"datadata"); + assert_eq!(connections(&count), 2); + } + + #[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", false).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(), @@ -700,6 +944,66 @@ mod tests { std::fs::remove_dir_all(&root).unwrap(); } + /// A release from before the server was a container is moved to the latest release by + /// `deployment_release`. The runtime update guard must not refuse that move, or such an + /// installation could never Install or Start again. + #[test] + fn a_release_whose_server_ran_on_the_host_is_laid_out_at_the_latest_version() { + let root = crate::test_support::temp_root("deployment-host-server-move"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("docker-compose.yml"), + "services:\n postgres: {}\n supervisor: {}\n", + ) + .unwrap(); + std::fs::write(images_path(&root), "{}").unwrap(); + record(&root, "v0.0.15", false).unwrap(); + assert!(predates_containers(&root)); + + let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut tarball = tar::Builder::new(encoder); + for entry in REQUIRED.iter().chain(ALSO_COPIED.iter()) { + // The latest release's Compose file is a file, with the server and worker in it. + let (path, body) = if *entry == "docker-compose.yml" { + ( + format!("OpenBot-0.0.16/{entry}"), + "services:\n server: {}\n worker: {}\n".to_string(), + ) + } else { + ( + format!("OpenBot-0.0.16/{entry}/release.txt"), + format!("release {entry}"), + ) + }; + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tarball + .append_data(&mut header, path, body.as_bytes()) + .unwrap(); + } + let tarball = tarball.into_inner().unwrap().finish().unwrap(); + let published: Vec<&str> = IMAGE_VARIABLES.iter().map(|(name, _)| *name).collect(); + let images = serde_json::to_vec(&manifest(&published)).unwrap(); + let get = |url: &str| -> Result, String> { + match url { + "https://github.com/CopilotKit/OpenBot/archive/refs/tags/v0.0.16.tar.gz" => { + Ok(tarball.clone()) + } + "https://github.com/CopilotKit/OpenBot/releases/download/v0.0.16/container-images.json" => { + Ok(images.clone()) + } + other => Err(format!("unexpected download {other}")), + } + }; + fetch_with(&root, "v0.0.16", &get).expect("a host-server release moves to latest"); + assert_eq!(installed(&root).unwrap().version, "v0.0.16"); + assert!(!predates_containers(&root)); + assert!(!needs_fetch(&root, "v0.0.16")); + std::fs::remove_dir_all(&root).unwrap(); + } + #[test] fn every_service_compose_can_run_is_pinned_to_a_published_digest() { let published: Vec<&str> = IMAGE_VARIABLES.iter().map(|(name, _)| *name).collect(); diff --git a/desktop/src-tauri/src/deployment_release.rs b/desktop/src-tauri/src/deployment_release.rs index 78420c1d66..fb726c6134 100644 --- a/desktop/src-tauri/src/deployment_release.rs +++ b/desktop/src-tauri/src/deployment_release.rs @@ -9,7 +9,9 @@ use crate::deployment; /// A pinned build always uses the version it was pinned to and never consults GitHub. Any other /// build keeps the release it already follows, so restarts and repairs retain that version; only a -/// new deployment, or one left behind by a pinned build, asks this distribution for its latest +/// new deployment, one left behind by a pinned build, or a release from before the server and +/// worker were Compose services (`deployment::predates_containers`, which this app cannot run and +/// a repair at its own version would only lay out again) asks this distribution for its latest /// release. The fetcher records the exact tag after the source and image manifest have both /// downloaded. Uses blocking HTTP, so callers in an async runtime must use a blocking task. pub fn resolve_version(root: &Path) -> Result { @@ -33,7 +35,9 @@ fn resolve_version_with( return Ok(pin.version.clone()); } match deployment::installed(root) { - Some(installed) if !installed.pinned => Ok(installed.version), + Some(installed) if !installed.pinned && !deployment::predates_containers(root) => { + Ok(installed.version) + } _ => latest(), } } @@ -84,6 +88,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", false).unwrap(); + let version = resolve_version_with(&root, None, || 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_connection.rs b/desktop/src-tauri/src/desktop_connection.rs index f28d00e035..313ccc2521 100644 --- a/desktop/src-tauri/src/desktop_connection.rs +++ b/desktop/src-tauri/src/desktop_connection.rs @@ -46,9 +46,6 @@ pub fn poll(client: &Client, port: u16, token: &str) -> Result { Ok(Some(Connection::Intelligence)) } - ("organization", "organization_authentication_failed") => { - Ok(Some(Connection::Organization)) - } _ => Err("Connection status response contained an unsupported failure.".into()), }, } @@ -77,7 +74,7 @@ mod tests { ( 200, r#"{"connection":"organization","code":"organization_authentication_failed"}"#, - Ok(Some(Connection::Organization)), + Err(()), ), (404, "", Ok(None)), (401, "provider auth failed", Err(())), diff --git a/desktop/src-tauri/src/desktop_page.rs b/desktop/src-tauri/src/desktop_page.rs new file mode 100644 index 0000000000..e1d563a8a8 --- /dev/null +++ b/desktop/src-tauri/src/desktop_page.rs @@ -0,0 +1,127 @@ +//! 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. + +Async, like every command here: on Windows a sync one deadlocked the window (see the test below). +*/ +#[tauri::command] +async 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] +async 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() +} + +#[cfg(test)] +mod tests { + /// Every command here is async. Tauri runs a plugin's command holding its plugin store, and a + /// sync one runs on the main thread: a window change made there (the theme, a size, focus) is + /// delivered on Windows as a window event before the call returns, and dispatching that event + /// takes the plugin store again. `appearance` was sync, and the window stopped responding the + /// first time OpenBot's page reported its theme. An async command runs on a worker, so the + /// window change is queued for the main thread instead. + #[test] + fn every_command_runs_off_the_main_thread() { + let source = include_str!("desktop_page.rs"); + let mut lines = source.lines(); + let mut commands = 0; + while let Some(line) = lines.next() { + if line.trim() != "#[tauri::command]" { + continue; + } + let signature = lines.next().unwrap_or_default().trim(); + commands += 1; + assert!( + signature.starts_with("async fn ") || signature.starts_with("pub async fn "), + "a plugin command must be async: {signature}" + ); + } + assert_eq!( + commands, + super::PERMISSIONS.len(), + "one command per permission" + ); + } +} diff --git a/desktop/src-tauri/src/desktop_status.rs b/desktop/src-tauri/src/desktop_status.rs new file mode 100644 index 0000000000..7432b08176 --- /dev/null +++ b/desktop/src-tauri/src/desktop_status.rs @@ -0,0 +1,153 @@ +//! What the Status window says about what OpenBot is running, and how much memory it takes. +//! +//! OpenBot's containers are the ones that carry this installation's Compose project label, its Bots' +//! computers among them (see `stack::running_containers_of`), and k3d names an Intelligence stack's +//! `k3d--`. Not every `openbot-` container: another project, or a source checkout +//! called `openbot`, names its own that way. 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, +/// the ones named in `owned`, and those of the Intelligence stack `stack`. Anybody else's containers +/// are left out. +pub fn containers(stats: &str, owned: &[String], 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 owned.iter().any(|own| own == name) { + 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 legacy = [ + "openbot-postgres-1".to_owned(), + "openbot-supervisor-1".to_owned(), + ]; + let (openbot, intelligence) = containers(stats, &legacy, 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, &legacy, None).1.is_empty()); + } + + /// Another project's containers can share the `openbot-` prefix; only the ones named as this + /// installation's own are listed. + #[test] + fn containers_named_like_openbots_but_not_its_own_are_left_out() { + let stats = "openbot-postgres-1\t1GiB / 8GiB\n\ + openbot-kite-demo-postgres-1\t1GiB / 8GiB\n\ + openbot-computer-person-me\t200MiB / 8GiB\n"; + let owned = [ + "openbot-postgres-1".to_owned(), + "openbot-computer-person-me".to_owned(), + ]; + let names: Vec = containers(stats, &owned, None) + .0 + .into_iter() + .map(|part| part.name) + .collect(); + assert_eq!(names, ["openbot-postgres-1", "openbot-computer-person-me"]); + } + + #[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, + &["openbot-postgres-1".to_owned()], + 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 43dba263ec..e855c4ea48 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 5090ae82cf..5de3ee9159 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::*; @@ -333,14 +366,12 @@ mod port_tests { 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) @@ -350,6 +381,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"); @@ -606,6 +659,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"] { @@ -695,6 +765,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 { @@ -710,15 +783,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. @@ -767,10 +845,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 @@ -782,12 +863,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. @@ -829,6 +909,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 @@ -979,14 +1065,27 @@ 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; 8] = [ "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, + // Written by earlier apps that could sign in through an organization's OpenBot. This app runs + // OpenBot for one person, so a Start takes it out rather than leave the server expecting it. + "OPENBOT_ORGANIZATION_AUTH_URL", ]; +/// 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 @@ -1733,6 +1832,40 @@ HTTPS_PROXY=http://proxy:8080 } } + /// An earlier app that could sign in through an organization wrote its address here. A Start + /// takes it out, so the server runs for this one person; the person's own lines stay. + #[test] + fn an_earlier_organization_address_is_taken_out_by_a_start() { + let dir = temp_root("env-organization-gone"); + std::fs::create_dir_all(&dir).unwrap(); + let path = env_at(&dir); + std::fs::write( + &path, + "OPENBOT_ORGANIZATION_AUTH_URL=https://openbot.company.example\nMY_OWN=kept\n", + ) + .unwrap(); + let settings = compose( + &intelligence(), + &Model::default(), + &engine_status(None), + &Ports::default(), + &pinned(), + None, + &BTreeMap::new(), + ); + assert!(!settings.contains_key("OPENBOT_ORGANIZATION_AUTH_URL")); + let purge: BTreeMap = REMOVED_WHEN_UNSET + .iter() + .filter(|key| !settings.contains_key(**key)) + .map(|key| (key.to_string(), String::new())) + .collect(); + write(&path, &settings, &purge).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(!text.contains("OPENBOT_ORGANIZATION_AUTH_URL"), "{text}"); + assert!(text.contains("MY_OWN=kept"), "{text}"); + std::fs::remove_dir_all(dir).unwrap(); + } + /// Chrome uninstalled since the last Start: the server must stop being pointed at a browser that /// no longer runs, or it sends the person's Browser button to a port nothing answers on. #[test] @@ -1843,6 +1976,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( @@ -2145,8 +2304,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" ); } @@ -2178,6 +2337,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.rs b/desktop/src-tauri/src/intelligence.rs deleted file mode 100644 index 17b58be19d..0000000000 --- a/desktop/src-tauri/src/intelligence.rs +++ /dev/null @@ -1,1220 +0,0 @@ -//! Signing in to CopilotKit Intelligence, so nobody is sent to a terminal for a key. -//! -//! THE LAST DEVELOPER-SHAPED ASK IN SETUP. Before this, the final screen wanted an "Intelligence -//! project key", and the only way to produce one was `npx copilotkit login` followed by -//! `copilotkit project select`. That is two commands, a terminal and a package manager, for -//! somebody whose entire relationship with this product is a window their IT department sent them. -//! The audience rule says any step that amounts to "go and get something and come back" is a -//! defect, and that was the largest one left. -//! -//! The flow is the CLI's own, done here instead: a loopback callback, an exchange, and a key this -//! deployment provisions for the project the person chose. Reading it out of the CLI rather than -//! inventing it is deliberate — the endpoints, the parameter names and the order all belong to -//! whoever changes them, and guessing at somebody else's auth is how this breaks silently later. - -use std::io::{BufRead, BufReader, Write}; -use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::time::{Duration, Instant}; - -use serde::Deserialize; - -/// Where the sign-in page and the CLI API live. -const OPS_FRONTEND: &str = "https://dashboard.operations.copilotkit.ai"; -pub(crate) const OPS_API: &str = "https://api.operations.copilotkit.ai"; -/// Where projects and their keys live. -const PRODUCT_API: &str = "https://api.intelligence.copilotkit.ai"; - -/// How long somebody gets to finish signing in. -const PATIENCE: Duration = Duration::from_secs(600); - -/// A project somebody can put OpenBot in. -#[derive(Clone, Debug, serde::Serialize, Deserialize, PartialEq, Eq)] -pub struct Project { - pub id: String, - pub name: String, -} - -/** -The callback the browser is sent back to. - -`127.0.0.1` and an ephemeral port, which is what the CLI does: the port is whatever the operating -system had free, so nothing has to be reserved and two sign-ins cannot collide. Never `localhost`, -for the reason the rest of this tree does not use it either. -*/ -pub struct SigningInToIntelligence { - listener: TcpListener, - state: String, - port: u16, -} - -/// What the browser hands back, pulled out of the callback line. -/// -/// Pure so the parsing is testable without a browser: this is a `GET /callback?...` request line, -/// and the two things that matter are in its query. -pub fn callback_values(request_line: &str) -> Option<(String, String)> { - let path = request_line.split_whitespace().nth(1)?; - let query = path.split_once('?')?.1; - let mut state = None; - let mut token = None; - for pair in query.split('&') { - let (key, value) = pair.split_once('=')?; - let value = percent_decode(value); - match key { - "state" => state = Some(value), - "clerkToken" => token = Some(value), - _ => {} - } - } - Some((state?, token?)) -} - -/// Enough percent-decoding for a token and a state, neither of which contains anything exotic. -fn percent_decode(value: &str) -> String { - let bytes = value.replace('+', " "); - let bytes = bytes.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' && i + 2 < bytes.len() { - if let Ok(byte) = u8::from_str_radix(&value[i + 1..i + 3], 16) { - out.push(byte); - i += 3; - continue; - } - } - out.push(bytes[i]); - i += 1; - } - String::from_utf8_lossy(&out).into_owned() -} - -/// The CopilotKit CLI session a browser sign-in produced. -/// -/// No `Debug` and no `Serialize`, and only this crate can read it, so it cannot reach a log, an -/// error or the window. Connecting apps is the one thing that uses it. -#[derive(Clone)] -pub struct OpsSession(String); - -impl OpsSession { - pub fn new(session: String) -> Self { - Self(session) - } - - pub(crate) fn bearer(&self) -> &str { - &self.0 - } -} - -/// What a finished browser sign-in brings back, none of it saved yet. -/// -/// The caller checks that this attempt is still the one the window wants before it keeps any of it. -pub struct SignedIn { - pub credential: String, - pub projects: Vec, - pub session: OpsSession, -} - -impl SigningInToIntelligence { - /// Open the callback and return the address a browser has to visit. - pub fn begin() -> Result<(Self, String), String> { - let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) - .map_err(|error| format!("A sign-in could not be started: {error}"))?; - let port = listener - .local_addr() - .map_err(|error| format!("A sign-in could not be started: {error}"))? - .port(); - // Random, and checked when the browser comes back: without it any page could complete - // somebody else's sign-in by hitting this port. - let state: String = { - use rand::Rng; - let mut rng = rand::rng(); - (0..32) - .map(|_| format!("{:x}", rng.random_range(0..16))) - .collect() - }; - let callback = format!("http://127.0.0.1:{port}/callback"); - // `source=cli&mode=openbot` makes the hosted sign-in page show its OpenBot copy. The pair - // is an allowlist key the page checks; the words themselves live in the page. - let url = format!( - "{OPS_FRONTEND}/cli-auth?callback={}&state={state}&source=cli&mode=openbot", - urlencode(&callback) - ); - Ok(( - Self { - listener, - state, - port, - }, - url, - )) - } - - /// The port the callback is listening on, for anything that needs to say so. - pub fn port(&self) -> u16 { - self.port - } - - /// Wait for the browser, then turn what it brings into a credential and its projects. - /// - /// Nothing is saved here, and the app broker is not contacted. Apps are a follow-on step that - /// can only add a key, so no app failure can fail this sign-in. - pub fn finish(self) -> Result { - self.finish_at(OPS_API, PRODUCT_API) - } - - fn finish_at(self, ops: &str, product_api: &str) -> Result { - let token = self.wait_for_token()?; - let session = exchange_at(ops, &token)?; - let credential = product_credential_at(ops, &session)?; - let projects = list_projects_at(product_api, &credential)?; - Ok(SignedIn { - credential, - projects, - session: OpsSession(session), - }) - } - - fn wait_for_token(&self) -> Result { - self.listener - .set_nonblocking(true) - .map_err(|error| format!("The sign-in could not be watched: {error}"))?; - let began = Instant::now(); - while began.elapsed() < PATIENCE { - match self.listener.accept() { - Ok((stream, _)) => { - if let Some(token) = self.read_callback(stream)? { - return Ok(token); - } - } - Err(ref error) if error.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(200)); - } - Err(error) => return Err(format!("The sign-in could not be read: {error}")), - } - } - Err("That sign-in was not finished in time. Start it again.".into()) - } - - fn read_callback(&self, mut stream: TcpStream) -> Result, String> { - stream - .set_nonblocking(false) - .map_err(|error| format!("The sign-in could not be read: {error}"))?; - let mut line = String::new(); - BufReader::new( - stream - .try_clone() - .map_err(|error| format!("The sign-in could not be read: {error}"))?, - ) - .read_line(&mut line) - .map_err(|error| format!("The sign-in could not be read: {error}"))?; - - let Some((state, token)) = callback_values(&line) else { - reply(&mut stream, "Waiting for the sign-in to finish."); - return Ok(None); - }; - /* - * The state is checked before anything is done with the token. - * - * Anything on this machine can reach a loopback port, so without this a page in any tab - * could complete a sign-in that nobody asked for. - */ - if state != self.state { - reply(&mut stream, "That sign-in did not match. Start it again."); - return Err("That sign-in did not match the one this window started.".into()); - } - reply( - &mut stream, - "Signed in. You can close this tab and go back to OpenBot.", - ); - Ok(Some(token)) - } -} - -/// A small page, so the browser does not sit on a blank tab. -fn reply(stream: &mut TcpStream, said: &str) { - let body = format!( - "OpenBot\ - \ -

{said}

" - ); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - let _ = stream.write_all(response.as_bytes()); - let _ = stream.flush(); -} - -fn urlencode(value: &str) -> String { - value - .chars() - .map(|c| match c { - 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), - other => format!("%{:02X}", other as u32), - }) - .collect() -} - -fn client() -> Result { - reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .map_err(|error| format!("The sign-in could not reach CopilotKit: {error}")) -} - -/** -Read a response as JSON, keeping what actually came back when it will not parse. - -WITHOUT THIS THE FAILURE IS UNDIAGNOSABLE, and it was. A sign-in that got all the way through the -browser ended on "That sign-in returned something unexpected: error decoding response body" — which -says a shape was wrong without saying which, from which endpoint, or what arrived instead. The body -is the only thing that answers any of those, and it is exactly what a two-fold failure is for. - -Capped, because a body that is not JSON is often a whole HTML error page and nobody needs all of -it. Reported as a `Problem`, so the sentence stays the person's and the body stays behind the -disclosure. -*/ -/** -The same body with anything that looks like a credential masked. - -BECAUSE THE DISCLOSURE IS STILL A SCREEN. The body that diagnosed the field-name bug also carried a -live session token, and a person doing the obvious thing with a technical detail is pasting it into -a bug report. What a developer needs from this is the SHAPE — which fields arrived and what they -were called — and the shape survives masking perfectly. -*/ -fn without_credentials(body: &str) -> String { - let Ok(mut raw) = serde_json::from_str::(body) else { - return body.to_string(); - }; - mask(&mut raw); - serde_json::to_string(&raw).unwrap_or_else(|_| body.to_string()) -} - -fn mask(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(fields) => { - for (name, held) in fields.iter_mut() { - let lower = name.to_lowercase(); - let secret = ["token", "key", "secret", "credential", "password"] - .iter() - .any(|word| lower.contains(word)); - if secret && held.is_string() { - *held = serde_json::Value::String("[hidden]".into()); - } else { - mask(held); - } - } - } - serde_json::Value::Array(items) => items.iter_mut().for_each(mask), - _ => {} - } -} - -fn read_json( - response: reqwest::blocking::Response, - what: &str, -) -> Result { - let status = response.status(); - let body = response.text().unwrap_or_default(); - serde_json::from_str(&body).map_err(|error| { - let mut shown = without_credentials(body.trim()); - shown.truncate(2000); - crate::problem::Problem::with( - format!("CopilotKit's {what} came back in a shape OpenBot does not understand."), - format!("HTTP {status}\n{error}\n\n{shown}"), - ) - }) -} - -/** -The session the ops API hands back for a browser sign-in. - -IT IS CALLED `cliToken`, and reading it as `token` was a whole sign-in that failed at the last step. -The alias is kept because this is somebody else's response and the older name may still appear; -being tolerant here costs nothing and being strict cost a person their setup. -*/ -#[derive(Deserialize)] -struct Session { - #[serde(alias = "cliToken", alias = "token")] - cli_token: String, -} - -fn exchange_at(ops: &str, clerk_token: &str) -> Result { - let response = client()? - .post(format!("{ops}/api/cli/auth/session")) - .json(&serde_json::json!({ "clerkToken": clerk_token })) - .send() - .map_err(|error| { - crate::problem::Problem::with("The sign-in could not be completed.", error.to_string()) - })?; - if !response.status().is_success() { - let status = response.status(); - return Err(crate::problem::Problem::with( - "CopilotKit refused that sign-in. Try again.", - format!("HTTP {status}\n{}", response.text().unwrap_or_default()), - )); - } - let raw = read_json(response, "sign-in")?; - serde_json::from_value::(raw.clone()) - .map(|session| session.cli_token) - .map_err(|error| { - crate::problem::Problem::with( - "CopilotKit's sign-in came back without the session OpenBot needs.", - format!("{error}\n\n{raw}"), - ) - }) -} - -#[derive(Deserialize)] -struct ProductCredential { - token: String, -} - -#[derive(Deserialize)] -struct ProductCredentialResponse { - #[serde(rename = "productCredential")] - product_credential: ProductCredential, -} - -fn product_credential_at(ops: &str, session: &str) -> Result { - let response = client()? - .post(format!("{ops}/api/cli/auth/product-credential")) - .bearer_auth(session) - .send() - .map_err(|error| { - crate::problem::Problem::with("The sign-in could not be completed.", error.to_string()) - })?; - if !response.status().is_success() { - let status = response.status(); - return Err(crate::problem::Problem::with( - "CopilotKit would not issue a credential for this account.", - format!("HTTP {status}\n{}", response.text().unwrap_or_default()), - )); - } - let raw = read_json(response, "credential")?; - serde_json::from_value::(raw.clone()) - .map(|payload| payload.product_credential.token) - .map_err(|error| { - crate::problem::Problem::with( - "CopilotKit's credential came back in a shape OpenBot does not understand.", - format!("{error}\n\n{raw}"), - ) - }) -} - -fn list_projects_at( - product_api: &str, - product: &str, -) -> Result, crate::problem::Problem> { - let response = client()? - .get(format!("{product_api}/api/projects")) - .bearer_auth(product) - .send() - .map_err(|error| { - crate::problem::Problem::with("Your projects could not be listed.", error.to_string()) - })?; - if !response.status().is_success() { - let status = response.status(); - return Err(crate::problem::Problem::with( - "Your CopilotKit projects could not be listed.", - format!("HTTP {status}\n{}", response.text().unwrap_or_default()), - )); - } - let raw = read_json(response, "project list")?; - let found = projects_in(&raw); - /* - * AN EMPTY LIST AND AN UNREADABLE ONE ARE DIFFERENT THINGS, and telling somebody with projects - * that they have none is the worse of the two. Measured: the sign-in got all the way here and - * the screen said "That account has no projects yet", which was false and which nobody could - * have argued with. If the payload carried something and none of it parsed as a project, the - * shape is what changed, and the shape is what gets shown. - */ - if found.is_empty() && !looks_genuinely_empty(&raw) { - return Err(crate::problem::Problem::with( - "CopilotKit's project list came back in a shape OpenBot does not understand.", - without_credentials(&raw.to_string()), - )); - } - Ok(found) -} - -fn project_creation_body(name: &str) -> Result { - let name = name.trim(); - // Match the product API's existing POST /api/projects schema. - if name.is_empty() || name.encode_utf16().count() > 255 { - return Err(crate::problem::Problem::plain( - "Enter a project name between 1 and 255 characters.", - )); - } - Ok(serde_json::json!({"name": name})) -} - -pub fn create_project(product: &str, name: &str) -> Result { - create_project_at(PRODUCT_API, product, name) -} - -fn create_project_at( - api: &str, - product: &str, - name: &str, -) -> Result { - let body = project_creation_body(name)?; - let response = client()? - .post(format!("{api}/api/projects")) - .bearer_auth(product) - .json(&body) - .send() - .map_err(|error| { - crate::problem::Problem::with("Your project could not be created.", error.to_string()) - })?; - if !response.status().is_success() { - let status = response.status(); - return Err(crate::problem::Problem::with( - "CopilotKit could not create that project. Check the name and try again.", - format!( - "HTTP {status}\n{}", - without_credentials(&response.text().unwrap_or_default()) - ), - )); - } - let raw = read_json(response, "created project")?; - projects_in(&serde_json::json!([raw])).into_iter().next().filter(|project| !project.id.trim().is_empty()).ok_or_else(|| { - crate::problem::Problem::plain("CopilotKit created a project but did not return its ID. Sign in again to refresh the project list.") - }) -} - -/// Whether a payload actually says "no projects" rather than saying something unrecognised. -fn looks_genuinely_empty(raw: &serde_json::Value) -> bool { - let rows = raw - .get("projects") - .or_else(|| raw.get("data")) - .and_then(|value| value.as_array()) - .or_else(|| raw.as_array()); - match rows { - Some(rows) => rows.is_empty(), - None => false, - } -} - -/** -Ask for a key for the project somebody chose. - -`POST /api/keys` with `project_id` and a name, which is the CLI's own call. The name says where the -key came from, because a person looking at a list of keys months later deserves to know which one -their laptop is using. -*/ -/// A project id as the keys endpoint wants it, and unchanged if it is not a number at all. -fn as_number(project_id: &str) -> serde_json::Value { - match project_id.trim().parse::() { - Ok(number) => serde_json::Value::from(number), - Err(_) => serde_json::Value::from(project_id), - } -} - -pub fn provision_key(product: &str, project_id: &str) -> Result { - provision_key_at(PRODUCT_API, product, project_id) -} - -fn provision_key_at( - api: &str, - product: &str, - project_id: &str, -) -> Result { - let response = client()? - .post(format!("{api}/api/keys")) - .bearer_auth(product) - /* - * `project_id` AS A NUMBER, which is what the endpoint's own schema requires. - * - * `api-keys-routes.ts` declares `project_id: z.number().int().positive()` — not `coerce`, - * so the string "7" is rejected outright. Measured as `HTTP 400 VALIDATION_ERROR: Request - * validation failed.` on the last step of a sign-in that had otherwise worked, which is the - * most expensive place in the product to fail. - * - * The id travels as a string because a project list can use either shape (see - * `projects_in`), so it is turned back into a number here, where the requirement is. - */ - .json(&serde_json::json!({ - "project_id": as_number(project_id), - "name": "OpenBot Desktop", - })) - .send() - .map_err(|error| { - crate::problem::Problem::with("A key could not be created.", error.to_string()) - })?; - if !response.status().is_success() { - let status = response.status(); - return Err(crate::problem::Problem::with( - "CopilotKit would not create a key for that project.", - format!("HTTP {status}\n{}", response.text().unwrap_or_default()), - )); - } - let raw = read_json(response, "key")?; - key_in(&raw).ok_or_else(|| { - crate::problem::Problem::with("That key came back without a value in it.", raw.to_string()) - }) -} - -/// Kept in the native shell: only this key and API may inherit the verified learning target. -pub struct ProvisionedConnection { - pub api_key: String, - pub api_url: String, - pub learning_container_id: String, -} - -pub fn provision_connection( - product: &str, - project_id: &str, -) -> Result { - provision_connection_at(PRODUCT_API, product, project_id) -} - -fn provision_connection_at( - api: &str, - product: &str, - project_id: &str, -) -> Result { - let learning_container_id = ensure_learning_container_at(api, product, project_id)?; - let api_key = provision_key_at(api, product, project_id)?; - Ok(ProvisionedConnection { - api_key, - api_url: api.into(), - learning_container_id, - }) -} - -fn learning_response( - request: reqwest::blocking::RequestBuilder, -) -> Result<(reqwest::StatusCode, serde_json::Value), crate::problem::Problem> { - let response = request.send().map_err(|error| { - crate::problem::Problem::with( - "Automatic Learning could not be prepared. Try connecting again.", - error.to_string(), - ) - })?; - let status = response.status(); - let raw = response.json().map_err(|error| { - crate::problem::Problem::with( - "CopilotKit returned an unreadable Learning response. Try connecting again.", - format!("HTTP {status}\n{error}"), - ) - })?; - Ok((status, raw)) -} - -fn learning_status(status: reqwest::StatusCode) -> Result<(), crate::problem::Problem> { - if status.is_success() { - return Ok(()); - } - Err(crate::problem::Problem::with( - "Automatic Learning could not be prepared for this project. Try connecting again.", - format!("HTTP {status}"), - )) -} - -fn verified_learning_container( - raw: &serde_json::Value, - project_id: u64, -) -> Result { - let container = &raw["container"]; - if container["id"].as_str() == Some("openbot") - && container["projectId"].as_u64() == Some(project_id) - { - return Ok("openbot".into()); - } - Err(crate::problem::Problem::plain( - "CopilotKit did not confirm the Learning container for this project. Try connecting again.", - )) -} - -fn ensure_learning_container_at( - api: &str, - product: &str, - project_id: &str, -) -> Result { - let project_id = project_id - .trim() - .parse::() - .ok() - .filter(|id| *id > 0) - .ok_or_else(|| crate::problem::Problem::plain("Choose a valid CopilotKit project."))?; - let client = client()?; - let collection = format!("{api}/api/learning/projects/{project_id}/containers"); - let item = format!("{collection}/openbot"); - let (status, raw) = learning_response(client.get(&item).bearer_auth(product))?; - learning_status(status)?; - // The control API represents absence as 200 {container: null}; a generic 404 is not evidence. - if raw.get("container") != Some(&serde_json::Value::Null) { - return verified_learning_container(&raw, project_id); - } - let (status, raw) = learning_response( - client - .post(&collection) - .bearer_auth(product) - .json(&serde_json::json!({"id":"openbot","name":"OpenBot"})), - )?; - if status == reqwest::StatusCode::CONFLICT - && raw["error"]["code"].as_str() == Some("LEARNING_CONTAINER_ALREADY_EXISTS") - { - // Another setup won the create; read its container once and verify the project again. - let (status, raw) = learning_response(client.get(&item).bearer_auth(product))?; - learning_status(status)?; - return verified_learning_container(&raw, project_id); - } - learning_status(status)?; - verified_learning_container(&raw, project_id) -} - -/** -The key itself, out of whatever the endpoint wrapped it in. - -Tolerant for the same reason the project list is, and pure so it is testable: this is somebody -else's response shape, and a setup that fails at the last step because a field moved is the worst -possible place to be strict. -*/ -pub fn key_in(raw: &serde_json::Value) -> Option { - for at in [ - raw.get("key"), - raw.get("apiKey"), - raw.get("data"), - Some(raw), - ] { - let Some(value) = at else { continue }; - if let Some(text) = value.as_str() { - if !text.trim().is_empty() { - return Some(text.to_string()); - } - } - for field in ["key", "apiKey", "value", "token", "secret"] { - if let Some(text) = value.get(field).and_then(|v| v.as_str()) { - if !text.trim().is_empty() { - return Some(text.to_string()); - } - } - } - } - None -} - -/** -The projects in whatever shape that endpoint answers with. - -Tolerant on purpose, and pure so it can be tested against real payloads: this is somebody else's -API, the response has been a bare array and an object with a `projects` key at different times, and -a setup screen that shows nothing because a wrapper changed is worse than one that shows a list. -*/ -pub fn projects_in(raw: &serde_json::Value) -> Vec { - let rows = raw - .get("projects") - .or_else(|| raw.get("data")) - .and_then(|value| value.as_array()) - .or_else(|| raw.as_array()); - let Some(rows) = rows else { - return Vec::new(); - }; - rows.iter() - .filter_map(|row| { - // THE ID IS A NUMBER, and requiring a string silently dropped every project. The - // account had ten of them and the screen said it had none: `{"id":7,"name":"my-app"}` - // parsed to nothing because `as_str` returns None for `7`. Both shapes are read now, - // because which one an endpoint uses is not ours to decide. - let id = match row.get("id")? { - serde_json::Value::String(text) => text.clone(), - serde_json::Value::Number(number) => number.to_string(), - _ => return None, - }; - let name = row - .get("name") - .and_then(|value| value.as_str()) - .unwrap_or(&id) - .to_string(); - Some(Project { id, name }) - }) - .collect() -} - -#[cfg(test)] -mod tests { - const LEARNING_CONTAINER: &str = - r#"{"container":{"id":"openbot","projectId":42,"name":"OpenBot"}}"#; - const NO_LEARNING_CONTAINER: &str = r#"{"container":null}"#; - - #[test] - fn learning_reuses_the_project_container_without_writing() { - let (url, requests) = response_server(vec![("200 OK", LEARNING_CONTAINER)]); - assert_eq!( - super::ensure_learning_container_at(&url, "product-session", "42").unwrap(), - "openbot" - ); - let requests = requests.join().unwrap(); - assert_eq!(requests.len(), 1); - assert!(requests[0] - .0 - .starts_with("GET /api/learning/projects/42/containers/openbot HTTP/1.1\r\n")); - assert!(requests[0] - .0 - .to_lowercase() - .contains("authorization: bearer product-session\r\n")); - } - - #[test] - fn learning_creates_only_after_an_explicit_missing_container() { - let (url, requests) = response_server(vec![ - ("200 OK", NO_LEARNING_CONTAINER), - ("201 Created", LEARNING_CONTAINER), - ]); - assert_eq!( - super::ensure_learning_container_at(&url, "product-session", "42").unwrap(), - "openbot" - ); - let requests = requests.join().unwrap(); - assert_eq!(requests.len(), 2); - assert!(requests[1] - .0 - .starts_with("POST /api/learning/projects/42/containers HTTP/1.1\r\n")); - assert!(requests[1] - .0 - .to_lowercase() - .contains("authorization: bearer product-session\r\n")); - assert_eq!( - serde_json::from_slice::(&requests[1].1).unwrap(), - serde_json::json!({"id":"openbot","name":"OpenBot"}) - ); - } - - #[test] - fn learning_reads_the_winner_of_an_exact_already_exists_race() { - let (url, requests) = response_server(vec![ - ("200 OK", NO_LEARNING_CONTAINER), - ( - "409 Conflict", - r#"{"error":{"code":"LEARNING_CONTAINER_ALREADY_EXISTS"}}"#, - ), - ("200 OK", LEARNING_CONTAINER), - ]); - assert_eq!( - super::ensure_learning_container_at(&url, "product-session", "42").unwrap(), - "openbot" - ); - let requests = requests.join().unwrap(); - assert_eq!(requests.len(), 3); - assert!(requests[2] - .0 - .starts_with("GET /api/learning/projects/42/containers/openbot HTTP/1.1\r\n")); - } - - #[test] - fn learning_errors_and_malformed_reads_never_create_a_container() { - for (status, body) in [ - ("403 Forbidden", r#"{"error":{"code":"FORBIDDEN"}}"#), - ("404 Not Found", r#"{"error":{"code":"NOT_FOUND"}}"#), - ( - "500 Internal Server Error", - r#"{"error":{"code":"INTERNAL_ERROR"}}"#, - ), - ("200 OK", r#"{}"#), - ("200 OK", r#"{"container":{"id":"other","projectId":42}}"#), - ("200 OK", r#"{"container":{"id":"openbot","projectId":43}}"#), - ("200 OK", "not json"), - ] { - let (url, requests) = response_server(vec![(status, body)]); - let error = super::provision_connection_at(&url, "product-session", "42") - .err() - .expect("a failed Learning lookup must not mint a runtime key"); - if !status.starts_with("200") { - assert!(error - .detail - .unwrap() - .contains(&format!("HTTP {}", &status[..3]))); - } - assert_eq!(requests.join().unwrap().len(), 1); - } - } - - #[test] - fn learning_create_failures_and_mismatched_responses_are_not_accepted() { - for (status, body) in [ - ("409 Conflict", r#"{"error":{"code":"OTHER_CONFLICT"}}"#), - ("403 Forbidden", r#"{"error":{"code":"FORBIDDEN"}}"#), - ("201 Created", NO_LEARNING_CONTAINER), - ( - "201 Created", - r#"{"container":{"id":"openbot","projectId":43}}"#, - ), - ] { - let (url, requests) = - response_server(vec![("200 OK", NO_LEARNING_CONTAINER), (status, body)]); - let error = - super::ensure_learning_container_at(&url, "product-session", "42").unwrap_err(); - if !status.starts_with("201") { - assert!(error - .detail - .unwrap() - .contains(&format!("HTTP {}", &status[..3]))); - } - assert_eq!(requests.join().unwrap().len(), 2); - } - } - - #[test] - fn learning_race_still_requires_a_verified_container() { - for (status, body) in [ - ("200 OK", NO_LEARNING_CONTAINER), - ( - "500 Internal Server Error", - r#"{"error":{"code":"INTERNAL_ERROR"}}"#, - ), - ] { - let (url, requests) = response_server(vec![ - ("200 OK", NO_LEARNING_CONTAINER), - ( - "409 Conflict", - r#"{"error":{"code":"LEARNING_CONTAINER_ALREADY_EXISTS"}}"#, - ), - (status, body), - ]); - assert!(super::ensure_learning_container_at(&url, "product-session", "42").is_err()); - assert_eq!(requests.join().unwrap().len(), 3); - } - } - - #[test] - fn learning_transport_failure_is_reported() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - drop(listener); - let error = super::ensure_learning_container_at(&url, "product-session", "42").unwrap_err(); - assert!(error.said.contains("could not be prepared")); - assert!(!error.detail.unwrap().contains("product-session")); - } - - #[test] - fn learning_provisioning_returns_proof_bound_to_the_minted_runtime_key_and_api() { - let (url, requests) = response_server(vec![ - ("200 OK", LEARNING_CONTAINER), - ("201 Created", r#"{"key":"runtime-key"}"#), - ]); - let proof = super::provision_connection_at(&url, "product-session", "42").unwrap(); - assert_eq!(proof.api_key, "runtime-key"); - assert_eq!(proof.api_url, url); - assert_eq!(proof.learning_container_id, "openbot"); - let requests = requests.join().unwrap(); - assert!(requests[1].0.starts_with("POST /api/keys HTTP/1.1\r\n")); - assert!(requests.iter().all(|(headers, _)| headers - .to_lowercase() - .contains("authorization: bearer product-session\r\n"))); - } - - #[test] - #[ignore = "requires an authorized isolated Intelligence project and product credential"] - fn live_learning_container_create_and_reuse() { - let product = std::env::var("OPENBOT_LIVE_INTELLIGENCE_PRODUCT_CREDENTIAL") - .expect("provide a product credential through the environment"); - let project = std::env::var("OPENBOT_LIVE_INTELLIGENCE_PROJECT_ID") - .expect("provide an isolated test project ID"); - let first = - super::ensure_learning_container_at(super::PRODUCT_API, &product, &project).unwrap(); - let second = - super::ensure_learning_container_at(super::PRODUCT_API, &product, &project).unwrap(); - assert_eq!(first, "openbot"); - assert_eq!(first, second); - println!("verified Learning container openbot in the isolated project; repeated provisioning reused it"); - } - - #[test] - fn project_creation_trims_names_and_rejects_empty_or_overlong_names() { - assert_eq!( - super::project_creation_body(" Desktop test ").unwrap(), - serde_json::json!({"name":"Desktop test"}) - ); - assert!(super::project_creation_body(" \n ").is_err()); - assert!(super::project_creation_body(&"x".repeat(256)).is_err()); - assert!(super::project_creation_body(&"x".repeat(255)).is_ok()); - } - - #[test] - fn creates_project_using_the_signed_in_credential_and_parses_numeric_id() { - let (url, request) = response_server(vec![( - "201 Created", - r#"{"id":42,"name":"Desktop validation"}"#, - )]); - let project = - super::create_project_at(&url, "synthetic-product-session", " Desktop validation ") - .unwrap(); - assert_eq!( - project, - super::Project { - id: "42".into(), - name: "Desktop validation".into() - } - ); - let (headers, body) = request.join().unwrap().pop().unwrap(); - assert!(headers.starts_with("POST /api/projects HTTP/1.1\r\n")); - assert!(headers - .to_lowercase() - .contains("authorization: bearer synthetic-product-session\r\n")); - assert_eq!( - serde_json::from_slice::(&body).unwrap(), - serde_json::json!({"name":"Desktop validation"}) - ); - } - - #[test] - fn project_creation_preserves_api_failure_and_rejects_missing_id() { - for (status, body) in [ - ("403 Forbidden", r#"{"error":"not allowed"}"#), - ("201 Created", r#"{"name":"No ID"}"#), - ] { - let (url, request) = response_server(vec![(status, body)]); - let error = - super::create_project_at(&url, "synthetic-product-session", "Desktop validation") - .unwrap_err(); - if status.starts_with("403") { - assert!(error.detail.unwrap().contains("HTTP 403")); - } else { - assert!(error.said.contains("did not return its ID")); - } - request.join().unwrap(); - } - } - - use crate::http_test_support::response_server; - /// The field name that broke a whole sign-in, read off the real response. - #[test] - fn the_session_is_read_from_the_name_the_endpoint_uses() { - // Verbatim shape from the ops API, with the value replaced. - let body = r#"{"cliToken":"abc","organization":{"organizationName":"CopilotKit"}}"#; - let session: super::Session = serde_json::from_str(body).expect("cliToken was not read"); - assert_eq!(session.cli_token, "abc"); - // The older name still works, because being strict here is what cost the setup. - let older: super::Session = serde_json::from_str(r#"{"token":"xyz"}"#).unwrap(); - assert_eq!(older.cli_token, "xyz"); - } - - /// A body shown to a person keeps its shape and loses its credentials. - #[test] - fn the_shown_body_has_no_credentials_left_in_it() { - let body = r#"{"cliToken":"live-secret","user":{"email":"a@b.c"},"apiKey":"another"}"#; - let shown = super::without_credentials(body); - assert!(!shown.contains("live-secret"), "{shown}"); - assert!(!shown.contains("another"), "{shown}"); - // The shape is the whole point of showing it at all. - assert!(shown.contains("cliToken") && shown.contains("email") && shown.contains("a@b.c")); - } - - /// The keys endpoint takes a number, and sending a string failed the whole sign-in. - #[test] - fn the_project_id_is_sent_as_the_number_the_endpoint_requires() { - assert_eq!(super::as_number("7"), serde_json::json!(7)); - assert_eq!(super::as_number(" 11 "), serde_json::json!(11)); - // A self-hosted deployment could use a real string id; that is not ours to mangle. - assert_eq!(super::as_number("p_abc"), serde_json::json!("p_abc")); - } - - /// A numeric id is still an id, and requiring a string hid every project this account had. - #[test] - fn projects_are_read_whether_the_id_is_a_number_or_a_string() { - // Verbatim shape from the product API, trimmed. - let real: serde_json::Value = serde_json::from_str( - r#"{"projects":[{"createdAt":"2026-06-17T21:52:32.994Z","id":7,"name":"my-app","slug":"my-app"},{"id":11,"name":"Test Project"}]}"#, - ) - .unwrap(); - let found = super::projects_in(&real); - assert_eq!(found.len(), 2, "a numeric id dropped the project"); - assert_eq!(found[0].id, "7"); - assert_eq!(found[0].name, "my-app"); - - // A string id keeps working, because some endpoints do use one. - let text: serde_json::Value = - serde_json::from_str(r#"[{"id":"p_1","name":"One"}]"#).unwrap(); - assert_eq!(super::projects_in(&text)[0].id, "p_1"); - } - - /// An empty answer and an unreadable one are told apart, because one of them is a lie. - #[test] - fn a_payload_we_cannot_read_is_not_reported_as_no_projects() { - let empty: serde_json::Value = serde_json::from_str(r#"{"projects":[]}"#).unwrap(); - assert!(super::looks_genuinely_empty(&empty)); - assert!(super::looks_genuinely_empty(&serde_json::json!([]))); - - // A shape nobody recognises is not an empty list, and saying so is the bug. - let odd: serde_json::Value = - serde_json::from_str(r#"{"items":[{"id":"p1","name":"One"}]}"#).unwrap(); - assert!(!super::looks_genuinely_empty(&odd)); - assert!(super::projects_in(&odd).is_empty()); - } - - /// Something that is not JSON is still worth showing, unchanged. - #[test] - fn a_body_that_is_not_json_is_shown_as_it_arrived() { - assert_eq!( - super::without_credentials("502"), - "502" - ); - } - - use super::*; - - #[test] - fn the_callback_gives_up_its_state_and_token() { - let line = "GET /callback?state=abc123&clerkToken=tok_xyz HTTP/1.1"; - assert_eq!( - callback_values(line), - Some(("abc123".into(), "tok_xyz".into())) - ); - } - - /// Percent-encoded values come back decoded, since a token may carry them. - #[test] - fn an_encoded_value_is_decoded() { - let line = "GET /callback?state=a%2Db&clerkToken=x%20y HTTP/1.1"; - assert_eq!(callback_values(line), Some(("a-b".into(), "x y".into()))); - } - - /// Anything that is not the callback is ignored rather than treated as a sign-in. - #[test] - fn a_request_that_is_not_the_callback_yields_nothing() { - assert_eq!(callback_values("GET /favicon.ico HTTP/1.1"), None); - assert_eq!(callback_values("GET /callback HTTP/1.1"), None); - assert_eq!(callback_values(""), None); - } - - /// Both shapes that endpoint has answered with, because a wrapper changing should not empty - /// the screen. - #[test] - fn projects_are_read_from_either_shape() { - let wrapped = serde_json::json!({"projects": [{"id": "p1", "name": "Ledgerline"}]}); - let bare = serde_json::json!([{"id": "p1", "name": "Ledgerline"}]); - let expected = vec![Project { - id: "p1".into(), - name: "Ledgerline".into(), - }]; - assert_eq!(projects_in(&wrapped), expected); - assert_eq!(projects_in(&bare), expected); - } - - /// A project with no name is listed under its id rather than dropped. - #[test] - fn a_nameless_project_is_still_offered() { - let raw = serde_json::json!([{"id": "p2"}]); - assert_eq!( - projects_in(&raw), - vec![Project { - id: "p2".into(), - name: "p2".into() - }] - ); - } - - /// The key, wherever that response decided to put it. - #[test] - fn the_key_is_found_in_the_shapes_that_endpoint_uses() { - for raw in [ - serde_json::json!({"key": "cpk-abc"}), - serde_json::json!({"apiKey": "cpk-abc"}), - serde_json::json!({"key": {"value": "cpk-abc"}}), - serde_json::json!({"data": {"key": "cpk-abc"}}), - ] { - assert_eq!(key_in(&raw).as_deref(), Some("cpk-abc"), "{raw}"); - } - } - - /// A response with no key is a failure to report, not an empty string to write into `.env`. - #[test] - fn a_response_without_a_key_yields_nothing() { - assert_eq!(key_in(&serde_json::json!({"key": ""})), None); - assert_eq!(key_in(&serde_json::json!({"unexpected": true})), None); - } - - #[test] - fn nothing_readable_is_an_empty_list_rather_than_a_crash() { - assert!(projects_in(&serde_json::json!({"unexpected": true})).is_empty()); - } - - /// The callback is opened on a loopback address, never a name. - #[test] - fn the_callback_is_loopback_and_the_url_carries_it() { - let (signing, url) = SigningInToIntelligence::begin().expect("it did not start"); - assert!(url.starts_with(OPS_FRONTEND), "{url}"); - assert!(url.contains("127.0.0.1"), "{url}"); - assert!(!url.contains("localhost"), "{url}"); - assert!(signing.port() > 0); - } - - /// The pair `source=cli` and `mode=openbot` makes the hosted sign-in page show its OpenBot - /// copy. The pair is an allowlist key the page checks; the words themselves live in the page, - /// never in this URL. - #[test] - fn the_url_names_the_openbot_handoff_context() { - let (_signing, url) = SigningInToIntelligence::begin().expect("it did not start"); - assert!(url.contains("source=cli"), "{url}"); - assert!(url.contains("mode=openbot"), "{url}"); - } - - /// Apps cannot fail CopilotKit sign-in. Composio provisioning sat in front of the credential, - /// so before Ops could provision every managed sign-in failed there. A browser sign-in now asks - /// Ops for its session and credential, lists the projects, and never calls app provisioning. - #[test] - fn a_finished_sign_in_never_waits_on_app_provisioning() { - let (ops, ops_requests) = response_server(vec![ - ("200 OK", r#"{"cliToken":"synthetic-ops-session"}"#), - ( - "200 OK", - r#"{"productCredential":{"token":"synthetic-product"}}"#, - ), - ]); - let (product, product_requests) = response_server(vec![( - "200 OK", - r#"{"projects":[{"id":7,"name":"my-app"}]}"#, - )]); - let (signing, _) = SigningInToIntelligence::begin().unwrap(); - let port = signing.port(); - let callback = format!( - "GET /callback?state={}&clerkToken=synthetic-clerk HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", - signing.state - ); - let browser = std::thread::spawn(move || { - use std::io::{Read, Write}; - let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); - stream.write_all(callback.as_bytes()).unwrap(); - let mut reply = String::new(); - let _ = stream.read_to_string(&mut reply); - }); - let signed_in = signing.finish_at(&ops, &product).unwrap(); - browser.join().unwrap(); - assert_eq!(signed_in.credential, "synthetic-product"); - assert_eq!(signed_in.projects.len(), 1); - assert_eq!(signed_in.session.bearer(), "synthetic-ops-session"); - let called: Vec = ops_requests - .join() - .unwrap() - .into_iter() - .map(|(headers, _)| headers.lines().next().unwrap_or_default().to_owned()) - .collect(); - assert_eq!( - called, - [ - "POST /api/cli/auth/session HTTP/1.1", - "POST /api/cli/auth/product-credential HTTP/1.1" - ] - ); - assert_eq!(product_requests.join().unwrap().len(), 1); - } -} - -#[cfg(test)] -mod wire { - /// What the window actually receives, which is the only thing that decides what it can render. - #[test] - fn a_project_reaches_the_window_with_both_fields() { - let project = super::Project { - id: "7".into(), - name: "my-app".into(), - }; - let json = serde_json::to_string(&project).unwrap(); - assert_eq!( - json, r#"{"id":"7","name":"my-app"}"#, - "the wire shape changed" - ); - } -} 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 7071750f62..71759b9a0c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,6 +1,5 @@ //! The parts of the shell that are worth testing without a window around them. pub mod acquire; -pub mod ask; pub mod composio; pub mod deployment; pub mod deployment_release; @@ -10,9 +9,9 @@ pub mod env; pub mod harness; pub mod host_access; pub mod install; -pub mod intelligence; pub mod local_browser; -pub mod organization_auth; +pub mod local_eval; +pub mod local_eval_state; pub mod plan; pub mod preparation; pub mod problem; @@ -24,7 +23,6 @@ pub mod recorder_external; pub mod recorder_host; pub mod recorder_install; pub mod saved_intent; -pub mod self_hosted_intelligence; pub mod shell_updates; pub mod stack; pub mod supervise; diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs new file mode 100644 index 0000000000..8a5cea18c0 --- /dev/null +++ b/desktop/src-tauri/src/local_eval.rs @@ -0,0 +1,4299 @@ +//! 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`, `connect` and `login --help` lists and the sign-in's +//! `--mode openbot`, 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"]; + +/// What `login` adds so the hosted sign-in page explains OpenBot's setup instead of the generic +/// CopilotKit pitch (`source=cli&mode=openbot`, Intelligence#2214 and #2240). Display only: who +/// signs in, what is approved and the token are the same either way. +pub const OPENBOT_SIGN_IN_MODE: [&str; 2] = ["--mode", "openbot"]; + +/// `copilotkit login --help`, read only, to learn whether this CLI takes `OPENBOT_SIGN_IN_MODE`. +pub const LOGIN_HELP_ARGUMENTS: [&str; 2] = ["login", "--help"]; + +/// The account sign-in's arguments: with `OPENBOT_SIGN_IN_MODE` only for a CLI that takes it. A +/// CLI from before the flag refuses it with a usage error and signs nobody in. +pub fn account_sign_in_arguments(takes_mode: bool) -> Vec<&'static str> { + let mut arguments = ACCOUNT_SIGN_IN_ARGUMENTS.to_vec(); + if takes_mode { + arguments.extend(OPENBOT_SIGN_IN_MODE); + } + arguments +} + +/// Whether `login --help` lists `--mode openbot`. Help is the CLI's own fixed text, read here and +/// never passed on. +pub fn help_lists_openbot_mode(exit_code: i32, stdout: &[u8]) -> bool { + exit_code == 0 + && stdout.len() <= MAX_OUTPUT + && std::str::from_utf8(stdout).is_ok_and(|help| help.contains("--mode openbot")) +} + +/// 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 arguments = account_sign_in_arguments(login_takes_mode(cli, cancel)?); + let (code, bytes) = execute(cli, &arguments, None, limits, cancel)?; + normalize_account_sign_in(code, &bytes) +} + +/** +Whether this CLI takes `OPENBOT_SIGN_IN_MODE`, asked of its own `login --help` once per CLI. + +The CLI is pinned by digest, so the answer holds for as long as those bytes run from that path. A help call that +fails or times out reads as no: the sign-in then runs as it always did, with the generic page. A +cancelled one is the sign-in cancelled. +*/ +fn login_takes_mode(cli: &PinnedCli, cancel: &Cancel) -> Result { + type Seen = (PathBuf, String, bool); + static KNOWN: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let seen = |found: &Seen| found.0 == cli.program && found.1 == cli.sha256; + if let Some((_, _, takes)) = KNOWN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .find(|found| seen(found)) + { + return Ok(*takes); + } + let limits = Limits { + timeout: Duration::from_secs(30), + grace: Duration::from_secs(2), + output: 64 * 1024, + }; + let takes = match execute(cli, &LOGIN_HELP_ARGUMENTS, None, limits, cancel) { + Ok((code, bytes)) => help_lists_openbot_mode(code, &bytes), + Err(Failure::Cancelled) => return Err(Failure::Cancelled), + Err(_) => return Ok(false), + }; + KNOWN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push((cli.program.clone(), cli.sha256.clone(), takes)); + Ok(takes) +} + +/// `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; + } + _ => {} + } + // The CLI's help for `login`: `help` beside the program, or a CLI from before `--mode`. + if args == ["login", "--help"] { + let asked = std::fs::read_to_string(dir.join("help-asked")).unwrap_or_default(); + std::fs::write(dir.join("help-asked"), format!("{asked}x")).unwrap(); + let help = std::fs::read_to_string(dir.join("help")).unwrap_or_else(|_| { + "Usage: copilotkit login [--json [--force] | --device]\n".to_owned() + }); + print!("{help}"); + 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:?}" + ); + } + } + + /// #991: the sign-in page a person sees explains OpenBot's setup. The CLI carries that as + /// `login --mode openbot`, once it has the flag; an older one would refuse it and sign nobody in. + #[test] + fn the_account_sign_in_asks_for_openbots_sign_in_page_when_the_cli_takes_it() { + let signed_in = "0\n{\"type\":\"completed\"}\n"; + let current = Fixture::new("local-eval-sign-in-mode", signed_in); + std::fs::write( + current.root.join("help"), + "Usage: copilotkit login [--json [--force] | --device] [--mode openbot]\n", + ) + .unwrap(); + assert_eq!( + account_sign_in( + ¤t.cli(), + Limits::for_account_sign_in(), + &Cancel::default() + ), + Ok(AccountSignIn { signed_in: true }) + ); + assert_eq!( + current.argv().as_deref(), + Some("login\n--json\n--mode\nopenbot") + ); + + let older = Fixture::new("local-eval-sign-in-no-mode", signed_in); + assert_eq!( + account_sign_in( + &older.cli(), + Limits::for_account_sign_in(), + &Cancel::default() + ), + Ok(AccountSignIn { signed_in: true }) + ); + assert_eq!(older.argv().as_deref(), Some("login\n--json")); + + // Asked once per CLI: these bytes do not change while they run. + account_sign_in( + ¤t.cli(), + Limits::for_account_sign_in(), + &Cancel::default(), + ) + .unwrap(); + assert_eq!( + std::fs::read_to_string(current.root.join("help-asked")).unwrap(), + "x" + ); + } + + #[test] + fn only_a_help_that_lists_the_openbot_mode_says_the_cli_takes_it() { + assert!(help_lists_openbot_mode( + 0, + b" --mode openbot Show the OpenBot setup text\n" + )); + assert!(!help_lists_openbot_mode( + 0, + b"Usage: copilotkit login [--json | --device]\n" + )); + assert!(!help_lists_openbot_mode(1, b"--mode openbot")); + assert!(!help_lists_openbot_mode(0, &[0xff, 0xfe])); + } + + #[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 532f1d139f..acaadc55ed 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -6,36 +6,32 @@ 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, shell_updates, stack, supervise, telemetry, tray, updates, 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}; use tauri::{Emitter, Manager}; -/// A finished CopilotKit sign-in, bound to the folder and the attempt it was started for. -/// -/// Neither the credential nor the Ops session leaves this side: the window only ever names the -/// folder and the project. -struct ManagedSignIn { - root: PathBuf, - generation: u64, - credential: String, - session: openbot_desktop_lib::intelligence::OpsSession, -} - /// What the shell is running, so the window and the tray say the same thing. #[derive(Default)] struct Shell { @@ -44,22 +40,23 @@ struct Shell { update_cancelled: std::sync::atomic::AtomicBool, /// 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, @@ -81,22 +78,6 @@ struct Shell { containers: Mutex>, /// A verified down allows a following Stop/Quit to be an idempotent no-op. stopped_container_root: Mutex>, - /// An Intelligence sign-in waiting for its loopback callback. - signing_in_to_intelligence: Mutex< - Option<( - PathBuf, - u64, - openbot_desktop_lib::intelligence::SigningInToIntelligence, - )>, - >, - intelligence_sign_in_generation: std::sync::atomic::AtomicU64, - /// The sign-in that finished, held so a project can be chosen with it and apps connected. - intelligence_credential: Mutex>, - /// Proof that this runtime key's project has the default Learning container. - intelligence_learning: Mutex>, - self_hosted_intelligence: - Mutex>>, - self_hosted_sign_in_generation: std::sync::atomic::AtomicU64, /// A ChatGPT sign-in waiting for the browser redirect to complete it. /// /// Held for the same reason the Claude one is: a person leaves and comes back in the middle. @@ -111,6 +92,21 @@ 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 origins of the OpenBot app this shell verified it owns (`trust_owned_app`). Only a page + /// from one of these is treated as OpenBot and gets the window's controls; any other page on + /// a loopback port is someone else's. + owned_app_origins: Mutex>, + /// The origins already given the window's controls (`allow_app_page`). + app_page_grants: Mutex>, } /// The deployment whose Compose up may have created containers, including a partial failure. @@ -228,7 +224,7 @@ impl Drop for StartAttempt<'_> { } } -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, Debug, PartialEq)] struct Progress { step: String, ok: bool, @@ -236,6 +232,9 @@ struct Progress { running: bool, #[serde(rename = "downloadBytes", skip_serializing_if = "Option::is_none")] download_bytes: Option, + /// Install's image downloads, while they run: see `pull_metrics::Downloads`. + #[serde(skip_serializing_if = "Option::is_none")] + downloads: Option, } #[derive(Serialize)] @@ -260,7 +259,6 @@ struct SavedModelSessions { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SavedConfiguration { - intelligence_api_key: Option, model_api_keys: SavedModelApiKeys, model_sessions: SavedModelSessions, model: Option, @@ -276,6 +274,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. @@ -410,6 +412,7 @@ fn report( detail: detail.into(), running: false, download_bytes: None, + downloads: None, }, ); } @@ -428,25 +431,31 @@ fn report_running( detail: detail.into(), running: true, download_bytes, + downloads: None, }, ); } -fn cancel_self_hosted_sign_in(shell: &Shell) { - shell - .self_hosted_sign_in_generation - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let signing = shell.self_hosted_intelligence.lock().unwrap().take(); - if let Some(signing) = signing { - signing.cancel(); - } +/// Install's running image downloads: the step's sentence, the overall bytes as `downloadBytes` +/// for anything that reads only that, and the per-image detail. +fn report_downloads( + app: &tauri::AppHandle, + downloads: pull_metrics::Downloads, +) { + let _ = app.emit( + "setup:progress", + Progress { + step: "images".into(), + ok: true, + detail: "Downloading local software.".into(), + running: true, + download_bytes: (downloads.bytes > 0).then_some(downloads.bytes), + downloads: Some(downloads), + }, + ); } fn remember_selected_root(shell: &Shell, root: &Path) { - let changed = shell.selected_root.lock().unwrap().as_deref() != Some(root); - if changed { - cancel_self_hosted_sign_in(shell); - } *shell.selected_root.lock().unwrap() = Some(root.to_path_buf()); } @@ -484,7 +493,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 { @@ -496,6 +513,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, }; @@ -521,6 +541,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 @@ -638,7 +712,7 @@ async fn stage_runtime_update( for (index,image) in images.iter().enumerate() { let detail=format!("Staging update software ({}/{}).",index+1,images.len()); report_running(&handle,"update-images",&detail,None); - pull_metrics::pull_image(&address,image,|bytes|report_running(&handle,"update-images",&detail,Some(bytes)),|metrics|desktop_telemetry::pull_completed(&handle,metrics))?; + pull_metrics::pull_image(&address,image,|bytes|report_running(&handle,"update-images",&detail,Some(bytes.bytes)),|metrics|desktop_telemetry::pull_completed(&handle,metrics))?; } updates::mark_images_staged(&root,saved)?; Ok::<_,Problem>(staged) @@ -652,12 +726,8 @@ async fn stage_runtime_update( async fn apply_runtime_update( app: tauri::AppHandle, root: String, - api_url: String, - gateway_ws_url: String, - api_key: String, model: ChosenModel, harness: Option, - organization_auth_url: Option, backup_acknowledged: bool, ) -> Result { refuse_runtime_update_in_pinned_build()?; @@ -767,15 +837,23 @@ async fn apply_runtime_update( )); } updates::mark_starting(&root, &mut journal)?; + // The local Intelligence addresses this installation last started with. The window holds them + // only while its local sequence runs, and an update is applied from a stopped screen too. + let mut addresses = openbot_env::already_set( + &root.join(".env"), + &["INTELLIGENCE_API_URL", "INTELLIGENCE_GATEWAY_WS_URL"], + ); + let api_url = addresses.remove("INTELLIGENCE_API_URL").unwrap_or_default(); + let gateway_ws_url = addresses + .remove("INTELLIGENCE_GATEWAY_WS_URL") + .unwrap_or_default(); let launched = start_stack_inner( app.clone(), root.clone(), api_url, gateway_ws_url, - api_key, model, harness, - organization_auth_url, ) .await; if let Err(problem) = launched { @@ -1178,6 +1256,9 @@ async fn prepare_installation_inner( if saved.is_none() { preparation::save_engine(&root, address.engine)?; } + // And the Compose project it runs as, decided once on that engine and recorded beside it, + // before anything is created under it. See `stack::project`. + stack::project(&address, &root)?; let picked = harness::picked(harness.as_ref(), &root).map_err(Problem::from)?; let handle = app.clone(); let _startup = attempt.lock_current()?; @@ -1200,34 +1281,16 @@ async fn prepare_installation_inner( 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 person's own + * browser, has them prepared by the first Start that runs it. + */ report_running( &handle, "images", @@ -1248,21 +1311,29 @@ async fn prepare_installation_inner( } images.sort(); images.dedup(); - for (index, image) in images.iter().enumerate() { - attempt.require_current()?; - let detail = format!( - "Downloading local software ({}/{}).", - index + 1, - images.len() - ); - report_running(&handle, "images", &detail, None); - pull_metrics::pull_image( - &address, - image, - |bytes| report_running(&handle, "images", &detail, Some(bytes)), - |metrics| desktop_telemetry::pull_completed(&handle, metrics), - )?; - } + // Several pulls run at once. Stop is checked before each pull starts; running pulls finish. + let pacer = Mutex::new(pull_metrics::Pacer::default()); + pull_metrics::pull_images_limited( + &images, + pull_metrics::IMAGE_PULLS_AT_ONCE, + |_, image, on_bytes| { + pull_metrics::pull_image(&address, image, on_bytes, |metrics| { + desktop_telemetry::pull_completed(&handle, metrics) + }) + }, + || attempt.require_current(), + |downloads| { + // `done` counts FINISHED pulls; the bytes are the sum over every pull, running and + // finished. Paced to a few reports a second, which also measures the speed. + let paced = pacer + .lock() + .unwrap() + .offer(std::time::Instant::now(), downloads); + if let Some(downloads) = paced { + report_downloads(&handle, downloads); + } + }, + )?; report( &handle, "images", @@ -1366,6 +1437,7 @@ async fn engine_ready( ) })??; } + local_eval::use_engine(&ready.address); report(app, "engine", true, ready.detail); return Ok(ready.address); } @@ -1410,7 +1482,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), ) }) @@ -1437,6 +1509,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); } @@ -1444,7 +1517,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 { @@ -1482,6 +1567,7 @@ async fn engine_ready( ready.detail, ) })?; + local_eval::use_engine(&address); report(app, "engine", true, "The container engine is answering."); Ok(address) } @@ -1501,6 +1587,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. @@ -1564,6 +1665,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", @@ -1781,70 +1895,23 @@ fn saved_secret(root: &Path, key: &str) -> Result { .map(|found| found.get(key).cloned().unwrap_or_default()) } +/// The key `local connect` gave OpenBot for its local Intelligence stack, from the credential store. fn intelligence_key_for_start( root: &Path, - given: String, mut resolve: impl FnMut(&Path, &str) -> Result, ) -> Result { - let key = if given.trim().is_empty() { - resolve(root, "INTELLIGENCE_API_KEY").map_err(|problem| { - problem.connection(openbot_desktop_lib::problem::Connection::Intelligence) - })? - } else { - given - }; + let key = resolve(root, "INTELLIGENCE_API_KEY").map_err(|problem| { + problem.connection(openbot_desktop_lib::problem::Connection::Intelligence) + })?; if key.trim().is_empty() { - return Err(Problem::plain("That saved CopilotKit connection is no longer available. Sign in again or enter a project key.") - .connection(openbot_desktop_lib::problem::Connection::Intelligence)); + return Err(Problem::plain( + "OpenBot's connection to Intelligence on this computer is no longer available. Set up Intelligence again.", + ) + .connection(openbot_desktop_lib::problem::Connection::Intelligence)); } Ok(key) } -fn seed_learning_default( - root: &Path, - settings: &mut std::collections::BTreeMap, - proof: Option<&openbot_desktop_lib::intelligence::ProvisionedConnection>, -) -> Result<(), Problem> { - const TARGET: &str = "CPK_INTELLIGENCE_LEARNING_CONTAINER_ID"; - let Some(proof) = proof else { - return Ok(()); - }; - if settings.get("INTELLIGENCE_API_KEY") != Some(&proof.api_key) - || settings - .get("INTELLIGENCE_API_URL") - .map(|api| api.trim_end_matches('/')) - != Some(proof.api_url.trim_end_matches('/')) - || settings.contains_key(TARGET) - { - return Ok(()); - } - let existing = match std::fs::read_to_string(root.join(".env")) { - Ok(existing) => existing, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => { - return Err(Problem::with( - "OpenBot could not read its Learning settings.", - error.to_string(), - )); - } - }; - // Preserve both dotenv assignment forms, including an optional export prefix and spacing. - let configured = existing.lines().any(|line| { - let Some((key, value)) = line.split_once('=') else { - return false; - }; - let mut words = key.split_whitespace(); - matches!( - (words.next(), words.next(), words.next()), - (Some(TARGET), None, None) | (Some("export"), Some(TARGET), None) - ) && !value.trim().is_empty() - }); - if !configured { - settings.insert(TARGET.into(), proof.learning_container_id.clone()); - } - Ok(()) -} - fn require_existing_encryption_key( root: &Path, secrets: &std::collections::BTreeMap, @@ -2032,7 +2099,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.")); } @@ -2107,22 +2174,19 @@ 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. +/// +/// `api_url` and `gateway_ws_url` are the local Intelligence stack's addresses; its key is the one +/// `local connect` gave OpenBot, read from the credential store. #[tauri::command] -#[allow( - clippy::too_many_arguments, - reason = "These named fields preserve the existing Tauri IPC contract." -)] async fn start_stack( app: tauri::AppHandle, root: String, api_url: String, gateway_ws_url: String, - api_key: String, model: ChosenModel, // The row the person picked, with the address only for the bring-your-own row. harness: Option, - organization_auth_url: Option, // Both registers on the way out: see `problem.rs`. Anything that still returns a bare string // converts to the plain half, so a path without its own sentence reads as it always did. ) -> Result<(), openbot_desktop_lib::problem::Problem> { @@ -2143,47 +2207,25 @@ async fn start_stack( )); } updates::require_no_interruption(&root)?; - start_stack_inner( - app, - root, - api_url, - gateway_ws_url, - api_key, - model, - harness, - organization_auth_url, - ) - .await + let menus = app.clone(); + let started = start_stack_inner(app, root, api_url, gateway_ws_url, model, harness).await; + // The menu bar says whether OpenBot is running. + refresh_menus(&menus); + // Setup is done once OpenBot starts: the window hands over to it at once. Recorded once. + if started.is_ok() { + desktop_telemetry::record(&menus, telemetry::EventData::Activated); + } + started } -#[allow( - clippy::too_many_arguments, - reason = "Mirror the command fields while testing startup with a resolved root." -)] async fn start_stack_inner( app: tauri::AppHandle, root: PathBuf, api_url: String, gateway_ws_url: String, - api_key: String, model: ChosenModel, harness: Option, - organization_auth_url: Option, ) -> Result<(), Problem> { - let organization_auth_url = organization_auth_url.map(|url| url.trim().to_string()); - if let Some(url) = organization_auth_url.as_ref().filter(|url| !url.is_empty()) { - if !reqwest::Url::parse(url).is_ok_and(|url| { - matches!(url.scheme(), "http" | "https") - && url.has_host() - && url.username().is_empty() - && url.password().is_none() - }) { - return Err(Problem::plain( - "Enter a valid http:// or https:// organization OpenBot URL.", - ) - .connection(openbot_desktop_lib::problem::Connection::Organization)); - } - } let shell = app.state::(); let attempt = StartAttempt::begin(&shell)?; install_recorder_host_at_start(); @@ -2308,7 +2350,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 @@ -2332,12 +2382,45 @@ async fn start_stack_inner( return Err(status.detail.into()); }; let found = found.pin()?; + // Decided here, before anything below runs Compose, so a project that cannot be decided + // stops Start with the reason instead of starting under a name. Never the folder's name + // alone: a source checkout called `openbot` runs as that too. See `stack::project`. + stack::project(&found, &root)?; + // 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() { @@ -2353,7 +2436,7 @@ async fn start_stack_inner( return Err(problem.into()); } - let api_key = intelligence_key_for_start(&root, api_key, saved_secret)?; + let api_key = intelligence_key_for_start(&root, saved_secret)?; let existing_secrets = openbot_desktop_lib::vault::already_given_no_ui( &root, &root.join(".env"), @@ -2369,7 +2452,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()) })?; @@ -2445,26 +2535,72 @@ async fn start_stack_inner( { settings.insert(key, value); } - if let Some(authority) = organization_auth_url { - settings.insert("OPENBOT_ORGANIZATION_AUTH_URL".into(), authority); - } openbot_env::explain_browser_absence(&mut settings, browser_absent); - seed_learning_default( - &root, - &mut settings, - shell.intelligence_learning.lock().unwrap().as_ref(), - )?; + /* + * 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 this installation runs as. See `stack::project`. + */ + settings.insert( + "COMPUTER_NETWORK".into(), + format!("{}_default", stack::project(&found, &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()), + ); + } let (settings, mut secrets) = persist_start_environment(&root, settings, &credential)?; 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()), + ) + })?; secrets.extend(updates::runtime_build_environment(&root)?); // Installation already verified these images. Start only raises the local containers; @@ -2505,53 +2641,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. 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() @@ -2561,7 +2746,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()], @@ -2580,18 +2765,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(()) } @@ -2608,9 +2787,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)] @@ -2624,29 +2805,244 @@ 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> { - app.state::() +fn stop_everything(app: &tauri::AppHandle, fallback_root: &Path) -> Stopped { + let shell = app.state::(); + // A person's stop outranks a runtime update that is under way. + shell .update_cancelled .store(true, std::sync::atomic::Ordering::SeqCst); - stop_for_update(app, fallback_root) + stop_everything_for(&shell, fallback_root) } + +/// Stop OpenBot's own processes and containers so a runtime update can replace their files, +/// without cancelling that update. The local Intelligence stack is left running: an update does +/// not touch it, and the restart that follows finds it already up. fn stop_for_update( 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) + stop_everything_then( + &shell, + &root, + stack::stop_processes_under, + |root| down_owned_containers(&shell, root), + |_| Ok(()), + ) + .into_result() +} + +/// 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, + downloads: 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 { @@ -2655,6 +3051,7 @@ fn root_for_stop(shell: &Shell, fallback_root: &Path) -> PathBuf { root } +#[cfg(test)] fn stop_everything_with( shell: &Shell, fallback_root: &Path, @@ -2665,7 +3062,31 @@ where C: FnOnce(&Path) -> Result, D: FnOnce(&Path) -> Result<(), String>, { - cancel_self_hosted_sign_in(shell); + 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(); shell .stopped_in_session .store(true, std::sync::atomic::Ordering::SeqCst); @@ -2675,7 +3096,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) { @@ -2686,12 +3107,91 @@ where failures.push(format!("Compose down failed: {problem}")); } + // Recovery is about what OpenBot runs, so only OpenBot's own failures keep it. if failures.is_empty() { clear_recovery_required(shell, &root); - Ok(()) - } else { - Err(failures.join("\n")) } + drop(startup); + Stopped { + openbot: failures, + intelligence: intelligence(&root) + .err() + .map(|problem| format!("{INTELLIGENCE_STOP_FAILED}{problem}")), + } +} + +/// The production Intelligence step for Stop and Quit, within `limits`. +fn stop_local_eval(root: &Path, limits: local_eval::Limits) -> Result<(), String> { + #[cfg(test)] + INTELLIGENCE_TIMEOUT.with(|used| used.set(Some(limits.timeout))); + let attempt = format!("openbot-shutdown-{:016x}", rand::random::()); + stop_local_eval_with(root, local_eval::cancel_all, |stack| { + local_eval::stop_for_shutdown(&attempt, stack, limits) + }) +} + +#[cfg(test)] +thread_local! { + /// The timeout the last `stop_local_eval` on this thread was given, so a test can tell which + /// limits a production path chose without a CLI to time. + static INTELLIGENCE_TIMEOUT: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +fn last_intelligence_timeout() -> Option { + INTELLIGENCE_TIMEOUT.with(std::cell::Cell::take) +} + +/** +Ask the CLI to stop the local Intelligence stack, if and only if OpenBot created it. + +No record, or a stack that was already there when OpenBot found it, is left exactly as it is: it +may be serving other apps, and stopping OpenBot is not a reason to stop them. Docker and the +stack's containers are never touched here; only the CLI stops what the CLI started. + +Calls already in flight are cancelled first, whoever owns the stack: cancelling ends only this +app's own CLI processes, never the stack, and a `start` left running could otherwise hold the stop +behind it, finish after it and leave the stack running, or outlive the app as an orphan. + +Every way this can fail is said in fixed words. There is no CLI output to repeat, and none would +be repeated if there were. +*/ +fn stop_local_eval_with(root: &Path, cancel_all: A, stop: S) -> Result<(), String> +where + A: FnOnce(), + S: FnOnce(&local_eval::StackId) -> Result, +{ + cancel_all(); + let Some(state) = local_eval_state::read(root, None).filter(|state| state.tied_to_openbot()) + else { + return Ok(()); + }; + let not_stopped = |why: String| { + format!("the Intelligence CLI did not stop the local Intelligence stack ({why})") + }; + match stop(&state.stack_id()) { + Ok(report) => match report.outcome { + local_eval::Outcome::Completed => Ok(()), + outcome => Err(not_stopped(match report.reason { + Some(reason) => format!("{}: {}", wire_name(outcome), wire_name(reason)), + None => wire_name(outcome), + })), + }, + Err(local_eval::Failure::Unavailable) => Err( + "this build has no Intelligence CLI to stop the local Intelligence stack OpenBot created" + .into(), + ), + Err(failure) => Err(not_stopped(wire_name(failure))), + } +} + +/// A category's name as the window knows it, `local-command-busy` rather than `LocalCommandBusy`. +fn wire_name(category: impl Serialize) -> String { + serde_json::to_value(category) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_default() } /// Called under the startup lock by both Stop and Quit. Host cleanup can clear its own root @@ -2739,102 +3239,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| { @@ -2845,14 +3259,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( @@ -2870,176 +3288,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, @@ -3129,9 +3377,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 @@ -3180,7 +3428,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(()); } @@ -3282,18 +3580,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>, { - 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(); + 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 root = cleanup_root(shell, fallback_root); let mut failures = Vec::new(); if let Err(problem) = cleanup_host_state(shell, &root, cleanup) { @@ -3312,7 +3661,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> @@ -3333,6 +3709,8 @@ where #[derive(Default)] struct QuitState { phase: Mutex, + /// The Shutting down screen's rows. + status: Mutex, } #[derive(Default)] @@ -3448,42 +3826,15 @@ fn show_openbot_on( let _startup = shell.startup.lock().unwrap(); let root = cleanup_root(&shell, &stack::default_root()); updates::require_no_interruption(&root)?; - let organization_recovery = !quit_cleanup_notice_path(&root).exists() - && shell - .recovery_required - .lock() - .unwrap() - .as_ref() - .is_some_and(|recovery| { - recovery.root == root - && recovery.connection - == Some(openbot_desktop_lib::problem::Connection::Organization) - }); - if recovery_required_or_pending_quit_notice(&shell, &root) && !organization_recovery { + if recovery_required_or_pending_quit_notice(&shell, &root) { 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 = - openbot_env::already_set(&root.join(".env"), &["OPENBOT_ORGANIZATION_AUTH_URL"]) - .remove("OPENBOT_ORGANIZATION_AUTH_URL") - .unwrap_or_default(); - let destination = if authority.is_empty() { - if organization_recovery { - return Err(Problem::plain( - "Restore this installation's organization OpenBot URL before signing in.", - ) - .connection(openbot_desktop_lib::problem::Connection::Organization)); - } - url - } else { - openbot_desktop_lib::organization_auth::session_destination(&root, &authority, &url)? - }; - if organization_recovery { - clear_recovery_required(&shell, &root); - } - // Organization destinations can contain a one-use session ticket; never log them. + // Only the owned app itself. + trust_owned_app(&app, &url); + let destination = url; eprintln!("[show] navigating to the owned OpenBot app"); let window = app .get_webview_window("main") @@ -3584,999 +3935,1183 @@ 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 (OpenBot's `--sidebar`, in sRGB). +#[cfg(any(target_os = "macos", target_os = "windows"))] +const SETUP_BACKGROUND: tauri::window::Color = tauri::window::Color(0xed, 0xed, 0xf5, 0xff); +#[cfg(any(target_os = "macos", target_os = "windows"))] +const SETUP_BACKGROUND_DARK: tauri::window::Color = tauri::window::Color(0x0d, 0x0b, 0x13, 0xff); -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}")) +/// 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)) } -/// 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) +/** +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()) } -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; +/// 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), } - 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) +/// 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; + } + let _ = std::fs::create_dir_all(&directory).and_then(|()| std::fs::write(&record, wanted)); +} + +/// What the window is showing, as far as its chrome is concerned. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WindowPage { + Setup, + /// The OpenBot this shell verified it owns, served from this machine: its sidebar takes the + /// title bar's place. A page on another loopback origin is `Other`. + 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: the system's title bar. + Other, +} + +fn window_page(app: &tauri::AppHandle, url: &tauri::Url) -> WindowPage { + if on_setup_page(app, url) { + WindowPage::Setup + } else if url.scheme() == "http" + && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")) + && is_owned_app_origin(app, url) + { + if app_makes_room_for_window_buttons(&cleanup_root( + &app.state::(), + &stack::default_root(), + )) { + WindowPage::OwnedApp + } else { + WindowPage::LocalApp + } + } else { + WindowPage::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)) -} +/** +Whether the OpenBot installed under `root` pads its sidebar for the window's buttons. -fn already_running_at(root: &Path, ports: &openbot_env::Ports) -> bool { - owned_app_url(root, ports).is_some() +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")) } -/// 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 { - // Durable update recovery outranks surviving process ownership after a shell crash. - // Every passive adoption, tray restore and Recorder handoff shares this boundary. - if updates::require_no_interruption(root).is_err() { - return None; +/** +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); + } } - if !already_running_on(root, ports.server, stack::recorded_server_owns_port) - || !stack::recorded_process_owns_port(root, "app", ports.app).unwrap_or(false) + #[cfg(target_os = "macos")] { - return None; + 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), } - stack::app_url(ports.app) } -/// An address the Recorder's native host answers in place of a Desktop deployment's own, so a test -/// can drive the whole handoff against an OpenBot it started itself. Debug builds only: a release -/// binary never reads the variable. -#[cfg(debug_assertions)] -fn recorder_host_test_app_url() -> Option { - std::env::var("OPENBOT_RECORDER_HOST_TEST_APP_URL") - .ok() - .filter(|url| !url.is_empty()) +/// 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); } -#[cfg(not(debug_assertions))] -fn recorder_host_test_app_url() -> Option { - None +/** +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 organization's sign-in address when this deployment runs in organization mode. -fn organization_authority(root: &Path) -> Option { - openbot_env::already_set(&root.join(".env"), &["OPENBOT_ORGANIZATION_AUTH_URL"]) - .remove("OPENBOT_ORGANIZATION_AUTH_URL") - .filter(|authority| !authority.is_empty()) +/// 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]; + } + }); } -/// A sign-in the organization or the local OpenBot refused is one to make again; anything else may pass. -fn organization_refusal(problem: &Problem) -> recorder_host::Refusal { - if problem.connection == Some(openbot_desktop_lib::problem::Connection::Organization) { - recorder_host::Refusal::NotSignedIn - } else { - recorder_host::Refusal::OrganizationUnavailable +/** +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(); +})();"#; + +/** +Record `address` as the OpenBot app this shell verified it owns, and let it use the Intelligence row. + +Called with an address `owned_app_url` verified, before the window navigates there. Its origin is +then the only loopback origin the window treats as OpenBot (`window_page`) and gives the window's +controls to (`allow_app_page`). +*/ +fn trust_owned_app(app: &tauri::AppHandle, address: &str) { + if let Some(origin) = intelligence_bridge::app_origin(address) { + let shell = app.state::(); + let mut trusted = shell.owned_app_origins.lock().unwrap(); + if !trusted.contains(&origin) { + trusted.push(origin); + } } + intelligence_bridge::allow(app, address); } -/// The ping's one question to the app: does `/api/capabilities` answer through the app's port, on either loopback. -fn recorder_ping_app_answers(port: u16) -> bool { - stack::answering_within( - port, - "/api/capabilities", - recorder_host::PING_PROBE_PATIENCE, - ) - .is_some() +/// Whether `url` is on the origin of the OpenBot app this shell verified it owns. +fn is_owned_app_origin(app: &tauri::AppHandle, url: &tauri::Url) -> bool { + let origin = url.origin().ascii_serialization(); + app.state::() + .owned_app_origins + .lock() + .unwrap() + .contains(&origin) } -/// What the Recorder's native host knows about this computer's OpenBot. -/// -/// The browser starts the host as a fresh process with no app around it, so it finds the deployment -/// the way the app's setup does: the root last launched, read from Tauri's config directory. -fn recorder_host_environment() -> recorder_host::HostEnvironment { - let home = recorder_home().unwrap_or_else(|| PathBuf::from(".")); - let config = recorder_host::app_config_dir( - recorder_os(), - &home, - std::env::var_os("XDG_CONFIG_HOME") - .as_deref() - .map(Path::new), - std::env::var_os("APPDATA").as_deref().map(Path::new), - ); - let root = preparation::selected_root(&config); - // For a ping: whether Desktop holds the organization sign-in at all, read from this computer alone. - let signed_in_root = root.clone(); - let signed_in: recorder_host::Check> = Box::new(move || { - let Some(root) = signed_in_root.as_deref() else { - return Ok(()); - }; - let Some(authority) = organization_authority(root) else { - return Ok(()); - }; - match openbot_desktop_lib::organization_auth::signed_in_locally(root, &authority) { - Ok(true) => Ok(()), - Ok(false) => Err(recorder_host::Refusal::NotSignedIn), - Err(problem) => Err(organization_refusal(&problem)), - } - }); - // For a handoff: in organization mode the local OpenBot admits only its organization session cookie, handed - // over from Desktop's saved sign-in exactly as opening the window does. Never logged. - let organization_root = root.clone(); - let organization_cookie: recorder_host::OrganizationCookie = Box::new(move |app_url| { - let Some(root) = organization_root.as_deref() else { - return Ok(None); - }; - let Some(authority) = organization_authority(root) else { - return Ok(None); - }; - openbot_desktop_lib::organization_auth::local_session_cookie(root, &authority, app_url) - .map(Some) - .map_err(|problem| organization_refusal(&problem)) - }); - if let Some(url) = recorder_host_test_app_url() { - let port = reqwest::Url::parse(&url) - .ok() - .and_then(|url| url.port_or_known_default()); - return recorder_host::HostEnvironment { - installed: Box::new(|| true), - app_answers: Box::new(move || port.is_some_and(recorder_ping_app_answers)), - signed_in, - app_url: Box::new(move || Some(url.clone())), - organization_cookie, - }; +/** +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. + +Only the origin this shell verified as its own OpenBot (`trust_owned_app`). A page on any other +loopback port, which a link in a reply can open in this window, gets none of it: Settings → This +computer can stop OpenBot and the local Intelligence. +*/ +fn allow_app_page(app: &tauri::AppHandle, url: &tauri::Url) { + if !is_owned_app_origin(app, url) { + return; } - let installed = root.is_some(); - let ping_root = root.clone(); - recorder_host::HostEnvironment { - installed: Box::new(move || installed), - // No ownership check: a ping proves nothing and sends nothing secret, and the handoff checks before it does. - app_answers: Box::new(move || { - ping_root - .as_deref() - .and_then(|root| openbot_env::Ports::read(root).ok()) - .is_some_and(|ports| recorder_ping_app_answers(ports.app)) - }), - signed_in, - app_url: Box::new(move || { - let root = root.as_deref()?; - let ports = openbot_env::Ports::read(root).ok()?; - owned_app_url(root, &ports) - }), - organization_cookie, + let origin = url.origin().ascii_serialization(); + let shell = app.state::(); + let mut granted = shell.app_page_grants.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}"), } } -/// 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. -/// Off the event-loop thread for the same reason as `already_running`. -#[tauri::command] -async fn last_failure( - app: tauri::AppHandle, -) -> Option { - tauri::async_runtime::spawn_blocking(move || last_failure_now(&app)) - .await - .unwrap_or_else(|error| { - Some(Problem::with( - "OpenBot could not read what stopped it.", - error.to_string(), - )) - }) -} +#[cfg(test)] +mod app_page_tests { + use super::*; -fn last_failure_now( - app: &tauri::AppHandle, -) -> Option { - let shell = app.state::(); - if let Some(problem) = shell.last_failure.lock().unwrap().take() { - return Some(problem); - } - let _startup = shell.startup.lock().unwrap(); - let root = cleanup_root(&shell, &stack::default_root()); - if !quit_cleanup_notice_path(&root).exists() { - return None; - } - recovery_required_or_pending_quit_notice(&shell, &root); - match read_quit_cleanup_notice(&root) { - Ok(problem) => problem, - Err(problem) => Some(problem), + /// A stand-in for `desktop_page` under its own name, so the Tauri ACL that guards the real + /// commands guards these: a command answers only when the page's origin was granted it. + fn app() -> ( + tauri::App, + tauri::WebviewWindow, + ) { + let stand_in = + tauri::plugin::Builder::::new(desktop_page::PLUGIN) + .invoke_handler(|invoke| { + invoke.resolver.resolve(()); + true + }) + .build(); + let app = tauri::test::mock_builder() + .plugin(stand_in) + .plugin(intelligence_bridge::plugin()) + .manage(Shell::default()) + .build(tauri::generate_context!(test = true)) + .unwrap(); + 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()) } -} -#[tauri::command] -fn default_root() -> String { - stack::default_root().to_string_lossy().into_owned() -} + const STOP: &str = "plugin:openbot-desktop|stop"; -#[tauri::command] -fn selected_root(app: tauri::AppHandle) -> Option { - app.state::() - .selected_root - .lock() - .unwrap() - .as_ref() - .map(|root| root.to_string_lossy().into_owned()) + /// Settings → This computer can stop OpenBot and the local Intelligence. A page on another + /// loopback port that this window loads, from a link in a reply for instance, used to get the + /// same controls as OpenBot's own page. + #[test] + fn only_the_owned_app_origin_can_stop_openbot_from_its_page() { + let (app, window) = app(); + let foreign: tauri::Url = "http://127.0.0.1:3011/".parse().unwrap(); + let owned: tauri::Url = "http://127.0.0.1:3010/settings/this-computer" + .parse() + .unwrap(); + + // Loaded before any OpenBot was verified: not OpenBot, and granted nothing. + assert_eq!(window_page(app.handle(), &foreign), WindowPage::Other); + allow_app_page(app.handle(), &foreign); + assert!(ask(&window, foreign.as_str(), STOP).is_err()); + + trust_owned_app(app.handle(), "http://127.0.0.1:3010"); + assert!(matches!( + window_page(app.handle(), &owned), + WindowPage::OwnedApp | WindowPage::LocalApp + )); + allow_app_page(app.handle(), &owned); + assert_eq!( + ask(&window, owned.as_str(), STOP), + Ok(serde_json::Value::Null) + ); + + // Another port on the same address, and another name for it, stay someone else's. + for other in [ + "http://127.0.0.1:3011/", + "http://localhost:3010/", + "http://[::1]:3010/", + ] { + let other: tauri::Url = other.parse().unwrap(); + assert_eq!( + window_page(app.handle(), &other), + WindowPage::Other, + "{other}" + ); + allow_app_page(app.handle(), &other); + assert!(ask(&window, other.as_str(), STOP).is_err(), "{other}"); + } + } } -/** -Put the wizard's last question to the Bot, and hand back what it said. +/// Where the Status window's page is: the setup page, at `#status`. +const STATUS_FRAGMENT: &str = "status"; -THE DEFINITION OF DONE FOR AN INSTALL. Everything before this proves that things started; only this -proves the configuration works. See `ask` for why a run that says nothing is a failure rather than -an empty answer, and why the harness's log is fetched to fill the developer half. +/** +The Status window: what OpenBot is running, what it costs, and Restart and Stop for each part. -The endpoint and the token come out of the `.env` this run just wrote, not from the window. They are -facts about the deployment, and a window carrying them would be a second copy to keep in step. +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. */ -#[tauri::command] -async fn ask_the_bot( - app: tauri::AppHandle, - root: String, - question: String, -) -> Result { - let result = ask_the_bot_inner(stack::root_from(&root), question).await; - if result.is_ok() { - desktop_telemetry::record(&app, telemetry::EventData::Activated); +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 { - desktop_telemetry::failure(&app, telemetry::SetupErrorClass::Unknown); + 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}"); } - result -} - -async fn ask_the_bot_inner(root: PathBuf, question: String) -> Result { - // The addresses come from the file and the token from the credential store, which is where - // this run put it. Asked for together, because one without the other cannot ask anything. - let settings = ask_saved_settings(&root)?; - ask_the_bot_with_settings(root, question, settings).await } -fn ask_saved_settings(root: &Path) -> Result, Problem> { - openbot_desktop_lib::vault::already_given_no_ui( - root, - &root.join(".env"), - &[ - "PICKED_HARNESS_URL", - "PICKED_HARNESS_KIND", - "PICKED_HARNESS_SOURCE", - "PICKED_HARNESS_AGENT_ID", - "MANAGED_AGENT_AG_UI_URL", - "MANAGED_AGENT_TOKEN", - ], - ) -} +/// Where OpenBot's Settings → This computer lives, in releases that have it. +/// +/// Not `/settings/computer`: the personal edition's Computers page (the Bots' computers) has that +/// address, and its guard sends every other edition to General. +const COMPUTER_SETTINGS_PATH: &str = "/settings/this-computer"; -async fn ask_the_bot_with_settings( - root: PathBuf, - question: String, - settings: std::collections::BTreeMap, -) -> 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. - let picked_endpoint = settings - .get("PICKED_HARNESS_URL") - .filter(|url| !url.trim().is_empty()); - let (endpoint, log_service, kind, agent_id) = match picked_endpoint { - Some(endpoint) => ( - endpoint.clone(), - // Only a selection this install explicitly recorded as local can explain itself - // through Compose logs. Legacy/unknown provenance and stale IMAGE/PORT do not. - (settings.get("PICKED_HARNESS_SOURCE").map(String::as_str) == Some("installed")) - .then_some("agent-harness"), - settings.get("PICKED_HARNESS_KIND").cloned(), - settings.get("PICKED_HARNESS_AGENT_ID").cloned(), - ), - None => ( - settings - .get("MANAGED_AGENT_AG_UI_URL") - .cloned() - .unwrap_or_default(), - Some("agent-langgraph"), - None, - None, - ), +/** +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/this-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 token = settings - .get("MANAGED_AGENT_TOKEN") - .cloned() - .unwrap_or_default(); - if endpoint.trim().is_empty() || token.trim().is_empty() { - return Err(openbot_desktop_lib::problem::Problem::plain( - "OpenBot cannot find the Bot it just set up. Stop OpenBot and start it again.", - )); + let Some(window) = app.get_webview_window("main") else { + return false; + }; + url.set_path(COMPUTER_SETTINGS_PATH); + trust_owned_app(app, url.as_str()); + if window.navigate(url).is_err() { + return false; } + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + true +} - let question = if question.trim().is_empty() { - openbot_desktop_lib::ask::SUGGESTED.to_string() - } else { - question - }; +/// 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, +} - 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(), - ) { - 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. - Err(problem) if problem.said.is_empty() && log_service.is_none() => { - Err(Some(Problem::with( - "The Bot at the selected endpoint returned no answer text. Check that endpoint's logs and ask again.", - format!("endpoint {endpoint}\nThe run returned no answer text."), - ))) - } - Err(problem) if problem.said.is_empty() => Err(None), - Err(problem) => Err(Some(problem)), - } - }) - .await - .map_err(|error| { - openbot_desktop_lib::problem::Problem::plain(format!( - "The question could not be asked: {error}" - )) - })?; +#[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}")) +} - match asked { - Ok(answer) => Ok(answer), - Err(Some(problem)) => Err(problem), - Err(None) => { - let log = log_service - .and_then(|service| { - engine::detect_saved(preparation::saved_engine(&root)) - .address - .map(|found| stack::service_log(&found, &root, service, 40)) - }) +/** +`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(); + // This installation's own, by their Compose project label, never by a name prefix + // another project can share. See `stack::running_containers_of`. + let owned = found + .address + .as_ref() + .map(|address| stack::running_containers_of(address, &root)) .unwrap_or_default(); - Err(openbot_desktop_lib::ask::why_nothing_came_back(&log)) + desktop_status::containers(&stats, &owned, 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(), + ), } } /** -What a previous run already wrote, so the wizard can arrive filled in. - -Returned to the window because that is where the fields are, and it is the same machine and the -same person: reading their own file back to them is not a disclosure. The key is not logged here or -anywhere, and only the settings the wizard asks about are read. +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] -fn already_configured( - app: tauri::AppHandle, - root: String, -) -> AlreadyConfigured { - let mut configured = already_configured_for_root(root); - configured.auto_start = !app - .state::() - .stopped_in_session - .load(std::sync::atomic::Ordering::SeqCst); - configured +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 already_configured_for_root(root: String) -> AlreadyConfigured { - let root = stack::root_from(&root); - let env_file = root.join(".env"); - let mut values = openbot_desktop_lib::vault::already_given_file_only( - &env_file, - &[ - "INTELLIGENCE_API_KEY", - "INTELLIGENCE_API_URL", - "INTELLIGENCE_GATEWAY_WS_URL", - "OPENBOT_ORGANIZATION_AUTH_URL", - /* - * The model credentials too, so the wizard never asks twice for one of these either. - * - * A key already in the file is one somebody has already produced, and making them find - * it again means opening a dotfile in an editor. Read back for the same reason the - * Intelligence key is: it is their own file, on their own machine, and this is the - * screen that asks for it. - */ - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "OPENAI_BASE_URL", - "OPENAI_CONTAINER_BASE_URL", - "BOT_MODEL", - "CLAUDE_CODE_OAUTH_TOKEN", - ], - ); - - use openbot_desktop_lib::saved_intent::{Category, SavedIntent}; - let intent = SavedIntent::read(&root); - let hint = |category, file_present| { - (file_present || intent.categories.contains(&category)).then_some(true) +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()); }; - let claude_plan = values.remove("CLAUDE_CODE_OAUTH_TOKEN").is_some(); - AlreadyConfigured { - launch: preparation::launch(&root), - installation: preparation::installation(&root), - auto_start: true, - saved: SavedConfiguration { - intelligence_api_key: hint( - Category::Intelligence, - values.contains_key("INTELLIGENCE_API_KEY"), - ), - model_api_keys: SavedModelApiKeys { - openai: hint( - Category::OpenAiApiKey, - values.contains_key("OPENAI_API_KEY"), - ), - anthropic: hint( - Category::AnthropicApiKey, - values.contains_key("ANTHROPIC_API_KEY"), - ), - compatible: values - .get("OPENAI_BASE_URL") - .is_some_and(|url| intent.has_compatible_key_for(url)) - .then_some(true), - }, - model_sessions: SavedModelSessions { - openai: hint( - Category::ChatGptPlan, - openbot_env::saved_chatgpt_plan_store(&root), - ), - anthropic: hint(Category::ClaudePlan, claude_plan), - google: hint(Category::GoogleOauth, false), - xai: hint(Category::XaiOauth, false), - }, - model: intent.model, - }, - values, - } + stack::restart_openbot(&address, &root) + .map_err(|error| format!("OpenBot could not restart: {error}")) } -/// The harness picker's rows. Data, so the screen is a list and not twelve branches. +/// Stop, as the menu used to offer it: everything, Intelligence included if OpenBot started it. #[tauri::command] -fn harnesses() -> Vec { - harness::catalogue() +fn stop_openbot_from_status(app: tauri::AppHandle) { + stop_from_menu(app); } -/// Start a Claude plan sign-in and return the address a browser has to open. -/// -/// Blocking work on a blocking thread: it starts a container and waits on its output, and doing -/// that on the UI thread is a window that stops repainting mid-setup. -#[tauri::command] -async fn begin_claude_sign_in(app: tauri::AppHandle, root: String) -> Result { - let root = stack::root_from(&root); - remember_selected_root(&app.state::(), &root); - /* - * The image is decided here, not by the window, and it is the Claude Agent SDK harness whatever - * harness the person picked. It is not being used as a Bot: it is the container that happens to - * carry Anthropic's bundled CLI, which is what does the OAuth. Letting the screen name an image - * would make the sign-in depend on a choice that has nothing to do with it. - */ - #[cfg(target_os = "linux")] - let service_app = app.clone(); - let (signing, url) = tauri::async_runtime::spawn_blocking(move || { - let (address, image) = prepared_sign_in(&root, openbot_desktop_lib::plan::SIGN_IN_IMAGE)?; - #[cfg(target_os = "linux")] - { - let shell = service_app.state::(); - let _startup = shell.startup.lock().unwrap(); - ensure_linux_podman_api(&shell, &address)?; - } - openbot_desktop_lib::plan::SigningIn::begin(&address, &image).map_err(Problem::from) - }) - .await - .map_err(|error| { - Problem::with( - "The sign-in did not start. Try again.", - format!("the sign-in task did not run: {error}"), - ) - })??; - *app.state::().signing_in.lock().unwrap() = Some(signing); - - /* - * Opened here rather than by the window, because the window would need the shell plugin's JS - * half for the one call. The URL is returned as well, and the screen shows it: on Linux without - * a registered browser, and in a session where the open silently does nothing, a link somebody - * can copy is the difference between a stuck screen and a finished sign-in. - */ - let _ = tauri_plugin_opener::OpenerExt::opener(&app).open_url(&url, None::<&str>); - Ok(url) +/// 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() } -/** -Redeem the code from the browser and return the plan token. - -The token crosses to the window and comes back in the model choice, which is the same path a typed -key takes. It is never logged, and the failure messages never carry the command's output: see -`SigningIn::gave_up`. -*/ -#[tauri::command] -async fn finish_claude_sign_in(app: tauri::AppHandle, code: String) -> Result { - // Taken, not borrowed. A sign-in is single-use, and leaving it in place would let a second - // attempt write a code into a flow that has already finished. - let signing = app - .state::() - .signing_in - .lock() - .unwrap() - .take() - .ok_or_else(|| "That sign-in is no longer running. Start it again.".to_string())?; - tauri::async_runtime::spawn_blocking(move || signing.finish(&code)) - .await - .map_err(|error| format!("The sign-in did not finish: {error}"))? +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}")) } -/// Start a ChatGPT plan sign-in and return the address a browser has to open. -#[tauri::command] -async fn begin_chatgpt_sign_in( - app: tauri::AppHandle, - root: String, -) -> Result { - let root = stack::root_from(&root); - remember_selected_root(&app.state::(), &root); - #[cfg(target_os = "linux")] - let service_app = app.clone(); - let (signing, url) = tauri::async_runtime::spawn_blocking(move || { - let (address, image) = - prepared_sign_in(&root, openbot_desktop_lib::plan::CHATGPT_SIGN_IN_IMAGE)?; - #[cfg(target_os = "linux")] - { - let shell = service_app.state::(); - let _startup = shell.startup.lock().unwrap(); - ensure_linux_podman_api(&shell, &address)?; - } - openbot_desktop_lib::plan::SigningInToChatGpt::begin(&address, &image) - }) - .await - .map_err(|error| { - Problem::with( - "The sign-in did not start. Try again.", - format!("the sign-in task did not run: {error}"), - ) - })??; - *app.state::().signing_in_to_chatgpt.lock().unwrap() = Some(signing); - let _ = tauri_plugin_opener::OpenerExt::opener(&app).open_url(&url, None::<&str>); - Ok(url) +/// 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) } -/** -Wait for the ChatGPT redirect to land, and return the plan token. +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) +} -Nothing is sent: the browser's callback is what finishes it. So this is a wait rather than a -redemption, which is why there is no code field on that half of the screen. -*/ +/// 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 finish_chatgpt_sign_in(app: tauri::AppHandle) -> Result { - let signing = app - .state::() - .signing_in_to_chatgpt - .lock() - .unwrap() - .take() - .ok_or_else(|| "That sign-in is no longer running. Start it again.".to_string())?; - tauri::async_runtime::spawn_blocking(move || signing.finish()) +async fn already_running(app: tauri::AppHandle, root: String) -> bool { + tauri::async_runtime::spawn_blocking(move || already_running_now(&app, &root)) .await - .map_err(|error| format!("The sign-in did not finish: {error}"))? + .unwrap_or(false) } -/// A self-hosted server authenticates in its own browser session. Only the selected project's -/// runtime key crosses into the window; the helper owns and then closes the temporary browser. -#[tauri::command] -async fn begin_self_hosted_intelligence_sign_in( - app: tauri::AppHandle, - root: String, - api_url: String, -) -> Result, Problem> { - let root = stack::root_from(&root); +fn already_running_now(app: &tauri::AppHandle, root: &str) -> bool { + let root = stack::root_from(root); let shell = app.state::(); - remember_selected_root(&shell, &root); - cancel_self_hosted_sign_in(&shell); - let generation = shell - .self_hosted_sign_in_generation - .load(std::sync::atomic::Ordering::SeqCst); - tauri::async_runtime::spawn_blocking(move || { - let bun = preparation::dependencies_ready(&root)?; - let shell = app.state::(); - let signing = { - let mut slot = shell.self_hosted_intelligence.lock().unwrap(); - if generation - != shell - .self_hosted_sign_in_generation - .load(std::sync::atomic::Ordering::SeqCst) - { - return Err(Problem::plain("That Intelligence sign-in was cancelled.")); - } - let signing = openbot_desktop_lib::self_hosted_intelligence::SigningIn::begin( - &root, &bun, &api_url, - )?; - *slot = Some(signing.clone()); - signing - }; - let result = signing.projects(); - if result.is_err() { - let mut slot = shell.self_hosted_intelligence.lock().unwrap(); - if slot - .as_ref() - .is_some_and(|active| std::sync::Arc::ptr_eq(active, &signing)) - { - slot.take(); - } - drop(slot); - signing.cancel(); - } - result - }) - .await - .map_err(|_| Problem::plain("Intelligence sign-in could not finish."))? + 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)) } -#[tauri::command] -async fn finish_self_hosted_intelligence_sign_in( - app: tauri::AppHandle, - root: String, - project: String, -) -> Result { - let root = stack::root_from(&root); - let signing = app - .state::() - .self_hosted_intelligence +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() - .clone() - .filter(|signing| signing.root == root) - .ok_or_else(|| Problem::plain("Sign in to this Intelligence server first."))?; - tauri::async_runtime::spawn_blocking(move || { - let result = signing.connect(&project); - let shell = app.state::(); - let mut slot = shell.self_hosted_intelligence.lock().unwrap(); - if !slot - .as_ref() - .is_some_and(|active| std::sync::Arc::ptr_eq(active, &signing)) - { - return Err(Problem::plain("That Intelligence sign-in was cancelled.")); - } - slot.take(); - let provisioned = match result { - Ok(provisioned) => provisioned, - Err(problem) => { - drop(slot); - signing.cancel(); - return Err(problem); - } - }; - let key = provisioned.api_key.clone(); - *shell.intelligence_learning.lock().unwrap() = Some(provisioned); - Ok(key) - }) - .await - .map_err(|_| Problem::plain("Intelligence sign-in could not finish."))? + .as_ref() + .filter(|owned| owned.root == root) + .map(|owned| owned.address.clone()); + app_url_published_by(owned.as_ref(), root, ports) } -#[tauri::command] -async fn cancel_self_hosted_intelligence_sign_in(app: tauri::AppHandle) -> Result<(), Problem> { - tauri::async_runtime::spawn_blocking(move || cancel_self_hosted_sign_in(&app.state::())) - .await - .map_err(|_| Problem::plain("The Intelligence sign-in could not be closed.")) +/// [`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 { + // Durable update recovery outranks surviving process ownership after a shell crash. + // Every passive adoption, tray restore and Recorder handoff shares this boundary. + if updates::require_no_interruption(root).is_err() { + return None; + } + if !already_running_on(root, ports.server, |root, server| { + server_container_publishes(owned, root, &[server, ports.app]) + }) { + return None; + } + stack::app_url(ports.app) } -/// Start signing in to Intelligence and return the address a browser has to open. -#[tauri::command] -async fn begin_intelligence_sign_in(app: tauri::AppHandle, root: String) -> Result { - let root = stack::root_from(&root); - if !root.is_absolute() { - return Err("Choose where OpenBot lives before signing in.".into()); - } - let (signing, url) = openbot_desktop_lib::intelligence::SigningInToIntelligence::begin()?; - let generation = supersede_managed_sign_in(&app.state::()); - *app.state::() - .signing_in_to_intelligence - .lock() - .unwrap() = Some((root, generation, signing)); - let _ = tauri_plugin_opener::OpenerExt::opener(&app).open_url(&url, None::<&str>); - Ok(url) +/// An address the Recorder's native host answers in place of a Desktop deployment's own, so a test +/// can drive the whole handoff against an OpenBot it started itself. Debug builds only: a release +/// binary never reads the variable. +#[cfg(debug_assertions)] +fn recorder_host_test_app_url() -> Option { + std::env::var("OPENBOT_RECORDER_HOST_TEST_APP_URL") + .ok() + .filter(|url| !url.is_empty()) } -/// Wait for that sign-in, and answer with the projects it can see. -/// -/// The credential is kept on this side rather than handed to the window: the window's business is -/// which project, and a credential it never holds is one it cannot leak into a log or a screenshot. -#[tauri::command] -async fn finish_intelligence_sign_in( - app: tauri::AppHandle, - root: String, -) -> Result, openbot_desktop_lib::problem::Problem> -{ - let (bound_root, generation, signing) = app - .state::() - .signing_in_to_intelligence - .lock() - .unwrap() - .take() - .ok_or_else(|| { - openbot_desktop_lib::problem::Problem::plain( - "That sign-in is no longer running. Start it again.", - ) - })?; - if stack::root_from(&root) != bound_root { - return Err(Problem::plain( - "Where OpenBot lives changed. Sign in again for this folder.", - )); - } - let signed_in = tauri::async_runtime::spawn_blocking(move || signing.finish()) - .await - .map_err(|_| { - openbot_desktop_lib::problem::Problem::plain( - "The sign-in did not finish. Try signing in again.", - ) - })??; - let projects = - publish_intelligence_sign_in(&app.state::(), generation, bound_root, signed_in)?; - Ok(projects) +#[cfg(not(debug_assertions))] +fn recorder_host_test_app_url() -> Option { + None } -/// Start a new managed sign-in: every earlier one, finished or not, stops counting. -fn supersede_managed_sign_in(shell: &Shell) -> u64 { - let mut held = shell.intelligence_credential.lock().unwrap(); - let generation = shell - .intelligence_sign_in_generation - .fetch_add(1, std::sync::atomic::Ordering::SeqCst) - + 1; - *held = None; - generation +/// The ping's one question to the app: does `/api/capabilities` answer through the app's port, on either loopback. +fn recorder_ping_app_answers(port: u16) -> bool { + stack::answering_within( + port, + "/api/capabilities", + recorder_host::PING_PROBE_PATIENCE, + ) + .is_some() } -/// Keep a finished sign-in, only if it is still the attempt the window is waiting for. +/// What the Recorder's native host knows about this computer's OpenBot. /// -/// Checked before anything is kept. An abandoned browser tab finished later, under another -/// account, must not leave its credential or its session behind for the newer attempt. -fn publish_intelligence_sign_in( - shell: &Shell, - generation: u64, - root: PathBuf, - signed_in: openbot_desktop_lib::intelligence::SignedIn, -) -> Result, Problem> { - let mut held = shell.intelligence_credential.lock().unwrap(); - if shell - .intelligence_sign_in_generation - .load(std::sync::atomic::Ordering::SeqCst) - != generation - { - return Err(Problem::plain( - "A newer sign-in was started. Finish that sign-in instead.", - )); +/// The browser starts the host as a fresh process with no app around it, so it finds the deployment +/// the way the app's setup does: the root last launched, read from Tauri's config directory. +fn recorder_host_environment() -> recorder_host::HostEnvironment { + let home = recorder_home().unwrap_or_else(|| PathBuf::from(".")); + let config = recorder_host::app_config_dir( + recorder_os(), + &home, + std::env::var_os("XDG_CONFIG_HOME") + .as_deref() + .map(Path::new), + std::env::var_os("APPDATA").as_deref().map(Path::new), + ); + let root = preparation::selected_root(&config); + if let Some(url) = recorder_host_test_app_url() { + let port = reqwest::Url::parse(&url) + .ok() + .and_then(|url| url.port_or_known_default()); + return recorder_host::HostEnvironment { + installed: Box::new(|| true), + app_answers: Box::new(move || port.is_some_and(recorder_ping_app_answers)), + app_url: Box::new(move || Some(url.clone())), + }; + } + let installed = root.is_some(); + let ping_root = root.clone(); + recorder_host::HostEnvironment { + installed: Box::new(move || installed), + // No ownership check: a ping proves nothing and sends nothing secret, and the handoff checks before it does. + app_answers: Box::new(move || { + ping_root + .as_deref() + .and_then(|root| openbot_env::Ports::read(root).ok()) + .is_some_and(|ports| recorder_ping_app_answers(ports.app)) + }), + app_url: Box::new(move || { + let root = root.as_deref()?; + let ports = openbot_env::Ports::read(root).ok()?; + app_url_published_by(None, root, &ports) + }), } - *held = Some(ManagedSignIn { - root, - generation, - credential: signed_in.credential, - session: signed_in.session, - }); - Ok(signed_in.projects) } -/// The finished sign-in for this folder. Another folder's sign-in is refused, never borrowed. -fn managed_sign_in_for( - shell: &Shell, +/// 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, -) -> Result<(u64, String, openbot_desktop_lib::intelligence::OpsSession), Problem> { - match shell.intelligence_credential.lock().unwrap().as_ref() { - Some(held) if held.root == root => Ok(( - held.generation, - held.credential.clone(), - held.session.clone(), - )), - Some(_) => Err(Problem::plain( - "Where OpenBot lives changed. Sign in again for this folder.", - )), - None => Err(Problem::plain("Sign in to CopilotKit first.")), - } + 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)) } -/// Connect this installation's apps with the sign-in that just finished, and again on a retry. +/// What stopped the stack, if anything did, and forget it once it has been read. /// -/// A follow-on step that can only add a key. Sign-in has already succeeded and Start never waits -/// on it, so a failure leaves apps unavailable and the window offers Connect apps. +/// Cleared on reading so a failure from an hour ago does not greet somebody who has since fixed it. +/// Off the event-loop thread for the same reason as `already_running`. #[tauri::command] -async fn connect_intelligence_apps( +async fn last_failure( app: tauri::AppHandle, - root: String, -) -> Result<(), Problem> { - let root = stack::root_from(&root); - tauri::async_runtime::spawn_blocking(move || { - connect_apps( - &app.state::(), - &root, - openbot_desktop_lib::composio::provision, - ) - }) - .await - .map_err(|_| { - Problem::plain("OpenBot couldn't connect your apps. Choose Connect apps to try again.") - })? +) -> Option { + tauri::async_runtime::spawn_blocking(move || last_failure_now(&app)) + .await + .unwrap_or_else(|error| { + Some(Problem::with( + "OpenBot could not read what stopped it.", + error.to_string(), + )) + }) } -fn connect_apps( - shell: &Shell, - root: &Path, - provision: impl FnOnce( - &Path, - &openbot_desktop_lib::intelligence::OpsSession, - ) -> Result, -) -> Result<(), Problem> { - let (generation, _, session) = managed_sign_in_for(shell, root)?; - match provision(root, &session)? { - openbot_desktop_lib::composio::Provisioned::Preserved => Ok(()), - openbot_desktop_lib::composio::Provisioned::New(key) => { - // Checked again, and held while saving: a sign-in started since, or one for another - // folder, must not receive this key. - let held = shell.intelligence_credential.lock().unwrap(); - if !held - .as_ref() - .is_some_and(|held| held.generation == generation && held.root == root) - { - return Err(Problem::plain( - "A newer sign-in was started. Finish that sign-in instead.", - )); - } - openbot_desktop_lib::composio::save(root, key) - } +fn last_failure_now( + app: &tauri::AppHandle, +) -> Option { + let shell = app.state::(); + if let Some(problem) = shell.last_failure.lock().unwrap().take() { + return Some(problem); + } + let _startup = shell.startup.lock().unwrap(); + let root = cleanup_root(&shell, &stack::default_root()); + if !quit_cleanup_notice_path(&root).exists() { + return None; + } + recovery_required_or_pending_quit_notice(&shell, &root); + match read_quit_cleanup_notice(&root) { + Ok(problem) => problem, + Err(problem) => Some(problem), } } -/// Create a project using the account already signed in, without exposing its credential to the UI. #[tauri::command] -async fn create_intelligence_project( - app: tauri::AppHandle, - root: String, - name: String, -) -> Result { - let (_, credential, _) = managed_sign_in_for(&app.state::(), &stack::root_from(&root))?; - tauri::async_runtime::spawn_blocking(move || { - openbot_desktop_lib::intelligence::create_project(&credential, &name) - }) - .await - .map_err(|error| Problem::with("Your project could not be created.", error.to_string()))? +fn default_root() -> String { + stack::default_root().to_string_lossy().into_owned() } -/// Create a key for the project somebody chose, and hand it back for the field. #[tauri::command] -async fn intelligence_key_for( - app: tauri::AppHandle, - root: String, - project: String, -) -> Result { - // Bound to the folder the sign-in was for. A key made for one folder's project, picked after - // the folder changed, would start the other folder without its apps. - let (_, credential, _) = managed_sign_in_for(&app.state::(), &stack::root_from(&root))?; - let provisioned = tauri::async_runtime::spawn_blocking(move || { - openbot_desktop_lib::intelligence::provision_connection(&credential, &project) - }) - .await - .map_err(|error| { - openbot_desktop_lib::problem::Problem::plain(format!("A key could not be created: {error}")) - })??; - let key = provisioned.api_key.clone(); - *app.state::().intelligence_learning.lock().unwrap() = Some(provisioned); - Ok(key) +fn selected_root(app: tauri::AppHandle) -> Option { + app.state::() + .selected_root + .lock() + .unwrap() + .as_ref() + .map(|root| root.to_string_lossy().into_owned()) } +/** +What a previous run already wrote, so the wizard can arrive filled in. + +Returned to the window because that is where the fields are, and it is the same machine and the +same person: reading their own file back to them is not a disclosure. The key is not logged here or +anywhere, and only the settings the wizard asks about are read. +*/ #[tauri::command] -async fn begin_organization_sign_in( - app: tauri::AppHandle, +fn already_configured( + app: tauri::AppHandle, root: String, - authority_url: String, - provider: String, -) -> Result { +) -> AlreadyConfigured { + let mut configured = already_configured_for_root(root); + configured.auto_start = !app + .state::() + .stopped_in_session + .load(std::sync::atomic::Ordering::SeqCst); + configured +} + +fn already_configured_for_root(root: String) -> AlreadyConfigured { let root = stack::root_from(&root); - let handle = app.clone(); - let url = tauri::async_runtime::spawn_blocking(move || { - let shell = handle.state::(); - let _startup = shell.startup.lock().unwrap(); - if shell.root.lock().unwrap().as_deref() != Some(root.as_path()) { - return Err(Problem::plain( - "Start this OpenBot installation before signing in to its organization.", - )); - } - let configured = - openbot_env::already_set(&root.join(".env"), &["OPENBOT_ORGANIZATION_AUTH_URL"]) - .remove("OPENBOT_ORGANIZATION_AUTH_URL") - .unwrap_or_default(); - if configured.trim() != authority_url.trim() { - return Err(Problem::plain( - "Save this organization's OpenBot URL before signing in.", - )); - } - openbot_desktop_lib::organization_auth::begin(&root, &authority_url, &provider) - }) - .await - .map_err(|_| Problem::plain("Organization sign-in could not start."))??; - if tauri_plugin_opener::OpenerExt::opener(&app) - .open_url(&url, None::<&str>) - .is_err() - { - eprintln!( - "[organization] browser could not open; use the sign-in address shown in the window" - ); + let env_file = root.join(".env"); + let mut values = openbot_desktop_lib::vault::already_given_file_only( + &env_file, + &[ + /* + * The model credentials, so the wizard never asks twice for one of these. + * + * A key already in the file is one somebody has already produced, and making them find + * it again means opening a dotfile in an editor. Read back because it is their own file, + * on their own machine, and this is the screen that asks for it. Intelligence's settings + * are not read: they belong to the local stack, which Start is given afresh. + */ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_CONTAINER_BASE_URL", + "BOT_MODEL", + "CLAUDE_CODE_OAUTH_TOKEN", + ], + ); + + use openbot_desktop_lib::saved_intent::{Category, SavedIntent}; + let intent = SavedIntent::read(&root); + let hint = |category, file_present| { + (file_present || intent.categories.contains(&category)).then_some(true) + }; + let claude_plan = values.remove("CLAUDE_CODE_OAUTH_TOKEN").is_some(); + let local_eval = local_eval_state::read(&root, None); + AlreadyConfigured { + /* + * A launch only with the local Intelligence stack it launched with. + * + * An installation set up by an earlier app against hosted or self-hosted Intelligence has a + * launch record and no local stack. Offering that launch would reopen it against a service + * this app no longer connects to, so it is not offered: the window starts the local setup, + * and the installation, its settings and its data are all left where they are. + */ + launch: preparation::launch(&root).filter(|_| local_eval.is_some()), + installation: preparation::installation(&root), + auto_start: true, + local_eval, + saved: SavedConfiguration { + model_api_keys: SavedModelApiKeys { + openai: hint( + Category::OpenAiApiKey, + values.contains_key("OPENAI_API_KEY"), + ), + anthropic: hint( + Category::AnthropicApiKey, + values.contains_key("ANTHROPIC_API_KEY"), + ), + compatible: values + .get("OPENAI_BASE_URL") + .is_some_and(|url| intent.has_compatible_key_for(url)) + .then_some(true), + }, + model_sessions: SavedModelSessions { + openai: hint( + Category::ChatGptPlan, + openbot_env::saved_chatgpt_plan_store(&root), + ), + anthropic: hint(Category::ClaudePlan, claude_plan), + google: hint(Category::GoogleOauth, false), + xai: hint(Category::XaiOauth, false), + }, + model: intent.model, + }, + values, } - Ok(url) } +/// Start a Claude plan sign-in and return the address a browser has to open. +/// +/// Blocking work on a blocking thread: it starts a container and waits on its output, and doing +/// that on the UI thread is a window that stops repainting mid-setup. #[tauri::command] -async fn finish_organization_sign_in( - app: tauri::AppHandle, - root: String, -) -> Result { +async fn begin_claude_sign_in(app: tauri::AppHandle, root: String) -> Result { let root = stack::root_from(&root); - tauri::async_runtime::spawn_blocking(move || { - let shell = app.state::(); - let generation = shell.generation.load(std::sync::atomic::Ordering::SeqCst); - let user = openbot_desktop_lib::organization_auth::finish(&root)?; - let _startup = shell.startup.lock().unwrap(); - if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation - || shell.root.lock().unwrap().as_deref() != Some(root.as_path()) + remember_selected_root(&app.state::(), &root); + /* + * The image is decided here, not by the window, and it is the Claude Agent SDK harness whatever + * harness the person picked. It is not being used as a Bot: it is the container that happens to + * carry Anthropic's bundled CLI, which is what does the OAuth. Letting the screen name an image + * would make the sign-in depend on a choice that has nothing to do with it. + */ + #[cfg(target_os = "linux")] + let service_app = app.clone(); + let (signing, url) = tauri::async_runtime::spawn_blocking(move || { + let (address, image) = prepared_sign_in(&root, openbot_desktop_lib::plan::SIGN_IN_IMAGE)?; + #[cfg(target_os = "linux")] { - return Err(Problem::plain( - "That sign-in belongs to a previous OpenBot run. Open this installation again.", - )); + let shell = service_app.state::(); + let _startup = shell.startup.lock().unwrap(); + ensure_linux_podman_api(&shell, &address)?; } - Ok(user) + openbot_desktop_lib::plan::SigningIn::begin(&address, &image).map_err(Problem::from) }) .await - .map_err(|_| Problem::plain("Organization sign-in did not finish."))? -} - -#[tauri::command] -fn cancel_organization_sign_in(root: String) { - openbot_desktop_lib::organization_auth::cancel(&stack::root_from(&root)); -} - -/// The model screen's rows. Independent of the picker above, and required to stay that way: no -/// harness on that list is tied to a vendor's models, so choosing one may not narrow this. -#[tauri::command] + .map_err(|error| { + Problem::with( + "The sign-in did not start. Try again.", + format!("the sign-in task did not run: {error}"), + ) + })??; + *app.state::().signing_in.lock().unwrap() = Some(signing); + + /* + * Opened here rather than by the window, because the window would need the shell plugin's JS + * half for the one call. The URL is returned as well, and the screen shows it: on Linux without + * a registered browser, and in a session where the open silently does nothing, a link somebody + * can copy is the difference between a stuck screen and a finished sign-in. + */ + let _ = tauri_plugin_opener::OpenerExt::opener(&app).open_url(&url, None::<&str>); + Ok(url) +} + +/** +Redeem the code from the browser and return the plan token. + +The token crosses to the window and comes back in the model choice, which is the same path a typed +key takes. It is never logged, and the failure messages never carry the command's output: see +`SigningIn::gave_up`. +*/ +#[tauri::command] +async fn finish_claude_sign_in(app: tauri::AppHandle, code: String) -> Result { + // Taken, not borrowed. A sign-in is single-use, and leaving it in place would let a second + // attempt write a code into a flow that has already finished. + let signing = app + .state::() + .signing_in + .lock() + .unwrap() + .take() + .ok_or_else(|| "That sign-in is no longer running. Start it again.".to_string())?; + tauri::async_runtime::spawn_blocking(move || signing.finish(&code)) + .await + .map_err(|error| format!("The sign-in did not finish: {error}"))? +} + +/// Start a ChatGPT plan sign-in and return the address a browser has to open. +#[tauri::command] +async fn begin_chatgpt_sign_in( + app: tauri::AppHandle, + root: String, +) -> Result { + let root = stack::root_from(&root); + remember_selected_root(&app.state::(), &root); + #[cfg(target_os = "linux")] + let service_app = app.clone(); + let (signing, url) = tauri::async_runtime::spawn_blocking(move || { + let (address, image) = + prepared_sign_in(&root, openbot_desktop_lib::plan::CHATGPT_SIGN_IN_IMAGE)?; + #[cfg(target_os = "linux")] + { + let shell = service_app.state::(); + let _startup = shell.startup.lock().unwrap(); + ensure_linux_podman_api(&shell, &address)?; + } + openbot_desktop_lib::plan::SigningInToChatGpt::begin(&address, &image) + }) + .await + .map_err(|error| { + Problem::with( + "The sign-in did not start. Try again.", + format!("the sign-in task did not run: {error}"), + ) + })??; + *app.state::().signing_in_to_chatgpt.lock().unwrap() = Some(signing); + let _ = tauri_plugin_opener::OpenerExt::opener(&app).open_url(&url, None::<&str>); + Ok(url) +} + +/** +Wait for the ChatGPT redirect to land, and return the plan token. + +Nothing is sent: the browser's callback is what finishes it. So this is a wait rather than a +redemption, which is why there is no code field on that half of the screen. +*/ +#[tauri::command] +async fn finish_chatgpt_sign_in(app: tauri::AppHandle) -> Result { + let signing = app + .state::() + .signing_in_to_chatgpt + .lock() + .unwrap() + .take() + .ok_or_else(|| "That sign-in is no longer running. Start it again.".to_string())?; + tauri::async_runtime::spawn_blocking(move || signing.finish()) + .await + .map_err(|error| format!("The sign-in did not finish: {error}"))? +} + +/// The model screen's rows. Independent of the Bot, and required to stay that way: no harness is +/// tied to a vendor's models, so the Bot may not narrow this. +#[tauri::command] fn providers() -> Vec { provider::catalogue() } @@ -4607,3537 +5142,6519 @@ 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); - } - if recovery_required(&shell, root) { - return Ok(true); +/** +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; } - 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()); + let found = engine::detect_saved(preparation::saved_engine(root)); + if let Some(address) = found.address.filter(|_| found.responding) { + local_eval::use_engine(&address); } - *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; +/// 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); + } +} - 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; - } +/// 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), +} - 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; - } - } - } - } +/** +`local setup` for this installation, and the stack it made recorded as OpenBot's. - // 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 - }; +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) +} - 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); - } +/** +Sign in to CopilotKit, which local evaluation needs to issue its licence: `login --json`. - 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 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)? +} - // 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); - } - } - } - } - }) +/// 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)? } -/// The same lock covers generation validation, launch, publication, and owned cleanup on every -/// platform. Stop can retire during spawn, but cannot finish before receiving that child handle. -fn restart_host_process_with( - shell: &Shell, - root: &Path, - name: &'static str, - generation: u64, - spawn: F, -) -> Result +/** +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 - F: FnOnce() -> std::io::Result, + S: FnOnce(&Path) -> Result<(local_eval::Report, String), local_eval::Failure>, { - let mut children = shell.children.lock().unwrap(); - if shell.generation.load(std::sync::atomic::Ordering::SeqCst) != generation - || shell.root.lock().unwrap().as_deref() != Some(root) - { - return Ok(false); - } - let child = spawn().map_err(|error| { - Problem::with( - format!("OpenBot could not restart {name}."), - error.to_string(), - ) - })?; - #[cfg(unix)] - stack::replace_host_process(root, &mut children, name, child)?; - #[cfg(not(unix))] - stack::replace_windows_host_process_with( - root, - &mut children, - name, - child, - Path::new("powershell"), - )?; - Ok(shell.generation.load(std::sync::atomic::Ordering::SeqCst) == generation) -} - -/// Point the window at OpenBot if it is up, and at the setup screen if it is not. -/// -/// Used by the tray and by a second launch, both of which happen at moments when the caller has no -/// idea which of the two the person should be looking at. -fn show_whichever_applies(app: &tauri::AppHandle) { - match ports_for_shell(app) { - Ok(ports) => restore_window_on(app, &ports), - Err(problem) => { - *app.state::().last_failure.lock().unwrap() = Some(problem); - if let Err(error) = show_setup_and_focus(app.clone()) { - eprintln!("{error}"); - } - } - } + 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) } -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}"), +/// 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) +} + +/// 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, + connect: C, + mut remember: R, + mut recall: K, +) -> Result +where + C: FnOnce(&Path) -> Result, + R: FnMut(&Path, &str, &str) -> Result<(), Problem>, + K: FnMut(&Path, &str) -> Result, Problem>, +{ + 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.", + )); }; - // 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() + 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 Err(unconnected( + "The Intelligence CLI's connection settings do not match its stack. OpenBot did not use them.", + )); + } + 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()), + )) + })?; } - }); - 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); + 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 _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); + 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) } -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)) +#[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) } -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}"), - ); - } +#[tauri::command] +fn cancel_local_eval(attempt: String) { + local_eval::cancel(&attempt); } -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); +/// 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 menu says. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DashboardFailure { + NotSetUp, + NotResponding, + BrowserFailed, + Busy, + EngineStopped, + Unsupported, + TimedOut, + Stopped, + Failed, } -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"); +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." } - // 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); + 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.", } - let _ = show_setup(app.clone()); - }); + } } -/// 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); - } - _ => {} - } +/// `sign_in` for the stack recorded in `root`, and nothing for a root with no record. +fn open_dashboard_at(root: &Path, sign_in: L) -> Result<(), DashboardFailure> +where + L: FnOnce(&local_eval::StackId) -> Result, +{ + use local_eval::{Failure, Outcome, Reason}; + let recorded = local_eval_state::read(root, None).ok_or(DashboardFailure::NotSetUp)?; + Err(match sign_in(&recorded.stack_id()) { + Ok(signed) if signed.outcome == Outcome::Completed => return Ok(()), + Ok(signed) => match signed.reason { + Some(Reason::DashboardUnavailable) => DashboardFailure::NotResponding, + Some(Reason::BrowserOpenFailed) => DashboardFailure::BrowserFailed, + Some(Reason::LocalCommandBusy) => DashboardFailure::Busy, + Some(Reason::DockerUnavailable) => DashboardFailure::EngineStopped, + _ => DashboardFailure::Failed, + }, + Err(Failure::Unavailable) => DashboardFailure::Unsupported, + Err(Failure::Timeout) => DashboardFailure::TimedOut, + Err(Failure::Cancelled) => DashboardFailure::Stopped, + Err(_) => DashboardFailure::Failed, + }) } -fn quit_menu_accelerator() -> Option<&'static str> { - Some(QUIT_MENU_ACCELERATOR) +/** +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>, } -/// The Recorder host install's real side effects on this computer. -struct RecorderInstallWriter; +#[derive(Default)] +struct KnownDashboard { + address: Option, + signed_in: bool, +} -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)?; +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; } - std::fs::write(path, contents) + entry.address = Some(stack.urls.dashboard.clone()); } - fn read_file(&self, path: &Path) -> Option { - std::fs::read_to_string(path).ok() + 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 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, - } + fn mark_signed_in(&self, stack: &local_eval::StackId) { + self.known + .lock() + .unwrap() + .entry(stack.as_str().to_owned()) + .or_default() + .signed_in = true; } +} - // 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) - ))) - } +/** +Open `stack`'s dashboard: straight to its address once this session has signed it in, otherwise +through `login`, remembering a completed sign-in. - fn exists(&self, path: &Path) -> bool { - path.exists() +`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 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 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 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 root whose dashboard the menu opens: only one in local mode with a recorded stack. +fn intelligence_menu_root(root: &Path, offered: bool) -> Option { + (offered && local_eval_state::read(root, None).is_some()).then(|| root.to_path_buf()) } -/// The variable a debug build's Recorder host install reads its home folder from. -const RECORDER_HOST_TEST_HOME: &str = "OPENBOT_RECORDER_HOST_TEST_HOME"; +/** +The window says whether it is in local mode for `root`, and the menus follow. -fn non_empty_var(name: &str) -> Option { - std::env::var_os(name).filter(|value| !value.is_empty()) +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)); } -/// 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(), +/// The menu's "Open Intelligence dashboard". The CLI can take many seconds the first time, so the +/// window comes forward without leaving what it shows, where the setup page or the app's sidebar +/// says the dashboard is opening. +fn open_intelligence_from_menu(app: tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + start_intelligence_opening(app); +} + +/** +Open the dashboard of the stack the menus offer, from the menu or the app's sidebar alike. + +Answered at once, before any CLI call: the menu item is disabled, a setup page shows the words +with a spinner, and the app's sidebar row spins (`intelligence_bridge`). How it went is said in the +same places. A failure that has none of them on screen is said in a dialog. +*/ +fn start_intelligence_opening(app: tauri::AppHandle) { + let shell = app.state::(); + let 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; }; - // 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 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(|_| ()) + }, ); - 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()), - )) +/// 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() + } } -#[cfg(debug_assertions)] -fn keep_recorder_host_installed() -> Option> { - None +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(), + } + } } -/// 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, +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)); + } +} + +const OPENING_INTELLIGENCE_ITEM: &str = "Opening Intelligence dashboard…"; + +/// The menu's "Open Intelligence dashboard" item: its words, and whether it can be chosen. +fn intelligence_menu_item(opening: bool) -> (&'static str, bool) { + if opening { + (OPENING_INTELLIGENCE_ITEM, false) + } else { + ("Open Intelligence dashboard", true) + } +} + +/// Tell the setup page and the app's sidebar, then put the menus in step. Nothing is written in +/// the menu bar: a status title there widened the item behind the camera notch and, on macOS, +/// clearing the title never took it away. A failure with no page on screen to say it gets a dialog. +/// Menus belong to the main thread. +fn show_intelligence_opening( + app: &tauri::AppHandle, + status: IntelligenceOpening, +) { + let _ = app.emit(INTELLIGENCE_OPENING_EVENT, &status); + let told = intelligence_bridge::tell(app, intelligence_bridge::Offer::after(&status)); + if let IntelligenceOpening::Failed(failure) = &status { + if !told && !showing_setup(app) { + use tauri_plugin_dialog::DialogExt; + app.dialog() + .message(failure.said()) + .title("Open Intelligence dashboard") + .show(|_| {}); + } + } + refresh_menus(app); +} + +/// Room left between a grown or clamped window and the edge of its monitor's work area. +const SCREEN_MARGIN: f64 = 24.0; + +/** +How tall the window's content area should become, in logical pixels, or None to leave it. + +Grows to `wanted` and never shrinks: a window that jumped smaller under somebody's pointer when a +row collapsed would be worse than one with a little room to spare. `tallest` is the most the content +area may have on this monitor; past it the page scrolls, as it always could. +*/ +fn grown_height(current: f64, wanted: f64, tallest: f64) -> Option { + let target = wanted.ceil().min(tallest.floor()); + (target > current.ceil()).then_some(target) +} + +/// The window's monitor work area in logical pixels, and the height of its title bar and frame. +fn screen_room( + window: &tauri::WebviewWindow, +) -> Option<(tauri::LogicalRect, f64)> { + let scale = window.scale_factor().ok()?; + let monitor = window.current_monitor().ok().flatten()?; + let area = monitor.work_area(); + let frame = window.outer_size().ok()?.height as f64 - window.inner_size().ok()?.height as f64; + Some(( + tauri::LogicalRect { + position: area.position.to_logical(scale), + size: area.size.to_logical(scale), + }, + frame / scale, )) } -#[cfg(debug_assertions)] -fn install_recorder_host_at_start() -> Option> { - None +/// Resize the content area to `height` logical pixels, moving the window up if its bottom would +/// leave the work area. +fn set_window_height( + window: &tauri::WebviewWindow, + area: &tauri::LogicalRect, + frame: f64, + height: f64, +) { + let Ok(scale) = window.scale_factor() else { + return; + }; + let Ok(inner) = window.inner_size() else { + return; + }; + let width = inner.to_logical::(scale).width; + let _ = window.set_size(tauri::LogicalSize::new(width, height)); + if let Ok(position) = window.outer_position() { + let position = position.to_logical::(scale); + let bottom = area.position.y + area.size.height - SCREEN_MARGIN; + if position.y + frame + height > bottom { + let top = (bottom - frame - height).max(area.position.y); + let _ = window.set_position(tauri::LogicalPosition::new(position.x, top)); + } + } } -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())); +/** +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; } - // 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 - } - }); + 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); } - let context = tauri::generate_context!(); - // A `shell-updater` build with compiled trust supplies `plugins.updater` when no bundled block - // (such as the signed Mac overlay) already does. Registration itself happens once, in `setup`. - #[cfg(feature = "shell-updater")] - let context = { - let mut context = context; - shell_updates::configure_plugin( - &mut context.config_mut().plugins, - shell_updates::compiled_trust().ok().flatten().as_ref(), - ); - context +} + +/// 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; }; - 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()) - // Only so the window can restart into what the updater wrote. - .plugin(tauri_plugin_process::init()) - .manage(Shell::default()) - .invoke_handler(tauri::generate_handler![ - record_setup_event, - detect_engine, - windows_blocker, - windows_blocker_instruction, - prepare_engine, - prepare_installation, - check_runtime_update, - check_shell_update, - apply_shell_update, - stage_runtime_update, - establish_runtime_baseline, - apply_runtime_update, - runtime_update_status, - recover_runtime_update, - 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, - connect_intelligence_apps, - 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| { - // A signed Mac build updates itself. The window asks once at launch and installs only - // if the person says yes; the plugin refuses anything whose minisign signature does not - // match the public key compiled in from `tauri.macos-signing.conf.json`, so the - // endpoint is a place to look rather than something to trust. Only that overlay carries - // `plugins.updater`, so every other build (desktop CI, local, the unsigned release - // installers) has no updater: it cannot verify anything, and offering an update it - // would then refuse is worse than offering none. The window's check finds no plugin - // and stays silent. A `shell-updater` build may also have filled the block in from its - // compiled trust before the app was built (see above); either way the block decides, - // and this is the one place the plugin is registered. - if app.config().plugins.0.contains_key("updater") { - app.handle() - .plugin(tauri_plugin_updater::Builder::new().build())?; - } else { - eprintln!("[updater] not configured in this build; no update check"); - } - 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(); - - // The status menu lets somebody open the window, stop the stack, or quit the app. - use tauri::menu::{Menu, MenuItem}; - use tauri::tray::TrayIconBuilder; - - let open = MenuItem::with_id(app, "open", "Open OpenBot", true, None::<&str>)?; - let stop = MenuItem::with_id(app, "stop", "Stop OpenBot", true, None::<&str>)?; - let quit = MenuItem::with_id(app, "quit", "Quit", true, quit_menu_accelerator())?; - let menu = Menu::with_items(app, &[&open, &stop, &quit])?; + 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()); + } +} - TrayIconBuilder::with_id("openbot") - .icon(tray::icon()) - .icon_as_template(false) - .tooltip("OpenBot") - .menu(&menu) - .build(app)?; +/// 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)) +} - // 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(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.", - ); - } - } - _ => {} - } - }); +/// `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()) } -#[cfg(test)] -mod runtime_update_tests { - use super::*; - #[test] - fn runtime_update_backup_down_clears_temporary_ownership_before_preparation() { - let shell = Shell::default(); - let root = PathBuf::from("/fixture/customer-installation"); - *shell.containers.lock().unwrap() = Some(ContainerDeployment { - root: root.clone(), - address: engine::Address::new(engine::Engine::Docker, None), - }); - down_containers_with(&shell, &root, |_| Ok(())).unwrap(); - assert!(shell.containers.lock().unwrap().is_none()); - assert_eq!( - shell.stopped_container_root.lock().unwrap().as_ref(), - Some(&root) - ); +/// 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); } - #[test] - fn runtime_update_user_stop_prevents_a_later_new_start_attempt() { - use std::sync::atomic::Ordering::SeqCst; - let shell = Shell::default(); - shell.updating.store(true, SeqCst); - shell.update_cancelled.store(true, SeqCst); - assert!(StartAttempt::begin(&shell).is_err()); - assert!(!shell.starting.load(SeqCst)); + if recovery_required(&shell, root) { + return Ok(true); + } + let said = match connection { + openbot_desktop_lib::problem::Connection::Model => "Your AI connection was refused. Refresh it to continue using your existing OpenBot.", + openbot_desktop_lib::problem::Connection::Intelligence => "Your CopilotKit connection was refused. Refresh it to continue using your existing OpenBot.", + openbot_desktop_lib::problem::Connection::Installation => "Part of OpenBot has to be installed again. OpenBot does that, then you can 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) } -#[cfg(test)] -mod tests { - use super::*; - use std::io::{Read, Write}; - - include!("stop_ipc_tests.rs"); - include!("composio_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()); +/// 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) +} - // 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), +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(), ) - .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 - ); +/// 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(()) +} - 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(); - } - 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)); + loop { + std::thread::sleep(std::time::Duration::from_secs(2)); + let shell = app.state::(); + if !current(&shell) { + return; + } + 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; } - } - std::fs::remove_dir_all(root).unwrap(); - } + report( + &app, + stack::LOCAL_BROWSER, + false, + "Your browser stopped. Starting it again.", + ); + std::thread::sleep(supervise::backoff(watch.restarts - 1)); - #[test] - fn quit_menu_uses_the_standard_quit_shortcut() { - assert_eq!(quit_menu_accelerator(), Some("CmdOrCtrl+KeyQ")); - } + // 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), + } + } + }) +} - #[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(); - }) +/** +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 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; }; - 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(); - } + let mut notice_sent = false; + let mut poll_failed = false; - #[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); - } + 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() + { + trust_owned_app(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. The tray's has "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>, + )?), + ]; + // "Open Intelligence dashboard" is the status bar menu's alone. + 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 + } + }); + } + let context = tauri::generate_context!(); + // A `shell-updater` build with compiled trust supplies `plugins.updater` when no bundled block + // (such as the signed Mac overlay) already does. Registration itself happens once, in `setup`. + #[cfg(feature = "shell-updater")] + let context = { + let mut context = context; + shell_updates::configure_plugin( + &mut context.config_mut().plugins, + shell_updates::compiled_trust().ok().flatten().as_ref(), + ); + context + }; + 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()) + // Only so the window can restart into what the updater wrote. + .plugin(tauri_plugin_process::init()) + .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, + check_runtime_update, + check_shell_update, + apply_shell_update, + stage_runtime_update, + establish_runtime_baseline, + apply_runtime_update, + runtime_update_status, + recover_runtime_update, + start_stack, + reset_leftover_database, + stop_stack, + show_openbot, + show_setup, + already_running, + last_failure, + default_root, + selected_root, + 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, + run_local_eval, + list_local_eval, + cancel_local_eval, + offer_local_intelligence, + set_up_local_eval, + sign_in_to_copilotkit, + local_eval_terms, + connect_local_eval, + last_local_eval_stop_failure, + 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_app_page(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| { + // A signed Mac build updates itself. The window asks once at launch and installs only + // if the person says yes; the plugin refuses anything whose minisign signature does not + // match the public key compiled in from `tauri.macos-signing.conf.json`, so the + // endpoint is a place to look rather than something to trust. Only that overlay carries + // `plugins.updater`, so every other build (desktop CI, local, the unsigned release + // installers) has no updater: it cannot verify anything, and offering an update it + // would then refuse is worse than offering none. The window's check finds no plugin + // and stays silent. A `shell-updater` build may also have filled the block in from its + // compiled trust before the app was built (see above); either way the block decides, + // and this is the one place the plugin is registered. + if app.config().plugins.0.contains_key("updater") { + app.handle() + .plugin(tauri_plugin_updater::Builder::new().build())?; + } else { + eprintln!("[updater] not configured in this build; no update check"); + } + 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(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 runtime_update_tests { + use super::*; + #[test] + fn runtime_update_backup_down_clears_temporary_ownership_before_preparation() { + let shell = Shell::default(); + let root = PathBuf::from("/fixture/customer-installation"); + *shell.containers.lock().unwrap() = Some(ContainerDeployment { + root: root.clone(), + address: engine::Address::new(engine::Engine::Docker, None), + }); + down_containers_with(&shell, &root, |_| Ok(())).unwrap(); + assert!(shell.containers.lock().unwrap().is_none()); + assert_eq!( + shell.stopped_container_root.lock().unwrap().as_ref(), + Some(&root) + ); + } + #[test] + fn runtime_update_user_stop_prevents_a_later_new_start_attempt() { + use std::sync::atomic::Ordering::SeqCst; + let shell = Shell::default(); + shell.updating.store(true, SeqCst); + shell.update_cancelled.store(true, SeqCst); + assert!(StartAttempt::begin(&shell).is_err()); + assert!(!shell.starting.load(SeqCst)); + } +} + +#[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 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(); + 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 = local_eval_root( + "stopped-completed-installation", + Some(local_eval_state::Ownership::Created), + ); + 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"), + "OPENAI_BASE_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!("OPENAI_BASE_URL=https://synthetic.example\n{legacy}")).unwrap(); + let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + assert_eq!(configured.values["OPENAI_BASE_URL"], "https://synthetic.example"); + assert!(!configured.values.contains_key("INTELLIGENCE_API_KEY")); + 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:?}"); + } + + // A pinned build installs its own pin, so the unpinned keep-the-installed-release behaviour does not apply. + #[cfg(not(openbot_pinned))] + /// Start refuses a deployment of the other kind, so Install must replace it even when the + /// version matches; otherwise the person is sent back to Install with nothing it will change. + #[test] + fn install_lays_out_again_a_deployment_of_the_other_kind_at_the_same_version() { + let root = temp_root("deployment-kind"); + write_installed_deployment(&root); + let pin = deployment::Pin { + version: "v0.3.0".into(), + commit: "0".repeat(40), + images: "{}", + }; + deployment::record(&root, "v0.3.0", false).unwrap(); + assert!(!deployment_needs_laying_out(&root, "v0.3.0", None)); + assert!(deployment_needs_laying_out(&root, "v0.3.0", Some(&pin))); + deployment::record(&root, "v0.3.0", true).unwrap(); + assert!(!deployment_needs_laying_out(&root, "v0.3.0", Some(&pin))); + assert!(deployment_needs_laying_out(&root, "v0.3.0", None)); + std::fs::remove_dir_all(root).unwrap(); + } + + #[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", false).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"), + "OPENAI_BASE_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("OPENAI_BASE_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!(!configured.values.contains_key("INTELLIGENCE_API_KEY")); + assert_eq!( + configured.values.get("OPENAI_API_KEY"), + Some(&"file-openai".to_string()) + ); + 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); + } + + /// An installation an earlier app set up against hosted or self-hosted Intelligence, or behind + /// an organization's sign-in: its launch is not offered, so the window starts the local setup, + /// and nothing of it is read back or removed. + #[test] + fn a_hosted_installation_is_not_offered_as_a_launch_and_is_left_in_place() { + let root = temp_root("openbot-already-configured-hosted"); + std::fs::create_dir_all(&root).unwrap(); + let env = "INTELLIGENCE_API_KEY=synthetic-hosted-key\nINTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai\nINTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai\nOPENBOT_ORGANIZATION_AUTH_URL=https://openbot.company.example\nOPENAI_API_KEY=synthetic-openai\n"; + std::fs::write(root.join(".env"), env).unwrap(); + std::fs::write(root.join(preparation::FILE), "{}").unwrap(); + preparation::record_launch(&root, None).unwrap(); + assert!(preparation::launch(&root).is_some()); + + let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + let json = serde_json::to_value(&configured).unwrap(); + assert!(json.get("launch").is_none(), "{json}"); + assert!(json.get("localEval").is_none(), "{json}"); + assert!(json["saved"].get("intelligenceApiKey").is_none(), "{json}"); + for hosted in [ + "INTELLIGENCE_API_KEY", + "INTELLIGENCE_API_URL", + "INTELLIGENCE_GATEWAY_WS_URL", + "OPENBOT_ORGANIZATION_AUTH_URL", + ] { + assert!(!configured.values.contains_key(hosted), "{json}"); + } + assert!(!json.to_string().contains("synthetic-hosted-key")); + // The model answer is still read back, and the saved files are untouched. + assert_eq!(configured.saved.model_api_keys.openai, Some(true)); + assert_eq!(std::fs::read_to_string(root.join(".env")).unwrap(), env); + assert!(preparation::launch(&root).is_some()); + + // Once the local stack is recorded, the same installation's launch is offered again. + 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()); + assert!(configured.launch.is_some()); + 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.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.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 responsive_quit_reports_cleanup_failures_before_requesting_exit() { - use std::sync::Arc; - let state = Arc::new(QuitState::default()); - let events = Arc::new(Mutex::new(Vec::new())); - let diagnostics = events.clone(); - let exiting = events.clone(); - let work = std::cell::RefCell::new(None); - request_quit_with( - state, - None, - || {}, - || { - vec![ - "host cleanup refused".into(), - "Compose down failed: synthetic".into(), - ] - }, - QuitDiagnostics { - sink: move |lines: Vec| { - diagnostics.lock().unwrap().extend(lines); - Ok(()) - }, - failed: |_: String| panic!("diagnostics should be recorded"), - }, - move |code| exiting.lock().unwrap().push(format!("exit:{code}")), - |task| { - *work.borrow_mut() = Some(task); - Ok(()) + fn 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, |_, 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_resolves_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(); + } + + 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() + } + ); + + 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 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!( - events.lock().unwrap().is_empty(), - "cleanup ran on the requesting thread" + require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Ok( + false + )) + .is_ok() ); - work.into_inner().expect("cleanup worker")(); + assert!( + require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Ok(true)) + .is_err() + ); + let unavailable = Problem::plain("selected engine unavailable"); assert_eq!( - *events.lock().unwrap(), - [ - "[exit] cleanup failed: host cleanup refused", - "[exit] cleanup failed: Compose down failed: synthetic", - "exit:0" - ] + require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Err( + unavailable.clone() + )), + Err(unavailable) ); + std::fs::write( + root.join(".env"), + "DATABASE_URL=postgres://synthetic-local\n", + ) + .unwrap(); + let original = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; + let secrets = + std::collections::BTreeMap::from([("KEY_ENCRYPTION_KEY".into(), original.into())]); + assert!(require_existing_encryption_key(&root, &secrets, || { + panic!("normal resume with the original key needs no volume probe") + }) + .is_ok()); + assert_eq!(secrets["KEY_ENCRYPTION_KEY"], original); + std::fs::remove_dir_all(root).unwrap(); } #[test] - fn responsive_quit_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")), - ); + 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!( - result.unwrap_err().to_string(), - "synthetic worker launch failure" + offered["leftover_reset"], + serde_json::json!(["openbot_postgres-data"]) ); - 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(()) - }, + 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") ) - .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); + .is_ok()); + std::fs::remove_dir_all(root).unwrap(); } - #[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(), - ]; + 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() + } + } - 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()); + 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 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(()) - }, + 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()), ) - .unwrap(); + .is_ok()); - 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}" + // 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!( - reported.contains("[exit] cleanup failed: raw cleanup failure"), - "{reported}" + assert_eq!( + error.leftover_volumes(), + database_leftovers(&BOT_LEFTOVERS).volumes ); - 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(()) - }, + + // 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(); - assert!( - exited.load(std::sync::atomic::Ordering::SeqCst), - "Quit did not retry after diagnostic failure" - ); - } + .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 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() + // 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()); - 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)); + .unwrap_err(); + assert_eq!(error.leftover_volumes(), BOT_LEFTOVERS); assert_eq!( - window.url().unwrap().as_str(), - "http://tauri.localhost/recovery" + error.leftover_containers(), + ["openbot-computer-general-assistant"] ); assert_eq!( - last_failure_now(app.handle()).unwrap().connection, - Some(openbot_desktop_lib::problem::Connection::Model) + serde_json::to_value(&error).unwrap()["leftover_containers"], + serde_json::json!(["openbot-computer-general-assistant"]) ); - assert!(publish_connection_failure( - app.handle(), + held.running = vec!["openbot-agent-computer-1".into()]; + let error = require_existing_encryption_key_with_recovery( &root, - 7, - openbot_desktop_lib::problem::Connection::Intelligence + &stack::Secrets::new(), + || Ok(false), + || Ok(held), ) - .unwrap()); + .unwrap_err(); + assert!(error.leftover_volumes().is_empty() && error.leftover_containers().is_empty()); assert!( - last_failure_now(app.handle()).is_none(), - "one refresh per run; a second signal cannot replace the pending screen" + error.said.contains("openbot-agent-computer-1") && error.said.contains("still running"), + "{}", + error.said ); - } - - #[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(); + // Unknown root metadata refuses Start but never offers deletion. std::fs::write( - root.join(".env"), - "INTELLIGENCE_API_URL=https://synthetic.example\n", + root.join(openbot_desktop_lib::saved_intent::FILE), + "invalid-settings-secret", ) .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:?}"); - } + 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 pinned build installs its own pin, so the unpinned keep-the-installed-release behaviour does not apply. - #[cfg(not(openbot_pinned))] - /// Start refuses a deployment of the other kind, so Install must replace it even when the - /// version matches; otherwise the person is sent back to Install with nothing it will change. - #[test] - fn install_lays_out_again_a_deployment_of_the_other_kind_at_the_same_version() { - let root = temp_root("deployment-kind"); - write_installed_deployment(&root); - let pin = deployment::Pin { - version: "v0.3.0".into(), - commit: "0".repeat(40), - images: "{}", - }; - deployment::record(&root, "v0.3.0", false).unwrap(); - assert!(!deployment_needs_laying_out(&root, "v0.3.0", None)); - assert!(deployment_needs_laying_out(&root, "v0.3.0", Some(&pin))); - deployment::record(&root, "v0.3.0", true).unwrap(); - assert!(!deployment_needs_laying_out(&root, "v0.3.0", Some(&pin))); - assert!(deployment_needs_laying_out(&root, "v0.3.0", None)); + // 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(); } #[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", false).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(); + 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 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())) + 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!(shell.leftover_database.lock().unwrap().is_none()); + std::fs::remove_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); + #[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"), + ( + 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(()) + } + ) + .is_ok()); + std::fs::remove_dir_all(root).unwrap(); } #[test] - fn command_roots_trim_paste_padding_and_preserve_interior_spaces() { - let root = temp_root("openbot-command-root My Files"); + 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(); - std::fs::write(root.join("settings-marker"), "this deployment").unwrap(); - for typed in [ - root.display().to_string(), - format!(" \n{}\t ", root.display()), + 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), ] { - let work_root = stack::root_from(&typed); - assert_eq!( - std::fs::read_to_string(work_root.join("settings-marker")).unwrap(), - "this deployment" - ); + 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()); } - 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", + // 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) + ); + 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(); - 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()) + assert!( + 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 already_configured_returns_file_values_and_saved_indicators() { - let root = temp_root("openbot-already-configured"); + #[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"); + // Recorded as this root's project, which every Compose command the app runs is given and + // which outranks the file's `name:`. See `stack::project`. 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", + root.join(stack::PROJECT_FILE), + serde_json::json!({ "project": project }).to_string(), ) .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", + 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 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); - } + 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"); - #[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") + // 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(); + 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(); - }); - 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() + 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(); - }) - .await + assert!( + ready.status.success(), + "Postgres must remain ready after the refused attached-volume reset" + ); }); - + let stopped = address + .command() + .current_dir(&root) + .args(["compose", "down"]) + .output() + .unwrap(); + let removed = address + .command() + .args(["volume", "rm", &volume]) + .output() + .unwrap(); + std::fs::remove_dir_all(&root).unwrap(); assert!( - scheduled.is_ok(), - "the async single-instance listener must not panic while scheduling restore" + stopped.status.success(), + "stop only the isolated fixture project" ); - 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!( + removed.status.success(), + "remove only the fixture-owned volume" ); - 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(); + if let Err(panic) = workflow { + std::panic::resume_unwind(panic); + } } #[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 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.", + ), ] { - 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()), + 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), - }; - 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 { + }, + |_, key| { + trace.push(format!("saved-secret:{key}")); 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(); + if result.is_ok() { + trace.push("external-start-boundary".to_string()); } - 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(); - } + let problem = result.expect_err("missing saved key should stop before compose"); - #[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() - } + 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); } - 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}"), + 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), + }, + |_, key| { + assert_eq!(key, saved_api_key(provider)); + Ok(expected_key.to_string()) + }, ) - .unwrap(); - } + .expect("saved key should be accepted"); - 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() + 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 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") - ); + let _ = std::fs::remove_dir_all(root); + } + } - std::fs::remove_dir_all(root_a).unwrap(); - std::fs::remove_dir_all(root_b).unwrap(); + fn saved_api_key(provider: &str) -> &'static str { + match provider { + "openai" => "OPENAI_API_KEY", + "anthropic" => "ANTHROPIC_API_KEY", + other => panic!("unexpected provider: {other}"), + } } - #[test] - fn saved_api_key_start_reports_unreadable_env_before_store_resolution() { - let root = temp_root("start-unreadable-env"); - std::fs::create_dir_all(root.join(".env")).unwrap(); + fn compatible_choice(base_url: Option<&str>, model: Option<&str>) -> ChosenModel { + ChosenModel { + provider: "openai-compatible".into(), + login: "endpoint".into(), + api_key: None, + base_url: base_url.map(String::from), + container_base_url: None, + model: model.map(String::from), + token: None, + saved: None, + } + } - let 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 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(), }, - ) - .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:?}" + &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(), ); - assert!(root.join(".env").is_dir()); - std::fs::remove_dir_all(root).unwrap(); + let (public, secrets) = openbot_desktop_lib::vault::split(settings); + openbot_desktop_lib::saved_intent::persist_configuration( + root, &public, &secrets, &secrets, credential, + ) + .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:?}" + 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") ); - assert!(root.join(".env").is_dir()); + assert_eq!(configured.saved.model_api_keys.compatible, Some(true)); + assert_eq!(configured.saved.model_api_keys.openai, None); + assert!(!serde_json::to_string(&configured) + .unwrap() + .contains("synthetic-endpoint-key")); + let mut reopened = compatible_choice( + configured.values.get("OPENAI_BASE_URL").map(String::as_str), + configured.values.get("BOT_MODEL").map(String::as_str), + ); + reopened.saved = Some(true); + assert_eq!(reopened.into_credential(&root).unwrap(), credential); + + for url in [ + "https://other.example/v1", + "https://models.example/v2", + "https://models.example:8443/v1", + ] { + let mut changed = compatible_choice(Some(url), Some("local-model")); + changed.saved = Some(true); + assert!(changed + .into_credential_with(&root, |_, _| panic!( + "different endpoint must not read a credential" + )) + .is_err()); + } + let other = root.join("other-root"); + let mut changed_root = + compatible_choice(Some("https://models.example/v1"), Some("local-model")); + changed_root.saved = Some(true); + assert!(changed_root + .into_credential_with(&other, |_, _| panic!( + "different root must not read a credential" + )) + .is_err()); std::fs::remove_dir_all(root).unwrap(); } #[test] - fn 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:?}" + 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 ); - } - std::fs::remove_dir_all(root).unwrap(); - } + Ok( + r#"{"base_url":"https://other.example/v1","api_key":"synthetic-other-key"}"# + .into(), + ) + }) + .unwrap_err(); + assert_eq!(reads, 1); + assert!(!error.said.contains("synthetic-other-key")); + std::fs::remove_dir_all(root).unwrap(); } #[test] - fn configured_root_without_original_key_is_rejected_but_fresh_root_is_allowed() { - let root = temp_root("valid-existing-encryption-key"); - std::fs::create_dir_all(&root).unwrap(); - assert!( - require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Ok( - false - )) - .is_ok() - ); - assert!( - require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Ok(true)) - .is_err() + 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") ); - let unavailable = Problem::plain("selected engine unavailable"); + 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!( - require_existing_encryption_key(&root, &std::collections::BTreeMap::new(), || Err( - unavailable.clone() - )), - Err(unavailable) + problem.said, + "Enter a valid http:// or https:// address for the container model endpoint." ); - 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}"#, - ), + 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"), ] { - 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"), + let problem = start_stack_credential( + Path::new("synthetic-unused-compatible-root"), + compatible_choice(base_url, Some("local-model")), ) - .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(); + .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:?}" + ); } - 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() + #[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 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() + #[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()); + } } } - 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()), + 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 + }, ) - .is_ok()); + .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"]); + } - // 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 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); - // 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") - ); + let problem = answering_after_install(&docker, |_| false).unwrap_err(); assert_eq!( - error.leftover_volumes(), - database_leftovers(&BOT_LEFTOVERS).volumes + problem.said, + acquire::missing_compose(engine::Engine::Docker.binary()) ); + } - // 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"] - ); - assert_eq!( - serde_json::to_value(&error).unwrap()["leftover_containers"], - serde_json::json!(["openbot-computer-general-assistant"]) - ); - 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()); + #[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!( - error.said.contains("openbot-agent-computer-1") && error.said.contains("still running"), - "{}", - error.said + answering_after_install(&podman, |_| panic!("nothing answers to run Compose")) + .unwrap() + .is_none() ); - - // 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(); } #[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); + 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"] + ); + // 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 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, + fn a_machine_that_fails_to_start_stops_start_with_its_own_words() { + let problem = wake_saved_engine( + Some(engine::Engine::Podman), 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(()) - }, + || false, + || panic!("a Podman installation never opens Docker Desktop"), + || true, + || Err(Problem::plain("machine did not start")), ) - .unwrap(); - assert!(shell.leftover_database.lock().unwrap().is_none()); - std::fs::remove_dir_all(root).unwrap(); + .unwrap_err(); + assert_eq!(problem.said, "machine did not start"); } #[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()), - ), - }); + 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(), }; - 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()], - &[], + let result = ready_responding_engine_after_compose_repair( + stopped.clone(), true, - || Ok(stack::Secrets::new()), - |_, _| Ok(()) + || Ok("installed".into()), + || stopped.clone(), + |_| panic!("a failed engine cannot run Compose"), + ); + assert!( + result.is_err(), + "Ok(None) would enter the virtual-machine path" + ); + assert_eq!( + result.err().unwrap().detail.as_deref(), + Some("podman native operation failed") + ); + } + + #[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(), + }; + 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 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 + }, ) - .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(); + .expect("missing Compose should be repaired") + .expect("responding engine should be returned"); + + 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 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; } - 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(()) - } + 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 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, ) - .is_ok()); - std::fs::remove_dir_all(root).unwrap(); + .expect("disposable provider should repair Compose") + .expect("responding fake podman should be ready"); + + assert!(installed, "install path must run before readiness returns"); + assert_eq!(detections, 1, "readiness must re-detect after install"); + assert_eq!(ready.address.engine, engine::Engine::Podman); + assert!( + ready.address.composes(), + "the later start_stack compose gate should now pass" + ); + println!( + "DTA-007 functional proof: installed={installed} detections={detections} composes={}", + ready.address.composes() + ); } #[test] - fn leftover_database_reset_keeps_offered_runtime_and_rejects_stale_offers() { + fn stop_shutdown_uses_the_active_root_at_the_external_command_boundary() { if crate::test_support::isolated_process( - "tests::leftover_database_reset_keeps_offered_runtime_and_rejects_stale_offers", + "tests::stop_shutdown_uses_the_active_root_at_the_external_command_boundary", ) { return; } - let root = temp_root("leftover-database-affinity"); - std::fs::create_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(); - 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()); + *shell.root.lock().unwrap() = Some(active.clone()); + let selected = shutdown_root(&shell, &fallback); + + let record = temp_root("openbot-stop-record").join("commands.log"); + let engine = fake_engine(&record); + stack::down(&engine, &selected).expect("fake compose down"); + + assert_compose_down_ran_under(&record, &active); + assert!(shell.root.lock().unwrap().is_none()); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); + } + + #[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; } - // 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) - ); - reset_leftover_database_with( + 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()); + + let problem = stop_everything_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(()) + &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(); + .expect_err("Stop must surface both cleanup and Compose failures"); + + assert_eq!( + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()) + ] + ); assert!( - shell.leftover_database.lock().unwrap().is_none(), - "successful reset consumes the offer" + problem.contains("OpenBot could not inspect or stop its host processes."), + "{problem}" ); - std::fs::remove_dir_all(root).unwrap(); + assert!(problem.contains("lsof exited with status 2"), "{problem}"); + assert!( + problem.contains("Compose down failed: compose refused"), + "{problem}" + ); + assert_eq!(shell.root.lock().unwrap().as_ref(), Some(&active)); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); } - /// 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" - ), - ) - .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"); + fn exit_cleanup_body_records_cleanup_and_down_failures_after_using_the_active_root() { + let active = temp_root("openbot-active-exit-failures"); + let fallback = temp_root("openbot-default-exit-failures"); + std::fs::create_dir_all(&active).unwrap(); + std::fs::write(active.join("docker-compose.yml"), "services: {}\n").unwrap(); + std::fs::create_dir_all(&fallback).unwrap(); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + let phases = std::cell::RefCell::new(Vec::new()); + + let 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()) + }, + ); - // 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(); - 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" - ); - let ready = address - .command() - .current_dir(&root) - .args([ - "compose", - "exec", - "-T", - "postgres", - "pg_isready", - "-U", - "postgres", - ]) - .output() - .unwrap(); - assert!( - ready.status.success(), - "Postgres must remain ready after the refused attached-volume reset" - ); - }); - let stopped = address - .command() - .current_dir(&root) - .args(["compose", "down"]) - .output() - .unwrap(); - let removed = address - .command() - .args(["volume", "rm", &volume]) - .output() - .unwrap(); - std::fs::remove_dir_all(&root).unwrap(); + assert_eq!( + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()) + ] + ); + assert_eq!(failures.len(), 2, "{failures:?}"); assert!( - stopped.status.success(), - "stop only the isolated fixture project" + failures[0].contains("taskkill exited with status 5"), + "{failures:?}" ); assert!( - removed.status.success(), - "remove only the fixture-owned volume" + failures[1].contains("Compose down failed: compose down refused"), + "{failures:?}" ); - if let Err(panic) = workflow { - std::panic::resume_unwind(panic); + assert_eq!(shell.root.lock().unwrap().as_ref(), Some(&active)); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); + } + + const LOCAL_EVAL_PIN: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + fn local_eval_root(label: &str, ownership: Option) -> PathBuf { + let root = temp_root(label); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("docker-compose.yml"), "services: {}\n").unwrap(); + if let Some(ownership) = ownership { + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + local_eval_state::begin(&root, &stack, ownership, LOCAL_EVAL_PIN).unwrap(); + } + root + } + + 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 saved_api_key_selection_without_a_saved_key_is_rejected_before_starting_services() { - for (provider, expected) in [ + 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 failure = + open_dashboard_at(&root, |_| panic!("login ran without a stack")).unwrap_err(); + assert_eq!(failure, DashboardFailure::NotSetUp); + assert_eq!( + failure.said(), + "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_dashboard_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 [ ( - "openai", - "That saved OpenAI API key is no longer available.", + signed_in(Outcome::Blocked, Some(Reason::DashboardUnavailable)), + "The Intelligence dashboard is not responding yet. Try again when Intelligence is ready.", ), ( - "anthropic", - "That saved Anthropic API key is no longer available.", + 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), ] { - let root = temp_root(&format!("openbot-missing-saved-{provider}")); - std::fs::create_dir_all(&root).unwrap(); - let mut trace = Vec::new(); + // Nothing but a fixed category and its sentence, so no CLI text can ride along. + assert_eq!(open_dashboard_at(&root, |_| result).unwrap_err().said(), said); + } + let _ = std::fs::remove_dir_all(root); + } - 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()) - }, + #[test] + fn the_intelligence_menu_item_is_offered_only_for_a_recorded_local_stack() { + let recorded = local_eval_root( + "openbot-intelligence-menu-recorded", + Some(local_eval_state::Ownership::Created), + ); + let unrecorded = local_eval_root("openbot-intelligence-menu-unrecorded", None); + assert_eq!( + 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), + ), + ] { + 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"), ); - if result.is_ok() { - trace.push("external-start-boundary".to_string()); - } - let problem = result.expect_err("missing saved key should stop before compose"); + assert_eq!(said.lock().unwrap().len(), 1); - println!( - "DTA-004 missing provider={provider} error={} trace={trace:?}", - problem.said + (work.borrow_mut().take().unwrap())(); + assert_eq!( + *said.lock().unwrap(), + [IntelligenceOpening::Opening, last.clone()] ); - assert_eq!(problem.said, expected); - assert_eq!(trace, [format!("saved-secret:{}", saved_api_key(provider))]); - let _ = std::fs::remove_dir_all(root); + assert!(!opening.load(std::sync::atomic::Ordering::SeqCst)); + } + assert_eq!( + serde_json::to_value(IntelligenceOpening::Opening).unwrap(), + serde_json::json!({"state": "opening", "said": "Opening the Intelligence dashboard…"}) + ); + assert_eq!( + serde_json::to_value(IntelligenceOpening::Opened).unwrap(), + serde_json::json!({"state": "opened", "said": "Opened in your browser."}) + ); + assert_eq!( + serde_json::to_value(IntelligenceOpening::Failed(DashboardFailure::BrowserFailed)) + .unwrap(), + serde_json::json!({"state": "failed", "said": "Your browser did not open. Try again."}) + ); + } + + #[test] + 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 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(); + 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", + )); - 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()) - }, + // 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(); + 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) + }; + 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, ) - .expect("saved key should be accepted"); - - match credential { - openbot_env::ModelCredential::OpenAi { api_key } - | openbot_env::ModelCredential::Anthropic { api_key } => { - assert_eq!(api_key, expected_key); - println!( - "DTA-004 present provider={provider} saved_key_len={}", - api_key.len() - ); - } - other => panic!("unexpected credential: {other:?}"), - } - let _ = std::fs::remove_dir_all(root); + .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") + ); } - fn saved_api_key(provider: &str) -> &'static str { - match provider { - "openai" => "OPENAI_API_KEY", - "anthropic" => "ANTHROPIC_API_KEY", - other => panic!("unexpected provider: {other}"), + #[test] + fn a_signed_in_stack_with_no_reported_address_signs_in_again() { + let dashboards = Dashboards::default(); + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + let logins = std::cell::Cell::new(0); + for _ in 0..2 { + open_or_sign_in( + &dashboards, + &stack, + |_| { + logins.set(logins.get() + 1); + signed_in(local_eval::Outcome::Completed, None) + }, + |_| panic!("no address was ever reported"), + ) + .unwrap(); } + assert_eq!(logins.get(), 2); + // Learning the address keeps the sign-in: the browser holds the session for it. + dashboards.learn(&reported_stack( + "local-abc123", + "http://intelligence.localhost:8080", + )); + assert_eq!( + dashboards.signed_in(&stack).as_deref(), + Some("http://intelligence.localhost:8080") + ); + // Another stack's sign-in is its own. + let other = local_eval::StackId::parse("local-def456").unwrap(); + assert_eq!(dashboards.signed_in(&other), None); } - 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 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}) + ); + } + for (offer, state) in [ + (intelligence_bridge::Offer::Unavailable, "unavailable"), + (intelligence_bridge::Offer::Ready, "ready"), + (intelligence_bridge::Offer::Opening, "opening"), + ] { + assert_eq!( + serde_json::to_value(offer).unwrap(), + serde_json::json!({ "state": state }) + ); } + assert_eq!( + 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) + ); } - 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(); + #[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 saved_compatible_endpoint_roundtrips_public_settings_and_scoped_key() { - let root = temp_root("compatible-roundtrip"); - std::fs::create_dir_all(&root).unwrap(); - let mut chosen = compatible_choice(Some("https://models.example/v1"), Some("local-model")); - chosen.api_key = Some("synthetic-endpoint-key".into()); - let credential = chosen.into_credential(&root).unwrap(); - persist_endpoint_fixture(&root, &credential); - let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + fn 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!( - configured.values.get("BOT_MODEL").map(String::as_str), - Some("local-model") + 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!(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!( + build_description("397d98a", "", 0), + "Built 1970-01-01 00:00 UTC from 397d98a" ); - 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(); + 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 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 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 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 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!( - container_base_url.as_deref(), - Some("http://ollama:11434/v1") + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()), + format!("intelligence:{}", active.display()), + ] + ); + assert_eq!( + stopped, + Stopped { + openbot: vec!["Compose down failed: compose refused".into()], + intelligence: Some("Intelligence stop failed: the CLI timed out".into()), + } + ); + assert_eq!( + stopped.into_result(), + Err("Compose down failed: compose refused\n\ + Intelligence stop failed: the CLI timed out" + .into()) + ); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); + } + + #[test] + fn quit_asks_intelligence_last_even_after_compose_down_failed() { + let active = local_eval_root("openbot-exit-intelligence-order", None); + let fallback = temp_root("openbot-default-exit-intelligence-order"); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + let phases = std::cell::RefCell::new(Vec::new()); + + let failures = exit_cleanup_then( + &shell, + &fallback, + |root| { + phases + .borrow_mut() + .push(format!("cleanup:{}", root.display())); + Ok(0) + }, + |root| { + phases.borrow_mut().push(format!("down:{}", root.display())); + Err("compose down refused".to_string()) + }, + |root| { + phases + .borrow_mut() + .push(format!("intelligence:{}", root.display())); + Err("the CLI timed out".to_string()) + }, ); - assert_eq!(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." + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()), + format!("intelligence:{}", active.display()), + ] ); + assert_eq!( + failures, + Stopped { + openbot: vec!["Compose down failed: compose down refused".into()], + intelligence: Some("Intelligence stop failed: the CLI timed out".into()), + } + ); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); } #[test] - fn compatible_endpoint_rejects_missing_or_invalid_http_url() { - for base_url in [ + 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, - Some(""), - Some(" \t\n "), - Some("ftp://localhost/v1"), - Some("file:///tmp/model"), - Some("httpx://localhost/v1"), - Some("localhost:11434/v1"), - Some("http://"), - Some("https://?query"), - Some("http://[invalid]/v1"), ] { - let problem = start_stack_credential( - Path::new("synthetic-unused-compatible-root"), - compatible_choice(base_url, Some("local-model")), - ) - .expect_err("a missing or invalid endpoint URL must stop setup"); - assert_eq!( - problem.said, "Enter a valid http:// or https:// address for your model endpoint.", - "base_url={base_url:?}" + 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 stopped = exit_cleanup_reporting( + &shell, + &root, + |_| { + phases.borrow_mut().push("cleanup".to_string()); + Ok(0) + }, + |_| { + phases.borrow_mut().push("down".to_string()); + Ok(()) + }, + |_| { + phases.borrow_mut().push("intelligence".to_string()); + Ok(()) + }, + |step, state| { + phases.borrow_mut().push(format!("{}:{state:?}", step.id())); + }, ); + + 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); } } #[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 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()); + + let stopped = exit_cleanup_reporting( + &shell, + &root, + |_| Ok(0), + |_| Err("compose down refused".to_string()), + |_| Err("the CLI timed out".to_string()), + |step, state| { + phases.borrow_mut().push(format!("{}:{state:?}", step.id())); + }, + ); + + assert_eq!( + phases.into_inner(), + [ + "stop-openbot:Running", + "stop-openbot:Failed", + "stop-intelligence:Running", + "stop-intelligence:Failed", + ] + ); + // Unchanged by the screen: the two failures are kept apart. + assert_eq!( + stopped, + Stopped { + openbot: vec!["Compose down failed: compose down refused".into()], + intelligence: Some("Intelligence stop failed: the CLI timed out".into()), + } + ); + report_exit_cleanup_failures(stopped.lines(), |lines| { + write_quit_cleanup_notice(&root, &lines) + }) + .unwrap(); + let openbot = read_quit_cleanup_notice(&root).unwrap().unwrap(); + assert_eq!(openbot.said, "OpenBot had trouble shutting down last time."); + assert!(openbot + .detail + .as_deref() + .unwrap() + .contains("containers stopped")); + assert!(take_local_eval_stop_notice(&root).is_some()); + let _ = std::fs::remove_dir_all(root); } #[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", + 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 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!( + 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}, + ]) + ); + 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 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), ] { - 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"); + let root = local_eval_root( + "openbot-quit-shutting-down", + Some(local_eval_state::Ownership::Created), + ); + // Nothing for Compose to take down; this build has no CLI, so Intelligence fails. + std::fs::remove_file(root.join("docker-compose.yml")).unwrap(); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(root.clone()); + *shell.setup_url.lock().unwrap() = Some("http://tauri.localhost/".into()); + let quit = Arc::clone(&shell.quit); + let app = tauri::test::mock_builder() + .manage(shell) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + let window = window_at.map(|url| { + let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap(); + window.navigate(url.parse().unwrap()).unwrap(); + window.hide().unwrap(); + window + }); + let seen = Arc::new(Mutex::new(Vec::new())); + if listening { + let seen = Arc::clone(&seen); + app.listen_any(QUIT_PROGRESS_EVENT, move |event| { + let status: serde_json::Value = serde_json::from_str(event.payload()).unwrap(); + let rows = status["steps"] + .as_array() + .unwrap() + .iter() + .map(|row| { + format!( + "{}:{}", + row["step"].as_str().unwrap(), + if row["running"] == true { + "running" + } else if row["ok"] == true { + "complete" + } else { + "failed" + } + ) + }) + .collect::>() + .join(","); + seen.lock().unwrap().push(rows); + }); + } + let exited = Arc::new(Mutex::new(None)); + let cleaning = app.handle().clone(); + let noticed = root.clone(); + let exiting = Arc::clone(&exited); + request_quit_with( + quit, + Some(0), + || {}, + move || quit_cleanup_shown(&cleaning), + QuitDiagnostics { + sink: move |lines: Vec| write_quit_cleanup_notice(¬iced, &lines), + failed: |error: String| panic!("the notice was not written: {error}"), + }, + move |code| *exiting.lock().unwrap() = Some(code), + |work| { + work(); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(*exited.lock().unwrap(), Some(0), "{window_at:?}"); + // The Intelligence failure is in its own notice, as before the screen existed, and + // OpenBot's is not written for it. + assert!(!quit_cleanup_notice_path(&root).exists(), "{window_at:?}"); + assert!( + take_local_eval_stop_notice(&root).is_some(), + "{window_at:?}" + ); + if listening { + assert_eq!( + *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!(actual_url, base_url); - assert_eq!(model, "local-model"); - assert_eq!(actual_key, api_key.unwrap_or_default().trim()); + 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 linux_first_install_redetects_native_podman_without_a_virtual_machine() { - let missing = engine::EngineStatus { - engine: None, - address: None, - responding: false, - engine_socket: None, - detail: "No container engine yet.".into(), - }; - let trace = std::cell::RefCell::new(Vec::new()); - let ready = ready_responding_engine_after_compose_repair( - missing, - true, - || { - trace.borrow_mut().push("install"); - Ok("Podman and Compose installed".into()) - }, - || { - trace.borrow_mut().push("detect"); - engine::EngineStatus { - engine: Some(engine::Engine::Podman), - address: Some(engine::Address::new(engine::Engine::Podman, None)), - responding: true, - engine_socket: None, - detail: "native Podman answers".into(), - } - }, - |_| { - trace.borrow_mut().push("compose"); - true - }, - ) - .unwrap() - .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"]); - } + 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); - #[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 stopped = stop_everything_then( + &shell, + &active, + |_| Ok(0), + |_| Ok(()), + |_| Err("the CLI timed out".to_string()), + ); - let problem = answering_after_install(&docker, |_| false).unwrap_err(); + assert!(stopped.openbot.is_empty(), "{stopped:?}"); assert_eq!( - problem.said, - acquire::missing_compose(engine::Engine::Docker.binary()) + 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)); - #[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() + *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 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(()) + 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)) }, - ) - .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:?}"); + ); + + 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); } - // A saved Docker opens Docker Desktop, never a Podman machine. - assert_eq!( - run(Some(Docker), true, false, true), - ["open Docker Desktop"] - ); - // 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 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")), - ) - .unwrap_err(); - assert_eq!(problem.said, "machine did not start"); + fn a_stack_openbot_created_is_stopped_after_cancelling_calls_in_flight() { + let root = local_eval_root( + "openbot-local-eval-created", + Some(local_eval_state::Ownership::Created), + ); + let phases = std::cell::RefCell::new(Vec::new()); + + let result = stop_local_eval_with( + &root, + || phases.borrow_mut().push("cancel-all".to_string()), + |stack| { + phases.borrow_mut().push(format!("stop:{}", stack.as_str())); + Ok(local_eval_report(local_eval::Outcome::Completed, None)) + }, + ); + + assert_eq!(result, Ok(())); + assert_eq!(phases.into_inner(), ["cancel-all", "stop:local-abc123"]); + let _ = std::fs::remove_dir_all(root); } #[test] - fn 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 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 result = ready_responding_engine_after_compose_repair( - stopped.clone(), - true, - || Ok("installed".into()), - || stopped.clone(), - |_| panic!("a failed engine cannot run Compose"), + + assert_eq!( + failed(Err(local_eval::Failure::Unavailable)), + "this build has no Intelligence CLI to stop the local Intelligence stack OpenBot created" ); - assert!( - result.is_err(), - "Ok(None) would enter the virtual-machine path" + assert_eq!( + failed(Err(local_eval::Failure::Timeout)), + "the Intelligence CLI did not stop the local Intelligence stack (timeout)" ); assert_eq!( - result.err().unwrap().detail.as_deref(), - Some("podman native operation failed") + 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 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; + 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 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"); + let failures = exit_cleanup_then( + &shell, + &root, + |_| Ok(0), + |_| Err("compose down refused".to_string()), + |root| stop_local_eval(root, local_eval::Limits::for_quit()), + ); - assert_eq!(&*trace.borrow(), &["install-engine", "re-detect"]); - assert_eq!(ready.installed.as_deref(), Some("Compose installed.")); - assert_eq!(ready.address.connection.as_deref(), Some("openbot")); + assert_eq!( + failures.lines(), + [ + "Compose down failed: compose down refused", + "Intelligence stop failed: this build has no Intelligence CLI to stop the local \ + Intelligence stack OpenBot created" + ] + ); + let _ = std::fs::remove_dir_all(root); } + const NO_CLI_LINE: &str = "Intelligence stop failed: this build has no Intelligence CLI to \ + stop the local Intelligence stack OpenBot created"; + #[test] - fn disposable_provider_fixture_repairs_missing_compose_at_process_boundary() { - if crate::test_support::isolated_process( - "tests::disposable_provider_fixture_repairs_missing_compose_at_process_boundary", - ) { - return; - } - let path = SerializedPath::set_only_with("podman", "podman"); - let address = engine::Address::new(engine::Engine::Podman, None); - assert!(address.responds(), "fake podman must answer before repair"); - assert!( - !address.composes(), - "fake podman must start without a compose provider" + 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()), ); - let mut installed = false; - let mut detections = 0; + // The quit sink, exactly as production wires it. + report_exit_cleanup_failures(failures.lines(), |lines| { + write_quit_cleanup_notice(&root, &lines) + }) + .unwrap(); - 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, + // 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}"), + ], ) - .expect("disposable provider should repair Compose") - .expect("responding fake podman should be ready"); + .unwrap(); - 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" + 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.") ); - println!( - "DTA-007 functional proof: installed={installed} detections={detections} composes={}", - ready.address.composes() + assert!(take_local_eval_stop_notice(&root).is_some()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + 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 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_eq!( + problem.detail.as_deref(), + Some(format!("Compose down failed: refused\n{NO_CLI_LINE}").as_str()) + ); + assert!(recovery_required(&shell, &root)); + + assert_eq!( + settle_menu_stop(&Shell::default(), &root, Stopped::default()), + None + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + 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(()) + }, + ) + }) + }; + 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 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); } #[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", + 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 _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 root = local_eval_root("openbot-stop-closes-local-eval", None); let shell = Shell::default(); - *shell.root.lock().unwrap() = Some(active.clone()); - let selected = shutdown_root(&shell, &fallback); + 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) + }, + |_| 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) + ); - let record = temp_root("openbot-stop-record").join("commands.log"); - let engine = fake_engine(&record); - stack::down(&engine, &selected).expect("fake compose down"); + exit_cleanup_then( + &shell, + &root, + |_| { + refused("during-quit-cleanup"); + Ok(0) + }, + |_| Ok(()), + |_| Ok(()), + ); + refused("after-quit"); + let _ = std::fs::remove_dir_all(root); + } - 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_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 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", + 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(); - 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 root = local_eval_root( + "openbot-production-intelligence-wiring", + Some(local_eval_state::Ownership::Created), + ); + std::fs::remove_file(root.join("docker-compose.yml")).unwrap(); let shell = Shell::default(); - *shell.root.lock().unwrap() = Some(active.clone()); - let selected = shutdown_root(&shell, &fallback); + *shell.root.lock().unwrap() = Some(root.clone()); - let record = temp_root("openbot-quit-record").join("commands.log"); - let engine = fake_engine(&record); - stack::down(&engine, &selected).expect("fake compose down"); + 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_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); + *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) + ); + 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 stop_reports_cleanup_and_down_failures_after_using_the_active_root() { - let active = temp_root("openbot-active-stop-failures"); - let fallback = temp_root("openbot-default-stop-failures"); - std::fs::create_dir_all(&active).unwrap(); - std::fs::write(active.join("docker-compose.yml"), "services: {}\n").unwrap(); - std::fs::create_dir_all(&fallback).unwrap(); - let shell = Shell::default(); - *shell.root.lock().unwrap() = Some(active.clone()); - let phases = std::cell::RefCell::new(Vec::new()); + fn 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); + } - 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", - )) + #[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 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); + } + + const LOCAL_KEY: &str = "cpk-synthetic-local-key"; + + /// 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(); + } + + 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, + } + } + + type FakeVault = std::cell::RefCell>; + + fn connect_with_vault( + root: &Path, + vault: &FakeVault, + connection: Result, + ) -> Result { + connect_local_eval_at( + root, + |folder| { + assert_eq!(folder, root, "connect runs in the installation"); + connection }, - |root| { - phases.borrow_mut().push(format!("down:{}", root.display())); - Err("compose refused".to_string()) + |_, name, value| { + vault.borrow_mut().insert(name.into(), value.into()); + Ok(()) }, + |_, name| Ok(vault.borrow().get(name).cloned()), ) - .expect_err("Stop must surface both cleanup and Compose failures"); + } + #[test] + fn a_completed_connection_moves_its_key_into_the_vault_and_out_of_the_file() { + let root = local_eval_root( + "openbot-local-eval-connect-vault", + Some(local_eval_state::Ownership::Created), + ); + local_eval_connected_env(&root); + let vault = FakeVault::default(); + let connection = connect_with_vault( + &root, + &vault, + Ok(local_eval_connection( + local_eval::Outcome::Completed, + "local-abc123", + None, + false, + )), + ) + .unwrap(); + assert_eq!(connection.outcome, local_eval::Outcome::Completed); + // OpenBot's server reads INTELLIGENCE_API_KEY; the CLI names it for an app's runtime. assert_eq!( - phases.into_inner(), - vec![ - format!("cleanup:{}", active.display()), - format!("down:{}", active.display()) + vault + .borrow() + .get("INTELLIGENCE_API_KEY") + .map(String::as_str), + Some(LOCAL_KEY) + ); + assert_eq!(vault.borrow().len(), 1); + let env = std::fs::read_to_string(root.join(".env")).unwrap(); + assert!(!env.contains(LOCAL_KEY), "{env}"); + assert!(!env.contains("CPK_INTELLIGENCE_API_KEY"), "{env}"); + for line in [ + "INTELLIGENCE_API_URL=http://localhost:64770", + "INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:64772", + "SL_ENABLED=true", + "SERVER_PORT=3001", + ] { + assert_eq!( + env.lines().filter(|found| *found == line).count(), + 1, + "{env}" + ); + } + assert!(local_eval_state::read(&root, None).unwrap().connected); + assert!(!serde_json::to_string(&connection) + .unwrap() + .contains(LOCAL_KEY)); + + // Start then takes it from the vault exactly like a key pasted earlier. + let key = intelligence_key_for_start(&root, |_, 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}" + 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 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); + } + } + + #[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!(problem.contains("lsof exited with status 2"), "{problem}"); - assert!( - problem.contains("Compose down failed: compose refused"), - "{problem}" + 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); + } + + #[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), + ); + // 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_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] - fn exit_cleanup_body_records_cleanup_and_down_failures_after_using_the_active_root() { - let active = temp_root("openbot-active-exit-failures"); - let fallback = temp_root("openbot-default-exit-failures"); - std::fs::create_dir_all(&active).unwrap(); - std::fs::write(active.join("docker-compose.yml"), "services: {}\n").unwrap(); - std::fs::create_dir_all(&fallback).unwrap(); - let shell = Shell::default(); - *shell.root.lock().unwrap() = Some(active.clone()); - let phases = std::cell::RefCell::new(Vec::new()); - - let 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()) - }, - ); + 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!( - phases.into_inner(), - vec![ - format!("cleanup:{}", active.display()), - format!("down:{}", active.display()) - ] - ); - assert_eq!(failures.len(), 2, "{failures:?}"); - assert!( - failures[0].contains("taskkill exited with status 5"), - "{failures:?}" - ); - assert!( - failures[1].contains("Compose down failed: compose down refused"), - "{failures:?}" + 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] @@ -8369,6 +11886,7 @@ fn main() { ["compose","config","--environment"] => (), ["compose","config","--format","json"] => println!("{{\"services\":{{\"postgres\":{{\"volumes\":[{{\"type\":\"volume\",\"source\":\"postgres-data\",\"target\":\"/var/lib/postgresql/data\"}}]}}}},\"volumes\":{{\"postgres-data\":{{\"name\":\"fixture_postgres-data\"}}}}}}"), ["volume","ls","--format","{{.Name}}"] => (), + ["ps","--all","--quiet","--no-trunc","--filter",_] => (), ["compose","ps","--format",_] => (), ["compose","up",..] => { fs::write(cwd.join("fixture-containers-running"),&identity).unwrap(); @@ -8378,7 +11896,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(); @@ -8451,9 +11969,10 @@ fn main() { } else { serde_json::json!({"provider":"openai","login":"api-key","apiKey":"synthetic-container-root-key"}) }; + remember_local_intelligence_key(root); let result = self.invoke("start_stack", serde_json::json!({ "root":root,"apiUrl":"https://intelligence.example.test","gatewayWsUrl":"wss://gateway.example.test", - "apiKey":"synthetic-intelligence-key","model":model,"harness":null, + "model":model,"harness":null, })).expect_err("fixture Start must stop before host startup"); println!( "CONTAINER_START={}", @@ -8836,6 +12355,8 @@ fn main() { "docker-compose.yml", "--profile", "harness", + "--profile", + "openbot", "down", ], ); @@ -9298,33 +12819,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"), }; @@ -9344,7 +12865,7 @@ fn main() { } let app = tauri::test::mock_builder() .manage(Shell::default()) - .invoke_handler(tauri::generate_handler![start_stack, ask_the_bot]) + .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()) @@ -9379,9 +12900,10 @@ fn main() { vec!["fixture-image".into()], ) .unwrap(); + remember_local_intelligence_key(&root); let problem = invoke("start_stack", serde_json::json!({ "root":root, "apiUrl":"https://intelligence.example.test", "gatewayWsUrl":"wss://gateway.example.test", - "apiKey":"synthetic-intelligence-key", "model":model, "harness":choice, + "model":model, "harness":choice, })).expect_err("intentional migration barrier prevents host/DB startup"); let commands = std::fs::read_to_string(&record).unwrap_or_default(); assert!( @@ -9405,6 +12927,24 @@ fn main() { ); 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::DESKTOP_PROJECT)) + ); let settings = openbot_env::read_already_set( &root.join(".env"), &[ @@ -9432,31 +12972,12 @@ fn main() { .any(|service| service == "agent-langgraph"), "case={case}, persisted advertisement must match actual Start services" ); - let mut asked = false; if case.starts_with("remote") || case == "compatible" { assert_eq!(settings.get("PICKED_HARNESS_URL"), Some(&remote)); assert_eq!( settings.get("PICKED_HARNESS_KIND").map(String::as_str), Some("remote-ag-ui") ); - let server = TestServer::from_listener(listener, - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n\ - data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"D53-BYO-REMOTE-ANSWER\"}\n\n\ - data: {\"type\":\"RUN_FINISHED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n"); - let answer = invoke( - "ask_the_bot", - serde_json::json!({"root":root,"question":"D53 remote IPC question"}), - ) - .unwrap(); - let request = server.request(); - assert_eq!(answer, "D53-BYO-REMOTE-ANSWER"); - assert_eq!(request.path, "/ag-ui"); - assert!(request.body.contains("D53 remote IPC question")); - assert!(request - .headers - .iter() - .any(|line| line.starts_with("x-openbot-agent-token: "))); - asked = true; } else if let Some(image) = expected_image { assert_eq!( settings.get("PICKED_HARNESS_IMAGE"), @@ -9471,17 +12992,16 @@ fn main() { serde_json::json!({ "case":case, "composeUp":up, "commands":commands, "intentionalMigrationBarrier":true, "publicSettings":settings, - "defaultPackage":"../examples/fintech", "remoteEndpointPersistedAndConsumed":asked, - "actualAskIpcResponse":asked.then_some("D53-BYO-REMOTE-ANSWER"), + "defaultPackage":"../examples/fintech", "noHostStartup":true, "nativeGui":false, "realEngineOrDatabase":false, }) ); } #[test] - fn remote_harness_start_ipc_skips_local_service_and_asks_persisted_endpoint() { + fn remote_harness_start_ipc_skips_local_service_and_persists_the_endpoint() { if crate::test_support::isolated_process( - "tests::remote_harness_start_ipc_skips_local_service_and_asks_persisted_endpoint", + "tests::remote_harness_start_ipc_skips_local_service_and_persists_the_endpoint", ) { return; } @@ -9567,6 +13087,7 @@ fn main() { } let root = temp_root("openbot-dead-compose-start"); write_installed_deployment(&root); + remember_local_intelligence_key(&root); let record = temp_root("openbot-dead-compose-record").join("commands.log"); std::fs::create_dir_all(record.parent().expect("record parent")).unwrap(); let _path = SerializedPath::set_only_with("docker", "dead-service"); @@ -9581,7 +13102,6 @@ fn main() { root.clone(), "https://intelligence.example.test".into(), "wss://gateway.example.test".into(), - "synthetic-intelligence-key".into(), ChosenModel { provider: "openai".into(), login: "api-key".into(), @@ -9593,7 +13113,6 @@ fn main() { saved: Some(false), }, None, - None, )) .expect_err("a dead required Compose service must fail Start"); @@ -9612,7 +13131,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}" @@ -9646,6 +13165,7 @@ fn main() { } let root = temp_root("openbot-anthropic-bot-selection-start"); write_installed_deployment(&root); + remember_local_intelligence_key(&root); let record = temp_root("openbot-anthropic-bot-selection-record").join("commands.log"); std::fs::create_dir_all(record.parent().expect("record parent")).unwrap(); let _path = SerializedPath::set_only_with("docker", "anthropic"); @@ -9660,7 +13180,6 @@ fn main() { root.clone(), "https://intelligence.example.test".into(), "wss://gateway.example.test".into(), - "synthetic-intelligence-key".into(), ChosenModel { provider: "anthropic".into(), login: "api-key".into(), @@ -9672,510 +13191,56 @@ fn main() { saved: Some(false), }, None, - None, )) .expect_err("dead selected LangGraph service must fail Start"); 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" - ), - "{commands}" - ); - assert!( - !commands - .contains("compose up -d --no-build --pull never postgres supervisor agent-computer agent-bot"), - "Anthropic Start must not target the OpenAI-only agent-bot: {commands}" - ); - assert_eq!(problem.said, "Part of OpenBot stopped during startup."); - let detail = problem.detail.as_deref().unwrap_or_default(); - assert!( - detail.contains("agent-langgraph stopped: langgraph died after boot"), - "{detail}" - ); - assert!( - !detail.contains("agent-bot"), - "stale, unrequested agent-bot exit must not fail this Anthropic Start: {detail}" - ); - assert!( - !commands.contains("\tcompose logs --tail 3 agent-bot\n"), - "stale unrequested agent-bot should not get reported: {commands}" - ); - assert!( - commands.contains("\tcompose logs --tail 3 agent-langgraph\n"), - "selected dead LangGraph service should get reported: {commands}" - ); - let _ = std::fs::remove_dir_all(root); - let _ = std::fs::remove_dir_all(record.parent().expect("record parent")); - } - - #[test] - fn ask_the_bot_uses_native_mastra_for_a_picked_mastra_harness() { - let server = TestServer::new( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n\ - data: {\"type\":\"text-delta\",\"payload\":{\"text\":\"391\"}}\n\n\ - data: {\"type\":\"finish\",\"payload\":{\"stepResult\":{\"reason\":\"stop\"}}}\n\n", - ); - let root = temp_root("openbot-mastra-ask"); - let answer = tauri::async_runtime::block_on(ask_the_bot_with_settings( - root.clone(), - "What is 17 times 23?".to_string(), - std::collections::BTreeMap::from([ - ("PICKED_HARNESS_URL".to_string(), server.url.clone()), - ( - "PICKED_HARNESS_KIND".to_string(), - "remote-mastra".to_string(), - ), - ("PICKED_HARNESS_AGENT_ID".to_string(), "openbot".to_string()), - ( - "MANAGED_AGENT_TOKEN".to_string(), - "managed-token".to_string(), - ), - ]), - )) - .expect("answer"); - - let request = server.request(); - assert_eq!(answer, "391"); - assert_eq!(request.path, "/api/agents/openbot/stream"); - assert!( - request - .headers - .iter() - .any(|line| line == "x-openbot-agent-token: managed-token"), - "{:?}", - request.headers - ); - let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body"); - assert_eq!( - body.pointer("/messages/0/content").and_then(|v| v.as_str()), - Some("What is 17 times 23?") - ); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn ask_the_bot_uses_the_picked_byo_ag_ui_endpoint_before_managed_fallback() { - let server = TestServer::new( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n\ - data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"391\"}\n\n\ - data: {\"type\":\"RUN_FINISHED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n", - ); - let root = temp_root("openbot-byo-ask"); - let answer = tauri::async_runtime::block_on(ask_the_bot_with_settings( - root.clone(), - "What is 17 times 23?".to_string(), - std::collections::BTreeMap::from([ - ("PICKED_HARNESS_URL".to_string(), server.url.clone()), - ( - "PICKED_HARNESS_KIND".to_string(), - "remote-ag-ui".to_string(), - ), - ( - "MANAGED_AGENT_AG_UI_URL".to_string(), - "http://127.0.0.1:9/ag-ui".to_string(), - ), - ( - "MANAGED_AGENT_TOKEN".to_string(), - "managed-token".to_string(), - ), - ]), - )) - .expect("answer"); - - let request = server.request(); - assert_eq!(answer, "391"); - assert_eq!(request.path, "/"); - assert!( - request - .headers - .iter() - .any(|line| line == "x-openbot-agent-token: managed-token"), - "{:?}", - request.headers - ); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn ask_the_bot_keeps_body_read_errors_out_of_the_empty_answer_path() { - let body = "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"391"; - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() + 64 - ); - let server = TestServer::new(response); - let root = temp_root("openbot-body-read-ask"); - - let problem = tauri::async_runtime::block_on(ask_the_bot_with_settings( - root.clone(), - "What is 17 times 23?".to_string(), - std::collections::BTreeMap::from([ - ("PICKED_HARNESS_URL".to_string(), server.url.clone()), - ( - "PICKED_HARNESS_KIND".to_string(), - "remote-ag-ui".to_string(), - ), - ( - "MANAGED_AGENT_TOKEN".to_string(), - "managed-token".to_string(), - ), - ]), - )) - .expect_err("body read errors must propagate as real problems"); - - assert!( - problem - .said - .contains("The Bot started answering and then stopped"), - "{}", - problem.said - ); - let detail = problem.detail.as_deref().expect("body read detail"); - assert!(detail.contains("kind remote-ag-ui"), "{detail}"); - assert!(detail.contains(&server.url), "{detail}"); - assert!(detail.contains("HTTP 200 OK"), "{detail}"); - assert!( - detail.contains("body") || detail.contains("error"), - "{detail}" - ); - let _ = server.request(); - let _ = std::fs::remove_dir_all(root); - } - - /// Exercise the generated command and the settings writer/reader, with real loopback HTTP - /// and a disposable external Compose executable. No model, engine or native GUI is started. - #[test] - fn ask_command_attributes_empty_answers_to_the_selected_endpoint() { - const TEST: &str = "tests::ask_command_attributes_empty_answers_to_the_selected_endpoint"; - if crate::test_support::isolated_process(TEST) { - return; - } - let path = SerializedPath::set_only_with("docker", "empty-answer"); - let app = tauri::test::mock_builder() - .invoke_handler(tauri::generate_handler![ask_the_bot]) - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .unwrap(); - let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) - .build() - .unwrap(); - let mut failures = Vec::new(); - for case in [ - "byo", - "installed-to-byo", - "legacy", - "unknown", - "installed-ag-ui", - "installed-mastra", - "managed", - ] { - let root = temp_root(&format!("ask-provenance-{case}")); - std::fs::create_dir_all(&root).unwrap(); - let record = root.join("commands.log"); - std::fs::write(&record, "").unwrap(); - std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); - let server = TestServer::new( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n\ - data: {\"type\":\"RUN_STARTED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n\ - data: {\"type\":\"RUN_FINISHED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n", - ); - let endpoint = format!("{}/ag-ui", server.url); - let installed = openbot_env::PickedHarness::Installed { - image: "localhost/synthetic-old-harness@sha256:00".into(), - port: test_server_port(&server), - name: "Installed fixture".into(), - mastra: case == "installed-mastra", - run_path: "/ag-ui".into(), - remote_agent_id: "fixture-agent".into(), - }; - let byo = openbot_env::PickedHarness::RemoteAgUi { - url: format!(" {endpoint} "), - name: "An agent you already run".into(), - remote_agent_id: String::new(), - }; - let ports = openbot_env::Ports { - langgraph: test_server_port(&server), - ..Default::default() - }; - let compose = |harness| { - openbot_env::compose( - &openbot_env::Intelligence { - api_url: "https://intelligence.example.test".into(), - gateway_ws_url: "wss://gateway.example.test".into(), - api_key: String::new(), - }, - &openbot_env::Model { - credential: openbot_env::ModelCredential::OpenAi { - api_key: "synthetic-provider-key".into(), - }, - }, - &engine::EngineStatus { - engine: None, - address: None, - responding: false, - engine_socket: None, - detail: String::new(), - }, - &ports, - &[], - harness, - &std::collections::BTreeMap::from([( - "MANAGED_AGENT_TOKEN".into(), - "synthetic-ask-token".into(), - )]), - ) - }; - let file = root.join(".env"); - let write_settings = |values: &std::collections::BTreeMap| { - openbot_env::write(&file, values, &Default::default()).unwrap(); - }; - if ["installed-to-byo", "legacy", "unknown"].contains(&case) { - write_settings(&compose(Some(&installed))); - } - write_settings(&compose(match case { - "managed" => None, - "installed-ag-ui" | "installed-mastra" => Some(&installed), - _ => Some(&byo), - })); - // Simulate older/unknown metadata only after the real installed -> BYO writes. The - // image/port remain stale, and KIND is the same as an installed AG-UI selection. - if case == "legacy" { - let text = std::fs::read_to_string(&file).unwrap(); - std::fs::write( - &file, - text.lines() - .filter(|line| !line.starts_with("PICKED_HARNESS_SOURCE=")) - .collect::>() - .join("\n"), - ) - .unwrap(); - } else if case == "unknown" { - write_settings(&std::collections::BTreeMap::from([( - "PICKED_HARNESS_SOURCE".into(), - "future-source".into(), - )])); - } - let public_settings = openbot_env::read_already_set( - &file, - &[ - "PICKED_HARNESS_URL", - "PICKED_HARNESS_KIND", - "PICKED_HARNESS_SOURCE", - "PICKED_HARNESS_IMAGE", - "PICKED_HARNESS_PORT", - "MANAGED_AGENT_AG_UI_URL", - ], - ) - .unwrap(); - let result = tauri::test::get_ipc_response( - &window, - tauri::webview::InvokeRequest { - cmd: "ask_the_bot".into(), - callback: tauri::ipc::CallbackFn(0), - error: tauri::ipc::CallbackFn(1), - url: if cfg!(any(windows, target_os = "android")) { - "http://tauri.localhost" - } else { - "tauri://localhost" - } - .parse() - .unwrap(), - body: tauri::ipc::InvokeBody::Json( - serde_json::json!({"root":root,"question":"F5499 endpoint provenance question"}), - ), - headers: Default::default(), - invoke_key: tauri::test::INVOKE_KEY.into(), - }, - ); - // Join the HTTP fixture and remove the private root before any result assertion. - let request = server.request(); - let commands = std::fs::read_to_string(&record).unwrap(); - let problem = result.expect_err("completed stream without text is a Problem"); - let said = problem["said"].as_str().unwrap(); - let detail = problem["detail"].as_str().unwrap_or(""); - let local = - case.starts_with("installed-") && case != "installed-to-byo" || case == "managed"; - let expected_service = if case == "managed" { - "agent-langgraph" - } else { - "agent-harness" - }; - let correct = if local { - said.contains("That key was refused") - && detail.contains(expected_service) - && commands - .lines() - .filter(|line| line.contains("compose logs")) - .count() - == 1 - && commands.contains(&format!("\tcompose logs --tail 40 {expected_service}\n")) - } else { - said.contains("selected endpoint") - && detail.contains(&endpoint) - && !said.contains("key was refused") - && !detail.contains("refused the key") - && commands.is_empty() - }; - let request_correct = request.path - == if case == "installed-mastra" { - "/api/agents/fixture-agent/stream" - } else { - "/ag-ui" - } - && request.body.contains("F5499 endpoint provenance question") - && request - .headers - .iter() - .any(|header| header == "x-openbot-agent-token: synthetic-ask-token"); - std::fs::remove_dir_all(&root).unwrap(); - println!( - "F5499_ASK_IPC={}", - serde_json::json!({ - "case":case, "publicSettings":public_settings, "problem":problem, "commands":commands, - "requestPath":request.path, "requestBody":request.body, "syntheticTokenHeaderCorrect":request_correct, - "expectedBehavior":correct, "httpThreadJoined":true, "rootRemoved":!root.exists(), - }) - ); - if !correct || !request_correct { - failures.push(case); - } - } - std::fs::remove_dir_all(path.bin()).unwrap(); - assert!( - failures.is_empty(), - "incorrect endpoint diagnostics: {failures:?}" - ); - } - - #[test] - fn ask_the_bot_uses_managed_log_for_managed_fallback_empty_answer() { - if crate::test_support::isolated_process( - "tests::ask_the_bot_uses_managed_log_for_managed_fallback_empty_answer", - ) { - return; - } - let _path = SerializedPath::set_with("docker", "empty-answer"); - let record = temp_root("openbot-managed-empty-answer-record").join("commands.log"); - std::fs::create_dir_all(record.parent().expect("record parent")).unwrap(); - std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); - let server = TestServer::new( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n\ - data: {\"type\":\"RUN_STARTED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n\ - data: {\"type\":\"RUN_FINISHED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n", - ); - let root = temp_root("openbot-managed-empty-answer"); - std::fs::create_dir_all(&root).unwrap(); - - let problem = tauri::async_runtime::block_on(ask_the_bot_with_settings( - root.clone(), - "What is 17 times 23?".to_string(), - std::collections::BTreeMap::from([ - ("MANAGED_AGENT_AG_UI_URL".to_string(), server.url.clone()), - ( - "MANAGED_AGENT_TOKEN".to_string(), - "managed-token".to_string(), - ), - ]), - )) - .expect_err("empty managed answer must be diagnosed from managed Bot logs"); - - let request = server.request(); - assert_eq!(request.path, "/"); - assert!( - problem.said.contains("That key was refused"), - "{}", - problem.said - ); - let detail = problem.detail.as_deref().expect("managed log detail"); - assert!( - detail.contains("agent-langgraph refused the key"), - "{detail}" - ); - let commands = std::fs::read_to_string(&record).expect("command record"); - assert!( - commands - .lines() - .any(|line| line.ends_with("\tcompose logs --tail 40 agent-langgraph")), + "\tcompose up -d --no-build --pull never postgres supervisor agent-computer server worker agent-langgraph\n" + ), "{commands}" ); assert!( !commands - .lines() - .any(|line| line.ends_with("\tcompose logs --tail 40 agent-harness")), - "{commands}" + .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}" ); - let _ = std::fs::remove_dir_all(root); - let _ = std::fs::remove_dir_all(record.parent().expect("record parent")); - } - - #[test] - fn ask_the_bot_keeps_harness_log_for_picked_harness_empty_answer() { - if crate::test_support::isolated_process( - "tests::ask_the_bot_keeps_harness_log_for_picked_harness_empty_answer", - ) { - return; - } - let _path = SerializedPath::set_with("docker", "empty-answer"); - let record = temp_root("openbot-picked-empty-answer-record").join("commands.log"); - std::fs::create_dir_all(record.parent().expect("record parent")).unwrap(); - std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); - let server = TestServer::new( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n\ - data: {\"type\":\"RUN_STARTED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n\ - data: {\"type\":\"RUN_FINISHED\",\"threadId\":\"t1\",\"runId\":\"r1\"}\n\n", + assert_eq!(problem.said, "Part of OpenBot stopped during startup."); + let detail = problem.detail.as_deref().unwrap_or_default(); + assert!( + detail.contains("agent-langgraph stopped: langgraph died after boot"), + "{detail}" ); - let root = temp_root("openbot-picked-empty-answer"); - std::fs::create_dir_all(&root).unwrap(); - - let problem = tauri::async_runtime::block_on(ask_the_bot_with_settings( - root.clone(), - "What is 17 times 23?".to_string(), - std::collections::BTreeMap::from([ - ("PICKED_HARNESS_URL".to_string(), server.url.clone()), - ("PICKED_HARNESS_SOURCE".to_string(), "installed".to_string()), - ( - "PICKED_HARNESS_KIND".to_string(), - "remote-ag-ui".to_string(), - ), - ( - "MANAGED_AGENT_AG_UI_URL".to_string(), - "http://127.0.0.1:9/ag-ui".to_string(), - ), - ( - "MANAGED_AGENT_TOKEN".to_string(), - "managed-token".to_string(), - ), - ]), - )) - .expect_err("picked harness empty answer must still be diagnosed from harness logs"); - - let request = server.request(); - assert_eq!(request.path, "/"); assert!( - problem.said.contains("That key was refused"), - "{}", - problem.said + !detail.contains("agent-bot"), + "stale, unrequested agent-bot exit must not fail this Anthropic Start: {detail}" ); - let detail = problem.detail.as_deref().expect("harness log detail"); - assert!(detail.contains("agent-harness refused the key"), "{detail}"); - let commands = std::fs::read_to_string(&record).expect("command record"); assert!( - commands - .lines() - .any(|line| line.ends_with("\tcompose logs --tail 40 agent-harness")), - "{commands}" + !commands.contains("\tcompose logs --tail 3 agent-bot\n"), + "stale unrequested agent-bot should not get reported: {commands}" ); assert!( - !commands - .lines() - .any(|line| line.ends_with("\tcompose logs --tail 40 agent-langgraph")), - "{commands}" + commands.contains("\tcompose logs --tail 3 agent-langgraph\n"), + "selected dead LangGraph service should get reported: {commands}" ); let _ = std::fs::remove_dir_all(root); 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"; + + /// The key `local connect` would have stored, which Start reads from the credential store. + fn remember_local_intelligence_key(root: &Path) { + openbot_desktop_lib::vault::remember( + root, + "INTELLIGENCE_API_KEY", + "synthetic-intelligence-key", + ) + .unwrap(); + } + fn write_installed_deployment(root: &Path) { const DEPLOYMENT_VERSION: &str = "v0.0.8"; std::fs::create_dir_all(root.join("server")).unwrap(); @@ -10184,7 +13249,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"}}"#, @@ -10231,6 +13296,13 @@ fn main() { ) .unwrap(); deployment::record(root, DEPLOYMENT_VERSION, false).unwrap(); + // Installed by this app, so its Compose project is decided and recorded already. Choosing + // one is `stack::project`'s, tested in `stack`. + std::fs::write( + root.join(stack::PROJECT_FILE), + serde_json::json!({ "project": stack::DESKTOP_PROJECT }).to_string(), + ) + .unwrap(); for package in ["", "server", "worker", "shared", "toolkit", "extension"] { std::fs::write(root.join(package).join("package.json"), "{}").unwrap(); } @@ -10241,8 +13313,6 @@ fn main() { struct TestRequest { path: String, - headers: Vec, - body: String, } struct TestServer { @@ -10290,667 +13360,107 @@ fn main() { let read = stream.read(&mut buffer).expect("read body"); request.extend_from_slice(&buffer[..read]); } - let mut lines = headers.lines(); - 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 path = headers + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .expect("path") + .to_string(); + sender.send(TestRequest { path }).expect("send request"); + stream + .write_all(response.as_bytes()) + .expect("write response"); + }); + Self { + url, + received, + done: Some(done), + } } - 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)); + + fn request(mut self) -> TestRequest { + let request = self.received.recv().expect("request"); + self.done.take().expect("thread").join().expect("join"); + request } } - 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{}"); - } + + fn test_server_port(server: &TestServer) -> u16 { + server + .url + .strip_prefix("http://127.0.0.1:") + .expect("loopback url") + .parse() + .expect("port") } -} -"#).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; - } - 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); + + #[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] @@ -10996,7 +13506,7 @@ fn main() { url: setup.parse().unwrap(), body: tauri::ipc::InvokeBody::Json(serde_json::json!({ "root": f.owned, "apiUrl": "https://intelligence.example.test", - "gatewayWsUrl": "wss://gateway.example.test", "apiKey": "synthetic-unused-key", + "gatewayWsUrl": "wss://gateway.example.test", "model": {"provider": "synthetic-invalid-provider", "login": "api-key"}, "harness": null, })), headers: Default::default(), invoke_key: tauri::test::INVOKE_KEY.into(), }).expect_err("synthetic credential preflight must reject before store access"); @@ -11010,7 +13520,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()); @@ -11040,7 +13550,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)); @@ -11119,234 +13629,38 @@ fn main() { // 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(()) - }, - )) - }) - }; - entered.wait(); - for process in stack::host_processes(false) { - fixture.observe(process.cwd); - } + 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" @@ -11414,283 +13728,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() @@ -11733,12 +13770,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 { @@ -11864,30 +13920,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"); @@ -11914,19 +13966,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()); @@ -11956,13 +13995,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() @@ -11977,33 +14010,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 } @@ -12034,13 +14053,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); } } @@ -12263,10 +14278,14 @@ fn main() { ) .unwrap(); assert!(server_capabilities_answer(f.ports.server)); - assert!(stack::recorded_server_owns_port(&f.owned, f.ports.server).unwrap()); - assert!(stack::recorded_process_owns_port(&f.owned, "app", f.ports.app).unwrap()); + // This root's own server container publishes both ports, so before the journal it is + // adoptable. + assert!( + server_container_publishes(None, &f.owned, &[f.ports.server, f.ports.app]).unwrap() + ); assert!(stack::app_url(f.ports.app).is_some()); - assert!(already_running_at(&f.owned, &f.ports)); + let before = Shell::default(); + assert!(already_running_at(&before, &f.owned, &f.ports)); std::fs::write( f.owned.join(updates::JOURNAL), r#"{"phase":"migration-startup-verification"}"#, @@ -12284,8 +14303,12 @@ fn main() { app.handle(), &f.owned.to_string_lossy() )); - assert!(!already_running_at(&f.owned, &f.ports)); - assert!(owned_app_url(&f.owned, &f.ports).is_none()); + assert!(!already_running_at( + &app.state::(), + &f.owned, + &f.ports + )); + assert!(owned_app_url(&app.state::(), &f.owned, &f.ports).is_none()); let error = show_openbot_on(app.handle().clone(), &f.ports).unwrap_err(); assert!( error.said.contains("interrupted runtime update"), @@ -12417,8 +14440,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); @@ -12434,34 +14457,6 @@ fn main() { } } - #[test] - fn showing_owned_organization_app_requires_its_session_without_forgetting_installation() { - let f = RestoreFixture::new(); - std::fs::write( - f.owned.join(".env"), - "OPENBOT_ORGANIZATION_AUTH_URL=https://company.example\n", - ) - .unwrap(); - let app = f.app(&f.owned, "tauri://localhost/"); - let window = app.get_webview_window("main").unwrap(); - let before = window.url().unwrap(); - let failure = show_openbot_on(app.handle().clone(), &f.ports).unwrap_err(); - assert_eq!( - failure.connection, - Some(openbot_desktop_lib::problem::Connection::Organization) - ); - assert_eq!( - window.url().unwrap(), - before, - "no unauthenticated navigation" - ); - assert_eq!( - app.state::().selected_root.lock().unwrap().as_ref(), - Some(&f.owned) - ); - assert!(stack::recorded_server_owns_port(&f.owned, f.ports.server).unwrap()); - } - #[test] fn restore_window_owned_runtime_opens_app_and_active_root_takes_precedence() { let f = RestoreFixture::new(); @@ -12538,7 +14533,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!( @@ -12561,16 +14557,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(); @@ -12579,25 +14575,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/organization_auth.rs b/desktop/src-tauri/src/organization_auth.rs deleted file mode 100644 index 76535f457f..0000000000 --- a/desktop/src-tauri/src/organization_auth.rs +++ /dev/null @@ -1,759 +0,0 @@ -//! Employee identity from the customer's OpenBot authority. Intelligence credentials are separate. -use crate::problem::{Connection, Problem}; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use rand::RngCore; -use reqwest::blocking::Client; -use reqwest::Url; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::io::Write; -use std::net::{TcpListener, TcpStream}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{mpsc, Arc, Mutex, OnceLock}; -use std::time::{Duration, Instant}; - -const SAVED_COOKIE: &str = "OPENBOT_ORGANIZATION_SESSION"; -const CALLBACK: &str = "/organization-auth/callback"; -const PATIENCE: Duration = Duration::from_secs(300); - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct OrganizationUser { - pub id: String, - pub email: String, - pub name: Option, - pub role: String, -} - -#[derive(Serialize, Deserialize)] -struct SavedSession { - authority: String, - cookie: String, -} - -struct Pending { - root: PathBuf, - authority: String, - state: String, - verifier: String, - receiver: Option>>, - cancelled: Arc, -} -static PENDING: OnceLock>> = OnceLock::new(); -fn pending() -> &'static Mutex> { - PENDING.get_or_init(|| Mutex::new(None)) -} -fn problem(message: &str) -> Problem { - Problem::plain(message) -} -fn auth_problem(message: &str) -> Problem { - problem(message).connection(Connection::Organization) -} -fn random() -> String { - let mut bytes = [0u8; 32]; - rand::rng().fill_bytes(&mut bytes); - URL_SAFE_NO_PAD.encode(bytes) -} - -fn authority(value: &str) -> Result { - let url = Url::parse(value.trim()) - .map_err(|_| problem("Enter your organization's OpenBot sign-in URL."))?; - let loopback = matches!( - url.host_str(), - Some("localhost" | "127.0.0.1" | "[::1]" | "::1") - ); - if (url.scheme() != "https" && !(url.scheme() == "http" && loopback)) - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - || url.path() != "/" - { - return Err(problem("Use the HTTPS origin of your organization's OpenBot. HTTP is allowed only for a local test.")); - } - Ok(url.origin().ascii_serialization()) -} - -fn client() -> Result { - Client::builder() - .timeout(Duration::from_secs(15)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|_| problem("OpenBot could not prepare organization sign-in.")) -} - -fn verify(client: &Client, saved: &SavedSession) -> Result { - #[derive(Deserialize)] - struct Me { - user: OrganizationUser, - } - let response = client - .get(format!("{}/api/me", saved.authority)) - .header("cookie", &saved.cookie) - .send() - .map_err(|_| { - problem("OpenBot could not reach your organization. Try again when it is available.") - })?; - if response.status() == 401 || response.status() == 403 { - return Err(auth_problem("Sign in to your organization again.")); - } - if !response.status().is_success() { - return Err(problem( - "Your organization could not answer. Try again when it is available.", - )); - } - let user = response - .json::() - .map_err(|_| problem("Your organization returned an invalid sign-in response."))? - .user; - if user.id.is_empty() - || user.id == "dev-local-user" - || user.email.is_empty() - || !matches!(user.role.as_str(), "admin" | "user") - { - return Err(auth_problem( - "Your organization has not granted this account access to OpenBot.", - )); - } - Ok(user) -} - -fn saved(root: &Path, expected: &str) -> Result, Problem> { - let Some(value) = crate::vault::recall(root, SAVED_COOKIE)? else { - return Ok(None); - }; - let session: SavedSession = serde_json::from_str(&value) - .map_err(|_| auth_problem("Sign in to your organization again."))?; - if session.authority != expected { - return Ok(None); - } - Ok(Some(session)) -} - -/// Whether Desktop holds a sign-in for this organization, read from this computer alone. The Recorder's native host -/// asks on every ping, which must stay cheap and never reach the network; whether the organization still accepts the -/// sign-in is the handoff's question. On Windows the saved sign-in is sealed with DPAPI and opening it starts -/// PowerShell, so there only its presence is read. -pub fn signed_in_locally(root: &Path, authority_url: &str) -> Result { - let expected = authority(authority_url)?; - if !crate::vault::stored(root, SAVED_COOKIE) { - return Ok(false); - } - if cfg!(target_os = "windows") { - return Ok(true); - } - Ok(saved(root, &expected)?.is_some()) -} - -pub fn status(root: &Path, authority_url: &str) -> Result, Problem> { - let expected = authority(authority_url)?; - saved(root, &expected)? - .map(|session| verify(&client()?, &session)) - .transpose() -} - -pub fn cancel(root: &Path) { - if let Ok(mut held) = pending().lock() { - if held.as_ref().is_some_and(|run| run.root == root) { - if let Some(run) = held.take() { - run.cancelled.store(true, Ordering::SeqCst); - } - } - } -} - -fn callback_code(request: &str, expected_state: &str) -> Result { - let first = request.lines().next().unwrap_or(""); - let mut parts = first.split_whitespace(); - if parts.next() != Some("GET") { - return Err(problem("The organization sign-in callback was invalid.")); - } - let target = parts.next().unwrap_or(""); - let parsed = Url::parse(&format!("http://127.0.0.1{target}")) - .map_err(|_| problem("The organization sign-in callback was invalid."))?; - let parameters: Vec<_> = parsed.query_pairs().collect(); - let states: Vec<_> = parameters - .iter() - .filter(|(key, _)| key == "state") - .collect(); - let codes: Vec<_> = parameters.iter().filter(|(key, _)| key == "code").collect(); - if parsed.path() != CALLBACK - || states.len() != 1 - || states[0].1 != expected_state - || codes.len() != 1 - || codes[0].1.len() != 32 - || !codes[0].1.bytes().all(|b| b.is_ascii_alphanumeric()) - { - return Err(problem( - "The organization sign-in callback did not match this request. Sign in again.", - )); - } - Ok(codes[0].1.to_string()) -} - -fn receive_callback(stream: &mut TcpStream, expected_state: &str) -> Result { - let result = crate::provider_oauth::read_callback_request(stream, Duration::from_secs(3)) - .map_err(|_| problem("OpenBot could not read the organization callback.")) - .and_then(|request| callback_code(&request, expected_state)); - let (status, message) = if result.is_ok() { - ("200 OK", "Sign-in received. You can return to OpenBot.") - } else { - ( - "400 Bad Request", - "Sign-in did not match. Return to OpenBot and try again.", - ) - }; - let reply = format!("HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{message}", message.len()); - let _ = stream.write_all(reply.as_bytes()); - result -} - -pub fn begin(root: &Path, authority_url: &str, provider: &str) -> Result { - let authority = authority(authority_url)?; - if !matches!(provider, "google" | "microsoft" | "okta") { - return Err(problem("Choose Google, Microsoft, or Okta.")); - } - let listener = TcpListener::bind(("127.0.0.1", 0)) - .map_err(|_| problem("OpenBot could not receive the organization sign-in callback."))?; - listener - .set_nonblocking(true) - .map_err(|_| problem("OpenBot could not prepare the organization callback."))?; - let port = listener - .local_addr() - .map_err(|_| problem("OpenBot could not read its callback address."))? - .port(); - let state = random(); - let verifier = random(); - let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); - let (sender, receiver) = mpsc::channel(); - let cancelled = Arc::new(AtomicBool::new(false)); - let cancel_listener = cancelled.clone(); - let expected_state = state.clone(); - let mut url = Url::parse(&format!("{authority}/api/auth/desktop")) - .map_err(|_| problem("The organization URL is invalid."))?; - url.query_pairs_mut() - .append_pair("provider", provider) - .append_pair("state", &state) - .append_pair("code_challenge", &challenge) - .append_pair( - "redirect_uri", - &format!("http://127.0.0.1:{port}{CALLBACK}"), - ); - let mut held = pending() - .lock() - .map_err(|_| problem("Restart OpenBot to begin organization sign-in."))?; - if let Some(old) = held.take() { - old.cancelled.store(true, Ordering::SeqCst); - } - *held = Some(Pending { - root: root.to_path_buf(), - authority, - state, - verifier, - receiver: Some(receiver), - cancelled, - }); - std::thread::spawn(move || { - let started = Instant::now(); - while started.elapsed() < PATIENCE && !cancel_listener.load(Ordering::SeqCst) { - match listener.accept() { - Ok((mut stream, _)) => { - let result = receive_callback(&mut stream, &expected_state); - let _ = sender.send(result); - return; - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(50)) - } - Err(_) => { - let _ = sender.send(Err(problem( - "OpenBot could not receive the organization callback.", - ))); - return; - } - } - } - let _ = sender.send(Err(problem( - "Organization sign-in expired or was cancelled. Try again.", - ))); - }); - Ok(url.to_string()) -} - -pub fn finish(root: &Path) -> Result { - let (run_authority, run_state, run_verifier, receiver, cancelled) = { - let mut held = pending() - .lock() - .map_err(|_| problem("Restart OpenBot to finish organization sign-in."))?; - let run = held - .as_mut() - .filter(|run| run.root == root) - .ok_or_else(|| problem("Begin organization sign-in for this installation first."))?; - ( - run.authority.clone(), - run.state.clone(), - run.verifier.clone(), - run.receiver - .take() - .ok_or_else(|| problem("This sign-in is already waiting for the browser."))?, - run.cancelled.clone(), - ) - }; - let code = receiver - .recv_timeout(PATIENCE) - .map_err(|_| problem("Organization sign-in expired. Try again."))??; - if cancelled.load(Ordering::SeqCst) { - return Err(problem("Organization sign-in was cancelled.")); - } - let client = client()?; - let response = client.post(format!("{}/api/auth/electron/token", run_authority)).header("origin", &run_authority) - .json(&serde_json::json!({ "token": code, "state": run_state, "code_verifier": run_verifier })).send() - .map_err(|_| problem("OpenBot could not finish organization sign-in. Try again."))?; - if response.status().is_client_error() { - return Err(auth_problem( - "Your organization refused this sign-in. Try again.", - )); - } - if !response.status().is_success() { - return Err(problem( - "Your organization could not complete sign-in. Try again.", - )); - } - let cookie = response - .headers() - .get_all(reqwest::header::SET_COOKIE) - .iter() - .filter_map(|value| value.to_str().ok()) - .filter_map(|value| value.split(';').next()) - .find(|value| { - value.starts_with("better-auth.session_token=") - || value.starts_with("__Secure-better-auth.session_token=") - }) - .ok_or_else(|| problem("Your organization did not return an OpenBot session."))? - .to_string(); - let session = SavedSession { - authority: run_authority, - cookie, - }; - let user = verify(&client, &session)?; - let encoded = serde_json::to_string(&session) - .map_err(|_| problem("OpenBot could not save organization sign-in."))?; - let mut held = pending() - .lock() - .map_err(|_| problem("Restart OpenBot to finish organization sign-in."))?; - if cancelled.load(Ordering::SeqCst) - || !held - .as_ref() - .is_some_and(|run| run.state == run_state && run.root == root) - { - return Err(problem("Organization sign-in was cancelled or replaced.")); - } - crate::vault::remember(root, SAVED_COOKIE, &encoded)?; - held.take(); - Ok(user) -} - -/// Hands the saved organization session to the local OpenBot and returns the one-use -/// ticket address that turns it into the local HttpOnly cookie. -fn handoff_destination( - root: &Path, - authority_url: &str, - app_url: &str, -) -> Result<(Client, Url), Problem> { - let authority = authority(authority_url)?; - let session = saved(root, &authority)? - .ok_or_else(|| auth_problem("Sign in to your organization to open OpenBot."))?; - let client = client()?; - verify(&client, &session)?; - let mut destination = Url::parse(app_url) - .map_err(|_| problem("OpenBot could not find this installation's application."))?; - if destination.scheme() != "http" - || !matches!( - destination.host_str(), - Some("127.0.0.1" | "localhost" | "[::1]" | "::1") - ) - { - return Err(problem( - "The installed OpenBot application must be on this computer.", - )); - } - destination.set_path("/api/auth/organization/session"); - destination.set_query(None); - destination.set_fragment(None); - #[derive(Deserialize)] - struct Ticket { - ticket: String, - } - let response = client - .post(destination.clone()) - .header("origin", destination.origin().ascii_serialization()) - .json(&serde_json::json!({ "cookie": session.cookie })) - .send() - .map_err(|_| { - problem("OpenBot could not deliver your organization sign-in to this installation.") - })?; - if response.status() == 401 || response.status() == 403 { - return Err(auth_problem("Sign in to your organization again.")); - } - if !response.status().is_success() { - return Err(problem( - "Your organization could not answer. Try again when it is available.", - )); - } - let ticket = response - .json::() - .map_err(|_| problem("OpenBot did not accept your organization session."))?; - if ticket.ticket.is_empty() || ticket.ticket.len() > 128 { - return Err(problem("OpenBot returned an invalid sign-in handoff.")); - } - destination - .query_pairs_mut() - .append_pair("ticket", &ticket.ticket); - Ok((client, destination)) -} - -/// A one-use navigation sets the local HttpOnly cookie; credentials never enter renderer IPC. -pub fn session_destination( - root: &Path, - authority_url: &str, - app_url: &str, -) -> Result { - handoff_destination(root, authority_url, app_url) - .map(|(_, destination)| destination.to_string()) -} - -const LOCAL_SESSION_COOKIE: &str = "openbot.organization-session="; - -/// The local OpenBot's organization session cookie pair for the saved sign-in, for the -/// Recorder's native host. Never logged. -pub fn local_session_cookie( - root: &Path, - authority_url: &str, - app_url: &str, -) -> Result { - let (client, destination) = handoff_destination(root, authority_url, app_url)?; - let response = client.get(destination).send().map_err(|_| { - problem("OpenBot could not read your organization sign-in from this installation.") - })?; - if response.status() == 401 || response.status() == 403 { - return Err(auth_problem("Sign in to your organization again.")); - } - if !response.status().is_success() && !response.status().is_redirection() { - return Err(problem( - "OpenBot could not accept your organization sign-in. Try again.", - )); - } - response - .headers() - .get_all(reqwest::header::SET_COOKIE) - .iter() - .filter_map(|value| value.to_str().ok()) - .filter_map(|value| value.split(';').next()) - .find(|value| value.starts_with(LOCAL_SESSION_COOKIE)) - .map(str::to_string) - .ok_or_else(|| problem("OpenBot did not return an organization session.")) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::{BufRead, BufReader, Read}; - fn callback_over_tcp(nonblocking: bool, fragmented: bool) { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - listener.set_nonblocking(true).unwrap(); - let mut browser = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); - browser - .set_read_timeout(Some(Duration::from_secs(3))) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(2); - let (mut stream, _) = loop { - match listener.accept() { - Ok(accepted) => break accepted, - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - assert!( - Instant::now() < deadline, - "browser connection was not accepted" - ); - std::thread::yield_now(); - } - Err(error) => panic!("browser connection failed: {error}"), - } - }; - // Unix does not consistently inherit this flag; model the Windows accept behavior. - if nonblocking { - stream.set_nonblocking(true).unwrap(); - } else { - stream.set_nonblocking(false).unwrap(); - } - let request = b"GET /organization-auth/callback?state=expected&code=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HTTP/1.1\r\nHost: localhost\r\n\r\n"; - let split = if fragmented { 18 } else { 0 }; - if fragmented { - browser.write_all(&request[..split]).unwrap(); - } - let (sender, receiver) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - sender - .send(receive_callback(&mut stream, "expected")) - .unwrap(); - }); - let early = receiver.recv_timeout(Duration::from_millis(100)); - if !matches!(early, Err(std::sync::mpsc::RecvTimeoutError::Timeout)) { - worker.join().unwrap(); - panic!("callback completed before its request line arrived: {early:?}"); - } - browser.write_all(&request[split..]).unwrap(); - assert_eq!( - receiver - .recv_timeout(Duration::from_secs(3)) - .unwrap() - .unwrap(), - "a".repeat(32) - ); - let mut response = String::new(); - browser.read_to_string(&mut response).unwrap(); - assert!(response.starts_with("HTTP/1.1 200 OK\r\n")); - worker.join().unwrap(); - } - - #[test] - fn callback_waits_for_delayed_get_on_nonblocking_stream() { - callback_over_tcp(true, false); - } - - #[test] - fn callback_waits_for_fragmented_request_line() { - callback_over_tcp(false, true); - } - - #[test] - fn cancellation_retires_a_finish_already_waiting_for_the_browser() { - let root = PathBuf::from("organization-cancellation-fixture"); - begin(&root, "https://company.example", "google").unwrap(); - let worker_root = root.clone(); - let worker = std::thread::spawn(move || finish(&worker_root)); - let deadline = Instant::now() + Duration::from_secs(2); - while pending() - .lock() - .unwrap() - .as_ref() - .is_some_and(|run| run.receiver.is_some()) - { - assert!(Instant::now() < deadline, "finish did not start waiting"); - std::thread::yield_now(); - } - cancel(&root); - assert!(worker.join().unwrap().is_err()); - assert!(pending().lock().unwrap().is_none()); - } - #[test] - fn only_rejected_sessions_request_organization_refresh() { - for (status, expected) in [ - ("401 Unauthorized", Some(Connection::Organization)), - ("503 Service Unavailable", None), - ] { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let address = listener.local_addr().unwrap(); - let worker = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut reader = BufReader::new(stream.try_clone().unwrap()); - let mut line = String::new(); - assert!(reader.read_line(&mut line).unwrap() > 0); - assert_eq!(line, "GET /api/me HTTP/1.1\r\n"); - loop { - line.clear(); - assert!(reader.read_line(&mut line).unwrap() > 0); - if line == "\r\n" { - break; - } - } - write!( - stream, - "HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - ) - .unwrap(); - }); - let result = verify( - &client().unwrap(), - &SavedSession { - authority: format!("http://{address}"), - cookie: "better-auth.session_token=fixture".into(), - }, - ); - assert_eq!(result.unwrap_err().connection, expected); - worker.join().unwrap(); - } - } - #[test] - fn authority_is_explicit_and_cannot_smuggle_credentials_or_destinations() { - assert_eq!( - authority("https://company.example").unwrap(), - "https://company.example" - ); - for bad in [ - "http://company.example", - "https://person:password@company.example", - "https://company.example/path", - "https://company.example?next=elsewhere", - ] { - assert!(authority(bad).is_err()); - } - } - #[test] - fn callback_requires_the_exact_single_state_and_code() { - let code = "a".repeat(32); - assert_eq!( - callback_code( - &format!("GET {CALLBACK}?code={code}&state=expected HTTP/1.1\r\n"), - "expected" - ) - .unwrap(), - code - ); - for query in [ - format!("code={code}&state=wrong"), - format!("code={code}&state=expected&state=expected"), - "code=short&state=expected".into(), - ] { - assert!( - callback_code(&format!("GET {CALLBACK}?{query} HTTP/1.1\r\n"), "expected").is_err() - ); - } - } - - /// A fake local OpenBot: answers the handoff POST, then the ticket GET. - fn fake_app( - post: &'static str, - get: &'static str, - ) -> (String, std::thread::JoinHandle>) { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let worker = std::thread::spawn(move || { - let mut seen = Vec::new(); - for reply in [post, get] { - let (mut stream, _) = listener.accept().unwrap(); - let mut reader = BufReader::new(stream.try_clone().unwrap()); - let mut request = String::new(); - let mut length = 0; - loop { - let mut line = String::new(); - if reader.read_line(&mut line).unwrap() == 0 { - break; - } - if line == "\r\n" { - break; - } - if let Some(value) = line.to_lowercase().strip_prefix("content-length:") { - length = value.trim().parse().unwrap(); - } - request.push_str(&line); - } - let mut body = vec![0; length]; - reader.read_exact(&mut body).unwrap(); - seen.push(request); - stream.write_all(reply.as_bytes()).unwrap(); - if reply.starts_with("HTTP/1.1 401") { - break; - } - } - seen - }); - (url, worker) - } - - fn authority_with_session(name: &str) -> (PathBuf, String, std::thread::JoinHandle<()>) { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let authority = format!("http://{}", listener.local_addr().unwrap()); - let worker = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut reader = BufReader::new(stream.try_clone().unwrap()); - loop { - let mut line = String::new(); - reader.read_line(&mut line).unwrap(); - if line == "\r\n" { - break; - } - } - let body = r#"{"user":{"id":"u1","email":"a@b.c","name":null,"role":"user"}}"#; - write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); - }); - let root = std::env::temp_dir().join(format!( - "openbot-local-cookie-{name}-{}", - std::process::id() - )); - std::fs::create_dir_all(&root).unwrap(); - crate::vault::remember( - &root, - SAVED_COOKIE, - &serde_json::to_string(&SavedSession { - authority: authority.clone(), - cookie: "better-auth.session_token=saved".into(), - }) - .unwrap(), - ) - .unwrap(); - (root, authority, worker) - } - - #[test] - fn signed_in_locally_reads_the_saved_sign_in_and_creates_nothing() { - // Nothing listens at either authority: the check never dials one. - let authority = "http://127.0.0.1:9"; - let root = std::env::temp_dir().join(format!( - "openbot-signed-in-locally-{}-{}", - std::process::id(), - random() - )); - std::fs::create_dir_all(&root).unwrap(); - assert!(!signed_in_locally(&root, authority).unwrap()); - assert!(!root.join(".secrets").exists()); - crate::vault::remember( - &root, - SAVED_COOKIE, - &serde_json::to_string(&SavedSession { - authority: authority.into(), - cookie: "better-auth.session_token=saved".into(), - }) - .unwrap(), - ) - .unwrap(); - assert!(signed_in_locally(&root, authority).unwrap()); - // Where the saved sign-in can be read cheaply, one for another organization is not this one's. - if !cfg!(target_os = "windows") { - assert!(!signed_in_locally(&root, "http://127.0.0.1:8").unwrap()); - } - assert!(signed_in_locally(&root, "not a url").is_err()); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn local_session_cookie_returns_the_set_cookie_pair() { - let (root, authority, authority_worker) = authority_with_session("ok"); - let (app, app_worker) = fake_app( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 14\r\nConnection: close\r\n\r\n{\"ticket\":\"t\"}", - "HTTP/1.1 302 Found\r\nLocation: /\r\nSet-Cookie: openbot.organization-session=v; Path=/; HttpOnly\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ); - let cookie = local_session_cookie(&root, &authority, &app).unwrap(); - assert_eq!(cookie, "openbot.organization-session=v"); - let seen = app_worker.join().unwrap(); - assert!(seen[0].starts_with("POST /api/auth/organization/session HTTP/1.1")); - assert!(seen[0].to_lowercase().contains(&format!("origin: {app}"))); - assert!(seen[1].starts_with("GET /api/auth/organization/session?ticket=t HTTP/1.1")); - authority_worker.join().unwrap(); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn local_session_cookie_maps_a_rejected_handoff_to_the_sign_in_problem() { - let (root, authority, authority_worker) = authority_with_session("401"); - let (app, app_worker) = fake_app( - "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - "", - ); - let error = local_session_cookie(&root, &authority, &app).unwrap_err(); - assert_eq!(error.connection, Some(Connection::Organization)); - app_worker.join().unwrap(); - authority_worker.join().unwrap(); - let _ = std::fs::remove_dir_all(root); - } -} diff --git a/desktop/src-tauri/src/preparation.rs b/desktop/src-tauri/src/preparation.rs index 3990b7ef6b..b9346415d0 100644 --- a/desktop/src-tauri/src/preparation.rs +++ b/desktop/src-tauri/src/preparation.rs @@ -89,6 +89,13 @@ pub fn record_launch(root: &Path, harness: Option<&HarnessChoice>) -> Result<(), ) } +/// Whether an app finished installing in `root`, or started OpenBot from it, at some point: its +/// completed-installation or launch record is there, valid or not. Proof that the folder is an +/// installation's own, which a source checkout never has (see `stack::project`). +pub fn installed_before(root: &Path) -> bool { + root.join(FILE).is_file() || root.join(LAUNCH).is_file() +} + /// Passive resume intent. Runtime and credential validity are checked by the normal Start path. pub fn launch(root: &Path) -> Option { let record: Launched = serde_json::from_slice(&std::fs::read(root.join(LAUNCH)).ok()?).ok()?; @@ -150,7 +157,7 @@ pub fn selected_root(config: &Path) -> Option { } pub fn required(detail: impl Into) -> Problem { - Problem::with(REQUIRED, detail) + Problem::with(REQUIRED, detail).connection(crate::problem::Connection::Installation) } fn hash_files(root: &Path, files: impl IntoIterator) -> Result { @@ -204,7 +211,6 @@ fn fingerprint(root: &Path) -> Result { PathBuf::from(".openbot-deployment"), PathBuf::from("container-images.json"), PathBuf::from("docker-compose.yml"), - PathBuf::from(DEPENDENCIES), ]; // A release with the person's browser is installed only with its Recorder in place, so one that // has gone missing sends Start back to Installation rather than starting a browser with no @@ -216,17 +222,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()) } /// Called only after candidate startup/readiness is verified and its pin is promoted. The stamp @@ -475,17 +491,20 @@ fn complete_with( images: Vec, verify: impl FnOnce(&[String]) -> Result<(), Problem>, ) -> Result<(), Problem> { - dependencies_ready(root)?; verify(&images)?; record(root, harness, images) } /// `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. The host +/// packages are the person's browser's, which a Start that runs one prepares and checks itself. pub fn require( root: &Path, selection: Option<&Option>, address: &Address, -) -> Result { +) -> Result<(), Problem> { require_with(root, selection, deployment::pinned().as_ref(), |images| { require_images(address, images) }) @@ -497,7 +516,7 @@ fn require_with( selection: Option<&Option>, pin: Option<&deployment::Pin>, 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()))?, ) @@ -512,6 +531,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) @@ -526,9 +551,7 @@ fn require_with( if let Some(problem) = other_release(deployment::installed(root).as_ref(), pin) { return Err(required(problem)); } - let bun = dependencies_ready(root)?; - verify(&record.images)?; - Ok(bun) + verify(&record.images) } /// Why the installed deployment is not one this build lays out, or `None` when it is. @@ -572,6 +595,21 @@ pub fn image_settings( mod tests { use super::*; + // The setup window recognises this refusal by its exact sentence (ProviderPicker's + // INSTALL_REQUIRED) to send the person back to Install; rewording it here alone would + // silently leave them stuck on the AI step again. + #[test] + fn the_setup_window_knows_the_required_sentence() { + let picker = include_str!("../../src/ProviderPicker.tsx"); + assert!( + picker.contains(&format!("\"{REQUIRED}\"")), + "desktop/src/ProviderPicker.tsx INSTALL_REQUIRED must equal preparation::REQUIRED" + ); + } + + /// 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"); @@ -642,7 +680,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", false).unwrap(); let source = root.join("runtime.rs"); @@ -819,6 +857,19 @@ fn main() { .contains(crate::install::BUN)); } + /// Installing is pulling images, so a clean machine, which has never installed the host's + /// packages, completes it. A Start that runs the person's browser installs them later, and that + /// is not a change to the installation. + #[test] + fn a_clean_machine_installs_without_the_hosts_packages() { + let f = Fixture::new(); + assert!(!f.root.join(DEPENDENCIES).exists()); + complete_with(&f.root, None, vec!["synthetic-image".into()], |_| Ok(())).unwrap(); + require_with(&f.root, Some(&None), None, |_| Ok(())).unwrap(); + f.dependencies(); + require_with(&f.root, Some(&None), None, |_| Ok(())).unwrap(); + } + #[test] fn failed_image_preparation_cannot_publish_completion() { let f = Fixture::new(); @@ -839,7 +890,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), 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(), @@ -851,12 +918,27 @@ 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), None, |_| Ok(())).is_err()); - f.dependencies(); - f.prepared(chosen.as_ref()); - std::fs::write(f.root.join("bun.lock"), "changed-lock").unwrap(); - assert!(require_with(&f.root, Some(&chosen), None, |_| Ok(())).is_err()); + std::fs::write(f.root.join("runtime-version"), "1.2.15").unwrap(); + require_with(&f.root, Some(&chosen), None, |_| Ok(())).unwrap(); + // The images still are. + assert!(require_with(&f.root, Some(&chosen), None, |_| 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(); + let refused = require_with(&f.root, Some(&chosen), None, |_| Ok(())).unwrap_err(); + // The window goes back to Install for it rather than stopping on the sentence. + assert_eq!( + refused.connection, + Some(crate::problem::Connection::Installation) + ); } #[test] @@ -926,11 +1008,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, 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/problem.rs b/desktop/src-tauri/src/problem.rs index 16dfa06b4f..3bd0d83fb0 100644 --- a/desktop/src-tauri/src/problem.rs +++ b/desktop/src-tauri/src/problem.rs @@ -17,7 +17,8 @@ use serde::Serialize; pub enum Connection { Model, Intelligence, - Organization, + /// Not a credential: Install has to run again before Start can, and the window goes back to it. + Installation, } /// A failure, in both registers. diff --git a/desktop/src-tauri/src/pull_metrics.rs b/desktop/src-tauri/src/pull_metrics.rs index 0b38aff47d..1b0717af2a 100644 --- a/desktop/src-tauri/src/pull_metrics.rs +++ b/desktop/src-tauri/src/pull_metrics.rs @@ -1,12 +1,12 @@ //! Metrics for an explicit image pull, before containers or provider sign-in start. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, VecDeque}; use std::io::{BufRead, BufReader, Read, Write}; use std::process::{Command, Output, Stdio}; use std::sync::mpsc::{sync_channel, SyncSender}; -use std::time::Instant; +use std::time::{Duration, Instant}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::engine::Address; use crate::problem::Problem; @@ -75,34 +75,105 @@ struct Progress { parent_id: Option, text: String, current: Option, + total: Option, +} + +/// One pull's download so far: the bytes counted, and the image's size once every layer still to +/// download has said how big it is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ImageBytes { + pub bytes: u64, + pub size: Option, } #[derive(Default)] -struct DownloadBytes(HashMap); +struct DownloadBytes { + /// Each layer's highest download counter. + current: HashMap, + /// Each layer's advertised size, from its download lines. + sizes: HashMap, + /// Layers this pull downloads, and those that finished downloading. + layers: HashSet, + finished: HashSet, +} impl DownloadBytes { fn observe(&mut self, progress: Progress) { - if progress.text == "Downloading" - && !progress.id.is_empty() - && !progress.parent_id.as_deref().unwrap_or_default().is_empty() - { - if let Some(current) = progress.current { - self.0 - .entry(progress.id) - .and_modify(|maximum| *maximum = (*maximum).max(current)) - .or_insert(current); + if progress.id.is_empty() || progress.parent_id.as_deref().unwrap_or_default().is_empty() { + return; + } + match progress.text.as_str() { + // Already on this computer: nothing to download. + "Already exists" => { + self.layers.remove(&progress.id); + return; + } + "Download complete" | "Verifying Checksum" | "Extracting" | "Pull complete" => { + self.finished.insert(progress.id.clone()); + } + "Downloading" => { + if let Some(current) = progress.current { + self.current + .entry(progress.id.clone()) + .and_modify(|maximum| *maximum = (*maximum).max(current)) + .or_insert(current); + } + if let Some(total) = progress.total.filter(|total| *total > 0) { + self.sizes.insert(progress.id.clone(), total); + } } + _ => {} } + self.layers.insert(progress.id); } fn total(&self) -> Option { - if self.0.is_empty() { + if self.current.is_empty() { return None; } - self.0 + self.current .values() .try_fold(0_u64, |sum, current| sum.checked_add(*current)) } + + /// The size is the sum of the layers' sizes, known only when every layer that is still + /// downloading has advertised one. A layer that finished without ever saying is counted at + /// what was seen of it, so a tiny layer does not leave the size unknown for good. + fn size(&self) -> Option { + if self.layers.is_empty() { + return None; + } + self.layers.iter().try_fold(0_u64, |sum, layer| { + let size = match self.sizes.get(layer) { + Some(size) => *size, + None if self.finished.contains(layer) => { + self.current.get(layer).copied().unwrap_or(0) + } + None => return None, + }; + sum.checked_add(size) + }) + } + + fn image_bytes(&self) -> ImageBytes { + ImageBytes { + bytes: self.total().unwrap_or(0), + size: self.size(), + } + } +} + +/// A short name a person can read for an image reference: its repository's last part, without +/// registry, tag, digest or the `openbot-` prefix. The server image is "OpenBot server". +pub fn display_name(image: &str) -> String { + let without_digest = image.split('@').next().unwrap_or_default(); + let last = without_digest.rsplit('/').next().unwrap_or_default(); + let repository = last.split(':').next().unwrap_or_default(); + let name = repository.strip_prefix("openbot-").unwrap_or(repository); + match name { + "openbot" | "server" => "OpenBot server".to_string(), + name => name.to_string(), + } } /// Compose's JSON writer carries Docker's byte counters as integers: @@ -128,7 +199,7 @@ fn download_bytes(stdout: &[u8], stderr: &[u8]) -> Option { pub fn pull_image( engine: &Address, image: &str, - on_progress: impl FnMut(u64), + on_progress: impl FnMut(ImageBytes), on_complete: impl FnOnce(PullMetrics), ) -> Result<(), Problem> { if crate::preparation::image_present(engine, image)? { @@ -163,6 +234,248 @@ pub fn pull_image( ) } +/// How many images Install pulls at once. +pub const IMAGE_PULLS_AT_ONCE: usize = 4; + +/// How often Install's download report is refreshed while no pull says anything, so the speed +/// falls when a download stalls. +pub const PROGRESS_TICK: Duration = Duration::from_millis(500); + +/// Install's download, as the window shows it under "Installing OpenBot". +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Downloads { + /// Pulls FINISHED, of `total`. + pub done: usize, + pub total: usize, + /// Bytes downloaded by every pull so far, running and finished. + pub bytes: u64, + /// Recent download speed, once there is enough of a window to say. Filled in by `Pacer`. + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes_per_second: Option, + /// The pulls running now, in list order. + pub pulling: Vec, +} + +/// One running pull. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ImagePull { + /// See `display_name`. + pub name: String, + pub bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +/// Pull `images` with at most `limit` pulls in flight, starting them in list order. +/// +/// `pull(index, image, on_bytes)` is one blocking pull; it calls `on_bytes` with what that pull +/// has downloaded so far. `keep_going` is asked before each pull starts; an `Err` (Stop) starts no +/// further pull. On the first failure no further pull starts, the pulls already running finish, +/// and that first error is returned. +/// +/// `on_progress` gets every change, and a repeat every `PROGRESS_TICK` while nothing changes. +/// `done` counts FINISHED pulls (not started ones), and `bytes` is the sum over every pull so +/// far, running and finished. Neither goes down, nor does a running pull's own `bytes`, and after +/// the last pull succeeds it is called with `done == total`. Pace it with `Pacer`. +pub fn pull_images_limited( + images: &[String], + limit: usize, + pull: P, + keep_going: K, + on_progress: O, +) -> Result<(), Problem> +where + P: Fn(usize, &str, &dyn Fn(ImageBytes)) -> Result<(), Problem> + Sync, + K: Fn() -> Result<(), Problem> + Sync, + O: Fn(&Downloads) + Sync, +{ + #[derive(Clone, Copy, PartialEq)] + enum Pull { + Queued, + Running, + Finished, + } + struct Shared { + next: usize, + done: usize, + state: Vec, + bytes: Vec, + error: Option, + } + let total = images.len(); + let names: Vec = images.iter().map(|image| display_name(image)).collect(); + let shared = std::sync::Mutex::new(Shared { + next: 0, + done: 0, + state: vec![Pull::Queued; total], + bytes: vec![ImageBytes::default(); total], + error: None, + }); + // Reported under the lock, so the person sees `done` and `bytes` only ever go up. + let report = |state: &Shared| { + on_progress(&Downloads { + done: state.done, + total, + bytes: state.bytes.iter().map(|bytes| bytes.bytes).sum(), + bytes_per_second: None, + pulling: (0..total) + .filter(|index| state.state[*index] == Pull::Running) + .map(|index| ImagePull { + name: names[index].clone(), + bytes: state.bytes[index].bytes, + size: state.bytes[index].size, + }) + .collect(), + }) + }; + report(&shared.lock().unwrap()); + let worker = || loop { + let index = { + let mut state = shared.lock().unwrap(); + if state.error.is_some() || state.next >= total { + return; + } + if let Err(problem) = keep_going() { + state.error = Some(problem); + return; + } + state.next += 1; + let index = state.next - 1; + state.state[index] = Pull::Running; + index + }; + let on_bytes = |bytes: ImageBytes| { + let mut state = shared.lock().unwrap(); + let seen = state.bytes[index]; + // One pull's own figure is cumulative; keep its highest so the sum never drops. + let next = ImageBytes { + bytes: seen.bytes.max(bytes.bytes), + size: bytes.size.or(seen.size), + }; + if next != seen { + state.bytes[index] = next; + report(&state); + } + }; + let outcome = pull(index, &images[index], &on_bytes); + let mut state = shared.lock().unwrap(); + state.state[index] = Pull::Finished; + match outcome { + Ok(()) => { + state.done += 1; + report(&state); + } + Err(problem) => { + state.error.get_or_insert(problem); + return; + } + } + }; + std::thread::scope(|scope| { + let (stop, stopped) = std::sync::mpsc::channel::<()>(); + let (shared, report) = (&shared, &report); + scope.spawn(move || { + // Wakes every tick until every worker has returned and `stop` is dropped. + while let Err(std::sync::mpsc::RecvTimeoutError::Timeout) = + stopped.recv_timeout(PROGRESS_TICK) + { + let state = shared.lock().unwrap(); + if state.error.is_none() && state.done < total { + report(&state); + } + } + }); + let workers: Vec<_> = (0..limit.max(1).min(total)) + .map(|_| scope.spawn(worker)) + .collect(); + for worker in workers { + if let Err(panic) = worker.join() { + std::panic::resume_unwind(panic); + } + } + drop(stop); + }); + match shared.into_inner().unwrap().error { + Some(problem) => Err(problem), + None => Ok(()), + } +} + +/// Download speed over the last few seconds. +pub struct Speed { + window: Duration, + samples: VecDeque<(Instant, u64)>, +} + +impl Default for Speed { + fn default() -> Self { + Self { + window: Duration::from_secs(5), + samples: VecDeque::new(), + } + } +} + +impl Speed { + /// Record `bytes` downloaded in all at `now`, and say the bytes per second over the window. + /// None until the window spans at least half a second. + pub fn observe(&mut self, now: Instant, bytes: u64) -> Option { + self.samples.push_back((now, bytes)); + while let Some((at, _)) = self.samples.front() { + if now.saturating_duration_since(*at) > self.window { + self.samples.pop_front(); + } else { + break; + } + } + let (since, from) = *self.samples.front()?; + let span = now.saturating_duration_since(since); + if span < Duration::from_millis(500) { + return None; + } + Some((bytes.saturating_sub(from) as f64 / span.as_secs_f64()).round() as u64) + } +} + +/// Paces `Downloads` reports for the window: at most one per `interval`, except that a pull +/// finishing is always shown at once. Measures the speed from every report it is offered. +pub struct Pacer { + interval: Duration, + last: Option<(Instant, usize)>, + speed: Speed, +} + +impl Default for Pacer { + fn default() -> Self { + Self { + interval: Duration::from_millis(250), + last: None, + speed: Speed::default(), + } + } +} + +impl Pacer { + pub fn offer(&mut self, now: Instant, downloads: &Downloads) -> Option { + let bytes_per_second = self.speed.observe(now, downloads.bytes); + let due = match self.last { + None => true, + Some((at, done)) => { + done != downloads.done || now.saturating_duration_since(at) >= self.interval + } + }; + if !due { + return None; + } + self.last = Some((now, downloads.done)); + Some(Downloads { + bytes_per_second, + ..downloads.clone() + }) + } +} + /// Complete one explicit pull and report its outcome before the caller can start containers. /// A failed pull is returned as the same two-part Problem used by the existing startup path. pub(crate) fn run( @@ -178,7 +491,7 @@ fn run_with_progress( mut command: Command, input: Option<&[u8]>, json_progress: bool, - mut on_progress: impl FnMut(u64), + mut on_progress: impl FnMut(ImageBytes), on_complete: impl FnOnce(PullMetrics), ) -> Result<(), Problem> { command @@ -214,15 +527,13 @@ fn run_with_progress( let out = scope.spawn(move || read_output(stdout, json_progress, stdout_sender)); let err = scope.spawn(move || read_output(stderr, json_progress, sender)); let mut layers = DownloadBytes::default(); - let mut last = None; + let mut last = ImageBytes::default(); for progress in receiver { layers.observe(progress); - let total = layers.total(); - if total != last { - if let Some(bytes) = total { - on_progress(bytes); - } - last = total; + let now = layers.image_bytes(); + if now != last { + on_progress(now); + last = now; } } let stdout = out @@ -300,6 +611,170 @@ fn read_output( #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; + use std::sync::Mutex; + use std::time::Duration; + + /// Wait, at most two seconds, until `count` reaches `want`. A bounded wait, so a sequential + /// implementation fails the test instead of hanging it. + fn wait_for(count: &AtomicUsize, want: usize) { + let deadline = Instant::now() + Duration::from_secs(2); + while count.load(SeqCst) < want && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(1)); + } + } + + fn names(count: usize) -> Vec { + (0..count).map(|index| format!("image-{index}")).collect() + } + + #[test] + fn pulls_several_images_at_once_but_never_more_than_the_limit() { + let images = names(10); + let running = AtomicUsize::new(0); + let most = AtomicUsize::new(0); + let arrived = AtomicUsize::new(0); + pull_images_limited( + &images, + IMAGE_PULLS_AT_ONCE, + |index, _, _| { + let now = running.fetch_add(1, SeqCst) + 1; + most.fetch_max(now, SeqCst); + // The first four pulls wait to be running together. + if index < IMAGE_PULLS_AT_ONCE { + arrived.fetch_add(1, SeqCst); + wait_for(&arrived, IMAGE_PULLS_AT_ONCE); + } + std::thread::sleep(Duration::from_millis(5)); + running.fetch_sub(1, SeqCst); + Ok(()) + }, + || Ok(()), + |_| {}, + ) + .unwrap(); + assert_eq!(most.load(SeqCst), IMAGE_PULLS_AT_ONCE); + } + + #[test] + fn first_failure_starts_no_new_pull_lets_running_ones_finish_and_is_returned() { + let images = names(10); + let started = Mutex::new(Vec::new()); + let finished = Mutex::new(Vec::new()); + let failed = std::sync::atomic::AtomicBool::new(false); + let started_after_failure = AtomicUsize::new(0); + let arrived = AtomicUsize::new(0); + let error = pull_images_limited( + &images, + 3, + |index, _, _| { + if failed.load(SeqCst) { + started_after_failure.fetch_add(1, SeqCst); + } + started.lock().unwrap().push(index); + if index < 3 { + arrived.fetch_add(1, SeqCst); + wait_for(&arrived, 3); + } + if index == 0 { + failed.store(true, SeqCst); + return Err(Problem::plain("pull 0 failed")); + } + if index == 1 { + // Fails later than pull 0; its error is not the one returned. + std::thread::sleep(Duration::from_millis(40)); + return Err(Problem::plain("pull 1 failed")); + } + std::thread::sleep(Duration::from_millis(40)); + finished.lock().unwrap().push(index); + Ok(()) + }, + || Ok(()), + |_| {}, + ) + .unwrap_err(); + assert_eq!(error, Problem::plain("pull 0 failed")); + assert_eq!(started_after_failure.load(SeqCst), 0); + let mut started = started.lock().unwrap().clone(); + started.sort(); + assert_eq!(started, vec![0, 1, 2]); + assert_eq!(*finished.lock().unwrap(), vec![2]); + } + + #[test] + fn cancellation_starts_no_new_pull() { + let images = names(10); + let started = AtomicUsize::new(0); + let cancelled = std::sync::atomic::AtomicBool::new(false); + let error = pull_images_limited( + &images, + 2, + |_, _, _| { + if started.fetch_add(1, SeqCst) + 1 == 3 { + cancelled.store(true, SeqCst); + } + Ok(()) + }, + || { + if cancelled.load(SeqCst) { + Err(Problem::plain("cancelled")) + } else { + Ok(()) + } + }, + |_| {}, + ) + .unwrap_err(); + assert_eq!(error, Problem::plain("cancelled")); + assert!(started.load(SeqCst) <= 4, "{}", started.load(SeqCst)); + } + + #[test] + fn progress_counts_finished_pulls_and_sums_bytes_across_pulls() { + let images = names(6); + let calls = Mutex::new(Vec::new()); + pull_images_limited( + &images, + 3, + |index, _, on_bytes| { + let base = (index as u64 + 1) * 100; + on_bytes(ImageBytes { + bytes: base, + size: None, + }); + std::thread::sleep(Duration::from_millis(2)); + on_bytes(ImageBytes { + bytes: base * 2, + size: None, + }); + Ok(()) + }, + || Ok(()), + |downloads| { + calls + .lock() + .unwrap() + .push((downloads.done, downloads.total, downloads.bytes)) + }, + ) + .unwrap(); + let calls = calls.lock().unwrap(); + // Each image ends at 200 * (index + 1) bytes: 200 * (1 + ... + 6) in all. + let expected: u64 = (1..=6).map(|n| n * 200).sum(); + assert_eq!(*calls.last().unwrap(), (6, 6, expected)); + assert!(calls.iter().all(|(_, total, _)| *total == 6)); + assert!(calls + .windows(2) + .all(|w| w[0].0 <= w[1].0 && w[0].2 <= w[1].2)); + // Done counts finishes: it goes up by one per pull, from 0 to 6. + let mut seen: Vec = calls.iter().map(|(done, _, _)| *done).collect(); + seen.dedup(); + assert_eq!(seen, (0..=6).collect::>()); + // Bytes from running pulls are summed, not only the last pull's own figure. + assert!(calls + .iter() + .any(|(done, _, bytes)| *done < 6 && *bytes > 1200)); + } #[test] fn streams_download_bytes_before_the_child_exits() { @@ -336,9 +811,9 @@ fn main() { command, None, true, - |bytes| { - updates.push(bytes); - if bytes == 12 { + |bytes: ImageBytes| { + updates.push(bytes.bytes); + if bytes.bytes == 12 { std::fs::write(&acknowledgement, "received").unwrap(); } }, @@ -521,4 +996,258 @@ Executing external compose provider assert_eq!(reports[0].outcome, crate::telemetry::Outcome::Failure); assert_eq!(reports[0].bytes, None); } + #[test] + fn names_an_image_by_its_repository_without_registry_tag_or_digest() { + assert_eq!( + display_name("ghcr.io/copilotkit/openbot-agent-bot@sha256:0123"), + "agent-bot" + ); + assert_eq!( + display_name("ghcr.io/copilotkit/openbot-server@sha256:0123"), + "OpenBot server" + ); + assert_eq!(display_name("openbot-server:latest"), "OpenBot server"); + assert_eq!(display_name("openbot"), "OpenBot server"); + assert_eq!(display_name("pgvector/pgvector:pg17"), "pgvector"); + assert_eq!(display_name("busybox:1.37"), "busybox"); + assert_eq!( + display_name("ghcr.io/spiffe/spire-agent:1.15.1"), + "spire-agent" + ); + // A registry with a port is not a tag. + assert_eq!( + display_name("localhost:5000/openbot-supervisor"), + "supervisor" + ); + assert_eq!(display_name(""), ""); + } + + #[test] + fn an_image_size_is_known_once_every_unfinished_layer_has_one() { + let mut layers = DownloadBytes::default(); + let line = |text: &str| serde_json::from_str::(text).unwrap(); + layers.observe(line( + r#"{"id":"a","parent_id":"image","text":"Pulling fs layer"}"#, + )); + layers.observe(line( + r#"{"id":"b","parent_id":"image","text":"Pulling fs layer"}"#, + )); + layers.observe(line( + r#"{"id":"c","parent_id":"image","text":"Already exists"}"#, + )); + layers.observe(line( + r#"{"id":"a","parent_id":"image","text":"Downloading","current":10,"total":100}"#, + )); + // Layer b has not said how big it is yet. + assert_eq!( + layers.image_bytes(), + ImageBytes { + bytes: 10, + size: None + } + ); + layers.observe(line( + r#"{"id":"b","parent_id":"image","text":"Downloading","current":5,"total":50}"#, + )); + assert_eq!( + layers.image_bytes(), + ImageBytes { + bytes: 15, + size: Some(150) + } + ); + // A tiny layer that finishes without ever reporting a size counts as done, not unknown. + layers.observe(line( + r#"{"id":"d","parent_id":"image","text":"Pulling fs layer"}"#, + )); + layers.observe(line( + r#"{"id":"d","parent_id":"image","text":"Download complete"}"#, + )); + assert_eq!(layers.image_bytes().size, Some(150)); + // The image's own line is not a layer. + layers.observe(line(r#"{"id":"image","text":"Pulling"}"#)); + assert_eq!(layers.image_bytes().size, Some(150)); + } + + #[test] + fn streams_each_change_of_bytes_or_size_for_one_pull() { + let mut command = crate::quiet::command("sh"); + command.args([ + "-c", + r#"printf '%s\n' '{"id":"a","parent_id":"image","text":"Pulling fs layer"}' '{"id":"a","parent_id":"image","text":"Downloading","current":4,"total":40}' '{"id":"a","parent_id":"image","text":"Downloading","current":4,"total":40}' '{"id":"a","parent_id":"image","text":"Downloading","current":40,"total":40}'"#, + ]); + let mut updates = Vec::new(); + run_with_progress(command, None, true, |bytes| updates.push(bytes), |_| {}).unwrap(); + assert_eq!( + updates, + vec![ + ImageBytes { + bytes: 4, + size: Some(40) + }, + ImageBytes { + bytes: 40, + size: Some(40) + }, + ] + ); + } + + #[test] + fn progress_lists_the_running_pulls_with_their_own_bytes() { + let images = vec![ + "ghcr.io/copilotkit/openbot-server@sha256:1".to_string(), + "ghcr.io/copilotkit/openbot-agent-bot@sha256:2".to_string(), + "busybox:1.37".to_string(), + ]; + let arrived = AtomicUsize::new(0); + let reported = AtomicUsize::new(0); + let calls = Mutex::new(Vec::::new()); + pull_images_limited( + &images, + 2, + |index, _, on_bytes| { + if index < 2 { + on_bytes(ImageBytes { + bytes: (index as u64 + 1) * 100, + size: (index == 0).then_some(1000), + }); + arrived.fetch_add(1, SeqCst); + // Both running pulls have reported before either finishes. + wait_for(&arrived, 2); + reported.fetch_add(1, SeqCst); + wait_for(&reported, 2); + } + Ok(()) + }, + || Ok(()), + |downloads| calls.lock().unwrap().push(downloads.clone()), + ) + .unwrap(); + let calls = calls.lock().unwrap(); + let both = calls + .iter() + .find(|downloads| downloads.bytes == 300 && downloads.done == 0) + .expect("a report while both pulls ran"); + assert_eq!( + both.pulling, + vec![ + ImagePull { + name: "OpenBot server".into(), + bytes: 100, + size: Some(1000) + }, + ImagePull { + name: "agent-bot".into(), + bytes: 200, + size: None + }, + ] + ); + assert_eq!(both.total, 3); + let last = calls.last().unwrap(); + assert_eq!((last.done, last.total, last.bytes), (3, 3, 300)); + assert!(last.pulling.is_empty()); + // A running pull's own figure never goes down. + let server: Vec = calls + .iter() + .flat_map(|downloads| &downloads.pulling) + .filter(|pull| pull.name == "OpenBot server") + .map(|pull| pull.bytes) + .collect(); + assert!(server.windows(2).all(|w| w[0] <= w[1]), "{server:?}"); + } + + #[test] + fn keeps_reporting_while_a_pull_is_quiet() { + let images = names(1); + let calls = AtomicUsize::new(0); + pull_images_limited( + &images, + 1, + |_, _, _| { + std::thread::sleep(PROGRESS_TICK * 3); + Ok(()) + }, + || Ok(()), + |_| { + calls.fetch_add(1, SeqCst); + }, + ) + .unwrap(); + // Start, at least two ticks, and the finish: the speed can fall while nothing arrives. + assert!(calls.load(SeqCst) >= 4, "{}", calls.load(SeqCst)); + } + + #[test] + fn speed_is_averaged_over_the_recent_window_and_falls_to_zero_when_stalled() { + let start = Instant::now(); + let at = |ms: u64| start + Duration::from_millis(ms); + let mut speed = Speed::default(); + assert_eq!(speed.observe(at(0), 0), None); + // Too short a span to say anything yet. + assert_eq!(speed.observe(at(200), 2_000_000), None); + assert_eq!(speed.observe(at(1_000), 10_000_000), Some(10_000_000)); + assert_eq!(speed.observe(at(2_000), 30_000_000), Some(15_000_000)); + // Samples older than the window are dropped: 20 MB over the last 5 s. + assert_eq!(speed.observe(at(6_000), 30_000_000), Some(4_000_000)); + assert_eq!(speed.observe(at(7_500), 30_000_000), Some(0)); + } + + fn downloads(done: usize, bytes: u64) -> Downloads { + Downloads { + done, + total: 3, + bytes, + bytes_per_second: None, + pulling: Vec::new(), + } + } + + #[test] + fn pacer_sends_at_most_four_reports_a_second_but_never_drops_a_finish() { + let start = Instant::now(); + let at = |ms: u64| start + Duration::from_millis(ms); + let mut pacer = Pacer::default(); + assert!(pacer.offer(at(0), &downloads(0, 0)).is_some()); + assert!(pacer.offer(at(100), &downloads(0, 10)).is_none()); + assert!(pacer.offer(at(200), &downloads(0, 20)).is_none()); + let paced = pacer.offer(at(260), &downloads(0, 30)).unwrap(); + assert_eq!(paced.bytes, 30); + // A pull finishing is shown at once. + assert_eq!(pacer.offer(at(300), &downloads(1, 40)).unwrap().done, 1); + assert!(pacer.offer(at(400), &downloads(1, 50)).is_none()); + let speed = pacer.offer(at(1_300), &downloads(1, 1_300)).unwrap(); + assert_eq!(speed.bytes_per_second, Some(1_000)); + assert_eq!( + pacer.offer(at(1_310), &downloads(3, 1_300)).unwrap().done, + 3 + ); + } + + #[test] + fn downloads_serialize_for_the_window() { + let value = serde_json::to_value(Downloads { + done: 1, + total: 9, + bytes: 5, + bytes_per_second: Some(2), + pulling: vec![ImagePull { + name: "agent-bot".into(), + bytes: 5, + size: None, + }], + }) + .unwrap(); + assert_eq!( + value, + serde_json::json!({ + "done": 1, + "total": 9, + "bytes": 5, + "bytesPerSecond": 2, + "pulling": [{"name": "agent-bot", "bytes": 5}], + }) + ); + } } diff --git a/desktop/src-tauri/src/recorder_host.rs b/desktop/src-tauri/src/recorder_host.rs index 36b70676d9..0baade09f4 100644 --- a/desktop/src-tauri/src/recorder_host.rs +++ b/desktop/src-tauri/src/recorder_host.rs @@ -9,21 +9,20 @@ //! The host answers three messages, each within its own deadline ([`DEADLINES`]), which sits below the time the //! extension waits for the answer, so the extension always hears why instead of giving up. //! -//! `ping` says, cheaply, whether a connect would work now: an OpenBot is installed, its app answers on its port, and in -//! organization mode Desktop holds a sign-in for the organization. It reads two files and asks the app one question. It -//! does not prove which process answers, sends nothing secret and mints nothing. +//! `ping` says, cheaply, whether a connect would work now: an OpenBot is installed and its app answers on its port. It +//! reads two files and asks the app one question. It does not prove which process answers, sends nothing secret and +//! mints nothing. //! -//! `handoff` makes sure the deployment's own processes hold its ports, hands the organization sign-in over in -//! organization mode, asks that OpenBot for a one-time connect code and returns it with the OpenBot's address, so the -//! Recorder can connect without the person typing either. +//! `handoff` makes sure the deployment's own processes hold its ports, asks that OpenBot for a one-time connect code and +//! returns it with the OpenBot's address, so the Recorder can connect without the person typing either. //! //! `address` says which address this computer's OpenBot answers on, after the same ownership checks a handoff makes, and //! mints nothing. The Recorder takes a connect from a page with no Connect click only when the page's origin is exactly //! this address: any other program on this computer can serve a page on a loopback port, and only the host knows which //! one is OpenBot Desktop's. //! -//! Every reply carries `ok`. `ok: false` comes with `error`, one of the stable codes of [`Refusal`]. Neither a code nor -//! any cookie is ever written to a log. +//! Every reply carries `ok`. `ok: false` comes with `error`, one of the stable codes of [`Refusal`]. A code is never +//! written to a log. use crate::recorder_install::{allowed_origins, linux_config_dir, Os}; use reqwest::blocking::Client; use serde_json::{json, Value}; @@ -52,13 +51,8 @@ pub enum Refusal { /// `not-running`: this computer's OpenBot is not running, its app does not answer, or a process that is not this /// OpenBot's holds its port. NotRunning, - /// `not-signed-in`: organization mode, and Desktop holds no sign-in for the organization, or the organization or - /// the local OpenBot refused the one it holds. Signing in again in OpenBot Desktop fixes it. + /// `not-signed-in`: the local OpenBot refused to give Desktop a connect code. NotSignedIn, - /// `organization-unavailable`: organization mode, and the sign-in Desktop holds could not be handed over now: the - /// organization's OpenBot did not answer, or answered with an error, or the saved sign-in could not be read. Trying - /// again later may work. - OrganizationUnavailable, /// `handoff-failed`: the local OpenBot answered the request for a connect code without one. HandoffFailed, /// `timed-out`: the host did not finish within its deadline. @@ -76,7 +70,6 @@ impl Refusal { Refusal::NotInstalled => "not-installed", Refusal::NotRunning => "not-running", Refusal::NotSignedIn => "not-signed-in", - Refusal::OrganizationUnavailable => "organization-unavailable", Refusal::HandoffFailed => "handoff-failed", Refusal::TimedOut => "timed-out", Refusal::HostFailed => "host-failed", @@ -88,21 +81,14 @@ impl Refusal { /// One question main.rs answers about this computer's OpenBot. Asked from the host's worker thread. pub type Check = Box T + Send + Sync>; -/// The local organization session cookie for the app at the given address. Ok(None) in single-user mode. -pub type OrganizationCookie = Box Result, Refusal> + Send + Sync>; - /// What main.rs knows about this computer's OpenBot. pub struct HostEnvironment { /// A selected root exists. pub installed: Check, /// For a ping: the app answers on its port. Quick, and not proof of which process answered. pub app_answers: Check, - /// For a ping: Ok in single-user mode or when Desktop holds an organization sign-in, read without the network. - pub signed_in: Check>, /// For a handoff: the owned app's address, None when it is not running or another process holds its port. pub app_url: Check>, - /// For a handoff: the organization sign-in handed to the local OpenBot, as its session cookie. - pub organization_cookie: OrganizationCookie, } /// How long the host gives each message before it answers `timed-out`. @@ -249,7 +235,7 @@ fn ping(origin_allowed: bool, env: &HostEnvironment) -> Result<(), Refusal> { if !(env.app_answers)() { return Err(Refusal::NotRunning); } - (env.signed_in)() + Ok(()) } /// The address this computer's OpenBot answers on, once its own processes are proved to hold its ports (`app_url`). It @@ -276,24 +262,21 @@ fn handoff( return Err(Refusal::NotInstalled); } let app_url = (env.app_url)().ok_or(Refusal::NotRunning)?; - let cookie = (env.organization_cookie)(&app_url)?; - let mut request = client + let response = client .post(format!( "{}/api/recorder/connect-codes", app_url.trim_end_matches('/') )) - .json(&json!({ "via": "desktop" })); - if let Some(cookie) = cookie { - request = request.header("cookie", cookie); - } - let response = request.send().map_err(|error| { - eprintln!("[recorder-host] the local OpenBot did not answer the handoff"); - if error.is_timeout() { - Refusal::TimedOut - } else { - Refusal::NotRunning - } - })?; + .json(&json!({ "via": "desktop" })) + .send() + .map_err(|error| { + eprintln!("[recorder-host] the local OpenBot did not answer the handoff"); + if error.is_timeout() { + Refusal::TimedOut + } else { + Refusal::NotRunning + } + })?; let status = response.status(); if status != reqwest::StatusCode::CREATED { eprintln!("[recorder-host] the local OpenBot answered the handoff with {status}"); @@ -445,7 +428,6 @@ mod tests { use std::net::TcpListener; use std::path::Path; use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Mutex; use std::time::Instant; const ID: &str = "icpcnhgjghecmklnjbccdgdicklblcgd"; @@ -682,48 +664,31 @@ mod tests { assert_eq!(config["identifier"], DESKTOP); } - /// What the host asked of the environment: each deep check it ran, and the addresses it asked a cookie for. + /// What the host asked of the environment: each deep check it ran. #[derive(Default)] struct Asked { app_answers: AtomicUsize, - signed_in: AtomicUsize, app_url: AtomicUsize, - cookies: Mutex>, - } - - impl Asked { - fn cookies(&self) -> Vec { - self.cookies.lock().unwrap().clone() - } } /// A computer's OpenBot as a test describes it. struct Computer { installed: bool, app_url: Option, - signed_in: Result<(), Refusal>, - cookie: Result, Refusal>, } - /// Installed, running at `app_url`, single-user mode. + /// Installed, running at `app_url`. fn running(app_url: &str) -> Computer { Computer { installed: true, app_url: Some(app_url.to_string()), - signed_in: Ok(()), - cookie: Ok(None), } } fn environment(computer: Computer) -> (Arc, Arc) { let asked = Arc::new(Asked::default()); - let (a, b, c, d) = (asked.clone(), asked.clone(), asked.clone(), asked.clone()); - let Computer { - installed, - app_url, - signed_in, - cookie, - } = computer; + let (a, c) = (asked.clone(), asked.clone()); + let Computer { installed, app_url } = computer; let answers = app_url.is_some(); let environment = HostEnvironment { installed: Box::new(move || installed), @@ -731,18 +696,10 @@ mod tests { a.app_answers.fetch_add(1, Ordering::SeqCst); answers }), - signed_in: Box::new(move || { - b.signed_in.fetch_add(1, Ordering::SeqCst); - signed_in - }), app_url: Box::new(move || { c.app_url.fetch_add(1, Ordering::SeqCst); app_url.clone() }), - organization_cookie: Box::new(move |url| { - d.cookies.lock().unwrap().push(url.to_string()); - cookie.clone() - }), }; (Arc::new(environment), asked) } @@ -819,7 +776,6 @@ mod tests { Refusal::NotInstalled, Refusal::NotRunning, Refusal::NotSignedIn, - Refusal::OrganizationUnavailable, Refusal::HandoffFailed, Refusal::TimedOut, Refusal::HostFailed, @@ -835,7 +791,6 @@ mod tests { "not-installed", "not-running", "not-signed-in", - "organization-unavailable", "handoff-failed", "timed-out", "host-failed", @@ -854,10 +809,8 @@ mod tests { ); assert_untouched(&listener); assert_eq!(asked.app_answers.load(Ordering::SeqCst), 1); - assert_eq!(asked.signed_in.load(Ordering::SeqCst), 1); - // Neither the ownership check behind `app_url` nor the organization handover runs for a ping. + // The ownership check behind `app_url` does not run for a ping. assert_eq!(asked.app_url.load(Ordering::SeqCst), 0); - assert!(asked.cookies().is_empty()); } #[test] @@ -881,22 +834,6 @@ mod tests { }, "not-running", ), - ( - true, - Computer { - signed_in: Err(Refusal::NotSignedIn), - ..running(&url) - }, - "not-signed-in", - ), - ( - true, - Computer { - signed_in: Err(Refusal::OrganizationUnavailable), - ..running(&url) - }, - "organization-unavailable", - ), ]; for (origin_allowed, computer, code) in cases { let (environment, asked) = environment(computer); @@ -905,7 +842,6 @@ mod tests { unavailable(code) ); assert_eq!(asked.app_url.load(Ordering::SeqCst), 0, "{code}"); - assert!(asked.cookies().is_empty(), "{code}"); } assert_untouched(&listener); } @@ -928,29 +864,9 @@ mod tests { serde_json::from_str::(&body).unwrap(), json!({ "via": "desktop" }) ); - assert_eq!(asked.cookies(), vec![url]); assert_eq!(asked.app_url.load(Ordering::SeqCst), 1); } - #[test] - fn handoff_in_organization_mode_sends_the_local_session_cookie() { - let (url, listener) = fake_app(); - let app = answer_once(listener, reply("201 Created", MINTED)); - let (environment, asked) = environment(Computer { - cookie: Ok(Some("openbot.organization-session=v".into())), - ..running(&url) - }); - assert_eq!( - ask(json!({ "type": "handoff" }), true, &environment), - json!({ "ok": true, "openbotUrl": url, "code": "obc_fixture" }) - ); - let (head, _) = app.join().unwrap(); - assert!(head - .to_lowercase() - .contains("cookie: openbot.organization-session=v\r\n")); - assert_eq!(asked.cookies(), vec![url]); - } - #[test] fn handoff_is_not_running_when_nothing_listens() { let (url, listener) = fake_app(); @@ -1013,7 +929,7 @@ mod tests { #[test] fn handoff_without_a_running_app_is_not_running() { - let (environment, asked) = environment(Computer { + let (environment, _) = environment(Computer { app_url: None, ..running("http://127.0.0.1:9") }); @@ -1021,13 +937,12 @@ mod tests { ask(json!({ "type": "handoff" }), true, &environment), not("not-running") ); - assert!(asked.cookies().is_empty()); } #[test] fn handoff_without_an_install_is_not_installed() { let (url, listener) = fake_app(); - let (environment, asked) = environment(Computer { + let (environment, _) = environment(Computer { installed: false, ..running(&url) }); @@ -1036,7 +951,6 @@ mod tests { not("not-installed") ); assert_untouched(&listener); - assert!(asked.cookies().is_empty()); } #[test] @@ -1048,30 +962,9 @@ mod tests { not("origin-not-allowed") ); assert_untouched(&listener); - assert!(asked.cookies().is_empty()); assert_eq!(asked.app_url.load(Ordering::SeqCst), 0); } - #[test] - fn handoff_passes_on_why_the_organization_sign_in_could_not_be_handed_over() { - for (refusal, code) in [ - (Refusal::NotSignedIn, "not-signed-in"), - (Refusal::OrganizationUnavailable, "organization-unavailable"), - ] { - let (url, listener) = fake_app(); - let (environment, asked) = environment(Computer { - cookie: Err(refusal), - ..running(&url) - }); - assert_eq!( - ask(json!({ "type": "handoff" }), true, &environment), - not(code) - ); - assert_untouched(&listener); - assert_eq!(asked.cookies(), vec![url]); - } - } - #[test] fn address_answers_the_owned_app_url_and_mints_nothing() { let (url, listener) = fake_app(); @@ -1080,10 +973,9 @@ mod tests { ask(json!({ "type": "address" }), true, &environment), json!({ "ok": true, "openbotUrl": url }) ); - // The ownership checks behind `app_url` ran; nothing asked the app for a code or handed over a sign-in. + // The ownership checks behind `app_url` ran; nothing asked the app for a code. assert_eq!(asked.app_url.load(Ordering::SeqCst), 1); assert_untouched(&listener); - assert!(asked.cookies().is_empty()); } #[test] @@ -1117,7 +1009,6 @@ mod tests { not(code) ); assert_eq!(asked.app_url.load(Ordering::SeqCst), checks, "{code}"); - assert!(asked.cookies().is_empty(), "{code}"); } assert_untouched(&listener); } @@ -1130,7 +1021,6 @@ mod tests { assert_eq!(ask(message, true, &environment), not("unknown-message")); } assert_untouched(&listener); - assert!(asked.cookies().is_empty()); assert_eq!(asked.app_answers.load(Ordering::SeqCst), 0); } @@ -1193,12 +1083,10 @@ mod tests { std::thread::sleep(Duration::from_secs(3)); true }), - signed_in: Box::new(|| Ok(())), app_url: Box::new(|| { std::thread::sleep(Duration::from_secs(3)); None }), - organization_cookie: Box::new(|_| Ok(None)), }; let deadlines = Deadlines { ping: Duration::from_millis(100), @@ -1238,9 +1126,7 @@ mod tests { let broken = HostEnvironment { installed: Box::new(|| panic!("a broken check")), app_answers: Box::new(|| true), - signed_in: Box::new(|| Ok(())), app_url: Box::new(|| None), - organization_cookie: Box::new(|_| Ok(None)), }; let (status, replies) = serve_all( &[json!({ "type": "ping" }), json!({ "type": "handoff" })], diff --git a/desktop/src-tauri/src/recorder_install.rs b/desktop/src-tauri/src/recorder_install.rs index 429ab03f91..e283b28fbe 100644 --- a/desktop/src-tauri/src/recorder_install.rs +++ b/desktop/src-tauri/src/recorder_install.rs @@ -1015,6 +1015,119 @@ mod tests { .all(|c| c[7] == manifest.to_string_lossy().as_ref())); } + // The CLI's detached sign-in service runs the bundled node.exe after the app quits; an install + // or uninstall that does not stop it first cannot replace or delete that file. + #[test] + fn installing_and_uninstalling_on_windows_first_stop_only_this_installations_bundled_cli() { + let hooks = include_str!("../windows/hooks.nsh"); + let body = |hook: &str| { + hooks + .split(&format!("!macro {hook}\n")) + .nth(1) + .and_then(|rest| rest.split("!macroend").next()) + .unwrap_or_else(|| panic!("hooks.nsh has no {hook}")) + .to_string() + }; + for hook in ["NSIS_HOOK_PREINSTALL", "NSIS_HOOK_PREUNINSTALL"] { + assert!( + body(hook).contains("!insertmacro OPENBOT_STOP_BUNDLED_CLI"), + "{hook}" + ); + } + let stop = body("OPENBOT_STOP_BUNDLED_CLI"); + assert!( + stop.contains( + r#"SetEnvironmentVariable(t, t)i ("OPENBOT_CLI_BUNDLE", "$INSTDIR\cli-bundle")"# + ), + "{stop}" + ); + assert!(stop.contains("Stop-Process -Force")); + } + + /// The text of `hooks.nsh`'s macro `name`, between `!macro name` and `!macroend`. + fn nsis_macro(name: &str) -> String { + include_str!("../windows/hooks.nsh") + .split(&format!("!macro {name}\n")) + .nth(1) + .and_then(|rest| rest.split("!macroend").next()) + .unwrap_or_else(|| panic!("hooks.nsh has no {name}")) + .to_string() + } + + /// The PowerShell script the bundled CLI's stop runs, as NSIS writes it (`$$` for `$`). + fn bundled_cli_stop_script() -> String { + let stop = nsis_macro("OPENBOT_STOP_BUNDLED_CLI"); + stop.split("-Command \"") + .nth(1) + .and_then(|rest| rest.split("\"`").next()) + .unwrap_or_else(|| panic!("no PowerShell command in {stop}")) + .to_string() + } + + /// A person whose Windows profile folder has an apostrophe in it (`C:\Users\O'Brien`) has + /// OpenBot installed under it. Put inside the script, the folder ended the script's string + /// early, PowerShell refused the whole command, and the uninstaller failed on the locked + /// node.exe again. `[` and `]` are wildcards to `-like`. The folder reaches the script as data. + #[test] + fn the_installation_folder_reaches_the_bundled_cli_stop_as_data_not_as_script() { + let script = bundled_cli_stop_script(); + assert!(!script.contains("$INSTDIR"), "{script}"); + assert!(!script.contains("-like"), "{script}"); + assert!(script.contains("$$env:OPENBOT_CLI_BUNDLE"), "{script}"); + } + + /// The script itself, run by PowerShell against a folder with an apostrophe and brackets in + /// its name: what runs from that folder's cli-bundle stops, and what runs beside it does not. + #[cfg(windows)] + #[test] + fn the_bundled_cli_stop_ends_only_what_runs_from_this_installations_cli_bundle() { + let script = bundled_cli_stop_script().replace("$$", "$"); + let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!("hook-test-{}", std::process::id())) + .join("O'Brien [OpenBot]"); + let runtime = base.join("cli-bundle").join("runtime"); + std::fs::create_dir_all(&runtime).unwrap(); + let source = base.join("waiter.rs"); + std::fs::write( + &source, + "fn main() { std::thread::sleep(std::time::Duration::from_secs(120)); }", + ) + .unwrap(); + let inside = runtime.join("node.exe"); + let beside = base.join("node.exe"); + crate::test_support::compile_fixture(&source, &inside); + std::fs::copy(&inside, &beside).unwrap(); + let mut bundled = std::process::Command::new(&inside).spawn().unwrap(); + let mut other = std::process::Command::new(&beside).spawn().unwrap(); + let status = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + ]) + .arg(&script) + .env("OPENBOT_CLI_BUNDLE", base.join("cli-bundle")) + .status() + .unwrap(); + assert!(status.success(), "{script}"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + while bundled.try_wait().unwrap().is_none() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(100)); + } + let stopped = bundled.try_wait().unwrap().is_some(); + let spared = other.try_wait().unwrap().is_none(); + let _ = bundled.kill(); + let _ = other.kill(); + let _ = bundled.wait(); + let _ = other.wait(); + let _ = std::fs::remove_dir_all(base.parent().unwrap()); + assert!(stopped, "the process under cli-bundle is still running"); + assert!(spared, "a process outside cli-bundle was stopped"); + } + #[test] fn uninstalling_on_windows_removes_every_key_and_the_manifest_the_install_writes() { let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); diff --git a/desktop/src-tauri/src/self_hosted_intelligence.rs b/desktop/src-tauri/src/self_hosted_intelligence.rs deleted file mode 100644 index 80e44b69a6..0000000000 --- a/desktop/src-tauri/src/self_hosted_intelligence.rs +++ /dev/null @@ -1,477 +0,0 @@ -//! Own the temporary browser helper across sign-in and project selection. -use crate::{ - intelligence::{Project, ProvisionedConnection}, - problem::Problem, -}; -use serde::Deserialize; -use std::{ - io::{BufRead, BufReader, Write}, - path::{Path, PathBuf}, - process::{Child, Command, Stdio}, - sync::{ - atomic::{AtomicBool, Ordering}, - mpsc, Arc, Mutex, - }, - time::{Duration, Instant}, -}; - -const PATIENCE: Duration = Duration::from_secs(600); - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum Event { - Projects { - projects: Vec, - }, - Connection { - #[serde(rename = "apiKey")] - api_key: String, - #[serde(rename = "apiUrl")] - api_url: String, - #[serde(rename = "learningContainerId")] - learning_container_id: String, - }, - Error { - message: String, - }, -} - -pub struct SigningIn { - pub root: PathBuf, - api_url: String, - child: Mutex>, - events: Mutex>>, - projects: Mutex>, - cancelled: AtomicBool, - deadline: Instant, -} - -fn stopped() -> Problem { - Problem::plain("That Intelligence sign-in ended or was cancelled. Sign in again.") -} -fn protocol_error() -> Problem { - Problem::plain("Intelligence sign-in returned an unexpected response. Sign in again.") -} - -pub fn normalize_api_url(value: &str) -> Result { - let url = reqwest::Url::parse(value.trim()) - .map_err(|_| Problem::plain("Enter a valid Intelligence API URL."))?; - let loopback = url.host_str().is_some_and(|host| { - host == "localhost" - || host == "[::1]" - || host - .parse::() - .is_ok_and(|ip| ip.is_loopback()) - }); - if !(url.scheme() == "https" || (url.scheme() == "http" && loopback)) - || !url.username().is_empty() - || url.password().is_some() - || url.path() != "/" - || url.query().is_some() - || url.fragment().is_some() - { - return Err(Problem::plain( - "Use an HTTPS Intelligence API origin without a path, or HTTP on localhost.", - )); - } - Ok(url.as_str().trim_end_matches('/').to_string()) -} - -impl SigningIn { - pub fn begin(root: &Path, bun: &Path, api_url: &str) -> Result, Problem> { - let api_url = normalize_api_url(api_url)?; - let mut command = crate::quiet::command(bun); - command - .current_dir(root) - .arg(root.join("scripts/self-hosted-learning.ts")) - .args(["--desktop", "--api-url", &api_url]); - Self::spawn(command, root, api_url, PATIENCE) - } - - fn spawn( - mut command: Command, - root: &Path, - api_url: String, - patience: Duration, - ) -> Result, Problem> { - let mut child = command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() - .map_err(|_| Problem::plain("The Intelligence sign-in helper could not start. Finish installing OpenBot and try again."))?; - let stdout = child.stdout.take().expect("piped stdout"); - let (sender, events) = mpsc::channel(); - std::thread::spawn(move || { - for line in BufReader::new(stdout).lines() { - let event = line - .ok() - .and_then(|line| serde_json::from_str::(&line).ok()) - .ok_or_else(protocol_error); - let failed = event.is_err(); - if sender.send(event).is_err() || failed { - break; - } - } - }); - let signing = Arc::new(Self { - root: root.to_path_buf(), - api_url, - child: Mutex::new(Some(child)), - events: Mutex::new(events), - projects: Mutex::new(Vec::new()), - cancelled: AtomicBool::new(false), - deadline: Instant::now() + patience, - }); - let weak = Arc::downgrade(&signing); - std::thread::spawn(move || loop { - let Some(signing) = weak.upgrade() else { break }; - if signing.child.lock().unwrap().is_none() { - break; - } - if Instant::now() >= signing.deadline { - signing.cancel(); - break; - } - drop(signing); - std::thread::sleep(Duration::from_millis(100)); - }); - Ok(signing) - } - - fn next(&self) -> Result { - loop { - if self.cancelled.load(Ordering::SeqCst) { - return Err(stopped()); - } - if Instant::now() >= self.deadline { - return Err(Problem::plain( - "Intelligence sign-in timed out. Sign in again.", - )); - } - match self - .events - .lock() - .unwrap() - .recv_timeout(Duration::from_millis(100)) - { - Ok(Ok(Event::Error { message })) => return Err(Problem::plain(message)), - Ok(event) => return event, - Err(mpsc::RecvTimeoutError::Timeout) => continue, - Err(mpsc::RecvTimeoutError::Disconnected) => return Err(stopped()), - } - } - } - - pub fn projects(&self) -> Result, Problem> { - let Event::Projects { projects } = self.next()? else { - return Err(protocol_error()); - }; - if projects - .iter() - .any(|p| p.id.is_empty() || p.name.is_empty()) - { - return Err(protocol_error()); - } - *self.projects.lock().unwrap() = projects.iter().map(|p| p.id.clone()).collect(); - Ok(projects) - } - - pub fn connect(&self, project: &str) -> Result { - if !self.projects.lock().unwrap().iter().any(|id| id == project) { - return Err(Problem::plain( - "Choose a project from this Intelligence sign-in.", - )); - } - { - let mut slot = self.child.lock().unwrap(); - let stdin = slot - .as_mut() - .and_then(|child| child.stdin.as_mut()) - .ok_or_else(stopped)?; - writeln!(stdin, "{}", serde_json::json!({"projectId": project})) - .and_then(|_| stdin.flush()) - .map_err(|_| stopped())?; - } - let Event::Connection { - api_key, - api_url, - learning_container_id, - } = self.next()? - else { - return Err(protocol_error()); - }; - if api_key.trim().is_empty() - || normalize_api_url(&api_url)? != self.api_url - || learning_container_id != "openbot" - { - return Err(protocol_error()); - } - // The helper closes its browser before exiting. Do not publish a connection before that. - loop { - if self.cancelled.load(Ordering::SeqCst) || Instant::now() >= self.deadline { - return Err(stopped()); - } - let mut slot = self.child.lock().unwrap(); - let child = slot.as_mut().ok_or_else(stopped)?; - if let Some(status) = child.try_wait().map_err(|_| stopped())? { - slot.take(); - if !status.success() { - return Err(stopped()); - } - break; - } - drop(slot); - std::thread::sleep(Duration::from_millis(25)); - } - Ok(ProvisionedConnection { - api_key, - api_url: self.api_url.clone(), - learning_container_id, - }) - } - - /// The stdin cancellation protocol also works on Windows, where killing a child cannot run - /// its signal handlers. Closing stdin is a second request to retire the owned browser. - pub fn cancel(&self) { - self.cancelled.store(true, Ordering::SeqCst); - let Some(mut child) = self.child.lock().unwrap().take() else { - return; - }; - if let Some(mut stdin) = child.stdin.take() { - let _ = writeln!(stdin, "{}", serde_json::json!({"type": "cancel"})); - let _ = stdin.flush(); - } - let deadline = Instant::now() + Duration::from_secs(5); - loop { - match child.try_wait() { - Ok(Some(_)) => return, - _ if Instant::now() >= deadline => break, - _ => std::thread::sleep(Duration::from_millis(25)), - } - } - let _ = child.kill(); - let _ = child.wait(); - } -} -impl Drop for SigningIn { - fn drop(&mut self) { - self.cancel(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::OnceLock; - - fn fixture(mode: &str, patience: Duration) -> Arc { - static BINARY: OnceLock = OnceLock::new(); - let binary = BINARY.get_or_init(|| { - let root = crate::test_support::temp_root("self-hosted-sign-in"); - std::fs::create_dir_all(&root).unwrap(); - let source = root.join("helper.rs"); - let binary = root.join(format!("helper{}", std::env::consts::EXE_SUFFIX)); - std::fs::write(&source, r##" - use std::io::{self, BufRead, Write}; - fn main() { - let mode = std::env::args().nth(1).unwrap(); - if mode == "malformed" { println!("secret-invalid-json"); return; } - if mode == "error" { println!("{}", r#"{"type":"error","message":"Sign-in was refused."}"#); return; } - if mode != "pending" { println!("{}", r#"{"type":"projects","projects":[{"id":"7","name":"Team"}]}"#); io::stdout().flush().unwrap(); } - for line in io::stdin().lock().lines() { - let line = line.unwrap(); - if line.contains("cancel") { return; } - if mode == "mismatch" { println!("{}", r#"{"type":"connection","apiKey":"test-key","apiUrl":"https://other.test","learningContainerId":"openbot"}"#); } - else { println!("{}", r#"{"type":"connection","apiKey":"test-key","apiUrl":"https://intelligence.test/","learningContainerId":"openbot"}"#); } - return; - } - } - "##).unwrap(); - crate::test_support::compile_fixture(&source, &binary); - binary - }); - let mut command = Command::new(binary); - command.arg(mode); - SigningIn::spawn( - command, - Path::new("installation"), - "https://intelligence.test".into(), - patience, - ) - .unwrap() - } - - #[test] - fn helper_project_selection_produces_matching_connection_after_exit() { - let signing = fixture("success", PATIENCE); - assert_eq!( - signing.projects().unwrap(), - vec![Project { - id: "7".into(), - name: "Team".into() - }] - ); - assert!(signing.connect("not-listed").is_err()); - let proof = signing.connect("7").unwrap(); - assert_eq!(proof.api_key, "test-key"); - assert_eq!(proof.api_url, "https://intelligence.test"); - assert_eq!(proof.learning_container_id, "openbot"); - assert!(signing.child.lock().unwrap().is_none()); - } - #[test] - fn mismatched_connection_and_malformed_output_are_not_published() { - let signing = fixture("mismatch", PATIENCE); - signing.projects().unwrap(); - assert!(signing.connect("7").is_err()); - let malformed = fixture("malformed", PATIENCE); - let problem = malformed.projects().unwrap_err(); - assert!(!problem.said.contains("secret")); - assert!(problem.detail.is_none()); - assert_eq!( - fixture("error", PATIENCE).projects().unwrap_err().said, - "Sign-in was refused." - ); - } - #[test] - fn cancellation_closes_helper_during_login_and_project_selection() { - for mode in ["pending", "success"] { - let signing = fixture(mode, PATIENCE); - if mode == "success" { - signing.projects().unwrap(); - } - signing.cancel(); - assert!(signing.child.lock().unwrap().is_none()); - assert!(signing.projects().is_err()); - } - } - #[test] - fn abandoned_project_picker_times_out_and_reaps_helper() { - let signing = fixture("success", Duration::from_secs(2)); - signing.projects().unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while !signing.cancelled.load(Ordering::SeqCst) && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(25)); - } - assert!(signing.cancelled.load(Ordering::SeqCst)); - assert!(signing.connect("7").is_err()); - } - #[test] - fn api_addresses_require_secure_remote_origins() { - assert_eq!( - normalize_api_url("https://intelligence.test/").unwrap(), - "https://intelligence.test" - ); - for url in [ - "http://localhost:3000/", - "http://127.0.0.1:3000", - "http://[::1]:3000", - ] { - assert!(normalize_api_url(url).is_ok()); - } - for url in [ - "http://remote.test", - "https://intelligence.test/base/", - "https://user:password@remote.test", - "https://remote.test?key=secret", - "file:///tmp/test", - ] { - assert!(normalize_api_url(url).is_err()); - } - } - /// Run explicitly against an isolated Intelligence project; the operator completes SSO in - /// the helper's temporary browser. Never print the runtime credential. - #[test] - #[ignore = "requires an isolated Intelligence server and interactive browser SSO"] - fn live_self_hosted_browser_connection() { - let root = - PathBuf::from(std::env::var("OPENBOT_LIVE_SELF_HOSTED_ROOT").expect("test root")); - let bun = PathBuf::from(std::env::var("OPENBOT_LIVE_SELF_HOSTED_BUN").expect("owned Bun")); - let api = std::env::var("OPENBOT_LIVE_SELF_HOSTED_API_URL").expect("test API"); - let project = std::env::var("OPENBOT_LIVE_SELF_HOSTED_PROJECT_ID").expect("test project"); - let signing = SigningIn::begin(&root, &bun, &api).unwrap(); - let projects = signing.projects().unwrap(); - assert!(projects.iter().any(|candidate| candidate.id == project)); - println!("self-hosted project discovered: {project}"); - let connection = signing.connect(&project).unwrap(); - assert!(!connection.api_key.is_empty()); - assert_eq!(connection.api_url, normalize_api_url(&api).unwrap()); - assert_eq!(connection.learning_container_id, "openbot"); - println!("self-hosted connection provisioned; browser helper exited"); - } - /// Observe only descendants of this helper, never the user's browsers or another sign-in. - #[cfg(target_os = "macos")] - #[test] - #[ignore = "launches a real temporary Chrome/Edge browser against an isolated API"] - fn live_self_hosted_browser_cancellation() { - fn processes() -> Vec<(u32, u32, String)> { - let output = Command::new("/bin/ps") - .args(["-axo", "pid=,ppid=,comm="]) - .output() - .expect("inspect child process ownership"); - assert!(output.status.success()); - String::from_utf8(output.stdout) - .unwrap() - .lines() - .filter_map(|line| { - let mut columns = line.split_whitespace(); - let pid = columns.next()?.parse().ok()?; - let parent = columns.next()?.parse().ok()?; - Some((pid, parent, columns.collect::>().join(" "))) - }) - .collect() - } - fn descendants(processes: &[(u32, u32, String)], parent: u32) -> Vec { - let mut owned = vec![parent]; - loop { - let previous = owned.len(); - for (pid, parent, _) in processes { - if owned.contains(parent) && !owned.contains(pid) { - owned.push(*pid); - } - } - if owned.len() == previous { - return owned; - } - } - } - let root = - PathBuf::from(std::env::var("OPENBOT_LIVE_SELF_HOSTED_ROOT").expect("test root")); - let bun = PathBuf::from(std::env::var("OPENBOT_LIVE_SELF_HOSTED_BUN").expect("owned Bun")); - let api = std::env::var("OPENBOT_LIVE_SELF_HOSTED_API_URL").expect("test API"); - let signing = SigningIn::begin(&root, &bun, &api).unwrap(); - let helper = signing.child.lock().unwrap().as_ref().unwrap().id(); - let deadline = Instant::now() + Duration::from_secs(30); - let observed = loop { - let snapshot = processes(); - let owned = descendants(&snapshot, helper); - let browser = snapshot.iter().any(|(pid, _, name)| { - owned.contains(pid) - && (name.ends_with("Google Chrome") || name.ends_with("Microsoft Edge")) - }); - let renderer = snapshot - .iter() - .any(|(pid, _, name)| owned.contains(pid) && name.contains("Renderer")); - if browser && renderer { - break owned; - } - assert!( - Instant::now() < deadline, - "owned browser and renderer did not start" - ); - std::thread::sleep(Duration::from_millis(100)); - }; - println!("owned browser and renderer started; cancelling native sign-in"); - signing.cancel(); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let remaining = processes().iter().any(|(pid, _, _)| observed.contains(pid)); - if !remaining { - break; - } - assert!( - Instant::now() < deadline, - "owned helper or browser descendant survived cancellation" - ); - std::thread::sleep(Duration::from_millis(100)); - } - assert!(signing.projects().is_err()); - println!("native cancellation reaped helper and all observed browser descendants"); - } -} diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index e58acbff55..9f69d481bd 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. @@ -38,7 +53,7 @@ gave them two containers that died on startup and two red lines on the setup scr never chose. Started when a key exists and left alone when it does not. The Bot the person actually picked speaks -its plan and answers either way, which is what the last screen proves. +its plan and answers either way. */ const AGENT_BOT: &str = "agent-bot"; const AGENT_LANGGRAPH: &str = "agent-langgraph"; @@ -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 { @@ -197,9 +220,20 @@ what it gave is one prompt and one failure point, where reading it per command i */ pub type Secrets = std::collections::BTreeMap; +/// Every Compose command for the installation, run as its own project (see `project`). +/// +/// Named through the environment, which Compose ranks above `.env` and the folder's name and +/// below only `-p`, so every caller's arguments stay exactly as they were. A project that could not +/// be decided, because the engine could not be asked, is `DESKTOP_PROJECT`: never the folder's +/// name, which would be adopting whatever else runs under it. fn compose_command(engine: &Address, root: &Path, secrets: &Secrets) -> Command { + let project = project(engine, root).unwrap_or_else(|_| DESKTOP_PROJECT.to_owned()); let mut command = engine.command(); command.current_dir(root).args(["compose"]).envs(secrets); + command.env("COMPOSE_PROJECT_NAME", &project); + if let Some(namespace) = computer_namespace_for(&project) { + command.env("COMPUTER_NAMESPACE", namespace); + } command } @@ -1273,9 +1307,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}"))?; @@ -1707,6 +1752,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)] @@ -3179,7 +3264,7 @@ The tail of one service's log. For the case where the wire says nothing. A framework that catches its own exception and ends the stream leaves the cause here and nowhere else, so this is not a debugging convenience: without it -the developer half of that failure would be empty. See `ask::why_nothing_came_back`. +the developer half of that failure would be empty. An engine that cannot be asked returns nothing rather than failing. This is only ever called to explain a failure that has already happened, and a second failure on top of it helps nobody. @@ -3314,6 +3399,392 @@ 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 gave an installation made by an earlier app, which ran Compose with no project. + +Those apps ran Compose from the deployment folder with no `-p` and the file names no `name:`, so +the project was 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. `~/OpenBot` is `openbot`. + +ONLY A LEGACY NAME NOW, NEVER ONE TO ADOPT. A developer who runs OpenBot from a source checkout +called `openbot` gets Compose project `openbot` too, and on such a machine an installation that +named itself after its folder took the developer's containers and data for its own previous +installation and refused to start over them. See `project` for the name an installation runs as. +*/ +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() +} + +/// Written beside the other `.openbot-*` records: the Compose project this installation runs as. +pub const PROJECT_FILE: &str = ".openbot-project.json"; + +/// The Compose project every installation this app makes runs as. +/// +/// Not the folder's name, because a folder name is not ours: a source checkout called `openbot` +/// runs as `openbot` too, and Compose treats every container and volume labelled with a project as +/// that project's whoever made it. Fixed rather than random so a person reading `docker ps` can +/// tell which containers are the app's. +pub const DESKTOP_PROJECT: &str = "openbot-desktop"; + +#[derive(Serialize, Deserialize)] +struct SavedProject { + project: String, +} + +/// What Compose accepts as a project name: lower-case letters, digits, `_` and `-`, starting with a +/// letter or digit. A record holding anything else is damaged and reads as absent. +fn compose_project_name(name: &str) -> bool { + name.as_bytes() + .first() + .is_some_and(|first| first.is_ascii_lowercase() || first.is_ascii_digit()) + && name.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' + }) +} + +/// The project this installation recorded, or `None` when it has recorded none yet. +pub fn saved_project(root: &Path) -> Option { + let record: SavedProject = + serde_json::from_slice(&std::fs::read(root.join(PROJECT_FILE)).ok()?).ok()?; + compose_project_name(&record.project).then_some(record.project) +} + +/** +The Compose project this installation runs as, decided once and then read from its record. + +Every Compose command for the installation is given it (see `compose_command`), so Install, Start, +Stop, the cleanup at Quit, status and Start fresh all name the same project, and none of them can +name one that is not the installation's own. + +A new installation runs as `DESKTOP_PROJECT`. One made by an earlier app ran as its folder's name +(`project_name`), and keeps it ONLY when the engine holds containers of that project that Compose +ran from this very folder: Compose labels each container with the folder it ran from, which is +the one thing a folder-named project cannot fake. Any other answer is `DESKTOP_PROJECT`. A tester's +`~/OpenBot` found a developer's checkout called `openbot` running as `openbot`, adopted its +containers and its database as a previous installation's, and stopped on a key that was never +its own; this is what keeps that from happening again. + +AN UPGRADE KEEPS ITS DATA. Stop and Quit take an earlier app's containers down, so most +installations reach this app with none left, only their volumes. Volumes do not carry the folder +Compose ran from, so the folder has to prove it instead: an earlier app finished installing here +(`preparation::installed_before`) and the legacy project's database volume is still on the +engine. Such an installation keeps its legacy project, and with it its database, its Bots' +computers and their volumes, provided no other folder is running that project right now. + +When another folder is running it (a developer's checkout called `openbot`), whose data the volume +holds cannot be told, so nothing is decided and nothing is recorded: Install stops and says so, +and runs again once that other OpenBot is stopped. Old volumes are never deleted or renamed here. +*/ +pub fn project(engine: &Address, root: &Path) -> Result { + project_choosing(engine, root, &project_name(root)) +} + +/// `project`, with the legacy name to consider given rather than derived from the folder. +pub fn project_choosing(engine: &Address, root: &Path, legacy: &str) -> Result { + if let Some(saved) = saved_project(root) { + return Ok(saved); + } + // No folder, no installation: nothing to decide for, and nowhere to record it. + if !root.is_dir() { + return Ok(DESKTOP_PROJECT.to_owned()); + } + let chosen = if legacy != DESKTOP_PROJECT && compose_project_name(legacy) { + let running = legacy_containers(engine, root, legacy)?; + if running.here { + legacy.to_owned() + } else if crate::preparation::installed_before(root) + && legacy_database(engine, root, legacy)? + { + if let Some(folder) = running.elsewhere.first() { + return Err(legacy_in_use_problem(legacy, folder)); + } + legacy.to_owned() + } else { + DESKTOP_PROJECT.to_owned() + } + } else { + DESKTOP_PROJECT.to_owned() + }; + crate::preparation::write_checkpoint( + root, + PROJECT_FILE, + &SavedProject { + project: chosen.clone(), + }, + ) + .map_err(|error| { + Problem::with( + "OpenBot could not record which containers belong to this installation.", + error, + ) + })?; + Ok(chosen) +} + +fn project_choice_problem(detail: impl Into) -> Problem { + Problem::with( + "OpenBot could not tell which containers belong to this installation. Nothing was changed. Check the selected container engine, then try again.", + detail, + ) +} + +/// The earlier installation's data is there, but another folder runs its Compose project now. +fn legacy_in_use_problem(legacy: &str, folder: &Path) -> Problem { + Problem::with( + format!( + "OpenBot found the data of your earlier installation, but another copy of OpenBot on this computer is using it right now, from {}. Stop that copy, then try again. Nothing was changed or deleted.", + folder.display() + ), + format!( + "Compose project `{legacy}` and its volume `{legacy}_postgres-data` are in use by containers that ran from {}.", + folder.display() + ), + ) +} + +/// Which folders the containers of Compose project `legacy` ran from: this one, or others. +#[derive(Default)] +struct LegacyContainers { + here: bool, + elsewhere: Vec, +} + +/// Whether the engine still holds the database volume Compose made for project `legacy`: named +/// `_postgres-data` and labelled as that project's `postgres-data`. Only reads. +fn legacy_database(engine: &Address, root: &Path, legacy: &str) -> Result { + let volume = format!("{legacy}_postgres-data"); + let listed = volume_inventory(engine, root) + .map_err(|problem| project_choice_problem(problem.detail.unwrap_or_default()))?; + if !listed.contains(&volume) { + return Ok(false); + } + let inspect = engine + .command() + .current_dir(root) + .args(["volume", "inspect", &volume]) + .output() + .map_err(|error| { + project_choice_problem(format!("Could not inspect volume {volume}: {error}")) + })?; + // Gone since it was listed. + if !inspect.status.success() { + return Ok(false); + } + let inspected: serde_json::Value = serde_json::from_slice(&inspect.stdout).map_err(|_| { + project_choice_problem(format!( + "Inspection of volume {volume} did not return valid JSON." + )) + })?; + Ok(inspected.as_array().into_iter().flatten().any(|found| { + let labels = &found["Labels"]; + labels["com.docker.compose.project"].as_str() == Some(legacy) + && labels["com.docker.compose.volume"].as_str() == Some("postgres-data") + })) +} + +/// Which folders ran the containers of Compose project `legacy`. Only reads: lists and inspects, +/// and never stops, removes or takes down anything, whoever's it is. +fn legacy_containers( + engine: &Address, + root: &Path, + legacy: &str, +) -> Result { + let listed = engine + .command() + .current_dir(root) + .args(["ps", "--all", "--quiet", "--no-trunc", "--filter"]) + .arg(format!("label=com.docker.compose.project={legacy}")) + .output() + .map_err(|error| project_choice_problem(format!("Could not list containers: {error}")))?; + if !listed.status.success() { + return Err(project_choice_problem(format!( + "Container listing exited with {}.", + listed.status + ))); + } + let here = canonical(root); + let mut found = LegacyContainers::default(); + for id in String::from_utf8_lossy(&listed.stdout).split_whitespace() { + let inspect = engine + .command() + .current_dir(root) + .args(["container", "inspect", id]) + .output() + .map_err(|error| { + project_choice_problem(format!("Could not inspect container {id}: {error}")) + })?; + // Gone since it was listed: nothing left of it to prove anything. + if !inspect.status.success() { + continue; + } + let inspected: serde_json::Value = + serde_json::from_slice(&inspect.stdout).map_err(|_| { + project_choice_problem(format!( + "Inspection of container {id} did not return valid JSON." + )) + })?; + for container in inspected.as_array().into_iter().flatten() { + let labels = &container["Config"]["Labels"]; + if labels["com.docker.compose.project"].as_str() != Some(legacy) { + continue; + } + let Some(folder) = labels["com.docker.compose.project.working_dir"].as_str() else { + continue; + }; + let folder = canonical(Path::new(folder)); + if folder == here { + found.here = true; + } else if !found.elsewhere.contains(&folder) { + found.elsewhere.push(folder); + } + } + } + Ok(found) +} + +/// The folder as the file system resolves it, so `/var` and `/private/var`, or `..` in a label, +/// compare equal. A folder that no longer exists compares as written. +fn canonical(folder: &Path) -> PathBuf { + std::fs::canonicalize(folder).unwrap_or_else(|_| folder.to_path_buf()) +} + +/// The namespace the supervisor gives this installation's Bots' computers and volumes, when the +/// installation has to say: `None` leaves it what earlier apps always ran with. +/// +/// `DESKTOP_PROJECT`'s own, so a new installation's `-computer-…`, `-profile-…` and +/// `-workspace-…` cannot be a source checkout's either. A legacy installation keeps the default its +/// Bots' computers and volumes were made under, or it would lose them. +pub fn computer_namespace_for(project: &str) -> Option<&str> { + (project == DESKTOP_PROJECT).then_some(DESKTOP_PROJECT) +} + +/** +The running containers that are this installation's, by name, for the Status listing. Only reads. + +By the Compose project label, which Compose puts on its own containers and the supervisor on the +Bots' computers, and not by name: an installation running as `openbot` would otherwise claim every +`openbot-…` container on the engine, another project's `openbot-kite-demo-…` among them. +*/ +pub fn running_containers_of(engine: &Address, root: &Path) -> Vec { + let project = recorded_project(root); + let listed = engine + .command() + .current_dir(root) + .args(["ps", "--all", "--quiet", "--no-trunc", "--filter"]) + .arg(format!("label=com.docker.compose.project={project}")) + .output(); + let Ok(listed) = listed.map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) + else { + return Vec::new(); + }; + let mut names = Vec::new(); + for id in listed.split_whitespace() { + let Ok(inspect) = engine + .command() + .current_dir(root) + .args(["container", "inspect", id]) + .output() + else { + continue; + }; + let inspected: serde_json::Value = + serde_json::from_slice(&inspect.stdout).unwrap_or_default(); + for container in inspected.as_array().into_iter().flatten() { + let name = container["Name"] + .as_str() + .unwrap_or_default() + .trim_start_matches('/'); + if container["State"]["Running"].as_bool() == Some(true) + && container["Config"]["Labels"]["com.docker.compose.project"].as_str() + == Some(project.as_str()) + { + names.push(name.to_owned()); + } + } + } + names +} + +/// For reading only, where no engine is at hand to decide with: the recorded project, else the +/// name an earlier app's installation in this folder ran as. Never used to run Compose. +pub fn recorded_project(root: &Path) -> String { + saved_project(root).unwrap_or_else(|| project_name(root)) +} + /** The published ports in a `compose ps` listing. @@ -3400,21 +3871,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, @@ -3457,33 +3918,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(()); } @@ -3492,56 +3948,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. @@ -4096,29 +4507,6 @@ mod tests { } 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([ @@ -4214,6 +4602,7 @@ mod tests { .bin .join(format!("{}-deployment", address.engine.binary())); std::fs::create_dir(&root).unwrap(); + project_decided(&root); let record = root.join("commands.log"); std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); std::fs::write( @@ -6438,6 +6827,11 @@ use std::io::Write; /// `.fixture-containers`: one container per line, `idnamestatusk=v,k=vvolume,volume`. struct FixtureContainer { id: String, name: String, status: String, labels: Vec<(String, String)>, mounts: Vec } +/// A JSON string, so a Windows path in a label stays valid JSON. +fn serde_json_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + fn fixture_containers() -> Vec { let Ok(listing) = std::fs::read_to_string(".fixture-containers") else { return Vec::new() }; listing.lines().filter(|line| !line.is_empty()).map(|line| { @@ -6518,7 +6912,7 @@ fn main() { }; // Docker reports names with a leading slash; Podman does not. let slash = if args.first().map(String::as_str) == Some("--connection") { "" } else { "/" }; - let labels: Vec = container.labels.iter().map(|(k, v)| format!("\"{k}\":\"{v}\"")).collect(); + let labels: Vec = container.labels.iter().map(|(k, v)| format!("\"{k}\":{}", serde_json_string(v))).collect(); let mounts: Vec = container.mounts.iter().map(|m| format!("{{\"Type\":\"volume\",\"Name\":\"{m}\"}}")).collect(); print!( "[{{\"Id\":\"{}\",\"Name\":\"{slash}{}\",\"State\":{{\"Status\":\"{}\",\"Running\":{}}},\"Config\":{{\"Labels\":{{{}}}}},\"Mounts\":[{}]}}]", @@ -6630,7 +7024,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(); @@ -8517,6 +8912,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. @@ -8872,7 +9290,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(); @@ -8946,11 +9371,25 @@ fn main() { std::fs::create_dir(&root).unwrap(); std::fs::write(root.join("docker-compose.yml"), "services: {}\n").unwrap(); std::fs::write(root.join(".fixture-config"), config).unwrap(); + project_decided(&root); let record = path.bin.join(format!("{label}.log")); std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); (root, record) } + /// An installation whose Compose project is already recorded, so the command traces below are + /// only the commands under test. Choosing one is `project`'s, tested on its own. + fn project_decided(root: &Path) { + crate::preparation::write_checkpoint( + root, + PROJECT_FILE, + &SavedProject { + project: DESKTOP_PROJECT.into(), + }, + ) + .unwrap(); + } + fn resolved_namespace_fixture(namespace: &str) -> String { serde_json::json!({"services":{"supervisor":{"environment":{"COMPUTER_NAMESPACE":namespace}}}}).to_string() } @@ -9026,7 +9465,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 @@ -9105,7 +9546,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}" ); @@ -9323,7 +9764,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 { @@ -9361,31 +9802,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)); @@ -9401,26 +9841,375 @@ fn main() { error.contains(&format!("the {service} is not answering on port {port}")), "{error}" ); + } + } + + #[test] + 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 = 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" + ); + } + + /// The folder-derived name is now only the name an earlier app's installation ran as, which + /// is why it is still worked out exactly as Compose would: a legacy installation is recognised + /// by it. It is never the name a new installation runs as. + #[test] + fn legacy_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" + ); + } + + /// A folder named the way the default one is, so its legacy (folder-derived) project is + /// `openbot`: the name a developer's source checkout called `openbot` also runs as. + fn project_choice_root(path: &PathFixture, label: &str) -> (PathBuf, PathBuf) { + let root = path.bin.join(label).join("OpenBot"); + std::fs::create_dir_all(&root).unwrap(); + let record = path.bin.join(format!("{label}.log")); + std::fs::write(&record, "").unwrap(); + std::env::set_var("OPENBOT_TEST_ENGINE_RECORD", &record); + (root, record) + } + + /// One `.fixture-containers` row for a Compose container of `project` run from `working_dir`. + fn compose_container_row(id: &str, service: &str, project: &str, working_dir: &Path) -> String { + format!( + "{id}\t{project}-{service}-1\trunning\tcom.docker.compose.project={project},com.docker.compose.project.working_dir={}\t\n", + working_dir.display() + ) + } + + /// The project and computer namespace every Compose command for `root` is given. + fn compose_names(engine: &Address, root: &Path) -> (Option, Option) { + let command = compose_command(engine, root, &Secrets::new()); + let named = |wanted: &str| { + command + .get_envs() + .find(|(key, _)| *key == wanted) + .and_then(|(_, value)| value) + .map(|value| value.to_string_lossy().into_owned()) + }; + (named("COMPOSE_PROJECT_NAME"), named("COMPUTER_NAMESPACE")) + } + + #[test] + fn a_new_folder_records_and_runs_as_its_own_project() { + if crate::test_support::isolated_process( + "stack::tests::a_new_folder_records_and_runs_as_its_own_project", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + let docker = Address::new(crate::engine::Engine::Docker, None); + let (root, _) = project_choice_root(&path, "fresh"); + assert_eq!(saved_project(&root), None, "nothing recorded yet"); + assert_eq!(project(&docker, &root).unwrap(), DESKTOP_PROJECT); + assert_eq!(saved_project(&root).as_deref(), Some(DESKTOP_PROJECT)); + assert!(root.join(PROJECT_FILE).is_file()); + // Its Bots' computers and volumes are named under it too, not under `openbot`. + assert_eq!( + compose_names(&docker, &root), + ( + Some(DESKTOP_PROJECT.to_owned()), + Some(DESKTOP_PROJECT.to_owned()) + ) + ); + } + + #[test] + fn a_recorded_project_is_reused_without_asking_the_engine() { + if crate::test_support::isolated_process( + "stack::tests::a_recorded_project_is_reused_without_asking_the_engine", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + let docker = Address::new(crate::engine::Engine::Docker, None); + for (label, recorded) in [ + ("kept-legacy", "openbot"), + ("kept-desktop", DESKTOP_PROJECT), + ] { + let (root, record) = project_choice_root(&path, label); + crate::preparation::write_checkpoint( + &root, + PROJECT_FILE, + &serde_json::json!({ "project": recorded }), + ) + .unwrap(); + // What the engine holds says the opposite of the record, and is never consulted. + let elsewhere = path.bin.join(label).join("elsewhere"); + let working_dir = if recorded == DESKTOP_PROJECT { + &root + } else { + &elsewhere + }; + std::fs::write( + root.join(".fixture-containers"), + compose_container_row("c1", "postgres", "openbot", working_dir), + ) + .unwrap(); + assert_eq!(project(&docker, &root).unwrap(), recorded, "{label}"); + assert_eq!( + compose_names(&docker, &root).0.as_deref(), + Some(recorded), + "{label}" + ); + assert_eq!( + std::fs::read_to_string(&record).unwrap(), + "", + "{label}: a recorded project is decided already" + ); + } + } + + #[test] + fn a_legacy_folder_whose_own_containers_run_as_openbot_keeps_openbot() { + if crate::test_support::isolated_process( + "stack::tests::a_legacy_folder_whose_own_containers_run_as_openbot_keeps_openbot", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + let docker = Address::new(crate::engine::Engine::Docker, None); + let (root, record) = project_choice_root(&path, "legacy-own"); + // Made by an earlier app: a stamp, and no project record. + crate::deployment::record(&root, "v0.2.0", false).unwrap(); + // Compose labels the folder as given, which need not be its canonical spelling. + let spelled = root.join("..").join("OpenBot"); + std::fs::write( + root.join(".fixture-containers"), + compose_container_row("c1", "postgres", "openbot", &spelled), + ) + .unwrap(); + assert_eq!(project(&docker, &root).unwrap(), "openbot"); + assert_eq!(saved_project(&root).as_deref(), Some("openbot")); + // The namespace its Bots' computers were made under stays what it was. + assert_eq!( + compose_names(&docker, &root), + (Some("openbot".into()), None) + ); + let log = std::fs::read_to_string(&record).unwrap(); + assert!( + log.contains( + "ps --all --quiet --no-trunc --filter label=com.docker.compose.project=openbot" + ), + "{log}" + ); + } + + /// An earlier app's installation in `root`: finished installing, its database volume left on + /// the engine by a Stop or Quit that took every container down. + fn earlier_installation(root: &Path) { + crate::deployment::record(root, "v0.2.0", false).unwrap(); + std::fs::write(root.join(crate::preparation::FILE), "{}").unwrap(); + std::fs::write(root.join(".fixture-volumes"), "openbot_postgres-data\n").unwrap(); + std::fs::write( + root.join(".fixture-inspect"), + r#"[{"Name":"openbot_postgres-data","Labels":{"com.docker.compose.project":"openbot","com.docker.compose.volume":"postgres-data"}}]"#, + ) + .unwrap(); + } + + /// The upgrade every person makes: they quit the earlier app (which takes its containers + /// down), install this one, and open it. Their database is still on the engine and has to be + /// the one OpenBot runs on, not a new empty one beside it. + #[test] + fn an_upgraded_install_keeps_its_data_after_its_containers_were_taken_down() { + if crate::test_support::isolated_process( + "stack::tests::an_upgraded_install_keeps_its_data_after_its_containers_were_taken_down", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + let docker = Address::new(crate::engine::Engine::Docker, None); + let (root, record) = project_choice_root(&path, "upgrade-after-quit"); + earlier_installation(&root); + assert_eq!(project(&docker, &root).unwrap(), "openbot"); + assert_eq!(saved_project(&root).as_deref(), Some("openbot")); + // The database, and the Bots' computers under the namespace they were made in. + assert_eq!( + compose_names(&docker, &root), + (Some("openbot".to_owned()), None) + ); + let log = std::fs::read_to_string(&record).unwrap(); + for line in log.lines() { + let command = line.split_once('\t').unwrap().1; assert!( - error.contains(&format!("synthetic {missing} diagnostic")), - "{error}" + command.starts_with("ps --all --quiet --no-trunc ") + || command == "volume ls --format {{.Name}}" + || command == "volume inspect openbot_postgres-data", + "an upgrade only looks at the engine: {command}" ); } } + /// The earlier installation's data is there, and a source checkout called `openbot` runs that + /// project right now. Whose data the volume holds cannot be told, so Install says so and + /// decides nothing, rather than starting empty beside it. #[test] - fn readiness_reports_child_exit_without_waiting_for_timeout() { - let mut fixture = ReadinessFixture::new("exit", "200"); - 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}"); + fn an_upgraded_install_whose_project_another_folder_runs_says_so_and_records_nothing() { + if crate::test_support::isolated_process( + "stack::tests::an_upgraded_install_whose_project_another_folder_runs_says_so_and_records_nothing", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + let docker = Address::new(crate::engine::Engine::Docker, None); + let (root, _) = project_choice_root(&path, "upgrade-in-use"); + earlier_installation(&root); + let checkout = path.bin.join("src").join("openbot"); + std::fs::create_dir_all(&checkout).unwrap(); + std::fs::write( + root.join(".fixture-containers"), + compose_container_row("dev0", "postgres", "openbot", &checkout), + ) + .unwrap(); + let problem = project(&docker, &root).unwrap_err(); + assert!( + problem + .said + .contains("another copy of OpenBot on this computer is using it"), + "{}", + problem.said + ); + assert!( + problem.said.contains("Nothing was changed or deleted"), + "{}", + problem.said + ); + assert_eq!( + saved_project(&root), + None, + "nothing is decided while it is in use" + ); + } + + /// A folder no app ever finished installing in does not take an `openbot` database it finds: + /// it may be a developer's. + #[test] + fn a_folder_no_app_installed_in_never_adopts_a_leftover_openbot_database() { + if crate::test_support::isolated_process( + "stack::tests::a_folder_no_app_installed_in_never_adopts_a_leftover_openbot_database", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + let docker = Address::new(crate::engine::Engine::Docker, None); + let (root, _) = project_choice_root(&path, "fresh-beside-leftover"); + earlier_installation(&root); + std::fs::remove_file(root.join(crate::preparation::FILE)).unwrap(); + assert_eq!(project(&docker, &root).unwrap(), DESKTOP_PROJECT); + } + + /// The Status listing after an upgrade: the installation runs as `openbot`, and another + /// Compose project on the engine is called `openbot-kite-demo`. Its containers were listed as + /// the installation's, memory and all, because their names start with `openbot-`. + #[test] + fn status_lists_only_the_installations_own_containers() { + if crate::test_support::isolated_process( + "stack::tests::status_lists_only_the_installations_own_containers", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + let docker = Address::new(crate::engine::Engine::Docker, None); + let (root, _) = project_choice_root(&path, "status-names"); + std::fs::write(root.join(PROJECT_FILE), r#"{"project":"openbot"}"#).unwrap(); + let elsewhere = path.bin.join("src").join("demo"); + std::fs::create_dir_all(&elsewhere).unwrap(); + let rows = [ + compose_container_row("own1", "postgres", "openbot", &root), + compose_container_row("own2", "server", "openbot", &root), + compose_container_row("dem1", "postgres", "openbot-kite-demo", &elsewhere), + compose_container_row("dem2", "supervisor", "openbot-kite-demo", &elsewhere), + ] + .concat() + // The rows name containers `--1`; the computer is the supervisor's. + .replace("own2\topenbot-server-1", "own2\topenbot-computer-person-me"); + std::fs::write(root.join(".fixture-containers"), rows).unwrap(); + assert_eq!( + running_containers_of(&docker, &root), + ["openbot-postgres-1", "openbot-computer-person-me"] + ); + } + + #[test] + fn a_legacy_folder_never_adopts_a_source_checkouts_openbot_containers() { + if crate::test_support::isolated_process( + "stack::tests::a_legacy_folder_never_adopts_a_source_checkouts_openbot_containers", + ) { + return; + } + let path = PathFixture::with_fake_engine("postgres-volume"); + let docker = Address::new(crate::engine::Engine::Docker, None); + let (root, record) = project_choice_root(&path, "legacy-collision"); + crate::deployment::record(&root, "v0.2.0", false).unwrap(); + // A developer's source checkout called `openbot`, running as Compose project `openbot`. + let checkout = path.bin.join("src").join("openbot"); + std::fs::create_dir_all(&checkout).unwrap(); + let containers: String = ["postgres", "supervisor", "agent-bot"] + .iter() + .enumerate() + .map(|(n, service)| { + compose_container_row(&format!("dev{n}"), service, "openbot", &checkout) + }) + .collect(); + std::fs::write(root.join(".fixture-containers"), &containers).unwrap(); + assert_eq!(project(&docker, &root).unwrap(), DESKTOP_PROJECT); + assert_eq!(saved_project(&root).as_deref(), Some(DESKTOP_PROJECT)); + assert_eq!( + compose_names(&docker, &root), + ( + Some(DESKTOP_PROJECT.to_owned()), + Some(DESKTOP_PROJECT.to_owned()) + ) + ); + // Only looked at: listed and inspected, never stopped, removed or taken down. + let log = std::fs::read_to_string(&record).unwrap(); + for line in log.lines() { + let command = line.split_once('\t').unwrap().1; + assert!( + command.starts_with("ps --all --quiet --no-trunc ") + || command.starts_with("container inspect dev"), + "unexpected engine command for another project's containers: {command}" + ); + } + assert_eq!( + std::fs::read_to_string(root.join(".fixture-containers")).unwrap(), + containers + ); + } + + #[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..efc895118e 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()); } @@ -347,6 +339,14 @@ mod stop_ipc { ) .unwrap(); preparation::record_launch(&fixture.root, None).unwrap(); + // A launch is offered only with its local Intelligence stack; a shared one is never stopped. + local_eval_state::begin( + &fixture.root, + &local_eval::StackId::parse("local-abc123").unwrap(), + local_eval_state::Ownership::Shared, + LOCAL_EVAL_PIN, + ) + .unwrap(); let configured = || { already_configured( fixture.app.handle().clone(), @@ -394,7 +394,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 7933c0bb49..a4daf583f9 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, @@ -764,6 +766,7 @@ pub enum WindowsStageOutcome { WslNoKernel, VirtualMachinePlatformDisabled, VirtualizationDisabled, + NestedVirtualizationUnavailable, NotAdministrator, CheckFailed, } @@ -807,7 +810,7 @@ pub fn schema_json() -> serde_json::Value { "model_chosen": { "provider": ["openai", "anthropic", "google", "xai", "compatible", "none"], "credential_path": ["subscription", "api_key", "oauth", "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/updates.rs b/desktop/src-tauri/src/updates.rs index dcb9e33dac..4eace9fcd9 100644 --- a/desktop/src-tauri/src/updates.rs +++ b/desktop/src-tauri/src/updates.rs @@ -1448,7 +1448,12 @@ mod tests { ("server/src/main.ts", contents.into()), ("app/src/main.ts", contents.into()), ("worker/src/main.ts", contents.into()), - ("docker-compose.yml", "services: {}".into()), + // A release from after OpenBot's server and worker became Compose services (see + // `deployment::predates_containers`). + ( + "docker-compose.yml", + "services:\n server: {}\n worker: {}\n".into(), + ), ( "server/drizzle/meta/_journal.json", "{\"entries\":[{\"tag\":\"openbot-db-1\",\"when\":1}]}".into(), diff --git a/desktop/src-tauri/src/windows.rs b/desktop/src-tauri/src/windows.rs index 06ded14256..76f644fbe4 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, @@ -439,11 +444,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)"; @@ -555,6 +569,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::*; @@ -576,7 +973,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") @@ -623,7 +1020,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" @@ -1528,6 +1925,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..91e7230ba9 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() @@ -116,17 +116,6 @@ fn main() { println!("1.44"); return; } - if SCENARIO == "empty-answer" { - if args.get(1).map(String::as_str) == Some("logs") { - if let Some(service) = args - .last() - .filter(|s| ["agent-langgraph", "agent-harness"].contains(&s.as_str())) - { - println!("OpenAIAuthenticationError: 401 {service} refused the key"); - } - } - return; - } match joined.as_str() { "compose version" => println!("Docker Compose synthetic"), "compose ps --format {{.Ports}}" => { @@ -137,7 +126,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-tauri/tests/recorder_host.rs b/desktop/src-tauri/tests/recorder_host.rs index 4ca773931a..32eafe0684 100644 --- a/desktop/src-tauri/tests/recorder_host.rs +++ b/desktop/src-tauri/tests/recorder_host.rs @@ -575,116 +575,29 @@ fn a_silent_app_is_answered_for_within_the_extensions_wait() { drop(held); } -/// A selected deployment in organization mode under `home`, its sign-in saved for `authority` when `cookie` is Some. -#[cfg(unix)] -fn organization_deployment(home: &Path, authority: &str, cookie: Option<&str>) -> PathBuf { - use std::os::unix::fs::PermissionsExt; - let config = if cfg!(target_os = "macos") { - home.join("Library").join("Application Support") - } else { - home.join(".config") - } - .join("ai.copilotkit.openbot.desktop"); - let root = home.join("deployment"); - std::fs::create_dir_all(&config).unwrap(); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write( - config.join("last-launched-root.json"), - serde_json::to_string(&root).unwrap(), - ) - .unwrap(); - std::fs::write( - root.join(".env"), - format!("OPENBOT_ORGANIZATION_AUTH_URL={authority}\n"), - ) - .unwrap(); - if let Some(cookie) = cookie { - let secrets = root.join(".secrets"); - std::fs::create_dir_all(&secrets).unwrap(); - std::fs::set_permissions(&secrets, std::fs::Permissions::from_mode(0o700)).unwrap(); - let saved = secrets.join("OPENBOT_ORGANIZATION_SESSION.secret"); - std::fs::write( - &saved, - json!({ "authority": authority, "cookie": cookie }).to_string(), - ) - .unwrap(); - std::fs::set_permissions(&saved, std::fs::Permissions::from_mode(0o600)).unwrap(); - } - root -} - -// Organization mode: the ping reads whether Desktop holds a sign-in, without the network. -#[cfg(unix)] -#[test] -fn an_organization_ping_without_a_saved_sign_in_is_not_signed_in() { - if !cfg!(debug_assertions) { - return; - } - let home = TempHome::new("organization-ping"); - let authority = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let authority_url = format!("http://{}", authority.local_addr().unwrap()); - organization_deployment(home.path(), &authority_url, None); - let app = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let url = format!("http://{}", app.local_addr().unwrap()); - let probe = serve_routes(app, 1, |_| http("200 OK", "", "{}")); - let mut host = Host::start(home.path(), &extension_origin(), Some(&url)); - assert_eq!( - host.ask(json!({ "type": "ping" })), - json!({ "ok": false, "available": false, "error": "not-signed-in" }) - ); - assert_eq!( - probe.join().unwrap()[0].line, - "GET /api/capabilities HTTP/1.1" - ); - assert!(host.close().success()); - authority.set_nonblocking(true).unwrap(); - match authority.accept() { - Err(error) => assert_eq!(error.kind(), ErrorKind::WouldBlock), - Ok((_, peer)) => panic!("a ping dialled the organization from {peer}"), - } -} - -// The host's stderr reaches Chrome's log and the terminal of whoever started the browser: never a connect code or -// any part of a cookie, on success or on failure. -#[cfg(unix)] +// The host's stderr reaches Chrome's log and the terminal of whoever started the browser: never a connect code, on +// success or on failure. #[test] -fn the_host_never_logs_a_code_or_a_cookie() { +fn the_host_never_logs_a_code() { if !cfg!(debug_assertions) { return; } let home = TempHome::new("stderr"); - let authority = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let authority_url = format!("http://{}", authority.local_addr().unwrap()); - organization_deployment( - home.path(), - &authority_url, - Some("better-auth.session_token=saved-secret"), - ); - let me = r#"{"user":{"id":"u1","email":"a@b.c","name":null,"role":"user"}}"#; - let organization = serve_routes(authority, 2, move |_| http("200 OK", "", me)); let app = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let url = format!("http://{}", app.local_addr().unwrap()); let minted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let app = serve_routes(app, 7, move |line| { + let app = serve_routes(app, 3, move |line| { let minted_code = r#"{"code":"obc_secretcode","expiresAt":"2026-10-18T12:00:00.000Z"}"#; if line.starts_with("GET /api/capabilities ") { http("200 OK", "", "{}") - } else if line.starts_with("POST /api/auth/organization/session ") { - http("200 OK", "", r#"{"ticket":"t"}"#) - } else if line.starts_with("GET /api/auth/organization/session?ticket=t ") { - http( - "302 Found", - "Location: /\r\nSet-Cookie: openbot.organization-session=local-secret; Path=/; HttpOnly\r\n", - "", - ) } else if minted.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { http("201 Created", "", minted_code) } else { - // A refusal that repeats the code and the cookie in its body. + // A refusal that repeats the code in its body. http( "500 Internal Server Error", "", - r#"{"error":"obc_secretcode openbot.organization-session=local-secret"}"#, + r#"{"error":"obc_secretcode"}"#, ) } }); @@ -703,23 +616,12 @@ fn the_host_never_logs_a_code_or_a_cookie() { ); let (status, stderr) = host.close_with_stderr(); assert!(status.success(), "{status:?}: {stderr}"); - let seen = app.join().unwrap(); - assert!(seen[3] - .head - .to_lowercase() - .contains("cookie: openbot.organization-session=local-secret\r\n")); - organization.join().unwrap(); + app.join().unwrap(); assert!( stderr.contains("answered the handoff with 500"), "the failure was not logged at all: {stderr}" ); - for secret in [ - "obc_", - "secret", - "session_token", - "organization-session", - "ticket", - ] { + for secret in ["obc_", "secret"] { assert!( !stderr.contains(secret), "stderr holds {secret:?}: {stderr}" diff --git a/desktop/src-tauri/windows/hooks.nsh b/desktop/src-tauri/windows/hooks.nsh index 639983d24a..0e5c54f951 100644 --- a/desktop/src-tauri/windows/hooks.nsh +++ b/desktop/src-tauri/windows/hooks.nsh @@ -14,6 +14,27 @@ ; HKCU Extensions key, with which the browser takes the Recorder it installed back out, and Desktop's note of them. ; There is one DeleteRegKey per store id set in shared/recorder-extension.json, and a test holds the two in step; ; none while the ids are empty. +; The bundled Intelligence CLI starts a background sign-in service with the bundled node.exe, detached, so it outlives +; the app. While it runs Windows will not let an install or an uninstall replace or delete +; $INSTDIR\cli-bundle\runtime\node.exe, and the uninstaller failed on exactly that. Before either, stop every +; process running from this installation's own cli-bundle, and nothing else: another node.exe is never touched. +; Intelligence brings the service back the next time it starts. +; The folder reaches PowerShell as data, in OPENBOT_CLI_BUNDLE, never inside the script: a profile folder like +; C:\Users\O'Brien would end a quoted string there, and [ and ] are wildcards to -like. +!macro OPENBOT_STOP_BUNDLED_CLI + System::Call 'Kernel32::SetEnvironmentVariable(t, t)i ("OPENBOT_CLI_BUNDLE", "$INSTDIR\cli-bundle")' + nsExec::Exec `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$bundle = $$env:OPENBOT_CLI_BUNDLE.TrimEnd('\') + '\'; Get-Process -ErrorAction SilentlyContinue | Where-Object { $$_.Path -and $$_.Path.StartsWith($$bundle, [System.StringComparison]::OrdinalIgnoreCase) } | Stop-Process -Force -ErrorAction SilentlyContinue"` + Pop $0 +!macroend + +!macro NSIS_HOOK_PREINSTALL + !insertmacro OPENBOT_STOP_BUNDLED_CLI +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + !insertmacro OPENBOT_STOP_BUNDLED_CLI +!macroend + !macro NSIS_HOOK_POSTUNINSTALL ${If} $UpdateMode <> 1 DeleteRegKey HKCU "Software\Google\Chrome\NativeMessagingHosts\ai.copilotkit.openbot.recorder" diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 0769c52c8f..af561d9df1 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -1,13 +1,6 @@ import { afterAll, afterEach, beforeAll, expect, mock, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; -import { - act, - cleanup, - fireEvent, - render, - waitFor, - within, -} from "@testing-library/react"; +import { act, cleanup, render, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { StrictMode } from "react"; @@ -18,6 +11,13 @@ type ProgressEvent = { detail: string; running?: boolean; downloadBytes?: number; + downloads?: { + done: number; + total: number; + bytes: number; + bytesPerSecond?: number; + pulling: Array<{ name: string; bytes: number; size?: number }>; + }; }; type Deferred = { promise: Promise; @@ -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,18 @@ mock.module("./Mark", () => ({ })); const { App } = await import("./App"); +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()); @@ -88,18 +108,10 @@ function setupEvents() { .map((call) => call.args); } -async function enterInstallation(view: Awaited>) { - await userEvent.click(view.getByRole("button", { name: "Set up OpenBot" })); - await userEvent.click(view.getByRole("button", { name: "Continue" })); -} - -async function completeInstallation( - view: Awaited>, -) { - await userEvent.click(view.getByRole("button", { name: "Install OpenBot" })); - await userEvent.click( - await view.findByRole("button", { name: "Continue to sign in" }), - ); +/** Install on the first screen, past the account step, to the AI provider screen. */ +async function reachAi(view: Awaited>) { + await installLocal(view); + await view.findByRole("heading", { name: "Connect your AI" }); } function installationCalls() { @@ -113,14 +125,13 @@ async function beginPendingInstallation() { emptyConfiguration(), ); let preparation = deferred(); - const previous = invokeHandler; - invokeHandler = async (command, args) => - command === "prepare_installation" - ? preparation.promise - : previous(command, args); + withLocalCli({ + prepare_installation: () => preparation.promise, + set_up_local_eval: () => new Promise(() => {}), + }); const view = await renderApp(); - await enterInstallation(view); - await userEvent.click(view.getByRole("button", { name: "Install OpenBot" })); + await installLocal(view); + await view.findByRole("heading", { name: "Installing OpenBot" }); return { view, preparation, @@ -134,6 +145,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 }); @@ -142,75 +202,24 @@ async function emitProgress(payload: ProgressEvent) { test("installation shows its active stage immediately and advances elapsed time", async () => { const { view, preparation } = await beginPendingInstallation(); - const engine = within( - view.getByRole("listitem", { name: "Container engine" }), + const preparing = within( + view.getByRole("listitem", { name: "Preparing this computer" }), ); - expect(engine.getByText("Checking the software OpenBot needs.")).toBeTruthy(); - expect(engine.getByText("In progress")).toBeTruthy(); - expect(engine.queryByText("✓")).toBeNull(); - await waitFor(() => expect(engine.getByText(/1s elapsed/)).toBeTruthy(), { + expect( + preparing.getByText("Checking the software OpenBot needs."), + ).toBeTruthy(); + expect(preparing.getByText("In progress")).toBeTruthy(); + expect(preparing.queryByText("✓")).toBeNull(); + await waitFor(() => expect(preparing.getByText(/1s elapsed/)).toBeTruthy(), { timeout: 1800, }); await act(async () => preparation.resolve()); - expect(engine.getByText("Complete")).toBeTruthy(); - expect(engine.getByText("✓")).toBeTruthy(); - expect(view.queryByText("In progress") === null).toBe(true); + // Installed: on to the AI provider screen, with nothing left in progress. + await view.findByRole("heading", { name: "Connect your AI" }); + expect(view.queryByText("In progress")).toBeNull(); expect(view.queryByText(/elapsed/)).toBeNull(); }); -test("running updates show real download bytes without completing a stage early", async () => { - const { view, preparation } = await beginPendingInstallation(); - await emitProgress({ - step: "engine", - ok: true, - detail: "Docker is answering.", - }); - await emitProgress({ - step: "dependencies", - ok: true, - running: true, - detail: "Downloading the local runtime.", - downloadBytes: 2_400_000, - }); - const dependencies = within( - view.getByRole("listitem", { name: "Dependencies" }), - ); - expect(dependencies.getByText("In progress")).toBeTruthy(); - expect(dependencies.getByText(/2.4 MB downloaded/)).toBeTruthy(); - expect(dependencies.queryByText("✓")).toBeNull(); - await emitProgress({ - step: "dependencies", - ok: true, - running: true, - detail: "Downloading the local runtime.", - downloadBytes: 5_700_000, - }); - expect(view.getAllByRole("listitem", { name: "Dependencies" })).toHaveLength( - 1, - ); - expect(dependencies.getByText(/5.7 MB downloaded/)).toBeTruthy(); - expect(dependencies.queryByText(/2.4 MB downloaded/)).toBeNull(); - expect(dependencies.queryByText("Complete")).toBeNull(); - await emitProgress({ - step: "dependencies", - ok: true, - running: false, - detail: "Local runtime installed.", - }); - expect(dependencies.getByText("Complete")).toBeTruthy(); - expect(dependencies.getByText("✓")).toBeTruthy(); - expect(dependencies.queryByText("In progress")).toBeNull(); - await emitProgress({ - step: "images", - ok: true, - running: true, - detail: "Downloading local software (2/4).", - }); - expect(view.getByText("Downloading local software (2/4).")).toBeTruthy(); - await act(async () => preparation.resolve()); - expect(view.queryByText("In progress") === null).toBe(true); -}); - test("installation failure stops active indicators and retry begins fresh", async () => { const { view, preparation, retry } = await beginPendingInstallation(); await emitProgress({ @@ -228,513 +237,1343 @@ test("installation failure stops active indicators and retry begins fresh", asyn await act(async () => preparation.reject({ said: "The download was interrupted." }), ); - const dependencies = within( - view.getByRole("listitem", { name: "Dependencies" }), + const installing = within( + view.getByRole("listitem", { name: "Installing OpenBot" }), + ); + expect(installing.getByText("Failed")).toBeTruthy(); + expect(installing.getByText("✗")).toBeTruthy(); + expect(view.getByRole("alert").textContent).toContain( + "The download was interrupted.", ); - expect(dependencies.getByText("Failed")).toBeTruthy(); - expect(dependencies.getByText("✗")).toBeTruthy(); - expect(dependencies.getByText("The download was interrupted.")).toBeTruthy(); expect(view.queryByText("In progress") === null).toBe(true); expect(view.queryByText(/elapsed/)).toBeNull(); expect( - within(view.getByRole("listitem", { name: "Container engine" })).getByText( - "Complete", - ), + within( + view.getByRole("listitem", { name: "Preparing this computer" }), + ).getByText("Complete"), ).toBeTruthy(); + // Nothing goes on to the AI step while the install has failed. + expect(view.queryByRole("heading", { name: "Connect your AI" })).toBeNull(); const secondAttempt = await retry(); - expect(view.queryByRole("listitem", { name: "Dependencies" })).toBeNull(); expect(view.queryByText("Failed")).toBeNull(); expect(view.getByText("In progress")).toBeTruthy(); expect(view.queryByRole("alert")).toBeNull(); await act(async () => secondAttempt.resolve()); - expect(view.queryByText("In progress") === null).toBe(true); + await view.findByRole("heading", { name: "Connect your AI" }); expect( - view.getByRole("heading", { name: "Installation complete" }), - ).toBeTruthy(); + installationCalls().filter( + (call) => call.command === "prepare_installation", + ), + ).toHaveLength(2); }); -test.each([ - [false, false], - [true, false], - [false, true], - [true, true], -])( - "startup settles active progress (automatic=%s, succeeds=%s)", - async (automatic, succeeds) => { - useInterruptedShutdownSetup(); - const launch = deferred(); - const previous = invokeHandler; - invokeHandler = async (command, args) => { - if (command === "last_failure" && automatic) return null; - if (command === "start_stack") return launch.promise; - return previous(command, args); - }; - const view = await renderApp(); - if (!automatic) - await userEvent.click( - view.getByRole("button", { name: "Start OpenBot" }), - ); - await emitProgress({ - step: "services", - ok: true, - running: true, - detail: "Starting local services.", - }); - expect(view.getByText("In progress")).toBeTruthy(); - await act(async () => { - if (succeeds) launch.resolve(); - else launch.reject({ said: "Local services could not start." }); - }); - expect(view.queryByText("In progress") === null).toBe(true); - const services = within(view.getByRole("listitem", { name: "Containers" })); - expect(services.getByText(succeeds ? "Complete" : "Failed")).toBeTruthy(); - expect(services.getByText(succeeds ? "✓" : "✗")).toBeTruthy(); - if (!succeeds) - expect( - view.getByRole("button", { name: "Start OpenBot" }), - ).toHaveProperty("disabled", false); - }, -); +const localListing = { outcome: "completed", stacks: [], reason: null }; -test("installation completes before either sign-in is available", async () => { - setupRootConfiguration("/tmp/install-before-signin", async () => - emptyConfiguration(), - ); - const preparation = deferred(); +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, +}; + +/** Local Intelligence that sets up, connects and reports ready and valid straight away. */ +function readyLocal() { + return { + set_up_local_eval: localStatus("ready", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + }; +} + +/** The local stack's own addresses, as `localStatus` reports them. */ +const LOCAL_ADDRESSES = { + apiUrl: "http://localhost:64770", + gatewayWsUrl: "ws://localhost:64772", +}; + +/** 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 === "prepare_installation") return preparation.promise; + 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); }; - 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", +} + +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), + ); +} + +/** A first run hands over the moment Start succeeds: OpenBot opens, and nothing is asked first. */ +async function expectHandover(timeout?: number) { + await waitFor( + () => + expect(invokeCalls.some((call) => call.command === "show_openbot")).toBe( + true, + ), + { timeout }, + ); + expect(invokeCalls.some((call) => call.command === "ask_the_bot")).toBe( + false, ); +} + +/** 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("the first screen is Install, with nothing to choose", async () => { + setupRootConfiguration("/tmp/local-eval-welcome", async () => + emptyConfiguration(), + ); + withLocalCli({}); + const view = await renderApp(); + expect(view.getByRole("heading", { name: "Set up OpenBot" })).toBeTruthy(); expect( - view.queryByRole("button", { name: /Sign in|Continue to sign in/i }), - ).toBeNull(); - await userEvent.click(view.getByRole("button", { name: "Install OpenBot" })); + view.getByText( + "Installs the OpenBot app and backend on this computer. The first setup takes about 10 minutes.", + ), + ).toBeTruthy(); + expect(buttons(view)).toEqual(["Install"]); + expect(view.queryByRole("radio")).toBeNull(); + expect(view.queryByText(/hosted/i)).toBeNull(); + expectPlain(view); + 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/install-before-signin", - harness: { id: "langgraph" }, - }, + args: { root: "/tmp/local-eval-install", harness: { id: "langgraph" } }, }, ]); - expect(view.getByLabelText("Where OpenBot lives")).toHaveProperty( - "disabled", - true, - ); - expect(view.getByRole("button", { name: "Back" })).toHaveProperty( - "disabled", - true, + expect( + view.getByRole("heading", { name: "Installing OpenBot" }), + ).toBeTruthy(); + expect(stepLabel(view)).toBe("Install"); + // Says this is the whole platform, so a large download is expected. + expect(view.getByText(/installs the full OpenBot platform/)).toBeTruthy(); + 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(view.queryByRole("heading", { name: "Connect your AI" })).toBeNull(); + 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( - 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.", - }, - }); + 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(view.getByText("Local runtime installed.")).toBeTruthy(); expect( - view.queryByRole("button", { name: "Continue to sign in" }), - ).toBeNull(); - await act(async () => preparation.resolve()); + 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(); + // The native side's per-image report: what is downloading now, and the whole download. + await emitProgress({ + step: "images", + ok: true, + running: true, + detail: "Downloading local software.", + downloadBytes: 52_000_000, + downloads: { + done: 2, + total: 7, + bytes: 52_000_000, + bytesPerSecond: 3_000_000, + pulling: [{ name: "agent-bot", bytes: 12_000_000, size: 40_000_000 }], + }, + }); expect( - view.getByRole("heading", { name: "Installation complete" }), + installing.getByText("52.0 MB downloaded · 3.0 MB/s · 2 of 7 images done"), ).toBeTruthy(); + expect(installing.getByText("12.0 MB of 40.0 MB")).toBeTruthy(); + expectPlain(view); 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" }), + 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" }), + }), + ]), ); - expect(view.getByRole("heading", { name: "Connect your AI" })).toBeTruthy(); }); -test("CopilotKit Back preserves the selected AI and connection settings", async () => { - setupRootConfiguration("/tmp/back-preserves-setup", async () => +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(); - const user = userEvent.setup({ document: view.container.ownerDocument }); - 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 userEvent.click( - view.getByText("Point at your own Intelligence server"), - ); - await userEvent.type(view.getByLabelText("Project key"), "project-test-key"); - const api = view.getByLabelText("API URL"); - await user.clear(api); - await user.type(api, "https://intelligence.example/api"); - const gateway = view.getByLabelText("Gateway WebSocket URL"); - await user.clear(gateway); - await user.type(gateway, "wss://intelligence.example/ws"); - await userEvent.click(view.getByRole("button", { name: "Back" })); - expect(view.getByRole("heading", { name: "Connect your AI" })).toBeTruthy(); - expect(view.getByRole("radio", { name: /OpenAI/ })).toHaveProperty( - "checked", - true, - ); - expect(view.getByLabelText("OpenAI API key")).toHaveProperty( - "value", - "model-test-key", - ); - await userEvent.click(view.getByRole("button", { name: "Continue" })); + 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( - view.getByRole("heading", { name: "Connect to CopilotKit" }), + view.getByText( + "Your conversations and files are stored on this computer. Signing in does not send them to CopilotKit.", + ), ).toBeTruthy(); - await userEvent.click( - view.getByText("Point at your own Intelligence server"), - ); - expect(view.getByLabelText("Project key")).toHaveProperty( - "value", - "project-test-key", - ); - expect(view.getByLabelText("API URL")).toHaveProperty( - "value", - "https://intelligence.example/api", - ); - expect(view.getByLabelText("Gateway WebSocket URL")).toHaveProperty( - "value", - "wss://intelligence.example/ws", + expect(stepLabel(view)).toBe("Account"); + await waitFor(() => + expect(installationCalls().map((call) => call.command)).toEqual([ + "prepare_installation", + ]), ); - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, + expect(localSequence()).toEqual([]); + await userEvent.click( + view.getByRole("button", { name: "Sign in to CopilotKit" }), ); + await view.findByRole("heading", { name: "Accept the license agreement" }); expect( - view.queryByRole("button", { name: "Change AI connection" }) === null, - ).toBe(true); + view.getByText( + "To run OpenBot on this computer, accept the CopilotKit Intelligence license 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( - view - .getByRole("button", { name: "Change installation" }) - .closest("details"), - ).toHaveProperty("open", false); - expect(installationCalls()).toHaveLength(1); + 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("failed installation blocks sign-in and retries before reporting completion", async () => { - setupRootConfiguration("/tmp/install-retry", async () => +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(), ); - const previous = invokeHandler; - let attempts = 0; - invokeHandler = async (command, args) => { - if (command === "prepare_installation") { - if (++attempts === 1) - throw { - said: "The download was interrupted.", - detail: "Synthetic network failure", - }; - return null; - } - return previous(command, args); - }; + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + }); const view = await renderApp(); - await enterInstallation(view); - await userEvent.click(view.getByRole("button", { name: "Install OpenBot" })); - expect((await view.findByRole("alert")).textContent).toContain( - "The download was interrupted.", - ); - expect( - view.queryByRole("button", { name: "Continue to sign in" }), - ).toBeNull(); + await installLocal(view); + await view.findByRole("heading", { name: "Connect your AI" }); expect( - invokeCalls.some((call) => /sign_in|^providers$/.test(call.command)), + invokeCalls.some((call) => call.command === "sign_in_to_copilotkit"), ).toBe(false); - await userEvent.click( - view.getByRole("button", { name: "Retry installation" }), - ); + await waitFor(() => expect(localSequence()).toContain("set_up_local_eval")); expect( - await view.findByRole("heading", { name: "Installation complete" }), - ).toBeTruthy(); - expect(view.queryByRole("alert")).toBeNull(); - expect(attempts).toBe(2); + invokeCalls.find((call) => call.command === "set_up_local_eval")?.args, + ).toMatchObject({ acceptedEulaVersion: null }); }); -test("provider sign-in retries and Back reuse the completed local installation", async () => { - setupRootConfiguration("/tmp/provider-retry", async () => +test("the local sequence starts when installation completes and survives the provider screen", async () => { + setupRootConfiguration("/tmp/local-eval-overlap", async () => emptyConfiguration(), ); - const previous = invokeHandler; - let signIns = 0; - invokeHandler = async (command, args) => { - if (command === "prepare_installation") return null; - if (command === "providers") - return [ - { - id: "openai", - name: "OpenAI", - summary: "Use OpenAI", - logins: ["plan"], - mark: null, - caution: null, - }, - ]; - if (command === "begin_chatgpt_sign_in") { - if (++signIns === 1) throw { said: "Sign-in was interrupted." }; - return "https://sign-in.example"; - } - if (command === "finish_chatgpt_sign_in") return "synthetic-token"; - return previous(command, args); - }; + 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 enterInstallation(view); - await completeInstallation(view); - await userEvent.click(view.getByRole("button", { name: "Back" })); + 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( - view.getByRole("heading", { name: "Installation complete" }), - ).toBeTruthy(); - await userEvent.click( - view.getByRole("button", { name: "Continue to sign in" }), - ); + 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 OpenBot/)).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 userEvent.click( - view.getByRole("button", { name: "Sign in with OpenAI" }), + await act(async () => setUp.resolve(localStatus("starting", "valid"))); + await waitFor(() => + expect(localSequence()).toEqual([ + "set_up_local_eval", + "connect_local_eval", + ]), ); - await view.findByText("Sign-in was interrupted."); - await userEvent.click( - view.getByRole("button", { name: "Sign in with OpenAI" }), + expect(view.queryByText(/Connecting OpenBot/)).toBeNull(); + expect(invokeCalls.some((call) => call.command === "cancel_local_eval")).toBe( + false, ); - await view.findByText(/Signed in to OpenAI/); - expect(signIns).toBe(2); - expect(installationCalls()).toHaveLength(1); + expectPlain(view); }); -test("a completed installation can be repaired after a provider reports missing assets", async () => { - setupRootConfiguration("/tmp/repair-installation", async () => +test("Continue during setup shows Getting ready, which starts and opens OpenBot by itself", async () => { + setupRootConfiguration("/tmp/local-eval-auto", async () => emptyConfiguration(), ); - const previous = invokeHandler; - const repair = deferred(); - let installations = 0; - invokeHandler = async (command, args) => { - if (command === "prepare_installation") - return ++installations === 1 ? null : repair.promise; - if (command === "providers") - return [ - { - id: "openai", - name: "OpenAI", - summary: "Use OpenAI", - logins: ["plan"], - mark: null, - caution: null, - }, - ]; - if (command === "begin_chatgpt_sign_in") - throw { said: "Return to Install and try again." }; - return previous(command, args); - }; + const setUp = deferred(); + withLocalCli({ + set_up_local_eval: () => setUp.promise, + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + }); const view = await renderApp(); - await enterInstallation(view); - await completeInstallation(view); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); - await userEvent.click( - view.getByRole("button", { name: "Sign in with OpenAI" }), - ); - await view.findByText("Return to Install and try again."); - await userEvent.click(view.getByRole("button", { name: "Back" })); - await userEvent.click(view.getByText("Installation options")); - await userEvent.click( - view.getByRole("button", { name: "Repair installation" }), - ); + 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 OpenBot" }); + expect(row.textContent).toContain("In progress"); + expectPlain(view); + await act(async () => setUp.resolve(localStatus("starting", "valid"))); + await expectHandover(); + expect(localSequence()).toEqual([ + "set_up_local_eval", + "connect_local_eval", + "run_local_eval", + "start_stack", + "show_openbot", + ]); expect( - view.queryByRole("button", { name: "Continue to sign in" }), - ).toBeNull(); - await act(async () => repair.resolve()); + 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", + ...LOCAL_ADDRESSES, + }); + expect(getStartStackPayload()).not.toHaveProperty("apiKey"); + expect(getStartStackPayload()).not.toHaveProperty("organizationAuthUrl"); + expectPlain(view); expect( - await view.findByRole("button", { name: "Continue to sign in" }), - ).toBeTruthy(); - expect(installations).toBe(2); + invokeCalls.filter((call) => call.command === "start_stack"), + ).toHaveLength(1); }); -test("changing the Bot invalidates its completed installation", async () => { - useBringYourOwnHarnessSetup(); - const view = await renderApp(); - await enterInstallation(view); - await completeInstallation(view); - await userEvent.click(view.getByRole("button", { name: "Back" })); - await userEvent.click(view.getByRole("button", { name: "Back" })); - await userEvent.click(view.getByText("Choose the agent framework")); - await userEvent.click( - view.getByRole("radio", { name: /An agent you already run/ }), +function intelligenceCalls() { + return invokeCalls.filter((call) => + ["offer_local_intelligence", "open_local_intelligence"].includes( + call.command, + ), ); - await userEvent.type( - view.getByLabelText("AG-UI endpoint"), - "https://agent.example/ag-ui", +} + +test("a first local run offers the Intelligence dashboard in the menus and opens OpenBot", async () => { + setupRootConfiguration("/tmp/local-eval-view", async () => + emptyConfiguration(), ); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - expect( - view.queryByRole("button", { name: "Continue to sign in" }), - ).toBeNull(); - await completeInstallation(view); - expect(installationCalls()).toEqual([ + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + offer_local_intelligence: null, + }); + const view = await renderApp(); + await installLocal(view); + await chooseOpenAi(view); + await expectHandover(); + // The menus offer the dashboard once local mode has a recorded stack. + expect(intelligenceCalls()).toEqual([ { - command: "prepare_installation", - args: { root: "/tmp/openbot-app-test", harness: { id: "langgraph" } }, + command: "offer_local_intelligence", + args: { root: "/tmp/local-eval-view", offered: true }, }, - { - command: "prepare_installation", - args: { - root: "/tmp/openbot-app-test", - harness: { id: "byo-url", agentUrl: "https://agent.example/ag-ui" }, + ]); +}); + +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"), + }); + 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 expectHandover(); + 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"), + }); + 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 expectHandover(4000); + expect(getStartStackPayload()).toMatchObject({ + model: { + provider: "anthropic", + login: "plan", + token: "synthetic-plan-token", }, + }); +}); + +test("a development double mount runs each local step and opens OpenBot once", 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"), + }); + const view = await renderApp(true); + await installLocal(view); + await chooseOpenAi(view); + await expectHandover(); + expect(localSequence()).toEqual([ + "set_up_local_eval", + "connect_local_eval", + "run_local_eval", + "start_stack", + "show_openbot", ]); }); -test.each([false, true])( - "reopening a successful setup starts its saved root and connections without the wizard or Ask (Strict Mode=%s)", - async (strictMode) => { - const root = "/tmp/successful-setup-root"; - setupRootConfiguration("/tmp/default-root", async () => ({ - values: { - INTELLIGENCE_API_URL: "https://own.example/api", - INTELLIGENCE_GATEWAY_WS_URL: "wss://own.example/ws", - }, - saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, - launch: { harness: { id: "mastra" } }, - })); - const launch = deferred(); - const previous = invokeHandler; - invokeHandler = async (command, args) => { - if (command === "selected_root") return root; - if (command === "start_stack") return launch.promise; - if (command === "show_openbot") return null; - return previous(command, args); - }; - const view = await renderApp(strictMode); - expect( - view.getByRole("heading", { name: "Starting OpenBot" }), - ).toBeTruthy(); - expect( - view.queryByRole("heading", { name: "Connect to CopilotKit" }), - ).toBeNull(); - expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); - await act(async () => launch.resolve()); - await waitFor(() => - expect(invokeCalls).toContainEqual({ command: "show_openbot" }), - ); - expect(getStartStackPayload()).toEqual({ - root, - apiKey: "", - apiUrl: "https://own.example/api", - gatewayWsUrl: "wss://own.example/ws", - harness: { id: "mastra" }, - model: { provider: "openai", login: "api-key", saved: true }, - }); - expect( - invokeCalls.filter((call) => call.command === "start_stack"), - ).toHaveLength(1); - expect( - invokeCalls.some((call) => - /prepare_|sign_in|^providers$|^harnesses$|ask_the_bot/.test( - call.command, - ), - ), - ).toBe(false); - expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); - expect(view.queryByRole("button", { name: "Ask" })).toBeNull(); - }, -); +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"), + }); + 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 expectHandover(); + expect(getStartStackPayload()).toMatchObject({ + model: { provider: "openai" }, + }); +}); -test("a failed automatic reopen offers recovery without retrying or installing", async () => { - setupRootConfiguration("/tmp/reopen-failure", async () => ({ - ...savedOpenAiConfiguration(), - saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, - launch: { harness: { id: "langgraph" } }, - })); - const previous = invokeHandler; - invokeHandler = async (command, args) => { - if (command === "start_stack") - throw { said: "Return to Install and try again." }; - return previous(command, args); - }; +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; + }, + }); const view = await renderApp(); - expect((await view.findByRole("alert")).textContent).toContain( - "Return to Install and try again.", + 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.filter((call) => call.command === "start_stack"), + 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 OpenBot" }); + 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"]); + await userEvent.click(view.getByRole("button", { name: "Back" })); + await view.findByRole("heading", { name: "Connect your AI" }); + expect( + invokeCalls.filter((call) => call.command === "set_up_local_eval"), ).toHaveLength(1); - expect(installationCalls()).toHaveLength(0); - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, +}); + +test("a blocked setup stops at its row in plain words and Retry resumes there", async () => { + setupRootConfiguration("/tmp/local-eval-blocked", async () => + emptyConfiguration(), ); - await userEvent.click( - view.getByRole("button", { name: "Change installation" }), + 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"), + }); + 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.", ); - expect(view.getByRole("button", { name: "Install OpenBot" })).toBeTruthy(); + 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 OpenBot" }); + 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 expectHandover(); + expect(setups).toBe(2); }); -test("menu Stop retains the installed root for explicit Start without the wizard", async () => { - const root = "/tmp/menu-stopped-installation"; - setupRootConfiguration(root, async () => ({ +test("a recorded, connected stack that never launched starts by itself and opens OpenBot", async () => { + setupRootConfiguration("/tmp/local-eval-recorded", async () => ({ ...savedOpenAiConfiguration(), saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, - launch: { harness: { id: "mastra" } }, - autoStart: false, + installation: { harness: { id: "mastra" } }, + localEval: { + stack: "local-abc123", + ownership: "created", + phase: "installed", + connected: true, + }, })); + withLocalCli({ + run_local_eval: localStatus("ready", "valid"), + }); + await renderApp(); + // Never launched, so this is a first run: it hands over as soon as Start succeeds. + await expectHandover(); + expect(localSequence()).toEqual([ + "run_local_eval", + "start_stack", + "show_openbot", + ]); +}); + +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) => { - if (command === "show_openbot") return null; - return previous(command, args); - }; + 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.getByRole("heading", { name: "OpenBot is stopped" }), + 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(); - expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( - false, - ); - await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); - expect(getStartStackPayload()).toMatchObject({ - root, - harness: { id: "mastra" }, - }); - expect(invokeCalls).toContainEqual({ command: "show_openbot" }); - expect(installationCalls()).toHaveLength(0); - expect(view.queryByRole("button", { name: "Ask" })).toBeNull(); + expectPlain(view); }); -test("completed local installation resumes at sign-in when it has never launched", async () => { - setupRootConfiguration("/tmp/installed-before-signin", async () => ({ - ...emptyConfiguration(), - installation: { harness: { id: "mastra" } }, +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(), + ); + 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 === "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 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, + ); + 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"), + ); + 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…", + }); + expectWorking(view, "Opening the Intelligence dashboard…"); + // The screen under it is unchanged. + expect( + 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 + .getAllByRole("status") + .some( + (line) => line.textContent === "Your browser did not open. Try again.", + ), + ).toBe(true); + expect(view.queryByText("Opened in your browser.")).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", + ...LOCAL_ADDRESSES, + }); + expect(getStartStackPayload()).not.toHaveProperty("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("Back from a stopped Getting ready keeps the AI already chosen", async () => { + setupRootConfiguration("/tmp/back-preserves-setup", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: { + ...localStatus("unknown", "unknown"), + outcome: "blocked", + stack: null, + reason: "docker-unavailable", + }, + }); const view = await renderApp(); + await installLocal(view); + await chooseOpenAi(view); + await view.findByRole("heading", { name: "Getting ready" }); + await userEvent.click(await view.findByRole("button", { name: "Back" })); expect(view.getByRole("heading", { name: "Connect your AI" })).toBeTruthy(); - expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); + expect(view.getByRole("radio", { name: /OpenAI/ })).toHaveProperty( + "checked", + true, + ); + expect(view.getByLabelText("OpenAI API key")).toHaveProperty( + "value", + "model-test-key", + ); + expect(installationCalls()).toHaveLength(1); + expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( + false, + ); +}); + +test("a provider sign-in can be tried again on the AI step without installing again", async () => { + setupRootConfiguration("/tmp/provider-retry", async () => + emptyConfiguration(), + ); + let signIns = 0; + withLocalCli({ + ...readyLocal(), + providers: [ + { + id: "openai", + name: "OpenAI", + summary: "Use OpenAI", + logins: ["plan"], + mark: null, + caution: null, + }, + ], + begin_chatgpt_sign_in: () => { + if (++signIns === 1) throw { said: "Sign-in was interrupted." }; + return "https://sign-in.example"; + }, + finish_chatgpt_sign_in: "synthetic-token", + }); + const view = await renderApp(); + await reachAi(view); + await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await userEvent.click( + view.getByRole("button", { name: "Sign in with OpenAI" }), + ); + await view.findByText("Sign-in was interrupted."); + await userEvent.click( + view.getByRole("button", { name: "Sign in with OpenAI" }), + ); + // Signed in, so on by itself after a second showing the tick, and OpenBot opens once started. + await expectHandover(4000); + expect(signIns).toBe(2); + expect(installationCalls()).toHaveLength(1); + expect(getStartStackPayload().model).toMatchObject({ + provider: "openai", + login: "plan", + token: "synthetic-token", + }); +}); + +test("a failed automatic reopen offers recovery without retrying or installing", async () => { + setupConnectedLocalStack( + "/tmp/reopen-failure", + localStatus("ready", "valid"), + ); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "start_stack") + throw { said: "Return to Install and try again." }; + return previous(command, args); + }; + const view = await renderApp(); + expect((await view.findByRole("alert")).textContent).toContain( + "Return to Install and try again.", + ); + expect( + invokeCalls.filter((call) => call.command === "start_stack"), + ).toHaveLength(1); + expect(installationCalls()).toHaveLength(0); + expect(view.getByRole("button", { name: "Retry" })).toHaveProperty( + "disabled", + false, + ); + await userEvent.click( + view.getByRole("button", { name: "Change installation" }), + ); + expect(view.getByRole("button", { name: "Install OpenBot" })).toBeTruthy(); +}); + +test("menu Stop retains the installed root for explicit Start without the wizard", async () => { + const root = "/tmp/menu-stopped-installation"; + setupConnectedLocalStack(root, localStatus("ready", "valid"), { + autoStart: false, + }); + const view = await renderApp(); + await view.findByRole("heading", { name: "OpenBot is stopped" }); + expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( + false, + ); + await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); + await waitFor(() => + expect(invokeCalls).toContainEqual({ command: "show_openbot" }), + ); + expect(getStartStackPayload()).toMatchObject({ + root, + harness: { id: "mastra" }, + }); + expect(installationCalls()).toHaveLength(0); +}); + +test("a completed installation with a recorded stack resumes at the AI step when it has never launched", async () => { + setupRootConfiguration("/tmp/installed-before-signin", async () => ({ + ...emptyConfiguration(), + installation: { harness: { id: "langgraph" } }, + localEval: { + stack: "local-abc123", + ownership: "created", + phase: "installed", + }, + })); + withLocalCli({}); + const view = await renderApp(); + expect( + await view.findByRole("heading", { name: "Connect your AI" }), + ).toBeTruthy(); + expect(view.queryByRole("button", { name: "Install" })).toBeNull(); expect(installationCalls()).toHaveLength(0); expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( false, ); }); +/** What an earlier app saved for an installation it connected to hosted Intelligence. */ +function previouslyHostedConfiguration(harness: string) { + return { + values: { + INTELLIGENCE_API_KEY: "synthetic-hosted-key", + INTELLIGENCE_API_URL: "https://api.intelligence.copilotkit.ai", + INTELLIGENCE_GATEWAY_WS_URL: "wss://realtime.intelligence.copilotkit.ai", + OPENBOT_ORGANIZATION_AUTH_URL: "https://company.example", + }, + saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, + launch: { harness: { id: harness } }, + installation: { harness: { id: harness } }, + autoStart: true, + }; +} + +test("an installation an earlier app connected to hosted Intelligence starts at the first screen and keeps its install", async () => { + const root = "/tmp/previously-hosted"; + setupRootConfiguration(root, async () => + previouslyHostedConfiguration("langgraph"), + ); + withLocalCli(readyLocal()); + const view = await renderApp(); + await view.findByRole("heading", { name: "Set up OpenBot" }); + expect(buttons(view)).toEqual(["Install"]); + await new Promise((resolve) => setTimeout(resolve, 20)); + // Nothing hosted is started, and nothing local either, until Install. + expect( + invokeCalls.some((call) => + ["start_stack", "run_local_eval", "set_up_local_eval"].includes( + call.command, + ), + ), + ).toBe(false); + await installLocal(view); + await view.findByRole("heading", { name: "Connect your AI" }); + // The finished install of the same Bot is kept. + expect(installationCalls()).toEqual([]); + await userEvent.click(view.getByRole("radio", { name: /OpenAI/ })); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await expectHandover(); + expect(localSequence()).toEqual([ + "set_up_local_eval", + "connect_local_eval", + "run_local_eval", + "start_stack", + "show_openbot", + ]); + // Started against the local stack, with nothing of the hosted connection. + expect(getStartStackPayload()).toEqual({ + root, + ...LOCAL_ADDRESSES, + harness: { id: "langgraph" }, + model: { provider: "openai", login: "api-key", saved: true }, + }); +}); + +test("a previously hosted installation of another Bot installs the default Bot", async () => { + const root = "/tmp/previously-hosted-crewai"; + setupRootConfiguration(root, async () => + previouslyHostedConfiguration("crewai"), + ); + withLocalCli(readyLocal()); + const view = await renderApp(); + await view.findByRole("heading", { name: "Set up OpenBot" }); + expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( + false, + ); + await installLocal(view); + await view.findByRole("heading", { name: "Connect your AI" }); + expect(installationCalls()).toEqual([ + { + command: "prepare_installation", + args: { root, harness: { id: "langgraph" } }, + }, + ]); +}); + test.each(["model", "intelligence"])( "a runtime %s authentication notification focuses refresh without adopting the running app", async (connection) => { @@ -752,7 +1591,7 @@ test.each(["model", "intelligence"])( name: connection === "model" ? "Refresh your AI connection" - : "Refresh your CopilotKit connection", + : "Getting ready", }), ).toBeTruthy(); expect( @@ -763,79 +1602,7 @@ test.each(["model", "intelligence"])( ), ).toBe(false); expect(installationCalls()).toHaveLength(0); - expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); - }, -); - -test.each(["reopen", "runtime"])( - "organization sign-in on %s retains the installation and opens its app after verification", - async (source) => { - const root = "/tmp/organization-installation"; - const authorityUrl = "https://company.example"; - setupRootConfiguration(root, async () => ({ - ...savedOpenAiConfiguration(), - values: { OPENBOT_ORGANIZATION_AUTH_URL: authorityUrl }, - saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, - launch: { harness: { id: "mastra" } }, - })); - const previous = invokeHandler; - let opens = 0; - invokeHandler = async (command, args) => { - if (command === "last_failure" && source === "runtime") - return { - said: "Your organization sign-in needs to be refreshed.", - connection: "organization", - }; - if (command === "show_openbot") { - if (source === "reopen" && opens++ === 0) - throw { - said: "Sign in to your organization again.", - connection: "organization", - }; - return null; - } - if (command === "begin_organization_sign_in") - return "https://company.example/api/auth/desktop"; - if (command === "finish_organization_sign_in") - return { - id: "employee", - email: "employee@example.test", - name: "Employee", - role: "user", - }; - if (command === "cancel_organization_sign_in") return null; - return previous(command, args); - }; - const view = await renderApp(); - expect( - view.getByRole("heading", { name: "Sign in to your organization" }), - ).toBeTruthy(); - expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); - expect(view.queryByText(/Step \d of 4/)).toBeNull(); - await userEvent.click( - view.getByRole("button", { name: "Continue with Google" }), - ); - expect(invokeCalls).toContainEqual({ - command: "begin_organization_sign_in", - args: { root, authorityUrl, provider: "google" }, - }); - expect(invokeCalls).toContainEqual({ - command: "finish_organization_sign_in", - args: { root }, - }); - expect( - invokeCalls.filter((call) => call.command === "show_openbot"), - ).toHaveLength(source === "reopen" ? 2 : 1); - expect( - invokeCalls.filter((call) => call.command === "start_stack"), - ).toHaveLength(source === "reopen" ? 1 : 0); - if (source === "reopen") { - expect( - invokeCalls.find((call) => call.command === "start_stack")?.args, - ).toMatchObject({ root, organizationAuthUrl: authorityUrl }); - } - expect(installationCalls()).toHaveLength(0); - expect(view.queryByRole("button", { name: "Ask" })).toBeNull(); + expect(view.queryByRole("button", { name: "Install" })).toBeNull(); }, ); @@ -843,11 +1610,7 @@ test.each(["model", "intelligence"])( "an unavailable saved %s connection opens only its refresh screen and returns to the existing app", async (connection) => { const root = "/tmp/installed-refresh"; - setupRootConfiguration(root, async () => ({ - ...savedOpenAiConfiguration(), - saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, - launch: { harness: { id: "mastra" } }, - })); + setupConnectedLocalStack(root, localStatus("ready", "valid")); const previous = invokeHandler; let starts = 0; invokeHandler = async (command, args) => { @@ -856,32 +1619,22 @@ test.each(["model", "intelligence"])( said: connection === "model" ? "That saved OpenAI API key is no longer available." - : "That saved CopilotKit connection is no longer available. Sign in again or enter a project key.", + : "OpenBot's connection to Intelligence on this computer is no longer available. Set up Intelligence again.", connection, }; - if (command === "show_openbot") return null; - 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 "refreshed-intelligence-key"; return previous(command, args); }; const view = await renderApp(); + await view.findByRole("heading", { + name: + connection === "model" ? "Refresh your AI connection" : "Getting ready", + }); expect( - view.getByRole("heading", { - name: - connection === "model" - ? "Refresh your AI connection" - : "Refresh your CopilotKit connection", - }), - ).toBeTruthy(); - expect(view.getByRole("alert").textContent).toContain( - "no longer available", - ); - expect(view.queryByText(/Step \d of 4/)).toBeNull(); - expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); + view + .getAllByRole("alert") + .some((alert) => alert.textContent?.includes("no longer available")), + ).toBe(true); + expect(view.queryByRole("button", { name: "Install" })).toBeNull(); if (connection === "model") { await userEvent.type( view.getByLabelText("OpenAI API key"), @@ -889,16 +1642,11 @@ test.each(["model", "intelligence"])( ); await userEvent.click(view.getByRole("button", { name: "Continue" })); } else { - await userEvent.click( - view.getByRole("button", { name: "Sign in to CopilotKit again" }), - ); - await userEvent.click( - await view.findByRole("button", { name: "Existing project" }), - ); - await userEvent.click( - view.getByRole("button", { name: "Start OpenBot" }), - ); + await userEvent.click(view.getByRole("button", { name: "Retry" })); } + await waitFor(() => + expect(invokeCalls).toContainEqual({ command: "show_openbot" }), + ); const startsMade = invokeCalls.filter( (call) => call.command === "start_stack", ); @@ -908,14 +1656,96 @@ test.each(["model", "intelligence"])( harness: { id: "mastra" }, ...(connection === "model" ? { model: { apiKey: "refreshed-provider-key" } } - : { apiKey: "refreshed-intelligence-key" }), + : {}), }); - expect(invokeCalls).toContainEqual({ command: "show_openbot" }); expect(installationCalls()).toHaveLength(0); - expect(view.queryByRole("button", { name: "Ask" })).toBeNull(); }, ); +/** + * The upgrade every earlier installation makes: its deployment is one this app cannot start (a + * release from before the server ran in a container), so Start is refused until Install lays out + * the new one. The window used to stop there with "Return to Install and try again" beside a button + * that said Repair installation. It now goes back to Install by itself. + */ +test("a Start refused until Install runs again goes back to Install by itself", async () => { + const root = "/tmp/installed-upgrade"; + // Installed with the default Bot, as the window itself installs: what it recorded as finished + // has to be installed again all the same. + setupConnectedLocalStack(root, localStatus("ready", "valid"), { + launch: { harness: { id: "langgraph" } }, + }); + const previous = invokeHandler; + let starts = 0; + invokeHandler = async (command, args) => { + if (command === "start_stack" && starts++ === 0) + throw { + said: "Finish installing OpenBot's local software before signing in or starting. Return to Install and try again.", + connection: "installation", + }; + if (command === "prepare_installation") return null; + return previous(command, args); + }; + const view = await renderApp(); + await waitFor(() => + expect( + invokeCalls.some((call) => call.command === "prepare_installation"), + ).toBe(true), + ); + expect( + invokeCalls.find((call) => call.command === "prepare_installation")?.args, + ).toMatchObject({ root }); + // Installed, it carries on to the AI screen, from which Start runs again. + await view.findByRole("heading", { name: /AI connection|Connect your AI/ }); +}); + +test("a refused provider's failure sits in the AI card and goes when another provider is chosen", async () => { + setupConnectedLocalStack( + "/tmp/installed-switch-provider", + localStatus("ready", "valid"), + ); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "last_failure") + return { said: "OpenAI refused the saved API key.", connection: "model" }; + if (command === "providers") + return [ + { + id: "openai", + name: "OpenAI", + summary: "Use OpenAI.", + logins: ["api-key"], + mark: null, + caution: null, + }, + { + id: "anthropic", + name: "Anthropic", + summary: "Use Claude.", + logins: ["api-key"], + mark: null, + caution: null, + }, + ]; + return previous(command, args); + }; + const view = await renderApp(); + const heading = await view.findByRole("heading", { + name: "Refresh your AI connection", + }); + const alert = await view.findByRole("alert"); + expect(alert.textContent).toContain("OpenAI refused the saved API key."); + // Inside the card it belongs to, not under it. + expect(alert.closest(".sheet")).toBe(heading.closest(".sheet")); + + await userEvent.click( + view.getByRole("button", { name: "Choose a different provider" }), + ); + expect(view.queryByRole("alert")).toBeNull(); + await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); + expect(view.queryByRole("alert")).toBeNull(); +}); + test("reopening a retained root waits for its saved setup without flashing the wizard", async () => { const root = "/tmp/reopen-pending-configuration"; const configuration = deferred>(); @@ -926,13 +1756,13 @@ 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.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. expect( - await view.findByRole("button", { name: "Set up OpenBot" }), + view.getByRole("heading", { name: "Getting OpenBot ready" }), ).toBeTruthy(); + expect(view.queryByRole("button", { name: "Install" })).toBeNull(); + await act(async () => configuration.resolve(savedOpenAiConfiguration())); + // A retained folder with no successful launch still needs the setup screens. + expect(await view.findByRole("button", { name: "Install" })).toBeTruthy(); expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( false, ); @@ -941,31 +1771,84 @@ test("reopening a retained root waits for its saved setup without flashing the w function useInterruptedShutdownSetup() { const root = "/tmp/interrupted-shutdown-root"; const notice = "OpenBot had trouble shutting down last time."; - setupRootConfiguration("/tmp/default-root", async () => ({ - values: { - INTELLIGENCE_API_URL: "https://own.example/api", - INTELLIGENCE_GATEWAY_WS_URL: "wss://own.example/ws", - }, - saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, - launch: { harness: { id: "mastra" } }, - })); + setupConnectedLocalStack("/tmp/default-root", localStatus("ready", "valid")); const previous = invokeHandler; invokeHandler = async (command, args) => { if (command === "selected_root") return root; if (command === "last_failure") return { said: notice }; - if (command === "show_openbot") return null; return previous(command, args); }; 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(); expect(view.getByRole("alert").textContent).toContain(notice); - expect(view.queryByRole("button", { name: "Set up OpenBot" }) !== null).toBe( - false, - ); + expect(view.queryByRole("button", { name: "Install" })).toBeNull(); expect( view.getByRole("heading", { name: "OpenBot is stopped" }), ).toBeTruthy(); @@ -976,12 +1859,13 @@ test("an interrupted shutdown restores saved setup for explicit recovery", async ); await userEvent.click(start); + await waitFor(() => + expect(invokeCalls).toContainEqual({ command: "show_openbot" }), + ); expect(getStartStackPayload()).toEqual({ root, - apiKey: "", - apiUrl: "https://own.example/api", - gatewayWsUrl: "wss://own.example/ws", + ...LOCAL_ADDRESSES, harness: { id: "mastra" }, model: { provider: "openai", login: "api-key", saved: true }, }); @@ -995,67 +1879,32 @@ test("an interrupted shutdown restores saved setup for explicit recovery", async ).toBe(false); expect(view.queryByText(notice)).toBeNull(); expect(invokeCalls).toContainEqual({ command: "show_openbot" }); - expect(view.queryByRole("button", { name: "Ask" }) !== null).toBe(false); }); -test.each(["model", "root", "Intelligence"])( - "changing the saved %s during recovery only repeats Ask for a different installation", - async (changed) => { - useInterruptedShutdownSetup(); - const view = await renderApp(); - const user = userEvent.setup({ document: view.container.ownerDocument }); - if (changed === "model") { - await user.click( - view.getByRole("button", { name: "Change AI connection" }), - ); - await user.type(view.getByLabelText("OpenAI API key"), "new-model-key"); - await user.click(view.getByRole("button", { name: "Continue" })); - } else if (changed === "root") { - await user.click( - view.getByRole("button", { name: "Change installation" }), - ); - const input = view.getByLabelText("Where OpenBot lives"); - await user.clear(input); - await user.type(input, "/tmp/changed-recovery-root"); - await completeInstallation(view); - await user.click(view.getByRole("button", { name: "Continue" })); - } else { - await user.click( - view.getByRole("button", { name: "Change CopilotKit connection" }), - ); - await user.click(view.getByText("Point at your own Intelligence server")); - const input = view.getByLabelText("API URL"); - await user.clear(input); - await user.type(input, "https://different.example/api"); - } - - if (changed !== "model") - await user.click(view.getByRole("button", { name: "Start OpenBot" })); - - expect(getStartStackPayload()).toMatchObject( - changed === "root" - ? { root: "/tmp/changed-recovery-root" } - : changed === "model" - ? { model: { apiKey: "new-model-key" } } - : { apiUrl: "https://different.example/api" }, - ); - expect(invokeCalls.some((call) => call.command === "show_openbot")).toBe( - changed !== "root", - ); - expect(view.queryByRole("button", { name: "Ask" }) !== null).toBe( - changed === "root", - ); - }, -); +test("changing the saved model during recovery still opens OpenBot after Start", async () => { + useInterruptedShutdownSetup(); + const view = await renderApp(); + await userEvent.click( + view.getByRole("button", { name: "Change AI connection" }), + ); + await userEvent.type(view.getByLabelText("OpenAI API key"), "new-model-key"); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await waitFor(() => + expect(invokeCalls).toContainEqual({ command: "show_openbot" }), + ); + expect(getStartStackPayload()).toMatchObject({ + model: { apiKey: "new-model-key" }, + }); + expect(view.queryByRole("button", { name: "Ask" })).toBeNull(); +}); test.each(["supervisor", "windows"])( "automatic reopen respects the existing %s blocker", async (blocker) => { - setupRootConfiguration("/tmp/reopen-blocked", async () => ({ - ...savedOpenAiConfiguration(), - saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" }, - launch: { harness: { id: "langgraph" } }, - })); + setupConnectedLocalStack( + "/tmp/reopen-blocked", + localStatus("ready", "valid"), + ); const previous = invokeHandler; invokeHandler = async (command, args) => { if (blocker === "supervisor" && command === "last_failure") @@ -1063,7 +1912,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 +1929,7 @@ test.each(["supervisor", "windows"])( view.getByText( blocker === "supervisor" ? "The supervisor stopped retrying." - : "Install WSL before continuing.", + : "wsl --install", ), ).toBeTruthy(); }, @@ -1090,11 +1939,10 @@ test("setup records telemetry without a consent gate and deduplicates viewed ste setupRootConfiguration("/tmp/private-setup-root", async () => emptyConfiguration(), ); - const previous = invokeHandler; - invokeHandler = async (command, args) => { - if (command === "record_setup_event") return null; - return previous(command, args); - }; + withLocalCli({ + record_setup_event: null, + set_up_local_eval: () => new Promise(() => {}), + }); let view!: ReturnType; await act(async () => { view = render( @@ -1109,22 +1957,26 @@ test("setup records telemetry without a consent gate and deduplicates viewed ste expect(setupEvents()).toEqual([ { event: { kind: "step_viewed", step: "welcome" } }, ]); - await userEvent.click(view.getByRole("button", { name: "Set up OpenBot" })); - await userEvent.click(view.getByRole("button", { name: "Continue" })); + await userEvent.click(view.getByRole("button", { name: "Install" })); + await view.findByRole("heading", { name: "Connect your AI" }); - expect(setupEvents()).toEqual([ - { event: { kind: "step_viewed", step: "welcome" } }, - { event: { kind: "step_viewed", step: "harness" } }, - { event: { kind: "harness_chosen", harness: "langgraph" } }, - { event: { kind: "step_viewed", step: "install" } }, + const events = setupEvents().map( + (event) => (event as { event: { kind: string; step?: string } }).event, + ); + expect(events.slice(0, 3)).toEqual([ + { kind: "step_viewed", step: "welcome" }, + { kind: "harness_chosen", harness: "langgraph" } as never, + { kind: "step_viewed", step: "account" }, ]); - await userEvent.click(view.getByRole("button", { name: "Back" })); - expect(setupEvents().at(-1)).toEqual({ - event: { kind: "step_viewed", step: "harness" }, - }); + expect(events.at(-1)).toEqual({ kind: "step_viewed", step: "model" }); + // Each step once, the development double mount included. + const viewed = events + .filter((event) => event.kind === "step_viewed") + .map((event) => event.step); + expect(new Set(viewed).size).toBe(viewed.length); }); -test("setup records only model categories and reaches Ask when telemetry is unavailable", async () => { +test("setup records only model categories and opens OpenBot when telemetry is unavailable", async () => { useCompatibleEndpointSetup({}); const previous = invokeHandler; invokeHandler = async (command, args) => { @@ -1143,11 +1995,8 @@ test("setup records only model categories and reaches Ask when telemetry is unav custom_base_url: true, }, }); - await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); - await view.findByRole("button", { name: "Ask" }); - expect(setupEvents().at(-1)).toEqual({ - event: { kind: "step_viewed", step: "ask" }, - }); + // Continue starts OpenBot, and it opens even though no event could be recorded. + await expectHandover(); const serialized = JSON.stringify(setupEvents()); for (const privateValue of [ privateUrl, @@ -1209,7 +2058,6 @@ function savedOpenAiConfiguration() { return { values: {}, saved: { - intelligenceApiKey: true, modelApiKeys: { openai: true, anthropic: false }, modelSessions: { openai: false, anthropic: false }, }, @@ -1220,7 +2068,6 @@ function emptyConfiguration() { return { values: {}, saved: { - intelligenceApiKey: false, modelApiKeys: { openai: false, anthropic: false }, modelSessions: { openai: false, anthropic: false }, }, @@ -1246,7 +2093,7 @@ test("cold reopen exposes explicit update recovery without a prepared installati const restore = await view.findByRole("button", { name: "Restore previous release", }); - expect(view.getByRole("button", { name: "Set up OpenBot" })).toBeTruthy(); + expect(view.getByRole("button", { name: "Install" })).toBeTruthy(); expect( view.queryByRole("button", { name: "Check runtime update" }), ).toBeNull(); @@ -1276,9 +2123,9 @@ test("fresh unprepared installation keeps update recovery hidden", async () => { invokeHandler = async (command, args) => command === "runtime_update_status" ? null : previous(command, args); const view = await renderApp(); - await view.findByRole("button", { name: "Set up OpenBot" }); + await view.findByRole("button", { name: "Install" }); expect(view.queryByText("Runtime updates")).toBeNull(); - await enterInstallation(view); + await userEvent.click(view.getByRole("button", { name: "Install" })); expect(view.queryByText("Runtime updates")).toBeNull(); }); @@ -1321,7 +2168,6 @@ test("leftover database is offered as a fresh start that carries on into Start", "synthetic-key", ); await userEvent.click(view.getByRole("button", { name: "Continue" })); - await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); const resets = () => invokeCalls.filter((call) => call.command === "reset_leftover_database"); const startFresh = await view.findByRole("button", { name: "Start fresh" }); @@ -1348,13 +2194,9 @@ test("leftover database is offered as a fresh start that carries on into Start", "disabled", true, ); - expect(view.getByRole("button", { name: "Working…" })).toHaveProperty( - "disabled", - true, - ); expect(starts).toBe(1); await act(async () => reset.resolve()); - await view.findByRole("button", { name: "Ask" }); + await expectHandover(); expect(starts).toBe(2); expect(installationCalls()).toHaveLength(1); }); @@ -1386,7 +2228,6 @@ test("starting fresh names every container and volume and removes only that list "synthetic-key", ); await userEvent.click(view.getByRole("button", { name: "Continue" })); - await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); const startFresh = await view.findByRole("button", { name: "Start fresh" }); expect(view.queryByText(/saved chats/)).toBeNull(); expect(view.queryByText(/encryption key/)).toBeNull(); @@ -1434,7 +2275,6 @@ test("a fresh start that cannot delete the leftovers is shown without starting", "synthetic-key", ); 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: "Start fresh" }), ); @@ -1443,7 +2283,8 @@ test("a fresh start that cannot delete the leftovers is shown without starting", expect( invokeCalls.filter((call) => call.command === "start_stack"), ).toHaveLength(1); - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( + // The way on is the Start step's own Retry. + expect(view.getByRole("button", { name: "Retry" })).toHaveProperty( "disabled", false, ); @@ -1480,21 +2321,6 @@ function setupRootConfiguration( if (command === "already_running") return false; if (command === "windows_blocker") return null; if (command === "last_failure") return null; - if (command === "harnesses") { - return [ - { - id: "langgraph", - name: "LangGraph", - summary: "Default Bot", - image: null, - health_path: null, - credential: "any-provider", - maintainer: "first-party", - mark: null, - port: 8000, - }, - ]; - } if (command === "providers") { return [ { @@ -1508,6 +2334,7 @@ function setupRootConfiguration( ]; } if (command === "start_stack") return null; + if (command === "show_openbot") return null; if (command === "connect_intelligence_apps") return null; throw new Error(`unexpected command ${command}`); }; @@ -1532,7 +2359,7 @@ test("Windows detection failure blocks setup and displays its diagnostic", async expect(alert.textContent).toContain(problem.said); await userEvent.click(view.getByText("Technical details")); expect(alert.textContent).toContain(problem.detail); - expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); + expect(view.queryByRole("button", { name: "Install" })).toBeNull(); expect(view.queryByRole("button", { name: "Start OpenBot" })).toBeNull(); expect( view.queryByText(/firmware settings|wsl --install|wsl --update/), @@ -1558,7 +2385,26 @@ test("a failed Windows blocker instruction is visible instead of an empty blocke expect((await view.findByRole("alert")).textContent).toContain( "The blocker instruction could not be read.", ); - expect(view.queryByRole("button", { name: "Set up OpenBot" })).toBeNull(); + expect(view.queryByRole("button", { name: "Install" })).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: "Install" })).toBeNull(); }); test("a successfully detected missing WSL feature keeps its setup instruction", async () => { @@ -1567,16 +2413,20 @@ test("a successfully detected missing WSL feature keeps its setup instruction", ); 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(); + expect(view.queryByRole("button", { name: "Install" })).toBeNull(); }); test("disabled Virtual Machine Platform displays its feature-specific fix and blocks setup", async () => { @@ -1596,14 +2446,16 @@ 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(); + expect(view.queryByRole("button", { name: "Install" })).toBeNull(); expect(view.queryByRole("button", { name: "Start OpenBot" })).toBeNull(); expect( invokeCalls.some((call) => @@ -1615,9 +2467,6 @@ test("disabled Virtual Machine Platform displays its feature-specific fix and bl }); type ExistingConfigurationValues = { - INTELLIGENCE_API_KEY?: string; - INTELLIGENCE_API_URL?: string; - INTELLIGENCE_GATEWAY_WS_URL?: string; OPENAI_API_KEY?: string; ANTHROPIC_API_KEY?: string; OPENAI_BASE_URL?: string; @@ -1628,20 +2477,11 @@ function useCompatibleEndpointSetup( ) { invokeHandler = async (command) => { if (command === "prepare_installation") return null; - if (command === "detect_engine") { - return { - engine: "docker", - responding: true, - engine_socket: null, - detail: "Docker is answering.", - }; - } if (command === "default_root") return "/tmp/openbot-app-test"; if (command === "already_configured") { return { values: existingValues, saved: { - intelligenceApiKey: true, modelApiKeys: { openai: true, anthropic: false }, modelSessions: { openai: false, anthropic: false }, }, @@ -1650,21 +2490,6 @@ function useCompatibleEndpointSetup( if (command === "already_running") return false; if (command === "windows_blocker") return null; if (command === "last_failure") return null; - if (command === "harnesses") { - return [ - { - id: "langgraph", - name: "LangGraph", - summary: "Default Bot", - image: null, - health_path: null, - credential: "any-provider", - maintainer: "first-party", - mark: null, - port: 8000, - }, - ]; - } if (command === "providers") { return [ { @@ -1678,8 +2503,10 @@ function useCompatibleEndpointSetup( ]; } if (command === "start_stack") return null; + if (command === "show_openbot") return null; throw new Error(`unexpected command ${command}`); }; + withLocalCli(readyLocal()); } async function enterCompatibleEndpoint( @@ -1689,11 +2516,7 @@ async function enterCompatibleEndpoint( ) { const view = await renderApp(); - await userEvent.click( - await view.findByRole("button", { name: "Set up OpenBot" }), - ); - await userEvent.click(await view.findByRole("button", { name: "Continue" })); - await completeInstallation(view); + await reachAi(view); await userEvent.click( await view.findByRole("radio", { name: /OpenAI-compatible/ }), ); @@ -1724,9 +2547,18 @@ async function startWithCompatibleEndpoint( endpointKey, containerBaseUrl, ); + // The go-ahead: OpenBot starts once local Intelligence reports ready. await userEvent.click(view.getByRole("button", { name: "Continue" })); - await userEvent.click( - await view.findByRole("button", { name: "Start OpenBot" }), + await waitForStart(); +} + +async function waitForStart(timeout?: number) { + await waitFor( + () => + expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( + true, + ), + timeout ? { timeout } : undefined, ); } @@ -1750,7 +2582,6 @@ function useSavedCompatibleEndpointSetup( BOT_MODEL: model, }, saved: { - intelligenceApiKey: true, model: savedModel, modelApiKeys: { compatible: keyed }, modelSessions: {}, @@ -1766,9 +2597,7 @@ test.each([true, false])( async (keyed) => { useSavedCompatibleEndpointSetup(undefined, undefined, keyed); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Set up OpenBot" })); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - await completeInstallation(view); + await reachAi(view); expect(view.getByLabelText("Base URL")).toHaveProperty( "value", "https://models.example/v1", @@ -1781,19 +2610,13 @@ test.each([true, false])( view.getByLabelText("API key, if the endpoint needs one"), ).toHaveProperty("value", ""); await userEvent.click(view.getByRole("button", { name: "Continue" })); - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, - ); - await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); + await waitForStart(); expect( invokeCalls.filter((call) => call.command === "start_stack"), ).toHaveLength(1); expect(getStartStackPayload()).toEqual({ root: "/tmp/openbot-app-test", - apiKey: "", - apiUrl: "https://api.intelligence.copilotkit.ai", - gatewayWsUrl: "wss://realtime.intelligence.copilotkit.ai", + ...LOCAL_ADDRESSES, harness: { id: "langgraph" }, model: { provider: "openai-compatible", @@ -1817,9 +2640,7 @@ test.each([ async (baseUrl, model) => { useSavedCompatibleEndpointSetup(baseUrl, model); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Set up OpenBot" })); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - await completeInstallation(view); + await reachAi(view); expect(view.getByRole("button", { name: "Continue" })).toHaveProperty( "disabled", true, @@ -1840,9 +2661,7 @@ test("an unsupported saved model kind does not become a startable endpoint", asy "unsupported-model", ); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Set up OpenBot" })); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - await completeInstallation(view); + await reachAi(view); expect(view.getByRole("button", { name: "Continue" })).toHaveProperty( "disabled", true, @@ -1853,1067 +2672,119 @@ test("an unsupported saved model kind does not become a startable endpoint", asy ); }); -test("Change the model after an Ask failure stops the stack and reaches the provider picker", async () => { - invokeHandler = async (command) => { - if (command === "prepare_installation") return null; - if (command === "detect_engine") { - return { - engine: "docker", - responding: true, - engine_socket: null, - detail: "Docker is answering.", - }; - } - if (command === "default_root") return "/tmp/openbot-app-test"; - if (command === "already_configured") { - return { - values: { - INTELLIGENCE_API_KEY: "ck-test", - OPENAI_API_KEY: "sk-test", - }, - saved: { - intelligenceApiKey: true, - modelApiKeys: { openai: true, anthropic: false }, - modelSessions: { openai: false, anthropic: false }, - }, - }; - } - if (command === "already_running") return false; - if (command === "windows_blocker") return null; - if (command === "last_failure") return null; - if (command === "harnesses") { - return [ - { - id: "langgraph", - name: "LangGraph", - summary: "Default Bot", - image: null, - health_path: null, - credential: "any-provider", - maintainer: "first-party", - mark: null, - port: 8000, - }, - ]; - } - if (command === "providers") { - return [ - { - id: "openai", - name: "OpenAI", - summary: "Use OpenAI.", - logins: ["api-key"], - mark: null, - caution: null, - }, - ]; - } - if (command === "start_stack") return null; - if (command === "ask_the_bot") { - throw { said: "The model could not answer.", detail: "401" }; - } - if (command === "stop_stack") return null; - throw new Error(`unexpected command ${command}`); - }; - - const view = await renderApp(); - - await userEvent.click( - await view.findByRole("button", { name: "Set up OpenBot" }), - ); - await userEvent.click(await view.findByRole("button", { name: "Continue" })); - await completeInstallation(view); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - await waitFor(() => - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, - ), - ); - - await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); - await userEvent.click(await view.findByRole("button", { name: "Ask" })); - await userEvent.click( - await view.findByRole("button", { name: "Change the model" }), - ); - - await waitFor(() => - expect(invokeCalls.some((call) => call.command === "stop_stack")).toBe( - true, - ), - ); - expect(view.getByRole("heading", { name: "Connect your AI" })).toBeTruthy(); - expect(view.getByRole("radio", { name: /OpenAI/ })).toBeTruthy(); - expect(view.queryByRole("button", { name: "Stop OpenBot" })).toBeNull(); -}); - -test("empty Intelligence projects keep sign-in retryable while Start waits for a project key", async () => { - let projectLists = 0; - invokeHandler = async (command) => { - if (command === "prepare_installation") return null; - if (command === "detect_engine") { - return { - engine: "docker", - responding: true, - engine_socket: null, - detail: "Docker is answering.", - }; - } - if (command === "default_root") return "/tmp/openbot-app-test"; - if (command === "already_configured") { - return { - values: { - OPENAI_API_KEY: "sk-test", - }, - saved: { - intelligenceApiKey: false, - modelApiKeys: { openai: true, anthropic: false }, - modelSessions: { openai: false, anthropic: false }, - }, - }; - } - if (command === "already_running") return false; - if (command === "windows_blocker") return null; - if (command === "last_failure") return null; - if (command === "harnesses") { - return [ - { - id: "langgraph", - name: "LangGraph", - summary: "Default Bot", - image: null, - health_path: null, - credential: "any-provider", - maintainer: "first-party", - mark: null, - port: 8000, - }, - ]; - } - if (command === "providers") { - return [ - { - id: "openai", - name: "OpenAI", - summary: "Use OpenAI.", - logins: ["api-key"], - mark: null, - caution: null, - }, - ]; - } - if (command === "begin_intelligence_sign_in") { - return "https://copilotkit.test/sign-in"; - } - if (command === "finish_intelligence_sign_in") { - projectLists += 1; - if (projectLists === 1) return []; - return [{ id: "project-1", name: "Project One" }]; - } - throw new Error(`unexpected command ${command}`); - }; - - const view = await renderApp(); - - await userEvent.click( - await view.findByRole("button", { name: "Set up OpenBot" }), - ); - await userEvent.click(await view.findByRole("button", { name: "Continue" })); - await completeInstallation(view); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - await userEvent.click( - await view.findByRole("button", { name: "Sign in to CopilotKit" }), - ); - - expect( - await view.findByText("That account has no projects yet.", { - exact: false, - }), - ).toBeTruthy(); - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - true, - ); - - await userEvent.click(view.getByRole("button", { name: "Sign in again" })); - - expect(await view.findByRole("button", { name: "Project One" })).toBeTruthy(); - - await userEvent.click( - view.getByText("Point at your own Intelligence server"), - ); - await userEvent.type(view.getByLabelText("Project key"), "ck-test"); - await waitFor(() => - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, - ), - ); -}); - -async function enterProjectSelection() { - setupRootConfiguration("/tmp/create-project", async () => - emptyConfiguration(), - ); - const previous = invokeHandler; - invokeHandler = async (command, args) => { - if (command === "begin_intelligence_sign_in") - return "https://copilotkit.test/sign-in"; - if (command === "finish_intelligence_sign_in") return []; - if (command === "intelligence_key_for") return "created-project-key"; - return previous(command, args); - }; - const view = await renderApp(); - 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 userEvent.click( - view.getByRole("button", { name: "Sign in to CopilotKit" }), - ); - return view; -} - -async function signInWithProjects(root: string, handler: Invoke) { - setupRootConfiguration(root, async () => emptyConfiguration()); - const previous = invokeHandler; - invokeHandler = async (command, args) => - command === "begin_intelligence_sign_in" - ? "https://copilotkit.test/sign-in" - : command === "finish_intelligence_sign_in" - ? [{ id: "project-1", name: "Project One" }] - : command === "intelligence_key_for" - ? "created-project-key" - : handler(command, args).catch((error) => - error === UNHANDLED - ? previous(command, args) - : Promise.reject(error), - ); - const view = await renderApp(); - await enterInstallation(view); - await completeInstallation(view); - await chooseOpenAi(view); - await userEvent.click( - view.getByRole("button", { name: "Sign in to CopilotKit" }), - ); - return view; -} - -const UNHANDLED = Symbol("unhandled"); - -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" })); -} - -/* - * APPS CANNOT FAIL SIGN-IN. Connecting apps ran inside the CopilotKit sign-in, so until Ops could - * provision, every managed sign-in failed with an apps error. Now sign-in finishes, the projects - * are offered, Start is available, and Connect apps retries for the same folder. - */ -test("managed sign-in finishes when apps cannot connect, and Connect apps retries", async () => { - let connects = 0; - const view = await signInWithProjects("/tmp/apps-retry", async (command) => { - if (command !== "connect_intelligence_apps") throw UNHANDLED; - if (++connects === 1) - throw { - said: "OpenBot couldn't connect your apps. Check your connection, then choose Connect apps. If it keeps happening, ask an admin.", - }; - return null; - }); - await userEvent.click( - await view.findByRole("button", { name: "Project One" }), - ); - expect(view.getByText("Connected to CopilotKit.")).toBeTruthy(); - expect( - await view.findByText(/OpenBot couldn't connect your apps/), - ).toBeTruthy(); - expect(view.getByText("You can start OpenBot without them.")).toBeTruthy(); - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, - ); - await userEvent.click(view.getByRole("button", { name: "Connect apps" })); - await waitFor(() => - expect(view.queryByRole("button", { name: "Connect apps" })).toBeNull(), - ); - expect(view.queryByText(/OpenBot couldn't connect your apps/)).toBeNull(); - expect( - invokeCalls - .filter((call) => call.command === "connect_intelligence_apps") - .map((call) => call.args), - ).toEqual([{ root: "/tmp/apps-retry" }, { root: "/tmp/apps-retry" }]); - expect(invokeCalls).toContainEqual({ - command: "intelligence_key_for", - args: { root: "/tmp/apps-retry", project: "project-1" }, - }); - expect(view.queryByLabelText(/Composio.*key/i)).toBeNull(); -}); - -/* - * A SIGN-IN BELONGS TO ITS FOLDER. The project list outlived a change of folder, so picking a - * project after switching made a key for the new folder with the old folder's sign-in, and the - * new folder started without its apps. - */ -test("changing the folder after sign-in drops that sign-in's projects", async () => { - const view = await signInWithProjects("/tmp/folder-a", async () => { - throw UNHANDLED; - }); - expect(await view.findByRole("button", { name: "Project One" })).toBeTruthy(); - await userEvent.click( - view.getByRole("button", { name: "Change installation" }), - ); - const rootField = view.getByLabelText("Where OpenBot lives"); - const user = userEvent.setup({ document: view.container.ownerDocument }); - await user.clear(rootField); - await user.type(rootField, "/tmp/folder-b"); - await act(async () => { - rootField.blur(); - }); - await completeInstallation(view); - await chooseOpenAi(view); - expect(view.queryByRole("button", { name: "Project One" })).toBeNull(); - expect( - view.getByRole("button", { name: "Sign in to CopilotKit" }), - ).toBeTruthy(); - expect( - invokeCalls.some((call) => call.command === "intelligence_key_for"), - ).toBe(false); -}); - -test("creating a named project connects it only after the user submits", async () => { - const view = await enterProjectSelection(); - const creation = deferred<{ id: string; name: string }>(); - const previous = invokeHandler; - invokeHandler = async (command, args) => - command === "create_intelligence_project" - ? creation.promise - : previous(command, args); - expect( - invokeCalls.some((call) => call.command === "create_intelligence_project"), - ).toBe(false); - const create = view.getByRole("button", { name: "Create project" }); - expect(create).toHaveProperty("disabled", true); - await userEvent.type( - view.getByLabelText("New project name"), - " OpenBot workspace ", - ); - await userEvent.click(create); - expect(invokeCalls).toContainEqual({ - command: "create_intelligence_project", - args: { root: "/tmp/create-project", name: "OpenBot workspace" }, - }); - expect( - view.getByRole("button", { name: "Creating project…" }), - ).toHaveProperty("disabled", true); - expect(view.getByLabelText("New project name")).toHaveProperty( - "disabled", - true, - ); - await act(async () => - creation.resolve({ id: "new-project", name: "OpenBot workspace" }), - ); - expect(view.getByText("Connected to CopilotKit.")).toBeTruthy(); - expect(invokeCalls).toContainEqual({ - command: "intelligence_key_for", - args: { root: "/tmp/create-project", project: "new-project" }, - }); - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, - ); -}); - -test("project creation failure preserves the name and retries without losing a created project", async () => { - const view = await enterProjectSelection(); - const previous = invokeHandler; - let creations = 0; - let provisions = 0; - invokeHandler = async (command, args) => { - if (command === "create_intelligence_project") { - if (++creations === 1) - throw { said: "The project could not be created." }; - return { id: "new-project", name: "OpenBot workspace" }; - } - if (command === "intelligence_key_for" && ++provisions === 1) - throw { said: "The project key could not be created." }; - return previous(command, args); - }; - await userEvent.type( - view.getByLabelText("New project name"), - "OpenBot workspace", - ); - await userEvent.click(view.getByRole("button", { name: "Create project" })); - expect(view.getByRole("alert").textContent).toContain( - "The project could not be created.", - ); - expect(view.getByLabelText("New project name")).toHaveProperty( - "value", - "OpenBot workspace", - ); - expect(view.getByRole("button", { name: "Create project" })).toHaveProperty( - "disabled", - false, - ); - await userEvent.click(view.getByRole("button", { name: "Create project" })); - expect(view.getByRole("alert").textContent).toContain( - "The project key could not be created.", - ); - await userEvent.click( - view.getByRole("button", { name: "OpenBot workspace" }), - ); - expect(view.getByText("Connected to CopilotKit.")).toBeTruthy(); - expect(creations).toBe(2); -}); - test("mount navigates to OpenBot only when the selected root is already owned and running", async () => { - const root = "/tmp/openbot-owned-running-root"; - setupRootConfiguration(root, async () => emptyConfiguration()); - const setupHandler = invokeHandler; - invokeHandler = async (command, args) => { - if (command === "already_running") { - expect(args).toEqual({ root }); - return true; - } - return setupHandler(command, args); - }; - - await renderApp(); - await waitFor(() => - expect(invokeCalls).toContainEqual({ command: "show_openbot" }), - ); -}); - -test("mount leaves setup visible when the shared port answers without selected root ownership", async () => { - const root = "/tmp/openbot-unowned-running-root"; - setupRootConfiguration(root, async () => emptyConfiguration()); - - const view = await renderApp(); - - expect( - await view.findByRole("button", { name: "Set up OpenBot" }), - ).toBeTruthy(); - expect(invokeCalls.some((call) => call.command === "show_openbot")).toBe( - false, - ); -}); - -for (const staleProbe of [false, true]) { - test(`recovery mount keeps setup available after ${staleProbe ? "a stale positive" : "a negative"} adoption probe`, async () => { - setupRootConfiguration("/tmp/openbot-worker-recovery", async () => - emptyConfiguration(), - ); - const setupHandler = invokeHandler; - const failure = { - said: "Part of OpenBot (worker) stopped and could not be started again. Try starting OpenBot once more.", - }; - invokeHandler = async (command, args) => { - if (command === "already_running") return staleProbe; - if (command === "last_failure") return failure; - if (command === "show_openbot") throw failure; - return setupHandler(command, args); - }; - const view = await renderApp(); - await waitFor(() => - expect( - invokeCalls.some((call) => call.command === "already_running"), - ).toBe(true), - ); - if (staleProbe) { - await waitFor(() => - expect( - invokeCalls.some((call) => call.command === "show_openbot"), - ).toBe(true), - ); - } - expect(view.getByRole("button", { name: "Set up OpenBot" })).toBeTruthy(); - expect(view.getByRole("alert").textContent).toContain(failure.said); - expect(view.queryByRole("button", { name: "Stop OpenBot" })).toBeNull(); - }); -} - -test("saved startup credentials enable Start without raw protected secrets on mount", async () => { - invokeHandler = async (command) => { - if (command === "prepare_installation") return null; - if (command === "detect_engine") { - return { - engine: "docker", - responding: true, - engine_socket: null, - detail: "Docker is answering.", - }; - } - if (command === "default_root") return "/tmp/openbot-app-test"; - if (command === "already_configured") { - return { - values: {}, - saved: { - intelligenceApiKey: true, - modelApiKeys: { openai: true, anthropic: false }, - modelSessions: { openai: false, anthropic: false }, - }, - }; - } - if (command === "already_running") return false; - if (command === "windows_blocker") return null; - if (command === "last_failure") return null; - if (command === "harnesses") { - return [ - { - id: "langgraph", - name: "LangGraph", - summary: "Default Bot", - image: null, - health_path: null, - credential: "any-provider", - maintainer: "first-party", - mark: null, - port: 8000, - }, - ]; - } - if (command === "providers") { - return [ - { - id: "openai", - name: "OpenAI", - summary: "Use OpenAI.", - logins: ["api-key"], - mark: null, - caution: null, - }, - ]; - } - throw new Error(`unexpected command ${command}`); - }; - - const view = await renderApp(); - - await userEvent.click( - await view.findByRole("button", { name: "Set up OpenBot" }), - ); - await userEvent.click(await view.findByRole("button", { name: "Continue" })); - await completeInstallation(view); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - - await waitFor(() => - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, - ), - ); - expect( - invokeCalls.filter((call) => call.command === "already_configured"), - ).toHaveLength(1); -}); - -async function chooseModelAfterRootEdit( - view: Awaited>, - apiKey?: string, -) { - expect(view.queryByRole("button", { name: "Start OpenBot" })).toBeNull(); - await completeInstallation(view); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); - if (apiKey) - await userEvent.type(view.getByLabelText("OpenAI API key"), apiKey); - await userEvent.click(view.getByRole("button", { name: "Continue" })); -} - -test("root edits reload saved configuration for that root and ignore stale saved responses", async () => { - const rootA = "/tmp/openbot-root-a"; - const rootB = "/tmp/openbot-root-b"; - const rootC = "/tmp/openbot-root-c"; - const savedForRootA = deferred>(); - const emptyForRootB = deferred>(); - const savedForRootC = deferred>(); - - setupRootConfiguration(rootA, async (requestedRoot) => { - if (requestedRoot === rootA) return savedForRootA.promise; - if (requestedRoot === rootB) return emptyForRootB.promise; - if (requestedRoot === rootC) return savedForRootC.promise; - throw new Error(`unexpected already_configured root ${requestedRoot}`); - }); - - const view = await renderApp(); - await act(async () => { - savedForRootA.resolve(savedOpenAiConfiguration()); - }); - - await userEvent.click( - await view.findByRole("button", { name: "Set up OpenBot" }), - ); - await userEvent.click(await view.findByRole("button", { name: "Continue" })); - await completeInstallation(view); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); - expect(view.getByText(/A saved OpenAI API key will be used/)).toBeTruthy(); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - await waitFor(() => - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, - ), - ); - - await userEvent.click( - view.getByRole("button", { name: "Change installation" }), - ); - const rootField = view.getByLabelText("Where OpenBot lives"); - const user = userEvent.setup({ - document: view.container.ownerDocument, - }); - await user.clear(rootField); - await user.type(rootField, rootB); - await act(async () => { - rootField.blur(); - }); - - await waitFor(() => - expect( - invokeCalls.filter((call) => call.command === "already_configured"), - ).toContainEqual({ command: "already_configured", args: { root: rootB } }), - ); - expect( - view.queryByRole("button", { name: "Continue to sign in" }), - ).toBeNull(); - - await user.clear(rootField); - await user.type(rootField, rootC); - await act(async () => { - rootField.blur(); - }); - - await waitFor(() => - expect( - invokeCalls.filter((call) => call.command === "already_configured"), - ).toContainEqual({ command: "already_configured", args: { root: rootC } }), - ); - await act(async () => { - emptyForRootB.resolve(emptyConfiguration()); - }); - expect( - view.queryByRole("button", { name: "Continue to sign in" }), - ).toBeNull(); - - await act(async () => { - savedForRootC.resolve(savedOpenAiConfiguration()); - }); - await chooseModelAfterRootEdit(view); - await waitFor(() => - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, - ), - ); - await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); - - expect(getStartStackPayload()).toMatchObject({ - root: rootC, - model: { - provider: "openai", - login: "api-key", - saved: true, - }, - }); -}); - -test("same-process setup remount prefers the retained selected root", async () => { - const rootA = "/tmp/openbot-default-root"; - const rootB = "/tmp/openbot-retained-root"; - setupRootConfiguration(rootA, async (requestedRoot) => { - if (requestedRoot !== rootB) - throw new Error(`unexpected already_configured root ${requestedRoot}`); - return savedOpenAiConfiguration(); - }); - const setupHandler = invokeHandler; - invokeHandler = async (command, args) => { - if (command === "selected_root") return rootB; - return setupHandler(command, args); - }; - - const view = await renderApp(); - await userEvent.click( - await view.findByRole("button", { name: "Set up OpenBot" }), - ); - await userEvent.click(await view.findByRole("button", { name: "Continue" })); - expect(view.getByLabelText("Where OpenBot lives")).toHaveProperty( - "value", - rootB, - ); - await completeInstallation(view); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - await userEvent.click( - await view.findByRole("button", { name: "Start OpenBot" }), - ); - - expect( - invokeCalls.filter((call) => call.command === "already_configured"), - ).toEqual([{ command: "already_configured", args: { root: rootB } }]); - expect(getStartStackPayload().root).toBe(rootB); -}); - -test.each([ - { - name: "initial folder reload", - pendingRoot: "a", - finalRoot: "b", - savedModel: false, - }, - { - name: "blur-started load", - pendingRoot: "b", - finalRoot: "c", - savedModel: true, - }, - { - name: "away-and-back edit", - pendingRoot: "a", - finalRoot: "a", - savedModel: false, - }, -])( - "root edits invalidate the $name before blur", - async ({ pendingRoot, finalRoot, savedModel }) => { - const rootA = "/tmp/openbot-root-a"; - const rootB = "/tmp/openbot-root-b"; - const currentRoot = `/tmp/openbot-root-${finalRoot}`; - const requests: Array<{ - root: string; - response: Deferred>; - }> = []; - setupRootConfiguration(rootA, async (root) => { - const response = deferred>(); - requests.push({ root, response }); - return response.promise; - }); - - const view = await renderApp(); - const user = userEvent.setup({ document: view.container.ownerDocument }); - // Bootstrap must finish checking installation before exposing any wizard controls. - await act(async () => - requests[0].response.resolve( - savedModel ? savedOpenAiConfiguration() : emptyConfiguration(), - ), - ); - await user.click( - await view.findByRole("button", { name: "Set up OpenBot" }), - ); - await user.click(await view.findByRole("button", { name: "Continue" })); - await completeInstallation(view); - await user.click(await view.findByRole("radio", { name: /OpenAI/ })); - expect( - Boolean(view.queryByText(/A saved OpenAI API key will be used/)), - ).toBe(savedModel); - if (!savedModel) { - await user.type( - view.getByLabelText("OpenAI API key"), - "sk-synthetic-current-model", - ); - } - await user.click(view.getByRole("button", { name: "Continue" })); - const previousStart = view.getByRole("button", { name: "Start OpenBot" }); - expect(previousStart).toHaveProperty("disabled", !savedModel); - await user.click(view.getByRole("button", { name: "Change installation" })); - const rootField = view.getByLabelText("Where OpenBot lives"); - - if (pendingRoot === "a") { - await user.click(rootField); - await act(async () => rootField.blur()); - } - - await user.clear(rootField); - await user.type(rootField, rootB); - if (pendingRoot === "b") { - await act(async () => rootField.blur()); - } - if (currentRoot !== rootB) { - await user.clear(rootField); - await user.type(rootField, currentRoot); - } - const pending = requests[requests.length - 1]; - expect(pending.root).toBe(`/tmp/openbot-root-${pendingRoot}`); - const requestCountBeforeBlur = requests.length; - - function expectBlockedSetup() { - expect(rootField).toHaveProperty("value", currentRoot); - expect(view.container.ownerDocument.activeElement === rootField).toBe( - true, - ); - expect( - view.queryByRole("button", { name: "Continue to sign in" }), - ).toBeNull(); - expect(view.queryByRole("button", { name: "Start OpenBot" })).toBeNull(); - expect(requests).toHaveLength(requestCountBeforeBlur); - expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( - false, - ); - } - - expectBlockedSetup(); - await act(async () => - pending.response.resolve({ - ...savedOpenAiConfiguration(), - values: { - INTELLIGENCE_API_KEY: "ck-synthetic-stale", - INTELLIGENCE_API_URL: "https://stale.example/api", - INTELLIGENCE_GATEWAY_WS_URL: "wss://stale.example/ws", - }, - }), - ); - expectBlockedSetup(); - - if (savedModel) { - // A different root's Intelligence connection cannot restore the model cleared by an edit. - await act(async () => rootField.blur()); - await act(async () => - requests[requests.length - 1].response.resolve({ - ...emptyConfiguration(), - saved: { ...emptyConfiguration().saved, intelligenceApiKey: true }, - }), - ); - await completeInstallation(view); - await user.click(await view.findByRole("radio", { name: /OpenAI/ })); - expect( - view.queryByText(/A saved OpenAI API key will be used/), - ).toBeNull(); - expect(view.getByRole("button", { name: "Continue" })).toHaveProperty( - "disabled", - true, - ); - await user.click(view.getByRole("button", { name: "Back" })); - await user.click(view.getByLabelText("Where OpenBot lives")); - await act(async () => view.getByLabelText("Where OpenBot lives").blur()); - } else { - await act(async () => rootField.blur()); - } - expect(requests[requests.length - 1].root).toBe(currentRoot); - await act(async () => - requests[requests.length - 1].response.resolve({ - ...savedOpenAiConfiguration(), - values: { - INTELLIGENCE_API_URL: "https://current.example/api", - INTELLIGENCE_GATEWAY_WS_URL: "wss://current.example/ws", - }, - }), - ); - if (savedModel) { - await user.click( - view.getByRole("button", { name: "Continue to sign in" }), - ); - await user.click(await view.findByRole("radio", { name: /OpenAI/ })); - await user.click(view.getByRole("button", { name: "Continue" })); - } else { - await chooseModelAfterRootEdit(view, "sk-synthetic-current-model"); - } - expect( - view.getByText( - "A saved CopilotKit connection will be checked when you start.", - ), - ).toBeTruthy(); - await user.click(view.getByText("Point at your own Intelligence server")); - expect(view.getByLabelText("Project key")).toHaveProperty("value", ""); - expect(view.getByLabelText("API URL")).toHaveProperty( - "value", - "https://current.example/api", - ); - expect(view.getByLabelText("Gateway WebSocket URL")).toHaveProperty( - "value", - "wss://current.example/ws", - ); - const currentStart = view.getByRole("button", { name: "Start OpenBot" }); - expect(currentStart).toHaveProperty("disabled", false); - await user.click(currentStart); - expect( - invokeCalls.filter((call) => call.command === "start_stack"), - ).toHaveLength(1); - expect(getStartStackPayload()).toEqual({ - root: currentRoot, - apiKey: "", - apiUrl: "https://current.example/api", - gatewayWsUrl: "wss://current.example/ws", - harness: { id: "langgraph" }, - model: savedModel - ? { provider: "openai", login: "api-key", saved: true } - : { - provider: "openai", - login: "api-key", - apiKey: "sk-synthetic-current-model", - }, - }); - }, -); - -function useBringYourOwnHarnessSetup() { - invokeHandler = async (command) => { - if (command === "prepare_installation") return null; - if (command === "detect_engine") { - return { - engine: "docker", - responding: true, - engine_socket: null, - detail: "Docker is answering.", - }; - } - if (command === "default_root") return "/tmp/openbot-app-test"; - if (command === "already_configured") { - return { - values: {}, - saved: { - intelligenceApiKey: true, - modelApiKeys: { openai: false, anthropic: false }, - modelSessions: { openai: false, anthropic: false }, - }, - }; - } - if (command === "already_running") return false; - if (command === "windows_blocker") return null; - if (command === "last_failure") return null; - if (command === "harnesses") { - return [ - { - id: "langgraph", - name: "LangGraph", - summary: "Default Bot", - image: null, - health_path: null, - credential: "any-provider", - maintainer: "first-party", - mark: null, - port: 8000, - }, - { - id: "byo-url", - name: "An agent you already run", - summary: "Give its address.", - image: null, - health_path: null, - credential: "their-endpoint", - maintainer: "community", - mark: null, - port: null, - }, - ]; - } - if (command === "providers") { - return [ - { - id: "openai-compatible", - name: "OpenAI-compatible", - summary: "Use your own endpoint.", - logins: ["endpoint"], - mark: null, - caution: null, - }, - ]; + const root = "/tmp/openbot-owned-running-root"; + setupRootConfiguration(root, async () => emptyConfiguration()); + const setupHandler = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "already_running") { + expect(args).toEqual({ root }); + return true; } - if (command === "start_stack") return null; - throw new Error(`unexpected command ${command}`); + return setupHandler(command, args); }; -} - -async function enterBringYourOwnHarnessEndpoint(agentUrl: string) { - const view = await renderApp(); - await userEvent.click( - await view.findByRole("button", { name: "Set up OpenBot" }), - ); - await userEvent.click(await view.findByText("Choose the agent framework")); - await userEvent.click( - await view.findByRole("radio", { name: /An agent you already run/ }), + await renderApp(); + await waitFor(() => + expect(invokeCalls).toContainEqual({ command: "show_openbot" }), ); +}); - const continueFromHarness = view.getByRole("button", { name: "Continue" }); - expect(continueFromHarness).toHaveProperty("disabled", true); - const agentEndpoint = view.getByLabelText("AG-UI endpoint"); - if (agentUrl) { - await userEvent.type(agentEndpoint, agentUrl.replaceAll("[", "[[")); - } - expect(agentEndpoint).toHaveProperty("value", agentUrl); - return view; -} +test("mount leaves setup visible when the shared port answers without selected root ownership", async () => { + const root = "/tmp/openbot-unowned-running-root"; + setupRootConfiguration(root, async () => emptyConfiguration()); -test.each([ - "", - "http://", - "https://", - "httpx://models.example/v1", - "httpfoo://models.example/v1", - "https://exa mple.example/v1", - "HTTP://agent.example/ag-ui", - "https:agent.example/ag-ui", -])("bring-your-own agent refuses startup for URL %s", async (agentUrl) => { - useBringYourOwnHarnessSetup(); - const view = await enterBringYourOwnHarnessEndpoint(agentUrl); + const view = await renderApp(); - const continueButton = view.getByRole("button", { name: "Continue" }); - expect(continueButton).toHaveProperty("disabled", true); - await userEvent.click(continueButton); - expect(view.getByRole("heading", { name: "Your first Bot" })).toBeTruthy(); - expect(view.queryByRole("heading", { name: "Connect your AI" })).toBeNull(); - expect(view.queryByRole("button", { name: "Start OpenBot" })).toBeNull(); - expect(invokeCalls.filter((call) => call.command === "start_stack")).toEqual( - [], + expect(await view.findByRole("button", { name: "Install" })).toBeTruthy(); + expect(invokeCalls.some((call) => call.command === "show_openbot")).toBe( + false, ); }); -test.each([ - ["https://agent.example/ag-ui", "https://agent.example/ag-ui"], - ["http://localhost:11434/v1", "http://localhost:11434/v1"], - ["https://models.example/v1", "https://models.example/v1"], - ["https://bücher.example/ag-ui", "https://bücher.example/ag-ui"], - ["http://[::1]:8000/ag-ui", "http://[::1]:8000/ag-ui"], - [" http://localhost:8000/ag-ui ", "http://localhost:8000/ag-ui"], -])( - "bring-your-own agent collects a distinct AG-UI endpoint for startup: %s", - async (agentUrl, expectedAgentUrl) => { - useBringYourOwnHarnessSetup(); - const view = await enterBringYourOwnHarnessEndpoint(agentUrl); - - const continueFromHarness = view.getByRole("button", { name: "Continue" }); - expect(continueFromHarness).toHaveProperty("disabled", false); - await userEvent.click(continueFromHarness); - await completeInstallation(view); - expect( - await view.findByRole("heading", { name: "Connect your AI" }), - ).toBeTruthy(); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); - await userEvent.type( - view.getByLabelText("Base URL"), - "https://provider.example/v1", +for (const staleProbe of [false, true]) { + test(`recovery mount keeps setup available after ${staleProbe ? "a stale positive" : "a negative"} adoption probe`, async () => { + setupRootConfiguration("/tmp/openbot-worker-recovery", async () => + emptyConfiguration(), ); - await userEvent.type(view.getByLabelText("Model name"), "local-model"); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - await userEvent.click( - await view.findByRole("button", { name: "Start OpenBot" }), + const setupHandler = invokeHandler; + const failure = { + said: "Part of OpenBot (worker) stopped and could not be started again. Try starting OpenBot once more.", + }; + invokeHandler = async (command, args) => { + if (command === "already_running") return staleProbe; + if (command === "last_failure") return failure; + if (command === "show_openbot") throw failure; + return setupHandler(command, args); + }; + const view = await renderApp(); + await waitFor(() => + expect( + invokeCalls.some((call) => call.command === "already_running"), + ).toBe(true), ); + if (staleProbe) { + await waitFor(() => + expect( + invokeCalls.some((call) => call.command === "show_openbot"), + ).toBe(true), + ); + } + expect(view.getByRole("button", { name: "Install" })).toBeTruthy(); + expect(view.getByRole("alert").textContent).toContain(failure.said); + expect(view.queryByRole("button", { name: "Stop OpenBot" })).toBeNull(); + }); +} - const payload = getStartStackPayload(); - expect( - invokeCalls.filter((call) => call.command === "start_stack"), - ).toHaveLength(1); - expect(payload).toEqual({ - root: "/tmp/openbot-app-test", - apiKey: "", - apiUrl: "https://api.intelligence.copilotkit.ai", - gatewayWsUrl: "wss://realtime.intelligence.copilotkit.ai", - harness: { id: "byo-url", agentUrl: expectedAgentUrl }, - model: { - provider: "openai-compatible", - login: "endpoint", - baseUrl: "https://provider.example/v1", - model: "local-model", - }, - }); - expect(payload.model).not.toHaveProperty("apiKey"); - }, -); +test("saved startup credentials start without raw protected secrets on mount", async () => { + setupRootConfiguration("/tmp/openbot-app-test", async () => + savedOpenAiConfiguration(), + ); + withLocalCli(readyLocal()); + + const view = await renderApp(); + await reachAi(view); + await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await waitForStart(); + + expect(getStartStackPayload().model).toEqual({ + provider: "openai", + login: "api-key", + saved: true, + }); + expect( + invokeCalls.filter((call) => call.command === "already_configured"), + ).toHaveLength(1); +}); + +test("same-process setup remount prefers the retained selected root", async () => { + const rootA = "/tmp/openbot-default-root"; + const rootB = "/tmp/openbot-retained-root"; + setupRootConfiguration(rootA, async (requestedRoot) => { + if (requestedRoot !== rootB) + throw new Error(`unexpected already_configured root ${requestedRoot}`); + return savedOpenAiConfiguration(); + }); + const setupHandler = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "selected_root") return rootB; + return setupHandler(command, args); + }; + withLocalCli(readyLocal()); + + const view = await renderApp(); + await reachAi(view); + await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await waitForStart(); + + expect( + invokeCalls.filter((call) => call.command === "already_configured"), + ).toEqual([{ command: "already_configured", args: { root: rootB } }]); + expect(installationCalls()[0].args).toMatchObject({ root: rootB }); + expect(getStartStackPayload().root).toBe(rootB); +}); test.each([ "http://", @@ -2950,9 +2821,7 @@ test.each(["http://localhost:11434/v1", "https://models.example/v1"])( ).toHaveLength(1); expect(payload).toEqual({ root: "/tmp/openbot-app-test", - apiKey: "", - apiUrl: "https://api.intelligence.copilotkit.ai", - gatewayWsUrl: "wss://realtime.intelligence.copilotkit.ai", + ...LOCAL_ADDRESSES, harness: { id: "langgraph" }, model: { provider: "openai-compatible", @@ -2995,17 +2864,13 @@ test("saved compatible endpoint restores the optional container URL", async () = ); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Set up OpenBot" })); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - await completeInstallation(view); + await reachAi(view); expect( view.getByLabelText("Container Base URL, if different"), ).toHaveProperty("value", "http://ollama:11434/v1"); await userEvent.click(view.getByRole("button", { name: "Continue" })); - await userEvent.click( - await view.findByRole("button", { name: "Start OpenBot" }), - ); + await waitForStart(); expect(getStartStackPayload().model).toMatchObject({ provider: "openai-compatible", @@ -3047,7 +2912,6 @@ for (const provider of [ ...emptyConfiguration(), saved: { ...emptyConfiguration().saved, - intelligenceApiKey: true, modelSessions: { [provider.id]: session === "saved" }, }, })); @@ -3072,15 +2936,10 @@ for (const provider of [ } return setupHandler(command, args); }; + withLocalCli(readyLocal()); const view = await renderApp(); - await userEvent.click( - await view.findByRole("button", { name: "Set up OpenBot" }), - ); - await userEvent.click( - await view.findByRole("button", { name: "Continue" }), - ); - await completeInstallation(view); + await reachAi(view); await userEvent.click( await view.findByRole("radio", { name: new RegExp(provider.name) }), ); @@ -3106,17 +2965,15 @@ 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" })); - await userEvent.click( - await view.findByRole("button", { name: "Start OpenBot" }), - ); + // 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" })); + } + // A fresh sign-in shows its tick for a second before it moves on. + await waitForStart(4000); const payload = getStartStackPayload(); expect(payload.model).not.toHaveProperty("apiKey"); @@ -3129,74 +2986,35 @@ for (const provider of [ }, ); - test(`saved ${provider.name} plan session enables Start without raw protected secrets on mount`, async () => { - invokeHandler = async (command) => { - if (command === "prepare_installation") return null; - if (command === "detect_engine") { - return { - engine: "docker", - responding: true, - engine_socket: null, - detail: "Docker is answering.", - }; - } - if (command === "default_root") return "/tmp/openbot-app-test"; - if (command === "already_configured") { - return { - values: {}, - saved: { - intelligenceApiKey: true, - modelApiKeys: { openai: false, anthropic: false }, - modelSessions: { - openai: provider.id === "openai", - anthropic: provider.id === "anthropic", + test(`saved ${provider.name} plan session starts without raw protected secrets on mount`, async () => { + setupRootConfiguration("/tmp/openbot-app-test", async () => ({ + values: {}, + saved: { + modelApiKeys: { openai: false, anthropic: false }, + modelSessions: { + openai: provider.id === "openai", + anthropic: provider.id === "anthropic", + }, + }, + })); + const setupHandler = invokeHandler; + invokeHandler = async (command, args) => + command === "providers" + ? [ + { + id: provider.id, + name: provider.name, + summary: `Use ${provider.name}.`, + logins: ["plan", "api-key"], + mark: null, + caution: null, }, - }, - }; - } - if (command === "already_running") return false; - if (command === "windows_blocker") return null; - if (command === "last_failure") return null; - if (command === "harnesses") { - return [ - { - id: "langgraph", - name: "LangGraph", - summary: "Default Bot", - image: null, - health_path: null, - credential: "any-provider", - maintainer: "first-party", - mark: null, - port: 8000, - }, - ]; - } - if (command === "providers") { - return [ - { - id: provider.id, - name: provider.name, - summary: `Use ${provider.name}.`, - logins: ["plan", "api-key"], - mark: null, - caution: null, - }, - ]; - } - if (command === "start_stack") return null; - throw new Error(`unexpected command ${command}`); - }; + ] + : setupHandler(command, args); + withLocalCli(readyLocal()); const view = await renderApp(); - - await userEvent.click( - await view.findByRole("button", { name: "Set up OpenBot" }), - ); - await userEvent.click( - await view.findByRole("button", { name: "Continue" }), - ); - await completeInstallation(view); + await reachAi(view); await userEvent.click( await view.findByRole("radio", { name: new RegExp(provider.name) }), ); @@ -3206,13 +3024,7 @@ for (const provider of [ ), ).toBeTruthy(); await userEvent.click(view.getByRole("button", { name: "Continue" })); - - await waitFor(() => - expect( - view.getByRole("button", { name: "Start OpenBot" }), - ).toHaveProperty("disabled", false), - ); - await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); + await waitForStart(); expect( invokeCalls.filter((call) => call.command === "already_configured"), @@ -3227,11 +3039,8 @@ for (const provider of [ expect(args).not.toContain("OPENAI_API_KEY"); expect(args).not.toContain("CLAUDE_CODE_OAUTH_TOKEN"); } - expect( - invokeCalls.find((call) => call.command === "start_stack")?.args, - ).toMatchObject({ + expect(getStartStackPayload()).toMatchObject({ root: "/tmp/openbot-app-test", - apiKey: "", model: { provider: provider.id, login: "plan", @@ -3239,6 +3048,7 @@ for (const provider of [ }, harness: { id: "langgraph" }, }); + expect(getStartStackPayload()).not.toHaveProperty("apiKey"); }); } @@ -3247,7 +3057,7 @@ for (const provider of [ { id: "anthropic", name: "Anthropic", plan: "Claude" }, ]) { for (const login of ["plan", "api-key"] as const) { - test(`unknown legacy ${provider.name} ${login} and Intelligence reuse stays passive until Start`, async () => { + test(`unknown legacy ${provider.name} ${login} reuse stays passive until Continue`, async () => { setupRootConfiguration("/tmp/synthetic-legacy-root", async () => ({ values: {}, saved: {}, @@ -3271,14 +3081,9 @@ for (const provider of [ }; return setupHandler(command, args); }; + withLocalCli(readyLocal()); const view = await renderApp(); - await userEvent.click( - await view.findByRole("button", { name: "Set up OpenBot" }), - ); - await userEvent.click( - await view.findByRole("button", { name: "Continue" }), - ); - await completeInstallation(view); + await reachAi(view); await userEvent.click( await view.findByRole("radio", { name: new RegExp(provider.name) }), ); @@ -3294,45 +3099,26 @@ for (const provider of [ : `Use a saved ${provider.name} API key`, }), ); - await userEvent.click(view.getByRole("button", { name: "Continue" })); - expect( - view.getByRole("button", { name: "Start OpenBot" }), - ).toHaveProperty("disabled", true); - await userEvent.click( - view.getByRole("button", { name: "Use a saved connection" }), - ); - expect(view.queryByText("Connected to CopilotKit.")).toBeNull(); - // Returning to the provider screen retains deliberate reuse without signing in automatically. - await userEvent.click(view.getByRole("button", { name: "Back" })); - await userEvent.click( - await view.findByRole("button", { name: "Continue" }), - ); + // Choosing to reuse it signs in to nothing and starts nothing. expect( invokeCalls.some((call) => - /sign_in|start_stack|ask_the_bot/.test(call.command), + /_sign_in$|start_stack|show_openbot/.test(call.command), ), ).toBe(false); - await userEvent.click( - view.getByRole("button", { name: "Start OpenBot" }), - ); + await userEvent.click(view.getByRole("button", { name: "Continue" })); await view.findByText("Synthetic saved credential is unavailable."); expect(getStartStackPayload()).toMatchObject({ - apiKey: "", model: { provider: provider.id, login, saved: true }, }); + expect(getStartStackPayload()).not.toHaveProperty("apiKey"); expect(getStartStackPayload().model).not.toHaveProperty("token"); expect(getStartStackPayload().model).not.toHaveProperty("apiKey"); - expect( - view.getByRole("button", { name: "Sign in to CopilotKit again" }), - ).toBeTruthy(); }); } } test("Start credential failures do not expose a restore action", async () => { - useCompatibleEndpointSetup({ - INTELLIGENCE_API_KEY: "synthetic-intelligence", - }); + useCompatibleEndpointSetup({}); const previous = invokeHandler; invokeHandler = async (command, args) => { if (command === "start_stack") @@ -3355,7 +3141,6 @@ test("Start credential failures do not expose a restore action", async () => { "synthetic-model-key", ); await userEvent.click(view.getByRole("button", { name: "Continue" })); - await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); expect( await view.findByText("Saved credential needs authorization."), @@ -3373,143 +3158,74 @@ test("Start credential failures do not expose a restore action", async () => { ).toEqual([]); }); -test("the Enter that finishes a composed character does not ask the Bot", 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" })); - const question = await view.findByLabelText("Your question"); - // Keys land on the focused field. - await userEvent.click(question); +const INSTALL_REQUIRED = + "Finish installing OpenBot's local software before signing in or starting. Return to Install and try again."; - // Japanese, Chinese and Korean are typed through an input method, where Enter confirms the - // character being built. Chromium marks that keydown `isComposing`; the macOS WebKit webview - // sends it after compositionend with key code 229. Neither should send the question. - await act(async () => { - fireEvent.keyDown(question, { key: "Enter", isComposing: true }); - fireEvent.keyDown(question, { key: "Enter", keyCode: 229 }); +function withAnthropicPlan(begin: () => unknown) { + 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: begin, + finish_claude_sign_in: "synthetic-plan-token", + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), }); - - expect(invokeCalls.filter((call) => call.command === "ask_the_bot")).toEqual( - [], - ); -}); - -async function enterSelfHostedIntelligence() { - const view = await enterProjectSelection(); - await userEvent.click( - view.getByText("Point at your own Intelligence server"), - ); - const user = userEvent.setup({ document: window.document }); - await user.clear(view.getByLabelText("API URL")); - await user.type( - view.getByLabelText("API URL"), - "https://intelligence.example.test", - ); - return view; } -test("self-hosted Intelligence sign-in selects a project and receives its provisioned key", async () => { - const view = await enterSelfHostedIntelligence(); - const previous = invokeHandler; - invokeHandler = async (command, args) => { - if (command === "begin_self_hosted_intelligence_sign_in") - return [{ id: "42", name: "Self-hosted team" }]; - if (command === "finish_self_hosted_intelligence_sign_in") - return "self-hosted-project-key"; - return previous(command, args); - }; - await userEvent.click( - view.getByRole("button", { name: "Sign in to Intelligence" }), +test("a plan sign-in refused for a stale installation goes back to Install and returns to the same provider", async () => { + setupRootConfiguration("/tmp/stale-install", async () => + emptyConfiguration(), ); - expect(invokeCalls).toContainEqual({ - command: "begin_self_hosted_intelligence_sign_in", - args: { - root: "/tmp/create-project", - apiUrl: "https://intelligence.example.test", - }, + let refused = true; + withAnthropicPlan(() => { + if (refused) + throw { + said: INSTALL_REQUIRED, + detail: + "The deployment folder or Bot selection changed after installation.", + }; + return "https://claude.example/authorize"; }); - expect(view.queryByRole("button", { name: "Create project" })).toBeNull(); + const view = await renderApp(); + await installLocal(view); + await userEvent.click(await view.findByRole("radio", { name: /Anthropic/ })); await userEvent.click( - await view.findByRole("button", { name: "Self-hosted team" }), - ); - expect(invokeCalls).toContainEqual({ - command: "finish_self_hosted_intelligence_sign_in", - args: { root: "/tmp/create-project", project: "42" }, - }); - expect(await view.findByText("Connected to Intelligence.")).toBeTruthy(); - expect(view.getByLabelText("Project key")).toHaveProperty( - "value", - "self-hosted-project-key", + await view.findByRole("button", { name: "Sign in with Anthropic" }), ); - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, + expect((await view.findByRole("alert")).textContent).toContain( + INSTALL_REQUIRED, ); + expect(stepLabel(view)).toContain("Install"); + refused = false; + await userEvent.click(view.getByRole("button", { name: "Install OpenBot" })); + const radio = await view.findByRole("radio", { name: /Anthropic/ }); + expect((radio as HTMLInputElement).checked).toBe(true); + expect(stepLabel(view)).toContain("AI"); + expect(view.queryByRole("alert")).toBeNull(); }); -test("cancelling self-hosted login ignores a late project response and keeps manual setup available", async () => { - const view = await enterSelfHostedIntelligence(); - const projects = deferred<{ id: string; name: string }[]>(); - const previous = invokeHandler; - invokeHandler = async (command, args) => { - if (command === "begin_self_hosted_intelligence_sign_in") - return projects.promise; - if (command === "cancel_self_hosted_intelligence_sign_in") return; - return previous(command, args); - }; - await userEvent.click( - view.getByRole("button", { name: "Sign in to Intelligence" }), - ); - expect(view.getByRole("status").textContent).toContain( - "Intelligence browser window", - ); - expect(view.getByLabelText("API URL")).toHaveProperty("disabled", true); - await userEvent.click( - view.getByRole("button", { name: "Cancel Intelligence sign-in" }), - ); - expect( - invokeCalls.some( - (call) => call.command === "cancel_self_hosted_intelligence_sign_in", - ), - ).toBe(true); - await act(async () => - projects.resolve([{ id: "42", name: "Cancelled team" }]), - ); - expect(view.queryByRole("button", { name: "Cancelled team" })).toBeNull(); - expect(view.getByLabelText("API URL")).toHaveProperty("disabled", false); - await userEvent.type(view.getByLabelText("Project key"), "manual-key"); - expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( - "disabled", - false, +test("any other plan sign-in failure still shows inline on the AI step", async () => { + setupRootConfiguration("/tmp/other-failure", async () => + emptyConfiguration(), ); -}); - -test("self-hosted provisioning failure offers a fresh sign-in without retaining a stale picker", async () => { - const view = await enterSelfHostedIntelligence(); - const previous = invokeHandler; - invokeHandler = async (command, args) => { - if (command === "begin_self_hosted_intelligence_sign_in") - return [{ id: "42", name: "Team" }]; - if (command === "finish_self_hosted_intelligence_sign_in") - throw { said: "Your account cannot create the Learning container." }; - return previous(command, args); - }; + withAnthropicPlan(() => { + throw { said: "Sign-in was interrupted." }; + }); + const view = await renderApp(); + await installLocal(view); + await userEvent.click(await view.findByRole("radio", { name: /Anthropic/ })); await userEvent.click( - view.getByRole("button", { name: "Sign in to Intelligence" }), + await view.findByRole("button", { name: "Sign in with Anthropic" }), ); - await userEvent.click(await view.findByRole("button", { name: "Team" })); - expect(view.getByRole("alert").textContent).toContain( - "cannot create the Learning container", - ); - expect(view.queryByRole("button", { name: "Team" })).toBeNull(); - expect( - view.getByRole("button", { name: "Sign in to Intelligence" }), - ).toHaveProperty("disabled", false); - expect(view.getByLabelText("Project key")).toHaveProperty("value", ""); + await view.findByText("Sign-in was interrupted."); + expect(stepLabel(view)).toContain("AI"); }); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 5e1e16f3db..afcd927088 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,15 +1,23 @@ 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 { - DEFAULT_HARNESS, - type HarnessChoice, - HarnessPicker, -} from "./HarnessPicker"; +import { CopilotKitAccount } from "./CopilotKitAccount"; +import { useWindowFitsContent } from "./fitWindow"; +import { DEFAULT_HARNESS, type HarnessChoice } from "./harness"; import { isHttpEndpointUrl } from "./http-endpoint-url"; -import { OrganizationSignIn } from "./OrganizationSignIn"; -import { asProblem, Failure, InlineFailure, type Problem } from "./Problem"; +import { IntelligenceOpening } from "./IntelligenceOpening"; +import { + LocalIntelligence, + type LocalStartable, + LocalStatus, + useLocalSequence, +} from "./LocalIntelligence"; +import { + intelligenceStopProblem, + offerLocalIntelligence, + splitStopFailure, +} from "./localEvalCli"; +import { asProblem, Failure, type Problem } from "./Problem"; import { type HeldConfiguration, type ModelChoice, @@ -17,7 +25,16 @@ import { recordedModel, } from "./ProviderPicker"; import { RuntimeUpdates } from "./RuntimeUpdates"; +import { Sections } from "./Sections"; import { StartFresh } from "./StartFresh"; +import { + INSTALL_BLURB, + type Progress, + plainInstallSteps, + SetupProgress, + StatusLine, +} from "./SetupProgress"; +import { ShuttingDown, useQuitStatus } from "./ShuttingDown"; import { harnessChoiceEvent, modelChoiceEvent, @@ -25,35 +42,23 @@ import { type SetupStep, } from "./telemetry"; import { Welcome } from "./Welcome"; - -type EngineStatus = { - engine: "docker" | "podman" | null; - responding: boolean; - engine_socket: string | null; - 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; -}; +import { type Blocker, WindowsBlocker, WindowsRestart } from "./WindowsBlocker"; type AlreadyConfigured = { values: Record; saved: NonNullable; + /** Reported only for an installation with a local Intelligence stack recorded. */ 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) { @@ -64,31 +69,27 @@ function installationKeyFor(root: string, harness: HarnessChoice | null) { ]); } -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() { - const [engine, setEngine] = useState(null); + useWindowFitsContent(); + return ( + <> + + + + ); +} + +function Setup() { 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); - const [apiKey, setApiKey] = useState(""); - const [organizationAuthorityUrl, setOrganizationAuthorityUrl] = useState< - string | null - >(null); - const [refreshOrganization, setRefreshOrganization] = useState(false); + const [localStack, setLocalStack] = useState(null); + const [localConnected, setLocalConnected] = useState(false); + // A relaunch found a connected stack it may start. + const [localAutoStart, setLocalAutoStart] = useState(false); /* * Which Bot and which model, as two separate answers. * @@ -100,188 +101,30 @@ export function App() { id: DEFAULT_HARNESS, }); const [model, setModel] = useState(null); - /** Model credentials a previous run already wrote, so the provider screen arrives filled in. */ - const [alreadyHeld, setAlreadyHeld] = useState({}); - const [projects, setProjects] = useState< - { id: string; name: string }[] | null - >(null); - const [projectName, setProjectName] = useState(""); - const [creatingProject, setCreatingProject] = useState(false); - const [signingIn, setSigningIn] = useState(false); - const [intelligenceSignInKind, setIntelligenceSignInKind] = useState< - "managed" | "self-hosted" - >("managed"); - const [selfHostedActive, setSelfHostedActive] = useState(false); - const selfHostedAttempt = useRef(0); - const selfHostedActiveRef = useRef(false); - const cancelSelfHostedSignIn = useCallback(async () => { - if (!selfHostedActiveRef.current) return; - selfHostedActiveRef.current = false; - selfHostedAttempt.current += 1; - setSelfHostedActive(false); - setSigningIn(false); - setProjects(null); - try { - await invoke("cancel_self_hosted_intelligence_sign_in"); - } catch (error) { - setFailure(asProblem(error)); - } - }, []); - - async function signInToSelfHostedIntelligence() { - await cancelSelfHostedSignIn(); - const attempt = ++selfHostedAttempt.current; - selfHostedActiveRef.current = true; - setSelfHostedActive(true); - setIntelligenceSignInKind("self-hosted"); - setSigningIn(true); - setFailure(null); - setProjects(null); - setApiKey(""); - setReuseIntelligence(false); - try { - const available = await invoke<{ id: string; name: string }[]>( - "begin_self_hosted_intelligence_sign_in", - { root, apiUrl }, - ); - if (selfHostedAttempt.current === attempt) setProjects(available); - } catch (error) { - if (selfHostedAttempt.current === attempt) { - setFailure(asProblem(error)); - selfHostedActiveRef.current = false; - setSelfHostedActive(false); - } - } finally { - if (selfHostedAttempt.current === attempt) setSigningIn(false); - } - } - - /* - * The address the browser was sent to, kept so the screen can show it. - * - * Both plan sign-ins already do this, for the reason written next to them: an open that silently - * did nothing, or a machine with no registered browser, leaves somebody watching a spinner with - * no idea where they are meant to go. This one threw the address away, so that case had no way - * out at all. - */ - const [signInUrl, setSignInUrl] = useState(null); - const managedAttempt = useRef(0); /* - * This installation's apps, connected after a managed sign-in has already succeeded. + * The model as of the latest choice, without waiting for a render. * - * A follow-on step that can only add a key. It used to run inside the sign-in, so a failure to - * connect apps failed the whole CopilotKit sign-in. Now sign-in and Start never wait on it, and a - * failure leaves apps unavailable with Connect apps to try again. + * 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 [apps, setApps] = useState< - | { state: "connecting" } - | { state: "ready" } - | { state: "unavailable"; problem: Problem } - | null - >(null); - - async function connectApps(attempt = managedAttempt.current) { - setApps({ state: "connecting" }); - try { - await invoke("connect_intelligence_apps", { root: root.trim() }); - if (managedAttempt.current === attempt) setApps({ state: "ready" }); - } catch (error) { - if (managedAttempt.current === attempt) - setApps({ state: "unavailable", problem: asProblem(error) }); - } - } - - async function signInToCopilotKit() { - await cancelSelfHostedSignIn(); - const attempt = ++managedAttempt.current; - setIntelligenceSignInKind("managed"); - setSigningIn(true); - setFailure(null); - setSignInUrl(null); - setApps(null); - try { - const url = await invoke("begin_intelligence_sign_in", { - root: root.trim(), - }); - if (managedAttempt.current !== attempt) return; - setSignInUrl(url); - const available = await invoke<{ id: string; name: string }[]>( - "finish_intelligence_sign_in", - { root: root.trim() }, - ); - if (managedAttempt.current !== attempt) return; - setProjects(available); - void connectApps(attempt); - } catch (error) { - if (managedAttempt.current === attempt) setFailure(asProblem(error)); - } finally { - if (managedAttempt.current === attempt) { - setSigningIn(false); - setSignInUrl(null); - } - } - } - - async function pickProject(id: string) { - setSigningIn(true); - setFailure(null); - const selfHosted = intelligenceSignInKind === "self-hosted"; - const attempt = selfHostedAttempt.current; - try { - const key = await invoke( - selfHosted - ? "finish_self_hosted_intelligence_sign_in" - : "intelligence_key_for", - selfHosted ? { root, project: id } : { root: root.trim(), project: id }, - ); - if (selfHosted && selfHostedAttempt.current !== attempt) return; - setApiKey(key); - setProjects(null); - } catch (error) { - if (!selfHosted || selfHostedAttempt.current === attempt) { - setFailure(asProblem(error)); - if (selfHosted) setProjects(null); - } - } finally { - if (!selfHosted || selfHostedAttempt.current === attempt) { - setSigningIn(false); - if (selfHosted) { - selfHostedActiveRef.current = false; - setSelfHostedActive(false); - } - } - } - } - async function createProject() { - const name = projectName.trim(); - if (!name || signingIn) return; - setSigningIn(true); - setCreatingProject(true); - setFailure(null); - try { - const project = await invoke<{ id: string; name: string }>( - "create_intelligence_project", - { root: root.trim(), name }, - ); - setProjects((current) => [...(current ?? []), project]); - setProjectName(""); - await pickProject(project.id); - } catch (error) { - setFailure(asProblem(error)); - } finally { - setSigningIn(false); - setCreatingProject(false); - } - } - + 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({}); + // Running-screen actions that wait on the native side, said while they do. + const [stopping, setStopping] = useState(false); + const [showing, setShowing] = useState(false); const [step, setStep] = useState("welcome"); - const [apiUrl, setApiUrl] = useState(MANAGED_INTELLIGENCE_API_URL); - const [wsUrl, setWsUrl] = useState(MANAGED_INTELLIGENCE_GATEWAY_WS_URL); const [steps, setSteps] = useState([]); const [busy, setBusy] = useState(false); - const [resuming, setResuming] = useState(false); const [checkingResume, setCheckingResume] = useState(true); const savedLaunch = useRef(null); + // An interrupted run needs an explicit Start. + const interruptedAtLaunch = useRef(false); const [refreshIntelligence, setRefreshIntelligence] = useState(false); const [preparation, setPreparation] = useState<{ key: string; @@ -290,16 +133,27 @@ export function App() { const installationKey = installationKeyFor(root, harness); const installationReady = preparation?.key === installationKey && preparation.status === "complete"; + /* + * A 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 = - checkingResume || - resuming || - blockerFailure || - blocker || - (running && step !== "ask") - ? null - : step; + checkingResume || blockerFailure || blocker || running ? null : step; const lastViewedStep = useRef(null); useEffect(() => { if (visibleSetupStep === lastViewedStep.current) return; @@ -308,7 +162,6 @@ export function App() { recordSetupEvent({ kind: "step_viewed", step: visibleSetupStep }); } }, [visibleSetupStep]); - const configuredRunRef = useRef(0); /* * A failure, in both registers. * @@ -329,9 +182,16 @@ export function App() { setRefreshIntelligence(true); setStep("connect"); } - if (problem.connection === "organization") setRefreshOrganization(true); + // Start was refused until Install runs again: an earlier app's deployment this app cannot + // start. Install does that by itself, as the person would otherwise be told to. + if (problem.connection === "installation") setReinstall(true); }, []); - const displayedFailure = failure ?? recoveryFailure; + const [reinstall, setReinstall] = useState(false); + // 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 @@ -339,88 +199,52 @@ export function App() { ? [displayedFailure.database_reset] : []; const leftoverContainers = displayedFailure?.leftover_containers ?? []; - const credentialContext = useRef([ + // Held here, above the screens, so changing screens never cancels it. + const local = useLocalSequence({ root, - model, - apiKey, - apiUrl, - wsUrl, - harness, - step, - reuseIntelligence, - ]); + stack: localStack, + connected: localConnected, + onStart: (startable) => start(chosenModel.current, startable), + onRecorded: (stack, connected) => { + setLocalStack(stack); + if (connected) setLocalConnected(true); + }, + }); + // A recorded stack: the dashboard can be opened, from here and from the menus. + const intelligenceOffered = localStack !== null; + // What the menus were last told. Starts hidden, so nothing is said before a stack is recorded. + 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, harness, step]); useEffect(() => { - const next = [ - root, - model, - apiKey, - apiUrl, - wsUrl, - harness, - step, - reuseIntelligence, - ]; + const next = [root, model, harness, step]; if ( next.some((value, index) => value !== credentialContext.current[index]) ) { credentialContext.current = next; setFailure(null); } - }, [root, model, apiKey, apiUrl, wsUrl, harness, step, reuseIntelligence]); - - const clearRootScopedSavedState = useCallback(() => { - void cancelSelfHostedSignIn(); - setIntelligenceSignInKind("managed"); - savedLaunch.current = null; - setRefreshIntelligence(false); - setRefreshOrganization(false); - setOrganizationAuthorityUrl(null); - setApiKey(""); - setReuseIntelligence(false); - setApiUrl(MANAGED_INTELLIGENCE_API_URL); - setWsUrl(MANAGED_INTELLIGENCE_GATEWAY_WS_URL); - setAlreadyHeld({}); - }, [cancelSelfHostedSignIn]); - - const loadConfiguredRoot = useCallback( - async (nextRoot: string) => { - const trimmedRoot = nextRoot.trim(); - const run = configuredRunRef.current + 1; - configuredRunRef.current = run; - clearRootScopedSavedState(); - if (!trimmedRoot) return; - try { - const configured = await invoke( - "already_configured", - { root: trimmedRoot }, - ); - if (configuredRunRef.current !== run) return; - const { values, saved } = configured; - if (values.INTELLIGENCE_API_KEY) setApiKey(values.INTELLIGENCE_API_KEY); - if (values.INTELLIGENCE_API_URL) setApiUrl(values.INTELLIGENCE_API_URL); - if (values.INTELLIGENCE_GATEWAY_WS_URL) - setWsUrl(values.INTELLIGENCE_GATEWAY_WS_URL); - setAlreadyHeld({ ...values, saved }); - setOrganizationAuthorityUrl( - values.OPENBOT_ORGANIZATION_AUTH_URL ?? null, - ); - return configured; - } catch { - if (configuredRunRef.current === run) { - setAlreadyHeld({}); - } - } - }, - [clearRootScopedSavedState], - ); + }, [root, model, harness, step]); useEffect(() => { let active = true; - 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( @@ -435,10 +259,19 @@ export function App() { setBlockerFailure(asProblem(error)); return false; }); + // 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); @@ -459,22 +292,40 @@ export function App() { * a dotfile in a text editor — the exact thing this product exists not to require. Their own * file, read back to them on their own machine. */ - const configured = await loadConfiguredRoot(found); + const configured = await invoke( + "already_configured", + { root: found.trim() }, + ).catch(() => null); if (!active) return; - const installed = configured?.launch ?? configured?.installation; - const resumedModel = configured - ? recordedModel({ - ...configured.values, - saved: configured.saved, - }) - : null; + if (configured) { + setAlreadyHeld({ ...configured.values, saved: configured.saved }); + } + /* + * Setup resumes part-way only for an installation with a local Intelligence stack recorded. + * Anything else, including an installation an earlier app connected to a hosted + * Intelligence, starts at the first screen. Its finished install is kept, so Install does + * not run again for the same Bot, and nothing it saved is removed. + */ + if (configured?.localEval) { + setLocalStack(configured.localEval.stack); + setLocalConnected(configured.localEval.connected === true); + } + const installed = configured?.localEval + ? (configured.launch ?? configured.installation) + : configured?.installation; + const resumedModel = + configured?.localEval && installed + ? recordedModel({ ...configured.values, saved: configured.saved }) + : null; if (installed) { const key = installationKeyFor(found, installed.harness); setHarness(installed.harness); - setModel(resumedModel); setPreparation({ key, status: "complete" }); - if (configured?.launch) savedLaunch.current = key; - setStep(resumedModel ? "connect" : "model"); + if (configured?.localEval) { + setModel(resumedModel); + if (configured.launch) savedLaunch.current = key; + setStep(resumedModel ? "connect" : "model"); + } } if (interrupted?.connection) { recoverFromStart(interrupted); @@ -492,47 +343,18 @@ export function App() { setRunning(true); return; } - if (!active || !canResume || !configured?.launch || !resumedModel) + if ( + !active || + !canResume || + !configured?.launch || + !configured.localEval || + !resumedModel + ) return; - const resumedHarness = configured.launch.harness; // An interrupted run needs an explicit Start, with its saved setup still available. if (interrupted || configured.autoStart === false) return; - setBusy(true); - setResuming(true); - try { - // Only a successful previous setup supplies launch intent. Reopening uses its saved - // connections and already installed assets; the native start still validates both. - await invoke("start_stack", { - root: found, - apiKey: "", - apiUrl: - configured.values.INTELLIGENCE_API_URL || - MANAGED_INTELLIGENCE_API_URL, - gatewayWsUrl: - configured.values.INTELLIGENCE_GATEWAY_WS_URL || - MANAGED_INTELLIGENCE_GATEWAY_WS_URL, - model: resumedModel, - harness: resumedHarness, - ...(configured.values.OPENBOT_ORGANIZATION_AUTH_URL - ? { - organizationAuthUrl: - configured.values.OPENBOT_ORGANIZATION_AUTH_URL, - } - : {}), - }); - if (!active) return; - setSteps((current) => settleProgress(current)); - setRunning(true); - setRecoveryFailure(null); - await invoke("show_openbot"); - } catch (error) { - if (active) recoverFromStart(error); - } finally { - if (active) { - setBusy(false); - setResuming(false); - } - } + // Intelligence must report ready and licensed first, which only its screen asks. + if (configured.localEval.connected) setLocalAutoStart(true); }) .catch((error) => { if (active) recoverFromStart(error); @@ -561,12 +383,15 @@ export function App() { stop.then((unlisten) => unlisten()); updateStopped.then((unlisten) => unlisten()); }; - }, [loadConfiguredRoot, recoverFromStart]); + }, [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); + setRecoveryFailure(null); setSteps([ { step: "engine", @@ -577,23 +402,86 @@ 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) { + /* + * Setup 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. An installation already complete for the default + * Bot is not installed again. + */ + const localInstall = useRef | null>(null); + /** `again`: what was recorded as installed is not what this app starts, so install anyway. */ + function beginLocalInstall(again = false): Promise { + if (localInstall.current) return localInstall.current; + const choice = { id: DEFAULT_HARNESS }; + setHarness(choice); + if ( + !again && + preparation?.key === installationKeyFor(root, choice) && + preparation.status === "complete" + ) { + return Promise.resolve(true); + } + 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; + } + + // biome-ignore lint/correctness/useExhaustiveDependencies: runs once per refused Start. + useEffect(() => { + // Once the refused Start has let go: Install will not begin while the window is busy. + if (!reinstall || busy) return; + setReinstall(false); + // What was recorded as installed is not what this app starts, so it is installed again. + localInstall.current = null; + setPreparation(null); + void installLocally(true); + }, [reinstall, busy]); + + /** Shows the install until it completes, then the AI provider screen. */ + async function installLocally(again = false) { + setStep("install"); + if (await beginLocalInstall(again)) { + setSteps([]); + setStep("model"); + } + } + + /** True when OpenBot started. */ + async function start( + nextModel = model, + intelligence: LocalStartable | null = null, + ): Promise { if (!installationReady) { setStep("install"); - return; + return false; + } + // OpenBot starts only from a stack its CLI reports ready and licensed. + if (!intelligence) { + setStep("connect"); + return false; } setBusy(true); setFailure(null); @@ -601,9 +489,9 @@ export function App() { try { await invoke("start_stack", { root: root.trim(), - apiUrl, - gatewayWsUrl: wsUrl, - apiKey, + // The key `local connect` gave OpenBot is in the vault; Start takes it from there. + apiUrl: intelligence.apiUrl, + gatewayWsUrl: intelligence.gatewayWsUrl, // 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. @@ -611,41 +499,22 @@ export function App() { // By id only. The image, the port and how it is dialled are facts about the harness, and // the window carrying them would be a second list to keep in step with the catalogue. harness, - ...(organizationAuthorityUrl !== null - ? { organizationAuthUrl: organizationAuthorityUrl } - : {}), }); setSteps((current) => settleProgress(current)); setRunning(true); setRecoveryFailure(null); - // Refreshing credentials does not turn an existing installation into a first run. - if ( - savedLaunch.current === installationKey || - organizationAuthorityUrl?.trim() - ) { - savedLaunch.current = installationKey; - setStep("connect"); - setRefreshIntelligence(false); - await invoke("show_openbot"); - return; - } - /* - * One screen short of the handover, on purpose. - * - * The window used to become OpenBot here, the moment the stack was up. But up is not the - * same as working: a refused key or a lapsed plan gives a stack that starts clean and a Bot - * that cannot answer, and handing over at this point means somebody discovers that inside - * the product with no idea which of their answers caused it. So the last screen asks a - * question, and the handover waits for an answer to come back. - */ - setStep("ask"); + // The handover, on a first run as on a return: the window becomes OpenBot the moment the + // stack is up. Recorded first, so a later Start of this installation reads as a return. + savedLaunch.current = installationKey; + setStep("connect"); + setRefreshIntelligence(false); + await invoke("show_openbot"); + return true; } catch (error) { recoverFromStart(error); + return false; } finally { setBusy(false); - invoke("detect_engine") - .then(setEngine) - .catch(() => undefined); } } @@ -656,14 +525,10 @@ export function App() { try { const result = await invoke("apply_runtime_update", { root: root.trim(), - apiUrl, - gatewayWsUrl: wsUrl, - apiKey, + // Intelligence's addresses are the ones this installation last started with; the native + // side reads them from its settings. model, harness, - ...(organizationAuthorityUrl !== null - ? { organizationAuthUrl: organizationAuthorityUrl } - : {}), backupAcknowledged: true, }); setRunning(true); @@ -730,7 +595,8 @@ export function App() { setFailure(null); setRecoveryFailure(null); setSteps([]); - await start(); + // Start again from the step that stopped, with the stack's addresses it already has. + local.retry(); } function modelCanStart() { @@ -752,143 +618,130 @@ 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 changeModelAfterAskFailure() { - setBusy(true); - setFailure(null); + async function showOpenBot() { + setShowing(true); try { - await invoke("stop_stack", { root }); - setRunning(false); - setRecoveryFailure(null); - setStep("model"); + await invoke("show_openbot"); } catch (error) { - setFailure(asProblem(error)); + recoverFromStart(error); } finally { - setBusy(false); + setShowing(false); } } + /* + * Starts local Intelligence without a click: 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" && + 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 (

-

OpenBot could not check Windows setup

- -
- ); - } - - if (blocker) { - return ( -
-

OpenBot needs one thing first

-
-

{titleFor(blocker)}

-

{instruction}

+
+

OpenBot could not check Windows setup

+
); } - if (resuming || checkingResume) { + if (restartPending) return ; + + if (blocker) { return ( -
-

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

-

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

- -
+ setRestartPending(true)} + /> ); } - if (refreshOrganization) { + if (checkingResume) { return (
- { - setRefreshOrganization(false); - setRefreshIntelligence(true); - setRunning(false); - }} - onSignedIn={async () => { - try { - await invoke("show_openbot"); - setRefreshOrganization(false); - setRecoveryFailure(null); - setRunning(true); - } catch (error) { - recoverFromStart(error); - } - }} - /> - {displayedFailure && } +
+

Getting OpenBot ready

+

+ Checking your saved setup… +

+ +
); } /* - * Install local software before showing either sign-in. A completed installation is retained - * while somebody changes or retries their connection; changing its folder or Bot invalidates it. - * * Skipped entirely when a stack is already up: somebody returning to a running OpenBot is not - * setting one up, and asking them to pick a Bot again would be the wizard asking twice. + * setting one up. */ if (!running && step === "welcome") { return (
- setStep("harness")} /> - {runtimeRecovery} - {displayedFailure && } -
- ); - } - - if (!running && step === "harness") { - return ( -
- { - if ( - choice.id !== harness?.id || - choice.agentUrl !== harness?.agentUrl - ) { - setPreparation(null); - setSteps([]); - } - setHarness(choice); - }} - onContinue={() => { - recordSetupEvent( - harnessChoiceEvent(harness?.id ?? DEFAULT_HARNESS), - ); - setHarness((choice) => - choice?.id === "byo-url" - ? { ...choice, agentUrl: choice.agentUrl?.trim() } - : choice, - ); - setStep("install"); + { + // The default Bot, recorded as a choice of it would be. + recordSetupEvent(harnessChoiceEvent(DEFAULT_HARNESS)); + // The account is the first screen; OpenBot installs behind it meanwhile. + setStep("account"); + void beginLocalInstall(); }} - onBack={() => setStep("welcome")} + failure={displayedFailure && } /> {runtimeRecovery}
@@ -899,89 +752,51 @@ export function App() { return (
-

Step 2 of 4

+

- {installationReady ? "Installation complete" : "Install OpenBot"} + {installationReady ? "Installation complete" : "Installing 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."} + ? "OpenBot’s local software is ready. Next, connect your AI." + : INSTALL_BLURB}

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

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

- )} -
- - { - configuredRunRef.current += 1; - managedAttempt.current += 1; - setSigningIn(false); - setSignInUrl(null); - // A sign-in belongs to the folder it was for. Its projects and apps do too. - setProjects(null); - setApps(null); - 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. */} + {displayedFailure && } - {busy &&

Installing local software…

} - {installationReady && ( -
- Installation options - -
- )} -
- - {installationReady ? ( + {!busy && ( +
- ) : ( - + ) : ( + - )} -
+ + )} +
+ )}
{runtimeRecovery}
@@ -989,26 +804,27 @@ export function App() { } /* - * Shown while the stack is running, which every other screen is skipped for. This is the one - * screen that needs a running stack: it is the proof, and there is nothing to ask before there - * is something to ask. + * A first run signs in to CopilotKit and accepts the evaluation agreement as a step of its own, + * before anything licensed is set up. Intelligence then sets itself up behind the AI provider + * screen, with the version accepted here. */ - if (step === "ask") { + if (!running && step === "account") { return (
- - invoke("ask_the_bot", { root, question }) - } - onOpen={() => { - savedLaunch.current = installationKey; - invoke("show_openbot").catch((error) => recoverFromStart(error)); + { + if (acceptedVersion) local.acceptTerms(acceptedVersion); + // Intelligence sets itself up once the install has Docker answering; see above. The + // install may have got there during sign-in, and that change has already been seen. + if (engineReady || installationReady) local.prepare(); + else intelligenceQueued.current = true; + if (installationReady) { + setSteps([]); + setStep("model"); + } else void installLocally(); }} - onBack={changeModelAfterAskFailure} + failure={displayedFailure && } /> - {runtimeUpdates} - {displayedFailure && }
); } @@ -1022,481 +838,202 @@ export function App() { chosen={model} returning={returningToInstallation} busy={busy} + status={} + onInstallRequired={(problem, choice) => { + // The record is from an earlier run. Install again, keeping the provider chosen. + localInstall.current = null; + setPreparation(null); + setSteps([]); + setModel(choice); + setRecoveryFailure(problem); + setStep("install"); + }} onChoose={(choice) => { recordSetupEvent(modelChoiceEvent(choice)); + chosenModel.current = choice; setModel(choice); - if (returningToInstallation) void start(choice); - else setStep("connect"); - }} - onBack={() => { + // The go-ahead: OpenBot starts once Intelligence is ready, wherever it has got to. setSteps([]); - setStep(returningToInstallation ? "connect" : "install"); + setStep("connect"); + local.start(); + // A Start the previous model was refused at stopped there: try it again with this one. + if (local.stopped && local.at === "start") local.retry(); + }} + // A first run has nothing behind this screen to go back to. + onBack={ + returningToInstallation + ? () => { + setSteps([]); + setStep("connect"); + } + : undefined + } + failure={displayedFailure && } + // A failure from the provider tried before is not about the one chosen now. + onProviderChange={() => { + setFailure(null); + setRecoveryFailure(null); }} /> - {displayedFailure && } {runtimeRecovery}
); } + const localStarting = local.running; + if (!running && returningToInstallation && !refreshIntelligence) { return (
-

OpenBot is stopped

-

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

- - {displayedFailure && } - - - - {displayedFailure && ( - - )} - {runtimeUpdates} -
- Installation options - -
-
- ); - } - - return ( -
-
- {returningToInstallation && runtimeUpdates} - {/* A failure outranks `running`. The supervisor gives up on a process and sends the window - back here, and a heading that still says everything is running while the box underneath - names the process that stopped is a screen arguing with itself. */} - {!running && leftoverVolumes.length > 0 && ( - - )} - - {!running && !returningToInstallation && ( -

Step 4 of 4

- )} -

- {running && !displayedFailure - ? "OpenBot is running" - : 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."} -

- - {!running && ( -
- {/* - Sign in on the main path; paste behind the disclosure. - - This screen used to ask for a key whose only source was two terminal commands, which is - the one thing the audience rule forbids. Somebody on managed CopilotKit now signs in and - OpenBot creates the key for the project they pick. Somebody running their own - Intelligence has a key this sign-in knows nothing about, so the field moves down there - with the addresses it belongs with. - */} - {apiKey && !signingIn && !projects ? ( - <> -

- {intelligenceSignInKind === "self-hosted" - ? "Connected to Intelligence." - : "Connected to CopilotKit."} -

- {returningToInstallation && ( - - )} - - ) : (alreadyHeld.saved?.intelligenceApiKey || reuseIntelligence) && - !signingIn && - !projects ? ( - <> -

- A saved CopilotKit connection will be checked when you start. -

+
+

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

+

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

+ + {displayedFailure && } + {/* While a local step runs, its Cancel is the only control. */} + {!localStarting && ( + <> +
- - ) : signInUrl ? ( - <> -

- Finish signing in to CopilotKit in your browser. If it did not - open, this is the address: -

- {/* Selectable text, not a link: the browser has already been asked to open it, and - what is needed here is something a person can copy. */} -

- {signInUrl} -

-

Waiting for you to approve it…

- - ) : projects ? ( - <> -

Which project should OpenBot use?

-
- Project - {projects.map((project) => ( - - ))} -
- {projects.length === 0 && ( - <> -

- {intelligenceSignInKind === "self-hosted" - ? "That account has no available projects. Ask your Intelligence administrator for access, or sign in with a different account." - : "That account has no projects yet. Create one below, or sign in with a different account."} -

- - - )} - {intelligenceSignInKind === "managed" && ( -
{ - event.preventDefault(); - void createProject(); - }} - > -
- - setProjectName(event.target.value)} - disabled={signingIn} - autoComplete="off" - /> -
- -
- )} - - ) : ( - <> -

- OpenBot keeps your conversations in CopilotKit. Sign in and it - sets the rest up for you. -

-
- - {!apiKey && - !reuseIntelligence && - alreadyHeld.saved?.intelligenceApiKey == null && - !signingIn && ( - - )} -
- - )} - {intelligenceSignInKind === "managed" && - !signInUrl && - (apps?.state === "connecting" ? ( -

- Connecting your apps… -

- ) : apps?.state === "unavailable" ? ( - <> - -

- You can start OpenBot without them. -

+ {displayedFailure && ( - - ) : null)} - {/* - This used to be headed "Self-hosted Intelligence" over two fields pre-filled with the - MANAGED service's addresses, which says the opposite of what it does: somebody opening - it to check where their data goes read "self-hosted" and saw CopilotKit's own hosts. - The heading now describes the action, and the note says what the defaults are. - */} -
- Point at your own Intelligence server -

- Enter your Intelligence server’s addresses, then sign in to - choose a project. OpenBot creates its Learning container for - you. Sign-in requires Chrome or Microsoft Edge. You can also use - an existing project key. -

-
- - setApiKey(event.target.value)} - placeholder="the key from your own Intelligence" - autoComplete="off" - spellCheck={false} - /> -
-
- - setApiUrl(event.target.value)} - spellCheck={false} - /> -
-
- - setWsUrl(event.target.value)} - spellCheck={false} - /> + )}
-
+ {runtimeUpdates} +
+ Installation options - {selfHostedActive && ( - - )} -
- {selfHostedActive && signingIn && !projects && ( -

- Finish signing in in the Intelligence browser window… -

- )} -
-
- Sign in through your organization -

- Enter your organization’s OpenBot address to use its sign-in and - access rules. -

-
- - - setOrganizationAuthorityUrl(event.target.value) - } - placeholder="https://openbot.your-company.com" - spellCheck={false} - /> -
-
-
- )} - - - - {/* Leftover data is a choice, offered above, not a failure to report here. */} - {displayedFailure && leftoverVolumes.length === 0 && ( - - )} + + + )} +
+
+ ); + } - {!running && !returningToInstallation && ( -
- Installation options - -
- )} -
- {!running && ( - + /* + * The Start step: Intelligence's 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) { + return ( +
+
+ {leftoverVolumes.length > 0 && ( + )} - {running ? ( - <> + {!returningToInstallation && } +

Getting ready

+

+ OpenBot is setting up on this computer and starts by itself when + it’s 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 && ( +
- - - ) : ( - +
)}
+
+ ); + } + + return ( +
+
+ {returningToInstallation && runtimeUpdates} + {/* A failure outranks `running`. The supervisor gives up on a process and sends the window + back here, and a heading that still says everything is running while the box underneath + names the process that stopped is a screen arguing with itself. */} +

{displayedFailure ? "OpenBot stopped" : "OpenBot is running"}

+ {!displayedFailure && ( +

+ OpenBot is open in this window. You can also show it or stop it from + the menu bar. +

+ )} + + {displayedFailure && } + {stopping && Stopping OpenBot…} + {showing && Loading OpenBot…} +
+ + +
); @@ -1514,115 +1051,3 @@ function settleProgress(steps: Progress[], problem?: Problem): Progress[] { : step, ); } - -function SetupProgress({ steps }: { steps: Progress[] }) { - if (steps.length === 0) return null; - return ( -
    - {steps.map((step) => ( - - ))} -
- ); -} - -function SetupProgressRow({ step }: { step: Progress }) { - const [elapsed, setElapsed] = useState(0); - const running = step.running ?? false; - useEffect(() => { - if (!running) return; - const started = Date.now(); - setElapsed(0); - const timer = setInterval( - () => setElapsed(Math.floor((Date.now() - started) / 1000)), - 1000, - ); - return () => clearInterval(timer); - }, [running]); - return ( -
  • -
  • - ); -} - -function formatDownloadBytes(bytes: number): string { - if (bytes < 1000) return `${bytes} B`; - const units = ["kB", "MB", "GB", "TB"]; - const power = Math.min(Math.floor(Math.log10(bytes) / 3), units.length); - return `${(bytes / 1000 ** power).toFixed(1)} ${units[power - 1]}`; -} - -function titleFor(blocker: Blocker): string { - switch (blocker) { - case "wsl-absent": - return "Windows Subsystem for Linux is not installed"; - case "wsl-one": - return "Windows Subsystem for Linux is at version 1"; - case "virtual-machine-platform-disabled": - return "Virtual Machine Platform is switched off"; - case "virtualization-disabled": - return "Virtualization is off in this machine's firmware"; - case "not-administrator": - return "This account cannot install Windows components"; - } -} - -function label(step: string): string { - switch (step) { - case "engine": - case "install-engine": - return "Container engine"; - case "create-machine": - return "Engine machine"; - case "start-machine": - return "Starting the machine"; - case "health-gate": - return "Engine answering"; - case "deployment": - return "Deployment"; - case "env": - return "Settings"; - case "ports": - return "Ports"; - case "dependencies": - return "Dependencies"; - case "recorder": - return "Recorder"; - case "images": - return "Local software"; - case "installation": - return "Installation"; - case "answering": - return "Answering"; - case "services": - return "Containers"; - case "migrate": - return "Database"; - default: - return step; - } -} diff --git a/desktop/src/Ask.tsx b/desktop/src/Ask.tsx deleted file mode 100644 index d4f9fd3624..0000000000 --- a/desktop/src/Ask.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useState } from "react"; -import { asProblem, Failure, type Problem } from "./Problem"; - -/** - * The last screen: a question, an answer, and only then the handover. - * - * The install does not end at "saved". Every step before this proves that something started, which - * is not the same as proving the choices work: a refused key, a lapsed plan or a model the account - * cannot use all produce a stack that comes up clean and a Bot that cannot answer. Somebody would - * find that out later, inside the product, with no idea which answer was the wrong one. So the - * wizard ends by asking, and the answer on this screen is the proof. - * - * One suggested question, already filled in, with one right answer. "Tell me about yourself" is - * answered convincingly by a Bot whose model credential is fine and whose everything else is - * broken, and this screen exists to prove rather than to reassure. - */ -export function Ask({ - suggestion, - onAsk, - onOpen, - onBack, -}: { - suggestion: string; - onAsk: (question: string) => Promise; - onOpen: () => void; - onBack: () => void; -}) { - const [question, setQuestion] = useState(suggestion); - const [answer, setAnswer] = useState(null); - const [asking, setAsking] = useState(false); - /* - * THE FAILURE IS THIS SCREEN'S TO SHOW, and it used to be nobody's. - * - * The catch below recorded that something went wrong and threw the problem away, on the belief - * that the screen around this one would render it. Nothing did. A plan that could not answer - * produced a "Change the model" button and no sentence at all: the exact silence this screen was - * built to replace, on the screen built to replace it. - */ - const [failure, setFailure] = useState(null); - - async function ask() { - setAsking(true); - setFailure(null); - setAnswer(null); - try { - const answer = await onAsk(question); - setAnswer(answer); - } catch (error) { - setFailure(asProblem(error)); - } finally { - setAsking(false); - } - } - - return ( -
    -

    Last step

    -

    Ask it something.

    -

    - Your Bot is set up. This proves it can answer before you start using it. -

    - -
    - - { - setQuestion(event.target.value); - }} - disabled={asking} - onKeyDown={(event) => { - // Enter also confirms a character being composed through an input method (Japanese, - // Chinese, Korean). Asking on that Enter would send the question with its last character - // still unconfirmed. Chromium marks that keydown `isComposing`; the macOS WebKit webview - // instead sends it after compositionend with key code 229. Wait for either. - if ( - event.key === "Enter" && - !asking && - !event.nativeEvent.isComposing && - event.nativeEvent.keyCode !== 229 - ) { - ask(); - } - }} - /> -
    - - {failure && } - - {answer !== null && ( -
    -

    Your Bot said

    -

    {answer}

    -
    - )} - -
    - {answer === null ? ( - - ) : ( - - )} - {/* - * Only after something went wrong, and it is the only way back to the answer that caused - * it. A model screen offered before the failure would be a way to change a choice that was - * working, which is how somebody breaks a finished install. - */} - {failure && ( - - )} - {answer !== null && ( - - )} -
    - -

    - Your question goes to the AI provider you connected. -

    -
    - ); -} diff --git a/desktop/src/CopilotKitAccount.tsx b/desktop/src/CopilotKitAccount.tsx new file mode 100644 index 0000000000..4a62110565 --- /dev/null +++ b/desktop/src/CopilotKitAccount.tsx @@ -0,0 +1,176 @@ +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { ExternalLink } from "./ExternalLink"; +import { + cancelLocalEval, + type LocalTerms, + localEvalTerms, + signInToCopilotKit, +} from "./localEvalCli"; +import { Sections } from "./Sections"; +import { StatusLine } from "./SetupProgress"; + +type Seen = + | { at: "checking" } + | { at: "signed-out" } + | { at: "signing-in" } + | { at: "agreement"; terms: Extract } + | { at: "failed"; said: string }; + +const COULD_NOT_CHECK = "Couldn’t check your CopilotKit account. Try again."; + +/** + * The CopilotKit account step of a local first run: sign in, then read and accept the evaluation + * agreement, before anything licensed is set up. + * + * Its own step, after the software is installed and before the AI provider is chosen, because the + * local evaluation licence is issued to this account and under this agreement. Somebody already + * signed in who has accepted the current agreement passes through it without a click. + * + * Accepting is the person's own act: they read the agreement at its address and click Accept here, + * and that click is what setup later passes to the CLI as the version they accepted (a product + * decision of 2026-10-22). Nothing accepts it without the click. + */ +export function CopilotKitAccount({ + onDone, + failure, +}: { + /** Signed in and agreed: the version accepted here, or null when nothing needed accepting. */ + onDone: (acceptedVersion: string | null) => void; + /** A failure from setup itself, shown inside this card rather than under it. */ + failure?: ReactNode; +}) { + const [seen, setSeen] = useState({ at: "checking" }); + const attempt = useRef(null); + const finished = useRef(false); + const done = useRef(onDone); + done.current = onDone; + + const finish = useCallback((version: string | null) => { + if (finished.current) return; + finished.current = true; + done.current(version); + }, []); + + const check = useCallback(() => { + setSeen({ at: "checking" }); + const started = localEvalTerms(); + attempt.current = started.attempt; + started.terms.then( + (terms) => { + if (attempt.current !== started.attempt) return; + attempt.current = null; + if (terms.status === "signed-out") setSeen({ at: "signed-out" }); + else if (terms.status === "required" && !terms.accepted) + setSeen({ at: "agreement", terms }); + else if (terms.status === "unavailable") + setSeen({ + at: "failed", + said: "The license agreement isn’t available right now. Try again later.", + }); + // Accepted already, or none needed: nothing to do here. + else if (terms.status === "required" || terms.status === "not-required") + finish(null); + // A status this window does not know is never taken as nothing to do. + else setSeen({ at: "failed", said: COULD_NOT_CHECK }); + }, + () => { + if (attempt.current !== started.attempt) return; + attempt.current = null; + setSeen({ at: "failed", said: COULD_NOT_CHECK }); + }, + ); + }, [finish]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: once, on arrival. + useEffect(() => { + check(); + return () => { + // Leaving stops whatever is still waiting, the browser sign-in included. + if (attempt.current) cancelLocalEval(attempt.current).catch(() => {}); + attempt.current = null; + }; + }, []); + + async function signIn() { + setSeen({ at: "signing-in" }); + const started = signInToCopilotKit(); + attempt.current = started.attempt; + try { + const signedIn = await started.signedIn; + if (attempt.current !== started.attempt) return; + attempt.current = null; + if (signedIn) check(); + else setSeen({ at: "failed", said: "Sign-in didn’t finish. Try again." }); + } catch { + if (attempt.current !== started.attempt) return; + attempt.current = null; + setSeen({ at: "failed", said: "Sign-in didn’t finish. Try again." }); + } + } + + const agreement = seen.at === "agreement" ? seen.terms : null; + return ( +
    + {/* OpenBot is what is being set up, so its mark heads this step as it heads every other. + CopilotKit is named where that is true: the account and the agreement's own title. */} + +

    + {agreement ? "Accept the license agreement" : "Sign in to CopilotKit"} +

    +

    + {agreement + ? `To run OpenBot on this computer, accept the CopilotKit Intelligence license agreement for ${agreement.organizationName}.` + : "OpenBot runs on this computer with your CopilotKit account. Sign in, then accept the license agreement."} +

    + {!agreement && ( +

    + Your conversations and files are stored on this computer. Signing in + does not send them to CopilotKit. +

    + )} + + {seen.at === "checking" && ( + Checking your CopilotKit account… + )} + {seen.at === "signing-in" && ( + + Finish signing in to CopilotKit in your browser… + + )} + {agreement && ( +

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

    + )} + {seen.at === "failed" && ( +

    {seen.said}

    + )} + {failure} + +
    + {seen.at === "signed-out" && ( + + )} + {agreement && ( + + )} + {seen.at === "failed" && ( + + )} +
    +
    + ); +} diff --git a/desktop/src/HarnessPicker.tsx b/desktop/src/HarnessPicker.tsx deleted file mode 100644 index fd26dece4c..0000000000 --- a/desktop/src/HarnessPicker.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { useEffect, useState } from "react"; -import { isHttpEndpointUrl } from "./http-endpoint-url"; -import { Mark } from "./Mark"; - -export type Harness = { - id: string; - name: string; - summary: string; - image: string | null; - health_path: string | null; - // Mirrors `Credential` in shared/harnesses.ts, which this cannot import: the desktop app is - // outside the workspace and reads the catalogue over the Tauri boundary as JSON. Adding a - // variant there means adding it here, and the tile below is what reads it. - credential: "any-provider" | "anthropic" | "google" | "their-endpoint"; - maintainer: "first-party" | "partnership" | "community"; - mark: string | null; - port: number | null; -}; - -export type HarnessChoice = { - id: string; - agentUrl?: string; -}; - -/** What OpenBot sets up unless somebody says otherwise. David's call. */ -export const DEFAULT_HARNESS = "langgraph"; - -/** - * Which Bot, answered for them. - * - * ONE CHOICE IS MADE FOR THE PERSON, and that is the point of this screen rather than a limitation - * of it. The twelve rows are agent frameworks, and to anybody who is not a developer the difference - * between them is nil: they all take any model and they all answer the same questions. Asking a - * non-technical person to pick one is asking them to make a decision they cannot inform, at the - * start, which is where people leave. - * - * So the default is stated in one line and the list moves behind a disclosure. A developer who - * wants CrewAI opens it and picks CrewAI; everybody else presses Continue and never learns the word - * "harness". The address-your-own row lives in there too, because pasting a URL is the most - * developer thing on this screen. - */ -export function HarnessPicker({ - chosen, - onChoose, - onContinue, - onBack, -}: { - chosen: HarnessChoice | null; - onChoose: (choice: HarnessChoice) => void; - onContinue: () => void; - onBack: () => void; -}) { - const [rows, setRows] = useState([]); - const [failure, setFailure] = useState(""); - // Open when the person has already chosen something other than the default, so coming back does - // not hide the choice they made. - const [open, setOpen] = useState( - chosen !== null && chosen.id !== DEFAULT_HARNESS, - ); - - useEffect(() => { - invoke("harnesses") - .then(setRows) - .catch((error) => setFailure(String(error))); - }, []); - - const chosenId = chosen?.id ?? DEFAULT_HARNESS; - const picked = rows.find((row) => row.id === chosenId); - const byoAgentUrl = chosenId === "byo-url" ? (chosen?.agentUrl ?? "") : ""; - const byoReady = - (byoAgentUrl.trim().startsWith("http://") || - byoAgentUrl.trim().startsWith("https://")) && - isHttpEndpointUrl(byoAgentUrl); - - if (failure) { - return ( -
    -
    -

    The list of Bots could not be read

    -

    {failure}

    -
    -
    - ); - } - - return ( -
    -

    Step 1 of 4

    -

    Your first Bot

    - {/* - Written to the person who has to act, not about the situation. - - An earlier version said the default "makes no difference unless you write code", which - describes a state and leaves a non-technical reader wondering what they were told. This - gives them the one thing to do — nothing — and puts the conditional where the only person it - applies to will read it. - */} -

    - OpenBot sets this up for you. If you write code, you can choose the - agent framework below. -

    - -
    setOpen(e.currentTarget.open)}> - - {picked && picked.id !== DEFAULT_HARNESS - ? `Using ${picked.name}` - : "Choose the agent framework"} - -

    - Any of these works with any AI provider. Only the last one asks you - for an address. -

    -
    - Bot - {rows.map((row) => ( - - ))} -
    - {chosenId === "byo-url" && ( -
    - - - onChoose({ - id: "byo-url", - agentUrl: event.target.value, - }) - } - placeholder="https://your-agent.example/ag-ui" - spellCheck={false} - /> -
    - )} -
    - -
    - - -
    -
    - ); -} 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..4801d04218 --- /dev/null +++ b/desktop/src/LocalIntelligence.test.tsx @@ -0,0 +1,686 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +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; + /** Shows the status card another screen shows, whatever runs. */ + status?: 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 || props.status) && } + + + ); +} + +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 OpenBot"; +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 license 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); +}); + +/** + * OpenBot itself failing to start is not Intelligence stopping. After a refused Start the AI screen + * said "Intelligence stopped" over a stack the CLI reported ready and licensed. + */ +test("a start that fails says OpenBot did not start, not that Intelligence stopped", async () => { + invokeHandler = answers({ run_local_eval: [ready] }); + const screen = view({ + stack: "local-abc123", + connected: true, + status: true, + onStart: async () => false, + }); + const card = await screen.findByRole("alert", { name: "OpenBot" }); + expect(card.textContent).toContain("OpenBot did not start"); + expect(card.textContent).toContain("Intelligence is ready"); + expect(card.textContent).not.toContain("Intelligence stopped"); + expect(within(card).getByRole("button", { name: "Retry" })).toBeTruthy(); +}); + +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"); +}); + +/** + * On the Install screen the card follows the install's own rows, and it sat on them with no gap, + * its border against theirs. Whatever comes before it, it stands apart. + */ +test("the card stands apart from the install rows above it", () => { + const styles = readFileSync(join(import.meta.dir, "styles.css"), "utf8"); + const rule = + styles.match(/\.steps\s*\+\s*\.local-alert\s*\{([^}]*)\}/)?.[1] ?? ""; + const gap = Number(rule.match(/margin-top:\s*(\d+)px/)?.[1] ?? 0); + expect(gap).toBeGreaterThanOrEqual(12); +}); diff --git a/desktop/src/LocalIntelligence.tsx b/desktop/src/LocalIntelligence.tsx new file mode 100644 index 0000000000..f269ad0c56 --- /dev/null +++ b/desktop/src/LocalIntelligence.tsx @@ -0,0 +1,685 @@ +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 OpenBot"], + ["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); + // Stopped at Start: Intelligence got as far as ready and licensed, and OpenBot is what failed. + const openBot = !needs && sequence.at === "start"; + return ( +
    +

    + {needs + ? "Intelligence needs you" + : openBot + ? "OpenBot did not start" + : "Intelligence stopped"} +

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

    + {openBot + ? "Intelligence is ready. Retry starts OpenBot again." + : (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 license agreement could not be shown. Try again. +

    +
    + +
    +
    + ); + if (terms?.status !== "required") + return Getting the license 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 + license agreement for {terms.organizationName}. +

    +

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

    +
    + +
    +
    + ); +} diff --git a/desktop/src/OrganizationSignIn.tsx b/desktop/src/OrganizationSignIn.tsx deleted file mode 100644 index 507a415ab3..0000000000 --- a/desktop/src/OrganizationSignIn.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { useEffect, useRef, useState } from "react"; -import { asProblem, Failure } from "./Problem"; - -export function OrganizationSignIn({ - root, - authorityUrl, - onSignedIn, - onBack, -}: { - root: string; - authorityUrl: string; - onSignedIn: () => void | Promise; - onBack?: () => void; -}) { - const [busy, setBusy] = useState(false); - const [signInUrl, setSignInUrl] = useState(null); - const [failure, setFailure] = useState | null>( - null, - ); - const active = useRef(true); - useEffect(() => { - active.current = true; - return () => { - active.current = false; - void invoke("cancel_organization_sign_in", { root }).catch((error) => - console.error("Could not cancel organization sign-in", error), - ); - }; - }, [root]); - async function signIn(provider: string) { - setBusy(true); - setFailure(null); - try { - setSignInUrl( - await invoke("begin_organization_sign_in", { - root, - authorityUrl, - provider, - }), - ); - await invoke("finish_organization_sign_in", { root }); - if (active.current) await onSignedIn(); - } catch (error) { - if (active.current) setFailure(asProblem(error)); - } finally { - if (active.current) setBusy(false); - } - } - return ( -
    -

    Sign in to your organization

    -

    - Your installation is ready. Sign in through your organization's OpenBot - to continue. -

    -

    {authorityUrl}

    - {( - [ - ["google", "Google"], - ["microsoft", "Microsoft"], - ["okta", "Okta"], - ] as const - ).map(([id, label]) => ( - - ))} - {busy && ( -

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

    - )} - {signInUrl && ( -

    - - Open organization sign-in - -
    - event.currentTarget.select()} - /> -

    - )} - {failure && } - {onBack && ( - - )} -
    - ); -} diff --git a/desktop/src/Problem.tsx b/desktop/src/Problem.tsx index 72a10b1b9b..9e437bd566 100644 --- a/desktop/src/Problem.tsx +++ b/desktop/src/Problem.tsx @@ -11,7 +11,7 @@ import { ExternalLink } from "./ExternalLink"; export type Problem = { said: string; detail?: string | null; - connection?: "model" | "intelligence" | "organization" | null; + connection?: "model" | "intelligence" | "installation" | null; database_reset?: string | null; /** Every volume a confirmed fresh-install reset removes, the database included. */ leftover_reset?: string[] | null; @@ -42,7 +42,7 @@ function SetupHelp() { export function Failure({ problem }: { problem: Problem }) { return (
    -

    That did not finish

    +

    That didn’t finish

    {problem.said}

    {/* The real output, kept but not the headline. Whoever is debugging opens this; the person reading the sentence above never has to. */} @@ -66,7 +66,7 @@ export function Failure({ problem }: { problem: Problem }) { */ export function InlineFailure({ problem }: { problem: Problem }) { return ( -
    +

    {problem.said}

    {problem.detail && (
    diff --git a/desktop/src/ProviderPicker.test.tsx b/desktop/src/ProviderPicker.test.tsx index a976856de7..7ba3bebd34 100644 --- a/desktop/src/ProviderPicker.test.tsx +++ b/desktop/src/ProviderPicker.test.tsx @@ -1,6 +1,14 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; 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 +160,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 +183,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 +245,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 +254,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 +293,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 +329,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 +360,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 +386,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 +424,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 +462,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 +492,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 +523,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 +554,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 +585,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 +607,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 +642,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 +667,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 +918,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 +1099,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 +1123,49 @@ 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); +}); + +/** + * The chosen row's own text ran under Change: "…vLLM or your own." sat behind the button on the + * compatible endpoint's row. The lone row keeps the room Change takes at its right. + */ +test("a chosen provider's text leaves room for Change", async () => { + invokeHandler = async (command) => { + if (command === "providers") return providers; + return null; + }; + const view = await renderPicker(); + await view.findAllByRole("radio"); + await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); + const row = view.getByRole("radio").closest("label"); + expect(row?.className).toContain("beside-change"); + const styles = readFileSync(join(import.meta.dir, "styles.css"), "utf8"); + const rule = styles.match(/\.tile\.beside-change\s*\{([^}]*)\}/)?.[1] ?? ""; + const room = Number(rule.match(/padding-right:\s*(\d+)px/)?.[1] ?? 0); + // Change is about 50px wide, 16px in from the edge, with a gap before the text. + expect(room).toBeGreaterThanOrEqual(80); +}); diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index c4cd3eb914..9267289f75 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; @@ -18,6 +23,10 @@ export type Provider = { }; /** What the flow carries forward once this screen is done. */ +/** The sentence `preparation::require` refuses a sign-in or start with when Install must run again. */ +export const INSTALL_REQUIRED = + "Finish installing OpenBot's local software before signing in or starting. Return to Install and try again."; + export type ModelChoice = { provider: string; login: Login; @@ -141,9 +150,13 @@ export function ProviderPicker({ held, root, onChoose, + onInstallRequired, onBack, returning = false, busy: starting = false, + status, + failure: setupFailure, + onProviderChange, }: { chosen: ModelChoice | null; /** @@ -155,9 +168,24 @@ export function ProviderPicker({ held: HeldConfiguration; root: string; onChoose: (choice: ModelChoice) => void; - onBack: () => void; + /** + * A plan sign-in was refused because the installation no longer matches. The window goes back + * to Install, and the choice made here comes back with the person afterwards. + */ + onInstallRequired?: (problem: Problem, choice: ModelChoice) => void; + /** Without it there is no Back, as while local Intelligence sets up behind this screen. */ + onBack?: () => void; returning?: boolean; busy?: boolean; + /** One line about work running behind this screen. */ + status?: ReactNode; + /** A failure from setup itself (a Start that sent the window back here), shown in this card. */ + failure?: ReactNode; + /** + * The person chose another provider, or went back to the list. Any failure from setup belongs to + * the provider tried before, so the caller drops it, as this screen drops its own. + */ + onProviderChange?: () => void; }) { const initialChoice = chosen ?? recordedModel(held); const [reuse, setReuse] = useState( @@ -212,6 +240,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 +299,7 @@ export function ProviderPicker({ attemptId: authorization.attemptId, }); if (current()) { + continueAfterSignIn.current = true; setOauthSignedIn(providerId); setSignInUrl(null); setOauthCode(null); @@ -337,13 +368,19 @@ 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); } } } catch (error) { if (stillCurrent()) { - setFailure(asProblem(error)); + const problem = asProblem(error); + if (onInstallRequired && problem.said === INSTALL_REQUIRED) { + onInstallRequired(problem, { provider: providerId, login: "plan" }); + return; + } + setFailure(problem); setSignInUrl(null); } } finally { @@ -367,6 +404,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,21 +508,84 @@ 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); + onProviderChange?.(); + 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. + Choose the AI your Bots use. Sign in with a plan you already have, or + use 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 && ( @@ -555,6 +668,8 @@ export function ProviderPicker({ onClick={() => { if (login === "oauth" && option !== login) void cancelOAuth(); + // A refused sign-in says nothing about the API key tab, and the reverse. + if (option !== login) setFailure(null); setLogin(option); }} > @@ -576,12 +691,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 +719,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.`}{" "} @@ -637,9 +758,9 @@ export function ProviderPicker({ * `harness::speaking_for` for the failure that came of not saying it at all. */}

    - Your Bot will be{" "} - {row.id === "anthropic" ? "Claude Agent SDK" : "LangGraph"}, - which is the one that can use this plan. + Your Bot will use{" "} + {row.id === "anthropic" ? "Claude Agent SDK" : "LangGraph"}. + It’s the one that can use this plan.

    ) : signInUrl ? ( @@ -677,11 +798,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 +829,8 @@ export function ProviderPicker({ )}
    - {busy && progress && ( -

    - {progress} -

    + {busy && ( + {progress ?? "Preparing sign-in…"} )} ))} @@ -862,15 +980,19 @@ export function ProviderPicker({
    )} + {setupFailure} +
    - + {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..e5ff4b170a 100644 --- a/desktop/src/Welcome.tsx +++ b/desktop/src/Welcome.tsx @@ -1,31 +1,38 @@ +import type { ReactNode } from "react"; + /** * What OpenBot is, before it asks for anything. * * Few words on purpose. The person here was sent a link by their IT department and has not decided * 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. + * and where the button is. Install sets up OpenBot and CopilotKit Intelligence on this computer; + * everything else waits for a screen that needs it. */ -export function Welcome({ onStart }: { onStart: () => void }) { +export function Welcome({ + onInstall, + failure, +}: { + onInstall: () => void; + /** A failure from setup itself, shown inside this card rather than under it. */ + failure?: ReactNode; +}) { return (
    -

    Your own AI coworkers, on this computer.

    +

    Set up OpenBot

    - They answer questions, use the tools you connect, and can work in a - browser for you. + Installs the OpenBot app and backend on this computer. The first setup + takes about 10 minutes.

    -
    -

    - Takes a few minutes. OpenBot installs what it needs first, then asks you - to sign in to the AI plan you already have. -

    + {failure}
    ); } 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/harness.ts b/desktop/src/harness.ts new file mode 100644 index 0000000000..8ffbb908c5 --- /dev/null +++ b/desktop/src/harness.ts @@ -0,0 +1,8 @@ +/** Which Bot an installation runs, by catalogue id; `agentUrl` only for an agent somebody runs. */ +export type HarnessChoice = { + id: string; + agentUrl?: string; +}; + +/** What OpenBot sets up. David's call. */ +export const DEFAULT_HARNESS = "langgraph"; 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..0ae2185a2a --- /dev/null +++ b/desktop/src/localEvalCli.ts @@ -0,0 +1,350 @@ +/** + * 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 menu says these as a fixed sentence. + | "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 }; +} + +/** 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 9bb350ff2d..966dd56bc2 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -1,6 +1,9 @@ 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/inter"; import "./styles.css"; import { UpdateOffer } from "./UpdateOffer"; @@ -8,12 +11,23 @@ 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. // The update offer sits beside the setup flow rather than inside it. `App` returns a different // screen from a dozen places, and a banner that has to be added to each of them is a banner that // will be missing from the next one somebody writes. createRoot(root).render( - - + {/* The ground every screen sits on; see `.backdrop` in styles.css. */} +