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);