From 14e58c83e892a6ab34ad89b89f4457f3400cc9b7 Mon Sep 17 00:00:00 2001
From: Dusty
Date: Wed, 21 Oct 2026 11:49:15 -0700
Subject: [PATCH 01/77] feat(desktop): run the Intelligence CLI's local
commands behind a bounded native boundary
local_eval.rs runs a SHA-256-pinned Intelligence CLI with fixed arguments
only (local status|start|stop --stack ID --json, local list --json, local
setup --defer-model --json), no shell, an environment allowlist, an
owner-only output file capped at 1 MiB, per-action timeouts, per-attempt
cancellation and process-group cleanup, one call at a time. It
normalizes the CLI's version-1 JSON to allowlisted facts; readiness and
licence stay unknown because v1 cannot state them. Release builds run no
CLI; debug builds accept a developer pin by path and digest.
local_eval_state.rs records the local evaluation checkpoint (stack,
created or shared, CLI digest, phase). preparation.rs shares its atomic
checkpoint write. localEvalCli.ts is the window's typed client.
---
desktop/src-tauri/src/lib.rs | 2 +
desktop/src-tauri/src/local_eval.rs | 2449 +++++++++++++++++++++
desktop/src-tauri/src/local_eval_state.rs | 285 +++
desktop/src-tauri/src/preparation.rs | 18 +-
desktop/src/localEvalCli.ts | 220 ++
5 files changed, 2970 insertions(+), 4 deletions(-)
create mode 100644 desktop/src-tauri/src/local_eval.rs
create mode 100644 desktop/src-tauri/src/local_eval_state.rs
create mode 100644 desktop/src/localEvalCli.ts
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 1a0a49877b..d5193b30e7 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -9,6 +9,8 @@ pub mod harness;
pub mod host_access;
pub mod install;
pub mod intelligence;
+pub mod local_eval;
+pub mod local_eval_state;
pub mod organization_auth;
pub mod plan;
pub mod preparation;
diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs
new file mode 100644
index 0000000000..86cbab662e
--- /dev/null
+++ b/desktop/src-tauri/src/local_eval.rs
@@ -0,0 +1,2449 @@
+//! 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` and `stop`, a validated stack id and `--json`,
+//! 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.
+//!
+//! Version 1 of the CLI's JSON reports that a command *completed*, not that Intelligence is ready
+//! or licensed. So readiness and licence are always `unknown` here until the CLI publishes those
+//! facts, and unknown keeps chat closed.
+//!
+//! Source: Intelligence `apps/cli/src/commands/local.ts`, `services/local-manager.ts` and
+//! `services/local-errors.ts` at 3935afbfe3cda88dfeabfd0250db08ff0ad51000.
+
+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,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Outcome {
+ Completed,
+ Blocked,
+ Failed,
+}
+
+/// A fact version 1 of the CLI cannot state. There is deliberately no other value yet.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Canonical {
+ 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,
+ /// 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,
+ _ => 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: Canonical,
+ pub licence: Canonical,
+}
+
+#[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. There is deliberately no way to pass `--accept-eula-version`. The person accepts
+the evaluation agreement themselves; the CLI refuses an agent that tries, and so does this.
+*/
+pub const SETUP_ARGUMENTS: [&str; 4] = ["local", "setup", "--defer-model", "--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);
+ let stack = match wire.get("stack") {
+ None | Some(Value::Null) if outcome == Outcome::Completed => {
+ return Err(Failure::MissingStack)
+ }
+ None | Some(Value::Null) => None,
+ Some(saved) => Some(stack(saved)?),
+ };
+ Ok(Report {
+ outcome,
+ stack,
+ reason: reason(outcome, wire)?,
+ readiness: Canonical::Unknown,
+ licence: Canonical::Unknown,
+ })
+}
+
+/// 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
- {!running && (
+ {!running && intelligenceMode === "local" && (
+ setIntelligenceMode("hosted")}
+ />
+ )}
+
+ {!running && intelligenceMode === "hosted" && (
)}
@@ -1333,6 +1409,8 @@ export function App() {
disabled={
busy ||
signingIn ||
+ // Local Intelligence cannot report readiness or credential OpenBot yet.
+ intelligenceMode === "local" ||
!installationReady ||
(apiKey.trim() === "" &&
!alreadyHeld.saved?.intelligenceApiKey &&
diff --git a/desktop/src/LocalIntelligence.test.tsx b/desktop/src/LocalIntelligence.test.tsx
new file mode 100644
index 0000000000..9c1cd87940
--- /dev/null
+++ b/desktop/src/LocalIntelligence.test.tsx
@@ -0,0 +1,196 @@
+import { afterAll, afterEach, beforeAll, expect, mock, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { cleanup, render, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { 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 } = await import("./LocalIntelligence");
+
+beforeAll(() => GlobalRegistrator.register());
+afterEach(() => {
+ invokeCalls = [];
+ cleanup();
+});
+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",
+ ...overrides,
+ };
+}
+
+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 };
+}
+
+function view(props: { stack?: string | null; developer?: boolean } = {}) {
+ return render(
+ {}}
+ />,
+ );
+}
+
+test("setup runs in this installation and a development build says how to accept the terms", async () => {
+ invokeHandler = async () => blockedOnTerms;
+ const screen = view({ developer: true });
+ await userEvent.click(
+ screen.getByRole("button", { name: "Set up local Intelligence" }),
+ );
+ await screen.findByText(/local accept-terms/);
+ const setup = invokeCalls.find(
+ (call) => call.command === "set_up_local_eval",
+ );
+ expect(setup?.args).toMatchObject({ root: "/tmp/openbot" });
+ expect(
+ screen.getByText("The Intelligence terms have not been accepted."),
+ ).toBeTruthy();
+});
+
+test("other builds never send the person to a terminal", async () => {
+ invokeHandler = async () => blockedOnTerms;
+ const screen = view({ developer: false });
+ await userEvent.click(
+ screen.getByRole("button", { name: "Set up local Intelligence" }),
+ );
+ await screen.findByText("The Intelligence terms have not been accepted.");
+ expect(screen.queryByText(/accept-terms|Terminal/)).toBeNull();
+});
+
+test("a completed setup is not readiness, and Start stays closed", async () => {
+ invokeHandler = async () => report();
+ const screen = view();
+ await userEvent.click(
+ screen.getByRole("button", { name: "Set up local Intelligence" }),
+ );
+ await screen.findByText(
+ "Intelligence started. Its licence and readiness are not reported yet.",
+ );
+ expect(screen.getByText(/local-abc123/)).toBeTruthy();
+ expect(
+ screen.getByText(/OpenBot cannot start against local Intelligence yet/),
+ ).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Check again" })).toBeTruthy();
+});
+
+test("a recorded stack is checked on arrival", async () => {
+ invokeHandler = async () => report();
+ view({ stack: "local-abc123" });
+ await waitFor(() =>
+ expect(
+ invokeCalls.find((call) => call.command === "run_local_eval")?.args,
+ ).toMatchObject({ action: "status", stack: "local-abc123" }),
+ );
+});
+
+test("a long setup can be cancelled by its own attempt", 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(
+ screen.getByRole("button", { name: "Set up local Intelligence" }),
+ );
+ await userEvent.click(await screen.findByRole("button", { name: "Cancel" }));
+ const setup = invokeCalls.find(
+ (call) => call.command === "set_up_local_eval",
+ );
+ const cancel = invokeCalls.find(
+ (call) => call.command === "cancel_local_eval",
+ );
+ expect(setup).toBeDefined();
+ expect(cancel?.args).toEqual({
+ attempt: (setup?.args as { attempt: string } | undefined)?.attempt,
+ });
+ await screen.findByText(
+ "The Intelligence command was stopped before it finished.",
+ );
+});
+
+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 userEvent.click(
+ screen.getByRole("button", { name: "Set up local Intelligence" }),
+ );
+ await screen.findByText("Intelligence could not start.");
+
+ invokeHandler = async () => {
+ throw {
+ said: "OpenBot could not save its local evaluation progress.",
+ detail: "synthetic",
+ };
+ };
+ await userEvent.click(
+ screen.getByRole("button", { name: "Set up local Intelligence" }),
+ );
+ 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 userEvent.click(
+ screen.getByRole("button", { name: "Set up local Intelligence" }),
+ );
+ await screen.findByRole("alert");
+ expect(document.body.textContent).not.toContain("synthetic-secret-marker");
+});
diff --git a/desktop/src/LocalIntelligence.tsx b/desktop/src/LocalIntelligence.tsx
new file mode 100644
index 0000000000..78fc89dc6a
--- /dev/null
+++ b/desktop/src/LocalIntelligence.tsx
@@ -0,0 +1,184 @@
+import { useEffect, useRef, useState } from "react";
+import {
+ cancelLocalEval,
+ LocalEvalError,
+ type LocalFailure,
+ type LocalReport,
+ runLocalEval,
+ setUpLocalEval,
+} from "./localEvalCli";
+import { localReadiness, type ReadinessRow } from "./localReadiness";
+import { asProblem, Failure, type Problem } from "./Problem";
+
+type Call = { attempt: string; report: Promise };
+
+/**
+ * Intelligence on this computer, run by the Intelligence CLI.
+ *
+ * Shown only when this build has a CLI to run. It sets the stack up and says where each part
+ * stands. Start stays closed: the CLI does not report readiness yet, and has no credential to give
+ * OpenBot for a local stack.
+ */
+export function LocalIntelligence({
+ root,
+ stack: recorded,
+ developer = import.meta.env.DEV,
+ onLeave,
+}: {
+ root: string;
+ stack: string | null;
+ developer?: boolean;
+ onLeave: () => void;
+}) {
+ const [stack, setStack] = useState(recorded);
+ const [answer, setAnswer] = useState(null);
+ const [problem, setProblem] = useState(null);
+ const [attempt, setAttempt] = useState(null);
+ const mounted = useRef(true);
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+
+ async function settle(call: Call): Promise {
+ setAttempt(call.attempt);
+ setProblem(null);
+ try {
+ const report = await call.report;
+ if (!mounted.current) return false;
+ setAnswer(report);
+ if (report.stack) setStack(report.stack.id);
+ return report.outcome === "completed";
+ } catch (error) {
+ if (!mounted.current) return false;
+ if (error instanceof LocalEvalError) {
+ if (error.category === "unrecognized") {
+ setProblem({
+ said: "OpenBot did not understand the Intelligence CLI's answer.",
+ });
+ } else {
+ setAnswer(error.category);
+ }
+ } else {
+ setProblem(asProblem(error));
+ }
+ return false;
+ } finally {
+ if (mounted.current) setAttempt(null);
+ }
+ }
+
+ // A recorded stack is asked about, never assumed to be in the state it was last seen in.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: checked once per recorded stack.
+ useEffect(() => {
+ if (recorded) void settle(runLocalEval("status", recorded));
+ }, [recorded]);
+
+ const setUp = () => settle(setUpLocalEval(root));
+ const check = () => stack && settle(runLocalEval("status", stack));
+ const restart = async () => {
+ if (!stack) return;
+ if (!(await settle(runLocalEval("stop", stack)))) return;
+ if (!(await settle(runLocalEval("start", stack)))) return;
+ await settle(runLocalEval("status", stack));
+ };
+
+ const rows = localReadiness({
+ runtime: "unknown",
+ intelligence: answer,
+ openBot: "unknown",
+ provider: "unvalidated",
+ });
+ const report = typeof answer === "object" ? answer : null;
+ const termsPending = report?.reason === "eula-acceptance-required";
+ const busy = attempt !== null;
+
+ function act(row: ReadinessRow) {
+ if (busy) return null;
+ if (row.action === "retry") {
+ return (
+
+ );
+ }
+ if (row.action === "restart-intelligence" && stack) {
+ return (
+
+ );
+ }
+ return null;
+ }
+
+ return (
+
+
+ Intelligence runs on this computer in Docker Desktop. The Intelligence
+ CLI sets it up and manages it.
+
A small installer installs all software and dependencies. The user then runs OpenBot, signs in with CopilotKit, selects an AI provider, and completes setup.
Confirmed: standard OS permission prompts and a restart with automatic resume are acceptable. No separate Docker installation, terminal commands, or manual resource settings.
Requirements
The installer owns dependency installation, configuration, updates, and recovery.
Desktop is the UI. The CLI owns Intelligence, credentials, licensing, and renewal.
Installation finishes before CopilotKit sign-in. Bot activation waits for provider validation.
macOS and Windows remain in scope. Under 15 minutes remains an unmeasured target.
Proposed change
Replace “Docker Desktop required” with an installer-managed runtime.
Recommended candidate: Docker Engine in a private Linux VM or WSL distribution.
Keep k3d and the hybrid OpenBot stack, subject to full compatibility tests.
The runtime choice is a recommendation. It is not an agreed or tested implementation.
Required sequence
Software preparation precedes account and provider configuration.
Step
User action
Installer / Desktop behavior
1 · Download
Download the small platform installer.
Select the correct architecture and fetch the versioned installation manifest.
2 · Install everything
Start installation. Approve OS permissions and restart if required.
Install OpenBot, CLI, runtime, dependencies, and available images. Configure resources. Save progress and resume automatically.
3 · Run OpenBot
Open OpenBot, or accept automatic launch.
Open the installed application. Verify the prepared runtime and retained state.
4 · CopilotKit sign-in
Complete browser sign-in and explicit EULA acceptance.
Hand a one-time code to the CLI. Obtain the local-eval licence and provision Intelligence automatically.
5 · AI provider
Choose a supported provider and supply required credentials.
Validate the key with a minimal request and store it in OS-backed secure storage.
6 · Success
Use the Bot.
Activate the configured Bot only when Intelligence is licensed/ready, OpenBot is connected, and provider validation passed.
Installation versus activation“Install everything” means prepare software, runtime, and downloadable assets before sign-in. Licence-bound provisioning follows sign-in. Provider-bound Bot activation follows validation. Any protected downloads must be identified and handled explicitly.
Feasibility findings
Official documentation establishes building blocks. No clean-machine installation or runtime compatibility test ran in this session.
Area
Evidence level
Finding
Source
Automated Docker installation
Documented
macOS has a command-line installer. Windows supports quiet installation and licence-acceptance flags. These commands can run inside our installer.
Professional use in larger organizations and government use require paid subscriptions. An OpenBot evaluation licence does not establish Docker Desktop entitlement.
The standard agreement restricts third-party distribution. Do not assume permission to bundle Desktop. A bootstrap download and its consent/entitlement flow need an agreed distribution approach.
The documented enterprise JSON management path requires Docker Business and organization sign-in. A general evaluator installer needs a separately validated configuration path.
Lima provides binary archives, a Docker VM template, socket forwarding, and a native macOS virtualization backend. Product packaging and OpenBot compatibility remain untested.
WSL can import a named Linux distribution. Packaging Docker Engine inside an OpenBot-owned distribution is a proposed design, not a tested product capability.
Firmware virtualization and enterprise policies can block local virtualization. Treat these machines as unsupported or blocked. Do not send the user to a terminal or BIOS procedure.
Avoids requiring Docker Desktop. We own VM integration, forwarding, updates, packaging, support, and component notices. OS consent/restart still applies.
Recommended candidate. Requires a compatibility gate before adoption.
C · Reuse installed Podman flow
Extend the engine installation and VM management already in OpenBot.
Reduces OpenBot installer work. Reopens the original Podman exclusion. k3d compatibility is experimental.
Keep out of the release path until both stacks pass a deliberate validation effort.
Feasibility conclusion
One installer is feasible in principle.
OS consent and restart/resume fit the confirmed requirement. A universal zero-interaction install is not the claim. Hardware and policy restrictions still limit supported machines.
Decision needed
Docker Desktop-only scope must change.
The revised requirement removes a user-installed Docker prerequisite. Option B is proposed for validation. Select the release runtime after evidence from both platforms.
Proposed ownership and runtime
Option B is shown for review. Desktop owns the machine runtime. The CLI retains ownership of Intelligence.
Trace
Revised requirements
User corrections supersede the original manual-Docker and manual-resource prerequisites. R01–R12 are presentation references.
Ref
Area
Required behavior
R01
Dependency installation
No preinstalled Docker, CLI, package manager, or developer tools. Download verified assets automatically. No terminal commands or manual settings.
R02
Bootstrap and resume
Small signed installer, versioned manifest, pinned digests, resumable downloads, progress, retry, and persisted installation checkpoints. Treat installer size separately from total download size.
R03
OS setup
Request standard elevation only when needed. Enable supported OS features and resume in the signed-in user context after an approved restart. Detect unsupported hardware or policy blocks early.
R04
Runtime ownership
Desktop manages the selected runtime and provides a private Docker endpoint/context to both stacks. No unauthenticated TCP Docker API. Never silently change the user’s default context or overwrite another runtime.
R05
Resources
Check host capacity and apply required settings automatically. Original estimates: 16 GB host, 8 GiB runtime RAM, 4 CPUs, 50 GiB free disk. 12 GiB runtime RAM recommended. Validate combined peaks and preserve existing global WSL settings.
R06
Authentication and licence
Run OpenBot before sign-in. Desktop opens browser authorization and hands a short-lived, one-time code to the CLI via stdin or protected IPC. The CLI owns refresh storage, EULA-backed licence setup, and renewal.
R07
CLI contract
CLI owns Intelligence topology, lifecycle, licensed readiness, and endpoint JSON. Add support for the selected runtime endpoint/context. The exact contract and Windows support remain to be validated.
R08
Provider and chat
Choose provider, enter own key, run minimal validation, and store secrets only in OS-backed secure storage. Invalid keys keep setup open. No Bot activation or chat before all prerequisites pass.
R09
Networking
Publish only configured loopback endpoints. Validate host↔guest forwarding, reverse callbacks, supervisor socket access, and volume paths. Loopback in the host and guest are different network namespaces.
R10
Lifecycle
Start runtime → Intelligence readiness → OpenBot. Stop OpenBot → Intelligence → owned runtime when safe. Retain both stacks’ data. Offer separate health/restart actions. Do not stop unrelated workloads.
R11
Renewal and diagnostics
Warn from day 25. Explicit renewal and EULA acceptance only. User-reviewed local diagnostics redact credentials, auth codes, chat, provider secrets, and persistent application data.
R12
Release evidence
Clean-machine macOS/Windows tests cover installation, reboot/resume, sign-in/EULA/licence, provider validation, first chat, interruption/retry, upgrades, retained data, lifecycle, renewal, and redaction.
Current code and implementation gaps
origin/main · 3c73cf0 · 23 September 2026. Code inspection establishes existing behavior, not clean-machine acceptance.
Current behavior conflicts with the revised requirementWindows blocker screens direct users to PowerShell commands and restarting OpenBot. The macOS vault uses an owner-only file. Neither meets the revised installation and storage requirements.
v2 · 20 Oct 2026Apple silicon + Windows x64Design · implementation in progress
OpenBot local installer · v2
One installer owns software acquisition, dependencies, runtime configuration, and recovery. Docker Desktop with CLI-managed k3d is the primary path. Podman is a contingency under separate investigation.
Current-code observations are pinned to source. Requirements below describe intended behavior; platform, licensing, security, and clean-machine qualification still require evidence.
Required sequence
Agreed sequence. Installation prepares software; sign-in authorizes and activates the local evaluation.
Download small installer → install everything → run OpenBot → sign in with CopilotKit → choose AI provider → success.
Step
Installer / app behavior
User input
Download / install
Fetch verified, pinned software; install OpenBot, CLI, dependencies and Docker Desktop if absent. Configure runtime resources, preserve checkpoints and resume after interruption. Prepare distributable assets before sign-in.
Start installer. Explicit software terms and required OS permission. Restart only with consent; resume automatically.
Run OpenBot
Open the installed app. Recover installation state and verify runtime ownership/health.
Open the app or accept automatic launch.
CopilotKit sign-in
Use browser authorization and the supported CLI credential handoff. Accept the eval EULA in-app. CLI obtains the licence and provisions Intelligence; Desktop connects OpenBot services.
Sign in and explicitly accept EULA. If multiple eligible organizations exist, select one within this step.
Choose AI provider
Validate the evaluator’s key with a minimal request; store it only in OS-backed secure storage.
Choose a supported provider and enter required credentials.
Success
Activate the Bot and enable chat only after licensed/ready Intelligence, running/connected OpenBot, and successful provider validation.
Use the Bot.
Decisions for implementation
These decisions settle the working design. Release gates below remain explicit.
Area
Decision
First release
Apple silicon macOS and Windows x64. Intel Macs and Windows ARM are deferred until validated. Linux is outside this installer release.
Runtime
Docker Desktop is the primary implementation target. The installer owns its acquisition and settings. No user-installed Docker prerequisite. A retained existing runtime is reused only if supported; unrelated workloads and settings are preserved.
Fallback
Podman CLI with an app-managed VM is the first contingency to validate. No Podman Desktop UI is required by this candidate. The separate investigation must prove full-stack support before a default change.
Scope of licence contingency
The contingency addresses Docker Desktop subscription/embedding terms. It is not a requirement to remove every Docker-named binary, API, or library. Open-source Compose can remain, subject to its own notices.
Ownership
Desktop owns installer/UI, machine prerequisites, runtime configuration and recovery. CLI owns Intelligence topology, credential exchange/refresh, eval licensing/renewal, service lifecycle, readiness and loopback endpoints.
OS setup
Standard permission prompts and an approved restart with automatic resume are allowed. No terminal commands, manual dependency installs, or manual resource settings in normal setup or recovery.
Installation boundary
Prepare the app, tools, runtime and distributable cache before sign-in. Provision/start licence-bound services after sign-in and EULA acceptance. Protected payload distribution must be resolved before shipping the exact install-everything promise.
EULA / organization
The person accepts for the selected organization under the current CLI policy. Do not assume a personal, organization-free grant. Select automatically only when the eligible organization is unambiguous.
Resources / timing
Initial design budget: 16 GB host, 8 GiB runtime RAM minimum, 12 GiB recommended, 4 CPUs, 50 GiB free disk. Installer configures supported settings automatically. Under 15 minutes is a test target; resource peaks and total download size are unmeasured.
Expiry / renewal
Use CLI-reported licence validity and expiry. Warn within five days of expiry: day 25 for a 30-day grant. Current Enterprise terms can differ. Renewal requires an explicit click and EULA acceptance; never silently renew.
Installations
One OpenBot installation per user per computer. The installer chooses its location; normal setup has no folder field. Repair fixes that installation in place. Moving or replacing it is an explicit action that states what happens to existing data. At most one local Intelligence stack is created for it, which is the stack that starts and stops with OpenBot. Hosted and enterprise setup can keep the folder choice.
Data / scope
Retain both stacks’ data and keep the per-user computer migration separate. No distribution gating work, SSO, managed provider keys, or separate local dashboard sign-in.
Primary architecture
Target behavior: Docker Desktop runtime, CLI-managed Intelligence, and the existing hybrid OpenBot stack.
Trace
Contracts and current gaps
Reuse existing CLI output where it exists. Add missing supported contracts instead of inferring them from files or containers.
Boundary
Existing source
Required implementation
Status and lifecycle
CLI already exposes version-1 JSON for local status/start/stop. Status includes urls, observed runtime/services, failedStep and update state. local commands · status fields.
Desktop adapter uses a pinned CLI binary and fixed argument arrays. Parse known fields, reject unsupported versions, map blockers to UI actions, and never execute CLI-provided resumeCommand strings.
Ready and licensed
Outer status completed means the command completed; it does not establish ready services or a valid licence. describeLocalStack does not expose licence validity/expiry. status fields.
Add canonical readiness and redacted licence metadata to CLI status. Missing/unknown licence or readiness keeps chat disabled. Never parse stored licence tokens or probe CLI containers from Desktop.
Define noninteractive one-time authorization-code handoff via stdin/protected IPC, explicit EULA acceptance receipt, and CLI refresh storage in OS-backed secure storage. No codes/tokens in argv, status, logs, or diagnostics.
Windows
Current Intelligence CLI rejects native Windows execution. OpenBot blockers still prescribe manual setup commands. CLI platform checks · Windows setup.
Implement supported Windows CLI packaging/runtime behavior and OS prerequisite automation. Persist/relaunch the signed-in user flow after approved restart. Do not claim current Windows support already satisfies this.
Runtime acquisition and configuration
OpenBot already fetches pinned Podman/Compose/Bun assets and supports Docker context selection. It does not establish a complete automatic Docker Desktop path. download/install · engine selection.
Add verified Docker Desktop install/configuration and explicit runtime selection. Validate supported unattended resource APIs and stop behavior. Preserve existing user workloads; no destructive global reset.
OpenBot activation / storage
The hybrid stack and provider UI exist; macOS vault uses an owner-only file. stack · vault.
Store provider keys in the OS-backed vault, validate before Bot activation, and use the readiness/licence/provider gate for chat. Preserve targeted retry and separate stack health.
Lifecycle and recovery
No automatic destructive teardown.
Trigger
Behavior
Start
Verify runtime → ask CLI to start Intelligence → wait for reported readiness/licence → start OpenBot → gate chat on provider validation.
Stop
Stop OpenBot first, then ask CLI to stop Intelligence. Stop an installer-owned runtime only when safe; never stop unrelated workloads.
Failure
Show independent runtime/Intelligence/OpenBot health. Offer targeted Retry, Restart Intelligence, Restart OpenBot, and local diagnostics review.
Provider failure
Remain on provider setup, identify provider and failure category, allow key edits or another provider, keep Bot activation/chat blocked.
Ports
CLI owns Intelligence endpoints. Desktop retains separate loopback ports for OpenBot. Provide recoverable conflicts without container inspection.
Diagnostics
Local bundle: installer/Desktop logs, CLI version and structured status, anonymized resource results, port/lifecycle errors. Exclude credentials, auth codes, provider keys, chat and persistent data. Review before sharing.
Contingencies
These options do not block work on the primary installer.
Option
Role now
Switch condition
Podman
First fallback investigation in a separate agent tab. Existing OpenBot support is useful; full Intelligence support and k3d compatibility remain unproven.
Use if Docker Desktop terms or deployment restrictions are unsuitable and Podman passes supported CLI/licence, network, volume, lifecycle and clean-machine tests.
Managed Docker Engine
Secondary option: private runtime instead of Docker Desktop. Requires more VM packaging, updates and support. Current macOS CLI policy would also need a supported alternative-runtime contract.
Consider only if terms or runtime constraints justify added ownership.
k3s/containerd or no-container execution
Larger alternatives documented in the research report. Not part of this implementation path.
Revisit only if the smaller runtime alternatives fail. No present performance or compatibility advantage is established.
Podman still needs a Linux VM on macOS/Windows; lower RAM/CPU/startup cost has not been measured. Podman machine. Compose is independently Apache-2.0: Compose licence. Docker Desktop terms are separate: Desktop licence.
Start coding: the Desktop CLI boundary
First slice is independent of the Podman investigation and release qualification.
Order
Deliverable
Acceptance
1 · Wire contract
Normalize current v1 CLI JSON in Desktop; expose typed status, ownership, endpoint and recovery fields. Add licence/readiness capability states that remain unknown until CLI supplies them.
Fixtures cover command completed with stopped/unready services, partial updates, unknown schema, missing licence, invalid endpoints, and structured blockers.
2 · Process adapter
Pinned CLI resolution, fixed command/argument builder, bounded execution/cancellation and output. Status/start/stop are the first supported calls.
Fake executable proves arguments, exit-code mapping, timeouts and no raw secret/log leakage. No shell or CLI-resume-command execution.
3 · Installer state
Persist nonsecret acquisition/checkpoint state separately from auth/runtime state. Enforce installation → app → sign-in → provider → activation.
Restart/interruption resumes correctly; existing valid installation is reused. Unknown prerequisites block activation, not installation work.
4 · CLI extensions
Coordinate secure auth/EULA handoff, licensed readiness/expiry JSON, and Windows support in the Intelligence repo.
Versioned contract fixtures pass on both sides. Desktop never duplicates licensing or reads raw credentials.
Pass the clean-machine matrix on Apple silicon and Windows x64 with consent/restart/resume. The current manual prerequisites are eliminated.
Implementation progress
Branch dustywusty/feat-local-eval-installer, not yet proposed upstream. Verified on Apple silicon only; Windows code is not compiled on this host.
Slice
State
Evidence / next
+
1–2 · Wire contract and process adapter
Done. local_eval.rs: SHA-256-pinned CLI, fixed arguments only (status/start/stop/list, setup --defer-model), environment allowlist, owner-only output file capped at 1 MiB, per-attempt cancel, process-group stop, one CLI call at a time. Readiness and licence stay unknown. Release builds run no CLI; debug builds accept a developer pin by path and digest.
Live against copilotkit 4.29.0: setup (after accept-terms in the developer’s terminal) 123 s, status 5.4 s, stop 6.7 s, start 47.9 s. Finished Jobs report ready: false on a healthy stack, so pod observations cannot stand in for readiness.
+
3 · Installer state and lifecycle
Partly done. Checkpoint record (.openbot-local-eval.json: stack, created or shared, CLI digest, phase). Stop and Quit stop only a stack OpenBot created, after OpenBot’s own cleanup; Intelligence failures are reported separately and never mark OpenBot for recovery; Quit allows the CLI 2 minutes.
Next: advance phases through sign-in and provider; resume after a Windows restart; one installation per computer.
+
Window
Local mode panel (LocalIntelligence.tsx) under Connect: four independent status rows, Set up / Check again / Restart Intelligence / Cancel, terms instruction in development builds only, Start disabled with the reason. Offered only when a pinned CLI answers.
Next: make local Intelligence the primary path of the local installer, with CopilotKit sign-in licensing it.
+
4 · CLI contracts
Draft requests ready (artifact and pin, stdout and exit guarantees, readiness and licence, sign-in, GUI EULA recorded by the CLI, Windows, pre-auth payloads, Bot credential, setup for a Bot and stack owner). Re-checked against Intelligence main 780313e: no CLI changes since the first read.
To be filed in Linear (Product Engineering); request 4 relates to PE-750.
+
Found while testing
Upstream fixes opened: #813 deployment missing agent-computer (new installs could not start their server); #821 a reinstall reused the previous installation’s Bot volumes and browser sign-ins; #822 the window froze when focused during an install.
Review and merge. Separate item: GitHub tools for Bots via GitHub’s MCP server with a device-flow sign-in.
+
5 · Full integration
Not started: Docker Desktop install and resource settings, provider key validation, macOS Keychain storage, diagnostics bundle, clean-machine matrix.
Docker Desktop installs from the command line on both platforms (one password prompt on macOS; UAC, WSL 2 and possibly a restart on Windows). Docker’s terms must be accepted by the person, not by a flag on their behalf.
+
Open release gates
These are concrete engineering or rollout tasks. They do not prevent coding the adapter and installer state now.
Gate
Required evidence
Owner role
Docker Desktop terms
Confirm entitlement, distribution/embedding approach and explicit user acceptance. No assumption that the OpenBot eval licence covers Docker Desktop.
Release/product owner
CLI contracts and Windows
Supported credential/EULA handoff, licence/readiness/expiry status, Windows x64 package/runtime acceptance. Current CLI does not satisfy these contracts fully.
CLI + Desktop owners
Install-before-sign-in payloads
Confirm a distributable pre-auth manifest/cache for all required software, or identify protected assets and resolve the packaging agreement.
Release + CLI owners
Automatic runtime resources
Prove supported settings/configuration on new and retained runtimes, including enterprise policy blocks and noninterference with user workloads.
Installer owner
Signed installers
macOS DMG signed with a Developer ID certificate and notarized (today ad-hoc only, so Gatekeeper blocks first launch); Windows NSIS installer signed for every release (signing is wired through Azure Key Vault but opt-in).
Release owner
Security / privacy
OS-backed storage on both platforms, secure handoff and redaction tests. No raw provider keys in persistent app data or diagnostic output.
Desktop + CLI/security owners
Resource / timing qualification
Combined provisioning/runtime peaks, actual payload and install time under stated conditions; test insufficient resources and recoverable failures.
Platform test owner
End-to-end release matrix
Browser auth/EULA, licensed readiness, loopback deployment, provider recovery/chat gate, start/stop/restarts, expiry/explicit renewal, retained data and diagnostic redaction.
Desktop + CLI/test owners
Delivery slices
Keep the first slice small, then integrate the current primary runtime.
+
+
+
+
diff --git a/docs/handoffs/local-eval-cli-contract-requests.md b/docs/handoffs/local-eval-cli-contract-requests.md
new file mode 100644
index 0000000000..913e3cf511
--- /dev/null
+++ b/docs/handoffs/local-eval-cli-contract-requests.md
@@ -0,0 +1,194 @@
+# Local evaluation: contract requests to the Intelligence CLI
+
+From: OpenBot Desktop. To: CopilotKit Intelligence CLI owners. Status: DRAFT, not sent. Date: 2026-10-20.
+
+Source read: Intelligence `main` at `780313e4edf6fc5f038c3cf5e4612ac668446c45` (CLI `copilotkit` 4.29.0; no `apps/cli` changes since `3935afbf`, where this was first read). Paths below are under `apps/cli/` unless they start with `docs/`.
+
+## Summary
+
+1. OpenBot Desktop runs a SHA-256-pinned CLI with fixed arguments only: `local status|start|stop --stack ID --json`, `local list --json`, and `local setup --defer-model --json` in the installation folder. No shell, no `resumeCommand` execution, output bounded. It has no release artifact to pin yet. Verified live against 4.29.0 on Apple silicon: setup (after `accept-terms` in the developer's own terminal), status, stop, start.
+2. Version 1 output says a command completed. It does not say Intelligence is ready or licensed, so Desktop reports both as `unknown` and keeps chat closed.
+3. Most of the data already exists inside the CLI: a verified licence with `expires_at`, the EULA policy, and a device-code sign-in with JSON events. It is not published as a contract.
+4. Windows is rejected in three places, not one.
+5. Every field marked PROPOSED is a suggestion. Desktop will not use a flag or field until the CLI ships it.
+
+## Decisions on the Desktop side (2026-10-20)
+
+- **Readiness is required.** Desktop will not start OpenBot from pod observations. It waits for request 2. Until then, local mode stops after Intelligence starts.
+- **OpenBot needs its own credential for local Intelligence.** This is new CLI surface: request 8.
+- **Tied lifecycle.** A stack that Desktop created for OpenBot starts when OpenBot starts and stops when OpenBot stops or quits. A stack that other apps also use is never stopped by OpenBot. Request 9 asks how to tell the two apart.
+
+## Priority
+
+| # | Request | Blocks Desktop slice |
+|---|---|---|
+| 1 | Release artifact | Every slice. Nothing can run. |
+| 7 | Stdout, exit codes, reason set | Hardens the adapter in use now. Low effort. |
+| 2 | Readiness and licence in status | Chat gate and success screen |
+| 3 | Non-interactive sign-in | "Sign in with CopilotKit" step |
+| 4 | EULA acceptance from a GUI | Licence provisioning after sign-in |
+| 5 | Native Windows x64 | All Windows work. Long lead, so start in parallel. |
+| 6 | Payloads fetched before sign-in | "Install everything" before sign-in. Release gate. |
+| 8 | Credential for OpenBot to local Intelligence | Starting OpenBot against local Intelligence |
+| 9 | Stack creation and ownership for Desktop | First run (no stack yet) and tied lifecycle |
+
+## 1. Release artifact for embedding
+
+**Current.** The CLI ships as npm package `copilotkit`, `bin: ./index.js`, `engines.node >=20.9.0` (`package.json`). `oidc-provider` is a runtime dependency and is kept external to the bundle (`esbuild.config.cjs:127`), so `index.js` is not self-contained. `local start` forks a detached sign-in worker with `child_process.fork` (`src/services/local-auth-service.ts:63`), and that worker runs on whatever runtime launched the CLI. Telemetry is on by default. `DO_NOT_TRACK` and `COPILOTKIT_TELEMETRY_DISABLED` turn it off (`src/services/consent/gate.ts:51-78`). With no pinned version, setup takes the newest chart through Helm's stable selection (`src/services/local-default-release.ts:130-136`). We found no code that updates the CLI itself.
+
+**Gap.** There is no per-platform file with a published digest. Bun support is not stated.
+
+**PROPOSED.**
+- One archive each for `darwin-arm64` and `win32-x64`, with a published `SHA256SUMS`. It holds either a single executable, or `index.js` plus its `node_modules` and a named runtime version.
+- An answer on runtime: is the CLI supported under Bun 1.3.14 (Desktop already bundles it), including `fork` IPC and `oidc-provider`? If not, which Node version should Desktop pin?
+- Release builds pin the chart version they were tested with.
+- No network self-update at any time.
+- A documented list of environment variables that are part of the contract: telemetry opt-out and `HTTPS_PROXY` (`src/services/api-client.ts:589`).
+- `copilotkit version --json` → `{ "version": "4.30.0", "chartVersion": "…", "contracts": { "local": 1 } }`.
+
+**Fixture.** Desktop downloads the archive and checks its digest. It runs `version --json` with the network blocked and gets exit 0 with one JSON document.
+
+**Until then.** `installed()` returns `Unavailable`, and the UI shows the CLI as missing.
+
+## 2. Canonical readiness and licence in `local status --json`
+
+**Current.** `local status` emits `stack` (from `describeLocalStack`, `src/services/local-manager.ts:93-111`), `observed` pods (`:126-138`), `auth` `{running,pid,issuer}` and `learning` (`src/commands/local.ts:278-284`). No field gives overall readiness or licence state. The CLI already verifies the licence offline and tolerates `expired` (`src/services/local-license-identity.ts:5-6`). The signed payload carries `expires_at` (see `packages/license-verifier` tests).
+
+**Gap.** Desktop must not read `setup.json` or decode the token itself. It needs these facts from the CLI.
+
+**PROPOSED** (additive top-level fields on `local status`, v1. Desktop treats a missing field as `unknown`):
+```json
+"readiness": { "state": "ready|starting|stopped|degraded|unknown", "checkedAt": "ISO-8601" },
+"license": { "state": "valid|expired|missing|invalid|organization_mismatch",
+ "expiresAt": "ISO-8601|null", "organizationId": "string", "organizationName": "string",
+ "renewal": "none|pending|applied" }
+```
+The output never contains a token. `ready` means the CLI's own end-to-end check passed. It does not mean pods are running.
+
+**Fixture.** One fixture per `license.state`. One with `expiresAt` 4 days ahead. One where pods are ready but the API is not, which must give a state other than `ready`.
+
+**Until then.** Both stay `unknown`. Desktop shows a warning when `expiresAt` is 5 days away or less. The person starts renewal with `local renew`. Desktop never renews on its own.
+
+## 3. Non-interactive sign-in
+
+**Current.** `copilotkit login --json` runs a device authorization grant. It streams JSON Lines with `type: action_required` (`authentication_url`, `user_code`, `browser_opened`), then `completed` or `failed` (`src/commands/login.tsx:819, 929-969, 1013`). No secret passes through the caller. The help text says "without opening a browser" (`src/index.ts:252`). The code reports `browser_opened`. Tokens and `clerkOrgId` go in a `conf` file with mode 0600 (`src/services/config.service.ts:33-41`, `src/services/private-conf.ts`). `whoami` prints text only (`src/index.ts:1778-1831`). We found no flag for choosing an organization.
+
+**Gap.** The event schema has no version. The CLI does not use OS secure storage. Choosing an organization is not specified.
+
+**PROPOSED.**
+- Make `login --json` a versioned NDJSON contract.
+- Say which side opens the browser.
+- When the person has several eligible organizations, they choose one in the browser page. The CLI picks one automatically only when exactly one is eligible.
+- Add `copilotkit whoami --json` → `{ "signedIn": bool, "organizationId", "organizationName" }`. No tokens.
+- Store CLI credentials in macOS Keychain and in Windows Credential Manager/DPAPI, with no plaintext fallback in packaged builds.
+- If a loopback or PKCE code is ever needed, it is delivered on stdin. Never in argv or environment variables. (`local auth approve ` passes a code in argv today, `src/index.ts:162`.)
+
+**Fixture.** A recorded event stream for each case: single organization, several organizations, expired code, denied.
+
+**Until then.** Desktop does not run `login`. Sign-in is shown as blocked by `authentication_required`.
+
+## 4. EULA acceptance from a GUI
+
+**Current.**
+- `onboard local-terms --json` reads `status`, `accepted`, `version`, `url`, `organizationId`, `organizationName` (`src/services/local-eula-terms.ts:30-90`, `src/commands/onboard.ts:1092`). It also returns agent prose: `acceptCommand`, `question`, `next`.
+- `local accept-terms` needs a TTY and no detected coding agent (`src/commands/local-accept-terms.ts:74-80`).
+- `--accept-eula-version` exists on `setup`, `start` and `renew`, and is documented as "scripts only" (`src/index.ts:166-167`, `src/commands/local-setup.ts:70`).
+- Renewal never uses a stored acceptance (`src/services/local-license.ts:115-119`).
+- Agent detection walks the process ancestry (`src/services/coding-agent.ts:367`).
+
+**Gap.** There is no GUI route that the policy (`docs/decisions/local-evaluation-cli-auth-and-license-policy.md:91-100`) clearly allows. There is no acceptance receipt.
+
+**PROPOSED.**
+- Product owner rules whether a person clicking "Accept" in Desktop counts as the person's acceptance. Desktop's AI bots never get the CLI.
+- `local terms --json` (read only) → `{ version, sha256, url, organizationId, organizationName, accepted }`, with no prose.
+- `local accept-terms --json --eula-version V --eula-sha256 H` reads `{"accept":true}` on stdin and returns a receipt `{ version, sha256, organizationId, acceptedAt }` recorded by Ops.
+- Preferred alternative (2026-10-20): the CLI, not Desktop, records the acceptance. The `blocked` answer for `eula_acceptance_required` carries the approved agreement's address in its existing `nextAction.url` field. That address is an acceptance page, hosted by CopilotKit or served locally by the CLI, where the person reads to the end and clicks Accept, and the acceptance is recorded against the person and selected organization. Desktop opens the page and then runs `local setup` again. Desktop never sends an "accepted" signal, so it cannot accept on the person's behalf. The page enforces reading to the end; Desktop cannot observe scroll position in a page from another site.
+
+**Fixture.** A changed sha256 is refused. An acceptance for a different organization is refused. An agent ancestor is refused.
+
+**Observed live (4.29.0, 2026-10-20).** `local setup --defer-model --json` in a fresh folder exits 2 with `blocked` / `eula_acceptance_required` for version `developer-edition-2026-10-01-web`, and states that an agent must not accept for the developer.
+
+**Until then.** Desktop opens `url` for the person to read and does not accept. Development builds may tell the person to run `local accept-terms` in their own terminal and then Continue; shipped builds may not, because normal setup has no terminal commands.
+
+## 5. Native Windows x64
+
+**Current.**
+- `assertLocalPlatform` allows only darwin and linux (`src/services/local-platform.ts:5-9`).
+- The Docker Desktop check is darwin-only (`:36-38`).
+- The sign-in control socket must be `/tmp/cpki-auth-*/control.sock` (`src/services/local-auth-control.ts:12, 95`).
+- Helm and k3d tools map every non-darwin system to `linux` (`src/services/local-default-release.ts:125`).
+- The release schema already accepts `win32` (`:26`).
+
+**PROPOSED.**
+- Native `win32-x64` CLI on Docker Desktop with the WSL2 backend.
+- A named-pipe control channel restricted to the current user by ACL.
+- Windows helm/k3d entries in the release metadata.
+- Desktop prefers native over a Linux CLI inside WSL: no distro management, no path translation, one credential store.
+
+**Fixture.** `local status --json` on clean Windows 11 x64 returns no `unsupported_platform`.
+
+**Until then.** Windows shows `unsupported_platform` as the CLI reports it.
+
+## 6. Payloads fetched before sign-in
+
+**Current.** Setup order is: platform check (`src/services/local-setup.ts:69`), release lookup (`:127`, an anonymous `helm pull` and `docker buildx imagetools inspect` of `ghcr.io/copilotkit/...`), then licence issuance (`:129`), then tool download (`:158`). We found no registry credential in CLI source.
+
+**Gap.** No command prepares the payloads without a licence. Whether those ghcr packages are public is not stated.
+
+**PROPOSED.**
+- Confirm which images and charts are public, and whether any payload is tied to the licence.
+- `local prepare --json [--candidate FILE]` → `{ release, chart: { version, sha256 }, images: [{ repository, digest, present }] }`. It creates no stack record and requests no licence.
+
+**Fixture.** Run signed out with the network on, then start with the network blocked. No pull happens.
+
+**Until then.** The image pull happens after sign-in, inside `local setup`.
+
+## 7. Machine-readable output
+
+**Current.**
+- `runLocal` catches every error in `--json` mode and prints one JSON line, exiting 0, 1 or 2 (`src/commands/local.ts:94-117`).
+- Progress is silent under `--json` (`src/services/local-setup-progress.ts:206-213`).
+- Argument errors raised before `runLocal` write to stderr and exit 1 with no JSON (`src/index.ts:1665-1666`).
+- Reasons come from two unions (`src/services/local-errors.ts:15, 24`) and from ad hoc literals: `confirmation_required`, `missing_inputs`, `stack_stopped` (on a *completed* renew, `src/commands/local.ts:243`), `approval_not_pending`, `browser_open_failed`, `dashboard_unavailable`.
+- `connect`/`cancel` return `blocked` with `nextAction: null` (`:274`).
+
+**PROPOSED.**
+- Every `local * --json` writes exactly one JSON document to stdout. This includes argument errors.
+- Exit codes are a closed table: 0 completed, 2 blocked, 1 failed.
+- An exported, closed `nextAction.reason` enum. A new reason is additive, and blocking unless documented otherwise.
+- Changing what an existing reason means requires a version bump.
+- `login --json` is documented separately, as NDJSON.
+
+**Fixture.** A golden file for each reason, plus a bad-flag case that must still produce JSON.
+
+**Until then.** An unrecognised reason maps to `Unrecognized` and is treated as blocked (OpenBot Desktop's reason table).
+
+## 8. Credential for OpenBot to call local Intelligence (new surface)
+
+**Current.** OpenBot's hosted path uses a project key (OpenBot Desktop's Intelligence sign-in), and `start_stack` needs an Intelligence API key. `local credentials --json` returns the dashboard address and sign-in commands (`src/commands/local.ts:252-255`), not a key for an app. `local connect` writes `CPK_INTELLIGENCE_API_KEY` into an app folder's `.env` (`:263-266`). Desktop may not read that file.
+
+**Gap.** OpenBot is a bot, not an app folder. It needs a credential for the local stack that the CLI issues and Desktop stores in OS-backed secure storage.
+
+**PROPOSED.** `local bot-credential --stack ID --json` (name open). It writes the credential to stdout once, in a field Desktop reads and never logs, or through protected IPC. It is scoped to that stack, can be revoked, and is listed without its value. Desktop passes it on stdin or through the OS vault, never in argv.
+
+**Fixture.** Issue, list (no value), revoke, and use after revocation, which must fail.
+
+**Until then.** Desktop does not start OpenBot against local Intelligence.
+
+## 9. Stack creation and ownership for Desktop
+
+**Current.**
+- `local list --json` returns every recorded stack as `describeLocalStack` output, including `appPaths` (`src/commands/local.ts:166-168`). Desktop now calls it and keeps only the allowlisted stack fields.
+- `local setup --json` creates a stack and returns it in a `completed` envelope (`src/commands/local-setup.ts:327`). It needs a signed-in CLI and EULA acceptance (requests 3 and 4).
+- Setup binds the stack to the working directory as an app folder (`local-setup.ts:85-90`), which fits a developer's app. Without `--model-config` or `--defer-model` it blocks with `missing_inputs` (`:61`).
+
+**Gap.** Desktop has no app folder to offer, and nothing records that a stack exists only for OpenBot.
+
+**PROPOSED.**
+- A supported way to run setup for a bot rather than an app folder, with `--defer-model` (model choice happens in OpenBot).
+- An `owner` (or `createdBy`) field on each stack, set at creation and shown in `list`/`status`, so Desktop can tell its own stack from a shared one.
+
+**Fixture.** Setup for OpenBot returns a stack whose owner is OpenBot. A stack created from an app folder shows a different owner.
+
+**Until then.** Desktop can list stacks but does not create one. It treats any stack it did not create as shared and never stops it.
+
diff --git a/docs/handoffs/local-eval-implementation-v2.md b/docs/handoffs/local-eval-implementation-v2.md
new file mode 100644
index 0000000000..d6db110463
--- /dev/null
+++ b/docs/handoffs/local-eval-implementation-v2.md
@@ -0,0 +1,89 @@
+# OpenBot local installer — implementation handoff
+
+Date: 2026-10-20. Take ownership of implementation in this checkout. The user wants to start coding today; the requirements and initial contract code are ready. Continue useful implementation through concrete, tested slices. Do not publish, deploy, or accept third-party legal terms on the user's behalf.
+
+## Read first
+
+- **Authoritative updated artifact:** `/Users/d/orca/workspaces/openbot/herring/docs/artifacts/openbot-local-eval-requirements-v2.html`. Read its full requirements, decisions, architecture, lifecycle, coding sequence, implementation progress, and release gates. It is a standalone interactive HTML page with a Present mode.
+- **Foundation plan:** `/Users/d/orca/workspaces/openbot/herring/docs/superpowers/plans/2026-10-20-local-eval-cli-boundary.md`. Tasks 1–3 are complete; native runner and live integration follow. This directory is gitignored, so preserve the plan deliberately if preparing a review.
+- **Existing new code:** `desktop/src/localEvalCli.ts` and `desktop/src/localEvalCli.test.ts`.
+- Read applicable repository instructions before editing. Preserve `docs/artifacts/openbot-local-eval-requirements-r21.html`; it is the original version.
+
+## Settled requirements
+
+The required flow is: **download small installer → install everything → run OpenBot → sign in with CopilotKit → choose AI provider → success**.
+
+First-release platforms are **Apple silicon macOS and Windows x64**. Intel Macs and Windows ARM are deferred until validated. The installer owns dependency installation, runtime setup, and resource configuration. Users must not install Docker, use terminal commands, install developer tools, or resize VMs manually. Standard OS permission prompts and a Windows restart with automatic resume are explicitly acceptable.
+
+The primary architecture is **Docker Desktop with CLI-managed k3d Intelligence**. The user's Docker concern is an off-ramp if Desktop licensing/distribution terms prove unsuitable. A separate Podman investigation exists; do not switch the default or block primary implementation waiting for it. Do not adopt a strict ban on Docker components or containers from older research.
+
+Desktop owns installer orchestration, dependencies, UI, recovery, and machine lifecycle. The Intelligence CLI owns Intelligence topology, auth exchange/refresh, licence lifecycle, readiness, and endpoints. Consume structured CLI contracts; do not recreate its installer, probe internal containers to infer missing facts, or parse its credential files.
+
+Prepare distributable software and runtime before sign-in. Licence-bound provisioning begins after CopilotKit sign-in and explicit current EULA acceptance. Pre-auth protected payload distribution is a release gate. Select the sole eligible org automatically; otherwise show the org choice during sign-in. Acceptance belongs to the accepting person and selected org.
+
+Use browser auth with one-time codes through stdin or protected IPC. Never put secrets/codes in argv, logs, or status. Use OS-backed secure storage for credentials and provider keys. Validate the user's provider key with a minimal request; invalid keys stay editable in setup. Chat requires canonical licensed and ready Intelligence, running and connected OpenBot, and a validated provider. Unknown is blocked, never guessed ready.
+
+Start runtime, then Intelligence, then OpenBot. Stop OpenBot before Intelligence. Stop an installer-owned runtime only when safe for unrelated workloads. Preserve per-Bot data and folder confinement; use targeted retry/recovery rather than destructive teardown. Keep CLI Intelligence endpoints separate from OpenBot ports.
+
+Initial capacity estimates (16 GB host RAM; runtime 8 GiB minimum/12 recommended, 4 CPUs, 50 GiB disk) and the under-15-minute target are unmeasured. Treat them as qualification targets. Licence warnings use actual expiry within five days; renewal/EULA remain explicit. Diagnostics must redact credentials, auth codes, chat, and persistent user data.
+
+The user prefers factual presentation with no marketing language or startup CTA copy.
+
+## Current workspace and work completed
+
+Worktree: `/Users/d/orca/workspaces/openbot/herring`.
+Orca worktree ID: `e94d6b71-21b7-4183-a5e1-6a4b7efbac4a::/Users/d/orca/workspaces/openbot/herring`.
+Branch: `dustywusty/feat-local-eval-installer`.
+OpenBot baseline: `3c73cf00efba46122dfd0447485e2b61f1d6a2cd`.
+
+There are no implementation commits yet. Both TypeScript files are untracked, as are the artifact and research directories. Preserve existing work. Leave unrelated `docs/.DS_Store` alone. This handoff uses the same checkout so the new agent receives the uncommitted files.
+
+`localEvalCli.ts` implements a bounded version-1 CLI status parser and fixed argument builder only. It is not imported into the app and executes no process. The parser caps UTF-8 input at 1 MiB, validates the envelope and stack identifier, retains allowlisted observations, validates loopback HTTP/WS endpoints (including the CLI's single-label `.localhost` dashboard), and strips raw auth/error/recovery fields. Error categories are static and contain no raw payload. Canonical licence and readiness remain `unknown` because the current CLI does not provide either fact. `localCommandArgs` permits only status/start/stop with separate fixed arguments; it also validates at runtime.
+
+No native Rust modifications remain from this foundation. Cargo and rustc were unavailable in the previous shell, including standard installation paths. Arrange a suitable toolchain or test host as needed; do not claim native validation from the frontend build.
+
+## Verified before handoff
+
+- `bun test desktop/src/localEvalCli.test.ts`: 29 passing contract tests.
+- `bun test desktop/src`: 175 pass, 0 fail, 902 assertions.
+- `bun run --cwd desktop typecheck`: passed.
+- `bun run --cwd desktop build`: passed (frontend only).
+- Biome format and lint for both new files: passed. `git diff --check`: passed.
+- Artifact smoke, navigation, Present mode, mobile overflow, and source links checked; no JS errors found.
+- Original r21 artifact SHA-256 remains `51fdd0d798547fb35fae2ac9536c9e5cc2d26329dac848b0fbc77a193bfb9491`.
+
+Bun 1.3.14 is available. Desktop dependencies were installed with `bun install --cwd desktop --frozen-lockfile`; no lockfile changes. Full test log: `/tmp/openbot-desktop-tests.log`. Revalidate after changes; these are prior results.
+
+## Next implementation slice
+
+1. Inspect the actual code and extend the plan for a bounded native CLI subprocess adapter. Resolve a verified pinned executable; independently validate actions and stack IDs in Rust. Use argument arrays without a shell. Bound stdout/stderr and time, support cancellation and child cleanup, and return sanitized failure categories. Never execute CLI-provided recovery command strings.
+2. Wire Tauri and setup UI through the typed boundary. Keep runtime, Intelligence, OpenBot, and provider states independent. Preserve unknown/blocked states until authoritative contracts exist. Test command outcomes, cancellation, limits, and gating with meaningful fixtures.
+3. Extend installer checkpoints and recovery toward the agreed sequence. Coordinate missing CLI capabilities rather than inventing flags, licence facts, or successful sign-in.
+4. Keep the v2 artifact's progress and release gates accurate as implementation advances. Platform clean-machine validation remains necessary.
+
+## Intelligence CLI evidence and outstanding contracts
+
+Nearby repo: `/Users/d/orca/workspaces/Intelligence/local-evaluation-cold-install`. The checked live `origin/main` was `3935afbfe3cda88dfeabfd0250db08ff0ad51000`; verify before relying on newer behavior. Read with `git show` without changing someone else's checkout. If coordinated edits are needed, use an appropriate isolated checkout and its instructions.
+
+- `apps/cli/src/commands/local.ts`: v1 JSON envelopes and `local status/start/stop --stack ID --json`. `status: completed` means the command completed, not that Intelligence is ready or licensed.
+- `apps/cli/src/services/local-manager.ts`: stack metadata and pod observations; no canonical licence validity/expiry or overall readiness.
+- `apps/cli/src/services/local-platform.ts`: native Windows, Podman, rootless, and unknown engines currently rejected; macOS Docker Desktop and Linux rootful Docker are supported policy paths. Running Linux CLI inside WSL is a distinct unvalidated contract.
+- `apps/cli/src/services/local-driver.ts` and `local-install.ts`: CLI owns Docker/k3d topology, pinned images, dependency ordering, and jobs. Do not duplicate in Desktop.
+- `apps/cli/src/services/local-dashboard.ts`: current dashboard permits localhost or a single-label `.localhost` host.
+- `docs/decisions/local-evaluation-cli-auth-and-license-policy.md`: accepted org/EULA and CLI auth/licence policy. Do not bypass its guards.
+
+Required coordinated work includes secure auth/EULA handoff, canonical readiness/licence metadata, and Windows runtime support. Docker Desktop entitlement/distribution terms, pre-auth payload distribution, automatic resources, secure storage, and clean-machine timing remain release gates. They do not prevent implementing the process boundary and honest blocked-state UI now.
+
+## OpenBot integration points
+
+Inspect `desktop/src-tauri/src/{main,install,acquire,windows,preparation,engine,stack,intelligence,vault,host_access}.rs` and `supervisor/src/docker.ts`.
+
+Current code already has pre-auth acquisition, pinned Podman/Compose/Bun downloads, named-VM sizing, nonsecret preparation checkpoints, Docker/Podman contexts, hybrid host/container services, and per-Bot confinement. Windows currently has manual WSL blockers. Existing Desktop auth must not duplicate CLI auth ownership. macOS vault storage currently needs OS-backed replacement; Windows uses DPAPI. Preserve working runtime and supervisor behavior while introducing the agreed architecture.
+
+## Separate research tab
+
+The Podman investigation tab is independent: `Investigate Podman fallback | herring`, terminal `term_012144b4-b37c-4cbb-aa55-a0ac429030bb`. Its files live under `research_notes/Podman fallback investigation/`, including `findings-brief.html`. Read its results if useful; leave ownership of those notes there. Its research is not a prerequisite for primary-path coding.
+
+Older `reports/OpenBot without Docker.md` and `research_notes/OpenBot without Docker/` contain constraints from a broader alternative investigation. The v2 installer artifact overrides their proposed direction.
+
+You own implementation after receiving this brief. The originating agent is handing off, not coordinating a supervised worker. Start by reading the artifact and source, then continue the next tested implementation slice.
From c022d8757330324a6e811a7aabf72d6c9239fa6e Mon Sep 17 00:00:00 2001
From: Dusty
Date: Wed, 21 Oct 2026 20:27:51 -0700
Subject: [PATCH 05/77] wip(desktop): local Intelligence start sequence,
readiness gate and automatic step 4
Local checkpoint for the demo; not for review as-is.
---
desktop/src-tauri/src/local_eval.rs | 660 +++++++++++++++++++++-
desktop/src-tauri/src/local_eval_state.rs | 52 +-
desktop/src-tauri/src/main.rs | 462 ++++++++++++++-
desktop/src/App.test.tsx | 324 +++++++++--
desktop/src/App.tsx | 272 ++++-----
desktop/src/LocalIntelligence.test.tsx | 429 +++++++++++---
desktop/src/LocalIntelligence.tsx | 429 ++++++++++----
desktop/src/SetupProgress.tsx | 105 ++++
desktop/src/localEvalCli.test.ts | 90 +++
desktop/src/localEvalCli.ts | 77 ++-
desktop/src/localReadiness.test.ts | 81 ++-
desktop/src/localReadiness.ts | 52 +-
12 files changed, 2613 insertions(+), 420 deletions(-)
create mode 100644 desktop/src/SetupProgress.tsx
diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs
index 86cbab662e..6a9e32cfea 100644
--- a/desktop/src-tauri/src/local_eval.rs
+++ b/desktop/src-tauri/src/local_eval.rs
@@ -10,17 +10,19 @@
//! - **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` and `stop`, a validated stack id and `--json`,
-//! as separate arguments with no shell. The CLI's `resumeCommand` strings are text for a
+//! plus the constant `list`, `setup` and `connect` lists, as separate arguments with no shell. The CLI's `resumeCommand` strings are text for a
//! terminal user; they are dropped here and never executed.
//! - **Only allowlisted facts leave.** Raw stdout, stderr, error messages and recovery text never
//! cross IPC or reach a log. Failures are static categories with no payload.
//!
-//! Version 1 of the CLI's JSON reports that a command *completed*, not that Intelligence is ready
-//! or licensed. So readiness and licence are always `unknown` here until the CLI publishes those
-//! facts, and unknown keeps chat closed.
+//! 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.
+//! `services/local-errors.ts` at 3935afbfe3cda88dfeabfd0250db08ff0ad51000; `local connect` at
+//! 780313e4edf6fc5f038c3cf5e4612ac668446c45.
use std::io::{Read, Seek};
use std::path::PathBuf;
@@ -114,10 +116,28 @@ pub enum Outcome {
Failed,
}
-/// A fact version 1 of the CLI cannot state. There is deliberately no other value yet.
+/// 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 Canonical {
+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,
}
@@ -140,6 +160,8 @@ pub enum Reason {
IncompatibleRelease,
LocalCommandBusy,
LocalCommandFailed,
+ /// `connect` asked for its approval flag. Desktop always sends it, so this is a contract gap.
+ ConfirmationRequired,
/// A reason this build does not know. Treated as blocking, never as success.
Unrecognized,
}
@@ -161,6 +183,7 @@ impl Reason {
"incompatible_release" => Reason::IncompatibleRelease,
"local_command_busy" => Reason::LocalCommandBusy,
"local_command_failed" => Reason::LocalCommandFailed,
+ "confirmation_required" => Reason::ConfirmationRequired,
_ => Reason::Unrecognized,
}
}
@@ -172,8 +195,22 @@ pub struct Report {
pub outcome: Outcome,
pub stack: Option,
pub reason: Option,
- pub readiness: Canonical,
- pub licence: Canonical,
+ 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,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
@@ -231,6 +268,18 @@ the evaluation agreement themselves; the CLI refuses an agent that tries, and so
*/
pub const SETUP_ARGUMENTS: [&str; 4] = ["local", "setup", "--defer-model", "--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"];
+
/**
Turn what the CLI printed, and how it exited, into the facts this app keeps.
@@ -250,22 +299,165 @@ pub fn normalize_setup(exit_code: i32, stdout: &[u8]) -> Result
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);
- let stack = match wire.get("stack") {
- None | Some(Value::Null) if outcome == Outcome::Completed => {
- return Err(Failure::MissingStack)
- }
- None | Some(Value::Null) => None,
- Some(saved) => Some(stack(saved)?),
+ // 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,
+ stack: reported_stack(outcome, wire)?,
reason: reason(outcome, wire)?,
- readiness: Canonical::Unknown,
- licence: Canonical::Unknown,
+ 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,
+ })
+}
+
+/**
+`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 {
@@ -759,6 +951,17 @@ pub fn setup(
normalize_setup(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 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)?;
@@ -766,7 +969,8 @@ pub fn list(cli: &PinnedCli, limits: Limits, cancel: &Cancel) -> Result 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 list` against the installed CLI.
pub fn list_installed(attempt: &str) -> Result {
let attempt = Attempt::register(attempt);
@@ -1241,8 +1457,8 @@ mod tests {
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, Canonical::Unknown);
- assert_eq!(report.licence, Canonical::Unknown);
+ 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);
@@ -1265,7 +1481,7 @@ mod tests {
let observed = stack.observed.unwrap();
assert_eq!(observed.running, running);
assert!(!observed.services[0].ready);
- assert_eq!(report.readiness, Canonical::Unknown);
+ assert_eq!(report.readiness, Readiness::Unknown);
assert!(!exposes_secret(&report));
}
}
@@ -1274,7 +1490,7 @@ mod tests {
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, Canonical::Unknown);
+ 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);
}
@@ -1323,11 +1539,197 @@ mod tests {
input["stack"]["readiness"] = json!("ready");
input["readiness"] = json!("ready");
let report = status(&input).unwrap();
- assert_eq!(report.licence, Canonical::Unknown);
- assert_eq!(report.readiness, Canonical::Unknown);
+ 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();
@@ -1493,6 +1895,166 @@ mod tests {
);
}
+ // 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!(
+ std::fs::read_to_string(fixture.root.join("cwd")).unwrap(),
+ installation.canonicalize().unwrap().display().to_string()
+ );
+ }
+
+ #[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();
+ }
+
#[test]
fn a_pinned_cli_is_known_by_the_digest_of_what_it_runs() {
let fixture = Fixture::new("local-eval-cli-digest", "0\n");
@@ -1950,6 +2512,54 @@ mod tests {
}
}
+ #[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
diff --git a/desktop/src-tauri/src/local_eval_state.rs b/desktop/src-tauri/src/local_eval_state.rs
index 749544bf8e..50204709ab 100644
--- a/desktop/src-tauri/src/local_eval_state.rs
+++ b/desktop/src-tauri/src/local_eval_state.rs
@@ -1,7 +1,7 @@
//! 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, and the furthest phase reached. Nothing here proves the stack is running or the
+//! 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.
//!
@@ -57,6 +57,10 @@ struct Record {
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.
@@ -66,6 +70,7 @@ pub struct State {
pub stack: String,
pub ownership: Ownership,
pub phase: Phase,
+ pub connected: bool,
}
impl State {
@@ -112,6 +117,7 @@ pub fn read(root: &Path, cli_sha256: Option<&str>) -> Option {
stack: record.stack,
ownership: record.ownership,
phase,
+ connected: record.connected,
})
}
@@ -127,7 +133,8 @@ 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.
+/// 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,
@@ -143,6 +150,8 @@ pub fn begin(
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"))
@@ -164,6 +173,16 @@ pub fn advance(root: &Path, cli_sha256: &str, to: Phase) -> Result 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(()),
@@ -258,6 +277,35 @@ mod tests {
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");
diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs
index e80f1889f8..1b749d0944 100644
--- a/desktop/src-tauri/src/main.rs
+++ b/desktop/src-tauri/src/main.rs
@@ -3675,6 +3675,144 @@ where
Ok(report)
}
+/// Why connecting OpenBot to local Intelligence produced nothing to show: the CLI's own static
+/// category, or a connection OpenBot would not take up. Told apart by shape, like `SetupError`.
+#[derive(Debug, Serialize)]
+#[serde(untagged)]
+enum ConnectError {
+ Failure(local_eval::Failure),
+ Unconnected(Problem),
+}
+
+/**
+`local connect` for this installation, after the person chose to connect OpenBot to it.
+
+Interim, until the CLI issues OpenBot a credential of its own: the key `connect` writes into the
+installation's `.env` is moved into the credential store as OpenBot's Intelligence key, the same
+place a pasted key goes, and Start takes it from there. Blocked and conflicted answers are returned
+as they are, with nothing taken.
+*/
+#[tauri::command]
+async fn connect_local_eval(
+ attempt: String,
+ root: String,
+) -> Result {
+ let root = stack::root_from(&root);
+ 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))?
+}
+
+/// 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.",
+ ));
+ };
+ 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()),
+ ))
+ })?;
+ }
+ None => {
+ let kept = recorded.connected
+ && recall(root, "INTELLIGENCE_API_KEY")
+ .map_err(ConnectError::Unconnected)?
+ .is_some_and(|key| !key.trim().is_empty());
+ if !kept {
+ return Err(unconnected(
+ "The Intelligence CLI connected, but OpenBot has no key for this stack.",
+ ));
+ }
+ }
+ }
+ let stack_id = local_eval::StackId::parse(&stack.id).map_err(ConnectError::Failure)?;
+ local_eval_state::mark_connected(root, &stack_id).map_err(ConnectError::Unconnected)?;
+ Ok(connection)
+}
+
#[tauri::command]
async fn list_local_eval(attempt: String) -> Result {
tauri::async_runtime::spawn_blocking(move || local_eval::list_installed(&attempt))
@@ -4163,6 +4301,7 @@ fn main() {
list_local_eval,
cancel_local_eval,
set_up_local_eval,
+ connect_local_eval,
last_local_eval_stop_failure,
ask_the_bot,
])
@@ -6530,8 +6669,9 @@ mod tests {
outcome,
stack: None,
reason,
- readiness: local_eval::Canonical::Unknown,
- licence: local_eval::Canonical::Unknown,
+ readiness: local_eval::Readiness::Unknown,
+ licence: local_eval::Licence::Unknown,
+ licence_expires_at: None,
}
}
@@ -7158,6 +7298,321 @@ mod tests {
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
+ },
+ |_, name, value| {
+ vault.borrow_mut().insert(name.into(), value.into());
+ Ok(())
+ },
+ |_, name| Ok(vault.borrow().get(name).cloned()),
+ )
+ }
+
+ #[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!(
+ vault
+ .borrow()
+ .get("INTELLIGENCE_API_KEY")
+ .map(String::as_str),
+ Some(LOCAL_KEY)
+ );
+ assert_eq!(vault.borrow().len(), 1);
+ let env = std::fs::read_to_string(root.join(".env")).unwrap();
+ assert!(!env.contains(LOCAL_KEY), "{env}");
+ assert!(!env.contains("CPK_INTELLIGENCE_API_KEY"), "{env}");
+ for line in [
+ "INTELLIGENCE_API_URL=http://localhost:64770",
+ "INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:64772",
+ "SL_ENABLED=true",
+ "SERVER_PORT=3001",
+ ] {
+ assert_eq!(
+ env.lines().filter(|found| *found == line).count(),
+ 1,
+ "{env}"
+ );
+ }
+ assert!(local_eval_state::read(&root, None).unwrap().connected);
+ assert!(!serde_json::to_string(&connection)
+ .unwrap()
+ .contains(LOCAL_KEY));
+
+ // Start then takes it from the vault exactly like a key pasted earlier.
+ let key = intelligence_key_for_start(&root, String::new(), |_, name| {
+ Ok(vault.borrow().get(name).cloned().unwrap_or_default())
+ })
+ .unwrap();
+ assert_eq!(key, LOCAL_KEY);
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn starting_after_a_connection_neither_clobbers_nor_duplicates_it() {
+ let root = local_eval_root(
+ "openbot-local-eval-connect-start-env",
+ Some(local_eval_state::Ownership::Created),
+ );
+ local_eval_connected_env(&root);
+ let vault = FakeVault::default();
+ connect_with_vault(
+ &root,
+ &vault,
+ Ok(local_eval_connection(
+ local_eval::Outcome::Completed,
+ "local-abc123",
+ None,
+ false,
+ )),
+ )
+ .unwrap();
+ // Start's own write: the same addresses as settings, its credentials purged to the vault.
+ let (settings, secrets) = openbot_desktop_lib::vault::split(
+ [
+ ("INTELLIGENCE_API_URL", "http://localhost:64770"),
+ ("INTELLIGENCE_GATEWAY_WS_URL", "ws://localhost:64772"),
+ ("INTELLIGENCE_API_KEY", LOCAL_KEY),
+ ("SERVER_PORT", "3001"),
+ ]
+ .into_iter()
+ .map(|(key, value)| (key.to_owned(), value.to_owned()))
+ .collect(),
+ );
+ 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!(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
+ );
+ let _ = std::fs::remove_dir_all(root);
+ }
+
#[test]
fn already_configured_shows_the_local_evaluation_only_when_there_is_one() {
let root = local_eval_root("openbot-already-configured-local-eval", None);
@@ -7180,7 +7635,8 @@ mod tests {
serde_json::json!({
"stack": "local-abc123",
"ownership": "created",
- "phase": "installed"
+ "phase": "installed",
+ "connected": false
})
);
let _ = std::fs::remove_dir_all(root);
diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx
index 408e7a2a26..2e0e965418 100644
--- a/desktop/src/App.test.tsx
+++ b/desktop/src/App.test.tsx
@@ -392,72 +392,316 @@ test("local Intelligence is not offered when this build has no CLI", async () =>
};
const view = await renderApp();
await reachConnect(view);
- expect(view.queryByText("Use Intelligence on this computer")).toBeNull();
+ expect(view.queryByText("Run Intelligence on this computer")).toBeNull();
+ expect(
+ view.getByRole("button", { name: "Sign in to CopilotKit" }),
+ ).toBeTruthy();
+ expect(invokeCalls.some((call) => call.command === "set_up_local_eval")).toBe(
+ false,
+ );
});
-test("local Intelligence replaces the hosted sign-in and keeps Start closed", async () => {
- setupRootConfiguration("/tmp/local-eval-available", async () =>
- emptyConfiguration(),
- );
+function localStatus(readiness: string, licence: string) {
+ return {
+ outcome: "completed",
+ stack: {
+ id: "local-abc123",
+ installedRelease: "0.15.0",
+ step: "ready",
+ urls: {
+ api: "http://localhost:64770",
+ dashboard: "http://cpk.localhost:64771",
+ gateway: "ws://localhost:64772",
+ },
+ hasFailedStep: false,
+ updateInProgress: false,
+ observed: null,
+ },
+ reason: null,
+ readiness,
+ licence,
+ licenceExpiresAt: null,
+ };
+}
+
+const localConnection = {
+ outcome: "completed",
+ stack: localStatus("unknown", "unknown").stack,
+ reason: null,
+ conflicted: false,
+};
+
+/** This build has a CLI; each local command answers from `local`. */
+function withLocalCli(local: Record) {
const previous = invokeHandler;
invokeHandler = async (command, args) => {
if (command === "list_local_eval") return localListing;
+ if (command === "cancel_local_eval") return null;
+ if (command === "show_openbot") return null;
+ if (command in local) {
+ const answer = local[command];
+ return typeof answer === "function" ? answer(args) : answer;
+ }
return previous(command, args);
};
+}
+
+async function reachLocal(view: Awaited>) {
+ await enterInstallation(view);
+ await completeInstallation(view);
+ await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ }));
+ await userEvent.type(view.getByLabelText("OpenAI API key"), "model-test-key");
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
+}
+
+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),
+ );
+}
+
+test("with a CLI and no hosted choice, step 4 sets up, connects, waits and starts by itself", async () => {
+ setupRootConfiguration("/tmp/local-eval-auto", 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 reachConnect(view);
- await userEvent.click(view.getByText("Use Intelligence on this computer"));
+ await reachLocal(view);
+ // No click after the model: the installation is the consent.
+ await view.findByRole("heading", { name: "OpenBot is running" });
+ expect(localSequence()).toEqual([
+ "set_up_local_eval",
+ "connect_local_eval",
+ "run_local_eval",
+ "start_stack",
+ "show_openbot",
+ ]);
+ expect(
+ invokeCalls.find((call) => call.command === "run_local_eval")?.args,
+ ).toMatchObject({ action: "status", stack: "local-abc123" });
+ // The local stack's own addresses, and no key from the window: Start takes the vaulted one.
+ expect(getStartStackPayload()).toMatchObject({
+ root: "/tmp/local-eval-auto",
+ apiUrl: "http://localhost:64770",
+ gatewayWsUrl: "ws://localhost:64772",
+ apiKey: "",
+ });
+});
+
+test("the hosted service is one quiet link away, and back", async () => {
+ setupRootConfiguration("/tmp/local-eval-switch", async () =>
+ emptyConfiguration(),
+ );
+ const pending = deferred();
+ withLocalCli({ set_up_local_eval: () => pending.promise });
+ const view = await renderApp();
+ await reachLocal(view);
+ await view.findByRole("heading", {
+ name: "Starting OpenBot on this computer",
+ });
+ expect(
+ view.queryByRole("button", { name: "Sign in to CopilotKit" }),
+ ).toBeNull();
+ expect(view.queryByRole("button", { name: "Start OpenBot" })).toBeNull();
await userEvent.click(
- view.getByRole("button", { name: "Use local Intelligence" }),
+ view.getByRole("button", {
+ name: "Use CopilotKit's hosted service instead",
+ }),
);
expect(
- view.getByRole("region", { name: "Intelligence on this computer" }),
+ view.getByRole("heading", { name: "Connect to CopilotKit" }),
).toBeTruthy();
- expect(view.queryByText("Point at your own Intelligence server")).toBeNull();
- expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty(
- "disabled",
+ expect(view.getByText("Point at your own Intelligence server")).toBeTruthy();
+ // Leaving stops the CLI call that was running.
+ expect(invokeCalls.some((call) => call.command === "cancel_local_eval")).toBe(
true,
);
await userEvent.click(
- view.getByRole("button", { name: "Use CopilotKit's service instead" }),
+ view.getByRole("button", { name: "Run Intelligence on this computer" }),
);
- expect(view.getByText("Point at your own Intelligence server")).toBeTruthy();
+ expect(
+ view.getByRole("heading", { name: "Starting OpenBot on this computer" }),
+ ).toBeTruthy();
});
-test("a recorded local stack opens in local mode and is checked", async () => {
- setupRootConfiguration("/tmp/local-eval-recorded", async () => ({
+test("a hosted connection chosen earlier keeps the hosted screen", async () => {
+ setupRootConfiguration("/tmp/local-eval-hosted-chosen", async () => ({
...emptyConfiguration(),
+ values: { INTELLIGENCE_API_URL: "https://intelligence.example" },
+ }));
+ withLocalCli({});
+ const view = await renderApp();
+ await reachConnect(view);
+ expect(
+ view.getByRole("button", { name: "Run Intelligence on this computer" }),
+ ).toBeTruthy();
+ expect(invokeCalls.some((call) => call.command === "set_up_local_eval")).toBe(
+ false,
+ );
+});
+
+test("a blocked setup stops at its row and Retry resumes there", async () => {
+ setupRootConfiguration("/tmp/local-eval-blocked", async () =>
+ emptyConfiguration(),
+ );
+ let setups = 0;
+ withLocalCli({
+ set_up_local_eval: () =>
+ ++setups === 1
+ ? {
+ ...localStatus("unknown", "unknown"),
+ outcome: "blocked",
+ stack: null,
+ reason: "docker-unavailable",
+ }
+ : localStatus("starting", "valid"),
+ connect_local_eval: localConnection,
+ run_local_eval: localStatus("ready", "valid"),
+ });
+ const view = await renderApp();
+ await reachLocal(view);
+ const row = await view.findByRole("listitem", {
+ name: "Setting up Intelligence on this computer",
+ });
+ await waitFor(() =>
+ expect(row.textContent).toContain(
+ "Intelligence cannot start until Docker Desktop is running.",
+ ),
+ );
+ expect(invokeCalls.some((call) => call.command === "start_stack")).toBe(
+ false,
+ );
+ await userEvent.click(view.getByRole("button", { name: "Retry" }));
+ await view.findByRole("heading", { name: "OpenBot is running" });
+ expect(setups).toBe(2);
+});
+
+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,
}));
- const previous = invokeHandler;
- invokeHandler = async (command, args) => {
- if (command === "list_local_eval") return localListing;
- if (command === "run_local_eval")
- return {
- outcome: "completed",
- stack: null,
- reason: null,
- readiness: "unknown",
- licence: "unknown",
- };
- 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 view.findByRole("region", { name: "Intelligence on this computer" });
- await waitFor(() =>
- expect(
- invokeCalls.find((call) => call.command === "run_local_eval")?.args,
- ).toMatchObject({ action: "status", stack: "local-abc123" }),
+ withLocalCli({
+ set_up_local_eval: status,
+ connect_local_eval: localConnection,
+ run_local_eval: status,
+ stop_stack: null,
+ });
+}
+
+test("relaunch starts a connected local stack that reports ready and valid", async () => {
+ setupConnectedLocalStack(
+ "/tmp/local-eval-ready",
+ localStatus("ready", "valid"),
);
+ const view = await renderApp();
+ await view.findByRole("heading", { name: "OpenBot is running" });
+ // A recorded, connected stack goes straight to waiting.
+ expect(localSequence()).toEqual([
+ "run_local_eval",
+ "start_stack",
+ "show_openbot",
+ ]);
+ expect(getStartStackPayload()).toMatchObject({
+ root: "/tmp/local-eval-ready",
+ apiUrl: "http://localhost:64770",
+ gatewayWsUrl: "ws://localhost:64772",
+ apiKey: "",
+ });
+ // Stopped by choice: nothing starts again on its own in this session.
+ await userEvent.click(view.getByRole("button", { name: "Stop OpenBot" }));
+ await view.findByRole("heading", { name: "OpenBot is stopped" });
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(
+ invokeCalls.filter((call) => call.command === "start_stack"),
+ ).toHaveLength(1);
+ expect(view.getByRole("button", { name: "Start OpenBot" })).toBeTruthy();
+});
+
+test("relaunch never starts a local stack that is not exactly ready and valid", async () => {
+ for (const [readiness, licence, said] of [
+ [
+ "unknown",
+ "unknown",
+ "Intelligence started. Its licence and readiness are not reported yet.",
+ ],
+ ["ready", "expired", "The Intelligence evaluation licence has expired."],
+ ["degraded", "valid", "Intelligence is running but not healthy."],
+ ]) {
+ setupConnectedLocalStack(
+ `/tmp/local-eval-${readiness}-${licence}`,
+ localStatus(readiness, licence),
+ );
+ const view = await renderApp();
+ await view.findByText(said);
+ expect(view.getByRole("button", { name: "Retry" })).toBeTruthy();
+ expect(invokeCalls.some((call) => call.command === "start_stack")).toBe(
+ false,
+ );
+ cleanup();
+ invokeCalls = [];
+ }
+});
+
+test("relaunch waits for Start when auto-start is off, interrupted, or the stack is not connected", async () => {
+ for (const [name, overrides, failure] of [
+ ["auto-start-off", { autoStart: false }, null],
+ [
+ "not-connected",
+ {
+ localEval: {
+ stack: "local-abc123",
+ ownership: "created",
+ phase: "installed",
+ },
+ },
+ null,
+ ],
+ ["interrupted", {}, { said: "OpenBot stopped unexpectedly." }],
+ ] as const) {
+ setupConnectedLocalStack(
+ `/tmp/local-eval-${name}`,
+ localStatus("ready", "valid"),
+ overrides,
+ );
+ if (failure) {
+ const previous = invokeHandler;
+ invokeHandler = async (command, args) =>
+ command === "last_failure" ? failure : previous(command, args);
+ }
+ const view = await renderApp();
+ await view.findByRole("heading", { name: "OpenBot is stopped" });
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(localSequence()).toEqual([]);
+ await userEvent.click(view.getByRole("button", { name: "Start OpenBot" }));
+ await waitFor(() => expect(localSequence()).toContain("start_stack"));
+ cleanup();
+ invokeCalls = [];
+ }
});
test("CopilotKit Back preserves the selected AI and connection settings", async () => {
diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx
index 1a2ea7d57c..75f126e9aa 100644
--- a/desktop/src/App.tsx
+++ b/desktop/src/App.tsx
@@ -9,7 +9,7 @@ import {
HarnessPicker,
} from "./HarnessPicker";
import { isHttpEndpointUrl } from "./http-endpoint-url";
-import { LocalIntelligence } from "./LocalIntelligence";
+import { LocalIntelligence, type LocalStartable } from "./LocalIntelligence";
import {
intelligenceStopProblem,
listLocalEval,
@@ -23,6 +23,7 @@ import {
ProviderPicker,
recordedModel,
} from "./ProviderPicker";
+import { type Progress, SetupProgress } from "./SetupProgress";
import {
harnessChoiceEvent,
modelChoiceEvent,
@@ -45,14 +46,6 @@ type Blocker =
| "virtualization-disabled"
| "not-administrator";
-type Progress = {
- step: string;
- ok: boolean;
- detail: string;
- running?: boolean;
- downloadBytes?: number;
-};
-
type AlreadyConfigured = {
values: Record;
saved: NonNullable;
@@ -60,7 +53,13 @@ type AlreadyConfigured = {
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 };
+ 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) {
@@ -94,10 +93,14 @@ export function App() {
// Offered only when this build has an Intelligence CLI to run; a local stack is its own mode,
// not another setup step.
const [localAvailable, setLocalAvailable] = useState(false);
- const [intelligenceMode, setIntelligenceMode] = useState<"hosted" | "local">(
- "hosted",
- );
+ // Null until somebody chooses; then local is the default where it is available.
+ const [intelligenceChoice, setIntelligenceMode] = useState<
+ "hosted" | "local" | null
+ >(null);
const [localStack, setLocalStack] = useState(null);
+ const [localConnected, setLocalConnected] = useState(false);
+ // A relaunch found a connected stack it may start, as a hosted launch resumes.
+ const [localAutoStart, setLocalAutoStart] = useState(false);
const [apiKey, setApiKey] = useState("");
const [organizationAuthorityUrl, setOrganizationAuthorityUrl] = useState<
string | null
@@ -116,6 +119,13 @@ export function App() {
const [model, setModel] = useState(null);
/** Model credentials a previous run already wrote, so the provider screen arrives filled in. */
const [alreadyHeld, setAlreadyHeld] = useState({});
+ // A hosted connection somebody chose earlier: a saved key, or their own server's address.
+ const hostedChosen =
+ Boolean(alreadyHeld.saved?.intelligenceApiKey) ||
+ Boolean(alreadyHeld.INTELLIGENCE_API_URL);
+ const intelligenceMode =
+ intelligenceChoice ??
+ (localAvailable && !hostedChosen ? "local" : "hosted");
const [projects, setProjects] = useState<
{ id: string; name: string }[] | null
>(null);
@@ -261,6 +271,8 @@ export function App() {
const [resuming, setResuming] = useState(false);
const [checkingResume, setCheckingResume] = useState(true);
const savedLaunch = useRef(null);
+ // An interrupted run needs an explicit Start, local Intelligence included.
+ const interruptedAtLaunch = useRef(false);
const [refreshIntelligence, setRefreshIntelligence] = useState(false);
const [preparation, setPreparation] = useState<{
key: string;
@@ -353,8 +365,10 @@ export function App() {
setOrganizationAuthorityUrl(null);
setApiKey("");
setReuseIntelligence(false);
- setIntelligenceMode("hosted");
+ setIntelligenceMode(null);
setLocalStack(null);
+ setLocalConnected(false);
+ setLocalAutoStart(false);
setApiUrl(MANAGED_INTELLIGENCE_API_URL);
setWsUrl(MANAGED_INTELLIGENCE_GATEWAY_WS_URL);
setAlreadyHeld({});
@@ -381,6 +395,7 @@ export function App() {
setAlreadyHeld({ ...values, saved });
if (configured.localEval) {
setLocalStack(configured.localEval.stack);
+ setLocalConnected(configured.localEval.connected === true);
setIntelligenceMode("local");
}
setOrganizationAuthorityUrl(
@@ -432,7 +447,10 @@ export function App() {
// 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);
@@ -491,6 +509,11 @@ export function App() {
const resumedHarness = configured.launch.harness;
// An interrupted run needs an explicit Start, with its saved setup still available.
if (interrupted || configured.autoStart === false) return;
+ // Local Intelligence must report ready and licensed first, which only its screen asks.
+ if (configured.localEval) {
+ if (configured.localEval.connected) setLocalAutoStart(true);
+ return;
+ }
setBusy(true);
setResuming(true);
try {
@@ -580,10 +603,19 @@ export function App() {
}
}
- async function start(nextModel = model) {
+ /** True when OpenBot started. */
+ async function start(
+ nextModel = model,
+ local: LocalStartable | null = null,
+ ): Promise {
if (!installationReady) {
setStep("install");
- return;
+ return false;
+ }
+ // Local Intelligence starts only from a stack its CLI reports ready and licensed.
+ if (intelligenceMode === "local" && !local) {
+ setStep("connect");
+ return false;
}
setBusy(true);
setFailure(null);
@@ -591,9 +623,10 @@ export function App() {
try {
await invoke("start_stack", {
root: root.trim(),
- apiUrl,
- gatewayWsUrl: wsUrl,
- apiKey,
+ apiUrl: local?.apiUrl ?? apiUrl,
+ gatewayWsUrl: local?.gatewayWsUrl ?? wsUrl,
+ // Empty for a local stack: Start takes the key `local connect` gave OpenBot from the vault.
+ apiKey: local ? "" : apiKey,
// The whole answer from the model screen, so the Rust side decides which keys that
// implies. Sending a bare key here is what made `ANTHROPIC_API_KEY` and a plan token
// expressible at the same time.
@@ -608,16 +641,18 @@ export function App() {
setSteps((current) => settleProgress(current));
setRunning(true);
setRecoveryFailure(null);
- // Refreshing credentials does not turn an existing installation into a first run.
+ // Refreshing credentials does not turn an existing installation into a first run. A local
+ // stack has already reported itself ready, so it is shown as the hosted reopen is.
if (
savedLaunch.current === installationKey ||
- organizationAuthorityUrl?.trim()
+ organizationAuthorityUrl?.trim() ||
+ local
) {
savedLaunch.current = installationKey;
setStep("connect");
setRefreshIntelligence(false);
await invoke("show_openbot");
- return;
+ return true;
}
/*
* One screen short of the handover, on purpose.
@@ -629,8 +664,10 @@ export function App() {
* question, and the handover waits for an answer to come back.
*/
setStep("ask");
+ return true;
} catch (error) {
recoverFromStart(error);
+ return false;
} finally {
setBusy(false);
invoke("detect_engine")
@@ -695,6 +732,8 @@ export function App() {
async function stop() {
setBusy(true);
+ // Stopped by choice: nothing starts again on its own in this session.
+ setLocalAutoStart(false);
try {
if (await stopStack()) {
setRunning(false);
@@ -967,6 +1006,22 @@ export function App() {
);
}
+ // Local Intelligence runs its own steps and starts OpenBot only once its CLI reports it ready.
+ const localIntelligence = (auto: boolean) => (
+ start(model, local)}
+ onConnected={(stack) => {
+ setLocalStack(stack);
+ setLocalConnected(true);
+ }}
+ onLeave={() => setIntelligenceMode("hosted")}
+ />
+ );
+
if (!running && returningToInstallation && !refreshIntelligence) {
return (
@@ -974,15 +1029,18 @@ export function App() {
Your installation and saved connections are ready to reopen.
+ );
+}
+
+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 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 "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/localEvalCli.test.ts b/desktop/src/localEvalCli.test.ts
index 37eb74d0b2..b84e5469c5 100644
--- a/desktop/src/localEvalCli.test.ts
+++ b/desktop/src/localEvalCli.test.ts
@@ -21,6 +21,10 @@ mock.module("@tauri-apps/api/core", () => ({
const {
cancelLocalEval,
chatGate,
+ connectLocalEval,
+ licenceExpiresSoon,
+ localLicences,
+ localReadinesses,
intelligenceStopFailed,
intelligenceStopProblem,
listLocalEval,
@@ -57,6 +61,7 @@ function report(overrides: Partial = {}): LocalReport {
reason: null,
readiness: "unknown",
licence: "unknown",
+ licenceExpiresAt: null,
...overrides,
};
}
@@ -128,6 +133,69 @@ test("cancels the attempt it names", async () => {
]);
});
+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",
@@ -152,6 +220,28 @@ test("unknown, failed and missing Intelligence states all block", () => {
}
});
+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;
diff --git a/desktop/src/localEvalCli.ts b/desktop/src/localEvalCli.ts
index 6854a0bdc3..4aa5839d6b 100644
--- a/desktop/src/localEvalCli.ts
+++ b/desktop/src/localEvalCli.ts
@@ -3,7 +3,7 @@
*
* 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.
+ * 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";
@@ -47,8 +47,30 @@ export type LocalReason =
| "incompatible-release"
| "local-command-busy"
| "local-command-failed"
+ | "confirmation-required"
| "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 | {
@@ -64,9 +86,20 @@ export type LocalReport = {
};
};
reason: LocalReason | null;
- // CLI v1 cannot state either fact. The native layer has no other value to send yet.
- readiness: "unknown";
- licence: "unknown";
+ // 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. */
@@ -144,6 +177,28 @@ export function setUpLocalEval(root: string): {
return { attempt, report };
}
+/**
+ * `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 };
+}
+
/** Stops one call; it then rejects with `cancelled`. */
export function cancelLocalEval(attempt: string): Promise {
return invoke("cancel_local_eval", { attempt });
@@ -160,7 +215,7 @@ export type ChatGateInput = {
/**
* Chat opens only when all three independent facts are known good. Unknown is blocked, never
- * guessed ready. No v1 report can open it, because v1 states neither readiness nor licence.
+ * guessed ready: a CLI that does not report readiness and licence cannot open it.
*/
export function chatGate(state: ChatGateInput): {
open: boolean;
@@ -187,6 +242,18 @@ 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: ";
diff --git a/desktop/src/localReadiness.test.ts b/desktop/src/localReadiness.test.ts
index 150290d4e4..650db365c0 100644
--- a/desktop/src/localReadiness.test.ts
+++ b/desktop/src/localReadiness.test.ts
@@ -24,6 +24,7 @@ const reasons: LocalReason[] = [
"incompatible-release",
"local-command-busy",
"local-command-failed",
+ "confirmation-required",
"unrecognized",
];
@@ -34,6 +35,7 @@ function report(overrides: Partial = {}): LocalReport {
reason: null,
readiness: "unknown",
licence: "unknown",
+ licenceExpiresAt: null,
...overrides,
};
}
@@ -79,7 +81,7 @@ test("every failure gives a plain, non-empty sentence without its own name", ()
});
test("the reason and failure lists in this test match the source", () => {
- expect(new Set(reasons).size).toBe(15);
+ expect(new Set(reasons).size).toBe(16);
expect(localFailures.length).toBe(15);
});
@@ -332,3 +334,80 @@ test("types: the failure and reason inputs are the upstream ones", () => {
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
index e2f068b4e1..e0d2f4a00b 100644
--- a/desktop/src/localReadiness.ts
+++ b/desktop/src/localReadiness.ts
@@ -5,19 +5,22 @@
* 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. CLI v1 cannot state readiness or licence, so today the
- * Intelligence row is never "ready".
+ * 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"
@@ -36,6 +39,8 @@ export type ReadinessInput = {
intelligence: ChatGateInput["intelligence"];
openBot: ChatGateInput["openBot"];
provider: ChatGateInput["provider"];
+ /** The clock the licence expiry warning is measured against. */
+ now?: number;
};
export type Readiness = {
@@ -88,9 +93,26 @@ const reasonRows: Record = {
"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(
+ "The Intelligence CLI asked for a confirmation OpenBot cannot give.",
+ ),
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 build does not include the Intelligence CLI."),
unverified: blocked(
@@ -118,6 +140,7 @@ const failureRows: Record = {
function intelligenceRow(
intelligence: ReadinessInput["intelligence"],
+ now: number,
): ReadinessRow {
if (intelligence === null) {
return {
@@ -132,9 +155,30 @@ function intelligenceRow(
openBot: "connected",
provider: "validated",
}).open;
- if (good) return { state: "ready", said: "Intelligence is ready." };
+ 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.",
@@ -168,7 +212,7 @@ function runtimeRow(
export function localReadiness(input: ReadinessInput): Readiness {
return {
runtime: runtimeRow(input.runtime, input.intelligence),
- intelligence: intelligenceRow(input.intelligence),
+ intelligence: intelligenceRow(input.intelligence, input.now ?? Date.now()),
openBot:
input.openBot === "connected"
? { state: "ready", said: "OpenBot is connected to Intelligence." }
From d391bbf124610b01cc310c518fe235a718753177 Mon Sep 17 00:00:00 2001
From: Dusty
Date: Wed, 21 Oct 2026 20:39:48 -0700
Subject: [PATCH 06/77] wip(desktop): choose local or hosted first at step 4;
restore the test step for local
Local checkpoint for the demo; not for review as-is.
---
desktop/src/App.test.tsx | 294 ++++++++++++++++++++++---
desktop/src/App.tsx | 129 ++++++++---
desktop/src/LocalIntelligence.test.tsx | 38 +++-
desktop/src/LocalIntelligence.tsx | 74 +++++--
desktop/src/styles.css | 8 +
5 files changed, 453 insertions(+), 90 deletions(-)
diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx
index 2e0e965418..d5d25927a0 100644
--- a/desktop/src/App.test.tsx
+++ b/desktop/src/App.test.tsx
@@ -58,8 +58,9 @@ const { App } = await import("./App");
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();
});
afterAll(() => GlobalRegistrator.unregister());
@@ -393,9 +394,14 @@ test("local Intelligence is not offered when this build has no CLI", async () =>
const view = await renderApp();
await reachConnect(view);
expect(view.queryByText("Run Intelligence on this computer")).toBeNull();
+ expect(view.queryByRole("radio", { name: /Intelligence/ })).toBeNull();
+ expect(view.queryByText("Choose where Intelligence runs")).toBeNull();
expect(
view.getByRole("button", { name: "Sign in to CopilotKit" }),
).toBeTruthy();
+ expect(
+ invokeCalls.some((call) => call.command === "begin_intelligence_sign_in"),
+ ).toBe(false);
expect(invokeCalls.some((call) => call.command === "set_up_local_eval")).toBe(
false,
);
@@ -468,7 +474,54 @@ function localSequence() {
);
}
-test("with a CLI and no hosted choice, step 4 sets up, connects, waits and starts by itself", async () => {
+/** Commands that set up, connect or run local Intelligence, or stop a call. */
+function localCliCalls() {
+ return invokeCalls.filter((call) =>
+ [
+ "set_up_local_eval",
+ "connect_local_eval",
+ "run_local_eval",
+ "cancel_local_eval",
+ ].includes(call.command),
+ );
+}
+
+const LOCAL_CHOICE = "Run Intelligence on this computer";
+const HOSTED_CHOICE = "Use CopilotKit's hosted Intelligence";
+
+function choice(view: Awaited>, name: string) {
+ return view.getByRole("radio", { name: new RegExp(name) });
+}
+
+test("with a CLI, step 4 opens on a choice with local preselected and nothing running", async () => {
+ setupRootConfiguration("/tmp/local-eval-choice", async () =>
+ emptyConfiguration(),
+ );
+ withLocalCli({});
+ const view = await renderApp();
+ await reachLocal(view);
+ await view.findByRole("heading", { name: "Choose where Intelligence runs" });
+ expect(choice(view, LOCAL_CHOICE)).toHaveProperty("checked", true);
+ expect(choice(view, HOSTED_CHOICE)).toHaveProperty("checked", false);
+ expect(
+ view.getByText(
+ "For evaluation. Uses Docker Desktop; the first setup takes a few minutes.",
+ ),
+ ).toBeTruthy();
+ expect(view.getByText("Signs in to your CopilotKit account.")).toBeTruthy();
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(localCliCalls()).toEqual([]);
+ expect(
+ invokeCalls.some((call) => call.command === "begin_intelligence_sign_in"),
+ ).toBe(false);
+ expect(view.queryByRole("button", { name: "Start OpenBot" })).toBeNull();
+ expect(
+ view.queryByRole("button", { name: "Sign in to CopilotKit" }),
+ ).toBeNull();
+ expect(view.getByRole("button", { name: "Back" })).toBeTruthy();
+});
+
+test("Continue with local sets up, connects, waits, starts and asks its question with no further clicks", async () => {
setupRootConfiguration("/tmp/local-eval-auto", async () =>
emptyConfiguration(),
);
@@ -476,17 +529,18 @@ test("with a CLI and no hosted choice, step 4 sets up, connects, waits and start
set_up_local_eval: localStatus("starting", "valid"),
connect_local_eval: localConnection,
run_local_eval: localStatus("ready", "valid"),
+ ask_the_bot: "391",
});
const view = await renderApp();
await reachLocal(view);
- // No click after the model: the installation is the consent.
- await view.findByRole("heading", { name: "OpenBot is running" });
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
+ // A first local run tests the model as a hosted one does, before handing over.
+ await view.findByRole("heading", { name: "Ask it something." });
expect(localSequence()).toEqual([
"set_up_local_eval",
"connect_local_eval",
"run_local_eval",
"start_stack",
- "show_openbot",
]);
expect(
invokeCalls.find((call) => call.command === "run_local_eval")?.args,
@@ -498,58 +552,233 @@ test("with a CLI and no hosted choice, step 4 sets up, connects, waits and start
gatewayWsUrl: "ws://localhost:64772",
apiKey: "",
});
+ await userEvent.click(view.getByRole("button", { name: "Ask" }));
+ await view.findByText("391");
+ expect(
+ invokeCalls.find((call) => call.command === "ask_the_bot")?.args,
+ ).toMatchObject({ root: "/tmp/local-eval-auto" });
+ await userEvent.click(
+ view.getByRole("button", { name: "Start using OpenBot" }),
+ );
+ await waitFor(() => expect(localSequence()).toContain("show_openbot"));
+ expect(
+ invokeCalls.filter((call) => call.command === "start_stack"),
+ ).toHaveLength(1);
});
-test("the hosted service is one quiet link away, and back", async () => {
- setupRootConfiguration("/tmp/local-eval-switch", async () =>
+test("a first local run whose model cannot answer offers Change the model, as hosted does", async () => {
+ setupRootConfiguration("/tmp/local-eval-ask-failure", async () =>
+ emptyConfiguration(),
+ );
+ withLocalCli({
+ set_up_local_eval: localStatus("ready", "valid"),
+ connect_local_eval: localConnection,
+ run_local_eval: localStatus("ready", "valid"),
+ stop_stack: null,
+ ask_the_bot: () => {
+ throw { said: "The model could not answer.", detail: "401" };
+ },
+ });
+ const view = await renderApp();
+ await reachLocal(view);
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
+ await userEvent.click(await view.findByRole("button", { name: "Ask" }));
+ await view.findByText("The model could not answer.");
+ expect(localSequence()).not.toContain("show_openbot");
+ await userEvent.click(view.getByRole("button", { name: "Change the model" }));
+ await view.findByRole("heading", { name: "Connect your AI" });
+ expect(invokeCalls.some((call) => call.command === "stop_stack")).toBe(true);
+});
+
+test("Continue with hosted starts the CopilotKit sign-in and never sets up local Intelligence", async () => {
+ setupRootConfiguration("/tmp/local-eval-hosted", async () =>
+ emptyConfiguration(),
+ );
+ const finished = deferred();
+ withLocalCli({
+ begin_intelligence_sign_in: "https://signin.example/fixture",
+ finish_intelligence_sign_in: () => finished.promise,
+ });
+ const view = await renderApp();
+ await reachLocal(view);
+ await userEvent.click(choice(view, HOSTED_CHOICE));
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
+ await view.findByText("https://signin.example/fixture");
+ expect(
+ view.getByRole("heading", { name: "Connect to CopilotKit" }),
+ ).toBeTruthy();
+ expect(
+ invokeCalls.filter((call) => call.command === "begin_intelligence_sign_in"),
+ ).toHaveLength(1);
+ await act(async () =>
+ finished.resolve([{ id: "fixture", name: "Existing project" }]),
+ );
+ expect(view.getByText("Which project should OpenBot use?")).toBeTruthy();
+ expect(localCliCalls()).toEqual([]);
+});
+
+test("Cancel stops the running CLI call and returns to the choice, where hosted can be chosen", async () => {
+ setupRootConfiguration("/tmp/local-eval-cancel", async () =>
emptyConfiguration(),
);
const pending = deferred();
- withLocalCli({ set_up_local_eval: () => pending.promise });
+ withLocalCli({
+ set_up_local_eval: () => pending.promise,
+ cancel_local_eval: () => {
+ pending.reject("cancelled");
+ return null;
+ },
+ begin_intelligence_sign_in: "https://signin.example/fixture",
+ finish_intelligence_sign_in: () => new Promise(() => {}),
+ });
const view = await renderApp();
await reachLocal(view);
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
await view.findByRole("heading", {
name: "Starting OpenBot on this computer",
});
+ // Cancel and Back are the only controls while it runs.
expect(
- view.queryByRole("button", { name: "Sign in to CopilotKit" }),
- ).toBeNull();
- expect(view.queryByRole("button", { name: "Start OpenBot" })).toBeNull();
- await userEvent.click(
- view.getByRole("button", {
+ view.queryByRole("button", {
name: "Use CopilotKit's hosted service instead",
}),
+ ).toBeNull();
+ expect(view.queryByRole("button", { name: "Start OpenBot" })).toBeNull();
+ expect(view.queryByText("Installation options")).toBeNull();
+ expect(
+ view
+ .getAllByRole("button")
+ .map((button) => button.textContent)
+ .sort(),
+ ).toEqual(["Back", "Cancel"]);
+ await userEvent.click(view.getByRole("button", { name: "Cancel" }));
+ const setup = invokeCalls.find(
+ (call) => call.command === "set_up_local_eval",
);
expect(
- view.getByRole("heading", { name: "Connect to CopilotKit" }),
- ).toBeTruthy();
- expect(view.getByText("Point at your own Intelligence server")).toBeTruthy();
+ invokeCalls.find((call) => call.command === "cancel_local_eval")?.args,
+ ).toEqual({
+ attempt: (setup?.args as { attempt: string } | undefined)?.attempt,
+ });
+ await view.findByRole("heading", { name: "Choose where Intelligence runs" });
+ await userEvent.click(choice(view, HOSTED_CHOICE));
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
+ await view.findByText("https://signin.example/fixture");
+ expect(
+ invokeCalls.filter((call) => call.command === "set_up_local_eval"),
+ ).toHaveLength(1);
+});
+
+test("Back stops the sequence, and returning to step 4 runs it again without asking", async () => {
+ setupRootConfiguration("/tmp/local-eval-back", async () =>
+ emptyConfiguration(),
+ );
+ withLocalCli({ set_up_local_eval: () => new Promise(() => {}) });
+ const view = await renderApp();
+ await reachLocal(view);
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
+ await waitFor(() => expect(localSequence()).toEqual(["set_up_local_eval"]));
+ await userEvent.click(view.getByRole("button", { name: "Back" }));
+ await view.findByRole("heading", { name: "Connect your AI" });
// Leaving stops the CLI call that was running.
expect(invokeCalls.some((call) => call.command === "cancel_local_eval")).toBe(
true,
);
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
+ await view.findByRole("heading", {
+ name: "Starting OpenBot on this computer",
+ });
+ expect(
+ view.queryByRole("radio", { name: new RegExp(LOCAL_CHOICE) }),
+ ).toBeNull();
+ await waitFor(() =>
+ expect(localSequence()).toEqual(["set_up_local_eval", "set_up_local_eval"]),
+ );
+});
+
+test("the hosted screen offers the choice again without running anything", async () => {
+ setupRootConfiguration("/tmp/local-eval-hosted-back", async () =>
+ emptyConfiguration(),
+ );
+ withLocalCli({});
+ const view = await renderApp();
+ await reachLocal(view);
+ await userEvent.click(choice(view, HOSTED_CHOICE));
+ // The sign-in is not started for this test.
+ const previous = invokeHandler;
+ invokeHandler = async (command, args) =>
+ command === "begin_intelligence_sign_in"
+ ? Promise.reject({ said: "Sign-in did not open." })
+ : previous(command, args);
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
await userEvent.click(
- view.getByRole("button", { name: "Run Intelligence on this computer" }),
+ await view.findByRole("button", { name: "Choose where Intelligence runs" }),
);
- expect(
- view.getByRole("heading", { name: "Starting OpenBot on this computer" }),
- ).toBeTruthy();
+ await view.findByRole("heading", { name: "Choose where Intelligence runs" });
+ expect(localCliCalls()).toEqual([]);
});
-test("a hosted connection chosen earlier keeps the hosted screen", async () => {
- setupRootConfiguration("/tmp/local-eval-hosted-chosen", async () => ({
- ...emptyConfiguration(),
- values: { INTELLIGENCE_API_URL: "https://intelligence.example" },
+test("a hosted connection chosen earlier preselects hosted", async () => {
+ for (const [name, configuration] of [
+ [
+ "url",
+ {
+ ...emptyConfiguration(),
+ values: { INTELLIGENCE_API_URL: "https://intelligence.example" },
+ },
+ ],
+ [
+ "key",
+ {
+ ...emptyConfiguration(),
+ saved: { ...emptyConfiguration().saved, intelligenceApiKey: true },
+ },
+ ],
+ ] as const) {
+ setupRootConfiguration(
+ `/tmp/local-eval-hosted-chosen-${name}`,
+ async () => configuration,
+ );
+ withLocalCli({});
+ const view = await renderApp();
+ await reachLocal(view);
+ await view.findByRole("heading", {
+ name: "Choose where Intelligence runs",
+ });
+ expect(choice(view, HOSTED_CHOICE)).toHaveProperty("checked", true);
+ expect(choice(view, LOCAL_CHOICE)).toHaveProperty("checked", false);
+ // A saved connection is checked at Start; Continue does not sign in again.
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
+ await view.findByRole("heading", { name: "Connect to CopilotKit" });
+ expect(
+ invokeCalls.some((call) => call.command === "begin_intelligence_sign_in"),
+ ).toBe(false);
+ expect(localCliCalls()).toEqual([]);
+ cleanup();
+ invokeCalls = [];
+ }
+});
+
+test("a recorded, connected stack on step 4 skips the choice and starts", async () => {
+ setupRootConfiguration("/tmp/local-eval-recorded", async () => ({
+ ...savedOpenAiConfiguration(),
+ saved: { ...savedOpenAiConfiguration().saved, model: "open-ai-api-key" },
+ installation: { harness: { id: "mastra" } },
+ localEval: {
+ stack: "local-abc123",
+ ownership: "created",
+ phase: "installed",
+ connected: true,
+ },
}));
- withLocalCli({});
+ withLocalCli({ run_local_eval: localStatus("ready", "valid") });
const view = await renderApp();
- await reachConnect(view);
+ // Never launched, so this is a first run: it asks its question before handing over.
+ await view.findByRole("heading", { name: "Ask it something." });
expect(
- view.getByRole("button", { name: "Run Intelligence on this computer" }),
- ).toBeTruthy();
- expect(invokeCalls.some((call) => call.command === "set_up_local_eval")).toBe(
- false,
- );
+ view.queryByRole("radio", { name: new RegExp(LOCAL_CHOICE) }),
+ ).toBeNull();
+ expect(localSequence()).toEqual(["run_local_eval", "start_stack"]);
});
test("a blocked setup stops at its row and Retry resumes there", async () => {
@@ -572,6 +801,7 @@ test("a blocked setup stops at its row and Retry resumes there", async () => {
});
const view = await renderApp();
await reachLocal(view);
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
const row = await view.findByRole("listitem", {
name: "Setting up Intelligence on this computer",
});
@@ -584,7 +814,7 @@ test("a blocked setup stops at its row and Retry resumes there", async () => {
false,
);
await userEvent.click(view.getByRole("button", { name: "Retry" }));
- await view.findByRole("heading", { name: "OpenBot is running" });
+ await view.findByRole("heading", { name: "Ask it something." });
expect(setups).toBe(2);
});
diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx
index 75f126e9aa..f4054e2dd8 100644
--- a/desktop/src/App.tsx
+++ b/desktop/src/App.tsx
@@ -9,7 +9,12 @@ import {
HarnessPicker,
} from "./HarnessPicker";
import { isHttpEndpointUrl } from "./http-endpoint-url";
-import { LocalIntelligence, type LocalStartable } from "./LocalIntelligence";
+import {
+ IntelligenceChoice,
+ type IntelligenceMode,
+ LocalIntelligence,
+ type LocalStartable,
+} from "./LocalIntelligence";
import {
intelligenceStopProblem,
listLocalEval,
@@ -93,10 +98,15 @@ export function App() {
// Offered only when this build has an Intelligence CLI to run; a local stack is its own mode,
// not another setup step.
const [localAvailable, setLocalAvailable] = useState(false);
- // Null until somebody chooses; then local is the default where it is available.
- const [intelligenceChoice, setIntelligenceMode] = useState<
- "hosted" | "local" | null
- >(null);
+ // Null until somebody presses Continue on the choice, or a recorded local stack implies it.
+ // Held per root in memory, so returning to step 4 does not ask again.
+ const [intelligenceChoice, setIntelligenceMode] =
+ useState(null);
+ // The choice screen's selection, before Continue commits it.
+ const [pickedIntelligence, setPickedIntelligence] =
+ useState(null);
+ // Continue chose local in this window: that click is the go-ahead, wherever step 4 is reached.
+ const [localContinued, setLocalContinued] = useState(false);
const [localStack, setLocalStack] = useState(null);
const [localConnected, setLocalConnected] = useState(false);
// A relaunch found a connected stack it may start, as a hosted launch resumes.
@@ -123,9 +133,12 @@ export function App() {
const hostedChosen =
Boolean(alreadyHeld.saved?.intelligenceApiKey) ||
Boolean(alreadyHeld.INTELLIGENCE_API_URL);
+ // Before a choice, the preselection: local only when the CLI answered and nothing hosted is saved.
const intelligenceMode =
intelligenceChoice ??
(localAvailable && !hostedChosen ? "local" : "hosted");
+ // Step 4 asks first, and runs nothing until Continue.
+ const choosingIntelligence = localAvailable && intelligenceChoice === null;
const [projects, setProjects] = useState<
{ id: string; name: string }[] | null
>(null);
@@ -263,6 +276,25 @@ export function App() {
}
}
+ function chooseIntelligence(mode: IntelligenceMode | null) {
+ setIntelligenceMode(mode);
+ setLocalContinued(mode === "local");
+ }
+
+ function continueWithIntelligence() {
+ const mode = pickedIntelligence ?? intelligenceMode;
+ chooseIntelligence(mode);
+ // Hosted begins at the sign-in, unless a connection is already saved to be checked at Start.
+ if (
+ mode === "hosted" &&
+ !hostedChosen &&
+ !apiKey.trim() &&
+ !reuseIntelligence
+ ) {
+ void signInToCopilotKit();
+ }
+ }
+
const [step, setStep] = useState("welcome");
const [apiUrl, setApiUrl] = useState(MANAGED_INTELLIGENCE_API_URL);
const [wsUrl, setWsUrl] = useState(MANAGED_INTELLIGENCE_GATEWAY_WS_URL);
@@ -366,6 +398,8 @@ export function App() {
setApiKey("");
setReuseIntelligence(false);
setIntelligenceMode(null);
+ setPickedIntelligence(null);
+ setLocalContinued(false);
setLocalStack(null);
setLocalConnected(false);
setLocalAutoStart(false);
@@ -641,12 +675,11 @@ export function App() {
setSteps((current) => settleProgress(current));
setRunning(true);
setRecoveryFailure(null);
- // Refreshing credentials does not turn an existing installation into a first run. A local
- // stack has already reported itself ready, so it is shown as the hosted reopen is.
+ // Refreshing credentials does not turn an existing installation into a first run. A first
+ // local run asks its question too: Intelligence reporting ready says nothing about the model.
if (
savedLaunch.current === installationKey ||
- organizationAuthorityUrl?.trim() ||
- local
+ organizationAuthorityUrl?.trim()
) {
savedLaunch.current = installationKey;
setStep("connect");
@@ -1007,21 +1040,30 @@ export function App() {
}
// Local Intelligence runs its own steps and starts OpenBot only once its CLI reports it ready.
- const localIntelligence = (auto: boolean) => (
+ const localIntelligence = (auto: boolean, onCancel?: () => void) => (
start(model, local)}
+ onStart={async (local) => {
+ const started = await start(model, local);
+ // Continue's go-ahead is spent once OpenBot starts; reopening waits for Start again.
+ if (started) setLocalContinued(false);
+ return started;
+ }}
onConnected={(stack) => {
setLocalStack(stack);
setLocalConnected(true);
}}
- onLeave={() => setIntelligenceMode("hosted")}
+ onCancel={onCancel}
/>
);
+ // On step 4 the local sequence replaces the hosted form, once local is chosen.
+ const localSequenceShown =
+ !running && !choosingIntelligence && intelligenceMode === "local";
+
if (!running && returningToInstallation && !refreshIntelligence) {
return (
@@ -1053,7 +1095,11 @@ export function App() {
type="button"
className="quiet"
disabled={busy}
- onClick={() => setRefreshIntelligence(true)}
+ onClick={() => {
+ // Changing it asks again where Intelligence runs.
+ chooseIntelligence(null);
+ setRefreshIntelligence(true);
+ }}
>
Change CopilotKit connection
@@ -1107,32 +1153,44 @@ export function App() {
{running && !displayedFailure
? "OpenBot is running"
- : intelligenceMode === "local" && !running
- ? "Starting OpenBot on this computer"
- : returningToInstallation
- ? "Refresh your CopilotKit connection"
- : "Connect to CopilotKit"}
+ : choosingIntelligence && !running
+ ? "Choose where Intelligence runs"
+ : intelligenceMode === "local" && !running
+ ? "Starting OpenBot on this computer"
+ : 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."
- : intelligenceMode === "local" && !running
- ? "Intelligence runs on this computer in Docker Desktop. The Intelligence CLI sets it up and manages it."
- : returningToInstallation
- ? "Update this connection to reopen your existing OpenBot."
- : "Local installation is complete. Connect CopilotKit, then start OpenBot."}
+ : choosingIntelligence && !running
+ ? "OpenBot keeps your conversations in CopilotKit Intelligence. Nothing is set up until you continue."
+ : intelligenceMode === "local" && !running
+ ? "Intelligence runs on this computer in Docker Desktop. The Intelligence CLI sets it up and manages it."
+ : returningToInstallation
+ ? "Update this connection to reopen your existing OpenBot."
+ : "Local installation is complete. Connect CopilotKit, then start OpenBot."}
- {/* Reaching this step with a model chosen is the go-ahead, unless a run was interrupted. */}
- {!running &&
- intelligenceMode === "local" &&
+ {!running && choosingIntelligence && (
+
+ )}
+
+ {/* Continue on the choice is the go-ahead. A recorded stack implies it, unless a run was
+ interrupted; reopening an installation waits for Start. Cancel goes back to the choice. */}
+ {localSequenceShown &&
localIntelligence(
- !returningToInstallation &&
- !interruptedAtLaunch.current &&
- modelCanStart(),
+ modelCanStart() &&
+ (localContinued ||
+ (!returningToInstallation && !interruptedAtLaunch.current)),
+ () => chooseIntelligence(null),
)}
- {!running && intelligenceMode === "hosted" && (
+ {!running && !choosingIntelligence && intelligenceMode === "hosted" && (
@@ -1394,7 +1453,7 @@ export function App() {
{displayedFailure && }
- {!running && !returningToInstallation && (
+ {!running && !returningToInstallation && !localSequenceShown && (
Installation options
>
+ ) : choosingIntelligence ? (
+
+ Continue
+
) : intelligenceMode === "local" ? null : (
GlobalRegistrator.register());
afterEach(() => {
- invokeCalls = [];
+ // Unmounting cancels a running step, so clear the record after it.
cleanup();
+ invokeCalls = [];
});
afterAll(() => GlobalRegistrator.unregister());
@@ -94,7 +95,7 @@ function view(
connected?: boolean;
auto?: boolean;
onStart?: (startable: Startable) => Promise;
- onLeave?: () => void;
+ onCancel?: () => void;
pollDelays?: number[];
} = {},
) {
@@ -105,7 +106,7 @@ function view(
connected={props.connected ?? false}
developer={props.developer ?? false}
auto={props.auto ?? true}
- onLeave={props.onLeave ?? (() => {})}
+ onCancel={props.onCancel}
onStart={props.onStart ?? (async () => true)}
pollDelays={props.pollDelays ?? [0, 0, 0]}
/>,
@@ -455,16 +456,29 @@ test("without auto, nothing runs until Start OpenBot is chosen", async () => {
await waitFor(() => expect(started).toEqual([startableUrls]));
});
-test("the hosted service is one quiet link away", async () => {
+test("with onCancel, Cancel stops the CLI call and hands back to the choice", 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}`);
+ };
+ let cancelled = 0;
+ const screen = view({ onCancel: () => cancelled++ });
+ await userEvent.click(await screen.findByRole("button", { name: "Cancel" }));
+ expect(cancelled).toBe(1);
+ expect(commands()).toEqual(["set_up_local_eval", "cancel_local_eval"]);
+ expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
+});
+
+test("the running screen offers no way to the hosted service", async () => {
invokeHandler = answers({ set_up_local_eval: [blockedOnTerms] });
- let left = 0;
- const screen = view({ onLeave: () => left++ });
- await userEvent.click(
- screen.getByRole("button", {
- name: "Use CopilotKit's hosted service instead",
- }),
- );
- expect(left).toBe(1);
+ 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 () => {
diff --git a/desktop/src/LocalIntelligence.tsx b/desktop/src/LocalIntelligence.tsx
index 49cc2258b3..9a090cabc4 100644
--- a/desktop/src/LocalIntelligence.tsx
+++ b/desktop/src/LocalIntelligence.tsx
@@ -75,12 +75,12 @@ function isReady(report: LocalReport): boolean {
/**
* Intelligence on this computer, run by the Intelligence CLI, then OpenBot started against it.
*
- * Shown only when this build has a CLI to run. Four steps, each idempotent, run in order without
- * a button: set up, connect (choosing this 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. 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.
+ * Shown only when this build has a CLI to run, and only once the person has chosen it. Four
+ * steps, each idempotent, run in order without a button: set up, connect (the choice 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. 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 LocalIntelligence({
root,
@@ -88,7 +88,7 @@ export function LocalIntelligence({
connected: recordedConnection = false,
auto,
developer = import.meta.env.DEV,
- onLeave,
+ onCancel,
onStart,
onConnected = () => {},
pollDelays = POLL_DELAYS,
@@ -100,7 +100,8 @@ export function LocalIntelligence({
/** Run the steps as soon as this is shown. Otherwise they wait for Start OpenBot. */
auto: boolean;
developer?: boolean;
- onLeave: () => void;
+ /** Cancel also leaves, back to the choice. Without it, Cancel stops at its row with Retry. */
+ onCancel?: () => void;
/** Starts OpenBot against the stack; true when it started. */
onStart: (startable: LocalStartable) => Promise;
onConnected?: (stack: string) => void;
@@ -297,6 +298,7 @@ export function LocalIntelligence({
function cancel() {
halt();
setRunning(false);
+ if (onCancel) return onCancel();
setBlocked({ said: said("cancelled"), reason: null });
}
@@ -360,12 +362,58 @@ export function LocalIntelligence({
Start OpenBot
)}
- {!(running && at === "start") && (
-
- Use CopilotKit's hosted service instead
-
- )}
);
}
+
+export type IntelligenceMode = "local" | "hosted";
+
+/**
+ * Where Intelligence runs, asked before anything is set up.
+ *
+ * Local setup downloads several gigabytes, so it waits for this answer rather than starting on
+ * arrival and offering hosted as an interruption. Continue, which belongs to the screen, commits it.
+ */
+export function IntelligenceChoice({
+ chosen,
+ onChoose,
+}: {
+ chosen: IntelligenceMode;
+ onChoose: (mode: IntelligenceMode) => void;
+}) {
+ const rows: [IntelligenceMode, string, string][] = [
+ [
+ "local",
+ "Run Intelligence on this computer",
+ "For evaluation. Uses Docker Desktop; the first setup takes a few minutes.",
+ ],
+ [
+ "hosted",
+ "Use CopilotKit's hosted Intelligence",
+ "Signs in to your CopilotKit account.",
+ ],
+ ];
+ return (
+
+ );
+}
diff --git a/desktop/src/styles.css b/desktop/src/styles.css
index 049420746b..bf45b22176 100644
--- a/desktop/src/styles.css
+++ b/desktop/src/styles.css
@@ -176,6 +176,14 @@ h2 {
overflow-wrap: anywhere;
}
+/* Where Intelligence runs: two rows with no mark. */
+.tile.intelligence-choice {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-areas:
+ "name"
+ "summary";
+}
+
button {
font: inherit;
font-weight: 500;
From 216f5d9042f8ea0e2432f21b62cb35e470b86b45 Mon Sep 17 00:00:00 2001
From: Dusty
Date: Wed, 21 Oct 2026 21:08:11 -0700
Subject: [PATCH 07/77] wip(desktop): three-step local first run with
background Intelligence and automatic test
Local checkpoint for the demo; not for review as-is.
---
desktop/src/App.test.tsx | 562 +++++++++++++++++--------
desktop/src/App.tsx | 461 +++++++++++++-------
desktop/src/Ask.tsx | 63 ++-
desktop/src/LocalIntelligence.test.tsx | 125 ++++--
desktop/src/LocalIntelligence.tsx | 294 +++++++++----
desktop/src/ProviderPicker.tsx | 31 +-
desktop/src/SetupProgress.tsx | 81 +++-
desktop/src/Welcome.tsx | 35 +-
desktop/src/localReadiness.test.ts | 4 +-
desktop/src/localReadiness.ts | 19 +-
desktop/src/styles.css | 26 ++
11 files changed, 1218 insertions(+), 483 deletions(-)
diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx
index d5d25927a0..888d02ccb6 100644
--- a/desktop/src/App.test.tsx
+++ b/desktop/src/App.test.tsx
@@ -452,14 +452,6 @@ function withLocalCli(local: Record) {
};
}
-async function reachLocal(view: Awaited>) {
- await enterInstallation(view);
- await completeInstallation(view);
- await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ }));
- await userEvent.type(view.getByLabelText("OpenAI API key"), "model-test-key");
- await userEvent.click(view.getByRole("button", { name: "Continue" }));
-}
-
function localSequence() {
return invokeCalls
.map((call) => call.command)
@@ -488,54 +480,225 @@ function localCliCalls() {
const LOCAL_CHOICE = "Run Intelligence on this computer";
const HOSTED_CHOICE = "Use CopilotKit's hosted Intelligence";
+const HOSTED_LINK = "Use CopilotKit's hosted service instead";
+const LOCAL_QUESTION =
+ "Reply with one short sentence to confirm you are working.";
function choice(view: Awaited>, name: string) {
return view.getByRole("radio", { name: new RegExp(name) });
}
-test("with a CLI, step 4 opens on a choice with local preselected and nothing running", async () => {
- setupRootConfiguration("/tmp/local-eval-choice", async () =>
+/** Words about the software underneath, which no local screen says. */
+const JARGON = [
+ /\bCLI\b/,
+ /Docker/i,
+ /Podman/i,
+ /Container engine/i,
+ /Engine machine/i,
+ /k3d/i,
+];
+
+function expectPlain(view: Awaited>) {
+ const text = view.container.textContent ?? "";
+ for (const word of JARGON) expect(text).not.toMatch(word);
+}
+
+function stepLabel(view: Awaited>) {
+ return view.container.querySelector(".steps-of")?.textContent;
+}
+
+function buttons(view: Awaited>) {
+ return view
+ .getAllByRole("button")
+ .map((button) => button.textContent)
+ .sort();
+}
+
+async function chooseOpenAi(view: Awaited>) {
+ await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ }));
+ await userEvent.type(view.getByLabelText("OpenAI API key"), "model-test-key");
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
+}
+
+test("with a CLI, the first screen is the one choice, and without one it is unchanged", async () => {
+ setupRootConfiguration("/tmp/local-eval-welcome", async () =>
emptyConfiguration(),
);
withLocalCli({});
- const view = await renderApp();
- await reachLocal(view);
- await view.findByRole("heading", { name: "Choose where Intelligence runs" });
- expect(choice(view, LOCAL_CHOICE)).toHaveProperty("checked", true);
- expect(choice(view, HOSTED_CHOICE)).toHaveProperty("checked", false);
+ let view = await renderApp();
+ expect(view.getByRole("heading", { name: "Set up OpenBot" })).toBeTruthy();
expect(
view.getByText(
- "For evaluation. Uses Docker Desktop; the first setup takes a few minutes.",
+ "Installs OpenBot and CopilotKit Intelligence on this computer. The first setup takes about 10 minutes.",
),
).toBeTruthy();
- expect(view.getByText("Signs in to your CopilotKit account.")).toBeTruthy();
- await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(buttons(view)).toEqual(["Install", HOSTED_LINK]);
+ expectPlain(view);
expect(localCliCalls()).toEqual([]);
+ cleanup();
+ invokeCalls = [];
+
+ setupRootConfiguration("/tmp/local-eval-welcome-none", async () =>
+ emptyConfiguration(),
+ );
+ view = await renderApp();
expect(
- invokeCalls.some((call) => call.command === "begin_intelligence_sign_in"),
- ).toBe(false);
- expect(view.queryByRole("button", { name: "Start OpenBot" })).toBeNull();
+ view.getByRole("heading", {
+ name: "Your own AI coworkers, on this computer.",
+ }),
+ ).toBeTruthy();
+ expect(buttons(view)).toEqual(["Set up OpenBot"]);
+ await userEvent.click(view.getByRole("button", { name: "Set up OpenBot" }));
+ expect(stepLabel(view)).toBe("Step 1 of 4");
+});
+
+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 userEvent.click(view.getByRole("button", { name: "Install" }));
+ expect(installationCalls()).toEqual([
+ {
+ command: "prepare_installation",
+ args: { root: "/tmp/local-eval-install", harness: { id: "langgraph" } },
+ },
+ ]);
expect(
- view.queryByRole("button", { name: "Sign in to CopilotKit" }),
- ).toBeNull();
- expect(view.getByRole("button", { name: "Back" })).toBeTruthy();
+ view.getByRole("heading", { name: "Installing OpenBot" }),
+ ).toBeTruthy();
+ expect(stepLabel(view)).toBe("Step 1 of 3");
+ 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([]);
+ // Native rows are summarised in plain words.
+ await emitProgress({
+ step: "engine",
+ ok: true,
+ detail: "The container engine is answering.",
+ });
+ await emitProgress({
+ step: "create-machine",
+ ok: true,
+ detail: "Preparing the engine machine.",
+ });
+ await emitProgress({
+ step: "images",
+ ok: true,
+ running: true,
+ detail: "Downloading local software (2/4).",
+ downloadBytes: 2_400_000,
+ });
+ expect(
+ within(
+ view.getByRole("listitem", { name: "Preparing this computer" }),
+ ).getByText("Complete"),
+ ).toBeTruthy();
+ const installing = within(
+ view.getByRole("listitem", { name: "Installing OpenBot" }),
+ );
+ expect(installing.getByText("In progress")).toBeTruthy();
+ expect(installing.getByText(/2.4 MB downloaded/)).toBeTruthy();
+ expectPlain(view);
+ expect(
+ setupEvents().filter(
+ (event) =>
+ (event as { event: { kind: string } }).event.kind === "harness_chosen",
+ ),
+ ).toEqual([{ event: { kind: "harness_chosen", harness: "langgraph" } }]);
+ expect(localCliCalls()).toEqual([]);
});
-test("Continue with local sets up, connects, waits, starts and asks its question with no further clicks", async () => {
+test("the hosted link reaches today's hosted flow and never sets up local Intelligence", async () => {
+ setupRootConfiguration("/tmp/local-eval-hosted-link", async () =>
+ emptyConfiguration(),
+ );
+ withLocalCli({});
+ const view = await renderApp();
+ await userEvent.click(view.getByRole("button", { name: HOSTED_LINK }));
+ expect(stepLabel(view)).toBe("Step 1 of 4");
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
+ expect(view.getByLabelText("Where OpenBot lives")).toBeTruthy();
+ await completeInstallation(view);
+ expect(stepLabel(view)).toBe("Step 3 of 4");
+ await chooseOpenAi(view);
+ await view.findByRole("heading", { name: "Connect to CopilotKit" });
+ expect(stepLabel(view)).toBe("Step 4 of 4");
+ expect(
+ view.getByRole("button", { name: "Sign in to CopilotKit" }),
+ ).toBeTruthy();
+ expect(view.queryByRole("radio", { name: /Intelligence/ })).toBeNull();
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(localCliCalls()).toEqual([]);
+});
+
+test("the local sequence starts when installation completes and survives the provider screen", async () => {
+ setupRootConfiguration("/tmp/local-eval-overlap", async () =>
+ emptyConfiguration(),
+ );
+ const preparation = deferred();
+ const setUp = deferred();
+ withLocalCli({
+ prepare_installation: () => preparation.promise,
+ set_up_local_eval: () => setUp.promise,
+ connect_local_eval: () => new Promise(() => {}),
+ });
+ const view = await renderApp();
+ await userEvent.click(view.getByRole("button", { name: "Install" }));
+ 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("Step 2 of 3");
+ await waitFor(() => expect(localSequence()).toEqual(["set_up_local_eval"]));
+ expect(
+ invokeCalls.find((call) => call.command === "set_up_local_eval")?.args,
+ ).toMatchObject({ root: "/tmp/local-eval-overlap" });
+ expect(view.getByRole("status").textContent).toMatch(
+ /^Setting up Intelligence · \d+s elapsed$/,
+ );
+ // A local first run has nothing behind this screen to go back to.
+ expect(view.queryByRole("button", { name: "Back" })).toBeNull();
+ await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ }));
+ await act(async () => setUp.resolve(localStatus("starting", "valid")));
+ await waitFor(() =>
+ expect(view.getByRole("status").textContent).toMatch(
+ /^Connecting OpenBot · /,
+ ),
+ );
+ expect(localSequence()).toEqual(["set_up_local_eval", "connect_local_eval"]);
+ expect(invokeCalls.some((call) => call.command === "cancel_local_eval")).toBe(
+ false,
+ );
+ expectPlain(view);
+});
+
+test("Continue during setup shows Getting ready, which starts, tests and opens OpenBot by itself", async () => {
setupRootConfiguration("/tmp/local-eval-auto", async () =>
emptyConfiguration(),
);
+ const setUp = deferred();
withLocalCli({
- set_up_local_eval: localStatus("starting", "valid"),
+ set_up_local_eval: () => setUp.promise,
connect_local_eval: localConnection,
run_local_eval: localStatus("ready", "valid"),
- ask_the_bot: "391",
+ ask_the_bot: "I am working.",
});
const view = await renderApp();
- await reachLocal(view);
- await userEvent.click(view.getByRole("button", { name: "Continue" }));
- // A first local run tests the model as a hosted one does, before handing over.
- await view.findByRole("heading", { name: "Ask it something." });
+ await userEvent.click(view.getByRole("button", { name: "Install" }));
+ await chooseOpenAi(view);
+ await view.findByRole("heading", { name: "Getting ready" });
+ expect(stepLabel(view)).toBe("Step 3 of 3");
+ const row = view.getByRole("listitem", { name: "Setting up Intelligence" });
+ expect(row.textContent).toContain("In progress");
+ expectPlain(view);
+ await act(async () => setUp.resolve(localStatus("starting", "valid")));
+ await view.findByRole("heading", { name: "Test OpenBot" });
+ expect(stepLabel(view)).toBe("Step 3 of 3");
expect(localSequence()).toEqual([
"set_up_local_eval",
"connect_local_eval",
@@ -552,72 +715,75 @@ test("Continue with local sets up, connects, waits, starts and asks its question
gatewayWsUrl: "ws://localhost:64772",
apiKey: "",
});
- await userEvent.click(view.getByRole("button", { name: "Ask" }));
- await view.findByText("391");
- expect(
- invokeCalls.find((call) => call.command === "ask_the_bot")?.args,
- ).toMatchObject({ root: "/tmp/local-eval-auto" });
- await userEvent.click(
- view.getByRole("button", { name: "Start using OpenBot" }),
- );
+ // The test question goes by itself, once.
+ await view.findByText("I am working.");
+ expect(view.getByText(LOCAL_QUESTION)).toBeTruthy();
+ expect(view.queryByLabelText("Your question")).toBeNull();
+ const asked = invokeCalls.filter((call) => call.command === "ask_the_bot");
+ expect(asked).toEqual([
+ {
+ command: "ask_the_bot",
+ args: { root: "/tmp/local-eval-auto", question: LOCAL_QUESTION },
+ },
+ ]);
+ expectPlain(view);
+ await userEvent.click(view.getByRole("button", { name: "Open OpenBot" }));
await waitFor(() => expect(localSequence()).toContain("show_openbot"));
expect(
invokeCalls.filter((call) => call.command === "start_stack"),
).toHaveLength(1);
});
-test("a first local run whose model cannot answer offers Change the model, as hosted does", async () => {
- setupRootConfiguration("/tmp/local-eval-ask-failure", async () =>
+test("a development double mount runs each local step and asks the question once", async () => {
+ setupRootConfiguration("/tmp/local-eval-strict", async () =>
emptyConfiguration(),
);
withLocalCli({
- set_up_local_eval: localStatus("ready", "valid"),
+ set_up_local_eval: localStatus("starting", "valid"),
connect_local_eval: localConnection,
run_local_eval: localStatus("ready", "valid"),
- stop_stack: null,
- ask_the_bot: () => {
- throw { said: "The model could not answer.", detail: "401" };
- },
+ ask_the_bot: "I am working.",
});
- const view = await renderApp();
- await reachLocal(view);
- await userEvent.click(view.getByRole("button", { name: "Continue" }));
- await userEvent.click(await view.findByRole("button", { name: "Ask" }));
- await view.findByText("The model could not answer.");
- expect(localSequence()).not.toContain("show_openbot");
- await userEvent.click(view.getByRole("button", { name: "Change the model" }));
- await view.findByRole("heading", { name: "Connect your AI" });
- expect(invokeCalls.some((call) => call.command === "stop_stack")).toBe(true);
+ const view = await renderApp(true);
+ await userEvent.click(view.getByRole("button", { name: "Install" }));
+ await chooseOpenAi(view);
+ await view.findByText("I am working.");
+ expect(localSequence()).toEqual([
+ "set_up_local_eval",
+ "connect_local_eval",
+ "run_local_eval",
+ "start_stack",
+ ]);
+ expect(
+ invokeCalls.filter((call) => call.command === "ask_the_bot"),
+ ).toHaveLength(1);
});
-test("Continue with hosted starts the CopilotKit sign-in and never sets up local Intelligence", async () => {
- setupRootConfiguration("/tmp/local-eval-hosted", async () =>
+test("Continue after Intelligence is ready starts OpenBot with the chosen model", async () => {
+ setupRootConfiguration("/tmp/local-eval-ready-first", async () =>
emptyConfiguration(),
);
- const finished = deferred();
withLocalCli({
- begin_intelligence_sign_in: "https://signin.example/fixture",
- finish_intelligence_sign_in: () => finished.promise,
+ set_up_local_eval: localStatus("ready", "valid"),
+ connect_local_eval: localConnection,
+ run_local_eval: localStatus("ready", "valid"),
+ ask_the_bot: "I am working.",
});
const view = await renderApp();
- await reachLocal(view);
- await userEvent.click(choice(view, HOSTED_CHOICE));
- await userEvent.click(view.getByRole("button", { name: "Continue" }));
- await view.findByText("https://signin.example/fixture");
- expect(
- view.getByRole("heading", { name: "Connect to CopilotKit" }),
- ).toBeTruthy();
- expect(
- invokeCalls.filter((call) => call.command === "begin_intelligence_sign_in"),
- ).toHaveLength(1);
- await act(async () =>
- finished.resolve([{ id: "fixture", name: "Existing project" }]),
+ await userEvent.click(view.getByRole("button", { name: "Install" }));
+ await view.findByRole("heading", { name: "Connect your AI" });
+ await waitFor(() =>
+ expect(view.getByRole("status").textContent).toBe("Intelligence is ready."),
);
- expect(view.getByText("Which project should OpenBot use?")).toBeTruthy();
- expect(localCliCalls()).toEqual([]);
+ expect(localSequence()).not.toContain("start_stack");
+ await chooseOpenAi(view);
+ await view.findByText("I am working.");
+ expect(getStartStackPayload()).toMatchObject({
+ model: { provider: "openai" },
+ });
});
-test("Cancel stops the running CLI call and returns to the choice, where hosted can be chosen", async () => {
+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(),
);
@@ -632,25 +798,11 @@ test("Cancel stops the running CLI call and returns to the choice, where hosted
finish_intelligence_sign_in: () => new Promise(() => {}),
});
const view = await renderApp();
- await reachLocal(view);
- await userEvent.click(view.getByRole("button", { name: "Continue" }));
- await view.findByRole("heading", {
- name: "Starting OpenBot on this computer",
- });
- // Cancel and Back are the only controls while it runs.
- expect(
- view.queryByRole("button", {
- name: "Use CopilotKit's hosted service instead",
- }),
- ).toBeNull();
- expect(view.queryByRole("button", { name: "Start OpenBot" })).toBeNull();
+ await userEvent.click(view.getByRole("button", { name: "Install" }));
+ await chooseOpenAi(view);
+ await view.findByRole("heading", { name: "Getting ready" });
+ expect(buttons(view)).toEqual(["Cancel"]);
expect(view.queryByText("Installation options")).toBeNull();
- expect(
- view
- .getAllByRole("button")
- .map((button) => button.textContent)
- .sort(),
- ).toEqual(["Back", "Cancel"]);
await userEvent.click(view.getByRole("button", { name: "Cancel" }));
const setup = invokeCalls.find(
(call) => call.command === "set_up_local_eval",
@@ -660,78 +812,154 @@ test("Cancel stops the running CLI call and returns to the choice, where hosted
).toEqual({
attempt: (setup?.args as { attempt: string } | undefined)?.attempt,
});
- await view.findByRole("heading", { name: "Choose where Intelligence runs" });
- await userEvent.click(choice(view, HOSTED_CHOICE));
- await userEvent.click(view.getByRole("button", { name: "Continue" }));
- await view.findByText("https://signin.example/fixture");
+ const row = view.getByRole("listitem", { name: "Setting up Intelligence" });
+ await waitFor(() =>
+ expect(row.textContent).toContain(
+ "Stopped at your request before it finished.",
+ ),
+ );
+ // Once nothing runs, leaving is offered.
+ expect(buttons(view)).toEqual(["Back", "Retry", HOSTED_LINK]);
+ await userEvent.click(view.getByRole("button", { name: HOSTED_LINK }));
+ await view.findByRole("heading", { name: "Connect to CopilotKit" });
expect(
invokeCalls.filter((call) => call.command === "set_up_local_eval"),
).toHaveLength(1);
});
-test("Back stops the sequence, and returning to step 4 runs it again without asking", async () => {
- setupRootConfiguration("/tmp/local-eval-back", async () =>
+test("a first local run whose model cannot answer offers Change the model, as hosted does", async () => {
+ setupRootConfiguration("/tmp/local-eval-ask-failure", async () =>
emptyConfiguration(),
);
- withLocalCli({ set_up_local_eval: () => new Promise(() => {}) });
+ withLocalCli({
+ set_up_local_eval: localStatus("ready", "valid"),
+ connect_local_eval: localConnection,
+ run_local_eval: localStatus("ready", "valid"),
+ stop_stack: null,
+ ask_the_bot: () => {
+ throw { said: "The model could not answer.", detail: "401" };
+ },
+ });
const view = await renderApp();
- await reachLocal(view);
- await userEvent.click(view.getByRole("button", { name: "Continue" }));
- await waitFor(() => expect(localSequence()).toEqual(["set_up_local_eval"]));
- await userEvent.click(view.getByRole("button", { name: "Back" }));
+ await userEvent.click(view.getByRole("button", { name: "Install" }));
+ await chooseOpenAi(view);
+ await view.findByText("The model could not answer.");
+ expect(localSequence()).not.toContain("show_openbot");
+ expect(view.queryByRole("button", { name: "Open OpenBot" })).toBeNull();
+ await userEvent.click(view.getByRole("button", { name: "Change the model" }));
await view.findByRole("heading", { name: "Connect your AI" });
- // Leaving stops the CLI call that was running.
- expect(invokeCalls.some((call) => call.command === "cancel_local_eval")).toBe(
- true,
- );
- await userEvent.click(view.getByRole("button", { name: "Continue" }));
- await view.findByRole("heading", {
- name: "Starting OpenBot on this computer",
- });
- expect(
- view.queryByRole("radio", { name: new RegExp(LOCAL_CHOICE) }),
- ).toBeNull();
+ expect(invokeCalls.some((call) => call.command === "stop_stack")).toBe(true);
+ // The rows start over: what ran before was for the OpenBot that stopped.
+ await chooseOpenAi(view);
await waitFor(() =>
- expect(localSequence()).toEqual(["set_up_local_eval", "set_up_local_eval"]),
+ expect(
+ invokeCalls.filter((call) => call.command === "start_stack"),
+ ).toHaveLength(2),
);
});
-test("the hosted screen offers the choice again without running anything", async () => {
- setupRootConfiguration("/tmp/local-eval-hosted-back", async () =>
+test("a blocked setup stops at its row in plain words and Retry resumes there", async () => {
+ setupRootConfiguration("/tmp/local-eval-blocked", async () =>
emptyConfiguration(),
);
- withLocalCli({});
+ let setups = 0;
+ withLocalCli({
+ set_up_local_eval: () =>
+ ++setups === 1
+ ? {
+ ...localStatus("unknown", "unknown"),
+ outcome: "blocked",
+ stack: null,
+ reason: "docker-unavailable",
+ }
+ : localStatus("starting", "valid"),
+ connect_local_eval: localConnection,
+ run_local_eval: localStatus("ready", "valid"),
+ ask_the_bot: "I am working.",
+ });
const view = await renderApp();
- await reachLocal(view);
- await userEvent.click(choice(view, HOSTED_CHOICE));
- // The sign-in is not started for this test.
- const previous = invokeHandler;
- invokeHandler = async (command, args) =>
- command === "begin_intelligence_sign_in"
- ? Promise.reject({ said: "Sign-in did not open." })
- : previous(command, args);
- await userEvent.click(view.getByRole("button", { name: "Continue" }));
- await userEvent.click(
- await view.findByRole("button", { name: "Choose where Intelligence runs" }),
+ await userEvent.click(view.getByRole("button", { name: "Install" }));
+ await view.findByRole("heading", { name: "Connect your AI" });
+ await waitFor(() =>
+ expect(view.getByRole("status").textContent).toBe(
+ "Intelligence cannot start until the software it runs on is running.",
+ ),
);
+ await chooseOpenAi(view);
+ await view.findByRole("heading", { name: "Getting ready" });
+ // Continue does not retry a blocked step: Retry does.
+ expect(setups).toBe(1);
+ const row = view.getByRole("listitem", { name: "Setting up Intelligence" });
+ expect(row.textContent).toContain(
+ "Intelligence cannot start until the software it runs on is running.",
+ );
+ expectPlain(view);
+ expect(invokeCalls.some((call) => call.command === "start_stack")).toBe(
+ false,
+ );
+ await userEvent.click(view.getByRole("button", { name: "Retry" }));
+ await view.findByText("I am working.");
+ expect(setups).toBe(2);
+});
+
+test("an installation from before the first-screen choice still asks at step 4", async () => {
+ setupRootConfiguration("/tmp/local-eval-choice", async () => ({
+ ...savedOpenAiConfiguration(),
+ saved: {
+ ...savedOpenAiConfiguration().saved,
+ intelligenceApiKey: false,
+ model: "open-ai-api-key",
+ },
+ installation: { harness: { id: "mastra" } },
+ }));
+ withLocalCli({
+ set_up_local_eval: localStatus("ready", "valid"),
+ connect_local_eval: localConnection,
+ run_local_eval: localStatus("ready", "valid"),
+ ask_the_bot: "I am working.",
+ });
+ const view = await renderApp();
await view.findByRole("heading", { name: "Choose where Intelligence runs" });
+ expect(choice(view, LOCAL_CHOICE)).toHaveProperty("checked", true);
+ expect(choice(view, HOSTED_CHOICE)).toHaveProperty("checked", false);
+ expect(
+ view.getByText("For evaluation. The first setup takes about 10 minutes."),
+ ).toBeTruthy();
+ await new Promise((resolve) => setTimeout(resolve, 20));
expect(localCliCalls()).toEqual([]);
+ await userEvent.click(view.getByRole("button", { name: "Continue" }));
+ await view.findByText("I am working.");
+ expect(localSequence()).toEqual([
+ "set_up_local_eval",
+ "connect_local_eval",
+ "run_local_eval",
+ "start_stack",
+ ]);
});
-test("a hosted connection chosen earlier preselects hosted", async () => {
+test("a hosted connection chosen earlier preselects hosted at step 4", async () => {
for (const [name, configuration] of [
[
"url",
{
...emptyConfiguration(),
+ saved: {
+ ...savedOpenAiConfiguration().saved,
+ model: "open-ai-api-key",
+ },
values: { INTELLIGENCE_API_URL: "https://intelligence.example" },
+ installation: { harness: { id: "mastra" } },
},
],
[
"key",
{
- ...emptyConfiguration(),
- saved: { ...emptyConfiguration().saved, intelligenceApiKey: true },
+ ...savedOpenAiConfiguration(),
+ saved: {
+ ...savedOpenAiConfiguration().saved,
+ model: "open-ai-api-key",
+ },
+ installation: { harness: { id: "mastra" } },
},
],
] as const) {
@@ -741,7 +969,6 @@ test("a hosted connection chosen earlier preselects hosted", async () => {
);
withLocalCli({});
const view = await renderApp();
- await reachLocal(view);
await view.findByRole("heading", {
name: "Choose where Intelligence runs",
});
@@ -771,51 +998,46 @@ test("a recorded, connected stack on step 4 skips the choice and starts", async
connected: true,
},
}));
- withLocalCli({ run_local_eval: localStatus("ready", "valid") });
+ withLocalCli({
+ run_local_eval: localStatus("ready", "valid"),
+ ask_the_bot: "I am working.",
+ });
const view = await renderApp();
- // Never launched, so this is a first run: it asks its question before handing over.
- await view.findByRole("heading", { name: "Ask it something." });
+ // Never launched, so this is a first run: it tests the model before handing over.
+ await view.findByText("I am working.");
expect(
view.queryByRole("radio", { name: new RegExp(LOCAL_CHOICE) }),
).toBeNull();
expect(localSequence()).toEqual(["run_local_eval", "start_stack"]);
});
-test("a blocked setup stops at its row and Retry resumes there", async () => {
- setupRootConfiguration("/tmp/local-eval-blocked", async () =>
- emptyConfiguration(),
+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"),
);
- 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 status = deferred();
+ const previous = invokeHandler;
+ invokeHandler = async (command, args) =>
+ command === "run_local_eval" ? status.promise : previous(command, args);
const view = await renderApp();
- await reachLocal(view);
- await userEvent.click(view.getByRole("button", { name: "Continue" }));
- const row = await view.findByRole("listitem", {
- name: "Setting up Intelligence on this computer",
- });
- await waitFor(() =>
- expect(row.textContent).toContain(
- "Intelligence cannot start until Docker Desktop is running.",
- ),
- );
- expect(invokeCalls.some((call) => call.command === "start_stack")).toBe(
- false,
- );
- await userEvent.click(view.getByRole("button", { name: "Retry" }));
- await view.findByRole("heading", { name: "Ask it something." });
- expect(setups).toBe(2);
+ await view.findByRole("heading", { name: "Starting OpenBot" });
+ expect(
+ view.queryByRole("heading", { name: "OpenBot is stopped" }),
+ ).toBeNull();
+ expect(buttons(view)).toEqual(["Cancel"]);
+ expectPlain(view);
+ await act(async () => status.resolve(localStatus("ready", "valid")));
+ await view.findByRole("heading", { name: "OpenBot is running" });
+ await userEvent.click(view.getByRole("button", { name: "Stop OpenBot" }));
+ await view.findByRole("heading", { name: "OpenBot is stopped" });
+ expect(
+ view.queryByRole("button", { name: "Change CopilotKit connection" }),
+ ).toBeNull();
+ expect(
+ view.getByRole("button", { name: "Change AI connection" }),
+ ).toBeTruthy();
+ expectPlain(view);
});
function setupConnectedLocalStack(
diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx
index f4054e2dd8..f6bd2b6951 100644
--- a/desktop/src/App.tsx
+++ b/desktop/src/App.tsx
@@ -13,7 +13,8 @@ import {
IntelligenceChoice,
type IntelligenceMode,
LocalIntelligence,
- type LocalStartable,
+ LocalStatus,
+ useLocalSequence,
} from "./LocalIntelligence";
import {
intelligenceStopProblem,
@@ -28,7 +29,11 @@ import {
ProviderPicker,
recordedModel,
} from "./ProviderPicker";
-import { type Progress, SetupProgress } from "./SetupProgress";
+import {
+ type Progress,
+ plainInstallSteps,
+ SetupProgress,
+} from "./SetupProgress";
import {
harnessChoiceEvent,
modelChoiceEvent,
@@ -88,6 +93,10 @@ const MANAGED_INTELLIGENCE_GATEWAY_WS_URL =
*/
const SUGGESTED_QUESTION = "What is 17 times 23?";
+/** What a first local run asks by itself on its test step. */
+const LOCAL_TEST_QUESTION =
+ "Reply with one short sentence to confirm you are working.";
+
export function App() {
const [engine, setEngine] = useState(null);
const [blocker, setBlocker] = useState(null);
@@ -98,6 +107,8 @@ export function App() {
// Offered only when this build has an Intelligence CLI to run; a local stack is its own mode,
// not another setup step.
const [localAvailable, setLocalAvailable] = useState(false);
+ // The CLI answered either way, so the first screen knows which choice it offers.
+ const [localChecked, setLocalChecked] = useState(false);
// Null until somebody presses Continue on the choice, or a recorded local stack implies it.
// Held per root in memory, so returning to step 4 does not ask again.
const [intelligenceChoice, setIntelligenceMode] =
@@ -105,8 +116,6 @@ export function App() {
// The choice screen's selection, before Continue commits it.
const [pickedIntelligence, setPickedIntelligence] =
useState(null);
- // Continue chose local in this window: that click is the go-ahead, wherever step 4 is reached.
- const [localContinued, setLocalContinued] = useState(false);
const [localStack, setLocalStack] = useState(null);
const [localConnected, setLocalConnected] = useState(false);
// A relaunch found a connected stack it may start, as a hosted launch resumes.
@@ -137,8 +146,10 @@ export function App() {
const intelligenceMode =
intelligenceChoice ??
(localAvailable && !hostedChosen ? "local" : "hosted");
- // Step 4 asks first, and runs nothing until Continue.
+ // Step 4 asks first, and runs nothing until Continue. Only when the first screen did not ask.
const choosingIntelligence = localAvailable && intelligenceChoice === null;
+ // Local mode: three steps, and nothing a person reads about the software underneath.
+ const localFlow = localAvailable && intelligenceChoice === "local";
const [projects, setProjects] = useState<
{ id: string; name: string }[] | null
>(null);
@@ -278,12 +289,13 @@ export function App() {
function chooseIntelligence(mode: IntelligenceMode | null) {
setIntelligenceMode(mode);
- setLocalContinued(mode === "local");
}
function continueWithIntelligence() {
const mode = pickedIntelligence ?? intelligenceMode;
chooseIntelligence(mode);
+ // Continue with local is the go-ahead.
+ if (mode === "local" && modelCanStart()) local.start();
// Hosted begins at the sign-in, unless a connection is already saved to be checked at Start.
if (
mode === "hosted" &&
@@ -359,6 +371,19 @@ export function App() {
null,
);
const displayedFailure = failure ?? recoveryFailure ?? intelligenceNotice;
+ // Held here, above the screens, so changing screens never cancels it.
+ const local = useLocalSequence({
+ root,
+ stack: localStack,
+ connected: localConnected,
+ onStart: (startable) => start(model, startable),
+ onRecorded: (stack, connected) => {
+ setLocalStack(stack);
+ if (connected) setLocalConnected(true);
+ },
+ });
+ // Stop OpenBot was chosen in this window: nothing local starts again on its own.
+ const stoppedByChoice = useRef(false);
const credentialContext = useRef([
root,
model,
@@ -399,7 +424,6 @@ export function App() {
setReuseIntelligence(false);
setIntelligenceMode(null);
setPickedIntelligence(null);
- setLocalContinued(false);
setLocalStack(null);
setLocalConnected(false);
setLocalAutoStart(false);
@@ -471,7 +495,10 @@ export function App() {
.listing.then(() => {
if (active) setLocalAvailable(true);
})
- .catch(() => {});
+ .catch(() => {})
+ .finally(() => {
+ if (active) setLocalChecked(true);
+ });
// Read once and kept apart from `last_failure`, which would hold auto-start.
invoke("last_local_eval_stop_failure")
.then((found) => {
@@ -610,8 +637,10 @@ export function App() {
};
}, [loadConfiguredRoot, recoverFromStart]);
- async function install() {
- if (busy || !root.trim()) return;
+ /** True when the installation completed. */
+ async function install(nextHarness = harness): Promise {
+ if (busy || !root.trim()) return false;
+ const installationKey = installationKeyFor(root, nextHarness);
setBusy(true);
setFailure(null);
setSteps([
@@ -624,23 +653,44 @@ 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);
}
}
+ /*
+ * Local mode installs on the first screen's click, with the default Bot in the default folder:
+ * one installation per computer. Once it completes, Intelligence sets up behind the AI provider
+ * screen rather than after it.
+ */
+ async function installLocally() {
+ const choice = { id: DEFAULT_HARNESS };
+ setHarness(choice);
+ setStep("install");
+ if (await install(choice)) {
+ setSteps([]);
+ setStep("model");
+ local.prepare();
+ }
+ }
+
/** True when OpenBot started. */
async function start(
nextModel = model,
- local: LocalStartable | null = null,
+ local: { apiUrl: string; gatewayWsUrl: string } | null = null,
): Promise {
if (!installationReady) {
setStep("install");
@@ -767,8 +817,10 @@ export function App() {
setBusy(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");
@@ -784,6 +836,7 @@ export function App() {
setFailure(null);
try {
if (await stopStack()) {
+ local.reset();
setRunning(false);
setRecoveryFailure(null);
setStep("model");
@@ -793,6 +846,27 @@ export function App() {
}
}
+ /*
+ * Starts local Intelligence without a click where a hosted setup would: a recorded stack on a
+ * first run that was not interrupted, or a connected one on relaunch with auto-start on. Never
+ * after Stop OpenBot.
+ */
+ const localAuto =
+ !checkingResume &&
+ !running &&
+ !stoppedByChoice.current &&
+ step === "connect" &&
+ intelligenceMode === "local" &&
+ !choosingIntelligence &&
+ modelCanStart() &&
+ (returningToInstallation
+ ? !refreshIntelligence && localAutoStart
+ : !interruptedAtLaunch.current);
+ // biome-ignore lint/correctness/useExhaustiveDependencies: when it turns on.
+ useEffect(() => {
+ if (localAuto) local.start();
+ }, [localAuto]);
+
// Nothing else on this screen can be done until the machine allows it, so nothing else is shown.
if (blockerFailure) {
return (
@@ -815,7 +889,11 @@ export function App() {
);
}
- if (resuming || checkingResume) {
+ if (
+ resuming ||
+ checkingResume ||
+ (!running && step === "welcome" && !localChecked)
+ ) {
return (
{installationReady
? "OpenBot’s local software is ready. Next, connect your AI and CopilotKit accounts."
- : "Install the software OpenBot needs on this computer. This can take a few minutes. You’ll sign in after installation finishes."}
+ : localFlow
+ ? "OpenBot is installing what it needs on this computer. This can take a few minutes."
+ : "Install the software OpenBot needs on this computer. This can take a few minutes. You’ll sign in after installation finishes."}
- setStep("harness")}
- >
- Back
-
+ {!(localFlow && busy) && (
+ setStep(localFlow ? "welcome" : "harness")}
+ >
+ Back
+
+ )}
{installationReady ? (
Continue to sign in
- ) : (
+ ) : localFlow && busy ? null : (
(localFlow ? installLocally() : install())}
>
{busy
? "Installing…"
@@ -999,7 +1111,9 @@ export function App() {
return (
invoke("ask_the_bot", { root, question })
}
@@ -1023,56 +1137,49 @@ export function App() {
chosen={model}
returning={returningToInstallation}
busy={busy}
+ stepLabel={localFlow ? "Step 2 of 3" : undefined}
+ status={localFlow && }
onChoose={(choice) => {
recordSetupEvent(modelChoiceEvent(choice));
setModel(choice);
- if (returningToInstallation) void start(choice);
+ if (localFlow) {
+ // The go-ahead: OpenBot starts once Intelligence is ready, wherever it has got to.
+ setSteps([]);
+ setStep("connect");
+ local.start();
+ } else if (returningToInstallation) void start(choice);
else setStep("connect");
}}
- onBack={() => {
- setSteps([]);
- setStep(returningToInstallation ? "connect" : "install");
- }}
+ // Local first runs have nothing behind this screen to go back to.
+ onBack={
+ localFlow && !returningToInstallation
+ ? undefined
+ : () => {
+ setSteps([]);
+ setStep(returningToInstallation ? "connect" : "install");
+ }
+ }
/>
{displayedFailure && }
);
}
- // Local Intelligence runs its own steps and starts OpenBot only once its CLI reports it ready.
- const localIntelligence = (auto: boolean, onCancel?: () => void) => (
- {
- const started = await start(model, local);
- // Continue's go-ahead is spent once OpenBot starts; reopening waits for Start again.
- if (started) setLocalContinued(false);
- return started;
- }}
- onConnected={(stack) => {
- setLocalStack(stack);
- setLocalConnected(true);
- }}
- onCancel={onCancel}
- />
- );
-
- // On step 4 the local sequence replaces the hosted form, once local is chosen.
- const localSequenceShown =
- !running && !choosingIntelligence && intelligenceMode === "local";
+ const localStarting = intelligenceMode === "local" && local.running;
if (!running && returningToInstallation && !refreshIntelligence) {
return (
-
OpenBot is stopped
+
{localStarting ? "Starting OpenBot" : "OpenBot is stopped"}
- Your installation and saved connections are ready to reopen.
+ {localStarting
+ ? "Starting Intelligence and OpenBot on this computer."
+ : "Your installation and saved connections are ready to reopen."}
- {intelligenceMode === "local" && localIntelligence(localAutoStart)}
-
+ {intelligenceMode === "local" && (
+
+ )}
+ {intelligenceMode !== "local" && }
{displayedFailure && }
{intelligenceMode === "hosted" && (
)}
- setStep("model")}
- >
- Change AI connection
-
- {
- // Changing it asks again where Intelligence runs.
- chooseIntelligence(null);
- setRefreshIntelligence(true);
- }}
- >
- Change CopilotKit connection
-
- {displayedFailure && (
-
- Repair installation
-
+ {/* While a local step runs, its Cancel is the only control. */}
+ {!localStarting && (
+ <>
+ setStep("model")}
+ >
+ Change AI connection
+
+ {intelligenceMode !== "local" && (
+ {
+ // Changing it asks again where Intelligence runs.
+ chooseIntelligence(null);
+ setRefreshIntelligence(true);
+ }}
+ >
+ Change CopilotKit connection
+
+ )}
+ {displayedFailure && (
+ install()}
+ >
+ Repair installation
+
+ )}
+
+ Installation options
+ {
+ setPreparation(null);
+ setSteps([]);
+ setStep("install");
+ }}
+ >
+ Change installation
+
+
+ >
)}
-
- Installation options
- {
- setPreparation(null);
- setSteps([]);
- setStep("install");
- }}
- >
- Change installation
-
-
+
+ );
+ }
+
+ /*
+ * Local Intelligence on step 4: its rows, and OpenBot starts by itself once the CLI reports it
+ * ready and valid. While a CLI call runs, Cancel is the only control; leaving is offered only
+ * once nothing runs.
+ */
+ if (!running && !choosingIntelligence && intelligenceMode === "local") {
+ return (
+
+
);
}
@@ -1155,22 +1330,18 @@ export function App() {
? "OpenBot is running"
: choosingIntelligence && !running
? "Choose where Intelligence runs"
- : intelligenceMode === "local" && !running
- ? "Starting OpenBot on this computer"
- : returningToInstallation
- ? "Refresh your CopilotKit connection"
- : "Connect to CopilotKit"}
+ : 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."
: choosingIntelligence && !running
? "OpenBot keeps your conversations in CopilotKit Intelligence. Nothing is set up until you continue."
- : intelligenceMode === "local" && !running
- ? "Intelligence runs on this computer in Docker Desktop. The Intelligence CLI sets it up and manages it."
- : returningToInstallation
- ? "Update this connection to reopen your existing OpenBot."
- : "Local installation is complete. Connect CopilotKit, then start OpenBot."}
+ : returningToInstallation
+ ? "Update this connection to reopen your existing OpenBot."
+ : "Local installation is complete. Connect CopilotKit, then start OpenBot."}
{!running && choosingIntelligence && (
@@ -1180,16 +1351,6 @@ export function App() {
/>
)}
- {/* Continue on the choice is the go-ahead. A recorded stack implies it, unless a run was
- interrupted; reopening an installation waits for Start. Cancel goes back to the choice. */}
- {localSequenceShown &&
- localIntelligence(
- modelCanStart() &&
- (localContinued ||
- (!returningToInstallation && !interruptedAtLaunch.current)),
- () => chooseIntelligence(null),
- )}
-
{!running && !choosingIntelligence && intelligenceMode === "hosted" && (