diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2cdd785c735..7f40b64328b 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -18,8 +18,21 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_CANDIDATE_ID"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); + if let Ok(candidate_id) = std::env::var("BUZZ_BUILD_CANDIDATE_ID") { + let valid = !candidate_id.is_empty() + && candidate_id.len() <= 48 + && candidate_id.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }); + if !valid { + panic!("BUZZ_BUILD_CANDIDATE_ID must match [a-z0-9-] and be at most 48 characters"); + } + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_CANDIDATE_ID={candidate_id}"); + } + // Explicit owner-only agent-access capability. Release packaging sets this // presence-only marker; OSS/custom builds leave agent access configurable. if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() { diff --git a/desktop/src-tauri/src/app_state_keyring.rs b/desktop/src-tauri/src/app_state_keyring.rs index 68d24e87f58..375903a8c32 100644 --- a/desktop/src-tauri/src/app_state_keyring.rs +++ b/desktop/src-tauri/src/app_state_keyring.rs @@ -7,7 +7,12 @@ fn dev_keyring_service(configured: Option) -> String { } pub(crate) fn keyring_service() -> &'static str { - if cfg!(debug_assertions) { + if let Some(candidate_id) = option_env!("BUZZ_DESKTOP_BUILD_CANDIDATE_ID") { + static CANDIDATE_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); + CANDIDATE_SERVICE + .get_or_init(|| format!("buzz-desktop-candidate.{candidate_id}")) + .as_str() + } else if cfg!(debug_assertions) { static DEV_SERVICE: std::sync::OnceLock = std::sync::OnceLock::new(); DEV_SERVICE .get_or_init(|| dev_keyring_service(std::env::var("BUZZ_DEV_KEYRING_SERVICE").ok())) diff --git a/desktop/src-tauri/src/commands/agent_auth.rs b/desktop/src-tauri/src/commands/agent_auth.rs index bba0f338602..be0ea44c31b 100644 --- a/desktop/src-tauri/src/commands/agent_auth.rs +++ b/desktop/src-tauri/src/commands/agent_auth.rs @@ -113,11 +113,34 @@ fn run_buzz_acp_auth_command( ) -> Result { let runtime = known_acp_runtime_exact(runtime_id) .ok_or_else(|| format!("unknown ACP runtime: {runtime_id}"))?; - let adapter_command = runtime - .commands - .iter() - .find_map(|command| resolve_command(command).map(|path| (*command, path))) - .ok_or_else(|| format!("{} ACP adapter is not installed", runtime.label))?; + let (adapter_name, adapter_path, runtime_plan) = if runtime.id == "codex" && !cfg!(windows) { + let mut planned_runtime = None; + let mut last_plan_error = None; + for adapter_name in runtime.commands { + match crate::managed_agents::runtime_plan::resolve_runtime_execution_plan(adapter_name) + { + Ok(Some(plan)) => { + planned_runtime = Some((*adapter_name, plan)); + break; + } + Ok(None) => {} + Err(error) => last_plan_error = Some(error), + } + } + let (adapter_name, plan) = planned_runtime.ok_or_else(|| { + last_plan_error + .unwrap_or_else(|| format!("{} ACP adapter is not installed", runtime.label)) + })?; + let adapter_path = plan.harness_path()?.to_path_buf(); + (adapter_name, adapter_path, Some(plan)) + } else { + let (adapter_name, adapter_path) = runtime + .commands + .iter() + .find_map(|command| resolve_command(command).map(|path| (*command, path))) + .ok_or_else(|| format!("{} ACP adapter is not installed", runtime.label))?; + (adapter_name, adapter_path, None) + }; let acp_path = std::env::current_exe() .map(|path| path.with_file_name(format!("buzz-acp{}", std::env::consts::EXE_SUFFIX))) @@ -129,10 +152,11 @@ fn run_buzz_acp_auth_command( let augmented_path = auth_command_path(); run_buzz_acp_auth_command_with_paths( &acp_path, - adapter_command.0, - &adapter_command.1, + adapter_name, + &adapter_path, args, augmented_path.as_deref(), + runtime_plan.as_ref(), ) } @@ -179,6 +203,7 @@ fn run_buzz_acp_auth_command_with_paths( adapter_path: &Path, args: [&str; N], augmented_path: Option<&str>, + runtime_plan: Option<&crate::managed_agents::runtime_plan::RuntimeExecutionPlan>, ) -> Result { let agent_args = normalize_agent_args(adapter_name, Vec::new()); let mut command = Command::new(acp_path); @@ -194,6 +219,10 @@ fn run_buzz_acp_auth_command_with_paths( if let Some(path) = augmented_path { command.env("PATH", path); } + if let Some(plan) = runtime_plan { + plan.verify()?; + plan.apply_environment(&mut command); + } crate::util::configure_no_window(&mut command); command @@ -254,9 +283,47 @@ fn launch_terminal_auth(runtime_id: &str, method: &AcpAuthMethod) -> Result<(), .iter() .find_map(|command| resolve_command(command).map(|path| (*command, path))) .ok_or_else(|| format!("{} ACP adapter is not installed", runtime.label))?; + let runtime_plan = if runtime.id == "codex" { + crate::managed_agents::runtime_plan::resolve_runtime_execution_plan(adapter_command.0)? + } else { + None + }; let fallback_command = adapter_command.1.display().to_string(); - let argv = adapter_terminal_argv(runtime.label, method, &fallback_command)?; - launch_visible_terminal(&argv) + let mut argv = adapter_terminal_argv(runtime.label, method, &fallback_command)?; + if let Some(plan) = runtime_plan.as_ref() { + let provider_path = plan + .provider_cli_path() + .ok_or_else(|| "Codex runtime plan has no provider CLI".to_string())?; + let command = argv + .first_mut() + .ok_or_else(|| "Codex terminal login command is empty".to_string())?; + *command = provider_path.display().to_string(); + plan.verify()?; + } + let terminal_prelude = runtime_plan + .as_ref() + .map(runtime_plan_shell_prelude) + .unwrap_or_default(); + launch_visible_terminal(&argv, &terminal_prelude) +} + +fn runtime_plan_shell_prelude( + plan: &crate::managed_agents::runtime_plan::RuntimeExecutionPlan, +) -> String { + let mut prelude = String::new(); + for key in plan.denied_environment() { + prelude.push_str("unset "); + prelude.push_str(key); + prelude.push('\n'); + } + for (key, value) in plan.generated_environment_entries() { + prelude.push_str("export "); + prelude.push_str(key); + prelude.push('='); + prelude.push_str(&shell_escape(value)); + prelude.push('\n'); + } + prelude } fn adapter_terminal_argv( @@ -361,7 +428,7 @@ fn spawn_without_stdio(mut command: Command) -> Result<(), String> { } #[cfg(target_os = "macos")] -fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { +fn launch_visible_terminal(argv: &[String], shell_prelude: &str) -> Result<(), String> { let mut script = tempfile::Builder::new() .prefix("buzz-auth-") .suffix(".command") @@ -369,7 +436,8 @@ fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { .map_err(|error| format!("failed to create terminal login script: {error}"))?; writeln!( script, - "#!/bin/sh\ntrap 'rm -f -- \"$0\"' EXIT\n{}", + "#!/bin/sh\ntrap 'rm -f -- \"$0\"' EXIT\n{}{}", + shell_prelude, shell_join(argv) ) .map_err(|error| format!("failed to write terminal login script: {error}"))?; @@ -395,8 +463,8 @@ fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { } #[cfg(target_os = "linux")] -fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { - let command = shell_join(argv); +fn launch_visible_terminal(argv: &[String], shell_prelude: &str) -> Result<(), String> { + let command = format!("{}{}", shell_prelude, shell_join(argv)); let candidates: [(&str, &[&str]); 4] = [ ("x-terminal-emulator", &["-e", "sh", "-lc"]), ("gnome-terminal", &["--", "sh", "-lc"]), @@ -414,7 +482,7 @@ fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { } #[cfg(target_os = "windows")] -fn launch_visible_terminal(argv: &[String]) -> Result<(), String> { +fn launch_visible_terminal(argv: &[String], _shell_prelude: &str) -> Result<(), String> { use std::os::windows::process::CommandExt; const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; @@ -436,7 +504,7 @@ fn windows_terminal_args(argv: &[String]) -> Vec { } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -fn launch_visible_terminal(_argv: &[String]) -> Result<(), String> { +fn launch_visible_terminal(_argv: &[String], _shell_prelude: &str) -> Result<(), String> { Err("opening a terminal is not supported on this platform".to_string()) } @@ -542,6 +610,7 @@ mod tests { &adapter_path, ["auth-methods", "--json"], Some(&augmented_path), + None, ) .expect("run auth command"); diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9609db5f2df..f22c1cdfe7d 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -138,7 +138,6 @@ pub async fn save_custom_harness( // so concurrent saves never produce a stale registry snapshot (B-6). custom_harnesses::save_and_warm(&custom_dir, &definition, rename_old_id.as_deref())?; - // Resolve availability for the returned catalog entry. let (availability, command_opt, binary_path) = match crate::managed_agents::find_command(&definition.command) { Some(path) => ( @@ -148,7 +147,6 @@ pub async fn save_custom_harness( ), None => (AcpAvailabilityStatus::NotInstalled, None, None), }; - let default_args = crate::managed_agents::normalize_agent_args(&definition.command, definition.args.clone()); @@ -159,6 +157,8 @@ pub async fn save_custom_harness( availability, command: command_opt, binary_path, + runtime_plan_id: None, + runtime_plan_source: None, default_args, mcp_command: None, model_env_var: None, diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 998edeca27d..4c1a8f2313a 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -14,6 +14,13 @@ pub(super) async fn run_agent_models_command( persisted_model: Option, merged_env: BTreeMap, ) -> Result { + let runtime_plan = + crate::managed_agents::runtime_plan::resolve_runtime_execution_plan(&agent_command)?; + let agent_command = match runtime_plan.as_ref() { + Some(plan) => plan.harness_path()?.display().to_string(), + None => agent_command, + }; + // Clone the env map for redaction below — `merged_env` is moved // into the spawn_blocking closure and we still need the values to // scrub any user-supplied secrets that the child surfaces in stderr. @@ -54,7 +61,15 @@ pub(super) async fn run_agent_models_command( for (k, v) in &merged_env { cmd.env(k, v); } - crate::managed_agents::configure_runtime_cli(&mut cmd, known_acp_runtime(&agent_command)); + if let Some(plan) = runtime_plan.as_ref() { + plan.verify()?; + plan.apply_environment(&mut cmd); + } else { + crate::managed_agents::configure_runtime_cli( + &mut cmd, + known_acp_runtime(&agent_command), + ); + } crate::util::configure_no_window(&mut cmd); cmd.stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index bc0e3a6cdae..049fd2a2045 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1005,16 +1005,25 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool { /// background threads to prevent pipe-buffer deadlock. On timeout the child is /// killed and `Unknown` is returned; no orphaned threads or processes are left /// behind. Returns `Unknown` on timeout. -fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { +fn probe_auth_status( + binary_path: &Path, + probe_args: &[&str], + runtime_plan: Option<&super::runtime_plan::RuntimeExecutionPlan>, +) -> AuthStatus { use crate::managed_agents::readiness::cli_probe; - let augmented_path = cli_probe::augmented_path(); + let augmented_path = runtime_plan + .and_then(|plan| plan.generated_environment("PATH").map(str::to_string)) + .or_else(cli_probe::augmented_path); let mut command = std::process::Command::new(binary_path); command.args(&probe_args[1..]); if let Some(ref path) = augmented_path { command.env("PATH", path); } + if let Some(plan) = runtime_plan { + plan.apply_environment(&mut command); + } command .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) @@ -1143,153 +1152,16 @@ pub(crate) fn classify_runtime( } } -/// The oldest `codex-acp` version supported by Buzz managed agents. -/// -/// Older 1.x adapters are detected successfully, but can still bundle a Codex runtime -/// that does not reliably give `buzz` CLI subprocesses outbound relay access. -/// -/// Bump policy: raise this only when a newer adapter fixes a defect that breaks managed -/// agents, and only to a version already published on npm — every user below the floor is -/// offered a reinstall on their next discovery pass. -pub(crate) const MIN_CODEX_ACP_VERSION: (u64, u64, u64) = (1, 1, 7); - -/// Probe the full version of a `codex-acp` binary by running `--version`. -/// -/// The 1.x adapter (`@agentclientprotocol/codex-acp`) outputs -/// `@agentclientprotocol/codex-acp ..` on stdout and exits 0. -/// The old 0.16.x adapter (`@zed-industries/codex-acp`) is a Rust binary that does -/// not recognise `--version` and exits non-zero. -/// -/// Returns the `(major, minor, patch)` triple on success, `None` on any failure -/// (non-zero exit, unparseable output, timeout, or missing binary). -/// -/// The parse is deliberately strict: exactly three numeric dot-separated components. -/// Partial versions (`1.2`) and prerelease tags (`1.2.0-rc1`) return `None` and so -/// classify as [`AcpAvailabilityStatus::AdapterOutdated`] — failing closed offers a -/// reinstall rather than running an adapter whose version cannot be compared. -/// -/// The probe is bounded by a 5-second deadline. The child is polled with -/// [`std::process::Child::try_wait`] (the repo's standard deadline pattern) and -/// killed if it does not exit in time. -/// -/// Stdout is redirected to a temporary file rather than a pipe, so forked -/// descendants cannot hold EOF open. Reads from a regular file return EOF at its -/// current write position regardless of inherited file descriptors, cross-platform. -pub(crate) fn probe_codex_acp_version(binary_path: &Path) -> Option<(u64, u64, u64)> { - probe_codex_acp_version_with_path( - binary_path, - crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), - ) -} -pub(crate) fn probe_codex_acp_version_with_path( - binary_path: &Path, - augmented_path: Option<&str>, -) -> Option<(u64, u64, u64)> { - use std::io::{Read as _, Seek as _, SeekFrom}; - use std::time::{Duration, Instant}; - const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); - - // A regular file returns EOF at its current size even when a descendant - // inherits its descriptor, bounding the post-exit read cross-platform. - let mut tmp = tempfile::tempfile().ok()?; - - let mut command = Command::new(binary_path); - command.arg("--version"); - if let Some(path) = augmented_path { - command.env("PATH", path); - } - crate::util::configure_no_window(&mut command); - let mut child = command - .stdout(tmp.try_clone().ok()?) - .stderr(std::process::Stdio::null()) - .spawn() - .ok()?; - - // Poll until the deadline rather than blocking on stdout EOF. - let deadline = Instant::now() + VERSION_PROBE_TIMEOUT; - let exit_status = loop { - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) => { - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - std::thread::sleep(Duration::from_millis(50)); - } - Err(_) => { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - } - }; - - if !exit_status.success() { - return None; - } - - // Read at most 4 KiB from the regular file without blocking. - tmp.seek(SeekFrom::Start(0)).ok()?; - let mut buf = Vec::with_capacity(128); - let _ = (&mut tmp as &mut dyn std::io::Read) - .take(4096) - .read_to_end(&mut buf); - - let stdout = String::from_utf8_lossy(&buf); - // Output format: " .." - let version_str = stdout.split_whitespace().last()?; - let mut components = version_str.split('.'); - let major = components.next()?.parse::().ok()?; - let minor = components.next()?.parse::().ok()?; - let patch = components.next()?.parse::().ok()?; - if components.next().is_some() { - return None; - } - Some((major, minor, patch)) -} - -/// Classifies a resolved codex-acp binary path as [`AcpAvailabilityStatus::Available`] -/// or [`AcpAvailabilityStatus::AdapterOutdated`]. -/// -/// The 0.16.x adapter (`@zed-industries/codex-acp`) does not recognise `--version` -/// and exits non-zero — that probe failure yields `AdapterOutdated`. An adapter is -/// available only when its version is at least [`MIN_CODEX_ACP_VERSION`]. -/// -/// Used by `discover_acp_runtimes`, `cli_login_requirements`, and -/// `install_acp_runtime_blocking` so the version-gate logic is not duplicated. -pub(crate) fn codex_adapter_availability(path: &Path) -> AcpAvailabilityStatus { - match probe_codex_acp_version(path) { - Some(version) if version >= MIN_CODEX_ACP_VERSION => AcpAvailabilityStatus::Available, - _ => AcpAvailabilityStatus::AdapterOutdated, - } -} +mod codex_version; -/// Returns `true` when the codex-acp binary at `path` is below -/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed using `augmented_path`. Thin wrapper -/// around [`codex_adapter_is_outdated_with_path`]. #[cfg(test)] -pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { - codex_adapter_is_outdated_with_path( - path, - crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), - ) -} - -/// Returns `true` when the codex-acp binary at `path` is below -/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed with the supplied PATH. -pub(crate) fn codex_adapter_is_outdated_with_path( - path: &Path, - augmented_path: Option<&str>, -) -> bool { - !matches!( - probe_codex_acp_version_with_path(path, augmented_path), - Some(version) if version >= MIN_CODEX_ACP_VERSION - ) -} +pub(crate) use codex_version::codex_adapter_is_outdated; +pub(crate) use codex_version::{ + codex_adapter_availability, codex_adapter_availability_with_plan, + codex_adapter_is_outdated_with_path, probe_codex_acp_version, + probe_codex_acp_version_with_path, MIN_CODEX_ACP_VERSION, +}; -/// Intermediate struct built before the (potentially slow) auth probe phase. struct PartialEntry { runtime: &'static KnownAcpRuntime, entry: AcpRuntimeCatalogEntry, @@ -1308,15 +1180,35 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr let (mut availability, command, binary_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - // For codex-acp: when the adapter resolves as Available, probe its full - // version. An adapter below MIN_CODEX_ACP_VERSION is treated as outdated. + let runtime_plan = + command.as_deref().and_then( + |cmd| match super::runtime_plan::resolve_runtime_execution_plan(cmd) { + Ok(plan) => plan, + Err(error) => { + tracing::warn!(runtime = runtime.id, %error, "runtime plan resolution failed"); + None + } + }, + ); + + // For codex-acp, version probing is execution and therefore consumes the + // same verified plan and sanitized environment as every later operation. if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available && command.as_deref() == Some("codex-acp") { - if let Some(path_str) = &binary_path { - availability = codex_adapter_availability(&PathBuf::from(path_str)); - } + availability = if cfg!(windows) { + binary_path + .as_deref() + .map(Path::new) + .map(codex_adapter_availability) + .unwrap_or(AcpAvailabilityStatus::AdapterOutdated) + } else { + runtime_plan + .as_ref() + .map(codex_adapter_availability_with_plan) + .unwrap_or(AcpAvailabilityStatus::AdapterOutdated) + }; } // Warm the adapter-availability cache for the badge fallback. @@ -1385,6 +1277,10 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr availability, command, binary_path, + runtime_plan_id: runtime_plan.as_ref().map(|plan| plan.id.clone()), + runtime_plan_source: runtime_plan + .as_ref() + .map(|plan| plan.source_label().to_string()), default_args, mcp_command: runtime.mcp_command.map(str::to_string), model_env_var: runtime.model_env_var.map(str::to_string), @@ -1455,13 +1351,31 @@ pub fn discover_acp_runtimes_from( return None; } let probe_args = partial.runtime.auth_probe_args?; - // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). - let binary_path = resolve_command(probe_args[0])?; + // Codex consumes the content-identified provider component and + // plan-owned PATH. Other runtime families retain their legacy + // discovery path until they gain a complete execution plan. + let runtime_command = partial.runtime.commands.first().copied()?; + let plan = match super::runtime_plan::resolve_runtime_execution_plan(runtime_command) { + Ok(plan) => plan, + Err(_) => return None, + }; + if partial.runtime.id == "codex" && !cfg!(windows) && plan.is_none() { + return None; + } + let binary_path = if let Some(plan) = plan.as_ref() { + plan.provider_cli_path()?.to_path_buf() + } else { + let provider_command = partial.runtime.underlying_cli?; + resolve_command(provider_command)? + }; let probe_args_owned: Vec = probe_args.iter().map(|s| s.to_string()).collect(); let handle = std::thread::spawn(move || { + if plan.as_ref().is_some_and(|plan| plan.verify().is_err()) { + return AuthStatus::Unknown; + } let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect(); - probe_auth_status(&binary_path, &refs) + probe_auth_status(&binary_path, &refs, plan.as_ref()) }); Some((idx, handle)) }) @@ -1544,6 +1458,8 @@ pub fn discover_acp_runtimes_from( availability, command, binary_path, + runtime_plan_id: None, + runtime_plan_source: None, default_args, // Custom harnesses are plain ACP — no MCP sidecar, no env-var // model switching, no thinking knobs. diff --git a/desktop/src-tauri/src/managed_agents/discovery/codex_version.rs b/desktop/src-tauri/src/managed_agents/discovery/codex_version.rs new file mode 100644 index 00000000000..b44868319a8 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/codex_version.rs @@ -0,0 +1,176 @@ +use std::{path::Path, process::Command}; + +use super::AcpAvailabilityStatus; + +/// The oldest `codex-acp` version supported by Buzz managed agents. +/// +/// Raise only for a published adapter that fixes a managed-agent defect. +pub(crate) const MIN_CODEX_ACP_VERSION: (u64, u64, u64) = (1, 1, 7); + +/// Probe the full version of a `codex-acp` binary by running `--version`. +/// +/// The 1.x adapter (`@agentclientprotocol/codex-acp`) outputs +/// `@agentclientprotocol/codex-acp ..` on stdout and exits 0. +/// The old 0.16.x adapter (`@zed-industries/codex-acp`) is a Rust binary that does +/// not recognise `--version` and exits non-zero. +/// +/// Returns the `(major, minor, patch)` triple on success, `None` on any failure +/// (non-zero exit, unparseable output, timeout, or missing binary). +/// +/// The parse is deliberately strict: exactly three numeric dot-separated components. +/// Partial versions (`1.2`) and prerelease tags (`1.2.0-rc1`) return `None` and so +/// classify as [`AcpAvailabilityStatus::AdapterOutdated`] — failing closed offers a +/// reinstall rather than running an adapter whose version cannot be compared. +/// +/// The probe is bounded by a 5-second deadline. The child is polled with +/// [`std::process::Child::try_wait`] (the repo's standard deadline pattern) and +/// killed if it does not exit in time. +/// +/// Stdout is redirected to a temporary file rather than a pipe, so forked +/// descendants cannot hold EOF open. Reads from a regular file return EOF at its +/// current write position regardless of inherited file descriptors, cross-platform. +pub(crate) fn probe_codex_acp_version(binary_path: &Path) -> Option<(u64, u64, u64)> { + probe_codex_acp_version_with_path( + binary_path, + crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), + ) +} +pub(crate) fn probe_codex_acp_version_with_path( + binary_path: &Path, + augmented_path: Option<&str>, +) -> Option<(u64, u64, u64)> { + probe_codex_acp_version_inner(binary_path, augmented_path, None) +} + +fn probe_codex_acp_version_with_plan( + binary_path: &Path, + plan: &crate::managed_agents::runtime_plan::RuntimeExecutionPlan, +) -> Option<(u64, u64, u64)> { + probe_codex_acp_version_inner(binary_path, plan.generated_environment("PATH"), Some(plan)) +} + +fn probe_codex_acp_version_inner( + binary_path: &Path, + augmented_path: Option<&str>, + runtime_plan: Option<&crate::managed_agents::runtime_plan::RuntimeExecutionPlan>, +) -> Option<(u64, u64, u64)> { + use std::io::{Read as _, Seek as _, SeekFrom}; + use std::time::{Duration, Instant}; + const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); + + // A regular file returns EOF at its current size even when a descendant + // inherits its descriptor, bounding the post-exit read cross-platform. + let mut tmp = tempfile::tempfile().ok()?; + + let mut command = Command::new(binary_path); + command.arg("--version"); + if let Some(path) = augmented_path { + command.env("PATH", path); + } + if let Some(plan) = runtime_plan { + plan.apply_environment(&mut command); + } + crate::util::configure_no_window(&mut command); + let mut child = command + .stdout(tmp.try_clone().ok()?) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + + // Poll until the deadline rather than blocking on stdout EOF. + let deadline = Instant::now() + VERSION_PROBE_TIMEOUT; + let exit_status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + }; + + if !exit_status.success() { + return None; + } + + // Read at most 4 KiB from the regular file without blocking. + tmp.seek(SeekFrom::Start(0)).ok()?; + let mut buf = Vec::with_capacity(128); + let _ = (&mut tmp as &mut dyn std::io::Read) + .take(4096) + .read_to_end(&mut buf); + + let stdout = String::from_utf8_lossy(&buf); + // Output format: " .." + let version_str = stdout.split_whitespace().last()?; + let mut components = version_str.split('.'); + let major = components.next()?.parse::().ok()?; + let minor = components.next()?.parse::().ok()?; + let patch = components.next()?.parse::().ok()?; + if components.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +/// Classifies a resolved codex-acp binary path as [`AcpAvailabilityStatus::Available`] +/// or [`AcpAvailabilityStatus::AdapterOutdated`]. +/// +/// The 0.16.x adapter (`@zed-industries/codex-acp`) does not recognise `--version` +/// and exits non-zero — that probe failure yields `AdapterOutdated`. An adapter is +/// available only when its version is at least [`MIN_CODEX_ACP_VERSION`]. +/// +/// Used by `discover_acp_runtimes`, `cli_login_requirements`, and +/// `install_acp_runtime_blocking` so the version-gate logic is not duplicated. +pub(crate) fn codex_adapter_availability(path: &Path) -> AcpAvailabilityStatus { + match probe_codex_acp_version(path) { + Some(version) if version >= MIN_CODEX_ACP_VERSION => AcpAvailabilityStatus::Available, + _ => AcpAvailabilityStatus::AdapterOutdated, + } +} + +pub(crate) fn codex_adapter_availability_with_plan( + plan: &crate::managed_agents::runtime_plan::RuntimeExecutionPlan, +) -> AcpAvailabilityStatus { + let version = plan.harness_path().ok().and_then(|path| { + plan.verify() + .ok() + .and_then(|()| probe_codex_acp_version_with_plan(path, plan)) + }); + match version { + Some(version) if version >= MIN_CODEX_ACP_VERSION => AcpAvailabilityStatus::Available, + _ => AcpAvailabilityStatus::AdapterOutdated, + } +} + +/// Returns `true` when the codex-acp binary at `path` is below +/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed using `augmented_path`. Thin wrapper +/// around [`codex_adapter_is_outdated_with_path`]. +#[cfg(test)] +pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool { + codex_adapter_is_outdated_with_path( + path, + crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), + ) +} + +/// Returns `true` when the codex-acp binary at `path` is below +/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed with the supplied PATH. +pub(crate) fn codex_adapter_is_outdated_with_path( + path: &Path, + augmented_path: Option<&str>, +) -> bool { + !matches!( + probe_codex_acp_version_with_path(path, augmented_path), + Some(version) if version >= MIN_CODEX_ACP_VERSION + ) +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index d86e5f33f05..dea7155aea5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -59,6 +59,8 @@ pub(super) fn preset_catalog_entry( availability, command, binary_path, + runtime_plan_id: None, + runtime_plan_source: None, default_args: normalize_agent_args( def.command, def.args.iter().map(|arg| arg.to_string()).collect(), diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index fe90ce430fd..c6ec1513128 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -32,6 +32,7 @@ mod restore; pub mod retention; mod runtime; mod runtime_commands; +pub(crate) mod runtime_plan; mod runtime_types; pub(crate) mod snapshot_avatar; pub(crate) mod spawn_snapshot; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index a57676f0a97..2bdc8d344c0 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -67,6 +67,16 @@ const NEST_DIR_PROD: &str = ".buzz"; /// `.repos-dir` dotfile and `REPOS` symlink. const NEST_DIR_DEV: &str = ".buzz-dev"; +fn configured_nest_suffix(is_dev: bool) -> String { + if let Some(candidate_id) = option_env!("BUZZ_DESKTOP_BUILD_CANDIDATE_ID") { + format!(".buzz-candidate-{candidate_id}") + } else if is_dev { + NEST_DIR_DEV.to_string() + } else { + NEST_DIR_PROD.to_string() + } +} + /// Process-lifetime nest directory. Initialized once at startup via /// [`init_nest_dir`] before any call to [`nest_dir`]. /// @@ -86,7 +96,7 @@ static NEST_DIR: std::sync::OnceLock> = std::sync::OnceLock::new /// when the Tauri app-data directory name starts with `"xyz.block.buzz.app.dev"`. /// Pass `false` for production (signed DMG) builds. pub fn init_nest_dir(is_dev: bool) { - let suffix = if is_dev { NEST_DIR_DEV } else { NEST_DIR_PROD }; + let suffix = configured_nest_suffix(is_dev); let path = dirs::home_dir().map(|h| h.join(suffix)); // set() is a no-op when already initialized, which is correct: only the // first call (at boot, before any filesystem work) should win. @@ -102,7 +112,7 @@ pub fn nest_dir() -> Option { match NEST_DIR.get() { Some(path) => path.clone(), // Not yet initialized — fall back to prod path. Covers test code. - None => dirs::home_dir().map(|h| h.join(NEST_DIR_PROD)), + None => dirs::home_dir().map(|h| h.join(configured_nest_suffix(false))), } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff13..d32a90e93d4 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1284,11 +1284,10 @@ mod tests { crate::managed_agents::clear_resolve_cache(); } - /// Codex readiness: outdated adapter (exits non-zero) → AdapterOutdated, - /// login probe skipped. + /// Thin-v6 refuses a shell adapter before running its version probe. #[cfg(unix)] #[test] - fn cli_login_requirements_codex_outdated_adapter_emits_adapter_outdated() { + fn cli_login_requirements_codex_shell_adapter_emits_plan_invalid() { let _guard = crate::managed_agents::lock_path_mutex(); let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\nexit 1\n"); @@ -1311,27 +1310,20 @@ mod tests { assert!( !reqs.is_empty(), - "outdated codex adapter must produce a requirement; got {reqs:?}" + "unsupported codex adapter must produce a requirement; got {reqs:?}" + ); + assert!( + matches!(&reqs[0], Requirement::CliConfigInvalid { diagnostic, .. } + if diagnostic.contains("supported Node runtime")), + "unsupported shell adapter must fail at the plan boundary; got {:?}", + reqs[0] ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, - "0.x codex adapter must yield AdapterOutdated; got {availability:?}" - ); - } else { - panic!("expected CliLogin requirement; got {:?}", reqs[0]); - } } - /// Codex readiness: adapter exits 0 but output is not a parseable version - /// → AdapterOutdated (garbage output treated as outdated, same as non-zero). + /// Unsupported launchers are refused before their output can influence readiness. #[cfg(unix)] #[test] - fn cli_login_requirements_codex_garbage_version_output_emits_adapter_outdated() { + fn cli_login_requirements_codex_shell_garbage_output_emits_plan_invalid() { let _guard = crate::managed_agents::lock_path_mutex(); let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\necho 'not a version string'\nexit 0\n"); @@ -1353,18 +1345,12 @@ mod tests { !reqs.is_empty(), "garbage version output must produce a requirement; got {reqs:?}" ); - if let Requirement::CliLogin { - ref availability, .. - } = reqs[0] - { - assert_eq!( - *availability, - crate::managed_agents::AcpAvailabilityStatus::AdapterOutdated, - "unparseable version output must yield AdapterOutdated; got {availability:?}" - ); - } else { - panic!("expected CliLogin requirement; got {:?}", reqs[0]); - } + assert!( + matches!(&reqs[0], Requirement::CliConfigInvalid { diagnostic, .. } + if diagnostic.contains("supported Node runtime")), + "unsupported shell adapter must fail at the plan boundary; got {:?}", + reqs[0] + ); } // ── custom/unknown command ───────────────────────────────────────────── diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs index 4036d9f2393..6235aa575a9 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs @@ -2,8 +2,8 @@ use std::path::Path; use crate::managed_agents::{ discovery::{ - classify_runtime, codex_adapter_availability, find_command, resolve_command, - KnownAcpRuntime, + classify_runtime, codex_adapter_availability, codex_adapter_availability_with_plan, + find_command, resolve_command, KnownAcpRuntime, }, AcpAvailabilityStatus, }; @@ -25,29 +25,68 @@ pub(super) fn requirements( .map(|cli| find_command(cli).is_some()) .unwrap_or(false); - let (availability, _cmd, adapter_path) = + let (mut availability, adapter_command, adapter_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - let availability = if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available + let runtime_plan = if runtime.id == "codex" + && availability == AcpAvailabilityStatus::Available + && !cfg!(windows) { - adapter_path - .as_deref() - .map(|path| codex_adapter_availability(Path::new(path))) - .unwrap_or(availability) + let Some(adapter_command) = adapter_command.as_deref() else { + return vec![invalid_plan_requirement( + setup_copy, + "Codex adapter command disappeared during readiness", + )]; + }; + let plan = match crate::managed_agents::runtime_plan::resolve_runtime_execution_plan( + adapter_command, + ) { + Ok(Some(plan)) => plan, + Ok(None) => { + return vec![invalid_plan_requirement( + setup_copy, + "Codex runtime plan is unavailable on this platform", + )]; + } + Err(error) => return vec![invalid_plan_requirement(setup_copy, &error)], + }; + if let Err(error) = plan.verify() { + return vec![invalid_plan_requirement(setup_copy, &error)]; + } + availability = codex_adapter_availability_with_plan(&plan); + Some(plan) } else { - availability + None }; + if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available && cfg!(windows) { + availability = adapter_path + .as_deref() + .map(|path| codex_adapter_availability(Path::new(path))) + .unwrap_or(AcpAvailabilityStatus::AdapterOutdated); + } match availability { AcpAvailabilityStatus::Available => { - let Some(binary_path) = resolve_command(probe_args[0]) else { + let binary_path = runtime_plan + .as_ref() + .and_then(|plan| plan.provider_cli_path().map(Path::to_path_buf)) + .or_else(|| resolve_command(probe_args[0])); + let Some(binary_path) = binary_path else { return vec![missing_requirement( probe_args, setup_copy, AcpAvailabilityStatus::Available, )]; }; - let augmented_path = cli_probe::augmented_path(); - match cli_probe::login_probe(&binary_path, probe_args, augmented_path.as_deref()) { + let augmented_path = runtime_plan + .as_ref() + .and_then(|plan| plan.generated_environment("PATH").map(str::to_string)) + .or_else(cli_probe::augmented_path); + let probe_outcome = if let Some(plan) = runtime_plan.as_ref() { + cli_probe::login_probe_with_runtime_plan(&binary_path, probe_args, plan) + } else { + cli_probe::login_probe(&binary_path, probe_args, augmented_path.as_deref()) + }; + match probe_outcome { cli_probe::ProbeOutcome::LoggedIn => vec![], cli_probe::ProbeOutcome::LoggedOut => vec![missing_requirement( probe_args, @@ -67,6 +106,14 @@ pub(super) fn requirements( } } +fn invalid_plan_requirement(setup_copy: &str, diagnostic: &str) -> Requirement { + Requirement::CliConfigInvalid { + probe_args: Vec::new(), + setup_copy: setup_copy.to_string(), + diagnostic: format!("runtime execution plan refused readiness: {diagnostic}"), + } +} + fn missing_requirement( probe_args: &[&str], setup_copy: &str, diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 513da4e2a85..2beae09ea05 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -57,12 +57,37 @@ pub(crate) fn login_probe( binary_path: &Path, probe_args: &[&str], augmented_path: Option<&str>, +) -> ProbeOutcome { + login_probe_inner(binary_path, probe_args, augmented_path, None) +} + +pub(crate) fn login_probe_with_runtime_plan( + binary_path: &Path, + probe_args: &[&str], + runtime_plan: &crate::managed_agents::runtime_plan::RuntimeExecutionPlan, +) -> ProbeOutcome { + login_probe_inner( + binary_path, + probe_args, + runtime_plan.generated_environment("PATH"), + Some(runtime_plan), + ) +} + +fn login_probe_inner( + binary_path: &Path, + probe_args: &[&str], + augmented_path: Option<&str>, + runtime_plan: Option<&crate::managed_agents::runtime_plan::RuntimeExecutionPlan>, ) -> ProbeOutcome { let mut command = std::process::Command::new(binary_path); command.args(&probe_args[1..]); if let Some(path) = augmented_path { command.env("PATH", path); } + if let Some(plan) = runtime_plan { + plan.apply_environment(&mut command); + } crate::util::configure_no_window(&mut command); match command.output() { diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index 8698d3a51d1..b4fddd37384 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -41,6 +41,10 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + // Provider executable selection belongs exclusively to an immutable + // RuntimeExecutionPlan. Saved or baked values must never redirect it. + "CLAUDE_CODE_EXECUTABLE", + "CODEX_PATH", // Control-plane parallelism: the Desktop resolves the effective // worker-pool size (applying any per-harness cap) and writes it into // launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index ec804869c42..3c291c635f8 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -8,8 +8,8 @@ use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + runtime_plan::resolve_runtime_execution_plan, spawn_key_refusal, KnownAcpRuntime, + ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentSummary, }, util::now_iso, }; @@ -458,6 +458,9 @@ pub fn spawn_agent_child( let effective_command = &descriptor.command; let agent_args = &descriptor.args; + let runtime_plan = resolve_runtime_execution_plan(effective_command) + .map_err(|error| format!("agent {} runtime plan: {error}", record.pubkey))?; + let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( &log_path, @@ -491,10 +494,13 @@ pub fn spawn_agent_child( } } }; - // Resolve agent command to a full path (DMG launches have minimal PATH). - let resolved_agent_command = resolve_command(effective_command) - .map(|p| p.display().to_string()) - .unwrap_or_else(|| effective_command.clone()); + // Custom harnesses remain on the legacy resolver until they gain a trust flow. + let resolved_agent_command = match runtime_plan.as_ref() { + Some(plan) => plan.harness_path()?.display().to_string(), + None => resolve_command(effective_command) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| effective_command.clone()), + }; // The caller supplies the explicit canonical pair relay. This is the only // relay this child may connect to, regardless of the record/workspace default. @@ -809,7 +815,11 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } - configure_runtime_cli(&mut command, runtime_meta); + if let Some(plan) = runtime_plan.as_ref() { + plan.apply_environment(&mut command); + } else { + configure_runtime_cli(&mut command, runtime_meta); + } // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible // transport at spawn time and scrub any unrelated ambient OpenAI key. @@ -864,6 +874,11 @@ pub fn spawn_agent_child( command.creation_flags(CREATE_NO_WINDOW); } + // Make the final filesystem operation before exec a complete plan + // revalidation. Any detected drift blocks the spawn without rediscovery. + if let Some(plan) = runtime_plan.as_ref() { + plan.verify()?; + } let child = command.spawn().map_err(|error| { format!( "failed to spawn `{}` for agent {}: {error}", diff --git a/desktop/src-tauri/src/managed_agents/runtime_plan.rs b/desktop/src-tauri/src/managed_agents/runtime_plan.rs new file mode 100644 index 00000000000..71311bd8cf5 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_plan.rs @@ -0,0 +1,750 @@ +//! Immutable execution plans for the first managed-agent runtime family. +//! +//! This is the first thin slice of ADR 0001. It resolves the existing runtime +//! catalog into content-identified absolute component paths, denies ambient +//! executable-selection overrides, and revalidates every component before a +//! child process is spawned. Managed packages, snapshots, persistence, signing, +//! update activation, and rollback are deliberately deferred. + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs::File, + io::{BufRead, BufReader, Read}, + path::{Path, PathBuf}, + process::Command, +}; + +use super::{known_acp_runtime, resolve_command}; + +/// Environment variables that may redirect a known provider executable. +pub(crate) const DENIED_EXECUTABLE_ENV: &[&str] = &[ + "CLAUDE_CODE_EXECUTABLE", + "CODEX_PATH", + "DYLD_FALLBACK_FRAMEWORK_PATH", + "DYLD_FALLBACK_LIBRARY_PATH", + "DYLD_FORCE_FLAT_NAMESPACE", + "DYLD_FRAMEWORK_PATH", + "DYLD_IMAGE_SUFFIX", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "DYLD_ROOT_PATH", + "DYLD_VERSIONED_FRAMEWORK_PATH", + "DYLD_VERSIONED_LIBRARY_PATH", + "LD_AUDIT", + "LD_DEBUG", + "LD_DEBUG_OUTPUT", + "LD_LIBRARY_PATH", + "LD_PRELOAD", + "LD_PROFILE", + "NODE_OPTIONS", + "NODE_PATH", + "PATH", +]; + +/// Runtime bytes are shipped with Buzz, held in Buzz's managed prefix, or +/// explicitly reused in place. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RuntimePlanSource { + Bundled, + Managed, + VerifiedExternal, +} + +/// Role played by one content-identified runtime component. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RuntimeComponentRole { + Harness, + ProviderCli, + Interpreter, + RuntimeDependency, +} + +/// One immutable component in a runtime execution plan. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimePlanComponent { + pub role: RuntimeComponentRole, + pub source: RuntimePlanSource, + pub path: PathBuf, + pub sha256: String, + pub bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimePackageInventory { + pub root: PathBuf, + pub tree_sha256: String, + pub files: usize, +} + +/// The sole executable identity consumed by a known runtime operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimeExecutionPlan { + pub id: String, + pub provider_family: String, + pub platform: String, + pub architecture: String, + pub source: RuntimePlanSource, + pub components: Vec, + pub packages: Vec, + pub generated_env: BTreeMap, + pub denied_env: Vec, +} + +impl RuntimeExecutionPlan { + /// Stable wire label for the plan's selected source. + pub(crate) fn source_label(&self) -> &'static str { + match self.source { + RuntimePlanSource::Bundled => "bundled", + RuntimePlanSource::Managed => "managed", + RuntimePlanSource::VerifiedExternal => "verified_external", + } + } + + /// Return the planned harness/adapter executable. + pub(crate) fn harness_path(&self) -> Result<&Path, String> { + self.components + .iter() + .find(|component| component.role == RuntimeComponentRole::Harness) + .map(|component| component.path.as_path()) + .ok_or_else(|| format!("runtime plan {} has no harness component", self.id)) + } + + /// Return the planned provider CLI, when the family has a separate one. + pub(crate) fn provider_cli_path(&self) -> Option<&Path> { + self.components + .iter() + .find(|component| component.role == RuntimeComponentRole::ProviderCli) + .map(|component| component.path.as_path()) + } + + pub(crate) fn generated_environment(&self, key: &str) -> Option<&str> { + self.generated_env.get(key).map(String::as_str) + } + + pub(crate) fn generated_environment_entries(&self) -> impl Iterator { + self.generated_env + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + } + + pub(crate) fn denied_environment(&self) -> impl Iterator { + self.denied_env.iter().map(String::as_str) + } + + /// Re-hash every component immediately before execution and refuse drift. + pub(crate) fn verify(&self) -> Result<(), String> { + for component in &self.components { + let current = component_identity(component.role, component.source, &component.path)?; + if current.sha256 != component.sha256 || current.bytes != component.bytes { + return Err(format!( + "runtime plan {} drifted: {} no longer matches approved SHA-256 {}", + self.id, + component.path.display(), + component.sha256 + )); + } + } + for package in &self.packages { + let current = package_inventory(&package.root)?; + if current.tree_sha256 != package.tree_sha256 || current.files != package.files { + return Err(format!( + "runtime plan {} drifted: package tree {} no longer matches SHA-256 {}", + self.id, + package.root.display(), + package.tree_sha256 + )); + } + } + Ok(()) + } + + /// Remove every executable override and then project plan-owned values. + pub(crate) fn apply_environment(&self, command: &mut Command) { + for key in &self.denied_env { + command.env_remove(key); + } + for (key, value) in &self.generated_env { + command.env(key, value); + } + } +} + +/// Resolve Codex into an immutable plan. Other known runtimes, plus custom and +/// preset harnesses, remain on the legacy path until they gain complete plans. +pub(crate) fn resolve_runtime_execution_plan( + effective_command: &str, +) -> Result, String> { + let Some(runtime) = known_acp_runtime(effective_command) else { + return Ok(None); + }; + // The first candidate proves the plan boundary for one external family. + // Other built-ins stay on their existing path until their complete runtime + // dependency closure can be represented without pretending it is verified. + if runtime.id != "codex" || cfg!(windows) { + return Ok(None); + } + + let harness_path = resolve_command(effective_command).ok_or_else(|| { + format!( + "cannot resolve {} runtime harness `{effective_command}`", + runtime.label + ) + })?; + let source = component_source(&harness_path, runtime.id == "buzz-agent"); + let mut generated_env = BTreeMap::new(); + let mut packages = Vec::new(); + let harness = component_identity(RuntimeComponentRole::Harness, source, &harness_path)?; + let mut components = vec![harness]; + append_node_runtime_closure( + &harness_path, + source, + &mut components, + &mut packages, + &mut generated_env, + )?; + + if let Some(provider_command) = runtime.underlying_cli { + let provider_path = resolve_command(provider_command).ok_or_else(|| { + format!( + "cannot resolve {} provider CLI `{provider_command}`", + runtime.label + ) + })?; + let provider = component_identity( + RuntimeComponentRole::ProviderCli, + RuntimePlanSource::VerifiedExternal, + &provider_path, + )?; + if provider.path != components[0].path { + components.push(provider.clone()); + append_provider_runtime_closure( + &provider.path, + RuntimePlanSource::VerifiedExternal, + &mut components, + &mut packages, + &mut generated_env, + )?; + match runtime.id { + "claude" => { + generated_env.insert( + "CLAUDE_CODE_EXECUTABLE".to_string(), + provider.path.display().to_string(), + ); + } + "codex" => { + generated_env.insert( + "CODEX_PATH".to_string(), + provider.path.display().to_string(), + ); + } + _ => {} + } + } + } + + let id = plan_identity(runtime.id, source, &components, &packages, &generated_env); + Ok(Some(RuntimeExecutionPlan { + id, + provider_family: runtime.id.to_string(), + platform: std::env::consts::OS.to_string(), + architecture: std::env::consts::ARCH.to_string(), + source, + components, + packages, + generated_env, + denied_env: DENIED_EXECUTABLE_ENV + .iter() + .map(|key| (*key).to_string()) + .collect(), + })) +} + +fn append_provider_runtime_closure( + provider: &Path, + source: RuntimePlanSource, + components: &mut Vec, + packages: &mut Vec, + generated_env: &mut BTreeMap, +) -> Result<(), String> { + let mut file = File::open(provider) + .map_err(|error| format!("failed to inspect {}: {error}", provider.display()))?; + let mut prefix = [0_u8; 4]; + file.read_exact(&mut prefix) + .map_err(|error| format!("failed to inspect {}: {error}", provider.display()))?; + if prefix.starts_with(b"#!") { + return append_node_runtime_closure(provider, source, components, packages, generated_env); + } + const NATIVE_MAGICS: [[u8; 4]; 9] = [ + *b"\x7fELF", + [0xfe, 0xed, 0xfa, 0xce], + [0xfe, 0xed, 0xfa, 0xcf], + [0xce, 0xfa, 0xed, 0xfe], + [0xcf, 0xfa, 0xed, 0xfe], + [0xca, 0xfe, 0xba, 0xbe], + [0xbe, 0xba, 0xfe, 0xca], + [0xca, 0xfe, 0xba, 0xbf], + [0xbf, 0xba, 0xfe, 0xca], + ]; + if NATIVE_MAGICS.contains(&prefix) { + // The provider executable itself is already a plan component. Dynamic + // loader injection variables are removed by apply_environment(); OS + // system libraries remain part of the platform trust boundary. + return Ok(()); + } + Err(format!( + "Codex provider {} is neither a Node package launcher nor a supported native executable", + provider.display() + )) +} + +fn append_node_runtime_closure( + launcher: &Path, + source: RuntimePlanSource, + components: &mut Vec, + packages: &mut Vec, + generated_env: &mut BTreeMap, +) -> Result<(), String> { + let file = File::open(launcher).map_err(|error| { + format!( + "failed to inspect Codex adapter launcher {}: {error}", + launcher.display() + ) + })?; + let mut first_line = String::new(); + BufReader::new(file) + .read_line(&mut first_line) + .map_err(|error| format!("failed to read Codex adapter shebang: {error}"))?; + let shebang = first_line + .strip_prefix("#!") + .ok_or_else(|| { + "Codex adapter is not a shebang launcher; refusing an incomplete plan".to_string() + })? + .trim(); + let words: Vec<&str> = shebang.split_whitespace().collect(); + let (interpreter_launcher, interpreter_name) = match words.as_slice() { + [env, name, ..] if *env == "/usr/bin/env" => (Some(PathBuf::from(env)), *name), + [interpreter, ..] => (None, *interpreter), + [] => return Err("Codex adapter has an empty shebang".to_string()), + }; + if Path::new(interpreter_name) + .file_name() + .and_then(|name| name.to_str()) + != Some("node") + { + return Err(format!( + "Codex adapter interpreter `{interpreter_name}` is not the supported Node runtime" + )); + } + + if let Some(env_path) = interpreter_launcher { + push_unique_component( + components, + component_identity( + RuntimeComponentRole::Interpreter, + RuntimePlanSource::VerifiedExternal, + &env_path, + )?, + ); + } + let node_path = resolve_command(interpreter_name) + .ok_or_else(|| "cannot resolve the Node interpreter for Codex adapter".to_string())?; + push_unique_component( + components, + component_identity( + RuntimeComponentRole::Interpreter, + component_source(&node_path, false), + &node_path, + )?, + ); + generated_env.insert("PATH".to_string(), planned_path(&node_path)?); + + let canonical_launcher = launcher.canonicalize().map_err(|error| { + format!( + "failed to canonicalize Codex adapter launcher {}: {error}", + launcher.display() + ) + })?; + let package_root = canonical_launcher + .parent() + .into_iter() + .flat_map(Path::ancestors) + .take(10) + .find(|directory| directory.join("package.json").is_file()) + .ok_or_else(|| { + format!( + "cannot identify the npm package containing Codex adapter {}", + canonical_launcher.display() + ) + })?; + let mut package_files = Vec::new(); + collect_package_files(package_root, &mut package_files, 20_000)?; + package_files.sort(); + for path in &package_files { + push_unique_component( + components, + component_identity(RuntimeComponentRole::RuntimeDependency, source, path)?, + ); + } + let inventory = package_inventory_from_files(package_root, &package_files)?; + if !packages + .iter() + .any(|package| package.root == inventory.root) + { + packages.push(inventory); + } + Ok(()) +} + +fn collect_package_files( + directory: &Path, + files: &mut Vec, + limit: usize, +) -> Result<(), String> { + // npm's `.bin` directory is only a set of alternate launcher symlinks; + // the selected canonical launcher and package payload are inventoried + // separately, so following or accepting those mutable aliases is unsafe. + if directory.file_name().and_then(|name| name.to_str()) == Some(".bin") { + return Ok(()); + } + let entries = std::fs::read_dir(directory).map_err(|error| { + format!( + "failed to read runtime package {}: {error}", + directory.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "failed to enumerate runtime package {}: {error}", + directory.display() + ) + })?; + let file_type = entry + .file_type() + .map_err(|error| format!("failed to inspect {}: {error}", entry.path().display()))?; + if file_type.is_symlink() { + return Err(format!( + "runtime package contains unsupported symlink: {}", + entry.path().display() + )); + } else if file_type.is_dir() { + collect_package_files(&entry.path(), files, limit)?; + } else if file_type.is_file() { + files.push(entry.path()); + } + if files.len() > limit { + return Err(format!( + "runtime package exceeds the {limit}-file verification limit" + )); + } + } + Ok(()) +} + +fn package_inventory(root: &Path) -> Result { + let mut files = Vec::new(); + collect_package_files(root, &mut files, 20_000)?; + files.sort(); + package_inventory_from_files(root, &files) +} + +fn package_inventory_from_files( + root: &Path, + files: &[PathBuf], +) -> Result { + let canonical_root = root.canonicalize().map_err(|error| { + format!( + "failed to canonicalize runtime package {}: {error}", + root.display() + ) + })?; + let mut hasher = Sha256::new(); + for path in files { + let canonical = path.canonicalize().map_err(|error| { + format!( + "failed to canonicalize runtime package file {}: {error}", + path.display() + ) + })?; + let relative = canonical.strip_prefix(&canonical_root).map_err(|_| { + format!( + "runtime package file escaped its root: {}", + canonical.display() + ) + })?; + let identity = component_identity( + RuntimeComponentRole::RuntimeDependency, + RuntimePlanSource::VerifiedExternal, + &canonical, + )?; + let relative = relative.to_string_lossy(); + hasher.update(relative.len().to_le_bytes()); + hasher.update(relative.as_bytes()); + hasher.update(identity.bytes.to_le_bytes()); + hasher.update(identity.sha256.as_bytes()); + } + Ok(RuntimePackageInventory { + root: canonical_root, + tree_sha256: hex::encode(hasher.finalize()), + files: files.len(), + }) +} + +fn planned_path(node_path: &Path) -> Result { + let node_dir = node_path.parent().ok_or_else(|| { + format!( + "Node runtime has no parent directory: {}", + node_path.display() + ) + })?; + let mut paths = vec![node_dir.to_path_buf()]; + for system_path in ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] { + let path = PathBuf::from(system_path); + if path.is_dir() && !paths.contains(&path) { + paths.push(path); + } + } + std::env::join_paths(paths) + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|error| format!("failed to construct plan-owned PATH: {error}")) +} + +fn push_unique_component( + components: &mut Vec, + component: RuntimePlanComponent, +) { + if !components + .iter() + .any(|existing| existing.path == component.path) + { + components.push(component); + } +} + +fn component_source(path: &Path, bundled: bool) -> RuntimePlanSource { + if bundled { + return RuntimePlanSource::Bundled; + } + let managed_prefix = + super::buzz_managed_npm_prefix().and_then(|prefix| prefix.canonicalize().ok()); + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if managed_prefix.is_some_and(|prefix| canonical.starts_with(prefix)) { + RuntimePlanSource::Managed + } else { + RuntimePlanSource::VerifiedExternal + } +} + +fn component_identity( + role: RuntimeComponentRole, + source: RuntimePlanSource, + path: &Path, +) -> Result { + let canonical = path.canonicalize().map_err(|error| { + format!( + "failed to canonicalize runtime component {}: {error}", + path.display() + ) + })?; + let metadata = canonical.metadata().map_err(|error| { + format!( + "failed to inspect runtime component {}: {error}", + canonical.display() + ) + })?; + if !metadata.is_file() { + return Err(format!( + "runtime component is not a file: {}", + canonical.display() + )); + } + let sha256 = sha256_file(&canonical)?; + Ok(RuntimePlanComponent { + role, + source, + path: canonical, + sha256, + bytes: metadata.len(), + }) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).map_err(|error| { + format!( + "failed to open runtime component {}: {error}", + path.display() + ) + })?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(|error| { + format!( + "failed to hash runtime component {}: {error}", + path.display() + ) + })?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(hex::encode(digest.finalize())) +} + +fn plan_identity( + provider_family: &str, + source: RuntimePlanSource, + components: &[RuntimePlanComponent], + packages: &[RuntimePackageInventory], + generated_env: &BTreeMap, +) -> String { + let mut digest = Sha256::new(); + digest.update(b"buzz-runtime-plan-v2\0"); + digest.update(provider_family.as_bytes()); + digest.update([0]); + digest.update(format!("{source:?}").as_bytes()); + digest.update([0]); + digest.update(std::env::consts::OS.as_bytes()); + digest.update([0]); + digest.update(std::env::consts::ARCH.as_bytes()); + for component in components { + digest.update([0]); + digest.update(format!("{:?}", component.role).as_bytes()); + digest.update([0]); + digest.update(component.path.to_string_lossy().as_bytes()); + digest.update([0]); + digest.update(component.sha256.as_bytes()); + digest.update(component.bytes.to_le_bytes()); + } + for package in packages { + digest.update([0]); + digest.update(package.root.to_string_lossy().as_bytes()); + digest.update([0]); + digest.update(package.tree_sha256.as_bytes()); + digest.update(package.files.to_le_bytes()); + } + for (key, value) in generated_env { + digest.update([0]); + digest.update(key.as_bytes()); + digest.update([0]); + digest.update(value.as_bytes()); + } + hex::encode(digest.finalize()) +} + +#[cfg(test)] +mod tests { + use super::{ + collect_package_files, component_identity, package_inventory, plan_identity, + RuntimeComponentRole, RuntimeExecutionPlan, RuntimePlanSource, DENIED_EXECUTABLE_ENV, + }; + use std::{collections::BTreeMap, fs}; + + #[test] + fn plan_identity_changes_with_component_bytes() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("adapter"); + fs::write(&path, b"first").expect("write component"); + let first = component_identity( + RuntimeComponentRole::Harness, + RuntimePlanSource::VerifiedExternal, + &path, + ) + .expect("first identity"); + fs::write(&path, b"second").expect("replace component"); + let second = component_identity( + RuntimeComponentRole::Harness, + RuntimePlanSource::VerifiedExternal, + &path, + ) + .expect("second identity"); + assert_ne!( + plan_identity( + "codex", + RuntimePlanSource::VerifiedExternal, + &[first], + &[], + &BTreeMap::new() + ), + plan_identity( + "codex", + RuntimePlanSource::VerifiedExternal, + &[second], + &[], + &BTreeMap::new() + ) + ); + } + + #[test] + fn verification_fails_closed_after_drift() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("adapter"); + fs::write(&path, b"approved").expect("write component"); + let component = component_identity( + RuntimeComponentRole::Harness, + RuntimePlanSource::VerifiedExternal, + &path, + ) + .expect("component identity"); + let plan = RuntimeExecutionPlan { + id: "test-plan".to_string(), + provider_family: "codex".to_string(), + platform: std::env::consts::OS.to_string(), + architecture: std::env::consts::ARCH.to_string(), + source: RuntimePlanSource::VerifiedExternal, + components: vec![component], + packages: vec![], + generated_env: BTreeMap::new(), + denied_env: DENIED_EXECUTABLE_ENV + .iter() + .map(|key| (*key).to_string()) + .collect(), + }; + plan.verify().expect("approved bytes verify"); + fs::write(&path, b"drifted").expect("replace component"); + assert!(plan.verify().is_err()); + } + + #[test] + fn package_inventory_includes_payload_and_skips_launcher_aliases() { + let dir = tempfile::tempdir().expect("temp dir"); + let nested = dir.path().join("dist"); + let aliases = dir.path().join("node_modules/.bin"); + fs::create_dir_all(&nested).expect("create payload directory"); + fs::create_dir_all(&aliases).expect("create alias directory"); + let payload = nested.join("index.js"); + let alias = aliases.join("codex-acp"); + fs::write(&payload, b"export {};").expect("write payload"); + fs::write(&alias, b"ignored launcher alias").expect("write alias"); + + let mut files = Vec::new(); + collect_package_files(dir.path(), &mut files, 20).expect("collect package"); + assert!(files.contains(&payload)); + assert!(!files.contains(&alias)); + + let inventory = package_inventory(dir.path()).expect("inventory package"); + let plan = RuntimeExecutionPlan { + id: "package-plan".to_string(), + provider_family: "codex".to_string(), + platform: std::env::consts::OS.to_string(), + architecture: std::env::consts::ARCH.to_string(), + source: RuntimePlanSource::VerifiedExternal, + components: vec![], + packages: vec![inventory], + generated_env: BTreeMap::new(), + denied_env: vec![], + }; + plan.verify().expect("package tree verifies"); + fs::write(dir.path().join("added.js"), b"unexpected").expect("add package file"); + assert!(plan.verify().is_err()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e5be105fed0..8084be0cc77 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -625,53 +625,8 @@ pub enum HarnessSource { /// Loaded at runtime from the user's `custom_harnesses/` directory. Custom, } - -#[derive(Debug, Clone, Serialize)] -pub struct AcpRuntimeCatalogEntry { - pub id: String, - pub label: String, - pub avatar_url: String, - pub availability: AcpAvailabilityStatus, - pub command: Option, - pub binary_path: Option, - pub default_args: Vec, - pub mcp_command: Option, - /// Environment variable used to apply the initial model, when supported. - pub model_env_var: Option, - /// Environment variable used to apply the selected LLM provider, when supported. - pub provider_env_var: Option, - /// Environment variable used to apply thinking effort, when supported. - pub thinking_env_var: Option, - pub max_tokens_env_var: Option, - pub context_limit_env_var: Option, - pub max_rounds_env_var: Option, - pub install_hint: String, - pub install_instructions_url: String, - /// true when at least one automated install step is available - pub can_auto_install: bool, - /// true when this runtime depends on a separately installed vendor CLI. - pub requires_external_cli: bool, - pub underlying_cli_path: Option, - /// true when an npm adapter step is pending but Node.js / npm is absent. - /// The UI hides the Install button and shows a Node.js install callout. - pub node_required: bool, - /// Login/authentication status for CLI-based runtimes. - pub auth_status: AuthStatus, - /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. - #[serde(skip_serializing_if = "Option::is_none")] - pub login_hint: Option, - /// Whether this entry came from the compiled-in catalog or a user-supplied - /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. - pub source: HarnessSource, - /// Definition-level env vars for `source: custom` entries; populated from - /// `HarnessDefinition.env` so saves don't silently erase existing vars. - /// Absent for builtin/preset entries. Skipped when empty in serialization. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub definition_env: BTreeMap, - /// Spawn-time parallelism cap; absent for uncapped harnesses. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_parallelism: Option, -} +mod runtime_catalog; +pub use runtime_catalog::AcpRuntimeCatalogEntry; /// Result of a single install step (CLI or adapter). #[derive(Debug, Clone, Serialize)] diff --git a/desktop/src-tauri/src/managed_agents/types/runtime_catalog.rs b/desktop/src-tauri/src/managed_agents/types/runtime_catalog.rs new file mode 100644 index 00000000000..33ccb0c7b5b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/runtime_catalog.rs @@ -0,0 +1,56 @@ +use std::collections::BTreeMap; + +use serde::Serialize; + +use super::{AcpAvailabilityStatus, AuthStatus, HarnessSource}; + +#[derive(Debug, Clone, Serialize)] +pub struct AcpRuntimeCatalogEntry { + pub id: String, + pub label: String, + pub avatar_url: String, + pub availability: AcpAvailabilityStatus, + pub command: Option, + pub binary_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_plan_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_plan_source: Option, + pub default_args: Vec, + pub mcp_command: Option, + /// Environment variable used to apply the initial model, when supported. + pub model_env_var: Option, + /// Environment variable used to apply the selected LLM provider, when supported. + pub provider_env_var: Option, + /// Environment variable used to apply thinking effort, when supported. + pub thinking_env_var: Option, + pub max_tokens_env_var: Option, + pub context_limit_env_var: Option, + pub max_rounds_env_var: Option, + pub install_hint: String, + pub install_instructions_url: String, + /// true when at least one automated install step is available + pub can_auto_install: bool, + /// true when this runtime depends on a separately installed vendor CLI. + pub requires_external_cli: bool, + pub underlying_cli_path: Option, + /// true when an npm adapter step is pending but Node.js / npm is absent. + /// The UI hides the Install button and shows a Node.js install callout. + pub node_required: bool, + /// Login/authentication status for CLI-based runtimes. + pub auth_status: AuthStatus, + /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. + #[serde(skip_serializing_if = "Option::is_none")] + pub login_hint: Option, + /// Whether this entry came from the compiled-in catalog or a user-supplied + /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. + pub source: HarnessSource, + /// Definition-level env vars for `source: custom` entries; populated from + /// `HarnessDefinition.env` so saves don't silently erase existing vars. + /// Absent for builtin/preset entries. Skipped when empty in serialization. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub definition_env: BTreeMap, + /// Spawn-time parallelism cap; absent for uncapped harnesses. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_parallelism: Option, +} diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index eb24d88084a..4d9c44ff520 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -6,6 +6,7 @@ import { getPersonaModelOptions, getPersonaProviderOptions, getProviderApiKeyLabel, + formatRuntimeOptionLabel, resetConfigForHarnessChange, runtimeSupportsLlmProviderSelection, } from "./agentConfigOptions.tsx"; @@ -24,6 +25,34 @@ function makeRuntime(id, availability = "available") { }; } +test("formatRuntimeOptionLabel surfaces verified external plan identity", () => { + const runtime = makeRuntime("codex"); + runtime.label = "Codex"; + runtime.runtimePlanSource = "verified_external"; + runtime.runtimePlanId = "0123456789abcdef0123456789abcdef"; + assert.equal( + formatRuntimeOptionLabel(runtime), + "Codex (verified external · 0123456789ab)", + ); +}); + +test("formatRuntimeOptionLabel keeps unavailability ahead of plan metadata", () => { + const runtime = makeRuntime("codex", "cli_missing"); + runtime.runtimePlanSource = "verified_external"; + runtime.runtimePlanId = "0123456789abcdef"; + assert.equal(formatRuntimeOptionLabel(runtime), "codex (CLI missing)"); +}); + +test("formatRuntimeOptionLabel identifies an unbundled managed adapter", () => { + const runtime = makeRuntime("codex"); + runtime.runtimePlanSource = "managed"; + runtime.runtimePlanId = "abcdef0123456789"; + assert.equal( + formatRuntimeOptionLabel(runtime), + "codex (managed adapter · abcdef012345)", + ); +}); + // ── getPersonaProviderOptions — hideProviderIds ─────────────────────────────── test("getPersonaProviderOptions returns databricks v1 and v2 when hideProviderIds is empty", () => { diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 5c515a05073..2612ea84604 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -475,7 +475,15 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { ? " (CLI missing)" : runtime.availability === "not_installed" ? " (not installed)" - : ""; + : runtime.runtimePlanSource === "verified_external" && + runtime.runtimePlanId + ? ` (verified external · ${runtime.runtimePlanId.slice(0, 12)})` + : runtime.runtimePlanSource === "managed" && runtime.runtimePlanId + ? ` (managed adapter · ${runtime.runtimePlanId.slice(0, 12)})` + : runtime.runtimePlanSource === "bundled" && + runtime.runtimePlanId + ? ` (bundled · ${runtime.runtimePlanId.slice(0, 12)})` + : ""; return `${runtime.label}${suffix}`; } diff --git a/desktop/src/shared/api/runtimeTypes.ts b/desktop/src/shared/api/runtimeTypes.ts new file mode 100644 index 00000000000..7901ecd59b7 --- /dev/null +++ b/desktop/src/shared/api/runtimeTypes.ts @@ -0,0 +1,57 @@ +export type AcpAvailabilityStatus = + | "available" + | "adapter_missing" + | "adapter_outdated" + | "cli_missing" + | "not_installed"; + +/** Authentication/login status for a CLI-based ACP runtime. */ +export type AuthStatus = + | { status: "logged_in" } + | { status: "logged_out" } + | { status: "config_invalid"; diagnostic: string } + | { status: "not_applicable" } + | { status: "unknown" }; +export type AcpRuntimeCatalogEntry = { + id: string; + label: string; + avatarUrl: string; + availability: AcpAvailabilityStatus; + command: string | null; + binaryPath: string | null; + runtimePlanId?: string; + runtimePlanSource?: "bundled" | "managed" | "verified_external"; + defaultArgs: string[]; + mcpCommand: string | null; + /** Environment variable used to apply the initial model, when supported. */ + modelEnvVar: string | null; + /** Environment variable used to apply the selected LLM provider, when supported. */ + providerEnvVar: string | null; + /** Environment variable used to apply thinking effort, when supported. */ + thinkingEnvVar: string | null; + maxTokensEnvVar: string | null; + contextLimitEnvVar: string | null; + maxRoundsEnvVar: string | null; + installHint: string; + installInstructionsUrl: string; + canAutoInstall: boolean; + /** True when the runtime depends on a separately installed vendor CLI. */ + requiresExternalCli: boolean; + underlyingCliPath: string | null; + /** True when an npm adapter step is pending but Node.js / npm is absent. */ + nodeRequired: boolean; + /** Login/auth status for CLI-based runtimes. */ + authStatus: AuthStatus; + /** Hint for completing authentication; null when not applicable or already logged in. */ + loginHint: string | null; + /** "builtin" (compiled in), "preset" (PATH-probed, not editable), or "custom" (user JSON). Controls UI editability. */ + source: "builtin" | "preset" | "custom"; + /** + * Definition-level env vars for `source: custom` entries. Populated from + * `HarnessDefinition.env` so saves don't erase existing vars. Absent for + * builtin/preset entries. + */ + definitionEnv?: Record; + /** Spawn-time parallelism cap; absent for uncapped harnesses. */ + maxParallelism?: number; +}; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 24ef6257832..8f154a76b32 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -1,3 +1,5 @@ +import type { AcpRuntimeCatalogEntry } from "./runtimeTypes"; + export type ChannelType = "stream" | "forum" | "dm"; export type ChannelVisibility = "open" | "private"; export type ChannelRole = "owner" | "admin" | "member" | "guest" | "bot"; @@ -487,62 +489,11 @@ export type GitBashPrerequisite = { installHint: string; }; -export type AcpAvailabilityStatus = - | "available" - | "adapter_missing" - | "adapter_outdated" - | "cli_missing" - | "not_installed"; - -/** Authentication/login status for a CLI-based ACP runtime. */ -export type AuthStatus = - | { status: "logged_in" } - | { status: "logged_out" } - | { status: "config_invalid"; diagnostic: string } - | { status: "not_applicable" } - | { status: "unknown" }; - -export type AcpRuntimeCatalogEntry = { - id: string; - label: string; - avatarUrl: string; - availability: AcpAvailabilityStatus; - command: string | null; - binaryPath: string | null; - defaultArgs: string[]; - mcpCommand: string | null; - /** Environment variable used to apply the initial model, when supported. */ - modelEnvVar: string | null; - /** Environment variable used to apply the selected LLM provider, when supported. */ - providerEnvVar: string | null; - /** Environment variable used to apply thinking effort, when supported. */ - thinkingEnvVar: string | null; - maxTokensEnvVar: string | null; - contextLimitEnvVar: string | null; - maxRoundsEnvVar: string | null; - installHint: string; - installInstructionsUrl: string; - canAutoInstall: boolean; - /** True when the runtime depends on a separately installed vendor CLI. */ - requiresExternalCli: boolean; - underlyingCliPath: string | null; - /** True when an npm adapter step is pending but Node.js / npm is absent. */ - nodeRequired: boolean; - /** Login/auth status for CLI-based runtimes. */ - authStatus: AuthStatus; - /** Hint for completing authentication; null when not applicable or already logged in. */ - loginHint: string | null; - /** "builtin" (compiled in), "preset" (PATH-probed, not editable), or "custom" (user JSON). Controls UI editability. */ - source: "builtin" | "preset" | "custom"; - /** - * Definition-level env vars for `source: custom` entries. Populated from - * `HarnessDefinition.env` so saves don't erase existing vars. Absent for - * builtin/preset entries. - */ - definitionEnv?: Record; - /** Spawn-time parallelism cap; absent for uncapped harnesses. */ - maxParallelism?: number; -}; +export type { + AcpAvailabilityStatus, + AcpRuntimeCatalogEntry, + AuthStatus, +} from "./runtimeTypes"; /** An AcpRuntimeCatalogEntry that is confirmed available — command and binaryPath are non-null. */ export type AcpRuntime = AcpRuntimeCatalogEntry & { diff --git a/docs/adr/0001-hybrid-agent-runtime-thin-v6-slice.md b/docs/adr/0001-hybrid-agent-runtime-thin-v6-slice.md new file mode 100644 index 00000000000..e12b28b9a30 --- /dev/null +++ b/docs/adr/0001-hybrid-agent-runtime-thin-v6-slice.md @@ -0,0 +1,64 @@ +# ADR 0001 implementation slice: thin v6 Codex runtime plan + +Status: Candidate for isolated verification + +Source identities: + +- Canonical source base: `block/buzz@119a84897f225c1e3213a09cd149abb37dcb3abc` +- Accepted ADR anchor: `Peakhunter/buzz@5be4a45a7c0c8a60cee3eb4273e2a425b4122b43` + +## Smallest real vertical slice + +This candidate applies ADR 0001's execution-authority boundary to one runtime family: Codex on non-Windows desktop platforms. + +For each Codex operation, Buzz resolves one runtime execution plan containing: + +- the absolute `codex-acp` adapter path; +- the complete npm adapter package tree; +- the exact Node interpreter; +- the absolute Codex provider path; +- either the complete npm provider package tree or a content-hashed native Mach-O/ELF provider; +- SHA-256 identities, byte counts, source classes, package-tree inventories, platform, architecture, and a deterministic plan ID; +- a plan-owned executable environment. + +The same plan contract is consumed by adapter version detection, readiness/login probes, account connection, visible terminal login, model discovery, and managed-agent spawn. Components and package inventories are revalidated immediately before execution. Runtime selection fails closed on drift, undeclared package symlinks, unsupported launchers, or unresolved components. + +The plan removes executable redirection, Node injection, dynamic-loader injection, and ambient `PATH` overrides. It supplies absolute adapter/provider paths and a minimal PATH containing the verified Node directory plus fixed operating-system directories. + +The runtime catalog exposes the plan source (`managed`, `verified_external`, or `bundled`) and a short immutable plan ID for UI acceptance. + +## Deliberate boundaries + +- Codex and `codex-acp` are not copied into or bundled with `Buzz.app`. +- Existing verified runtimes are reused in place. Runtime installation remains an explicit user action. +- Other runtime families retain their existing behavior and are deferred to later slices. +- Windows Codex retains its existing behavior and is deferred. +- Native providers are treated as self-contained; operating-system libraries remain in the platform trust boundary. +- Same-user mutation after the immediate pre-execution verification is outside this slice's threat model. + +## Isolated candidate + +The CI candidate is an ad-hoc-signed Apple Silicon app named `Buzz v6 Candidate` with: + +- bundle identifier `xyz.block.buzz.app.dev.thinv6`; +- keyring service `buzz-desktop-candidate.thin-v6`; +- nest directory `~/.buzz-candidate-thin-v6`; +- deep-link scheme `buzz-v6-candidate`; +- updater endpoints disabled. + +This keeps the installed/running v5 app, its keyring, nest, deep links, source, caches, and rollback material untouched. + +## Gates + +The gates remain independent and ordered: + +1. ADR anchor and human review. +2. Source review of this implementation slice. +3. Public free-runner build. +4. Independent artifact download and hash verification. +5. Isolated Mac UI/product acceptance. +6. Signing review. +7. Installation approval. +8. Release approval. + +This candidate authorizes only gates 1-4. It does not authorize production signing, notarization, installation, GitHub Releases, or replacement of v5.