diff --git a/apps/docs/next/deployment/blue-yaml.mdx b/apps/docs/next/deployment/blue-yaml.mdx index b941f0c..642af42 100644 --- a/apps/docs/next/deployment/blue-yaml.mdx +++ b/apps/docs/next/deployment/blue-yaml.mdx @@ -305,6 +305,7 @@ The `governance` section is the deployment-authored baseline delivered to client | `contract_version` | Wire contract version. Current clients support version 3. | | `required_capabilities` | Features a client must support before accepting policy. | | `minimum_client_version` | Operator-visible rollout floor; required when using harness version ranges or versioned package adapters. | +| `required_client_version` | Optional canonical exact SemVer recommendation for the Blue CLI and major compatibility boundary. Same-major differences warn; different majors block. | | `ttl_seconds` | Client cache and polling interval; defaults to 300 seconds. | | `required` | When true, clients fail closed instead of launching from stale cached policy. | | `allowed_harnesses` | Supported agent keys users may launch through Blue. | @@ -402,3 +403,46 @@ Every source must be immutable and SHA-256 pinned. Use `platform_sources` when h A complete rollout has healthy service probes, no unresolved environment references, the expected governance revision in the dashboard, and matching desired/applied revisions on a test client. + +## Tenant-compatible Blue CLI + +The reference control API advertises its deployment version as the exact +recommended Blue CLI release and the major-version compatibility boundary. Set +`governance.required_client_version` to a published, compatible release to +override that default, for example during a rolling upgrade. The default is +applied when serving a document, so old revisions follow new deployments without +being rewritten. An explicit value stays fixed across rollouts. Use canonical +SemVer without a `v` prefix or range operators. `minimum_client_version` remains +informational. + +The `tenant_client_version_pin` capability is required whenever a pin is served. +The `X-Blue-Required-Client-Version` header exposes the same pin on successful +config responses and capability/contract rejections (HTTP 426). Older clients +without this capability are blocked and need a manual install. + +A CLI with the same major version continues governed commands and prints a +neutral recommendation when its minor, patch, prerelease, or build metadata +differs. This applies whether the client is older or newer. Exact matches are +silent. Foreground commands warn at most once; background watchers log the +recommendation without interrupting a running harness. + +A CLI with a different major version blocks governed commands before launching a +harness or applying managed configuration. In an interactive foreground command, +Blue offers to install the exact recommended release, with consent defaulting to +no. It stages and verifies the release before replacing the running executable. +After success, rerun the original command; the old process never continues the +operation and returns a nonzero exit code. Automatic installation supports stable +releases only and requires a writable, safely resolved installation destination. + +Noninteractive commands, daemons and internal hooks never prompt or install. +`blue apply --yes` does not approve replacing Blue. A difference displays the +recommended installer command when available. A major mismatch also displays +`blue reset` as a way to detach; declining or failing installation leaves that +command blocked. + +A live cross-major mismatch invalidates the previous cached policy. Cached +documents are also checked against the executing CLI version. Same-major drift +does not invalidate the cache or managed state. A client that has never observed +a remote version change cannot discover it offline; otherwise compatible cached +policy retains the usual offline behavior. `blue reset` remains available without +fetching governance policy. diff --git a/apps/docs/next/reference/governance-config.mdx b/apps/docs/next/reference/governance-config.mdx index 4d612b4..41ac88b 100644 --- a/apps/docs/next/reference/governance-config.mdx +++ b/apps/docs/next/reference/governance-config.mdx @@ -79,6 +79,7 @@ harnesses: | `allowed_harnesses` | Harness names permitted by policy. | | `harnesses` | Per-harness policy keyed by supported harness name. | | `minimum_client_version` | Operator-visible rollout floor; it does not replace capability enforcement. | +| `required_client_version` | Optional canonical exact SemVer recommendation for the Blue CLI and major compatibility boundary. Same-major differences warn; different majors block. | | `packages` | Organization-selected, immutable extension packages and their harness adapters. | | `gateway` | Global inference routing policy applied to every allowed coding agent. | | `session_upload` | Global portable-session capture policy; compatible profiles upload integrity-checked bundles while older clients remain accepted as non-resumable raw artifacts. | @@ -138,3 +139,30 @@ Standalone skills can live anywhere in an archive and do not require a native pl The dashboard can inspect `github:owner/repository@ref` for a public GitHub repository, a public HTTPS archive, or a deployment-managed GitHub/Bitbucket connection. Public GitHub refs resolve to immutable codeload URLs; clients download public sources directly and verify their exact digest. Managed repositories are resolved to an immutable commit and mirrored into organization-scoped object storage. Governance contains an `artifact_id` and exact digest; clients use their existing harness session to request a fresh short-lived download and never receive repository credentials. Removing a package disables it for subsequent governed launches. Unmodified owned content is deleted; locally changed content is moved to quarantine and reported as drift. + +## Tenant-compatible Blue CLI + +The reference control API advertises its deployment version as the exact +recommended Blue CLI release and the major-version compatibility boundary. Set +`governance.required_client_version` to a published, compatible release to +override that default. The value remains an exact canonical SemVer, not a range. +`minimum_client_version` remains informational. + +The `tenant_client_version_pin` capability is required whenever a pin is served. +The `X-Blue-Required-Client-Version` header exposes the same pin on successful +config responses and capability/contract rejections (HTTP 426). Older clients +without this capability are blocked and need a manual install. + +A different minor, patch, prerelease, or build value within the same major emits +a neutral recommendation and continues, whether the client is older or newer. +Exact matches are silent. A different major blocks before launch or configuration +changes and may offer a consent-based install of the exact recommended release. + +Noninteractive commands, daemons and internal hooks never prompt or install; +background sessions only log same-major recommendations. `blue apply --yes` does +not approve replacing Blue. Cross-major errors include `blue reset` as a way to +detach. + +A live cross-major mismatch invalidates the previous cached policy. Same-major +drift keeps cached policy and managed state usable. Cached documents are checked +on load, and `blue reset` remains available without fetching governance policy. diff --git a/apps/docs/openapi/next.yaml b/apps/docs/openapi/next.yaml index f973c2b..7aee223 100644 --- a/apps/docs/openapi/next.yaml +++ b/apps/docs/openapi/next.yaml @@ -188,12 +188,21 @@ paths: responses: "200": description: The governance config + headers: + X-Blue-Required-Client-Version: + description: Exact recommended Blue CLI release, identical to required_client_version in the document. Same-major differences warn; different majors block. + schema: { type: string } content: application/json: schema: { $ref: "#/components/schemas/GovernanceConfig" } "403": { description: Authenticated email has no provisioned gateway account } "409": { description: Gateway key provisioning is required } - "426": { description: Client contract version or capabilities are insufficient } + "426": + description: Client contract version or capabilities are insufficient + headers: + X-Blue-Required-Client-Version: + description: Exact recommended Blue CLI release and major compatibility boundary, provided even when the document cannot be consumed. + schema: { type: string } /harness-metadata: get: @@ -1684,8 +1693,12 @@ components: contract_version: { type: integer, default: 1, description: Governance wire contract version } required_capabilities: type: array - items: { type: string, enum: [adapter_intervals, compiled_harness_registry, transactional_reconcile, unverified_harness_versions, versioned_state] } + items: { type: string, enum: [adapter_intervals, compiled_harness_registry, tenant_client_version_pin, transactional_reconcile, unverified_harness_versions, versioned_state] } minimum_client_version: { type: string, description: Operator-visible rollout floor; required capabilities enforce compatibility } + required_client_version: + type: string + description: Canonical exact SemVer of the recommended Blue CLI release and major compatibility boundary. Unequal versions within the same major warn and continue; different majors block. Defaults to the control API deployment version. Requires tenant_client_version_pin capability. + example: "0.1.0" ttl_seconds: { type: integer, description: Client cache TTL (default 300), example: 300 } required: { type: boolean, description: "If true, client fails closed without fresh config" } allowed_harnesses: diff --git a/crates/gh-agent/src/lib.rs b/crates/gh-agent/src/lib.rs index 7a557de..8050121 100644 --- a/crates/gh-agent/src/lib.rs +++ b/crates/gh-agent/src/lib.rs @@ -399,9 +399,10 @@ pub fn reconcile_loop( /// not be, because nothing the daemon does will fix it. fn failure_backoff(error: &GhError) -> (u64, bool) { match error { - GhError::Unauthorized(_) | GhError::Forbidden(_) | GhError::ActionRequired(_) => { - (GovernanceConfig::DEFAULT_TTL_SECONDS * 4, true) - } + GhError::ClientVersionMismatch { .. } + | GhError::Unauthorized(_) + | GhError::Forbidden(_) + | GhError::ActionRequired(_) => (GovernanceConfig::DEFAULT_TTL_SECONDS * 4, true), _ => (GovernanceConfig::DEFAULT_TTL_SECONDS, false), } } @@ -475,6 +476,7 @@ mod tests { contract_version: GovernanceConfig::CONTRACT_VERSION, required_capabilities: Vec::new(), minimum_client_version: None, + required_client_version: None, ttl_seconds: None, allowed_harnesses: vec!["codex".into(), "claude".into()], harnesses: Default::default(), diff --git a/crates/gh-cli/src/client_version.rs b/crates/gh-cli/src/client_version.rs new file mode 100644 index 0000000..65032ee --- /dev/null +++ b/crates/gh-cli/src/client_version.rs @@ -0,0 +1,760 @@ +//! Foreground-only, consent-based repair of the Blue executable itself. +use anyhow::{bail, Context, Result}; +use gh_service::GovernanceConfig; +use std::fs::{self, File, OpenOptions}; +use std::io::{IsTerminal, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const RELEASES: &str = "https://github.com/BlocksOrg/blue/releases"; + +pub(crate) fn recommendation(installed: &str, recommended: &str, os: &str, arch: &str) -> String { + let mut message = format!( + "Blue {installed} is compatible, but this tenant recommends Blue {recommended}.\nRelease: {RELEASES}/tag/v{recommended}\n" + ); + match (target(os, arch), stable_pin(recommended)) { + (Some(target), Ok(())) => { + let extension = if os == "windows" { "zip" } else { "tar.gz" }; + message.push_str(&format!( + "Asset: blue-v{recommended}-{target}.{extension}\n" + )); + if os == "windows" { + message.push_str(&format!("$env:BLUE_VERSION = \"v{recommended}\"\nirm {RELEASES}/download/v{recommended}/install.ps1 | iex\n")); + } else { + message.push_str(&format!("curl --proto '=https' --tlsv1.2 -LsSf {RELEASES}/download/v{recommended}/install.sh | BLUE_VERSION=v{recommended} sh\n")); + } + } + _ => message.push_str( + "Automatic installation is unavailable for this target or release. Consult the release page.\n", + ), + } + message +} + +pub(crate) fn warn_if_recommended(config: &GovernanceConfig) { + let Some(required) = config.required_client_version.as_deref() else { + return; + }; + if let Ok(gh_service::ClientVersionCompatibility::SameMajorRecommendation { + installed, + recommended, + }) = GovernanceConfig::client_version_compatibility(required, env!("CARGO_PKG_VERSION")) + { + eprintln!( + "{}", + recommendation( + &installed, + &recommended, + std::env::consts::OS, + std::env::consts::ARCH + ) + ); + } +} + +fn target(os: &str, arch: &str) -> Option { + let arch = match arch { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + _ => return None, + }; + let os = match os { + "linux" => "unknown-linux-musl", + "macos" => "apple-darwin", + "windows" => "pc-windows-msvc", + _ => return None, + }; + Some(format!("{arch}-{os}")) +} + +fn stable_pin(required: &str) -> Result<()> { + let version = GovernanceConfig::validate_client_version_pin(required)?; + if !version.pre.is_empty() || !version.build.is_empty() { + bail!("automatic installation supports only published stable MAJOR.MINOR.PATCH releases"); + } + Ok(()) +} + +pub(crate) fn remedy(installed: &str, required: &str, os: &str, arch: &str) -> String { + let mut message = format!("Blue {installed} has an incompatible major version; this tenant requires Blue {required}.\nRelease: {RELEASES}/tag/v{required}\n"); + match (target(os, arch), stable_pin(required)) { + (Some(target), Ok(())) => { + let extension = if os == "windows" { "zip" } else { "tar.gz" }; + message.push_str(&format!("Asset: blue-v{required}-{target}.{extension}\n\n")); + if os == "windows" { + message.push_str(&format!("$env:BLUE_VERSION = \"v{required}\"\nirm {RELEASES}/download/v{required}/install.ps1 | iex\n")); + } else { + message.push_str(&format!("curl --proto '=https' --tlsv1.2 -LsSf {RELEASES}/download/v{required}/install.sh | BLUE_VERSION=v{required} sh\n")); + } + } + _ => message.push_str("Automatic installation is unavailable for this target or release. Consult the release page.\n"), + } + message.push_str("\nOr run `blue reset` to detach from this tenant."); + message +} + +trait Runtime { + fn destination(&mut self) -> Result; + fn confirm(&mut self, message: &str) -> Result; + fn install(&mut self, destination: &Path, required: &str) -> Result<()>; +} + +fn repair( + runtime: &mut dyn Runtime, + installed: &str, + required: &str, + interactive: bool, +) -> Result> { + stable_pin(required)?; + if !interactive { + return Ok(None); + } + let destination = runtime.destination()?; + let direction = if semver::Version::parse(installed)? < semver::Version::parse(required)? { + "upgrade" + } else { + "downgrade" + }; + if !runtime.confirm(&format!( + "Blue {installed} must {direction} to {required}. Install Blue {required} now at {}?", + destination.display() + ))? { + return Ok(None); + } + runtime.install(&destination, required)?; + Ok(Some(destination)) +} + +/// Returns true when this is the specific error handled here. The caller always +/// exits unsuccessfully: even a successful repair did not run the user's command. +pub(crate) fn handle(error: &anyhow::Error, foreground: bool) -> bool { + let Some(gh_common::GhError::ClientVersionMismatch { + installed, + required, + }) = error.downcast_ref::() + else { + return false; + }; + eprintln!( + "{}", + remedy( + installed, + required, + std::env::consts::OS, + std::env::consts::ARCH + ) + ); + if target(std::env::consts::OS, std::env::consts::ARCH).is_none() { + return true; + } + let interactive = + foreground && std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); + match repair(&mut NativeRuntime, installed, required, interactive) { + Ok(Some(path)) => eprintln!( + "Installed Blue {required} at {}. Rerun your command to continue.", + path.display() + ), + Ok(None) => {} + Err(error) => { + eprintln!("Blue installation did not complete: {error:#}. Use the manual remedy above.") + } + } + true +} + +struct NativeRuntime; +impl Runtime for NativeRuntime { + fn destination(&mut self) -> Result { + let path = std::env::current_exe()?.canonicalize()?; + check_destination(&path)?; + // Check directory writability before asking for consent. + let _probe = AttemptDirectory::new(path.parent().context("executable has no parent")?)?; + Ok(path) + } + fn confirm(&mut self, message: &str) -> Result { + Ok(cliclack::confirm(message).initial_value(false).interact()?) + } + fn install(&mut self, destination: &Path, required: &str) -> Result<()> { + install(destination, required) + } +} + +fn check_destination(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_file() || metadata.permissions().readonly() { + bail!("destination is not a writable regular executable"); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let parent = fs::metadata(path.parent().context("executable has no parent")?)?; + // Avoid replacing another user's or a shared/package-linked executable. + let uid = unsafe { libc::geteuid() }; + if metadata.uid() != uid + || parent.uid() != uid + || parent.mode() & 0o022 != 0 + || metadata.nlink() != 1 + { + bail!("destination ownership, directory permissions, or hard-link layout requires manual installation"); + } + } + Ok(()) +} + +struct AttemptDirectory(PathBuf); +impl AttemptDirectory { + fn new(parent: &Path) -> Result { + let path = parent.join(format!(".blue-install-{}", uuid::Uuid::new_v4())); + #[cfg(unix)] + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + #[cfg(not(unix))] + let builder = fs::DirBuilder::new(); + builder.create(&path)?; + Ok(Self(path)) + } +} +impl Drop for AttemptDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +// File-backed output avoids a full stdout pipe deadlocking the child. All +// processes have a deadline; installer descendants are terminated on timeout. +fn bounded_command(command: &mut Command, directory: &Path, timeout: Duration) -> Result { + let output_path = directory.join(format!("output-{}", uuid::Uuid::new_v4())); + let output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&output_path)?; + command + .stdin(Stdio::null()) + .stdout(output.try_clone()?) + .stderr(output); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = command + .spawn() + .context("starting installer or version probe")?; + let started = Instant::now(); + let mut heartbeat = Instant::now(); + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if started.elapsed() >= timeout { + #[cfg(unix)] + unsafe { + libc::kill(-(child.id() as i32), libc::SIGKILL); + } + #[cfg(windows)] + { + let _ = Command::new("taskkill.exe") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .status(); + } + let _ = child.kill(); + let _ = child.wait(); + bail!( + "installer or version probe timed out after {} seconds", + timeout.as_secs() + ); + } + if heartbeat.elapsed() >= Duration::from_secs(15) { + eprintln!( + "Waiting for Blue installer ({} seconds elapsed)…", + started.elapsed().as_secs() + ); + heartbeat = Instant::now(); + } + std::thread::sleep(Duration::from_millis(100)); + }; + let mut output = String::new(); + File::open(&output_path)? + .take(64 * 1024) + .read_to_string(&mut output)?; + if !status.success() { + bail!( + "installer or version probe exited with {status}: {}", + gh_service::source::bounded_detail(&output).unwrap_or_default() + ); + } + Ok(output) +} + +fn probe(path: &Path, required: &str, directory: &Path) -> Result<()> { + let output = bounded_command( + Command::new(path).arg("version"), + directory, + Duration::from_secs(10), + )?; + if output.trim() != format!("Blue metaharness {required}") { + bail!("{} did not report Blue {required}", path.display()); + } + Ok(()) +} + +fn path_executable() -> Option { + let name = if cfg!(windows) { "blue.exe" } else { "blue" }; + let mut dirs: Vec = + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect(); + if cfg!(windows) { + dirs.insert(0, std::env::current_dir().ok()?); + } + dirs.into_iter().map(|dir| dir.join(name)).find(|path| { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } + }) +} + +fn replace_verified( + destination: &Path, + staged: &Path, + required: &str, + directory: &Path, +) -> Result<()> { + replace_with_verification(destination, staged, directory, || { + probe(destination, required, directory).and_then(|()| { + if let Some(selected) = path_executable() { + if selected.canonicalize()? != destination { + probe(&selected, required, directory).with_context(|| { + format!("PATH selects a different Blue at {}", selected.display()) + })?; + } + } + Ok(()) + }) + }) +} + +fn replace_with_verification( + destination: &Path, + staged: &Path, + _directory: &Path, + verify: impl FnOnce() -> Result<()>, +) -> Result<()> { + check_destination(destination)?; + let backup = destination.with_file_name(format!(".blue-backup-{}", uuid::Uuid::new_v4())); + #[cfg(unix)] + fs::hard_link(destination, &backup).context("backing up current executable")?; + #[cfg(windows)] + fs::rename(destination, &backup).context("backing up running executable")?; + if let Err(error) = fs::rename(staged, destination) { + #[cfg(windows)] + fs::rename(&backup, destination).with_context(|| { + format!( + "replacement and rollback failed; original executable retained at {}", + backup.display() + ) + })?; + #[cfg(unix)] + let _ = fs::remove_file(&backup); + return Err(error).context("replacing executable"); + } + let verification = verify(); + if let Err(error) = verification { + #[cfg(windows)] + fs::rename(destination, _directory.join("rejected-blue.exe"))?; + fs::rename(&backup, destination).with_context(|| { + format!( + "rollback failed; original executable retained at {}", + backup.display() + ) + })?; + return Err(error); + } + // Retain and name a backup which Windows still maps into this process. + if let Err(error) = fs::remove_file(&backup) { + eprintln!( + "Previous executable retained at {}: {error}", + backup.display() + ); + } + Ok(()) +} + +fn install(destination: &Path, required: &str) -> Result<()> { + stable_pin(required)?; + check_destination(destination)?; + let parent = destination.parent().context("executable has no parent")?; + // Keep the lock inode permanently: unlinking an unlocked file races another + // process which already opened it. OS locks release on crash as well. + let lock_path = parent.join(format!( + ".{}.install.lock", + destination + .file_name() + .context("executable has no name")? + .to_string_lossy() + )); + if fs::symlink_metadata(&lock_path).is_ok_and(|metadata| !metadata.is_file()) { + bail!("unsafe installation lock"); + } + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let lock = options.open(lock_path)?; + lock.try_lock() + .context("another Blue installation is in progress")?; + let attempt = AttemptDirectory::new(parent)?; + let directory = &attempt.0; + let script_name = if cfg!(windows) { + "install.ps1" + } else { + "install.sh" + }; + let script = directory.join(script_name); + let mut response = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(60)) + .build()? + .get(format!("{RELEASES}/download/v{required}/{script_name}")) + .send()? + .error_for_status()?; + let mut bytes = Vec::new(); + Read::by_ref(&mut response) + .take(1024 * 1024 + 1) + .read_to_end(&mut bytes)?; + if bytes.len() > 1024 * 1024 { + bail!("release installer is too large"); + } + fs::write(&script, bytes)?; + let staging = directory.join("staging"); + fs::create_dir(&staging)?; + let mut command = if cfg!(windows) { + let mut command = Command::new("powershell.exe"); + command.args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + ]); + command + } else { + Command::new("sh") + }; + command + .arg(&script) + .env("BLUE_VERSION", format!("v{required}")) + .env("BLUE_INSTALL_DIR", &staging) + .env("BLUE_REPOSITORY", "BlocksOrg/blue") + .env("BLUE_UPDATE_PATH", "0"); + bounded_command(&mut command, directory, Duration::from_secs(300))?; + let staged = staging.join(if cfg!(windows) { "blue.exe" } else { "blue" }); + if !fs::symlink_metadata(&staged)?.is_file() { + bail!("installer did not produce a regular executable"); + } + probe(&staged, required, directory)?; + OpenOptions::new() + .read(true) + .write(true) + .open(&staged)? + .sync_all()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&staged, fs::Permissions::from_mode(0o755))?; + } + replace_verified(destination, &staged, required, directory) +} + +#[cfg(test)] +mod tests { + use super::*; + struct Fake { + consent: Option, + confirms: usize, + installs: usize, + fail: bool, + } + impl Runtime for Fake { + fn destination(&mut self) -> Result { + Ok("/fixture/blue".into()) + } + fn confirm(&mut self, message: &str) -> Result { + self.confirms += 1; + assert!(message.contains("/fixture/blue")); + self.consent.context("cancelled") + } + fn install(&mut self, _: &Path, _: &str) -> Result<()> { + self.installs += 1; + if self.fail { + bail!("fixture installer failure"); + } + Ok(()) + } + } + #[test] + fn recommendation_is_neutral_and_does_not_invoke_repair() { + for (installed, recommended) in [("1.2.2", "1.2.3"), ("1.3.0", "1.2.3")] { + let message = recommendation(installed, recommended, "linux", "x86_64"); + assert!(message.starts_with(&format!( + "Blue {installed} is compatible, but this tenant recommends Blue {recommended}." + ))); + assert!(!message.contains("must upgrade")); + assert!(!message.contains("must downgrade")); + assert!(!message.contains("Install Blue")); + } + } + #[test] + fn consent_is_required_for_upgrade_and_downgrade() { + for installed in ["1.0.0", "3.0.0"] { + for consent in [Some(true), Some(false), None] { + for interactive in [true, false] { + let mut fake = Fake { + consent, + confirms: 0, + installs: 0, + fail: false, + }; + let result = repair(&mut fake, installed, "2.0.0", interactive); + assert_eq!(fake.confirms, usize::from(interactive)); + assert_eq!( + fake.installs, + usize::from(interactive && consent == Some(true)) + ); + match (interactive, consent) { + (true, None) => assert!(result.is_err()), + (true, Some(true)) => { + assert_eq!(result.unwrap(), Some(PathBuf::from("/fixture/blue"))) + } + _ => assert!(result.unwrap().is_none()), + } + } + } + } + let mut fake = Fake { + consent: Some(true), + confirms: 0, + installs: 0, + fail: true, + }; + assert!(repair(&mut fake, "1.0.0", "2.0.0", true).is_err()); + } + #[test] + fn foreground_commands_do_not_grant_implicit_consent() { + use clap::Parser; + for args in [ + vec!["blue"], + vec!["blue", "run", "codex"], + vec!["blue", "codex"], + vec!["blue", "apply", "--yes"], + ] { + let cli = crate::Cli::try_parse_from(args).unwrap(); + assert!(crate::foreground_command(&cli.command)); + let mut fake = Fake { + consent: Some(false), + confirms: 0, + installs: 0, + fail: false, + }; + assert!(repair(&mut fake, "1.0.0", "2.0.0", true).unwrap().is_none()); + assert_eq!(fake.installs, 0); + } + for args in [ + vec!["blue", "daemon"], + vec!["blue", "session-upload", "codex"], + vec!["blue", "session-start", "codex"], + vec!["blue", "session-upload-worker"], + ] { + let cli = crate::Cli::try_parse_from(args).unwrap(); + assert!(!crate::foreground_command(&cli.command)); + } + } + #[test] + fn installation_lock_excludes_another_attempt() { + let root = AttemptDirectory::new(&std::env::temp_dir()).unwrap(); + let path = root.0.join("install.lock"); + let first = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + first.try_lock().unwrap(); + let second = OpenOptions::new().write(true).open(&path).unwrap(); + assert!(second.try_lock().is_err()); + drop(first); + second.try_lock().unwrap(); + } + + #[test] + fn remedies_for_all_published_targets() { + for (os, suffix, ext) in [ + ("macos", "apple-darwin", "tar.gz"), + ("linux", "unknown-linux-musl", "tar.gz"), + ("windows", "pc-windows-msvc", "zip"), + ] { + for arch in ["x86_64", "aarch64"] { + let actual = remedy("1.0.0", "2.0.0", os, arch); + let command = if os == "windows" { + "$env:BLUE_VERSION = \"v2.0.0\"\nirm https://github.com/BlocksOrg/blue/releases/download/v2.0.0/install.ps1 | iex" + } else { + "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/BlocksOrg/blue/releases/download/v2.0.0/install.sh | BLUE_VERSION=v2.0.0 sh" + }; + assert_eq!(actual, format!("Blue 1.0.0 has an incompatible major version; this tenant requires Blue 2.0.0.\nRelease: https://github.com/BlocksOrg/blue/releases/tag/v2.0.0\nAsset: blue-v2.0.0-{arch}-{suffix}.{ext}\n\n{command}\n\nOr run `blue reset` to detach from this tenant.")); + } + } + let unsupported = remedy("1.0.0", "2.0.0", "linux", "riscv64"); + assert!(unsupported.contains("unavailable")); + assert!(unsupported.contains("blue reset")); + assert!(!unsupported.contains("curl")); + } + #[cfg(unix)] + fn fixture_binary(path: &Path, version: &str) { + use std::os::unix::fs::PermissionsExt; + fs::write( + path, + format!("#!/bin/sh\nprintf 'Blue metaharness {version}\\n'\n"), + ) + .unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); + } + #[test] + #[cfg(unix)] + fn native_replacement_and_failed_verification_restore_original() { + let root = AttemptDirectory::new(&std::env::temp_dir()).unwrap(); + let destination = root.0.join("blue"); + let staged = root.0.join("next-blue"); + fixture_binary(&destination, "1.0.0"); + fixture_binary(&staged, "2.0.0"); + replace_with_verification(&destination, &staged, &root.0, || { + probe(&destination, "2.0.0", &root.0) + }) + .unwrap(); + probe(&destination, "2.0.0", &root.0).unwrap(); + let before = fs::read(&destination).unwrap(); + fixture_binary(&staged, "9.9.9"); + assert!( + replace_with_verification(&destination, &staged, &root.0, || probe( + &destination, + "2.0.0", + &root.0 + )) + .is_err() + ); + assert_eq!(before, fs::read(&destination).unwrap()); + } + #[test] + #[cfg(unix)] + fn wrong_version_readonly_and_failed_installer_do_not_replace() { + use std::os::unix::fs::PermissionsExt; + let root = AttemptDirectory::new(&std::env::temp_dir()).unwrap(); + let destination = root.0.join("blue"); + fixture_binary(&destination, "1.0.0"); + assert!(probe(&destination, "2.0.0", &root.0).is_err()); + assert!(bounded_command( + Command::new("sh").args(["-c", "echo checksum verification failed; exit 1"]), + &root.0, + Duration::from_secs(2) + ) + .is_err()); + probe(&destination, "1.0.0", &root.0).unwrap(); + fs::set_permissions(&destination, fs::Permissions::from_mode(0o555)).unwrap(); + assert!(check_destination(&destination).is_err()); + } +} + +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + #[test] + fn running_executable_fixture() { + if std::env::var_os("BLUE_RUNNING_EXECUTABLE_FIXTURE").is_some() { + println!("blue-fixture-ready"); + std::thread::sleep(Duration::from_secs(20)); + } + } + #[test] + fn locked_windows_destination_remains_intact() { + use std::os::windows::fs::OpenOptionsExt; + let root = AttemptDirectory::new(&std::env::temp_dir()).unwrap(); + let destination = root.0.join("blue.exe"); + let staged = root.0.join("next.exe"); + fs::write(&destination, b"original").unwrap(); + fs::write(&staged, b"replacement").unwrap(); + let locked = OpenOptions::new() + .read(true) + .share_mode(0) + .open(&destination) + .unwrap(); + assert!(replace_with_verification(&destination, &staged, &root.0, || Ok(())).is_err()); + drop(locked); + assert_eq!(fs::read(&destination).unwrap(), b"original"); + } + + #[test] + fn replacing_running_windows_executable_preserves_a_working_installation() { + use std::io::BufRead; + let root = AttemptDirectory::new(&std::env::temp_dir()).unwrap(); + let destination = root.0.join("blue.exe"); + let staged = root.0.join("next-blue.exe"); + fs::copy(std::env::current_exe().unwrap(), &destination).unwrap(); + fs::copy(&destination, &staged).unwrap(); + let original = fs::read(&destination).unwrap(); + let mut child = Command::new(&destination) + .args([ + "--exact", + "client_version::windows_tests::running_executable_fixture", + "--nocapture", + ]) + .env("BLUE_RUNNING_EXECUTABLE_FIXTURE", "1") + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let stdout = child.stdout.take().unwrap(); + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for line in std::io::BufReader::new(stdout) + .lines() + .map_while(|line| line.ok()) + { + if line.contains("blue-fixture-ready") { + let _ = sender.send(()); + break; + } + } + }); + let ready = receiver.recv_timeout(Duration::from_secs(10)); + if ready.is_err() { + let _ = child.kill(); + let _ = child.wait(); + panic!("fixture did not start"); + } + let result = replace_with_verification(&destination, &staged, &root.0, || Ok(())); + // Either native rename succeeds or the filesystem refuses sharing; + // refusal must leave the original working executable at its path. + assert_eq!(fs::read(&destination).unwrap(), original, "{result:?}"); + let _ = child.kill(); + child.wait().unwrap(); + fs::copy(&destination, &staged).unwrap(); + assert!( + replace_with_verification(&destination, &staged, &root.0, || anyhow::bail!( + "failed installed probe" + )) + .is_err() + ); + assert_eq!(fs::read(&destination).unwrap(), original); + } +} diff --git a/crates/gh-cli/src/commands.rs b/crates/gh-cli/src/commands.rs index 5dd1a19..e37b9ab 100644 --- a/crates/gh-cli/src/commands.rs +++ b/crates/gh-cli/src/commands.rs @@ -25,7 +25,8 @@ use similar::{ChangeTag, TextDiff}; /// Build the service client and load the (possibly default) client config. fn load_client() -> Result<(BlueToml, ServiceClient)> { let cfg = BlueToml::load().context("loading blue.toml")?; - let client = ServiceClient::from_config(&cfg).context("building service client")?; + let client = ServiceClient::from_config(&cfg, env!("CARGO_PKG_VERSION")) + .context("building service client")?; Ok((cfg, client)) } @@ -610,6 +611,7 @@ fn prepare_launch( .context("fetching governance config")? } }; + crate::client_version::warn_if_recommended(&config); let mut inventory = discover_inventory_cached(&config.allowed_harnesses, applied.as_ref()); gh_agent::evaluate_inventory(&config, &mut inventory); Ok(PreparedLaunch { @@ -722,6 +724,7 @@ fn agent_context() -> Result { let config = client .fetch_or_cached(&session, now_unix()) .context("fetching governance config")?; + crate::client_version::warn_if_recommended(&config); let applied = load_applied_state()?; let mut inventory = discover_inventory_cached(&config.allowed_harnesses, applied.as_ref()); gh_agent::evaluate_inventory(&config, &mut inventory); @@ -913,7 +916,8 @@ pub fn start() -> Result<()> { activate_discovered(discover_configuration(&prompt_control_api_url()?)?)? }; let session = ensure_session_for_start(&cfg)?; - let client = ServiceClient::from_config(&cfg).context("building service client")?; + let client = ServiceClient::from_config(&cfg, env!("CARGO_PKG_VERSION")) + .context("building service client")?; let prepared = prepare_launch(cfg, client, session)?; // Keep installed-but-incompatible agents in the picker. `run_prepared` // persists the selection only after its authoritative version check, so a @@ -999,11 +1003,18 @@ pub fn login(force: bool) -> Result<()> { // asking, and never `/auth/me`, which never reaches the code that // fails. let probe = if cfg.has_http_service() { - let client = ServiceClient::from_config(&cfg).context("building service client")?; + let client = ServiceClient::from_config(&cfg, env!("CARGO_PKG_VERSION")) + .context("building service client")?; Some(client.fetch(&session, now_unix())) } else { None }; + if matches!( + &probe, + Some(Err(gh_common::GhError::ClientVersionMismatch { .. })) + ) { + return Err(probe.unwrap().unwrap_err().into()); + } let decision = match &probe { Some(probe) => login_decision(is_oidc(&cfg), probe), // A `file` source has no session for a service to reject. @@ -1011,6 +1022,9 @@ pub fn login(force: bool) -> Result<()> { }; match decision { LoginDecision::Valid => { + if let Some(Ok(config)) = &probe { + crate::client_version::warn_if_recommended(config); + } println!("{}", describe_session(&session, "Already logged in")); println!("Run `blue logout` before signing in with a different account."); return Ok(()); @@ -1055,6 +1069,13 @@ pub fn login(force: bool) -> Result<()> { None => gh_service::login(&cfg).context("login")?, }; println!("{}", describe_session(&session, "Logged in")); + let client = ServiceClient::from_config(&cfg, env!("CARGO_PKG_VERSION")) + .context("building service client")?; + match client.fetch(&session, now_unix()) { + Ok(config) => crate::client_version::warn_if_recommended(&config), + Err(error @ gh_common::GhError::ClientVersionMismatch { .. }) => return Err(error.into()), + Err(_) => {} + } Ok(()) } @@ -1424,6 +1445,12 @@ pub(crate) fn doctor_text() -> Result { // the backing browser session is gone. Ask the service instead, live — // never through the cache, which cannot tell us anything about auth. let live = session_for(&cfg).map(|session| client.fetch(&session, now_unix())); + if matches!( + &live, + Ok(Err(gh_common::GhError::ClientVersionMismatch { .. })) + ) { + return Err(live.unwrap().unwrap_err().into()); + } match (Session::load()?, cfg.has_http_service(), &live) { (None, true, _) => lines.push(" session : MISSING (run `blue login`)".into()), (None, false, _) => lines.push(" session : n/a (local file source)".into()), @@ -1467,6 +1494,8 @@ pub(crate) fn doctor_text() -> Result { .map(|cached| cached.config), }; if let Some(c) = &allowed { + c.ensure_client_compatible(env!("CARGO_PKG_VERSION"))?; + crate::client_version::warn_if_recommended(c); lines.push(format!(" revision : {}", c.revision)); lines.push(format!( " allowed : {}", @@ -2203,6 +2232,7 @@ pub(crate) fn status_text(strict: bool) -> Result { let desired = client .fetch_or_cached(&session, now_unix()) .context("fetching desired configuration")?; + crate::client_version::warn_if_recommended(&desired); let applied = load_applied_state()?; let revision_ok = applied .as_ref() @@ -2307,6 +2337,7 @@ pub fn config() -> Result<()> { let config = client .fetch_or_cached(&session, now_unix()) .context("fetching governance config")?; + crate::client_version::warn_if_recommended(&config); println!("# source: {}", client.describe_source()); println!("{}", serde_json::to_string_pretty(&config)?); Ok(()) @@ -2467,7 +2498,8 @@ pub fn run(name: &str, args: &[String]) -> Result<()> { ); } let cfg = activate_discovered(discover_configuration(&prompt_control_api_url()?)?)?; - let client = ServiceClient::from_config(&cfg).context("building service client")?; + let client = ServiceClient::from_config(&cfg, env!("CARGO_PKG_VERSION")) + .context("building service client")?; (cfg, client) } else { load_client()? @@ -2773,7 +2805,7 @@ fn run_prepared( startup.ready()?; } let gateway_state = if config.gateway.is_some() && !cfg.mode.force_governance_only { - "managed" + "gateway" } else { "direct" }; @@ -2896,7 +2928,7 @@ fn start_revision_watcher( let pending_for_thread = notices.revision.clone(); let auth_for_thread = notices.auth.clone(); std::thread::spawn(move || { - let client = match ServiceClient::from_config(&cfg) { + let client = match ServiceClient::from_config(&cfg, env!("CARGO_PKG_VERSION")) { Ok(client) => client, Err(error) => { tracing::debug!(%error, "revision watcher could not start"); @@ -2948,13 +2980,16 @@ fn start_revision_watcher( // it the one place a dead session reliably surfaces: the OAuth // refresh above still succeeds after the browser session is gone. match client.fetch(&session, now_unix()) { - Ok(config) => handle_revision_notice( - &active_revision, - harness, - config.revision, - &mut seen, - &pending_for_thread, - ), + Ok(config) => { + trace_version_recommendation(&config, &mut seen); + handle_revision_notice( + &active_revision, + harness, + config.revision, + &mut seen, + &pending_for_thread, + ) + } Err(error) => record_auth_notice(&error, &auth_for_thread), } std::thread::sleep(std::time::Duration::from_secs(reconnect_seconds)); @@ -2978,6 +3013,7 @@ fn poll_for_revisions( match session.refresh_if_needed(now_unix()) { Ok(()) => match client.fetch(session, now_unix()) { Ok(config) => { + trace_version_recommendation(&config, seen); handle_revision_notice(active_revision, harness, config.revision, seen, pending) } Err(error) => record_auth_notice(&error, auth), @@ -2988,10 +3024,35 @@ fn poll_for_revisions( } } +fn trace_version_recommendation(config: &GovernanceConfig, seen: &mut BTreeSet) { + let Some(required) = config.required_client_version.as_deref() else { + return; + }; + if let Ok(gh_service::ClientVersionCompatibility::SameMajorRecommendation { + installed, + recommended, + }) = GovernanceConfig::client_version_compatibility(required, env!("CARGO_PKG_VERSION")) + { + let key = format!("client-version:{installed}:{recommended}"); + if seen.insert(key) { + tracing::info!(%installed, %recommended, "tenant recommends a different compatible Blue release"); + } + } +} + /// Records a notice for errors only the user can clear, and clears it when the /// service starts answering again. fn record_auth_notice(error: &gh_common::GhError, auth: &Arc>>) { let message = match error { + gh_common::GhError::ClientVersionMismatch { + installed, + required, + } => crate::client_version::remedy( + installed, + required, + std::env::consts::OS, + std::env::consts::ARCH, + ), gh_common::GhError::Unauthorized(message) | gh_common::GhError::Forbidden(message) | gh_common::GhError::ActionRequired(message) => message.clone(), @@ -3350,6 +3411,7 @@ fn apply_internal(assume_yes: bool, quiet: bool, target_override: Option Result<(Session, reqwest::Url, reqwest::blocking:: let governance = client .fetch_or_cached(&session, now_unix()) .context("loading session policy")?; + crate::client_version::warn_if_recommended(&governance); let Some(upload) = governance.session_upload else { bail!("remote resume is unavailable because session upload is not configured"); }; @@ -5320,6 +5383,7 @@ mod tests { contract_version: GovernanceConfig::CONTRACT_VERSION, required_capabilities: Vec::new(), minimum_client_version: None, + required_client_version: None, ttl_seconds: None, allowed_harnesses: vec!["codex".to_owned()], harnesses: Default::default(), diff --git a/crates/gh-cli/src/main.rs b/crates/gh-cli/src/main.rs index 4c48802..5d7eb9f 100644 --- a/crates/gh-cli/src/main.rs +++ b/crates/gh-cli/src/main.rs @@ -4,6 +4,7 @@ // governance config, and transparently wrap the chosen harness. See the plan // and each crate's docs for the design. +mod client_version; mod commands; mod repair; mod supervisor; @@ -131,6 +132,25 @@ enum ShimAction { }, } +fn foreground_command(command: &Option) -> bool { + matches!( + command, + None | Some( + Command::Setup + | Command::Login { .. } + | Command::Doctor + | Command::Agent { .. } + | Command::Status + | Command::Verify + | Command::Run { .. } + | Command::Config + | Command::Gateway + | Command::Apply { .. } + | Command::External(_) + ) + ) +} + fn main() { tracing_subscriber::fmt() .with_env_filter( @@ -142,6 +162,7 @@ fn main() { .init(); let cli = Cli::parse(); + let foreground = foreground_command(&cli.command); let result = match cli.command { None => commands::start(), Some(Command::Version) => commands::version(), @@ -176,7 +197,9 @@ fn main() { }; if let Err(e) = result { - print_error(&e); + if !client_version::handle(&e, foreground) { + print_error(&e); + } std::process::exit(1); } } diff --git a/crates/gh-cli/tests/client_version.rs b/crates/gh-cli/tests/client_version.rs new file mode 100644 index 0000000..f15416f --- /dev/null +++ b/crates/gh-cli/tests/client_version.rs @@ -0,0 +1,176 @@ +//! Exercise actual dispatch, including the interactive bare entrypoint. +#![cfg(unix)] +use std::fs; +use std::io::{Read, Write}; +use std::os::fd::FromRawFd; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::Duration; + +struct Fixture(PathBuf); +impl Fixture { + fn new(required: &str) -> Self { + let root = std::env::temp_dir().join(format!("blue-version-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join("config/blue")).unwrap(); + fs::create_dir_all(root.join("bin")).unwrap(); + fs::write(root.join("config/blue/blue.yaml"), format!("revision: mismatch\nrequired_client_version: {required}\nallowed_harnesses: [codex]\nharnesses:\n codex:\n managed_config:\n model: must-not-be-written\n")).unwrap(); + fs::write( + root.join("config/blue/blue.toml"), + "[service]\n[ui]\npreferred_harness = \"codex\"\n", + ) + .unwrap(); + fs::write( + root.join("bin/codex"), + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'codex-cli 0.150.0'; exit 0; fi\necho launched > \"$BLUE_TEST_MARKER\"\n", + ) + .unwrap(); + fs::set_permissions(root.join("bin/codex"), fs::Permissions::from_mode(0o755)).unwrap(); + fs::copy(env!("CARGO_BIN_EXE_blue"), root.join("bin/blue")).unwrap(); + Self(root) + } + fn command(&self) -> Command { + let mut command = Command::new(self.0.join("bin/blue")); + command + .current_dir(&self.0) + .env("XDG_CONFIG_HOME", self.0.join("config")) + .env("XDG_CACHE_HOME", self.0.join("cache")) + .env("PATH", self.0.join("bin")) + .env("BLUE_TEST_MARKER", self.0.join("launched")) + .env("TERM", "xterm"); + command + } + fn assert_blocked(&self, output: &str, required: &str) { + assert!( + output.contains(&format!("tenant requires Blue {required}")), + "{output}" + ); + assert!(output.contains("blue reset"), "{output}"); + assert!(!self.0.join("launched").exists()); + assert!(!self.0.join("cache/blue/governance-config.json").exists()); + assert_eq!( + fs::read_to_string(self.0.join("config/blue/blue.toml")).unwrap(), + "[service]\n[ui]\npreferred_harness = \"codex\"\n" + ); + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn explicit_shorthand_and_apply_fail_before_harness_detection_or_managed_writes() { + for required in ["99.0.0"] { + let fixture = Fixture::new(required); + for args in [ + vec!["run", "codex"], + vec!["codex"], + vec!["apply", "--yes"], + vec!["config"], + vec!["doctor"], + ] { + let output = fixture.command().args(args).output().unwrap(); + assert!(!output.status.success()); + fixture.assert_blocked(&String::from_utf8_lossy(&output.stderr), required); + } + // A malformed policy proves reset has no dependency on a usable fetch. + fs::write(fixture.0.join("config/blue/blue.yaml"), "not valid: [").unwrap(); + let output = fixture.command().args(["reset", "--yes"]).output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!fixture.0.join("config/blue/blue.toml").exists()); + } +} + +#[test] +fn same_major_commands_continue_with_one_noninteractive_recommendation() { + for args in [ + vec!["run", "codex"], + vec!["codex"], + vec!["apply", "--yes"], + vec!["config"], + vec!["doctor"], + ] { + let fixture = Fixture::new("0.0.1"); + let output = fixture.command().args(args).output().unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stderr}"); + assert_eq!( + stderr + .matches("is compatible, but this tenant recommends") + .count(), + 1, + "{stderr}" + ); + assert!(!stderr.contains("Install Blue"), "{stderr}"); + assert!(fixture.0.join("cache/blue/governance-config.json").exists()); + } +} + +#[test] +fn bare_blue_decline_remains_blocked_and_never_launches() { + let fixture = Fixture::new("99.0.0"); + let (mut master, mut slave) = (0, 0); + assert_eq!( + unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + ) + }, + 0 + ); + let mut master = unsafe { fs::File::from_raw_fd(master) }; + let slave = unsafe { fs::File::from_raw_fd(slave) }; + let mut child = fixture + .command() + .stdin(Stdio::from(slave.try_clone().unwrap())) + .stdout(Stdio::from(slave.try_clone().unwrap())) + .stderr(Stdio::from(slave)) + .spawn() + .unwrap(); + let mut reader = master.try_clone().unwrap(); + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut buffer = [0; 4096]; + while let Ok(count) = reader.read(&mut buffer) { + if count == 0 || sender.send(buffer[..count].to_vec()).is_err() { + break; + } + } + }); + let deadline = std::time::Instant::now() + Duration::from_secs(15); + let mut output = String::new(); + let mut declined = false; + while let Some(remaining) = deadline.checked_duration_since(std::time::Instant::now()) { + match receiver.recv_timeout(remaining) { + Ok(bytes) => { + output.push_str(&String::from_utf8_lossy(&bytes)); + if !declined && output.contains("Install Blue 99.0.0 now") { + master.write_all(b"n\r").unwrap(); + declined = true; + } + } + Err(_) => break, + } + } + let status = match child.try_wait().unwrap() { + Some(status) => status, + None => { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("bare Blue did not terminate: {output}"); + } + }; + assert!(!status.success()); + assert!(declined, "no default-no install prompt: {output}"); + fixture.assert_blocked(&output, "99.0.0"); +} diff --git a/crates/gh-common/src/error.rs b/crates/gh-common/src/error.rs index 8fd103b..e9059cb 100644 --- a/crates/gh-common/src/error.rs +++ b/crates/gh-common/src/error.rs @@ -6,6 +6,9 @@ use std::path::PathBuf; /// (`services/*`) define their own error types. #[derive(Debug, thiserror::Error)] pub enum GhError { + #[error("Blue {installed} and this tenant's recommended Blue {required} have incompatible major versions")] + ClientVersionMismatch { installed: String, required: String }, + #[error("io error at {path}: {source}")] Io { path: PathBuf, diff --git a/crates/gh-service/src/cache.rs b/crates/gh-service/src/cache.rs index 3a576d1..bda7d41 100644 --- a/crates/gh-service/src/cache.rs +++ b/crates/gh-service/src/cache.rs @@ -43,12 +43,22 @@ pub fn save(config: &GovernanceConfig, fetched_at: i64) -> Result<(), GhError> { let cached = CachedConfig { fetched_at, config }; let path = paths::governance_cache_path()?; let body = serde_json::to_vec_pretty(&cached).map_err(|e| GhError::Serde(e.to_string()))?; - write_atomic(&path, body) + write_atomic(&path, body)?; + match std::fs::remove_file(path.with_extension("invalidated")) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(GhError::Io { path, source }), + } } /// Load the cached config, if present. pub fn load() -> Result, GhError> { let path = paths::governance_cache_path()?; + // A deletion failure (e.g. a read-only cache on Windows) must not make an + // old policy usable again. Only a successful live save clears this marker. + if path.with_extension("invalidated").try_exists()? { + return Ok(None); + } match std::fs::read(&path) { Ok(bytes) => serde_json::from_slice::(&bytes) .map(|mut cached| { @@ -121,3 +131,39 @@ mod tests { assert_eq!(gateway.proxy_url, None); } } + +/// Remove a policy after a definitive live incompatibility. +pub fn invalidate() -> Result<(), GhError> { + let path = paths::governance_cache_path()?; + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => { + tracing::warn!(%source, path = %path.display(), "could not delete rejected cache; marking it unusable"); + write_atomic( + &path.with_extension("invalidated"), + b"live governance rejected this client", + ) + } + } +} + +#[cfg(test)] +mod invalidation_tests { + use super::*; + #[test] + fn failed_cache_deletion_cannot_resurrect_policy() { + let _guard = test_support::with_cache_home("invalidation-marker"); + let path = paths::governance_cache_path().unwrap(); + // A directory at the file path deterministically refuses remove_file + // even for elevated test runners on Unix and Windows. + std::fs::create_dir_all(&path).unwrap(); + invalidate().unwrap(); + assert!(load().unwrap().is_none()); + std::fs::remove_dir(&path).unwrap(); + let config: GovernanceConfig = serde_json::from_str(r#"{"revision":"fresh"}"#).unwrap(); + save(&config, 1).unwrap(); + assert_eq!(load().unwrap().unwrap().config.revision, "fresh"); + assert!(!path.with_extension("invalidated").exists()); + } +} diff --git a/crates/gh-service/src/client.rs b/crates/gh-service/src/client.rs index 2b7fc64..a8346bc 100644 --- a/crates/gh-service/src/client.rs +++ b/crates/gh-service/src/client.rs @@ -14,6 +14,7 @@ use crate::source::{ConfigSource, FileConfigSource, HttpConfigSource}; pub struct ServiceClient { source: Box, + client_version: String, service_url: Option, } @@ -29,24 +30,29 @@ impl ServiceClient { /// Build the client from `blue.toml`: `http` if a service URL is set, /// otherwise a `file` source (explicit `config_file`, else a conventional /// local path). This is the "no vendor URL, zero-config file dev" path. - pub fn from_config(cfg: &BlueToml) -> Result { + pub fn from_config(cfg: &BlueToml, client_version: &str) -> Result { let source: Box = if cfg.has_http_service() { - Box::new(HttpConfigSource::new(cfg.service.url.clone())?) + Box::new(HttpConfigSource::new( + cfg.service.url.clone(), + client_version, + )?) } else if let Some(file) = &cfg.service.config_file { - Box::new(FileConfigSource::new(file)) + Box::new(FileConfigSource::new(file, client_version)) } else { let default = paths::blue_config_dir()?.join("blue.yaml"); - Box::new(FileConfigSource::new(default)) + Box::new(FileConfigSource::new(default, client_version)) }; Ok(ServiceClient { source, + client_version: client_version.into(), service_url: cfg.has_http_service().then(|| cfg.service.url.clone()), }) } - pub fn with_source(source: Box) -> Self { + pub fn with_source(source: Box, client_version: &str) -> Self { ServiceClient { source, + client_version: client_version.into(), service_url: None, } } @@ -186,7 +192,19 @@ impl ServiceClient { /// Fetch fresh config and update the cache. `now` is unix seconds (injected /// for testability). pub fn fetch(&self, session: &Session, now: i64) -> Result { - let config = self.source.fetch(session)?; + let result = self.source.fetch(session).and_then(|config| { + config.ensure_client_compatible(&self.client_version)?; + Ok(config) + }); + if matches!( + &result, + Err(GhError::ClientVersionMismatch { .. } | GhError::Config(_) | GhError::Serde(_)) + ) { + if let Err(error) = cache::invalidate() { + tracing::error!(%error, "could not invalidate incompatible governance cache"); + } + } + let config = result?; cache::save(&config, now)?; Ok(config) } @@ -205,7 +223,8 @@ impl ServiceClient { // describe something only the user or administrator can fix. // Falling back to cache here would hide the rejection. Err( - error @ (GhError::Config(_) + error @ (GhError::ClientVersionMismatch { .. } + | GhError::Config(_) | GhError::Serde(_) | GhError::Unauthorized(_) | GhError::Forbidden(_) @@ -219,11 +238,7 @@ impl ServiceClient { // A cache written by a newer client must not bypass the // current binary's contract/capability gate after a // downgrade. - config.ensure_client_compatible().map_err(|reason| { - GhError::config(format!( - "cached governance-config is unsupported by this client: {reason}" - )) - })?; + config.ensure_client_compatible(&self.client_version)?; // The cache deliberately carries no inference JWT, so a // gateway-mode config read from it can never be launched. // Fail closed rather than dropping to direct mode, which @@ -404,7 +419,22 @@ mod tests { } fn client(error: fn() -> GhError) -> ServiceClient { - ServiceClient::with_source(Box::new(FailingSource(error))) + ServiceClient::with_source(Box::new(FailingSource(error)), "0.1.0") + } + + #[test] + fn contract_rejection_invalidates_cache_across_invocations() { + let _guard = with_cache_home("client-426"); + cache::save(&governance_only(), 100).unwrap(); + assert!(matches!( + client(|| GhError::config("HTTP 426: unsupported capabilities")) + .fetch_or_cached(&Session::bearer("t"), 101), + Err(GhError::Config(_)) + )); + assert!(cache::load().unwrap().is_none()); + assert!(client(|| GhError::service("offline")) + .fetch_or_cached(&Session::bearer("t"), 102) + .is_err()); } #[test] @@ -489,3 +519,61 @@ mod tests { .contains("invalid governance revision event")); } } + +#[cfg(test)] +mod client_version_tests { + use super::*; + struct Source(Option); + impl ConfigSource for Source { + fn describe(&self) -> String { + "fixture".into() + } + fn fetch(&self, _: &Session) -> Result { + Ok(serde_json::from_value( + serde_json::json!({"revision":"r1", "required_client_version": self.0}), + ) + .unwrap()) + } + } + struct Offline; + impl ConfigSource for Offline { + fn describe(&self) -> String { + "offline".into() + } + fn fetch(&self, _: &Session) -> Result { + Err(GhError::service("offline")) + } + } + #[test] + fn live_custom_source_mismatch_invalidates_previous_cache_for_next_invocation() { + let _guard = cache::test_support::with_cache_home("version-live"); + let session = Session::bearer("fixture"); + let matching = ServiceClient::with_source(Box::new(Source(Some("7.8.9".into()))), "7.8.9"); + matching.fetch(&session, 100).unwrap(); + let changed = ServiceClient::with_source(Box::new(Source(Some("8.0.0".into()))), "7.8.9"); + assert!(matches!( + changed.fetch_or_cached(&session, 101), + Err(GhError::ClientVersionMismatch { .. }) + )); + assert!(cache::load().unwrap().is_none()); + let offline = ServiceClient::with_source(Box::new(Offline), "7.8.9"); + assert!(offline.fetch_or_cached(&session, 102).is_err()); + } + #[test] + fn cached_pin_is_checked_even_when_optional_and_stale() { + let _guard = cache::test_support::with_cache_home("version-cached"); + let session = Session::bearer("fixture"); + ServiceClient::with_source(Box::new(Source(Some("7.8.9".into()))), "7.8.9") + .fetch(&session, 100) + .unwrap(); + let matching = ServiceClient::with_source(Box::new(Offline), "7.8.9"); + assert!(matching.fetch_or_cached(&session, 9999).is_ok()); + let same_major = ServiceClient::with_source(Box::new(Offline), "7.8.10"); + assert!(same_major.fetch_or_cached(&session, 9999).is_ok()); + let different = ServiceClient::with_source(Box::new(Offline), "8.0.0"); + assert!(matches!( + different.fetch_or_cached(&session, 9999), + Err(GhError::ClientVersionMismatch { .. }) + )); + } +} diff --git a/crates/gh-service/src/lib.rs b/crates/gh-service/src/lib.rs index 9f0c337..684ed35 100644 --- a/crates/gh-service/src/lib.rs +++ b/crates/gh-service/src/lib.rs @@ -15,10 +15,10 @@ pub use client::{authenticate, login, require_session, RevisionStreamEnd, Servic pub use discovery::{discover, validate_deployment_url, DiscoveryDocument, DiscoveryOAuth}; pub use identity::{default_cli_scopes, IdentityProvider, RefreshFailure, Session}; pub use schema::{ - legacy_requirement_interval, GatewayConfig, GovernanceConfig, HarnessPolicy, ManagedConfig, - ManagedPackage, McpServer, PackageAdapter, PackageAdapterInterval, PackageAdapterVariant, - PackageOverride, PackageSource, PlatformAsset, PresignedUpload, SessionUploadConfig, - TelemetryConfig, + legacy_requirement_interval, ClientVersionCompatibility, GatewayConfig, GovernanceConfig, + HarnessPolicy, ManagedConfig, ManagedPackage, McpServer, PackageAdapter, + PackageAdapterInterval, PackageAdapterVariant, PackageOverride, PackageSource, PlatformAsset, + PresignedUpload, SessionUploadConfig, TelemetryConfig, }; pub use source::{ bounded_detail, server_error_detail, session_rejected_message, ConfigSource, FileConfigSource, diff --git a/crates/gh-service/src/schema.rs b/crates/gh-service/src/schema.rs index 0f9e372..f80d29f 100644 --- a/crates/gh-service/src/schema.rs +++ b/crates/gh-service/src/schema.rs @@ -28,6 +28,10 @@ pub struct GovernanceConfig { /// enforces whether a client may consume the document. #[serde(default, skip_serializing_if = "Option::is_none")] pub minimum_client_version: Option, + /// Exact tenant-recommended release. A different major is incompatible; + /// other differences are allowed with a recommendation to align. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub required_client_version: Option, /// Client-side cache TTL. `None` ⇒ operator default (see [`Self::DEFAULT_TTL_SECONDS`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub ttl_seconds: Option, @@ -62,6 +66,7 @@ impl GovernanceConfig { pub const DEFAULT_TTL_SECONDS: u64 = 300; pub const CONTRACT_VERSION: u32 = 3; pub const CAPABILITIES: &'static [&'static str] = &[ + "tenant_client_version_pin", "adapter_intervals", "compiled_harness_registry", "transactional_reconcile", @@ -82,13 +87,66 @@ impl GovernanceConfig { self.harnesses.get(harness) } - pub fn ensure_client_compatible(&self) -> Result<(), String> { + pub fn validate_client_version_pin( + required: &str, + ) -> Result { + let version = semver::Version::parse(required).map_err(|_| { + gh_common::GhError::config("required_client_version must be canonical exact SemVer") + })?; + if version.to_string() != required { + return Err(gh_common::GhError::config( + "required_client_version must be canonical exact SemVer", + )); + } + Ok(version) + } + + pub fn client_version_compatibility( + required: &str, + installed: &str, + ) -> Result { + let required_version = Self::validate_client_version_pin(required)?; + let installed_version = semver::Version::parse(installed) + .map_err(|_| gh_common::GhError::config("invalid executing CLI version"))?; + if required_version.major != installed_version.major { + Ok(ClientVersionCompatibility::MajorMismatch { + installed: installed.into(), + recommended: required.into(), + }) + } else if required_version == installed_version { + Ok(ClientVersionCompatibility::Exact) + } else { + Ok(ClientVersionCompatibility::SameMajorRecommendation { + installed: installed.into(), + recommended: required.into(), + }) + } + } + + pub fn check_client_version(required: &str, installed: &str) -> Result<(), gh_common::GhError> { + match Self::client_version_compatibility(required, installed)? { + ClientVersionCompatibility::MajorMismatch { + installed, + recommended, + } => Err(gh_common::GhError::ClientVersionMismatch { + installed, + required: recommended, + }), + ClientVersionCompatibility::Exact + | ClientVersionCompatibility::SameMajorRecommendation { .. } => Ok(()), + } + } + + pub fn ensure_client_compatible(&self, client_version: &str) -> Result<(), gh_common::GhError> { + if let Some(required) = &self.required_client_version { + Self::check_client_version(required, client_version)?; + } if self.contract_version > Self::CONTRACT_VERSION { - return Err(format!( + return Err(gh_common::GhError::config(format!( "governance contract {} is newer than this client's contract {}", self.contract_version, Self::CONTRACT_VERSION - )); + ))); } let unsupported = self .required_capabilities @@ -97,15 +155,28 @@ impl GovernanceConfig { .cloned() .collect::>(); if !unsupported.is_empty() { - return Err(format!( + return Err(gh_common::GhError::config(format!( "governance requires unsupported client capabilities: {}", unsupported.join(", ") - )); + ))); } Ok(()) } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientVersionCompatibility { + Exact, + SameMajorRecommendation { + installed: String, + recommended: String, + }, + MajorMismatch { + installed: String, + recommended: String, + }, +} + const fn default_contract_version() -> u32 { 1 } @@ -565,7 +636,7 @@ mod tests { "minimum_client_version": "999.0.0" })) .unwrap(); - assert!(config.ensure_client_compatible().is_ok()); + assert!(config.ensure_client_compatible("0.1.0").is_ok()); } #[test] @@ -733,14 +804,16 @@ harnesses: })) .unwrap(); assert!(config - .ensure_client_compatible() + .ensure_client_compatible("0.1.0") .unwrap_err() + .to_string() .contains("newer")); config.contract_version = GovernanceConfig::CONTRACT_VERSION; config.required_capabilities = vec!["future_feature".into()]; assert!(config - .ensure_client_compatible() + .ensure_client_compatible("0.1.0") .unwrap_err() + .to_string() .contains("future_feature")); } @@ -761,3 +834,61 @@ harnesses: ); } } + +#[cfg(test)] +mod client_version_tests { + use super::*; + use gh_common::GhError; + #[test] + fn exact_is_silent_and_same_major_recommends_in_both_directions() { + assert_eq!( + GovernanceConfig::client_version_compatibility("1.2.3", "1.2.3").unwrap(), + ClientVersionCompatibility::Exact + ); + for (required, installed) in [ + ("1.2.3", "1.2.2"), + ("1.2.3", "1.3.0"), + ("1.2.3", "1.2.4"), + ("1.2.3+one", "1.2.3+two"), + ("1.2.3-rc.1", "1.2.3"), + ] { + assert!(matches!( + GovernanceConfig::client_version_compatibility(required, installed), + Ok(ClientVersionCompatibility::SameMajorRecommendation { .. }) + )); + assert!(GovernanceConfig::check_client_version(required, installed).is_ok()); + } + } + #[test] + fn different_majors_block_in_both_directions() { + for (required, installed) in [("2.0.0", "1.9.9"), ("1.9.9", "2.0.0")] { + assert!(matches!( + GovernanceConfig::check_client_version(required, installed), + Err(GhError::ClientVersionMismatch { .. }) + )); + } + } + #[test] + fn rejects_noncanonical_or_ranged_pins() { + for pin in [ + "", "v1.2.3", " 1.2.3", "1.2.3\n", ">=1.2.3", "1.2", "01.2.3", + ] { + assert!(matches!( + GovernanceConfig::check_client_version(pin, "1.2.3"), + Err(GhError::Config(_)) + )); + } + } + #[test] + fn version_precedes_capabilities_but_never_overrides_them() { + let config: GovernanceConfig = serde_json::from_value(serde_json::json!({"revision":"r1", "required_client_version":"1.2.3", "required_capabilities":["future"]})).unwrap(); + assert!(matches!( + config.ensure_client_compatible("2.0.0"), + Err(GhError::ClientVersionMismatch { .. }) + )); + assert!(matches!( + config.ensure_client_compatible("1.2.4"), + Err(GhError::Config(_)) + )); + } +} diff --git a/crates/gh-service/src/source.rs b/crates/gh-service/src/source.rs index 4165672..81b46a6 100644 --- a/crates/gh-service/src/source.rs +++ b/crates/gh-service/src/source.rs @@ -79,22 +79,21 @@ fn session_rejection(status: reqwest::StatusCode, detail: Option<&str>) -> GhErr /// Fetches from `GET {base_url}/governance-config` with a bearer token. pub struct HttpConfigSource { base_url: String, + client_version: String, client: reqwest::blocking::Client, } impl HttpConfigSource { - pub fn new(base_url: impl Into) -> Result { + pub fn new(base_url: impl Into, client_version: &str) -> Result { let client = reqwest::blocking::Client::builder() - // `format!`, not `concat!`: `concat!` needs a literal, and the - // version is only a literal for a release build. A candidate has - // to identify itself as one here too. - .user_agent(format!("blue/{}", gh_common::blue_version())) + .user_agent(format!("blue/{client_version}")) .timeout(std::time::Duration::from_secs(15)) .build() .map_err(|e| GhError::service(format!("building http client: {e}")))?; Ok(HttpConfigSource { base_url: base_url.into().trim_end_matches('/').to_string(), client, + client_version: client_version.into(), }) } @@ -130,6 +129,31 @@ impl ConfigSource for HttpConfigSource { let detail = server_error_detail(&resp.text().unwrap_or_default()); return Err(session_rejection(status, detail.as_deref())); } + let pin = if status.is_success() || status == reqwest::StatusCode::UPGRADE_REQUIRED { + if resp + .headers() + .get_all("x-blue-required-client-version") + .iter() + .count() + > 1 + { + return Err(GhError::config("multiple required client version headers")); + } + resp.headers() + .get("x-blue-required-client-version") + .map(|value| { + value + .to_str() + .map(str::to_owned) + .map_err(|_| GhError::config("invalid required client version header")) + }) + .transpose()? + } else { + None + }; + if let Some(required) = &pin { + GovernanceConfig::check_client_version(required, &self.client_version)?; + } if status == reqwest::StatusCode::UPGRADE_REQUIRED { let body = bounded_detail(&resp.text().unwrap_or_default()) .unwrap_or_else(|| "no error detail".to_owned()); @@ -153,8 +177,15 @@ impl ConfigSource for HttpConfigSource { } let config = resp .json::() - .map_err(|e| GhError::service(format!("decoding governance-config: {e}")))?; - config.ensure_client_compatible().map_err(GhError::config)?; + .map_err(|e| GhError::Serde(format!("decoding governance-config: {e}")))?; + if let Some(required) = pin { + if config.required_client_version.as_deref() != Some(required.as_str()) { + return Err(GhError::config( + "required client version header and document disagree", + )); + } + } + config.ensure_client_compatible(&self.client_version)?; Ok(config) } @@ -167,11 +198,15 @@ impl ConfigSource for HttpConfigSource { /// the minimal self-host (Control API over a static file uses the same shape). pub struct FileConfigSource { path: PathBuf, + client_version: String, } impl FileConfigSource { - pub fn new(path: impl Into) -> Self { - FileConfigSource { path: path.into() } + pub fn new(path: impl Into, client_version: &str) -> Self { + FileConfigSource { + path: path.into(), + client_version: client_version.into(), + } } } @@ -182,7 +217,7 @@ impl ConfigSource for FileConfigSource { source: e, })?; let config = parse_config(&self.path, &text)?; - config.ensure_client_compatible().map_err(GhError::config)?; + config.ensure_client_compatible(&self.client_version)?; Ok(config) } @@ -304,3 +339,109 @@ governance: assert!(cfg.is_allowed("codex")); } } + +#[cfg(test)] +mod client_version_tests { + use super::*; + use std::io::{Read, Write}; + fn fetch_response( + status: &str, + headers: &str, + body: &str, + ) -> Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let response = format!( + "HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let thread = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .unwrap(); + let mut request = [0; 8192]; + let _ = stream.read(&mut request).unwrap(); + stream.write_all(response.as_bytes()).unwrap(); + }); + let result = HttpConfigSource::new(format!("http://{address}"), "7.8.9") + .unwrap() + .fetch(&Session::bearer("fixture")); + thread.join().unwrap(); + result + } + #[test] + fn http_header_enforces_pin_on_success_and_426_but_auth_has_priority() { + for status in ["200 OK", "426 Upgrade Required"] { + assert!(matches!( + fetch_response( + status, + "X-Blue-Required-Client-Version: 8.0.0\r\n", + "not a document" + ), + Err(GhError::ClientVersionMismatch { .. }) + )); + } + assert!(matches!( + fetch_response( + "401 Unauthorized", + "X-Blue-Required-Client-Version: 8.0.0\r\n", + "" + ), + Err(GhError::Unauthorized(_)) + )); + assert!(matches!( + fetch_response( + "426 Upgrade Required", + "X-Blue-Required-Client-Version: 7.9.0\r\n", + "unsupported capability" + ), + Err(GhError::Config(_)) + )); + } + #[test] + fn http_rejects_malformed_and_conflicting_pins_and_supports_old_servers() { + let body = r#"{"revision":"r1", "required_client_version":"7.8.9"}"#; + assert!( + fetch_response("200 OK", "X-Blue-Required-Client-Version: 7.8.9\r\n", body).is_ok() + ); + assert!(fetch_response( + "200 OK", + "X-Blue-Required-Client-Version: 7.9.0\r\n", + r#"{"revision":"r1", "required_client_version":"7.9.0"}"# + ) + .is_ok()); + assert!(fetch_response("200 OK", "", body).is_ok()); + assert!(matches!( + fetch_response("200 OK", "X-Blue-Required-Client-Version: ^7.8.9\r\n", body), + Err(GhError::Config(_)) + )); + assert!(matches!( + fetch_response( + "200 OK", + "X-Blue-Required-Client-Version: 7.8.9\r\n", + r#"{"revision":"r1","required_client_version":"7.8.10"}"# + ), + Err(GhError::Config(_)) + )); + } + #[test] + fn file_source_uses_executing_cli_version() { + let _guard = crate::cache::test_support::with_cache_home("version-file"); + let path = gh_common::paths::blue_config_dir() + .unwrap() + .join("pin.yaml"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "revision: r1\nrequired_client_version: 7.8.9\n").unwrap(); + assert!(FileConfigSource::new(&path, "7.8.9") + .fetch(&Session::bearer("fixture")) + .is_ok()); + assert!(matches!( + FileConfigSource::new(&path, "8.0.0").fetch(&Session::bearer("fixture")), + Err(GhError::ClientVersionMismatch { .. }) + )); + assert!(FileConfigSource::new(&path, "7.8.10") + .fetch(&Session::bearer("fixture")) + .is_ok()); + } +} diff --git a/deploy/blue.yaml b/deploy/blue.yaml index e4c3136..d05570c 100644 --- a/deploy/blue.yaml +++ b/deploy/blue.yaml @@ -67,6 +67,9 @@ governance: - gateway_inference_jwt # Required when harness version ranges or versioned package adapters are used. minimum_client_version: "0.1.0" + # Exact recommended release and major compatibility boundary. Same-major + # differences warn and continue; different majors block. + # required_client_version: "0.1.0" ttl_seconds: 300 required: true allowed_harnesses: [codex, claude, kimi, opencode] diff --git a/deploy/consumer/blue/blue.yaml b/deploy/consumer/blue/blue.yaml index 215171b..c822d3d 100644 --- a/deploy/consumer/blue/blue.yaml +++ b/deploy/consumer/blue/blue.yaml @@ -68,6 +68,9 @@ governance: # Required when gateway is enabled. # - gateway_inference_jwt minimum_client_version: "0.1.0" + # Exact recommended release and major compatibility boundary. Same-major + # differences warn and continue; different majors block. + # required_client_version: "0.1.0" ttl_seconds: 300 required: true allowed_harnesses: [codex, claude, kimi, opencode] diff --git a/deploy/contract/governance.openapi.yaml b/deploy/contract/governance.openapi.yaml index f973c2b..7aee223 100644 --- a/deploy/contract/governance.openapi.yaml +++ b/deploy/contract/governance.openapi.yaml @@ -188,12 +188,21 @@ paths: responses: "200": description: The governance config + headers: + X-Blue-Required-Client-Version: + description: Exact recommended Blue CLI release, identical to required_client_version in the document. Same-major differences warn; different majors block. + schema: { type: string } content: application/json: schema: { $ref: "#/components/schemas/GovernanceConfig" } "403": { description: Authenticated email has no provisioned gateway account } "409": { description: Gateway key provisioning is required } - "426": { description: Client contract version or capabilities are insufficient } + "426": + description: Client contract version or capabilities are insufficient + headers: + X-Blue-Required-Client-Version: + description: Exact recommended Blue CLI release and major compatibility boundary, provided even when the document cannot be consumed. + schema: { type: string } /harness-metadata: get: @@ -1684,8 +1693,12 @@ components: contract_version: { type: integer, default: 1, description: Governance wire contract version } required_capabilities: type: array - items: { type: string, enum: [adapter_intervals, compiled_harness_registry, transactional_reconcile, unverified_harness_versions, versioned_state] } + items: { type: string, enum: [adapter_intervals, compiled_harness_registry, tenant_client_version_pin, transactional_reconcile, unverified_harness_versions, versioned_state] } minimum_client_version: { type: string, description: Operator-visible rollout floor; required capabilities enforce compatibility } + required_client_version: + type: string + description: Canonical exact SemVer of the recommended Blue CLI release and major compatibility boundary. Unequal versions within the same major warn and continue; different majors block. Defaults to the control API deployment version. Requires tenant_client_version_pin capability. + example: "0.1.0" ttl_seconds: { type: integer, description: Client cache TTL (default 300), example: 300 } required: { type: boolean, description: "If true, client fails closed without fresh config" } allowed_harnesses: diff --git a/services/control-api/src/lib.rs b/services/control-api/src/lib.rs index 05dc05f..66b5a56 100644 --- a/services/control-api/src/lib.rs +++ b/services/control-api/src/lib.rs @@ -5042,19 +5042,61 @@ async fn revoke_current_gateway_session( Ok(StatusCode::NO_CONTENT) } +fn default_client_version(runtime: Option<&str>) -> String { + runtime + .filter(|version| { + gh_service::GovernanceConfig::validate_client_version_pin(version).is_ok() + }) + .unwrap_or(env!("CARGO_PKG_VERSION")) + .to_owned() +} + +fn prepare_client_version_pin( + config: &mut gh_service::GovernanceConfig, + deployment_version: &str, +) -> Result { + let required = config + .required_client_version + .get_or_insert_with(|| deployment_version.to_owned()); + gh_service::GovernanceConfig::validate_client_version_pin(required) + .map_err(|error| ApiError::internal(error.to_string()))?; + let mut headers = HeaderMap::new(); + headers.insert( + "x-blue-required-client-version", + required + .parse() + .map_err(|_| ApiError::internal("invalid client version header"))?, + ); + if !config + .required_capabilities + .iter() + .any(|cap| cap == "tenant_client_version_pin") + { + config + .required_capabilities + .push("tenant_client_version_pin".into()); + } + Ok(headers) +} + async fn governance_config( State(state): State>, Extension(who): Extension, headers: HeaderMap, -) -> Result, ApiError> { +) -> Result { let row = current_config(&state.pool, who.organization_id).await?; let revision = row.revision; let mut config = serde_json::from_value(row.document) .map_err(|error| ApiError::internal(format!("decoding stored config: {error}")))?; - enforce_client_capabilities(&headers, &config)?; + let runtime_version = std::env::var("BLUE_DEPLOYMENT_VERSION").ok(); + let deployment_version = default_client_version(runtime_version.as_deref()); + let pin_headers = prepare_client_version_pin(&mut config, &deployment_version)?; + if let Err(error) = enforce_client_capabilities(&headers, &config) { + return Ok((pin_headers, error).into_response()); + } personalize_package_config(&state.pool, &revision, who.user_id, &mut config).await?; personalize_gateway_config(&state, &who, &mut config).await?; - Ok(Json(config)) + Ok((pin_headers, Json(config)).into_response()) } async fn personalize_package_config( @@ -6568,6 +6610,10 @@ fn uncertified_harness_range(config: &gh_service::GovernanceConfig) -> Option Result<(), String> { + if let Some(pin) = &config.required_client_version { + gh_service::GovernanceConfig::validate_client_version_pin(pin) + .map_err(|error| error.to_string())?; + } if config.contract_version == 0 || config.contract_version > gh_service::GovernanceConfig::CONTRACT_VERSION { @@ -8066,6 +8112,16 @@ async fn validate_package_audiences( } fn stamp_version_aware_client_floor(config: &mut gh_service::GovernanceConfig) { + if config.required_client_version.is_some() + && !config + .required_capabilities + .iter() + .any(|cap| cap == "tenant_client_version_pin") + { + config + .required_capabilities + .push("tenant_client_version_pin".into()); + } let required = config .harnesses .values() @@ -12151,3 +12207,83 @@ mod gateway_ttl_config_tests { ); } } + +#[cfg(test)] +mod client_version_pin_tests { + use super::*; + fn document() -> gh_service::GovernanceConfig { + serde_json::from_value(serde_json::json!({"revision":"r1"})).unwrap() + } + #[test] + fn default_client_version_uses_only_canonical_runtime_semver() { + for version in ["1.2.3", "1.2.3-rc.gabcdef0"] { + assert_eq!(default_client_version(Some(version)), version); + } + + for version in [ + None, + Some("development"), + Some("e2e"), + Some("v1.2.3"), + Some("1.2.03"), + ] { + assert_eq!(default_client_version(version), env!("CARGO_PKG_VERSION")); + } + } + #[test] + fn old_revisions_acquire_current_deployment_pin_without_persistence() { + let stored = document(); + for runtime_version in ["1.2.3", "1.2.4-rc.gabcdef0"] { + let version = default_client_version(Some(runtime_version)); + let mut served = stored.clone(); + let headers = prepare_client_version_pin(&mut served, &version).unwrap(); + assert_eq!(headers["x-blue-required-client-version"], version); + assert_eq!( + served.required_client_version.as_deref(), + Some(version.as_str()) + ); + assert!(served + .required_capabilities + .iter() + .any(|cap| cap == "tenant_client_version_pin")); + } + assert!(stored.required_client_version.is_none()); + assert!(stored.required_capabilities.is_empty()); + } + #[test] + fn override_and_admin_validation_use_exact_recommendation() { + let mut config = document(); + config.required_client_version = Some("3.2.1".into()); + stamp_version_aware_client_floor(&mut config); + assert!(validate_complete_governance(&config).is_ok()); + let headers = prepare_client_version_pin(&mut config, "1.2.3").unwrap(); + assert_eq!(headers["x-blue-required-client-version"], "3.2.1"); + config.required_client_version = Some("^3.2.1".into()); + assert!(validate_complete_governance(&config).is_err()); + assert!(prepare_client_version_pin(&mut config, "1.2.3").is_err()); + } + #[test] + fn successful_and_rejected_responses_expose_the_same_pin() { + let mut config = document(); + let pin_headers = prepare_client_version_pin(&mut config, "1.2.3").unwrap(); + let rejection = enforce_client_capabilities(&HeaderMap::new(), &config).unwrap_err(); + let response = (pin_headers.clone(), rejection).into_response(); + assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); + assert_eq!( + response.headers()["x-blue-required-client-version"], + "1.2.3" + ); + let mut supported = HeaderMap::new(); + supported.insert( + "x-blue-capabilities", + "tenant_client_version_pin".parse().unwrap(), + ); + assert!(enforce_client_capabilities(&supported, &config).is_ok()); + let response = (pin_headers, Json(config)).into_response(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()["x-blue-required-client-version"], + "1.2.3" + ); + } +} diff --git a/tests/e2e/coverage.json b/tests/e2e/coverage.json index 27a244e..ffd5f4e 100644 --- a/tests/e2e/coverage.json +++ b/tests/e2e/coverage.json @@ -37,7 +37,16 @@ "test": "journey.spec.ts", "behavioral_tests": [ "same-base concurrent updates produce one revision and one conflict", - "invalid YAML, runtime gateway fields, and unsupported harnesses do not publish revisions" + "invalid YAML, runtime gateway fields, and unsupported harnesses do not publish revisions", + "tenant client-version recommendations allow same-major drift while cross-major pins reject without managed-state mutation" + ] + }, + "client_version": { + "test": "client-version.spec.ts", + "behavioral_tests": [ + "matching pins are exposed in governance headers and documents", + "missing tenant_client_version_pin capability returns 426 with the required version header", + "noncanonical pins are rejected atomically and exact mismatches block apply and launch while reset remains available" ] } } diff --git a/tests/e2e/specs/client-version.spec.ts b/tests/e2e/specs/client-version.spec.ts new file mode 100644 index 0000000..4a864ef --- /dev/null +++ b/tests/e2e/specs/client-version.spec.ts @@ -0,0 +1,199 @@ +import { expect, test, type Page } from "@playwright/test"; +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import YAML from "yaml"; +import { + collect, + prepareClient, + readClientFile, + runCli, + spawnCli, + waitForOutput, +} from "../support/cli.js"; +import { loginAsAdmin } from "../support/dashboard.js"; + +const CONTROL = process.env.E2E_CONTROL_API_URL ?? "http://127.0.0.1:8080"; +const DEVICE_URL = /http:\/\/127\.0\.0\.1:3000\/device\/[A-Za-z0-9_-]+/; +const CAPABILITIES = [ + "tenant_client_version_pin", + "adapter_intervals", + "compiled_harness_registry", + "transactional_reconcile", + "versioned_state", + "unverified_harness_versions", + "gateway_inference_jwt", +]; + +async function completeDeviceLogin(page: Page, home: string): Promise { + const login = spawnCli(home, ["login"]); + const completion = collect(login); + const deviceUrl = await waitForOutput(login, DEVICE_URL); + await page.goto(deviceUrl); + await page.getByRole("button", { name: "Authorize" }).click(); + await expect(page.getByText("CLI authorized")).toBeVisible(); + const result = await completion; + expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0); +} + +function withClientVersionPin(managedYaml: string, version: string): string { + const document = YAML.parse(managedYaml); + document.required_client_version = version; + document.required_capabilities = Array.from( + new Set([ + ...(document.required_capabilities ?? []), + "tenant_client_version_pin", + ]), + ); + return YAML.stringify(document); +} + +test("@smoke tenant client-version pins reject incompatible CLI commands without changing managed state", async ({ page }) => { + const home = await prepareClient("client-version-pin"); + await loginAsAdmin(page, { fresh: true }); + + const versionResult = await runCli(home, ["version"]); + expect(versionResult.code, versionResult.stderr).toBe(0); + const executingVersion = versionResult.stdout.match(/Blue metaharness (\S+)/)?.[1]; + expect(executingVersion).toMatch(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/); + const executingMajor = Number(executingVersion!.split(".")[0]); + const [major, minor, patchVersion] = executingVersion!.split(/[.+-]/).slice(0, 3).map(Number); + const recommendedVersion = `${major}.${minor}.${patchVersion + 1}`; + const incompatibleVersion = `${executingMajor + 1}.0.0`; + + const originalResponse = await page.request.get(`${CONTROL}/admin/governance-config`); + expect(originalResponse.status(), await originalResponse.text()).toBe(200); + const original = await originalResponse.json(); + + try { + await completeDeviceLogin(page, home); + + const gateway = await runCli(home, ["gateway"]); + expect(gateway.code, gateway.stderr).toBe(0); + expect(gateway.stdout).toContain("status : ready"); + + const matchingResponse = await page.request.put(`${CONTROL}/admin/governance-config`, { + data: { + base_revision: original.revision, + managed_yaml: withClientVersionPin(original.managed_yaml, executingVersion!), + }, + }); + expect(matchingResponse.status(), await matchingResponse.text()).toBe(200); + const matching = await matchingResponse.json(); + + const selectCodex = await runCli(home, ["agent", "codex"]); + expect(selectCodex.code, selectCodex.stderr).toBe(0); + const initialApply = await runCli(home, ["apply", "--yes"]); + expect(initialApply.code, initialApply.stderr).toBe(0); + + const managedPath = path.join(home, ".codex", "blue.config.toml"); + const cachePath = path.join(home, ".cache", "blue", "governance-config.json"); + const managedBeforeMismatch = await readFile(managedPath); + expect((await readFile(cachePath)).length).toBeGreaterThan(0); + + const session = JSON.parse(await readClientFile(home, ".config/blue/session.json")); + const compatible = await page.request.get(`${CONTROL}/governance-config`, { + headers: { + authorization: `Bearer ${session.token}`, + "x-blue-contract-version": "3", + "x-blue-capabilities": CAPABILITIES.join(","), + }, + }); + expect(compatible.status(), await compatible.text()).toBe(200); + expect(compatible.headers()["x-blue-required-client-version"]).toBe(executingVersion); + const compatibleDocument = await compatible.json(); + expect(compatibleDocument.required_client_version).toBe(executingVersion); + expect(compatibleDocument.required_capabilities).toContain("tenant_client_version_pin"); + + const missingCapability = await page.request.get(`${CONTROL}/governance-config`, { + headers: { + authorization: `Bearer ${session.token}`, + "x-blue-contract-version": "3", + "x-blue-capabilities": CAPABILITIES.filter( + (capability) => capability !== "tenant_client_version_pin", + ).join(","), + }, + }); + expect(missingCapability.status(), await missingCapability.text()).toBe(426); + expect(missingCapability.headers()["x-blue-required-client-version"]).toBe(executingVersion); + + const recommendedResponse = await page.request.put(`${CONTROL}/admin/governance-config`, { + data: { + base_revision: matching.revision, + managed_yaml: withClientVersionPin(matching.managed_yaml, recommendedVersion), + }, + }); + expect(recommendedResponse.status(), await recommendedResponse.text()).toBe(200); + const recommended = await recommendedResponse.json(); + + for (const args of [["apply", "--yes"], ["config"], ["doctor"]]) { + const allowed = await runCli(home, args); + const output = `${allowed.stdout}\n${allowed.stderr}`; + expect(allowed.code, output).toBe(0); + expect(output.match(/is compatible, but this tenant recommends/g)).toHaveLength(1); + expect(output).toContain(`Blue ${recommendedVersion}`); + expect(output).not.toContain(`Install Blue ${recommendedVersion} now`); + } + expect((await readFile(cachePath)).length).toBeGreaterThan(0); + expect(await readFile(managedPath)).toEqual(managedBeforeMismatch); + + const rangedPin = await page.request.put(`${CONTROL}/admin/governance-config`, { + data: { + base_revision: recommended.revision, + managed_yaml: withClientVersionPin(recommended.managed_yaml, `^${executingVersion}`), + }, + }); + expect(rangedPin.status(), await rangedPin.text()).toBe(400); + const afterRejectedPin = await ( + await page.request.get(`${CONTROL}/admin/governance-config`) + ).json(); + expect(afterRejectedPin.revision).toBe(recommended.revision); + expect(afterRejectedPin.managed_yaml).toBe(recommended.managed_yaml); + + const incompatibleResponse = await page.request.put(`${CONTROL}/admin/governance-config`, { + data: { + base_revision: recommended.revision, + managed_yaml: withClientVersionPin(recommended.managed_yaml, incompatibleVersion), + }, + }); + expect(incompatibleResponse.status(), await incompatibleResponse.text()).toBe(200); + + const personalized = await page.request.get(`${CONTROL}/governance-config`, { + headers: { + authorization: `Bearer ${session.token}`, + "x-blue-contract-version": "3", + "x-blue-capabilities": CAPABILITIES.join(","), + }, + }); + expect(personalized.status(), await personalized.text()).toBe(200); + expect(personalized.headers()["x-blue-required-client-version"]).toBe(incompatibleVersion); + expect((await personalized.json()).required_client_version).toBe(incompatibleVersion); + + await rm(path.join(home, "agent-log", "codex.env"), { force: true }); + for (const args of [["apply", "--yes"], ["run", "codex"]]) { + const blocked = await runCli(home, args); + const output = `${blocked.stdout}\n${blocked.stderr}`; + expect(blocked.code, output).not.toBe(0); + expect(output).toContain( + `Blue ${executingVersion} has an incompatible major version; this tenant requires Blue ${incompatibleVersion}.`, + ); + expect(output).toContain(`Release: https://github.com/BlocksOrg/blue/releases/tag/v${incompatibleVersion}`); + expect(output).toContain("Or run `blue reset` to detach from this tenant."); + expect(output).not.toContain(`Install Blue ${incompatibleVersion} now`); + expect(await readFile(managedPath)).toEqual(managedBeforeMismatch); + await expect(readFile(path.join(home, "agent-log", "codex.env"))).rejects.toThrow(); + } + await expect(readFile(cachePath)).rejects.toThrow(); + + const reset = await runCli(home, ["reset", "--yes"]); + expect(reset.code, `${reset.stdout}\n${reset.stderr}`).toBe(0); + expect(reset.stdout).toContain("Blue reset complete"); + } finally { + const currentResponse = await page.request.get(`${CONTROL}/admin/governance-config`); + expect(currentResponse.status(), await currentResponse.text()).toBe(200); + const current = await currentResponse.json(); + const restored = await page.request.put(`${CONTROL}/admin/governance-config`, { + data: { base_revision: current.revision, managed_yaml: original.managed_yaml }, + }); + expect(restored.status(), await restored.text()).toBe(200); + } +}); diff --git a/tests/e2e/specs/contract-surface.spec.ts b/tests/e2e/specs/contract-surface.spec.ts index 9618b7a..117692a 100644 --- a/tests/e2e/specs/contract-surface.spec.ts +++ b/tests/e2e/specs/contract-surface.spec.ts @@ -35,6 +35,7 @@ type OpenApiDocument = { operationId?: string; security?: SecurityRequirement[]; requestBody?: { content?: Record }; + responses?: Record }>; }>>; components?: { schemas?: Record }; }; @@ -138,6 +139,31 @@ test("every OpenAPI operation resolves and enforces its declared authentication } }); +test("OpenAPI declares the tenant client-version compatibility surface", async () => { + const { document } = await contract(); + const schema = document.components?.schemas?.GovernanceConfig; + expect(schema).toBeDefined(); + expect(typeof schema).not.toBe("boolean"); + if (!schema || typeof schema === "boolean") return; + + const capabilities = schema.properties?.required_capabilities; + expect(typeof capabilities).not.toBe("boolean"); + if (!capabilities || typeof capabilities === "boolean") return; + const capabilityItems = capabilities.items; + expect(typeof capabilityItems).not.toBe("boolean"); + if (!capabilityItems || typeof capabilityItems === "boolean") return; + expect(capabilityItems.enum).toContain("tenant_client_version_pin"); + expect(schema.properties?.required_client_version).toBeDefined(); + + const responses = document.paths["/governance-config"]?.get?.responses; + for (const status of ["200", "426"]) { + expect( + responses?.[status]?.headers, + `GET /governance-config ${status} response headers`, + ).toHaveProperty("X-Blue-Required-Client-Version"); + } +}); + test("coverage registry tracks all public CLI commands and harnesses", async () => { const coverage = JSON.parse(await readFile(path.join(process.cwd(), "coverage.json"), "utf8")); const help = await new Promise((resolve, reject) => { diff --git a/tests/e2e/specs/gateway-m2m.spec.ts b/tests/e2e/specs/gateway-m2m.spec.ts index 3191a18..63667f8 100644 --- a/tests/e2e/specs/gateway-m2m.spec.ts +++ b/tests/e2e/specs/gateway-m2m.spec.ts @@ -107,7 +107,7 @@ async function mintInferenceToken( authorization: `Bearer ${session.token}`, "x-blue-contract-version": "3", "x-blue-capabilities": - "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt", + "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,tenant_client_version_pin", }, }); expect(config.status(), await config.text()).toBe(200); @@ -265,7 +265,7 @@ test.describe.serial("Gateway M2M auth", () => { authorization: `Bearer ${session.token}`, "x-blue-contract-version": "3", "x-blue-capabilities": - "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt", + "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,tenant_client_version_pin", }, }, ); diff --git a/tests/e2e/specs/journey.spec.ts b/tests/e2e/specs/journey.spec.ts index 574141f..c68ed33 100644 --- a/tests/e2e/specs/journey.spec.ts +++ b/tests/e2e/specs/journey.spec.ts @@ -1622,7 +1622,7 @@ esac headers: { authorization: `Bearer ${memberOauth.token}`, "x-blue-contract-version": "3", - "x-blue-capabilities": "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,unverified_harness_versions", + "x-blue-capabilities": "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,unverified_harness_versions,tenant_client_version_pin", }, }); expect(memberConfig.status(), await memberConfig.text()).toBe(200);