diff --git a/Cargo.lock b/Cargo.lock index aee243b03..a5839317c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4342,6 +4342,8 @@ dependencies = [ "image", "libc", "mslnk", + "objc2-app-kit", + "objc2-foundation", "plist", "ratatui", "security-framework", @@ -4358,6 +4360,7 @@ dependencies = [ "toml_edit 0.25.13+spec-1.1.0", "url", "uuid", + "windows", "windows-sys 0.61.2", ] @@ -5651,6 +5654,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -5664,6 +5688,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5692,6 +5727,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -5744,6 +5789,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/crates/switchyard-desktop/Cargo.toml b/crates/switchyard-desktop/Cargo.toml index 0a37057a7..e9bd25858 100644 --- a/crates/switchyard-desktop/Cargo.toml +++ b/crates/switchyard-desktop/Cargo.toml @@ -32,6 +32,8 @@ shlex = "2" strum = { version = "0.28", features = ["derive"] } [target.'cfg(target_os = "macos")'.dependencies] +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSWorkspace"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSString", "NSURL"] } security-framework = "3" [target.'cfg(unix)'.dependencies] @@ -44,6 +46,7 @@ dbus-secret-service = { version = "4.1", features = ["crypto-rust", "vendored"] [target.'cfg(windows)'.dependencies] mslnk = "0.1.8" self-replace = "1.5" +windows = { version = "0.62.2", features = ["Management_Deployment", "ApplicationModel"] } windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_Security_Credentials", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Console", "Win32_Storage_FileSystem"] } [dev-dependencies] diff --git a/crates/switchyard-desktop/src/accounts.rs b/crates/switchyard-desktop/src/accounts.rs new file mode 100644 index 000000000..e115f5e75 --- /dev/null +++ b/crates/switchyard-desktop/src/accounts.rs @@ -0,0 +1,296 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Named login directories use the harness's own authentication commands. + +use crate::harness::{self, Harness}; +use std::path::{Path, PathBuf}; + +// PendingAccount removes only an empty directory if login setup fails. +// The harness may already have saved login files, which must stay. +struct PendingAccount(Option); + +impl Drop for PendingAccount { + fn drop(&mut self) { + if let Some(path) = &self.0 { + let _ = std::fs::remove_dir(path); + } + } +} + +fn root(tool: Harness) -> Result { + let name = match tool { + Harness::CodexCli => "codex", + Harness::Claude => "claude", + _ => return Err("Named accounts are available for Codex CLI and Claude Code.".into()), + }; + let home = crate::config::expand_home(Path::new("~"))?; + let mut path = home; + for component in [".switchyard", "accounts", name] { + path.push(component); + if std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink()) { + return Err(format!( + "{} is a symlink. Edit its target yourself.", + path.display() + )); + } + } + Ok(path) +} + +pub fn validate(tool: Harness, path: &Path) -> Result<(), String> { + let root = root(tool)?; + if path.parent() != Some(root.as_path()) + || !std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir() && !m.file_type().is_symlink()) + { + return Err("Choose a supported harness.".into()); + } + Ok(()) +} + +pub fn discover(tool: Harness) -> Result, String> { + if !matches!(tool, Harness::CodexCli | Harness::Claude) { + return Ok(Vec::new()); + } + let root = root(tool)?; + let entries = match std::fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "Could not list accounts in {}: {error}", + root.display() + )); + } + }; + let mut accounts = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + if !entry + .file_name() + .to_string_lossy() + .starts_with(".archived-") + && entry + .file_type() + .map_err(|error| error.to_string())? + .is_dir() + && let Ok(name) = entry.file_name().into_string() + { + accounts.push((name, entry.path())); + } + } + accounts.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(accounts) +} + +pub fn list(tool: Harness) -> Vec<(String, PathBuf)> { + discover(tool).unwrap_or_default() +} + +pub fn config_paths(tool: Harness, account: Option<&Path>) -> Result, String> { + let Some(account) = account else { + return harness::paths(tool); + }; + match tool { + Harness::CodexCli => Ok(vec![account.join("sy.config.toml")]), + Harness::Claude => Ok(vec![account.join("settings.json")]), + _ => harness::paths(tool), + } +} + +pub fn add(tool: Harness, name: &str) -> Result { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err("Account names need 1–64 letters, numbers, hyphens, or underscores.".into()); + } + let root = root(tool)?; + harness::binary(tool).ok_or("Install the selected harness first.")?; + std::fs::create_dir_all(&root).map_err(|e| e.to_string())?; + let path = root.join(name); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .mode(0o700) + .create(&path) + .map_err(|e| { + format!( + "Create account: {e}. \ + Pick an existing account after Refresh, or choose a new name." + ) + })?; + } + #[cfg(not(unix))] + std::fs::create_dir(&path).map_err(|e| e.to_string())?; + let mut pending = PendingAccount(Some(path.clone())); + login(tool, &path, name)?; + pending.0 = None; + Ok(format!( + "Opened the harness's login in Terminal for account {name}. Complete login, \ + click Refresh, and select this account. Switchyard does not read the login tokens. \ + The selected account applies to new sessions only. Account directory: {}", + path.display() + )) +} + +pub fn login(tool: Harness, path: &Path, name: &str) -> Result { + validate(tool, path)?; + let binary = harness::binary(tool).ok_or("Install the selected harness first.")?; + let variable = match tool { + Harness::CodexCli => "CODEX_HOME", + Harness::Claude => "CLAUDE_CONFIG_DIR", + _ => return Err("Choose a supported harness.".into()), + }; + #[cfg(windows)] + crate::windows::open_terminal(crate::windows::TerminalJob { + binary: binary.clone(), + args: match tool { + Harness::CodexCli => vec!["login".into()], + _ => vec!["auth".into(), "login".into()], + }, + directory: None, + environment: vec![(variable.into(), path.display().to_string())], + remove_environment: vec![], + })?; + #[cfg(not(windows))] + { + let login = match tool { + Harness::CodexCli => "login", + _ => "auth login", + }; + let script = format!( + "#!/usr/bin/env bash\n\ + rm -- \"$0\"\n\ + export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\ + export {variable}={}\n\ + {} {login}", + crate::sessions::quote(&path.display().to_string()), + crate::sessions::quote(&binary.display().to_string()) + ); + crate::server::open_terminal_script("switchyard-login-", &script)?; + } + Ok(format!( + "Opened login in Terminal for account {name}. Finish login in the harness. \ + Login status remains unknown until the harness confirms it." + )) +} + +pub fn archive(tool: Harness, path: &Path) -> Result { + validate(tool, path)?; + let archived = path.with_file_name(format!(".archived-{}", uuid::Uuid::now_v7())); + std::fs::rename(path, &archived).map_err(|error| error.to_string())?; + Ok(format!( + "Archived the account folder at {}. Login files are preserved. \ + Existing sessions may still use the old directory and need a new login.", + archived.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn archived_accounts_leave_the_picker_and_keep_login_files() { + if std::env::var_os("SWITCHYARD_ACCOUNT_ARCHIVE_FIXTURE").is_some() { + let entries = discover(Harness::CodexCli).expect("accounts"); + assert_eq!(entries.len(), 1); + let path = &entries[0].1; + archive(Harness::CodexCli, path).expect("archive"); + assert!(!path.exists()); + assert!( + discover(Harness::CodexCli) + .expect("accounts after archive") + .is_empty() + ); + let hidden = std::fs::read_dir(root(Harness::CodexCli).expect("root")) + .expect("archived root") + .next() + .expect("archived entry") + .expect("entry") + .path(); + assert_eq!( + std::fs::read_to_string(hidden.join("auth.json")).expect("login"), + "private-fixture-login" + ); + return; + } + let fixture = tempfile::tempdir().expect("fixture"); + let path = fixture.path().join(".switchyard/accounts/codex/named"); + std::fs::create_dir_all(&path).expect("account"); + std::fs::write(path.join("auth.json"), "private-fixture-login").expect("login"); + let child = std::process::Command::new(std::env::current_exe().expect("runner")) + .args([ + "--exact", + "accounts::tests::archived_accounts_leave_the_picker_and_keep_login_files", + ]) + .env("HOME", fixture.path()) + .env("SWITCHYARD_ACCOUNT_ARCHIVE_FIXTURE", "1") + .output() + .expect("child"); + assert!( + child.status.success(), + "{}", + String::from_utf8_lossy(&child.stdout) + ); + } + + #[test] + fn unsafe_names_fail_before_any_side_effect() { + for name in ["", "..", "a/b", "$(touch bad)", "a\nb"] { + assert!(add(Harness::CodexCli, name).is_err()); + } + assert!(add(Harness::Pi, "valid").is_err()); + } + + #[test] + fn a_login_setup_failure_removes_only_its_empty_directory() { + let root = tempfile::tempdir().expect("directory"); + let path = root.path().join("account"); + std::fs::create_dir(&path).expect("account"); + drop(PendingAccount(Some(path.clone()))); + assert!(!path.exists()); + std::fs::create_dir(&path).expect("account"); + std::fs::write(path.join("auth.json"), "private").expect("auth"); + drop(PendingAccount(Some(path.clone()))); + assert!(path.join("auth.json").is_file()); + } + // The subprocess isolates HOME so parallel tests keep their own settings. + #[cfg(unix)] + #[test] + fn symlinked_account_roots_are_not_listed_or_used_for_login() { + if std::env::var_os("SWITCHYARD_ACCOUNT_FIXTURE").is_some() { + assert!(list(Harness::CodexCli).is_empty()); + assert!(discover(Harness::CodexCli).is_err()); + assert!(add(Harness::CodexCli, "new").is_err()); + let path = crate::config::expand_home(Path::new("~/.switchyard/accounts/codex/named")) + .expect("home"); + assert!(validate(Harness::CodexCli, &path).is_err()); + return; + } + let dir = tempfile::tempdir().expect("fixture"); + let home = dir.path().join("home"); + let target = dir.path().join("target"); + std::fs::create_dir(&home).expect("home"); + std::fs::create_dir_all(target.join("accounts/codex/named")).expect("accounts"); + std::os::unix::fs::symlink(&target, home.join(".switchyard")).expect("symlink"); + let output = std::process::Command::new(std::env::current_exe().expect("runner")) + .args([ + "--exact", + "accounts::tests::symlinked_account_roots_are_not_listed_or_used_for_login", + ]) + .env("SWITCHYARD_ACCOUNT_FIXTURE", "1") + .env("HOME", &home) + .output() + .expect("child"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + assert!(!target.join("accounts/codex/new").exists()); + } +} diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 91878ada4..715b150f3 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -7,7 +7,9 @@ //! build until the terminal app can run it. use crate::{ + accounts, config::Config, + harness::{self, Harness}, history, models, pricing, rollup, server, server_config::{ALGORITHMS, Choice, Route, ServerConfig}, }; @@ -68,6 +70,53 @@ pub enum Action { refresh: bool, key: Option, }, + PreviewInstall { + tool: Harness, + account: Option, + route: String, + id: String, + settings_file: Option, + }, + Install { + tool: Harness, + account: Option, + route: String, + id: String, + settings_file: Option, + preview_token: Option, + }, + PreviewRestore { + tool: Harness, + account: Option, + settings_file: Option, + }, + Restore { + tool: Harness, + account: Option, + settings_file: Option, + preview_token: Option, + }, + AddAccount { + tool: Harness, + name: String, + }, + LoginAccount { + tool: Harness, + name: String, + }, + PreviewAccountArchive { + tool: Harness, + name: String, + }, + ArchiveAccount { + tool: Harness, + name: String, + preview_token: Option, + }, + OpenAccount { + tool: Harness, + name: String, + }, Restart {}, PreviewUpdate {}, Update { @@ -110,6 +159,7 @@ pub struct Snapshot { pub generation: u64, pub routes: Vec, pub clients: Vec, + pub tools: Vec, pub algorithms: Vec, pub summary: Vec, pub metrics: Metrics, @@ -161,6 +211,47 @@ pub struct RouteInfo { /// This field holds the marker of the generated block that contains the route. The tool /// that wrote the block replaces the route the next time it runs. pub generated: Option, + /// This field maps each harness to the reason it cannot use the route, or to `None` + /// when it can. + pub compatibility: BTreeMap>, + /// This field lists the harness settings files that select the route's public model ID. + pub dependents: Vec, +} + +/// Dependent names harness settings that select a route, so deleting the route breaks them. +#[derive(Clone, Serialize)] +pub struct Dependent { + pub tool: Harness, + pub label: &'static str, + /// This field is `None` for the harness's own settings and names a saved account otherwise. + pub account: Option, + pub files: Vec, +} + +/// ToolInfo describes one harness whose settings the apps can change. +#[derive(Clone, Serialize)] +pub struct ToolInfo { + pub tool: Harness, + pub label: &'static str, + /// This field is true when the settings files resolve and the harness program is installed. + pub available: bool, + pub files: Vec, + /// This field summarizes the current settings, or holds the error that stopped the read. + pub status: String, + pub accounts: Vec, + pub account_details: Vec, + pub discovery_error: Option, +} + +/// AccountInfo describes one saved account folder of a harness. +#[derive(Clone, Serialize)] +pub struct AccountInfo { + pub name: String, + pub path: PathBuf, + /// The apps cannot ask a harness whether a login finished, so this field is `unknown`. + pub login_status: &'static str, + /// This field summarizes the account's settings, or holds the error that stopped the read. + pub status: String, } /// ClientInfo describes one endpoint under `[llm_clients]` in the server config. @@ -259,7 +350,7 @@ impl Reply { struct Review { token: String, inputs: Value, - files: Vec<(PathBuf, Option>)>, + files: Vec, } impl Review { fn capture(inputs: Value, paths: Vec) -> Result { @@ -318,6 +409,8 @@ pub struct Controller { // The generation ties a draft to the server config file selected by the settings. generation: u64, route_reviews: std::collections::HashMap, + tool_review: Option, + account_archive: Option<(Harness, String, String)>, route_change: Option, update_review: Option<(String, Value)>, } @@ -329,6 +422,8 @@ impl Controller { reader: rollup::Reader::default(), generation: 0, route_reviews: Default::default(), + tool_review: None, + account_archive: None, route_change: None, update_review: None, } @@ -338,6 +433,7 @@ impl Controller { if config.config_file != self.config.config_file { self.generation += 1; self.route_reviews.clear(); + self.tool_review = None; self.route_change = None; } self.config = config; @@ -461,6 +557,11 @@ impl Controller { .map(|r| r.tier.map(|t| format!("{t:?}"))) .collect() }), + compatibility: harness::HARNESSES + .iter() + .map(|&(tool, _)| (tool, login_mode(tool, &config, &route).err())) + .collect(), + dependents: known_dependents(&route.id), editable: algorithm.is_some(), generated: config .generated_by("routes", &route.key) @@ -496,6 +597,42 @@ impl Controller { history::History::default() }); let activity = history::hourly_calls(&history.entries, chrono::Utc::now()); + let tools = harness::HARNESSES + .iter() + .map(|&(tool, label)| { + let discovered = accounts::discover(tool); + let discovery_error = discovered.as_ref().err().cloned(); + let discovered = discovered.unwrap_or_default(); + let account_details = discovered + .iter() + .map(|(name, path)| AccountInfo { + name: name.clone(), + path: path.clone(), + login_status: "unknown", + status: accounts::config_paths(tool, Some(path)) + .and_then(|files| harness::inspect(tool, &files)) + .unwrap_or_else(|error| error), + }) + .collect(); + // A path error stays in status; files remains an array and the tool is unavailable. + let files = harness::paths(tool); + let status = files + .as_ref() + .map_err(Clone::clone) + .and_then(|files| harness::inspect(tool, files)) + .unwrap_or_else(|error| error); + ToolInfo { + tool, + label, + available: files.is_ok() && harness::binary(tool).is_some(), + files: files.unwrap_or_default(), + status, + accounts: discovered.into_iter().map(|(name, _)| name).collect(), + account_details, + discovery_error, + } + }) + .collect(); let update_status = install_metadata(&self.settings) .and_then(|path| switchyard_desktop::install::update_status(&path)) .unwrap_or_else(|error| json!({"state":"unavailable","message":error})); @@ -527,6 +664,7 @@ impl Controller { generation: self.generation, routes, clients, + tools, algorithms: ALGORITHMS .iter() .map(|a| AlgorithmInfo { @@ -572,7 +710,7 @@ impl Controller { }), }) } - /// The TUI uses these file paths to run an editor in the foreground. + /// The TUI uses these file and folder paths to run an editor in the foreground. pub fn editor_target(&mut self, action: &Action) -> Result, String> { match action { Action::OpenSettings {} => Ok(Some(self.settings.clone())), @@ -588,6 +726,9 @@ impl Controller { } Action::OpenUpdateLog {} => self.update_log().map(Some), Action::OpenLogs {} => logs_dir().map(Some), + // An account folder opens in the editor too, because a terminal may run over SSH, + // where `open` would start a file manager on another screen. + Action::OpenAccount { tool, name } => account_path(*tool, Some(name)), _ => Ok(None), } } @@ -811,6 +952,152 @@ impl Controller { data: json!({"notes":edited.notes,"revision":revision,"file":self.config.config_file}), }) } + Action::PreviewInstall { + tool, + account, + route, + id, + settings_file, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + let inputs = json!({"tool":tool,"account":account,"route":route,"id":id,"settings_file":settings_file,"server_url":self.config.server_url}); + let mut paths = harness::review_files(&files); + paths.push(self.config.config_file.clone()); + let review = Review::capture(inputs, paths)?; + let config = self.routes()?; + let selected = current_route(&config, &route, Some(&id))?; + let mut data = harness::preview( + tool, + &files, + &self.config.server_url, + &selected.id, + config.context_window(&selected.key), + login_mode(tool, &config, &selected)?, + )?; + review.check(Some(&review.token), &review.inputs)?; + data["preview_token"] = json!(review.token); + self.tool_review = Some(review); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::Install { + tool, + account, + route, + id, + settings_file, + preview_token, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + let inputs = json!({"tool":tool,"account":account,"route":route,"id":id,"settings_file":settings_file,"server_url":self.config.server_url}); + self.tool_review + .as_ref() + .ok_or("Preview these settings before applying them.")? + .check(preview_token.as_deref(), &inputs)?; + let config = self.routes()?; + let route = current_route(&config, &route, Some(&id))?; + harness::install_reviewed( + tool, + &files, + &self.config.server_url, + &route.id, + config.context_window(&route.key), + login_mode(tool, &config, &route)?, + self.tool_review + .as_ref() + .map(|review| review.files.as_slice()), + ) + .map(Reply::message) + } + Action::PreviewRestore { + tool, + account, + settings_file, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + let review = Review::capture( + json!({"restore":true,"tool":tool,"account":account,"settings_file":settings_file}), + harness::review_files(&files), + )?; + let mut data = harness::preview_restore(tool, &files)?; + review.check(Some(&review.token), &review.inputs)?; + data["preview_token"] = json!(review.token); + self.tool_review = Some(review); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::Restore { + tool, + account, + settings_file, + preview_token, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + self.tool_review.as_ref().ok_or("Preview the restore before applying it.")?.check(preview_token.as_deref(),&json!({"restore":true,"tool":tool,"account":account,"settings_file":settings_file}))?; + match harness::restore_reviewed( + &files, + self.tool_review + .as_ref() + .map(|review| review.files.as_slice()), + ) { + Ok(message) => Ok(Reply::message(message)), + Err(error) if error.starts_with("Restored settings, but") => Ok(Reply { + message: error, + data: json!({"warning":true}), + }), + Err(error) => Err(error), + } + } + Action::AddAccount { tool, name } => { + accounts::add(tool, name.trim()).map(Reply::message) + } + Action::LoginAccount { tool, name } => { + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + accounts::login(tool, &path, &name).map(Reply::message) + } + Action::PreviewAccountArchive { tool, name } => { + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + let token = uuid::Uuid::now_v7().to_string(); + self.account_archive = Some((tool, name.clone(), token.clone())); + Ok(Reply { + message: String::new(), + data: json!({ + "name": name, + "path": path, + "preview_token": token, + "notice": "Archive moves this account out of the picker and preserves all \ + login files. Existing sessions using its old directory may \ + need a new login." + }), + }) + } + Action::ArchiveAccount { + tool, + name, + preview_token, + } => { + let (expected_tool, expected_name, token) = self + .account_archive + .as_ref() + .ok_or("Review account archive first.")?; + if tool != *expected_tool + || name != *expected_name + || preview_token.as_deref() != Some(token.as_str()) + { + return Err("Review account archive again.".into()); + } + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + accounts::archive(tool, &path).map(Reply::message) + } + Action::OpenAccount { tool, name } => { + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + server::command("open", &[&path.display().to_string()])?; + Ok(Reply::message("Opened the account folder.".into())) + } Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), @@ -916,6 +1203,34 @@ impl Controller { fn logs_dir() -> Result { crate::config::expand_home(std::path::Path::new("~/.switchyard/logs")) } +fn known_dependents(id: &str) -> Vec { + let mut dependents = Vec::new(); + for &(tool, label) in harness::HARNESSES { + let mut destinations = vec![(None, harness::paths(tool))]; + destinations.extend( + accounts::list(tool) + .into_iter() + .map(|(name, path)| (Some(name), accounts::config_paths(tool, Some(&path)))), + ); + for (account, files) in destinations { + let Ok(files) = files else { + continue; + }; + if harness::inspect(tool, &files) + .is_ok_and(|status| status.lines().next() == Some(format!("Model: {id}").as_str())) + { + dependents.push(Dependent { + tool, + label, + account, + files, + }); + } + } + } + dependents +} + fn role_info(role: &crate::server_config::Role) -> RoleInfo { RoleInfo { label: role.label.clone(), @@ -937,6 +1252,90 @@ fn current_route(config: &ServerConfig, key: &str, id: Option<&str>) -> Result, + file: Option<&str>, +) -> Result, String> { + if let Some(file) = file { + if account.is_some() { + return Err("Choose a saved account or a custom settings file, not both.".into()); + } + let path = PathBuf::from(file); + if !path.is_absolute() || file.trim().is_empty() || file.contains(['\n', '\r']) { + return Err("Enter an absolute settings file path without line breaks.".into()); + } + if path.is_dir() { + return Err("Choose a settings file, not a folder.".into()); + } + if tool == Harness::Pi { + if path.file_name().is_none_or(|name| name != "models.json") { + return Err("Choose Pi’s models.json; settings.json is read beside it.".into()); + } + return Ok(vec![path.clone(), path.with_file_name("settings.json")]); + } + return Ok(vec![path]); + } + let account = account_path(tool, account)?; + accounts::config_paths(tool, account.as_deref()) +} + +fn account_path(tool: Harness, name: Option<&str>) -> Result, String> { + let Some(name) = name else { + return Ok(None); + }; + let path = accounts::list(tool) + .into_iter() + .find(|(n, _)| n == name) + .map(|(_, p)| p) + .ok_or("Unknown account. Refresh and choose an existing account.")?; + accounts::validate(tool, &path)?; + Ok(Some(path)) +} +fn login_mode( + tool: Harness, + config: &ServerConfig, + route: &crate::server_config::Route, +) -> Result { + let clients = config.clients(); + let choices = config.choices(&route.key); + if choices.is_empty() { + return Err( + "This route cannot be installed by the app. Choose a route with models.".into(), + ); + } + let mut login = false; + for choice in choices { + let client = clients + .iter() + .find(|c| c.name == choice.client) + .ok_or("The route has an unknown endpoint.")?; + if client.forward_auth { + let url = url::Url::parse(&client.base_url) + .map_err(|_| "The route has an invalid endpoint URL.")?; + let host = url + .host_str() + .ok_or("The route has an invalid endpoint URL.")?; + let supported = url.scheme() == "https" + && url.port().is_none_or(|port| port == 443) + && match tool { + Harness::CodexCli | Harness::CodexApp => host == "chatgpt.com", + Harness::Claude => host == "api.anthropic.com", + Harness::Pi => false, + }; + if !supported { + return Err(format!( + "This route forwards the caller's login to {host}. \ + Pick a matching harness or a route with server API credentials." + )); + } + login = true; + } + } + Ok(login) +} + fn install_metadata(settings: &std::path::Path) -> Result { let executable = std::env::current_exe().map_err(|e| e.to_string())?; let bundle = switchyard_desktop::install::bundle_metadata(&executable); @@ -1075,6 +1474,132 @@ mod tests { let input = r#"{"kind":"apply","generation":0,"route":"r","algorithm":"passthrough","choices":[{"client":"c","model":"m","extra":1}]}"#; assert!(serde_json::from_str::(input).is_err()); } + #[test] + // A custom destination must use the same files for preview, installation, backup, and restore. + fn custom_settings_are_validated_and_restored() { + let dir = tempfile::tempdir().expect("directory"); + for (tool, _) in harness::HARNESSES { + let file = dir.path().join(if *tool == Harness::Pi { + "models.json" + } else { + "custom-settings" + }); + assert_eq!( + install_paths(*tool, None, Some(file.to_str().expect("path"))).expect("custom")[0], + file + ); + } + for (account, file) in [ + (None, "relative.json"), + (None, ""), + (Some("account"), "/tmp/settings.json"), + (None, "/tmp/settings\n.json"), + ] { + assert!(install_paths(Harness::Claude, account, Some(file)).is_err()); + } + assert!(install_paths(Harness::Pi, None, Some("/tmp/settings.json")).is_err()); + assert!(install_paths(Harness::Claude, None, dir.path().to_str()).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 0); + let settings = dir.path().join("app.toml"); + let config = dir.path().join("server.toml"); + std::fs::write( + &config, + "[llm_clients.c]\n\ + base_url='https://example.com/v1'\n\ + format='openai_chat'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n", + ) + .expect("config"); + std::fs::write( + &settings, + format!( + "config_file={config:?}\nrouting_log={:?}", + dir.path().join("log") + ), + ) + .expect("settings"); + let custom = dir.path().join("custom.json"); + std::fs::write(&custom, "{\"user\":true}").expect("custom"); + let mut controller = Controller::new(Config::load(&settings).expect("settings"), settings); + let file = Some(custom.display().to_string()); + let preview = controller + .dispatch(Action::PreviewInstall { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + route: "r".into(), + id: "public".into(), + }) + .expect("preview"); + assert!( + preview.data["changes"] + .as_array() + .expect("diff") + .iter() + .any(|change| change["file"] == custom.display().to_string()) + ); + std::fs::write(&custom, "{\"external\":true}").expect("external edit"); + let rejected = controller.dispatch(Action::Install { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + route: "r".into(), + id: "public".into(), + preview_token: preview.data["preview_token"].as_str().map(str::to_string), + }); + assert!(rejected.is_err()); + assert_eq!( + std::fs::read_to_string(&custom).expect("custom"), + "{\"external\":true}" + ); + assert!( + !custom + .with_file_name("custom.json.switchyard-original") + .exists() + ); + std::fs::write(&custom, "{\"user\":true}").expect("restore fixture"); + controller + .dispatch(Action::Install { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + route: "r".into(), + id: "public".into(), + preview_token: preview.data["preview_token"].as_str().map(str::to_string), + }) + .expect("install"); + assert_eq!( + preview.data["proposed"], + harness::inspect(Harness::Claude, std::slice::from_ref(&custom)).expect("inspect") + ); + let restore_preview = controller + .dispatch(Action::PreviewRestore { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + }) + .expect("restore preview"); + controller + .dispatch(Action::Restore { + tool: Harness::Claude, + account: None, + settings_file: file, + preview_token: restore_preview.data["preview_token"] + .as_str() + .map(str::to_string), + }) + .expect("restore"); + assert_eq!( + std::fs::read_to_string(custom).expect("custom"), + "{\"user\":true}" + ); + } #[cfg(any(target_os = "macos", windows))] #[test] @@ -1471,4 +1996,60 @@ mod tests { assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); } } + + #[test] + fn rejected_install_has_no_writes() { + let dir = tempfile::tempdir().expect("directory"); + let settings = dir.path().join("settings.toml"); + let config = dir.path().join("server.toml"); + let text = "[llm_clients.c]\n\ + base_url='http://chatgpt.com/backend-api/codex'\n\ + forward_auth=true\n\ + format='openai_responses'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n"; + std::fs::write(&config, text).expect("config"); + std::fs::write( + &settings, + format!( + "config_file={:?}\nrouting_log={:?}", + config, + dir.path().join("log") + ), + ) + .expect("settings"); + let mut controller = Controller::new(Config::load(&settings).expect("settings"), settings); + for id in ["old", "public"] { + assert!( + controller + .dispatch(Action::Install { + tool: Harness::CodexCli, + account: None, + settings_file: None, + route: "r".into(), + id: id.into(), + preview_token: None, + }) + .is_err() + ); + assert_eq!(std::fs::read_to_string(&config).expect("config"), text); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); + } + let snapshot = controller.snapshot().expect("snapshot"); + assert!( + snapshot.routes[0] + .label + .contains("passthrough · c / actual") + ); + assert!( + !serde_json::to_string(&snapshot) + .expect("json") + .contains("backend-api") + ); + } } diff --git a/crates/switchyard-desktop/src/harness.rs b/crates/switchyard-desktop/src/harness.rs new file mode 100644 index 000000000..40fd320a3 --- /dev/null +++ b/crates/switchyard-desktop/src/harness.rs @@ -0,0 +1,1524 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module installs and restores user settings for the supported harnesses. + +use serde_json::{Value, json}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use toml_edit::{DocumentMut, value}; + +pub type ReviewedFile = (PathBuf, Option>); + +#[derive( + Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize, +)] +#[serde(rename_all = "snake_case")] +pub enum Harness { + CodexCli, + CodexApp, + Claude, + Pi, +} + +pub const HARNESSES: &[(Harness, &str)] = &[ + (Harness::CodexCli, "Codex CLI profile"), + (Harness::CodexApp, "Codex app and CLI defaults"), + (Harness::Claude, "Claude Code"), + (Harness::Pi, "Pi"), +]; + +/// This function uses the selected harness's environment variable when it is present. +/// This function resolves the home directory only when that variable is absent. +pub fn paths(tool: Harness) -> Result, String> { + let (variable, default, files): (&str, &str, &[&str]) = match tool { + Harness::CodexCli => ("CODEX_HOME", "~/.codex", &["sy.config.toml"]), + Harness::CodexApp => ("CODEX_HOME", "~/.codex", &["config.toml"]), + Harness::Claude => ("CLAUDE_CONFIG_DIR", "~/.claude", &["settings.json"]), + Harness::Pi => ( + "PI_CODING_AGENT_DIR", + "~/.pi/agent", + &["models.json", "settings.json"], + ), + }; + let directory = match std::env::var_os(variable) { + Some(path) => PathBuf::from(path), + None => crate::config::expand_home(Path::new(default))?, + }; + Ok(files.iter().map(|file| directory.join(file)).collect()) +} + +pub fn inspect(tool: Harness, files: &[PathBuf]) -> Result { + let texts = files + .iter() + .map(|path| read(path)) + .collect::, _>>()?; + inspect_settings(tool, files, &texts) +} + +fn inspect_settings(tool: Harness, files: &[PathBuf], texts: &[String]) -> Result { + let text = &texts[0]; + let model; + let endpoint; + match tool { + Harness::CodexCli | Harness::CodexApp => { + let doc = text + .parse::() + .map_err(|e| e.message().to_owned())?; + model = doc + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("Default") + .to_string(); + let provider = doc + .get("model_provider") + .and_then(|v| v.as_str()) + .unwrap_or("openai"); + endpoint = doc + .get("model_providers") + .and_then(|v| v.get(provider)) + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()) + .unwrap_or("Provider default") + .to_string(); + } + Harness::Claude => { + let doc = object(text)?; + model = doc + .pointer("/env/ANTHROPIC_MODEL") + .and_then(Value::as_str) + .or_else(|| doc.get("model").and_then(Value::as_str)) + .unwrap_or("Default") + .to_string(); + endpoint = doc + .pointer("/env/ANTHROPIC_BASE_URL") + .and_then(Value::as_str) + .unwrap_or("Anthropic default") + .to_string(); + } + Harness::Pi => { + let doc = object(&texts[1])?; + model = doc + .get("defaultModel") + .and_then(Value::as_str) + .unwrap_or("Default") + .to_string(); + let provider = doc + .get("defaultProvider") + .and_then(Value::as_str) + .unwrap_or("Default"); + let models = object(text)?; + endpoint = models + .get("providers") + .and_then(|v| v.get(provider)) + .and_then(|v| v.get("baseUrl")) + .and_then(Value::as_str) + .unwrap_or("Provider default") + .to_string(); + } + } + // The display keeps the URL scheme, host, and port to omit credentials and queries. + let endpoint = url::Url::parse(&endpoint) + .ok() + .map(|u| { + format!( + "{}://{}{}", + u.scheme(), + u.host_str().unwrap_or("?"), + u.port().map(|p| format!(":{p}")).unwrap_or_default() + ) + }) + .unwrap_or_else(|| "Provider default".into()); + Ok(format!( + "Model: {model}\nEndpoint: {endpoint}\nSettings: {}", + files + .iter() + .map(|file| file.display().to_string()) + .collect::>() + .join("\n ") + )) +} + +fn read_file(path: &Path) -> Result, String> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(format!( + "{} is a symlink. Edit its target yourself.", + path.display() + )); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.to_string()), + _ => {} + } + std::fs::read_to_string(path) + .map(Some) + .map_err(|e| format!("Read {}: {e}", path.display())) +} + +fn read(path: &Path) -> Result { + Ok(read_file(path)?.unwrap_or_default()) +} + +fn object(text: &str) -> Result { + let doc: Value = if text.is_empty() { + json!({}) + } else { + serde_json::from_str(text).map_err(|e| e.to_string())? + }; + if !doc.is_object() { + return Err("Settings must be a JSON object.".into()); + } + Ok(doc) +} + +fn map<'a>(doc: &'a mut Value, key: &str) -> Result<&'a mut Value, String> { + if doc.get(key).is_none() { + doc[key] = json!({}); + } + if !doc[key].is_object() { + return Err(format!("{key} must be a JSON object.")); + } + Ok(&mut doc[key]) +} + +pub fn validate(tool: Harness, root: &str, model: &str, login: bool) -> Result { + let url = url::Url::parse(root).map_err(|e| e.to_string())?; + if url.scheme() != "http" + || !matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + return Err( + "Install requires a local HTTP server URL, such as http://127.0.0.1:4123.".into(), + ); + } + if model.trim().is_empty() { + return Err("Pick a route first.".into()); + } + if tool == Harness::Pi && login { + return Err( + "Pi's custom provider needs a route with server API credentials. \ + Use Codex or Claude for subscription login forwarding." + .into(), + ); + } + // validate returns the parsed URL without a trailing slash so callers can append API paths. + Ok(url.to_string().trim_end_matches('/').to_owned()) +} + +fn codex_settings( + text: &str, + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result { + let mut doc = text + .parse::() + .map_err(|e| e.message().to_owned())?; + if doc + .get("model_providers") + .is_some_and(|item| item.as_table_like().is_none()) + { + return Err("model_providers must be a TOML table.".into()); + } + if doc + .get("model_providers") + .and_then(|v| v.get("sy")) + .is_some_and(|item| item.as_table_like().is_none()) + { + return Err("sy must be a TOML provider table.".into()); + } + doc["model"] = value(model); + doc["model_provider"] = value("sy"); + // Codex does not know a route ID, so it compacts only when the settings name the + // route's window. A window kept from another route would compact at the wrong size. + match context_window.and_then(|window| i64::try_from(window).ok()) { + Some(window) => doc["model_context_window"] = value(window), + None => { + doc.remove("model_context_window"); + } + } + doc["model_providers"]["sy"]["name"] = value("Switchyard"); + doc["model_providers"]["sy"]["base_url"] = value(format!("{root}/v1")); + doc["model_providers"]["sy"]["wire_api"] = value("responses"); + doc["model_providers"]["sy"]["requires_openai_auth"] = value(login); + // A stale env_key would make Codex require a local API key even when the + // server owns the credentials or forwards the harness’s subscription login. + doc["model_providers"]["sy"] + .as_table_like_mut() + .ok_or("sy must be a provider table")? + .remove("env_key"); + Ok(doc.to_string()) +} + +fn claude_settings(text: &str, root: &str, model: &str, login: bool) -> Result { + let mut doc = object(text)?; + let env = map(&mut doc, "env")?; + let role_models = [ + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + ]; + // The complete installation signature distinguishes our placeholder from a user credential. + let prior_switchyard = env["ANTHROPIC_BASE_URL"].as_str() == Some(root) + && env["ANTHROPIC_AUTH_TOKEN"].as_str() == Some("switchyard-local") + && env["ANTHROPIC_API_KEY"].as_str() == Some("") + && env["ANTHROPIC_MODEL"] + .as_str() + .is_some_and(|model| !model.is_empty()) + && role_models + .iter() + .all(|key| env[*key] == env["ANTHROPIC_MODEL"]); + env["ANTHROPIC_BASE_URL"] = json!(root); + env["ANTHROPIC_MODEL"] = json!(model); + // All model roles use the selected public route, including subagents. + for key in role_models { + env[key] = json!(model); + } + if login { + if prior_switchyard { + let values = env.as_object_mut().ok_or("env must be a JSON object.")?; + values.remove("ANTHROPIC_AUTH_TOKEN"); + values.remove("ANTHROPIC_API_KEY"); + } + for key in ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] { + if env.get(key).is_some_and(|value| value.as_str() != Some("")) { + return Err(format!( + "{key} overrides subscription login. \ + Remove it from Claude settings before installing this route." + )); + } + } + } else { + env["ANTHROPIC_AUTH_TOKEN"] = json!("switchyard-local"); + env["ANTHROPIC_API_KEY"] = json!(""); + } + Ok(serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n") +} + +// The preview uses the installation transformations without creating files or backups. +pub fn preview( + tool: Harness, + files: &[PathBuf], + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result { + let root = validate(tool, root, model, login)?; + for path in files { + if read_file(&restore_pending_path(path))?.is_some() { + return Err("A previous restore still needs backup cleanup. \ + Retry Restore before connecting another route." + .into()); + } + } + let originals = files + .iter() + .map(|path| read_file(path)) + .collect::, _>>()?; + let incoming = configured(tool, &originals, &root, model, context_window, login)?; + let current = originals + .iter() + .map(|text| text.clone().unwrap_or_default()) + .collect::>(); + Ok(json!({ + "changes": settings_changes(tool, files, ¤t, &incoming)?, + "current": inspect_settings(tool, files, ¤t)?, + "proposed": inspect_settings(tool, files, &incoming)?, + "authentication": if login { + "This route uses the harness’s subscription login." + } else { + "This route uses the API credentials configured on the Switchyard server." + }, + "files": files.iter().map(|path| path.display().to_string()).collect::>(), + "file_actions": files.iter().zip(originals.iter().zip(&incoming)).map(|(path,(before,after))|json!({"file":path,"action":if before.as_deref()==Some(after.as_str()) {"preserve"} else if before.is_none() {"create"} else {"update"}})).collect::>() + })) +} + +// PreviewModel and its nested types omit unknown fields that could contain credentials. +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct PreviewModel { + id: Option, + name: Option, + reasoning: Option, + input: Option>, + context_window: Option, + max_tokens: Option, + cost: Option, +} +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +enum PreviewInput { + Text, + Image, +} +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct PreviewCosts { + input: Option, + output: Option, + cache_read: Option, + cache_write: Option, +} + +// settings_changes compares supported routing fields and hides credential values before display. +fn settings_changes( + tool: Harness, + files: &[PathBuf], + before: &[String], + after: &[String], +) -> Result, String> { + let mut changes = Vec::new(); + for (index, file) in files.iter().enumerate() { + let decode = |text: &str| -> Result { + if matches!(tool, Harness::CodexCli | Harness::CodexApp) { + let doc: toml::Value = toml::from_str(text).map_err(|e| e.to_string())?; + serde_json::to_value(doc).map_err(|e| e.to_string()) + } else { + object(text) + } + }; + let old = decode(&before[index])?; + let new = decode(&after[index])?; + let keys: &[&str] = match tool { + Harness::CodexCli | Harness::CodexApp => &[ + "/model", + "/model_provider", + "/model_context_window", + "/model_providers/sy/name", + "/model_providers/sy/env_key", + "/model_providers/sy/base_url", + "/model_providers/sy/requires_openai_auth", + "/model_providers/sy/wire_api", + ], + Harness::Claude => &[ + "/model", + "/env/ANTHROPIC_MODEL", + "/env/ANTHROPIC_DEFAULT_OPUS_MODEL", + "/env/ANTHROPIC_DEFAULT_SONNET_MODEL", + "/env/ANTHROPIC_DEFAULT_HAIKU_MODEL", + "/env/ANTHROPIC_BASE_URL", + "/env/ANTHROPIC_AUTH_TOKEN", + "/env/ANTHROPIC_API_KEY", + ], + Harness::Pi if index == 0 => &[ + "/providers/switchyard/baseUrl", + "/providers/switchyard/api", + "/providers/switchyard/apiKey", + "/providers/switchyard/headers", + "/providers/switchyard/models", + ], + Harness::Pi => &["/defaultModel", "/defaultProvider"], + }; + for key in keys { + let left = old.pointer(key); + let right = new.pointer(key); + if left == right { + continue; + } + let display = |value: Option<&Value>| -> String { + let Some(value) = value else { + return "(unset)".into(); + }; + match *key { + "/env/ANTHROPIC_AUTH_TOKEN" + | "/env/ANTHROPIC_API_KEY" + | "/providers/switchyard/apiKey" => if value.as_str() == Some("") { + "(empty)" + } else { + "(set; hidden)" + } + .into(), + "/providers/switchyard/headers" => value + .as_object() + .map(|headers| { + headers + .keys() + .map(|name| format!("{name}: (set; hidden)")) + .collect::>() + .join(", ") + }) + .unwrap_or_else(|| "(configured headers; values hidden)".into()), + "/providers/switchyard/models" => value + .as_array() + .map(|models| { + models + .iter() + .map(|model| { + serde_json::from_value::(model.clone()) + .and_then(serde_json::to_value) + .unwrap_or_else(|_| { + json!("(unsupported model metadata; hidden)") + }) + }) + .collect::>() + }) + .map(|models| Value::Array(models).to_string()) + .unwrap_or_default(), + "/env/ANTHROPIC_BASE_URL" + | "/model_providers/sy/base_url" + | "/providers/switchyard/baseUrl" => value + .as_str() + .and_then(|v| url::Url::parse(v).ok()) + .map(|mut url| { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + url.to_string() + }) + .unwrap_or_else(|| "(configured endpoint)".into()), + _ => match value { + Value::String(value) => value.clone(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + _ => "(set; hidden)".into(), + }, + } + }; + changes.push(json!({"file":file.display().to_string(),"key":key.trim_start_matches('/').replace('/',"."),"before":display(left),"after":display(right)})); + } + } + Ok(changes) +} + +// Both preview and installation use these transformations so the proposed settings match the saved settings. +fn configured( + tool: Harness, + originals: &[Option], + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result, String> { + let mut incoming = Vec::new(); + match tool { + Harness::CodexCli | Harness::CodexApp => incoming.push(codex_settings( + originals[0].as_deref().unwrap_or_default(), + root, + model, + context_window, + login, + )?), + Harness::Claude => incoming.push(claude_settings( + originals[0].as_deref().unwrap_or_default(), + root, + model, + login, + )?), + Harness::Pi => { + let mut doc = object(originals[0].as_deref().unwrap_or_default())?; + let providers = map(&mut doc, "providers")?; + providers["switchyard"] = json!({"baseUrl":format!("{root}/v1"), "api":"openai-completions", "apiKey":"switchyard-local", "headers":{"x-switchyard-origin":"pi"}, "models":[{"id":model,"name":model,"reasoning":true,"input":["text"],"contextWindow":context_window.unwrap_or(128000),"maxTokens":16384,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}); + incoming.push(serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n"); + let mut settings = object(originals[1].as_deref().unwrap_or_default())?; + settings["defaultProvider"] = json!("switchyard"); + settings["defaultModel"] = json!(model); + incoming + .push(serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())? + "\n"); + } + } + Ok(incoming) +} + +#[cfg(test)] +pub fn install( + tool: Harness, + files: &[PathBuf], + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result { + install_reviewed(tool, files, root, model, context_window, login, None) +} + +pub fn install_reviewed( + tool: Harness, + files: &[PathBuf], + root: &str, + model: &str, + context_window: Option, + login: bool, + expected: Option<&[ReviewedFile]>, +) -> Result { + let root = validate(tool, root, model, login)?; + for path in files { + if read_file(&restore_pending_path(path))?.is_some() { + return Err("A previous restore did not finish retiring its backups. \ + Retry Restore before installing another route." + .into()); + } + } + let originals = files + .iter() + .map(|p| read_file(p)) + .collect::, _>>()?; + check_review(expected)?; + let incoming = configured(tool, &originals, &root, model, context_window, login)?; + let incoming: Vec<_> = incoming.into_iter().map(Some).collect(); + for (path, original) in files.iter().zip(&originals) { + if let Some(expected) = expected + .and_then(|files| files.iter().find(|(file, _)| file == path)) + .map(|(_, bytes)| bytes) + && original.as_ref().map(|text| text.as_bytes()) != expected.as_deref() + { + return Err("Harness settings changed after the preview. Review them again.".into()); + } + } + for (path, before) in files.iter().zip(&originals) { + let backup = backup_path(path); + let absent = absent_path(path); + if read_file(&backup)?.is_some() || read_file(&absent)?.is_some() { + continue; + } + let (destination, text) = match before { + Some(text) => (&backup, text.as_str()), + None => (&absent, ""), + }; + prepare(destination, text)? + .persist_noclobber(destination) + .map_err(|e| e.to_string())?; + } + replace(files, &originals, &incoming)?; + let next = match tool { + Harness::CodexCli => { + let profile = files[0] + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix(".config.toml")); + profile + .map(|name| format!(" Start Codex CLI with codex -p {name}.")) + .unwrap_or_else(|| " Restart Codex CLI.".into()) + } + Harness::Pi => " Reload Pi models with /model.".into(), + Harness::CodexApp => " Restart Codex app.".into(), + Harness::Claude => " Restart Claude Code.".into(), + }; + Ok(format!( + "Connected route {model}. Changed {}.{next}", + files + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + )) +} + +fn backup_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + "{}.switchyard-original", + path.file_name().unwrap_or_default().to_string_lossy() + )) +} + +// The marker distinguishes an originally missing file from an empty file. +// Restore removes installed settings only when the original file was missing. +fn absent_path(path: &Path) -> PathBuf { + backup_path(path).with_extension("switchyard-original-missing") +} + +fn restore_pending_path(path: &Path) -> PathBuf { + backup_path(path).with_extension("switchyard-restore-pending") +} + +pub fn review_files(files: &[PathBuf]) -> Vec { + files + .iter() + .flat_map(|path| { + [ + path.clone(), + backup_path(path), + absent_path(path), + restore_pending_path(path), + ] + }) + .collect() +} + +pub fn preview_restore(tool: Harness, files: &[PathBuf]) -> Result { + let resuming = files.iter().any(|path| restore_pending_path(path).exists()); + let mut plan = Vec::new(); + let mut before = Vec::new(); + let mut after = Vec::new(); + for path in files { + let current = read_file(path)?; + let pending = read_file(&restore_pending_path(path))?; + let original = match pending { + Some(text) => { + serde_json::from_str::>(&text).map_err(|error| error.to_string())? + } + None => match read_file(&backup_path(path))? { + Some(text) => Some(text), + None if read_file(&absent_path(path))?.is_some() => None, + None if resuming => current.clone(), + None => return Err("No original backup is available for this destination.".into()), + }, + }; + let action = if current == original { + "unchanged" + } else { + match (¤t, &original) { + (_, None) => "remove", + (None, _) => "create", + _ => "replace", + } + }; + before.push(current.unwrap_or_default()); + after.push(original.unwrap_or_default()); + plan.push(json!({"file":path,"action":action,"backup":backup_path(path)})); + } + Ok(json!({ + "files": plan, + "changes": settings_changes(tool, files, &before, &after).ok(), + "warning": "Restore replaces each listed file with its original version. \ + Later edits are kept in a recovery copy before replacement." + })) +} + +// Restore receipts keep the original contents or absence available until backup cleanup finishes. +#[cfg(test)] +pub fn restore(files: &[PathBuf]) -> Result { + restore_reviewed(files, None) +} + +pub fn restore_reviewed( + files: &[PathBuf], + expected: Option<&[ReviewedFile]>, +) -> Result { + let pending = files + .iter() + .map(|path| read_file(&restore_pending_path(path))) + .collect::, _>>()?; + let resuming = pending.iter().any(Option::is_some); + let mut restored = Vec::new(); + for (path, pending) in files.iter().zip(pending) { + let original = read_file(&backup_path(path))?; + let absent = read_file(&absent_path(path))?.is_some(); + let original = match pending { + Some(text) => serde_json::from_str::>(&text) + .map_err(|e| format!("Read pending restore: {e}"))?, + None if original.is_none() && !absent => { + if !resuming { + return Err("No original backup is available for this installation.".into()); + } + // A cleared receipt means this file finished restoring, so a retry preserves later edits. + read_file(path)? + } + None => original, + }; + restored.push(original); + } + let current = files + .iter() + .map(|p| read_file(p)) + .collect::, _>>()?; + check_review(expected)?; + for (path, original) in files.iter().zip(¤t) { + if let Some(expected) = expected + .and_then(|files| files.iter().find(|(file, _)| file == path)) + .map(|(_, bytes)| bytes) + && original.as_ref().map(|text| text.as_bytes()) != expected.as_deref() + { + return Err( + "Harness settings changed after the restore preview. Review them again.".into(), + ); + } + } + let mut kept_paths = Vec::new(); + for (path, before) in files.iter().zip(¤t) { + let Some(before) = before else { + continue; + }; + let mut recovery = tempfile::Builder::new() + .prefix("switchyard-before-restore-") + .tempfile_in(path.parent().ok_or("Settings need a parent directory")?) + .map_err(|e| e.to_string())?; + recovery + .write_all(before.as_bytes()) + .map_err(|e| e.to_string())?; + let (_, kept) = recovery.keep().map_err(|e| e.to_string())?; + kept_paths.push(kept.display().to_string()); + } + // Each private receipt stores the original contents or absence and blocks installation during cleanup. + for (path, original) in files.iter().zip(&restored) { + let pending = restore_pending_path(path); + if read_file(&pending)?.is_none() { + let text = serde_json::to_string(original).map_err(|e| e.to_string())?; + prepare(&pending, &text)? + .persist_noclobber(&pending) + .map_err(|e| e.to_string())?; + } + } + let recovery = format!("Previous settings: {}", kept_paths.join(", ")); + replace(files, ¤t, &restored).map_err(|error| format!("{error}. {recovery}"))?; + for path in files { + for retired in [backup_path(path), absent_path(path)] { + match std::fs::remove_file(&retired) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "Restored settings, but could not retire {}: {error}. \ + Retry Restore before installing another route. {recovery}", + retired.display() + )); + } + } + } + } + for path in files { + let pending = restore_pending_path(path); + std::fs::remove_file(&pending).map_err(|error| { + format!( + "Restored settings, but could not clear {}: {error}. {recovery}", + pending.display() + ) + })?; + } + Ok(format!( + "Restored original settings. Restart the harness. {recovery}" + )) +} + +fn check_review(expected: Option<&[ReviewedFile]>) -> Result<(), String> { + if let Some(files) = expected { + for (path, expected) in files { + let current = match std::fs::read(path) { + Ok(bytes) => Some(std::sync::Arc::<[u8]>::from(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.to_string()), + }; + if ¤t != expected { + return Err(format!( + "{} changed after the preview. Review it again.", + path.display() + )); + } + } + } + Ok(()) +} + +fn prepare(path: &Path, text: &str) -> Result { + let parent = path.parent().ok_or("Settings need a parent directory")?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + temp.write_all(text.as_bytes()) + .and_then(|_| temp.as_file().sync_all()) + .map_err(|e| e.to_string())?; + Ok(temp) +} + +// Each file replacement is atomic, but the set of replacements is not. +// A later failure triggers an attempt to restore files already replaced. +fn replace( + files: &[PathBuf], + before: &[Option], + after: &[Option], +) -> Result<(), String> { + let staged = files + .iter() + .zip(after) + .map(|(path, text)| text.as_deref().map(|text| prepare(path, text)).transpose()) + .collect::, _>>()?; + for (path, expected) in files.iter().zip(before) { + if read_file(path)? != *expected { + return Err(format!( + "{} changed while installing. Refresh and try again.", + path.display() + )); + } + } + for (index, (path, temp)) in files.iter().zip(staged).enumerate() { + let result = match temp { + Some(temp) => temp.persist(path).map(|_| ()).map_err(|e| e.to_string()), + None if before[index].is_some() => { + std::fs::remove_file(path).map_err(|e| e.to_string()) + } + None => Ok(()), + }; + if let Err(error) = result { + for (path, original) in files[..index].iter().zip(before) { + match original { + Some(text) => { + prepare(path, text)? + .persist(path) + .map_err(|e| e.to_string())?; + } + None => std::fs::remove_file(path).map_err(|e| e.to_string())?, + } + } + return Err(error); + } + } + Ok(()) +} + +pub fn binary(tool: Harness) -> Option { + if tool == Harness::CodexApp { + #[cfg(windows)] + return crate::windows::codex_app(); + #[cfg(not(windows))] + { + #[cfg(target_os = "macos")] + if let Some(path) = objc2_app_kit::NSWorkspace::sharedWorkspace() + .URLForApplicationWithBundleIdentifier(&objc2_foundation::NSString::from_str( + "com.openai.codex", + )) + .and_then(|url| url.path()) + .map(|path| PathBuf::from(path.to_string())) + .filter(|path| path.is_dir()) + { + return Some(path); + } + return [ + Some(PathBuf::from("/Applications/Codex.app")), + crate::config::expand_home(Path::new("~/Applications/Codex.app")).ok(), + ] + .into_iter() + .flatten() + .find(|p| p.exists()); + } + } + let name = match tool { + Harness::CodexCli => "codex", + Harness::Claude => "claude", + Harness::Pi => "pi", + Harness::CodexApp => return None, + }; + let mut dirs: Vec<_> = std::env::var_os("PATH") + .map(|s| std::env::split_paths(&s).collect()) + .unwrap_or_default(); + dirs.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + ]); + dirs.extend( + [ + crate::config::expand_home(Path::new("~/.local/bin")).ok(), + crate::config::expand_home(Path::new("~/.npm-global/bin")).ok(), + ] + .into_iter() + .flatten(), + ); + #[cfg(windows)] + { + dirs.extend( + [ + crate::config::expand_home(Path::new("~/AppData/Roaming/npm")).ok(), + crate::config::expand_home(Path::new("~/.cargo/bin")).ok(), + crate::config::expand_home(Path::new("~/.local/bin")).ok(), + ] + .into_iter() + .flatten(), + ); + dirs.into_iter() + .flat_map(|p| [p.join(format!("{name}.exe")), p.join(format!("{name}.cmd"))]) + .find(|p| p.is_file()) + } + #[cfg(not(windows))] + dirs.into_iter().map(|p| p.join(name)).find(|p| p.is_file()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + // Nested unknown fields and malformed metadata must not expose credentials in the preview. + fn pi_preview_includes_model_limits_and_redacts_headers() { + let dir = tempfile::tempdir().expect("directory"); + let files = vec![ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ]; + std::fs::write(&files[0], r#"{"providers":{"switchyard":{"baseUrl":"http://localhost:4123/v1","api":"openai-completions","apiKey":"SECRET","headers":{"Authorization":"SECRET"},"models":[{"id":"route","name":"Route","reasoning":true,"input":["text","image"],"contextWindow":1000,"maxTokens":20,"cost":{"input":12,"output":3,"cacheRead":1,"cacheWrite":2,"unknown":{"key":"SECRET"}}},{"id":"bad-name","name":{"key":"SECRET"}},{"id":"bad-cost","cost":{"input":{"key":"SECRET"}}},{"id":"bad-input","input":["text",{"key":"SECRET"}]}]}}}"#).expect("models"); + let result = preview( + Harness::Pi, + &files, + "http://localhost:4123", + "route", + None, + false, + ) + .expect("preview"); + assert!(!result.to_string().contains("SECRET")); + let changes = result["changes"].as_array().expect("changes"); + let models = changes + .iter() + .find(|change| change["key"] == "providers.switchyard.models") + .expect("models diff"); + assert!(models["before"].as_str().expect("before").contains("1000")); + assert!(models["after"].as_str().expect("after").contains("128000")); + let projected: Value = + serde_json::from_str(models["before"].as_str().expect("before")).expect("models"); + assert_eq!( + projected[0]["cost"], + json!({"input":12.0,"output":3.0,"cacheRead":1.0,"cacheWrite":2.0}) + ); + assert_eq!(projected[0]["input"], json!(["text", "image"])); + assert_eq!(projected[0]["name"], "Route"); + for model in projected.as_array().expect("models").iter().skip(1) { + assert_eq!(model, "(unsupported model metadata; hidden)"); + } + + assert!( + changes + .iter() + .any(|change| change["key"] == "providers.switchyard.headers") + ); + } + + #[test] + // Preview must match the installed settings without creating files or backups. + fn preview_matches_installation_without_writes() { + for (tool, _) in HARNESSES { + let dir = tempfile::tempdir().expect("directory"); + let files = if *tool == Harness::Pi { + vec![ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ] + } else { + vec![dir.path().join("config")] + }; + let original = if matches!(tool, Harness::CodexCli | Harness::CodexApp) { + "# user comment\nmodel='before'\nsandbox_mode='workspace-write'\n" + } else { + "{\"custom\":true}" + }; + std::fs::write(&files[0], original).expect("settings"); + let result = preview( + *tool, + &files, + "http://localhost:4123", + "new-route", + None, + false, + ) + .expect("preview"); + assert!( + result["changes"] + .as_array() + .expect("diff") + .iter() + .any(|change| change["after"] + .as_str() + .is_some_and(|value| value.contains("new-route"))) + ); + assert_eq!(read(&files[0]).expect("unchanged"), original); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 1); + install( + *tool, + &files, + "http://localhost:4123", + "new-route", + None, + false, + ) + .expect("install"); + assert_eq!( + result["proposed"], + inspect(*tool, &files).expect("installed") + ); + install( + *tool, + &files, + "http://localhost:4123", + "refreshed-route", + None, + false, + ) + .expect("refresh"); + let installed = read(&files[0]).expect("settings"); + assert!(installed.contains("refreshed-route")); + assert!( + installed.contains(if matches!(tool, Harness::CodexCli | Harness::CodexApp) { + "sandbox_mode" + } else { + "custom" + }) + ); + assert_eq!(read(&backup_path(&files[0])).expect("backup"), original); + restore(&files).expect("restore"); + assert_eq!(read(&files[0]).expect("original"), original); + if *tool == Harness::Pi { + assert!(!files[1].exists()); + } + std::fs::write(&files[0], original.replace("before", "new-user-model")) + .expect("user edit"); + install(*tool, &files, "http://localhost:4123", "again", None, false) + .expect("reinstall"); + restore(&files).expect("restore again"); + assert_eq!( + read(&files[0]).expect("new original"), + original.replace("before", "new-user-model") + ); + assert!( + result["authentication"] + .as_str() + .expect("authentication") + .contains("API credentials") + ); + } + let dir = tempfile::tempdir().expect("directory"); + let files = vec![dir.path().join("settings.json")]; + let original = + r#"{"env":{"ANTHROPIC_API_KEY":"SECRET","ANTHROPIC_MODEL":{"key":"SECRET"}}}"#; + std::fs::write(&files[0], original).expect("settings"); + assert!( + preview( + Harness::Claude, + &files, + "http://localhost:4123", + "route", + None, + true + ) + .is_err() + ); + assert_eq!(read(&files[0]).expect("unchanged"), original); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 1); + let safe = preview( + Harness::Claude, + &files, + "http://localhost:4123", + "route", + None, + false, + ) + .expect("API preview"); + assert!(!safe.to_string().contains("SECRET")); + assert!( + safe["changes"] + .as_array() + .expect("diff") + .iter() + .any(|change| change["key"] == "env.ANTHROPIC_API_KEY" + && change["before"] == "(set; hidden)") + ); + std::fs::write(&files[0], "{}").expect("settings"); + let result = preview( + Harness::Claude, + &files, + "http://localhost:4123", + "route", + None, + true, + ) + .expect("login preview"); + assert!( + result["authentication"] + .as_str() + .expect("authentication") + .contains("subscription login") + ); + } + + #[test] + fn a_pending_restore_blocks_install_and_retires_stale_backups_on_retry() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("settings.json"); + let files = std::slice::from_ref(&path); + std::fs::write(&path, "{}").expect("settings"); + install( + Harness::Claude, + files, + "http://localhost:4123", + "api", + None, + false, + ) + .expect("install"); + let pending = restore_pending_path(&path); + std::fs::write( + &pending, + serde_json::to_string(&Some("{}".to_string())).expect("receipt"), + ) + .expect("pending retirement"); + let installed = read(&path).expect("installed"); + assert!( + install( + Harness::Claude, + files, + "http://localhost:4123", + "another", + None, + false + ) + .is_err() + ); + assert_eq!(read(&path).expect("unchanged"), installed); + // The backup was already retired, so retry uses the saved restoration receipt. + std::fs::remove_file(backup_path(&path)).expect("retired backup"); + restore(files).expect("retry restore"); + assert_eq!(read(&path).expect("restored"), "{}"); + assert!(!pending.exists()); + } + + // Pi cleanup can stop between files; retry must keep edits to a file that already finished. + #[test] + fn pi_restore_retry_preserves_a_file_whose_receipt_was_already_cleared() { + let dir = tempfile::tempdir().expect("directory"); + let files = vec![ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ]; + for path in &files { + std::fs::write(path, "{}").expect("original"); + } + install( + Harness::Pi, + &files, + "http://localhost:4123", + "route", + None, + false, + ) + .expect("install"); + for path in &files { + std::fs::remove_file(backup_path(path)).expect("retired backup"); + std::fs::write(path, "{}").expect("restored settings"); + } + std::fs::write(&files[0], "{\"user_edit\":true}").expect("edit after restore"); + std::fs::write(restore_pending_path(&files[1]), r#""{}""#).expect("remaining receipt"); + restore(&files).expect("retry cleanup"); + assert_eq!(read(&files[0]).expect("first file"), "{\"user_edit\":true}"); + assert_eq!(read(&files[1]).expect("second file"), "{}"); + assert!( + files + .iter() + .all(|path| !restore_pending_path(path).exists()) + ); + } + + #[test] + fn subscription_install_removes_only_switchyard_credentials() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("settings.json"); + let files = std::slice::from_ref(&path); + std::fs::write(&path, "{}").expect("settings"); + install( + Harness::Claude, + files, + "http://localhost:4123", + "api-route", + None, + false, + ) + .expect("API install"); + install( + Harness::Claude, + files, + "http://localhost:4123", + "subscription", + None, + true, + ) + .expect("subscription install"); + let doc = object(&read(&path).expect("settings")).expect("JSON"); + assert!(doc["env"].get("ANTHROPIC_AUTH_TOKEN").is_none()); + assert!(doc["env"].get("ANTHROPIC_API_KEY").is_none()); + assert_eq!(doc["env"]["ANTHROPIC_MODEL"], "subscription"); + for key in ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] { + for value in [ + json!("user-secret"), + json!("switchyard-local"), + json!(null), + json!(false), + json!(123), + json!([]), + json!({}), + ] { + let original = json!({"env":{key:value}}).to_string(); + std::fs::write(&path, &original).expect("settings"); + let before = std::fs::read_dir(dir.path()).expect("files").count(); + assert!( + install( + Harness::Claude, + files, + "http://localhost:4123", + "subscription", + None, + true + ) + .is_err() + ); + assert_eq!(read(&path).expect("unchanged"), original); + assert_eq!( + std::fs::read_dir(dir.path()).expect("files").count(), + before + ); + } + } + } + + #[test] + fn invalid_inputs_change_no_files() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("settings.json"); + for invalid in ["[]", "null", "{", "{\"env\":false}"] { + std::fs::write(&path, invalid).expect("settings"); + assert!( + install( + Harness::Claude, + std::slice::from_ref(&path), + "http://localhost:4123", + "route", + None, + false + ) + .is_err() + ); + assert_eq!(read(&path).expect("read"), invalid); + assert!(!backup_path(&path).exists()); + } + std::fs::write(&path, "{}").expect("settings"); + for root in [ + "https://example.com", + "http://localhost.evil", + "http://user:secret@localhost", + "http://127.0.0.1/path", + "http://localhost?q=a", + ] { + assert!( + install( + Harness::Claude, + std::slice::from_ref(&path), + root, + "route", + None, + false + ) + .is_err() + ); + assert_eq!(read(&path).expect("read"), "{}"); + } + assert!(!backup_path(&path).exists()); + } + #[test] + fn pi_preflights_both_files() { + let dir = tempfile::tempdir().expect("directory"); + let files = vec![ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ]; + std::fs::write(&files[0], "{}").expect("models"); + std::fs::write(&files[1], "[]").expect("settings"); + assert!( + install( + Harness::Pi, + &files, + "http://localhost:4123", + "route", + None, + false + ) + .is_err() + ); + assert_eq!(read(&files[0]).expect("read"), "{}"); + assert!(!backup_path(&files[0]).exists()); + } + // Codex compacts only at the window the settings name, so a stale window must not survive. + #[test] + fn route_windows_reach_codex_and_pi_settings() { + let dir = tempfile::tempdir().expect("directory"); + let codex = [dir.path().join("config.toml")]; + let window = |text: &str| { + text.parse::() + .expect("toml") + .get("model_context_window") + .and_then(|item| item.as_integer()) + }; + install( + Harness::CodexCli, + &codex, + "http://localhost:4123", + "wide", + Some(1_000_000), + false, + ) + .expect("install"); + assert_eq!(window(&read(&codex[0]).expect("read")), Some(1_000_000)); + let removal = preview( + Harness::CodexCli, + &codex, + "http://localhost:4123", + "unsized", + None, + false, + ) + .expect("preview"); + assert!( + removal["changes"] + .as_array() + .expect("diff") + .iter() + .any(|change| change["key"] == "model_context_window" + && change["before"] == "1000000" + && change["after"] == "(unset)") + ); + install( + Harness::CodexCli, + &codex, + "http://localhost:4123", + "unsized", + None, + false, + ) + .expect("reinstall"); + assert_eq!(window(&read(&codex[0]).expect("read")), None); + let pi = [ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ]; + install( + Harness::Pi, + &pi, + "http://localhost:4123", + "wide", + Some(500_000), + false, + ) + .expect("pi"); + let models: Value = serde_json::from_str(&read(&pi[0]).expect("read")).expect("json"); + assert_eq!( + models["providers"]["switchyard"]["models"][0]["contextWindow"], + 500_000 + ); + } + #[test] + fn malformed_codex_provider_tables_do_not_panic_or_write() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("config.toml"); + for text in [ + "model_providers = 1", + "model_providers = []", + "[model_providers]\nsy = false", + "[[model_providers.sy]]\nname = 'old'", + ] { + std::fs::write(&path, text).expect("settings"); + assert!( + install( + Harness::CodexCli, + std::slice::from_ref(&path), + "http://localhost:4123", + "route", + None, + false + ) + .is_err() + ); + assert_eq!(read(&path).expect("read"), text); + assert!(!backup_path(&path).exists()); + } + std::fs::write( + &path, + "model_providers = { sy = { name = 'old', env_key = 'OLD_KEY' } }\n", + ) + .expect("settings"); + install( + Harness::CodexCli, + std::slice::from_ref(&path), + "http://localhost:4123", + "route", + None, + false, + ) + .expect("inline table"); + let doc = read(&path) + .expect("read") + .parse::() + .expect("parse"); + assert_eq!( + doc["model_providers"]["sy"]["base_url"].as_str(), + Some("http://localhost:4123/v1") + ); + assert!(doc["model_providers"]["sy"].get("env_key").is_none()); + } + + #[test] + fn inspection_omits_url_secrets_and_invalid_toml_source() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + "model_provider = 'p'\n\ + [model_providers.p]\n\ + base_url = 'https://user:SECRET@example.com:8443/v1?key=SECRET'\n", + ) + .expect("settings"); + let info = inspect(Harness::CodexCli, std::slice::from_ref(&path)).expect("inspect"); + assert!(info.contains("https://example.com:8443")); + assert!(!info.contains("SECRET")); + std::fs::write(&path, "api_key = \"SECRET\n").expect("invalid"); + let error = inspect(Harness::CodexCli, std::slice::from_ref(&path)).expect_err("invalid"); + assert!(!error.contains("SECRET")); + } + + #[test] + fn replacement_prepares_every_file_and_rejects_changed_inputs() { + let dir = tempfile::tempdir().expect("directory"); + let first = dir.path().join("first"); + let blocked = dir.path().join("blocked"); + std::fs::write(&first, "original").expect("first"); + std::fs::write(&blocked, "not a directory").expect("blocked"); + let files = vec![first.clone(), blocked.join("second")]; + assert!( + replace( + &files, + &[Some("original".into()), None], + &[Some("new".into()), Some("new".into())] + ) + .is_err() + ); + assert_eq!(read(&first).expect("read"), "original"); + assert!( + replace( + std::slice::from_ref(&first), + &[Some("stale".into())], + &[Some("new".into())] + ) + .is_err() + ); + assert_eq!(read(&first).expect("read"), "original"); + } + + #[cfg(unix)] + #[test] + fn restore_preflights_symlinks_before_changing_any_settings() { + let dir = tempfile::tempdir().expect("directory"); + let files = vec![ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ]; + for path in &files { + std::fs::write(path, "{}").expect("original"); + } + install( + Harness::Pi, + &files, + "http://localhost:4123", + "route", + None, + false, + ) + .expect("install"); + let before = read(&files[0]).expect("read"); + std::fs::remove_file(&files[1]).expect("remove"); + let target = dir.path().join("target"); + std::fs::write(&target, "private").expect("target"); + std::os::unix::fs::symlink(&target, &files[1]).expect("symlink"); + assert!(restore(&files).is_err()); + assert!(files.iter().all(|path| backup_path(path).is_file())); + assert_eq!(read(&files[0]).expect("read"), before); + assert_eq!(std::fs::read_to_string(target).expect("target"), "private"); + } +} diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index 2e046b75d..e6b727da2 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -10,7 +10,9 @@ #![cfg_attr(windows, windows_subsystem = "windows")] use switchyard_desktop::{config, health}; +mod accounts; mod controller; +mod harness; mod history; mod models; mod pricing; @@ -31,7 +33,7 @@ use config::Config; const USAGE: &str = "\ Usage: switchyard-desktop [--tui | --print | --validate-toml] [FILE] -This app manages routes and usage. +This app manages routes, harness settings, and usage. FILE The app reads settings from FILE. --tui Run the terminal app. @@ -52,6 +54,17 @@ baseline_model's bundled rates or the table selected by pricing_file. Recorded spending remains visible when savings are unknown."; fn main() -> ExitCode { + #[cfg(windows)] + if let Some(result) = windows::terminal_job() { + return match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + ExitCode::FAILURE + } + }; + } + #[cfg(windows)] windows::attach_console(); let mut settings = None; diff --git a/crates/switchyard-desktop/src/server.rs b/crates/switchyard-desktop/src/server.rs index 961352148..b74611606 100644 --- a/crates/switchyard-desktop/src/server.rs +++ b/crates/switchyard-desktop/src/server.rs @@ -460,6 +460,77 @@ pub fn command(program: &str, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } +// This function saves the script before opening a terminal so a save error cannot launch it. +// If no terminal starts, this function removes the script; callers supply scripts that remove themselves on startup. +#[cfg(not(windows))] +pub fn open_terminal_script(prefix: &str, text: &str) -> Result<(), String> { + let mut script = tempfile::Builder::new() + .prefix(prefix) + .suffix(".command") + .tempfile() + .map_err(|e| e.to_string())?; + writeln!(script, "{text}").map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + script + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + } + let (_, path) = script.keep().map_err(|e| e.to_string())?; + if let Err(error) = run_in_terminal(&path) { + let _ = std::fs::remove_file(path); + return Err(error); + } + Ok(()) +} + +// macOS `open` runs a `.command` file in Terminal. +#[cfg(not(any(windows, target_os = "linux")))] +fn run_in_terminal(path: &Path) -> Result<(), String> { + command("open", &[&path.display().to_string()]).map(drop) +} + +// xdg-open may open the script in an editor, so this function starts a terminal program that runs it. +// It tries $TERMINAL first and then common terminal programs, and uses the first one that starts. +#[cfg(target_os = "linux")] +fn run_in_terminal(path: &Path) -> Result<(), String> { + let preferred = std::env::var("TERMINAL") + .ok() + .filter(|name| !name.is_empty()); + let terminals = preferred + .as_deref() + .map(|name| (name, "-e")) + .into_iter() + .chain([ + ("x-terminal-emulator", "-e"), + ("gnome-terminal", "--"), + ("konsole", "-e"), + ("xterm", "-e"), + ]); + for (program, flag) in terminals { + let started = Command::new(program) + .arg(flag) + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + if let Ok(mut terminal) = started { + // A thread waits for the terminal so the exited process does not stay a zombie. + std::thread::spawn(move || terminal.wait()); + return Ok(()); + } + } + Err( + "Could not start a terminal. Switchyard tried $TERMINAL, x-terminal-emulator, \ + gnome-terminal, konsole, and xterm. Set the TERMINAL environment variable to your \ + terminal program, for example TERMINAL=xterm, and try again." + .into(), + ) +} + #[cfg(all(test, unix))] mod tests { use super::*; @@ -597,6 +668,42 @@ exec "$3" --config "$5" --dry-run assert_eq!(std::fs::read_to_string(&real).expect("read"), NEW); } + // The subprocess isolates PATH, TERMINAL, and TMPDIR so the launch cannot open an app + // and every script it writes stays in a directory the test checks. + // On macOS the `open` fixture fails; on Linux PATH holds no terminal program. + #[test] + fn a_rejected_terminal_launch_removes_the_kept_script() { + if let Ok(scripts) = std::env::var("SWITCHYARD_TERMINAL_FIXTURE") { + assert!(open_terminal_script("switchyard-terminal-fixture-", "#!/bin/sh").is_err()); + let left = std::fs::read_dir(scripts).expect("scripts").count(); + assert_eq!(left, 0, "the script was removed"); + return; + } + let dir = tempfile::tempdir().expect("fixture"); + let scripts = dir.path().join("scripts"); + std::fs::create_dir(&scripts).expect("script directory"); + let open = dir.path().join("open"); + std::fs::write(&open, "#!/bin/sh\nexit 1\n").expect("Terminal stub"); + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o700)) + .expect("permissions"); + let output = Command::new(std::env::current_exe().expect("runner")) + .args([ + "--exact", + "server::tests::a_rejected_terminal_launch_removes_the_kept_script", + ]) + .env("SWITCHYARD_TERMINAL_FIXTURE", &scripts) + .env("TMPDIR", &scripts) + .env("PATH", dir.path()) + .env_remove("TERMINAL") + .output() + .expect("child"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + } + #[cfg(target_os = "linux")] #[test] fn restart_uses_the_linux_user_service_and_returns_command_failure() { diff --git a/crates/switchyard-desktop/src/server_config.rs b/crates/switchyard-desktop/src/server_config.rs index a4d16e0c1..c248441a9 100644 --- a/crates/switchyard-desktop/src/server_config.rs +++ b/crates/switchyard-desktop/src/server_config.rs @@ -590,6 +590,15 @@ impl ServerConfig { ALGORITHMS.iter().find(|algorithm| algorithm.kind == kind) } + /// This function returns the route's `context_window`, the largest request in tokens + /// that the route accepts, or `None` when the route does not set it. + pub fn context_window(&self, route: &str) -> Option { + self.route(route)? + .get("context_window")? + .as_integer() + .and_then(|window| u64::try_from(window).ok()) + } + /// This function returns the roles that `algorithm` needs on `route`. A random route /// keeps its number of targets, and has at least two. pub fn roles(&self, route: &str, algorithm: &Algorithm) -> Vec { diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index fe6280e6b..648255337 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -1,15 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The terminal app shows usage, recent calls, and routes from controller snapshots, and it -//! runs server and route actions. +//! The terminal app shows usage, recent calls, routes, and harness settings from controller +//! snapshots, and it runs server, route, harness, and account actions. //! //! The UI thread owns every view setting. One worker thread owns the controller and runs //! refreshes and actions, so moving around and filtering never wait for the log or a service. use crate::controller::{ - Action, ActionKind, AppSettings, Availability, Controller, Reply, RouteInfo, Snapshot, + AccountInfo, Action, ActionKind, AppSettings, Availability, Controller, Dependent, Reply, + RouteInfo, Snapshot, ToolInfo, }; +use crate::harness::Harness; use crate::history::Entry; use crate::pricing::Estimate; use crate::rollup::{CLASSIFIER_TIER, ModelTokens, Totals, Usage}; @@ -52,7 +54,7 @@ const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', ' type Key = (&'static str, &'static str, &'static str); const GLOBAL_KEYS: &[Key] = &[ ( - "1-5", + "1-6", "page", "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", ), @@ -108,6 +110,27 @@ const ROUTES_KEYS: &[Key] = &[ ), ("esc", "clear", "Clear the filter."), ]; +const HARNESSES_KEYS: &[Key] = &[ + ( + "↑↓", + "route", + "Choose the route to install. j, k, PgUp, PgDn, g, and G also move it. The Routes page \ + selects the same route, and its filter limits the choices.", + ), + ("t", "harness", "Select the next harness."), + ( + "a", + "account", + "Select the next saved account, then the harness's own settings again. Install, \ + restore, and the account keys use the selection.", + ), + ( + "x", + "custom file", + "Install to or restore a custom settings file instead. Leave it empty to use the \ + detected settings again.", + ), +]; const SETTINGS_KEYS: &[Key] = &[ ("↑↓", "select", "Select an action. j, g, and G also move."), ( @@ -206,6 +229,62 @@ const ACTIONS: &[ActionRow] = &[ "Saves an API key that lets the app load an endpoint's model IDs, then loads them.", &[ActionKind::Models], ), + ( + Tab::Harnesses, + 'p', + "preview", + "Shows the settings diff that installing the selected route would make. Nothing is \ + saved.", + &[ActionKind::PreviewInstall], + ), + ( + Tab::Harnesses, + 'i', + "install", + "Shows the settings diff, then saves it after you type INSTALL. A backup keeps the \ + original settings.", + &[ActionKind::PreviewInstall, ActionKind::Install], + ), + ( + Tab::Harnesses, + 'u', + "restore", + "Shows which files return to their original versions, then restores them after you \ + type RESTORE.", + &[ActionKind::PreviewRestore, ActionKind::Restore], + ), + ( + Tab::Harnesses, + 'n', + "add account", + "Creates a saved account and opens the harness's login in a new terminal window.", + &[ActionKind::AddAccount], + ), + ( + Tab::Harnesses, + 'l', + "login", + "Opens the selected account's login again in a new terminal window.", + &[ActionKind::LoginAccount], + ), + ( + Tab::Harnesses, + 'o', + "open account", + "Opens the selected account folder in $VISUAL or $EDITOR.", + &[ActionKind::OpenAccount], + ), + ( + Tab::Harnesses, + 'd', + "archive", + "Moves the selected account out of the list after you type its name. Its login files \ + are kept.", + &[ + ActionKind::PreviewAccountArchive, + ActionKind::ArchiveAccount, + ], + ), ( Tab::Settings, 'o', @@ -326,6 +405,14 @@ const ROUTE_COLUMNS: [(Column, bool); 3] = [ (("ALGORITHM", Constraint::Length(24), false), false), (("MODELS", Constraint::Fill(2), false), true), ]; +/// The harnesses table drops the columns marked `true` below 80 columns; the details still +/// show the current settings. +const HARNESS_COLUMNS: [(Column, bool); 4] = [ + (("HARNESS", Constraint::Fill(2), false), false), + (("STATE", Constraint::Length(13), false), false), + (("CURRENT SETTINGS", Constraint::Fill(3), false), true), + (("ACCOUNTS", Constraint::Length(8), true), false), +]; /// Theme maps each kind of text to one style. NO_COLOR keeps only bold, dim, and reverse. #[derive(Clone, Copy)] @@ -379,13 +466,15 @@ enum Tab { Calls, Models, Routes, + Harnesses, Settings, } -const TABS: [Tab; 5] = [ +const TABS: [Tab; 6] = [ Tab::Overview, Tab::Calls, Tab::Models, Tab::Routes, + Tab::Harnesses, Tab::Settings, ]; impl Tab { @@ -395,6 +484,7 @@ impl Tab { Tab::Calls => "Calls", Tab::Models => "Models", Tab::Routes => "Routes", + Tab::Harnesses => "Harnesses", Tab::Settings => "Settings", } } @@ -481,8 +571,9 @@ enum FormKind { operation: &'static str, route: String, }, - /// The action is the one that the user reviewed. Typing SAVE submits it unchanged. - Save(Box), + /// The fields hold the word that the user must type and the reviewed action, which the + /// form submits unchanged. + Confirm(Cow<'static, str>, Box), Remove { generation: u64, route: String, @@ -491,17 +582,22 @@ enum FormKind { }, Models, ModelKey, + /// The harness is the one selected when the form opened. + Account(Harness), + SettingsFile, } /// Choices names the list that ↑ and ↓ cycle through in a form field. enum Choices { Algorithms, Endpoints, } -/// Submit is what a form sends: a controller action, or the next form of a flow that needs -/// more input before the controller can review it. +/// Submit is what a form sends: a controller action, the next form of a flow that needs more +/// input before the controller can review it, or a custom settings file, which changes only +/// where the Harnesses page installs. enum Submit { Run(Action), Next(Form), + SettingsFile(String), } /// Form holds the text fields for an action that needs input. struct Form { @@ -744,8 +840,8 @@ impl Form { choices: Vec::new(), } } - FormKind::Save(action) => { - confirm("SAVE", "Type SAVE to apply the reviewed changes.")?; + FormKind::Confirm(word, action) => { + confirm(word, &format!("Type {word} to apply the reviewed changes."))?; (**action).clone() } FormKind::Remove { @@ -772,6 +868,11 @@ impl Form { refresh: true, key: Some(self.value(1)), }, + FormKind::Account(tool) => Action::AddAccount { + tool: *tool, + name: self.value(0), + }, + FormKind::SettingsFile => return Ok(Submit::SettingsFile(self.value(0))), })) } fn label(&self) -> &'static str { @@ -786,10 +887,18 @@ impl Form { FormKind::NewRoute { .. } | FormKind::NewRouteModels { .. } | FormKind::RouteIdentity { .. } => "Reviewing the route change", - FormKind::Save(_) => "Saving the route", + // The reviewed action names what typing the confirmation word saves. + FormKind::Confirm(_, action) => match **action { + Action::Install { .. } => "Installing the harness settings", + Action::Restore { .. } => "Restoring the harness settings", + Action::ArchiveAccount { .. } => "Archiving the account", + _ => "Saving the route", + }, FormKind::Remove { .. } => "Deleting the route", FormKind::Models => "Loading models", FormKind::ModelKey => "Saving the key and loading models", + FormKind::Account(_) => "Adding the account", + FormKind::SettingsFile => "Choosing the settings file", } } } @@ -859,8 +968,10 @@ fn route_models_form( } /// This function returns the action that a preview's review window submits after the user -/// types SAVE: the previewed inputs, plus the token for a route change. +/// types the confirmation word: the previewed inputs, plus the preview's token for every flow +/// except a route edit, which sends its revision instead. fn reviewed(previewed: Action, preview: &Value) -> Option { + let preview_token = preview["preview_token"].as_str().map(str::to_owned); Some(match previewed { Action::PreviewRoute { generation, @@ -891,7 +1002,36 @@ fn reviewed(previewed: Action, preview: &Value) -> Option { id, algorithm, choices, - preview_token: preview["preview_token"].as_str().map(str::to_owned), + preview_token, + }, + Action::PreviewInstall { + tool, + account, + route, + id, + settings_file, + } => Action::Install { + tool, + account, + route, + id, + settings_file, + preview_token, + }, + Action::PreviewRestore { + tool, + account, + settings_file, + } => Action::Restore { + tool, + account, + settings_file, + preview_token, + }, + Action::PreviewAccountArchive { tool, name } => Action::ArchiveAccount { + tool, + name, + preview_token, }, _ => return None, }) @@ -970,18 +1110,23 @@ enum Done { struct Request { job: Job, label: &'static str, + /// This field holds the `ACTIONS` key that started the job. Keys that share a first action + /// continue differently when the reply arrives, such as `p` and `i` on the Harnesses page. + key: Option, } impl Request { fn run(action: Action, label: &'static str) -> Self { Self { job: Job::Run(action), label, + key: None, } } fn refresh() -> Self { Self { job: Job::Refresh, label: "Refreshing", + key: None, } } } @@ -990,6 +1135,7 @@ struct Busy { /// This field holds the running action, so its reply can update the right view and a /// preview's review window can submit exactly what the controller reviewed. action: Option, + key: Option, started: Instant, } struct Toast { @@ -1017,6 +1163,18 @@ impl Tone { } } +/// Destination is where the Harnesses page installs and restores settings. +enum Destination { + /// The harness's own settings files. + Harness, + /// A saved account, by name. The name stays after a refresh stops listing the account, so + /// its actions still send it and the controller rejects them instead of changing other + /// settings. + Account(String), + /// A custom settings file path. + File(String), +} + struct App { theme: Theme, data: Snapshot, @@ -1035,6 +1193,12 @@ struct App { route_visible: Vec, route_selected: usize, route_offset: usize, + /// This field indexes `data.tools`, which lists every harness in a fixed order. + harness: usize, + destination: Destination, + /// This field holds the last install preview and the action that produced it. The + /// Harnesses page shows it only while that action matches the selection. + install_preview: Option<(Action, Value)>, /// This field holds the last `ServerKeys` reply. It is `None` until the user checks the keys, /// and a failed check clears it so the page never shows outdated key states. server_keys: Option, @@ -1066,6 +1230,9 @@ impl App { route_visible: Vec::new(), route_selected: 0, route_offset: 0, + harness: 0, + destination: Destination::Harness, + install_preview: None, server_keys: None, popup: None, toast: None, @@ -1174,6 +1341,80 @@ impl App { } } + fn harness(&self) -> Result<&ToolInfo, String> { + self.data + .tools + .get(self.harness) + .ok_or_else(|| "No harness is listed. Fix the app settings, then press r.".into()) + } + + /// This function returns the selected account's name, or `None` when the harness's own + /// settings or a custom settings file is the destination. + fn account(&self) -> Option { + match &self.destination { + Destination::Account(name) => Some(name.clone()), + Destination::Harness | Destination::File(_) => None, + } + } + + fn custom_file(&self) -> Option { + match &self.destination { + Destination::File(path) => Some(path.clone()), + Destination::Harness | Destination::Account(_) => None, + } + } + + /// Account actions change one saved account, so this function rejects other destinations. + fn selected_account(&self) -> Result<(Harness, String), String> { + let name = self + .account() + .ok_or("Press a to select a saved account first.")?; + Ok((self.harness()?.tool, name)) + } + + /// This function builds the install preview of the selected route for the selected + /// harness and destination. + fn install_action(&self) -> Result { + let tool = self.harness()?.tool; + let route = self.selected_route()?; + Ok(Action::PreviewInstall { + tool, + account: self.account(), + route: route.key.clone(), + id: route.id.clone(), + settings_file: self.custom_file(), + }) + } + + /// This function returns the last install preview while it still describes the selection. + fn install_preview(&self) -> Option<&Value> { + let ( + Action::PreviewInstall { + tool, + account, + route, + id, + settings_file, + }, + preview, + ) = self.install_preview.as_ref()? + else { + return None; + }; + let selected = self.selected_route().ok()?; + let destination = match (&self.destination, account, settings_file) { + (Destination::Harness, None, None) => true, + (Destination::Account(name), Some(previewed), None) => name == previewed, + (Destination::File(path), None, Some(previewed)) => path == previewed, + _ => false, + }; + let current = destination + && self.harness().is_ok_and(|harness| harness.tool == *tool) + && selected.key == *route + && selected.id == *id; + current.then_some(preview) + } + /// Short messages appear in the status bar; long ones open a window so nothing is cut off. fn notify(&mut self, text: String, tone: Tone) { if text.is_empty() { @@ -1205,9 +1446,21 @@ impl App { reply: Option>, snapshot: Result, String>, ) -> Option { - let action = self.busy.take().and_then(|busy| busy.action); + let (action, key) = match self.busy.take() { + Some(busy) => (busy.action, busy.key), + None => (None, None), + }; self.apply_snapshot(snapshot.map(|snapshot| *snapshot)); - match (action.as_ref().map(ActionKind::from), reply?) { + let reply = reply?; + match (&action, reply.is_ok()) { + // Saved settings no longer match an earlier diff, and a failed preview has none. + (Some(Action::Install { .. } | Action::Restore { .. }), true) + | (Some(Action::PreviewInstall { .. }), false) => self.install_preview = None, + // The archived account leaves the list, so the harness's own settings are next. + (Some(Action::ArchiveAccount { .. }), true) => self.destination = Destination::Harness, + _ => {} + } + match (action.as_ref().map(ActionKind::from), reply) { (Some(ActionKind::ServerKeys), Err(error)) => { self.server_keys = None; self.notify(error, Tone::Error); @@ -1227,6 +1480,60 @@ impl App { "Type UPDATE to start", )); } + // The p key shows the diff on the page. The i key also opens the review, unless the + // settings already match the route. + (Some(ActionKind::PreviewInstall), Ok(reply)) => { + let previewed = action?; + let changed = reply.data["changes"] + .as_array() + .is_some_and(|changes| !changes.is_empty()); + if !changed { + self.notify("The settings already match this route.".into(), Tone::Done); + } else if key == Some('i') + && let Some(install @ Action::Install { tool, .. }) = + reviewed(previewed.clone(), &reply.data) + { + let known = self.data.tools.iter().find(|known| known.tool == tool); + let label = known.map_or("the harness", |known| known.label); + let title = format!("Apply routing to {label}?"); + self.popup = Some(Popup::review( + install_lines(self.theme, &reply.data), + "Install", + FormKind::Confirm("INSTALL".into(), Box::new(install)), + title, + "Type INSTALL to save the settings", + )); + } else { + let text = "Press i to apply this diff."; + self.notify(text.into(), Tone::Done); + } + self.install_preview = Some((previewed, reply.data)); + } + (Some(ActionKind::PreviewRestore), Ok(reply)) => { + if let Some(restore) = action.and_then(|action| reviewed(action, &reply.data)) { + self.popup = Some(Popup::review( + restore_lines(self.theme, &reply.data), + "Restore", + FormKind::Confirm("RESTORE".into(), Box::new(restore)), + "Restore harness settings?", + "Type RESTORE to replace the files", + )); + } + } + (Some(ActionKind::PreviewAccountArchive), Ok(reply)) => { + if let Some(archive) = action.and_then(|action| reviewed(action, &reply.data)) + && let Action::ArchiveAccount { name, .. } = &archive + { + let name = name.clone(); + self.popup = Some(Popup::review( + archive_lines(self.theme, &reply.data), + "Archive", + FormKind::Confirm(name.clone().into(), Box::new(archive)), + format!("Archive the account {name}?"), + "Type the account name to archive", + )); + } + } // The reply lists the roles of the chosen algorithm. The models form replaces the // algorithm form only while that form is still open for the same route. (Some(ActionKind::Editor), Ok(reply)) => { @@ -1254,7 +1561,7 @@ impl App { self.popup = Some(Popup::review( change_lines(self.theme, &reply.data), "Save", - FormKind::Save(Box::new(reviewed)), + FormKind::Confirm("SAVE".into(), Box::new(reviewed)), "Review route changes", "Type SAVE to apply", )); @@ -1351,6 +1658,7 @@ impl App { self.busy = Some(Busy { label: request.label, action, + key: request.key, started: Instant::now(), }); Ok(()) @@ -1368,6 +1676,7 @@ impl App { (None, false, Tab::Calls) => CALLS_KEYS, (None, false, Tab::Models) => MODELS_KEYS, (None, false, Tab::Routes) => ROUTES_KEYS, + (None, false, Tab::Harnesses) => HARNESSES_KEYS, (None, false, Tab::Settings) => SETTINGS_KEYS, } } @@ -1417,7 +1726,7 @@ impl App { match key.code { KeyCode::Char('q') => self.quit = true, KeyCode::Char('?') => self.popup = Some(Popup::Help { scroll: 0 }), - KeyCode::Char(digit @ '1'..='5') => { + KeyCode::Char(digit @ '1'..='6') => { self.tab = TABS[digit as usize - '1' as usize]; } KeyCode::Tab | KeyCode::Right => self.tab = TABS[(self.tab.index() + 1) % TABS.len()], @@ -1437,7 +1746,7 @@ impl App { self.route_filter.clear(); self.refilter(); } - Tab::Overview | Tab::Models | Tab::Settings => {} + Tab::Overview | Tab::Models | Tab::Harnesses | Tab::Settings => {} } } code => { @@ -1461,6 +1770,7 @@ impl App { _ => {} }, Tab::Routes => self.routes_key(code), + Tab::Harnesses => self.harnesses_key(code), Tab::Settings => return self.settings_key(code), } } @@ -1504,6 +1814,44 @@ impl App { } } + /// The arrows choose the route, which is the longer list; `t` cycles through the few + /// harnesses. A new harness starts at its own settings, because accounts belong to one + /// harness. + fn harnesses_key(&mut self, code: KeyCode) { + let last = self.route_visible.len().saturating_sub(1); + if let Some(selected) = step(code, self.route_selected, last) { + self.route_selected = selected; + return; + } + match code { + KeyCode::Char('t') => { + self.harness = (self.harness + 1) % self.data.tools.len().max(1); + self.destination = Destination::Harness; + } + // The cycle runs through the harness's own settings and then each saved account. + KeyCode::Char('a') => { + let names = self.harness().map_or(&[][..], |tool| &tool.accounts); + let current = match &self.destination { + Destination::Account(name) => names.iter().position(|known| known == name), + Destination::Harness | Destination::File(_) => None, + }; + let next = names.get(current.map_or(0, |index| index + 1)).cloned(); + self.destination = next.map_or(Destination::Harness, Destination::Account); + } + KeyCode::Char('x') => { + self.popup = Some(Popup::Form(Form::new( + FormKind::SettingsFile, + "Custom settings file · leave empty to use the detected settings", + vec![( + "Absolute file path (Pi: models.json)", + self.custom_file().unwrap_or_default(), + )], + ))); + } + _ => {} + } + } + fn filter_key(&mut self, key: KeyEvent) { let control = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { @@ -1542,11 +1890,19 @@ impl App { None } + /// This function runs a row's flow and records its key on the request, so the reply can + /// continue that key's flow. fn run_row(&mut self, kind: ActionKind, key: char) -> Option { - self.start_action(kind, key).unwrap_or_else(|error| { - self.notify(error, Tone::Error); - None - }) + match self.start_action(kind, key) { + Ok(request) => request.map(|request| Request { + key: Some(key), + ..request + }), + Err(error) => { + self.notify(error, Tone::Error); + None + } + } } fn open(&mut self, form: Form) -> Result, String> { @@ -1659,6 +2015,55 @@ impl App { }; self.open(form) } + // Both keys preview the install. App::finish shows the diff for p and opens the + // review, which submits Install, for i. + ActionKind::PreviewInstall | ActionKind::Install => { + let label = match key { + 'p' => "Previewing the settings", + 'i' => "Reviewing the settings", + _ => return Err(format!("The {key} key does not preview harness settings.")), + }; + request(self.install_action()?, label) + } + // The review window lists the files that return to their backups and submits + // Restore. + ActionKind::PreviewRestore | ActionKind::Restore => { + let tool = self.harness()?.tool; + let restore = Action::PreviewRestore { + tool, + account: self.account(), + settings_file: self.custom_file(), + }; + request(restore, "Reading the original settings") + } + ActionKind::AddAccount => { + let harness = self.harness()?; + let title = format!("Sign in to another {} account", harness.label); + let kind = FormKind::Account(harness.tool); + self.open(Form::new( + kind, + title, + vec![("Account name", String::new())], + )) + } + ActionKind::LoginAccount => { + let (tool, name) = self.selected_account()?; + request(Action::LoginAccount { tool, name }, "Opening the login") + } + // Controller::editor_target returns the account folder, so it opens in the editor. + ActionKind::OpenAccount => { + let (tool, name) = self.selected_account()?; + request( + Action::OpenAccount { tool, name }, + "Opening the account folder", + ) + } + // The review window names the account folder and submits ArchiveAccount. + ActionKind::PreviewAccountArchive | ActionKind::ArchiveAccount => { + let (tool, name) = self.selected_account()?; + let archive = Action::PreviewAccountArchive { tool, name }; + request(archive, "Reviewing the account archive") + } ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") @@ -1736,6 +2141,16 @@ impl App { KeyCode::Enter => match form.action(&self.data) { Ok(Submit::Run(action)) => return Some(Request::run(action, form.label())), Ok(Submit::Next(next)) => self.popup = Some(Popup::Form(next)), + Ok(Submit::SettingsFile(path)) => { + self.popup = None; + self.destination = if path.is_empty() { + Destination::Harness + } else { + Destination::File(path) + }; + let text = "Press p to preview or i to install."; + self.notify(text.into(), Tone::Done); + } Err(error) => form.error = error, }, // A field with choices cycles through them. Otherwise only the review @@ -1980,6 +2395,7 @@ fn draw(frame: &mut Frame, app: &mut App) { generation: _, // every route form sends it back with its action routes: _, // draw_routes and the route details clients: _, // draw_endpoints, the endpoint fields, and the endpoint forms + tools: _, // draw_harnesses and harness_lines algorithms: _, // draw_routes, the route details, and the algorithm fields summary: _, // --print shows these rows; draw_top_models builds the same notes metrics: _, // draw_header and the Overview cards @@ -2013,6 +2429,7 @@ fn draw(frame: &mut Frame, app: &mut App) { Tab::Calls => draw_calls(frame, app, body), Tab::Models => draw_models(frame, app, body), Tab::Routes => draw_routes(frame, app, body), + Tab::Harnesses => draw_harnesses(frame, app, body), Tab::Settings => draw_settings(frame, app, body), } draw_status(frame, app, status); @@ -2046,14 +2463,27 @@ fn draw_too_small(frame: &mut Frame, theme: Theme, area: Rect) { fn draw_header(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; - let titles = TABS.map(|tab| { - Line::from(vec![ - Span::styled(format!("{} ", tab.index() + 1), theme.key), - Span::raw(tab.name()), - ]) - }); + // Each title is the tab's digit and at most `letters` letters of its name. + let titles = |letters: usize| { + TABS.map(|tab| { + let name = tab.name().get(..letters).unwrap_or(tab.name()); + Line::from(vec![ + Span::styled((tab.index() + 1).to_string(), theme.key), + Span::raw(if name.is_empty() { "" } else { " " }), + Span::raw(name), + ]) + }) + }; // Tabs pads each title by one space on both sides and puts a divider between titles. - let tabs_width: usize = titles.iter().map(|title| title.width() + 3).sum(); + let width = |titles: &[Line]| titles.iter().map(|title| title.width() + 3).sum::(); + // Narrow terminals shorten the names to three letters, then show the digits alone, so no + // tab is cut off. + let titles = [usize::MAX, 3] + .into_iter() + .map(titles) + .find(|titles| width(titles) <= usize::from(area.width)) + .unwrap_or_else(|| titles(0)); + let tabs_width = width(&titles); let running = app.data.metrics.running; let mut status = server_state(theme, running).to_vec(); let updated = match app.refreshed { @@ -2897,24 +3327,36 @@ fn draw_route_table(frame: &mut Frame, app: &mut App, area: Rect) { /// short terminal still shows them, then the notes that limit editing it here. fn route_lines(theme: Theme, data: &Snapshot, route: &RouteInfo) -> Vec> { const WIDTH: usize = 11; + // This destructuring has no `..`, so adding a route field breaks the build until the + // terminal app shows it. Each comment names the place that shows the field. + let RouteInfo { + key, + id: _, // draw_routes titles these details with it + kind, + revision: _, // the edit and delete flows send it back + label: _, // harness_lines names the route with it, and the filter reads it + choices, + tiers: _, // route_models_form keeps each role's model by tier + editable, + generated, + compatibility: _, // harness_lines explains it for the selected harness + dependents: _, // remove_lines lists them in the delete review + } = route; let field = |name: &str, value: String| labeled(theme, name, WIDTH, value); - let algorithm = data - .algorithms - .iter() - .find(|known| known.kind == route.kind); + let algorithm = data.algorithms.iter().find(|known| known.kind == *kind); let mut lines = vec![ - field("Config", format!("[routes.{}]", route.key)), + field("Config", format!("[routes.{key}]")), field( "Algorithm", algorithm.map_or_else( - || route.kind.clone(), + || kind.clone(), |known| format!("{} ({})", known.title, known.kind), ), ), ]; // A random route can list more models than a new one starts with, so later roles are // numbered the same way. - for (index, choice) in route.choices.iter().enumerate() { + for (index, choice) in choices.iter().enumerate() { let role = algorithm .and_then(|known| known.roles.get(index)) .map_or_else(|| format!("Model {}", index + 1), |role| role.label.clone()); @@ -2927,14 +3369,14 @@ fn route_lines(theme: Theme, data: &Snapshot, route: &RouteInfo) -> Vec>, area: frame.render_widget(Paragraph::new(notes).wrap(Wrap { trim: false }), notes_area); } +fn draw_harnesses(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + // The borders and the header row take three rows. + let rows = app.data.tools.len().max(1) as u16 + 3; + let [list, rest] = Layout::vertical([ + Constraint::Length(rows.min(area.height / 2)), + Constraint::Fill(1), + ]) + .areas(area); + draw_harness_table(frame, app, list); + let Ok(harness) = app.harness() else { + return; + }; + let detail = Paragraph::new(harness_lines(app, harness)) + .wrap(Wrap { trim: false }) + .block(block(theme, harness.label).padding(Padding::horizontal(1))); + let Some(preview) = app.install_preview() else { + frame.render_widget(detail, rest); + return; + }; + let diff = Paragraph::new(install_lines(theme, preview)) + .wrap(Wrap { trim: false }) + .block(block(theme, "Settings diff").padding(Padding::horizontal(1))); + // Wide terminals put the diff beside the details; others put it below them. + let [details_area, diff_area] = if rest.width >= SIDE_DETAIL_WIDTH { + Layout::horizontal([Constraint::Fill(1); 2]).areas(rest) + } else { + Layout::vertical([Constraint::Fill(1); 2]).areas(rest) + }; + frame.render_widget(detail, details_area); + frame.render_widget(diff, diff_area); +} + +fn draw_harness_table(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let tools = &app.data.tools; + let outer = block(theme, &format!("Harnesses [{}]", tools.len())); + if tools.is_empty() { + let text = "No harness is listed. Fix the app settings, then press r."; + frame.render_widget( + Paragraph::new(Line::styled(text, theme.muted)) + .centered() + .block(outer), + area, + ); + return; + } + let wide = area.width >= 80; + let shown = |column: &(Column, bool)| wide || !column.1; + let rows: Vec = tools + .iter() + .map(|tool| { + let state = if tool.available { + Span::styled("Detected", theme.ok) + } else { + Span::styled("Not detected", theme.warn) + }; + // The first two lines name the model and the endpoint, or hold the read error. + let settings: Vec<&str> = tool.status.lines().take(2).collect(); + let cells = [ + Cell::from(tool.label), + Cell::from(state), + Cell::from(settings.join(" · ")), + right(count(tool.accounts.len() as u64)), + ]; + Row::new( + cells + .into_iter() + .zip(&HARNESS_COLUMNS) + .filter(|(_, column)| shown(column)) + .map(|(cell, _)| cell), + ) + }) + .collect(); + let columns: Vec = HARNESS_COLUMNS + .iter() + .filter(|column| shown(column)) + .map(|column| column.0) + .collect(); + let mut state = TableState::default().with_selected(Some(app.harness)); + frame.render_stateful_widget( + table(theme, &columns, rows) + .row_highlight_style(theme.selected) + .highlight_symbol("› ") + .block(outer), + area, + &mut state, + ); +} + +/// This function shows the route to install and whether the harness can use it, then where +/// the settings go and what they hold now, then the saved accounts. +fn harness_lines(app: &App, harness: &ToolInfo) -> Vec> { + const WIDTH: usize = 12; + let theme = app.theme; + let field = |name: &str, value: String| labeled(theme, name, WIDTH, value); + let heading = |text: &'static str| Line::styled(text, theme.muted.bold()); + // This destructuring has no `..`, so adding a harness field breaks the build until this + // page shows it. + let ToolInfo { + tool, + label: _, // draw_harnesses titles these details with it + available, + files, + status, + accounts: _, // the a key cycles through them, and account_details has the same names + account_details, + discovery_error, + } = harness; + let mut lines = Vec::new(); + match app.selected_route() { + Ok(route) => { + lines.push(field("Route", route.label.clone())); + match route.compatibility.get(tool) { + Some(Some(reason)) => lines.push(Line::styled(reason.clone(), theme.warn)), + Some(None) => lines.push(Line::styled("This harness can use the route.", theme.ok)), + None => {} + } + } + Err(error) => lines.push(Line::from(vec![ + Span::styled(format!("{: { + let files: Vec = files.iter().map(|f| f.display().to_string()).collect(); + lines.push(field("Install to", "The harness's own settings".into())); + lines.push(field("Files", files.join(", "))); + (status.clone(), Style::new()) + } + Destination::Account(name) => { + match account_details.iter().find(|account| account.name == *name) { + Some(account) => { + lines.push(field("Install to", format!("Account {name}"))); + lines.push(field("Folder", account.path.display().to_string())); + (account.status.clone(), Style::new()) + } + None => ( + format!( + "Account {name} is no longer listed. Press a to choose another \ + destination." + ), + theme.warn, + ), + } + } + Destination::File(path) => { + lines.push(field("Install to", "Custom file".into())); + lines.push(field("File", path.clone())); + ("Press p to read it.".into(), theme.muted) + } + }; + lines.extend([Line::default(), heading("CURRENT SETTINGS")]); + // A summary spans several lines, and Paragraph does not start a new line at '\n'. + lines.extend( + current + .lines() + .map(|line| Line::styled(line.to_string(), style)), + ); + lines.extend([ + Line::default(), + heading("SAVED ACCOUNTS · a folder does not confirm a completed login"), + ]); + if let Some(error) = discovery_error { + lines.push(Line::styled(error.clone(), theme.error)); + } + for account in account_details { + // This destructuring has no `..`, so adding an account field breaks the build until + // this page shows it. + let AccountInfo { + name, + path, + login_status, + status: _, // CURRENT SETTINGS shows it for the selected account + } = account; + let selected = matches!(&app.destination, Destination::Account(chosen) if chosen == name); + lines.push(Line::from(vec![ + Span::styled(if selected { "› " } else { " " }, theme.key), + Span::raw(name.clone()), + Span::styled( + format!(" · login status {login_status} · {}", path.display()), + theme.muted, + ), + ])); + } + if account_details.is_empty() { + lines.push(Line::styled( + "None. Press n to sign in to another account.", + theme.muted, + )); + } + lines.extend([ + Line::default(), + Line::styled( + "Login opens in a new terminal window, and account folders open in $VISUAL or \ + $EDITOR. Native account limits still apply. Switchyard does not rotate login \ + tokens.", + theme.muted, + ), + ]); + lines +} + /// This function lists the server key variables from the last `ServerKeys` reply. fn key_lines(theme: Theme, keys: Option<&Value>) -> Vec> { let Some(data) = keys else { @@ -3474,9 +4127,24 @@ fn form_notes(app: &App, form: &Form) -> Vec> { lines.push(Line::default()); lines } + FormKind::SettingsFile => vec![ + Line::from( + "The harness must load a custom file itself. Pi reads settings.json from the \ + folder of models.json.", + ), + Line::default(), + ], + FormKind::Account(_) => vec![ + Line::from( + "Use 1–64 ASCII letters, digits, hyphens, or underscores. Login opens in a new \ + terminal window; finish it there. API routes use credentials configured on \ + the server. The Codex app manages its own account.", + ), + Line::default(), + ], FormKind::Preferences | FormKind::Update(_) - | FormKind::Save(_) + | FormKind::Confirm(..) | FormKind::Remove { .. } => Vec::new(), } } @@ -3508,6 +4176,33 @@ fn remove_lines( kept. Any harness using {} must choose another route.", route.id ))]; + if !route.dependents.is_empty() { + lines.push(Line::default()); + lines.push(Line::styled( + format!( + "These harness settings use {} and must choose another route:", + route.id + ), + theme.warn, + )); + for dependent in &route.dependents { + // This destructuring has no `..`, so adding a field breaks the build until this + // review shows it. + let Dependent { + tool: _, // the label names the harness + label, + account, + files, + } = dependent; + let account = account.as_ref().map(|name| format!(" ({name})")); + let files: Vec = files.iter().map(|f| f.display().to_string()).collect(); + lines.push(Line::from(format!( + "{label}{}: {}", + account.unwrap_or_default(), + files.join(", ") + ))); + } + } if let Some(generated) = &route.generated { lines.push(Line::default()); lines.push(Line::styled( @@ -3531,6 +4226,81 @@ fn remove_lines( lines } +/// This function returns a text field of a controller reply, or nothing when it is missing. +fn text(value: &Value, key: &str) -> String { + value[key].as_str().unwrap_or_default().to_string() +} + +/// This function lists each setting that a harness preview changes: the file and setting, the +/// current value, and the value afterward. +fn diff_lines(theme: Theme, preview: &Value, after: &str) -> Vec> { + let mut lines = vec![Line::styled( + format!("SETTINGS DIFF · − current / + {after}"), + theme.muted.bold(), + )]; + for change in preview["changes"].as_array().into_iter().flatten() { + lines.extend([ + Line::default(), + Line::styled( + format!("{} · {}", text(change, "file"), text(change, "key")), + theme.muted, + ), + Line::styled(format!("− {}", text(change, "before")), theme.error), + Line::styled(format!("+ {}", text(change, "after")), theme.ok), + ]); + } + lines +} + +/// This function shows an install preview: the settings afterward, each changed setting, what +/// happens to each file, and how the harness signs in. +fn install_lines(theme: Theme, preview: &Value) -> Vec> { + let mut lines = vec![Line::styled("AFTER APPLYING ROUTING", theme.muted.bold())]; + let proposed = text(preview, "proposed"); + lines.extend(proposed.lines().map(|line| Line::from(line.to_string()))); + lines.push(Line::default()); + lines.extend(diff_lines(theme, preview, "proposed")); + lines.push(Line::default()); + for file in preview["file_actions"].as_array().into_iter().flatten() { + let (action, file) = (text(file, "action"), text(file, "file")); + lines.push(Line::from(format!("Will {action}: {file}"))); + } + lines.push(Line::default()); + lines.push(Line::from(text(preview, "authentication"))); + lines +} + +/// This function shows a restore preview: what restoring does to each file, the backup it +/// uses, and the settings that change. +fn restore_lines(theme: Theme, preview: &Value) -> Vec> { + let mut lines = vec![Line::from(text(preview, "warning"))]; + for file in preview["files"].as_array().into_iter().flatten() { + lines.extend([ + Line::default(), + Line::from(format!("{}: {}", text(file, "action"), text(file, "file"))), + Line::styled( + format!("Original backup: {}", text(file, "backup")), + theme.muted, + ), + ]); + } + // The controller leaves the diff out when a file cannot be compared. + if preview["changes"].is_array() { + lines.push(Line::default()); + lines.extend(diff_lines(theme, preview, "restored")); + } + lines +} + +fn archive_lines(theme: Theme, preview: &Value) -> Vec> { + vec![ + Line::from(text(preview, "notice")), + Line::default(), + labeled(theme, "Account", 11, text(preview, "name")), + labeled(theme, "Directory", 11, text(preview, "path")), + ] +} + fn update_lines(theme: Theme, preview: &Value) -> Vec> { // `git status --short` starts some lines with a space, so only trailing space is removed. let field = |name: &str| { @@ -3582,6 +4352,7 @@ fn help_lines(theme: Theme, width: usize) -> Vec> { ("While filtering calls or routes", FILTER_KEYS, None), ("Models", MODELS_KEYS, Some(Tab::Models)), ("Routes", ROUTES_KEYS, Some(Tab::Routes)), + ("Harnesses", HARNESSES_KEYS, Some(Tab::Harnesses)), ("Settings", SETTINGS_KEYS, Some(Tab::Settings)), ] { if !lines.is_empty() { @@ -3825,8 +4596,12 @@ mod tests { } fn screen(app: &mut App, tab: Tab) -> String { + screen_sized(app, tab, 140, 40) + } + + fn screen_sized(app: &mut App, tab: Tab, width: u16, height: u16) -> String { app.tab = tab; - let mut terminal = Terminal::new(TestBackend::new(140, 40)).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal.draw(|frame| draw(frame, app)).unwrap(); let buffer = terminal.backend().buffer(); buffer @@ -3944,6 +4719,7 @@ mod tests { Tab::Calls => &['/', 's'], Tab::Models => &['p', '[', ']'], Tab::Routes => &['/'], + Tab::Harnesses => &['t', 'a', 'x'], Tab::Overview | Tab::Settings => &[], } }; @@ -3982,7 +4758,7 @@ mod tests { Some(Action::Update { preview_token: Some(token) }) if token == "t" )); assert!(submit(FormKind::Update(None), "update").is_none()); - let save = || FormKind::Save(Box::new(Action::CheckConfig {})); + let save = || FormKind::Confirm("SAVE".into(), Box::new(Action::CheckConfig {})); assert!(matches!( submit(save(), "SAVE"), Some(Action::CheckConfig {}) @@ -4122,6 +4898,24 @@ mod tests { assert!(text.contains(" ! Saved the route"), "{text}"); } + #[test] + // The narrowest supported terminal must still show every tab, so no page's digit or name is + // cut off, whichever page is selected. + fn narrow_header_shows_every_tab() { + let mut app = App::new(Theme::new(false), Snapshot::default()); + for selected in TABS { + let text = screen_sized(&mut app, selected, MIN_WIDTH, MIN_HEIGHT); + let header = text.lines().next().unwrap_or_default(); + for tab in TABS { + let title = format!("{} {}", tab.index() + 1, &tab.name()[..3]); + assert!( + header.contains(&title), + "{selected:?}: {title:?} is cut off in {header:?}" + ); + } + } + } + #[test] // The models form must keep the revision from when the editor opened, so a server config // that changed since then fails the review and keeps the draft instead of saving old @@ -4170,4 +4964,69 @@ mod tests { ); assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); } + + #[test] + // The p and i keys run the same preview, so only the recorded key decides whether its reply + // opens the install review. The review saves nothing until INSTALL is typed. + fn only_i_opens_the_install_review() { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + let routes = dir.path().join("server.toml"); + std::fs::write( + &routes, + "[llm_clients.c]\n\ + base_url='https://example.com/v1'\n\ + format='openai_chat'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n", + ) + .expect("routes"); + std::fs::write( + &settings, + format!( + "config_file={routes:?}\nrouting_log={:?}\nserver_url='http://127.0.0.1:0'", + dir.path().join("log") + ), + ) + .expect("settings"); + let mut controller = Controller::new(Config::load(&settings).expect("config"), settings); + let mut app = App::new(Theme::new(false), controller.snapshot().expect("snapshot")); + app.tab = Tab::Harnesses; + // A custom file keeps the install inside the fixture instead of the user's settings. + let claude = app + .data + .tools + .iter() + .position(|t| t.tool == Harness::Claude); + app.harness = claude.expect("Claude Code"); + let custom = dir.path().join("claude.json"); + std::fs::write(&custom, "{\"user\":true}").expect("custom"); + press(&mut app, KeyCode::Char('x')); + type_text(&mut app, custom.to_str().expect("path")); + press(&mut app, KeyCode::Enter); + + let preview = press(&mut app, KeyCode::Char('p')); + work(&mut app, &mut controller, preview); + assert!(app.popup.is_none(), "p opened a window"); + assert!(screen(&mut app, Tab::Harnesses).contains("SETTINGS DIFF")); + + let review = press(&mut app, KeyCode::Char('i')); + work(&mut app, &mut controller, review); + assert!(matches!(app.popup, Some(Popup::Review { .. }))); + type_text(&mut app, "install"); + assert!(press(&mut app, KeyCode::Enter).is_none()); + let unchanged = std::fs::read_to_string(&custom).expect("custom"); + assert_eq!(unchanged, "{\"user\":true}"); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + type_text(&mut app, "INSTALL"); + let install = press(&mut app, KeyCode::Enter); + work(&mut app, &mut controller, install); + let installed = std::fs::read_to_string(&custom).expect("custom"); + assert!(installed.contains("public"), "{installed}"); + } } diff --git a/crates/switchyard-desktop/src/windows.rs b/crates/switchyard-desktop/src/windows.rs index 8e0d2d000..7f768cfd8 100644 --- a/crates/switchyard-desktop/src/windows.rs +++ b/crates/switchyard-desktop/src/windows.rs @@ -1,6 +1,65 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +//! Harnesses run in their own Windows console with explicit arguments and account directories. + +use serde::{Deserialize, Serialize}; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +/// This function reads current-user MSIX registrations without launching or changing the Codex app. +pub fn codex_app() -> Option { + use windows::{Management::Deployment::PackageManager, core::HSTRING}; + // An empty SID restricts discovery to packages registered for the current user. + let packages = PackageManager::new() + .ok()? + .FindPackagesByUserSecurityId(&HSTRING::new()) + .ok()?; + for package in packages { + let Some(name) = package.Id().ok().and_then(|id| id.Name().ok()) else { + continue; + }; + if name == "OpenAI.Codex" { + return Some(PathBuf::from(package.InstalledPath().ok()?.to_os_string())); + } + } + None +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TerminalJob { + pub binary: PathBuf, + pub args: Vec, + pub directory: Option, + pub environment: Vec<(String, String)>, + pub remove_environment: Vec, +} +/// The child reads structured launch settings so arguments and account directories remain separate. +pub fn open_terminal(job: TerminalJob) -> Result<(), String> { + use std::io::Write; + let mut file = tempfile::Builder::new() + .prefix("switchyard-terminal-") + .suffix(".json") + .tempfile() + .map_err(|e| e.to_string())?; + file.write_all(&serde_json::to_vec(&job).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let mut child = Command::new(std::env::current_exe().map_err(|e| e.to_string())?); + // The child deletes the file, so the file must be kept before the child starts. + let path = file + .into_temp_path() + .keep() + .map_err(|error| error.to_string())?; + child.arg("--terminal-job").arg(&path); + if let Err(error) = child.spawn() { + let _ = std::fs::remove_file(&path); + return Err(error.to_string()); + } + Ok(()) +} pub fn attach_console() { unsafe { windows_console(false); @@ -29,3 +88,42 @@ unsafe fn windows_console(allocate: bool) { } } } +pub fn terminal_job() -> Option> { + use std::io::IsTerminal; + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if args.first().is_none_or(|v| v != "--terminal-job") { + return None; + } + Some((|| { + if args.len() != 2 { + return Err("The terminal job requires one saved launch file.".into()); + } + let path = Path::new(&args[1]); + let job: TerminalJob = + serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + std::fs::remove_file(path).map_err(|e| e.to_string())?; + allocate_console(); + let mut child = Command::new(&job.binary); + child.args(&job.args).envs(job.environment); + for name in job.remove_environment { + child.env_remove(name); + } + if let Some(directory) = job.directory { + child.current_dir(directory); + } + let status = child + .status() + .map_err(|e| format!("Could not start the harness: {e}"))?; + println!("Harness exited with {status}. Press Enter to close this window."); + if std::io::stdin().is_terminal() { + let mut line = String::new(); + let _ = std::io::stdin().read_line(&mut line); + } + if status.success() { + Ok(()) + } else { + Err(format!("Harness exited with {status}.")) + } + })()) +} diff --git a/crates/switchyard-desktop/tests/windows_terminal.rs b/crates/switchyard-desktop/tests/windows_terminal.rs new file mode 100644 index 000000000..2242174fd --- /dev/null +++ b/crates/switchyard-desktop/tests/windows_terminal.rs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(windows)] + +#[test] +fn native_and_npm_launchers_preserve_arguments_accounts_and_working_directory() { + use std::{ + fs, + process::{Command, Stdio}, + }; + let root = tempfile::Builder::new() + .prefix("Terminal équipe space ") + .tempdir() + .expect("fixture"); + let source = root.path().join("tool.rs"); + fs::write(&source,r#"fn main() { + let output=std::env::var_os("FIXTURE_OUTPUT").unwrap(); + let text=format!("{:?}\n{:?}\n{:?}\n{:?}",std::env::args().skip(1).collect::>(),std::env::current_dir().unwrap(),std::env::var("CODEX_HOME"),std::env::var("ANTHROPIC_AUTH_TOKEN")); + std::fs::write(output,text).unwrap(); + }"#).expect("tool source"); + let tool = root.path().join("tool.exe"); + assert!( + Command::new("rustc") + .arg(&source) + .arg("-o") + .arg(&tool) + .status() + .expect("compile fixture") + .success() + ); + let shim = root.path().join("tool.cmd"); + fs::write(&shim, "@echo off\r\n@\"%~dp0tool.exe\" %*\r\n").expect("npm shim"); + let args = vec![ + "space équipe".to_string(), + "a\"b".into(), + "%PATH%".into(), + "& echo injected > forbidden-file".into(), + "C:\\space équipe\\".into(), + "|output^".into(), + ]; + let output = root.path().join("arguments.txt"); + let account = root.path().join("account équipe space"); + for binary in [tool, shim] { + let launch = root.path().join("launch.json"); + fs::write(&launch,serde_json::to_vec(&serde_json::json!({"binary":binary,"args":args,"directory":root.path(),"environment":[["CODEX_HOME",account.display().to_string()],["FIXTURE_OUTPUT",output.display().to_string()]],"remove_environment":["ANTHROPIC_AUTH_TOKEN"]})).expect("launch JSON")).expect("launch record"); + let result = Command::new(env!("CARGO_BIN_EXE_switchyard-desktop")) + .arg("--terminal-job") + .arg(&launch) + .env("ANTHROPIC_AUTH_TOKEN", "must-be-removed") + .stdin(Stdio::null()) + .output() + .expect("terminal job"); + assert!( + result.status.success(), + "terminal launch failed for {binary:?}: status={:?}; stdout={}; stderr={}", + result.status, + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + let captured = fs::read_to_string(&output).expect("captured tool launch"); + assert!(captured.starts_with(&format!("{args:?}\n")), "{captured}"); + assert!(captured.contains(&format!("Ok({:?})", account.display().to_string()))); + assert!(captured.contains("NotPresent")); + assert!(captured.contains(&format!("{:?}", root.path()))); + assert!(!launch.exists()); + assert!(!root.path().join("forbidden-file").exists()); + fs::remove_file(&output).expect("reset output"); + } +}