diff --git a/apps/dashboard/app/(dashboard)/extensions/page.tsx b/apps/dashboard/app/(dashboard)/extensions/page.tsx index 731a04c..8606c33 100644 --- a/apps/dashboard/app/(dashboard)/extensions/page.tsx +++ b/apps/dashboard/app/(dashboard)/extensions/page.tsx @@ -6,7 +6,7 @@ import { type PackageSourceConnection, } from "../configuration/package-manager"; import type { McpByHarness, McpServer } from "../configuration/mcp-manager"; -import type { HarnessMetadataResponse } from "@/lib/harness-metadata"; +import { effectiveHarnesses, type HarnessMetadataResponse } from "@/lib/harness-metadata"; type PackageOverride = { enabled?: boolean; @@ -60,7 +60,7 @@ export default async function Extensions() { overrides={overrides} connections={connections} mcp={mcp} - harnesses={metadata.harnesses} + harnesses={effectiveHarnesses(metadata)} /> ); } diff --git a/apps/dashboard/app/(dashboard)/harnesses/harness-config-editor.tsx b/apps/dashboard/app/(dashboard)/harnesses/harness-config-editor.tsx index 01d20f5..b175729 100644 --- a/apps/dashboard/app/(dashboard)/harnesses/harness-config-editor.tsx +++ b/apps/dashboard/app/(dashboard)/harnesses/harness-config-editor.tsx @@ -357,7 +357,7 @@ export function HarnessConfigEditor({ Harness - Supported versions + Adapter interval / verified ceiling Allowed versions Managed config Actions diff --git a/apps/dashboard/app/(dashboard)/harnesses/page.tsx b/apps/dashboard/app/(dashboard)/harnesses/page.tsx index fe7b673..d2b65a1 100644 --- a/apps/dashboard/app/(dashboard)/harnesses/page.tsx +++ b/apps/dashboard/app/(dashboard)/harnesses/page.tsx @@ -1,6 +1,6 @@ import { api, requireAdminIdentity } from "@/lib/api"; import { HarnessConfigEditor } from "./harness-config-editor"; -import type { HarnessMetadataResponse } from "@/lib/harness-metadata"; +import { effectiveHarnesses, type HarnessMetadataResponse } from "@/lib/harness-metadata"; type HarnessManagedConfigs = { revision: string; @@ -22,7 +22,7 @@ export default async function Harnesses() { configurations={value.configurations} versionRequirements={value.version_requirements} unverifiedOverrides={value.allow_unverified_versions} - harnesses={metadata.harnesses} + harnesses={effectiveHarnesses(metadata)} /> ); } diff --git a/apps/dashboard/lib/harness-metadata.ts b/apps/dashboard/lib/harness-metadata.ts index 7647c07..6576ca2 100644 --- a/apps/dashboard/lib/harness-metadata.ts +++ b/apps/dashboard/lib/harness-metadata.ts @@ -29,4 +29,19 @@ export type HarnessMetadata = { export type HarnessMetadataResponse = { contract_version: number; harnesses: HarnessMetadata[]; + effective_verified_ceilings?: Record>; + known_versions_source?: "compiled" | "public_manifest"; + known_versions_refreshed_at?: string | null; }; + +export function effectiveHarnesses(response: HarnessMetadataResponse): HarnessMetadata[] { + return response.harnesses.map((harness) => ({ + ...harness, + generations: harness.generations.map((generation) => ({ + ...generation, + verified_before: + response.effective_verified_ceilings?.[harness.key]?.[generation.profile] ?? + generation.verified_before, + })), + })); +} diff --git a/apps/docs/next/concepts/harness-version-compatibility.mdx b/apps/docs/next/concepts/harness-version-compatibility.mdx index a790049..c7fa624 100644 --- a/apps/docs/next/concepts/harness-version-compatibility.mdx +++ b/apps/docs/next/concepts/harness-version-compatibility.mdx @@ -250,6 +250,20 @@ Blue records the selected profile, normalized version, and generated paths in ha See [Governance configuration](/next/reference/governance-config) for the complete schema and [Troubleshooting](/next/cli/troubleshooting) for reconciliation diagnostics. +## Refreshing certification without a client release + +When a tested upstream release uses the existing compiled profile, maintainers +update `tests/e2e/agents.lock.json` and root `known-agent-versions.json` +together. The Control API fetches and validates this data-only manifest and +sends effective exclusive ceilings through governance config; clients never +fetch it directly. Layout or behavior changes still require a new `VersionSpec` +and a Blue release. + +Operators may override `control_api.known_versions.url` with an HTTPS mirror, +change `refresh_seconds`, or set `enabled: false`. Failed refreshes retain the +last valid in-memory snapshot; after restart the fallback is the stricter +compiled ceiling. `allow_unverified_versions` remains the explicit escape hatch. + ## Install an absent agent In a Unix terminal, `blue `, bare `blue`, and `blue agent` offer diff --git a/apps/docs/next/development/harness-adapter-architecture.mdx b/apps/docs/next/development/harness-adapter-architecture.mdx index d8ad80c..6dbcbda 100644 --- a/apps/docs/next/development/harness-adapter-architecture.mdx +++ b/apps/docs/next/development/harness-adapter-architecture.mdx @@ -270,3 +270,8 @@ Before review, verify these invariants: - Old specs, operation sets, fixtures, and golden plans remain present. Run the complete repository checks documented in [Contributing](/next/development/contributing). + +For certification without an adapter behavior change, update the E2E agent lock +and root `known-agent-versions.json` together. The manifest can only extend the +exact compiled profile; it cannot change selection, capabilities, or intervals. +Behavioral or layout changes require the full generation workflow and a release. diff --git a/crates/gh-agent/src/lib.rs b/crates/gh-agent/src/lib.rs index 52e4c0b..a5cbd81 100644 --- a/crates/gh-agent/src/lib.rs +++ b/crates/gh-agent/src/lib.rs @@ -11,9 +11,9 @@ use std::collections::{BTreeMap, BTreeSet}; use gh_common::{GhError, Harness}; use gh_config::{ acquire_revision_locks, preflight_packages, preflight_packages_with_fetcher, - resolve_compatibility, teardown_inactive_packages, write_harness_with_package_fetcher, - write_harness_with_packages, AuthenticatedPackageFetcher, HarnessWrite, PackageFetcher, - ProfileStatus, WriteOptions, + resolve_compatibility_with_effective, teardown_inactive_packages, + write_harness_with_package_fetcher, write_harness_with_packages, AuthenticatedPackageFetcher, + HarnessWrite, PackageFetcher, ProfileStatus, WriteOptions, }; use gh_harness::HarnessInventory; use gh_service::{GovernanceConfig, HarnessPolicy, ServiceClient, Session}; @@ -27,6 +27,7 @@ pub struct HarnessReconcile { /// Annotate a PATH inventory with the exact compatibility decision that will /// be used by reconciliation and launch. pub fn evaluate_inventory(config: &GovernanceConfig, inventory: &mut HarnessInventory) { + let ceilings = gh_config::EffectiveVerifiedCeilings(config.effective_verified_ceilings.clone()); let empty = HarnessPolicy::default(); for entry in &mut inventory.entries { if !(entry.api_allowed && entry.client_supported && entry.installed) { @@ -36,11 +37,12 @@ pub fn evaluate_inventory(config: &GovernanceConfig, inventory: &mut HarnessInve continue; }; let policy = config.policy(&entry.name).unwrap_or(&empty); - match resolve_compatibility( + match resolve_compatibility_with_effective( harness, entry.version.as_ref(), entry.raw_version.as_deref(), policy, + &ceilings, ) { Ok(context) => { entry.compatibility_profile = Some(context.profile.id.to_owned()); @@ -100,6 +102,7 @@ fn apply_once_with_inventory_and_optional_fetcher( let mut opts = opts; opts.session_upload_enabled = config.session_upload.is_some(); let empty = HarnessPolicy::default(); + let ceilings = gh_config::EffectiveVerifiedCeilings(config.effective_verified_ceilings.clone()); // Compatibility is a revision-wide preflight. Never begin mutating one // harness if another required, installed harness cannot be planned. @@ -121,11 +124,12 @@ fn apply_once_with_inventory_and_optional_fetcher( continue; }; let policy = config.policy(&entry.name).unwrap_or(&empty); - match resolve_compatibility( + match resolve_compatibility_with_effective( harness, entry.version.as_ref(), entry.raw_version.as_deref(), policy, + &ceilings, ) { Err(error) => { preflight_failures.push((harness, error.with_install_hint(policy))); @@ -474,6 +478,7 @@ mod tests { revision: "r1".into(), contract_version: GovernanceConfig::CONTRACT_VERSION, required_capabilities: Vec::new(), + effective_verified_ceilings: Default::default(), minimum_client_version: None, required_client_version: None, ttl_seconds: None, diff --git a/crates/gh-cli/src/commands.rs b/crates/gh-cli/src/commands.rs index 5c2a86f..bfe430b 100644 --- a/crates/gh-cli/src/commands.rs +++ b/crates/gh-cli/src/commands.rs @@ -5323,6 +5323,7 @@ mod tests { revision: revision.to_owned(), contract_version: GovernanceConfig::CONTRACT_VERSION, required_capabilities: Vec::new(), + effective_verified_ceilings: Default::default(), minimum_client_version: None, required_client_version: None, ttl_seconds: None, diff --git a/crates/gh-config/src/compat.rs b/crates/gh-config/src/compat.rs index bae1d42..e8e70eb 100644 --- a/crates/gh-config/src/compat.rs +++ b/crates/gh-config/src/compat.rs @@ -6,6 +6,7 @@ //! renderer until its announced deprecation. use crate::adapters::{self, HarnessImplementation, ImplementationLifecycle, VersionInterval}; +use crate::known_versions::EffectiveVerifiedCeilings; use gh_common::{GhError, Harness, InstallInvocation}; use gh_service::HarnessPolicy; use semver::{Version, VersionReq}; @@ -88,7 +89,7 @@ pub enum CompatibilityFailure { harness: Harness, installed: Version, profile: &'static str, - verified_before: &'static str, + verified_before: String, }, } @@ -155,6 +156,18 @@ fn select_profile(harness: Harness, version: &Version) -> Option Result { + supported_install_with_effective( + harness, + harness_policy, + &EffectiveVerifiedCeilings::default(), + ) +} + +pub fn supported_install_with_effective( + harness: Harness, + harness_policy: &HarnessPolicy, + ceilings: &EffectiveVerifiedCeilings, ) -> Result { if harness_policy.allow_unverified_versions && harness_policy @@ -181,10 +194,15 @@ pub fn supported_install( .iter() .rev() .find(|registration| { - interval_intersects( + interval_intersects_with_effective( ®istration.interval, policy.as_ref(), harness_policy.allow_unverified_versions, + ceilings.effective( + harness.key(), + registration.interval.profile, + registration.interval.verified_before, + ), ) }) .ok_or_else(|| { @@ -201,14 +219,16 @@ pub fn supported_install( let upper = if harness_policy.allow_unverified_versions { interval.before } else { + let effective = + ceilings.effective(harness.key(), interval.profile, interval.verified_before); match interval.before { Some(before) if Version::parse(before).expect("compiled interval") - < Version::parse(interval.verified_before).expect("compiled ceiling") => + < Version::parse(effective).expect("validated ceiling") => { Some(before) } - _ => Some(interval.verified_before), + _ => Some(effective), } }; if let Some(upper) = upper { @@ -226,13 +246,28 @@ pub(crate) fn interval_intersects( interval: &VersionInterval, requirement: Option<&VersionReq>, allow_unverified: bool, +) -> bool { + interval_intersects_with_effective( + interval, + requirement, + allow_unverified, + interval.verified_before, + ) +} + +fn interval_intersects_with_effective( + interval: &VersionInterval, + requirement: Option<&VersionReq>, + allow_unverified: bool, + effective_verified_before: &str, ) -> bool { let lower = Version::parse(interval.introduced).expect("compiled interval"); let mut upper = interval .before .map(|value| Version::parse(value).expect("compiled interval")); if !allow_unverified { - let verified = Version::parse(interval.verified_before).expect("compiled verified ceiling"); + let verified = + Version::parse(effective_verified_before).expect("validated verified ceiling"); if upper.as_ref().is_none_or(|value| verified < *value) { upper = Some(verified); } @@ -469,14 +504,53 @@ pub fn resolve( raw_version: Option<&str>, policy: &HarnessPolicy, ) -> Result { - resolve_for_definition(adapters::definition(harness), version, raw_version, policy) + resolve_with_effective( + harness, + version, + raw_version, + policy, + &EffectiveVerifiedCeilings::default(), + ) } +pub fn resolve_with_effective( + harness: Harness, + version: Option<&Version>, + raw_version: Option<&str>, + policy: &HarnessPolicy, + ceilings: &EffectiveVerifiedCeilings, +) -> Result { + resolve_for_definition_with_effective( + adapters::definition(harness), + version, + raw_version, + policy, + ceilings, + ) +} + +#[cfg(test)] pub fn resolve_for_definition( definition: &'static adapters::HarnessDefinition, version: Option<&Version>, raw_version: Option<&str>, policy: &HarnessPolicy, +) -> Result { + resolve_for_definition_with_effective( + definition, + version, + raw_version, + policy, + &EffectiveVerifiedCeilings::default(), + ) +} + +fn resolve_for_definition_with_effective( + definition: &'static adapters::HarnessDefinition, + version: Option<&Version>, + raw_version: Option<&str>, + policy: &HarnessPolicy, + ceilings: &EffectiveVerifiedCeilings, ) -> Result { let harness = definition.harness; let raw = raw_version.unwrap_or("unknown"); @@ -518,18 +592,19 @@ pub fn resolve_for_definition( installed: version.clone(), } })?; - let verified_before = - Version::parse(profile.interval.verified_before).expect("compiled verified ceiling"); + let effective_ceiling = + ceilings.effective(harness.key(), profile.id, profile.interval.verified_before); + let verified_before = Version::parse(effective_ceiling).expect("validated verified ceiling"); let unverified = version >= &verified_before; if unverified && !policy.allow_unverified_versions { return Err(CompatibilityFailure::UnverifiedGeneration { harness, installed: version.clone(), profile: profile.id, - verified_before: profile.interval.verified_before, + verified_before: effective_ceiling.to_owned(), }); } - let unverified_warning = unverified.then(|| format!("{harness} {version} is at or beyond Blue's exclusive certified ceiling {} for {}; continuing because allow_unverified_versions is enabled. Vendor breaking changes may produce invalid configuration", profile.interval.verified_before, profile.id)); + let unverified_warning = unverified.then(|| format!("{harness} {version} is at or beyond Blue's exclusive certified ceiling {} for {}; continuing because allow_unverified_versions is enabled. Vendor breaking changes may produce invalid configuration", effective_ceiling, profile.id)); Ok(HarnessContext { definition, harness, diff --git a/crates/gh-config/src/known_versions.rs b/crates/gh-config/src/known_versions.rs new file mode 100644 index 0000000..f7ddece --- /dev/null +++ b/crates/gh-config/src/known_versions.rs @@ -0,0 +1,149 @@ +use std::collections::BTreeMap; + +use semver::Version; +use serde::{Deserialize, Serialize}; + +use crate::adapters; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EffectiveVerifiedCeilings(pub BTreeMap>); + +impl EffectiveVerifiedCeilings { + pub fn get(&self, harness: &str, profile: &str) -> Option<&str> { + self.0.get(harness)?.get(profile).map(String::as_str) + } + + pub fn effective<'a>(&'a self, harness: &str, profile: &str, compiled: &'a str) -> &'a str { + self.get(harness, profile).unwrap_or(compiled) + } + + pub fn extends_compiled_registry(&self) -> bool { + adapters::HARNESS_DEFINITIONS.iter().any(|definition| { + definition.implementations.iter().any(|registration| { + self.get(definition.metadata.key, registration.interval.profile) + .is_some_and(|value| { + Version::parse(value).expect("validated ceiling") + > Version::parse(registration.interval.verified_before) + .expect("compiled ceiling") + }) + }) + }) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KnownVersionsManifest { + pub schema_version: u32, + pub harnesses: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KnownHarnessVersion { + pub profile: String, + pub verified_before: String, +} + +impl KnownVersionsManifest { + pub fn parse_and_validate(bytes: &[u8]) -> Result { + let manifest: Self = serde_json::from_slice(bytes) + .map_err(|error| format!("invalid known-agent-versions manifest: {error}"))?; + manifest.validate() + } + + pub fn validate(self) -> Result { + if self.schema_version != 1 { + return Err(format!( + "unsupported known-agent-versions schema_version {}", + self.schema_version + )); + } + let mut result = EffectiveVerifiedCeilings::default(); + for (key, entry) in self.harnesses { + let definition = adapters::HARNESS_DEFINITIONS + .iter() + .find(|definition| definition.metadata.key == key) + .ok_or_else(|| format!("unknown harness `{key}`"))?; + let registration = definition + .implementations + .iter() + .find(|registration| registration.interval.profile == entry.profile) + .ok_or_else(|| { + format!("unknown profile `{}` for harness `{key}`", entry.profile) + })?; + let ceiling = Version::parse(&entry.verified_before).map_err(|_| { + format!( + "verified_before for {key}/{} must be canonical SemVer", + entry.profile + ) + })?; + if ceiling.to_string() != entry.verified_before { + return Err(format!( + "verified_before for {key}/{} must be canonical SemVer", + entry.profile + )); + } + let compiled = + Version::parse(registration.interval.verified_before).expect("compiled ceiling"); + if ceiling < compiled { + return Err(format!( + "verified_before for {key}/{} regresses below compiled ceiling", + entry.profile + )); + } + if let Some(before) = registration.interval.before { + if ceiling > Version::parse(before).expect("compiled interval") { + return Err(format!( + "verified_before for {key}/{} exceeds compiled adapter interval", + entry.profile + )); + } + } + result + .0 + .entry(key) + .or_default() + .insert(entry.profile, entry.verified_before); + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checked_in_manifest_is_valid() { + let bytes = include_bytes!("../../../known-agent-versions.json"); + KnownVersionsManifest::parse_and_validate(bytes).unwrap(); + let manifest: KnownVersionsManifest = serde_json::from_slice(bytes).unwrap(); + let lock: serde_json::Value = + serde_json::from_slice(include_bytes!("../../../tests/e2e/agents.lock.json")).unwrap(); + for (harness, entry) in manifest.harnesses { + let pinned = lock["agents"][&harness]["version"].as_str().unwrap(); + let mut expected = Version::parse(pinned).unwrap(); + expected.patch += 1; + expected.pre = semver::Prerelease::new("0").unwrap(); + assert_eq!(entry.verified_before, expected.to_string(), "{harness}"); + assert_eq!(entry.profile, lock["agents"][&harness]["profile"]); + } + } + + #[test] + fn rejects_unknown_fields_and_regressions() { + assert!(KnownVersionsManifest::parse_and_validate( + br#"{"schema_version":1,"extra":true,"harnesses":{}}"# + ) + .is_err()); + assert!(KnownVersionsManifest::parse_and_validate(br#"{"schema_version":1,"harnesses":{"codex":{"profile":"codex-v0_145_0","verified_before":"0.150.0"}}}"#).is_err()); + } + + #[test] + fn accepts_an_unbounded_profile_extension() { + let ceilings = KnownVersionsManifest::parse_and_validate(br#"{"schema_version":1,"harnesses":{"opencode":{"profile":"opencode-v0_0_0","verified_before":"1.18.27-0"}}}"#).unwrap(); + assert!(ceilings.extends_compiled_registry()); + } +} diff --git a/crates/gh-config/src/lib.rs b/crates/gh-config/src/lib.rs index 5c8358d..005fd13 100644 --- a/crates/gh-config/src/lib.rs +++ b/crates/gh-config/src/lib.rs @@ -9,6 +9,7 @@ pub mod implementations { pub use super::adapters::*; } mod compat; +pub mod known_versions; mod package_archive; mod packages; mod plan; @@ -40,9 +41,12 @@ pub(crate) fn managed_runtime_dir(home: &std::path::Path) -> PathBuf { } pub use compat::{ - resolve as resolve_compatibility, supported_install, validate_package_adapter_for_policy, + resolve as resolve_compatibility, + resolve_with_effective as resolve_compatibility_with_effective, supported_install, + supported_install_with_effective, validate_package_adapter_for_policy, validate_package_adapters_for_policy, CompatibilityFailure, HarnessContext, ProfileStatus, }; +pub use known_versions::{EffectiveVerifiedCeilings, KnownVersionsManifest}; pub use packages::PackageFetcher; pub struct AuthenticatedPackageFetcher<'a> { diff --git a/crates/gh-service/src/schema.rs b/crates/gh-service/src/schema.rs index 7d9c066..ec9dd75 100644 --- a/crates/gh-service/src/schema.rs +++ b/crates/gh-service/src/schema.rs @@ -24,6 +24,10 @@ pub struct GovernanceConfig { /// Features a client must implement before accepting this document. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub required_capabilities: Vec, + /// Server-validated certification ceilings. These can only extend a + /// compiled profile and never participate in implementation selection. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub effective_verified_ceilings: BTreeMap>, /// Operator-visible rollout floor. Capability negotiation, not this field, /// enforces whether a client may consume the document. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -72,6 +76,7 @@ impl GovernanceConfig { "versioned_state", "unverified_harness_versions", "gateway_inference_jwt", + "dynamic_verified_ceilings", ]; pub fn ttl_seconds(&self) -> u64 { diff --git a/deploy/blue.yaml b/deploy/blue.yaml index d05570c..bb654d9 100644 --- a/deploy/blue.yaml +++ b/deploy/blue.yaml @@ -4,6 +4,10 @@ control_api: listen: 0.0.0.0:8080 database_url: os.environ/HARNESS_DATABASE_URL + known_versions: + enabled: true + url: https://raw.githubusercontent.com/BlocksOrg/blue/main/known-agent-versions.json + refresh_seconds: 3600 bootstrap: organization_slug: dev diff --git a/deploy/contract/governance.openapi.yaml b/deploy/contract/governance.openapi.yaml index 3062328..137c538 100644 --- a/deploy/contract/governance.openapi.yaml +++ b/deploy/contract/governance.openapi.yaml @@ -1689,11 +1689,17 @@ components: type: object required: [revision, allowed_harnesses] properties: + effective_verified_ceilings: + type: object + description: Server-validated exclusive certification ceilings keyed by harness then compiled profile; omitted entries use compiled ceilings. + additionalProperties: + type: object + additionalProperties: { type: string } revision: { type: string, description: "Reconcile key; daemon acts on change", example: 251e6087-98cf-4861-9668-f09f537d5e4d } contract_version: { type: integer, default: 1, description: Governance wire contract version } required_capabilities: type: array - items: { type: string, enum: [adapter_intervals, compiled_harness_registry, tenant_client_version_pin, transactional_reconcile, unverified_harness_versions, versioned_state] } + items: { type: string, enum: [adapter_intervals, compiled_harness_registry, dynamic_verified_ceilings, 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 diff --git a/deploy/helm/templates/deployment.yaml b/deploy/helm/templates/deployment.yaml index 2a7322a..0b845cb 100644 --- a/deploy/helm/templates/deployment.yaml +++ b/deploy/helm/templates/deployment.yaml @@ -76,6 +76,9 @@ spec: value: "false" - name: HARNESS_INTERNAL_LISTEN value: 0.0.0.0:8082 + - { name: HARNESS_KNOWN_VERSIONS_ENABLED, value: {{ .Values.blue.knownVersions.enabled | quote }} } + - { name: HARNESS_KNOWN_VERSIONS_URL, value: {{ .Values.blue.knownVersions.url | quote }} } + - { name: HARNESS_KNOWN_VERSIONS_REFRESH_SECONDS, value: {{ .Values.blue.knownVersions.refreshSeconds | quote }} } {{- if .Values.blue.enableInferenceProxy }} - { name: HARNESS_GATEWAY_TYPE, value: {{ required "blue.gatewayType is required when blue.enableInferenceProxy=true" .Values.blue.gatewayType | quote }} } - { name: HARNESS_INTERNAL_TRANSPORT_MODE, value: {{ .Values.blue.internalTransport.mode | quote }} } diff --git a/deploy/helm/values.schema.json b/deploy/helm/values.schema.json index 01c2cc9..59d70b7 100644 --- a/deploy/helm/values.schema.json +++ b/deploy/helm/values.schema.json @@ -66,6 +66,15 @@ } }, "existingSecret": { "type": "string" }, + "knownVersions": { + "type": "object", + "required": ["enabled", "url", "refreshSeconds"], + "properties": { + "enabled": { "type": "boolean" }, + "url": { "type": "string", "format": "uri" }, + "refreshSeconds": { "type": "integer", "minimum": 1 } + } + }, "config": { "type": "object", "properties": { diff --git a/deploy/helm/values.yaml b/deploy/helm/values.yaml index addca5a..4facf6d 100644 --- a/deploy/helm/values.yaml +++ b/deploy/helm/values.yaml @@ -82,6 +82,10 @@ blue: controlApi: https://api.blue.example.com inferenceProxy: https://inference.blue.example.com env: {} + knownVersions: + enabled: true + url: https://raw.githubusercontent.com/BlocksOrg/blue/main/known-agent-versions.json + refreshSeconds: 3600 components: controlApi: diff --git a/known-agent-versions.json b/known-agent-versions.json new file mode 100644 index 0000000..01da374 --- /dev/null +++ b/known-agent-versions.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "harnesses": { + "codex": { "profile": "codex-v0_145_0", "verified_before": "0.151.1-0" }, + "claude": { "profile": "claude-v2_0_12", "verified_before": "2.1.253-0" }, + "kimi": { "profile": "kimi-v0_0_0", "verified_before": "0.39.2-0" }, + "opencode": { "profile": "opencode-v0_0_0", "verified_before": "1.18.26-0" } + } +} diff --git a/services/control-api/src/lib.rs b/services/control-api/src/lib.rs index 66b5a56..95eb3dc 100644 --- a/services/control-api/src/lib.rs +++ b/services/control-api/src/lib.rs @@ -167,6 +167,8 @@ pub struct AppConfig { pub gateway_kms_max_concurrency: usize, pub gateway_kms_timeout_seconds: u64, pub run_background_jobs: bool, + pub known_versions_url: Option, + pub known_versions_refresh_seconds: u64, pub blue_config_file: PathBuf, pub blue_config_overlay_files: Vec, pub bootstrap_org: String, @@ -277,6 +279,17 @@ impl AppConfig { &["control_api", "run_background_jobs"], "true", )?)?, + known_versions_url: { + let enabled = parse_bool_setting(&setting_or("HARNESS_KNOWN_VERSIONS_ENABLED", &settings, &["control_api", "known_versions", "enabled"], "true")?)?; + if enabled { + let value = setting_or("HARNESS_KNOWN_VERSIONS_URL", &settings, &["control_api", "known_versions", "url"], "https://raw.githubusercontent.com/BlocksOrg/blue/main/known-agent-versions.json")?; + let url = reqwest::Url::parse(&value).map_err(|error| ApiError::internal(format!("invalid known versions URL: {error}")))?; + let loopback_http = url.scheme() == "http" && url.host_str().is_some_and(|host| host == "localhost" || host.parse::().is_ok_and(|ip| ip.is_loopback())); + if url.scheme() != "https" && !loopback_http { return Err(ApiError::internal("known versions URL must use HTTPS (HTTP is allowed only for loopback tests)")); } + Some(value) + } else { None } + }, + known_versions_refresh_seconds: positive_setting("HARNESS_KNOWN_VERSIONS_REFRESH_SECONDS", &settings, &["control_api", "known_versions", "refresh_seconds"], 3600)? as u64, blue_config_file, blue_config_overlay_files, bootstrap_org: setting_or( @@ -1075,6 +1088,7 @@ pub struct AppState { package_blob: BlobStore, config: AppConfig, http: reqwest::Client, + known_versions: KnownVersionsProvider, secret_protector: Option, gateway_provisioner: Option>, jwks: tokio::sync::RwLock>, @@ -1084,6 +1098,83 @@ pub struct AppState { gateway_provisioning_permits: Arc, } +#[derive(Clone, Default)] +struct KnownVersionsSnapshot { + ceilings: gh_config::EffectiveVerifiedCeilings, + etag: Option, + refreshed_at: Option, +} + +struct KnownVersionsProvider { + url: Option, + http: reqwest::Client, + snapshot: tokio::sync::RwLock, +} + +impl KnownVersionsProvider { + fn new(url: Option) -> Result { + Ok(Self { + url, + http: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| { + ApiError::internal(format!("building known versions client: {error}")) + })?, + snapshot: tokio::sync::RwLock::new(KnownVersionsSnapshot::default()), + }) + } + + async fn refresh(&self) -> Result { + let Some(url) = &self.url else { + return Ok(false); + }; + let etag = self.snapshot.read().await.etag.clone(); + let mut request = self.http.get(url); + if let Some(etag) = etag { + request = request.header(reqwest::header::IF_NONE_MATCH, etag); + } + let response = request + .send() + .await + .map_err(|error| format!("request failed: {error}"))?; + if response.status() == reqwest::StatusCode::NOT_MODIFIED { + return Ok(false); + } + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status())); + } + if response + .content_length() + .is_some_and(|length| length > 64 * 1024) + { + return Err("response exceeds 64 KiB".into()); + } + let next_etag = response + .headers() + .get(reqwest::header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let bytes = response + .bytes() + .await + .map_err(|error| format!("reading response failed: {error}"))?; + if bytes.len() > 64 * 1024 { + return Err("response exceeds 64 KiB".into()); + } + let ceilings = gh_config::KnownVersionsManifest::parse_and_validate(&bytes)?; + let mut snapshot = self.snapshot.write().await; + let changed = snapshot.ceilings != ceilings; + *snapshot = KnownVersionsSnapshot { + ceilings, + etag: next_etag, + refreshed_at: Some(OffsetDateTime::now_utc()), + }; + Ok(changed) + } +} + struct GatewayJwtKeyRing { issuer: String, audience: String, @@ -1523,6 +1614,7 @@ async fn build_app_inner( .map(|value| value.max_concurrency) .unwrap_or_else(default_provisioner_max_concurrency) .min((config.database_max_connections as usize / 2).max(1)); + let known_versions = KnownVersionsProvider::new(config.known_versions_url.clone())?; let state = Arc::new(AppState { pool, gateway_log_pool, @@ -1533,6 +1625,7 @@ async fn build_app_inner( .timeout(std::time::Duration::from_secs(5)) .build() .map_err(|error| ApiError::internal(format!("building auth client: {error}")))?, + known_versions, secret_protector, gateway_provisioner, jwks: tokio::sync::RwLock::new(None), @@ -1547,9 +1640,34 @@ async fn build_app_inner( state.pool.clone(), revision_events, )); + match state.known_versions.refresh().await { + Ok(changed) => tracing::info!(changed, "known agent versions refreshed"), + Err(reason) => { + tracing::warn!(%reason, "known agent versions unavailable; using compiled ceilings") + } + } if state.config.run_background_jobs { tokio::spawn(cleanup_gateway_request_logs(state.gateway_log_pool.clone())); tokio::spawn(process_gateway_revocations(state.clone())); + let refresh_state = state.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs( + refresh_state.config.known_versions_refresh_seconds, + )); + interval.tick().await; + loop { + interval.tick().await; + match refresh_state.known_versions.refresh().await { + Ok(true) => { + let _ = refresh_state.revision_events.send(RevisionSignal::Resync); + } + Ok(false) => {} + Err(reason) => { + tracing::warn!(%reason, "known agent versions refresh failed; retaining last valid snapshot") + } + } + } + }); } let public_routes = Router::new() @@ -2068,10 +2186,16 @@ async fn update_branding( Ok(Json(value)) } -async fn harness_metadata() -> Result, ApiError> { +async fn harness_metadata( + State(state): State>, +) -> Result, ApiError> { + let snapshot = state.known_versions.snapshot.read().await; Ok(Json(json!({ "contract_version": gh_service::GovernanceConfig::CONTRACT_VERSION, "harnesses": gh_config::implementations::registry_metadata(), + "effective_verified_ceilings": snapshot.ceilings.0, + "known_versions_source": if state.known_versions.url.is_some() { "public_manifest" } else { "compiled" }, + "known_versions_refreshed_at": snapshot.refreshed_at.and_then(|value| value.format(&Rfc3339).ok()), }))) } @@ -5086,8 +5210,23 @@ async fn governance_config( ) -> Result { let row = current_config(&state.pool, who.organization_id).await?; let revision = row.revision; - let mut config = serde_json::from_value(row.document) + let mut config: gh_service::GovernanceConfig = serde_json::from_value(row.document) .map_err(|error| ApiError::internal(format!("decoding stored config: {error}")))?; + let effective = state.known_versions.snapshot.read().await.ceilings.clone(); + if !effective.0.is_empty() { + let extends_compiled = effective.extends_compiled_registry(); + config.effective_verified_ceilings = effective.0; + if extends_compiled + && !config + .required_capabilities + .iter() + .any(|value| value == "dynamic_verified_ceilings") + { + config + .required_capabilities + .push("dynamic_verified_ceilings".into()); + } + } 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)?; @@ -5347,7 +5486,7 @@ fn reconciliation_scope( } async fn classify_client_revision( - pool: &PgPool, + state: &AppState, organization_id: Uuid, user_id: Uuid, input: &ClientStatusRequest, @@ -5368,7 +5507,7 @@ async fn classify_client_revision( organization_id, revision ) - .fetch_optional(pool) + .fetch_optional(&state.pool) .await?; let Some(document) = document else { return Ok(RevisionHealth { @@ -5378,7 +5517,8 @@ async fn classify_client_revision( }; let mut config: gh_service::GovernanceConfig = serde_json::from_value(document) .map_err(|error| ApiError::internal(format!("decoding stored config: {error}")))?; - personalize_package_config(pool, revision, user_id, &mut config).await?; + personalize_package_config(&state.pool, revision, user_id, &mut config).await?; + let effective = state.known_versions.snapshot.read().await.ceilings.clone(); let mut reasons = Vec::new(); let applied_for_revision = input.applied && input.config_revision.as_deref() == Some(revision); @@ -5433,9 +5573,13 @@ async fn classify_client_revision( let (version, raw_version) = reported_version(entry); let default_policy = gh_service::HarnessPolicy::default(); let policy = config.policy(name).unwrap_or(&default_policy); - if let Err(error) = - gh_config::resolve_compatibility(harness, version.as_ref(), raw_version, policy) - { + if let Err(error) = gh_config::resolve_compatibility_with_effective( + harness, + version.as_ref(), + raw_version, + policy, + &effective, + ) { reasons.push(format!("{name}: {error}")); } } @@ -5585,8 +5729,7 @@ async fn report_client_status( )); } } - let health = - classify_client_revision(&state.pool, who.organization_id, who.user_id, &input).await?; + let health = classify_client_revision(&state, who.organization_id, who.user_id, &input).await?; sqlx::query!("insert into public.client_status (id,organization_id,user_id,instance_id,hostname,client_version,platform,architecture,config_revision,applied,files_ok,harnesses,packages,error,revision_matches,status_reasons) \ values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) \ on conflict (user_id,instance_id) do update set hostname=excluded.hostname,client_version=excluded.client_version, \ @@ -10377,6 +10520,8 @@ mod tests { gateway_kms_max_concurrency: 4, gateway_kms_timeout_seconds: 5, run_background_jobs: false, + known_versions_url: None, + known_versions_refresh_seconds: 3600, blue_config_file: PathBuf::from("/etc/blue/blue.yaml"), blue_config_overlay_files: Vec::new(), bootstrap_org: "org".into(),