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, Failure> { + match (outcome, wire.get("nextAction")) { + (Outcome::Completed, _) | (_, None | Some(Value::Null)) => Ok(None), + (_, Some(next)) => match object(next)?.get("reason") { + Some(Value::String(reason)) => Ok(Some(Reason::from_wire(reason))), + None | Some(Value::Null) => Ok(None), + Some(_) => Err(Failure::InvalidStatus), + }, + } +} + +/// The version-1 envelope every `local` command shares: version, command, and a status that +/// agrees with the exit code. +fn parse_envelope( + command: &str, + exit_code: i32, + stdout: &[u8], +) -> Result<(Outcome, Map), Failure> { + let expected = match exit_code { + 0 => Outcome::Completed, + 1 => Outcome::Failed, + 2 => Outcome::Blocked, + _ => return Err(Failure::UnexpectedExit), + }; + if stdout.len() > MAX_OUTPUT { + return Err(Failure::OutputLimit); + } + let text = std::str::from_utf8(stdout).map_err(|_| Failure::InvalidJson)?; + let wire: Value = serde_json::from_str(text).map_err(|_| Failure::InvalidJson)?; + let Value::Object(wire) = wire else { + return Err(Failure::InvalidStatus); + }; + if wire.get("version").and_then(Value::as_u64) != Some(1) { + return Err(Failure::UnsupportedVersion); + } + if wire.get("command").and_then(Value::as_str) != Some(command) { + return Err(Failure::UnexpectedCommand); + } + let outcome = match wire.get("status").and_then(Value::as_str) { + Some("completed") => Outcome::Completed, + Some("blocked") => Outcome::Blocked, + Some("failed") => Outcome::Failed, + _ => return Err(Failure::InvalidStatus), + }; + if outcome != expected { + return Err(Failure::ContractMismatch); + } + Ok((outcome, wire)) +} + +fn object(value: &Value) -> Result<&Map, Failure> { + value.as_object().ok_or(Failure::InvalidStatus) +} + +/// A release, step, service name or phase: an identifier, never prose. Anything longer or +/// with other characters is refused, so a message or a token cannot ride through in one. +fn text(value: Option<&Value>) -> Result { + match value { + Some(Value::String(text)) + if !text.is_empty() + && text.len() <= 128 + && text.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'+') + }) => + { + Ok(text.clone()) + } + _ => Err(Failure::InvalidStatus), + } +} + +/// More services than any release runs is not an observation. +const MAX_SERVICES: usize = 256; + +fn stack(value: &Value) -> Result { + let saved = object(value)?; + let id = match saved.get("id") { + Some(Value::String(id)) => StackId::parse(id)?, + _ => return Err(Failure::InvalidStack), + }; + let urls = object(saved.get("urls").unwrap_or(&Value::Null))?; + let has_failed_step = match saved.get("failedStep") { + None | Some(Value::Null) => false, + Some(Value::String(step)) => !step.is_empty(), + Some(_) => return Err(Failure::InvalidStatus), + }; + let update_in_progress = match saved.get("update") { + None | Some(Value::Null) => false, + Some(update) => { + object(update)?; + true + } + }; + Ok(Stack { + id: id.0, + installed_release: text(saved.get("installedRelease"))?, + step: text(saved.get("step"))?, + urls: Urls { + api: endpoint(urls.get("api"), Endpoint::Api)?, + dashboard: endpoint(urls.get("dashboard"), Endpoint::Dashboard)?, + gateway: endpoint(urls.get("gateway"), Endpoint::Gateway)?, + }, + has_failed_step, + update_in_progress, + observed: observation(saved.get("observed"))?, + }) +} + +fn observation(value: Option<&Value>) -> Result, Failure> { + let observed = match value { + None | Some(Value::Null) => return Ok(None), + Some(observed) => object(observed)?, + }; + let (Some(Value::Bool(running)), Some(Value::Array(services))) = + (observed.get("running"), observed.get("services")) + else { + return Err(Failure::InvalidStatus); + }; + if services.len() > MAX_SERVICES { + return Err(Failure::InvalidStatus); + } + let services = services + .iter() + .map(|service| { + let service = object(service)?; + let Some(Value::Bool(ready)) = service.get("ready") else { + return Err(Failure::InvalidStatus); + }; + Ok(Service { + name: text(service.get("name"))?, + phase: text(service.get("phase"))?, + ready: *ready, + }) + }) + .collect::>()?; + Ok(Some(Observed { + running: *running, + services, + })) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Endpoint { + Api, + Dashboard, + Gateway, +} + +/** +A loopback endpoint in exactly the form the CLI writes: `scheme://host:port`, optionally `/`. + +Stricter than a URL parser on purpose. No userinfo, path, query or fragment; lowercase only; the +host is `localhost`, a dotted-decimal `127.x.y.z`, `[::1]`, or for the dashboard alone one DNS +label under `.localhost`, which browsers resolve to loopback (RFC 6761). `localhost.evil.test` +and `a.b.localhost` are refused. +*/ +fn endpoint(value: Option<&Value>, kind: Endpoint) -> Result { + let Some(Value::String(url)) = value else { + return Err(Failure::UnsafeEndpoint); + }; + let scheme = if kind == Endpoint::Gateway { + "ws://" + } else { + "http://" + }; + let rest = url.strip_prefix(scheme).ok_or(Failure::UnsafeEndpoint)?; + let rest = rest.strip_suffix('/').unwrap_or(rest); + let (host, port) = rest.rsplit_once(':').ok_or(Failure::UnsafeEndpoint)?; + let port_valid = !port.is_empty() + && port.len() <= 5 + && port.bytes().all(|byte| byte.is_ascii_digit()) + && port + .parse::() + .is_ok_and(|port| (1..=65535).contains(&port)); + let host_valid = host == "localhost" + || host == "[::1]" + || loopback_ipv4(host) + || (kind == Endpoint::Dashboard && dashboard_host(host)); + if port_valid && host_valid { + Ok(url.clone()) + } else { + Err(Failure::UnsafeEndpoint) + } +} + +fn loopback_ipv4(host: &str) -> bool { + let octets: Vec<&str> = host.split('.').collect(); + octets.len() == 4 + && octets[0] == "127" + && octets.iter().all(|octet| { + !octet.is_empty() + && octet.len() <= 3 + && octet.bytes().all(|byte| byte.is_ascii_digit()) + && (octet.len() == 1 || !octet.starts_with('0')) + && octet.parse::().is_ok_and(|value| value <= 255) + }) +} + +/// The CLI's own rule: `^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.localhost$`. +fn dashboard_host(host: &str) -> bool { + let Some(label) = host.strip_suffix(".localhost") else { + return false; + }; + let bytes = label.as_bytes(); + let allowed = |byte: &u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); + !bytes.is_empty() + && bytes.len() <= 63 + && allowed(&bytes[0]) + && allowed(&bytes[bytes.len() - 1]) + && bytes.iter().all(|byte| allowed(byte) || *byte == b'-') +} + +/// A file and the digest it has to have. +#[derive(Clone, Debug)] +pub struct Pin { + pub path: PathBuf, + pub sha256: String, +} + +/** +The CLI, checked against its pins. + +`entry` exists because the CLI is published today as an npm package (`copilotkit`, `bin: +index.js`) rather than a single executable. Whether the release ships a native build or a script +run by a pinned runtime is still to be agreed with the CLI owners, so both shapes are verified the +same way and neither is assumed. A script's own imports are not covered by its pin; that is part of +the same agreement. + +The files are checked when this is built and run by path afterwards, so they belong in a directory +only this app writes to. +*/ +#[derive(Clone, Debug)] +pub struct PinnedCli { + program: PathBuf, + entry: Option, + sha256: String, +} + +impl PinnedCli { + pub fn verify(program: &Pin, entry: Option<&Pin>) -> Result { + Ok(Self { + program: verified(program)?, + entry: entry.map(verified).transpose()?, + sha256: entry.unwrap_or(program).sha256.clone(), + }) + } + + /// Which CLI this is, as a local evaluation record keeps it: the script's digest when a + /// runtime runs one, since the runtime runs many. + pub fn sha256(&self) -> &str { + &self.sha256 + } +} + +/// A regular file, not a link to one, whose bytes hash to the pin. Hashed in chunks: a pinned +/// runtime can be a hundred megabytes. +fn verified(pin: &Pin) -> Result { + let metadata = std::fs::symlink_metadata(&pin.path).map_err(|_| Failure::Unverified)?; + if !metadata.file_type().is_file() { + return Err(Failure::Unverified); + } + let mut file = std::fs::File::open(&pin.path).map_err(|_| Failure::Unverified)?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher).map_err(|_| Failure::Unverified)?; + let digest: String = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + if digest == pin.sha256 { + Ok(pin.path.clone()) + } else { + Err(Failure::Unverified) + } +} + +/// How long a call may take and how much it may say. +/// +/// The per-action timeouts are initial bounds, not measurements: `start` waits for a k3d cluster, +/// which has not been timed on a clean machine yet. +#[derive(Clone, Copy, Debug)] +pub struct Limits { + pub timeout: Duration, + /// Between asking the process tree to stop and forcing it. + pub grace: Duration, + pub output: usize, +} + +impl Limits { + pub fn for_action(action: Action) -> Self { + let timeout = match action { + Action::Status => Duration::from_secs(120), + Action::Start => Duration::from_secs(15 * 60), + Action::Stop => Duration::from_secs(10 * 60), + }; + Self { + timeout, + grace: Duration::from_secs(5), + output: MAX_OUTPUT, + } + } + + /// `local setup` downloads a release and creates the cluster. 88 seconds on a warm machine + /// with a fast connection; a first pull of every image is what this allows for. + pub fn for_setup() -> Self { + Self { + timeout: Duration::from_secs(30 * 60), + ..Self::for_action(Action::Start) + } + } + + /// `stop` while OpenBot quits. Somebody is waiting for the app to close, so the stop gets two + /// minutes rather than ten; a stack that has not stopped by then is reported, not waited on. + pub fn for_quit() -> Self { + Self { + timeout: Duration::from_secs(120), + ..Self::for_action(Action::Stop) + } + } +} + +/// Asks a running call to stop. Cloning shares the request. +#[derive(Clone, Debug, Default)] +pub struct Cancel(Arc); + +impl Cancel { + pub fn cancel(&self) { + self.0.store(true, Ordering::SeqCst); + } + + fn requested(&self) -> bool { + self.0.load(Ordering::SeqCst) + } +} + +/// The CLI this build runs, if it has one. +/// +/// None yet: the CLI's release artifact and its digest are not agreed, so there is nothing to pin. +/// Saying so is the honest answer; finding something on `PATH` would run whatever is there. +/// +/// A debug build also runs a CLI the developer pinned (`developer_cli`), so the flow can be tried +/// against a real release before one is pinned here. A release build never reads those settings. +pub fn installed() -> Result { + #[cfg(debug_assertions)] + if let Some(cli) = developer_cli() { + return cli; + } + Err(Failure::Unavailable) +} + +/// The program a developer pinned: a native CLI, or the runtime that runs `DEVELOPER_ENTRY`. +#[cfg(debug_assertions)] +const DEVELOPER_PROGRAM: &str = "OPENBOT_DEV_LOCAL_EVAL_PROGRAM"; +#[cfg(debug_assertions)] +const DEVELOPER_PROGRAM_SHA256: &str = "OPENBOT_DEV_LOCAL_EVAL_PROGRAM_SHA256"; +/// The script the program runs, for a CLI published as one, like `copilotkit`'s `index.js`. +#[cfg(debug_assertions)] +const DEVELOPER_ENTRY: &str = "OPENBOT_DEV_LOCAL_EVAL_ENTRY"; +#[cfg(debug_assertions)] +const DEVELOPER_ENTRY_SHA256: &str = "OPENBOT_DEV_LOCAL_EVAL_ENTRY_SHA256"; + +/** +Debug builds only: a CLI named by path *and* digest, checked exactly like a built-in pin. + +A path without its digest is refused, not trusted, so the boundary is the same one a release has: +the developer chose the bytes and said so. None when no program is named. +*/ +#[cfg(debug_assertions)] +fn developer_cli() -> Option> { + let pin = |path: std::ffi::OsString, digest: &str| -> Result { + Ok(Pin { + path: PathBuf::from(path), + sha256: std::env::var(digest).map_err(|_| Failure::Unverified)?, + }) + }; + let program = std::env::var_os(DEVELOPER_PROGRAM)?; + Some((|| { + let program = pin(program, DEVELOPER_PROGRAM_SHA256)?; + let entry = std::env::var_os(DEVELOPER_ENTRY) + .map(|entry| pin(entry, DEVELOPER_ENTRY_SHA256)) + .transpose()?; + PinnedCli::verify(&program, entry.as_ref()) + })()) +} + +/// What the CLI inherits. Everything else, `NODE_OPTIONS` and `NODE_PATH` included, is dropped: +/// either could load code the pin never saw. The CLI needs a home for its configuration, `PATH` +/// for Docker and k3d, a temporary directory, the proxy settings of a managed network, and the +/// person's telemetry choice. +const INHERITED: &[&str] = &[ + "PATH", + "HOME", + "USER", + "LOGNAME", + "TMPDIR", + "LANG", + "LC_ALL", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + // The CLI's telemetry opt-outs (`services/consent/gate.ts`). Dropping them would turn + // telemetry back on for somebody who turned it off. + "DO_NOT_TRACK", + "COPILOTKIT_TELEMETRY_DISABLED", + "SystemRoot", + "SystemDrive", + "windir", + "ComSpec", + "PATHEXT", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "APPDATA", + "LOCALAPPDATA", + "ProgramData", + "ProgramFiles", + "ProgramFiles(x86)", + "TEMP", + "TMP", +]; + +/** +Run one CLI call to completion, within its limits. + +stdout goes to a private file, not a pipe. The call is finished when the CLI exits; anything it +left running with that file open cannot hold the call, and a normally exiting CLI keeps whatever it +deliberately detached. A deadline, a cancellation or too much output stops the whole process tree, +not only the CLI: it starts Docker and k3d, and leaving those running after a timeout is how a +retry collides with its own leftovers. +*/ +pub fn run( + cli: &PinnedCli, + action: Action, + stack: &StackId, + limits: Limits, + cancel: &Cancel, +) -> Result { + let (code, bytes) = execute(cli, &arguments(action, stack), None, limits, cancel)?; + normalize(action, code, &bytes) +} + +/// `local setup` in `installation`, the folder the stack is created for. +pub fn setup( + cli: &PinnedCli, + installation: &std::path::Path, + limits: Limits, + cancel: &Cancel, +) -> Result { + let (code, bytes) = execute(cli, &SETUP_ARGUMENTS, Some(installation), limits, cancel)?; + normalize_setup(code, &bytes) +} + +/// `local list`, within the same limits as `status`. +pub fn list(cli: &PinnedCli, limits: Limits, cancel: &Cancel) -> Result { + let (code, bytes) = execute(cli, &LIST_ARGUMENTS, None, limits, cancel)?; + normalize_list(code, &bytes) +} + +/// The exit code and stdout of one bounded CLI process. Arguments come only from `arguments`, +/// `LIST_ARGUMENTS` and `SETUP_ARGUMENTS`; only setup runs somewhere other than inherited. +fn execute( + cli: &PinnedCli, + arguments: &[&str], + folder: Option<&std::path::Path>, + limits: Limits, + cancel: &Cancel, +) -> Result<(i32, Vec), Failure> { + let output = Output::create()?; + let mut command = crate::quiet::command(&cli.program); + if let Some(entry) = &cli.entry { + command.arg(entry); + } + command.env_clear(); + for name in INHERITED { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + if let Some(folder) = folder { + command.current_dir(folder); + } + command + .args(arguments) + .stdin(Stdio::null()) + .stdout(output.file.try_clone().map_err(|_| Failure::Spawn)?) + // stderr can carry anything a dependency prints. It is never read, so it is not kept. + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = command.spawn().map_err(|_| Failure::Spawn)?; + // Only the child holds the file for writing now. + drop(command); + + let deadline = Instant::now() + limits.timeout; + loop { + if exited(&mut child)? { + let code = child.wait().map_err(|_| Failure::Spawn)?.code(); + let bytes = output.read(limits.output)?; + // A signal-terminated CLI has no code and no report. + return Ok((code.ok_or(Failure::UnexpectedExit)?, bytes)); + } + if output.len() > limits.output as u64 { + return Err(stop_tree(&mut child, limits.grace, Failure::OutputLimit)); + } + if cancel.requested() { + return Err(stop_tree(&mut child, limits.grace, Failure::Cancelled)); + } + if Instant::now() >= deadline { + return Err(stop_tree(&mut child, limits.grace, Failure::Timeout)); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +/// The owner-only file the CLI writes its report to, removed when the call ends. +struct Output { + path: PathBuf, + file: std::fs::File, +} + +impl Output { + fn create() -> Result { + let path = std::env::temp_dir().join(format!( + "openbot-local-eval-{}-{:016x}.json", + std::process::id(), + rand::random::() + )); + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path).map_err(|_| Failure::Spawn)?; + Ok(Self { path, file }) + } + + fn len(&self) -> u64 { + self.file + .metadata() + .map(|metadata| metadata.len()) + .unwrap_or(0) + } + + fn read(&self, cap: usize) -> Result, Failure> { + // The CLI wrote through a clone of this handle, which shares its offset. + (&self.file) + .seek(std::io::SeekFrom::Start(0)) + .map_err(|_| Failure::InvalidJson)?; + let mut bytes = Vec::new(); + (&self.file) + .take(cap as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| Failure::InvalidJson)?; + if bytes.len() > cap { + return Err(Failure::OutputLimit); + } + Ok(bytes) + } +} + +impl Drop for Output { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/** +Whether the CLI has exited, without reaping it. + +The CLI's process id is also its process group id. While it is unreaped, neither can be given to +another process, so `stop_tree` can only ever signal the group this call created. +*/ +#[cfg(unix)] +fn exited(child: &mut Child) -> Result { + // SAFETY: an all-zero siginfo_t is a valid out-parameter for waitid. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: waits on this call's own child; WNOWAIT leaves it unreaped. + let result = unsafe { + libc::waitid( + libc::P_PID, + child.id() as libc::id_t, + &mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result != 0 { + return Err(Failure::Spawn); + } + // SAFETY: waitid filled `info`; with WNOHANG and no state change the pid stays zero. + Ok(unsafe { info.si_pid() } != 0) +} + +#[cfg(not(unix))] +fn exited(child: &mut Child) -> Result { + child + .try_wait() + .map(|status| status.is_some()) + .map_err(|_| Failure::Spawn) +} + +/** +Stop the CLI and everything it started, then reap it. Returns `failure` for the caller. + +Unix: the CLI leads its own process group, which is asked to stop, given `grace`, then killed. The +CLI is reaped only after the last signal, so the group id cannot have been reused. Windows: +`taskkill /T /F` on the CLI's tree, which reaches what the CLI still parents; a Job Object would +also reach orphans, and is not used yet. +*/ +fn stop_tree(child: &mut Child, grace: Duration, failure: Failure) -> Failure { + #[cfg(unix)] + { + let group = child.id() as libc::pid_t; + // SAFETY: signalling the unreaped group this call created; no memory is shared. + unsafe { libc::killpg(group, libc::SIGTERM) }; + let until = Instant::now() + grace; + while Instant::now() < until && !matches!(exited(child), Ok(true)) { + std::thread::sleep(Duration::from_millis(20)); + } + // SAFETY: as above. The leader is still unreaped, so the id is still this group's. + unsafe { libc::killpg(group, libc::SIGKILL) }; + } + #[cfg(windows)] + { + let _ = grace; + let _ = crate::quiet::command("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + let _ = child.kill(); + let _ = child.wait(); + failure +} + +/// Calls in progress by attempt id, so the window can stop the one it means. +/// +/// A cancel can arrive before its call has registered: the window sends both, and nothing orders +/// them. So a cancel for an unknown attempt is kept, and the call finds itself already cancelled. +static IN_FLIGHT: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// At most this many attempts are remembered, so cancels for calls that never come cannot grow +/// the list without bound. +const REMEMBERED: usize = 64; + +fn in_flight() -> std::sync::MutexGuard<'static, Vec<(String, Cancel)>> { + IN_FLIGHT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// How many Stops are shutting the stack down right now. +static STOPPING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Set once OpenBot is quitting, and never cleared: nothing the window asks for after that should +/// start. +static QUITTING: AtomicBool = AtomicBool::new(false); + +fn closed() -> bool { + QUITTING.load(Ordering::SeqCst) || STOPPING.load(Ordering::SeqCst) > 0 +} + +/// A Stop in progress. Calls are refused until it is dropped. +#[must_use = "calls are allowed again as soon as this is dropped"] +pub struct Closing(()); + +impl Drop for Closing { + fn drop(&mut self) { + STOPPING.fetch_sub(1, Ordering::SeqCst); + } +} + +/** +Refuse new calls and cancel the running ones, until the returned guard is dropped. + +Cancelling once is not enough. A status or start the window sends while Stop is taking OpenBot's +containers down would otherwise take its turn ahead of the shutdown's own stop, or start after it +and leave the stack running. So every call registered while closing is cancelled before it begins, +except the shutdown's own (`stop_for_shutdown`). +*/ +pub fn closing_for_stop() -> Closing { + STOPPING.fetch_add(1, Ordering::SeqCst); + cancel_all(); + Closing(()) +} + +/// As `closing_for_stop`, for good. Quitting also cancels a Stop's own stop, so Quit never waits +/// behind it; Quit runs its own. +pub fn close_for_quit() { + QUITTING.store(true, Ordering::SeqCst); + cancel_all(); +} + +/// An attempt's place in `IN_FLIGHT`, removed however the call ends. +struct Attempt(Cancel); + +impl Attempt { + /// Read under the same lock `cancel_all` takes, so a call registering as closing begins is + /// either refused here or cancelled there. + fn register(id: &str) -> Self { + Self::enter(id, true) + } + + fn register_for_shutdown(id: &str) -> Self { + Self::enter(id, false) + } + + fn enter(id: &str, refused_while_closing: bool) -> Self { + let mut held = in_flight(); + let cancel = match held.iter().position(|(attempt, _)| attempt == id) { + Some(index) => held.remove(index).1, + None => Cancel::default(), + }; + if refused_while_closing && closed() { + cancel.cancel(); + } + held.push((id.to_owned(), cancel.clone())); + Self(cancel) + } +} + +impl Drop for Attempt { + fn drop(&mut self) { + in_flight().retain(|(_, cancel)| !Arc::ptr_eq(&cancel.0, &self.0 .0)); + } +} + +/// Held by the one call that is running. The CLI refuses a second `local` command while one is in +/// progress (`local_command_busy`), so a start racing a stop would fail one of them for nothing. +static CALLS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/** +Run `call` once every earlier call has finished, with what is left of `limits.timeout`. + +The wait counts against the deadline, because the person waiting does not care which half of the +time went where. A cancel is honoured while waiting, and checked again once the turn comes, so a +call cancelled in the queue never starts a process. +*/ +fn one_at_a_time( + cancel: &Cancel, + limits: Limits, + call: impl FnOnce(Limits) -> Result, +) -> Result { + let deadline = Instant::now() + limits.timeout; + let _turn = loop { + if cancel.requested() { + return Err(Failure::Cancelled); + } + match CALLS.try_lock() { + Ok(turn) => break turn, + // A call that panicked held nothing the next one depends on. + Err(std::sync::TryLockError::Poisoned(poisoned)) => break poisoned.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => {} + } + if Instant::now() >= deadline { + return Err(Failure::Timeout); + } + std::thread::sleep(Duration::from_millis(20)); + }; + if cancel.requested() { + return Err(Failure::Cancelled); + } + let timeout = deadline.saturating_duration_since(Instant::now()); + if timeout.is_zero() { + return Err(Failure::Timeout); + } + call(Limits { timeout, ..limits }) +} + +/// Run against the CLI this build has installed, with the default limits for `action`. +pub fn run_installed(attempt: &str, action: Action, stack: &StackId) -> Result { + run_installed_with(attempt, action, stack, Limits::for_action(action)) +} + +/// Run against the installed CLI within `limits`, after any call already running has finished. +pub fn run_installed_with( + attempt: &str, + action: Action, + stack: &StackId, + limits: Limits, +) -> Result { + run_attempt(Attempt::register(attempt), action, stack, limits) +} + +/// `stop` for Stop or Quit itself: the one call let through while closing. A later Quit still +/// cancels it. +pub fn stop_for_shutdown( + attempt: &str, + stack: &StackId, + limits: Limits, +) -> Result { + run_attempt( + Attempt::register_for_shutdown(attempt), + Action::Stop, + stack, + limits, + ) +} + +fn run_attempt( + attempt: Attempt, + action: Action, + stack: &StackId, + limits: Limits, +) -> Result { + one_at_a_time(&attempt.0, limits, |limits| { + let cli = installed()?; + run(&cli, action, stack, limits, &attempt.0) + }) +} + +/// `local setup` in `installation` against the installed CLI, in its turn like every other call. +/// The digest of the CLI that ran comes back with the report, so the caller records which one. +pub fn setup_installed( + attempt: &str, + installation: &std::path::Path, +) -> Result<(Report, String), Failure> { + let attempt = Attempt::register(attempt); + one_at_a_time(&attempt.0, Limits::for_setup(), |limits| { + let cli = installed()?; + let report = setup(&cli, installation, limits, &attempt.0)?; + Ok((report, cli.sha256().to_owned())) + }) +} + +/// `local list` against the installed CLI. +pub fn list_installed(attempt: &str) -> Result { + let attempt = Attempt::register(attempt); + one_at_a_time(&attempt.0, Limits::for_action(Action::Status), |limits| { + let cli = installed()?; + list(&cli, limits, &attempt.0) + }) +} + +/// Stop one call, whether or not it has started yet. +pub fn cancel(attempt: &str) { + let mut held = in_flight(); + match held.iter().find(|(id, _)| id == attempt) { + Some((_, cancel)) => cancel.cancel(), + None => { + if held.len() >= REMEMBERED { + held.remove(0); + } + let cancel = Cancel::default(); + cancel.cancel(); + held.push((attempt.to_owned(), cancel)); + } + } +} + +/// Stop every call in progress, before Stop or Quit asks the CLI to stop. Each stops its own +/// process tree, and a call still waiting for its turn never starts. +pub fn cancel_all() { + for (_, cancel) in in_flight().iter() { + cancel.cancel(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{compile_fixture, temp_root}; + use serde_json::json; + use std::path::Path; + use std::sync::OnceLock; + + const SECRET: &str = "synthetic-secret-marker"; + + // Mirrors local.ts and local-manager.ts at Intelligence 3935afbf, with synthetic identifiers. + fn envelope() -> Value { + json!({ + "version": 1, + "command": "local status", + "status": "completed", + "stack": { + "id": "local-abc123", + "installedRelease": "0.15.0", + "step": "ready", + "urls": { + "api": "http://localhost:8082", + "dashboard": "http://intelligence.localhost:8080", + "gateway": "ws://[::1]:4401" + }, + "failedStep": null, + "update": null, + "observed": { + "running": true, + "services": [{ "name": "cpki-app-api-test", "phase": "Running", "ready": true }] + } + }, + "auth": { "running": true }, + "nextAction": null, + "error": null, + "diagnostics": [] + }) + } + + // As 4.29.0 printed it, with the agreement version shortened. + fn setup_blocked(reason: &str) -> String { + json!({ + "version": 1, + "command": "local setup", + "status": "blocked", + "error": null, + "nextAction": { + "reason": reason, + "summary": "Ask the developer to run `copilotkit local accept-terms`.", + "instructions": ["Ask the developer to run `copilotkit local accept-terms`."], + "resumeCommand": null, + "url": null + }, + "diagnostics": [] + }) + .to_string() + } + + fn status(input: &Value) -> Result { + normalize(Action::Status, 0, input.to_string().as_bytes()) + } + + fn exposes_secret(report: &Report) -> bool { + serde_json::to_string(report).unwrap().contains(SECRET) + } + + #[test] + fn a_completed_command_with_ready_pods_is_not_licensed_readiness() { + let report = status(&envelope()).unwrap(); + assert_eq!(report.outcome, Outcome::Completed); + assert_eq!(report.readiness, Canonical::Unknown); + assert_eq!(report.licence, Canonical::Unknown); + let stack = report.stack.unwrap(); + assert_eq!(stack.id, "local-abc123"); + assert!(stack.observed.unwrap().services[0].ready); + } + + #[test] + fn stopped_unready_and_interrupted_stacks_are_kept_as_observed() { + for running in [false, true] { + let mut input = envelope(); + input["stack"]["observed"] = json!({ + "running": running, + "services": [{ "name": "cpki-app-api-test", "phase": "Running", "ready": false }] + }); + input["stack"]["failedStep"] = json!("services-start"); + input["stack"]["update"] = json!({ "state": "migrated", "recoveryCommand": SECRET }); + let report = status(&input).unwrap(); + let stack = report.stack.clone().unwrap(); + assert!(stack.has_failed_step); + assert!(stack.update_in_progress); + let observed = stack.observed.unwrap(); + assert_eq!(observed.running, running); + assert!(!observed.services[0].ready); + assert_eq!(report.readiness, Canonical::Unknown); + assert!(!exposes_secret(&report)); + } + } + + #[test] + fn empty_or_missing_observations_never_imply_readiness() { + let mut input = envelope(); + input["stack"]["observed"] = json!({ "running": true, "services": [] }); + assert_eq!(status(&input).unwrap().readiness, Canonical::Unknown); + input["stack"].as_object_mut().unwrap().remove("observed"); + assert_eq!(status(&input).unwrap().stack.unwrap().observed, None); + } + + #[test] + fn blocked_and_failed_keep_only_the_reason() { + for (code, outcome, reason, expected) in [ + ( + 2, + "blocked", + "authentication_required", + Reason::AuthenticationRequired, + ), + ( + 1, + "failed", + "local_command_failed", + Reason::LocalCommandFailed, + ), + ] { + let input = json!({ + "version": 1, + "command": "local status", + "status": outcome, + "error": { "message": SECRET }, + "nextAction": { + "reason": reason, + "summary": SECRET, + "instructions": [SECRET], + "resumeCommand": format!("echo {SECRET}") + } + }); + let report = normalize(Action::Status, code, input.to_string().as_bytes()).unwrap(); + assert_eq!(report.reason, Some(expected)); + assert_eq!(report.stack, None); + assert!(!exposes_secret(&report)); + } + } + + #[test] + fn unknown_fields_and_unsupported_licence_claims_are_discarded() { + let mut input = envelope(); + input["credentials"] = json!(SECRET); + input["stack"]["apiKey"] = json!(SECRET); + input["stack"]["licence"] = json!({ "valid": true }); + input["stack"]["readiness"] = json!("ready"); + input["readiness"] = json!("ready"); + let report = status(&input).unwrap(); + assert_eq!(report.licence, Canonical::Unknown); + assert_eq!(report.readiness, Canonical::Unknown); + assert!(!exposes_secret(&report)); + } + + #[test] + fn unsupported_versions_wrong_commands_and_missing_stacks_are_refused() { + let mut input = envelope(); + input["version"] = json!(2); + assert_eq!(status(&input), Err(Failure::UnsupportedVersion)); + let mut input = envelope(); + input["command"] = json!("local delete"); + assert_eq!(status(&input), Err(Failure::UnexpectedCommand)); + assert_eq!( + normalize(Action::Start, 0, envelope().to_string().as_bytes()), + Err(Failure::UnexpectedCommand) + ); + let mut input = envelope(); + input.as_object_mut().unwrap().remove("stack"); + assert_eq!(status(&input), Err(Failure::MissingStack)); + } + + #[test] + fn malformed_and_oversized_output_fail_without_its_contents() { + assert_eq!( + normalize(Action::Status, 0, SECRET.as_bytes()), + Err(Failure::InvalidJson) + ); + assert_eq!( + normalize(Action::Status, 0, &[0xff, 0xfe]), + Err(Failure::InvalidJson) + ); + assert_eq!( + normalize(Action::Status, 0, " ".repeat(MAX_OUTPUT + 1).as_bytes()), + Err(Failure::OutputLimit) + ); + let mut input = envelope(); + input["status"] = json!("ready"); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + let mut input = envelope(); + input["stack"] = json!({ "id": "local-test" }); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + // Failures are static categories, so nothing from the input can be in one. + assert_eq!( + serde_json::to_string(&Failure::InvalidJson).unwrap(), + "\"invalid-json\"" + ); + } + + #[test] + fn unsafe_api_endpoints_are_refused() { + for url in [ + "http://example.com:8082", + "http://localhost.evil.test:8082", + "http://user:synthetic-secret-marker@localhost:8082", + "http://localhost:8082?key=synthetic-secret-marker", + "http://localhost:8082/#secret", + "http://localhost:8082/api", + "file:///tmp/sock", + "https://localhost:8082", + "http://intelligence.localhost:8082", + "http://LOCALHOST:8082", + "http://localhost", + "http://localhost:0", + "http://localhost:65536", + "http://localhost:+80", + "http://127.1:8082", + "http://127.0.0.01:8082", + "http://127.0.0.256:8082", + "http://[::2]:8082", + ] { + let mut input = envelope(); + input["stack"]["urls"]["api"] = json!(url); + assert_eq!(status(&input), Err(Failure::UnsafeEndpoint), "{url}"); + } + } + + #[test] + fn explicit_loopback_hosts_and_the_cli_dashboard_host_are_accepted() { + for host in ["localhost", "127.0.0.1", "127.255.0.9", "[::1]"] { + let mut input = envelope(); + input["stack"]["urls"]["api"] = json!(format!("http://{host}:8082")); + input["stack"]["urls"]["gateway"] = json!(format!("ws://{host}:4401/")); + let urls = status(&input).unwrap().stack.unwrap().urls; + assert_eq!(urls.api, format!("http://{host}:8082")); + } + let urls = status(&envelope()).unwrap().stack.unwrap().urls; + assert_eq!(urls.dashboard, "http://intelligence.localhost:8080"); + + let mut input = envelope(); + input["stack"]["urls"]["gateway"] = json!("http://localhost:4401"); + assert_eq!(status(&input), Err(Failure::UnsafeEndpoint)); + for dashboard in [ + "http://a.b.localhost:8080", + "http://-a.localhost:8080", + "http://.localhost:8080", + ] { + let mut input = envelope(); + input["stack"]["urls"]["dashboard"] = json!(dashboard); + assert_eq!(status(&input), Err(Failure::UnsafeEndpoint), "{dashboard}"); + } + } + + #[test] + fn malformed_observations_are_refused_rather_than_partly_believed() { + let mut input = envelope(); + input["stack"]["observed"] = json!({ "running": "true", "services": [] }); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + input["stack"]["observed"] = json!({ + "running": true, + "services": [{ "name": "pod", "phase": "Running", "ready": "true" }] + }); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + } + + #[test] + fn arguments_are_fixed() { + let stack = StackId::parse("local-abc123").unwrap(); + for (action, name) in [ + (Action::Status, "status"), + (Action::Start, "start"), + (Action::Stop, "stop"), + ] { + assert_eq!( + arguments(action, &stack), + ["local", name, "--stack", "local-abc123", "--json"] + ); + } + // An action outside the allowlist cannot arrive from the window either. + assert!(serde_json::from_value::(json!("delete")).is_err()); + } + + #[test] + fn setup_defers_the_model_and_never_accepts_the_agreement() { + assert_eq!( + SETUP_ARGUMENTS, + ["local", "setup", "--defer-model", "--json"] + ); + assert!(!SETUP_ARGUMENTS + .iter() + .any(|argument| argument.contains("eula") || argument.contains("accept"))); + } + + #[test] + fn setup_runs_in_the_installation_folder() { + let fixture = Fixture::new( + "local-eval-setup", + &format!("2\n{}", setup_blocked("eula_acceptance_required")), + ); + let installation = fixture.root.join("installation"); + std::fs::create_dir_all(&installation).unwrap(); + let report = setup( + &fixture.cli(), + &installation, + Limits::for_setup(), + &Cancel::default(), + ) + .unwrap(); + assert_eq!(report.outcome, Outcome::Blocked); + assert_eq!(report.reason, Some(Reason::EulaAcceptanceRequired)); + assert_eq!( + fixture.argv().unwrap(), + "local\nsetup\n--defer-model\n--json" + ); + assert_eq!( + std::fs::read_to_string(fixture.root.join("cwd")).unwrap(), + installation.canonicalize().unwrap().display().to_string() + ); + } + + #[test] + fn a_pinned_cli_is_known_by_the_digest_of_what_it_runs() { + let fixture = Fixture::new("local-eval-cli-digest", "0\n"); + assert_eq!(fixture.cli().sha256(), sha256(&fixture.program)); + let entry = fixture.root.join("index.js"); + std::fs::write(&entry, "// pinned entry").unwrap(); + let entry_pin = Pin { + path: entry.clone(), + sha256: sha256(&entry), + }; + let cli = PinnedCli::verify(&fixture.pin(), Some(&entry_pin)).unwrap(); + // A runtime runs many scripts; the script is what identifies the CLI. + assert_eq!(cli.sha256(), sha256(&entry)); + } + + #[test] + fn setup_waits_its_turn_and_is_refused_once_cancelled() { + let installation = temp_root("local-eval-setup-installed"); + std::fs::create_dir_all(&installation).unwrap(); + assert_eq!( + setup_installed("setup-no-cli", &installation).map(|(report, _)| report), + Err(Failure::Unavailable) + ); + cancel("setup-cancelled"); + assert_eq!( + setup_installed("setup-cancelled", &installation).map(|(report, _)| report), + Err(Failure::Cancelled) + ); + std::fs::remove_dir_all(installation).unwrap(); + } + + #[test] + fn a_setup_report_is_held_to_the_same_rules() { + let blocked = + normalize_setup(2, setup_blocked("eula_acceptance_required").as_bytes()).unwrap(); + assert_eq!(blocked.reason, Some(Reason::EulaAcceptanceRequired)); + assert!(!serde_json::to_string(&blocked) + .unwrap() + .contains("accept-terms")); + // Another command's report is not a setup report. + assert_eq!( + normalize_setup(0, envelope().to_string().as_bytes()), + Err(Failure::UnexpectedCommand) + ); + } + + #[test] + fn invalid_stack_ids_are_refused_in_arguments_and_reports() { + let long = "a".repeat(129); + for id in [ + "", + "--json", + "a b", + "a/b", + "a\nb", + "a;id", + "a$(id)", + "_a", + long.as_str(), + ] { + assert_eq!(StackId::parse(id), Err(Failure::InvalidStack), "{id:?}"); + let mut input = envelope(); + input["stack"]["id"] = json!(id); + assert_eq!(status(&input), Err(Failure::InvalidStack), "{id:?}"); + } + assert!(StackId::parse(&"a".repeat(128)).is_ok()); + } + + #[test] + fn start_stop_and_busy_envelopes_are_understood() { + for action in [Action::Start, Action::Stop] { + let mut input = envelope(); + input["command"] = json!(format!("local {}", action.as_str())); + input["stack"].as_object_mut().unwrap().remove("observed"); + let report = normalize(action, 0, input.to_string().as_bytes()).unwrap(); + assert_eq!(report.stack.unwrap().observed, None); + + let busy = json!({ + "version": 1, + "command": format!("local {}", action.as_str()), + "status": "blocked", + "error": null, + "nextAction": { + "reason": "local_command_busy", + "resumeCommand": format!("copilotkit local {} --stack local-abc123 --json", action.as_str()) + }, + "diagnostics": [] + }); + let report = normalize(action, 2, busy.to_string().as_bytes()).unwrap(); + assert_eq!(report.reason, Some(Reason::LocalCommandBusy)); + } + let unknown = json!({ + "version": 1, "command": "local start", "status": "blocked", + "nextAction": { "reason": "something_new" } + }); + let report = normalize(Action::Start, 2, unknown.to_string().as_bytes()).unwrap(); + assert_eq!(report.reason, Some(Reason::Unrecognized)); + } + + #[test] + fn exit_codes_and_statuses_must_agree() { + let completed = envelope().to_string(); + assert_eq!( + normalize(Action::Status, 2, completed.as_bytes()), + Err(Failure::ContractMismatch) + ); + let blocked = json!({ "version": 1, "command": "local status", "status": "blocked" }); + assert_eq!( + normalize(Action::Status, 0, blocked.to_string().as_bytes()), + Err(Failure::ContractMismatch) + ); + for code in [3, 75, 127, -1] { + assert_eq!( + normalize(Action::Status, code, completed.as_bytes()), + Err(Failure::UnexpectedExit), + "{code}" + ); + } + } + + #[test] + fn this_build_has_no_cli_to_run() { + assert!(matches!(installed(), Err(Failure::Unavailable))); + let stack = StackId::parse("local-abc123").unwrap(); + assert_eq!( + run_installed("no-cli-attempt", Action::Status, &stack), + Err(Failure::Unavailable) + ); + // The attempt is gone however the call ended. + assert!(!in_flight().iter().any(|(id, _)| id == "no-cli-attempt")); + } + + #[test] + fn a_debug_build_runs_only_the_cli_a_developer_pinned() { + // Sets process environment, which other tests read. + if crate::test_support::isolated_process( + "local_eval::tests::a_debug_build_runs_only_the_cli_a_developer_pinned", + ) { + return; + } + let fixture = Fixture::new("local-eval-developer-pin", &format!("0\n{}", envelope())); + let stack = StackId::parse("local-abc123").unwrap(); + // A path alone is not a pin, and neither is a digest of something else. + std::env::set_var(DEVELOPER_PROGRAM, &fixture.program); + assert!(matches!(installed(), Err(Failure::Unverified))); + std::env::set_var(DEVELOPER_PROGRAM_SHA256, "0".repeat(64)); + assert!(matches!(installed(), Err(Failure::Unverified))); + assert_eq!(fixture.argv(), None, "an unverified CLI ran"); + + std::env::set_var(DEVELOPER_PROGRAM_SHA256, sha256(&fixture.program)); + let report = run_installed("developer-pin", Action::Status, &stack).unwrap(); + assert_eq!(report.outcome, Outcome::Completed); + assert_eq!( + fixture.argv().unwrap(), + "local\nstatus\n--stack\nlocal-abc123\n--json" + ); + + let entry = fixture.root.join("index.js"); + std::fs::write(&entry, "// pinned entry").unwrap(); + std::env::set_var(DEVELOPER_ENTRY, &entry); + assert!(matches!(installed(), Err(Failure::Unverified))); + std::env::set_var(DEVELOPER_ENTRY_SHA256, sha256(&entry)); + run_installed("developer-pin-entry", Action::Status, &stack).unwrap(); + assert_eq!( + fixture.argv().unwrap(), + format!( + "{}\nlocal\nstatus\n--stack\nlocal-abc123\n--json", + entry.display() + ) + ); + } + + #[test] + fn a_cancel_that_arrives_first_is_kept_for_its_call() { + cancel("early-attempt"); + let attempt = Attempt::register("early-attempt"); + assert!(attempt.0.requested()); + drop(attempt); + assert!(!in_flight().iter().any(|(id, _)| id == "early-attempt")); + } + + #[test] + fn cancelling_one_attempt_leaves_the_others_running() { + let start = Attempt::register("start-attempt"); + let status = Attempt::register("status-attempt"); + cancel("status-attempt"); + assert!(status.0.requested()); + assert!(!start.0.requested()); + } + + /// Take the turn every call through `one_at_a_time` waits for, as a running call would. + fn hold_turn() -> std::sync::MutexGuard<'static, ()> { + CALLS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + #[test] + fn quitting_allows_a_stop_two_minutes() { + let quit = Limits::for_quit(); + assert_eq!(quit.timeout, Duration::from_secs(120)); + assert_eq!(quit.grace, Limits::for_action(Action::Stop).grace); + assert_eq!(quit.output, MAX_OUTPUT); + let stack = StackId::parse("local-abc123").unwrap(); + assert_eq!( + run_installed_with("quit-no-cli", Action::Stop, &stack, quit), + Err(Failure::Unavailable) + ); + } + + #[test] + fn calls_run_one_at_a_time() { + let active = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let most = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls: Vec<_> = (0..4) + .map(|_| { + let (active, most) = (active.clone(), most.clone()); + std::thread::spawn(move || { + one_at_a_time( + &Cancel::default(), + Limits::for_action(Action::Status), + |_| { + let now = active.fetch_add(1, Ordering::SeqCst) + 1; + most.fetch_max(now, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(50)); + active.fetch_sub(1, Ordering::SeqCst); + Ok(()) + }, + ) + }) + }) + .collect(); + for call in calls { + assert_eq!(call.join().unwrap(), Ok(())); + } + assert_eq!(most.load(Ordering::SeqCst), 1); + } + + #[test] + fn a_call_cancelled_while_waiting_for_its_turn_never_runs() { + let turn = hold_turn(); + let cancel = Cancel::default(); + let ran = Arc::new(AtomicBool::new(false)); + let waiting = { + let (cancel, ran) = (cancel.clone(), ran.clone()); + std::thread::spawn(move || { + one_at_a_time(&cancel, Limits::for_action(Action::Stop), |_| { + ran.store(true, Ordering::SeqCst); + Ok(()) + }) + }) + }; + std::thread::sleep(Duration::from_millis(100)); + cancel.cancel(); + // Released only after the cancel, so a call that missed it would run and fail the test + // rather than hang it. + std::thread::sleep(Duration::from_millis(100)); + drop(turn); + assert_eq!(waiting.join().unwrap(), Err(Failure::Cancelled)); + assert!(!ran.load(Ordering::SeqCst)); + } + + #[test] + fn a_call_that_waits_past_its_deadline_never_runs() { + let turn = hold_turn(); + let ran = AtomicBool::new(false); + let result = one_at_a_time( + &Cancel::default(), + short(Duration::from_millis(100)), + |_| { + ran.store(true, Ordering::SeqCst); + Ok(()) + }, + ); + drop(turn); + assert_eq!(result, Err(Failure::Timeout)); + assert!(!ran.load(Ordering::SeqCst)); + } + + #[test] + fn waiting_for_a_turn_counts_against_the_deadline() { + let turn = hold_turn(); + let (asking, asked) = std::sync::mpsc::channel(); + let waiting = std::thread::spawn(move || { + asking.send(()).unwrap(); + one_at_a_time( + &Cancel::default(), + short(Duration::from_secs(10)), + |limits| Ok(limits.timeout), + ) + }); + asked.recv().unwrap(); + std::thread::sleep(Duration::from_millis(200)); + drop(turn); + let left = waiting.join().unwrap().unwrap(); + assert!(left <= Duration::from_millis(9_900), "{left:?}"); + } + + #[test] + fn a_cancelled_attempt_does_not_run_the_cli() { + let fixture = Fixture::new("local-eval-precancelled", &format!("0\n{}", envelope())); + let cancelled = Cancel::default(); + cancelled.cancel(); + let result = one_at_a_time(&cancelled, short(Duration::from_secs(30)), |limits| { + fixture.run(Action::Status, limits, &cancelled) + }); + assert_eq!(result, Err(Failure::Cancelled)); + assert_eq!(fixture.argv(), None, "the fake CLI ran"); + // Through the public entry point, the cancel is seen before looking for a CLI at all. + let stack = StackId::parse("local-abc123").unwrap(); + cancel("precancelled-attempt"); + assert_eq!( + run_installed("precancelled-attempt", Action::Start, &stack), + Err(Failure::Cancelled) + ); + } + + #[test] + fn calls_are_refused_while_a_stop_is_closing_and_allowed_after() { + // Closing is process-wide, and would refuse other tests' calls. + if crate::test_support::isolated_process( + "local_eval::tests::calls_are_refused_while_a_stop_is_closing_and_allowed_after", + ) { + return; + } + let stack = StackId::parse("local-abc123").unwrap(); + let running = Attempt::register("running-before-stop"); + let closing = closing_for_stop(); + assert!(running.0.requested(), "a call already running is cancelled"); + assert_eq!( + run_installed("start-during-stop", Action::Start, &stack), + Err(Failure::Cancelled) + ); + assert_eq!(list_installed("list-during-stop"), Err(Failure::Cancelled)); + // The shutdown's own stop is the one call allowed through; this build then has no CLI. + assert_eq!( + stop_for_shutdown("the-shutdown", &stack, Limits::for_quit()), + Err(Failure::Unavailable) + ); + let second = closing_for_stop(); + drop(closing); + assert_eq!( + run_installed("status-during-second-stop", Action::Status, &stack), + Err(Failure::Cancelled), + "one Stop finishing does not reopen for another still running" + ); + drop(second); + assert_eq!( + run_installed("status-after-stop", Action::Status, &stack), + Err(Failure::Unavailable) + ); + } + + #[test] + fn quitting_closes_for_good_and_cancels_a_stop_in_progress() { + if crate::test_support::isolated_process( + "local_eval::tests::quitting_closes_for_good_and_cancels_a_stop_in_progress", + ) { + return; + } + let stack = StackId::parse("local-abc123").unwrap(); + let stopping = closing_for_stop(); + let stops = Attempt::register_for_shutdown("stop-in-progress"); + close_for_quit(); + assert!( + stops.0.requested(), + "Quit does not wait behind Stop's own stop" + ); + drop(stopping); + drop(closing_for_stop()); + assert_eq!( + run_installed("status-after-quit", Action::Status, &stack), + Err(Failure::Cancelled) + ); + assert_eq!( + stop_for_shutdown("quit-shutdown", &stack, Limits::for_quit()), + Err(Failure::Unavailable) + ); + } + + #[test] + fn remembered_cancels_are_bounded() { + // Evicting the oldest remembered cancel would race with tests that rely on one. + if crate::test_support::isolated_process( + "local_eval::tests::remembered_cancels_are_bounded", + ) { + return; + } + for index in 0..(REMEMBERED * 2) { + cancel(&format!("never-runs-{index}")); + } + assert!(in_flight().len() <= REMEMBERED + 8); + } + + #[test] + fn identifiers_cannot_carry_prose_and_observations_are_bounded() { + for step in ["services start", "a\nb", "ready;", &"a".repeat(129)] { + let mut input = envelope(); + input["stack"]["step"] = json!(step); + assert_eq!(status(&input), Err(Failure::InvalidStatus), "{step:?}"); + } + let mut input = envelope(); + input["stack"]["observed"]["services"][0]["phase"] = json!("Error: token=abc"); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + let service = json!({ "name": "pod", "phase": "Running", "ready": true }); + input = envelope(); + input["stack"]["observed"]["services"] = json!(vec![service; MAX_SERVICES + 1]); + assert_eq!(status(&input), Err(Failure::InvalidStatus)); + input = envelope(); + input["stack"]["installedRelease"] = json!("0.15.0-rc.1+build.7"); + assert!(status(&input).is_ok()); + } + + #[test] + fn every_failure_category_is_known_to_the_window() { + let window = include_str!("../../src/localEvalCli.ts"); + let all = [ + Failure::Unavailable, + Failure::Unverified, + Failure::InvalidStack, + Failure::Spawn, + Failure::Timeout, + Failure::Cancelled, + Failure::OutputLimit, + Failure::InvalidJson, + Failure::UnsupportedVersion, + Failure::UnexpectedCommand, + Failure::InvalidStatus, + Failure::MissingStack, + Failure::UnsafeEndpoint, + Failure::ContractMismatch, + Failure::UnexpectedExit, + ]; + // Adding a variant without listing it above stops compiling here. + for failure in all { + match failure { + Failure::Unavailable + | Failure::Unverified + | Failure::InvalidStack + | Failure::Spawn + | Failure::Timeout + | Failure::Cancelled + | Failure::OutputLimit + | Failure::InvalidJson + | Failure::UnsupportedVersion + | Failure::UnexpectedCommand + | Failure::InvalidStatus + | Failure::MissingStack + | Failure::UnsafeEndpoint + | Failure::ContractMismatch + | Failure::UnexpectedExit => {} + } + let name = serde_json::to_string(&failure).unwrap(); + assert!(window.contains(&name), "localEvalCli.ts is missing {name}"); + } + } + + // A fake CLI. It records its arguments, then does what `scenario` (beside it) says: an exit + // code and a body to print, or `sleep`, `hold` or `flood`. `sleep` and `hold` start a + // grandchild that sleeps, holding stdout, so tree cleanup can be observed. `--warm` only + // exits: macOS checks a newly written executable on its first run, and that delay belongs + // before a test's deadline starts, not inside it. + const FAKE_CLI: &str = r#" +use std::io::Write; +fn main() { + let exe = std::env::current_exe().unwrap(); + let dir = exe.parent().unwrap().to_path_buf(); + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("--warm") => return, + Some("--grandchild") => { + std::thread::sleep(std::time::Duration::from_secs(60)); + return; + } + _ => {} + } + std::fs::write(dir.join("argv"), args.join("\n")).unwrap(); + std::fs::write(dir.join("cwd"), std::env::current_dir().unwrap().display().to_string()).unwrap(); + let inherited = |name: &str| format!("{name}={}", std::env::var(name).is_ok()); + std::fs::write(dir.join("env"), [inherited("NODE_OPTIONS"), inherited("HOME"), inherited("DO_NOT_TRACK")].join("\n")).unwrap(); + let scenario = std::fs::read_to_string(dir.join("scenario")).unwrap(); + let (mode, body) = scenario.split_once('\n').unwrap_or((scenario.as_str(), "")); + match mode { + "sleep" | "hold" => { + let grandchild = std::process::Command::new(&exe).arg("--grandchild").spawn().unwrap(); + std::fs::write(dir.join("grandchild.tmp"), grandchild.id().to_string()).unwrap(); + std::fs::rename(dir.join("grandchild.tmp"), dir.join("grandchild")).unwrap(); + if mode == "hold" { + print!("{body}"); + std::io::stdout().flush().unwrap(); + return; + } + std::thread::sleep(std::time::Duration::from_secs(60)); + } + "flood" => { + let chunk = vec![b' '; 65536]; + let mut out = std::io::stdout(); + loop { + if out.write_all(&chunk).is_err() { + std::process::exit(0); + } + } + } + code => { + eprint!("synthetic-secret-marker"); + print!("{body}"); + std::io::stdout().flush().unwrap(); + std::process::exit(code.parse().unwrap()); + } + } +} +"#; + + fn compiled() -> &'static Path { + static BINARY: OnceLock = OnceLock::new(); + BINARY.get_or_init(|| { + let root = temp_root("local-eval-cli-build"); + std::fs::create_dir_all(&root).unwrap(); + let source = root.join("cli.rs"); + std::fs::write(&source, FAKE_CLI).unwrap(); + let binary = root.join(format!("cli{}", std::env::consts::EXE_SUFFIX)); + compile_fixture(&source, &binary); + binary + }) + } + + fn sha256(path: &Path) -> String { + Sha256::digest(std::fs::read(path).unwrap()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + + struct Fixture { + root: PathBuf, + program: PathBuf, + } + + impl Fixture { + fn new(label: &str, scenario: &str) -> Self { + let root = temp_root(label); + std::fs::create_dir_all(&root).unwrap(); + let program = root.join(format!("cli{}", std::env::consts::EXE_SUFFIX)); + std::fs::copy(compiled(), &program).unwrap(); + std::fs::write(root.join("scenario"), scenario).unwrap(); + let warm = std::process::Command::new(&program).arg("--warm").status(); + assert!(warm.unwrap().success()); + Self { root, program } + } + + fn pin(&self) -> Pin { + Pin { + path: self.program.clone(), + sha256: sha256(&self.program), + } + } + + fn cli(&self) -> PinnedCli { + PinnedCli::verify(&self.pin(), None).unwrap() + } + + fn argv(&self) -> Option { + std::fs::read_to_string(self.root.join("argv")).ok() + } + + fn run(&self, action: Action, limits: Limits, cancel: &Cancel) -> Result { + run( + &self.cli(), + action, + &StackId::parse("local-abc123").unwrap(), + limits, + cancel, + ) + } + + /// The fake has started the grandchild it will hold the call open with. + fn wait_for_grandchild(root: &Path) { + let until = Instant::now() + Duration::from_secs(30); + while !root.join("grandchild").exists() { + assert!( + Instant::now() < until, + "the fake never started its grandchild" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[cfg(unix)] + fn grandchild(&self) -> libc::pid_t { + std::fs::read_to_string(self.root.join("grandchild")) + .expect("the fake started its grandchild before the call ended") + .parse() + .unwrap() + } + + #[cfg(unix)] + fn grandchild_stops(&self) -> bool { + let pid = self.grandchild(); + let until = Instant::now() + Duration::from_secs(5); + while Instant::now() < until { + // SAFETY: signal 0 only checks existence. + if unsafe { libc::kill(pid, 0) } != 0 { + return true; + } + std::thread::sleep(Duration::from_millis(20)); + } + false + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + fn short(timeout: Duration) -> Limits { + Limits { + timeout, + grace: Duration::from_millis(500), + output: MAX_OUTPUT, + } + } + + #[test] + fn runs_the_fixed_arguments_and_keeps_only_the_report() { + let fixture = Fixture::new("local-eval-status", &format!("0\n{}", envelope())); + let report = fixture + .run( + Action::Status, + Limits::for_action(Action::Status), + &Cancel::default(), + ) + .unwrap(); + assert_eq!(report.outcome, Outcome::Completed); + assert_eq!( + fixture.argv().unwrap(), + "local\nstatus\n--stack\nlocal-abc123\n--json" + ); + // The fake writes the marker to stderr; stderr is never read. + assert!(!exposes_secret(&report)); + } + + #[test] + fn a_blocked_call_never_runs_its_recovery_command() { + let fixture = Fixture::new("local-eval-blocked", ""); + let executed = fixture.root.join("executed"); + let body = json!({ + "version": 1, + "command": "local start", + "status": "blocked", + "error": null, + "nextAction": { + "reason": "authentication_required", + "summary": SECRET, + "resumeCommand": format!("touch {}", executed.display()) + } + }); + std::fs::write(fixture.root.join("scenario"), format!("2\n{body}")).unwrap(); + let report = fixture + .run( + Action::Start, + Limits::for_action(Action::Start), + &Cancel::default(), + ) + .unwrap(); + assert_eq!(report.outcome, Outcome::Blocked); + assert_eq!(report.reason, Some(Reason::AuthenticationRequired)); + assert!(!executed.exists()); + assert!(!exposes_secret(&report)); + } + + #[test] + fn exit_codes_local_does_not_use_are_not_believed() { + let fixture = Fixture::new("local-eval-exit", &format!("3\n{}", envelope())); + assert_eq!( + fixture.run( + Action::Status, + Limits::for_action(Action::Status), + &Cancel::default() + ), + Err(Failure::UnexpectedExit) + ); + } + + #[test] + fn a_cli_that_does_not_match_its_pin_is_never_run() { + let fixture = Fixture::new("local-eval-unverified", &format!("0\n{}", envelope())); + let mut pin = fixture.pin(); + pin.sha256 = "0".repeat(64); + assert!(matches!( + PinnedCli::verify(&pin, None), + Err(Failure::Unverified) + )); + let missing = Pin { + path: fixture.root.join("absent"), + sha256: "0".repeat(64), + }; + assert!(matches!( + PinnedCli::verify(&missing, None), + Err(Failure::Unverified) + )); + // An entry script is pinned the same way. + assert!(matches!( + PinnedCli::verify(&fixture.pin(), Some(&missing)), + Err(Failure::Unverified) + )); + assert_eq!(fixture.argv(), None); + } + + #[cfg(unix)] + #[test] + fn a_link_to_the_pinned_file_is_not_the_pinned_file() { + let fixture = Fixture::new("local-eval-link", "0\n"); + let link = fixture.root.join("linked"); + std::os::unix::fs::symlink(&fixture.program, &link).unwrap(); + let pin = Pin { + path: link, + sha256: sha256(&fixture.program), + }; + assert!(matches!( + PinnedCli::verify(&pin, None), + Err(Failure::Unverified) + )); + } + + #[test] + fn a_verified_entry_script_is_the_first_argument() { + let fixture = Fixture::new("local-eval-entry", &format!("0\n{}", envelope())); + let entry = fixture.root.join("index.js"); + std::fs::write(&entry, "// pinned entry").unwrap(); + let entry_pin = Pin { + path: entry.clone(), + sha256: sha256(&entry), + }; + let cli = PinnedCli::verify(&fixture.pin(), Some(&entry_pin)).unwrap(); + run( + &cli, + Action::Status, + &StackId::parse("local-abc123").unwrap(), + Limits::for_action(Action::Status), + &Cancel::default(), + ) + .unwrap(); + assert_eq!( + fixture.argv().unwrap(), + format!( + "{}\nlocal\nstatus\n--stack\nlocal-abc123\n--json", + entry.display() + ) + ); + } + + #[test] + fn a_call_past_its_deadline_stops_its_whole_tree() { + let fixture = Fixture::new("local-eval-timeout", "sleep"); + let started = Instant::now(); + assert_eq!( + fixture.run( + Action::Start, + short(Duration::from_secs(5)), + &Cancel::default() + ), + Err(Failure::Timeout) + ); + assert!(started.elapsed() < Duration::from_secs(15)); + #[cfg(unix)] + assert!( + fixture.grandchild_stops(), + "grandchild outlived the timeout" + ); + } + + #[test] + fn a_cancelled_call_stops_its_whole_tree() { + let fixture = Fixture::new("local-eval-cancel", "sleep"); + let cancel = Cancel::default(); + let later = cancel.clone(); + let root = fixture.root.clone(); + std::thread::spawn(move || { + Fixture::wait_for_grandchild(&root); + later.cancel(); + }); + let started = Instant::now(); + assert_eq!( + fixture.run(Action::Start, short(Duration::from_secs(60)), &cancel), + Err(Failure::Cancelled) + ); + assert!(started.elapsed() < Duration::from_secs(10)); + #[cfg(unix)] + assert!( + fixture.grandchild_stops(), + "grandchild outlived cancellation" + ); + } + + #[test] + fn too_much_output_stops_the_call() { + let fixture = Fixture::new("local-eval-flood", "flood"); + let started = Instant::now(); + assert_eq!( + fixture.run( + Action::Status, + short(Duration::from_secs(60)), + &Cancel::default() + ), + Err(Failure::OutputLimit) + ); + assert!(started.elapsed() < Duration::from_secs(10)); + } + + #[test] + fn a_cli_that_exits_is_finished_even_if_something_keeps_its_output_open() { + let fixture = Fixture::new("local-eval-hold", &format!("hold\n{}", envelope())); + let started = Instant::now(); + let report = fixture + .run( + Action::Status, + short(Duration::from_secs(30)), + &Cancel::default(), + ) + .unwrap(); + assert_eq!(report.outcome, Outcome::Completed); + assert!(started.elapsed() < Duration::from_secs(10)); + #[cfg(unix)] + { + // A CLI that exited normally keeps what it left running; this test cleans it up. + let pid = fixture.grandchild(); + // SAFETY: signal 0 checks existence; SIGKILL stops the fixture's own grandchild. + assert_eq!(unsafe { libc::kill(pid, 0) }, 0, "the holder was stopped"); + unsafe { libc::kill(pid, libc::SIGKILL) }; + } + } + + #[test] + fn the_cli_does_not_inherit_code_loading_settings() { + if crate::test_support::isolated_process( + "local_eval::tests::the_cli_does_not_inherit_code_loading_settings", + ) { + return; + } + std::env::set_var("NODE_OPTIONS", "--require=/tmp/synthetic-secret-marker.js"); + std::env::set_var("DO_NOT_TRACK", "1"); + let fixture = Fixture::new("local-eval-env", &format!("0\n{}", envelope())); + fixture + .run( + Action::Status, + Limits::for_action(Action::Status), + &Cancel::default(), + ) + .unwrap(); + let env = std::fs::read_to_string(fixture.root.join("env")).unwrap(); + assert!(env.contains("NODE_OPTIONS=false"), "{env}"); + assert!(env.contains("DO_NOT_TRACK=true"), "{env}"); + assert_eq!( + env.contains("HOME=true"), + std::env::var_os("HOME").is_some(), + "{env}" + ); + } + + fn listing(stacks: Value) -> Value { + json!({ + "version": 1, + "command": "local list", + "status": "completed", + "stacks": stacks, + "nextAction": null, + "error": null, + "diagnostics": [] + }) + } + + fn listed_stack(id: &str) -> Value { + let mut stack = envelope()["stack"].clone(); + stack["id"] = json!(id); + stack.as_object_mut().unwrap().remove("observed"); + stack["appPaths"] = + json!([{ "path": format!("/Users/someone/{SECRET}"), "missing": false }]); + stack + } + + #[test] + fn a_listing_keeps_each_stack_and_none_of_its_app_folders() { + let input = listing(json!([ + listed_stack("local-one"), + listed_stack("local-two") + ])); + let listed = normalize_list(0, input.to_string().as_bytes()).unwrap(); + let ids: Vec<_> = listed + .stacks + .iter() + .map(|stack| stack.id.as_str()) + .collect(); + assert_eq!(ids, ["local-one", "local-two"]); + assert!(!serde_json::to_string(&listed).unwrap().contains(SECRET)); + let empty = normalize_list(0, listing(json!([])).to_string().as_bytes()).unwrap(); + assert!(empty.stacks.is_empty()); + } + + #[test] + fn a_listing_fails_whole_rather_than_partly() { + let mut bad = listed_stack("local-two"); + bad["urls"]["api"] = json!("http://example.com:8082"); + let input = listing(json!([listed_stack("local-one"), bad])); + assert_eq!( + normalize_list(0, input.to_string().as_bytes()), + Err(Failure::UnsafeEndpoint) + ); + let mut input = listing(json!([])); + input.as_object_mut().unwrap().remove("stacks"); + assert_eq!( + normalize_list(0, input.to_string().as_bytes()), + Err(Failure::InvalidStatus) + ); + let many = vec![listed_stack("local-one"); MAX_STACKS + 1]; + assert_eq!( + normalize_list(0, listing(json!(many)).to_string().as_bytes()), + Err(Failure::InvalidStatus) + ); + assert_eq!( + normalize_list(0, envelope().to_string().as_bytes()), + Err(Failure::UnexpectedCommand) + ); + assert_eq!( + normalize_list(2, listing(json!([])).to_string().as_bytes()), + Err(Failure::ContractMismatch) + ); + let failed = json!({ + "version": 1, "command": "local list", "status": "failed", + "error": { "message": SECRET }, + "nextAction": { "reason": "local_command_failed", "resumeCommand": SECRET } + }); + let listed = normalize_list(1, failed.to_string().as_bytes()).unwrap(); + assert_eq!(listed.reason, Some(Reason::LocalCommandFailed)); + assert!(listed.stacks.is_empty()); + assert!(!serde_json::to_string(&listed).unwrap().contains(SECRET)); + } + + #[test] + fn listing_runs_without_a_stack() { + let body = listing(json!([listed_stack("local-one")])); + let fixture = Fixture::new("local-eval-list", &format!("0\n{body}")); + let listed = list( + &fixture.cli(), + Limits::for_action(Action::Status), + &Cancel::default(), + ) + .unwrap(); + assert_eq!(listed.stacks[0].id, "local-one"); + assert_eq!(fixture.argv().unwrap(), "local\nlist\n--json"); + assert_eq!(list_installed("list-no-cli"), Err(Failure::Unavailable)); + } +} diff --git a/desktop/src-tauri/src/local_eval_state.rs b/desktop/src-tauri/src/local_eval_state.rs new file mode 100644 index 0000000000..749544bf8e --- /dev/null +++ b/desktop/src-tauri/src/local_eval_state.rs @@ -0,0 +1,285 @@ +//! 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 +//! person is signed in; the CLI is asked for that every time. A missing, unreadable or unknown +//! record reads as no record, exactly like the installation's own checkpoints. +//! +//! Phases advance one at a time and never backwards, in the agreed order: installed, app opened, +//! signed in, provider validated. Activation is deliberately not a phase yet. It requires the CLI +//! to report readiness and licence, which version 1 cannot, so it cannot be recorded truthfully. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::local_eval::StackId; +use crate::problem::Problem; + +pub const FILE: &str = ".openbot-local-eval.json"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Phase { + Installed, + AppOpened, + SignedIn, + ProviderValidated, +} + +impl Phase { + fn next(self) -> Option { + match self { + Phase::Installed => Some(Phase::AppOpened), + Phase::AppOpened => Some(Phase::SignedIn), + Phase::SignedIn => Some(Phase::ProviderValidated), + Phase::ProviderValidated => None, + } + } +} + +/// Whether the stack exists only for OpenBot. +/// +/// A stack OpenBot created starts and stops with OpenBot. One it found already recorded may serve +/// other apps too, so OpenBot never stops it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Ownership { + Created, + Shared, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Record { + version: u8, + stack: String, + ownership: Ownership, + cli_sha256: String, + phase: Phase, +} + +/// What the window and the stop path may know. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct State { + pub stack: String, + pub ownership: Ownership, + pub phase: Phase, +} + +impl State { + pub fn stack_id(&self) -> StackId { + // `read` and `begin` only ever hold an id that parsed. + StackId::parse(&self.stack).expect("a recorded stack id was validated") + } + + /// Whether stopping OpenBot also stops this stack. + pub fn tied_to_openbot(&self) -> bool { + self.ownership == Ownership::Created + } +} + +fn digest_valid(digest: &str) -> bool { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn load(root: &Path) -> Option { + let record: Record = serde_json::from_slice(&std::fs::read(root.join(FILE)).ok()?).ok()?; + (record.version == 1 + && StackId::parse(&record.stack).is_ok() + && digest_valid(&record.cli_sha256)) + .then_some(record) +} + +/** +The recorded state, or none. + +`cli_sha256` is the digest of the CLI this build would run, when it has one. A record made with a +different CLI keeps its stack and ownership but goes back to `installed`: what the earlier CLI +reported says nothing about this one. +*/ +pub fn read(root: &Path, cli_sha256: Option<&str>) -> Option { + let record = load(root)?; + let phase = match cli_sha256 { + Some(current) if current != record.cli_sha256 => Phase::Installed, + _ => record.phase, + }; + Some(State { + stack: record.stack, + ownership: record.ownership, + phase, + }) +} + +fn problem(detail: String) -> Problem { + Problem::with( + "OpenBot could not save its local evaluation progress. Check that the installation folder \ + is writable, then try again.", + detail, + ) +} + +fn write(root: &Path, record: &Record) -> Result<(), Problem> { + crate::preparation::write_checkpoint(root, FILE, record).map_err(problem) +} + +/// Record a stack at `installed`, replacing any earlier record. +pub fn begin( + root: &Path, + stack: &StackId, + ownership: Ownership, + cli_sha256: &str, +) -> Result { + if !digest_valid(cli_sha256) { + return Err(Problem::plain("OpenBot refused an invalid CLI digest.")); + } + let record = Record { + version: 1, + stack: stack.as_str().to_owned(), + ownership, + cli_sha256: cli_sha256.to_owned(), + phase: Phase::Installed, + }; + write(root, &record)?; + Ok(read(root, Some(cli_sha256)).expect("the record just written reads back")) +} + +/// Move to `to`, which must be the current phase or the one after it. +pub fn advance(root: &Path, cli_sha256: &str, to: Phase) -> Result { + let current = read(root, Some(cli_sha256)) + .ok_or_else(|| Problem::plain("OpenBot has no local evaluation progress to continue."))?; + if to != current.phase && current.phase.next() != Some(to) { + return Err(Problem::plain( + "OpenBot refused to skip or repeat a local evaluation step.", + )); + } + let mut record = load(root).expect("read found the record"); + record.phase = to; + record.cli_sha256 = cli_sha256.to_owned(); + write(root, &record)?; + Ok(read(root, Some(cli_sha256)).expect("the record just written reads back")) +} + +pub fn clear(root: &Path) -> Result<(), Problem> { + match std::fs::remove_file(root.join(FILE)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(problem(error.to_string())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::temp_root; + + const PIN: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + const OTHER: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + + fn root(label: &str) -> std::path::PathBuf { + let root = temp_root(label); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn stack() -> StackId { + StackId::parse("local-abc123").unwrap() + } + + #[test] + fn missing_malformed_unknown_and_wrong_version_records_read_as_none() { + let root = root("local-eval-state-unknown"); + assert_eq!(read(&root, None), None); + for contents in [ + "not json".to_owned(), + format!(r#"{{"version":2,"stack":"local-abc123","ownership":"created","cliSha256":"{PIN}","phase":"installed"}}"#), + format!(r#"{{"version":1,"stack":"local-abc123","ownership":"created","cliSha256":"{PIN}","phase":"installed","token":"x"}}"#), + format!(r#"{{"version":1,"stack":"--json","ownership":"created","cliSha256":"{PIN}","phase":"installed"}}"#), + format!(r#"{{"version":1,"stack":"local-abc123","ownership":"created","cliSha256":"{PIN}","phase":"activated"}}"#), + r#"{"version":1,"stack":"local-abc123","ownership":"created","cliSha256":"short","phase":"installed"}"#.to_owned(), + ] { + std::fs::write(root.join(FILE), contents.as_bytes()).unwrap(); + assert_eq!(read(&root, None), None, "{contents}"); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn phases_advance_one_at_a_time_and_survive_a_restart() { + let root = root("local-eval-state-advance"); + let state = begin(&root, &stack(), Ownership::Created, PIN).unwrap(); + assert_eq!(state.phase, Phase::Installed); + assert!( + advance(&root, PIN, Phase::SignedIn).is_err(), + "skipped a phase" + ); + for phase in [Phase::AppOpened, Phase::SignedIn, Phase::ProviderValidated] { + assert_eq!(advance(&root, PIN, phase).unwrap().phase, phase); + // Repeating the current phase is harmless; reopening the app does it. + assert_eq!(advance(&root, PIN, phase).unwrap().phase, phase); + } + assert!( + advance(&root, PIN, Phase::AppOpened).is_err(), + "went backwards" + ); + // A restart is a fresh read of the same file. + let state = read(&root, Some(PIN)).unwrap(); + assert_eq!(state.phase, Phase::ProviderValidated); + assert_eq!(state.stack_id(), stack()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn a_different_cli_keeps_the_stack_but_restarts_the_phases() { + let root = root("local-eval-state-pin"); + begin(&root, &stack(), Ownership::Created, PIN).unwrap(); + advance(&root, PIN, Phase::AppOpened).unwrap(); + let state = read(&root, Some(OTHER)).unwrap(); + assert_eq!(state.phase, Phase::Installed); + assert_eq!(state.stack, "local-abc123"); + // Without a CLI to compare against, the record is reported as it was written. + assert_eq!(read(&root, None).unwrap().phase, Phase::AppOpened); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn only_a_stack_openbot_created_is_tied_to_it() { + let root = root("local-eval-state-owner"); + assert!(begin(&root, &stack(), Ownership::Created, PIN) + .unwrap() + .tied_to_openbot()); + assert!(!begin(&root, &stack(), Ownership::Shared, PIN) + .unwrap() + .tied_to_openbot()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn the_record_holds_only_its_own_fields() { + let root = root("local-eval-state-fields"); + begin(&root, &stack(), Ownership::Shared, PIN).unwrap(); + let written: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.join(FILE)).unwrap()).unwrap(); + let mut keys: Vec<_> = written.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + assert_eq!( + keys, + ["cliSha256", "ownership", "phase", "stack", "version"] + ); + assert!(begin( + &root, + &stack(), + Ownership::Shared, + "synthetic-secret-marker" + ) + .is_err()); + clear(&root).unwrap(); + clear(&root).unwrap(); + assert_eq!(read(&root, None), None); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/desktop/src-tauri/src/preparation.rs b/desktop/src-tauri/src/preparation.rs index 0747dae969..1f40b9b44b 100644 --- a/desktop/src-tauri/src/preparation.rs +++ b/desktop/src-tauri/src/preparation.rs @@ -141,17 +141,27 @@ fn fingerprint(root: &Path) -> Result { } fn write_record(root: &Path, name: &str, value: &impl Serialize) -> Result<(), Problem> { + write_checkpoint(root, name, value).map_err(required) +} + +/// Replace a checkpoint file through a temporary file. The error is the underlying cause, for the +/// caller to put beside its own sentence. +pub(crate) fn write_checkpoint( + root: &Path, + name: &str, + value: &impl Serialize, +) -> Result<(), String> { let target = root.join(name); let temporary = root.join(format!("{name}.tmp")); - let bytes = serde_json::to_vec(value).map_err(|e| required(e.to_string()))?; - std::fs::write(&temporary, bytes).map_err(|e| required(e.to_string()))?; + let bytes = serde_json::to_vec(value).map_err(|e| e.to_string())?; + std::fs::write(&temporary, bytes).map_err(|e| e.to_string())?; // Windows rename does not replace an existing file. A missing checkpoint is safe on interruption. match std::fs::remove_file(&target) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(required(e.to_string())), + Err(e) => return Err(e.to_string()), } - std::fs::rename(temporary, target).map_err(|e| required(e.to_string())) + std::fs::rename(temporary, target).map_err(|e| e.to_string()) } pub fn invalidate(root: &Path) -> Result<(), Problem> { diff --git a/desktop/src/localEvalCli.ts b/desktop/src/localEvalCli.ts new file mode 100644 index 0000000000..6854a0bdc3 --- /dev/null +++ b/desktop/src/localEvalCli.ts @@ -0,0 +1,220 @@ +/** + * The window's view of the local evaluation CLI boundary in src-tauri/src/local_eval.rs. + * + * The native layer runs the pinned CLI, normalizes its version-1 JSON and returns only allowlisted + * facts or a static failure category. Raw CLI output never reaches the window. Runtime and licence + * decisions stay in the CLI. + */ +import { invoke } from "@tauri-apps/api/core"; +import type { Problem } from "./Problem"; + +export type LocalAction = "status" | "start" | "stop"; +export type LocalOutcome = "completed" | "blocked" | "failed"; + +/** Static categories from `local_eval::Failure`. None carries CLI output. */ +export const localFailures = [ + "unavailable", + "unverified", + "invalid-stack", + "spawn", + "timeout", + "cancelled", + "output-limit", + "invalid-json", + "unsupported-version", + "unexpected-command", + "invalid-status", + "missing-stack", + "unsafe-endpoint", + "contract-mismatch", + "unexpected-exit", +] as const; +export type LocalFailure = (typeof localFailures)[number]; + +/** The CLI's own blocker reasons, from `local_eval::Reason`. */ +export type LocalReason = + | "authentication-required" + | "eula-acceptance-required" + | "eula-unavailable" + | "eula-declined" + | "release-unavailable" + | "feature-disabled" + | "unsupported-platform" + | "unsupported-engine" + | "port-unavailable" + | "docker-unavailable" + | "update-recovery-required" + | "incompatible-release" + | "local-command-busy" + | "local-command-failed" + | "unrecognized"; + +export type LocalReport = { + outcome: LocalOutcome; + stack: null | { + id: string; + installedRelease: string; + step: string; + urls: { api: string; dashboard: string; gateway: string }; + hasFailedStep: boolean; + updateInProgress: boolean; + observed: null | { + running: boolean; + services: { name: string; phase: string; ready: boolean }[]; + }; + }; + reason: LocalReason | null; + // CLI v1 cannot state either fact. The native layer has no other value to send yet. + readiness: "unknown"; + licence: "unknown"; +}; + +/** The stacks the CLI has recorded. App folders are not included. */ +export type LocalListing = { + outcome: LocalOutcome; + stacks: NonNullable[]; + reason: LocalReason | null; +}; + +export class LocalEvalError extends Error { + constructor(readonly category: LocalFailure | "unrecognized") { + super(category); + this.name = "LocalEvalError"; + } +} + +function failure(rejection: unknown): LocalEvalError { + // Only a known static category is passed on. Anything else is not echoed. + const known = localFailures.find((category) => category === rejection); + return new LocalEvalError(known ?? "unrecognized"); +} + +/** + * Starts one CLI call. `attempt` names it for `cancelLocalEval`, which works even before the + * native side has started the call. + */ +export function runLocalEval( + action: LocalAction, + stack: string, +): { attempt: string; report: Promise } { + const attempt = crypto.randomUUID(); + const report = invoke("run_local_eval", { + attempt, + action, + stack, + }).catch((rejection: unknown) => { + throw failure(rejection); + }); + return { attempt, report }; +} + +/** Lists recorded stacks, so a stack id comes from the CLI. Cancel it like any other call. */ +export function listLocalEval(): { + attempt: string; + listing: Promise; +} { + const attempt = crypto.randomUUID(); + const listing = invoke("list_local_eval", { attempt }).catch( + (rejection: unknown) => { + throw failure(rejection); + }, + ); + return { attempt, listing }; +} + +/** + * `local setup` for this installation. A stack it completes is recorded as OpenBot's, so it + * starts and stops with OpenBot. Blocked is an answer, not an error: the first is usually the + * evaluation terms. A stack that was set up but could not be recorded rejects with a `Problem`. + */ +export function setUpLocalEval(root: string): { + attempt: string; + report: Promise; +} { + const attempt = crypto.randomUUID(); + const report = invoke("set_up_local_eval", { + attempt, + root, + }).catch((rejection: unknown) => { + if (rejection && typeof rejection === "object" && "said" in rejection) { + throw rejection as Problem; + } + throw failure(rejection); + }); + return { attempt, report }; +} + +/** Stops one call; it then rejects with `cancelled`. */ +export function cancelLocalEval(attempt: string): Promise { + return invoke("cancel_local_eval", { attempt }); +} + +export type ChatBlocker = "intelligence" | "openbot" | "provider"; + +export type ChatGateInput = { + /** The latest status call: a report, a failure category, or nothing yet. */ + intelligence: LocalReport | LocalFailure | null; + openBot: "connected" | "not-connected" | "unknown"; + provider: "validated" | "invalid" | "unvalidated"; +}; + +/** + * Chat opens only when all three independent facts are known good. Unknown is blocked, never + * guessed ready. No v1 report can open it, because v1 states neither readiness nor licence. + */ +export function chatGate(state: ChatGateInput): { + open: boolean; + blockers: ChatBlocker[]; +} { + const blockers: ChatBlocker[] = []; + if (!intelligenceReady(state.intelligence)) blockers.push("intelligence"); + if (state.openBot !== "connected") blockers.push("openbot"); + if (state.provider !== "validated") blockers.push("provider"); + return { open: blockers.length === 0, blockers }; +} + +function intelligenceReady(report: ChatGateInput["intelligence"]): boolean { + if (report === null || typeof report === "string") return false; + return ( + report.outcome === "completed" && + canonical(report.readiness, "ready") && + canonical(report.licence, "valid") + ); +} + +// Positive match only: a future value that is not exactly the good one still blocks. +function canonical(value: string, good: string): boolean { + return value === good; +} + +/** How main.rs starts an Intelligence stop failure (`INTELLIGENCE_STOP_FAILED`). */ +export const intelligenceStopFailed = "Intelligence stop failed: "; + +/** + * Splits a Stop failure into OpenBot's own lines and the local Intelligence stack's. OpenBot has + * stopped when only the second is present. Anything not in the native line format is OpenBot's. + */ +export function splitStopFailure(thrown: unknown): { + openBot: string | null; + intelligence: string | null; +} { + if (typeof thrown !== "string") { + return { openBot: String(thrown), intelligence: null }; + } + const lines = thrown.split("\n"); + const own = lines.filter((line) => !line.startsWith(intelligenceStopFailed)); + const intelligence = lines.filter((line) => + line.startsWith(intelligenceStopFailed), + ); + return { + openBot: own.length ? own.join("\n") : null, + intelligence: intelligence.length ? intelligence.join("\n") : null, + }; +} + +export function intelligenceStopProblem(line: string): Problem { + return { + said: "OpenBot stopped, but could not stop the local Intelligence stack it started.", + detail: line, + }; +} From 528565c0c013f2dc545f732e1bed67aa23f7866b Mon Sep 17 00:00:00 2001 From: Dusty Date: Wed, 21 Oct 2026 11:49:15 -0700 Subject: [PATCH 02/77] feat(desktop): stop a local Intelligence stack OpenBot created on Stop and Quit Stop and Quit ask the CLI to stop the local stack after OpenBot's own cleanup and Compose down, only when OpenBot created it. In-flight calls are cancelled and new ones refused while closing; Quit allows the CLI two minutes and no longer waits behind a Stop. Intelligence failures are reported apart from OpenBot's and never mark OpenBot for recovery; a failed stop at Quit is kept for the next launch. Adds the Tauri commands for status/start/stop, list, setup and cancel, and exposes the checkpoint as AlreadyConfigured.localEval. --- desktop/src-tauri/src/main.rs | 1132 +++++++++++++++++++++++++++++++-- 1 file changed, 1089 insertions(+), 43 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3877d8b906..e80f1889f8 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -13,11 +13,15 @@ mod test_support; use openbot_desktop_lib::{ acquire, deployment, deployment_release, engine, env as openbot_env, harness, host_access, - install, preparation, problem::Problem, provider, pull_metrics, quiet, stack, supervise, - telemetry, tray, windows as win, + install, local_eval, local_eval_state, preparation, problem::Problem, provider, pull_metrics, + quiet, stack, supervise, telemetry, tray, windows as win, }; const QUIT_CLEANUP_NOTICE_FILE: &str = ".openbot-quit-cleanup-notice"; +/// Kept apart from the quit cleanup notice, whose presence alone means OpenBot needs recovery. +const LOCAL_EVAL_STOP_NOTICE_FILE: &str = ".openbot-local-eval-stop-notice"; +/// How an Intelligence failure starts, so Stop and Quit can tell it from OpenBot's own. +const INTELLIGENCE_STOP_FAILED: &str = "Intelligence stop failed: "; const QUIT_MENU_ACCELERATOR: &str = "CmdOrCtrl+KeyQ"; const QUIT_CLEANUP_NOTICE_LIMIT: usize = 16 * 1024; use serde::{Deserialize, Serialize}; @@ -247,6 +251,10 @@ struct AlreadyConfigured { #[serde(skip_serializing_if = "Option::is_none")] installation: Option, auto_start: bool, + /// Where the local evaluation has got to, if one was ever begun here. The checkpoint only: + /// whether its stack is running is the CLI's to say. + #[serde(skip_serializing_if = "Option::is_none")] + local_eval: Option, } struct ReadyRespondingEngine { @@ -1655,9 +1663,11 @@ async fn stop_stack( ) -> Result<(), String> { // Inventory, held-child cleanup and Compose all block. Keep the complete shutdown off both // Tauri's event loop and its async workers, and resolve IPC only when shutdown has finished. - tauri::async_runtime::spawn_blocking(move || stop_everything(&app, &stack::root_from(&root))) - .await - .map_err(|error| format!("the shutdown did not run: {error}"))? + tauri::async_runtime::spawn_blocking(move || { + stop_everything(&app, &stack::root_from(&root)).into_result() + }) + .await + .map_err(|error| format!("the shutdown did not run: {error}"))? } #[cfg(test)] @@ -1671,20 +1681,84 @@ fn shutdown_root(shell: &Shell, fallback_root: &Path) -> PathBuf { root } -/// Take the whole stack down: the host processes, anything left over, and the containers. +/// Take the whole stack down: the host processes, anything left over, the containers, and the +/// local Intelligence stack if OpenBot created it. /// /// One implementation, because there are three ways to ask for it (the button, the menu bar, and /// quitting) and a person who used one of them and got a different amount of stopping would be /// right to call that a bug. -fn stop_everything( - app: &tauri::AppHandle, - fallback_root: &Path, -) -> Result<(), String> { - let shell = app.state::(); - let root = root_for_stop(&shell, fallback_root); - stop_everything_with(&shell, &root, stack::stop_processes_under, |root| { - down_owned_containers(&shell, root) - }) +fn stop_everything(app: &tauri::AppHandle, fallback_root: &Path) -> Stopped { + stop_everything_for(&app.state::(), fallback_root) +} + +/// Stop as the button and the menu bar run it. +fn stop_everything_for(shell: &Shell, fallback_root: &Path) -> Stopped { + let root = root_for_stop(shell, fallback_root); + stop_everything_then( + shell, + &root, + stack::stop_processes_under, + |root| down_owned_containers(shell, root), + intelligence_step(Shutdown::Stop), + ) +} + +/// Quit's cleanup as the exit handler runs it. +fn quit_cleanup(shell: &Shell) -> Stopped { + exit_cleanup_then( + shell, + &stack::default_root(), + stack::stop_processes_under, + |root| down_containers_on_quit(shell, root), + intelligence_step(Shutdown::Quit), + ) +} + +/// What a Stop or Quit could not do, kept apart: only OpenBot's own failures mean OpenBot needs +/// recovery. A local Intelligence stack that would not stop is said, but it is not OpenBot's +/// processes or containers left behind, and must not hold OpenBot's next start. +#[derive(Debug, Default, PartialEq, Eq)] +struct Stopped { + openbot: Vec, + /// The whole line, starting with `INTELLIGENCE_STOP_FAILED`. + intelligence: Option, +} + +impl Stopped { + /// Every failure, OpenBot's first. + fn lines(self) -> Vec { + self.openbot.into_iter().chain(self.intelligence).collect() + } + + fn into_result(self) -> Result<(), String> { + let lines = self.lines(); + if lines.is_empty() { + Ok(()) + } else { + Err(lines.join("\n")) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Shutdown { + Stop, + Quit, +} + +/// How long the CLI may take to stop the stack. Quit's is shorter: somebody is waiting for the +/// app to close. +fn intelligence_limits(kind: Shutdown) -> local_eval::Limits { + match kind { + Shutdown::Stop => local_eval::Limits::for_action(local_eval::Action::Stop), + Shutdown::Quit => local_eval::Limits::for_quit(), + } +} + +/// The Intelligence step both production paths run, chosen here so they cannot drift apart. +fn intelligence_step(kind: Shutdown) -> impl FnOnce(&Path) -> Result<(), String> { + let limits = intelligence_limits(kind); + move |root: &Path| stop_local_eval(root, limits) } fn root_for_stop(shell: &Shell, fallback_root: &Path) -> PathBuf { @@ -1693,6 +1767,7 @@ fn root_for_stop(shell: &Shell, fallback_root: &Path) -> PathBuf { root } +#[cfg(test)] fn stop_everything_with( shell: &Shell, fallback_root: &Path, @@ -1703,6 +1778,31 @@ where C: FnOnce(&Path) -> Result, D: FnOnce(&Path) -> Result<(), String>, { + stop_everything_then(shell, fallback_root, cleanup, down, |_| Ok(())).into_result() +} + +/** +Stop, then `intelligence` last, once OpenBot's own processes and containers have had their turn, +and whether or not they stopped. Its failure is kept beside theirs, never instead of them. + +Local evaluation closes first, before any lock: calls in flight are cancelled and new ones refused +until this returns, so neither a running `start` nor one the window sends now can hold the stop up +or outlive it. The startup lock is released before the Intelligence step, which can take minutes, +so a Quit arriving meanwhile is not held behind it. +*/ +fn stop_everything_then( + shell: &Shell, + fallback_root: &Path, + cleanup: C, + down: D, + intelligence: I, +) -> Stopped +where + C: FnOnce(&Path) -> Result, + D: FnOnce(&Path) -> Result<(), String>, + I: FnOnce(&Path) -> Result<(), String>, +{ + let _closing = local_eval::closing_for_stop(); cancel_self_hosted_sign_in(shell); shell .stopped_in_session @@ -1713,7 +1813,7 @@ where shell .generation .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let _startup = shell.startup.lock().unwrap(); + let startup = shell.startup.lock().unwrap(); let root = cleanup_root(shell, fallback_root); let mut failures = Vec::new(); if let Err(problem) = cleanup_host_state(shell, &root, cleanup) { @@ -1724,14 +1824,93 @@ where failures.push(format!("Compose down failed: {problem}")); } + // Recovery is about what OpenBot runs, so only OpenBot's own failures keep it. if failures.is_empty() { clear_recovery_required(shell, &root); - Ok(()) - } else { - Err(failures.join("\n")) + } + drop(startup); + Stopped { + openbot: failures, + intelligence: intelligence(&root) + .err() + .map(|problem| format!("{INTELLIGENCE_STOP_FAILED}{problem}")), + } +} + +/// The production Intelligence step for Stop and Quit, within `limits`. +fn stop_local_eval(root: &Path, limits: local_eval::Limits) -> Result<(), String> { + #[cfg(test)] + INTELLIGENCE_TIMEOUT.with(|used| used.set(Some(limits.timeout))); + let attempt = format!("openbot-shutdown-{:016x}", rand::random::()); + stop_local_eval_with(root, local_eval::cancel_all, |stack| { + local_eval::stop_for_shutdown(&attempt, stack, limits) + }) +} + +#[cfg(test)] +thread_local! { + /// The timeout the last `stop_local_eval` on this thread was given, so a test can tell which + /// limits a production path chose without a CLI to time. + static INTELLIGENCE_TIMEOUT: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +fn last_intelligence_timeout() -> Option { + INTELLIGENCE_TIMEOUT.with(std::cell::Cell::take) +} + +/** +Ask the CLI to stop the local Intelligence stack, if and only if OpenBot created it. + +No record, or a stack that was already there when OpenBot found it, is left exactly as it is: it +may be serving other apps, and stopping OpenBot is not a reason to stop them. Docker and the +stack's containers are never touched here; only the CLI stops what the CLI started. + +Calls already in flight are cancelled first, whoever owns the stack: cancelling ends only this +app's own CLI processes, never the stack, and a `start` left running could otherwise hold the stop +behind it, finish after it and leave the stack running, or outlive the app as an orphan. + +Every way this can fail is said in fixed words. There is no CLI output to repeat, and none would +be repeated if there were. +*/ +fn stop_local_eval_with(root: &Path, cancel_all: A, stop: S) -> Result<(), String> +where + A: FnOnce(), + S: FnOnce(&local_eval::StackId) -> Result, +{ + cancel_all(); + let Some(state) = local_eval_state::read(root, None).filter(|state| state.tied_to_openbot()) + else { + return Ok(()); + }; + let not_stopped = |why: String| { + format!("the Intelligence CLI did not stop the local Intelligence stack ({why})") + }; + match stop(&state.stack_id()) { + Ok(report) => match report.outcome { + local_eval::Outcome::Completed => Ok(()), + outcome => Err(not_stopped(match report.reason { + Some(reason) => format!("{}: {}", wire_name(outcome), wire_name(reason)), + None => wire_name(outcome), + })), + }, + Err(local_eval::Failure::Unavailable) => Err( + "this build has no Intelligence CLI to stop the local Intelligence stack OpenBot created" + .into(), + ), + Err(failure) => Err(not_stopped(wire_name(failure))), } } +/// A category's name as the window knows it, `local-command-busy` rather than `LocalCommandBusy`. +fn wire_name(category: impl Serialize) -> String { + serde_json::to_value(category) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_default() +} + /// Called under the startup lock by both Stop and Quit. Host cleanup can clear its own root /// first; Compose must still use the deployment captured before up, retaining it on any error. fn down_containers_with(shell: &Shell, fallback_root: &Path, down: D) -> Result<(), String> @@ -2164,7 +2343,57 @@ fn quit_cleanup_failure_summary(failure: QuitCleanupFailure) -> &'static str { } } +fn is_intelligence_quit_failure(line: &str) -> bool { + line.strip_prefix("[exit] cleanup failed: ") + .is_some_and(|failure| failure.starts_with(INTELLIGENCE_STOP_FAILED)) +} + +/// OpenBot's own failures go to the notice that means recovery; an Intelligence one goes to its +/// own, which only says so. fn write_quit_cleanup_notice(root: &Path, lines: &[String]) -> Result<(), String> { + let (intelligence, openbot): (Vec<&String>, Vec<&String>) = lines + .iter() + .partition(|line| is_intelligence_quit_failure(line)); + write_openbot_quit_cleanup_notice(root, &openbot)?; + if intelligence.is_empty() { + return Ok(()); + } + let path = root.join(LOCAL_EVAL_STOP_NOTICE_FILE); + std::fs::create_dir_all(root) + .and_then(|()| std::fs::write(&path, br#"{"version":1}"#)) + .map_err(|error| { + format!( + "{}: could not write the Intelligence shutdown notice: {error}", + path.display() + ) + }) +} + +/** +That the last Quit could not stop the local Intelligence stack, said once. + +Apart from `last_failure` on purpose. The window treats that as an interrupted run and holds its +automatic start, and OpenBot's own processes are not what was left running. Fixed words: the +notice records only that it happened. +*/ +fn take_local_eval_stop_notice(root: &Path) -> Option { + std::fs::remove_file(root.join(LOCAL_EVAL_STOP_NOTICE_FILE)).ok()?; + Some(Problem::with( + "When OpenBot last quit, it could not stop the local Intelligence stack it started.", + "It may still be running. Stopping OpenBot again asks it to stop.", + )) +} + +/// What `take_local_eval_stop_notice` says, for the window. +#[tauri::command] +fn last_local_eval_stop_failure( + app: tauri::AppHandle, +) -> Option { + let shell = app.state::(); + take_local_eval_stop_notice(&cleanup_root(&shell, &stack::default_root())) +} + +fn write_openbot_quit_cleanup_notice(root: &Path, lines: &[&String]) -> Result<(), String> { if lines.is_empty() { return Ok(()); } @@ -2266,18 +2495,44 @@ fn recovery_required_or_pending_quit_notice(shell: &Shell, root: &Path) -> bool true } +#[cfg(test)] fn exit_cleanup_with(shell: &Shell, fallback_root: &Path, cleanup: C, down: D) -> Vec where C: FnOnce(&Path) -> Result, D: FnOnce(&Path) -> Result<(), String>, { + exit_cleanup_then(shell, fallback_root, cleanup, down, |_| Ok(())).lines() +} + +/** +Quit's cleanup, then `intelligence` last, as in `stop_everything_then`. + +Local evaluation closes for good here, which also cancels a Stop's own CLI stop, so Quit never +waits behind one. The order is host processes, Compose down, on Linux the Podman API this session +started, and only then Intelligence, with no lock held. So Quit's Intelligence cap starts after all +of that: 120 seconds for the CLI, its turn included, plus up to five seconds of grace to stop the +CLI's process tree if it overruns. +*/ +fn exit_cleanup_then( + shell: &Shell, + fallback_root: &Path, + cleanup: C, + down: D, + intelligence: I, +) -> Stopped +where + C: FnOnce(&Path) -> Result, + D: FnOnce(&Path) -> Result<(), String>, + I: FnOnce(&Path) -> Result<(), String>, +{ + local_eval::close_for_quit(); shell .start_generation .fetch_add(1, std::sync::atomic::Ordering::SeqCst); shell .generation .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let _startup = shell.startup.lock().unwrap(); + let startup = shell.startup.lock().unwrap(); let root = cleanup_root(shell, fallback_root); let mut failures = Vec::new(); if let Err(problem) = cleanup_host_state(shell, &root, cleanup) { @@ -2296,7 +2551,13 @@ where if failures.is_empty() { clear_recovery_required(shell, &root); } - failures + drop(startup); + Stopped { + openbot: failures, + intelligence: intelligence(&root) + .err() + .map(|problem| format!("{INTELLIGENCE_STOP_FAILED}{problem}")), + } } fn report_exit_cleanup_failures(failures: Vec, sink: F) -> Result<(), String> @@ -2880,6 +3141,7 @@ fn already_configured_for_root(root: String) -> AlreadyConfigured { launch: preparation::launch(&root), installation: preparation::installation(&root), auto_start: true, + local_eval: local_eval_state::read(&root, None), saved: SavedConfiguration { intelligence_api_key: hint( Category::Intelligence, @@ -3334,6 +3596,97 @@ fn cancel_model_oauth(attempt_id: String) { openbot_desktop_lib::provider_oauth::cancel(&attempt_id); } +/// One Intelligence CLI call for the local evaluation. Returns only the normalized report or a +/// static failure category; the CLI's own output never reaches the window. `attempt` names the +/// call so `cancel_local_eval` can stop it, including before it has started. +#[tauri::command] +async fn run_local_eval( + attempt: String, + action: local_eval::Action, + stack: String, +) -> Result { + let stack = local_eval::StackId::parse(&stack)?; + tauri::async_runtime::spawn_blocking(move || { + local_eval::run_installed(&attempt, action, &stack) + }) + .await + .map_err(|_| local_eval::Failure::Spawn)? +} + +/// The stacks the CLI has recorded, so a stack id comes from the CLI rather than being typed. +/// Why a local evaluation setup produced nothing to show: the CLI's own static category, or a +/// stack that was set up but could not be recorded. The second is not a CLI category, so it is +/// not one: the window tells them apart by shape. +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum SetupError { + Failure(local_eval::Failure), + Unrecorded(Problem), +} + +/** +`local setup` for this installation, and the stack it made recorded as OpenBot's. + +Blocked is a normal answer here, not an error: the first one is the evaluation agreement, which the +person accepts in their own terminal before the window asks again. Only a completed setup is +recorded, and only then does Stop or Quit stop that stack. +*/ +#[tauri::command] +async fn set_up_local_eval( + attempt: String, + root: String, +) -> Result { + let root = stack::root_from(&root); + tauri::async_runtime::spawn_blocking(move || { + set_up_local_eval_at(&root, |folder| { + local_eval::setup_installed(&attempt, folder) + }) + }) + .await + .map_err(|_| SetupError::Failure(local_eval::Failure::Spawn))? +} + +/** +Run `setup` in `root` and record what it made. + +A stack set up here was made for OpenBot, so it starts and stops with it. The one exception is a +record that already calls the same stack shared: setting up again resumes that stack, it does not +make OpenBot its owner. +*/ +fn set_up_local_eval_at(root: &Path, setup: S) -> Result +where + S: FnOnce(&Path) -> Result<(local_eval::Report, String), local_eval::Failure>, +{ + let (report, cli_sha256) = setup(root).map_err(SetupError::Failure)?; + let Some(stack) = report + .stack + .as_ref() + .filter(|_| report.outcome == local_eval::Outcome::Completed) + else { + return Ok(report); + }; + let stack = local_eval::StackId::parse(&stack.id).map_err(SetupError::Failure)?; + let ownership = match local_eval_state::read(root, None) { + Some(state) if state.stack_id() == stack => state.ownership, + _ => local_eval_state::Ownership::Created, + }; + local_eval_state::begin(root, &stack, ownership, &cli_sha256) + .map_err(SetupError::Unrecorded)?; + Ok(report) +} + +#[tauri::command] +async fn list_local_eval(attempt: String) -> Result { + tauri::async_runtime::spawn_blocking(move || local_eval::list_installed(&attempt)) + .await + .map_err(|_| local_eval::Failure::Spawn)? +} + +#[tauri::command] +fn cancel_local_eval(attempt: String) { + local_eval::cancel(&attempt); +} + /// `bun` from PATH, or the places an installer puts it when PATH has not been reloaded. fn which_bun() -> Option { if quiet::command("bun") @@ -3690,27 +4043,19 @@ fn stop_from_menu(app: tauri::AppHandle) { let root = default_root(); let root_path = PathBuf::from(&root); eprintln!("[menu] stopping the stack under {root}"); - match stop_everything(&app, &root_path) { - Ok(()) => { + let stopped = stop_everything(&app, &root_path); + match settle_menu_stop(&app.state::(), &root_path, stopped) { + None => { eprintln!("[menu] stopped"); report(&app, "stopped", true, "OpenBot has been stopped"); } // Said rather than swallowed. A menu item that fails silently is worse than one // that is not there: the person believes the stack is down and it is not. - Err(detail) => { - eprintln!("[menu] stop failed: {detail}"); - let problem = Problem::with( - "OpenBot could not finish stopping. Try Stop OpenBot again.", - detail, + Some(problem) => { + eprintln!( + "[menu] stop failed: {}", + problem.detail.as_deref().unwrap_or_default() ); - let shell = app.state::(); - { - let _startup = shell.startup.lock().unwrap(); - let root = cleanup_root(&shell, &root_path); - let generation = shell.generation.load(std::sync::atomic::Ordering::SeqCst); - mark_recovery_required(&shell, &root, generation); - *shell.last_failure.lock().unwrap() = Some(problem.clone()); - } report(&app, "stopped", false, problem.said); } } @@ -3718,6 +4063,31 @@ fn stop_from_menu(app: tauri::AppHandle) { }); } +/// Keep a menu Stop's failure for the setup screen, and mark recovery only when OpenBot's own +/// processes or containers may still be running. None when everything stopped. +fn settle_menu_stop(shell: &Shell, fallback_root: &Path, stopped: Stopped) -> Option { + let openbot_failed = !stopped.openbot.is_empty(); + let problem = if openbot_failed { + Problem::with( + "OpenBot could not finish stopping. Try Stop OpenBot again.", + stopped.lines().join("\n"), + ) + } else { + Problem::with( + "OpenBot stopped, but could not stop the local Intelligence stack it started.", + stopped.intelligence?, + ) + }; + let _startup = shell.startup.lock().unwrap(); + if openbot_failed { + let root = cleanup_root(shell, fallback_root); + let generation = shell.generation.load(std::sync::atomic::Ordering::SeqCst); + mark_recovery_required(shell, &root, generation); + } + *shell.last_failure.lock().unwrap() = Some(problem.clone()); + Some(problem) +} + /// What each of the three items does, wherever it was chosen from. /// /// The tray and the window menu carry the same items, so they share one function: two copies would @@ -3789,6 +4159,11 @@ fn main() { begin_organization_sign_in, finish_organization_sign_in, cancel_organization_sign_in, + run_local_eval, + list_local_eval, + cancel_local_eval, + set_up_local_eval, + last_local_eval_stop_failure, ask_the_bot, ]) // A packaged application is not a browser tab. Left alone, WebView2 answers a right-click @@ -3919,13 +4294,7 @@ fn main() { || api.prevent_exit(), move || { desktop_telemetry::shutdown(&cleaning_app); - let shell = cleaning_app.state::(); - exit_cleanup_with( - &shell, - &stack::default_root(), - stack::stop_processes_under, - |root| down_containers_on_quit(&shell, root), - ) + quit_cleanup(&cleaning_app.state::()).lines() }, QuitDiagnostics { sink: move |failures: Vec| { @@ -6140,6 +6509,683 @@ mod tests { let _ = std::fs::remove_dir_all(fallback); } + const LOCAL_EVAL_PIN: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + fn local_eval_root(label: &str, ownership: Option) -> PathBuf { + let root = temp_root(label); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("docker-compose.yml"), "services: {}\n").unwrap(); + if let Some(ownership) = ownership { + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + local_eval_state::begin(&root, &stack, ownership, LOCAL_EVAL_PIN).unwrap(); + } + root + } + + fn local_eval_report( + outcome: local_eval::Outcome, + reason: Option, + ) -> local_eval::Report { + local_eval::Report { + outcome, + stack: None, + reason, + readiness: local_eval::Canonical::Unknown, + licence: local_eval::Canonical::Unknown, + } + } + + #[test] + fn stop_asks_intelligence_last_even_after_compose_down_failed() { + let active = local_eval_root("openbot-stop-intelligence-order", None); + let fallback = temp_root("openbot-default-stop-intelligence-order"); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + let phases = std::cell::RefCell::new(Vec::new()); + + let stopped = stop_everything_then( + &shell, + &fallback, + |root| { + phases + .borrow_mut() + .push(format!("cleanup:{}", root.display())); + Ok(0) + }, + |root| { + phases.borrow_mut().push(format!("down:{}", root.display())); + Err("compose refused".to_string()) + }, + |root| { + phases + .borrow_mut() + .push(format!("intelligence:{}", root.display())); + Err("the CLI timed out".to_string()) + }, + ); + + assert_eq!( + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()), + format!("intelligence:{}", active.display()), + ] + ); + assert_eq!( + stopped, + Stopped { + openbot: vec!["Compose down failed: compose refused".into()], + intelligence: Some("Intelligence stop failed: the CLI timed out".into()), + } + ); + assert_eq!( + stopped.into_result(), + Err("Compose down failed: compose refused\n\ + Intelligence stop failed: the CLI timed out" + .into()) + ); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); + } + + #[test] + fn quit_asks_intelligence_last_even_after_compose_down_failed() { + let active = local_eval_root("openbot-exit-intelligence-order", None); + let fallback = temp_root("openbot-default-exit-intelligence-order"); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + let phases = std::cell::RefCell::new(Vec::new()); + + let failures = exit_cleanup_then( + &shell, + &fallback, + |root| { + phases + .borrow_mut() + .push(format!("cleanup:{}", root.display())); + Ok(0) + }, + |root| { + phases.borrow_mut().push(format!("down:{}", root.display())); + Err("compose down refused".to_string()) + }, + |root| { + phases + .borrow_mut() + .push(format!("intelligence:{}", root.display())); + Err("the CLI timed out".to_string()) + }, + ); + + assert_eq!( + phases.into_inner(), + vec![ + format!("cleanup:{}", active.display()), + format!("down:{}", active.display()), + format!("intelligence:{}", active.display()), + ] + ); + assert_eq!( + failures, + Stopped { + openbot: vec!["Compose down failed: compose down refused".into()], + intelligence: Some("Intelligence stop failed: the CLI timed out".into()), + } + ); + let _ = std::fs::remove_dir_all(active); + let _ = std::fs::remove_dir_all(fallback); + } + + #[test] + fn an_intelligence_failure_is_reported_but_does_not_keep_openbot_in_recovery() { + let active = local_eval_root("openbot-stop-intelligence-recovery", None); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(active.clone()); + mark_recovery_required(&shell, &active, 0); + + let stopped = stop_everything_then( + &shell, + &active, + |_| Ok(0), + |_| Ok(()), + |_| Err("the CLI timed out".to_string()), + ); + + assert!(stopped.openbot.is_empty(), "{stopped:?}"); + assert_eq!( + stopped.into_result(), + Err("Intelligence stop failed: the CLI timed out".into()), + "an Intelligence failure is not a silent success" + ); + assert!(!recovery_required(&shell, &active)); + + *shell.root.lock().unwrap() = Some(active.clone()); + mark_recovery_required( + &shell, + &active, + shell.generation.load(std::sync::atomic::Ordering::SeqCst), + ); + let failures = exit_cleanup_then( + &shell, + &active, + |_| Ok(0), + |_| Ok(()), + |_| Err("the CLI timed out".to_string()), + ); + assert_eq!( + failures.lines(), + ["Intelligence stop failed: the CLI timed out"] + ); + assert!(!recovery_required(&shell, &active)); + let _ = std::fs::remove_dir_all(active); + } + + #[test] + fn a_stack_openbot_did_not_create_is_never_stopped() { + for ownership in [None, Some(local_eval_state::Ownership::Shared)] { + let root = local_eval_root("openbot-local-eval-untied", ownership); + let phases = std::cell::RefCell::new(Vec::new()); + + let result = stop_local_eval_with( + &root, + || phases.borrow_mut().push("cancel-all".to_string()), + |stack| { + phases.borrow_mut().push(format!("stop:{}", stack.as_str())); + Ok(local_eval_report(local_eval::Outcome::Completed, None)) + }, + ); + + assert_eq!(result, Ok(()), "{ownership:?}"); + // Its own calls are cancelled; the stack is not stopped. + assert_eq!(phases.into_inner(), ["cancel-all"], "{ownership:?}"); + // The production adapter too: had it tried, this build's missing CLI would have failed. + assert_eq!( + stop_local_eval(&root, local_eval::Limits::for_quit()), + Ok(()), + "{ownership:?}" + ); + let _ = std::fs::remove_dir_all(root); + } + } + + #[test] + fn a_stack_openbot_created_is_stopped_after_cancelling_calls_in_flight() { + let root = local_eval_root( + "openbot-local-eval-created", + Some(local_eval_state::Ownership::Created), + ); + let phases = std::cell::RefCell::new(Vec::new()); + + let result = stop_local_eval_with( + &root, + || phases.borrow_mut().push("cancel-all".to_string()), + |stack| { + phases.borrow_mut().push(format!("stop:{}", stack.as_str())); + Ok(local_eval_report(local_eval::Outcome::Completed, None)) + }, + ); + + assert_eq!(result, Ok(())); + assert_eq!(phases.into_inner(), ["cancel-all", "stop:local-abc123"]); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn every_way_a_stop_can_fail_is_reported_in_fixed_words() { + let root = local_eval_root( + "openbot-local-eval-stop-failures", + Some(local_eval_state::Ownership::Created), + ); + let failed = |result: Result| { + stop_local_eval_with(&root, || {}, move |_| result) + .expect_err("a stop that did not complete is reported") + }; + + assert_eq!( + failed(Err(local_eval::Failure::Unavailable)), + "this build has no Intelligence CLI to stop the local Intelligence stack OpenBot created" + ); + assert_eq!( + failed(Err(local_eval::Failure::Timeout)), + "the Intelligence CLI did not stop the local Intelligence stack (timeout)" + ); + assert_eq!( + failed(Ok(local_eval_report( + local_eval::Outcome::Blocked, + Some(local_eval::Reason::LocalCommandBusy) + ))), + "the Intelligence CLI did not stop the local Intelligence stack (blocked: local-command-busy)" + ); + assert_eq!( + failed(Ok(local_eval_report(local_eval::Outcome::Failed, None))), + "the Intelligence CLI did not stop the local Intelligence stack (failed)" + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn quit_reports_a_created_stack_this_build_cannot_stop() { + let root = local_eval_root( + "openbot-local-eval-quit-no-cli", + Some(local_eval_state::Ownership::Created), + ); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(root.clone()); + + let failures = exit_cleanup_then( + &shell, + &root, + |_| Ok(0), + |_| Err("compose down refused".to_string()), + |root| stop_local_eval(root, local_eval::Limits::for_quit()), + ); + + assert_eq!( + failures.lines(), + [ + "Compose down failed: compose down refused", + "Intelligence stop failed: this build has no Intelligence CLI to stop the local \ + Intelligence stack OpenBot created" + ] + ); + let _ = std::fs::remove_dir_all(root); + } + + const NO_CLI_LINE: &str = "Intelligence stop failed: this build has no Intelligence CLI to \ + stop the local Intelligence stack OpenBot created"; + + #[test] + fn an_intelligence_failure_on_quit_is_shown_next_launch_without_recovery() { + let root = local_eval_root("openbot-quit-intelligence-notice", None); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(root.clone()); + let failures = exit_cleanup_then( + &shell, + &root, + |_| Ok(0), + |_| Ok(()), + |_| Err("the CLI timed out".to_string()), + ); + // The quit sink, exactly as production wires it. + report_exit_cleanup_failures(failures.lines(), |lines| { + write_quit_cleanup_notice(&root, &lines) + }) + .unwrap(); + + // Next launch: nothing about OpenBot's own processes, and nothing holding its start. + let next = Shell::default(); + assert!(!quit_cleanup_notice_path(&root).exists()); + assert!(!recovery_required_or_pending_quit_notice(&next, &root)); + assert!(!recovery_required(&next, &root)); + assert_eq!(read_quit_cleanup_notice(&root).unwrap(), None); + let problem = + take_local_eval_stop_notice(&root).expect("the Intelligence failure is still shown"); + assert!(problem.said.contains("Intelligence"), "{problem:?}"); + assert!(!problem.said.contains("app processes"), "{problem:?}"); + assert_eq!(take_local_eval_stop_notice(&root), None, "shown once"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn a_quit_notice_keeps_openbot_and_intelligence_failures_apart() { + let root = local_eval_root("openbot-quit-mixed-notice", None); + write_quit_cleanup_notice( + &root, + &[ + "[exit] cleanup failed: Compose down failed: refused".to_string(), + format!("[exit] cleanup failed: {NO_CLI_LINE}"), + ], + ) + .unwrap(); + + let next = Shell::default(); + assert!(recovery_required_or_pending_quit_notice(&next, &root)); + let openbot = read_quit_cleanup_notice(&root).unwrap().unwrap(); + assert_eq!( + openbot.detail.as_deref(), + Some("OpenBot could not confirm all containers stopped.") + ); + assert!(take_local_eval_stop_notice(&root).is_some()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn menu_stop_marks_recovery_only_for_openbot_failures() { + let root = local_eval_root("openbot-menu-stop-intelligence", None); + let shell = Shell::default(); + + let intelligence_only = Stopped { + openbot: Vec::new(), + intelligence: Some(NO_CLI_LINE.into()), + }; + let problem = settle_menu_stop(&shell, &root, intelligence_only) + .expect("an Intelligence failure is still said"); + assert!(problem.said.contains("Intelligence"), "{problem:?}"); + assert_eq!(problem.detail.as_deref(), Some(NO_CLI_LINE)); + assert!(!recovery_required(&shell, &root)); + assert_eq!(shell.last_failure.lock().unwrap().as_ref(), Some(&problem)); + + let both = Stopped { + openbot: vec!["Compose down failed: refused".into()], + intelligence: Some(NO_CLI_LINE.into()), + }; + let problem = settle_menu_stop(&shell, &root, both).unwrap(); + assert_eq!( + problem.said, + "OpenBot could not finish stopping. Try Stop OpenBot again." + ); + assert_eq!( + problem.detail.as_deref(), + Some(format!("Compose down failed: refused\n{NO_CLI_LINE}").as_str()) + ); + assert!(recovery_required(&shell, &root)); + + assert_eq!( + settle_menu_stop(&Shell::default(), &root, Stopped::default()), + None + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn quit_does_not_wait_behind_a_stop_asking_intelligence_to_stop() { + let root = local_eval_root("openbot-quit-behind-stop", None); + let shell = std::sync::Arc::new(Shell::default()); + *shell.root.lock().unwrap() = Some(root.clone()); + let (asking, asked) = std::sync::mpsc::channel(); + let (release, released) = std::sync::mpsc::channel::<()>(); + let stopping = { + let (shell, root) = (shell.clone(), root.clone()); + std::thread::spawn(move || { + stop_everything_then( + &shell, + &root, + |_| Ok(0), + |_| Ok(()), + |_| { + asking.send(()).unwrap(); + released.recv().unwrap(); + Ok(()) + }, + ) + }) + }; + asked.recv().unwrap(); + let quitting = { + let (shell, root) = (shell.clone(), root.clone()); + std::thread::spawn(move || { + exit_cleanup_then(&shell, &root, |_| Ok(0), |_| Ok(()), |_| Ok(())) + }) + }; + let until = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !quitting.is_finished() { + assert!( + std::time::Instant::now() < until, + "Quit waited behind Stop's Intelligence step" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + release.send(()).unwrap(); + assert_eq!(quitting.join().unwrap(), Stopped::default()); + assert_eq!(stopping.join().unwrap(), Stopped::default()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn stop_closes_local_eval_before_anything_else_and_reopens_after() { + // Closing is process-wide, and Quit's never reopens. + if test_support::isolated_process( + "tests::stop_closes_local_eval_before_anything_else_and_reopens_after", + ) { + return; + } + let root = local_eval_root("openbot-stop-closes-local-eval", None); + let shell = Shell::default(); + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + let refused = |when: &str| { + assert_eq!( + local_eval::run_installed(when, local_eval::Action::Start, &stack), + Err(local_eval::Failure::Cancelled), + "{when}" + ); + }; + let stopped = stop_everything_then( + &shell, + &root, + |_| { + refused("during-cleanup"); + Ok(0) + }, + |_| Ok(()), + |_| { + refused("during-intelligence"); + Ok(()) + }, + ); + assert_eq!(stopped, Stopped::default()); + assert_eq!( + local_eval::run_installed("after-stop", local_eval::Action::Status, &stack), + Err(local_eval::Failure::Unavailable) + ); + + exit_cleanup_then( + &shell, + &root, + |_| { + refused("during-quit-cleanup"); + Ok(0) + }, + |_| Ok(()), + |_| Ok(()), + ); + refused("after-quit"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn stop_and_quit_choose_their_own_limits_for_the_real_adapter() { + assert_eq!( + intelligence_limits(Shutdown::Stop).timeout, + local_eval::Limits::for_action(local_eval::Action::Stop).timeout + ); + assert_eq!( + intelligence_limits(Shutdown::Quit).timeout, + local_eval::Limits::for_quit().timeout + ); + let root = local_eval_root( + "openbot-intelligence-step", + Some(local_eval_state::Ownership::Created), + ); + for kind in [Shutdown::Stop, Shutdown::Quit] { + assert_eq!( + intelligence_step(kind)(&root), + Err(NO_CLI_LINE + .strip_prefix(INTELLIGENCE_STOP_FAILED) + .unwrap() + .to_owned()), + "{kind:?}" + ); + assert_eq!( + last_intelligence_timeout(), + Some(intelligence_limits(kind).timeout), + "{kind:?}" + ); + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn production_stop_and_quit_ask_intelligence_with_their_own_limits() { + // Quit closes local evaluation for the rest of the process. + if test_support::isolated_process( + "tests::production_stop_and_quit_ask_intelligence_with_their_own_limits", + ) { + return; + } + let root = local_eval_root( + "openbot-production-intelligence-wiring", + Some(local_eval_state::Ownership::Created), + ); + std::fs::remove_file(root.join("docker-compose.yml")).unwrap(); + let shell = Shell::default(); + *shell.root.lock().unwrap() = Some(root.clone()); + + let stopped = stop_everything_for(&shell, &root); + assert_eq!( + stopped, + Stopped { + openbot: Vec::new(), + intelligence: Some(NO_CLI_LINE.into()), + } + ); + assert_eq!( + last_intelligence_timeout(), + Some(local_eval::Limits::for_action(local_eval::Action::Stop).timeout) + ); + + *shell.root.lock().unwrap() = Some(root.clone()); + let quit = quit_cleanup(&shell); + assert_eq!(quit.lines(), [NO_CLI_LINE]); + assert_eq!( + last_intelligence_timeout(), + Some(local_eval::Limits::for_quit().timeout) + ); + let _ = std::fs::remove_dir_all(root); + } + + fn local_eval_completed(stack: &str) -> local_eval::Report { + let listing = serde_json::json!({ + "version": 1, + "command": "local list", + "status": "completed", + "stacks": [{ + "id": stack, + "installedRelease": "0.13.14", + "step": "ready", + "urls": { + "api": "http://localhost:64770", + "dashboard": "http://localhost:64771", + "gateway": "ws://localhost:64772" + } + }] + }); + let listed = local_eval::normalize_list(0, listing.to_string().as_bytes()).unwrap(); + local_eval::Report { + stack: listed.stacks.into_iter().next(), + ..local_eval_report(local_eval::Outcome::Completed, None) + } + } + + #[test] + fn a_completed_setup_records_the_stack_as_openbots() { + let root = local_eval_root("openbot-local-eval-setup-created", None); + let report = set_up_local_eval_at(&root, |folder| { + assert_eq!(folder, root.as_path(), "setup runs in the installation"); + Ok(( + local_eval_completed("local-67db320bed3f3e893a4f8122"), + LOCAL_EVAL_PIN.to_owned(), + )) + }) + .unwrap(); + assert_eq!(report.outcome, local_eval::Outcome::Completed); + let state = local_eval_state::read(&root, Some(LOCAL_EVAL_PIN)).unwrap(); + assert_eq!(state.stack, "local-67db320bed3f3e893a4f8122"); + assert!(state.tied_to_openbot()); + assert_eq!(state.phase, local_eval_state::Phase::Installed); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn a_setup_that_did_not_complete_records_nothing() { + let root = local_eval_root("openbot-local-eval-setup-blocked", None); + let blocked = local_eval_report( + local_eval::Outcome::Blocked, + Some(local_eval::Reason::EulaAcceptanceRequired), + ); + let report = + set_up_local_eval_at(&root, |_| Ok((blocked, LOCAL_EVAL_PIN.to_owned()))).unwrap(); + assert_eq!( + report.reason, + Some(local_eval::Reason::EulaAcceptanceRequired) + ); + assert_eq!(local_eval_state::read(&root, None), None); + assert!(matches!( + set_up_local_eval_at(&root, |_| Err(local_eval::Failure::Timeout)), + Err(SetupError::Failure(local_eval::Failure::Timeout)) + )); + assert_eq!(local_eval_state::read(&root, None), None); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn setting_up_again_keeps_a_stack_openbot_found_shared() { + let root = local_eval_root( + "openbot-local-eval-setup-shared", + Some(local_eval_state::Ownership::Shared), + ); + set_up_local_eval_at(&root, |_| { + Ok(( + local_eval_completed("local-abc123"), + LOCAL_EVAL_PIN.to_owned(), + )) + }) + .unwrap(); + assert!(!local_eval_state::read(&root, None) + .unwrap() + .tied_to_openbot()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn a_stack_set_up_but_not_recorded_is_said() { + let parent = temp_root("openbot-local-eval-setup-unrecorded"); + std::fs::create_dir_all(&parent).unwrap(); + // A file where the installation folder should be: nothing can be written inside it. + let root = parent.join("not-a-folder"); + std::fs::write(&root, "").unwrap(); + let error = set_up_local_eval_at(&root, |_| { + Ok(( + local_eval_completed("local-abc123"), + LOCAL_EVAL_PIN.to_owned(), + )) + }) + .expect_err("an unrecorded stack would never be stopped with OpenBot"); + let SetupError::Unrecorded(problem) = error else { + panic!("{error:?}"); + }; + assert!(problem.said.contains("local evaluation"), "{problem:?}"); + let _ = std::fs::remove_dir_all(parent); + } + + #[test] + fn already_configured_shows_the_local_evaluation_only_when_there_is_one() { + let root = local_eval_root("openbot-already-configured-local-eval", None); + let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + let json = serde_json::to_value(&configured).unwrap(); + assert!(json.get("localEval").is_none(), "{json}"); + + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + local_eval_state::begin( + &root, + &stack, + local_eval_state::Ownership::Created, + LOCAL_EVAL_PIN, + ) + .unwrap(); + let configured = already_configured_for_root(root.to_string_lossy().into_owned()); + let json = serde_json::to_value(&configured).unwrap(); + assert_eq!( + json["localEval"], + serde_json::json!({ + "stack": "local-abc123", + "ownership": "created", + "phase": "installed" + }) + ); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn production_stop_root_selection_retains_resolved_root_not_menu_fallback() { let selected = temp_root("openbot-production-stop-selected-root"); From 2cb5bc9f6bc28254edd9fca73350066d8eb45e8b Mon Sep 17 00:00:00 2001 From: Dusty Date: Wed, 21 Oct 2026 11:49:15 -0700 Subject: [PATCH 03/77] feat(desktop): local Intelligence panel, readiness rows and Stop failure split Connect offers "Use Intelligence on this computer" only when a pinned CLI answers. The panel shows independent Docker Desktop, Intelligence, OpenBot and provider rows, runs setup for this installation, and keeps Start disabled because the CLI does not report readiness or give OpenBot a credential. A Stop that fails only for Intelligence marks OpenBot stopped and shows the Intelligence failure separately; a failed stop at Quit is shown on the next launch without holding auto-start. --- desktop/src/App.test.tsx | 153 +++++++++++ desktop/src/App.tsx | 106 ++++++-- desktop/src/LocalIntelligence.test.tsx | 196 +++++++++++++++ desktop/src/LocalIntelligence.tsx | 184 ++++++++++++++ desktop/src/localEvalCli.test.ts | 217 ++++++++++++++++ desktop/src/localReadiness.test.ts | 334 +++++++++++++++++++++++++ desktop/src/localReadiness.ts | 194 ++++++++++++++ 7 files changed, 1370 insertions(+), 14 deletions(-) create mode 100644 desktop/src/LocalIntelligence.test.tsx create mode 100644 desktop/src/LocalIntelligence.tsx create mode 100644 desktop/src/localEvalCli.test.ts create mode 100644 desktop/src/localReadiness.test.ts create mode 100644 desktop/src/localReadiness.ts diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 98e42fe0ef..408e7a2a26 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -370,6 +370,96 @@ test("installation completes before either sign-in is available", async () => { expect(view.getByRole("heading", { name: "Connect your AI" })).toBeTruthy(); }); +async function reachConnect(view: Awaited>) { + await enterInstallation(view); + await completeInstallation(view); + await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await userEvent.type(view.getByLabelText("OpenAI API key"), "model-test-key"); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await view.findByRole("heading", { name: "Connect to CopilotKit" }); +} + +const localListing = { outcome: "completed", stacks: [], reason: null }; + +test("local Intelligence is not offered when this build has no CLI", async () => { + setupRootConfiguration("/tmp/local-eval-unavailable", async () => + emptyConfiguration(), + ); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "list_local_eval") throw "unavailable"; + return previous(command, args); + }; + const view = await renderApp(); + await reachConnect(view); + expect(view.queryByText("Use Intelligence on this computer")).toBeNull(); +}); + +test("local Intelligence replaces the hosted sign-in and keeps Start closed", async () => { + setupRootConfiguration("/tmp/local-eval-available", async () => + emptyConfiguration(), + ); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "list_local_eval") return localListing; + return previous(command, args); + }; + const view = await renderApp(); + await reachConnect(view); + await userEvent.click(view.getByText("Use Intelligence on this computer")); + await userEvent.click( + view.getByRole("button", { name: "Use local Intelligence" }), + ); + expect( + view.getByRole("region", { name: "Intelligence on this computer" }), + ).toBeTruthy(); + expect(view.queryByText("Point at your own Intelligence server")).toBeNull(); + expect(view.getByRole("button", { name: "Start OpenBot" })).toHaveProperty( + "disabled", + true, + ); + await userEvent.click( + view.getByRole("button", { name: "Use CopilotKit's service instead" }), + ); + expect(view.getByText("Point at your own Intelligence server")).toBeTruthy(); +}); + +test("a recorded local stack opens in local mode and is checked", async () => { + setupRootConfiguration("/tmp/local-eval-recorded", async () => ({ + ...emptyConfiguration(), + localEval: { + stack: "local-abc123", + ownership: "created", + phase: "installed", + }, + })); + 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" }), + ); +}); + test("CopilotKit Back preserves the selected AI and connection settings", async () => { setupRootConfiguration("/tmp/back-preserves-setup", async () => emptyConfiguration(), @@ -959,6 +1049,69 @@ function useInterruptedShutdownSetup() { return { root, notice }; } +test("a local Intelligence stack left running at Quit is shown without holding auto-start", async () => { + useInterruptedShutdownSetup(); + const notice = { + said: "OpenBot could not stop the local Intelligence stack it started when it last quit.", + detail: "Intelligence stop failed: the CLI timed out", + }; + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "last_failure") return null; + if (command === "last_local_eval_stop_failure") return notice; + return previous(command, args); + }; + const view = await renderApp(); + await waitFor(() => + expect(invokeCalls.some((call) => call.command === "start_stack")).toBe( + true, + ), + ); + expect(view.getByRole("alert").textContent).toContain(notice.said); +}); + +test("Stop that fails only for the local Intelligence stack still stops OpenBot", async () => { + useInterruptedShutdownSetup(); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "last_failure") return null; + if (command === "stop_stack") + throw "Intelligence stop failed: the CLI timed out"; + return previous(command, args); + }; + const view = await renderApp(); + await userEvent.click( + await view.findByRole("button", { name: "Stop OpenBot" }), + ); + await waitFor(() => + expect(view.queryByRole("button", { name: "Stop OpenBot" })).toBeNull(), + ); + expect(view.getByRole("alert").textContent).toContain( + "OpenBot stopped, but could not stop the local Intelligence stack it started.", + ); +}); + +test("Stop that fails for OpenBot itself keeps OpenBot running", async () => { + useInterruptedShutdownSetup(); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "last_failure") return null; + if (command === "stop_stack") + throw "Compose down failed: busy\nIntelligence stop failed: the CLI timed out"; + return previous(command, args); + }; + const view = await renderApp(); + await userEvent.click( + await view.findByRole("button", { name: "Stop OpenBot" }), + ); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + "Compose down failed: busy", + ), + ); + expect(view.getByRole("button", { name: "Stop OpenBot" })).toBeTruthy(); +}); + test("an interrupted shutdown restores saved setup for explicit recovery", async () => { const { root, notice } = useInterruptedShutdownSetup(); const view = await renderApp(); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index ad54068c62..1a2ea7d57c 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -9,6 +9,12 @@ import { HarnessPicker, } from "./HarnessPicker"; import { isHttpEndpointUrl } from "./http-endpoint-url"; +import { LocalIntelligence } from "./LocalIntelligence"; +import { + intelligenceStopProblem, + listLocalEval, + splitStopFailure, +} from "./localEvalCli"; import { OrganizationSignIn } from "./OrganizationSignIn"; import { asProblem, Failure, type Problem } from "./Problem"; import { @@ -53,6 +59,8 @@ type AlreadyConfigured = { launch?: { harness: HarnessChoice | null } | null; installation?: { harness: HarnessChoice | null } | null; autoStart?: boolean; + /** The local evaluation's checkpoint, when this installation has one. */ + localEval?: { stack: string; ownership: "created" | "shared"; phase: string }; }; function installationKeyFor(root: string, harness: HarnessChoice | null) { @@ -83,6 +91,13 @@ export function App() { const [instruction, setInstruction] = useState(""); const [root, setRoot] = useState(""); const [reuseIntelligence, setReuseIntelligence] = useState(false); + // Offered only when this build has an Intelligence CLI to run; a local stack is its own mode, + // not another setup step. + const [localAvailable, setLocalAvailable] = useState(false); + const [intelligenceMode, setIntelligenceMode] = useState<"hosted" | "local">( + "hosted", + ); + const [localStack, setLocalStack] = useState(null); const [apiKey, setApiKey] = useState(""); const [organizationAuthorityUrl, setOrganizationAuthorityUrl] = useState< string | null @@ -295,7 +310,11 @@ export function App() { } if (problem.connection === "organization") setRefreshOrganization(true); }, []); - const displayedFailure = failure ?? recoveryFailure; + // A local Intelligence stack OpenBot could not stop. Shown, but it never holds a start. + const [intelligenceNotice, setIntelligenceNotice] = useState( + null, + ); + const displayedFailure = failure ?? recoveryFailure ?? intelligenceNotice; const credentialContext = useRef([ root, model, @@ -334,6 +353,8 @@ export function App() { setOrganizationAuthorityUrl(null); setApiKey(""); setReuseIntelligence(false); + setIntelligenceMode("hosted"); + setLocalStack(null); setApiUrl(MANAGED_INTELLIGENCE_API_URL); setWsUrl(MANAGED_INTELLIGENCE_GATEWAY_WS_URL); setAlreadyHeld({}); @@ -358,6 +379,10 @@ export function App() { if (values.INTELLIGENCE_GATEWAY_WS_URL) setWsUrl(values.INTELLIGENCE_GATEWAY_WS_URL); setAlreadyHeld({ ...values, saved }); + if (configured.localEval) { + setLocalStack(configured.localEval.stack); + setIntelligenceMode("local"); + } setOrganizationAuthorityUrl( values.OPENBOT_ORGANIZATION_AUTH_URL ?? null, ); @@ -392,6 +417,18 @@ export function App() { setBlockerFailure(asProblem(error)); return false; }); + // A build without a pinned CLI answers `unavailable`, and local Intelligence is not offered. + listLocalEval() + .listing.then(() => { + if (active) setLocalAvailable(true); + }) + .catch(() => {}); + // Read once and kept apart from `last_failure`, which would hold auto-start. + invoke("last_local_eval_stop_failure") + .then((found) => { + if (active && found) setIntelligenceNotice(found); + }) + .catch(() => {}); // A supervisor that stopped retrying must stay stopped when it returns to this window. const interruptedRun = invoke("last_failure") .then((found) => { @@ -639,16 +676,32 @@ export function App() { return model.login === "plan" || model.login === "api-key"; } - async function stop() { - setBusy(true); + /** True when OpenBot stopped, even if the local Intelligence stack it started did not. */ + async function stopStack(): Promise { try { await invoke("stop_stack", { root }); - setRunning(false); - setRecoveryFailure(null); - setStep("connect"); - setRefreshIntelligence(false); + setIntelligenceNotice(null); + return true; } catch (error) { + const stopped = splitStopFailure(error); + if (stopped.openBot === null && stopped.intelligence !== null) { + setIntelligenceNotice(intelligenceStopProblem(stopped.intelligence)); + return true; + } setFailure(asProblem(error)); + return false; + } + } + + async function stop() { + setBusy(true); + try { + if (await stopStack()) { + setRunning(false); + setRecoveryFailure(null); + setStep("connect"); + setRefreshIntelligence(false); + } } finally { setBusy(false); } @@ -658,12 +711,11 @@ export function App() { setBusy(true); setFailure(null); try { - await invoke("stop_stack", { root }); - setRunning(false); - setRecoveryFailure(null); - setStep("model"); - } catch (error) { - setFailure(asProblem(error)); + if (await stopStack()) { + setRunning(false); + setRecoveryFailure(null); + setStep("model"); + } } finally { setBusy(false); } @@ -1009,7 +1061,15 @@ export function App() { : "Local installation is complete. Connect CopilotKit, then start OpenBot."}

- {!running && ( + {!running && intelligenceMode === "local" && ( + setIntelligenceMode("hosted")} + /> + )} + + {!running && intelligenceMode === "hosted" && (
{/* Sign in on the main path; paste behind the disclosure. @@ -1255,6 +1315,22 @@ export function App() { /> + {localAvailable && ( +
+ Use Intelligence on this computer +

+ Runs Intelligence in Docker Desktop for a local evaluation. + The Intelligence CLI sets it up and manages it. +

+ +
+ )}
)} @@ -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. +

+
    + {( + [ + ["Docker Desktop", rows.runtime], + ["Intelligence", rows.intelligence], + ["OpenBot", rows.openBot], + ["AI provider", rows.provider], + ] as const + ).map(([label, row]) => ( +
  • + {label} {row.said} {act(row)} +
  • + ))} +
+ {stack &&

Stack {stack}

} + {termsPending && + (developer ? ( +

+ Development build: run copilotkit local accept-terms in + Terminal, then choose Set up local Intelligence again. +

+ ) : ( +

+ Accepting the evaluation terms in OpenBot is not available yet. +

+ ))} +

+ OpenBot cannot start against local Intelligence yet. The Intelligence + CLI does not report readiness or give OpenBot a credential. +

+ {problem && } +
+ {busy ? ( + + ) : ( + <> + {(!stack || report?.outcome !== "completed") && ( + + )} + {stack && ( + + )} + + + )} +
+
+ ); +} diff --git a/desktop/src/localEvalCli.test.ts b/desktop/src/localEvalCli.test.ts new file mode 100644 index 0000000000..37eb74d0b2 --- /dev/null +++ b/desktop/src/localEvalCli.test.ts @@ -0,0 +1,217 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import type { ChatGateInput, LocalReport } from "./localEvalCli"; + +// Wire-format cases live with the parser in src-tauri/src/local_eval.rs. These cover the window's +// side: the command it invokes, how failures arrive, and the chat gate. + +type Invoke = (command: string, args?: unknown) => Promise; + +let invokeCalls: Array<{ command: string; args?: unknown }> = []; +let invokeHandler: Invoke = async () => { + throw new Error("invoke handler was not installed"); +}; + +mock.module("@tauri-apps/api/core", () => ({ + invoke: (command: string, args?: unknown) => { + invokeCalls.push({ command, args }); + return invokeHandler(command, args); + }, +})); + +const { + cancelLocalEval, + chatGate, + intelligenceStopFailed, + intelligenceStopProblem, + listLocalEval, + LocalEvalError, + runLocalEval, + splitStopFailure, +} = await import("./localEvalCli"); + +afterEach(() => { + invokeCalls = []; +}); + +function report(overrides: Partial = {}): LocalReport { + return { + outcome: "completed", + stack: { + id: "local-abc123", + installedRelease: "0.15.0", + step: "ready", + urls: { + api: "http://localhost:8082", + dashboard: "http://intelligence.localhost:8080", + gateway: "ws://localhost:4401", + }, + hasFailedStep: false, + updateInProgress: false, + observed: { + running: true, + services: [ + { name: "cpki-app-api-test", phase: "Running", ready: true }, + ], + }, + }, + reason: null, + readiness: "unknown", + licence: "unknown", + ...overrides, + }; +} + +test("runs one native command named by its attempt", async () => { + invokeHandler = async () => report(); + const { attempt, report: pending } = runLocalEval("status", "local-abc123"); + expect((await pending).outcome).toBe("completed"); + expect(invokeCalls).toEqual([ + { + command: "run_local_eval", + args: { attempt, action: "status", stack: "local-abc123" }, + }, + ]); +}); + +test("gives each call its own attempt", () => { + invokeHandler = async () => report(); + const first = runLocalEval("status", "local-abc123"); + const second = runLocalEval("status", "local-abc123"); + expect(first.attempt).not.toBe(second.attempt); +}); + +test("passes known failure categories through", async () => { + invokeHandler = async () => { + throw "unavailable"; + }; + const error = await runLocalEval("start", "local-abc123").report.catch( + (e) => e, + ); + expect(error).toBeInstanceOf(LocalEvalError); + expect(error.category).toBe("unavailable"); +}); + +test("never echoes an unrecognized rejection", async () => { + invokeHandler = async () => { + throw "synthetic-secret-marker"; + }; + const error = await runLocalEval("status", "local-abc123").report.catch( + (e) => e, + ); + expect(error.category).toBe("unrecognized"); + expect(String(error.message)).not.toContain("synthetic-secret-marker"); +}); + +test("lists recorded stacks without a stack argument", async () => { + invokeHandler = async () => ({ + outcome: "completed", + stacks: [report().stack], + reason: null, + }); + const { attempt, listing } = listLocalEval(); + expect((await listing).stacks[0].id).toBe("local-abc123"); + expect(invokeCalls).toEqual([ + { command: "list_local_eval", args: { attempt } }, + ]); + invokeHandler = async () => { + throw "timeout"; + }; + const error = await listLocalEval().listing.catch((e) => e); + expect(error.category).toBe("timeout"); +}); + +test("cancels the attempt it names", async () => { + invokeHandler = async () => undefined; + await cancelLocalEval("attempt-1"); + expect(invokeCalls).toEqual([ + { command: "cancel_local_eval", args: { attempt: "attempt-1" } }, + ]); +}); + +const good: ChatGateInput = { + intelligence: report(), + openBot: "connected", + provider: "validated", +}; + +test("a v1 report with every service ready does not open chat", () => { + expect(chatGate(good)).toEqual({ open: false, blockers: ["intelligence"] }); +}); + +test("unknown, failed and missing Intelligence states all block", () => { + for (const intelligence of [ + null, + "unavailable" as const, + "timeout" as const, + report({ outcome: "blocked", reason: "authentication-required" }), + report({ outcome: "failed", stack: null }), + ]) { + expect(chatGate({ ...good, intelligence }).blockers).toContain( + "intelligence", + ); + } +}); + +test("future readiness and licence values open chat only when exactly good", () => { + const future = (readiness: string, licence: string) => + ({ ...report(), readiness, licence }) as unknown as LocalReport; + expect(chatGate({ ...good, intelligence: future("ready", "valid") })).toEqual( + { open: true, blockers: [] }, + ); + for (const [readiness, licence] of [ + ["not-ready", "valid"], + ["ready", "expired"], + ["ready", "unknown"], + ["unknown", "valid"], + ]) { + expect( + chatGate({ ...good, intelligence: future(readiness, licence) }).open, + ).toBe(false); + } +}); + +test("OpenBot and provider states block independently", () => { + const ready = { ...report(), readiness: "ready", licence: "valid" }; + const intelligence = ready as unknown as LocalReport; + expect( + chatGate({ intelligence, openBot: "unknown", provider: "invalid" }), + ).toEqual({ open: false, blockers: ["openbot", "provider"] }); + expect( + chatGate({ intelligence, openBot: "not-connected", provider: "validated" }) + .blockers, + ).toEqual(["openbot"]); + expect( + chatGate({ intelligence, openBot: "connected", provider: "unvalidated" }) + .blockers, + ).toEqual(["provider"]); +}); + +test("the Stop failure prefix is the one the native side writes", async () => { + const native = await Bun.file( + new URL("../src-tauri/src/main.rs", import.meta.url), + ).text(); + expect(native).toContain( + `const INTELLIGENCE_STOP_FAILED: &str = "${intelligenceStopFailed}";`, + ); +}); + +test("a Stop failure is split into OpenBot's part and Intelligence's part", () => { + const intelligence = `${intelligenceStopFailed}the CLI timed out`; + expect(splitStopFailure(intelligence)).toEqual({ + openBot: null, + intelligence, + }); + expect( + splitStopFailure(`Compose down failed: busy\n${intelligence}`), + ).toEqual({ openBot: "Compose down failed: busy", intelligence }); + expect(splitStopFailure("Compose down failed: busy")).toEqual({ + openBot: "Compose down failed: busy", + intelligence: null, + }); + // Anything that is not the native line format is OpenBot's own failure. + expect(splitStopFailure({ said: "x" }).openBot).not.toBeNull(); + expect(intelligenceStopProblem(intelligence)).toEqual({ + said: "OpenBot stopped, but could not stop the local Intelligence stack it started.", + detail: intelligence, + }); +}); diff --git a/desktop/src/localReadiness.test.ts b/desktop/src/localReadiness.test.ts new file mode 100644 index 0000000000..150290d4e4 --- /dev/null +++ b/desktop/src/localReadiness.test.ts @@ -0,0 +1,334 @@ +import { expect, test } from "bun:test"; +import { + type ChatGateInput, + chatGate, + type LocalFailure, + type LocalReason, + type LocalReport, + localFailures, +} from "./localEvalCli"; +import { localReadiness, type ReadinessInput } from "./localReadiness"; + +const reasons: LocalReason[] = [ + "authentication-required", + "eula-acceptance-required", + "eula-unavailable", + "eula-declined", + "release-unavailable", + "feature-disabled", + "unsupported-platform", + "unsupported-engine", + "port-unavailable", + "docker-unavailable", + "update-recovery-required", + "incompatible-release", + "local-command-busy", + "local-command-failed", + "unrecognized", +]; + +function report(overrides: Partial = {}): LocalReport { + return { + outcome: "completed", + stack: null, + reason: null, + readiness: "unknown", + licence: "unknown", + ...overrides, + }; +} + +function future(readiness: string, licence: string): LocalReport { + return { ...report(), readiness, licence } as unknown as LocalReport; +} + +const base: ReadinessInput = { + runtime: "running", + intelligence: report(), + openBot: "connected", + provider: "validated", +}; + +function hasText(said: string, slug: string): boolean { + return said.toLowerCase().includes(slug); +} + +test("every reason gives a plain, non-empty sentence without its own name", () => { + for (const reason of reasons) { + const rows = localReadiness({ + ...base, + intelligence: report({ outcome: "blocked", reason }), + }); + for (const row of Object.values(rows)) { + expect(row.said.length).toBeGreaterThan(0); + expect(row.said).not.toContain("!"); + expect(hasText(row.said, reason)).toBe(false); + } + expect(rows.intelligence.state).toBe("blocked"); + } +}); + +test("every failure gives a plain, non-empty sentence without its own name", () => { + for (const failure of localFailures) { + const rows = localReadiness({ ...base, intelligence: failure }); + expect(rows.intelligence.said.length).toBeGreaterThan(0); + expect(rows.intelligence.said).not.toContain("!"); + expect(hasText(rows.intelligence.said, failure)).toBe(false); + expect(rows.intelligence.state).toBe("blocked"); + } +}); + +test("the reason and failure lists in this test match the source", () => { + expect(new Set(reasons).size).toBe(15); + expect(localFailures.length).toBe(15); +}); + +test("Docker reasons block the runtime when the probe has no answer", () => { + const rows = localReadiness({ + ...base, + runtime: "unknown", + intelligence: report({ outcome: "blocked", reason: "docker-unavailable" }), + }); + expect(rows.runtime).toEqual({ + state: "blocked", + said: "Docker Desktop is not running.", + action: "retry", + }); + expect(rows.intelligence.state).toBe("blocked"); + const engine = localReadiness({ + ...base, + runtime: "unknown", + intelligence: report({ outcome: "blocked", reason: "unsupported-engine" }), + }); + expect(engine.runtime.said).toBe("This Docker setup is not supported."); + expect(engine.runtime.action).toBeUndefined(); +}); + +test("a running probe is not overridden by the CLI", () => { + const rows = localReadiness({ + ...base, + intelligence: report({ outcome: "blocked", reason: "docker-unavailable" }), + }); + expect(rows.runtime.state).toBe("ready"); +}); + +test("runtime probe maps directly", () => { + expect(localReadiness({ ...base, runtime: "running" }).runtime.state).toBe( + "ready", + ); + expect(localReadiness({ ...base, runtime: "not-running" }).runtime).toEqual({ + state: "blocked", + said: "Docker Desktop is not running.", + action: "retry", + }); + expect(localReadiness({ ...base, runtime: "unknown" }).runtime.state).toBe( + "unknown", + ); +}); + +test("specific wording and actions", () => { + const said = (intelligence: ReadinessInput["intelligence"]) => + localReadiness({ ...base, intelligence }).intelligence; + expect( + said(report({ outcome: "blocked", reason: "port-unavailable" })), + ).toEqual({ + state: "blocked", + said: "A port Intelligence needs is in use.", + action: "retry", + }); + expect( + said(report({ outcome: "blocked", reason: "authentication-required" })), + ).toEqual({ + state: "blocked", + said: "Sign in to CopilotKit.", + action: "sign-in", + }); + expect(said("cancelled").action).toBe("retry"); + expect(said("cancelled").said).toBe( + "The Intelligence command was stopped before it finished.", + ); + expect( + said(report({ outcome: "blocked", reason: "local-command-busy" })).said, + ).toBe("Another Intelligence command is still running."); + expect(said("timeout")).toEqual({ + state: "blocked", + said: "Intelligence could not start.", + action: "restart-intelligence", + }); + expect(said("unavailable").action).toBeUndefined(); + expect(said("unverified").action).toBe("repair"); + expect(said("invalid-json")).toEqual({ + state: "blocked", + said: "OpenBot does not understand this CLI's reply.", + }); +}); + +test("a blocked or failed report with no reason still blocks", () => { + for (const outcome of ["blocked", "failed"] as const) { + const row = localReadiness({ + ...base, + intelligence: report({ outcome, reason: null }), + }).intelligence; + expect(row.state).toBe("blocked"); + expect(row.action).toBe("restart-intelligence"); + } +}); + +test("a v1 report is unknown, never ready", () => { + const row = localReadiness(base).intelligence; + expect(row).toEqual({ + state: "unknown", + said: "Intelligence started. Its licence and readiness are not reported yet.", + }); + expect( + localReadiness({ ...base, intelligence: null }).intelligence.state, + ).toBe("unknown"); +}); + +test("no v1 input ever gives an Intelligence row of ready", () => { + const inputs: ReadinessInput["intelligence"][] = [ + null, + ...localFailures, + ...reasons.map((reason) => report({ outcome: "blocked", reason })), + ...reasons.map((reason) => report({ outcome: "completed", reason })), + report(), + report({ outcome: "failed" }), + report({ outcome: "blocked" }), + ]; + for (const intelligence of inputs) { + expect( + localReadiness({ ...base, intelligence }).intelligence.state, + ).not.toBe("ready"); + } +}); + +test("only exactly ready and valid on a completed report is ready", () => { + expect( + localReadiness({ ...base, intelligence: future("ready", "valid") }) + .intelligence.state, + ).toBe("ready"); + for (const [readiness, licence] of [ + ["not-ready", "valid"], + ["ready", "expired"], + ["ready", "unknown"], + ["unknown", "valid"], + ["Ready", "valid"], + ]) { + expect( + localReadiness({ ...base, intelligence: future(readiness, licence) }) + .intelligence.state, + ).not.toBe("ready"); + } + const failed = { + ...future("ready", "valid"), + outcome: "failed", + } as LocalReport; + expect( + localReadiness({ ...base, intelligence: failed }).intelligence.state, + ).not.toBe("ready"); +}); + +test("OpenBot and provider rows", () => { + expect(localReadiness({ ...base, openBot: "connected" }).openBot.state).toBe( + "ready", + ); + expect( + localReadiness({ ...base, openBot: "not-connected" }).openBot, + ).toMatchObject({ state: "blocked", action: "restart-openbot" }); + expect(localReadiness({ ...base, openBot: "unknown" }).openBot.state).toBe( + "unknown", + ); + expect( + localReadiness({ ...base, provider: "validated" }).provider.state, + ).toBe("ready"); + expect( + localReadiness({ ...base, provider: "invalid" }).provider, + ).toMatchObject({ + state: "blocked", + said: "The AI provider rejected the key.", + }); + expect( + localReadiness({ ...base, provider: "unvalidated" }).provider.state, + ).toBe("unknown"); +}); + +test("rows are independent", () => { + const before = localReadiness(base); + const changed = (input: ReadinessInput) => { + const after = localReadiness(input); + return (Object.keys(after) as (keyof typeof after)[]).filter( + (key) => JSON.stringify(after[key]) !== JSON.stringify(before[key]), + ); + }; + expect(changed({ ...base, runtime: "not-running" })).toEqual(["runtime"]); + expect(changed({ ...base, openBot: "not-connected" })).toEqual(["openBot"]); + expect(changed({ ...base, provider: "invalid" })).toEqual(["provider"]); + expect(changed({ ...base, intelligence: "timeout" })).toEqual([ + "intelligence", + ]); + expect( + changed({ + ...base, + intelligence: report({ outcome: "blocked", reason: "port-unavailable" }), + }), + ).toEqual(["intelligence"]); +}); + +// chatGate does not take the runtime. So the direction that holds is: if every row is ready, chat +// is open; and if chat is open, the Intelligence, OpenBot and provider rows are ready (the runtime +// row may still be unknown or blocked). +test("agrees with chatGate", () => { + const intelligences: ChatGateInput["intelligence"][] = [ + null, + "timeout", + report(), + report({ outcome: "blocked", reason: "authentication-required" }), + future("ready", "valid"), + future("ready", "expired"), + ]; + const runtimes: ReadinessInput["runtime"][] = [ + "running", + "not-running", + "unknown", + ]; + const openBots: ChatGateInput["openBot"][] = [ + "connected", + "not-connected", + "unknown", + ]; + const providers: ChatGateInput["provider"][] = [ + "validated", + "invalid", + "unvalidated", + ]; + let allReady = 0; + for (const intelligence of intelligences) + for (const runtime of runtimes) + for (const openBot of openBots) + for (const provider of providers) { + const rows = localReadiness({ + runtime, + intelligence, + openBot, + provider, + }); + const open = chatGate({ intelligence, openBot, provider }).open; + const every = Object.values(rows).every((r) => r.state === "ready"); + const three = [rows.intelligence, rows.openBot, rows.provider].every( + (r) => r.state === "ready", + ); + if (every) { + allReady += 1; + expect(open).toBe(true); + } + expect(three).toBe(open); + } + expect(allReady).toBe(1); +}); + +test("types: the failure and reason inputs are the upstream ones", () => { + const failure: LocalFailure = "spawn"; + expect( + localReadiness({ ...base, intelligence: failure }).intelligence.said, + ).toBe("Intelligence could not start."); +}); diff --git a/desktop/src/localReadiness.ts b/desktop/src/localReadiness.ts new file mode 100644 index 0000000000..e2f068b4e1 --- /dev/null +++ b/desktop/src/localReadiness.ts @@ -0,0 +1,194 @@ +/** + * What the window can honestly say about each part of the local setup. + * + * Four rows, one per independent fact: the container runtime, Intelligence, OpenBot's connection + * to it, and the AI provider key. Every sentence here is static. Nothing from the CLI is shown, + * because the native boundary passes on only enumerated categories. + * + * Unknown is never shown as ready. CLI v1 cannot state readiness or licence, so today the + * Intelligence row is never "ready". + */ +import { + type ChatGateInput, + chatGate, + type LocalFailure, + type LocalReason, +} from "./localEvalCli"; + +export type ReadinessState = "ready" | "blocked" | "unknown"; +export type ReadinessAction = + | "retry" + | "restart-intelligence" + | "restart-openbot" + | "sign-in" + | "repair"; + +export type ReadinessRow = { + state: ReadinessState; + said: string; + action?: ReadinessAction; +}; + +export type ReadinessInput = { + /** Docker Desktop, as probed by the window. */ + runtime: "running" | "not-running" | "unknown"; + /** The latest status call: a report, a failure category, or nothing yet. */ + intelligence: ChatGateInput["intelligence"]; + openBot: ChatGateInput["openBot"]; + provider: ChatGateInput["provider"]; +}; + +export type Readiness = { + runtime: ReadinessRow; + intelligence: ReadinessRow; + openBot: ReadinessRow; + provider: ReadinessRow; +}; + +function blocked(said: string, action?: ReadinessAction): ReadinessRow { + return action + ? { state: "blocked", said, action } + : { state: "blocked", said }; +} + +const unavailableHere = + "Intelligence is not available on this computer or release."; +const contract = "OpenBot does not understand this CLI's reply."; +const couldNotStart = blocked( + "Intelligence could not start.", + "restart-intelligence", +); +const busy = blocked("Another Intelligence command is still running.", "retry"); + +// Compiler-enforced: a new upstream reason or failure breaks the build here. +const reasonRows: Record = { + "authentication-required": blocked("Sign in to CopilotKit.", "sign-in"), + // Acceptance is an explicit step of its own; retrying start cannot satisfy it. + "eula-acceptance-required": blocked( + "The Intelligence terms have not been accepted.", + ), + "eula-unavailable": blocked( + "The Intelligence terms could not be shown.", + "retry", + ), + "eula-declined": blocked("The Intelligence terms were declined."), + "release-unavailable": blocked(unavailableHere), + "feature-disabled": blocked(unavailableHere), + "unsupported-platform": blocked(unavailableHere), + "incompatible-release": blocked(unavailableHere), + "update-recovery-required": blocked(unavailableHere), + // The runtime row carries the Docker detail. Here Intelligence is simply blocked behind it. + "unsupported-engine": blocked( + "Intelligence cannot start on this Docker setup.", + ), + "docker-unavailable": blocked( + "Intelligence cannot start until Docker Desktop is running.", + "retry", + ), + "port-unavailable": blocked("A port Intelligence needs is in use.", "retry"), + "local-command-busy": busy, + "local-command-failed": couldNotStart, + unrecognized: couldNotStart, +}; + +const failureRows: Record = { + unavailable: blocked("This build does not include the Intelligence CLI."), + unverified: blocked( + "The Intelligence CLI failed its integrity check.", + "repair", + ), + // Cancelled is the person's own call stopping, not another command holding the stack. + cancelled: blocked( + "The Intelligence command was stopped before it finished.", + "retry", + ), + spawn: couldNotStart, + timeout: couldNotStart, + "unexpected-exit": couldNotStart, + "invalid-stack": blocked(contract), + "output-limit": blocked(contract), + "invalid-json": blocked(contract), + "unsupported-version": blocked(contract), + "unexpected-command": blocked(contract), + "invalid-status": blocked(contract), + "missing-stack": blocked(contract), + "unsafe-endpoint": blocked(contract), + "contract-mismatch": blocked(contract), +}; + +function intelligenceRow( + intelligence: ReadinessInput["intelligence"], +): ReadinessRow { + if (intelligence === null) { + return { + state: "unknown", + said: "Intelligence has not been checked yet.", + }; + } + if (typeof intelligence === "string") return failureRows[intelligence]; + // Same positive match as the chat gate: only an exactly good report is ready. + const good = chatGate({ + intelligence, + openBot: "connected", + provider: "validated", + }).open; + if (good) return { state: "ready", said: "Intelligence is ready." }; + if (intelligence.reason !== null) return reasonRows[intelligence.reason]; + if (intelligence.outcome !== "completed") return couldNotStart; + return { + state: "unknown", + said: "Intelligence started. Its licence and readiness are not reported yet.", + }; +} + +/** + * The CLI's Docker reasons are evidence about the runtime, used only when the window's own probe + * has no answer. A probe that says Docker is running is not overridden. + */ +function runtimeRow( + runtime: ReadinessInput["runtime"], + intelligence: ReadinessInput["intelligence"], +): ReadinessRow { + if (runtime === "running") { + return { state: "ready", said: "Docker Desktop is running." }; + } + const notRunning = blocked("Docker Desktop is not running.", "retry"); + if (runtime === "not-running") return notRunning; + const reason = + intelligence !== null && typeof intelligence !== "string" + ? intelligence.reason + : null; + if (reason === "docker-unavailable") return notRunning; + if (reason === "unsupported-engine") { + return blocked("This Docker setup is not supported."); + } + return { state: "unknown", said: "Docker Desktop has not been checked." }; +} + +export function localReadiness(input: ReadinessInput): Readiness { + return { + runtime: runtimeRow(input.runtime, input.intelligence), + intelligence: intelligenceRow(input.intelligence), + openBot: + input.openBot === "connected" + ? { state: "ready", said: "OpenBot is connected to Intelligence." } + : input.openBot === "not-connected" + ? blocked( + "OpenBot is not connected to Intelligence.", + "restart-openbot", + ) + : { + state: "unknown", + said: "OpenBot's connection has not been checked.", + }, + provider: + input.provider === "validated" + ? { state: "ready", said: "The AI provider key is valid." } + : input.provider === "invalid" + ? blocked("The AI provider rejected the key.") + : { + state: "unknown", + said: "The AI provider key has not been checked.", + }, + }; +} From d593c8afe900bccfcd4e329ac24b0352b62953a2 Mon Sep 17 00:00:00 2001 From: Dusty Date: Wed, 21 Oct 2026 11:49:15 -0700 Subject: [PATCH 04/77] docs: local evaluation installer requirements v2 and CLI contract requests The v2 requirements artifact (decisions, architecture, coding sequence, progress and release gates), the original r21 artifact unchanged, the implementation handoff, and the contract requests for the Intelligence CLI owners. --- .../openbot-local-eval-requirements-r21.html | 727 +++++++++++++++++ .../openbot-local-eval-requirements-v2.html | 732 ++++++++++++++++++ .../local-eval-cli-contract-requests.md | 194 +++++ docs/handoffs/local-eval-implementation-v2.md | 89 +++ 4 files changed, 1742 insertions(+) create mode 100644 docs/artifacts/openbot-local-eval-requirements-r21.html create mode 100644 docs/artifacts/openbot-local-eval-requirements-v2.html create mode 100644 docs/handoffs/local-eval-cli-contract-requests.md create mode 100644 docs/handoffs/local-eval-implementation-v2.md diff --git a/docs/artifacts/openbot-local-eval-requirements-r21.html b/docs/artifacts/openbot-local-eval-requirements-r21.html new file mode 100644 index 0000000000..d7e4ece0b1 --- /dev/null +++ b/docs/artifacts/openbot-local-eval-requirements-r21.html @@ -0,0 +1,727 @@ + + + + +OpenBot · Local installer requirements · Revision 2 + + + + + + + + +
OpenBot · Installer requirementsRevision 2 · 20 Oct 2026Runtime proposal · unvalidated

Local installer requirements

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.

StepUser actionInstaller / Desktop behavior
1 · DownloadDownload the small platform installer.Select the correct architecture and fetch the versioned installation manifest.
2 · Install everythingStart 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 OpenBotOpen OpenBot, or accept automatic launch.Open the installed application. Verify the prepared runtime and retained state.
4 · CopilotKit sign-inComplete 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 providerChoose a supported provider and supply required credentials.Validate the key with a minimal request and store it in OS-backed secure storage.
6 · SuccessUse 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.

AreaEvidence levelFindingSource
Automated Docker installationDocumentedmacOS has a command-line installer. Windows supports quiet installation and licence-acceptance flags. These commands can run inside our installer.macOS install · Windows install
Windows first-time setupDocumentedWSL enablement needs elevated permission and can require a reboot. The installer must own the operation and resume afterward.Windows permissions · WSL installation
Docker Desktop licensingDocumentedProfessional use in larger organizations and government use require paid subscriptions. An OpenBot evaluation licence does not establish Docker Desktop entitlement.Docker Desktop licence
Docker Desktop distributionUnresolved for this productThe 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.Docker agreement
Automatic Desktop resource settingsIntegration gapThe documented enterprise JSON management path requires Docker Business and organization sign-in. A general evaluator installer needs a separately validated configuration path.Settings management
Private macOS runtimeDocumented building blocksLima provides binary archives, a Docker VM template, socket forwarding, and a native macOS virtualization backend. Product packaging and OpenBot compatibility remain untested.Lima installation · Docker template · VZ backend
Private Windows runtimeDocumented building blocksWSL can import a named Linux distribution. Packaging Docker Engine inside an OpenBot-owned distribution is a proposed design, not a tested product capability.WSL distribution import
Global WSL settingsDocumented constraintThe .wslconfig file applies across WSL 2 distributions. An OpenBot distribution does not imply isolated host resource settings.WSL configuration
Podman with IntelligenceExperimental upstreamk3d documents Podman support as experimental and gives no compatibility guarantee. Existing OpenBot Podman support does not validate Intelligence.k3d Podman support
Hardware / organization limitsDocumented constraintFirmware 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.WSL troubleshooting

Runtime options

Recommendation: validate option B first. It removes a separate Desktop product from the required install path.

OptionWhat it meansTradeoffAssessment
A · Automate Docker DesktopOur bootstrapper downloads, installs, configures, and starts Docker Desktop.Least change to the original runtime assumption. Adds Desktop terms, possible subscription entitlement, and configuration/distribution integration.Technically plausible. Conditional on an acceptable licence and deployment arrangement.
B · Product-managed Docker EnginemacOS: private Lima/VZ VM. Windows: private WSL 2 distribution. Install pinned Docker Engine, CLI tools, and Compose.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 flowExtend 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.

Revised requirements

User corrections supersede the original manual-Docker and manual-resource prerequisites. R01–R12 are presentation references.

RefAreaRequired behavior
R01Dependency installationNo preinstalled Docker, CLI, package manager, or developer tools. Download verified assets automatically. No terminal commands or manual settings.
R02Bootstrap and resumeSmall signed installer, versioned manifest, pinned digests, resumable downloads, progress, retry, and persisted installation checkpoints. Treat installer size separately from total download size.
R03OS setupRequest 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.
R04Runtime ownershipDesktop 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.
R05ResourcesCheck 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.
R06Authentication and licenceRun 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.
R07CLI contractCLI 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.
R08Provider and chatChoose 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.
R09NetworkingPublish 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.
R10LifecycleStart 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.
R11Renewal and diagnosticsWarn from day 25. Explicit renewal and EULA acceptance only. User-reviewed local diagnostics redact credentials, auth codes, chat, provider secrets, and persistent application data.
R12Release evidenceClean-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.
CodeExisting behaviorRequired change
install.rsDownloads pinned Podman, Compose, and Bun assets. Supports platform installation.Reuse verified-download mechanics. Add the selected Docker runtime package path. No managed Docker Desktop installation is established here.
acquire.rsCreates and starts a named OpenBot Podman VM with resource arguments.Reuse ownership and health patterns. Implement the selected runtime adapter and explicit resource configuration.
windows.rsDetects WSL/virtualization blockers and stores setup state. Instructions currently require manual commands.Implement OS feature enablement, elevation, relaunch registration, and automatic resume. State persistence alone is insufficient.
preparation.rsSeparates installation checkpoints from sign-in and runtime state.Extend checkpoints to track all runtime and bootstrap stages without credentials.
engine.rsSupports Docker and Podman with explicit runtime selection.Pass the owned endpoint/context into OpenBot and the Intelligence CLI. Verify context and socket behavior on both platforms.
intelligence.rsDesktop implements browser callback, exchange, and project-key flow.Move local-eval credential and licence ownership behind the supported CLI contract.
vault.rsmacOS owner-only secret file; Windows DPAPI.Replace file-based macOS secret storage with an OS-backed store. Review all end-to-end secret handling.

Validation before selecting the release runtime

This is the first delivery gate. No local dependency was installed or OS setting changed during this research.

GateEvidence requiredWhat failure means
Clean machinesmacOS Apple silicon and Windows x64 with no Docker, package manager, or CLI. Windows case starts with WSL absent.Do not claim a single-installer flow until both pass. Intel Mac and Windows ARM scope remain open.
Consent and resumeInstaller performs feature setup, survives reboot and interrupted downloads, and resumes as the user with no commands.A manual-command recovery path fails acceptance.
Actual stack compatibilityRun the real Intelligence CLI/k3d and OpenBot stack against one explicit owned Docker Engine endpoint.Adapt the CLI or runtime before committing to the candidate. CLI source was not supplied in this review.
Networking and storageVerify loopback publication, host callbacks, mounts, Docker socket access, retained volumes, and isolation from other runtimes.Basic docker run success is insufficient.
Capacity and timingRecord total payload size, download time, provisioning peaks, and end-to-end time including restart and user inputs under stated test conditions.Keep under 15 minutes as a target until measured. The small bootstrapper does not imply small downloads.
Packaging and maintenanceSigned/notarized macOS assets, signed Windows installer, complete component notices, pinned artifacts, upgrade/rollback, and security update ownership.The product must own runtime maintenance. Static Engine binaries do not update themselves. Docker binary-install guidance

Delivery order

Runtime validation precedes the remaining installer slices.

    Proposed sequence updates the four original slices with an explicit runtime-validation gate.

    Inputs: grillbot-r21.md plus the user’s corrections in this conversation: install all dependencies; install before sign-in; no manual Docker/settings/commands; OS consent and automatic restart/resume accepted.
    Research: official Docker, Microsoft WSL, Lima, and k3d documentation, accessed 20 October 2026. Evidence links appear beside findings.
    Code: dustywusty/openbot origin/main · 3c73cf00efba46122dfd0447485e2b61f1d6a2cd · 23 September 2026. The Intelligence CLI repository was not provided or inspected.
    Status: revised requirements reflect user direction. The private Docker Engine runtime is an author recommendation, not an approved or tested implementation. No clean-machine benchmark or installation ran. Other r21 constraints remain: single user, macOS/Windows, local stacks, no SSO or managed provider credentials, no distribution gating, per-user computer migration separate.
    Unresolved: runtime selection, exact CLI contract, Intel Mac / Windows ARM scope, combined resource peaks, timing, packaging, and security/privacy review.
    + + + + + + + + diff --git a/docs/artifacts/openbot-local-eval-requirements-v2.html b/docs/artifacts/openbot-local-eval-requirements-v2.html new file mode 100644 index 0000000000..e66eb4af82 --- /dev/null +++ b/docs/artifacts/openbot-local-eval-requirements-v2.html @@ -0,0 +1,732 @@ + + + + +OpenBot local installer · v2 + + + + + + + + +
    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.

    StepInstaller / app behaviorUser input
    Download / installFetch 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 OpenBotOpen the installed app. Recover installation state and verify runtime ownership/health.Open the app or accept automatic launch.
    CopilotKit sign-inUse 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 providerValidate the evaluator’s key with a minimal request; store it only in OS-backed secure storage.Choose a supported provider and enter required credentials.
    SuccessActivate 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.

    AreaDecision
    First releaseApple silicon macOS and Windows x64. Intel Macs and Windows ARM are deferred until validated. Linux is outside this installer release.
    RuntimeDocker 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.
    FallbackPodman 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 contingencyThe 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.
    OwnershipDesktop 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 setupStandard 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 boundaryPrepare 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 / organizationThe 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 / timingInitial 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 / renewalUse 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.
    InstallationsOne 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 / scopeRetain 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.

    Contracts and current gaps

    Reuse existing CLI output where it exists. Add missing supported contracts instead of inferring them from files or containers.

    BoundaryExisting sourceRequired implementation
    Status and lifecycleCLI 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 licensedOuter 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.
    Credential / EULA handoffExisting Desktop implements sign-in itself; CLI has its own setup/EULA policy. Desktop auth · current eval policy.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.
    WindowsCurrent 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 configurationOpenBot 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 / storageThe 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.

    TriggerBehavior
    StartVerify runtime → ask CLI to start Intelligence → wait for reported readiness/licence → start OpenBot → gate chat on provider validation.
    StopStop OpenBot first, then ask CLI to stop Intelligence. Stop an installer-owned runtime only when safe; never stop unrelated workloads.
    FailureShow independent runtime/Intelligence/OpenBot health. Offer targeted Retry, Restart Intelligence, Restart OpenBot, and local diagnostics review.
    Provider failureRemain on provider setup, identify provider and failure category, allow key edits or another provider, keep Bot activation/chat blocked.
    PortsCLI owns Intelligence endpoints. Desktop retains separate loopback ports for OpenBot. Provide recoverable conflicts without container inspection.
    DiagnosticsLocal 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.

    OptionRole nowSwitch condition
    PodmanFirst 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 EngineSecondary 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 executionLarger 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.

    OrderDeliverableAcceptance
    1 · Wire contractNormalize 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 adapterPinned 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 statePersist 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 extensionsCoordinate 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.
    5 · Full integrationAutomatic Docker Desktop setup/resources, CLI provisioning, provider validation, lifecycle, secure storage and diagnostics.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.

    + + + + + + +
    SliceStateEvidence / next
    1–2 · Wire contract and process adapterDone. 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 lifecyclePartly 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.
    WindowLocal 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 contractsDraft 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 testingUpstream 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 integrationNot 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.

    GateRequired evidenceOwner role
    Docker Desktop termsConfirm entitlement, distribution/embedding approach and explicit user acceptance. No assumption that the OpenBot eval licence covers Docker Desktop.Release/product owner
    CLI contracts and WindowsSupported 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 payloadsConfirm a distributable pre-auth manifest/cache for all required software, or identify protected assets and resolve the packaging agreement.Release + CLI owners
    Automatic runtime resourcesProve supported settings/configuration on new and retained runtimes, including enterprise policy blocks and noninterference with user workloads.Installer owner
    Signed installersmacOS 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 / privacyOS-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 qualificationCombined provisioning/runtime peaks, actual payload and install time under stated conditions; test insufficient resources and recoverable failures.Platform test owner
    End-to-end release matrixBrowser 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.

      Inputs: grillbot-r21.md and this conversation. Latest direction: automatic installation, Docker Desktop primary, Podman fallback investigation, Apple silicon + Windows x64 first, factual presentation.
      Code: OpenBot origin/main 3c73cf00efba46122dfd0447485e2b61f1d6a2cd (23 Sep 2026); Intelligence origin/main 3935afbfe3cda88dfeabfd0250db08ff0ad51000 (fetched 20 Oct 2026). local commands · current eval policy.
      Status: design and implementation work plan. The native CLI boundary (local_eval.rs) and typed client are a local working-tree draft; neither the full installer nor the fallback is qualified. The original r21 artifact remains unchanged. Research details remain in reports/OpenBot without Docker.md; strict Docker-free/no-container research is not the implementation baseline.
      Podman tab: Podman fallback investigation · same worktree · results under research_notes/Podman fallback investigation/. No default switch depends on an unmeasured claim of lower resource usage.
      + + + + 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.

      + {intelligenceMode === "local" && localIntelligence(localAutoStart)} {displayedFailure && } - + {intelligenceMode === "hosted" && ( + + )} - + )} )} @@ -1400,7 +1456,7 @@ export function App() { Stop OpenBot - ) : ( + ) : intelligenceMode === "local" ? null : ( - ); + function pause(ms: number, token: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + wake.current = null; + resolve(generation.current === token); + }, ms); + wake.current = () => { + clearTimeout(timer); + wake.current = null; + resolve(false); + }; + }); + } + + /** + * Status until the CLI reports ready and valid, or anything that waiting will not change. + * Only "starting" is waited on: unknown is what an older CLI always says. A stopped stack is + * started once, as its own readiness row offers. + */ + async function waitForReady( + id: string, + token: number, + ): Promise { + const report = (action: "status" | "start") => { + const started = runLocalEval(action, id); + return call({ attempt: started.attempt, answer: started.report }, token); + }; + const stuck = (found: LocalReport) => { + if (generation.current === token) { + setBlocked({ said: said(found), reason: found.reason }); + } + return null; + }; + let startedIntelligence = false; + for (let tries = 0; ; tries += 1) { + const found = await report("status"); + if (!found) return null; + if (isReady(found)) { + if (!found.stack) { + setBlocked({ said: said("missing-stack"), reason: null }); + return null; + } + return { + apiUrl: found.stack.urls.api, + gatewayWsUrl: found.stack.urls.gateway, + }; + } + const row = localReadiness({ + runtime: "unknown", + intelligence: found, + openBot: "unknown", + provider: "unvalidated", + }).intelligence; + if (row.action === "start-intelligence" && !startedIntelligence) { + startedIntelligence = true; + const started = await report("start"); + if (!started) return null; + if (started.outcome !== "completed") return stuck(started); + continue; + } + if (found.readiness !== "starting" || row.state === "blocked") { + return stuck(found); + } + if (tries >= pollDelays.length) { + setBlocked({ + said: "Intelligence did not report ready in time.", + reason: null, + }); + return null; + } + if (!(await pause(pollDelays[tries], token))) return null; } - if (row.action === "restart-intelligence" && stack) { - return ( - - ); + } + + async function run(from: Phase): Promise { + halt(); + const token = generation.current; + setBlocked(null); + setProblem(null); + let id = stack; + const stop = (reason?: { said: string; reason?: LocalReason | null }) => { + if (generation.current !== token) return; + if (reason) { + setBlocked({ said: reason.said, reason: reason.reason ?? null }); + } + setRunning(false); + }; + for (const [phase] of PHASES.slice(PHASES.findIndex(([p]) => p === from))) { + if (generation.current !== token) return; + setAt(phase); + setRunning(true); + if (phase === "setup") { + const started = setUpLocalEval(root); + const report = await call( + { attempt: started.attempt, answer: started.report }, + token, + ); + if (!report) return stop(); + if (report.outcome !== "completed") { + return stop({ said: said(report), reason: report.reason }); + } + if (!report.stack) return stop({ said: said("missing-stack") }); + id = report.stack.id; + setStack(id); + } + if (phase === "connect") { + const started = connectLocalEval(root); + const answered = await call( + { attempt: started.attempt, answer: started.connection }, + token, + ); + if (!answered) return stop(); + if (answered.outcome !== "completed" || answered.conflicted) { + return stop({ + said: connectionSaid(answered), + reason: answered.reason, + }); + } + id = answered.stack?.id ?? id; + if (!id) return stop({ said: said("missing-stack") }); + setStack(id); + setConnected(true); + onConnected(id); + } + if (phase === "wait") { + if (!id) return stop({ said: said("missing-stack") }); + const found = await waitForReady(id, token); + if (!found) return stop(); + startable.current = found; + } + if (phase === "start") { + const target = startable.current; + if (!target) return run("wait"); + if (!(await onStart(target))) { + return stop({ said: "OpenBot did not start." }); + } + } } - return null; + stop(); + } + + function cancel() { + halt(); + setRunning(false); + setBlocked({ said: said("cancelled"), reason: null }); } + const from: Phase = stack && connected ? "wait" : "setup"; + // Deferred a tick, so a development double mount runs the CLI once. + // biome-ignore lint/correctness/useExhaustiveDependencies: runs when auto is turned on. + useEffect(() => { + if (!auto || at !== null) return; + const timer = setTimeout(() => void run(from), 0); + return () => clearTimeout(timer); + }, [auto]); + + const reached = at ? PHASES.findIndex(([phase]) => phase === at) : -1; + const steps: Progress[] = PHASES.slice(0, reached + 1).map( + ([phase, label], index) => + index < reached + ? { step: label, ok: true, detail: "" } + : running + ? { step: label, ok: true, running: true, detail: "" } + : blocked || problem + ? { step: label, ok: false, detail: blocked?.said ?? "" } + : { step: label, ok: phase === "start", detail: "" }, + ); + const stopped = !running && at !== null && (blocked || problem); + const reason = stopped ? blocked?.reason : null; + return (
      -

      - Intelligence runs on this computer in Docker Desktop. The Intelligence - CLI sets it up and manages it. -

      -
        - {( - [ - ["Docker Desktop", rows.runtime], - ["Intelligence", rows.intelligence], - ["OpenBot", rows.openBot], - ["AI provider", rows.provider], - ] as const - ).map(([label, row]) => ( -
      • - {label} {row.said} {act(row)} -
      • - ))} -
      - {stack &&

      Stack {stack}

      } - {termsPending && + + {reason === "eula-acceptance-required" && (developer ? (

      Development build: run copilotkit local accept-terms in - Terminal, then choose Set up local Intelligence again. + Terminal, then choose Retry.

      ) : (

      Accepting the evaluation terms in OpenBot is not available yet.

      ))} -

      - OpenBot cannot start against local Intelligence yet. The Intelligence - CLI does not report readiness or give OpenBot a credential. -

      - {problem && } + {reason === "authentication-required" && developer && ( +

      + Development build: run copilotkit login in Terminal, then + choose Retry. +

      + )} + {stopped && problem && }
      - {busy ? ( - - ) : ( - <> - {(!stack || report?.outcome !== "completed") && ( - - )} - {stack && ( - - )} - - + )} + {stopped && at && ( + + )} + {!running && at === null && !auto && ( + + )} + {!(running && at === "start") && ( + )}
      diff --git a/desktop/src/SetupProgress.tsx b/desktop/src/SetupProgress.tsx new file mode 100644 index 0000000000..c7e5fbed16 --- /dev/null +++ b/desktop/src/SetupProgress.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; + +/** One setup step, as the native side reports it or the window runs it. */ +export type Progress = { + step: string; + ok: boolean; + detail: string; + running?: boolean; + downloadBytes?: number; +}; + +export function SetupProgress({ steps }: { steps: Progress[] }) { + if (steps.length === 0) return null; + return ( +
        + {steps.map((step) => ( + + ))} +
      + ); +} + +function SetupProgressRow({ step }: { step: Progress }) { + const [elapsed, setElapsed] = useState(0); + const running = step.running ?? false; + useEffect(() => { + if (!running) return; + const started = Date.now(); + setElapsed(0); + const timer = setInterval( + () => setElapsed(Math.floor((Date.now() - started) / 1000)), + 1000, + ); + return () => clearInterval(timer); + }, [running]); + return ( +
    1. +
    2. + ); +} + +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" && (
      {/* Sign in on the main path; paste behind the disclosure. @@ -1382,9 +1440,10 @@ export function App() { )}
      @@ -1394,7 +1453,7 @@ export function App() { {displayedFailure && } - {!running && !returningToInstallation && ( + {!running && !returningToInstallation && !localSequenceShown && (
      Installation options ) : intelligenceMode === "local" ? null : ( )} - {!(running && at === "start") && ( - - )} ); } + +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 ( +
      + Where Intelligence runs + {rows.map(([mode, name, summary]) => ( + + ))} +
      + ); +} 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 (

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

      @@ -866,7 +944,25 @@ export function App() { if (!running && step === "welcome") { return (
      - setStep("harness")} /> + setStep("harness")} + local={ + localAvailable + ? { + onInstall: () => { + chooseIntelligence("local"); + // The default Bot, recorded as its Continue would. + recordSetupEvent(harnessChoiceEvent(DEFAULT_HARNESS)); + void installLocally(); + }, + onHosted: () => { + chooseIntelligence("hosted"); + setStep("harness"); + }, + } + : undefined + } + /> {displayedFailure && }
      ); @@ -908,59 +1004,75 @@ export function App() { return (
      -

      Step 2 of 4

      +

      + {localFlow ? "Step 1 of 3" : "Step 2 of 4"} +

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

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

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

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

      )} -
      - - { - configuredRunRef.current += 1; - setRoot(event.target.value); - setModel(null); - setPreparation(null); - setSteps([]); - clearRootScopedSavedState(); - }} - onBlur={(event) => loadConfiguredRoot(event.target.value)} - spellCheck={false} - /> -
      - + {!localFlow && ( +
      + + { + configuredRunRef.current += 1; + setRoot(event.target.value); + setModel(null); + setPreparation(null); + setSteps([]); + clearRootScopedSavedState(); + }} + onBlur={(event) => loadConfiguredRoot(event.target.value)} + spellCheck={false} + /> +
      + )} + {displayedFailure && } - {busy &&

      Installing local software…

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

      Installing local software…

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

      + {localFlow ? "Step 3 of 3" : "Step 4 of 4"} +

      + )} +

      Getting ready

      +

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

      + + {displayedFailure && } + {!local.running && !busy && ( +
      + + {localAvailable && ( + + )} +
      + )} +
      ); } @@ -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" && (
      {/* @@ -1453,7 +1614,7 @@ export function App() { {displayedFailure && } - {!running && !returningToInstallation && !localSequenceShown && ( + {!running && !returningToInstallation && (
      Installation options - ) : intelligenceMode === "local" ? null : ( + ) : ( + )} + {failure && ( + <> + + + + )} + + + ); + } + return (
      -

      Last step

      +

      {stepLabel}

      Ask it something.

      Your Bot is set up. This proves it can answer before you start using it. diff --git a/desktop/src/LocalIntelligence.test.tsx b/desktop/src/LocalIntelligence.test.tsx index 104b34a046..a01b96bfb1 100644 --- a/desktop/src/LocalIntelligence.test.tsx +++ b/desktop/src/LocalIntelligence.test.tsx @@ -2,6 +2,7 @@ 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 { useEffect } from "react"; import type { LocalConnection, LocalReport } from "./localEvalCli"; type Invoke = (command: string, args?: unknown) => Promise; @@ -18,7 +19,9 @@ mock.module("@tauri-apps/api/core", () => ({ }, })); -const { LocalIntelligence } = await import("./LocalIntelligence"); +const { LocalIntelligence, LocalStatus, useLocalSequence } = await import( + "./LocalIntelligence" +); beforeAll(() => GlobalRegistrator.register()); afterEach(() => { @@ -88,31 +91,48 @@ function deferred() { type Startable = { apiUrl: string; gatewayWsUrl: string }; -function view( - props: { - stack?: string | null; - developer?: boolean; - connected?: boolean; - auto?: boolean; - onStart?: (startable: Startable) => Promise; - onCancel?: () => void; - pollDelays?: number[]; - } = {}, -) { - return render( - true)} - pollDelays={props.pollDelays ?? [0, 0, 0]} - />, +type Props = { + stack?: string | null; + developer?: boolean; + connected?: boolean; + /** Gives the go-ahead on arrival. Without it, Start OpenBot does. */ + auto?: boolean; + /** Runs up to ready on arrival, without the go-ahead, under its status line. */ + prepare?: boolean; + onStart?: (startable: Startable) => Promise; + pollDelays?: number[]; +}; + +/** The window's part: it holds the sequence, and a screen shows it. */ +function Holder(props: Props) { + const sequence = useLocalSequence({ + root: "/tmp/openbot", + stack: props.stack ?? null, + connected: props.connected ?? false, + onStart: props.onStart ?? (async () => true), + pollDelays: props.pollDelays ?? [0, 0, 0], + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: once, on arrival. + useEffect(() => { + if (props.prepare) sequence.prepare(); + else if (props.auto ?? true) sequence.start(); + }, []); + return ( + <> + {props.prepare && } + + ); } +function view(props: Props = {}) { + return render(); +} + /** Answers each command from its own queue; the last answer repeats. */ function answers(queues: Record): Invoke { return async (command) => { @@ -144,7 +164,7 @@ function row(screen: ReturnType, name: string) { return screen.getByRole("listitem", { name }); } -const SETUP = "Setting up Intelligence on this computer"; +const SETUP = "Setting up Intelligence"; const CONNECT = "Connecting OpenBot"; const WAIT = "Waiting for Intelligence"; const START = "Starting OpenBot"; @@ -254,11 +274,11 @@ test("a blocked or conflicted connection stops at its row, and Retry resumes at for (const [answer, said] of [ [ connection({ outcome: "blocked", reason: "confirmation-required" }), - "The Intelligence CLI asked for a confirmation OpenBot cannot give.", + "Intelligence asked for a confirmation OpenBot cannot give.", ], [ connection({ outcome: "blocked", conflicted: true }), - "Settings the Intelligence CLI wrote for OpenBot were changed since. OpenBot did not connect.", + "Settings Intelligence wrote for OpenBot were changed since. OpenBot did not connect.", ], ] as const) { invokeHandler = answers({ @@ -364,7 +384,7 @@ test("Cancel stops the running CLI call by its own attempt, and Retry resumes th }); await waitFor(() => expect(row(screen, SETUP).textContent).toContain( - "The Intelligence command was stopped before it finished.", + "Stopped at your request before it finished.", ), ); invokeHandler = answers({ set_up_local_eval: [blockedOnTerms] }); @@ -456,22 +476,41 @@ test("without auto, nothing runs until Start OpenBot is chosen", async () => { await waitFor(() => expect(started).toEqual([startableUrls])); }); -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("prepare runs to ready and waits for the go-ahead, which starts at once", async () => { + invokeHandler = answers({ + set_up_local_eval: [report()], + connect_local_eval: [connection()], + run_local_eval: [ready], + }); + const started: Startable[] = []; + const screen = view({ + prepare: true, + onStart: async (startable) => { + started.push(startable); + return true; + }, + }); + await screen.findByText("Intelligence is ready."); + expect(row(screen, WAIT).textContent).toContain("Complete"); + expect(screen.queryByRole("listitem", { name: START })).toBeNull(); + expect(started).toEqual([]); + // No Start OpenBot once it has run: the go-ahead comes from the screen in front. + expect(screen.queryByRole("button")).toBeNull(); + cleanup(); + invokeCalls = []; +}); + +test("the status line names the running row and its elapsed time", async () => { + invokeHandler = answers({ + set_up_local_eval: [new Promise(() => {})], + cancel_local_eval: [null], + }); + const screen = view({ prepare: true }); + await waitFor(() => + expect(screen.getByRole("status").textContent).toBe( + "Setting up Intelligence · 0s elapsed", + ), + ); }); test("the running screen offers no way to the hosted service", async () => { diff --git a/desktop/src/LocalIntelligence.tsx b/desktop/src/LocalIntelligence.tsx index 9a090cabc4..146a8e10d9 100644 --- a/desktop/src/LocalIntelligence.tsx +++ b/desktop/src/LocalIntelligence.tsx @@ -13,7 +13,12 @@ import { } from "./localEvalCli"; import { localReadiness } from "./localReadiness"; import { asProblem, Failure, type Problem } from "./Problem"; -import { type Progress, SetupProgress } from "./SetupProgress"; +import { + formatElapsed, + type Progress, + SetupProgress, + useElapsed, +} from "./SetupProgress"; type Call = { attempt: string; answer: Promise }; @@ -27,13 +32,13 @@ export type LocalStartable = { apiUrl: string; gatewayWsUrl: string }; const POLL_DELAYS: number[] = [2_000, 4_000, 8_000, ...Array(12).fill(15_000)]; const unreadable: Problem = { - said: "OpenBot did not understand the Intelligence CLI's answer.", + said: "OpenBot did not understand Intelligence's answer.", }; type Phase = "setup" | "connect" | "wait" | "start"; const PHASES: [Phase, string][] = [ - ["setup", "Setting up Intelligence on this computer"], + ["setup", "Setting up Intelligence"], ["connect", "Connecting OpenBot"], ["wait", "Waiting for Intelligence"], ["start", "Starting OpenBot"], @@ -52,7 +57,7 @@ function said(intelligence: LocalReport | LocalFailure): string { function connectionSaid(answered: LocalConnection): string { // A conflict has no reason of its own, so it is said in this window's words. if (answered.conflicted) { - return "Settings the Intelligence CLI wrote for OpenBot were changed since. OpenBot did not connect."; + return "Settings Intelligence wrote for OpenBot were changed since. OpenBot did not connect."; } return said({ outcome: answered.outcome, @@ -72,65 +77,98 @@ function isReady(report: LocalReport): boolean { }).open; } +type State = { + at: Phase | null; + running: boolean; + /** When the current row began, so its elapsed time survives a change of screen. */ + since: number | null; + blocked: { said: string; reason: LocalReason | null } | null; + problem: Problem | null; +}; + +const idle: State = { + at: null, + running: false, + since: null, + blocked: null, + problem: null, +}; + +export type LocalSequence = State & { + /** Set up, connected and reported ready and valid, and waiting for the go-ahead to start. */ + ready: boolean; + /** Stopped at its row with a reason; Retry resumes there. */ + stopped: boolean; + steps: Progress[]; + /** Runs set up, connect and wait, and stops before Start. */ + prepare: () => void; + /** The go-ahead: runs what is left and starts OpenBot once Intelligence is ready. */ + start: () => void; + retry: () => void; + /** Stops the running CLI call and says so. */ + cancel: () => void; + /** Forgets the rows, as after Stop OpenBot. */ + reset: () => void; +}; + /** * Intelligence on this computer, run by the Intelligence CLI, then OpenBot started against it. * - * 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 + * Held by the window rather than by a screen, so moving between screens never cancels it. Four + * steps, each idempotent, run in order without a button: set up, connect (the installation is the * consent, and the native side keeps the key the CLI wrote), wait until the CLI itself reports - * readiness "ready" and licence "valid", then start OpenBot. 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. + * readiness "ready" and licence "valid", then start OpenBot. `prepare` runs the first three while + * the person chooses an AI provider; Start waits for `start`. A recorded, connected stack starts + * at waiting. A step that is blocked or fails stops there with its reason, and Retry resumes at + * that step. A CLI that does not report ready and valid never reaches Start. */ -export function LocalIntelligence({ +export function useLocalSequence({ root, - stack: recorded, - connected: recordedConnection = false, - auto, - developer = import.meta.env.DEV, - onCancel, + stack, + connected = false, onStart, - onConnected = () => {}, + onRecorded = () => {}, pollDelays = POLL_DELAYS, }: { root: string; stack: string | null; /** OpenBot already holds the key `local connect` gave it for this stack. */ connected?: boolean; - /** Run the steps as soon as this is shown. Otherwise they wait for Start OpenBot. */ - auto: boolean; - developer?: boolean; - /** 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; + /** A stack was set up, or connected. */ + onRecorded?: (stack: string, connected: boolean) => void; pollDelays?: number[]; -}) { - const [stack, setStack] = useState(recorded); - const [connected, setConnected] = useState(recordedConnection); - const [at, setAt] = useState(null); - const [running, setRunning] = useState(false); - const [blocked, setBlocked] = useState<{ - said: string; - reason: LocalReason | null; - } | null>(null); - const [problem, setProblem] = useState(null); +}): LocalSequence { + const [state, setState] = useState(idle); + const now = useRef(state); + const latest = useRef({ root, stack, connected, onStart, onRecorded }); + latest.current = { root, stack, connected, onStart, onRecorded }; // Bumped to stop a run: by Cancel, by leaving, and by any newer run. const generation = useRef(0); const attempt = useRef(null); const wake = useRef<(() => void) | null>(null); + const queued = useRef | null>(null); const startable = useRef(null); + // Start OpenBot once Intelligence is ready. Spent when it starts. + const go = useRef(false); + + function update(patch: Partial) { + now.current = { ...now.current, ...patch }; + setState(now.current); + } function halt() { generation.current += 1; wake.current?.(); + if (queued.current !== null) clearTimeout(queued.current); + queued.current = null; const inFlight = attempt.current; attempt.current = null; if (inFlight) void cancelLocalEval(inFlight); } - // Leaving stops whatever is running, the CLI call included. + // Closing the window stops whatever is running, the CLI call included. // biome-ignore lint/correctness/useExhaustiveDependencies: once, on leaving. useEffect(() => halt, []); @@ -143,10 +181,10 @@ export function LocalIntelligence({ } catch (error) { if (generation.current !== token) return null; if (error instanceof LocalEvalError) { - if (error.category === "unrecognized") setProblem(unreadable); - else setBlocked({ said: said(error.category), reason: null }); + if (error.category === "unrecognized") update({ problem: unreadable }); + else update({ blocked: { said: said(error.category), reason: null } }); } else { - setProblem(asProblem(error)); + update({ problem: asProblem(error) }); } return null; } finally { @@ -183,7 +221,7 @@ export function LocalIntelligence({ }; const stuck = (found: LocalReport) => { if (generation.current === token) { - setBlocked({ said: said(found), reason: found.reason }); + update({ blocked: { said: said(found), reason: found.reason } }); } return null; }; @@ -193,7 +231,7 @@ export function LocalIntelligence({ if (!found) return null; if (isReady(found)) { if (!found.stack) { - setBlocked({ said: said("missing-stack"), reason: null }); + update({ blocked: { said: said("missing-stack"), reason: null } }); return null; } return { @@ -218,9 +256,11 @@ export function LocalIntelligence({ return stuck(found); } if (tries >= pollDelays.length) { - setBlocked({ - said: "Intelligence did not report ready in time.", - reason: null, + update({ + blocked: { + said: "Intelligence did not report ready in time.", + reason: null, + }, }); return null; } @@ -231,20 +271,23 @@ export function LocalIntelligence({ async function run(from: Phase): Promise { halt(); const token = generation.current; - setBlocked(null); - setProblem(null); - let id = stack; + update({ blocked: null, problem: null }); + const { root } = latest.current; + let id = latest.current.stack; const stop = (reason?: { said: string; reason?: LocalReason | null }) => { if (generation.current !== token) return; - if (reason) { - setBlocked({ said: reason.said, reason: reason.reason ?? null }); - } - setRunning(false); + update({ + running: false, + ...(reason + ? { blocked: { said: reason.said, reason: reason.reason ?? null } } + : {}), + }); }; for (const [phase] of PHASES.slice(PHASES.findIndex(([p]) => p === from))) { if (generation.current !== token) return; - setAt(phase); - setRunning(true); + // Ready, and waiting for the go-ahead. + if (phase === "start" && !go.current) return stop(); + update({ at: phase, running: true, since: Date.now() }); if (phase === "setup") { const started = setUpLocalEval(root); const report = await call( @@ -257,7 +300,7 @@ export function LocalIntelligence({ } if (!report.stack) return stop({ said: said("missing-stack") }); id = report.stack.id; - setStack(id); + latest.current.onRecorded(id, false); } if (phase === "connect") { const started = connectLocalEval(root); @@ -274,9 +317,7 @@ export function LocalIntelligence({ } id = answered.stack?.id ?? id; if (!id) return stop({ said: said("missing-stack") }); - setStack(id); - setConnected(true); - onConnected(id); + latest.current.onRecorded(id, true); } if (phase === "wait") { if (!id) return stop({ said: said("missing-stack") }); @@ -287,47 +328,120 @@ export function LocalIntelligence({ if (phase === "start") { const target = startable.current; if (!target) return run("wait"); - if (!(await onStart(target))) { + if (!(await latest.current.onStart(target))) { return stop({ said: "OpenBot did not start." }); } + go.current = false; } } stop(); } + const first = (): Phase => + latest.current.stack && latest.current.connected ? "wait" : "setup"; + const stopped = + !state.running && + state.at !== null && + (state.blocked !== null || state.problem !== null); + const ready = !state.running && state.at === "wait" && !stopped; + + function prepare() { + if (now.current.running) return; + void run(first()); + } + + function start() { + go.current = true; + const { at, running, blocked, problem } = now.current; + // A running sequence starts OpenBot when it gets there; a stopped one waits for Retry. + if (running || blocked || problem) return; + const from: Phase = at === "wait" ? "start" : first(); + // Deferred a tick: the screen that gave the go-ahead has rendered what it chose by then, and a + // development double mount runs the CLI once. + update({ running: true }); + queued.current = setTimeout(() => { + queued.current = null; + void run(from); + }, 0); + } + + function retry() { + const { at } = now.current; + if (at) void run(at); + } + function cancel() { halt(); - setRunning(false); - if (onCancel) return onCancel(); - setBlocked({ said: said("cancelled"), reason: null }); + // Before its first row, nothing ran: there is nothing to retry. + update( + now.current.at === null + ? { running: false } + : { + running: false, + blocked: { said: said("cancelled"), reason: null }, + }, + ); } - const from: Phase = stack && connected ? "wait" : "setup"; - // Deferred a tick, so a development double mount runs the CLI once. - // biome-ignore lint/correctness/useExhaustiveDependencies: runs when auto is turned on. - useEffect(() => { - if (!auto || at !== null) return; - const timer = setTimeout(() => void run(from), 0); - return () => clearTimeout(timer); - }, [auto]); + function reset() { + halt(); + go.current = false; + startable.current = null; + update(idle); + } - const reached = at ? PHASES.findIndex(([phase]) => phase === at) : -1; + const reached = state.at + ? PHASES.findIndex(([phase]) => phase === state.at) + : -1; const steps: Progress[] = PHASES.slice(0, reached + 1).map( - ([phase, label], index) => + ([, label], index) => index < reached ? { step: label, ok: true, detail: "" } - : running - ? { step: label, ok: true, running: true, detail: "" } - : blocked || problem - ? { step: label, ok: false, detail: blocked?.said ?? "" } - : { step: label, ok: phase === "start", detail: "" }, + : state.running + ? { + step: label, + ok: true, + running: true, + detail: "", + startedAt: state.since ?? undefined, + } + : stopped + ? { step: label, ok: false, detail: state.blocked?.said ?? "" } + : { step: label, ok: true, detail: "" }, ); - const stopped = !running && at !== null && (blocked || problem); + + return { + ...state, + ready, + stopped, + steps, + prepare, + start, + retry, + cancel, + reset, + }; +} + +/** + * The local sequence's rows and its one control at a time: Cancel while a CLI call runs, Retry + * once it stopped, and Start OpenBot when nothing has run yet and `offerStart` is set. + */ +export function LocalIntelligence({ + sequence, + developer = import.meta.env.DEV, + offerStart = false, +}: { + sequence: LocalSequence; + developer?: boolean; + offerStart?: boolean; +}) { + const { at, running, stopped, blocked, problem } = sequence; const reason = stopped ? blocked?.reason : null; return (

      - + {reason === "eula-acceptance-required" && (developer ? (

      @@ -348,17 +462,17 @@ export function LocalIntelligence({ {stopped && problem && }

      {running && at !== "start" && ( - )} - {stopped && at && ( - )} - {!running && at === null && !auto && ( - )} @@ -367,6 +481,24 @@ export function LocalIntelligence({ ); } +/** One line for the screen in front: the current row and how long it has taken. */ +export function LocalStatus({ sequence }: { sequence: LocalSequence }) { + const current = sequence.steps.at(-1); + const elapsed = useElapsed(sequence.running, sequence.since); + if (!current) return null; + return ( +

      + {sequence.running + ? `${current.step} · ${formatElapsed(elapsed)} elapsed` + : sequence.ready + ? "Intelligence is ready." + : sequence.stopped + ? (sequence.blocked?.said ?? sequence.problem?.said) + : `${current.step}: complete.`} +

      + ); +} + export type IntelligenceMode = "local" | "hosted"; /** @@ -386,7 +518,7 @@ export function IntelligenceChoice({ [ "local", "Run Intelligence on this computer", - "For evaluation. Uses Docker Desktop; the first setup takes a few minutes.", + "For evaluation. The first setup takes about 10 minutes.", ], [ "hosted", diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index c4cd3eb914..4a02f3f085 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { useEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { ExternalLink } from "./ExternalLink"; import { isHttpEndpointUrl } from "./http-endpoint-url"; import { Mark } from "./Mark"; @@ -144,6 +144,8 @@ export function ProviderPicker({ onBack, returning = false, busy: starting = false, + stepLabel = "Step 3 of 4", + status, }: { chosen: ModelChoice | null; /** @@ -155,9 +157,13 @@ export function ProviderPicker({ held: HeldConfiguration; root: string; onChoose: (choice: ModelChoice) => void; - onBack: () => void; + /** Without it there is no Back, as while local Intelligence sets up behind this screen. */ + onBack?: () => void; returning?: boolean; busy?: boolean; + stepLabel?: string; + /** One line about work running behind this screen. */ + status?: ReactNode; }) { const initialChoice = chosen ?? recordedModel(held); const [reuse, setReuse] = useState( @@ -473,11 +479,12 @@ export function ProviderPicker({ return (
      - {!returning &&

      Step 3 of 4

      } + {!returning &&

      {stepLabel}

      }

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

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

      + {status}
      Model provider @@ -863,14 +870,16 @@ export function ProviderPicker({ )}
      - + {onBack && ( + + )} +
      + +
      + ); + } return (
      diff --git a/desktop/src/localReadiness.test.ts b/desktop/src/localReadiness.test.ts index 650db365c0..612fae94c0 100644 --- a/desktop/src/localReadiness.test.ts +++ b/desktop/src/localReadiness.test.ts @@ -147,7 +147,7 @@ test("specific wording and actions", () => { }); expect(said("cancelled").action).toBe("retry"); expect(said("cancelled").said).toBe( - "The Intelligence command was stopped before it finished.", + "Stopped at your request before it finished.", ); expect( said(report({ outcome: "blocked", reason: "local-command-busy" })).said, @@ -161,7 +161,7 @@ test("specific wording and actions", () => { expect(said("unverified").action).toBe("repair"); expect(said("invalid-json")).toEqual({ state: "blocked", - said: "OpenBot does not understand this CLI's reply.", + said: "OpenBot did not understand Intelligence's reply.", }); }); diff --git a/desktop/src/localReadiness.ts b/desktop/src/localReadiness.ts index e0d2f4a00b..7bf16b17c5 100644 --- a/desktop/src/localReadiness.ts +++ b/desktop/src/localReadiness.ts @@ -58,7 +58,7 @@ function blocked(said: string, action?: ReadinessAction): ReadinessRow { const unavailableHere = "Intelligence is not available on this computer or release."; -const contract = "OpenBot does not understand this CLI's reply."; +const contract = "OpenBot did not understand Intelligence's reply."; const couldNotStart = blocked( "Intelligence could not start.", "restart-intelligence", @@ -84,10 +84,10 @@ const reasonRows: Record = { "update-recovery-required": blocked(unavailableHere), // The runtime row carries the Docker detail. Here Intelligence is simply blocked behind it. "unsupported-engine": blocked( - "Intelligence cannot start on this Docker setup.", + "Intelligence cannot run on this computer's current setup.", ), "docker-unavailable": blocked( - "Intelligence cannot start until Docker Desktop is running.", + "Intelligence cannot start until the software it runs on is running.", "retry", ), "port-unavailable": blocked("A port Intelligence needs is in use.", "retry"), @@ -95,7 +95,7 @@ const reasonRows: Record = { "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.", + "Intelligence asked for a confirmation OpenBot cannot give.", ), unrecognized: couldNotStart, }; @@ -114,16 +114,15 @@ const licenceRows: Record< }; const failureRows: Record = { - unavailable: blocked("This build does not include the Intelligence CLI."), + unavailable: blocked( + "This version of OpenBot cannot set up Intelligence on this computer.", + ), unverified: blocked( - "The Intelligence CLI failed its integrity check.", + "The Intelligence software failed its integrity check.", "repair", ), // Cancelled is the person's own call stopping, not another command holding the stack. - cancelled: blocked( - "The Intelligence command was stopped before it finished.", - "retry", - ), + cancelled: blocked("Stopped at your request before it finished.", "retry"), spawn: couldNotStart, timeout: couldNotStart, "unexpected-exit": couldNotStart, diff --git a/desktop/src/styles.css b/desktop/src/styles.css index bf45b22176..4abbbcd40b 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -219,6 +219,32 @@ button.quiet:hover:not(:disabled) { opacity: 1; } +/* A way out that reads as a sentence, not as a second choice of equal weight. */ +button.text-link { + background: transparent; + border: 0; + padding: 0; + margin-top: 1rem; + color: var(--ink-soft); + font-size: 0.85rem; + font-weight: 400; + text-decoration: underline; + text-underline-offset: 0.2em; +} + +button.text-link:hover:not(:disabled) { + color: var(--ink); + opacity: 1; +} + +/* Local Intelligence setting up behind the screen in front: one line. */ +.local-status { + font-size: 0.8rem; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; + margin: -0.75rem 0 1.25rem; +} + :focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; From e9be1d1903899aaee720cebf8833d85ec5f32f41 Mon Sep 17 00:00:00 2001 From: Dusty Date: Wed, 21 Oct 2026 21:30:59 -0700 Subject: [PATCH 08/77] wip(desktop): regression tests for Start after background readiness; temporary model-field debug line The debug line logs field names and token length only, never values. Remove before review. --- desktop/src-tauri/src/main.rs | 9 +++++ desktop/src/App.test.tsx | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 1b749d0944..b9a56d81d4 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -866,6 +866,15 @@ impl ChosenModel { ) -> Result { let given = |value: Option| value.unwrap_or_default().trim().to_string(); let saved = self.saved.unwrap_or(false); + // TEMPORARY demo diagnosis: which fields arrived, never their values. Remove after. + eprintln!( + "[debug] model credential: provider={} login={} token_present={} token_len={} saved={:?}", + self.provider, + self.login, + self.token.is_some(), + self.token.as_deref().map(str::len).unwrap_or(0), + self.saved + ); match (self.provider.as_str(), self.login.as_str()) { ("google" | "xai", "oauth") => { let saved = openbot_desktop_lib::provider_oauth::read(root, &self.provider) diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 888d02ccb6..bb30c240cc 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -734,6 +734,79 @@ test("Continue during setup shows Getting ready, which starts, tests and opens O ).toHaveLength(1); }); +test("Continue after Intelligence is already ready starts OpenBot with the AI just chosen", async () => { + setupRootConfiguration("/tmp/local-eval-ready-first", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + }); + const view = await renderApp(); + await userEvent.click(view.getByRole("button", { name: "Install" })); + await view.findByRole("heading", { name: "Connect your AI" }); + // Intelligence finishes while the person is still choosing. + await waitFor(() => + expect(localSequence()).toEqual([ + "set_up_local_eval", + "connect_local_eval", + "run_local_eval", + ]), + ); + await act(async () => new Promise((resolve) => setTimeout(resolve, 100))); + await chooseOpenAi(view); + await view.findByRole("heading", { name: "Test OpenBot" }); + expect(getStartStackPayload()).toMatchObject({ + model: { provider: "openai", login: "api-key", apiKey: "model-test-key" }, + }); +}); + +test("a Claude plan signed in on the provider screen reaches Start with its token", async () => { + setupRootConfiguration("/tmp/local-eval-plan", async () => + emptyConfiguration(), + ); + withLocalCli({ + providers: [ + { + id: "anthropic", + name: "Anthropic", + summary: "Sign in with a Claude plan.", + logins: ["plan", "api-key"], + mark: null, + caution: null, + }, + ], + begin_claude_sign_in: "https://claude.example/authorize", + finish_claude_sign_in: "synthetic-plan-token", + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + }); + const view = await renderApp(); + await userEvent.click(view.getByRole("button", { name: "Install" })); + await userEvent.click(await view.findByRole("radio", { name: /Anthropic/ })); + await userEvent.click( + await view.findByRole("button", { name: "Sign in with Anthropic" }), + ); + await userEvent.type( + await view.findByLabelText("Code from your browser"), + "synthetic-code", + ); + await userEvent.click(view.getByRole("button", { name: "Finish signing in" })); + await view.findByText(/Signed in to Anthropic/); + await waitFor(() => expect(localSequence()).toContain("run_local_eval")); + // Intelligence is ready and waiting minutes before Continue, as on a real machine. + await act(async () => new Promise((resolve) => setTimeout(resolve, 100))); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await view.findByRole("heading", { name: "Test OpenBot" }); + expect(getStartStackPayload()).toMatchObject({ + model: { provider: "anthropic", login: "plan", token: "synthetic-plan-token" }, + }); +}); + test("a development double mount runs each local step and asks the question once", async () => { setupRootConfiguration("/tmp/local-eval-strict", async () => emptyConfiguration(), From 567cd895f5d72c5acfb753cd0a27ef4ec9706572 Mon Sep 17 00:00:00 2001 From: Dusty Date: Wed, 21 Oct 2026 21:46:05 -0700 Subject: [PATCH 09/77] wip(desktop): Open Intelligence dashboard through local login, no terminal Local checkpoint for the demo; not for review as-is. --- desktop/src-tauri/src/local_eval.rs | 241 +++++++++++++++++- desktop/src-tauri/src/main.rs | 370 +++++++++++++++++++++++----- desktop/src/App.test.tsx | 115 ++++++++- desktop/src/App.tsx | 18 ++ desktop/src/Ask.tsx | 31 +++ desktop/src/localEvalCli.ts | 35 +++ desktop/src/localReadiness.test.ts | 4 +- desktop/src/localReadiness.ts | 6 + 8 files changed, 751 insertions(+), 69 deletions(-) diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs index 6a9e32cfea..2cf409f82c 100644 --- a/desktop/src-tauri/src/local_eval.rs +++ b/desktop/src-tauri/src/local_eval.rs @@ -9,8 +9,8 @@ //! //! - **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`, -//! plus the constant `list`, `setup` and `connect` lists, as separate arguments with no shell. The CLI's `resumeCommand` strings are text for a +//! - **Arguments are fixed.** Only `status`, `start`, `stop` and `login`, a validated stack id and +//! `--json`, plus the constant `list`, `setup` and `connect` lists, as separate arguments with no shell. The CLI's `resumeCommand` strings are text for a //! terminal user; they are dropped here and never executed. //! - **Only allowlisted facts leave.** Raw stdout, stderr, error messages and recovery text never //! cross IPC or reach a log. Failures are static categories with no payload. @@ -21,8 +21,8 @@ //! unknown keeps chat closed. //! //! Source: Intelligence `apps/cli/src/commands/local.ts`, `services/local-manager.ts` and -//! `services/local-errors.ts` at 3935afbfe3cda88dfeabfd0250db08ff0ad51000; `local connect` at -//! 780313e4edf6fc5f038c3cf5e4612ac668446c45. +//! `services/local-errors.ts` at 3935afbfe3cda88dfeabfd0250db08ff0ad51000; `local connect` and +//! `local login` (`commands/local-login.ts`) at 780313e4edf6fc5f038c3cf5e4612ac668446c45. use std::io::{Read, Seek}; use std::path::PathBuf; @@ -162,6 +162,10 @@ pub enum Reason { LocalCommandFailed, /// `connect` asked for its approval flag. Desktop always sends it, so this is a contract gap. ConfirmationRequired, + /// `login`: the dashboard did not answer within the CLI's own wait. + DashboardUnavailable, + /// `login`: the CLI could not open the browser. Its fallback is a code approved in a terminal. + BrowserOpenFailed, /// A reason this build does not know. Treated as blocking, never as success. Unrecognized, } @@ -184,6 +188,8 @@ impl Reason { "local_command_busy" => Reason::LocalCommandBusy, "local_command_failed" => Reason::LocalCommandFailed, "confirmation_required" => Reason::ConfirmationRequired, + "dashboard_unavailable" => Reason::DashboardUnavailable, + "browser_open_failed" => Reason::BrowserOpenFailed, _ => Reason::Unrecognized, } } @@ -213,6 +219,15 @@ pub struct Connection { pub conflicted: bool, } +/// What `local login` did. The single-use sign-in link it opens is a bearer credential, and the CLI +/// never prints it; of the rest, only the outcome and the reason are kept. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SignIn { + pub outcome: Outcome, + pub reason: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct Stack { @@ -280,6 +295,17 @@ its own (contract request 8). */ pub const CONNECT_ARGUMENTS: [&str; 4] = ["local", "connect", "--approve-connection", "--json"]; +/** +`local login` signs the browser in to the stack's dashboard with no code to approve. + +The CLI waits for the dashboard, mints a single-use link and opens the browser itself, so the app +opens nothing of its own. The browser lands on the dashboard's `/login?autoSignIn=1`; the command +takes no return path. +*/ +pub fn login_arguments(stack: &StackId) -> [&str; 5] { + ["local", "login", "--stack", stack.as_str(), "--json"] +} + /** Turn what the CLI printed, and how it exited, into the facts this app keeps. @@ -345,6 +371,26 @@ pub fn normalize_connect(exit_code: i32, stdout: &[u8]) -> Result Result { + let wire = parse_envelope("local login", exit_code, stdout)?; + let (outcome, wire) = (wire.0, &wire.1); + if outcome == Outcome::Completed + && (wire.get("browserOpened") != Some(&Value::Bool(true)) + || wire.get("stackId").and_then(Value::as_str) != Some(stack.as_str())) + { + return Err(Failure::InvalidStatus); + } + Ok(SignIn { + outcome, + reason: reason(outcome, wire)?, + }) +} + /** `readiness.state`, believed only as one of its exact wire values and with a strict `checkedAt`. @@ -962,6 +1008,17 @@ pub fn connect( normalize_connect(code, &bytes) } +/// `local login` for `stack`. +pub fn login( + cli: &PinnedCli, + stack: &StackId, + limits: Limits, + cancel: &Cancel, +) -> Result { + let (code, bytes) = execute(cli, &login_arguments(stack), None, limits, cancel)?; + normalize_login(stack, code, &bytes) +} + /// `local list`, within the same limits as `status`. pub fn list(cli: &PinnedCli, limits: Limits, cancel: &Cancel) -> Result { let (code, bytes) = execute(cli, &LIST_ARGUMENTS, None, limits, cancel)?; @@ -969,8 +1026,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()?; + login(&cli, stack, limits, &attempt.0) + }) +} + /// `local list` against the installed CLI. pub fn list_installed(attempt: &str) -> Result { let attempt = Attempt::register(attempt); @@ -2055,6 +2122,168 @@ mod tests { std::fs::remove_dir_all(installation).unwrap(); } + // As `local login --json` prints it at Intelligence 780313e4 (`local-login.ts`), with the + // bearer-free fields it really has: no handoff link is ever in it. + fn logged_in(status: &str, next: Value) -> String { + let mut input = json!({ + "version": 1, + "command": "local login", + "status": status, + "error": null, + "nextAction": next, + "diagnostics": [] + }); + if status == "completed" { + input["browserOpened"] = json!(true); + input["stackId"] = json!("local-abc123"); + } + input.to_string() + } + + fn login_blocked(reason: &str) -> Value { + json!({ + "reason": reason, + "summary": format!("Open http://cpk.localhost:8080/login {SECRET}"), + "instructions": ["http://cpk.localhost:8080/login", "copilotkit local auth pending --stack local-abc123"], + "resumeCommand": "copilotkit local login --stack local-abc123", + "url": "http://cpk.localhost:8080/login", + "linkLabel": "Open dashboard" + }) + } + + #[test] + fn login_arguments_are_fixed() { + let stack = StackId::parse("local-abc123").unwrap(); + assert_eq!( + login_arguments(&stack), + ["local", "login", "--stack", "local-abc123", "--json"] + ); + } + + #[test] + fn login_runs_the_fixed_arguments_and_keeps_only_that_it_opened() { + let fixture = Fixture::new( + "local-eval-login", + &format!("0\n{}", logged_in("completed", Value::Null)), + ); + let stack = StackId::parse("local-abc123").unwrap(); + let signed = login( + &fixture.cli(), + &stack, + Limits::for_action(Action::Status), + &Cancel::default(), + ) + .unwrap(); + assert_eq!( + signed, + SignIn { + outcome: Outcome::Completed, + reason: None + } + ); + assert_eq!( + fixture.argv().unwrap(), + "local\nlogin\n--stack\nlocal-abc123\n--json" + ); + } + + #[test] + fn login_answers_keep_only_their_reason() { + let stack = StackId::parse("local-abc123").unwrap(); + for (wire, reason) in [ + ("dashboard_unavailable", Reason::DashboardUnavailable), + ("browser_open_failed", Reason::BrowserOpenFailed), + ("local_command_busy", Reason::LocalCommandBusy), + ("docker_unavailable", Reason::DockerUnavailable), + ("something_new", Reason::Unrecognized), + ] { + let signed = normalize_login( + &stack, + 2, + logged_in("blocked", login_blocked(wire)).as_bytes(), + ) + .unwrap(); + assert_eq!( + signed, + SignIn { + outcome: Outcome::Blocked, + reason: Some(reason) + } + ); + let said = serde_json::to_string(&signed).unwrap(); + for leaked in [SECRET, "cpk.localhost", "auth pending", "copilotkit"] { + assert!(!said.contains(leaked), "{said} carried {leaked}"); + } + } + let failed = normalize_login( + &stack, + 1, + logged_in("failed", login_blocked("local_command_failed")).as_bytes(), + ) + .unwrap(); + assert_eq!(failed.outcome, Outcome::Failed); + assert_eq!(failed.reason, Some(Reason::LocalCommandFailed)); + } + + #[test] + fn a_login_report_is_held_to_the_same_rules() { + let stack = StackId::parse("local-abc123").unwrap(); + // Opened only when the CLI says it opened the browser, for this stack. + let mut unopened: Value = + serde_json::from_str(&logged_in("completed", Value::Null)).unwrap(); + unopened["browserOpened"] = json!(false); + let mut other: Value = serde_json::from_str(&logged_in("completed", Value::Null)).unwrap(); + other["stackId"] = json!("local-other"); + let mut missing: Value = + serde_json::from_str(&logged_in("completed", Value::Null)).unwrap(); + missing.as_object_mut().unwrap().remove("browserOpened"); + for input in [unopened, other, missing] { + assert_eq!( + normalize_login(&stack, 0, input.to_string().as_bytes()), + Err(Failure::InvalidStatus) + ); + } + // Another command's report, a status the exit code contradicts, and the CLI's text output. + assert_eq!( + normalize_login(&stack, 0, envelope().to_string().as_bytes()), + Err(Failure::UnexpectedCommand) + ); + assert_eq!( + normalize_login( + &stack, + 0, + logged_in("blocked", login_blocked("x")).as_bytes() + ), + Err(Failure::ContractMismatch) + ); + assert_eq!( + normalize_login( + &stack, + 0, + b"Opened local sign-in in your browser. No code approval is needed; if the dashboard shows Sign In, choose it.\n" + ), + Err(Failure::InvalidJson) + ); + assert_eq!( + normalize_login(&stack, 9, b""), + Err(Failure::UnexpectedExit) + ); + } + + #[test] + fn login_waits_its_turn_and_is_refused_once_cancelled() { + let stack = StackId::parse("local-abc123").unwrap(); + assert_eq!( + login_installed("login-no-cli", &stack), + Err(Failure::Unavailable) + ); + cancel("login-cancelled"); + assert_eq!( + login_installed("login-cancelled", &stack), + Err(Failure::Cancelled) + ); + } + #[test] fn a_pinned_cli_is_known_by_the_digest_of_what_it_runs() { let fixture = Fixture::new("local-eval-cli-digest", "0\n"); diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index b9a56d81d4..71ab57aff5 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -93,6 +93,9 @@ struct Shell { /// The configured setup destination, resolved using Tauri's build mode and platform. /// WebView2's current URL can still be about:blank during startup; it is never a setup source. setup_url: Mutex>, + /// The installation whose Intelligence dashboard the menus open, while the window is in local + /// mode with a recorded stack. None hides the item. + intelligence_menu: Mutex>, } /// The deployment whose Compose up may have created containers, including a partial failure. @@ -3834,6 +3837,110 @@ fn cancel_local_eval(attempt: String) { local_eval::cancel(&attempt); } +/** +Sign the browser in to the recorded local stack's Intelligence dashboard, with no code to approve. + +`local login` opens the browser itself, so nothing here opens a second tab. The window gets nothing +back but success or a fixed sentence; `attempt` lets `cancel_local_eval` stop the call. +*/ +#[tauri::command] +async fn open_local_intelligence(attempt: String, root: String) -> Result<(), Problem> { + let root = stack::root_from(&root); + tauri::async_runtime::spawn_blocking(move || { + open_local_intelligence_at(&root, |stack| local_eval::login_installed(&attempt, stack)) + }) + .await + .map_err(|_| Problem::plain(OPEN_INTELLIGENCE_FAILED))? +} + +const OPEN_INTELLIGENCE_FAILED: &str = + "OpenBot could not open the Intelligence dashboard. Try again."; + +/// `local login` for the stack recorded in `root`, and nothing for a root with no record. +fn open_local_intelligence_at(root: &Path, login: L) -> Result<(), Problem> +where + L: FnOnce(&local_eval::StackId) -> Result, +{ + use local_eval::{Failure, Outcome, Reason}; + let recorded = local_eval_state::read(root, None) + .ok_or_else(|| Problem::plain("Set up local Intelligence before opening its dashboard."))?; + let said = match login(&recorded.stack_id()) { + Ok(signed) if signed.outcome == Outcome::Completed => return Ok(()), + Ok(signed) => match signed.reason { + Some(Reason::DashboardUnavailable) => { + "The Intelligence dashboard is not responding yet. Try again when Intelligence is ready." + } + Some(Reason::BrowserOpenFailed) => "Your browser did not open. Try again.", + Some(Reason::LocalCommandBusy) => { + "Another Intelligence command is still running. Try again when it finishes." + } + Some(Reason::DockerUnavailable) => "The software Intelligence runs on is not running.", + _ => OPEN_INTELLIGENCE_FAILED, + }, + Err(Failure::Unavailable) => { + "This version of OpenBot cannot open the Intelligence dashboard." + } + Err(Failure::Timeout) => "The Intelligence dashboard took too long to open. Try again.", + Err(Failure::Cancelled) => "Opening the Intelligence dashboard was stopped.", + Err(_) => OPEN_INTELLIGENCE_FAILED, + }; + Err(Problem::plain(said)) +} + +/// The root whose dashboard the menu opens: only one in local mode with a recorded stack. +fn intelligence_menu_root(root: &Path, offered: bool) -> Option { + (offered && local_eval_state::read(root, None).is_some()).then(|| root.to_path_buf()) +} + +/** +The window says whether it is in local mode for `root`, and the menus follow. + +The window decides local mode, as it does for its own link; the menus add "Open Intelligence +dashboard" only while it says so and a stack is recorded there. Sync, so the menus are rebuilt on +the main thread. +*/ +#[tauri::command] +fn offer_local_intelligence(app: tauri::AppHandle, root: String, offered: bool) { + let next = intelligence_menu_root(&stack::root_from(&root), offered); + let shell = app.state::(); + let mut current = shell.intelligence_menu.lock().unwrap(); + if *current == next { + return; + } + let show = next.is_some(); + *current = next; + drop(current); + if let Err(error) = install_menus(&app, show) { + eprintln!("[menu] could not update the menus: {error}"); + } +} + +/// The menu's "Open Intelligence dashboard": a failure is said in a dialog, since the window may +/// be hidden. +fn open_intelligence_from_menu(app: tauri::AppHandle) { + let Some(root) = app + .state::() + .intelligence_menu + .lock() + .unwrap() + .clone() + else { + return; + }; + std::thread::spawn(move || { + let attempt = format!("menu-open-intelligence-{:016x}", rand::random::()); + if let Err(problem) = + open_local_intelligence_at(&root, |stack| local_eval::login_installed(&attempt, stack)) + { + use tauri_plugin_dialog::DialogExt; + app.dialog() + .message(problem.said) + .title("Open Intelligence dashboard") + .show(|_| {}); + } + }); +} + /// `bun` from PATH, or the places an installer puts it when PATH has not been reloaded. fn which_bun() -> Option { if quiet::command("bun") @@ -4245,6 +4352,7 @@ fn chose(app: &tauri::AppHandle, item: &str) { // Stop without quitting: the stack is what costs something to leave running, and somebody // who wants it stopped does not necessarily want the application gone. "stop" => stop_from_menu(app.clone()), + "open-intelligence" => open_intelligence_from_menu(app.clone()), // Exit rather than hide: quitting is a decision to stop, and the exit handler is what stops // the processes with it. "quit" => { @@ -4254,6 +4362,101 @@ fn chose(app: &tauri::AppHandle, item: &str) { } } +/** +The tray menu and the window's menu bar, with "Open Intelligence dashboard" when `intelligence`. + +Built again whenever that changes, because a menu item cannot be hidden, only removed. +*/ +fn install_menus(app: &tauri::AppHandle, intelligence: bool) -> tauri::Result<()> { + use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; + + let items = || -> tauri::Result>> { + let mut items = vec![MenuItem::with_id( + app, + "open", + "Open OpenBot", + true, + None::<&str>, + )?]; + if intelligence { + items.push(MenuItem::with_id( + app, + "open-intelligence", + "Open Intelligence dashboard", + true, + None::<&str>, + )?); + } + items.push(MenuItem::with_id( + app, + "stop", + "Stop OpenBot", + true, + None::<&str>, + )?); + items.push(MenuItem::with_id( + app, + "quit", + "Quit", + true, + quit_menu_accelerator(), + )?); + Ok(items) + }; + fn refs(items: &[MenuItem]) -> Vec<&dyn IsMenuItem> { + items + .iter() + .map(|item| item as &dyn IsMenuItem) + .collect() + } + + let tray_items = items()?; + if let Some(tray) = app.tray_by_id("openbot") { + tray.set_menu(Some(Menu::with_items(app, &refs(&tray_items))?))?; + } + + // The same items on the window itself, because the tray cannot be relied on and Stop lives + // nowhere else. + // + // Linux needs a tray host to draw the icon, and Windows can place it in overflow. + // The tray library restores the Windows icon after Explorer restarts, but the window + // menu still provides access when the tray is unavailable or hard to find. + // Its own items, not the tray's: a menu item belongs to one menu, and the two menus + // outlive each other. The ids match so both arrive at the same function. + let window_items = items()?; + // A submenu, because a top-level entry in a menu bar has to be one to open at all. + let openbot = Submenu::with_items(app, "OpenBot", true, &refs(&window_items))?; + /* + * AN EDIT MENU, WITHOUT WHICH COMMAND-V DOES NOTHING. + * + * MEASURED, on the screen that asks for a paste. macOS routes the clipboard shortcuts + * through the menu bar, so a window with no Edit menu has no Paste, and a webview text + * field silently ignores the keystroke. Typing worked and pasting did not, on the one + * screen whose own instruction is "paste the code it shows you". Every person signing + * in to a Claude plan would have reached that field, pressed the shortcut everybody + * knows, and had nothing happen. + * + * Predefined items rather than our own: these carry the standard shortcuts and the + * standard behaviour, which is the whole point of them being where a person expects. + */ + let edit = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?; + app.set_menu(Menu::with_items(app, &[&openbot, &edit])?)?; + Ok(()) +} + fn quit_menu_accelerator() -> Option<&'static str> { Some(QUIT_MENU_ACCELERATOR) } @@ -4309,6 +4512,8 @@ fn main() { run_local_eval, list_local_eval, cancel_local_eval, + open_local_intelligence, + offer_local_intelligence, set_up_local_eval, connect_local_eval, last_local_eval_stop_failure, @@ -4353,70 +4558,12 @@ fn main() { remember_setup_url(app.handle())?; // The status menu lets somebody open the window, stop the stack, or quit the app. - use tauri::menu::{Menu, MenuItem}; - use tauri::tray::TrayIconBuilder; - - let open = MenuItem::with_id(app, "open", "Open OpenBot", true, None::<&str>)?; - let stop = MenuItem::with_id(app, "stop", "Stop OpenBot", true, None::<&str>)?; - let quit = MenuItem::with_id(app, "quit", "Quit", true, quit_menu_accelerator())?; - let menu = Menu::with_items(app, &[&open, &stop, &quit])?; - - TrayIconBuilder::with_id("openbot") + tauri::tray::TrayIconBuilder::with_id("openbot") .icon(tray::icon()) .icon_as_template(false) .tooltip("OpenBot") - .menu(&menu) .build(app)?; - - // The same three items on the window itself, because the tray cannot be relied on and - // Stop lives nowhere else. - // - // Linux needs a tray host to draw the icon, and Windows can place it in overflow. - // The tray library restores the Windows icon after Explorer restarts, but the window - // menu still provides access when the tray is unavailable or hard to find. - // Its own items, not the tray's: a menu item belongs to one menu, and the two menus - // outlive each other. The ids match so both arrive at the same function. - use tauri::menu::Submenu; - let window_open = MenuItem::with_id(app, "open", "Open OpenBot", true, None::<&str>)?; - let window_stop = MenuItem::with_id(app, "stop", "Stop OpenBot", true, None::<&str>)?; - let window_quit = - MenuItem::with_id(app, "quit", "Quit", true, quit_menu_accelerator())?; - // A submenu, because a top-level entry in a menu bar has to be one to open at all. - let openbot = Submenu::with_items( - app, - "OpenBot", - true, - &[&window_open, &window_stop, &window_quit], - )?; - /* - * AN EDIT MENU, WITHOUT WHICH COMMAND-V DOES NOTHING. - * - * MEASURED, on the screen that asks for a paste. macOS routes the clipboard shortcuts - * through the menu bar, so a window with no Edit menu has no Paste, and a webview text - * field silently ignores the keystroke. Typing worked and pasting did not, on the one - * screen whose own instruction is "paste the code it shows you". Every person signing - * in to a Claude plan would have reached that field, pressed the shortcut everybody - * knows, and had nothing happen. - * - * Predefined items rather than our own: these carry the standard shortcuts and the - * standard behaviour, which is the whole point of them being where a person expects. - */ - use tauri::menu::PredefinedMenuItem; - let edit = Submenu::with_items( - app, - "Edit", - true, - &[ - &PredefinedMenuItem::undo(app, None)?, - &PredefinedMenuItem::redo(app, None)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::cut(app, None)?, - &PredefinedMenuItem::copy(app, None)?, - &PredefinedMenuItem::paste(app, None)?, - &PredefinedMenuItem::select_all(app, None)?, - ], - )?; - app.set_menu(Menu::with_items(app, &[&openbot, &edit])?)?; + install_menus(app.handle(), false)?; app.on_menu_event(|app, event| chose(app, event.id().as_ref())); Ok(()) }) @@ -6684,6 +6831,109 @@ mod tests { } } + fn signed_in( + outcome: local_eval::Outcome, + reason: Option, + ) -> Result { + Ok(local_eval::SignIn { outcome, reason }) + } + + #[test] + fn opening_intelligence_needs_a_recorded_stack_and_never_asks_the_cli_without_one() { + let root = local_eval_root("openbot-open-intelligence-unrecorded", None); + let problem = + open_local_intelligence_at(&root, |_| panic!("login ran without a stack")).unwrap_err(); + assert_eq!( + problem, + Problem::plain("Set up local Intelligence before opening its dashboard.") + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn opening_intelligence_signs_in_the_recorded_stack() { + let root = local_eval_root( + "openbot-open-intelligence-recorded", + Some(local_eval_state::Ownership::Shared), + ); + let mut asked = None; + open_local_intelligence_at(&root, |stack| { + asked = Some(stack.as_str().to_owned()); + signed_in(local_eval::Outcome::Completed, None) + }) + .unwrap(); + assert_eq!(asked.as_deref(), Some("local-abc123")); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn opening_intelligence_fails_with_fixed_sentences_only() { + use local_eval::{Failure, Outcome, Reason}; + let root = local_eval_root( + "openbot-open-intelligence-failures", + Some(local_eval_state::Ownership::Created), + ); + let retry = "OpenBot could not open the Intelligence dashboard. Try again."; + for (result, said) in [ + ( + signed_in(Outcome::Blocked, Some(Reason::DashboardUnavailable)), + "The Intelligence dashboard is not responding yet. Try again when Intelligence is ready.", + ), + ( + signed_in(Outcome::Blocked, Some(Reason::BrowserOpenFailed)), + "Your browser did not open. Try again.", + ), + ( + signed_in(Outcome::Blocked, Some(Reason::LocalCommandBusy)), + "Another Intelligence command is still running. Try again when it finishes.", + ), + ( + signed_in(Outcome::Blocked, Some(Reason::DockerUnavailable)), + "The software Intelligence runs on is not running.", + ), + (signed_in(Outcome::Blocked, Some(Reason::Unrecognized)), retry), + (signed_in(Outcome::Blocked, None), retry), + (signed_in(Outcome::Failed, Some(Reason::LocalCommandFailed)), retry), + ( + Err(Failure::Unavailable), + "This version of OpenBot cannot open the Intelligence dashboard.", + ), + ( + Err(Failure::Timeout), + "The Intelligence dashboard took too long to open. Try again.", + ), + ( + Err(Failure::Cancelled), + "Opening the Intelligence dashboard was stopped.", + ), + (Err(Failure::InvalidJson), retry), + (Err(Failure::Unverified), retry), + ] { + let problem = open_local_intelligence_at(&root, |_| result).unwrap_err(); + // Nothing but the sentence: no detail, so no CLI text can ride along. + assert_eq!(problem, Problem::plain(said)); + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn the_intelligence_menu_item_is_offered_only_for_a_recorded_local_stack() { + let recorded = local_eval_root( + "openbot-intelligence-menu-recorded", + Some(local_eval_state::Ownership::Created), + ); + let unrecorded = local_eval_root("openbot-intelligence-menu-unrecorded", None); + assert_eq!( + intelligence_menu_root(&recorded, true), + Some(recorded.clone()) + ); + // Hosted mode, or local mode with nothing recorded: no item. + assert_eq!(intelligence_menu_root(&recorded, false), None); + assert_eq!(intelligence_menu_root(&unrecorded, true), None); + let _ = std::fs::remove_dir_all(recorded); + let _ = std::fs::remove_dir_all(unrecorded); + } + #[test] fn stop_asks_intelligence_last_even_after_compose_down_failed() { let active = local_eval_root("openbot-stop-intelligence-order", None); diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index bb30c240cc..5c0158e3dc 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -734,6 +734,87 @@ test("Continue during setup shows Getting ready, which starts, tests and opens O ).toHaveLength(1); }); +function intelligenceCalls() { + return invokeCalls.filter((call) => + ["offer_local_intelligence", "open_local_intelligence"].includes( + call.command, + ), + ); +} + +test("a local Test OpenBot screen links to the Intelligence dashboard through the native sign-in", async () => { + setupRootConfiguration("/tmp/local-eval-view", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + offer_local_intelligence: null, + open_local_intelligence: null, + }); + const view = await renderApp(); + await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseOpenAi(view); + await view.findByText("I am working."); + // The menus offer the dashboard once local mode has a recorded stack. + expect(intelligenceCalls()).toEqual([ + { + command: "offer_local_intelligence", + args: { root: "/tmp/local-eval-view", offered: true }, + }, + ]); + await userEvent.click( + view.getByRole("button", { name: "View in Intelligence" }), + ); + const opened = intelligenceCalls().filter( + (call) => call.command === "open_local_intelligence", + ); + expect(opened).toEqual([ + { + command: "open_local_intelligence", + args: { attempt: expect.any(String), root: "/tmp/local-eval-view" }, + }, + ]); + // The sign-in opens the browser itself; the window stays on its screen. + expect(localSequence()).not.toContain("show_openbot"); + expect(view.getByRole("heading", { name: "Test OpenBot" })).toBeTruthy(); + expectPlain(view); +}); + +test("View in Intelligence says the native sentence when the dashboard does not open", async () => { + setupRootConfiguration("/tmp/local-eval-view-failed", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + ask_the_bot: "I am working.", + offer_local_intelligence: null, + open_local_intelligence: () => + Promise.reject({ + said: "Your browser did not open. Try again.", + detail: null, + }), + }); + const view = await renderApp(); + await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseOpenAi(view); + await view.findByText("I am working."); + await userEvent.click( + view.getByRole("button", { name: "View in Intelligence" }), + ); + expect( + await view.findByText("Your browser did not open. Try again."), + ).toBeTruthy(); + // The test result and its way forward are unchanged. + expect(view.getByText("I am working.")).toBeTruthy(); + expect(view.getByRole("button", { name: "Open OpenBot" })).toBeTruthy(); + expectPlain(view); +}); + test("Continue after Intelligence is already ready starts OpenBot with the AI just chosen", async () => { setupRootConfiguration("/tmp/local-eval-ready-first", async () => emptyConfiguration(), @@ -795,7 +876,9 @@ test("a Claude plan signed in on the provider screen reaches Start with its toke await view.findByLabelText("Code from your browser"), "synthetic-code", ); - await userEvent.click(view.getByRole("button", { name: "Finish signing in" })); + await userEvent.click( + view.getByRole("button", { name: "Finish signing in" }), + ); await view.findByText(/Signed in to Anthropic/); await waitFor(() => expect(localSequence()).toContain("run_local_eval")); // Intelligence is ready and waiting minutes before Continue, as on a real machine. @@ -803,7 +886,11 @@ test("a Claude plan signed in on the provider screen reaches Start with its toke await userEvent.click(view.getByRole("button", { name: "Continue" })); await view.findByRole("heading", { name: "Test OpenBot" }); expect(getStartStackPayload()).toMatchObject({ - model: { provider: "anthropic", login: "plan", token: "synthetic-plan-token" }, + model: { + provider: "anthropic", + login: "plan", + token: "synthetic-plan-token", + }, }); }); @@ -4064,6 +4151,30 @@ test("Start credential failures do not expose a restore action", async () => { ).toEqual([]); }); +test("a hosted Ask screen has no Intelligence dashboard link and leaves the menus alone", async () => { + useCompatibleEndpointSetup({}); + const previous = invokeHandler; + invokeHandler = async (command, args) => { + if (command === "ask_the_bot") return "42"; + return previous(command, args); + }; + const view = await enterCompatibleEndpoint("https://models.example/v1"); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + await userEvent.click(view.getByRole("button", { name: "Start OpenBot" })); + await userEvent.click(await view.findByRole("button", { name: "Ask" })); + await view.findByText("42"); + expect( + view.queryByRole("button", { name: "View in Intelligence" }), + ).toBeNull(); + expect( + invokeCalls.filter((call) => + ["offer_local_intelligence", "open_local_intelligence"].includes( + call.command, + ), + ), + ).toEqual([]); +}); + test("the Enter that finishes a composed character does not ask the Bot", async () => { useCompatibleEndpointSetup({}); const previous = invokeHandler; diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index f6bd2b6951..dddfb30547 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -19,6 +19,8 @@ import { import { intelligenceStopProblem, listLocalEval, + offerLocalIntelligence, + openLocalIntelligence, splitStopFailure, } from "./localEvalCli"; import { OrganizationSignIn } from "./OrganizationSignIn"; @@ -382,6 +384,17 @@ export function App() { if (connected) setLocalConnected(true); }, }); + // Local mode with a recorded stack: the dashboard can be opened, from here and from the menus. + const intelligenceOffered = localFlow && localStack !== null; + // What the menus were last told. Starts hidden, so a hosted run never calls. + const offeredToMenus = useRef({ root: "", offered: false }); + useEffect(() => { + const last = offeredToMenus.current; + if (last.offered === intelligenceOffered && last.root === root) return; + if (!last.offered && !intelligenceOffered) return; + offeredToMenus.current = { root, offered: intelligenceOffered }; + offerLocalIntelligence(root, intelligenceOffered).catch(() => {}); + }, [root, intelligenceOffered]); // Stop OpenBot was chosen in this window: nothing local starts again on its own. const stoppedByChoice = useRef(false); const credentialContext = useRef([ @@ -1122,6 +1135,11 @@ export function App() { invoke("show_openbot").catch((error) => recoverFromStart(error)); }} onBack={changeModelAfterAskFailure} + onViewIntelligence={ + intelligenceOffered + ? () => openLocalIntelligence(root).opened + : undefined + } /> {displayedFailure && }
      diff --git a/desktop/src/Ask.tsx b/desktop/src/Ask.tsx index 608635517c..6e9cc3bf19 100644 --- a/desktop/src/Ask.tsx +++ b/desktop/src/Ask.tsx @@ -19,6 +19,7 @@ export function Ask({ onAsk, onOpen, onBack, + onViewIntelligence, auto = false, stepLabel = "Last step", }: { @@ -26,6 +27,8 @@ export function Ask({ onAsk: (question: string) => Promise; onOpen: () => void; onBack: () => void; + /** Local mode with a recorded stack: opens its Intelligence dashboard, already signed in. */ + onViewIntelligence?: () => Promise; /** Asks `suggestion` on arrival, with no field to change it: the test is the screen. */ auto?: boolean; stepLabel?: string; @@ -42,6 +45,21 @@ export function Ask({ * built to replace, on the screen built to replace it. */ const [failure, setFailure] = useState(null); + const [opening, setOpening] = useState(false); + const [viewFailure, setViewFailure] = useState(null); + + async function viewIntelligence() { + if (!onViewIntelligence) return; + setOpening(true); + setViewFailure(null); + try { + await onViewIntelligence(); + } catch (error) { + setViewFailure(asProblem(error)); + } finally { + setOpening(false); + } + } // Deferred a tick, so a development double mount asks once. // biome-ignore lint/correctness/useExhaustiveDependencies: once, on arrival. @@ -85,6 +103,19 @@ export function Ask({

      {answer}

      )} + {answer !== null && onViewIntelligence && ( + <> + + {viewFailure &&

      {viewFailure.said}

      } + + )}
      {answer !== null && ( - )} - {/* While a local step runs, its Cancel is the only control. */} - {!localStarting && ( - <> - - {intelligenceMode !== "local" && ( - - )} - {displayedFailure && ( - - )} +
      +

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

      +

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

      + {intelligenceMode === "local" && ( + + )} + {intelligenceMode !== "local" && } + {displayedFailure && } + {/* While a local step runs, its Cancel is the only control. */} + {(intelligenceMode === "hosted" || !localStarting) && ( +
      + {intelligenceMode === "hosted" && ( + + )} + {!localStarting && ( + <> + + {intelligenceMode !== "local" && ( + + )} + {displayedFailure && ( + + )} + + )} +
      + )} + {!localStarting && (
      Installation options
      - - )} + )} +
      ); } @@ -1423,7 +1475,7 @@ export function App() {

      {signInUrl}

      -

      Waiting for you to approve it…

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

      @@ -1522,6 +1579,11 @@ export function App() { )} + {signingIn && ( + + Opening CopilotKit sign-in in your browser… + + )} )} {/* @@ -1649,6 +1711,8 @@ export function App() { )} + {stopping && Stopping OpenBot…} + {showing && Opening OpenBot…}

      {!running && ( @@ -1689,7 +1750,7 @@ export function App() { type="button" className="quiet" onClick={stop} - disabled={busy} + disabled={busy || showing} > Stop OpenBot diff --git a/desktop/src/Ask.tsx b/desktop/src/Ask.tsx index 6e9cc3bf19..84a8aa858a 100644 --- a/desktop/src/Ask.tsx +++ b/desktop/src/Ask.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { asProblem, Failure, type Problem } from "./Problem"; +import { StatusLine } from "./SetupProgress"; /** * The last screen: a question, an answer, and only then the handover. @@ -25,8 +26,10 @@ export function Ask({ }: { suggestion: string; onAsk: (question: string) => Promise; - onOpen: () => void; - onBack: () => void; + /** Shows OpenBot in the window; settles once it has, or has failed to. */ + onOpen: () => void | Promise; + /** Stops OpenBot and returns to the model screen; settles once it has. */ + onBack: () => void | Promise; /** Local mode with a recorded stack: opens its Intelligence dashboard, already signed in. */ onViewIntelligence?: () => Promise; /** Asks `suggestion` on arrival, with no field to change it: the test is the screen. */ @@ -46,14 +49,19 @@ export function Ask({ */ const [failure, setFailure] = useState(null); const [opening, setOpening] = useState(false); + const [opened, setOpened] = useState(false); const [viewFailure, setViewFailure] = useState(null); + // Leaving takes a while: Change the model stops OpenBot first, and showing it can wait on it. + const [leaving, setLeaving] = useState<"stopping" | "showing" | null>(null); async function viewIntelligence() { if (!onViewIntelligence) return; setOpening(true); + setOpened(false); setViewFailure(null); try { await onViewIntelligence(); + setOpened(true); } catch (error) { setViewFailure(asProblem(error)); } finally { @@ -61,6 +69,22 @@ export function Ask({ } } + async function leave(how: "stopping" | "showing", go: () => unknown) { + setLeaving(how); + try { + await go(); + } finally { + setLeaving(null); + } + } + const back = () => leave("stopping", onBack); + const open = () => leave("showing", onOpen); + const leavingSaid = leaving && ( + + {leaving === "stopping" ? "Stopping OpenBot…" : "Opening OpenBot…"} + + ); + // Deferred a tick, so a development double mount asks once. // biome-ignore lint/correctness/useExhaustiveDependencies: once, on arrival. useEffect(() => { @@ -95,7 +119,7 @@ export function Ask({

      Question

      {question}

      - {asking &&

      Asking…

      } + {asking && Asking…} {failure && } {answer !== null && (
      @@ -113,25 +137,36 @@ export function Ask({ > View in Intelligence + {opening && ( + Opening the Intelligence dashboard… + )} + {opened && ( + Opened in your browser. + )} {viewFailure &&

      {viewFailure.said}

      } )} + {leavingSaid}
      {answer !== null && ( - )} {failure && ( <> - @@ -185,13 +220,14 @@ export function Ask({
      )} + {leavingSaid}
      {answer === null ? ( ) : ( - )} @@ -204,10 +240,8 @@ export function Ask({ diff --git a/desktop/src/IntelligenceOpening.tsx b/desktop/src/IntelligenceOpening.tsx new file mode 100644 index 0000000000..c71317bb5d --- /dev/null +++ b/desktop/src/IntelligenceOpening.tsx @@ -0,0 +1,40 @@ +import { listen } from "@tauri-apps/api/event"; +import { useEffect, useState } from "react"; +import { StatusLine } from "./SetupProgress"; + +/** What the menu's "Open Intelligence dashboard" says, in fixed native words. */ +type Opening = { state: "opening" | "opened" | "failed"; said: string }; + +/** How long how it went stays on screen. */ +const SHOWN_FOR_MS = 6_000; + +/** + * The menu's dashboard opening, over whichever setup screen is showing. + * + * The menu can be chosen while the window shows anything, so this sits apart from every screen + * rather than in one of them, and never moves what is under it. + */ +export function IntelligenceOpening() { + const [opening, setOpening] = useState(null); + useEffect(() => { + const stop = listen("intelligence:opening", (event) => + setOpening(event.payload), + ); + return () => { + stop.then((unlisten) => unlisten()); + }; + }, []); + useEffect(() => { + if (!opening || opening.state === "opening") return; + const timer = setTimeout(() => setOpening(null), SHOWN_FOR_MS); + return () => clearTimeout(timer); + }, [opening]); + if (!opening) return null; + return ( +
      + + {opening.said} + +
      + ); +} diff --git a/desktop/src/OrganizationSignIn.tsx b/desktop/src/OrganizationSignIn.tsx index 507a415ab3..71a9280bea 100644 --- a/desktop/src/OrganizationSignIn.tsx +++ b/desktop/src/OrganizationSignIn.tsx @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { useEffect, useRef, useState } from "react"; import { asProblem, Failure } from "./Problem"; +import { StatusLine } from "./SetupProgress"; export function OrganizationSignIn({ root, @@ -72,9 +73,9 @@ export function OrganizationSignIn({ ))} {busy && ( -

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

      + )} {signInUrl && (

      diff --git a/desktop/src/ProviderPicker.test.tsx b/desktop/src/ProviderPicker.test.tsx index a976856de7..7e096bc02c 100644 --- a/desktop/src/ProviderPicker.test.tsx +++ b/desktop/src/ProviderPicker.test.tsx @@ -206,6 +206,10 @@ test("a pending plan sign-in completion is ignored after switching provider rows invokeCalls.some((call) => call.command === "finish_chatgpt_sign_in"), ).toBe(true), ); + // The wait for the browser is said with a spinner, as every setup row is. + const waiting = view.getByRole("status"); + expect(waiting.textContent).toBe("Waiting for you to approve it…"); + expect(waiting.querySelector(".step-spinner")).not.toBeNull(); await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); await act(async () => { diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index 4a02f3f085..d9ba5e163a 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -5,6 +5,7 @@ import { ExternalLink } from "./ExternalLink"; import { isHttpEndpointUrl } from "./http-endpoint-url"; import { Mark } from "./Mark"; import { asProblem, InlineFailure, type Problem } from "./Problem"; +import { StatusLine } from "./SetupProgress"; export type Login = "plan" | "api-key" | "endpoint" | "oauth"; @@ -586,9 +587,9 @@ export function ProviderPicker({

      Signed in to {row.name}.

      ) : signInUrl ? ( <> -

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

      + {oauthCode && (

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

      Preparing sign-in…

      } + {busy && !signInUrl && ( + Preparing sign-in… + )}
      {busy ? "Checking…" : "Finish signing in"} + {busy && Checking the code…} ) : ( -

      - Waiting for you to approve it… -

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

      - {progress} -

      + {busy && ( + {progress ?? "Preparing sign-in…"} )} ))} diff --git a/desktop/src/SetupProgress.tsx b/desktop/src/SetupProgress.tsx index d8f16d0255..f7cc01a53c 100644 --- a/desktop/src/SetupProgress.tsx +++ b/desktop/src/SetupProgress.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; /** One setup step, as the native side reports it or the window runs it. */ export type Progress = { @@ -11,10 +11,16 @@ export type Progress = { startedAt?: number; }; -export function SetupProgress({ steps }: { steps: Progress[] }) { +export function SetupProgress({ + steps, + label = "Setup progress", +}: { + steps: Progress[]; + label?: string; +}) { if (steps.length === 0) return null; return ( -
        +
          {steps.map((step) => ( ))} @@ -22,6 +28,27 @@ export function SetupProgress({ steps }: { steps: Progress[] }) { ); } +/** + * One sentence about something that is happening now, with the rows' spinner while `working`. + * + * For any action that takes longer than a moment: said on the click, before the call it waits for + * has answered, so pressing a button never looks like nothing happened. + */ +export function StatusLine({ + working = true, + children, +}: { + working?: boolean; + children: ReactNode; +}) { + return ( +

          + {working &&

          + ); +} + /** Whole seconds since `since`, or since `running` turned on, while it is on. */ export function useElapsed(running: boolean, since?: number | null): number { const [elapsed, setElapsed] = useState(0); @@ -154,6 +181,10 @@ function label(step: string): string { return "Containers"; case "migrate": return "Database"; + case "stop-openbot": + return "Stopping OpenBot"; + case "stop-intelligence": + return "Stopping Intelligence"; default: return step; } diff --git a/desktop/src/ShuttingDown.tsx b/desktop/src/ShuttingDown.tsx new file mode 100644 index 0000000000..7a19aebe9b --- /dev/null +++ b/desktop/src/ShuttingDown.tsx @@ -0,0 +1,75 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useEffect, useState } from "react"; +import { type Progress, SetupProgress } from "./SetupProgress"; + +/** + * The Shutting down screen's rows, as `quit:progress` carries them: the whole list each time, with + * a sequence that only rises, so whichever copy is newest wins. + */ +export type QuitStatus = { seq: number; steps: Progress[] }; + +/** The setup page's fragment that the native side opens to show this screen alone. */ +const SHUTTING_DOWN_HASH = "#shutting-down"; + +export function isShuttingDownPage(hash: string): boolean { + return hash === SHUTTING_DOWN_HASH; +} + +/** + * The rows of a Quit in progress, or null while nobody has quit. + * + * `catchUp` also asks for the rows sent before this page loaded, for a window the native side + * navigated here once the cleanup had already begun. + */ +export function useQuitStatus(catchUp: boolean): QuitStatus | null { + const [status, setStatus] = useState(null); + useEffect(() => { + let active = true; + const keep = (next: QuitStatus | null) => { + if (!active || !next) return; + setStatus((current) => + current && current.seq >= next.seq ? current : next, + ); + }; + const stop = listen("quit:progress", (event) => + keep(event.payload), + ); + if (catchUp) { + stop + .then(() => invoke("quit_status")) + .then(keep) + // The rows still arrive as events; the screen does not depend on this answer. + .catch(() => {}); + } + return () => { + active = false; + stop.then((unlisten) => unlisten()); + }; + }, [catchUp]); + return status; +} + +/** What the person sees between choosing Quit and the app closing. Nothing to press. */ +export function ShuttingDown({ status }: { status: QuitStatus | null }) { + const steps = status?.steps ?? []; + const failed = steps.some((step) => !step.ok); + return ( +
          +
          +

          Shutting down OpenBot

          +

          + {failed + ? "Part of it could not be stopped. OpenBot says what happened the next time it opens." + : "OpenBot is stopping what it started on this computer, then it closes."} +

          + +
          +
          + ); +} + +/** The whole window when the native side opened the setup page on this screen. */ +export function ShuttingDownWindow() { + return ; +} diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index b536b4b047..93731fd49e 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -1,14 +1,20 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { isShuttingDownPage, ShuttingDownWindow } from "./ShuttingDown"; import "./styles.css"; const root = document.getElementById("root"); if (!root) throw new Error("index.html has no #root for the window to render into."); +// Sent here by Quit: only the Shutting down screen, so none of setup's launch checks run again. createRoot(root).render( - + {isShuttingDownPage(window.location.hash) ? ( + + ) : ( + + )} , ); diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 4abbbcd40b..36d3af4a73 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -773,3 +773,35 @@ details > summary:hover { color: var(--ink); white-space: pre-wrap; } + +/* Something running now, said beside the rows' spinner. */ +.status-line { + display: flex; + align-items: center; + gap: 0.55rem; + margin: 0.75rem 0 0; + font-size: 0.85rem; + color: var(--ink-soft); +} + +.status-line .step-spinner { + flex: none; + margin-top: 0; +} + +/* The menu's status on whichever setup screen is showing, without moving that screen. */ +.window-status { + position: fixed; + left: 50%; + bottom: 1.25rem; + transform: translateX(-50%); + max-width: calc(100% - 2rem); + padding: 0.5rem 0.9rem; + border: 1px solid var(--hair); + border-radius: var(--r-card); + background: var(--raised); +} + +.window-status .status-line { + margin: 0; +} From 91afa16aa4fb232a89116de2c6f4d0eb903887e5 Mon Sep 17 00:00:00 2001 From: Dusty Date: Wed, 21 Oct 2026 23:24:15 -0700 Subject: [PATCH 11/77] wip(desktop): Intelligence sidebar row with instant dashboard open; no menu-bar status text The menu-bar title is gone (tray-icon never clears it on macOS). An Intelligence row at the bottom of the app sidebar opens the local dashboard through a two-command plugin granted only to the owned loopback app origin. After the first sign-in of a Desktop run, Open sends the stored dashboard address straight to the browser with no CLI call. Debug builds hash the CLI pin with an optimised sha2 (4.86 s to 0.29 s). --- .../components/app-sidebar/app-sidebar.tsx | 3 + .../app-sidebar/intelligence-row.tsx | 54 ++ app/src/lib/desktop/intelligence-dashboard.ts | 171 +++++ app/tests/intelligence-row.test.tsx | 184 ++++++ desktop/src-tauri/Cargo.toml | 5 + desktop/src-tauri/build.rs | 9 +- .../src-tauri/gen/schemas/acl-manifests.json | 2 +- .../src-tauri/gen/schemas/desktop-schema.json | 24 + .../src-tauri/gen/schemas/macOS-schema.json | 24 + desktop/src-tauri/src/intelligence_bridge.rs | 308 +++++++++ desktop/src-tauri/src/main.rs | 583 +++++++++++++++--- 11 files changed, 1266 insertions(+), 101 deletions(-) create mode 100644 app/src/components/app-sidebar/intelligence-row.tsx create mode 100644 app/src/lib/desktop/intelligence-dashboard.ts create mode 100644 app/tests/intelligence-row.test.tsx create mode 100644 desktop/src-tauri/src/intelligence_bridge.rs diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index 47e336b056..0d59ab7e78 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -64,6 +64,7 @@ import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; import { Channel } from "./channel"; import { ChannelPagination } from "./channel-pagination"; +import { IntelligenceRow } from "./intelligence-row"; const appLinkOptions = { to: "/" } satisfies LinkOptions; const adminLinkOptions = { to: "/admin" } satisfies LinkOptions; @@ -410,6 +411,8 @@ export function AppSidebar({ ...props }: React.ComponentProps) { Connections + {/* Inside OpenBot Desktop with a local Intelligence stack only; nothing otherwise. */} + + +
          + {dashboard.opening ? ( +
          + + {dashboard.opening ? "Opening…" : "Intelligence"} + +
          + {dashboard.failure ? ( +

          + {dashboard.failure} +

          + ) : null} +
          + ); +} diff --git a/app/src/lib/desktop/intelligence-dashboard.ts b/app/src/lib/desktop/intelligence-dashboard.ts new file mode 100644 index 0000000000..b4e02eaef8 --- /dev/null +++ b/app/src/lib/desktop/intelligence-dashboard.ts @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useState } from "react"; + +/** + * The OpenBot Desktop shell's "Open Intelligence dashboard", reached from this page. + * + * Not a server request: inside Desktop the page is loaded in the shell's own window, and the shell + * lets exactly this app's origin call two commands of one plugin (`openbot-intelligence`), `watch` + * and `open`. In a plain browser there is no shell, and in Desktop without a local Intelligence + * stack the shell says `unavailable`; either way the row is not drawn. + * + * What the shell tells the page is only a state and, for a failure, a fixed category. The words are + * chosen here. + */ +export type DashboardFailure = + | "not_set_up" + | "not_responding" + | "browser_failed" + | "busy" + | "engine_stopped" + | "unsupported" + | "timed_out" + | "stopped" + | "failed"; + +export type DashboardOffer = + | { state: "unavailable" | "ready" | "opening" } + | { state: "failed"; reason: DashboardFailure }; + +const FAILURES: Record = { + not_set_up: "Set up local Intelligence before opening it.", + not_responding: "Intelligence is not responding yet. Try again shortly.", + browser_failed: "Your browser did not open. Try again.", + busy: "Another Intelligence command is running. Try again when it finishes.", + engine_stopped: "The software Intelligence runs on is not running.", + unsupported: "This version of OpenBot cannot open Intelligence.", + timed_out: "Intelligence took too long to open. Try again.", + stopped: "Opening Intelligence was stopped.", + failed: "OpenBot could not open Intelligence. Try again.", +}; + +export function dashboardFailureSaid(reason: DashboardFailure): string { + return FAILURES[reason]; +} + +/** The part of Tauri's injected IPC this page uses. Present only inside the Desktop window. */ +export type ShellIpc = { + transformCallback: (callback: (message: unknown) => void) => number; + unregisterCallback?: (id: number) => void; + invoke: (command: string, args?: Record) => Promise; +}; + +export function desktopShell(): ShellIpc | null { + if (typeof window === "undefined") return null; + const internals = (window as { __TAURI_INTERNALS__?: Partial }) + .__TAURI_INTERNALS__; + return typeof internals?.invoke === "function" && + typeof internals.transformCallback === "function" + ? (internals as ShellIpc) + : null; +} + +const WATCH = "plugin:openbot-intelligence|watch"; +const OPEN = "plugin:openbot-intelligence|open"; + +/** A message from the shell, or null for anything that is not exactly one of the known shapes. */ +export function readOffer(message: unknown): DashboardOffer | null { + if (!message || typeof message !== "object") return null; + const { state, reason } = message as { state?: unknown; reason?: unknown }; + if (state === "unavailable" || state === "ready" || state === "opening") + return { state }; + if (state === "failed") + return { + state, + reason: + typeof reason === "string" && reason in FAILURES + ? (reason as DashboardFailure) + : "failed", + }; + return null; +} + +/** + * Hand the shell a channel and call `onOffer` with each thing it says, in order. Returns the + * cleanup. A shell that refuses (another origin, an older Desktop) reads as `unavailable`. + */ +export function watchDashboard( + shell: ShellIpc, + onOffer: (offer: DashboardOffer) => void, +): () => void { + let active = true; + // Tauri numbers a channel's messages; one that arrives late is older than what is shown. + let next = 0; + const id = shell.transformCallback((raw) => { + if (!active || !raw || typeof raw !== "object" || !("message" in raw)) + return; + const index = (raw as { index?: unknown }).index; + if (typeof index === "number") { + if (index < next) return; + next = index + 1; + } + const offer = readOffer((raw as { message: unknown }).message); + if (offer) onOffer(offer); + }); + shell.invoke(WATCH, { onOffer: `__CHANNEL__:${id}` }).catch(() => { + if (active) onOffer({ state: "unavailable" }); + }); + return () => { + active = false; + shell.unregisterCallback?.(id); + }; +} + +/** How long a failure stays under the row. */ +const FAILURE_SHOWN_FOR_MS = 8_000; + +export type IntelligenceDashboard = { + /** Draw the row at all. */ + available: boolean; + opening: boolean; + /** A short sentence about the last opening that failed, while it is shown. */ + failure: string | null; + open: () => void; +}; + +/** + * The sidebar row's state, from the same in-flight state as Desktop's menu: an opening started + * from the menu spins here too. + */ +export function useIntelligenceDashboard( + shell: ShellIpc | null = desktopShell(), +): IntelligenceDashboard { + const [offer, setOffer] = useState({ state: "unavailable" }); + const [failure, setFailure] = useState(null); + + useEffect(() => { + if (!shell) return; + return watchDashboard(shell, (next) => { + if (next.state === "failed") { + setFailure(dashboardFailureSaid(next.reason)); + setOffer({ state: "ready" }); + return; + } + if (next.state === "opening") setFailure(null); + setOffer(next); + }); + }, [shell]); + + useEffect(() => { + if (!failure) return; + const timer = setTimeout(() => setFailure(null), FAILURE_SHOWN_FOR_MS); + return () => clearTimeout(timer); + }, [failure]); + + const open = useCallback(() => { + if (!shell) return; + // Said at once; the shell's own `opening` follows, then how it went. + setFailure(null); + setOffer({ state: "opening" }); + shell.invoke(OPEN).catch(() => { + setFailure(dashboardFailureSaid("failed")); + setOffer({ state: "ready" }); + }); + }, [shell]); + + return { + available: shell !== null && offer.state !== "unavailable", + opening: offer.state === "opening", + failure, + open, + }; +} diff --git a/app/tests/intelligence-row.test.tsx b/app/tests/intelligence-row.test.tsx new file mode 100644 index 0000000000..5aa2cb6253 --- /dev/null +++ b/app/tests/intelligence-row.test.tsx @@ -0,0 +1,184 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { IntelligenceRow } from "@/components/app-sidebar/intelligence-row"; +import { SidebarMenu, SidebarProvider } from "@/components/ui/sidebar"; +import { + type DashboardFailure, + dashboardFailureSaid, + readOffer, + type ShellIpc, +} from "@/lib/desktop/intelligence-dashboard"; + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(() => GlobalRegistrator.unregister()); + +/** + * The Desktop shell as the page sees it: Tauri's injected IPC. `say` delivers a channel message the + * way Tauri does, `{ message, index }`; `refuse` makes `watch` reject, as it does for an origin the + * shell never allowed or a Desktop without the plugin. + */ +function fakeShell({ refuse = false } = {}) { + const calls: Array<{ command: string; args?: Record }> = []; + const callbacks = new Map void>(); + let index = 0; + let open: () => Promise = async () => null; + const shell: ShellIpc = { + transformCallback: (callback) => { + const id = callbacks.size + 1; + callbacks.set(id, callback); + return id; + }, + unregisterCallback: (id) => callbacks.delete(id), + invoke: async (command, args) => { + calls.push({ command, args }); + if (command.endsWith("|watch")) { + if (refuse) throw new Error("not allowed"); + return null; + } + return open(); + }, + }; + return { + shell, + calls, + say(message: unknown) { + const channel = [...callbacks.values()].at(-1); + act(() => channel?.({ message, index: index++ })); + }, + openWith(next: () => Promise) { + open = next; + }, + }; +} + +function draw(shell: ShellIpc | null) { + return render( + + + + + , + ); +} + +test("the row is not drawn in a plain browser", () => { + const view = draw(null); + expect(view.queryByRole("button", { name: /Intelligence/ })).toBeNull(); +}); + +test("the row is not drawn until Desktop says a local stack can be opened", async () => { + const desktop = fakeShell(); + const view = draw(desktop.shell); + await waitFor(() => + expect(desktop.calls.map((call) => call.command)).toEqual([ + "plugin:openbot-intelligence|watch", + ]), + ); + // The channel is handed over in Tauri's own form. + expect(desktop.calls[0]?.args).toEqual({ onOffer: "__CHANNEL__:1" }); + desktop.say({ state: "unavailable" }); + expect(view.queryByRole("button", { name: /Intelligence/ })).toBeNull(); + desktop.say({ state: "ready" }); + expect(view.getByRole("button", { name: "Intelligence" })).toBeTruthy(); + // Hosted Intelligence, or local mode turned off: gone again. + desktop.say({ state: "unavailable" }); + expect(view.queryByRole("button", { name: /Intelligence/ })).toBeNull(); +}); + +test("a shell that refuses this page draws no row", async () => { + const desktop = fakeShell({ refuse: true }); + const view = draw(desktop.shell); + await waitFor(() => expect(desktop.calls).toHaveLength(1)); + expect(view.queryByRole("button", { name: /Intelligence/ })).toBeNull(); +}); + +test("pressing the row spins and disables it at once, until the browser has the dashboard", async () => { + const desktop = fakeShell(); + const view = draw(desktop.shell); + await waitFor(() => expect(desktop.calls).toHaveLength(1)); + desktop.say({ state: "ready" }); + await userEvent.click(view.getByRole("button", { name: "Intelligence" })); + // Before the shell has said anything back. + const busy = view.getByRole("button", { name: "Opening…" }); + expect(busy.hasAttribute("disabled")).toBe(true); + expect(busy.getAttribute("aria-busy")).toBe("true"); + expect(desktop.calls.at(-1)?.command).toBe( + "plugin:openbot-intelligence|open", + ); + desktop.say({ state: "opening" }); + expect(view.getByRole("button", { name: "Opening…" })).toBeTruthy(); + desktop.say({ state: "ready" }); + const ready = view.getByRole("button", { name: "Intelligence" }); + expect(ready.hasAttribute("disabled")).toBe(false); + expect(view.queryByRole("status")).toBeNull(); +}); + +test("an opening started from Desktop's menu spins the row too", async () => { + const desktop = fakeShell(); + const view = draw(desktop.shell); + await waitFor(() => expect(desktop.calls).toHaveLength(1)); + desktop.say({ state: "opening" }); + expect( + view.getByRole("button", { name: "Opening…" }).hasAttribute("disabled"), + ).toBe(true); + expect(desktop.calls).toHaveLength(1); +}); + +test("a failure is said under the row in a short fixed sentence, and the row can be pressed again", async () => { + const desktop = fakeShell(); + const view = draw(desktop.shell); + await waitFor(() => expect(desktop.calls).toHaveLength(1)); + desktop.say({ state: "ready" }); + await userEvent.click(view.getByRole("button", { name: "Intelligence" })); + desktop.say({ state: "failed", reason: "not_responding" }); + expect(view.getByRole("status").textContent).toBe( + "Intelligence is not responding yet. Try again shortly.", + ); + expect( + view.getByRole("button", { name: "Intelligence" }).hasAttribute("disabled"), + ).toBe(false); + // Pressing again clears it. + await userEvent.click(view.getByRole("button", { name: "Intelligence" })); + expect(view.queryByRole("status")).toBeNull(); +}); + +test("a shell that cannot start the opening is said as a failure", async () => { + const desktop = fakeShell(); + desktop.openWith(async () => { + throw new Error("refused"); + }); + const view = draw(desktop.shell); + await waitFor(() => expect(desktop.calls).toHaveLength(1)); + desktop.say({ state: "ready" }); + await userEvent.click(view.getByRole("button", { name: "Intelligence" })); + await waitFor(() => + expect(view.getByRole("status").textContent).toBe( + "OpenBot could not open Intelligence. Try again.", + ), + ); +}); + +test("every failure category has its own sentence, and anything else is the general one", () => { + const reasons: DashboardFailure[] = [ + "not_set_up", + "not_responding", + "browser_failed", + "busy", + "engine_stopped", + "unsupported", + "timed_out", + "stopped", + "failed", + ]; + const said = reasons.map(dashboardFailureSaid); + expect(new Set(said).size).toBe(reasons.length); + for (const sentence of said) expect(sentence).toMatch(/^[A-Z].*\.$/); + expect( + readOffer({ state: "failed", reason: "https://leak.example" }), + ).toEqual({ state: "failed", reason: "failed" }); + expect(readOffer({ state: "open-everything" })).toBeNull(); + expect(readOffer("ready")).toBeNull(); +}); diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 78245f3b42..e949754bd7 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -40,3 +40,8 @@ libc = "0.2" [dev-dependencies] tauri = { version = "2", features = ["test"] } + +# Every local evaluation CLI call checks the pinned runtime and script against their digests, +# about 150 MB. Unoptimized, that hash alone took 4.9 s per call in a debug build; optimized, 0.3 s. +[profile.dev.package.sha2] +opt-level = 3 diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index f99ce6ebd6..c6788257c2 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -30,5 +30,12 @@ fn main() { ); println!("cargo:rerun-if-env-changed=RUSTC"); println!("cargo:rerun-if-env-changed=PATH"); - tauri_build::build() + // The one way the OpenBot app's own page reaches the shell: an inline plugin whose commands get + // permissions of their own, granted at runtime to the exact owned app origin. App commands stay + // out of a remote page's reach. + tauri_build::try_build(tauri_build::Attributes::new().plugin( + "openbot-intelligence", + tauri_build::InlinedPlugin::new().commands(&["watch", "open"]), + )) + .expect("the Tauri build failed") } diff --git a/desktop/src-tauri/gen/schemas/acl-manifests.json b/desktop/src-tauri/gen/schemas/acl-manifests.json index 0c61428572..6949f94d0a 100644 --- a/desktop/src-tauri/gen/schemas/acl-manifests.json +++ b/desktop/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"openbot-intelligence":{"default_permission":null,"permissions":{"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-watch":{"identifier":"allow-watch","description":"Enables the watch command without any pre-configured scope.","commands":{"allow":["watch"],"deny":[]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-watch":{"identifier":"deny-watch","description":"Denies the watch command without any pre-configured scope.","commands":{"allow":[],"deny":["watch"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file diff --git a/desktop/src-tauri/gen/schemas/desktop-schema.json b/desktop/src-tauri/gen/schemas/desktop-schema.json index 214b840f55..e39f59a361 100644 --- a/desktop/src-tauri/gen/schemas/desktop-schema.json +++ b/desktop/src-tauri/gen/schemas/desktop-schema.json @@ -2636,6 +2636,30 @@ "const": "dialog:deny-save", "markdownDescription": "Denies the save command without any pre-configured scope." }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the watch command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:allow-watch", + "markdownDescription": "Enables the watch command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the watch command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:deny-watch", + "markdownDescription": "Denies the watch command without any pre-configured scope." + }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/desktop/src-tauri/gen/schemas/macOS-schema.json b/desktop/src-tauri/gen/schemas/macOS-schema.json index 214b840f55..e39f59a361 100644 --- a/desktop/src-tauri/gen/schemas/macOS-schema.json +++ b/desktop/src-tauri/gen/schemas/macOS-schema.json @@ -2636,6 +2636,30 @@ "const": "dialog:deny-save", "markdownDescription": "Denies the save command without any pre-configured scope." }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the watch command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:allow-watch", + "markdownDescription": "Enables the watch command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the watch command without any pre-configured scope.", + "type": "string", + "const": "openbot-intelligence:deny-watch", + "markdownDescription": "Denies the watch command without any pre-configured scope." + }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/desktop/src-tauri/src/intelligence_bridge.rs b/desktop/src-tauri/src/intelligence_bridge.rs new file mode 100644 index 0000000000..63ccc816c5 --- /dev/null +++ b/desktop/src-tauri/src/intelligence_bridge.rs @@ -0,0 +1,308 @@ +/*! +The OpenBot app's own page asking the shell to open the local Intelligence dashboard. + +The app is served from a loopback port and loaded in the main window, so to Tauri it is a remote +origin, and a remote origin reaches no command of this app (Tauri refuses every app command to one +that no capability names). This is the one exception, kept as small as it can be: + +- an inline plugin, `openbot-intelligence`, with two commands: `watch`, which hands over a channel + for what the sidebar row should show, and `open`, which starts the same opening as the menu; +- a capability added at runtime for exactly the owned app's origin, `http://127.0.0.1:`, on + the main window only, granting those two commands and nothing else. No `core:event`, so the page + hears none of the setup window's events; +- what the page is told is an `Offer`: a state, and for a failure a fixed category. No address, + no CLI text, no stack id. +*/ + +use serde::Serialize; +use std::sync::Mutex; +use tauri::ipc::Channel; +use tauri::Manager; + +use super::{DashboardFailure, IntelligenceOpening, Shell}; + +pub const PLUGIN: &str = "openbot-intelligence"; + +/// What the app's sidebar row shows. A failure is told once, as it happens; a page that starts +/// watching afterwards is told the state that followed it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "state", content = "reason", rename_all = "lowercase")] +pub enum Offer { + /// Not local mode, or no stack recorded: the row is hidden. + Unavailable, + Ready, + Opening, + Failed(DashboardFailure), +} + +impl Offer { + /// What the row shows once the opening has said `status`. + pub fn after(status: &IntelligenceOpening) -> Offer { + match status { + IntelligenceOpening::Opening => Offer::Opening, + IntelligenceOpening::Opened => Offer::Ready, + IntelligenceOpening::Failed(failure) => Offer::Failed(*failure), + } + } +} + +#[derive(Default)] +pub struct Bridge { + /// The newest page's channel. Only the main window can watch, and a page that loads again + /// watches again, so one is kept. + watcher: Mutex>>, + /// The app origins already granted, so a navigation back does not add the same capability. + granted: Mutex>, + /// Set once the app was built with this plugin's permissions (`enable`). Tauri panics, holding + /// its permission lock, on a capability that names permissions it never compiled in. + enabled: std::sync::atomic::AtomicBool, +} + +/// Say the running app carries this plugin's permissions, so `allow` may grant them. Called from +/// the real app's setup; a test app built from a mock context never is. +pub fn enable(app: &tauri::AppHandle) { + app.state::() + .intelligence_bridge + .enabled + .store(true, std::sync::atomic::Ordering::SeqCst); +} + +/// The row's state now, from the same state the menu item uses. +pub fn current(app: &tauri::AppHandle) -> Offer { + let shell = app.state::(); + if shell + .intelligence_opening + .load(std::sync::atomic::Ordering::SeqCst) + { + Offer::Opening + } else if shell.intelligence_menu.lock().unwrap().is_some() { + Offer::Ready + } else { + Offer::Unavailable + } +} + +/// Tell the watching page. True when the main window shows a granted app page now and the channel +/// took the message, so a failure has somewhere on screen to be read. +pub fn tell(app: &tauri::AppHandle, offer: Offer) -> bool { + let bridge = &app.state::().intelligence_bridge; + let mut watcher = bridge.watcher.lock().unwrap(); + let Some(channel) = watcher.as_ref() else { + return false; + }; + if channel.send(offer).is_err() { + *watcher = None; + return false; + } + drop(watcher); + let showing = app + .get_webview_window("main") + .and_then(|window| window.url().ok()) + .map(|url| url.origin().ascii_serialization()); + showing.is_some_and(|origin| bridge.granted.lock().unwrap().contains(&origin)) +} + +/// The exact origin of an owned app address, or None for anything but `http` on a loopback IP. +pub fn app_origin(address: &str) -> Option { + let url = tauri::Url::parse(address).ok()?; + // An IP literal only: a name could resolve anywhere. + let loopback = url + .host_str()? + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + (url.scheme() == "http" && loopback && url.port().is_some()) + .then(|| url.origin().ascii_serialization()) +} + +/// Let the owned app at `address` use this plugin from the main window, once per origin. +/// +/// Called with the address the shell verified it owns, before navigating there. Anything that is +/// not a loopback `http` address with a port is refused. +pub fn allow(app: &tauri::AppHandle, address: &str) { + let Some(origin) = app_origin(address) else { + return; + }; + let shell = app.state::(); + if !shell + .intelligence_bridge + .enabled + .load(std::sync::atomic::Ordering::SeqCst) + { + return; + } + let mut granted = shell.intelligence_bridge.granted.lock().unwrap(); + if granted.contains(&origin) { + return; + } + let capability = tauri::ipc::CapabilityBuilder::new(format!("openbot-app-{}", granted.len())) + .remote(format!("{origin}/*")) + .local(false) + .window("main") + .permission(format!("{PLUGIN}:allow-watch")) + .permission(format!("{PLUGIN}:allow-open")); + match app.add_capability(capability) { + Ok(()) => granted.push(origin), + Err(error) => eprintln!("[intelligence] could not allow the app page: {error}"), + } +} + +/// Keep `on_offer` as the page's channel and tell it the state now. +#[tauri::command] +fn watch(app: tauri::AppHandle, on_offer: Channel) { + let now = current(&app); + if on_offer.send(now).is_ok() { + *app.state::() + .intelligence_bridge + .watcher + .lock() + .unwrap() = Some(on_offer); + } +} + +/// Start opening the dashboard, exactly as the menu does. How it goes arrives through `watch`. +#[tauri::command] +fn open(app: tauri::AppHandle) { + super::start_intelligence_opening(app); +} + +pub fn plugin() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new(PLUGIN) + .invoke_handler(tauri::generate_handler![watch, open]) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_a_loopback_ip_with_a_port_is_an_app_origin() { + assert_eq!( + app_origin("http://127.0.0.1:3010").as_deref(), + Some("http://127.0.0.1:3010") + ); + assert_eq!( + app_origin("http://[::1]:3010/channel/x").as_deref(), + Some("http://[::1]:3010") + ); + for refused in [ + "http://localhost:3010", + "https://127.0.0.1:3010", + "http://127.0.0.1", + "http://192.168.1.4:3010", + "http://openbot.example.com:3010", + "tauri://localhost", + "not a url", + ] { + assert_eq!(app_origin(refused), None, "{refused}"); + } + } + + fn app() -> ( + tauri::App, + tauri::WebviewWindow, + ) { + let app = tauri::test::mock_builder() + .plugin(plugin()) + .manage(Shell::default()) + .invoke_handler(tauri::generate_handler![crate::default_root]) + .build(tauri::generate_context!(test = true)) + .unwrap(); + enable(app.handle()); + let window = app.get_webview_window("main").unwrap_or_else(|| { + tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap() + }); + (app, window) + } + + fn ask( + window: &tauri::WebviewWindow, + from: &str, + cmd: &str, + ) -> Result { + tauri::test::get_ipc_response( + window, + tauri::webview::InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: from.parse().unwrap(), + body: tauri::ipc::InvokeBody::Json(serde_json::json!({})), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.into(), + }, + ) + .map(|body| body.deserialize().unwrap()) + } + + const OPEN: &str = "plugin:openbot-intelligence|open"; + + #[test] + fn the_owned_app_origin_reaches_these_commands_and_nothing_else() { + let (app, window) = app(); + let page = "http://127.0.0.1:3010/channel/existing"; + // Before the shell has verified and allowed the app, its page reaches nothing. + assert!(ask(&window, page, OPEN).is_err()); + + allow(app.handle(), "http://127.0.0.1:3010"); + assert_eq!(ask(&window, page, OPEN), Ok(serde_json::Value::Null)); + // Not the app's other commands, and not another port on the same address. + assert!(ask(&window, page, "default_root").is_err()); + assert!( + ask(&window, "tauri://localhost", "default_root").is_ok(), + "the setup page still reaches it, so the refusal above is the origin's" + ); + assert!(ask(&window, "http://127.0.0.1:3011/", OPEN).is_err()); + assert!(ask(&window, "http://localhost:3010/", OPEN).is_err()); + // Allowing again adds nothing new and changes nothing. + allow(app.handle(), "http://127.0.0.1:3010"); + assert_eq!( + app.state::() + .intelligence_bridge + .granted + .lock() + .unwrap() + .len(), + 1 + ); + // A refused address is never allowed. + allow(app.handle(), "http://localhost:3011"); + assert!(ask(&window, "http://localhost:3011/", OPEN).is_err()); + } + + #[test] + fn an_app_built_without_the_plugins_permissions_grants_nothing() { + let app = tauri::test::mock_builder() + .manage(Shell::default()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + // No panic, and nothing recorded as granted. + allow(app.handle(), "http://127.0.0.1:3010"); + assert!(app + .state::() + .intelligence_bridge + .granted + .lock() + .unwrap() + .is_empty()); + } + + #[test] + fn the_row_follows_the_menus_state() { + let (app, _window) = app(); + assert_eq!(current(app.handle()), Offer::Unavailable); + let shell = app.state::(); + *shell.intelligence_menu.lock().unwrap() = Some("/tmp/openbot".into()); + assert_eq!(current(app.handle()), Offer::Ready); + shell + .intelligence_opening + .store(true, std::sync::atomic::Ordering::SeqCst); + assert_eq!(current(app.handle()), Offer::Opening); + // With no page watching, nothing is told and a failure needs another place. + assert!(!tell(app.handle(), Offer::Failed(DashboardFailure::Busy))); + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index ebb14575ae..dd6f92a1b9 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -7,6 +7,7 @@ use std::sync::Mutex; mod desktop_connection; mod desktop_host_access; mod desktop_telemetry; +mod intelligence_bridge; #[cfg(test)] mod test_support; @@ -98,6 +99,10 @@ struct Shell { intelligence_menu: Mutex>, /// The menu's "Open Intelligence dashboard" is running; its item is disabled until it ends. intelligence_opening: std::sync::Arc, + /// Dashboard addresses and this session's sign-ins, so a later opening needs no CLI call. + dashboards: Dashboards, + /// The OpenBot app page's sidebar row, and the app origins allowed to reach it. + intelligence_bridge: intelligence_bridge::Bridge, } /// The deployment whose Compose up may have created containers, including a partial failure. @@ -2917,6 +2922,8 @@ fn show_openbot_on( ) .connection(openbot_desktop_lib::problem::Connection::Organization)); } + // Only the owned app itself; an organization's OpenBot is not this machine's page. + intelligence_bridge::allow(&app, &url); url } else { openbot_desktop_lib::organization_auth::session_destination(&root, &authority, &url)? @@ -3798,16 +3805,30 @@ fn cancel_model_oauth(attempt_id: String) { /// call so `cancel_local_eval` can stop it, including before it has started. #[tauri::command] async fn run_local_eval( + app: tauri::AppHandle, attempt: String, action: local_eval::Action, stack: String, ) -> Result { let stack = local_eval::StackId::parse(&stack)?; - tauri::async_runtime::spawn_blocking(move || { + let report = tauri::async_runtime::spawn_blocking(move || { local_eval::run_installed(&attempt, action, &stack) }) .await - .map_err(|_| local_eval::Failure::Spawn)? + .map_err(|_| local_eval::Failure::Spawn)??; + learn_dashboards(&app, report.stack.iter()); + Ok(report) +} + +/// Keep the dashboard address of every stack a CLI report described, for `Dashboards`. +fn learn_dashboards<'a>( + app: &tauri::AppHandle, + stacks: impl Iterator, +) { + let shell = app.state::(); + for stack in stacks { + shell.dashboards.learn(stack); + } } /// The stacks the CLI has recorded, so a stack id comes from the CLI rather than being typed. @@ -3830,17 +3851,20 @@ recorded, and only then does Stop or Quit stop that stack. */ #[tauri::command] async fn set_up_local_eval( + app: tauri::AppHandle, attempt: String, root: String, ) -> Result { let root = stack::root_from(&root); - tauri::async_runtime::spawn_blocking(move || { + let report = tauri::async_runtime::spawn_blocking(move || { set_up_local_eval_at(&root, |folder| { local_eval::setup_installed(&attempt, folder) }) }) .await - .map_err(|_| SetupError::Failure(local_eval::Failure::Spawn))? + .map_err(|_| SetupError::Failure(local_eval::Failure::Spawn))??; + learn_dashboards(&app, report.stack.iter()); + Ok(report) } /** @@ -3891,11 +3915,12 @@ as they are, with nothing taken. */ #[tauri::command] async fn connect_local_eval( + app: tauri::AppHandle, attempt: String, root: String, ) -> Result { let root = stack::root_from(&root); - tauri::async_runtime::spawn_blocking(move || { + let connection = tauri::async_runtime::spawn_blocking(move || { connect_local_eval_at( &root, |folder| local_eval::connect_installed(&attempt, folder), @@ -3904,7 +3929,9 @@ async fn connect_local_eval( ) }) .await - .map_err(|_| ConnectError::Failure(local_eval::Failure::Spawn))? + .map_err(|_| ConnectError::Failure(local_eval::Failure::Spawn))??; + learn_dashboards(&app, connection.stack.iter()); + Ok(connection) } /// Where `local connect` puts the key for an app's runtime. OpenBot's server reads it as @@ -4011,10 +4038,16 @@ where } #[tauri::command] -async fn list_local_eval(attempt: String) -> Result { - tauri::async_runtime::spawn_blocking(move || local_eval::list_installed(&attempt)) - .await - .map_err(|_| local_eval::Failure::Spawn)? +async fn list_local_eval( + app: tauri::AppHandle, + attempt: String, +) -> Result { + let listing = + tauri::async_runtime::spawn_blocking(move || local_eval::list_installed(&attempt)) + .await + .map_err(|_| local_eval::Failure::Spawn)??; + learn_dashboards(&app, listing.stacks.iter()); + Ok(listing) } #[tauri::command] @@ -4023,53 +4056,212 @@ fn cancel_local_eval(attempt: String) { } /** -Sign the browser in to the recorded local stack's Intelligence dashboard, with no code to approve. +Open the recorded local stack's Intelligence dashboard, signed in, with no code to approve. -`local login` opens the browser itself, so nothing here opens a second tab. The window gets nothing -back but success or a fixed sentence; `attempt` lets `cancel_local_eval` stop the call. +`local login` opens the browser itself, so nothing here opens a second tab; once it has signed this +stack in during this session the dashboard's address is opened directly (see `Dashboards`). The +window gets nothing back but success or a fixed sentence; `attempt` lets `cancel_local_eval` stop +the call. */ #[tauri::command] -async fn open_local_intelligence(attempt: String, root: String) -> Result<(), Problem> { +async fn open_local_intelligence( + app: tauri::AppHandle, + attempt: String, + root: String, +) -> Result<(), Problem> { let root = stack::root_from(&root); tauri::async_runtime::spawn_blocking(move || { - open_local_intelligence_at(&root, |stack| local_eval::login_installed(&attempt, stack)) + open_dashboard_from(&app, &root, &attempt).map_err(|failure| failure.problem()) }) .await - .map_err(|_| Problem::plain(OPEN_INTELLIGENCE_FAILED))? + .map_err(|_| DashboardFailure::Failed.problem())? +} + +/// Why the dashboard did not open: a fixed category, which is all the OpenBot app's page is told, +/// and one fixed sentence for each, which is all the setup window and the menu say. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum DashboardFailure { + NotSetUp, + NotResponding, + BrowserFailed, + Busy, + EngineStopped, + Unsupported, + TimedOut, + Stopped, + Failed, } -const OPEN_INTELLIGENCE_FAILED: &str = - "OpenBot could not open the Intelligence dashboard. Try again."; +impl DashboardFailure { + fn said(self) -> &'static str { + match self { + DashboardFailure::NotSetUp => "Set up local Intelligence before opening its dashboard.", + DashboardFailure::NotResponding => { + "The Intelligence dashboard is not responding yet. Try again when Intelligence is ready." + } + DashboardFailure::BrowserFailed => "Your browser did not open. Try again.", + DashboardFailure::Busy => { + "Another Intelligence command is still running. Try again when it finishes." + } + DashboardFailure::EngineStopped => "The software Intelligence runs on is not running.", + DashboardFailure::Unsupported => { + "This version of OpenBot cannot open the Intelligence dashboard." + } + DashboardFailure::TimedOut => { + "The Intelligence dashboard took too long to open. Try again." + } + DashboardFailure::Stopped => "Opening the Intelligence dashboard was stopped.", + DashboardFailure::Failed => "OpenBot could not open the Intelligence dashboard. Try again.", + } + } + + fn problem(self) -> Problem { + Problem::plain(self.said()) + } +} -/// `local login` for the stack recorded in `root`, and nothing for a root with no record. +/// `open_dashboard_at` with its sentence, as the setup window is told. +#[cfg(test)] fn open_local_intelligence_at(root: &Path, login: L) -> Result<(), Problem> +where + L: FnOnce(&local_eval::StackId) -> Result, +{ + open_dashboard_at(root, login).map_err(DashboardFailure::problem) +} + +/// `sign_in` for the stack recorded in `root`, and nothing for a root with no record. +fn open_dashboard_at(root: &Path, sign_in: L) -> Result<(), DashboardFailure> where L: FnOnce(&local_eval::StackId) -> Result, { use local_eval::{Failure, Outcome, Reason}; - let recorded = local_eval_state::read(root, None) - .ok_or_else(|| Problem::plain("Set up local Intelligence before opening its dashboard."))?; - let said = match login(&recorded.stack_id()) { + let recorded = local_eval_state::read(root, None).ok_or(DashboardFailure::NotSetUp)?; + Err(match sign_in(&recorded.stack_id()) { Ok(signed) if signed.outcome == Outcome::Completed => return Ok(()), Ok(signed) => match signed.reason { - Some(Reason::DashboardUnavailable) => { - "The Intelligence dashboard is not responding yet. Try again when Intelligence is ready." - } - Some(Reason::BrowserOpenFailed) => "Your browser did not open. Try again.", - Some(Reason::LocalCommandBusy) => { - "Another Intelligence command is still running. Try again when it finishes." - } - Some(Reason::DockerUnavailable) => "The software Intelligence runs on is not running.", - _ => OPEN_INTELLIGENCE_FAILED, + Some(Reason::DashboardUnavailable) => DashboardFailure::NotResponding, + Some(Reason::BrowserOpenFailed) => DashboardFailure::BrowserFailed, + Some(Reason::LocalCommandBusy) => DashboardFailure::Busy, + Some(Reason::DockerUnavailable) => DashboardFailure::EngineStopped, + _ => DashboardFailure::Failed, }, - Err(Failure::Unavailable) => { - "This version of OpenBot cannot open the Intelligence dashboard." + Err(Failure::Unavailable) => DashboardFailure::Unsupported, + Err(Failure::Timeout) => DashboardFailure::TimedOut, + Err(Failure::Cancelled) => DashboardFailure::Stopped, + Err(_) => DashboardFailure::Failed, + }) +} + +/** +The dashboard addresses the CLI has reported this session, and which stacks `local login` has +signed in since OpenBot started. + +`local login` costs a CLI call: about 0.3 s to check the pinned CLI's digests and 0.25 s for it to +start, before it waits for the dashboard, mints a single-use link and hands that to the browser. +The browser keeps the session it was given, so once a stack has been signed in during this session, +"Open Intelligence dashboard" opens its address directly and at once. OpenBot does not read the +CLI's credential files or the browser to decide; the first opening in each session signs in again, +and a dashboard whose session ended shows its own Sign In. + +Addresses come only from CLI reports, whose dashboard address was checked to be a loopback one when +the report was read. A stack whose address changes is signed in again: the browser's session +belongs to the old address. +*/ +#[derive(Default)] +struct Dashboards { + known: Mutex>, +} + +#[derive(Default)] +struct KnownDashboard { + address: Option, + signed_in: bool, +} + +impl Dashboards { + fn learn(&self, stack: &local_eval::Stack) { + let mut known = self.known.lock().unwrap(); + let entry = known.entry(stack.id.clone()).or_default(); + if entry + .address + .as_ref() + .is_some_and(|address| *address != stack.urls.dashboard) + { + entry.signed_in = false; } - Err(Failure::Timeout) => "The Intelligence dashboard took too long to open. Try again.", - Err(Failure::Cancelled) => "Opening the Intelligence dashboard was stopped.", - Err(_) => OPEN_INTELLIGENCE_FAILED, - }; - Err(Problem::plain(said)) + entry.address = Some(stack.urls.dashboard.clone()); + } + + fn signed_in(&self, stack: &local_eval::StackId) -> Option { + let known = self.known.lock().unwrap(); + let entry = known.get(stack.as_str())?; + entry.signed_in.then(|| entry.address.clone()).flatten() + } + + fn mark_signed_in(&self, stack: &local_eval::StackId) { + self.known + .lock() + .unwrap() + .entry(stack.as_str().to_owned()) + .or_default() + .signed_in = true; + } +} + +/** +Open `stack`'s dashboard: straight to its address once this session has signed it in, otherwise +through `login`, remembering a completed sign-in. + +`open` hands an address to the browser. Its failure is the CLI's own category for the same thing. +*/ +fn open_or_sign_in( + dashboards: &Dashboards, + stack: &local_eval::StackId, + login: L, + open: O, +) -> Result +where + L: FnOnce(&local_eval::StackId) -> Result, + O: FnOnce(&str) -> Result<(), ()>, +{ + use local_eval::{Outcome, Reason, SignIn}; + if let Some(address) = dashboards.signed_in(stack) { + return Ok(match open(&address) { + Ok(()) => SignIn { + outcome: Outcome::Completed, + reason: None, + }, + Err(()) => SignIn { + outcome: Outcome::Failed, + reason: Some(Reason::BrowserOpenFailed), + }, + }); + } + let signed = login(stack)?; + if signed.outcome == Outcome::Completed { + dashboards.mark_signed_in(stack); + } + Ok(signed) +} + +/// The real `open_dashboard_at`: the installed CLI, and the browser through the opener plugin. +fn open_dashboard_from( + app: &tauri::AppHandle, + root: &Path, + attempt: &str, +) -> Result<(), DashboardFailure> { + open_dashboard_at(root, |stack| { + open_or_sign_in( + &app.state::().dashboards, + stack, + |stack| local_eval::login_installed(attempt, stack), + |address| { + use tauri_plugin_opener::OpenerExt; + app.opener().open_url(address, None::<&str>).map_err(|_| ()) + }, + ) + }) } /// The root whose dashboard the menu opens: only one in local mode with a recorded stack. @@ -4098,42 +4290,43 @@ fn offer_local_intelligence(app: tauri::AppHandle, root: String, offered: bool) if let Err(error) = install_menus(&app, show) { eprintln!("[menu] could not update the menus: {error}"); } + intelligence_bridge::tell(&app, intelligence_bridge::current(&app)); } -/// The menu's "Open Intelligence dashboard": a failure is said in a dialog, since the window may -/// be hidden. -/// -/// The CLI can take many seconds, so the click is answered at once: the window comes forward -/// without leaving what it shows, the menu bar and the tray's tooltip say the dashboard is -/// opening, the item is disabled, and a setup page shows the same words with a spinner. -fn open_intelligence_from_menu(app: tauri::AppHandle) { - let shell = app.state::(); - let Some(root) = shell.intelligence_menu.lock().unwrap().clone() else { - return; - }; - let opening = std::sync::Arc::clone(&shell.intelligence_opening); +/// The menu's "Open Intelligence dashboard". The CLI can take many seconds the first time, so the +/// window comes forward without leaving what it shows, where the setup page or the app's sidebar +/// says the dashboard is opening. +fn open_intelligence_from_menu(app: tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } + start_intelligence_opening(app); +} + +/** +Open the dashboard of the stack the menus offer, from the menu or the app's sidebar alike. + +Answered at once, before any CLI call: the menu item is disabled, a setup page shows the words +with a spinner, and the app's sidebar row spins (`intelligence_bridge`). How it went is said in the +same places. A failure that has none of them on screen is said in a dialog. +*/ +fn start_intelligence_opening(app: tauri::AppHandle) { + let shell = app.state::(); + let Some(root) = shell.intelligence_menu.lock().unwrap().clone() else { + // Nothing to open any more: a row that thought otherwise is told, so it does not spin. + intelligence_bridge::tell(&app, intelligence_bridge::current(&app)); + return; + }; + let opening = std::sync::Arc::clone(&shell.intelligence_opening); let status_app = app.clone(); open_intelligence_reporting( opening, move |status| show_intelligence_opening(&status_app, status), move || { let attempt = format!("menu-open-intelligence-{:016x}", rand::random::()); - let opened = open_local_intelligence_at(&root, |stack| { - local_eval::login_installed(&attempt, stack) - }); - if let Err(problem) = &opened { - use tauri_plugin_dialog::DialogExt; - app.dialog() - .message(problem.said.clone()) - .title("Open Intelligence dashboard") - .show(|_| {}); - } - opened + open_dashboard_from(&app, &root, &attempt) }, |job| { std::thread::Builder::new() @@ -4150,8 +4343,7 @@ fn open_intelligence_from_menu(app: tauri::AppHandle) { enum IntelligenceOpening { Opening, Opened, - /// One of `open_local_intelligence_at`'s fixed sentences. - Failed(String), + Failed(DashboardFailure), } /// `{"state": "opening" | "opened" | "failed", "said": …}`, the words always included. @@ -4177,7 +4369,7 @@ impl IntelligenceOpening { match self { IntelligenceOpening::Opening => "Opening the Intelligence dashboard…", IntelligenceOpening::Opened => "Opened in your browser.", - IntelligenceOpening::Failed(said) => said, + IntelligenceOpening::Failed(failure) => failure.said(), } } } @@ -4198,7 +4390,7 @@ fn open_intelligence_reporting( spawn: W, ) where S: Fn(IntelligenceOpening) + Clone + Send + 'static, - O: FnOnce() -> Result<(), Problem> + Send + 'static, + O: FnOnce() -> Result<(), DashboardFailure> + Send + 'static, W: FnOnce(Box) -> std::io::Result<()>, { use std::sync::atomic::Ordering; @@ -4211,46 +4403,47 @@ fn open_intelligence_reporting( let job = Box::new(move || { let outcome = match open() { Ok(()) => IntelligenceOpening::Opened, - Err(problem) => IntelligenceOpening::Failed(problem.said), + Err(failure) => IntelligenceOpening::Failed(failure), }; finishing.store(false, Ordering::SeqCst); finished(outcome); }); if spawn(job).is_err() { opening.store(false, Ordering::SeqCst); - status(IntelligenceOpening::Failed(OPEN_INTELLIGENCE_FAILED.into())); + status(IntelligenceOpening::Failed(DashboardFailure::Failed)); } } -const OPENING_INTELLIGENCE_TRAY: &str = "Opening Intelligence dashboard…"; - -/// A menu action that takes a while, said beside the tray icon: the title on macOS, the tooltip -/// everywhere. None puts both back. The tray belongs to the main thread. -fn tray_status(app: &tauri::AppHandle, said: Option<&'static str>) { - let tray_app = app.clone(); - let _ = app.run_on_main_thread(move || { - if let Some(tray) = tray_app.tray_by_id("openbot") { - let _ = tray.set_title(said); - let _ = tray.set_tooltip(Some(said.unwrap_or("OpenBot"))); - } - }); -} +const OPENING_INTELLIGENCE_ITEM: &str = "Opening Intelligence dashboard…"; /// The menu's "Open Intelligence dashboard" item: its words, and whether it can be chosen. fn intelligence_menu_item(opening: bool) -> (&'static str, bool) { if opening { - (OPENING_INTELLIGENCE_TRAY, false) + (OPENING_INTELLIGENCE_ITEM, false) } else { ("Open Intelligence dashboard", true) } } -/// Tell the window, then put the menu bar's title, the tray's tooltip and the menus in step. -/// Menus and the tray belong to the main thread. -fn show_intelligence_opening(app: &tauri::AppHandle, status: IntelligenceOpening) { +/// Tell the setup page and the app's sidebar, then put the menus in step. Nothing is written in +/// the menu bar: a status title there widened the item behind the camera notch and, on macOS, +/// clearing the title never took it away. A failure with no page on screen to say it gets a dialog. +/// Menus belong to the main thread. +fn show_intelligence_opening( + app: &tauri::AppHandle, + status: IntelligenceOpening, +) { let _ = app.emit(INTELLIGENCE_OPENING_EVENT, &status); - let opening = status == IntelligenceOpening::Opening; - tray_status(app, opening.then_some(OPENING_INTELLIGENCE_TRAY)); + let told = intelligence_bridge::tell(app, intelligence_bridge::Offer::after(&status)); + if let IntelligenceOpening::Failed(failure) = &status { + if !told && !showing_setup(app) { + use tauri_plugin_dialog::DialogExt; + app.dialog() + .message(failure.said()) + .title("Open Intelligence dashboard") + .show(|_| {}); + } + } let tray_app = app.clone(); let _ = app.run_on_main_thread(move || { let offered = tray_app @@ -4265,6 +4458,19 @@ fn show_intelligence_opening(app: &tauri::AppHandle, status: IntelligenceOpening }); } +/// Whether the main window shows the setup page, whose own overlay says the menu's outcome. +fn showing_setup(app: &tauri::AppHandle) -> bool { + let Some(window) = app.get_webview_window("main") else { + return false; + }; + let Ok(setup) = setup_destination(app) else { + return false; + }; + window + .url() + .is_ok_and(|current| same_window_app(¤t, &setup)) +} + /// `bun` from PATH, or the places an installer puts it when PATH has not been reloaded. fn which_bun() -> Option { if quiet::command("bun") @@ -4557,6 +4763,7 @@ fn restore_window_on(app: &tauri::AppHandle, ports: &openb .then(|| owned_app_url(&root, ports)) .flatten() { + intelligence_bridge::allow(app, &url); url.parse().ok() } else { setup_destination(app).ok() @@ -4617,9 +4824,6 @@ fn publish_quit_notice_failure(app: tauri::AppHandle, erro } fn stop_from_menu(app: tauri::AppHandle) { - // Stop can take minutes and the window may be showing OpenBot or nothing: said at once beside - // the tray icon, and put back once it is over either way. - tray_status(&app, Some("Stopping OpenBot…")); std::thread::spawn(move || { let root = default_root(); let root_path = PathBuf::from(&root); @@ -4640,7 +4844,6 @@ fn stop_from_menu(app: tauri::AppHandle) { report(&app, "stopped", false, problem.said); } } - tray_status(&app, None); let _ = show_setup(app.clone()); }); } @@ -4695,10 +4898,13 @@ The tray menu and the window's menu bar, with "Open Intelligence dashboard" when Built again whenever that changes, because a menu item cannot be hidden, only removed. */ -fn install_menus(app: &tauri::AppHandle, intelligence: bool) -> tauri::Result<()> { +fn install_menus( + app: &tauri::AppHandle, + intelligence: bool, +) -> tauri::Result<()> { use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; - let items = || -> tauri::Result>> { + let items = || -> tauri::Result>> { let mut items = vec![MenuItem::with_id( app, "open", @@ -4736,10 +4942,10 @@ fn install_menus(app: &tauri::AppHandle, intelligence: bool) -> tauri::Result<() )?); Ok(items) }; - fn refs(items: &[MenuItem]) -> Vec<&dyn IsMenuItem> { + fn refs(items: &[MenuItem]) -> Vec<&dyn IsMenuItem> { items .iter() - .map(|item| item as &dyn IsMenuItem) + .map(|item| item as &dyn IsMenuItem) .collect() } @@ -4805,6 +5011,7 @@ fn main() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(intelligence_bridge::plugin()) .manage(Shell::default()) .invoke_handler(tauri::generate_handler![ record_setup_event, @@ -4890,6 +5097,8 @@ fn main() { ))); remember_setup_url(app.handle())?; + // Built by `generate_context!`, so the bridge plugin's permissions are compiled in. + intelligence_bridge::enable(app.handle()); // The status menu lets somebody open the window, stop the stack, or quit the app. tauri::tray::TrayIconBuilder::with_id("openbot") @@ -7283,8 +7492,8 @@ mod tests { for (result, last) in [ (Ok(()), IntelligenceOpening::Opened), ( - Err(Problem::plain("Your browser did not open. Try again.")), - IntelligenceOpening::Failed("Your browser did not open. Try again.".into()), + Err(DashboardFailure::BrowserFailed), + IntelligenceOpening::Failed(DashboardFailure::BrowserFailed), ), ] { let opening = Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -7335,8 +7544,9 @@ mod tests { serde_json::json!({"state": "opened", "said": "Opened in your browser."}) ); assert_eq!( - serde_json::to_value(IntelligenceOpening::Failed("Fixed.".into())).unwrap(), - serde_json::json!({"state": "failed", "said": "Fixed."}) + serde_json::to_value(IntelligenceOpening::Failed(DashboardFailure::BrowserFailed)) + .unwrap(), + serde_json::json!({"state": "failed", "said": "Your browser did not open. Try again."}) ); } @@ -7359,12 +7569,187 @@ mod tests { *said.lock().unwrap(), [ IntelligenceOpening::Opening, - IntelligenceOpening::Failed(OPEN_INTELLIGENCE_FAILED.into()) + IntelligenceOpening::Failed(DashboardFailure::Failed) ] ); assert!(!opening.load(std::sync::atomic::Ordering::SeqCst)); } + fn reported_stack(id: &str, dashboard: &str) -> local_eval::Stack { + local_eval::Stack { + id: id.into(), + installed_release: "0.13.14".into(), + step: "ready".into(), + urls: local_eval::Urls { + api: "http://127.0.0.1:4201".into(), + dashboard: dashboard.into(), + gateway: "ws://127.0.0.1:4401".into(), + }, + has_failed_step: false, + update_in_progress: false, + observed: None, + } + } + + #[test] + fn the_dashboard_signs_in_once_per_session_then_opens_its_address_with_no_cli_call() { + use local_eval::{Outcome, Reason}; + use std::cell::RefCell; + let dashboards = Dashboards::default(); + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + let opened = RefCell::new(Vec::new()); + let open = |address: &str| { + opened.borrow_mut().push(address.to_owned()); + Ok(()) + }; + dashboards.learn(&reported_stack( + "local-abc123", + "http://intelligence.localhost:8080", + )); + + // A sign-in that did not complete is not remembered. + let signed = open_or_sign_in( + &dashboards, + &stack, + |_| signed_in(Outcome::Blocked, Some(Reason::DashboardUnavailable)), + open, + ) + .unwrap(); + assert_eq!(signed.outcome, Outcome::Blocked); + // The first completed opening in this session goes through `local login`. + let logins = RefCell::new(0); + let login = |_: &local_eval::StackId| { + *logins.borrow_mut() += 1; + signed_in(Outcome::Completed, None) + }; + open_or_sign_in(&dashboards, &stack, login, open).unwrap(); + assert_eq!(*logins.borrow(), 1); + assert!( + opened.borrow().is_empty(), + "local login opens the browser itself" + ); + // After that: the address, at once, and the CLI is never asked. + for _ in 0..2 { + let signed = open_or_sign_in( + &dashboards, + &stack, + |_| panic!("the CLI was asked again"), + open, + ) + .unwrap(); + assert_eq!(signed.outcome, Outcome::Completed); + } + assert_eq!( + *opened.borrow(), + [ + "http://intelligence.localhost:8080", + "http://intelligence.localhost:8080" + ] + ); + // A browser that would not open is the CLI's own category for it. + let failed = + open_or_sign_in(&dashboards, &stack, |_| panic!("no CLI"), |_| Err(())).unwrap(); + assert_eq!( + (failed.outcome, failed.reason), + (Outcome::Failed, Some(Reason::BrowserOpenFailed)) + ); + // A new address is a new origin with no session: signed in again. + dashboards.learn(&reported_stack( + "local-abc123", + "http://intelligence.localhost:8081", + )); + open_or_sign_in(&dashboards, &stack, login, open).unwrap(); + assert_eq!(*logins.borrow(), 2); + open_or_sign_in(&dashboards, &stack, |_| panic!("no CLI"), open).unwrap(); + assert_eq!( + opened.borrow().last().map(String::as_str), + Some("http://intelligence.localhost:8081") + ); + } + + #[test] + fn a_signed_in_stack_with_no_reported_address_signs_in_again() { + let dashboards = Dashboards::default(); + let stack = local_eval::StackId::parse("local-abc123").unwrap(); + let logins = std::cell::Cell::new(0); + for _ in 0..2 { + open_or_sign_in( + &dashboards, + &stack, + |_| { + logins.set(logins.get() + 1); + signed_in(local_eval::Outcome::Completed, None) + }, + |_| panic!("no address was ever reported"), + ) + .unwrap(); + } + assert_eq!(logins.get(), 2); + // Learning the address keeps the sign-in: the browser holds the session for it. + dashboards.learn(&reported_stack( + "local-abc123", + "http://intelligence.localhost:8080", + )); + assert_eq!( + dashboards.signed_in(&stack).as_deref(), + Some("http://intelligence.localhost:8080") + ); + // Another stack's sign-in is its own. + let other = local_eval::StackId::parse("local-def456").unwrap(); + assert_eq!(dashboards.signed_in(&other), None); + } + + #[test] + fn dashboard_failures_reach_the_app_page_as_categories_only() { + for (failure, category) in [ + (DashboardFailure::NotSetUp, "not_set_up"), + (DashboardFailure::NotResponding, "not_responding"), + (DashboardFailure::BrowserFailed, "browser_failed"), + (DashboardFailure::Busy, "busy"), + (DashboardFailure::EngineStopped, "engine_stopped"), + (DashboardFailure::Unsupported, "unsupported"), + (DashboardFailure::TimedOut, "timed_out"), + (DashboardFailure::Stopped, "stopped"), + (DashboardFailure::Failed, "failed"), + ] { + assert_eq!( + serde_json::to_value(intelligence_bridge::Offer::Failed(failure)).unwrap(), + serde_json::json!({"state": "failed", "reason": category}) + ); + } + for (offer, state) in [ + (intelligence_bridge::Offer::Unavailable, "unavailable"), + (intelligence_bridge::Offer::Ready, "ready"), + (intelligence_bridge::Offer::Opening, "opening"), + ] { + assert_eq!( + serde_json::to_value(offer).unwrap(), + serde_json::json!({ "state": state }) + ); + } + assert_eq!( + intelligence_bridge::Offer::after(&IntelligenceOpening::Opening), + intelligence_bridge::Offer::Opening + ); + assert_eq!( + intelligence_bridge::Offer::after(&IntelligenceOpening::Opened), + intelligence_bridge::Offer::Ready + ); + assert_eq!( + intelligence_bridge::Offer::after(&IntelligenceOpening::Failed(DashboardFailure::Busy)), + intelligence_bridge::Offer::Failed(DashboardFailure::Busy) + ); + } + + #[test] + fn nothing_is_ever_written_in_the_menu_bar() { + // The status title widened the tray item behind the camera notch, and on macOS + // clearing the title never took it away. The tray keeps its icon, tooltip and menu only. + let source = include_str!("main.rs"); + let call = ["set_", "title("].concat(); + assert!(!source.contains(&call), "the tray title is set again"); + } + #[test] fn stop_asks_intelligence_last_even_after_compose_down_failed() { let active = local_eval_root("openbot-stop-intelligence-order", None); From de1fe4956a3f2b3309ebde3be93762218cc87c7d Mon Sep 17 00:00:00 2001 From: Dusty Date: Wed, 21 Oct 2026 23:21:00 -0700 Subject: [PATCH 12/77] wip(desktop): Test OpenBot asks through the server so the thread is in Intelligence; question box with presets The test question went straight to the harness, which keeps its thread in memory, so Intelligence never saw it and View in Intelligence opened a dashboard with no conversation. A picked Bot is now asked through the owned server's runtime: the server mints the thread, the runtime records the run in Intelligence, and the desktop reads the thread's events until RUN_FINISHED or RUN_ERROR. The local test step is a question box again: a question already in it, presets one click away (strawberry r's, Die Hard, Pop-Tarts, 17 x 23), your own typed question, and Ask again after an answer. Nothing is asked until Ask. --- desktop/src-tauri/src/ask.rs | 236 ++++++++++++++++++++++++++++++++++ desktop/src-tauri/src/main.rs | 54 ++++++-- desktop/src/App.test.tsx | 53 ++++++-- desktop/src/App.tsx | 19 +-- desktop/src/Ask.tsx | 162 ++++++++++------------- desktop/src/styles.css | 28 ++++ 6 files changed, 420 insertions(+), 132 deletions(-) diff --git a/desktop/src-tauri/src/ask.rs b/desktop/src-tauri/src/ask.rs index 37b66efbdd..9208672a77 100644 --- a/desktop/src-tauri/src/ask.rs +++ b/desktop/src-tauri/src/ask.rs @@ -168,6 +168,139 @@ pub fn ask_mastra( mastra_answer_in(&text).ok_or_else(|| Problem::plain(String::new())) } +/// The runtime agent id the server gives the Bot that setup picked. See `server/src/routing/routes.ts`. +pub const PICKED_HARNESS_AGENT: &str = "picked-harness"; + +/// How often the thread's events are read while the run is going. +const WATCH_EVERY: Duration = Duration::from_millis(400); + +/** +Put a question to a Bot through the OpenBot server's own runtime, so it is a thread in Intelligence. + +`ask` goes straight to the harness, which proves the credential but leaves nothing behind: the +harness keeps its thread in memory and Intelligence never hears of it, so "View in Intelligence" +opened a dashboard with no conversation in it. Asked here, the server mints the thread id, the +runtime records the run in Intelligence, and the dashboard shows the question and the answer. + +The runtime in Intelligence mode does not stream the run back on the request: it answers with a +token for the realtime gateway and runs on its own. Rather than a websocket client for one +question, this reads the thread's recorded events until the run finishes or fails. + +`server` is the server's own address, which a single-user owned deployment admits with no sign-in. +*/ +pub fn ask_through_server(server: &str, agent_id: &str, question: &str) -> Result { + let server = server.trim_end_matches('/'); + let client = reqwest::blocking::Client::builder() + .timeout(PATIENCE) + .build() + .map_err(|error| { + Problem::plain(format!("This machine cannot make web requests: {error}")) + })?; + let unreachable = |error: reqwest::Error| { + Problem::with("OpenBot could not reach its own server.", error.to_string()) + }; + + let minted = client + .post(format!("{server}/api/threads/mint")) + .send() + .map_err(unreachable)?; + let (status, text) = read_response(minted, "openbot-server", server)?; + let thread_id = status + .is_success() + .then(|| serde_json::from_str::(&text).ok()) + .flatten() + .and_then(|minted| minted.get("threadId")?.as_str().map(str::to_owned)) + .ok_or_else(|| { + Problem::with( + "OpenBot's server would not start a conversation.", + format!("HTTP {status}\n{text}"), + ) + })?; + + let started = client + .post(format!("{server}/api/copilotkit/agent/{agent_id}/run")) + .header("content-type", "application/json") + .json(&serde_json::json!({ + "threadId": thread_id, + "runId": format!("openbot-setup-{}", moment()), + "state": {}, + "messages": [{ "id": format!("openbot-setup-question-{}", moment()), "role": "user", "content": question }], + "tools": [], + "context": [], + "forwardedProps": {}, + })) + .send() + .map_err(unreachable)?; + let (status, text) = read_response(started, "openbot-server", server)?; + if !status.is_success() { + return Err(Problem::with( + "The Bot could not answer.", + format!("HTTP {status}\n{text}"), + )); + } + + let events = format!("{server}/api/copilotkit/threads/{thread_id}/events"); + let deadline = std::time::Instant::now() + PATIENCE; + while std::time::Instant::now() < deadline { + std::thread::sleep(WATCH_EVERY); + // Not recorded yet reads as an error or an empty list; both are "not finished". + let Ok(response) = client.get(&events).send() else { + continue; + }; + if !response.status().is_success() { + continue; + } + let Ok(body) = response.text() else { + continue; + }; + if let Some(outcome) = run_outcome(&body) { + return outcome; + } + } + Err(Problem::with( + "The Bot did not answer in time. Ask again.", + format!("thread {thread_id}\nno RUN_FINISHED or RUN_ERROR within {PATIENCE:?}"), + )) +} + +/** +How a run on a thread ended, out of the thread's recorded events, or None while it is going. + +The answer is the `TEXT_MESSAGE_CONTENT` deltas, as in `answer_in`. A run that finished with no text +is the same empty sentence `ask` returns, so the caller can fetch the harness's log for the cause. +*/ +pub fn run_outcome(body: &str) -> Option> { + let recorded: serde_json::Value = serde_json::from_str(body).ok()?; + let events = recorded.get("events")?.as_array()?; + let mut answer = String::new(); + for event in events { + match event.get("type").and_then(|kind| kind.as_str()) { + Some("TEXT_MESSAGE_CONTENT") => { + if let Some(delta) = event.get("delta").and_then(|delta| delta.as_str()) { + answer.push_str(delta); + } + } + Some("RUN_ERROR") => { + let said = event + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or_default(); + return Some(Err(why_nothing_came_back(said))); + } + Some("RUN_FINISHED") => { + let answer = answer.trim(); + return Some(if answer.is_empty() { + Err(Problem::plain(String::new())) + } else { + Ok(answer.to_owned()) + }); + } + _ => {} + } + } + None +} + fn read_response( response: reqwest::blocking::Response, kind: &str, @@ -597,6 +730,109 @@ mod tests { let _ = server.request(); } + fn json_response(status: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + } + + /// One connection per response, answered in order; each request line is sent back. + fn serve_in_order(responses: Vec) -> (String, std::sync::mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let url = format!("http://{}", listener.local_addr().expect("addr")); + let (sender, received) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + use std::io::{Read, Write}; + for response in responses { + let (mut stream, _) = listener.accept().expect("accept"); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut buffer).expect("read"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + let text = String::from_utf8_lossy(&request).to_string(); + let _ = sender.send(text.lines().next().unwrap_or_default().to_owned()); + stream.write_all(response.as_bytes()).expect("write"); + } + }); + (url, received) + } + + #[test] + fn a_question_asked_through_the_server_is_a_minted_thread_read_back_until_it_finishes() { + let thread = "55569917-dab5-8fe4-84e1-a85613418e1b"; + let (url, requests) = serve_in_order(vec![ + json_response("200 OK", &format!("{{\"threadId\":\"{thread}\"}}")), + json_response( + "200 OK", + &format!("{{\"threadId\":\"{thread}\",\"joinToken\":\"t\"}}"), + ), + // Not recorded yet, then still going, then finished. + json_response("404 Not Found", "{}"), + json_response( + "200 OK", + r#"{"events":[{"type":"RUN_STARTED"},{"type":"TEXT_MESSAGE_CONTENT","delta":"Three"}]}"#, + ), + json_response( + "200 OK", + r#"{"events":[{"type":"RUN_STARTED"},{"type":"TEXT_MESSAGE_CONTENT","delta":"Three"},{"type":"TEXT_MESSAGE_CONTENT","delta":" r's."},{"type":"RUN_FINISHED"}]}"#, + ), + ]); + + let answer = ask_through_server(&url, PICKED_HARNESS_AGENT, "How many r's?").unwrap(); + + assert_eq!(answer, "Three r's."); + let asked: Vec = requests.try_iter().collect(); + assert_eq!( + asked, + [ + "POST /api/threads/mint HTTP/1.1".to_owned(), + "POST /api/copilotkit/agent/picked-harness/run HTTP/1.1".to_owned(), + format!("GET /api/copilotkit/threads/{thread}/events HTTP/1.1"), + format!("GET /api/copilotkit/threads/{thread}/events HTTP/1.1"), + format!("GET /api/copilotkit/threads/{thread}/events HTTP/1.1"), + ] + ); + } + + #[test] + fn a_run_that_will_not_start_on_the_server_says_so_with_the_response_behind_it() { + let (url, _) = serve_in_order(vec![ + json_response( + "200 OK", + r#"{"threadId":"55569917-dab5-8fe4-84e1-a85613418e1b"}"#, + ), + json_response("404 Not Found", r#"{"error":"Agent not found"}"#), + ]); + let problem = ask_through_server(&url, "nobody", "hello").unwrap_err(); + assert_eq!(problem.said, "The Bot could not answer."); + assert!(problem.detail.unwrap().contains("Agent not found")); + } + + #[test] + fn run_outcome_reads_finished_failed_and_still_going_runs() { + assert!(run_outcome("{}").is_none()); + assert!(run_outcome(r#"{"events":[{"type":"RUN_STARTED"}]}"#).is_none()); + // Finished with no text: the empty sentence the caller fills from the harness's log. + let empty = run_outcome(r#"{"events":[{"type":"RUN_FINISHED"}]}"#).unwrap(); + assert_eq!(empty.unwrap_err().said, ""); + let refused = run_outcome( + r#"{"events":[{"type":"RUN_STARTED"},{"type":"RUN_ERROR","message":"Error code: 401 - invalid_api_key"}]}"#, + ) + .unwrap() + .unwrap_err(); + assert!( + refused.said.starts_with("That key was refused."), + "{}", + refused.said + ); + } + struct TestRequest { path: String, headers: Vec, diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index dd6f92a1b9..2eb85a83df 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -3180,7 +3180,23 @@ async fn ask_the_bot_inner(root: PathBuf, question: String) -> Result Result, Problem> { @@ -3198,10 +3214,21 @@ fn ask_saved_settings(root: &Path) -> Result, +) -> Result { + ask_the_bot_via(root, question, settings, None).await +} + +async fn ask_the_bot_via( + root: PathBuf, + question: String, + settings: std::collections::BTreeMap, + server: Option, ) -> Result { // The picked harness if there is one, and the Bot that ships with OpenBot if there is not. // Both speak AG-UI at the same address shape, so this screen does not care which it got. @@ -3244,14 +3271,25 @@ async fn ask_the_bot_with_settings( question }; + // A picked Bot is the server's `picked-harness`, asked through the runtime so the question and + // its answer are a thread in Intelligence. The Bot that ships with OpenBot is asked directly. + let through = server.filter(|_| picked_endpoint.is_some()); let asked = tauri::async_runtime::spawn_blocking(move || { - match openbot_desktop_lib::ask::ask_harness( - &endpoint, - &token, - &question, - kind.as_deref(), - agent_id.as_deref(), - ) { + let answered = match &through { + Some(server) => openbot_desktop_lib::ask::ask_through_server( + server, + openbot_desktop_lib::ask::PICKED_HARNESS_AGENT, + &question, + ), + None => openbot_desktop_lib::ask::ask_harness( + &endpoint, + &token, + &question, + kind.as_deref(), + agent_id.as_deref(), + ), + }; + match answered { Ok(answer) => Ok(answer), // An empty sentence carries no cause. Local logs can explain an installed Bot; // they cannot explain a BYO endpoint, even when an old local harness still has logs. diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 40ec089b99..3d285b9b8f 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -549,8 +549,16 @@ 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."; +const LOCAL_QUESTION = "How many r's are in strawberry?"; + +/** Ask the question already in the Test OpenBot box, and wait for the answer. */ +async function askTheTest( + view: Awaited>, + answer = "I am working.", +) { + await userEvent.click(await view.findByRole("button", { name: "Ask" })); + return view.findByText(answer); +} function choice(view: Awaited>, name: string) { return view.getByRole("radio", { name: new RegExp(name) }); @@ -783,17 +791,32 @@ test("Continue during setup shows Getting ready, which starts, tests and opens O gatewayWsUrl: "ws://localhost:64772", apiKey: "", }); - // The test question goes by itself, once. + // A question is already in the box, nothing is asked until Ask, and a preset or a typed + // question replaces it. + const question = view.getByLabelText("Your question") as HTMLInputElement; + expect(question.value).toBe(LOCAL_QUESTION); + expect(invokeCalls.some((call) => call.command === "ask_the_bot")).toBe( + false, + ); + await userEvent.click( + view.getByRole("button", { name: "How many Die Hard movies are there?" }), + ); + expect(question.value).toBe("How many Die Hard movies are there?"); + await userEvent.type(question, "Who are you?", { + initialSelectionStart: 0, + initialSelectionEnd: question.value.length, + }); + await userEvent.click(view.getByRole("button", { name: "Ask" })); await view.findByText("I am working."); - 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 }, + args: { root: "/tmp/local-eval-auto", question: "Who are you?" }, }, ]); + // Asking again stays open after an answer. + expect(view.getByRole("button", { name: "Ask again" })).toBeTruthy(); expectPlain(view); await userEvent.click(view.getByRole("button", { name: "Open OpenBot" })); await waitFor(() => expect(localSequence()).toContain("show_openbot")); @@ -826,7 +849,7 @@ test("a local Test OpenBot screen links to the Intelligence dashboard through th const view = await renderApp(); await userEvent.click(view.getByRole("button", { name: "Install" })); await chooseOpenAi(view); - await view.findByText("I am working."); + await askTheTest(view); // The menus offer the dashboard once local mode has a recorded stack. expect(intelligenceCalls()).toEqual([ { @@ -873,6 +896,7 @@ test("a local Test OpenBot step says it is asking, with a spinner, until the ans await userEvent.click(view.getByRole("button", { name: "Install" })); await chooseOpenAi(view); await view.findByRole("heading", { name: "Test OpenBot" }); + await userEvent.click(view.getByRole("button", { name: "Ask" })); await waitFor(() => expectWorking(view, "Asking…")); await act(async () => answer.resolve("I am working.")); expect(view.queryByText("Asking…")).toBeNull(); @@ -897,7 +921,7 @@ test("View in Intelligence says the native sentence when the dashboard does not const view = await renderApp(); await userEvent.click(view.getByRole("button", { name: "Install" })); await chooseOpenAi(view); - await view.findByText("I am working."); + await askTheTest(view); await userEvent.click( view.getByRole("button", { name: "View in Intelligence" }), ); @@ -989,7 +1013,7 @@ test("a Claude plan signed in on the provider screen reaches Start with its toke }); }); -test("a development double mount runs each local step and asks the question once", async () => { +test("a development double mount runs each local step and asks the question once, on Ask", async () => { setupRootConfiguration("/tmp/local-eval-strict", async () => emptyConfiguration(), ); @@ -1002,7 +1026,7 @@ test("a development double mount runs each local step and asks the question once const view = await renderApp(true); await userEvent.click(view.getByRole("button", { name: "Install" })); await chooseOpenAi(view); - await view.findByText("I am working."); + await askTheTest(view); expect(localSequence()).toEqual([ "set_up_local_eval", "connect_local_eval", @@ -1032,7 +1056,7 @@ test("Continue after Intelligence is ready starts OpenBot with the chosen model" ); expect(localSequence()).not.toContain("start_stack"); await chooseOpenAi(view); - await view.findByText("I am working."); + await askTheTest(view); expect(getStartStackPayload()).toMatchObject({ model: { provider: "openai" }, }); @@ -1098,6 +1122,7 @@ test("a first local run whose model cannot answer offers Change the model, as ho const view = await renderApp(); await userEvent.click(view.getByRole("button", { name: "Install" })); await chooseOpenAi(view); + await userEvent.click(await view.findByRole("button", { name: "Ask" })); await view.findByText("The model could not answer."); expect(localSequence()).not.toContain("show_openbot"); expect(view.queryByRole("button", { name: "Open OpenBot" })).toBeNull(); @@ -1153,7 +1178,7 @@ test("a blocked setup stops at its row in plain words and Retry resumes there", false, ); await userEvent.click(view.getByRole("button", { name: "Retry" })); - await view.findByText("I am working."); + await askTheTest(view); expect(setups).toBe(2); }); @@ -1183,7 +1208,7 @@ test("an installation from before the first-screen choice still asks at step 4", await new Promise((resolve) => setTimeout(resolve, 20)); expect(localCliCalls()).toEqual([]); await userEvent.click(view.getByRole("button", { name: "Continue" })); - await view.findByText("I am working."); + await askTheTest(view); expect(localSequence()).toEqual([ "set_up_local_eval", "connect_local_eval", @@ -1259,7 +1284,7 @@ test("a recorded, connected stack on step 4 skips the choice and starts", async }); const view = await renderApp(); // Never launched, so this is a first run: it tests the model before handing over. - await view.findByText("I am working."); + await askTheTest(view); expect( view.queryByRole("radio", { name: new RegExp(LOCAL_CHOICE) }), ).toBeNull(); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 584d06b627..3df970b9f3 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,7 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Ask } from "./Ask"; +import { Ask, PRESET_QUESTIONS } from "./Ask"; import { DatabaseReset } from "./DatabaseReset"; import { DEFAULT_HARNESS, @@ -89,19 +89,6 @@ const MANAGED_INTELLIGENCE_API_URL = "https://api.intelligence.copilotkit.ai"; const MANAGED_INTELLIGENCE_GATEWAY_WS_URL = "wss://realtime.intelligence.copilotkit.ai"; -/** - * What the last screen offers to ask, mirroring `ask::SUGGESTED`. - * - * Two copies of one sentence, and a test in `ask.rs` pins what it has to contain. The window needs - * it before it calls anything, and the Rust side needs it for the case where somebody clears the - * field, so neither can be the only one that has it. - */ -const SUGGESTED_QUESTION = "What is 17 times 23?"; - -/** 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() { return ( <> @@ -1166,8 +1153,8 @@ function Setup() { return (
          invoke("ask_the_bot", { root, question }) diff --git a/desktop/src/Ask.tsx b/desktop/src/Ask.tsx index 84a8aa858a..f9a9b90a90 100644 --- a/desktop/src/Ask.tsx +++ b/desktop/src/Ask.tsx @@ -1,7 +1,22 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { asProblem, Failure, type Problem } from "./Problem"; import { StatusLine } from "./SetupProgress"; +/** + * Questions a person can put in the box with one click. The first is the one already in it. + * + * Each has an answer somebody can check, or at least an opinion worth reading: "Tell me about + * yourself" is answered convincingly by a Bot whose model credential is fine and whose everything + * else is broken, and this screen exists to prove rather than to reassure. The silly ones are on + * purpose: this is the first thing somebody says to their Bot, and it may as well be fun. + */ +export const PRESET_QUESTIONS = [ + "How many r's are in strawberry?", + "How many Die Hard movies are there?", + "Why are frosted strawberry Pop-Tarts better than brown sugar cinnamon?", + "What is 17 times 23?", +] as const; + /** * The last screen: a question, an answer, and only then the handover. * @@ -11,9 +26,8 @@ import { StatusLine } from "./SetupProgress"; * find that out later, inside the product, with no idea which answer was the wrong one. So the * wizard ends by asking, and the answer on this screen is the proof. * - * One suggested question, already filled in, with one right answer. "Tell me about yourself" is - * answered convincingly by a Bot whose model credential is fine and whose everything else is - * broken, and this screen exists to prove rather than to reassure. + * A question already in the box, a few more one click away, and room to type your own. Asking + * again with another question is always open, so trying the Bot does not end at the first answer. */ export function Ask({ suggestion, @@ -21,7 +35,7 @@ export function Ask({ onOpen, onBack, onViewIntelligence, - auto = false, + local = false, stepLabel = "Last step", }: { suggestion: string; @@ -32,8 +46,8 @@ export function Ask({ onBack: () => void | Promise; /** Local mode with a recorded stack: opens its Intelligence dashboard, already signed in. */ onViewIntelligence?: () => Promise; - /** Asks `suggestion` on arrival, with no field to change it: the test is the screen. */ - auto?: boolean; + /** The local first run's wording: "Test OpenBot", then "Open OpenBot". */ + local?: boolean; stepLabel?: string; }) { const [question, setQuestion] = useState(suggestion); @@ -85,14 +99,6 @@ export function Ask({ ); - // Deferred a tick, so a development double mount asks once. - // biome-ignore lint/correctness/useExhaustiveDependencies: once, on arrival. - useEffect(() => { - if (!auto) return; - const timer = setTimeout(() => void ask(), 0); - return () => clearTimeout(timer); - }, []); - async function ask() { setAsking(true); setFailure(null); @@ -107,82 +113,15 @@ export function Ask({ } } - if (auto) { - return ( -
          -

          {stepLabel}

          -

          Test OpenBot

          -

          - OpenBot asks your AI provider one question to check that it answers. -

          -
          -

          Question

          -

          {question}

          -
          - {asking && Asking…} - {failure && } - {answer !== null && ( -
          -

          Your Bot said

          -

          {answer}

          -
          - )} - {answer !== null && onViewIntelligence && ( - <> - - {opening && ( - Opening the Intelligence dashboard… - )} - {opened && ( - Opened in your browser. - )} - {viewFailure &&

          {viewFailure.said}

          } - - )} - {leavingSaid} -
          - {answer !== null && ( - - )} - {failure && ( - <> - - - - )} -
          -
          - ); - } - + const asked = answer !== null; return (

          {stepLabel}

          -

          Ask it something.

          +

          {local ? "Test OpenBot" : "Ask it something."}

          - Your Bot is set up. This proves it can answer before you start using it. + {local + ? "Ask your Bot a question to check that it answers. Pick one, or type your own." + : "Your Bot is set up. This proves it can answer before you start using it."}

          @@ -210,25 +149,60 @@ export function Ask({ }} />
          +
            + {PRESET_QUESTIONS.map((preset) => ( +
          • + +
          • + ))} +
          + {asking && Asking…} {failure && } - {answer !== null && ( + {asked && (

          Your Bot said

          {answer}

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

          {viewFailure.said}

          } + + )} {leavingSaid}
          - {answer === null ? ( - ) : ( - )} {/* @@ -246,7 +220,7 @@ export function Ask({ Change the model )} - {answer !== null && ( + {asked && ( - - ))} - + + + + +
          +
          {asking && Asking…} {failure && } diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 04902d3945..01a3fef899 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -237,29 +237,31 @@ button.text-link:hover:not(:disabled) { opacity: 1; } -/* Questions to try on the last screen: small pills that fill the box, not answers of their own. */ -ul.presets { - list-style: none; - margin: 0 0 0.5rem; - padding: 0; +/* The last screen's question box, with the button that swaps in another question beside it. */ +.field-with-action { display: flex; - flex-wrap: wrap; - gap: 0.4rem; + gap: 0.5rem; } -button.preset { +.field-with-action input { + flex: 1; + min-width: 0; +} + +button.another-question { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 2.75rem; + padding: 0; background: var(--raised); color: var(--ink-soft); border: 1px solid var(--hair); - border-radius: 999px; - padding: 0.3rem 0.75rem; - font-size: 0.8rem; - font-weight: 400; - text-align: left; + border-radius: var(--r-row); } -button.preset:hover:not(:disabled), -button.preset[aria-pressed="true"] { +button.another-question:hover:not(:disabled) { color: var(--ink); border-color: var(--ink-faint); opacity: 1; From 7cb0563d4ecffc8b81a8e9a8311f38340837c1ca Mon Sep 17 00:00:00 2001 From: Dusty Date: Wed, 21 Oct 2026 23:56:07 -0700 Subject: [PATCH 15/77] wip(desktop): first screen asks where Intelligence runs, local or CopilotKit hosted, assuming neither Install with a small 'hosted instead' link assumed local. The first screen now offers both as equal choices with nothing selected, and Continue waits for one. --- desktop/src/App.test.tsx | 55 +++++++++++++++++++------------ desktop/src/LocalIntelligence.tsx | 3 +- desktop/src/Welcome.tsx | 24 +++++++++----- 3 files changed, 51 insertions(+), 31 deletions(-) diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 4ba844c06b..302dea0f15 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -564,6 +564,15 @@ function choice(view: Awaited>, name: string) { return view.getByRole("radio", { name: new RegExp(name) }); } +/** The first screen's answer to where Intelligence runs, then Continue. */ +async function chooseWhere( + view: Awaited>, + name: string, +) { + await userEvent.click(choice(view, name)); + await userEvent.click(view.getByRole("button", { name: "Continue" })); +} + /** Words about the software underneath, which no local screen says. */ const JARGON = [ /\bCLI\b/, @@ -596,19 +605,23 @@ async function chooseOpenAi(view: Awaited>) { 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 () => { +test("with a CLI, the first screen asks where Intelligence runs, assuming neither, and without one it is unchanged", async () => { setupRootConfiguration("/tmp/local-eval-welcome", async () => emptyConfiguration(), ); withLocalCli({}); let view = await renderApp(); expect(view.getByRole("heading", { name: "Set up OpenBot" })).toBeTruthy(); + expect(choice(view, LOCAL_CHOICE)).toHaveProperty("checked", false); + expect(choice(view, HOSTED_CHOICE)).toHaveProperty("checked", false); + expect(buttons(view)).toEqual(["Continue"]); expect( - view.getByText( - "Installs OpenBot and CopilotKit Intelligence on this computer. The first setup takes about 10 minutes.", - ), - ).toBeTruthy(); - expect(buttons(view)).toEqual(["Install", HOSTED_LINK]); + view.getByRole("button", { name: "Continue" }).hasAttribute("disabled"), + ).toBe(true); + await userEvent.click(choice(view, HOSTED_CHOICE)); + expect( + view.getByRole("button", { name: "Continue" }).hasAttribute("disabled"), + ).toBe(false); expectPlain(view); expect(localCliCalls()).toEqual([]); cleanup(); @@ -635,7 +648,7 @@ test("Install prepares the default Bot in the default folder, with no Bot or fol const preparation = deferred(); withLocalCli({ prepare_installation: () => preparation.promise }); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); expect(installationCalls()).toEqual([ { command: "prepare_installation", @@ -689,13 +702,13 @@ test("Install prepares the default Bot in the default folder, with no Bot or fol expect(localCliCalls()).toEqual([]); }); -test("the hosted link reaches today's hosted flow and never sets up local Intelligence", async () => { +test("choosing hosted first 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 })); + await chooseWhere(view, HOSTED_CHOICE); expect(stepLabel(view)).toBe("Step 1 of 4"); await userEvent.click(view.getByRole("button", { name: "Continue" })); expect(view.getByLabelText("Where OpenBot lives")).toBeTruthy(); @@ -724,7 +737,7 @@ test("the local sequence starts when installation completes and survives the pro connect_local_eval: () => new Promise(() => {}), }); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await new Promise((resolve) => setTimeout(resolve, 20)); expect(localCliCalls()).toEqual([]); await act(async () => preparation.resolve()); @@ -765,7 +778,7 @@ test("Continue during setup shows Getting ready, which starts, tests and opens O ask_the_bot: "I am working.", }); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await chooseOpenAi(view); await view.findByRole("heading", { name: "Getting ready" }); expect(stepLabel(view)).toBe("Step 3 of 3"); @@ -854,7 +867,7 @@ test("a local Test OpenBot screen links to the Intelligence dashboard through th }); const opening = deferred(); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await chooseOpenAi(view); await askTheTest(view); // The menus offer the dashboard once local mode has a recorded stack. @@ -900,7 +913,7 @@ test("a local Test OpenBot step says it is asking, with a spinner, until the ans offer_local_intelligence: null, }); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await chooseOpenAi(view); await view.findByRole("heading", { name: "Test OpenBot" }); await userEvent.click(view.getByRole("button", { name: "Ask" })); @@ -926,7 +939,7 @@ test("View in Intelligence says the native sentence when the dashboard does not }), }); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await chooseOpenAi(view); await askTheTest(view); await userEvent.click( @@ -952,7 +965,7 @@ test("Continue after Intelligence is already ready starts OpenBot with the AI ju ask_the_bot: "I am working.", }); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await view.findByRole("heading", { name: "Connect your AI" }); // Intelligence finishes while the person is still choosing. await waitFor(() => @@ -993,7 +1006,7 @@ test("a Claude plan signed in on the provider screen reaches Start with its toke ask_the_bot: "I am working.", }); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await userEvent.click(await view.findByRole("radio", { name: /Anthropic/ })); await userEvent.click( await view.findByRole("button", { name: "Sign in with Anthropic" }), @@ -1031,7 +1044,7 @@ test("a development double mount runs each local step and asks the question once ask_the_bot: "I am working.", }); const view = await renderApp(true); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await chooseOpenAi(view); await askTheTest(view); expect(localSequence()).toEqual([ @@ -1056,7 +1069,7 @@ test("Continue after Intelligence is ready starts OpenBot with the chosen model" ask_the_bot: "I am working.", }); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await view.findByRole("heading", { name: "Connect your AI" }); await waitFor(() => expect(view.getByRole("status").textContent).toBe("Intelligence is ready."), @@ -1084,7 +1097,7 @@ test("while a CLI step runs only Cancel is offered, and Cancel stops the call an finish_intelligence_sign_in: () => new Promise(() => {}), }); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await chooseOpenAi(view); await view.findByRole("heading", { name: "Getting ready" }); expect(buttons(view)).toEqual(["Cancel"]); @@ -1127,7 +1140,7 @@ test("a first local run whose model cannot answer offers Change the model, as ho }, }); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await chooseOpenAi(view); await userEvent.click(await view.findByRole("button", { name: "Ask" })); await view.findByText("The model could not answer."); @@ -1165,7 +1178,7 @@ test("a blocked setup stops at its row in plain words and Retry resumes there", ask_the_bot: "I am working.", }); const view = await renderApp(); - await userEvent.click(view.getByRole("button", { name: "Install" })); + await chooseWhere(view, LOCAL_CHOICE); await view.findByRole("heading", { name: "Connect your AI" }); await waitFor(() => expect(view.getByRole("status").textContent).toBe( diff --git a/desktop/src/LocalIntelligence.tsx b/desktop/src/LocalIntelligence.tsx index 146a8e10d9..97f622ddae 100644 --- a/desktop/src/LocalIntelligence.tsx +++ b/desktop/src/LocalIntelligence.tsx @@ -511,7 +511,8 @@ export function IntelligenceChoice({ chosen, onChoose, }: { - chosen: IntelligenceMode; + /** Null until somebody picks one, so neither is assumed. */ + chosen: IntelligenceMode | null; onChoose: (mode: IntelligenceMode) => void; }) { const rows: [IntelligenceMode, string, string][] = [ diff --git a/desktop/src/Welcome.tsx b/desktop/src/Welcome.tsx index 958dc4dbd5..ce40f26549 100644 --- a/desktop/src/Welcome.tsx +++ b/desktop/src/Welcome.tsx @@ -1,3 +1,6 @@ +import { useState } from "react"; +import { IntelligenceChoice, type IntelligenceMode } from "./LocalIntelligence"; + /** * What OpenBot is, before it asks for anything. * @@ -11,11 +14,12 @@ export function Welcome({ }: { onStart: () => void; /** - * When this build can run Intelligence on this computer, this screen is the one choice: Install - * sets up both here, and the hosted service is the way out. + * When this build can run Intelligence on this computer, this screen asks where it runs, with + * neither answer chosen for the person: local installs both here, hosted goes to the Bot screen. */ local?: { onInstall: () => void; onHosted: () => void }; }) { + const [where, setWhere] = useState(null); if (local) { return (
          @@ -25,17 +29,19 @@ export function Welcome({

          Set up OpenBot

          - Installs OpenBot and CopilotKit Intelligence on this computer. The - first setup takes about 10 minutes. + OpenBot keeps your conversations in CopilotKit Intelligence. Where + should it run?

          +
          -
          -
      ); } From 14cd1e36e3d6b2039c7ae056595feef33370ac99 Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 00:00:08 -0700 Subject: [PATCH 16/77] wip(desktop): larger setup window that grows to fit a taller step, within its screen 900x640 was too short for Connect your AI once a provider's sign-in opened under its row: the page scrolled and the step label slid under the title bar. The window opens at 1000x800, brought within the monitor's work area on launch, and the setup page asks the shell to grow it whenever a step is taller than the window. fit_setup_window only grows, never past the work area, and leaves a maximized or full-screen window alone; past that the page scrolls as before. The page gets no core:window permission. --- desktop/src-tauri/src/main.rs | 119 ++++++++++++++++++++++++++++++ desktop/src-tauri/tauri.conf.json | 4 +- desktop/src/App.tsx | 2 + desktop/src/fitWindow.ts | 35 +++++++++ desktop/src/styles.css | 6 +- 5 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 desktop/src/fitWindow.ts diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 95eb232115..b6eabf2753 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -4496,6 +4496,111 @@ fn show_intelligence_opening( }); } +/// Room left between a grown or clamped window and the edge of its monitor's work area. +const SCREEN_MARGIN: f64 = 24.0; + +/** +How tall the window's content area should become, in logical pixels, or None to leave it. + +Grows to `wanted` and never shrinks: a window that jumped smaller under somebody's pointer when a +row collapsed would be worse than one with a little room to spare. `tallest` is the most the content +area may have on this monitor; past it the page scrolls, as it always could. +*/ +fn grown_height(current: f64, wanted: f64, tallest: f64) -> Option { + let target = wanted.ceil().min(tallest.floor()); + (target > current.ceil()).then_some(target) +} + +/// The window's monitor work area in logical pixels, and the height of its title bar and frame. +fn screen_room( + window: &tauri::WebviewWindow, +) -> Option<(tauri::LogicalRect, f64)> { + let scale = window.scale_factor().ok()?; + let monitor = window.current_monitor().ok().flatten()?; + let area = monitor.work_area(); + let frame = window.outer_size().ok()?.height as f64 - window.inner_size().ok()?.height as f64; + Some(( + tauri::LogicalRect { + position: area.position.to_logical(scale), + size: area.size.to_logical(scale), + }, + frame / scale, + )) +} + +/// Resize the content area to `height` logical pixels, moving the window up if its bottom would +/// leave the work area. +fn set_window_height( + window: &tauri::WebviewWindow, + area: &tauri::LogicalRect, + frame: f64, + height: f64, +) { + let Ok(scale) = window.scale_factor() else { + return; + }; + let Ok(inner) = window.inner_size() else { + return; + }; + let width = inner.to_logical::(scale).width; + let _ = window.set_size(tauri::LogicalSize::new(width, height)); + if let Ok(position) = window.outer_position() { + let position = position.to_logical::(scale); + let bottom = area.position.y + area.size.height - SCREEN_MARGIN; + if position.y + frame + height > bottom { + let top = (bottom - frame - height).max(area.position.y); + let _ = window.set_position(tauri::LogicalPosition::new(position.x, top)); + } + } +} + +/** +Make the setup window tall enough for the screen it is showing, within its monitor. + +The setup page measures its own content and asks; it is not granted any `core:window` permission, +so this is the only way it can change the window, and only ever larger. A maximized or full-screen +window is left as the person made it. +*/ +#[tauri::command] +fn fit_setup_window(window: tauri::WebviewWindow, content_height: f64) { + if !content_height.is_finite() + || window.is_maximized().unwrap_or(true) + || window.is_fullscreen().unwrap_or(true) + { + return; + } + let (Some((area, frame)), Ok(scale), Ok(inner)) = ( + screen_room(&window), + window.scale_factor(), + window.inner_size(), + ) else { + return; + }; + let current = inner.to_logical::(scale).height; + let tallest = area.size.height - frame - 2.0 * SCREEN_MARGIN; + if let Some(height) = grown_height(current, content_height, tallest) { + set_window_height(&window, &area, frame, height); + } +} + +/// On launch: a window taller than its monitor's work area is brought within it. +fn fit_main_window_to_screen(app: &tauri::AppHandle) { + let Some(window) = app.get_webview_window("main") else { + return; + }; + let (Some((area, frame)), Ok(scale), Ok(inner)) = ( + screen_room(&window), + window.scale_factor(), + window.inner_size(), + ) else { + return; + }; + let tallest = area.size.height - frame - 2.0 * SCREEN_MARGIN; + if inner.to_logical::(scale).height > tallest { + set_window_height(&window, &area, frame, tallest.floor()); + } +} + /// Whether the main window shows the setup page, whose own overlay says the menu's outcome. fn showing_setup(app: &tauri::AppHandle) -> bool { let Some(window) = app.get_webview_window("main") else { @@ -5140,6 +5245,7 @@ fn main() { .manage(Shell::default()) .invoke_handler(tauri::generate_handler![ record_setup_event, + fit_setup_window, detect_engine, windows_blocker, windows_blocker_instruction, @@ -5222,6 +5328,7 @@ fn main() { ))); remember_setup_url(app.handle())?; + fit_main_window_to_screen(app.handle()); // Built by `generate_context!`, so the bridge plugin's permissions are compiled in. intelligence_bridge::enable(app.handle()); @@ -7866,6 +7973,18 @@ mod tests { ); } + #[test] + fn the_setup_window_only_grows_and_never_past_its_screen() { + // Content taller than the window: grown to fit. + assert_eq!(grown_height(640.0, 812.4, 900.0), Some(813.0)); + // Taller than the screen allows: grown to the most it may have, and the page scrolls. + assert_eq!(grown_height(640.0, 1400.0, 900.5), Some(900.0)); + // Content that fits, or a window already as tall as it can be: left alone, never shrunk. + assert_eq!(grown_height(800.0, 620.0, 900.0), None); + assert_eq!(grown_height(900.0, 1400.0, 900.0), None); + assert_eq!(grown_height(799.6, 800.0, 900.0), None); + } + #[test] fn about_openbot_names_the_commit_branch_and_build_time() { assert_eq!(utc_minute(0), "1970-01-01 00:00 UTC"); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index a3d81e35f4..25d8f97f7d 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -13,8 +13,8 @@ "windows": [ { "title": "OpenBot", - "width": 900, - "height": 640, + "width": 1000, + "height": 800, "minWidth": 720, "minHeight": 560, "resizable": true diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 5c8f5aca45..88937b63e7 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useRef, useState } from "react"; import { Ask } from "./Ask"; import { DatabaseReset } from "./DatabaseReset"; +import { useWindowFitsContent } from "./fitWindow"; import { DEFAULT_HARNESS, type HarnessChoice, @@ -90,6 +91,7 @@ const MANAGED_INTELLIGENCE_GATEWAY_WS_URL = "wss://realtime.intelligence.copilotkit.ai"; export function App() { + useWindowFitsContent(); return ( <> diff --git a/desktop/src/fitWindow.ts b/desktop/src/fitWindow.ts new file mode 100644 index 0000000000..15bd5478e9 --- /dev/null +++ b/desktop/src/fitWindow.ts @@ -0,0 +1,35 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect } from "react"; + +/** + * Ask the shell for a taller window whenever the step on screen is taller than the window. + * + * Some steps grow as they are used (a provider's sign-in opening under its row, an answer arriving), + * and the window is sized for the common case. The shell decides how much it may grow on this + * monitor and never shrinks it; see `fit_setup_window`. Past what the screen allows, the page + * scrolls as before. + */ +export function useWindowFitsContent() { + useEffect(() => { + if (typeof ResizeObserver === "undefined") return; + let frame = 0; + const measure = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const wanted = document.documentElement.scrollHeight; + if (wanted > window.innerHeight) { + void invoke("fit_setup_window", { contentHeight: wanted }).catch( + () => {}, + ); + } + }); + }; + const observer = new ResizeObserver(measure); + observer.observe(document.body); + measure(); + return () => { + observer.disconnect(); + cancelAnimationFrame(frame); + }; + }, []); +} diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 01a3fef899..ab2adfbc76 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -65,9 +65,9 @@ body { } /* - * The window is 900x640 and cannot be resized into something else, so it is composed as a room - * rather than a page: one centred column, vertically settled, with the same optical centre on every - * step. A wizard whose content jumps position between steps reads as four different screens. + * The window opens at 1000x800 and grows to fit a taller step (see fitWindow.ts), so it is composed + * as a room rather than a page: one centred column, vertically settled, with the same optical centre + * on every step. A wizard whose content jumps position between steps reads as four different screens. */ main { min-height: 100vh; From a6532d41e626e3feebfc06f0ec5151bd183edc1f Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 00:04:37 -0700 Subject: [PATCH 17/77] wip(desktop): setup type and column a step larger for the 1000x800 window The root size goes from 14px to 16px; everything else is in rem, so type, column width and spacing scale together. The answer card's fixed px sizes become rem so it scales with them. --- desktop/src/styles.css | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/desktop/src/styles.css b/desktop/src/styles.css index ab2adfbc76..a91c878598 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -35,7 +35,9 @@ font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ui-sans-serif, system-ui, sans-serif; - font-size: 14px; + /* 16px rather than 14px since the window grew to 1000x800: everything below is in rem, so the + type, the column and the spacing grow together and the room does not read as empty. */ + font-size: 16px; line-height: 1.5; color: var(--ink); background: var(--ground); @@ -781,16 +783,16 @@ details > summary:hover { * wizard that was produced rather than written, and it is the proof the install actually worked. */ .answer { - margin: 20px 0 4px; - padding: 16px 18px; - border-radius: 14px; + margin: 1.4rem 0 0.3rem; + padding: 1.15rem 1.3rem; + border-radius: 1rem; background: var(--raised); border: 1px solid var(--hair); } .answer-from { - margin: 0 0 6px; - font-size: 12px; + margin: 0 0 0.45rem; + font-size: 0.85rem; font-weight: 600; letter-spacing: 0.02em; color: var(--ink-faint); @@ -798,7 +800,7 @@ details > summary:hover { .answer-text { margin: 0; - font-size: 17px; + font-size: 1.2rem; line-height: 1.45; color: var(--ink); white-space: pre-wrap; From aa5354fa16f9d7b8b736938970cafc412c042967 Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 00:28:22 -0700 Subject: [PATCH 18/77] wip(desktop): say 'Opening CopilotKit sign-in' for the managed sign-in only Upstream's self-hosted sign-in sets signingIn too and says its own line; both showed at once. --- desktop/src/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 88937b63e7..545716a0b2 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1567,7 +1567,8 @@ function Setup() { )}
      - {signingIn && ( + {/* Managed sign-in only: the self-hosted one says its own line below. */} + {signingIn && intelligenceSignInKind === "managed" && ( Opening CopilotKit sign-in in your browser… From f7c723865c70e2581e4f4a0b51d1003e31acdcbd Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 00:39:58 -0700 Subject: [PATCH 19/77] fix(desktop): install a release from before the toolkit, which has no shared or toolkit manifest #784 added shared and toolkit to the packages whose manifests preparation reads, but the latest release, v0.2.0, predates it: shared is plain source and there is no toolkit. A desktop built from main failed every new install with 'shared/package.json: No such file or directory'. Those two are now read only when the release has them; the four every release has stay required. --- desktop/src-tauri/src/preparation.rs | 42 ++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/preparation.rs b/desktop/src-tauri/src/preparation.rs index 1f40b9b44b..502af65741 100644 --- a/desktop/src-tauri/src/preparation.rs +++ b/desktop/src-tauri/src/preparation.rs @@ -12,6 +12,17 @@ pub const FILE: &str = ".openbot-prepared.json"; const DEPENDENCIES: &str = ".openbot-dependencies.json"; const LAUNCH: &str = ".openbot-launched.json"; const PACKAGES: [&str; 6] = ["", "server", "app", "worker", "shared", "toolkit"]; +/// Packages a release has only since #784. v0.2.0, still the latest release when this desktop was +/// built, has `shared` as plain source and no `toolkit`; requiring their manifests failed every new +/// install with "shared/package.json: No such file or directory". +const LATER_PACKAGES: [&str; 2] = ["shared", "toolkit"]; + +/// The packages this release actually has: every one in `PACKAGES`, less a later one it predates. +fn packages(root: &Path) -> impl Iterator + '_ { + PACKAGES.into_iter().filter(move |package| { + !LATER_PACKAGES.contains(package) || root.join(package).join("package.json").is_file() + }) +} #[derive(Serialize, Deserialize)] struct Dependencies { @@ -124,7 +135,7 @@ fn dependency_fingerprint(root: &Path) -> Result { hash_files( root, std::iter::once(PathBuf::from("bun.lock")) - .chain(PACKAGES.map(|path| Path::new(path).join("package.json"))), + .chain(packages(root).map(|path| Path::new(path).join("package.json"))), ) } @@ -175,7 +186,7 @@ pub fn invalidate(root: &Path) -> Result<(), Problem> { /// Record resolved package manifests, not just the existence of a partial node_modules directory. fn package_files(root: &Path) -> Result, Problem> { let mut files = BTreeSet::new(); - for package in PACKAGES { + for package in packages(root) { let directory = root.join(package); let manifest: serde_json::Value = serde_json::from_slice( &std::fs::read(directory.join("package.json")).map_err(|e| required(e.to_string()))?, @@ -364,6 +375,33 @@ pub fn image_settings( mod tests { use super::*; + #[test] + fn a_release_from_before_the_toolkit_needs_no_shared_or_toolkit_manifest() { + let root = crate::test_support::temp_root("preparation-v020-layout"); + for package in ["", "server", "app", "worker"] { + std::fs::create_dir_all(root.join(package)).unwrap(); + std::fs::write(root.join(package).join("package.json"), "{}").unwrap(); + } + // v0.2.0: `shared` is source with no manifest, and there is no `toolkit`. + std::fs::create_dir_all(root.join("shared")).unwrap(); + assert_eq!( + packages(&root).collect::>(), + ["", "server", "app", "worker"] + ); + std::fs::write(root.join("bun.lock"), "lock").unwrap(); + assert!(dependency_fingerprint(&root).is_ok()); + assert!(package_files(&root).is_ok()); + // A release that has them is held to them. + for package in ["shared", "toolkit"] { + std::fs::create_dir_all(root.join(package)).unwrap(); + std::fs::write(root.join(package).join("package.json"), "{}").unwrap(); + } + assert_eq!(packages(&root).count(), PACKAGES.len()); + // The packages every release has stay required. + std::fs::remove_file(root.join("server").join("package.json")).unwrap(); + assert!(dependency_fingerprint(&root).is_err()); + } + struct Fixture { root: PathBuf, bun: PathBuf, From 4338cedc7e3c7249b73bf44a6683e3d5251e81c6 Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 11:00:35 -0700 Subject: [PATCH 20/77] wip(desktop): local Intelligence by default at install, with nothing to choose Sam's call: one fewer decision before anything is installed. With a CLI the first screen is Install, which sets up OpenBot and Intelligence on this computer; the hosted choice and the 'hosted instead' link are gone from it. The hosted way out after a failed local setup stays. --- desktop/src/App.test.tsx | 76 ++++++++++++---------------------------- desktop/src/App.tsx | 4 --- desktop/src/Welcome.tsx | 23 ++++-------- 3 files changed, 30 insertions(+), 73 deletions(-) diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 302dea0f15..9990019e66 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -564,13 +564,9 @@ function choice(view: Awaited>, name: string) { return view.getByRole("radio", { name: new RegExp(name) }); } -/** The first screen's answer to where Intelligence runs, then Continue. */ -async function chooseWhere( - view: Awaited>, - name: string, -) { - await userEvent.click(choice(view, name)); - await userEvent.click(view.getByRole("button", { name: "Continue" })); +/** The first screen's Install, which with a CLI installs OpenBot and local Intelligence. */ +async function installLocal(view: Awaited>) { + await userEvent.click(view.getByRole("button", { name: "Install" })); } /** Words about the software underneath, which no local screen says. */ @@ -605,23 +601,20 @@ async function chooseOpenAi(view: Awaited>) { await userEvent.click(view.getByRole("button", { name: "Continue" })); } -test("with a CLI, the first screen asks where Intelligence runs, assuming neither, and without one it is unchanged", async () => { +test("with a CLI, the first screen is Install, local by default with nothing to choose, and without one it is unchanged", async () => { setupRootConfiguration("/tmp/local-eval-welcome", async () => emptyConfiguration(), ); withLocalCli({}); let view = await renderApp(); expect(view.getByRole("heading", { name: "Set up OpenBot" })).toBeTruthy(); - expect(choice(view, LOCAL_CHOICE)).toHaveProperty("checked", false); - expect(choice(view, HOSTED_CHOICE)).toHaveProperty("checked", false); - expect(buttons(view)).toEqual(["Continue"]); expect( - view.getByRole("button", { name: "Continue" }).hasAttribute("disabled"), - ).toBe(true); - await userEvent.click(choice(view, HOSTED_CHOICE)); - expect( - view.getByRole("button", { name: "Continue" }).hasAttribute("disabled"), - ).toBe(false); + view.getByText( + "Installs OpenBot and CopilotKit Intelligence on this computer. The first setup takes about 10 minutes.", + ), + ).toBeTruthy(); + expect(buttons(view)).toEqual(["Install"]); + expect(view.queryByRole("radio")).toBeNull(); expectPlain(view); expect(localCliCalls()).toEqual([]); cleanup(); @@ -648,7 +641,7 @@ test("Install prepares the default Bot in the default folder, with no Bot or fol const preparation = deferred(); withLocalCli({ prepare_installation: () => preparation.promise }); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); expect(installationCalls()).toEqual([ { command: "prepare_installation", @@ -702,29 +695,6 @@ test("Install prepares the default Bot in the default folder, with no Bot or fol expect(localCliCalls()).toEqual([]); }); -test("choosing hosted first 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 chooseWhere(view, HOSTED_CHOICE); - 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(), @@ -737,7 +707,7 @@ test("the local sequence starts when installation completes and survives the pro connect_local_eval: () => new Promise(() => {}), }); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await new Promise((resolve) => setTimeout(resolve, 20)); expect(localCliCalls()).toEqual([]); await act(async () => preparation.resolve()); @@ -778,7 +748,7 @@ test("Continue during setup shows Getting ready, which starts, tests and opens O ask_the_bot: "I am working.", }); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await chooseOpenAi(view); await view.findByRole("heading", { name: "Getting ready" }); expect(stepLabel(view)).toBe("Step 3 of 3"); @@ -867,7 +837,7 @@ test("a local Test OpenBot screen links to the Intelligence dashboard through th }); const opening = deferred(); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await chooseOpenAi(view); await askTheTest(view); // The menus offer the dashboard once local mode has a recorded stack. @@ -913,7 +883,7 @@ test("a local Test OpenBot step says it is asking, with a spinner, until the ans offer_local_intelligence: null, }); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await chooseOpenAi(view); await view.findByRole("heading", { name: "Test OpenBot" }); await userEvent.click(view.getByRole("button", { name: "Ask" })); @@ -939,7 +909,7 @@ test("View in Intelligence says the native sentence when the dashboard does not }), }); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await chooseOpenAi(view); await askTheTest(view); await userEvent.click( @@ -965,7 +935,7 @@ test("Continue after Intelligence is already ready starts OpenBot with the AI ju ask_the_bot: "I am working.", }); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await view.findByRole("heading", { name: "Connect your AI" }); // Intelligence finishes while the person is still choosing. await waitFor(() => @@ -1006,7 +976,7 @@ test("a Claude plan signed in on the provider screen reaches Start with its toke ask_the_bot: "I am working.", }); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await userEvent.click(await view.findByRole("radio", { name: /Anthropic/ })); await userEvent.click( await view.findByRole("button", { name: "Sign in with Anthropic" }), @@ -1044,7 +1014,7 @@ test("a development double mount runs each local step and asks the question once ask_the_bot: "I am working.", }); const view = await renderApp(true); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await chooseOpenAi(view); await askTheTest(view); expect(localSequence()).toEqual([ @@ -1069,7 +1039,7 @@ test("Continue after Intelligence is ready starts OpenBot with the chosen model" ask_the_bot: "I am working.", }); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await view.findByRole("heading", { name: "Connect your AI" }); await waitFor(() => expect(view.getByRole("status").textContent).toBe("Intelligence is ready."), @@ -1097,7 +1067,7 @@ test("while a CLI step runs only Cancel is offered, and Cancel stops the call an finish_intelligence_sign_in: () => new Promise(() => {}), }); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await chooseOpenAi(view); await view.findByRole("heading", { name: "Getting ready" }); expect(buttons(view)).toEqual(["Cancel"]); @@ -1140,7 +1110,7 @@ test("a first local run whose model cannot answer offers Change the model, as ho }, }); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await chooseOpenAi(view); await userEvent.click(await view.findByRole("button", { name: "Ask" })); await view.findByText("The model could not answer."); @@ -1178,7 +1148,7 @@ test("a blocked setup stops at its row in plain words and Retry resumes there", ask_the_bot: "I am working.", }); const view = await renderApp(); - await chooseWhere(view, LOCAL_CHOICE); + await installLocal(view); await view.findByRole("heading", { name: "Connect your AI" }); await waitFor(() => expect(view.getByRole("status").textContent).toBe( diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 545716a0b2..34eb968782 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -999,10 +999,6 @@ function Setup() { recordSetupEvent(harnessChoiceEvent(DEFAULT_HARNESS)); void installLocally(); }, - onHosted: () => { - chooseIntelligence("hosted"); - setStep("harness"); - }, } : undefined } diff --git a/desktop/src/Welcome.tsx b/desktop/src/Welcome.tsx index ce40f26549..49af74a3cd 100644 --- a/desktop/src/Welcome.tsx +++ b/desktop/src/Welcome.tsx @@ -1,6 +1,3 @@ -import { useState } from "react"; -import { IntelligenceChoice, type IntelligenceMode } from "./LocalIntelligence"; - /** * What OpenBot is, before it asks for anything. * @@ -14,12 +11,11 @@ export function Welcome({ }: { onStart: () => void; /** - * When this build can run Intelligence on this computer, this screen asks where it runs, with - * neither answer chosen for the person: local installs both here, hosted goes to the Bot screen. + * When this build can run Intelligence on this computer, Install sets up both here. Local is the + * default with nothing to choose: one fewer decision before anything is installed. */ - local?: { onInstall: () => void; onHosted: () => void }; + local?: { onInstall: () => void }; }) { - const [where, setWhere] = useState(null); if (local) { return (
      @@ -29,17 +25,12 @@ export function Welcome({

      Set up OpenBot

      - OpenBot keeps your conversations in CopilotKit Intelligence. Where - should it run? + Installs OpenBot and CopilotKit Intelligence on this computer. The + first setup takes about 10 minutes.

      -
      -
      From d363380a39b0db8dfd28b414eb5ea08e335ee90d Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 11:10:27 -0700 Subject: [PATCH 21/77] wip(desktop): a finished sign-in on Connect your AI moves on by itself After a Claude code is accepted, the ChatGPT redirect lands, or a Gemini/xAI OAuth completes, the screen continues as Continue would. Checked once right after the sign-in, so a field filled in later never moves the screen on from under somebody typing; a saved sign-in and a typed key still wait for Continue. --- desktop/src/App.test.tsx | 23 ++++++++++------------- desktop/src/ProviderPicker.test.tsx | 18 +++++++++++++++--- desktop/src/ProviderPicker.tsx | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 9990019e66..0a4826380f 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -988,11 +988,8 @@ test("a Claude plan signed in on the provider screen reaches Start with its toke await userEvent.click( view.getByRole("button", { name: "Finish signing in" }), ); - await view.findByText(/Signed in to Anthropic/); + // Signed in, so on by itself: no Continue after "Signed in". await waitFor(() => expect(localSequence()).toContain("run_local_eval")); - // Intelligence is ready and waiting minutes before Continue, as on a real machine. - await act(async () => new Promise((resolve) => setTimeout(resolve, 100))); - await userEvent.click(view.getByRole("button", { name: "Continue" })); await view.findByRole("heading", { name: "Test OpenBot" }); expect(getStartStackPayload()).toMatchObject({ model: { @@ -1816,7 +1813,8 @@ test("provider sign-in retries and Back reuse the completed local installation", await userEvent.click( view.getByRole("button", { name: "Sign in with OpenAI" }), ); - await view.findByText(/Signed in to OpenAI/); + // Signed in, so on to the next step by itself. + await view.findByRole("heading", { name: "Connect to CopilotKit" }); expect(signIns).toBe(2); expect(installationCalls()).toHaveLength(1); }); @@ -4244,14 +4242,13 @@ for (const provider of [ ); } } - await view.findByText( - new RegExp( - session === "saved" - ? `A saved ${provider.name} sign-in will be checked` - : `Signed in to ${provider.name}`, - ), - ); - await userEvent.click(view.getByRole("button", { name: "Continue" })); + // A fresh sign-in moves on by itself; a saved one is only offered, so it waits for Continue. + if (session === "saved") { + await view.findByText( + new RegExp(`A saved ${provider.name} sign-in will be checked`), + ); + await userEvent.click(view.getByRole("button", { name: "Continue" })); + } await userEvent.click( await view.findByRole("button", { name: "Start OpenBot" }), ); diff --git a/desktop/src/ProviderPicker.test.tsx b/desktop/src/ProviderPicker.test.tsx index 7e096bc02c..f30d8d753a 100644 --- a/desktop/src/ProviderPicker.test.tsx +++ b/desktop/src/ProviderPicker.test.tsx @@ -173,6 +173,12 @@ test("a completed plan sign-in enables and submits only its issuing provider", a await waitFor(() => expect(view.getByText(/Signed in to OpenAI/)).toBeTruthy(), ); + // The finished sign-in moves on by itself, with its own provider's token. + await waitFor(() => + expect(choices).toEqual([ + { provider: "openai", login: "plan", token: "chatgpt-token" }, + ]), + ); await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); expect(view.queryByText(/Signed in to Anthropic/)).toBeNull(); @@ -180,7 +186,7 @@ test("a completed plan sign-in enables and submits only its issuing provider", a expect(continueButton).toHaveProperty("disabled", true); await userEvent.click(continueButton); - expect(choices).toEqual([]); + expect(choices).toHaveLength(1); }); test("a pending plan sign-in completion is ignored after switching provider rows", async () => { @@ -314,7 +320,12 @@ for (const provider of providers) { ), ); expect(view.queryByLabelText(`${provider.name} API key`)).toBeNull(); - await userEvent.click(view.getByRole("button", { name: "Continue" })); + // A fresh sign-in moves on by itself; a saved one is only offered, so it waits for Continue. + if (session === "saved") { + await userEvent.click(view.getByRole("button", { name: "Continue" })); + } else { + await waitFor(() => expect(choices).toHaveLength(1)); + } expect(choices).toHaveLength(1); expect(choices[0]).not.toHaveProperty("apiKey"); @@ -885,7 +896,8 @@ for (const provider of cloudProviders) { view.getByRole("button", { name: `Sign in with ${provider.name}` }), ); await view.findByText(`Signed in to ${provider.name}.`); - await user.click(view.getByRole("button", { name: "Continue" })); + // Signed in with a model already named: on by itself, no Continue. + await waitFor(() => expect(choices).toHaveLength(1)); expect(choices).toEqual([ { provider: provider.id, diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index d9ba5e163a..7c66ccc10d 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -219,6 +219,8 @@ export function ProviderPicker({ const [failure, setFailure] = useState(null); const openRef = useRef(open); const signInRunRef = useRef(0); + // A sign-in that just finished on this screen moves on by itself once the screen is ready. + const continueAfterSignIn = useRef(false); const oauthAttempt = useRef(null); const [oauthCode, setOauthCode] = useState(null); const [oauthSignedIn, setOauthSignedIn] = useState( @@ -276,6 +278,7 @@ export function ProviderPicker({ attemptId: authorization.attemptId, }); if (current()) { + continueAfterSignIn.current = true; setOauthSignedIn(providerId); setSignInUrl(null); setOauthCode(null); @@ -344,6 +347,7 @@ export function ProviderPicker({ if (providerId !== "anthropic") { const nextToken = await invoke("finish_chatgpt_sign_in"); if (stillCurrent()) { + continueAfterSignIn.current = true; setTokens((previous) => ({ ...previous, [providerId]: nextToken })); setSignInUrl(null); } @@ -374,6 +378,7 @@ export function ProviderPicker({ // Held, not shown. It goes on to `start_stack` the same way a typed key does. const nextToken = await invoke("finish_claude_sign_in", { code }); if (stillCurrent()) { + continueAfterSignIn.current = true; setTokens((previous) => ({ ...previous, [providerId]: nextToken })); setSignInUrl(null); setCode(""); @@ -477,6 +482,19 @@ export function ProviderPicker({ ...(trimmedModel ? { model: trimmedModel } : {}), }); } + /* + * Signed in, so on to the next step: the sign-in was the thing this screen was waiting for, and + * a Continue after "Signed in" is a click that only confirms what just happened. A sign-in that + * leaves something else to fill in (an OAuth row's model) waits for Continue as before. + */ + // biome-ignore lint/correctness/useExhaustiveDependencies: runs when readiness or a sign-in changes. + useEffect(() => { + if (!continueAfterSignIn.current || busy) return; + // Used up on the first look after the sign-in either way, so a field filled in later never + // moves the screen on from under somebody still typing. + continueAfterSignIn.current = false; + if (ready && !starting) continueWithChoice(); + }, [ready, busy, starting, tokens, oauthSignedIn]); return (
      From 0451d5cf4ee9006b7b63ed2507356989fe487751 Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 12:57:07 -0700 Subject: [PATCH 22/77] fix(desktop): start Docker Desktop when it is installed, install it when it is not; never Podman in its place With Docker Desktop installed but quit, setup found no answering engine and installed Podman behind an administrator prompt: a second engine on a machine that already had Docker, and one local Intelligence refuses. On macOS and Windows the engine is now Docker Desktop. Installed and stopped, it is opened and waited for. Missing, Docker's own installer (4.96.3, pinned to the digests Docker publishes) is downloaded with progress, installed without accepting Docker's terms, and opened so the person accepts them in Docker's window. A Podman somebody already has is still used; a stopped docker with no Docker Desktop (OrbStack, Colima) is left for its owner to start. Linux is unchanged. --- desktop/src-tauri/src/docker_desktop.rs | 569 ++++++++++++++++++++++++ desktop/src-tauri/src/install.rs | 271 ++++++++--- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 40 +- 4 files changed, 808 insertions(+), 73 deletions(-) create mode 100644 desktop/src-tauri/src/docker_desktop.rs diff --git a/desktop/src-tauri/src/docker_desktop.rs b/desktop/src-tauri/src/docker_desktop.rs new file mode 100644 index 0000000000..a7809925f0 --- /dev/null +++ b/desktop/src-tauri/src/docker_desktop.rs @@ -0,0 +1,569 @@ +//! Docker Desktop: started when it is installed, installed when it is not. +//! +//! Docker is the engine OpenBot runs on, and the only one local Intelligence accepts. A Mac or a +//! Windows machine with Docker Desktop installed but not running is not a machine without an engine: +//! starting it is the repair, not installing a second engine beside it. A machine without it gets +//! Docker's own installer. +//! +//! **Podman is never chosen for somebody.** It is for an organization that cannot use Docker +//! Desktop under its licence, and that is a decision a person makes, not a fallback the app takes +//! because Docker happened to be stopped. A Podman somebody already runs is still used (see +//! `install::install_engine_with`); one is never installed in Docker's place. +//! +//! **Docker's terms are accepted by the person, in Docker's own window.** The installer is run +//! without `--accept-license`, so the first start shows Docker's subscription agreement and its +//! privileged-helper prompt, and the wait below allows for somebody reading them. +//! +//! Linux is not handled here: Docker there is the distribution's Docker Engine, not Docker Desktop. + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; + +use crate::engine::{self, Engine}; +use crate::install::Download; +use crate::problem::Problem; + +/// The release whose installers are pinned below, and Docker's build number for it. +/// +/// Pinned for the reason every other download is: these files are executed. The digests come from +/// the `checksums.txt` Docker publishes beside each build, recorded here rather than fetched, since a +/// checksum from the server that served the file proves nothing. Moving the version means +/// re-recording all three. +pub const DOCKER_DESKTOP: &str = "4.96.3"; +const BUILD: &str = "244472"; + +/// How long a Docker Desktop that was already installed gets to start answering. +const START_PATIENCE: Duration = Duration::from_secs(180); +/// How long a first start gets: it shows Docker's terms and a helper prompt, which somebody reads. +const FIRST_START_PATIENCE: Duration = Duration::from_secs(15 * 60); +const POLL: Duration = Duration::from_secs(2); + +/// Docker Desktop's installer for this machine, or why there is not one. +pub fn download() -> Result { + download_for(std::env::consts::OS, std::env::consts::ARCH) +} + +fn download_for(os: &str, arch: &str) -> Result { + let (path, file, sha256) = + match (os, arch) { + ("macos", "aarch64") => ( + "mac/main/arm64", + "Docker.dmg", + "a3ac78b7de67af169ca58f33a4fa4c26007840870574c80c59a7ab0c7f68d954", + ), + ("macos", "x86_64") => ( + "mac/main/amd64", + "Docker.dmg", + "9d3409c689628d4f42972e4773a700817ff3526b1810a30a8de33fc1379e016b", + ), + ("windows", "x86_64") => ( + "win/main/amd64", + "Docker Desktop Installer.exe", + "3d8adad4f6c86583e0b00ea19a76197c51ccf5c83437c9ed775b739756452507", + ), + (os, arch) => return Err(Problem::with( + "OpenBot cannot install Docker Desktop on this kind of computer. Install Docker, \ + then start OpenBot again.", + format!("no Docker Desktop {DOCKER_DESKTOP} installer for {os} on {arch}"), + )), + }; + Ok(Download { + url: format!( + "https://desktop.docker.com/{path}/{BUILD}/{}", + file.replace(' ', "%20") + ), + sha256, + file, + }) +} + +/// Where Docker Desktop is installed on this machine, if it is. +pub fn installed_app() -> Option { + installed_app_in(&app_places()) +} + +fn installed_app_in(places: &[PathBuf]) -> Option { + places.iter().find(|place| place.exists()).cloned() +} + +fn app_places() -> Vec { + #[cfg(target_os = "macos")] + { + let mut places = vec![PathBuf::from("/Applications/Docker.app")]; + if let Some(home) = std::env::var_os("HOME") { + places.push(PathBuf::from(home).join("Applications/Docker.app")); + } + places + } + #[cfg(target_os = "windows")] + { + std::env::var_os("ProgramFiles") + .map(|files| PathBuf::from(files).join("Docker\\Docker\\Docker Desktop.exe")) + .into_iter() + .collect() + } + #[cfg(target_os = "linux")] + { + Vec::new() + } +} + +/// Whether Docker answers now. +pub fn answers() -> bool { + engine::Address::new(Engine::Docker, None).responds() +} + +/// Downloads and installs Docker Desktop, saying how it is going. +type Install<'a> = &'a mut dyn FnMut(&mut dyn FnMut(Progress)) -> Result<(), Problem>; + +/// What `ensure` needs from the machine, so its decisions can be tested without one. +pub(crate) struct Machine<'a> { + pub installed: &'a dyn Fn() -> bool, + pub answers: &'a dyn Fn() -> bool, + pub install: Install<'a>, + pub open: &'a dyn Fn() -> Result<(), Problem>, + pub sleep: &'a dyn Fn(Duration), + pub now: &'a dyn Fn() -> Instant, +} + +/// One line for the setup screen's step row, with download progress when there is some. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Progress { + pub said: String, + pub downloaded: Option, +} + +impl Progress { + fn said(said: impl Into) -> Self { + Self { + said: said.into(), + downloaded: None, + } + } +} + +/// What `ensure` did, for the step row and for telemetry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ensured { + pub said: String, + /// True when Docker Desktop was installed by this call. + pub installed: bool, +} + +/// Docker answering, by starting it or installing and starting it. See the module comment. +pub fn ensure(cache: &Path, progress: &mut dyn FnMut(Progress)) -> Result { + let into = crate::acquire::download_dir(cache); + ensure_with( + &mut Machine { + installed: &|| installed_app().is_some(), + answers: &answers, + install: &mut |progress| install(&into, progress), + open: &open, + sleep: &std::thread::sleep, + now: &Instant::now, + }, + progress, + ) +} + +pub(crate) fn ensure_with( + machine: &mut Machine, + progress: &mut dyn FnMut(Progress), +) -> Result { + if (machine.answers)() { + return Ok(Ensured { + said: "Docker Desktop is running.".into(), + installed: false, + }); + } + let fresh = !(machine.installed)(); + if fresh { + (machine.install)(progress)?; + } + progress(Progress::said(if fresh { + "Starting Docker Desktop. Accept its terms in its own window when it asks." + } else { + "Starting Docker Desktop." + })); + (machine.open)()?; + + let patience = if fresh { + FIRST_START_PATIENCE + } else { + START_PATIENCE + }; + let started = (machine.now)(); + while (machine.now)().duration_since(started) < patience { + if (machine.answers)() { + return Ok(Ensured { + said: if fresh { + format!("Docker Desktop {DOCKER_DESKTOP} installed and running.") + } else { + "Docker Desktop is running.".into() + }, + installed: fresh, + }); + } + (machine.sleep)(POLL); + } + Err(Problem::with( + "Docker Desktop did not start. Open Docker Desktop, finish anything it asks for, then try \ + again.", + format!( + "Docker did not answer within {}s of opening Docker Desktop", + patience.as_secs() + ), + )) +} + +/// Open Docker Desktop the way the person would. +fn open() -> Result<(), Problem> { + let Some(app) = installed_app() else { + return Err(Problem::plain( + "OpenBot cannot find Docker Desktop. Install it, then try again.", + )); + }; + #[cfg(target_os = "macos")] + let spawned = crate::quiet::command("open").arg("-a").arg(&app).status(); + #[cfg(not(target_os = "macos"))] + let spawned = crate::quiet::command(&app) + .spawn() + .map(|_| Default::default()); + match spawned { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(Problem::with( + "OpenBot could not open Docker Desktop. Open it yourself, then try again.", + format!("opening {} ended with {status}", app.display()), + )), + Err(error) => Err(Problem::with( + "OpenBot could not open Docker Desktop. Open it yourself, then try again.", + format!("opening {}: {error}", app.display()), + )), + } +} + +/// Download Docker Desktop's installer and install it, without accepting its terms. +fn install(into: &Path, progress: &mut dyn FnMut(Progress)) -> Result<(), Problem> { + let download = download()?; + let installer = fetch_streamed(&download, into, progress)?; + progress(Progress::said("Installing Docker Desktop.")); + install_from(&installer, into) +} + +#[cfg(target_os = "macos")] +fn install_from(dmg: &Path, into: &Path) -> Result<(), Problem> { + let mount = into.join("docker-desktop-volume"); + let _ = std::fs::create_dir_all(&mount); + let attached = crate::quiet::command("hdiutil") + .args([ + "attach", + "-nobrowse", + "-readonly", + "-noautoopen", + "-mountpoint", + ]) + .arg(&mount) + .arg(dmg) + .output() + .map_err(|error| install_failed(format!("hdiutil attach: {error}")))?; + if !attached.status.success() { + return Err(install_failed(format!( + "hdiutil attach: {}", + crate::quiet::said(&attached.stderr) + ))); + } + // Copied as the person, which an administrator account can do into /Applications with no + // prompt. Docker's own first start then asks for its privileged helper. + let copied = crate::quiet::command("ditto") + .arg(mount.join("Docker.app")) + .arg("/Applications/Docker.app") + .output(); + let _ = crate::quiet::command("hdiutil") + .args(["detach", "-quiet"]) + .arg(&mount) + .output(); + match copied { + Ok(out) if out.status.success() => Ok(()), + Ok(out) => Err(install_failed(format!( + "ditto Docker.app /Applications: {}", + crate::quiet::said(&out.stderr) + ))), + Err(error) => Err(install_failed(format!("ditto: {error}"))), + } +} + +#[cfg(target_os = "windows")] +fn install_from(exe: &Path, _into: &Path) -> Result<(), Problem> { + // Docker's installer elevates itself through UAC. `install` without `--accept-license` leaves + // the agreement to Docker Desktop's first start, where the person accepts it. + let status = crate::quiet::command(exe) + .arg("install") + .status() + .map_err(|error| install_failed(format!("{}: {error}", exe.display())))?; + if status.success() { + Ok(()) + } else { + Err(install_failed(format!( + "{} install ended with {status}", + exe.display() + ))) + } +} + +#[cfg(target_os = "linux")] +fn install_from(_installer: &Path, _into: &Path) -> Result<(), Problem> { + Err(Problem::plain( + "On Linux, Docker comes from the distribution's own packages.", + )) +} + +fn install_failed(detail: String) -> Problem { + Problem::with( + "OpenBot could not install Docker Desktop. Install it from docker.com, then try again.", + detail, + ) +} + +/// Fetch to `into`, hashing as it streams, and refuse anything whose digest is not the pinned one. +/// +/// Streamed rather than read whole like the smaller downloads: the installer is about 600 MB, and +/// the step row shows how much has arrived. A file already there with the right digest is kept. +fn fetch_streamed( + download: &Download, + into: &Path, + progress: &mut dyn FnMut(Progress), +) -> Result { + let path = into.join(download.file); + if file_digest(&path).as_deref() == Some(download.sha256) { + return Ok(path); + } + std::fs::create_dir_all(into).map_err(|error| unwritable(into, &error.to_string()))?; + + let unreachable = |error: String| { + Problem::with( + "OpenBot could not download Docker Desktop. Check the internet connection and try \ + again.", + format!("{}: {error}", download.url), + ) + }; + let mut response = reqwest::blocking::Client::builder() + .user_agent("openbot-desktop") + .build() + .map_err(|error| unreachable(error.to_string()))? + .get(&download.url) + .send() + .map_err(|error| unreachable(error.to_string()))?; + if !response.status().is_success() { + return Err(unreachable(format!("answered {}", response.status()))); + } + + let partial = into.join(format!("{}.part", download.file)); + let mut out = std::fs::File::create(&partial) + .map_err(|error| unwritable(&partial, &error.to_string()))?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0; 1 << 20]; + let mut received: u64 = 0; + let mut last_said = 0; + loop { + let read = response + .read(&mut buffer) + .map_err(|error| unreachable(format!("the download did not finish: {error}")))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + out.write_all(&buffer[..read]) + .map_err(|error| unwritable(&partial, &error.to_string()))?; + received += read as u64; + if received - last_said >= 8 << 20 { + last_said = received; + progress(Progress { + said: "Downloading Docker Desktop.".into(), + downloaded: Some(received), + }); + } + } + drop(out); + + let got = format!("{:x}", hasher.finalize()); + if got != download.sha256 { + let _ = std::fs::remove_file(&partial); + return Err(Problem::with( + "What OpenBot downloaded is not what it was expecting, so it has not been run. Try \ + again.", + format!( + "{} from {}: expected sha256 {}, got {got}", + download.file, download.url, download.sha256 + ), + )); + } + std::fs::rename(&partial, &path).map_err(|error| unwritable(&path, &error.to_string()))?; + Ok(path) +} + +fn file_digest(path: &Path) -> Option { + let mut file = std::fs::File::open(path).ok()?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher).ok()?; + Some(format!("{:x}", hasher.finalize())) +} + +fn unwritable(path: &Path, error: &str) -> Problem { + Problem::with( + "OpenBot could not save Docker Desktop's installer. Check there is free disk space and try \ + again.", + format!("{}: {error}", path.display()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::{Cell, RefCell}; + + /// A machine whose Docker answers after `answers_after` polls, counting what was done to it. + struct Fake { + installed: Cell, + polls: Cell, + answers_after: Option, + opened: Cell, + installs: Cell, + clock: Cell, + } + + impl Fake { + fn new(installed: bool, answers_after: Option) -> Self { + Self { + installed: Cell::new(installed), + polls: Cell::new(0), + answers_after, + opened: Cell::new(0), + installs: Cell::new(0), + clock: Cell::new(Instant::now()), + } + } + + fn run(&self) -> (Result, Vec) { + let said = RefCell::new(Vec::new()); + let answers = || { + let polls = self.polls.get(); + self.polls.set(polls + 1); + self.opened.get() > 0 && self.answers_after.is_some_and(|after| polls >= after) + }; + let result = ensure_with( + &mut Machine { + installed: &|| self.installed.get(), + answers: &answers, + install: &mut |progress| { + self.installs.set(self.installs.get() + 1); + progress(Progress { + said: "Downloading Docker Desktop.".into(), + downloaded: Some(8 << 20), + }); + self.installed.set(true); + Ok(()) + }, + open: &|| { + self.opened.set(self.opened.get() + 1); + Ok(()) + }, + sleep: &|step| self.clock.set(self.clock.get() + step), + now: &|| self.clock.get(), + }, + &mut |line| said.borrow_mut().push(line), + ); + (result, said.into_inner()) + } + } + + #[test] + fn an_answering_docker_is_used_as_it_is() { + let fake = Fake::new(true, Some(0)); + fake.opened.set(1); + let (result, said) = fake.run(); + assert!(!result.unwrap().installed); + assert_eq!(fake.opened.get(), 1, "nothing was opened again"); + assert_eq!(fake.installs.get(), 0); + assert!(said.is_empty()); + } + + #[test] + fn an_installed_docker_desktop_that_is_stopped_is_started_not_replaced() { + let fake = Fake::new(true, Some(3)); + let (result, said) = fake.run(); + let ensured = result.unwrap(); + assert!(!ensured.installed); + assert_eq!( + fake.installs.get(), + 0, + "an installed Docker is never installed again" + ); + assert_eq!(fake.opened.get(), 1); + assert_eq!(said, [Progress::said("Starting Docker Desktop.")]); + } + + #[test] + fn a_machine_without_docker_desktop_gets_it_installed_then_started() { + let fake = Fake::new(false, Some(2)); + let (result, said) = fake.run(); + let ensured = result.unwrap(); + assert!(ensured.installed); + assert_eq!(fake.installs.get(), 1); + assert_eq!(fake.opened.get(), 1); + assert_eq!(said[0].downloaded, Some(8 << 20)); + // The person accepts Docker's terms themselves, in Docker's window. + assert!(said[1].said.contains("Accept its terms in its own window")); + } + + #[test] + fn a_docker_desktop_that_never_answers_says_so_after_its_patience() { + let fake = Fake::new(true, None); + let (result, _) = fake.run(); + let problem = result.unwrap_err(); + assert!(problem.said.starts_with("Docker Desktop did not start.")); + assert!(problem.detail.unwrap().contains("180s")); + // A first start waits longer: it shows terms somebody reads. + let fresh = Fake::new(false, None); + let problem = fresh.run().0.unwrap_err(); + assert!(problem.detail.unwrap().contains("900s")); + } + + #[test] + fn the_installers_are_pinned_to_docker_s_published_digests() { + for (os, arch) in [ + ("macos", "aarch64"), + ("macos", "x86_64"), + ("windows", "x86_64"), + ] { + let download = download_for(os, arch).unwrap(); + assert!( + download.url.starts_with("https://desktop.docker.com/"), + "{download:?}" + ); + assert!(download.url.contains(&format!("/{BUILD}/")), "{download:?}"); + assert_eq!(download.sha256.len(), 64); + assert!(download + .sha256 + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + } + assert!(download_for("windows", "x86_64") + .unwrap() + .url + .ends_with("Docker%20Desktop%20Installer.exe")); + assert!(download_for("linux", "x86_64").is_err()); + } + + #[test] + fn an_installed_app_is_found_in_any_of_its_places() { + let root = crate::test_support::temp_root("docker-desktop-places"); + let first = root.join("Applications/Docker.app"); + let second = root.join("home/Applications/Docker.app"); + assert_eq!(installed_app_in(&[first.clone(), second.clone()]), None); + std::fs::create_dir_all(&second).unwrap(); + assert_eq!(installed_app_in(&[first, second.clone()]), Some(second)); + } +} diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 2900d3a579..199019517e 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -1,5 +1,11 @@ //! Installing an engine, instead of telling somebody to go and get one. //! +//! **On macOS and Windows the engine is Docker Desktop** (see `docker_desktop`): started when it is +//! installed, installed when it is not. Podman is never installed there for somebody; it is for an +//! organization that cannot use Docker under its licence, and that is their decision. The Podman +//! installers below are kept for that explicit path and for Linux, where Podman comes from the +//! distribution's packages. +//! //! The person this app is for does not have a package manager, has never heard of Podman, and is //! not going to read a release page. Every sentence that begins "install ..." is a step where an //! install stops for good, so the engine is fetched and installed here. @@ -369,35 +375,104 @@ pub(crate) fn verify_bun(binary: &Path) -> Result<(), Problem> { /// somebody presses Start. Answers with the sentence for the step row, or a failure in both /// registers. pub fn install_engine(cache: &Path) -> Result { - install_engine_observed(cache, |_| {}) + install_engine_observed(cache, |_, _| {}, &mut |_| {}) } -/// Observe an actual Podman installer invocation, excluding existing engines and Compose repair. +/// Observe an actual engine install (Docker Desktop, or Podman on Linux), excluding engines that +/// were already here and Compose repair; `progress` is each line for the step row. pub fn install_engine_observed( cache: &Path, - installed: impl FnMut(bool), + installed: impl FnMut(Engine, bool), + progress: &mut dyn FnMut(crate::docker_desktop::Progress), ) -> Result { - install_engine_with(cache, installed, install_podman, place_compose) + install_engine_with( + cache, + Found::here(), + installed, + |cache| crate::docker_desktop::ensure(cache, progress), + install_podman, + place_compose, + ) +} + +/// What engines this machine has, read once so the choice below can be tested on any machine. +#[derive(Debug, Clone, Copy)] +struct Found { + /// Docker Desktop is how this platform gets Docker: macOS and Windows, not Linux. + docker_desktop_platform: bool, + docker_answers: bool, + docker_app_installed: bool, + docker_cli_installed: bool, + podman_installed: bool, +} + +impl Found { + fn here() -> Self { + Self { + docker_desktop_platform: cfg!(any(target_os = "macos", target_os = "windows")), + docker_answers: engine::Address::new(Engine::Docker, None).responds(), + docker_app_installed: crate::docker_desktop::installed_app().is_some(), + docker_cli_installed: engine::program(Engine::Docker).is_some(), + podman_installed: engine::program(Engine::Podman).is_some(), + } + } } fn install_engine_with( cache: &Path, - mut installed: impl FnMut(bool), + found: Found, + mut installed: impl FnMut(Engine, bool), + ensure_docker: impl FnOnce(&Path) -> Result, install_podman: impl FnOnce(&Path) -> Result<(), Problem>, place_compose: impl FnOnce(&Path) -> Result, ) -> Result { let into = crate::acquire::download_dir(cache); + let docker_app_installed = found.docker_app_installed; - // engine_ready can start an installed Podman machine, but cannot start Docker. A stopped - // Docker CLI must not skip installing the Podman that preparation will then try to run. - if engine::program(Engine::Podman).is_some() - || engine::Address::new(Engine::Docker, None).responds() - { + if found.docker_answers { + return place_compose(&into); + } + + if found.docker_desktop_platform { + /* + * DOCKER IS THE ENGINE, AND PODMAN IS NEVER CHOSEN FOR SOMEBODY. + * + * An installed Docker Desktop that is not running is started; a machine with no engine at + * all gets Docker Desktop. This used to install Podman in both cases, behind an + * administrator prompt, which put a second engine on a machine that already had Docker and + * gave local Intelligence an engine it refuses. Podman is for an organization that cannot + * use Docker under its licence: a Podman somebody already has is theirs and is used, and + * one is never installed in Docker's place. See `docker_desktop`. + */ + if docker_app_installed || !found.podman_installed { + // A `docker` with no Docker Desktop behind it is OrbStack, Colima or similar, stopped. + // Installing Docker Desktop over it is not the repair; starting it is, and only they + // can. + if !docker_app_installed && found.docker_cli_installed { + return Err(Problem::with( + "Docker is installed but not running. Start it, then try again.", + "a docker command is installed, Docker Desktop is not, and docker does not \ + answer", + )); + } + let result = ensure_docker(cache); + if !docker_app_installed { + installed(Engine::Docker, result.is_ok()); + } + let ensured = result?; + place_compose(&into)?; + return Ok(ensured.said); + } + return place_compose(&into); + } + + // Linux: the distribution's own Podman, through its package manager. See the module comment. + if found.podman_installed { return place_compose(&into); } let result = install_podman(&into); - installed(result.is_ok()); + installed(Engine::Podman, result.is_ok()); result?; // Installed is not found. The MSI extends the *user's* PATH and this process was started with @@ -665,65 +740,139 @@ mod tests { use super::*; use crate::test_support::temp_root; + fn found(docker_answers: bool, docker_app: bool, docker_cli: bool, podman: bool) -> Found { + Found { + docker_desktop_platform: true, + docker_answers, + docker_app_installed: docker_app, + docker_cli_installed: docker_cli, + podman_installed: podman, + } + } + + fn ensured(installed: bool) -> crate::docker_desktop::Ensured { + crate::docker_desktop::Ensured { + said: if installed { "installed" } else { "started" }.into(), + installed, + } + } + #[test] - #[cfg(windows)] - fn a_stopped_docker_does_not_skip_the_podman_needed_for_setup() { - if crate::test_support::isolated_process( - "install::tests::a_stopped_docker_does_not_skip_the_podman_needed_for_setup", - ) { - return; + fn a_stopped_docker_desktop_is_started_and_podman_is_never_installed_for_it() { + let root = temp_root("stopped-docker-desktop"); + let mut observed = Vec::new(); + // Installed and stopped, with or without a Podman beside it: Docker is started. + for podman in [false, true] { + let said = install_engine_with( + &root, + found(false, true, true, podman), + |engine, ok| observed.push((engine, ok)), + |_| Ok(ensured(false)), + |_| panic!("Podman is never installed in Docker's place"), + |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!(said, "started"); } - let root = temp_root("stopped-docker-install"); - let bin = root.join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - let source = root.join("engine.rs"); - std::fs::write( - &source, - r#"fn main() { - if std::env::var_os("OPENBOT_TEST_DOCKER_RUNNING").is_none() { - std::process::exit(1); - } - println!("1.44"); - }"#, + assert!(observed.is_empty(), "starting is not an install"); + } + + #[test] + fn a_machine_with_no_engine_gets_docker_desktop_not_podman() { + let root = temp_root("no-engine"); + let mut observed = Vec::new(); + let said = install_engine_with( + &root, + found(false, false, false, false), + |engine, ok| observed.push((engine, ok)), + |_| Ok(ensured(true)), + |_| panic!("Podman is never installed in Docker's place"), + |_| Ok("Compose placed".into()), ) .unwrap(); - crate::test_support::compile_fixture(&source, &bin.join("docker.exe")); - std::env::set_var("PATH", &bin); - std::env::set_var("LOCALAPPDATA", root.join("Local")); - std::env::set_var("ProgramFiles", root.join("Program Files")); - std::env::remove_var("OPENBOT_TEST_DOCKER_RUNNING"); - - let found = engine::detect(); - assert_eq!(found.engine, Some(Engine::Docker)); - assert!(!found.responding); - assert!(engine::program(Engine::Podman).is_none()); + assert_eq!(said, "installed"); + assert_eq!(observed, [(Engine::Docker, true)]); + // A failed Docker Desktop install is reported as one, and nothing else is tried. let mut observed = Vec::new(); - let result = install_engine_with( + let problem = install_engine_with( &root, - |success| observed.push(success), - |_| { - // Installation supplies the engine that engine_ready will create/start. - std::fs::copy(bin.join("docker.exe"), bin.join("podman.exe")).unwrap(); - Ok(()) - }, + found(false, false, false, false), + |engine, ok| observed.push((engine, ok)), + |_| Err(Problem::plain("no")), + |_| panic!("no Podman fallback"), + |_| panic!("no Compose without an engine"), + ) + .unwrap_err(); + assert_eq!(problem.said, "no"); + assert_eq!(observed, [(Engine::Docker, false)]); + } + + #[test] + fn a_podman_somebody_already_has_is_theirs_and_is_used() { + let root = temp_root("existing-podman"); + let said = install_engine_with( + &root, + found(false, false, false, true), + |_, _| panic!("nothing is installed"), + |_| panic!("Docker Desktop is not installed over somebody's own Podman"), + |_| panic!("no install"), |_| Ok("Compose placed".into()), - ); - result.expect("setup should install its missing Podman"); - assert_eq!(observed, [true], "the Podman installer must run"); - assert!(engine::program(Engine::Podman).is_some()); - - // A responding Docker still supplies the engine; never install a replacement. - std::fs::remove_file(bin.join("podman.exe")).unwrap(); - std::env::set_var("OPENBOT_TEST_DOCKER_RUNNING", "1"); - assert!(engine::detect().responding); - let result = install_engine_with( + ) + .unwrap(); + assert_eq!(said, "Compose placed"); + } + + #[test] + fn a_stopped_docker_without_docker_desktop_is_left_for_its_owner_to_start() { + let root = temp_root("stopped-orbstack"); + let problem = install_engine_with( &root, - |_| panic!("no Podman installation should be observed"), - |_| panic!("responding Docker must be reused"), + found(false, false, true, false), + |_, _| panic!("nothing is installed"), + |_| panic!("Docker Desktop is not installed over another Docker"), + |_| panic!("no Podman fallback"), + |_| panic!("no Compose without an engine"), + ) + .unwrap_err(); + assert!(problem + .said + .starts_with("Docker is installed but not running.")); + } + + #[test] + fn an_answering_docker_is_used_as_it_is() { + let root = temp_root("answering-docker"); + let said = install_engine_with( + &root, + found(true, false, true, false), + |_, _| panic!("nothing is installed"), + |_| panic!("an answering Docker is not started again"), + |_| panic!("no install"), |_| Ok("Compose placed".into()), - ); - assert_eq!(result.unwrap(), "Compose placed"); - std::fs::remove_dir_all(root).unwrap(); + ) + .unwrap(); + assert_eq!(said, "Compose placed"); + } + + #[test] + fn linux_keeps_its_distribution_podman() { + let root = temp_root("linux-engine"); + let mut observed = Vec::new(); + let linux = Found { + docker_desktop_platform: false, + ..found(false, false, false, false) + }; + install_engine_with( + &root, + linux, + |engine, ok| observed.push((engine, ok)), + |_| panic!("Linux does not get Docker Desktop"), + |_| Ok(()), + |_| Ok("Compose placed".into()), + ) + .unwrap_err(); + // The Podman install ran; it then finds no podman program on this test machine. + assert_eq!(observed, [(Engine::Podman, true)]); } /// Every platform this app runs on has a Compose build, or the stack cannot be raised there. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d5193b30e7..1024b315f1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod acquire; pub mod ask; pub mod deployment; pub mod deployment_release; +pub mod docker_desktop; pub mod engine; pub mod env; pub mod harness; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index b6eabf2753..a2d37355fe 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -676,20 +676,36 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result telemetry::Engine::Docker, + engine::Engine::Podman => telemetry::Engine::Podman, + }, + outcome: if success { + telemetry::EngineInstallOutcome::Success + } else { + telemetry::EngineInstallOutcome::Failure + }, }, - }, - ); - }) + ); + }, + // Starting or installing Docker Desktop takes minutes; each line says where it is. + &mut |progress| { + report_running( + &progress_app, + "install-engine", + progress.said, + progress.downloaded, + ) + }, + ) }) .await .map_err(|error| { From 96d60386ec777163c0ab5538b5e2fbd470c230c3 Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 13:02:04 -0700 Subject: [PATCH 23/77] fix(desktop): local Start uses the model just chosen, even when the choice moved on by itself The local sequence starts OpenBot a tick after the go-ahead and read the model from the last render. A Continue click has rendered by then; a sign-in that finished and continued from an effect may not have, so with Intelligence already ready Start ran with model null ('invalid type: null, expected struct ChosenModel'). The choice is now carried in a ref set when it is made, and the sequence reads that. The Claude plan test again signs in only after Intelligence is ready. --- desktop/src/App.test.tsx | 7 +++++-- desktop/src/App.tsx | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 0a4826380f..c025ecac34 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -985,11 +985,14 @@ test("a Claude plan signed in on the provider screen reaches Start with its toke await view.findByLabelText("Code from your browser"), "synthetic-code", ); + // Intelligence is ready and waiting before the sign-in finishes, as on a real machine. The + // finished sign-in then moves on by itself, from an effect rather than a click, and Start must + // still get the model it chose (it once got null: Start ran before the choice had rendered). + await waitFor(() => expect(localSequence()).toContain("run_local_eval")); + await act(async () => new Promise((resolve) => setTimeout(resolve, 100))); await userEvent.click( view.getByRole("button", { name: "Finish signing in" }), ); - // Signed in, so on by itself: no Continue after "Signed in". - await waitFor(() => expect(localSequence()).toContain("run_local_eval")); await view.findByRole("heading", { name: "Test OpenBot" }); expect(getStartStackPayload()).toMatchObject({ model: { diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 34eb968782..84312accca 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -139,6 +139,18 @@ function Setup() { id: DEFAULT_HARNESS, }); const [model, setModel] = useState(null); + /* + * The model as of the latest choice, without waiting for a render. + * + * Local Intelligence starts OpenBot from its own sequence, a tick after the go-ahead. A choice + * made by a click has rendered by then; one made from an effect (a sign-in that finished and moved + * on by itself) may not have, and Start ran with the model from before it: null. + */ + const chosenModel = useRef(model); + // Follows every other change to the model, once committed; a choice sets it at once (below). + useEffect(() => { + chosenModel.current = model; + }, [model]); /** Model credentials a previous run already wrote, so the provider screen arrives filled in. */ const [alreadyHeld, setAlreadyHeld] = useState({}); // A hosted connection somebody chose earlier: a saved key, or their own server's address. @@ -390,7 +402,7 @@ function Setup() { root, stack: localStack, connected: localConnected, - onStart: (startable) => start(model, startable), + onStart: (startable) => start(chosenModel.current, startable), onRecorded: (stack, connected) => { setLocalStack(stack); if (connected) setLocalConnected(true); @@ -1187,6 +1199,7 @@ function Setup() { status={localFlow && } onChoose={(choice) => { recordSetupEvent(modelChoiceEvent(choice)); + chosenModel.current = choice; setModel(choice); if (localFlow) { // The go-ahead: OpenBot starts once Intelligence is ready, wherever it has got to. From 148f77e90b38a5fe262d8cc100ff5d96f0d9ef4c Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 13:06:12 -0700 Subject: [PATCH 24/77] wip(desktop): a finished sign-in shows a tick for a second before moving on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '✓ Signed in to .' appears beside Continue, then the screen continues by itself after CONFIRMED_FOR_MS (1 s). Choosing another provider row in that second cancels the move. --- desktop/src/App.test.tsx | 21 +++++++++++++--- desktop/src/ProviderPicker.test.tsx | 39 ++++++++++++++++++++++++----- desktop/src/ProviderPicker.tsx | 28 ++++++++++++++++++++- desktop/src/styles.css | 15 +++++++++++ 4 files changed, 92 insertions(+), 11 deletions(-) diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index c025ecac34..798a8f3471 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -993,7 +993,11 @@ test("a Claude plan signed in on the provider screen reaches Start with its toke await userEvent.click( view.getByRole("button", { name: "Finish signing in" }), ); - await view.findByRole("heading", { name: "Test OpenBot" }); + // A tick for a second, then on by itself. + expect((await view.findByText(/✓/)).closest("p")?.textContent).toContain( + "Signed in to Anthropic.", + ); + await view.findByRole("heading", { name: "Test OpenBot" }, { timeout: 4000 }); expect(getStartStackPayload()).toMatchObject({ model: { provider: "anthropic", @@ -1816,8 +1820,12 @@ test("provider sign-in retries and Back reuse the completed local installation", await userEvent.click( view.getByRole("button", { name: "Sign in with OpenAI" }), ); - // Signed in, so on to the next step by itself. - await view.findByRole("heading", { name: "Connect to CopilotKit" }); + // Signed in, so on to the next step by itself, after a second showing the tick. + await view.findByRole( + "heading", + { name: "Connect to CopilotKit" }, + { timeout: 4000 }, + ); expect(signIns).toBe(2); expect(installationCalls()).toHaveLength(1); }); @@ -4253,7 +4261,12 @@ for (const provider of [ await userEvent.click(view.getByRole("button", { name: "Continue" })); } await userEvent.click( - await view.findByRole("button", { name: "Start OpenBot" }), + await view.findByRole( + "button", + { name: "Start OpenBot" }, + // A fresh sign-in shows its tick for a second before it moves on. + { timeout: 4000 }, + ), ); const payload = getStartStackPayload(); diff --git a/desktop/src/ProviderPicker.test.tsx b/desktop/src/ProviderPicker.test.tsx index f30d8d753a..0c9fdc9d83 100644 --- a/desktop/src/ProviderPicker.test.tsx +++ b/desktop/src/ProviderPicker.test.tsx @@ -174,10 +174,12 @@ test("a completed plan sign-in enables and submits only its issuing provider", a expect(view.getByText(/Signed in to OpenAI/)).toBeTruthy(), ); // The finished sign-in moves on by itself, with its own provider's token. - await waitFor(() => - expect(choices).toEqual([ - { provider: "openai", login: "plan", token: "chatgpt-token" }, - ]), + await waitFor( + () => + expect(choices).toEqual([ + { provider: "openai", login: "plan", token: "chatgpt-token" }, + ]), + { timeout: 4000 }, ); await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); @@ -189,6 +191,29 @@ test("a completed plan sign-in enables and submits only its issuing provider", a expect(choices).toHaveLength(1); }); +test("a finished sign-in shows its tick for a second, and choosing another row then cancels the move", async () => { + const choices: unknown[] = []; + invokeHandler = async (command) => { + if (command === "providers") return providers; + if (command === "begin_chatgpt_sign_in") return "https://chatgpt.test"; + if (command === "finish_chatgpt_sign_in") return "chatgpt-token"; + throw new Error(`unexpected command ${command}`); + }; + const view = await renderPicker((choice) => choices.push(choice)); + await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await userEvent.click( + view.getByRole("button", { name: "Sign in with OpenAI" }), + ); + const tick = await view.findByText(/✓/); + expect(tick.closest("p")?.textContent).toContain("Signed in to OpenAI."); + expect(choices).toEqual([]); + // Another row inside the second: nothing moves on. + await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); + await act(async () => new Promise((resolve) => setTimeout(resolve, 1300))); + expect(choices).toEqual([]); + expect(view.queryByText(/✓/)).toBeNull(); +}); + test("a pending plan sign-in completion is ignored after switching provider rows", async () => { const chatgpt = deferred(); const choices: unknown[] = []; @@ -324,7 +349,9 @@ for (const provider of providers) { if (session === "saved") { await userEvent.click(view.getByRole("button", { name: "Continue" })); } else { - await waitFor(() => expect(choices).toHaveLength(1)); + await waitFor(() => expect(choices).toHaveLength(1), { + timeout: 4000, + }); } expect(choices).toHaveLength(1); @@ -897,7 +924,7 @@ for (const provider of cloudProviders) { ); await view.findByText(`Signed in to ${provider.name}.`); // Signed in with a model already named: on by itself, no Continue. - await waitFor(() => expect(choices).toHaveLength(1)); + await waitFor(() => expect(choices).toHaveLength(1), { timeout: 4000 }); expect(choices).toEqual([ { provider: provider.id, diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index 7c66ccc10d..f06d129804 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -9,6 +9,9 @@ import { StatusLine } from "./SetupProgress"; export type Login = "plan" | "api-key" | "endpoint" | "oauth"; +/** How long "✓ Signed in" shows before the screen moves on by itself. */ +export const CONFIRMED_FOR_MS = 1000; + export type Provider = { id: string; name: string; @@ -486,15 +489,33 @@ export function ProviderPicker({ * Signed in, so on to the next step: the sign-in was the thing this screen was waiting for, and * a Continue after "Signed in" is a click that only confirms what just happened. A sign-in that * leaves something else to fill in (an OAuth row's model) waits for Continue as before. + * + * Not at once, though: a tick beside "Signed in" for a second first, so the screen says it worked + * before it changes. Choosing another row in that second cancels the move. */ + const [confirmed, setConfirmed] = useState(null); + const continueLatest = useRef(continueWithChoice); + continueLatest.current = continueWithChoice; // biome-ignore lint/correctness/useExhaustiveDependencies: runs when readiness or a sign-in changes. useEffect(() => { if (!continueAfterSignIn.current || busy) return; // Used up on the first look after the sign-in either way, so a field filled in later never // moves the screen on from under somebody still typing. continueAfterSignIn.current = false; - if (ready && !starting) continueWithChoice(); + if (ready && !starting && row) setConfirmed(row.id); }, [ready, busy, starting, tokens, oauthSignedIn]); + useEffect(() => { + if (!confirmed) return; + if (confirmed !== open) { + setConfirmed(null); + return; + } + const timer = setTimeout(() => { + setConfirmed(null); + continueLatest.current(); + }, CONFIRMED_FOR_MS); + return () => clearTimeout(timer); + }, [confirmed, open]); return (
      @@ -887,6 +908,11 @@ export function ProviderPicker({
      )} + {confirmed && row && confirmed === row.id && ( +

      + Signed in to {row.name}. +

      + )}
      {onBack && (
      )} - {confirmed && row && confirmed === row.id && ( -

      - Signed in to {row.name}. -

      - )}
      {onBack && ( +
      + )} + {failed &&

      {failed}

      } +
      + ); +} + +function AcceptTerms({ + onAccept, + onRetry, +}: { + onAccept: (version: string) => void; + onRetry: () => void; +}) { + const [terms, setTerms] = useState(null); + const [failed, setFailed] = useState(false); + useEffect(() => { + let current = true; + const started = localEvalTerms(); + started.terms.then( + (read) => current && setTerms(read), + () => current && setFailed(true), + ); + return () => { + current = false; + cancelLocalEval(started.attempt).catch(() => {}); + }; + }, []); + + if (failed || (terms && terms.status !== "required")) + return ( +
      +

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

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

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

      +

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

      +
      + +
      +
      ); } diff --git a/desktop/src/localEvalCli.ts b/desktop/src/localEvalCli.ts index 3fc55fa6b0..c43895cd22 100644 --- a/desktop/src/localEvalCli.ts +++ b/desktop/src/localEvalCli.ts @@ -28,6 +28,7 @@ export const localFailures = [ "unsafe-endpoint", "contract-mismatch", "unexpected-exit", + "invalid-acceptance", ] as const; export type LocalFailure = (typeof localFailures)[number]; @@ -163,7 +164,11 @@ export function listLocalEval(): { * starts and stops with OpenBot. Blocked is an answer, not an error: the first is usually the * evaluation terms. A stack that was set up but could not be recorded rejects with a `Problem`. */ -export function setUpLocalEval(root: string): { +export function setUpLocalEval( + root: string, + /** The agreement version the person accepted in this window, once they have. */ + acceptedEulaVersion: string | null = null, +): { attempt: string; report: Promise; } { @@ -171,6 +176,7 @@ export function setUpLocalEval(root: string): { const report = invoke("set_up_local_eval", { attempt, root, + acceptedEulaVersion, }).catch((rejection: unknown) => { if (rejection && typeof rejection === "object" && "said" in rejection) { throw rejection as Problem; @@ -180,6 +186,51 @@ export function setUpLocalEval(root: string): { return { attempt, report }; } +/** + * Signs in to CopilotKit, which local Intelligence needs for its evaluation licence. The CLI opens + * the approval page in the browser; this settles once the person approved it there (true) or it + * did not finish (false). + */ +export function signInToCopilotKit(): { + attempt: string; + signedIn: Promise; +} { + const attempt = crypto.randomUUID(); + const signedIn = invoke<{ signedIn: boolean }>("sign_in_to_copilotkit", { + attempt, + }).then( + (answer) => answer.signedIn, + (rejection: unknown) => { + throw failure(rejection); + }, + ); + return { attempt, signedIn }; +} + +/** The evaluation agreement, as the CLI reports it: what the person reads before Accept. */ +export type LocalTerms = + | { + status: "required"; + accepted: boolean; + version: string; + url: string; + organizationName: string; + } + | { status: "not-required" | "unavailable" | "signed-out" }; + +export function localEvalTerms(): { + attempt: string; + terms: Promise; +} { + const attempt = crypto.randomUUID(); + const terms = invoke("local_eval_terms", { attempt }).catch( + (rejection: unknown) => { + throw failure(rejection); + }, + ); + return { attempt, terms }; +} + /** * `local connect` for this installation, after the person chose to connect OpenBot. The native * side keeps the key the CLI wrote as OpenBot's Intelligence key. A connection it would not take diff --git a/desktop/src/localReadiness.test.ts b/desktop/src/localReadiness.test.ts index 50b2e6684e..04b36ba0f3 100644 --- a/desktop/src/localReadiness.test.ts +++ b/desktop/src/localReadiness.test.ts @@ -84,7 +84,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(18); - expect(localFailures.length).toBe(15); + expect(localFailures.length).toBe(16); }); test("Docker reasons block the runtime when the probe has no answer", () => { diff --git a/desktop/src/localReadiness.ts b/desktop/src/localReadiness.ts index b2aaecc67f..55cbf00cab 100644 --- a/desktop/src/localReadiness.ts +++ b/desktop/src/localReadiness.ts @@ -141,6 +141,10 @@ const failureRows: Record = { "missing-stack": blocked(contract), "unsafe-endpoint": blocked(contract), "contract-mismatch": blocked(contract), + "invalid-acceptance": blocked( + "The evaluation agreement could not be accepted.", + "retry", + ), }; function intelligenceRow( From e4849d8f480d5c13e40827a6cc051485f4ae2002 Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 18:28:34 -0700 Subject: [PATCH 27/77] fix(desktop): a download fails only when it stalls, not when it takes longer than 30 seconds deployment::get read the body with bytes(), which the blocking client bounds by its 30-second default timeout and reports as 'error decoding response body' when that runs out. Any download taking longer than 30 seconds failed however steadily it arrived: Bun's 30 MB on a connection under about 1 MB/s, the Compose and Podman downloads, and the release lookup. Present since get() was written (#392). The body is now read as a stream, where the timeout bounds each read, so a download is given up only after 30 seconds with nothing arriving. A slow-server test fails with the old code's exact message and passes now. --- desktop/src-tauri/src/deployment.rs | 73 +++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/deployment.rs b/desktop/src-tauri/src/deployment.rs index 563d8474e9..f4c80148ff 100644 --- a/desktop/src-tauri/src/deployment.rs +++ b/desktop/src-tauri/src/deployment.rs @@ -332,8 +332,25 @@ fn fetch_images(root: &Path, version: &str) -> Result<(), String> { /// Shared with `install.rs`, which fetches the engine's installers through it and then checks their /// digests. One client, one user agent, one set of TLS defaults. pub fn get(url: &str) -> Result, String> { - let response = reqwest::blocking::Client::builder() + get_waiting(url, IDLE_TIMEOUT) +} + +/// How long a download may go without receiving anything before it is given up. +const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/** +`get`, giving up only when nothing arrives for `idle`. + +READ AS A STREAM, NOT WITH `bytes()`. The blocking client's timeout (30 s by default) bounds each +read on a stream, but `bytes()` waits for the whole body within it, and reports running out of time +as "error decoding response body". So any download that took longer than 30 seconds failed, however +steadily it was arriving: Bun's 30 MB on a connection under about 1 MB/s, every time. +*/ +fn get_waiting(url: &str, idle: std::time::Duration) -> Result, String> { + use std::io::Read; + let mut response = reqwest::blocking::Client::builder() .user_agent("openbot-desktop") + .timeout(idle) .build() .map_err(|error| format!("could not prepare the download: {error}"))? .get(url) @@ -342,16 +359,64 @@ pub fn get(url: &str) -> Result, String> { if !response.status().is_success() { return Err(format!("{url} answered {}", response.status())); } + let mut body = Vec::new(); response - .bytes() - .map(|body| body.to_vec()) - .map_err(|error| format!("the download did not finish: {error}")) + .read_to_end(&mut body) + .map_err(|error| format!("the download did not finish: {error}"))?; + Ok(body) } #[cfg(test)] mod tests { use super::*; + /// A server that sends `chunks` pieces of `body`, `gap` apart, then closes. + fn dripping(chunks: usize, gap: std::time::Duration) -> (String, std::thread::JoinHandle<()>) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/file", listener.local_addr().unwrap()); + let served = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 1024]; + let _ = stream.read(&mut request); + let length = chunks * 4; + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-length: {length}\r\nconnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .unwrap(); + for _ in 0..chunks { + std::thread::sleep(gap); + if stream.write_all(b"data").is_err() { + return; + } + let _ = stream.flush(); + } + }); + (url, served) + } + + #[test] + fn a_download_that_keeps_arriving_finishes_however_long_it_takes() { + // Six pieces 300 ms apart take 1.8 s in all, longer than the 1 s allowed between pieces. + // `bytes()` gave up here with "error decoding response body"; a stream does not. + let (url, served) = dripping(6, std::time::Duration::from_millis(300)); + let body = get_waiting(&url, std::time::Duration::from_secs(1)).unwrap(); + assert_eq!(body, b"data".repeat(6)); + served.join().unwrap(); + } + + #[test] + fn a_download_that_stalls_is_given_up() { + let (url, served) = dripping(2, std::time::Duration::from_millis(1500)); + let error = get_waiting(&url, std::time::Duration::from_millis(500)).unwrap_err(); + assert!(error.starts_with("the download did not finish"), "{error}"); + served.join().unwrap(); + } + fn manifest(names: &[&str]) -> Images { Images { version: "v0.0.7".into(), From 5a9aeeb1f5db0d3aa102cf9ed80a862bfa40bdd6 Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 22:24:09 -0700 Subject: [PATCH 28/77] feat(desktop): CopilotKit sign-in and the evaluation agreement are a step of their own in a local first run Sign-in and Accept sat on the AI provider screen, under the Intelligence status line. A local first run is now four steps: install OpenBot, then CopilotKit (sign in, then read and accept the evaluation agreement), then choose the AI while Intelligence sets up behind it, then test. Nothing licensed starts before the account step finishes, and setup runs with the version accepted there. Somebody already signed in with the current agreement accepted passes the step without a click. The provider screen no longer offers sign-in; the Getting ready panel keeps it as a fallback for a session that ends later. step_viewed telemetry gains 'account'. --- desktop/src-tauri/src/telemetry.rs | 2 + desktop/src/App.test.tsx | 91 ++++++++++++++++- desktop/src/App.tsx | 38 +++++-- desktop/src/CopilotKitAccount.tsx | 156 +++++++++++++++++++++++++++++ desktop/src/LocalIntelligence.tsx | 1 - desktop/src/telemetry.ts | 1 + 6 files changed, 274 insertions(+), 15 deletions(-) create mode 100644 desktop/src/CopilotKitAccount.tsx diff --git a/desktop/src-tauri/src/telemetry.rs b/desktop/src-tauri/src/telemetry.rs index 3a6d9b2fd8..ad234daaea 100644 --- a/desktop/src-tauri/src/telemetry.rs +++ b/desktop/src-tauri/src/telemetry.rs @@ -687,6 +687,8 @@ pub enum Step { Welcome, Harness, Install, + /// The CopilotKit sign-in and evaluation agreement of a local first run. + Account, Model, Connect, Ask, diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 798a8f3471..8fa0df6ed8 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -507,12 +507,24 @@ const localConnection = { }; /** This build has a CLI; each local command answers from `local`. */ +/** The agreement as the CLI reports it for a person who has already accepted it. */ +const ACCEPTED_TERMS = { + status: "required", + accepted: true, + version: "developer-edition-2026-10-01-web", + url: "https://copilotkit.ai/legal/developer-edition", + organizationName: "Acme", +}; + function withLocalCli(local: Record) { const previous = invokeHandler; invokeHandler = async (command, args) => { if (command === "list_local_eval") return localListing; if (command === "cancel_local_eval") return null; if (command === "show_openbot") return null; + // Signed in and agreed already, unless a test says otherwise: the account step passes. + if (command === "local_eval_terms" && !("local_eval_terms" in local)) + return ACCEPTED_TERMS; if (command in local) { const answer = local[command]; return typeof answer === "function" ? answer(args) : answer; @@ -651,7 +663,7 @@ test("Install prepares the default Bot in the default folder, with no Bot or fol expect( view.getByRole("heading", { name: "Installing OpenBot" }), ).toBeTruthy(); - expect(stepLabel(view)).toBe("Step 1 of 3"); + expect(stepLabel(view)).toBe("Step 1 of 4"); 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); @@ -695,6 +707,77 @@ test("Install prepares the default Bot in the default folder, with no Bot or fol expect(localCliCalls()).toEqual([]); }); +test("a signed-out first run signs in to CopilotKit and accepts the agreement as its own step, before choosing an AI", async () => { + setupRootConfiguration("/tmp/local-eval-account", async () => + emptyConfiguration(), + ); + let signedIn = false; + withLocalCli({ + local_eval_terms: () => + signedIn + ? { ...ACCEPTED_TERMS, accepted: false } + : { status: "signed-out" }, + sign_in_to_copilotkit: () => { + signedIn = true; + return { signedIn: true }; + }, + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + }); + const view = await renderApp(); + await installLocal(view); + // Its own step, after the software and before the AI provider. + await view.findByRole("heading", { name: "Sign in to CopilotKit" }); + expect(stepLabel(view)).toBe("Step 2 of 4"); + expect(localSequence()).toEqual([]); + await userEvent.click( + view.getByRole("button", { name: "Sign in to CopilotKit" }), + ); + await view.findByRole("heading", { name: "Accept the evaluation agreement" }); + expect( + view.getByText( + "To run Intelligence on this computer, accept the CopilotKit Intelligence evaluation agreement for Acme.", + ), + ).toBeTruthy(); + expect(view.getByRole("link", { name: "Read the agreement" })).toBeTruthy(); + // Nothing licensed runs before Accept. + expect(localSequence()).toEqual([]); + await userEvent.click(view.getByRole("button", { name: "Accept" })); + await view.findByRole("heading", { name: "Connect your AI" }); + expect(stepLabel(view)).toBe("Step 3 of 4"); + expect(view.queryByText(/needs your CopilotKit account/)).toBeNull(); + await waitFor(() => expect(localSequence()).toContain("set_up_local_eval")); + expect( + invokeCalls.find((call) => call.command === "set_up_local_eval")?.args, + ).toMatchObject({ + root: "/tmp/local-eval-account", + acceptedEulaVersion: "developer-edition-2026-10-01-web", + }); + expectPlain(view); +}); + +test("a first run already signed in with the agreement accepted passes the account step by itself", async () => { + setupRootConfiguration("/tmp/local-eval-account-done", async () => + emptyConfiguration(), + ); + withLocalCli({ + set_up_local_eval: localStatus("starting", "valid"), + connect_local_eval: localConnection, + run_local_eval: localStatus("ready", "valid"), + }); + const view = await renderApp(); + await installLocal(view); + await view.findByRole("heading", { name: "Connect your AI" }); + expect( + invokeCalls.some((call) => call.command === "sign_in_to_copilotkit"), + ).toBe(false); + await waitFor(() => expect(localSequence()).toContain("set_up_local_eval")); + expect( + invokeCalls.find((call) => call.command === "set_up_local_eval")?.args, + ).toMatchObject({ acceptedEulaVersion: null }); +}); + test("the local sequence starts when installation completes and survives the provider screen", async () => { setupRootConfiguration("/tmp/local-eval-overlap", async () => emptyConfiguration(), @@ -712,7 +795,7 @@ test("the local sequence starts when installation completes and survives the pro expect(localCliCalls()).toEqual([]); await act(async () => preparation.resolve()); await view.findByRole("heading", { name: "Connect your AI" }); - expect(stepLabel(view)).toBe("Step 2 of 3"); + expect(stepLabel(view)).toBe("Step 3 of 4"); await waitFor(() => expect(localSequence()).toEqual(["set_up_local_eval"])); expect( invokeCalls.find((call) => call.command === "set_up_local_eval")?.args, @@ -751,13 +834,13 @@ test("Continue during setup shows Getting ready, which starts, tests and opens O await installLocal(view); await chooseOpenAi(view); await view.findByRole("heading", { name: "Getting ready" }); - expect(stepLabel(view)).toBe("Step 3 of 3"); + expect(stepLabel(view)).toBe("Step 4 of 4"); 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(stepLabel(view)).toBe("Step 4 of 4"); expect(localSequence()).toEqual([ "set_up_local_eval", "connect_local_eval", diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 84312accca..79ee6c1307 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useRef, useState } from "react"; import { Ask } from "./Ask"; +import { CopilotKitAccount } from "./CopilotKitAccount"; import { DatabaseReset } from "./DatabaseReset"; import { useWindowFitsContent } from "./fitWindow"; import { @@ -719,8 +720,8 @@ function Setup() { setStep("install"); if (await install(choice)) { setSteps([]); - setStep("model"); - local.prepare(); + // The licensed part waits for the CopilotKit account and its agreement: see `account`. + setStep("account"); } } @@ -1057,7 +1058,7 @@ function Setup() {

      - {localFlow ? "Step 1 of 3" : "Step 2 of 4"} + {localFlow ? "Step 1 of 4" : "Step 2 of 4"}

      {installationReady @@ -1164,7 +1165,7 @@ function Setup() {
      invoke("ask_the_bot", { root, question }) } @@ -1186,6 +1187,27 @@ function Setup() { ); } + /* + * A local first run signs in to CopilotKit and accepts the evaluation agreement as a step of its + * own, after the software is installed and before anything licensed is set up. Intelligence then + * sets itself up behind the AI provider screen, with the version accepted here. + */ + if (!running && step === "account") { + return ( +
      + { + if (acceptedVersion) local.acceptTerms(acceptedVersion); + setStep("model"); + local.prepare(); + }} + /> + {displayedFailure && } +
      + ); + } + if (!running && step === "model") { return (
      @@ -1195,7 +1217,7 @@ function Setup() { chosen={model} returning={returningToInstallation} busy={busy} - stepLabel={localFlow ? "Step 2 of 3" : undefined} + stepLabel={localFlow ? "Step 3 of 4" : undefined} status={localFlow && } onChoose={(choice) => { recordSetupEvent(modelChoiceEvent(choice)); @@ -1330,11 +1352,7 @@ function Setup() { onReset={resetLeftoverDatabase} /> )} - {!returningToInstallation && ( -

      - {localFlow ? "Step 3 of 3" : "Step 4 of 4"} -

      - )} + {!returningToInstallation &&

      Step 4 of 4

      }

      Getting ready

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

      +

      {stepLabel}

      +

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

      +

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

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

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

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

      {seen.said}

      } + +
      + {seen.at === "signed-out" && ( + + )} + {agreement && ( + + )} + {seen.at === "failed" && ( + + )} +
      +
      + ); +} diff --git a/desktop/src/LocalIntelligence.tsx b/desktop/src/LocalIntelligence.tsx index 75941e1531..c543e9c6cf 100644 --- a/desktop/src/LocalIntelligence.tsx +++ b/desktop/src/LocalIntelligence.tsx @@ -494,7 +494,6 @@ export function LocalStatus({ sequence }: { sequence: LocalSequence }) { ? (sequence.blocked?.said ?? sequence.problem?.said) : `${current.step}: complete.`}

      - ); } diff --git a/desktop/src/telemetry.ts b/desktop/src/telemetry.ts index 3b6e98fbe9..0ace94a36c 100644 --- a/desktop/src/telemetry.ts +++ b/desktop/src/telemetry.ts @@ -5,6 +5,7 @@ export type SetupStep = | "welcome" | "harness" | "install" + | "account" | "model" | "connect" | "ask"; From ca7ce1a5d725ec348c4c15d11396cbf91eb321d2 Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 22:31:35 -0700 Subject: [PATCH 29/77] fix(desktop): a quit from the Dock, AppleScript or logout stops OpenBot's CLI calls like its own Quit does Quitting OpenBot from the Dock or with an AppleScript quit left a running 'local setup' behind, orphaned with its sign-in worker and an image pull; the next setup was refused as local_command_busy. Two causes. - Tauri on macOS answers a quit macOS starts only in applicationWillTerminate:, which ends the event loop at once, so RunEvent::ExitRequested, where OpenBot's Quit cleanup lives (CLI calls, containers, the Intelligence stack it made), never ran. The app delegate now also answers applicationShouldTerminate:, cancelling macOS's termination and asking for OpenBot's own Quit, whose cleanup then exits. Tauri leaves with stop: and process::exit, so it is not asked again. - close_for_quit only asked calls to cancel. The CLI forwards SIGTERM to its children instead of exiting, so its tree went only at SIGKILL after the grace, and an app that exited sooner left it running. close_for_quit now waits, up to 10 s, until every CLI process tree it started is gone. A CLI that ignores SIGTERM is tested: the test fails without the wait. The menu's Quit and its Cmd-Q already went through the cleanup and are unchanged. --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 5 ++ desktop/src-tauri/src/local_eval.rs | 107 ++++++++++++++++++++++++++++ desktop/src-tauri/src/main.rs | 70 ++++++++++++++++++ 4 files changed, 183 insertions(+) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 75485f07f2..19aad181cb 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2513,6 +2513,7 @@ dependencies = [ "base64 0.22.1", "flate2", "libc", + "objc2", "portable-pty", "rand 0.9.5", "reqwest 0.12.28", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 64b4593161..bafe81ee40 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -40,6 +40,11 @@ tauri-plugin-dialog = "2" [target.'cfg(unix)'.dependencies] libc = "0.2" +# The macOS quit OpenBot did not start (Dock, AppleScript, log out) routed through its own Quit. +# The version Tauri already uses. See `macos_quit` in main.rs. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" + [dev-dependencies] tauri = { version = "2", features = ["test"] } diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs index e6717137c0..d23d6ea06c 100644 --- a/desktop/src-tauri/src/local_eval.rs +++ b/desktop/src-tauri/src/local_eval.rs @@ -1293,6 +1293,8 @@ fn execute( command.process_group(0); } let mut child = command.spawn().map_err(|_| Failure::Spawn)?; + // Until this call has reaped the tree, by its own end or by `stop_tree`. + let _running = Running::start(); // Only the child holds the file for writing now. drop(command); @@ -1497,6 +1499,41 @@ pub fn closing_for_stop() -> Closing { pub fn close_for_quit() { QUITTING.store(true, Ordering::SeqCst); cancel_all(); + // Cancelling only asks: each call stops its CLI's process tree on its own thread, and the CLI + // forwards SIGTERM to its children rather than exiting, so the tree goes at SIGKILL, after the + // grace. An app that exits before then leaves it running with nobody to stop it. + wait_until_stopped(Duration::from_secs(10)); +} + +/// CLI process trees started and not yet stopped or finished, across every call. +static RUNNING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Counts one CLI process tree as running for as long as it is held. +struct Running; + +impl Running { + fn start() -> Self { + RUNNING.fetch_add(1, Ordering::SeqCst); + Self + } +} + +impl Drop for Running { + fn drop(&mut self) { + RUNNING.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Waits, at most `patience`, until no CLI process tree is running. True when none is. +fn wait_until_stopped(patience: Duration) -> bool { + let until = Instant::now() + patience; + while RUNNING.load(Ordering::SeqCst) > 0 { + if Instant::now() >= until { + return false; + } + std::thread::sleep(Duration::from_millis(20)); + } + true } /// An attempt's place in `IN_FLIGHT`, removed however the call ends. @@ -2916,6 +2953,59 @@ mod tests { ); } + #[test] + #[cfg(unix)] + fn quitting_waits_until_a_cli_that_ignores_sigterm_is_gone() { + // Quit used to only ask: the app could exit before the CLI's SIGKILL, and the CLI then ran + // on with nobody to stop it, holding its stack's lock. + if crate::test_support::isolated_process( + "local_eval::tests::quitting_waits_until_a_cli_that_ignores_sigterm_is_gone", + ) { + return; + } + let fixture = Fixture::new("local-eval-quit-stubborn", "stubborn"); + let cli = fixture.cli(); + let attempt = Attempt::register("stubborn-call"); + let cancel = attempt.0.clone(); + let call = std::thread::spawn(move || { + run( + &cli, + Action::Status, + &StackId::parse("local-abc123").unwrap(), + Limits { + timeout: Duration::from_secs(60), + grace: Duration::from_secs(1), + output: MAX_OUTPUT, + }, + &cancel, + ) + }); + Fixture::wait_for_grandchild(&fixture.root); + let pid: libc::pid_t = std::fs::read_to_string(fixture.root.join("pid")) + .unwrap() + .trim() + .parse() + .unwrap(); + let alive = |pid: libc::pid_t| unsafe { libc::kill(pid, 0) } == 0; + assert!(alive(pid)); + + close_for_quit(); + + // Gone when Quit goes on, not some time after. + assert!(!alive(pid), "the CLI outlived close_for_quit"); + let grandchild = fixture.grandchild(); + let until = Instant::now() + Duration::from_secs(2); + while alive(grandchild) && Instant::now() < until { + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + !alive(grandchild), + "the CLI's child outlived close_for_quit" + ); + assert_eq!(call.join().unwrap(), Err(Failure::Cancelled)); + drop(attempt); + } + #[test] fn remembered_cancels_are_bounded() { // Evicting the oldest remembered cancel would race with tests that rely on one. @@ -3080,6 +3170,23 @@ fn main() { } std::thread::sleep(std::time::Duration::from_secs(60)); } + // Like the real CLI, which forwards SIGTERM to its children instead of exiting: only + // SIGKILL stops it. + "stubborn" => { + #[cfg(unix)] + { + extern "C" { + fn signal(signum: i32, handler: usize) -> usize; + } + // SAFETY: SIGTERM (15) set to SIG_IGN (1) before any other thread exists. + unsafe { signal(15, 1) }; + } + std::fs::write(dir.join("pid"), std::process::id().to_string()).unwrap(); + let grandchild = std::process::Command::new(&exe).arg("--grandchild").spawn().unwrap(); + std::fs::write(dir.join("grandchild.tmp"), grandchild.id().to_string()).unwrap(); + std::fs::rename(dir.join("grandchild.tmp"), dir.join("grandchild")).unwrap(); + std::thread::sleep(std::time::Duration::from_secs(60)); + } "flood" => { let chunk = vec![b' '; 65536]; let mut out = std::io::stdout(); diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 586d02a43a..9e6a92e74a 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -5082,6 +5082,74 @@ fn chose(app: &tauri::AppHandle, item: &str) { } } +/** +A quit macOS starts, not OpenBot: Dock > Quit, an AppleScript `quit`, logging out or shutting down. + +Tauri on macOS answers those only in `applicationWillTerminate:`, which ends the event loop at once: +`RunEvent::ExitRequested`, where OpenBot's own Quit stops its CLI calls, its containers and the +Intelligence stack it made, never runs. A local setup quit that way kept running with nobody to +stop it, and the next setup was refused as busy. + +So the app delegate also answers `applicationShouldTerminate:`: it cancels macOS's termination +and asks for OpenBot's own Quit (`app.exit`), whose cleanup then exits. Tauri ends the loop with +`stop:` and `process::exit`, never `terminate:`, so this is not asked again on the way out. A +logout waits for nothing here: macOS reports that OpenBot cancelled it, and logging out again +after OpenBot has closed goes through. +*/ +#[cfg(target_os = "macos")] +mod macos_quit { + use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; + use std::sync::OnceLock; + + static APP: OnceLock = OnceLock::new(); + + /// `NSApplicationTerminateReply`. + const TERMINATE_CANCEL: usize = 0; + const TERMINATE_NOW: usize = 1; + + extern "C-unwind" fn should_terminate(_: *mut AnyObject, _: Sel, _: *mut AnyObject) -> usize { + match APP.get() { + Some(app) => { + app.exit(0); + TERMINATE_CANCEL + } + None => TERMINATE_NOW, + } + } + + /// Adds `applicationShouldTerminate:` to the delegate Tauri installed. Called once, in setup. + pub fn install(app: &tauri::AppHandle) { + if APP.set(app.clone()).is_err() { + return; + } + // SAFETY: on the main thread during setup, after Tauri set the application's delegate. The + // method is added to the delegate's own class, with the signature AppKit calls it with: + // NSUInteger (Q) on self (@), _cmd (:), sender (@). + unsafe { + let application: *mut AnyObject = + objc2::msg_send![objc2::class!(NSApplication), sharedApplication]; + if application.is_null() { + return; + } + let delegate: *mut AnyObject = objc2::msg_send![application, delegate]; + if delegate.is_null() { + return; + } + let class = objc2::ffi::object_getClass(delegate) as *mut AnyClass; + let imp: Imp = std::mem::transmute::< + extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize, + Imp, + >(should_terminate); + objc2::ffi::class_addMethod( + class, + objc2::sel!(applicationShouldTerminate:), + imp, + c"Q@:@".as_ptr(), + ); + } + } +} + /** What About OpenBot says: the version, and which build this is. @@ -5388,6 +5456,8 @@ fn main() { .build(app)?; install_menus(app.handle(), false)?; app.on_menu_event(|app, event| chose(app, event.id().as_ref())); + #[cfg(target_os = "macos")] + macos_quit::install(app.handle()); Ok(()) }) .build(tauri::generate_context!()) From dfc34572b2fb50ff8b5b21eeaeae53d7c094a73b Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 22:53:43 -0700 Subject: [PATCH 30/77] feat(desktop): a local first run starts with the CopilotKit account, and Intelligence sets up alongside the install The order is now: CopilotKit (sign in, accept the agreement), Installing OpenBot, Connect your AI, then Getting ready and Test. The account needs nothing installed, so it comes first. Intelligence starts once the install reports the container engine ready (it runs on the same Docker, which the install starts or installs), not after the whole install, and its status shows on the install screen. Connect your AI follows the install because a plan sign-in runs in the image the install brings; Intelligence carries on behind it. set_up_local_eval creates the installation folder, which may not exist yet when it starts. --- desktop/src-tauri/src/main.rs | 4 ++++ desktop/src/App.test.tsx | 20 +++++++++++++++---- desktop/src/App.tsx | 36 ++++++++++++++++++++++++++--------- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 9e6a92e74a..24fe255a86 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -3918,6 +3918,10 @@ async fn set_up_local_eval( .transpose() .map_err(SetupError::Failure)?; let report = tauri::async_runtime::spawn_blocking(move || { + // A local first run starts setup while OpenBot is still installing into this folder, which + // may not exist yet; the CLI runs in it. + std::fs::create_dir_all(&root) + .map_err(|_| SetupError::Failure(local_eval::Failure::Spawn))?; set_up_local_eval_at(&root, |folder| { local_eval::setup_installed(&attempt, folder, accepted.as_ref()) }) diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 8fa0df6ed8..347d55650f 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -663,12 +663,14 @@ test("Install prepares the default Bot in the default folder, with no Bot or fol expect( view.getByRole("heading", { name: "Installing OpenBot" }), ).toBeTruthy(); - expect(stepLabel(view)).toBe("Step 1 of 4"); + expect(stepLabel(view)).toBe("Step 2 of 4"); expect(view.queryByLabelText("Where OpenBot lives")).toBeNull(); expect(view.queryByText(/Pick a Bot|Which Bot/i)).toBeNull(); expect(invokeCalls.some((call) => call.command === "harnesses")).toBe(false); // Nothing to press, and no way out while it installs. expect(view.queryAllByRole("button")).toEqual([]); + // Intelligence waits for the engine the install starts: nothing local runs yet. + expect(localCliCalls()).toEqual([]); // Native rows are summarised in plain words. await emitProgress({ step: "engine", @@ -704,7 +706,16 @@ test("Install prepares the default Bot in the default folder, with no Bot or fol (event as { event: { kind: string } }).event.kind === "harness_chosen", ), ).toEqual([{ event: { kind: "harness_chosen", harness: "langgraph" } }]); - expect(localCliCalls()).toEqual([]); + // Once the engine answers, Intelligence sets itself up alongside the install, once. + await waitFor(() => + expect( + localCliCalls().filter((call) => call.command === "set_up_local_eval"), + ).toEqual([ + expect.objectContaining({ + args: expect.objectContaining({ root: "/tmp/local-eval-install" }), + }), + ]), + ); }); test("a signed-out first run signs in to CopilotKit and accepts the agreement as its own step, before choosing an AI", async () => { @@ -727,9 +738,10 @@ test("a signed-out first run signs in to CopilotKit and accepts the agreement as }); const view = await renderApp(); await installLocal(view); - // Its own step, after the software and before the AI provider. + // The first step: it needs nothing installed, and nothing is installed before it. await view.findByRole("heading", { name: "Sign in to CopilotKit" }); - expect(stepLabel(view)).toBe("Step 2 of 4"); + expect(stepLabel(view)).toBe("Step 1 of 4"); + expect(installationCalls()).toEqual([]); expect(localSequence()).toEqual([]); await userEvent.click( view.getByRole("button", { name: "Sign in to CopilotKit" }), diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 79ee6c1307..f400442759 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -352,6 +352,23 @@ function Setup() { const installationKey = installationKeyFor(root, harness); const installationReady = preparation?.key === installationKey && preparation.status === "complete"; + /* + * A local first run sets Intelligence up alongside the OpenBot install, not after it: from the + * moment the install reports the container engine ready (Intelligence runs on the same Docker, + * and the install is what starts or installs it), or at once when the install is already done. + * Queued by the CopilotKit account step, which runs first. + */ + const intelligenceQueued = useRef(false); + const engineReady = steps.some( + (row) => row.step === "engine" && row.ok && !row.running, + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: runs when the engine or install changes. + useEffect(() => { + if (!intelligenceQueued.current) return; + if (!engineReady && !installationReady) return; + intelligenceQueued.current = false; + local.prepare(); + }, [engineReady, installationReady]); const returningToInstallation = savedLaunch.current === installationKey; const [running, setRunning] = useState(false); const visibleSetupStep = @@ -720,8 +737,7 @@ function Setup() { setStep("install"); if (await install(choice)) { setSteps([]); - // The licensed part waits for the CopilotKit account and its agreement: see `account`. - setStep("account"); + setStep("model"); } } @@ -1010,7 +1026,8 @@ function Setup() { chooseIntelligence("local"); // The default Bot, recorded as its Continue would. recordSetupEvent(harnessChoiceEvent(DEFAULT_HARNESS)); - void installLocally(); + // The account comes first: it needs nothing installed. + setStep("account"); }, } : undefined @@ -1057,9 +1074,7 @@ function Setup() { return (
      -

      - {localFlow ? "Step 1 of 4" : "Step 2 of 4"} -

      +

      Step 2 of 4

      {installationReady ? "Installation complete" @@ -1103,6 +1118,8 @@ function Setup() { + {/* Intelligence sets itself up alongside, once the install has Docker answering. */} + {localFlow && } {displayedFailure && } {busy && !localFlow && (

      Installing local software…

      @@ -1196,11 +1213,12 @@ function Setup() { return (
      { if (acceptedVersion) local.acceptTerms(acceptedVersion); - setStep("model"); - local.prepare(); + // Intelligence sets itself up once the install has Docker answering; see below. + intelligenceQueued.current = true; + void installLocally(); }} /> {displayedFailure && } From 625ff7638193032bb8a54c8fa35c1b4c0c013aa5 Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 23:07:17 -0700 Subject: [PATCH 31/77] fix(desktop): step 1 shows sign-in when signed out, and a blocked Intelligence offers its action The terms enum serialized with rename_all = "camelCase", so the window got "signedOut" and "organizationName" never arrived. CopilotKitAccount took the unknown status as nothing to accept and passed straight through, and local setup then stopped at authentication-required. The enum now sends the CLI's own words, and an unknown status stops the step instead of passing it. The status line under the install and provider steps had a negative top margin that drew it over the last progress row; a stop the person clears (sign-in, agreement) now shows its button there. The provider list no longer scrolls inside a 17rem box. --- desktop/src-tauri/src/local_eval.rs | 40 ++++++++++++++++++++++++++++- desktop/src/CopilotKitAccount.tsx | 5 +++- desktop/src/LocalIntelligence.tsx | 17 ++++++++++-- desktop/src/styles.css | 11 +++++++- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/local_eval.rs b/desktop/src-tauri/src/local_eval.rs index d23d6ea06c..00b363e09b 100644 --- a/desktop/src-tauri/src/local_eval.rs +++ b/desktop/src-tauri/src/local_eval.rs @@ -475,8 +475,16 @@ pub fn normalize_account_sign_in(exit_code: i32, stdout: &[u8]) -> Result { if (attempt.current !== started.attempt) return; diff --git a/desktop/src/LocalIntelligence.tsx b/desktop/src/LocalIntelligence.tsx index c543e9c6cf..cdad3bcb67 100644 --- a/desktop/src/LocalIntelligence.tsx +++ b/desktop/src/LocalIntelligence.tsx @@ -478,11 +478,15 @@ export function LocalIntelligence({ ); } -/** One line for the screen in front: the current row and how long it has taken. */ +/** + * One line for the screen in front: the current row and how long it has taken. A stop the person + * clears themselves (signing in, accepting the agreement) shows what to do instead of the line. + */ export function LocalStatus({ sequence }: { sequence: LocalSequence }) { const current = sequence.steps.at(-1); const elapsed = useElapsed(sequence.running, sequence.since); if (!current) return null; + if (needsPerson(sequence)) return ; return ( <>

      @@ -507,7 +511,8 @@ export function LocalStatus({ sequence }: { sequence: LocalSequence }) { * Nothing accepts it without the click. */ export function LocalNeeds({ sequence }: { sequence: LocalSequence }) { - const reason = sequence.stopped ? sequence.blocked?.reason : null; + if (!needsPerson(sequence)) return null; + const reason = sequence.blocked?.reason; if (reason === "authentication-required") return ; if (reason === "eula-acceptance-required") @@ -517,6 +522,14 @@ export function LocalNeeds({ sequence }: { sequence: LocalSequence }) { return null; } +function needsPerson(sequence: LocalSequence): boolean { + const reason = sequence.stopped ? sequence.blocked?.reason : null; + return ( + reason === "authentication-required" || + reason === "eula-acceptance-required" + ); +} + function SignInToCopilotKit({ onSignedIn }: { onSignedIn: () => void }) { const [waiting, setWaiting] = useState(false); const [failed, setFailed] = useState(null); diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 86dac55fda..069f91f65d 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -274,7 +274,7 @@ button.another-question:hover:not(:disabled) { font-size: 0.8rem; color: var(--ink-soft); font-variant-numeric: tabular-nums; - margin: -0.75rem 0 1.25rem; + margin: 0 0 1.25rem; } :focus-visible { @@ -326,6 +326,15 @@ fieldset.picker { overflow-y: auto; } +/* + * Every provider shows without scrolling the list: the window grows to fit (see `fitWindow.ts`), + * and past what the screen allows the page scrolls instead. + */ +fieldset.picker.providers { + max-height: none; + overflow-y: visible; +} + /* * A tile carries its own ink, and this is not belt-and-braces. * From 897f70444ba42ef6af3529f0833b13ffa9e00b58 Mon Sep 17 00:00:00 2001 From: Dusty Date: Fri, 23 Oct 2026 03:46:48 -0700 Subject: [PATCH 32/77] fix(desktop): an agreement acceptance setup refuses is said once, not offered again Setup answered eula-acceptance-required to the version it was just given, and the window showed the same Accept again, so each click blinked through a setup run and came back. The CLI refuses an acceptance when it finds a coding agent among its parent processes, which a dev build launched from an agent session is. The window now remembers the version it carried and, when setup refuses that same version, says so with Try again. --- desktop/src/LocalIntelligence.test.tsx | 15 ++++++++++ desktop/src/LocalIntelligence.tsx | 38 ++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/desktop/src/LocalIntelligence.test.tsx b/desktop/src/LocalIntelligence.test.tsx index 6ec8ddcdb0..9f0ac9a3e3 100644 --- a/desktop/src/LocalIntelligence.test.tsx +++ b/desktop/src/LocalIntelligence.test.tsx @@ -305,6 +305,21 @@ test("setup that needs the agreement shows it, and only Accept sets up again wit ).toEqual([null, "developer-edition-2026-10-01-web"]); }); +test("an acceptance setup does not take is said once, not offered again", async () => { + invokeHandler = answers({ + set_up_local_eval: [blockedOnTerms, blockedOnTerms], + local_eval_terms: [requiredTerms, requiredTerms], + cancel_local_eval: [null], + }); + const screen = view(); + await userEvent.click(await screen.findByRole("button", { name: "Accept" })); + await screen.findByText(/Intelligence did not take the acceptance/); + expect(screen.queryByRole("button", { name: "Accept" })).toBeNull(); + expect( + invokeCalls.filter((call) => call.command === "set_up_local_eval"), + ).toHaveLength(2); +}); + test("no build sends the person to a terminal", async () => { for (const [blocked, terms] of [ [blockedOnTerms, requiredTerms], diff --git a/desktop/src/LocalIntelligence.tsx b/desktop/src/LocalIntelligence.tsx index cdad3bcb67..6a484779c8 100644 --- a/desktop/src/LocalIntelligence.tsx +++ b/desktop/src/LocalIntelligence.tsx @@ -89,6 +89,12 @@ type State = { since: number | null; blocked: { said: string; reason: LocalReason | null } | null; problem: Problem | null; + /** + * The agreement version setup was given and still answered "acceptance required" to. The CLI + * refuses an acceptance it believes a coding agent passed on, so offering the same Accept again + * would only loop. + */ + refused: string | null; }; const idle: State = { @@ -97,6 +103,7 @@ const idle: State = { since: null, blocked: null, problem: null, + refused: null, }; export type LocalSequence = State & { @@ -298,12 +305,17 @@ export function useLocalSequence({ if (phase === "start" && !go.current) return stop(); update({ at: phase, running: true, since: Date.now() }); if (phase === "setup") { - const started = setUpLocalEval(root, accepted.current); + const carried = accepted.current; + const started = setUpLocalEval(root, carried); const report = await call( { attempt: started.attempt, answer: started.report }, token, ); if (!report) return stop(); + update({ + refused: + report.reason === "eula-acceptance-required" ? carried : null, + }); if (report.outcome !== "completed") { return stop({ said: said(report), reason: report.reason }); } @@ -517,7 +529,11 @@ export function LocalNeeds({ sequence }: { sequence: LocalSequence }) { return ; if (reason === "eula-acceptance-required") return ( - + ); return null; } @@ -580,9 +596,12 @@ function SignInToCopilotKit({ onSignedIn }: { onSignedIn: () => void }) { } function AcceptTerms({ + refused, onAccept, onRetry, }: { + /** The version setup was already given and did not take. */ + refused: string | null; onAccept: (version: string) => void; onRetry: () => void; }) { @@ -616,6 +635,21 @@ function AcceptTerms({ ); if (terms?.status !== "required") return Getting the evaluation agreement…; + if (terms.version === refused) + return ( +

      +

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

      +
      + +
      +
      + ); return (

      From e2890f14975330fdd81a88af8c2fa35d3ecd36a4 Mon Sep 17 00:00:00 2001 From: Dusty Date: Fri, 23 Oct 2026 03:55:40 -0700 Subject: [PATCH 33/77] feat(desktop): a chosen AI provider stands alone, with Change and Escape back to the list Picking a provider left every other row on screen above its sign-in panel, so the panel landed below the fold. The chosen row now replaces the list, with Change on the row and Escape (outside a text field) to choose again; going back stops that provider's sign-in as switching rows did. --- desktop/src/ProviderPicker.test.tsx | 104 ++++++++++++++++------------ desktop/src/ProviderPicker.tsx | 41 ++++++++++- desktop/src/styles.css | 10 +++ 3 files changed, 110 insertions(+), 45 deletions(-) diff --git a/desktop/src/ProviderPicker.test.tsx b/desktop/src/ProviderPicker.test.tsx index 0c9fdc9d83..b9af4a0c27 100644 --- a/desktop/src/ProviderPicker.test.tsx +++ b/desktop/src/ProviderPicker.test.tsx @@ -1,6 +1,12 @@ import { afterAll, afterEach, beforeAll, expect, mock, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; -import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { HeldConfiguration, @@ -152,6 +158,15 @@ async function renderPickerWithHeld( return view; } +/** Chooses a provider row, going back to the whole list first when one is already chosen. */ +async function pick(view: ReturnType, name: RegExp) { + const change = view.queryByRole("button", { + name: "Choose a different provider", + }); + if (change) await userEvent.click(change); + await userEvent.click(await view.findByRole("radio", { name })); +} + test("a completed plan sign-in enables and submits only its issuing provider", async () => { const choices: unknown[] = []; invokeHandler = async (command, args) => { @@ -166,7 +181,7 @@ test("a completed plan sign-in enables and submits only its issuing provider", a const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await pick(view, /OpenAI/); await userEvent.click( view.getByRole("button", { name: "Sign in with OpenAI" }), ); @@ -181,7 +196,7 @@ test("a completed plan sign-in enables and submits only its issuing provider", a ]), { timeout: 4000 }, ); - await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); + await pick(view, /Anthropic/); expect(view.queryByText(/Signed in to Anthropic/)).toBeNull(); const continueButton = view.getByRole("button", { name: "Continue" }); @@ -200,7 +215,7 @@ test("a finished sign-in shows its tick for a second, and choosing another row t throw new Error(`unexpected command ${command}`); }; const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await pick(view, /OpenAI/); await userEvent.click( view.getByRole("button", { name: "Sign in with OpenAI" }), ); @@ -208,7 +223,7 @@ test("a finished sign-in shows its tick for a second, and choosing another row t expect(tick.closest("p")?.textContent).toContain("Signed in to OpenAI."); expect(choices).toEqual([]); // Another row inside the second: nothing moves on. - await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); + await pick(view, /Anthropic/); await act(async () => new Promise((resolve) => setTimeout(resolve, 1300))); expect(choices).toEqual([]); expect(view.queryByText(/✓/)).toBeNull(); @@ -228,7 +243,7 @@ test("a pending plan sign-in completion is ignored after switching provider rows const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await pick(view, /OpenAI/); await userEvent.click( view.getByRole("button", { name: "Sign in with OpenAI" }), ); @@ -241,7 +256,7 @@ test("a pending plan sign-in completion is ignored after switching provider rows const waiting = view.getByRole("status"); expect(waiting.textContent).toBe("Waiting for you to approve it…"); expect(waiting.querySelector(".step-spinner")).not.toBeNull(); - await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); + await pick(view, /Anthropic/); await act(async () => { chatgpt.resolve("chatgpt-token"); @@ -276,7 +291,7 @@ test("a saved provider-scoped plan session enables continue without exposing a t (choice) => choices.push(choice), ); - await userEvent.click(await view.findByRole("radio", { name: /OpenAI/ })); + await pick(view, /OpenAI/); expect(view.getByText(/A saved OpenAI sign-in will be checked/)).toBeTruthy(); await userEvent.click(view.getByRole("button", { name: "Continue" })); @@ -312,9 +327,7 @@ for (const provider of providers) { (choice) => choices.push(choice), ); - await userEvent.click( - await view.findByRole("radio", { name: new RegExp(provider.name) }), - ); + await pick(view, new RegExp(provider.name)); await userEvent.click(view.getByRole("tab", { name: "Use an API key" })); await userEvent.type( view.getByLabelText(`${provider.name} API key`), @@ -371,9 +384,7 @@ for (const provider of providers) { throw new Error(`unexpected command ${command}`); }; const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: new RegExp(provider.name) }), - ); + await pick(view, new RegExp(provider.name)); await userEvent.click(view.getByRole("tab", { name: "Use an API key" })); await userEvent.type( view.getByLabelText(`${provider.name} API key`), @@ -411,9 +422,7 @@ test("a compatible endpoint does not inherit a saved OpenAI API key", async () = (choice) => choices.push(choice), ); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type( view.getByLabelText("Base URL"), "https://models.example/v1", @@ -451,9 +460,7 @@ test("a compatible endpoint submits the key typed into its endpoint key field", (choice) => choices.push(choice), ); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type( view.getByLabelText("Base URL"), "https://models.example/v1", @@ -483,9 +490,7 @@ test("a compatible endpoint carries an optional container-only URL", async () => }; const view = await renderPickerWithHeld({}, (choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type( view.getByLabelText("Base URL"), "http://127.0.0.1:11434/v1", @@ -516,9 +521,7 @@ test("a compatible endpoint uses the host URL for containers when no override is }; const view = await renderPickerWithHeld({}, (choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type( view.getByLabelText("Base URL"), "https://models.example/v1", @@ -549,9 +552,7 @@ test("a compatible endpoint refuses an invalid container-only URL", async () => }; const view = await renderPickerWithHeld({}, (choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type( view.getByLabelText("Base URL"), "http://127.0.0.1:11434/v1", @@ -582,9 +583,7 @@ test.each([ }; const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type(view.getByLabelText("Base URL"), baseUrl); await userEvent.type(view.getByLabelText("Model name"), "local-model"); @@ -606,9 +605,7 @@ test("a compatible endpoint accepts local http and external https URLs", async ( "https://models.example/v1", ]) { const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: /OpenAI-compatible/ }), - ); + await pick(view, /OpenAI-compatible/); await userEvent.type(view.getByLabelText("Base URL"), baseUrl); await userEvent.type(view.getByLabelText("Model name"), "local-model"); @@ -643,9 +640,7 @@ for (const provider of providers) { throw new Error(`unexpected protected command ${command}`); }; const view = await renderPicker((choice) => choices.push(choice)); - await userEvent.click( - await view.findByRole("radio", { name: new RegExp(provider.name) }), - ); + await pick(view, new RegExp(provider.name)); if (login === "api-key") await userEvent.click( view.getByRole("tab", { name: "Use an API key" }), @@ -670,9 +665,7 @@ for (const provider of providers) { await userEvent.click(view.getByRole("button", { name: "Continue" })); expect(choices).toEqual([{ provider: provider.id, login, saved: true }]); const other = providers.find((item) => item.id !== provider.id)!; - await userEvent.click( - view.getByRole("radio", { name: new RegExp(other.name) }), - ); + await pick(view, new RegExp(other.name)); expect(view.getByRole("button", { name: "Continue" })).toHaveProperty( "disabled", true, @@ -1104,9 +1097,7 @@ for (const provider of cloudProviders) { const other = cloudProviders.find( (candidate) => candidate.id !== provider.id, )!; - await userEvent.click( - view.getByRole("radio", { name: new RegExp(other.name) }), - ); + await pick(view, new RegExp(other.name)); expect(view.getByLabelText(`${other.name} API key`)).toHaveProperty( "value", "", @@ -1130,3 +1121,28 @@ for (const provider of cloudProviders) { }); }); } + +test("a chosen provider stands alone, and Change or Escape shows them all again", async () => { + invokeHandler = async (command) => { + if (command === "providers") return providers; + return null; + }; + const view = await renderPicker(); + const everyRow = (await view.findAllByRole("radio")).length; + expect(everyRow).toBeGreaterThan(1); + + await userEvent.click(view.getByRole("radio", { name: /Anthropic/ })); + expect(view.getAllByRole("radio")).toHaveLength(1); + await userEvent.click( + view.getByRole("button", { name: "Choose a different provider" }), + ); + expect(view.getAllByRole("radio")).toHaveLength(everyRow); + expect( + view.queryByRole("button", { name: "Choose a different provider" }), + ).toBeNull(); + + await userEvent.click(view.getByRole("radio", { name: /OpenAI$|^OpenAI/ })); + expect(view.getAllByRole("radio")).toHaveLength(1); + fireEvent.keyDown(window, { key: "Escape" }); + expect(view.getAllByRole("radio")).toHaveLength(everyRow); +}); diff --git a/desktop/src/ProviderPicker.tsx b/desktop/src/ProviderPicker.tsx index deddd798f2..78a838234f 100644 --- a/desktop/src/ProviderPicker.tsx +++ b/desktop/src/ProviderPicker.tsx @@ -517,6 +517,34 @@ export function ProviderPicker({ return () => clearTimeout(timer); }, [confirmed, open]); + /** Back to every provider: the chosen one's sign-in, if any, stops. */ + function chooseAnother() { + if (oauthAttempt.current) void cancelOAuth(); + signInRunRef.current += 1; + setOpen(null); + setFailure(null); + setSignInUrl(null); + setCode(""); + setBusy(false); + setProgress(null); + setLogin(null); + } + const chooseAnotherLatest = useRef(chooseAnother); + chooseAnotherLatest.current = chooseAnother; + // Escape goes back too, except while typing, where it would throw away a half-typed key. + const anyChosen = row !== null; + useEffect(() => { + if (!anyChosen) return; + const onKey = (event: KeyboardEvent) => { + const typing = + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement; + if (event.key === "Escape" && !typing) chooseAnotherLatest.current(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [anyChosen]); + return (

      {!returning &&

      {stepLabel}

      } @@ -528,7 +556,8 @@ export function ProviderPicker({
      Model provider - {rows.map((r) => ( + {/* Once one is chosen it stands alone, with Change to see them all again. */} + {(row ? [row] : rows).map((r) => ( ))} + {row && ( + + )}
      {row && ( diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 069f91f65d..f5ab865761 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -333,6 +333,16 @@ fieldset.picker { fieldset.picker.providers { max-height: none; overflow-y: visible; + position: relative; +} + +/* Sits on the one chosen row, so going back is where the eye already is. */ +button.text-link.change-provider { + position: absolute; + top: 50%; + right: 1rem; + transform: translateY(-50%); + margin: 0; } /* From 8312676a219a03cf866978790b193cc65a5f3d33 Mon Sep 17 00:00:00 2001 From: Dusty Date: Fri, 23 Oct 2026 03:59:33 -0700 Subject: [PATCH 34/77] feat(desktop): OpenBot installs from the welcome click, while the person signs in The install needs no CopilotKit account; only Intelligence waits for the evaluation agreement. The install now starts with the account step and runs behind it, and Done goes straight to the AI provider screen when it has finished, or to its progress when it has not. Intelligence starts at Done when Docker already answered during sign-in. --- desktop/src/App.test.tsx | 15 +++++++++++++-- desktop/src/App.tsx | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 347d55650f..8424da9f36 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -738,10 +738,15 @@ test("a signed-out first run signs in to CopilotKit and accepts the agreement as }); const view = await renderApp(); await installLocal(view); - // The first step: it needs nothing installed, and nothing is installed before it. + // The first step. OpenBot needs no account, so it installs behind it, exactly once; nothing + // of Intelligence's runs before the agreement. await view.findByRole("heading", { name: "Sign in to CopilotKit" }); expect(stepLabel(view)).toBe("Step 1 of 4"); - expect(installationCalls()).toEqual([]); + await waitFor(() => + expect(installationCalls().map((call) => call.command)).toEqual([ + "prepare_installation", + ]), + ); expect(localSequence()).toEqual([]); await userEvent.click( view.getByRole("button", { name: "Sign in to CopilotKit" }), @@ -759,6 +764,12 @@ test("a signed-out first run signs in to CopilotKit and accepts the agreement as await view.findByRole("heading", { name: "Connect your AI" }); expect(stepLabel(view)).toBe("Step 3 of 4"); expect(view.queryByText(/needs your CopilotKit account/)).toBeNull(); + // Leaving the account step does not install a second time. + expect( + installationCalls().filter( + (call) => call.command === "prepare_installation", + ), + ).toHaveLength(1); await waitFor(() => expect(localSequence()).toContain("set_up_local_eval")); expect( invokeCalls.find((call) => call.command === "set_up_local_eval")?.args, diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index f400442759..9de58dc5a7 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -728,14 +728,28 @@ function Setup() { /* * 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. + * one installation per computer. It needs no CopilotKit account, so it runs while the person + * signs in; only Intelligence waits for the agreement. Once it completes, Intelligence sets up + * behind the AI provider screen rather than after it. */ - async function installLocally() { + const localInstall = useRef | null>(null); + function beginLocalInstall(): Promise { + if (localInstall.current) return localInstall.current; const choice = { id: DEFAULT_HARNESS }; setHarness(choice); + const started = install(choice).then((ok) => { + // A failed install is started again by the next Install, not remembered. + if (!ok) localInstall.current = null; + return ok; + }); + localInstall.current = started; + return started; + } + + /** Shows the install until it completes, then the AI provider screen. */ + async function installLocally() { setStep("install"); - if (await install(choice)) { + if (await beginLocalInstall()) { setSteps([]); setStep("model"); } @@ -1026,8 +1040,9 @@ function Setup() { chooseIntelligence("local"); // The default Bot, recorded as its Continue would. recordSetupEvent(harnessChoiceEvent(DEFAULT_HARNESS)); - // The account comes first: it needs nothing installed. + // The account is the first screen; OpenBot installs behind it meanwhile. setStep("account"); + void beginLocalInstall(); }, } : undefined @@ -1216,9 +1231,14 @@ function Setup() { stepLabel="Step 1 of 4" onDone={(acceptedVersion) => { if (acceptedVersion) local.acceptTerms(acceptedVersion); - // Intelligence sets itself up once the install has Docker answering; see below. - intelligenceQueued.current = true; - void installLocally(); + // Intelligence sets itself up once the install has Docker answering; see below. The + // install may have got there during sign-in, and that change has already been seen. + if (engineReady || installationReady) local.prepare(); + else intelligenceQueued.current = true; + if (installationReady) { + setSteps([]); + setStep("model"); + } else void installLocally(); }} /> {displayedFailure && } From 945e81600ad789f69a6f2369bbc5d0f283dd2dfe Mon Sep 17 00:00:00 2001 From: Dusty Date: Fri, 23 Oct 2026 04:09:09 -0700 Subject: [PATCH 35/77] feat(desktop): the setup window in the CopilotKit visual system Lavender-gray ground with the six blurred colour fields, one frosted-glass card per screen with the white edge, Plus Jakarta Sans for reading and Spline Sans Mono uppercase for buttons, pills and status, the 4px grid and the 4/8/full radius scale, lilac focus and selection, and the CopilotKit logotype on the account step. Both faces are bundled with pinned @fontsource-variable packages so the window never needs the network to draw. The macOS title bar takes the setup ground and goes back to the system's when the window becomes OpenBot. --- desktop/bun.lock | 6 + desktop/package.json | 2 + desktop/src-tauri/src/main.rs | 31 +- desktop/src-tauri/tauri.conf.json | 4 +- desktop/src/CopilotKitAccount.tsx | 2 + desktop/src/assets/copilotkit-logo.svg | 46 ++ desktop/src/main.tsx | 11 + desktop/src/styles.css | 728 ++++++++++++++++--------- 8 files changed, 567 insertions(+), 263 deletions(-) create mode 100644 desktop/src/assets/copilotkit-logo.svg diff --git a/desktop/bun.lock b/desktop/bun.lock index ca0195321a..d91546ef3c 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -5,6 +5,8 @@ "": { "name": "openbot-desktop", "dependencies": { + "@fontsource-variable/plus-jakarta-sans": "5.3.0", + "@fontsource-variable/spline-sans-mono": "5.3.0", "@tauri-apps/api": "^2", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -116,6 +118,10 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@fontsource-variable/plus-jakarta-sans": ["@fontsource-variable/plus-jakarta-sans@5.3.0", "", {}, "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw=="], + + "@fontsource-variable/spline-sans-mono": ["@fontsource-variable/spline-sans-mono@5.3.0", "", {}, "sha512-dH/qBj/MCE4ObiuCwlA9X1o3AftMO9dwfFgoRVYjy6VCPYkpWAlEtK/RjeyICgsR8rW763IAQARskVgShBY5JA=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.14.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.14.0" } }, "sha512-Xn0kdkaA1eHOaFFopr+YQ37sPNTZ3JScpUMz3j4rIsn1Nneqoh94z79iW23Ej0Ytt99+LlFr4JrSDd6ow9c7MQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], diff --git a/desktop/package.json b/desktop/package.json index 216f1060ab..809b6866ff 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -9,6 +9,8 @@ "tauri": "tauri" }, "dependencies": { + "@fontsource-variable/plus-jakarta-sans": "5.3.0", + "@fontsource-variable/spline-sans-mono": "5.3.0", "@tauri-apps/api": "^2", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 24fe255a86..d7790e75e3 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -3048,6 +3048,30 @@ fn show_setup(app: tauri::AppHandle) -> Result<(), String> .map_err(|error| format!("could not go back to setup: {error}")) } +/// The setup screens' background, which the macOS title bar takes while they show. +const SETUP_BACKGROUND: tauri::window::Color = tauri::window::Color(0xde, 0xde, 0xe9, 0xff); + +/** +The title bar is the setup screen's own colour on setup, and the system's on OpenBot. + +The window starts transparent-titled over the setup background (`tauri.conf.json`), so the first +paint already matches. The same window then navigates to OpenBot, which keeps its own light and +dark, and a lavender title bar over it would be a seam; it gets the system's bar back. +*/ +fn match_title_bar(window: &tauri::Window, on_setup: bool) { + #[cfg(target_os = "macos")] + { + let _ = window.set_title_bar_style(if on_setup { + tauri::TitleBarStyle::Transparent + } else { + tauri::TitleBarStyle::Visible + }); + let _ = window.set_background_color(on_setup.then_some(SETUP_BACKGROUND)); + } + #[cfg(not(target_os = "macos"))] + let _ = (window, on_setup); +} + /// Both pages are single-page apps. Keep their route and in-memory state on restore. /// Compare the authority explicitly: tauri:// has an opaque URL origin. fn same_window_app(current: &tauri::Url, destination: &tauri::Url) -> bool { @@ -5417,9 +5441,14 @@ fn main() { // macOS never showed this because Tauri suppresses it there in release builds; Windows has // no such setting, and Tauri has no configuration option for it either, so the page is // asked to refuse. Every navigation, because the window navigates to OpenBot and back. - .on_page_load(|window, _| { + .on_page_load(|window, payload| { let _ = window .eval("document.addEventListener('contextmenu', e => e.preventDefault(), true)"); + let setup = window.app_handle().state::().setup_url.lock().unwrap().clone(); + let on_setup = setup + .and_then(|setup| setup.parse::().ok()) + .is_some_and(|setup| same_window_app(&setup, payload.url())); + match_title_bar(&window.window(), on_setup); }) // Closing the window hides it. A tray application whose window is destroyed on close has a // menu item that points at nothing: `get_webview_window` returns None from then on, and the diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 25d8f97f7d..fe5e483269 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -17,7 +17,9 @@ "height": 800, "minWidth": 720, "minHeight": 560, - "resizable": true + "resizable": true, + "titleBarStyle": "Transparent", + "backgroundColor": "#dedee9" } ], "security": { "csp": null } diff --git a/desktop/src/CopilotKitAccount.tsx b/desktop/src/CopilotKitAccount.tsx index f55ac719c1..22f3a5a8f8 100644 --- a/desktop/src/CopilotKitAccount.tsx +++ b/desktop/src/CopilotKitAccount.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import copilotKitLogo from "./assets/copilotkit-logo.svg"; import { ExternalLink } from "./ExternalLink"; import { cancelLocalEval, @@ -111,6 +112,7 @@ export function CopilotKitAccount({ const agreement = seen.at === "agreement" ? seen.terms : null; return (
      + CopilotKit

      {stepLabel}

      {agreement diff --git a/desktop/src/assets/copilotkit-logo.svg b/desktop/src/assets/copilotkit-logo.svg new file mode 100644 index 0000000000..646e344a02 --- /dev/null +++ b/desktop/src/assets/copilotkit-logo.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 93731fd49e..8571beef84 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -2,6 +2,8 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; import { isShuttingDownPage, ShuttingDownWindow } from "./ShuttingDown"; +import "@fontsource-variable/plus-jakarta-sans"; +import "@fontsource-variable/spline-sans-mono"; import "./styles.css"; const root = document.getElementById("root"); @@ -11,6 +13,15 @@ if (!root) // Sent here by Quit: only the Shutting down screen, so none of setup's launch checks run again. createRoot(root).render( + {/* The six blurred colour fields every screen sits on; see `.backdrop` in styles.css. */} + {isShuttingDownPage(window.location.hash) ? ( ) : ( diff --git a/desktop/src/styles.css b/desktop/src/styles.css index f5ab865761..b45aa7ae5e 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -1,88 +1,178 @@ /* - * OpenBot Desktop: the setup window. + * OpenBot Desktop: the setup window, in the CopilotKit visual system. * - * The world is the one the product sits next to: warm off-white ground, near-black text, almost no - * chrome, and one rounded focal element per screen. Measured from Grok rather than guessed — the - * ground is a warm #F9F8F7 and the hairlines are a warm-tinted black at 4-8% rather than a cool - * gray, which is most of why that interface reads calm instead of clinical. + * Values come from the CopilotKit UI theme and brand rules (the CopilotCloud design tokens, also + * used by the AG-UI dojo) and are applied as given rather than approximated: the cool lavender-gray + * ground with six blurred colour fields behind it, one frosted-glass card per screen with a white + * edge, Plus Jakarta Sans for reading, Spline Sans Mono uppercase for buttons and technical status, + * and a 4px spacing grid. Where a value is not a named token it is marked an implementation default. * - * `universalSans` is deliberately absent: it is xAI's own face and not ours to ship. Inter is the - * documented fallback and the system stack is what a desktop window can rely on with no network at - * the moment it draws. + * Both faces are bundled (`@fontsource-variable`), not fetched: the window draws before anything + * guarantees a network, and a first-run screen that falls back to the system face is not on brand. * - * Light is committed rather than defaulted. The use scene is a corporate laptop in an office in the - * daytime, and this window is the first thing somebody sees after a download. + * Light only, as before: this window exists for a few minutes, once, and then becomes OpenBot, + * which keeps its own light and dark. The macOS title bar takes this ground (see `match_title_bar`). */ :root { - /* Warm, not neutral. Every gray here is tinted from the same 15/13/10 black. */ - --ground: #f9f8f7; - --raised: #ffffff; - --ink: #050505; - --ink-soft: rgba(15, 13, 10, 0.62); - --ink-faint: rgba(15, 13, 10, 0.45); - --hair: rgba(15, 13, 10, 0.08); - --hair-soft: rgba(15, 13, 10, 0.04); - --sunk: rgba(15, 13, 10, 0.04); - --accent: #050505; - --accent-ink: #ffffff; - --bad: #b3261e; - --good: #1a7f37; - - /* One radius family: 12px for rows and controls, 16px for the focal card. */ - --r-row: 12px; - --r-card: 16px; - - font-family: - Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, ui-sans-serif, - system-ui, sans-serif; - /* 16px rather than 14px since the window grew to 1000x800: everything below is in rem, so the - type, the column and the spacing grow together and the room does not read as empty. */ + /* Surfaces */ + --surface-main: #dedee9; + --surface-container: #ffffff; + --surface-hovered: #fafcfa; + --white-50: rgba(255, 255, 255, 0.5); + --white-65: rgba(255, 255, 255, 0.65); + --white-70: rgba(255, 255, 255, 0.7); + --active-tint: #bec2ff1a; + + /* Borders */ + --border-default: #ffffff; + --border-container: #dbdbe5; + --border-action: #bec2ff; + + /* Text */ + --text-primary: #010507; + --text-secondary: #57575b; + --text-disabled: #838389; + --text-invert: #ffffff; + + /* Greys */ + --grey-200: #f0f0f4; + --grey-600: #afafb7; + --grey-900: #2b2b2b; + --ink-5: #0105070d; + + /* State */ + --good: #189370; + --bad: #fa5f67; + --lilac-30: #bec2ff4d; + + /* Radius: 4px items, 8px cards and controls, full for pills. */ + --r-item: 4px; + --r-card: 8px; + --r-full: 9999px; + + --elevation-sm: 0px 1px 3px 0px rgba(1, 5, 7, 0.08); + --elevation-md: 0px 6px 6px -2px rgba(1, 5, 7, 0.08); + + --sans: "Plus Jakarta Sans Variable", "Plus Jakarta Sans", -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --mono: "Spline Sans Mono Variable", "Spline Sans Mono", ui-monospace, + SFMono-Regular, Menlo, monospace; + + /* The names the screens were written against, kept so every rule reads one vocabulary. */ + --ink: var(--text-primary); + --ink-soft: var(--text-secondary); + --ink-faint: var(--text-disabled); + --hair: var(--border-container); + --raised: var(--surface-container); + --sunk: var(--ink-5); + --accent: var(--text-primary); + --accent-ink: var(--text-invert); + --r-row: var(--r-card); + + font-family: var(--sans); font-size: 16px; line-height: 1.5; - color: var(--ink); - background: var(--ground); + color: var(--text-primary); + background: var(--surface-main); -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } -/* - * NO DARK VARIANT, DELIBERATELY, and this is the one place in the product where that is right. - * - * This window exists for about five minutes, once, before it becomes OpenBot and hands over to the - * app — which does follow the system. A first-run screen is the product's handshake, and the look - * committed here is the warm light one measured from the interface this is meant to sit beside. - * Following the system instead would mean the first thing half of all installers ever see is a - * variant nobody composed against a reference. - * - * The app this hands over to keeps its own light and dark. Only the setup window is pinned. - */ * { box-sizing: border-box; } body { margin: 0; - background: var(--ground); - color: var(--ink); + background: var(--surface-main); + color: var(--text-primary); } /* - * The window opens at 1000x800 and grows to fit a taller step (see fitWindow.ts), so it is composed - * as a room rather than a page: one centred column, vertically settled, with the same optical centre - * on every step. A wizard whose content jumps position between steps reads as four different screens. + * The six blurred colour fields behind everything: the signature depth cue, without which the + * ground reads flat. Positions and sizes are the CopilotCloud frame's (1440 wide), scaled to the + * window so they sit in the same places at 1000 wide; colours and blur are as given. + */ +.backdrop { + position: fixed; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; +} + +.backdrop span { + position: absolute; + border-radius: 50%; + filter: blur(103px); +} + +.backdrop span:nth-child(1) { + width: 31vw; + height: 31vw; + left: 72.2vw; + top: 0.8vw; + background: rgba(255, 172, 77, 0.2); +} +.backdrop span:nth-child(2) { + width: 42.3vw; + height: 42.3vw; + left: 93vw; + top: 43.4vw; + background: #c9c9da; +} +.backdrop span:nth-child(3) { + width: 42.3vw; + height: 42.3vw; + left: 46.5vw; + top: -25.3vw; + background: #c9c9da; +} +.backdrop span:nth-child(4) { + width: 42.3vw; + height: 42.3vw; + left: 35.3vw; + top: 48.8vw; + background: #f3f3fc; +} +.backdrop span:nth-child(5) { + width: 31vw; + height: 31vw; + left: 8.9vw; + top: 23vw; + background: rgba(255, 243, 136, 0.3); +} +.backdrop span:nth-child(6) { + width: 31vw; + height: 31vw; + left: -14.2vw; + top: 55.8vw; + background: rgba(255, 172, 77, 0.2); +} + +/* + * The window opens at 1000x800 and grows to fit a taller step (see fitWindow.ts): one centred card, + * vertically settled, with the same optical centre on every step. */ main { + position: relative; + z-index: 1; min-height: 100vh; display: grid; place-items: center; - padding: 2.5rem 2rem; + padding: 32px 24px; } +/* The one focal surface per screen: frosted glass with the white edge. */ .sheet { width: 100%; - max-width: 30rem; + max-width: 36rem; display: flex; flex-direction: column; + padding: 40px; + background: var(--white-50); + border: 2px solid var(--border-default); + border-radius: var(--r-card); /* One authored entrance, on the step rather than on every element. */ animation: settle 0.42s cubic-bezier(0.16, 1, 0.3, 1) both; } @@ -100,55 +190,69 @@ main { } } -/* Where you are, stated quietly. Operate mode: a four-step flow that says nothing about progress - makes people wonder how much is left, which is when they close it. */ +/* Where you are: a technical label in mono, with the line the theme's section titles carry. */ .steps-of { - font-size: 0.72rem; - letter-spacing: 0.06em; + display: flex; + align-items: center; + gap: 8px; + font-family: var(--mono); + font-size: 12px; + line-height: 12px; + font-weight: 500; + letter-spacing: 0.04em; text-transform: uppercase; - color: var(--ink-faint); - margin: 0 0 1.25rem; + color: var(--text-secondary); + margin: 0 0 24px; +} + +.steps-of::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border-container); } h1 { - font-size: 1.5rem; - line-height: 1.2; - font-weight: 600; - letter-spacing: -0.02em; - margin: 0 0 0.6rem; + font-size: 32px; + line-height: 36px; + font-weight: 500; + margin: 0 0 12px; text-wrap: balance; } h2 { - font-size: 0.95rem; + font-size: 18px; + line-height: 20px; font-weight: 600; - margin: 0 0 0.35rem; + margin: 0 0 8px; } .lede { - color: var(--ink-soft); - margin: 0 0 1.5rem; + font-size: 16px; + line-height: 24px; + color: var(--text-secondary); + margin: 0 0 24px; max-width: 34rem; } .lede.big { - font-size: 0.95rem; - line-height: 1.6; + font-size: 20px; + line-height: 28px; } .footnote { - font-size: 0.8rem; - color: var(--ink-faint); - line-height: 1.55; - margin: 1.25rem 0 0; + font-size: 14px; + line-height: 22px; + color: var(--text-secondary); + margin: 20px 0 0; } /* Controls */ .row { display: flex; - gap: 0.6rem; + gap: 8px; align-items: center; - margin-top: 1.5rem; + margin-top: 24px; } .connection-sheet .row { @@ -168,7 +272,7 @@ h2 { } .new-project-form { - margin-top: 1rem; + margin-top: 16px; } .tile.project-choice { @@ -186,63 +290,81 @@ h2 { "summary"; } +/* Buttons are mono and uppercase, as the brand sets controls and pills. */ button { - font: inherit; + font-family: var(--mono); + font-size: 14px; + line-height: 14px; font-weight: 500; - padding: 0.6rem 1.1rem; - border-radius: var(--r-row); + letter-spacing: 0.04em; + text-transform: uppercase; + min-height: 44px; + padding: 0 20px; + border-radius: var(--r-card); border: 1px solid transparent; - background: var(--accent); - color: var(--accent-ink); + background: var(--text-primary); + color: var(--text-invert); cursor: pointer; transition: - opacity 0.15s ease, - background 0.15s ease; + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; } button:hover:not(:disabled) { - opacity: 0.88; + background: var(--grey-900); } button:disabled { - opacity: 0.35; + background: var(--grey-600); + color: var(--text-invert); cursor: default; } button.quiet { - background: transparent; - color: var(--ink-soft); - border-color: var(--hair); + background: var(--surface-container); + color: var(--text-primary); + border-color: var(--border-container); } button.quiet:hover:not(:disabled) { - color: var(--ink); - border-color: var(--ink-faint); - opacity: 1; + background: var(--surface-hovered); + border-color: var(--grey-600); +} + +button.quiet:disabled, +button.text-link:disabled, +button.another-question:disabled { + opacity: 0.5; } /* A way out that reads as a sentence, not as a second choice of equal weight. */ button.text-link { + font-family: var(--sans); + font-size: 14px; + line-height: 22px; + font-weight: 400; + letter-spacing: 0; + text-transform: none; + min-height: 0; background: transparent; border: 0; padding: 0; - margin-top: 1rem; - color: var(--ink-soft); - font-size: 0.85rem; - font-weight: 400; + margin-top: 16px; + color: var(--text-secondary); text-decoration: underline; text-underline-offset: 0.2em; } button.text-link:hover:not(:disabled) { - color: var(--ink); - opacity: 1; + background: transparent; + color: var(--text-primary); } /* The last screen's question box, with the button that swaps in another question beside it. */ .field-with-action { display: flex; - gap: 0.5rem; + gap: 8px; } .field-with-action input { @@ -255,73 +377,78 @@ button.another-question { display: flex; align-items: center; justify-content: center; - width: 2.75rem; + width: 44px; padding: 0; - background: var(--raised); - color: var(--ink-soft); - border: 1px solid var(--hair); - border-radius: var(--r-row); + background: var(--surface-container); + color: var(--text-secondary); + border: 1px solid var(--border-container); + border-radius: var(--r-card); } button.another-question:hover:not(:disabled) { - color: var(--ink); - border-color: var(--ink-faint); - opacity: 1; + background: var(--surface-hovered); + color: var(--text-primary); + border-color: var(--grey-600); } /* Local Intelligence setting up behind the screen in front: one line. */ .local-status { - font-size: 0.8rem; - color: var(--ink-soft); + font-size: 14px; + line-height: 22px; + color: var(--text-secondary); font-variant-numeric: tabular-nums; - margin: 0 0 1.25rem; + margin: 0 0 20px; } :focus-visible { - outline: 2px solid var(--ink); + outline: 2px solid var(--text-primary); outline-offset: 2px; } /* Fields */ .field { - margin-bottom: 0.9rem; + margin-bottom: 16px; } .field label { display: block; - font-size: 0.8rem; - color: var(--ink-soft); - margin-bottom: 0.35rem; + font-size: 14px; + line-height: 22px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 4px; } .field input { font: inherit; + font-size: 16px; width: 100%; - padding: 0.6rem 0.75rem; - border-radius: var(--r-row); - border: 1px solid var(--hair); - background: var(--raised); - color: var(--ink); + min-height: 44px; + padding: 0 12px; + border-radius: var(--r-card); + border: 1px solid var(--border-container); + background: var(--surface-container); + color: var(--text-primary); } .field input::placeholder { - color: var(--ink-faint); + color: var(--text-disabled); } .field input:focus-visible { - border-color: var(--ink-faint); + border-color: var(--border-action); outline: none; - box-shadow: 0 0 0 3px var(--sunk); + box-shadow: 0 0 0 3px var(--lilac-30); } /* The list of choices. Rows, not a grid of same-size cards. */ fieldset.picker { border: 0; padding: 0; - margin: 0.75rem 0 0; + margin: 12px 0 0; display: flex; flex-direction: column; - gap: 0.4rem; + gap: 8px; max-height: 17rem; overflow-y: auto; } @@ -340,7 +467,7 @@ fieldset.picker.providers { button.text-link.change-provider { position: absolute; top: 50%; - right: 1rem; + right: 16px; transform: translateY(-50%); margin: 0; } @@ -354,34 +481,47 @@ button.text-link.change-provider { * tile in the product that is a real button was the one nobody could read. Nothing errored and * nothing looked broken: the names were simply invisible, and the screen asked somebody to choose * between six empty boxes. + * + * A tile that is a button also undoes the button's mono, uppercase and fixed height: it is a row + * of reading text, not a control label. */ .tile { position: relative; - color: var(--ink); + color: var(--text-primary); + font-family: var(--sans); + font-weight: 400; + letter-spacing: 0; + text-transform: none; + line-height: 1.5; + min-height: 0; display: grid; - grid-template-columns: 32px 1fr; + grid-template-columns: 40px 1fr; grid-template-areas: "mark name" "mark summary"; align-items: center; - column-gap: 0.7rem; - row-gap: 0.1rem; - padding: 0.6rem 0.7rem; - border: 1px solid var(--hair-soft); - border-radius: var(--r-row); - background: var(--raised); + column-gap: 12px; + row-gap: 2px; + padding: 12px 16px; + border: 1px solid var(--border-default); + border-radius: var(--r-card); + background: var(--white-65); cursor: pointer; transition: border-color 0.15s ease, - background 0.15s ease; + background 0.15s ease, + box-shadow 0.15s ease; } -.tile:hover { - border-color: var(--hair); +.tile:hover, +button.tile:hover:not(:disabled) { + background: var(--surface-container); } .tile.chosen { - border-color: var(--ink); + background: var(--surface-container); + border-color: var(--border-action); + box-shadow: var(--elevation-sm); } .mark-tile { @@ -399,36 +539,43 @@ button.text-link.change-provider { .tile-name { font-weight: 500; - font-size: 0.85rem; + font-size: 16px; + line-height: 24px; } .tile-summary { - color: var(--ink-faint); - font-size: 0.75rem; - line-height: 1.35; + color: var(--text-disabled); + font-size: 14px; + line-height: 22px; } +/* A pill: mono and uppercase, as the brand sets them. */ .tile-note { justify-self: start; - font-size: 0.68rem; - color: var(--ink-soft); - background: var(--sunk); - border-radius: 999px; - padding: 0.08rem 0.45rem; - margin-top: 0.15rem; + font-family: var(--mono); + font-size: 10px; + line-height: 10px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-secondary); + background: var(--grey-200); + border-radius: var(--r-full); + padding: 4px 8px; + margin-top: 4px; } /* A brand's mark, or its name where no usable mark exists. Same box either way. */ .mark-tile { - width: 32px; - height: 32px; + width: 40px; + height: 40px; display: flex; align-items: center; justify-content: center; - padding: 5px; - border-radius: 8px; - background: #fff; - border: 1px solid var(--hair-soft); + padding: 8px; + border-radius: var(--r-card); + background: var(--surface-container); + border: 1px solid var(--border-container); flex: none; } @@ -447,7 +594,7 @@ button.text-link.change-provider { line-height: 1.05; text-align: center; letter-spacing: -0.02em; - color: #050505; + color: var(--text-primary); hyphens: none; } @@ -461,24 +608,25 @@ button.text-link.change-provider { } .tile:has(.tile-input:focus-visible) { - outline: 2px solid var(--ink); + outline: 2px solid var(--text-primary); outline-offset: 2px; } /* The framework list, which almost nobody should open. */ details { - border-top: 1px solid var(--hair-soft); - padding-top: 0.9rem; + border-top: 1px solid var(--border-container); + padding-top: 16px; } details > summary { cursor: pointer; - font-size: 0.82rem; - color: var(--ink-faint); + font-size: 14px; + line-height: 22px; + color: var(--text-secondary); list-style: none; display: flex; align-items: center; - gap: 0.4rem; + gap: 8px; } details > summary::-webkit-details-marker { @@ -500,135 +648,173 @@ details[open] > summary::before { } details > summary:hover { - color: var(--ink); + color: var(--text-primary); } -/* The chosen provider's own panel: the one raised surface per screen. */ +/* The chosen provider's own panel: solid white inside the glass card. */ .chosen-provider { - border: 1px solid var(--hair); + border: 1px solid var(--border-default); border-radius: var(--r-card); - background: var(--raised); - padding: 1rem; - margin-top: 0.9rem; + background: var(--surface-container); + box-shadow: var(--elevation-sm); + padding: 20px; + margin-top: 12px; } .provider-sign-in-actions { display: flex; flex-wrap: wrap; - gap: 0.6rem; + gap: 8px; } +/* The theme's view toggle: active white, inactive transparent, on a faint well. */ .segmented { display: inline-flex; - background: var(--sunk); - border-radius: 999px; - padding: 3px; - margin-bottom: 0.9rem; + gap: 4px; + background: var(--ink-5); + border-radius: var(--r-card); + padding: 4px; + margin-bottom: 16px; } .segmented button { + font-family: var(--sans); + font-size: 14px; + line-height: 14px; + font-weight: 500; + letter-spacing: 0; + text-transform: none; + min-height: 32px; + padding: 0 12px; border: 0; background: transparent; - color: var(--ink-soft); - border-radius: 999px; - padding: 0.3rem 0.8rem; - font-size: 0.78rem; - font-weight: 500; + color: var(--text-secondary); + border-radius: var(--r-card); } -.segmented button.on { - background: var(--raised); - color: var(--ink); - box-shadow: 0 1px 2px rgba(15, 13, 10, 0.08); +.segmented button:hover:not(:disabled) { + background: var(--white-50); + color: var(--text-primary); +} + +.segmented button.on, +.segmented button.on:hover:not(:disabled) { + background: var(--surface-container); + color: var(--text-primary); + box-shadow: var(--elevation-sm); } .caution, .fallback { - font-size: 0.78rem; - color: var(--ink-faint); - line-height: 1.5; - margin: 0.9rem 0 0; + font-size: 14px; + line-height: 22px; + color: var(--text-disabled); + margin: 16px 0 0; } .caution a, .fallback a { - color: var(--ink); + color: var(--text-primary); text-underline-offset: 2px; } +a { + color: var(--text-primary); + text-underline-offset: 0.2em; +} + /* Something is in the way, or something failed. */ .blocker { - /* No coloured side border: the heading already carries the state, and a thick accent stripe is - decoration standing in for hierarchy. */ - border: 1px solid var(--hair); - border-radius: var(--r-row); - background: var(--raised); - padding: 0.9rem 1rem; - margin: 1rem 0 0; + /* No coloured side border: the heading carries the state, with one small dot of red. */ + border: 1px solid var(--border-default); + border-radius: var(--r-card); + background: var(--surface-container); + box-shadow: var(--elevation-sm); + padding: 16px 20px; + margin: 16px 0 0; } .blocker h2 { - color: var(--bad); + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); +} + +.blocker h2::before { + content: ""; + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--bad); } .blocker p { margin: 0; - color: var(--ink-soft); - font-size: 0.85rem; + color: var(--text-secondary); + font-size: 14px; + line-height: 22px; } .setup-help { display: inline-block; - margin-top: 0.65rem; - color: var(--ink-soft); - font-size: 0.8rem; + margin-top: 12px; + color: var(--text-secondary); + font-size: 14px; text-underline-offset: 2px; } .external-link-error { display: block; - margin-top: 0.5rem; - color: var(--ink-soft); - font-size: 0.8rem; + margin-top: 8px; + color: var(--text-secondary); + font-size: 14px; overflow-wrap: anywhere; } /* Per-item progress, because this is the slowest screen in the product. */ .steps { - margin: 1.25rem 0 0; + margin: 20px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; - gap: 0.8rem; + gap: 12px; } .step { display: grid; - grid-template-columns: 1rem minmax(0, 1fr) auto; + grid-template-columns: 16px minmax(0, 1fr) auto; grid-template-areas: "mark label status" ". detail detail"; - column-gap: 0.5rem; - row-gap: 0.15rem; - align-items: baseline; - font-size: 0.82rem; + column-gap: 12px; + row-gap: 4px; + align-items: center; + font-size: 16px; + line-height: 24px; } .step .mark { grid-area: mark; - align-self: start; - font-size: 0.75rem; + font-size: 14px; } .step-label { grid-area: label; } +/* Status is technical annotation: mono, uppercase, small. */ .step-status { grid-area: status; - color: var(--ink-soft); - font-size: 0.72rem; + font-family: var(--mono); + font-size: 12px; + line-height: 12px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-secondary); text-align: right; } @@ -639,11 +825,10 @@ details > summary:hover { .step-spinner { display: inline-block; - width: 0.85rem; - height: 0.85rem; - margin-top: 0.2rem; - border: 2px solid var(--hair); - border-top-color: var(--ink); + width: 14px; + height: 14px; + border: 2px solid var(--border-container); + border-top-color: var(--text-primary); border-radius: 50%; animation: step-spin 0.8s linear infinite; } @@ -669,14 +854,19 @@ details > summary:hover { .step .detail { grid-area: detail; - color: var(--ink-soft); - font-size: 0.75rem; + color: var(--text-secondary); + font-size: 14px; + line-height: 22px; overflow-wrap: anywhere; } @media (max-width: 580px) { + .sheet { + padding: 24px; + } + .step { - grid-template-columns: 1rem minmax(0, 1fr); + grid-template-columns: 16px minmax(0, 1fr); grid-template-areas: "mark label" ". status" @@ -718,8 +908,8 @@ details > summary:hover { --c2: oklch(70% 0.18 210); --c3: oklch(66% 0.2 285); --c4: oklch(72% 0.19 325); - width: 30px; - height: 30px; + width: 32px; + height: 32px; border-radius: 50%; position: relative; isolation: isolate; @@ -757,44 +947,54 @@ details > summary:hover { inset 0 -2px 5px hsl(0 0% 0% / 0.4); } +/* The app's name beside its mark: light, as the theme sets an app title. */ .lockup { display: flex; align-items: center; - gap: 0.6rem; - margin-bottom: 1.75rem; + gap: 12px; + margin-bottom: 32px; } .lockup span { - font-size: 1.05rem; - font-weight: 600; - letter-spacing: -0.02em; + font-size: 18px; + line-height: 20px; + font-weight: 300; +} + +/* The CopilotKit logotype, full lockup, with its clearspace (half its height) below it. */ +.copilotkit-logo { + display: block; + height: 28px; + width: auto; + align-self: flex-start; + margin-bottom: 28px; } /* The developer half of a failure: present, and never the headline. */ .detail-of { border: 0; - padding: 0.6rem 0 0; + padding: 12px 0 0; margin: 0; } .detail-of > summary { - font-size: 0.78rem; + font-size: 14px; } .detail-of pre { - margin: 0.5rem 0 0; - padding: 0.6rem 0.7rem; - background: var(--sunk); - border-radius: 8px; - font-size: 0.72rem; - line-height: 1.45; + margin: 8px 0 0; + padding: 12px; + background: var(--ink-5); + border-radius: var(--r-item); + font-size: 12px; + line-height: 18px; white-space: pre-wrap; word-break: break-word; max-height: 9rem; overflow-y: auto; - color: var(--ink-soft); + color: var(--text-secondary); /* Monospace here is for machine output, which is what it is for. */ - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--mono); } /* @@ -802,26 +1002,30 @@ details > summary:hover { * wizard that was produced rather than written, and it is the proof the install actually worked. */ .answer { - margin: 1.4rem 0 0.3rem; - padding: 1.15rem 1.3rem; - border-radius: 1rem; - background: var(--raised); - border: 1px solid var(--hair); + margin: 24px 0 4px; + padding: 20px 24px; + border-radius: var(--r-card); + background: var(--surface-container); + border: 1px solid var(--border-default); + box-shadow: var(--elevation-sm); } .answer-from { - margin: 0 0 0.45rem; - font-size: 0.85rem; - font-weight: 600; - letter-spacing: 0.02em; - color: var(--ink-faint); + margin: 0 0 8px; + font-family: var(--mono); + font-size: 12px; + line-height: 12px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-secondary); } .answer-text { margin: 0; - font-size: 1.2rem; - line-height: 1.45; - color: var(--ink); + font-size: 20px; + line-height: 28px; + color: var(--text-primary); white-space: pre-wrap; } @@ -835,28 +1039,30 @@ details > summary:hover { .status-line { display: flex; align-items: center; - gap: 0.55rem; - margin: 0.75rem 0 0; - font-size: 0.85rem; - color: var(--ink-soft); + gap: 8px; + margin: 12px 0 0; + font-size: 14px; + line-height: 22px; + color: var(--text-secondary); } .status-line .step-spinner { flex: none; - margin-top: 0; } /* The menu's status on whichever setup screen is showing, without moving that screen. */ .window-status { position: fixed; + z-index: 2; left: 50%; - bottom: 1.25rem; + bottom: 20px; transform: translateX(-50%); - max-width: calc(100% - 2rem); - padding: 0.5rem 0.9rem; - border: 1px solid var(--hair); + max-width: calc(100% - 32px); + padding: 8px 16px; + border: 1px solid var(--border-container); border-radius: var(--r-card); - background: var(--raised); + background: var(--surface-container); + box-shadow: var(--elevation-md); } .window-status .status-line { From 6d568ef7f371df589ab4c68037280f94a65a2290 Mon Sep 17 00:00:00 2001 From: Dusty Date: Fri, 23 Oct 2026 04:12:31 -0700 Subject: [PATCH 36/77] perf(desktop): paint the background once and press buttons instantly The six colour fields were discs under filter: blur(103px), which WebKit repaints whenever anything above changes; the spinners and elapsed counters change every frame and second, and the window felt laggy. They are now radial gradients with the same centres and soft edges. Buttons faded their hover colour over 150ms and had no pressed state, so nothing moved at the moment of a click. Pressed is now instant and the fades are 80ms. --- desktop/src/main.tsx | 9 +--- desktop/src/styles.css | 98 ++++++++++++++++++------------------------ 2 files changed, 42 insertions(+), 65 deletions(-) diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 8571beef84..3b1e764d66 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -14,14 +14,7 @@ if (!root) createRoot(root).render( {/* The six blurred colour fields every screen sits on; see `.backdrop` in styles.css. */} - +