diff --git a/apps/docs/next/deployment/blue-yaml.mdx b/apps/docs/next/deployment/blue-yaml.mdx index b941f0c..e350843 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. Defaults to the control API deployment version and requires `tenant_client_version_pin`. | | `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,19 @@ 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. + +## Recommended Blue CLI release + +The reference control API advertises its deployment version as the exact +recommended Blue CLI release. 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 cannot consume the document. diff --git a/apps/docs/next/reference/governance-config.mdx b/apps/docs/next/reference/governance-config.mdx index 4d612b4..019449c 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. Defaults to the control API deployment version and requires `tenant_client_version_pin`. | | `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,16 @@ 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. + +## Recommended Blue CLI release + +The reference control API advertises its deployment version as the exact +recommended Blue CLI release. 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 cannot consume the document. diff --git a/apps/docs/openapi/next.yaml b/apps/docs/openapi/next.yaml index f973c2b..051db45 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. + 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, 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. 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..52e4c0b 100644 --- a/crates/gh-agent/src/lib.rs +++ b/crates/gh-agent/src/lib.rs @@ -475,6 +475,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/commands.rs b/crates/gh-cli/src/commands.rs index 5dd1a19..34b122b 100644 --- a/crates/gh-cli/src/commands.rs +++ b/crates/gh-cli/src/commands.rs @@ -5320,6 +5320,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-service/src/schema.rs b/crates/gh-service/src/schema.rs index 0f9e372..7d9c066 100644 --- a/crates/gh-service/src/schema.rs +++ b/crates/gh-service/src/schema.rs @@ -28,6 +28,9 @@ 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 Blue release exported by the control plane. + #[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 +65,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,6 +86,20 @@ impl GovernanceConfig { self.harnesses.get(harness) } + 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 ensure_client_compatible(&self) -> Result<(), String> { if self.contract_version > Self::CONTRACT_VERSION { return Err(format!( 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..051db45 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. + 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, 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. 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/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);