mirror of
https://github.com/openai/codex.git
synced 2026-06-03 20:02:10 +00:00
Compare commits
8 Commits
codemode_i
...
codemode_i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49a78e5d80 | ||
|
|
bec21c7114 | ||
|
|
f752b25fc4 | ||
|
|
c6d76750e8 | ||
|
|
d55e5a9bde | ||
|
|
68e2c8ed69 | ||
|
|
e7039f9844 | ||
|
|
f6d64bd6ab |
16
.github/workflows/rust-release-windows.yml
vendored
16
.github/workflows/rust-release-windows.yml
vendored
@@ -6,6 +6,19 @@ on:
|
||||
release-lto:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
AZURE_TRUSTED_SIGNING_CLIENT_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_TENANT_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME:
|
||||
required: true
|
||||
|
||||
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
|
||||
# nested submodules. Prefer the system git CLI across every Cargo invocation.
|
||||
@@ -151,9 +164,6 @@ jobs:
|
||||
- build-windows-binaries
|
||||
name: Build - ${{ matrix.runner }} - ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
environment:
|
||||
name: azure-artifact-signing
|
||||
deployment: false
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
1
.github/workflows/rust-release.yml
vendored
1
.github/workflows/rust-release.yml
vendored
@@ -865,6 +865,7 @@ jobs:
|
||||
uses: ./.github/workflows/rust-release-windows.yml
|
||||
with:
|
||||
release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }}
|
||||
secrets: inherit
|
||||
|
||||
argument-comment-lint-release-assets:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }}
|
||||
|
||||
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2347,7 +2347,6 @@ dependencies = [
|
||||
name = "codex-cloud-config"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"codex-backend-client",
|
||||
|
||||
@@ -30,6 +30,12 @@ use wiremock::matchers::path;
|
||||
|
||||
const RESULT: &str = "cG5n";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ImagegenTestMode {
|
||||
Direct,
|
||||
CodeModeOnly,
|
||||
}
|
||||
|
||||
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
|
||||
// subprocesses or processing test RPCs under load.
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
@@ -69,7 +75,7 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
create_config_toml(codex_home.path(), &server.uri(), ImagegenTestMode::Direct)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt"),
|
||||
@@ -79,34 +85,7 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
|
||||
let mut mcp =
|
||||
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams::default())
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
client_user_message_id: None,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Generate an image".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
start_image_generation_turn(&mut mcp).await?;
|
||||
|
||||
let completed = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
@@ -157,6 +136,153 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_image_generation_is_exposed_in_code_mode_only() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let response_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
ImagegenTestMode::CodeModeOnly,
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp =
|
||||
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
start_image_generation_turn(&mut mcp).await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert!(
|
||||
response_mock
|
||||
.single_request()
|
||||
.body_contains_text("image_gen__imagegen")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn standalone_image_generation_is_callable_from_code_mode_only() -> Result<()> {
|
||||
let call_id = "code-mode-image-run-1";
|
||||
let server = responses::start_mock_server().await;
|
||||
mount_image_response(&server).await;
|
||||
|
||||
let response_mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_custom_tool_call(
|
||||
call_id,
|
||||
"exec",
|
||||
r#"
|
||||
const result = await tools.image_gen__imagegen({
|
||||
action: "generate",
|
||||
prompt: "paint a blue whale",
|
||||
});
|
||||
image(result);
|
||||
"#,
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
ImagegenTestMode::CodeModeOnly,
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp =
|
||||
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
start_image_generation_turn(&mut mcp).await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let requests = response_mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(requests[0].body_contains_text("image_gen__imagegen"));
|
||||
let output = requests[1].custom_tool_call_output(call_id);
|
||||
assert_eq!(
|
||||
output["output"][1],
|
||||
json!({
|
||||
"type": "input_image",
|
||||
"image_url": format!("data:image/png;base64,{RESULT}"),
|
||||
"detail": "high",
|
||||
})
|
||||
);
|
||||
assert_eq!(output["output"].as_array().map(Vec::len), Some(2));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_image_generation_turn(mcp: &mut TestAppServer) -> Result<()> {
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams::default())
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
client_user_message_id: None,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Generate an image".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_image_generation_completed(
|
||||
mcp: &mut TestAppServer,
|
||||
) -> Result<ItemCompletedNotification> {
|
||||
@@ -187,7 +313,15 @@ async fn mount_image_response(server: &MockServer) {
|
||||
.await;
|
||||
}
|
||||
|
||||
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
fn create_config_toml(
|
||||
codex_home: &Path,
|
||||
server_uri: &str,
|
||||
mode: ImagegenTestMode,
|
||||
) -> std::io::Result<()> {
|
||||
let code_mode_only = match mode {
|
||||
ImagegenTestMode::Direct => "",
|
||||
ImagegenTestMode::CodeModeOnly => "code_mode_only = true",
|
||||
};
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!(
|
||||
@@ -200,6 +334,7 @@ chatgpt_base_url = "{server_uri}"
|
||||
|
||||
[features]
|
||||
imagegenext = true
|
||||
{code_mode_only}
|
||||
|
||||
[model_providers.openai-custom]
|
||||
name = "OpenAI"
|
||||
@@ -17,7 +17,7 @@ mod experimental_feature_list;
|
||||
mod external_agent_config;
|
||||
mod fs;
|
||||
mod hooks_list;
|
||||
mod image_generation;
|
||||
mod imagegen_extension;
|
||||
mod initialize;
|
||||
mod marketplace_add;
|
||||
mod marketplace_remove;
|
||||
|
||||
@@ -8,7 +8,6 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
codex-backend-client = { workspace = true }
|
||||
|
||||
136
codex-rs/cloud-config/src/backend.rs
Normal file
136
codex-rs/cloud-config/src/backend.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_backend_client::ConfigBundleResponse;
|
||||
use codex_backend_client::DeliveredTomlFragment;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use codex_config::CloudConfigFragment;
|
||||
use codex_config::CloudConfigTomlBundle;
|
||||
use codex_config::CloudRequirementsFragment;
|
||||
use codex_config::CloudRequirementsTomlBundle;
|
||||
use codex_login::CodexAuth;
|
||||
use std::future::Future;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum RetryableFailureKind {
|
||||
BackendClientInit,
|
||||
Request { status_code: Option<u16> },
|
||||
}
|
||||
|
||||
impl RetryableFailureKind {
|
||||
pub(crate) fn status_code(self) -> Option<u16> {
|
||||
match self {
|
||||
Self::BackendClientInit => None,
|
||||
Self::Request { status_code } => status_code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum BundleRequestError {
|
||||
Retryable(RetryableFailureKind),
|
||||
Unauthorized {
|
||||
status_code: Option<u16>,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Retrieves one cloud config bundle from the backend.
|
||||
///
|
||||
/// Implementations should return the backend-selected bundle exactly as delivered and leave
|
||||
/// validation, caching, and config/requirements parsing decisions to the service layer.
|
||||
pub(crate) trait BundleClient: Send + Sync {
|
||||
fn get_bundle(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
) -> impl Future<Output = Result<CloudConfigBundle, BundleRequestError>> + Send;
|
||||
}
|
||||
|
||||
pub(crate) struct BackendBundleClient {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl BackendBundleClient {
|
||||
pub(crate) fn new(base_url: String) -> Self {
|
||||
Self { base_url }
|
||||
}
|
||||
}
|
||||
|
||||
impl BundleClient for BackendBundleClient {
|
||||
async fn get_bundle(&self, auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
|
||||
let client = BackendClient::from_auth(self.base_url.clone(), auth)
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Failed to construct backend client for cloud config bundle"
|
||||
);
|
||||
})
|
||||
.map_err(|_| BundleRequestError::Retryable(RetryableFailureKind::BackendClientInit))?;
|
||||
|
||||
let response = client
|
||||
.get_config_bundle()
|
||||
.await
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(error = %err, "Failed to fetch cloud config bundle");
|
||||
})
|
||||
.map_err(|err| {
|
||||
let status_code = err.status().map(|status| status.as_u16());
|
||||
if err.is_unauthorized() {
|
||||
BundleRequestError::Unauthorized {
|
||||
status_code,
|
||||
message: err.to_string(),
|
||||
}
|
||||
} else {
|
||||
BundleRequestError::Retryable(RetryableFailureKind::Request { status_code })
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(bundle_from_response(response))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle {
|
||||
let config_toml = response
|
||||
.config_toml
|
||||
.flatten()
|
||||
.map(|config_toml| *config_toml)
|
||||
.and_then(|config_toml| config_toml.enterprise_managed.flatten())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(config_fragment_from_delivered)
|
||||
.collect();
|
||||
let requirements_toml = response
|
||||
.requirements_toml
|
||||
.flatten()
|
||||
.map(|requirements_toml| *requirements_toml)
|
||||
.and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(requirements_fragment_from_delivered)
|
||||
.collect();
|
||||
|
||||
CloudConfigBundle {
|
||||
config_toml: CloudConfigTomlBundle {
|
||||
enterprise_managed: config_toml,
|
||||
},
|
||||
requirements_toml: CloudRequirementsTomlBundle {
|
||||
enterprise_managed: requirements_toml,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment {
|
||||
CloudConfigFragment {
|
||||
id: fragment.id,
|
||||
name: fragment.name,
|
||||
contents: fragment.contents,
|
||||
}
|
||||
}
|
||||
|
||||
fn requirements_fragment_from_delivered(
|
||||
fragment: DeliveredTomlFragment,
|
||||
) -> CloudRequirementsFragment {
|
||||
CloudRequirementsFragment {
|
||||
id: fragment.id,
|
||||
name: fragment.name,
|
||||
contents: fragment.contents,
|
||||
}
|
||||
}
|
||||
68
codex-rs/cloud-config/src/bundle_loader.rs
Normal file
68
codex-rs/cloud-config/src/bundle_loader.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use crate::backend::BackendBundleClient;
|
||||
use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT;
|
||||
use crate::service::CloudConfigBundleService;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::AuthManager;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
fn refresher_task_slot() -> &'static Mutex<Option<JoinHandle<()>>> {
|
||||
static REFRESHER_TASK: OnceLock<Mutex<Option<JoinHandle<()>>>> = OnceLock::new();
|
||||
REFRESHER_TASK.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
pub fn cloud_config_bundle_loader(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
chatgpt_base_url: String,
|
||||
codex_home: PathBuf,
|
||||
) -> CloudConfigBundleLoader {
|
||||
let service = CloudConfigBundleService::new(
|
||||
auth_manager,
|
||||
Arc::new(BackendBundleClient::new(chatgpt_base_url)),
|
||||
codex_home,
|
||||
CLOUD_CONFIG_BUNDLE_TIMEOUT,
|
||||
);
|
||||
let refresh_service = service.clone();
|
||||
let task = tokio::spawn(async move { service.load_startup_bundle_with_timeout().await });
|
||||
let refresh_task =
|
||||
tokio::spawn(async move { refresh_service.refresh_cache_in_background().await });
|
||||
let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| {
|
||||
tracing::warn!("cloud config bundle refresher task slot was poisoned");
|
||||
err.into_inner()
|
||||
});
|
||||
if let Some(existing_task) = refresher_guard.replace(refresh_task) {
|
||||
existing_task.abort();
|
||||
}
|
||||
CloudConfigBundleLoader::new(async move {
|
||||
task.await.map_err(|err| {
|
||||
tracing::error!(error = %err, "Cloud config bundle task failed");
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Internal,
|
||||
/*status_code*/ None,
|
||||
format!("cloud config bundle load failed: {err}"),
|
||||
)
|
||||
})?
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn cloud_config_bundle_loader_for_storage(
|
||||
codex_home: PathBuf,
|
||||
enable_codex_api_key_env: bool,
|
||||
credentials_store_mode: AuthCredentialsStoreMode,
|
||||
chatgpt_base_url: String,
|
||||
) -> CloudConfigBundleLoader {
|
||||
let auth_manager = AuthManager::shared(
|
||||
codex_home.clone(),
|
||||
enable_codex_api_key_env,
|
||||
credentials_store_mode,
|
||||
Some(chatgpt_base_url.clone()),
|
||||
)
|
||||
.await;
|
||||
cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home)
|
||||
}
|
||||
253
codex-rs/cloud-config/src/cache.rs
Normal file
253
codex-rs/cloud-config/src/cache.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
//! Signed on-disk cache for cloud config bundles.
|
||||
//!
|
||||
//! The cache is scoped to the authenticated ChatGPT user and account, has a
|
||||
//! short TTL, and is HMAC-signed so malformed or edited files fail closed.
|
||||
|
||||
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::AbsolutePathBuf;
|
||||
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::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: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl CloudConfigBundleCache {
|
||||
pub(super) fn new(codex_home: AbsolutePathBuf) -> 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,
|
||||
#[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")]
|
||||
CacheInvalidBundle,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
#[path = "cache_tests.rs"]
|
||||
mod tests;
|
||||
206
codex-rs/cloud-config/src/cache_tests.rs
Normal file
206
codex-rs/cloud-config/src/cache_tests.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use super::*;
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::CloudConfigFragment;
|
||||
use codex_config::CloudConfigTomlBundle;
|
||||
use codex_config::CloudRequirementsFragment;
|
||||
use codex_config::CloudRequirementsTomlBundle;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
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");
|
||||
}
|
||||
|
||||
fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache {
|
||||
CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_writes_signed_payload_and_loads_for_matching_identity() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
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!(
|
||||
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_eq!(
|
||||
cache_file,
|
||||
signed_cache_file(CloudConfigBundleCacheSignedPayload {
|
||||
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
|
||||
cached_at: cache_file.signed_payload.cached_at,
|
||||
expires_at: cache_file.signed_payload.expires_at,
|
||||
chatgpt_user_id: Some("user-12345".to_string()),
|
||||
account_id: Some("account-12345".to_string()),
|
||||
bundle,
|
||||
})
|
||||
);
|
||||
|
||||
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 = create_test_cache(codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
cache
|
||||
.load(/*chatgpt_user_id*/ None, Some("account-12345"))
|
||||
.await,
|
||||
Err(CacheLoadStatus::AuthIdentityIncomplete)
|
||||
);
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), /*account_id*/ None).await,
|
||||
Err(CacheLoadStatus::AuthIdentityIncomplete)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_reports_missing_and_malformed_cache_files() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
|
||||
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 = create_test_cache(codex_home.path());
|
||||
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 = create_test_cache(codex_home.path());
|
||||
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 = create_test_cache(codex_home.path());
|
||||
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 = create_test_cache(codex_home.path());
|
||||
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))
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
95
codex-rs/cloud-config/src/metrics.rs
Normal file
95
codex-rs/cloud-config/src/metrics.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use codex_config::CloudConfigBundle;
|
||||
|
||||
const CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt";
|
||||
const CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final";
|
||||
const CLOUD_CONFIG_BUNDLE_LOAD_METRIC: &str = "codex.cloud_config_bundle.load";
|
||||
|
||||
pub(crate) fn emit_fetch_attempt_metric(
|
||||
trigger: &str,
|
||||
attempt: usize,
|
||||
outcome: &str,
|
||||
status_code: Option<u16>,
|
||||
) {
|
||||
let attempt_tag = attempt.to_string();
|
||||
let status_code_tag = status_code_tag(status_code);
|
||||
emit_metric(
|
||||
CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC,
|
||||
vec![
|
||||
("trigger", trigger.to_string()),
|
||||
("attempt", attempt_tag),
|
||||
("outcome", outcome.to_string()),
|
||||
("status_code", status_code_tag),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_fetch_final_metric(
|
||||
trigger: &str,
|
||||
outcome: &str,
|
||||
reason: &str,
|
||||
attempt_count: usize,
|
||||
status_code: Option<u16>,
|
||||
bundle: Option<&CloudConfigBundle>,
|
||||
) {
|
||||
let attempt_count_tag = attempt_count.to_string();
|
||||
let status_code_tag = status_code_tag(status_code);
|
||||
emit_metric(
|
||||
CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC,
|
||||
vec![
|
||||
("trigger", trigger.to_string()),
|
||||
("outcome", outcome.to_string()),
|
||||
("reason", reason.to_string()),
|
||||
("attempt_count", attempt_count_tag),
|
||||
("status_code", status_code_tag),
|
||||
("bundle_shape", bundle_shape_tag(bundle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) {
|
||||
emit_metric(
|
||||
CLOUD_CONFIG_BUNDLE_LOAD_METRIC,
|
||||
vec![
|
||||
("trigger", trigger.to_string()),
|
||||
("outcome", outcome.to_string()),
|
||||
("bundle_shape", bundle_shape_tag(bundle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String {
|
||||
let Some(bundle) = bundle else {
|
||||
return "none".to_string();
|
||||
};
|
||||
|
||||
let mut sources = Vec::new();
|
||||
if !bundle.config_toml.enterprise_managed.is_empty() {
|
||||
sources.push("enterprise_config");
|
||||
}
|
||||
if !bundle.requirements_toml.enterprise_managed.is_empty() {
|
||||
sources.push("enterprise_requirements");
|
||||
}
|
||||
|
||||
if sources.is_empty() {
|
||||
"empty".to_string()
|
||||
} else {
|
||||
sources.sort_unstable();
|
||||
sources.join(",")
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code_tag(status_code: Option<u16>) -> String {
|
||||
status_code
|
||||
.map(|status_code| status_code.to_string())
|
||||
.unwrap_or_else(|| "none".to_string())
|
||||
}
|
||||
|
||||
fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) {
|
||||
if let Some(metrics) = codex_otel::global() {
|
||||
let tag_refs = tags
|
||||
.iter()
|
||||
.map(|(key, value)| (*key, value.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs);
|
||||
}
|
||||
}
|
||||
500
codex-rs/cloud-config/src/service.rs
Normal file
500
codex-rs/cloud-config/src/service.rs
Normal file
@@ -0,0 +1,500 @@
|
||||
//! Cloud config bundle lifecycle orchestration.
|
||||
//!
|
||||
//! Startup loads a single shared bundle from cache or backend, and a background
|
||||
//! refresher keeps the cache warm for future app starts without changing the
|
||||
//! already-snapshotted runtime config.
|
||||
|
||||
use crate::backend::BundleClient;
|
||||
use crate::backend::BundleRequestError;
|
||||
use crate::backend::RetryableFailureKind;
|
||||
use crate::cache::CacheLoadStatus;
|
||||
use crate::cache::CloudConfigBundleCache;
|
||||
use crate::metrics::emit_fetch_attempt_metric;
|
||||
use crate::metrics::emit_fetch_final_metric;
|
||||
use crate::metrics::emit_load_metric;
|
||||
use crate::validation::validate_bundle;
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_core::util::backoff;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::RefreshTokenError;
|
||||
use codex_login::UnauthorizedRecovery;
|
||||
use codex_protocol::account::PlanType;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub(crate) const CLOUD_CONFIG_BUNDLE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS: usize = 5;
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE: &str =
|
||||
"Failed to load cloud config bundle (workspace-managed policies).";
|
||||
const CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!(
|
||||
"Your authentication session could not be refreshed automatically. ",
|
||||
"Please log out and sign in again."
|
||||
);
|
||||
|
||||
fn auth_identity(auth: &CodexAuth) -> (Option<String>, Option<String>) {
|
||||
(auth.get_chatgpt_user_id(), auth.get_account_id())
|
||||
}
|
||||
|
||||
fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool {
|
||||
let Some(plan_type) = auth.account_plan_type() else {
|
||||
return false;
|
||||
};
|
||||
auth.uses_codex_backend()
|
||||
&& (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
|
||||
}
|
||||
|
||||
fn optional_bundle(bundle: CloudConfigBundle) -> Option<CloudConfigBundle> {
|
||||
if bundle.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
enum CachedBundleLookup {
|
||||
Hit(Option<CloudConfigBundle>),
|
||||
Miss,
|
||||
}
|
||||
|
||||
enum UnauthorizedRecoveryAction {
|
||||
RetrySameAttempt,
|
||||
RetryNextAttempt,
|
||||
}
|
||||
|
||||
pub(crate) struct CloudConfigBundleService<C> {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
client: Arc<C>,
|
||||
cache: CloudConfigBundleCache,
|
||||
codex_home: AbsolutePathBuf,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl<C> Clone for CloudConfigBundleService<C> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
auth_manager: Arc::clone(&self.auth_manager),
|
||||
client: Arc::clone(&self.client),
|
||||
cache: self.cache.clone(),
|
||||
codex_home: self.codex_home.clone(),
|
||||
timeout: self.timeout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> CloudConfigBundleService<C>
|
||||
where
|
||||
C: BundleClient + 'static,
|
||||
{
|
||||
pub(crate) fn new(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
client: Arc<C>,
|
||||
codex_home: PathBuf,
|
||||
timeout: Duration,
|
||||
) -> Self {
|
||||
let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/");
|
||||
Self {
|
||||
auth_manager,
|
||||
client,
|
||||
cache: CloudConfigBundleCache::new(codex_home.clone()),
|
||||
codex_home,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_startup_bundle_with_timeout(
|
||||
&self,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
let _timer =
|
||||
codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]);
|
||||
let started_at = Instant::now();
|
||||
let load_result = timeout(self.timeout, self.load_startup_bundle())
|
||||
.await
|
||||
.inspect_err(|_| {
|
||||
let message = format!(
|
||||
"Timed out waiting for cloud config bundle after {}s",
|
||||
self.timeout.as_secs()
|
||||
);
|
||||
tracing::error!("{message}");
|
||||
emit_load_metric("startup", "error", /*bundle*/ None);
|
||||
})
|
||||
.map_err(|_| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Timeout,
|
||||
/*status_code*/ None,
|
||||
format!(
|
||||
"timed out waiting for cloud config bundle after {}s",
|
||||
self.timeout.as_secs()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = match load_result {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
emit_load_metric("startup", "error", /*bundle*/ None);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
match result.as_ref() {
|
||||
Some(bundle) => {
|
||||
tracing::info!(
|
||||
elapsed_ms = started_at.elapsed().as_millis(),
|
||||
config_fragments = bundle.config_toml.enterprise_managed.len(),
|
||||
requirements_fragments = bundle.requirements_toml.enterprise_managed.len(),
|
||||
"Cloud config bundle load completed"
|
||||
);
|
||||
emit_load_metric("startup", "success", Some(bundle));
|
||||
}
|
||||
None => {
|
||||
tracing::info!(
|
||||
elapsed_ms = started_at.elapsed().as_millis(),
|
||||
"Cloud config bundle load completed (none)"
|
||||
);
|
||||
emit_load_metric("startup", "success", /*bundle*/ None);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn load_startup_bundle(
|
||||
&self,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !cloud_config_eligible_auth(&auth) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Startup prefers a valid, identity-matched cache entry. The backend is
|
||||
// only consulted on cache miss or invalid cache contents.
|
||||
let (chatgpt_user_id, account_id) = auth_identity(&auth);
|
||||
match self
|
||||
.load_valid_cached_bundle(chatgpt_user_id.as_deref(), account_id.as_deref())
|
||||
.await
|
||||
{
|
||||
CachedBundleLookup::Hit(bundle) => return Ok(bundle),
|
||||
CachedBundleLookup::Miss => {}
|
||||
}
|
||||
|
||||
self.fetch_remote_bundle_and_update_cache_with_retries(auth, "startup")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_valid_cached_bundle(
|
||||
&self,
|
||||
chatgpt_user_id: Option<&str>,
|
||||
account_id: Option<&str>,
|
||||
) -> CachedBundleLookup {
|
||||
match self.cache.load(chatgpt_user_id, account_id).await {
|
||||
Ok(signed_payload) => {
|
||||
if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) {
|
||||
tracing::warn!(
|
||||
path = %self.cache.path().display(),
|
||||
error = %err,
|
||||
"Ignoring invalid cached cloud config bundle"
|
||||
);
|
||||
self.cache
|
||||
.log_load_status(&CacheLoadStatus::CacheInvalidBundle);
|
||||
CachedBundleLookup::Miss
|
||||
} else {
|
||||
tracing::info!(
|
||||
path = %self.cache.path().display(),
|
||||
"Using cached cloud config bundle"
|
||||
);
|
||||
CachedBundleLookup::Hit(optional_bundle(signed_payload.bundle))
|
||||
}
|
||||
}
|
||||
Err(cache_load_status) => {
|
||||
self.cache.log_load_status(&cache_load_status);
|
||||
CachedBundleLookup::Miss
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_remote_bundle_and_update_cache_with_retries(
|
||||
&self,
|
||||
mut auth: CodexAuth,
|
||||
trigger: &'static str,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
let mut attempt = 1;
|
||||
let mut last_status_code: Option<u16> = None;
|
||||
let mut auth_recovery = self.auth_manager.unauthorized_recovery();
|
||||
|
||||
while attempt <= CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
|
||||
match self.client.get_bundle(&auth).await {
|
||||
Ok(bundle) => {
|
||||
return self
|
||||
.validate_and_cache_remote_bundle(&auth, trigger, attempt, bundle)
|
||||
.await;
|
||||
}
|
||||
Err(BundleRequestError::Retryable(status)) => {
|
||||
last_status_code = status.status_code();
|
||||
if self
|
||||
.retry_after_request_failure(trigger, attempt, status)
|
||||
.await
|
||||
{
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(BundleRequestError::Unauthorized {
|
||||
status_code,
|
||||
message,
|
||||
}) => {
|
||||
last_status_code = status_code;
|
||||
match self
|
||||
.handle_unauthorized(
|
||||
&mut auth,
|
||||
&mut auth_recovery,
|
||||
trigger,
|
||||
attempt,
|
||||
status_code,
|
||||
&message,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
UnauthorizedRecoveryAction::RetrySameAttempt => continue,
|
||||
UnauthorizedRecoveryAction::RetryNextAttempt => {
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"request_retry_exhausted",
|
||||
CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
last_status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
tracing::error!(
|
||||
path = %self.cache.path().display(),
|
||||
"{CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE}"
|
||||
);
|
||||
Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::RequestFailed,
|
||||
last_status_code,
|
||||
CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE,
|
||||
))
|
||||
}
|
||||
|
||||
async fn validate_and_cache_remote_bundle(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
trigger: &'static str,
|
||||
attempt: usize,
|
||||
bundle: CloudConfigBundle,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
emit_fetch_attempt_metric(trigger, attempt, "success", /*status_code*/ None);
|
||||
if let Err(err) = validate_bundle(&bundle, &self.codex_home) {
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"invalid_bundle",
|
||||
attempt,
|
||||
/*status_code*/ None,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let (chatgpt_user_id, account_id) = auth_identity(auth);
|
||||
if let Err(err) = self
|
||||
.cache
|
||||
.save(chatgpt_user_id, account_id, bundle.clone())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Failed to write cloud config bundle cache"
|
||||
);
|
||||
}
|
||||
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"success",
|
||||
"none",
|
||||
attempt,
|
||||
/*status_code*/ None,
|
||||
Some(&bundle),
|
||||
);
|
||||
Ok(optional_bundle(bundle))
|
||||
}
|
||||
|
||||
async fn retry_after_request_failure(
|
||||
&self,
|
||||
trigger: &'static str,
|
||||
attempt: usize,
|
||||
status: RetryableFailureKind,
|
||||
) -> bool {
|
||||
let status_code = status.status_code();
|
||||
emit_fetch_attempt_metric(trigger, attempt, "error", status_code);
|
||||
if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
|
||||
tracing::warn!(
|
||||
status = ?status,
|
||||
attempt,
|
||||
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
"Failed to fetch cloud config bundle; retrying"
|
||||
);
|
||||
sleep(backoff(attempt as u64)).await;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_unauthorized(
|
||||
&self,
|
||||
auth: &mut CodexAuth,
|
||||
auth_recovery: &mut UnauthorizedRecovery,
|
||||
trigger: &'static str,
|
||||
attempt: usize,
|
||||
status_code: Option<u16>,
|
||||
message: &str,
|
||||
) -> Result<UnauthorizedRecoveryAction, CloudConfigBundleLoadError> {
|
||||
emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code);
|
||||
if auth_recovery.has_next() {
|
||||
tracing::warn!(
|
||||
attempt,
|
||||
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
"Cloud config bundle request was unauthorized; attempting auth recovery"
|
||||
);
|
||||
match auth_recovery.next().await {
|
||||
Ok(_) => {
|
||||
let Some(refreshed_auth) = self.auth_manager.auth().await else {
|
||||
tracing::error!(
|
||||
"Auth recovery succeeded but no auth is available for cloud config bundle"
|
||||
);
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"auth_recovery_missing_auth",
|
||||
attempt,
|
||||
status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
return Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
status_code,
|
||||
CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
|
||||
));
|
||||
};
|
||||
*auth = refreshed_auth;
|
||||
return Ok(UnauthorizedRecoveryAction::RetrySameAttempt);
|
||||
}
|
||||
Err(RefreshTokenError::Permanent(failed)) => {
|
||||
tracing::warn!(
|
||||
error = %failed,
|
||||
"Failed to recover from unauthorized cloud config bundle request"
|
||||
);
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"auth_recovery_unrecoverable",
|
||||
attempt,
|
||||
status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
return Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
status_code,
|
||||
failed.message,
|
||||
));
|
||||
}
|
||||
Err(RefreshTokenError::Transient(recovery_err)) => {
|
||||
if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
|
||||
tracing::warn!(
|
||||
error = %recovery_err,
|
||||
attempt,
|
||||
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
"Failed to recover from unauthorized cloud config bundle request; retrying"
|
||||
);
|
||||
sleep(backoff(attempt as u64)).await;
|
||||
}
|
||||
return Ok(UnauthorizedRecoveryAction::RetryNextAttempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
error = %message,
|
||||
"Cloud config bundle request was unauthorized and no auth recovery is available"
|
||||
);
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"auth_recovery_unavailable",
|
||||
attempt,
|
||||
status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
status_code,
|
||||
CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_cache_in_background(&self) {
|
||||
loop {
|
||||
sleep(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL).await;
|
||||
match timeout(self.timeout, self.refresh_cache_once()).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => break,
|
||||
Err(_) => {
|
||||
tracing::error!(
|
||||
"Timed out refreshing cloud config bundle cache from remote; keeping existing cache"
|
||||
);
|
||||
emit_load_metric("refresh", "error", /*bundle*/ None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_cache_once(&self) -> bool {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return false;
|
||||
};
|
||||
if !cloud_config_eligible_auth(&auth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match self
|
||||
.fetch_remote_bundle_and_update_cache_with_retries(auth, "refresh")
|
||||
.await
|
||||
{
|
||||
Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()),
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
path = %self.cache.path().display(),
|
||||
error = %err,
|
||||
"Failed to refresh cloud config bundle cache from remote"
|
||||
);
|
||||
emit_load_metric("refresh", "error", /*bundle*/ None);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "service_tests.rs"]
|
||||
mod tests;
|
||||
1035
codex-rs/cloud-config/src/service_tests.rs
Normal file
1035
codex-rs/cloud-config/src/service_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
34
codex-rs/cloud-config/src/validation.rs
Normal file
34
codex-rs/cloud-config/src/validation.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use codex_config::CloudConfigBundleLayers;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_config::compose_requirements;
|
||||
|
||||
pub(crate) fn validate_bundle(
|
||||
bundle: &CloudConfigBundle,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Result<(), CloudConfigBundleLoadError> {
|
||||
let bundle_layers =
|
||||
CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::InvalidBundle,
|
||||
/*status_code*/ None,
|
||||
format!("invalid cloud config bundle: {err}"),
|
||||
)
|
||||
})?;
|
||||
let CloudConfigBundleLayers {
|
||||
enterprise_managed_config: _,
|
||||
enterprise_managed_requirements,
|
||||
} = bundle_layers;
|
||||
|
||||
compose_requirements(enterprise_managed_requirements).map_err(|err| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::InvalidBundle,
|
||||
/*status_code*/ None,
|
||||
format!("invalid cloud config bundle: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -724,16 +724,13 @@ impl ConfigToml {
|
||||
pub async fn derive_permission_profile(
|
||||
&self,
|
||||
sandbox_mode_override: Option<SandboxMode>,
|
||||
profile_sandbox_mode: Option<SandboxMode>,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
active_project: Option<&ProjectConfig>,
|
||||
permission_profile_constraint: Option<&crate::Constrained<PermissionProfile>>,
|
||||
) -> PermissionProfile {
|
||||
let sandbox_mode_was_explicit = sandbox_mode_override.is_some()
|
||||
|| profile_sandbox_mode.is_some()
|
||||
|| self.sandbox_mode.is_some();
|
||||
let sandbox_mode_was_explicit =
|
||||
sandbox_mode_override.is_some() || self.sandbox_mode.is_some();
|
||||
let resolved_sandbox_mode = sandbox_mode_override
|
||||
.or(profile_sandbox_mode)
|
||||
.or(self.sandbox_mode)
|
||||
.or(if sandbox_mode_was_explicit {
|
||||
None
|
||||
|
||||
@@ -32,7 +32,6 @@ use codex_config::permissions_toml::NetworkToml;
|
||||
use codex_config::permissions_toml::PermissionProfileToml;
|
||||
use codex_config::permissions_toml::PermissionsToml;
|
||||
use codex_config::permissions_toml::WorkspaceRootsToml;
|
||||
use codex_config::profile_toml::ConfigProfile;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::BundledSkillsConfig;
|
||||
@@ -165,7 +164,6 @@ fn http_mcp(url: &str) -> McpServerConfig {
|
||||
async fn derive_legacy_sandbox_policy_for_test(
|
||||
cfg: &ConfigToml,
|
||||
sandbox_mode_override: Option<SandboxMode>,
|
||||
profile_sandbox_mode: Option<SandboxMode>,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
active_project: Option<&ProjectConfig>,
|
||||
permission_profile_constraint: Option<&Constrained<PermissionProfile>>,
|
||||
@@ -173,7 +171,6 @@ async fn derive_legacy_sandbox_policy_for_test(
|
||||
let permission_profile = cfg
|
||||
.derive_permission_profile(
|
||||
sandbox_mode_override,
|
||||
profile_sandbox_mode,
|
||||
windows_sandbox_level,
|
||||
active_project,
|
||||
permission_profile_constraint,
|
||||
@@ -3530,7 +3527,6 @@ network_access = false # This should be ignored.
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&sandbox_full_access_cfg,
|
||||
sandbox_mode_override,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
/*active_project*/ None,
|
||||
/*permission_profile_constraint*/ None,
|
||||
@@ -3551,7 +3547,6 @@ network_access = true # This should be ignored.
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&sandbox_read_only_cfg,
|
||||
sandbox_mode_override,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
/*active_project*/ None,
|
||||
/*permission_profile_constraint*/ None,
|
||||
@@ -3583,7 +3578,6 @@ trust_level = "trusted"
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&sandbox_workspace_write_cfg,
|
||||
sandbox_mode_override,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
/*active_project*/ None,
|
||||
/*permission_profile_constraint*/ None,
|
||||
@@ -3623,7 +3617,6 @@ exclude_slash_tmp = true
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&sandbox_workspace_write_cfg,
|
||||
sandbox_mode_override,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
/*active_project*/ None,
|
||||
/*permission_profile_constraint*/ None,
|
||||
@@ -4998,38 +4991,6 @@ model = "gpt-project-local"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unselected_profile_sandbox_mode_is_ignored() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut profiles = HashMap::new();
|
||||
profiles.insert(
|
||||
"work".to_string(),
|
||||
ConfigProfile {
|
||||
sandbox_mode: Some(SandboxMode::DangerFullAccess),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let cfg = ConfigToml {
|
||||
profiles,
|
||||
sandbox_mode: Some(SandboxMode::ReadOnly),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.legacy_sandbox_policy(),
|
||||
SandboxPolicy::new_read_only_policy()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feature_table_overrides_legacy_flags() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -8587,7 +8548,6 @@ trust_level = "untrusted"
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&cfg,
|
||||
/*sandbox_mode_override*/ None,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
Some(&active_project),
|
||||
/*permission_profile_constraint*/ None,
|
||||
@@ -8644,7 +8604,6 @@ async fn derive_sandbox_policy_falls_back_to_read_only_for_implicit_defaults() -
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&cfg,
|
||||
/*sandbox_mode_override*/ None,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
Some(&active_project),
|
||||
Some(&constrained),
|
||||
@@ -8697,7 +8656,6 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&cfg,
|
||||
/*sandbox_mode_override*/ None,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
Some(&active_project),
|
||||
Some(&constrained),
|
||||
|
||||
@@ -2936,7 +2936,6 @@ impl Config {
|
||||
let mut permission_profile = cfg
|
||||
.derive_permission_profile(
|
||||
sandbox_mode,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
windows_sandbox_level,
|
||||
Some(&active_project),
|
||||
Some(&constrained_permission_profile),
|
||||
|
||||
@@ -20,7 +20,6 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
@@ -719,9 +718,7 @@ async fn run_review_on_session(
|
||||
.total_token_usage()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// The legacy SandboxPolicy should match the PermissionProfile.
|
||||
let guardian_permission_profile = PermissionProfile::read_only();
|
||||
let legacy_sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
|
||||
let submit_result = run_before_review_deadline(
|
||||
deadline,
|
||||
@@ -736,7 +733,7 @@ async fn run_review_on_session(
|
||||
#[allow(deprecated)]
|
||||
cwd: Some(params.parent_turn.cwd.to_path_buf()),
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: Some(legacy_sandbox_policy),
|
||||
sandbox_policy: None,
|
||||
permission_profile: Some(guardian_permission_profile),
|
||||
summary: Some(params.reasoning_summary),
|
||||
personality: params.personality,
|
||||
|
||||
@@ -210,15 +210,7 @@ fn build_model_visible_specs_and_registry(
|
||||
specs.push(spec_for_model_request(turn_context, exposure, spec));
|
||||
}
|
||||
}
|
||||
for spec in hosted_specs {
|
||||
if !is_hidden_by_code_mode_only(
|
||||
turn_context,
|
||||
&ToolName::plain(spec.name()),
|
||||
ToolExposure::Direct,
|
||||
) {
|
||||
specs.push(spec);
|
||||
}
|
||||
}
|
||||
specs.extend(hosted_specs);
|
||||
|
||||
let registry = ToolRegistry::from_tools(runtimes);
|
||||
let model_visible_specs = merge_into_namespaces(specs)
|
||||
@@ -267,6 +259,7 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
|
||||
}) {
|
||||
specs.push(web_search_tool);
|
||||
}
|
||||
// TODO: Remove hosted image generation once the standalone extension is ready.
|
||||
if image_generation_tool_enabled(turn_context)
|
||||
&& !standalone_image_generation_available(turn_context, context.extension_tool_executors)
|
||||
{
|
||||
|
||||
@@ -1167,7 +1167,16 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() {
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(plan.visible_names, vec!["exec", "wait", "agents"]);
|
||||
assert_eq!(
|
||||
plan.visible_names,
|
||||
vec![
|
||||
"exec",
|
||||
"wait",
|
||||
"agents",
|
||||
// Hosted Responses tools.
|
||||
"web_search",
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
!plan
|
||||
.namespace_function_names("agents")
|
||||
@@ -1235,6 +1244,32 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() {
|
||||
}
|
||||
);
|
||||
|
||||
let code_mode_only = probe(|turn| {
|
||||
use_chatgpt_auth(turn);
|
||||
set_features(turn, &[Feature::CodeModeOnly, Feature::MultiAgentV2]);
|
||||
set_web_search_mode(turn, WebSearchMode::Live);
|
||||
turn.model_info.input_modalities = vec![InputModality::Image];
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
code_mode_only.visible_names,
|
||||
vec![
|
||||
// Code-mode entrypoints.
|
||||
codex_code_mode::PUBLIC_TOOL_NAME,
|
||||
codex_code_mode::WAIT_TOOL_NAME,
|
||||
// Multi-agent v2 tools.
|
||||
"spawn_agent",
|
||||
"send_message",
|
||||
"followup_task",
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
"list_agents",
|
||||
// Hosted Responses tools.
|
||||
"web_search",
|
||||
"image_generation",
|
||||
]
|
||||
);
|
||||
|
||||
let standalone_web_search_without_web_run = probe(|turn| {
|
||||
set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true);
|
||||
set_web_search_mode(turn, WebSearchMode::Live);
|
||||
|
||||
@@ -5,6 +5,7 @@ use codex_login::CodexAuth;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_models_manager::model_info::model_info_from_slug;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
@@ -165,12 +166,17 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> {
|
||||
|
||||
let mut code_mode_only_model = remote_model("test-tool-mode-code-mode-only");
|
||||
code_mode_only_model.tool_mode = Some(ToolMode::CodeModeOnly);
|
||||
code_mode_only_model.input_modalities = vec![InputModality::Text, InputModality::Image];
|
||||
let code_mode_only_body = response_body_for_remote_model(code_mode_only_model, |_| {}).await?;
|
||||
assert_eq!(
|
||||
tool_names(&code_mode_only_body),
|
||||
vec![
|
||||
// Code-mode entrypoints.
|
||||
codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
|
||||
codex_code_mode::WAIT_TOOL_NAME.to_string(),
|
||||
// Hosted Responses tools.
|
||||
"web_search".to_string(),
|
||||
"image_generation".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID;
|
||||
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
@@ -22,6 +23,9 @@ use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::PathBufExt;
|
||||
@@ -345,6 +349,209 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let Some(_remote_env) = get_remote_test_env() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.approvals_reviewer = ApprovalsReviewer::User;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::ExecPermissionApprovals)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::RequestPermissionsTool)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build_with_remote_and_local_env(&server).await?;
|
||||
|
||||
let local_cwd = TempDir::new()?;
|
||||
let remote_cwd = PathBuf::from(format!(
|
||||
"/tmp/codex-remote-request-permissions-{}",
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
||||
))
|
||||
.abs();
|
||||
let relative_write_root = "granted";
|
||||
let relative_target_path = "granted/request-permissions-output.txt";
|
||||
let remote_write_root = remote_cwd.join(relative_write_root);
|
||||
let remote_target_path = remote_cwd.join(relative_target_path);
|
||||
let local_write_root = local_cwd.path().join(relative_write_root);
|
||||
let local_target_path = local_cwd.path().join(relative_target_path);
|
||||
fs::create_dir(&local_write_root)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_write_root,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let expected_permissions = RequestPermissionProfile {
|
||||
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
||||
Some(vec![]),
|
||||
Some(vec![remote_write_root.clone()]),
|
||||
)),
|
||||
..RequestPermissionProfile::default()
|
||||
};
|
||||
let approved_response = RequestPermissionsResponse {
|
||||
permissions: expected_permissions.clone(),
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: false,
|
||||
};
|
||||
let command = format!(
|
||||
"printf 'remote-request-permissions-ok' > {relative_target_path} && cat {relative_target_path}"
|
||||
);
|
||||
let response_mock = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-request-permissions-remote-1"),
|
||||
ev_function_call(
|
||||
"permissions-call",
|
||||
"request_permissions",
|
||||
&json!({
|
||||
"environment_id": REMOTE_ENVIRONMENT_ID,
|
||||
"reason": "Allow writing inside the selected remote environment",
|
||||
"permissions": {
|
||||
"file_system": {
|
||||
"write": [relative_write_root],
|
||||
},
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
ev_completed("resp-request-permissions-remote-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-request-permissions-remote-2"),
|
||||
ev_function_call(
|
||||
"exec-call",
|
||||
"exec_command",
|
||||
&json!({
|
||||
"shell": "/bin/sh",
|
||||
"cmd": command,
|
||||
"login": false,
|
||||
"yield_time_ms": 1_000,
|
||||
"environment_id": REMOTE_ENVIRONMENT_ID,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
ev_completed("resp-request-permissions-remote-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-request-permissions-remote-3"),
|
||||
ev_assistant_message("msg-request-permissions-remote-1", "done"),
|
||||
ev_completed("resp-request-permissions-remote-3"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn_with_approval_and_environments(
|
||||
&test,
|
||||
"request permissions, then write in the remote environment",
|
||||
vec![
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
cwd: local_cwd.path().abs(),
|
||||
},
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
||||
cwd: remote_cwd.clone(),
|
||||
},
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let EventMsg::RequestPermissions(request) = event else {
|
||||
panic!("expected remote request_permissions before completion: {event:?}");
|
||||
};
|
||||
assert_eq!(request.call_id, "permissions-call");
|
||||
assert_eq!(
|
||||
request.environment_id.as_deref(),
|
||||
Some(REMOTE_ENVIRONMENT_ID)
|
||||
);
|
||||
assert_eq!(request.cwd.as_ref(), Some(&remote_cwd));
|
||||
assert_eq!(request.permissions, expected_permissions);
|
||||
|
||||
test.codex
|
||||
.submit(Op::RequestPermissionsResponse {
|
||||
id: "permissions-call".to_string(),
|
||||
response: approved_response.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
match event {
|
||||
EventMsg::TurnComplete(_) => {}
|
||||
EventMsg::ExecApprovalRequest(approval) => {
|
||||
panic!("remote request_permissions grant should preapprove exec: {approval:?}");
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
|
||||
let permissions_output: RequestPermissionsResponse = serde_json::from_str(
|
||||
&response_mock
|
||||
.function_call_output_text("permissions-call")
|
||||
.expect("expected request_permissions output"),
|
||||
)?;
|
||||
assert_eq!(permissions_output, approved_response);
|
||||
let exec_output = response_mock
|
||||
.function_call_output_text("exec-call")
|
||||
.expect("expected exec output");
|
||||
assert!(
|
||||
exec_output.contains("remote-request-permissions-ok"),
|
||||
"unexpected exec output: {exec_output}",
|
||||
);
|
||||
assert_eq!(
|
||||
test.fs()
|
||||
.read_file_text(&remote_target_path, /*sandbox*/ None)
|
||||
.await?,
|
||||
"remote-request-permissions-ok"
|
||||
);
|
||||
assert!(
|
||||
!local_target_path.exists(),
|
||||
"remote exec should not write through the local environment"
|
||||
);
|
||||
|
||||
test.fs()
|
||||
.remove(
|
||||
&remote_cwd,
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
force: true,
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -77,9 +77,9 @@ impl ToolExecutor<ToolCall> for ImageGenerationTool {
|
||||
imagegen_tool_spec()
|
||||
}
|
||||
|
||||
/// Keeps this model-facing tool out of the nested code-mode tool surface.
|
||||
/// Exposes image generation directly and through the nested code-mode tool surface.
|
||||
fn exposure(&self) -> ToolExposure {
|
||||
ToolExposure::DirectModelOnly
|
||||
ToolExposure::Direct
|
||||
}
|
||||
|
||||
/// Executes the selected image operation and returns the completed image result.
|
||||
|
||||
Reference in New Issue
Block a user