mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
Add experimental GCP account setup to the app server (#57394)
## What changed - Add `account/gcp/setup` to configure the built-in Vertex AI provider with a project, optional location (defaulting to `global`), and optional credential file. Omitting the file clears the active user override and uses Application Default Credentials. - Validate configuration and obtain an access token before saving, return structured credential failure reasons, and reload the model catalog on the next `model/list` request. - Share configuration previews and version-checked writes with Bedrock setup to reject conflicting settings and concurrent configuration changes. ## Testing Add tests for credential selection and ADC fallback, catalog refresh, invalid credentials, policy and endpoint conflicts, cancellation when network permission is revoked, and configuration version conflicts. GitOrigin-RevId: 6f411a85893f8da49581427451e0180529955b16
This commit is contained in:
BIN
Binary file not shown.
@@ -1338,6 +1338,13 @@ client_request_definitions! {
|
||||
response: v2::BedrockSetupResponse,
|
||||
},
|
||||
|
||||
#[experimental("account/gcp/setup")]
|
||||
GcpSetup => "account/gcp/setup" {
|
||||
params: v2::GcpSetupParams,
|
||||
serialization: global("account-auth"),
|
||||
response: v2::GcpSetupResponse,
|
||||
},
|
||||
|
||||
#[experimental("account/bedrock/checkGovCloudRequirements")]
|
||||
BedrockCheckGovCloudRequirements => "account/bedrock/checkGovCloudRequirements" {
|
||||
params: v2::BedrockCheckGovCloudRequirementsParams,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Shared setup parameters for the built-in Google Cloud Vertex AI provider.
|
||||
|
||||
use crate::JsonSchema;
|
||||
use crate::TS;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct GcpSetupParams {
|
||||
pub project_id: String,
|
||||
/// Vertex AI location. Omission or null selects `global`.
|
||||
#[ts(optional = nullable)]
|
||||
pub location: Option<String>,
|
||||
/// Absolute path to a credential file on the app-server host.
|
||||
/// Omit or pass `null` to clear the active user configuration's override and use ADC.
|
||||
/// If another configuration layer would still supply a credential file, setup fails
|
||||
/// without saving changes.
|
||||
#[ts(optional = nullable)]
|
||||
pub credential_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct GcpSetupResponse {}
|
||||
@@ -16,6 +16,7 @@ mod environment;
|
||||
mod experimental_feature;
|
||||
mod feedback;
|
||||
mod fs;
|
||||
mod gcp;
|
||||
mod hook;
|
||||
mod item;
|
||||
mod mcp;
|
||||
@@ -57,6 +58,7 @@ pub use environment::*;
|
||||
pub use experimental_feature::*;
|
||||
pub use feedback::*;
|
||||
pub use fs::*;
|
||||
pub use gcp::*;
|
||||
pub use hook::*;
|
||||
pub use item::*;
|
||||
pub use mcp::*;
|
||||
|
||||
@@ -646,3 +646,36 @@ and out-of-scope item IDs return invalid params (`-32602`) with
|
||||
Omitted or null cursors preserve normal first-page behavior. Continue anchored
|
||||
pages with the returned opaque string `nextCursor`; response fields and
|
||||
`backwardsCursor` semantics are unchanged.
|
||||
|
||||
# GCP provider account
|
||||
|
||||
The experimental `account/gcp/setup` method configures the built-in Vertex AI provider.
|
||||
|
||||
- `projectId` (required) — Google Cloud project ID.
|
||||
- `location` (optional) — Defaults to `global`.
|
||||
- `credentialFile` (optional) — Absolute path to a credential file on the app-server
|
||||
host. Omit it or pass `null` to clear the saved path in the active user
|
||||
configuration and use Application Default Credentials (ADC), including
|
||||
`GOOGLE_APPLICATION_CREDENTIALS`.
|
||||
|
||||
If the active profile inherits `model_providers.gcp.credential_file` from the base
|
||||
`config.toml`, clearing the profile’s own setting would expose the base path again.
|
||||
Setup returns an error without saving changes. To use ADC, remove the inherited
|
||||
setting from the file named in the error and retry.
|
||||
|
||||
Setup rejects a GCP `base_url` override or higher-priority settings that conflict
|
||||
with the request. It fetches an access token before saving the settings and
|
||||
selecting GCP, then returns `{}`. The next `model/list` reloads the catalog.
|
||||
`account/read` reports `{ "account": { "type": "gcp" }, "requiresOpenaiAuth": false }`.
|
||||
Use a model request to verify Vertex model access.
|
||||
|
||||
Credential failures include `error.data.reason`.
|
||||
|
||||
- `gcloudNotInstalled` — The app-server could not find `gcloud` on its PATH.
|
||||
- `credentialFileInvalid` — The path is invalid or the file is unreadable. For
|
||||
default `gcloud` auth, setup also checks for a JSON object with a nonempty string `type`.
|
||||
- `authCommandFailed` — The token command failed, including when ADC is missing
|
||||
or a token refresh fails.
|
||||
|
||||
For personal sign-in, users can run `gcloud auth application-default login` on
|
||||
the app-server host and retry setup.
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::config_layer::config_layer_to_api;
|
||||
use crate::config_manager::ConfigManager;
|
||||
use codex_app_server_protocol::Config as ApiConfig;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigEdit as ApiConfigEdit;
|
||||
use codex_app_server_protocol::ConfigReadParams;
|
||||
use codex_app_server_protocol::ConfigReadResponse;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
@@ -31,6 +32,7 @@ use codex_core::path_utils;
|
||||
use codex_core::path_utils::SymlinkWritePaths;
|
||||
use codex_core::path_utils::resolve_symlink_write_paths;
|
||||
use codex_core::path_utils::write_atomically;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde_json::Value as JsonValue;
|
||||
@@ -44,6 +46,8 @@ use toml_edit::Item as TomlItem;
|
||||
|
||||
#[path = "config_manager_service_credential_edits.rs"]
|
||||
mod credential_edits;
|
||||
#[path = "config_manager_service_provider_setup.rs"]
|
||||
mod provider_setup;
|
||||
|
||||
use credential_edits::CredentialProviderEdits;
|
||||
|
||||
@@ -116,6 +120,11 @@ impl ConfigManagerError {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PreparedProviderWrite {
|
||||
edits: Vec<ApiConfigEdit>,
|
||||
expected_version: String,
|
||||
}
|
||||
|
||||
impl ConfigManager {
|
||||
pub(crate) async fn read(
|
||||
&self,
|
||||
@@ -202,6 +211,63 @@ impl ConfigManager {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_provider_config<'a>(
|
||||
&self,
|
||||
layers: &ConfigLayerStack,
|
||||
provider_id: &'a str,
|
||||
settings: impl IntoIterator<Item = (&'a str, Option<&'a str>)>,
|
||||
) -> Result<(PreparedProviderWrite, ModelProviderInfo), ConfigManagerError> {
|
||||
let selection = match layers.required_model_provider() {
|
||||
Some(required) if required == provider_id => None,
|
||||
Some(required) => {
|
||||
return Err(ConfigManagerError::write(
|
||||
ConfigWriteErrorCode::ConfigRequirementReadonly,
|
||||
format!(
|
||||
"Cannot select `{provider_id}` because requirements set `model_provider` to `{required}`."
|
||||
),
|
||||
));
|
||||
}
|
||||
None => Some(("model_provider".to_owned(), Some(provider_id))),
|
||||
};
|
||||
let settings = settings
|
||||
.into_iter()
|
||||
.map(|(key, value)| (format!("model_providers.{provider_id}.{key}"), value));
|
||||
let edits = selection
|
||||
.into_iter()
|
||||
.chain(settings)
|
||||
.map(|(key_path, value)| ApiConfigEdit {
|
||||
key_path,
|
||||
value: value.into(),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
})
|
||||
.collect();
|
||||
let user_path = self
|
||||
.user_config_path()
|
||||
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?;
|
||||
provider_setup::preview(layers, &user_path, provider_id, edits)
|
||||
}
|
||||
|
||||
pub(crate) async fn commit_provider_config(
|
||||
&self,
|
||||
write: PreparedProviderWrite,
|
||||
) -> Result<(), ConfigManagerError> {
|
||||
let response = self
|
||||
.batch_write(ConfigBatchWriteParams {
|
||||
edits: write.edits,
|
||||
file_path: None,
|
||||
expected_version: Some(write.expected_version),
|
||||
reload_user_config: false,
|
||||
})
|
||||
.await?;
|
||||
if let Some(overridden) = response.overridden_metadata {
|
||||
return Err(ConfigManagerError::write(
|
||||
ConfigWriteErrorCode::ConfigLayerReadonly,
|
||||
overridden.message,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn batch_write(
|
||||
&self,
|
||||
params: ConfigBatchWriteParams,
|
||||
@@ -262,18 +328,7 @@ impl ConfigManager {
|
||||
let mut config_edits = Vec::new();
|
||||
|
||||
for (key_path, value, strategy) in edits.into_iter() {
|
||||
let mut segments = parse_key_path(&key_path).map_err(|message| {
|
||||
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
|
||||
})?;
|
||||
if let Some(field) = layers
|
||||
.requirements_toml()
|
||||
.exact_requirement_for_config_path(&segments)
|
||||
{
|
||||
return Err(ConfigManagerError::write(
|
||||
ConfigWriteErrorCode::ConfigRequirementReadonly,
|
||||
format!("`{field}` is managed by requirements and cannot be changed"),
|
||||
));
|
||||
}
|
||||
let mut segments = writable_key_path(&layers, &key_path)?;
|
||||
if (value.is_null() || matches!(strategy, MergeStrategy::Upsert))
|
||||
&& let Some(pattern) = shell_environment_filter_entry(&user_config, &segments)
|
||||
.map(|(pattern, _)| pattern.clone())
|
||||
@@ -318,9 +373,7 @@ impl ConfigManager {
|
||||
let parsed_value = if preserves_network_proxy_settings {
|
||||
Some(TomlValue::Boolean(false))
|
||||
} else {
|
||||
parse_value(value).map_err(|message| {
|
||||
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
|
||||
})?
|
||||
parse_value(value)?
|
||||
};
|
||||
if matches!(strategy, MergeStrategy::Upsert)
|
||||
&& let Some(value) = parsed_value.as_ref()
|
||||
@@ -526,14 +579,38 @@ async fn write_empty_user_config(write_path: PathBuf) -> Result<(), ConfigManage
|
||||
.map_err(|err| ConfigManagerError::io("failed to create empty user config.toml", err))
|
||||
}
|
||||
|
||||
fn parse_value(value: JsonValue) -> Result<Option<TomlValue>, String> {
|
||||
fn parse_value(value: JsonValue) -> Result<Option<TomlValue>, ConfigManagerError> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
serde_json::from_value::<TomlValue>(value)
|
||||
.map(Some)
|
||||
.map_err(|err| format!("invalid value: {err}"))
|
||||
.map_err(|err| {
|
||||
ConfigManagerError::write(
|
||||
ConfigWriteErrorCode::ConfigValidationError,
|
||||
format!("invalid value: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn writable_key_path(
|
||||
layers: &ConfigLayerStack,
|
||||
key_path: &str,
|
||||
) -> Result<Vec<String>, ConfigManagerError> {
|
||||
let segments = parse_key_path(key_path).map_err(|message| {
|
||||
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
|
||||
})?;
|
||||
if let Some(field) = layers
|
||||
.requirements_toml()
|
||||
.exact_requirement_for_config_path(&segments)
|
||||
{
|
||||
return Err(ConfigManagerError::write(
|
||||
ConfigWriteErrorCode::ConfigRequirementReadonly,
|
||||
format!("`{field}` is managed by requirements and cannot be changed"),
|
||||
));
|
||||
}
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn parse_key_path(path: &str) -> Result<Vec<String>, String> {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Previews the effective provider configuration and captures the user layer version for commit.
|
||||
|
||||
use super::ConfigManagerError;
|
||||
use super::MergeError;
|
||||
use super::PreparedProviderWrite;
|
||||
use super::apply_merge;
|
||||
use super::find_effective_layer;
|
||||
use super::override_message;
|
||||
use super::parse_value;
|
||||
use super::value_at_path;
|
||||
use super::writable_key_path;
|
||||
use codex_app_server_protocol::ConfigEdit;
|
||||
use codex_app_server_protocol::ConfigWriteErrorCode;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::version_for_toml;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
pub(super) fn preview(
|
||||
layers: &ConfigLayerStack,
|
||||
user_path: &AbsolutePathBuf,
|
||||
provider_id: &str,
|
||||
edits: Vec<ConfigEdit>,
|
||||
) -> Result<(PreparedProviderWrite, ModelProviderInfo), ConfigManagerError> {
|
||||
let (mut user_config, expected_version) = match layers.get_active_user_layer() {
|
||||
Some(layer) => (layer.config.clone(), layer.version.clone()),
|
||||
None => {
|
||||
let empty = TomlValue::Table(Default::default());
|
||||
let version = version_for_toml(&empty);
|
||||
(empty, version)
|
||||
}
|
||||
};
|
||||
let mut fields = Vec::with_capacity(edits.len());
|
||||
for edit in &edits {
|
||||
let path = writable_key_path(layers, &edit.key_path)?;
|
||||
let value = parse_value(edit.value.clone())?;
|
||||
apply_merge(
|
||||
&mut user_config,
|
||||
&path,
|
||||
value.as_ref(),
|
||||
edit.merge_strategy.clone(),
|
||||
)
|
||||
.map_err(|MergeError::Validation(message)| {
|
||||
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
|
||||
})?;
|
||||
fields.push((&edit.key_path, path, value));
|
||||
}
|
||||
|
||||
let preview = layers
|
||||
.with_user_config(user_path, user_config)
|
||||
.map_err(|err| {
|
||||
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, err.to_string())
|
||||
})?;
|
||||
let effective = preview.effective_config();
|
||||
for (key, path, expected) in fields {
|
||||
if value_at_path(&effective, &path) != expected.as_ref() {
|
||||
let source = find_effective_layer(&preview, &path)
|
||||
.map(|layer| override_message(&layer.name))
|
||||
.unwrap_or_else(|| "Overridden by another configuration layer".to_owned());
|
||||
return Err(ConfigManagerError::write(
|
||||
ConfigWriteErrorCode::ConfigLayerReadonly,
|
||||
format!("Cannot configure `{key}`: {source}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
let provider = effective
|
||||
.get("model_providers")
|
||||
.and_then(|providers| providers.get(provider_id))
|
||||
.cloned()
|
||||
.map(TomlValue::try_into)
|
||||
.transpose()
|
||||
.map_err(|_| {
|
||||
ConfigManagerError::write(
|
||||
ConfigWriteErrorCode::ConfigValidationError,
|
||||
format!("Invalid `{provider_id}` provider configuration."),
|
||||
)
|
||||
})?
|
||||
.unwrap_or_default();
|
||||
Ok((
|
||||
PreparedProviderWrite {
|
||||
edits,
|
||||
expected_version,
|
||||
},
|
||||
provider,
|
||||
))
|
||||
}
|
||||
@@ -1187,6 +1187,29 @@ async fn write_value_reports_override() {
|
||||
assert!(result.overridden_metadata.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_setup_rejects_config_changed_after_preview() -> Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
let layers = service.load_config_layers(/*cwd*/ None).await?;
|
||||
let (write, _) = service.prepare_provider_config(
|
||||
&layers,
|
||||
codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID,
|
||||
[("aws.region", Some("us-west-2"))],
|
||||
)?;
|
||||
|
||||
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
let newer_config = "model = 'newer'";
|
||||
std::fs::write(&user_path, newer_config)?;
|
||||
let error = service.commit_provider_config(write).await.unwrap_err();
|
||||
assert_eq!(
|
||||
error.write_error_code(),
|
||||
Some(ConfigWriteErrorCode::ConfigVersionConflict)
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(user_path)?, newer_config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_conflict_rejected() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -50,6 +50,7 @@ use crate::request_processors::ThreadRequestProcessor;
|
||||
use crate::request_processors::ThreadResumeTarget;
|
||||
use crate::request_processors::TurnRequestProcessor;
|
||||
use crate::request_processors::WindowsSandboxRequestProcessor;
|
||||
use crate::request_processors::account_processor::handle_gcp_setup;
|
||||
use crate::request_processors::read_server_diagnostics;
|
||||
use crate::request_serialization::QueuedInitializedRequest;
|
||||
use crate::request_serialization::RequestSerializationQueueKey;
|
||||
@@ -1806,6 +1807,9 @@ impl MessageProcessor {
|
||||
ClientRequest::BedrockSetup { params, .. } => {
|
||||
self.account_processor.bedrock_setup(params).await
|
||||
}
|
||||
ClientRequest::GcpSetup { params, .. } => {
|
||||
handle_gcp_setup(&self.account_processor, params).await
|
||||
}
|
||||
ClientRequest::BedrockCheckGovCloudRequirements { .. } => {
|
||||
self.account_processor
|
||||
.bedrock_check_gov_cloud_requirements()
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_core::config::Config;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::config_manager::ConfigManager;
|
||||
@@ -48,6 +50,16 @@ impl ModelCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the same startup context used to reload the catalog after account setup.
|
||||
pub(crate) async fn load_startup_config_layers(&self) -> std::io::Result<ConfigLayerStack> {
|
||||
let cwd = self
|
||||
.startup_cwd
|
||||
.as_deref()
|
||||
.map(AbsolutePathBuf::from_absolute_path)
|
||||
.transpose()?;
|
||||
self.config_manager.load_config_layers(cwd).await
|
||||
}
|
||||
|
||||
/// Marks catalog configuration stale after an account transition.
|
||||
pub(crate) async fn invalidate(&self) {
|
||||
self.state.lock().await.needs_reload = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::bedrock_auth::BedrockProviderConfig;
|
||||
use super::bedrock_auth::clear_user_model_provider_if_bedrock;
|
||||
use super::bedrock_auth::configure_bedrock_provider;
|
||||
use super::bedrock_auth::ensure_user_model_provider_can_be_bedrock;
|
||||
use super::bedrock_auth::prepare_bedrock_provider;
|
||||
use super::config_processor::map_error as map_config_error;
|
||||
use super::*;
|
||||
use crate::auth_mode::auth_mode_to_api;
|
||||
use crate::external_auth::ExternalAuthBridge;
|
||||
@@ -21,11 +21,13 @@ mod bedrock_gov_cloud;
|
||||
mod bedrock_setup;
|
||||
mod enterprise_login;
|
||||
mod gateway_oauth;
|
||||
mod gcp_setup;
|
||||
mod rate_limit_resets;
|
||||
mod workspace_routing;
|
||||
|
||||
pub(super) use enterprise_login::EnterpriseLoginCompletion;
|
||||
pub(super) use enterprise_login::EnterpriseLoginTarget;
|
||||
pub(crate) use gcp_setup::handle_gcp_setup;
|
||||
|
||||
// Duration before a browser ChatGPT login attempt is abandoned.
|
||||
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
@@ -80,6 +82,11 @@ enum RefreshTokenRequestOutcome {
|
||||
FailedPermanently,
|
||||
}
|
||||
|
||||
enum CloudLoginProvider {
|
||||
AmazonBedrock,
|
||||
Gcp,
|
||||
}
|
||||
|
||||
enum BedrockLoginCredentials {
|
||||
ApiKey(String),
|
||||
AccessKeys {
|
||||
@@ -426,7 +433,10 @@ impl AccountRequestProcessor {
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_bedrock_login_allowed(&self) -> Result<(), JSONRPCErrorError> {
|
||||
fn ensure_provider_login_allowed(
|
||||
&self,
|
||||
provider: CloudLoginProvider,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
if self.auth_manager.is_workload_identity_selected() {
|
||||
return Err(self.configured_auth_owned_by_host_error());
|
||||
}
|
||||
@@ -437,9 +447,13 @@ impl AccountRequestProcessor {
|
||||
.auth_manager
|
||||
.is_login_method_allowed(ForcedLoginMethod::Api)
|
||||
{
|
||||
return Err(invalid_request(
|
||||
"Amazon Bedrock login is disabled. Use ChatGPT login instead.",
|
||||
));
|
||||
let operation = match provider {
|
||||
CloudLoginProvider::AmazonBedrock => "Amazon Bedrock login",
|
||||
CloudLoginProvider::Gcp => "Google Cloud setup",
|
||||
};
|
||||
return Err(invalid_request(format!(
|
||||
"{operation} is disabled. Use ChatGPT login instead."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -508,7 +522,7 @@ impl AccountRequestProcessor {
|
||||
region: String,
|
||||
) {
|
||||
let result = async {
|
||||
self.ensure_bedrock_login_allowed()?;
|
||||
self.ensure_provider_login_allowed(CloudLoginProvider::AmazonBedrock)?;
|
||||
|
||||
match &credentials {
|
||||
BedrockLoginCredentials::ApiKey(api_key) => {
|
||||
@@ -536,8 +550,7 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
|
||||
self.cancel_active_login().await;
|
||||
ensure_user_model_provider_can_be_bedrock(&self.config_manager).await?;
|
||||
configure_bedrock_provider(
|
||||
let write = prepare_bedrock_provider(
|
||||
&self.config_manager,
|
||||
BedrockProviderConfig {
|
||||
region: matches!(&credentials, BedrockLoginCredentials::AccessKeys { .. })
|
||||
@@ -546,6 +559,10 @@ impl AccountRequestProcessor {
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.config_manager
|
||||
.commit_provider_config(write)
|
||||
.await
|
||||
.map_err(map_config_error)?;
|
||||
|
||||
match credentials {
|
||||
BedrockLoginCredentials::ApiKey(api_key) => login_with_bedrock_api_key(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::super::bedrock_auth::BedrockProviderConfig;
|
||||
use super::super::bedrock_auth::configure_bedrock_provider;
|
||||
use super::super::bedrock_auth::ensure_user_model_provider_can_be_bedrock;
|
||||
use super::super::bedrock_auth::prepare_bedrock_provider;
|
||||
use super::super::config_processor::map_error as map_config_error;
|
||||
use super::AccountRequestProcessor;
|
||||
use super::CloudLoginProvider;
|
||||
use crate::error_code::internal_error;
|
||||
use crate::error_code::invalid_request;
|
||||
use codex_app_server_protocol::AwsCredentialType;
|
||||
@@ -25,7 +26,7 @@ impl AccountRequestProcessor {
|
||||
&self,
|
||||
_params: BedrockDiscoverParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.ensure_bedrock_login_allowed()?;
|
||||
self.ensure_provider_login_allowed(CloudLoginProvider::AmazonBedrock)?;
|
||||
let profiles = codex_aws_auth::discover_aws_profiles()
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to discover AWS profiles: {err}")))?
|
||||
@@ -69,9 +70,22 @@ impl AccountRequestProcessor {
|
||||
&self,
|
||||
params: BedrockSetupParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.ensure_bedrock_login_allowed()?;
|
||||
ensure_user_model_provider_can_be_bedrock(&self.config_manager).await?;
|
||||
if matches!(¶ms, BedrockSetupParams::Environment { .. })
|
||||
self.ensure_provider_login_allowed(CloudLoginProvider::AmazonBedrock)?;
|
||||
let (profile, region) = match ¶ms {
|
||||
BedrockSetupParams::Profile { profile, region } => {
|
||||
(Some(profile.trim()), region.trim())
|
||||
}
|
||||
BedrockSetupParams::Environment { region } => (None, region.trim()),
|
||||
};
|
||||
let write = prepare_bedrock_provider(
|
||||
&self.config_manager,
|
||||
BedrockProviderConfig {
|
||||
region: Some(region),
|
||||
profile,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if profile.is_none()
|
||||
&& matches!(
|
||||
self.auth_manager.auth_cached(),
|
||||
Some(CodexAuth::BedrockApiKey(_) | CodexAuth::BedrockAccessKeys(_))
|
||||
@@ -82,58 +96,44 @@ impl AccountRequestProcessor {
|
||||
));
|
||||
}
|
||||
|
||||
let region = match ¶ms {
|
||||
BedrockSetupParams::Profile { region, .. }
|
||||
| BedrockSetupParams::Environment { region, .. } => region.trim(),
|
||||
};
|
||||
if !is_supported_amazon_bedrock_region(region) {
|
||||
return Err(invalid_request(format!(
|
||||
"Amazon Bedrock does not support region `{region}`"
|
||||
)));
|
||||
}
|
||||
|
||||
let profile = match ¶ms {
|
||||
BedrockSetupParams::Profile { profile, .. } => {
|
||||
let profile = profile.trim();
|
||||
if profile.is_empty() {
|
||||
return Err(invalid_request("AWS profile name must not be empty."));
|
||||
}
|
||||
codex_aws_auth::validate_aws_profile(
|
||||
profile,
|
||||
region,
|
||||
self.config.http_client_factory(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
invalid_request(format!(
|
||||
"failed to load credentials for AWS profile `{profile}`: {err}"
|
||||
))
|
||||
})?;
|
||||
Some(profile.to_string())
|
||||
if let Some(profile) = profile {
|
||||
if profile.is_empty() {
|
||||
return Err(invalid_request("AWS profile name must not be empty."));
|
||||
}
|
||||
BedrockSetupParams::Environment { .. } => {
|
||||
let has_bedrock_api_key = non_empty_env_var(AWS_BEARER_TOKEN_BEDROCK).is_some();
|
||||
let has_access_keys = non_empty_env_var(AWS_ACCESS_KEY_ID).is_some()
|
||||
&& non_empty_env_var(AWS_SECRET_ACCESS_KEY).is_some();
|
||||
if !has_bedrock_api_key && !has_access_keys {
|
||||
return Err(invalid_request(
|
||||
"No AWS credentials found. Please Configure AWS credentials or complete AWS sign-in, then try again.",
|
||||
));
|
||||
}
|
||||
None
|
||||
codex_aws_auth::validate_aws_profile(
|
||||
profile,
|
||||
region,
|
||||
self.config.http_client_factory(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
invalid_request(format!(
|
||||
"failed to load credentials for AWS profile `{profile}`: {err}"
|
||||
))
|
||||
})?;
|
||||
} else {
|
||||
let has_bedrock_api_key = non_empty_env_var(AWS_BEARER_TOKEN_BEDROCK).is_some();
|
||||
let has_access_keys = non_empty_env_var(AWS_ACCESS_KEY_ID).is_some()
|
||||
&& non_empty_env_var(AWS_SECRET_ACCESS_KEY).is_some();
|
||||
if !has_bedrock_api_key && !has_access_keys {
|
||||
return Err(invalid_request(
|
||||
"No AWS credentials found. Please Configure AWS credentials or complete AWS sign-in, then try again.",
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Validate the selected credentials against Bedrock ListModels once supported.
|
||||
self.cancel_active_login().await;
|
||||
configure_bedrock_provider(
|
||||
&self.config_manager,
|
||||
BedrockProviderConfig {
|
||||
region: Some(region),
|
||||
profile: profile.as_deref(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.config_manager
|
||||
.commit_provider_config(write)
|
||||
.await
|
||||
.map_err(map_config_error)?;
|
||||
|
||||
Ok(Some(BedrockSetupResponse {}.into()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Validates and persists GCP setup without changing saved login credentials or existing threads.
|
||||
|
||||
use super::super::config_processor::map_error as map_config_error;
|
||||
use super::AccountRequestProcessor;
|
||||
use super::CloudLoginProvider;
|
||||
use crate::error_code::internal_error;
|
||||
use crate::error_code::invalid_request;
|
||||
use codex_app_server_protocol::ClientResponsePayload;
|
||||
use codex_app_server_protocol::GcpSetupParams;
|
||||
use codex_app_server_protocol::GcpSetupResponse;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_config::apply_default_gcp_auth;
|
||||
use codex_login::RefreshTokenError;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_model_provider_info::GCP_PROVIDER_ID;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_model_provider_info::merge_gcp_provider_overrides;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
use serde::de::MapAccess;
|
||||
use serde::de::Visitor;
|
||||
use serde::de::value::MapAccessDeserializer;
|
||||
use serde_json::json;
|
||||
use std::fmt;
|
||||
use std::io::BufReader;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs::File;
|
||||
|
||||
pub(crate) async fn handle_gcp_setup(
|
||||
processor: &AccountRequestProcessor,
|
||||
params: GcpSetupParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
processor.ensure_provider_login_allowed(CloudLoginProvider::Gcp)?;
|
||||
let network_policy = processor
|
||||
.auth_manager
|
||||
.application_network_policy()
|
||||
.for_current_account();
|
||||
let layers = processor
|
||||
.model_catalog
|
||||
.load_startup_config_layers()
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to load configuration layers: {err}")))?;
|
||||
let (write, provider) = processor
|
||||
.config_manager
|
||||
.prepare_provider_config(
|
||||
&layers,
|
||||
GCP_PROVIDER_ID,
|
||||
[
|
||||
("project_id", Some(params.project_id.trim())),
|
||||
(
|
||||
"location",
|
||||
Some(params.location.as_deref().unwrap_or("global").trim()),
|
||||
),
|
||||
("credential_file", params.credential_file.as_deref()),
|
||||
],
|
||||
)
|
||||
.map_err(map_config_error)?;
|
||||
if provider.base_url.is_some() {
|
||||
return Err(invalid_request(
|
||||
"Google Cloud has a custom `base_url`. Clear `model_providers.gcp.base_url` before configuring a project and location.",
|
||||
));
|
||||
}
|
||||
// Even checking a Windows UNC credential path can contact the remote host.
|
||||
let permit = network_policy
|
||||
.acquire_for_unsupported_sdk()
|
||||
.map_err(|err| invalid_request(err.to_string()))?;
|
||||
permit
|
||||
.run(verify_credentials(provider, &processor.config.cwd))
|
||||
.await
|
||||
.map_err(|err| invalid_request(err.to_string()))??;
|
||||
|
||||
processor
|
||||
.config_manager
|
||||
.commit_provider_config(write)
|
||||
.await
|
||||
.map_err(map_config_error)?;
|
||||
processor.cancel_active_login().await;
|
||||
processor.model_catalog.invalidate().await;
|
||||
Ok(Some(GcpSetupResponse {}.into()))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum AuthErrorReason {
|
||||
GcloudNotInstalled,
|
||||
CredentialFileInvalid,
|
||||
AuthCommandFailed,
|
||||
}
|
||||
|
||||
impl AuthErrorReason {
|
||||
fn error(self, message: impl Into<String>) -> JSONRPCErrorError {
|
||||
let mut error = invalid_request(message);
|
||||
error.data = Some(json!({ "reason": self }));
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_credential_file(path: &Path) -> std::io::Result<File> {
|
||||
if !tokio::fs::metadata(path).await?.is_file() {
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"expected a regular file",
|
||||
));
|
||||
}
|
||||
File::open(path).await
|
||||
}
|
||||
|
||||
// Require a JSON object. Serde also accepts positional arrays when deserializing a struct.
|
||||
struct AdcObject;
|
||||
|
||||
impl<'de> Visitor<'de> for AdcObject {
|
||||
type Value = ();
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("a Google Cloud credential object")
|
||||
}
|
||||
|
||||
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<(), A::Error> {
|
||||
#[derive(Deserialize)]
|
||||
struct Adc {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
}
|
||||
let adc = Adc::deserialize(MapAccessDeserializer::new(map))?;
|
||||
if adc.kind.trim().is_empty() {
|
||||
return Err(serde::de::Error::custom(
|
||||
"expected a nonempty credential type",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_adc_file(file: File) -> std::io::Result<()> {
|
||||
let file = file.into_std().await;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Stream past type-specific fields without retaining the credential contents.
|
||||
let mut json = serde_json::Deserializer::from_reader(BufReader::new(file));
|
||||
json.deserialize_map(AdcObject)?;
|
||||
json.end()?;
|
||||
Ok(())
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
async fn verify_credentials(
|
||||
configured: ModelProviderInfo,
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
configured
|
||||
.validate_gcp_credential_file()
|
||||
.map_err(|err| AuthErrorReason::CredentialFileInvalid.error(err))?;
|
||||
let default_gcloud = configured.auth.is_none();
|
||||
let mut candidate = ModelProviderInfo::create_gcp_provider();
|
||||
merge_gcp_provider_overrides(Some(&mut candidate), configured).map_err(invalid_request)?;
|
||||
apply_default_gcp_auth(&mut candidate, cwd);
|
||||
candidate.validate().map_err(invalid_request)?;
|
||||
let auth_file = candidate.credential_file.clone().or_else(|| {
|
||||
if default_gcloud {
|
||||
std::env::var_os("GOOGLE_APPLICATION_CREDENTIALS").map(PathBuf::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(path) = auth_file {
|
||||
let path = cwd.as_path().join(path);
|
||||
let file = open_credential_file(&path).await.map_err(|err| {
|
||||
AuthErrorReason::CredentialFileInvalid.error(format!(
|
||||
"Cannot read the Google Cloud credential file: {err}"
|
||||
))
|
||||
})?;
|
||||
if default_gcloud {
|
||||
validate_adc_file(file).await.map_err(|_| AuthErrorReason::CredentialFileInvalid.error(
|
||||
"Expected a Google Cloud credential file containing a JSON object with a nonempty string type.",
|
||||
))?;
|
||||
}
|
||||
}
|
||||
let provider = create_model_provider(candidate, /*auth_manager*/ None);
|
||||
provider
|
||||
.runtime_base_url()
|
||||
.await
|
||||
.map_err(|err| invalid_request(err.to_string()))?;
|
||||
// This fresh provider owns a command-backed manager. A refresh runs that command
|
||||
// once and preserves errors that the inference auth path reduces to missing auth.
|
||||
let auth_manager = provider
|
||||
.auth_manager()
|
||||
.ok_or_else(|| internal_error("Missing GCP auth manager."))?;
|
||||
if let Err(err) = auth_manager.refresh_token().await {
|
||||
if default_gcloud
|
||||
&& let RefreshTokenError::Transient(err) = err
|
||||
&& err.kind() == ErrorKind::NotFound
|
||||
&& cwd.as_path().is_dir()
|
||||
{
|
||||
return Err(AuthErrorReason::GcloudNotInstalled.error(
|
||||
"Cannot find gcloud on the app-server host. Check its installation and PATH.",
|
||||
));
|
||||
}
|
||||
return Err(AuthErrorReason::AuthCommandFailed
|
||||
.error("The Google Cloud auth command could not obtain an access token."));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::config_processor::map_error as map_config_error;
|
||||
use crate::config_manager::ConfigManager;
|
||||
use crate::config_manager_service::PreparedProviderWrite;
|
||||
use crate::error_code::internal_error;
|
||||
use crate::error_code::invalid_request;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
@@ -7,9 +8,6 @@ use codex_app_server_protocol::ConfigEdit;
|
||||
use codex_app_server_protocol::ConfigWriteErrorCode;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::format_config_layer_source;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_model_provider::AMAZON_BEDROCK_PROVIDER_ID;
|
||||
@@ -19,100 +17,30 @@ pub(super) struct BedrockProviderConfig<'a> {
|
||||
pub(super) profile: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub(super) async fn configure_bedrock_provider(
|
||||
pub(super) async fn prepare_bedrock_provider(
|
||||
config_manager: &ConfigManager,
|
||||
config: BedrockProviderConfig<'_>,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
let mut edits = vec![
|
||||
(
|
||||
"model_provider",
|
||||
serde_json::json!(AMAZON_BEDROCK_PROVIDER_ID),
|
||||
),
|
||||
(
|
||||
"model_providers.amazon-bedrock.aws.profile",
|
||||
serde_json::json!(config.profile),
|
||||
),
|
||||
];
|
||||
if let Some(region) = config.region {
|
||||
edits.push((
|
||||
"model_providers.amazon-bedrock.aws.region",
|
||||
serde_json::json!(region),
|
||||
));
|
||||
}
|
||||
|
||||
let response = config_manager
|
||||
.batch_write(ConfigBatchWriteParams {
|
||||
edits: edits
|
||||
.into_iter()
|
||||
.map(|(key_path, value)| ConfigEdit {
|
||||
key_path: key_path.to_string(),
|
||||
value,
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
})
|
||||
.collect(),
|
||||
file_path: None,
|
||||
expected_version: None,
|
||||
reload_user_config: false,
|
||||
})
|
||||
.await
|
||||
.map_err(map_config_error)?;
|
||||
if let Some(overridden) = response.overridden_metadata {
|
||||
let message = overridden.message;
|
||||
return Err(invalid_request(format!(
|
||||
"Amazon Bedrock configuration cannot take effect: {message}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_user_model_provider_can_be_bedrock(
|
||||
config_manager: &ConfigManager,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
) -> Result<PreparedProviderWrite, JSONRPCErrorError> {
|
||||
let layers = config_manager
|
||||
.load_config_layers(/*cwd*/ None)
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to load configuration layers: {err}")))?;
|
||||
let user_precedence = match layers.get_active_user_layer() {
|
||||
Some(layer) => layer.name.precedence(),
|
||||
None => ConfigLayerSource::User {
|
||||
file: config_manager.user_config_path().map_err(|err| {
|
||||
internal_error(format!("failed to resolve user config path: {err}"))
|
||||
})?,
|
||||
profile: None,
|
||||
}
|
||||
.precedence(),
|
||||
};
|
||||
if let Some((overriding_layer, effective_provider)) = layers
|
||||
.layers_high_to_low()
|
||||
.filter(|layer| layer.name.precedence() > user_precedence)
|
||||
.find_map(|layer| {
|
||||
layer
|
||||
.config
|
||||
.get("model_provider")
|
||||
.map(|value| (layer, value))
|
||||
})
|
||||
&& effective_provider.as_str() != Some(AMAZON_BEDROCK_PROVIDER_ID)
|
||||
{
|
||||
let source = format_config_layer_source(&overriding_layer.name, CONFIG_TOML_FILE);
|
||||
return Err(invalid_request(format!(
|
||||
"Amazon Bedrock login cannot select `{AMAZON_BEDROCK_PROVIDER_ID}` because {source} sets `model_provider` to {effective_provider}"
|
||||
)));
|
||||
let mut settings = vec![("aws.profile", config.profile)];
|
||||
if let Some(region) = config.region {
|
||||
settings.push(("aws.region", Some(region)));
|
||||
}
|
||||
|
||||
if layers
|
||||
.effective_config()
|
||||
.get("model_providers")
|
||||
.and_then(|providers| providers.get(AMAZON_BEDROCK_PROVIDER_ID))
|
||||
.and_then(|provider| provider.get("aws"))
|
||||
.and_then(|aws| aws.get("credential_export"))
|
||||
.is_some()
|
||||
let (write, provider) = config_manager
|
||||
.prepare_provider_config(&layers, AMAZON_BEDROCK_PROVIDER_ID, settings)
|
||||
.map_err(map_config_error)?;
|
||||
if provider
|
||||
.aws
|
||||
.is_some_and(|aws| aws.credential_export.is_some())
|
||||
{
|
||||
return Err(invalid_request(
|
||||
"Amazon Bedrock is configured to use `aws.credential_export`. Please clear this setting to use another sign-in method.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(write)
|
||||
}
|
||||
|
||||
pub(super) async fn clear_user_model_provider_if_bedrock(
|
||||
|
||||
@@ -1441,7 +1441,7 @@ async fn login_amazon_bedrock_rejects_non_bedrock_provider_override_without_chan
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"Amazon Bedrock login cannot select `amazon-bedrock` because session-flags sets `model_provider` to \"mock_provider\""
|
||||
"Cannot configure `model_provider`: Overridden by session flags"
|
||||
);
|
||||
assert_eq!(load_file_auth(codex_home.path())?, expected_auth);
|
||||
assert_eq!(read_config_toml(codex_home.path())?, expected_config);
|
||||
@@ -1470,10 +1470,9 @@ async fn login_amazon_bedrock_rejects_non_bedrock_provider_override_without_chan
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_amazon_bedrock_access_keys_rejects_overridden_aws_configuration() -> Result<()> {
|
||||
for config_override in [
|
||||
r#"model_providers.amazon-bedrock.aws.profile="other-account""#,
|
||||
r#"model_providers.amazon-bedrock.aws.region="eu-west-1""#,
|
||||
] {
|
||||
for (field, value) in [("profile", "other-account"), ("region", "eu-west-1")] {
|
||||
let key = format!("model_providers.amazon-bedrock.aws.{field}");
|
||||
let config_override = format!(r#"{key}="{value}""#);
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||
login_with_api_key(
|
||||
@@ -1488,9 +1487,11 @@ async fn login_amazon_bedrock_access_keys_rejects_overridden_aws_configuration()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.with_args(&["-c", config_override])
|
||||
.with_args(&["-c", &config_override])
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let expected_config = std::fs::read_to_string(&config_path)?;
|
||||
|
||||
let request_id = mcp
|
||||
.send_login_account_request(json!({
|
||||
@@ -1508,9 +1509,10 @@ async fn login_amazon_bedrock_access_keys_rejects_overridden_aws_configuration()
|
||||
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"Amazon Bedrock configuration cannot take effect: Overridden by session flags"
|
||||
format!("Cannot configure `{key}`: Overridden by session flags")
|
||||
);
|
||||
assert_eq!(load_file_auth(codex_home.path())?, expected_auth);
|
||||
assert_eq!(std::fs::read_to_string(&config_path)?, expected_config);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -199,7 +199,11 @@ async fn setup_bedrock_profile_and_environment() -> Result<()> {
|
||||
|
||||
let managed_home = TempDir::new()?;
|
||||
let managed_config_path = managed_home.path().join("config.toml");
|
||||
std::fs::write(&managed_config_path, "")?;
|
||||
std::fs::write(&managed_config_path, "model_provider = 'openai'")?;
|
||||
std::fs::write(
|
||||
managed_home.path().join("requirements.toml"),
|
||||
"model_provider = 'amazon-bedrock'",
|
||||
)?;
|
||||
login_with_bedrock_api_key(
|
||||
managed_home.path(),
|
||||
"managed-bedrock-api-key",
|
||||
@@ -223,7 +227,6 @@ async fn setup_bedrock_profile_and_environment() -> Result<()> {
|
||||
.as_table_mut()
|
||||
.expect("config should be a table")
|
||||
.extend(toml::toml! {
|
||||
model_provider = "amazon-bedrock"
|
||||
[model_providers.amazon-bedrock.aws]
|
||||
profile = "engineering"
|
||||
region = "us-west-2"
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! GCP setup validates before saving and shares provider configuration with subsequent requests.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::GcpSetupParams;
|
||||
use codex_app_server_protocol::GcpSetupResponse;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const CLEARED_AUTH_ENV: &[(&str, Option<&str>)] = &[
|
||||
("OPENAI_API_KEY", None),
|
||||
("CODEX_API_KEY", None),
|
||||
("OPENAI_FEDERATION_RULE_ID", None),
|
||||
("OPENAI_IDENTITY_TOKEN_FILE", None),
|
||||
("OPENAI_WORKLOAD_IDENTITY_CONTEXT", None),
|
||||
];
|
||||
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 60);
|
||||
|
||||
fn write_fake_gcloud(home: &Path) -> Result<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
let script = home.join("gcloud");
|
||||
#[cfg(unix)]
|
||||
codex_utils_cargo_bin::write_executable(
|
||||
&script,
|
||||
r#"#!/bin/sh
|
||||
if [ -f fail ]; then exit 1; fi
|
||||
printf '%s\n' "$GOOGLE_APPLICATION_CREDENTIALS" >> credential-log
|
||||
printf '%s\n' setup-token
|
||||
"#,
|
||||
)?;
|
||||
#[cfg(windows)]
|
||||
let script = home.join("gcloud.cmd");
|
||||
#[cfg(windows)]
|
||||
std::fs::write(
|
||||
&script,
|
||||
"@echo off\r\nif exist fail exit /b 1\r\nsetlocal EnableDelayedExpansion\r\necho(!GOOGLE_APPLICATION_CREDENTIALS!>>credential-log\r\nendlocal\r\necho setup-token\r\n",
|
||||
)?;
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
async fn gcp_app_server(
|
||||
home: &Path,
|
||||
env_overrides: &[(&str, Option<&str>)],
|
||||
) -> Result<TestAppServer> {
|
||||
let existing_path = std::env::var_os("PATH").unwrap_or_default();
|
||||
let child_path = std::env::join_paths(
|
||||
std::iter::once(home.to_path_buf()).chain(std::env::split_paths(&existing_path)),
|
||||
)?;
|
||||
TestAppServer::builder()
|
||||
.with_codex_home(home)
|
||||
.with_env_overrides(&[
|
||||
(
|
||||
"PATH",
|
||||
Some(child_path.to_str().context("child PATH must be UTF-8")?),
|
||||
),
|
||||
("GOOGLE_APPLICATION_CREDENTIALS", None),
|
||||
("GOOGLE_CLOUD_PROJECT", Some("ambient-project")),
|
||||
("GOOGLE_CLOUD_LOCATION", Some("ambient-location")),
|
||||
])
|
||||
.with_env_overrides(CLEARED_AUTH_ENV)
|
||||
.with_env_overrides(env_overrides)
|
||||
.build_initialized_with_timeout(READ_TIMEOUT)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn setup_error(app_server: &mut TestAppServer, params: Value) -> Result<JSONRPCErrorError> {
|
||||
let id = app_server
|
||||
.send_raw_request("account/gcp/setup", Some(params))
|
||||
.await?;
|
||||
let error = timeout(
|
||||
READ_TIMEOUT,
|
||||
app_server.read_stream_until_error_message(RequestId::Integer(id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(error.error.code, -32600);
|
||||
Ok(error.error)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn setup_gcp_saves_overrides_restores_adc_and_refreshes_catalog() -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let home = home.path();
|
||||
write_fake_gcloud(home)?;
|
||||
let mut catalog = codex_models_manager::bundled_models_response()?;
|
||||
catalog.models.retain(|model| model.slug == "gpt-6.1-sol");
|
||||
let model = catalog
|
||||
.models
|
||||
.first_mut()
|
||||
.context("bundled catalog must include a supported Vertex model")?;
|
||||
model.visibility = ModelVisibility::List;
|
||||
model.supported_in_api = true;
|
||||
let mut unsupported_model = model.clone();
|
||||
unsupported_model.slug = "test-non-vertex-model".to_string();
|
||||
catalog.models.push(unsupported_model);
|
||||
let catalog_path = home.join("models.json");
|
||||
std::fs::write(&catalog_path, serde_json::to_vec(&catalog)?)?;
|
||||
let config_path = home.join("config.toml");
|
||||
let mut expected_config = json!({
|
||||
"model_catalog_json": catalog_path,
|
||||
"model_providers": {"gcp": {"http_headers": {"X-Existing": "preserved"}}}
|
||||
});
|
||||
std::fs::write(&config_path, toml::to_string(&expected_config)?)?;
|
||||
let selected = home.join("selected.json");
|
||||
let ambient = home.join("ambient.json");
|
||||
for file in [&selected, &ambient] {
|
||||
std::fs::write(file, r#"{"type":"external_account"}"#)?;
|
||||
}
|
||||
let mut app_server = gcp_app_server(
|
||||
home,
|
||||
&[(
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
Some(ambient.to_str().context("non-UTF-8 ADC path")?),
|
||||
)],
|
||||
)
|
||||
.await?;
|
||||
let before: ModelListResponse = app_server
|
||||
.request(|request_id| ClientRequest::ModelList {
|
||||
request_id,
|
||||
params: ModelListParams::default(),
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(before.data.len(), 2);
|
||||
|
||||
let _: GcpSetupResponse = app_server
|
||||
.request(|request_id| ClientRequest::GcpSetup {
|
||||
request_id,
|
||||
params: GcpSetupParams {
|
||||
project_id: " selected-project ".to_string(),
|
||||
location: Some(" us-central1 ".to_string()),
|
||||
credential_file: Some(selected.to_string_lossy().into_owned()),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
expected_config["model_provider"] = json!("gcp");
|
||||
expected_config["model_providers"]["gcp"]["project_id"] = json!("selected-project");
|
||||
expected_config["model_providers"]["gcp"]["location"] = json!("us-central1");
|
||||
expected_config["model_providers"]["gcp"]["credential_file"] = json!(selected);
|
||||
assert_eq!(
|
||||
toml::from_str::<Value>(&std::fs::read_to_string(&config_path)?)?,
|
||||
expected_config
|
||||
);
|
||||
|
||||
let after: ModelListResponse = app_server
|
||||
.request(|request_id| ClientRequest::ModelList {
|
||||
request_id,
|
||||
params: ModelListParams::default(),
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(after.data.len(), 1);
|
||||
assert_eq!(after.data[0].model, "gpt-6.1-sol");
|
||||
|
||||
// A managed GCP selection takes precedence without replacing the user's preference.
|
||||
expected_config["model_provider"] = json!("openai");
|
||||
std::fs::write(&config_path, toml::to_string(&expected_config)?)?;
|
||||
std::fs::write(home.join("requirements.toml"), "model_provider = 'gcp'")?;
|
||||
let request_id = app_server
|
||||
.send_raw_request(
|
||||
"account/gcp/setup",
|
||||
Some(json!({"projectId": "other-project"})),
|
||||
)
|
||||
.await?;
|
||||
let _: GcpSetupResponse = app_server.read_response(request_id).await?;
|
||||
expected_config["model_providers"]["gcp"]["project_id"] = json!("other-project");
|
||||
expected_config["model_providers"]["gcp"]["location"] = json!("global");
|
||||
expected_config["model_providers"]["gcp"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("credential_file");
|
||||
assert_eq!(
|
||||
toml::from_str::<Value>(&std::fs::read_to_string(&config_path)?)?,
|
||||
expected_config
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(home.join("credential-log"))?
|
||||
.lines()
|
||||
.map(Path::new)
|
||||
.collect::<Vec<_>>(),
|
||||
[selected.as_path(), ambient.as_path()]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn setup_gcp_rejects_invalid_inputs_and_credentials_before_saving() -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let home = home.path();
|
||||
let config_path = home.join("config.toml");
|
||||
std::fs::write(&config_path, "")?;
|
||||
let invalid = home.join("invalid.json");
|
||||
std::fs::write(&invalid, r#"["authorized_user"]"#)?;
|
||||
let mut server = gcp_app_server(
|
||||
home,
|
||||
&[("PATH", Some(home.to_str().context("non-UTF-8 PATH")?))],
|
||||
)
|
||||
.await?;
|
||||
let params = json!({"projectId": "project"});
|
||||
for (request, reason) in [
|
||||
(json!({"projectId": " "}), None),
|
||||
(params.clone(), Some("gcloudNotInstalled")),
|
||||
(
|
||||
json!({"projectId": "project", "credentialFile": invalid}),
|
||||
Some("credentialFileInvalid"),
|
||||
),
|
||||
] {
|
||||
let error = setup_error(&mut server, request).await?;
|
||||
assert_eq!(error.data, reason.map(|reason| json!({ "reason": reason })));
|
||||
}
|
||||
|
||||
write_fake_gcloud(home)?;
|
||||
std::fs::write(home.join("fail"), "")?;
|
||||
let error = setup_error(&mut server, params).await?;
|
||||
assert_eq!(error.data, Some(json!({"reason": "authCommandFailed"})));
|
||||
assert_eq!(std::fs::read_to_string(config_path)?, "");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn setup_gcp_validates_the_auth_override_relative_to_the_startup_directory() -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
let startup = TempDir::new()?;
|
||||
let script = write_fake_gcloud(home.path())?;
|
||||
let auth = toml::Value::try_from(json!({"command": script, "cwd": "."}))?;
|
||||
let config_path = home.path().join("config.toml");
|
||||
std::fs::write(&config_path, "")?;
|
||||
// The same command succeeds in CODEX_HOME but fails in the catalog's startup context.
|
||||
std::fs::write(startup.path().join("fail"), "")?;
|
||||
let mut server = TestAppServer::builder()
|
||||
.with_codex_home(startup.path())
|
||||
.with_env_overrides(CLEARED_AUTH_ENV)
|
||||
.with_env_overrides(&[(
|
||||
"CODEX_HOME",
|
||||
Some(home.path().to_str().context("non-UTF-8 CODEX_HOME")?),
|
||||
)])
|
||||
.with_args(&["-c", &format!("model_providers.gcp.auth={auth}")])
|
||||
.build_initialized_with_timeout(READ_TIMEOUT)
|
||||
.await?;
|
||||
|
||||
let error = setup_error(&mut server, json!({"projectId": "project"})).await?;
|
||||
assert_eq!(error.data, Some(json!({"reason": "authCommandFailed"})));
|
||||
assert_eq!(std::fs::read_to_string(config_path)?, "");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn setup_gcp_rejects_policy_and_endpoint_conflicts_before_saving() -> Result<()> {
|
||||
for (file, policy, expected_message) in [
|
||||
(
|
||||
"managed_config.toml",
|
||||
"forced_login_method = 'chatgpt'",
|
||||
"Google Cloud setup is disabled. Use ChatGPT login instead.",
|
||||
),
|
||||
(
|
||||
"managed_config.toml",
|
||||
"[model_providers.gcp]\nproject_id = 'managed-project'",
|
||||
"Cannot configure `model_providers.gcp.project_id`: Overridden by legacy managed_config.toml",
|
||||
),
|
||||
(
|
||||
"managed_config.toml",
|
||||
"[model_providers.gcp]\nbase_url = 'https://vertex.example.com/v1'",
|
||||
"Google Cloud has a custom `base_url`",
|
||||
),
|
||||
(
|
||||
"requirements.toml",
|
||||
"model_provider = 'openai'",
|
||||
"requirements set `model_provider` to `openai`",
|
||||
),
|
||||
(
|
||||
"requirements.toml",
|
||||
"[application.network.domains]\n'oauth2.googleapis.com' = 'allow'",
|
||||
"disabled by application network restrictions",
|
||||
),
|
||||
] {
|
||||
let home = TempDir::new()?;
|
||||
let home = home.path();
|
||||
write_fake_gcloud(home)?;
|
||||
std::fs::write(home.join(file), policy)?;
|
||||
let config_path = home.join("config.toml");
|
||||
std::fs::write(&config_path, "")?;
|
||||
let mut app_server = gcp_app_server(home, &[]).await?;
|
||||
let error = setup_error(
|
||||
&mut app_server,
|
||||
json!({"projectId": "project", "credentialFile": home.join("missing.json")}),
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
error.message.contains(expected_message),
|
||||
"expected {expected_message:?}, got {:?}",
|
||||
error.message
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(config_path)?, "");
|
||||
assert!(!home.join("credential-log").exists());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn setup_gcp_stops_the_credential_command_when_network_permission_is_revoked() -> Result<()> {
|
||||
use core_test_support::process::wait_for_pid_file;
|
||||
use core_test_support::process::wait_for_process_exit;
|
||||
|
||||
let home = TempDir::new()?;
|
||||
let config_path = home.path().join("config.toml");
|
||||
let initial_config = r#"
|
||||
[model_providers.gcp.auth]
|
||||
command = "sh"
|
||||
args = ["-c", "echo $$ > refresh.pid; exec sleep 120"]
|
||||
timeout_ms = 120000
|
||||
"#;
|
||||
std::fs::write(&config_path, initial_config)?;
|
||||
let mut app_server = gcp_app_server(home.path(), &[]).await?;
|
||||
let request_id = app_server
|
||||
.send_raw_request("account/gcp/setup", Some(json!({"projectId": "project"})))
|
||||
.await?;
|
||||
let pid = wait_for_pid_file(&home.path().join("refresh.pid")).await?;
|
||||
|
||||
std::fs::write(
|
||||
home.path().join("requirements.toml"),
|
||||
"[application.network]",
|
||||
)?;
|
||||
app_server
|
||||
.send_raw_request("config/read", Some(json!({})))
|
||||
.await?;
|
||||
let error = timeout(
|
||||
READ_TIMEOUT,
|
||||
app_server.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"application network permission was revoked"
|
||||
);
|
||||
wait_for_process_exit(&pid).await?;
|
||||
assert_eq!(std::fs::read_to_string(config_path)?, initial_config);
|
||||
Ok(())
|
||||
}
|
||||
@@ -76,6 +76,8 @@ mod frozen_cloud_skills;
|
||||
mod fs;
|
||||
#[path = "gateway_oauth_tests.rs"]
|
||||
mod gateway_oauth;
|
||||
#[path = "gcp_setup_tests.rs"]
|
||||
mod gcp_setup;
|
||||
mod git_attribution;
|
||||
mod guardian_v2;
|
||||
mod history_notes_extension;
|
||||
|
||||
@@ -130,10 +130,13 @@ async fn run_provider_auth_command(
|
||||
))
|
||||
})?
|
||||
.map_err(|err| {
|
||||
io::Error::other(format!(
|
||||
"provider auth command `{}` failed to start: {err}",
|
||||
config.command
|
||||
))
|
||||
io::Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
"provider auth command `{}` failed to start: {err}",
|
||||
config.command
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
|
||||
@@ -800,40 +800,7 @@ pub fn merge_configured_model_providers(
|
||||
}
|
||||
}
|
||||
} else if key == GCP_PROVIDER_ID {
|
||||
provider
|
||||
.validate_gcp_credential_file()
|
||||
.and_then(|()| provider.validate())
|
||||
.map_err(|message| format!("model_providers.{GCP_PROVIDER_ID} {message}"))?;
|
||||
let base_url_override = provider.base_url.take();
|
||||
let auth_override = provider.auth.take();
|
||||
let credential_file_override = provider.credential_file.take();
|
||||
let http_headers_override = provider.http_headers.take();
|
||||
let project_id_override = provider.project_id.take();
|
||||
let location_override = provider.location.take();
|
||||
if provider != ModelProviderInfo::default() {
|
||||
return Err(format!(
|
||||
"model_providers.{GCP_PROVIDER_ID} only supports changing \
|
||||
`base_url`, `auth`, `http_headers`, `project_id`, `location`, and `credential_file`; other non-default provider fields are not supported"
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(built_in_provider) = model_providers.get_mut(GCP_PROVIDER_ID) {
|
||||
built_in_provider.project_id = project_id_override;
|
||||
built_in_provider.location = location_override;
|
||||
built_in_provider.credential_file = credential_file_override;
|
||||
if let Some(base_url_override) = base_url_override {
|
||||
built_in_provider.base_url = Some(base_url_override);
|
||||
}
|
||||
if let Some(auth_override) = auth_override {
|
||||
built_in_provider.auth = Some(auth_override);
|
||||
}
|
||||
if let Some(http_headers_override) = http_headers_override {
|
||||
built_in_provider
|
||||
.http_headers
|
||||
.get_or_insert_default()
|
||||
.extend(http_headers_override);
|
||||
}
|
||||
}
|
||||
merge_gcp_provider_overrides(model_providers.get_mut(&key), provider)?;
|
||||
} else {
|
||||
model_providers.entry(key).or_insert(provider);
|
||||
}
|
||||
@@ -842,6 +809,48 @@ pub fn merge_configured_model_providers(
|
||||
Ok(model_providers)
|
||||
}
|
||||
|
||||
/// Validates GCP overrides and applies them when the built-in provider is present.
|
||||
pub fn merge_gcp_provider_overrides(
|
||||
built_in: Option<&mut ModelProviderInfo>,
|
||||
mut overrides: ModelProviderInfo,
|
||||
) -> Result<(), String> {
|
||||
overrides
|
||||
.validate_gcp_credential_file()
|
||||
.and_then(|()| overrides.validate())
|
||||
.map_err(|message| format!("model_providers.{GCP_PROVIDER_ID} {message}"))?;
|
||||
let base_url = overrides.base_url.take();
|
||||
let auth = overrides.auth.take();
|
||||
let credential_file = overrides.credential_file.take();
|
||||
let http_headers = overrides.http_headers.take();
|
||||
let project_id = overrides.project_id.take();
|
||||
let location = overrides.location.take();
|
||||
if overrides != ModelProviderInfo::default() {
|
||||
return Err(format!(
|
||||
"model_providers.{GCP_PROVIDER_ID} only supports changing \
|
||||
`base_url`, `auth`, `http_headers`, `project_id`, `location`, and `credential_file`; other non-default provider fields are not supported"
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(provider) = built_in {
|
||||
provider.project_id = project_id;
|
||||
provider.location = location;
|
||||
provider.credential_file = credential_file;
|
||||
if let Some(base_url) = base_url {
|
||||
provider.base_url = Some(base_url);
|
||||
}
|
||||
if let Some(auth) = auth {
|
||||
provider.auth = Some(auth);
|
||||
}
|
||||
if let Some(http_headers) = http_headers {
|
||||
provider
|
||||
.http_headers
|
||||
.get_or_insert_default()
|
||||
.extend(http_headers);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_oss_provider(default_provider_port: u16, wire_api: WireApi) -> ModelProviderInfo {
|
||||
// These CODEX_OSS_ environment variables are experimental: we may
|
||||
// switch to reading values from config.toml instead.
|
||||
|
||||
Reference in New Issue
Block a user