mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
Add cloud config bundle transport
Introduce codex-cloud-config as the bundle-oriented replacement path for the existing cloud requirements transport. This deliberately adapts the cloud-requirements behavior rather than changing semantics: eligible ChatGPT Business/Enterprise accounts fetch from the backend, failures fail closed, auth recovery is attempted on unauthorized responses, transient request failures retry with backoff, and the cache is scoped to the ChatGPT user/account identity. The new crate fetches the generated backend config bundle endpoint and converts it into the config-domain CloudConfigBundle contract. The bundle shape mirrors the backend API with config_toml.enterprise_managed and requirements_toml.enterprise_managed sections only; we are not modeling future source types yet. Fragment order from the backend is preserved so later config/requirements composition can apply the same deterministic precedence rules as the server-delivered bundle. Add a separate signed cache file for the bundle instead of reusing the legacy requirements cache. The cache keeps the same broad goals as cloud requirements: local reuse for active sessions, a 30 minute TTL, a 5 minute background refresh cadence, HMAC tamper detection, cache versioning, and identity mismatch rejection. Metrics mirror the old fetch_attempt/fetch_final/load split while adding a bundle_shape tag that reports none, empty, or the sorted enterprise_config/enterprise_requirements sources present in the bundle. This commit keeps the existing codex-cloud-requirements path intact. Follow-up PRs will wire config loading to the new bundle loader, migrate requirements consumption onto the bundle, and then delete the legacy cloud requirements crate once the cutover is complete. Also add the config-domain CloudConfigBundle/CloudConfigBundleLoader types in codex-config, matching the existing CloudRequirementsLoader injection pattern. codex-cloud-config owns transport, auth, caching, refresh, and metrics; codex-config owns the domain contract that later config composition will consume. Verification: just fmt; cargo test -p codex-cloud-config; cargo test -p codex-config; just fix -p codex-cloud-config; just bazel-lock-update; just bazel-lock-check; git diff --check.
This commit is contained in:
Generated
+23
@@ -2316,6 +2316,29 @@ dependencies = [
|
||||
"zstd 0.13.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-cloud-config"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"codex-backend-client",
|
||||
"codex-config",
|
||||
"codex-core",
|
||||
"codex-login",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"hmac",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-cloud-requirements"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -22,6 +22,7 @@ members = [
|
||||
"install-context",
|
||||
"codex-backend-openapi-models",
|
||||
"code-mode",
|
||||
"cloud-config",
|
||||
"cloud-requirements",
|
||||
"cloud-tasks",
|
||||
"cloud-tasks-client",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "cloud-config",
|
||||
crate_name = "codex_cloud_config",
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "codex-cloud-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
codex-backend-client = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
hmac = "0.12.1"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
@@ -0,0 +1,447 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Utc;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use hmac::Hmac;
|
||||
use hmac::Mac;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use sha2::Sha256;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use tokio::fs;
|
||||
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1;
|
||||
pub(super) const CLOUD_CONFIG_BUNDLE_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json";
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY: &[u8] =
|
||||
b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e";
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS: &[&[u8]] =
|
||||
&[CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY];
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct CloudConfigBundleCache {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl CloudConfigBundleCache {
|
||||
pub(super) fn new(codex_home: PathBuf) -> Self {
|
||||
Self {
|
||||
path: codex_home.join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub(super) async fn load(
|
||||
&self,
|
||||
chatgpt_user_id: Option<&str>,
|
||||
account_id: Option<&str>,
|
||||
) -> Result<CloudConfigBundleCacheSignedPayload, CacheLoadStatus> {
|
||||
let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else {
|
||||
return Err(CacheLoadStatus::AuthIdentityIncomplete);
|
||||
};
|
||||
|
||||
let bytes = match fs::read(&self.path).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(CacheLoadStatus::CacheReadFailed(err.to_string()));
|
||||
}
|
||||
return Err(CacheLoadStatus::CacheFileNotFound);
|
||||
}
|
||||
};
|
||||
|
||||
let cache_file: CloudConfigBundleCacheFile = match serde_json::from_slice(&bytes) {
|
||||
Ok(cache_file) => cache_file,
|
||||
Err(err) => {
|
||||
return Err(CacheLoadStatus::CacheParseFailed(err.to_string()));
|
||||
}
|
||||
};
|
||||
let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) {
|
||||
Some(payload_bytes) => payload_bytes,
|
||||
None => {
|
||||
return Err(CacheLoadStatus::CacheParseFailed(
|
||||
"failed to serialize cache payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if !verify_cache_signature(&payload_bytes, &cache_file.signature) {
|
||||
return Err(CacheLoadStatus::CacheSignatureInvalid);
|
||||
}
|
||||
if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION {
|
||||
return Err(CacheLoadStatus::CacheVersionUnsupported(
|
||||
cache_file.signed_payload.version,
|
||||
));
|
||||
}
|
||||
|
||||
let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = (
|
||||
cache_file.signed_payload.chatgpt_user_id.as_deref(),
|
||||
cache_file.signed_payload.account_id.as_deref(),
|
||||
) else {
|
||||
return Err(CacheLoadStatus::CacheIdentityIncomplete);
|
||||
};
|
||||
|
||||
if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id {
|
||||
return Err(CacheLoadStatus::CacheIdentityMismatch);
|
||||
}
|
||||
|
||||
if cache_file.signed_payload.expires_at <= Utc::now() {
|
||||
return Err(CacheLoadStatus::CacheExpired);
|
||||
}
|
||||
|
||||
Ok(cache_file.signed_payload)
|
||||
}
|
||||
|
||||
pub(super) fn log_load_status(&self, status: &CacheLoadStatus) {
|
||||
if matches!(status, CacheLoadStatus::CacheFileNotFound) {
|
||||
return;
|
||||
}
|
||||
|
||||
let warn = matches!(
|
||||
status,
|
||||
CacheLoadStatus::CacheReadFailed(_)
|
||||
| CacheLoadStatus::CacheParseFailed(_)
|
||||
| CacheLoadStatus::CacheSignatureInvalid
|
||||
);
|
||||
|
||||
if warn {
|
||||
tracing::warn!(path = %self.path.display(), "{status}");
|
||||
} else {
|
||||
tracing::info!(path = %self.path.display(), "{status}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn save(
|
||||
&self,
|
||||
chatgpt_user_id: Option<String>,
|
||||
account_id: Option<String>,
|
||||
bundle: CloudConfigBundle,
|
||||
) -> Result<(), CloudConfigBundleCacheError> {
|
||||
let now = Utc::now();
|
||||
let expires_at = now
|
||||
.checked_add_signed(
|
||||
ChronoDuration::from_std(CLOUD_CONFIG_BUNDLE_CACHE_TTL)
|
||||
.map_err(|_| CloudConfigBundleCacheError)?,
|
||||
)
|
||||
.ok_or(CloudConfigBundleCacheError)?;
|
||||
let signed_payload = CloudConfigBundleCacheSignedPayload {
|
||||
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
|
||||
cached_at: now,
|
||||
expires_at,
|
||||
chatgpt_user_id,
|
||||
account_id,
|
||||
bundle,
|
||||
};
|
||||
let payload_bytes =
|
||||
cache_payload_bytes(&signed_payload).ok_or(CloudConfigBundleCacheError)?;
|
||||
let serialized = serde_json::to_vec_pretty(&CloudConfigBundleCacheFile {
|
||||
signature: sign_cache_payload(&payload_bytes).ok_or(CloudConfigBundleCacheError)?,
|
||||
signed_payload,
|
||||
})
|
||||
.map_err(|_| CloudConfigBundleCacheError)?;
|
||||
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|_| CloudConfigBundleCacheError)?;
|
||||
}
|
||||
|
||||
fs::write(&self.path, serialized)
|
||||
.await
|
||||
.map_err(|_| CloudConfigBundleCacheError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
pub(super) enum CacheLoadStatus {
|
||||
#[error("Skipping cloud config bundle cache read because auth identity is incomplete.")]
|
||||
AuthIdentityIncomplete,
|
||||
#[error("Cloud config bundle cache file not found.")]
|
||||
CacheFileNotFound,
|
||||
#[error("Failed to read cloud config bundle cache: {0}.")]
|
||||
CacheReadFailed(String),
|
||||
#[error("Failed to parse cloud config bundle cache: {0}.")]
|
||||
CacheParseFailed(String),
|
||||
#[error("Cloud config bundle cache failed signature verification.")]
|
||||
CacheSignatureInvalid,
|
||||
#[error("Ignoring cloud config bundle cache because cached identity is incomplete.")]
|
||||
CacheIdentityIncomplete,
|
||||
#[error("Ignoring cloud config bundle cache for different auth identity.")]
|
||||
CacheIdentityMismatch,
|
||||
#[error("Ignoring cloud config bundle cache with unsupported version {0}.")]
|
||||
CacheVersionUnsupported(u32),
|
||||
#[error("Cloud config bundle cache expired.")]
|
||||
CacheExpired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("failed to write cloud config bundle cache")]
|
||||
pub(super) struct CloudConfigBundleCacheError;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub(super) struct CloudConfigBundleCacheFile {
|
||||
pub(super) signed_payload: CloudConfigBundleCacheSignedPayload,
|
||||
pub(super) signature: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub(super) struct CloudConfigBundleCacheSignedPayload {
|
||||
pub(super) version: u32,
|
||||
pub(super) cached_at: DateTime<Utc>,
|
||||
pub(super) expires_at: DateTime<Utc>,
|
||||
pub(super) chatgpt_user_id: Option<String>,
|
||||
pub(super) account_id: Option<String>,
|
||||
pub(super) bundle: CloudConfigBundle,
|
||||
}
|
||||
|
||||
pub(super) fn cache_payload_bytes(
|
||||
payload: &CloudConfigBundleCacheSignedPayload,
|
||||
) -> Option<Vec<u8>> {
|
||||
serde_json::to_vec(&payload).ok()
|
||||
}
|
||||
|
||||
pub(super) fn sign_cache_payload(payload_bytes: &[u8]) -> Option<String> {
|
||||
let mut mac = HmacSha256::new_from_slice(CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY).ok()?;
|
||||
mac.update(payload_bytes);
|
||||
let signature = mac.finalize().into_bytes();
|
||||
Some(BASE64_STANDARD.encode(signature))
|
||||
}
|
||||
|
||||
pub(super) fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool {
|
||||
let signature_bytes = match BASE64_STANDARD.decode(signature) {
|
||||
Ok(signature_bytes) => signature_bytes,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS
|
||||
.iter()
|
||||
.any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key))
|
||||
}
|
||||
|
||||
fn verify_cache_signature_with_key(
|
||||
payload_bytes: &[u8],
|
||||
signature_bytes: &[u8],
|
||||
key: &[u8],
|
||||
) -> bool {
|
||||
let mut mac = match HmacSha256::new_from_slice(key) {
|
||||
Ok(mac) => mac,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(payload_bytes);
|
||||
mac.verify_slice(signature_bytes).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_config::CloudConfigFragment;
|
||||
use codex_config::CloudConfigTomlBundle;
|
||||
use codex_config::CloudRequirementsFragment;
|
||||
use codex_config::CloudRequirementsTomlBundle;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn test_bundle() -> CloudConfigBundle {
|
||||
CloudConfigBundle {
|
||||
config_toml: CloudConfigTomlBundle {
|
||||
enterprise_managed: vec![CloudConfigFragment {
|
||||
id: "cfg_1".to_string(),
|
||||
name: "Base config".to_string(),
|
||||
contents: "model = \"gpt-5\"".to_string(),
|
||||
}],
|
||||
},
|
||||
requirements_toml: CloudRequirementsTomlBundle {
|
||||
enterprise_managed: vec![CloudRequirementsFragment {
|
||||
id: "req_1".to_string(),
|
||||
name: "Base requirements".to_string(),
|
||||
contents: "allowed_approval_policies = [\"never\"]".to_string(),
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_cache_file(
|
||||
signed_payload: CloudConfigBundleCacheSignedPayload,
|
||||
) -> CloudConfigBundleCacheFile {
|
||||
let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes");
|
||||
CloudConfigBundleCacheFile {
|
||||
signature: sign_cache_payload(&payload_bytes).expect("signature"),
|
||||
signed_payload,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_signed_payload() -> CloudConfigBundleCacheSignedPayload {
|
||||
let cached_at = Utc::now();
|
||||
CloudConfigBundleCacheSignedPayload {
|
||||
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
|
||||
cached_at,
|
||||
expires_at: cached_at + ChronoDuration::minutes(30),
|
||||
chatgpt_user_id: Some("user-12345".to_string()),
|
||||
account_id: Some("account-12345".to_string()),
|
||||
bundle: test_bundle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_cache_file(cache: &CloudConfigBundleCache, cache_file: &CloudConfigBundleCacheFile) {
|
||||
std::fs::write(
|
||||
cache.path(),
|
||||
serde_json::to_vec_pretty(cache_file).expect("serialize cache"),
|
||||
)
|
||||
.expect("write cache");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_writes_signed_payload_and_loads_for_matching_identity() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = CloudConfigBundleCache::new(codex_home.path().to_path_buf());
|
||||
let bundle = test_bundle();
|
||||
|
||||
cache
|
||||
.save(
|
||||
Some("user-12345".to_string()),
|
||||
Some("account-12345".to_string()),
|
||||
bundle.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("save cache");
|
||||
|
||||
let cache_file: CloudConfigBundleCacheFile =
|
||||
serde_json::from_slice(&std::fs::read(cache.path()).expect("read cache"))
|
||||
.expect("parse cache");
|
||||
assert_eq!(cache_file.signed_payload.version, 1);
|
||||
assert_eq!(
|
||||
cache_file.signed_payload.chatgpt_user_id,
|
||||
Some("user-12345".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
cache_file.signed_payload.account_id,
|
||||
Some("account-12345".to_string())
|
||||
);
|
||||
assert_eq!(cache_file.signed_payload.bundle, bundle);
|
||||
assert!(
|
||||
cache_file.signed_payload.expires_at
|
||||
<= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30)
|
||||
);
|
||||
assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at);
|
||||
assert!(verify_cache_signature(
|
||||
&cache_payload_bytes(&cache_file.signed_payload).expect("payload bytes"),
|
||||
&cache_file.signature,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Ok(cache_file.signed_payload)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_missing_request_identity_before_reading_cache_file() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = CloudConfigBundleCache::new(codex_home.path().to_path_buf());
|
||||
|
||||
assert_eq!(
|
||||
cache.load(None, Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::AuthIdentityIncomplete)
|
||||
);
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), None).await,
|
||||
Err(CacheLoadStatus::AuthIdentityIncomplete)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_reports_missing_and_malformed_cache_files() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = CloudConfigBundleCache::new(codex_home.path().to_path_buf());
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheFileNotFound)
|
||||
);
|
||||
|
||||
std::fs::write(cache.path(), "{").expect("write malformed cache");
|
||||
assert!(matches!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheParseFailed(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_tampered_payload() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = CloudConfigBundleCache::new(codex_home.path().to_path_buf());
|
||||
let mut cache_file = signed_cache_file(valid_signed_payload());
|
||||
cache_file
|
||||
.signed_payload
|
||||
.bundle
|
||||
.requirements_toml
|
||||
.enterprise_managed[0]
|
||||
.contents = "allowed_approval_policies = [\"on-request\"]".to_string();
|
||||
write_cache_file(&cache, &cache_file);
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheSignatureInvalid)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_cache_for_incomplete_or_different_identity() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = CloudConfigBundleCache::new(codex_home.path().to_path_buf());
|
||||
let cache_file = signed_cache_file(valid_signed_payload());
|
||||
write_cache_file(&cache, &cache_file);
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-99999"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheIdentityMismatch)
|
||||
);
|
||||
|
||||
let mut signed_payload = valid_signed_payload();
|
||||
signed_payload.chatgpt_user_id = None;
|
||||
write_cache_file(&cache, &signed_cache_file(signed_payload));
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheIdentityIncomplete)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_expired_cache() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = CloudConfigBundleCache::new(codex_home.path().to_path_buf());
|
||||
let mut signed_payload = valid_signed_payload();
|
||||
signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1);
|
||||
write_cache_file(&cache, &signed_cache_file(signed_payload));
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheExpired)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_unsupported_cache_version() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = CloudConfigBundleCache::new(codex_home.path().to_path_buf());
|
||||
let mut signed_payload = valid_signed_payload();
|
||||
signed_payload.version = 2;
|
||||
write_cache_file(&cache, &signed_cache_file(signed_payload));
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheVersionUnsupported(2))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Cloud-hosted configuration data for Codex.
|
||||
//!
|
||||
//! This crate owns transport, caching, and refresh behavior for cloud-delivered
|
||||
//! config data. Parsing and composition remain in `codex-config`.
|
||||
|
||||
mod cache;
|
||||
mod loader;
|
||||
|
||||
pub use loader::cloud_config_bundle_loader;
|
||||
pub use loader::cloud_config_bundle_loader_for_storage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
use crate::CloudConfigFragment;
|
||||
use crate::CloudRequirementsFragment;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::future::FutureExt;
|
||||
use futures::future::Shared;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CloudConfigBundle {
|
||||
pub config_toml: CloudConfigTomlBundle,
|
||||
pub requirements_toml: CloudRequirementsTomlBundle,
|
||||
}
|
||||
|
||||
impl CloudConfigBundle {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.config_toml.enterprise_managed.is_empty()
|
||||
&& self.requirements_toml.enterprise_managed.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CloudConfigTomlBundle {
|
||||
pub enterprise_managed: Vec<CloudConfigFragment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CloudRequirementsTomlBundle {
|
||||
pub enterprise_managed: Vec<CloudRequirementsFragment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CloudConfigBundleLoadErrorCode {
|
||||
Auth,
|
||||
Timeout,
|
||||
RequestFailed,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
#[error("{message}")]
|
||||
pub struct CloudConfigBundleLoadError {
|
||||
code: CloudConfigBundleLoadErrorCode,
|
||||
message: String,
|
||||
status_code: Option<u16>,
|
||||
}
|
||||
|
||||
impl CloudConfigBundleLoadError {
|
||||
pub fn new(
|
||||
code: CloudConfigBundleLoadErrorCode,
|
||||
status_code: Option<u16>,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
status_code,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code(&self) -> CloudConfigBundleLoadErrorCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn status_code(&self) -> Option<u16> {
|
||||
self.status_code
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CloudConfigBundleLoader {
|
||||
fut: Shared<BoxFuture<'static, Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError>>>,
|
||||
}
|
||||
|
||||
impl CloudConfigBundleLoader {
|
||||
pub fn new<F>(fut: F) -> Self
|
||||
where
|
||||
F: Future<Output = Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
{
|
||||
Self {
|
||||
fut: fut.boxed().shared(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
self.fut.clone().await
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CloudConfigBundleLoader {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CloudConfigBundleLoader").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CloudConfigBundleLoader {
|
||||
fn default() -> Self {
|
||||
Self::new(async { Ok(None) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_future_runs_once() {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_clone = Arc::clone(&counter);
|
||||
let loader = CloudConfigBundleLoader::new(async move {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Some(CloudConfigBundle::default()))
|
||||
});
|
||||
|
||||
let (first, second) = tokio::join!(loader.get(), loader.get());
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ use crate::ConfigLayerSource;
|
||||
use crate::TomlValue;
|
||||
use crate::loader::resolve_relative_paths_in_config_toml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
@@ -12,7 +14,7 @@ use thiserror::Error;
|
||||
/// The bundle orders fragments from highest precedence to lowest precedence.
|
||||
/// This module returns config layers in stack order, so callers can append the
|
||||
/// result between system and user config without re-sorting.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CloudConfigFragment {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::RequirementSource;
|
||||
use crate::RequirementsExecPolicyToml;
|
||||
use crate::Sourced;
|
||||
use crate::config_requirements::merge_app_requirements_descending;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -27,7 +29,7 @@ mod permissions;
|
||||
// ConfigRequirementsToml so adding a new requirements field forces an explicit
|
||||
// merge policy decision.
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CloudRequirementsFragment {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod cloud_config_bundle;
|
||||
mod cloud_config_layers;
|
||||
mod cloud_requirements;
|
||||
mod cloud_requirements_composition;
|
||||
@@ -30,6 +31,12 @@ pub mod types;
|
||||
|
||||
pub const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
pub use cloud_config_bundle::CloudConfigBundle;
|
||||
pub use cloud_config_bundle::CloudConfigBundleLoadError;
|
||||
pub use cloud_config_bundle::CloudConfigBundleLoadErrorCode;
|
||||
pub use cloud_config_bundle::CloudConfigBundleLoader;
|
||||
pub use cloud_config_bundle::CloudConfigTomlBundle;
|
||||
pub use cloud_config_bundle::CloudRequirementsTomlBundle;
|
||||
pub use cloud_config_layers::CloudConfigFragment;
|
||||
pub use cloud_config_layers::CloudConfigFragmentSource;
|
||||
pub use cloud_config_layers::CloudConfigLayerError;
|
||||
|
||||
Reference in New Issue
Block a user