diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8a3b531366..bf2f0c231e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2145,6 +2145,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-path-uri", + "codex-utils-redacted-string", "inventory", "pretty_assertions", "rmcp", @@ -2654,6 +2655,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-path", "codex-utils-path-uri", + "codex-utils-redacted-string", "core-foundation 0.9.4", "dns-lookup", "dunce", @@ -2807,6 +2809,7 @@ dependencies = [ "codex-utils-path-uri", "codex-utils-plugins", "codex-utils-pty", + "codex-utils-redacted-string", "codex-utils-stream-parser", "codex-utils-string", "codex-web-search-extension", @@ -3751,6 +3754,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-response-debug-context", + "codex-utils-redacted-string", "http 1.4.0", "pretty_assertions", "serde_json", @@ -3766,6 +3770,7 @@ dependencies = [ "codex-api", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-redacted-string", "http 1.4.0", "maplit", "pretty_assertions", @@ -3936,6 +3941,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-image", "codex-utils-path-uri", + "codex-utils-redacted-string", "codex-utils-string", "encoding_rs", "globset", @@ -4674,6 +4680,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-utils-redacted-string" +version = "0.0.0" +dependencies = [ + "schemars 0.8.22", + "serde", +] + [[package]] name = "codex-utils-rustls-provider" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 62ee03aedc..4c69bcffee 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -109,6 +109,7 @@ members = [ "utils/home-dir", "utils/pty", "utils/readiness", + "utils/redacted-string", "utils/rustls-provider", "utils/string", "utils/cli", @@ -269,6 +270,7 @@ codex-utils-path = { path = "utils/path-utils" } codex-utils-path-uri = { path = "utils/path-uri" } codex-utils-plugins = { path = "utils/plugins" } codex-utils-pty = { path = "utils/pty" } +codex-utils-redacted-string = { path = "utils/redacted-string" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } codex-utils-sandbox-summary = { path = "utils/sandbox-summary" } codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index 781aafa8d8..2122f79ba5 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -24,6 +24,7 @@ codex-secrets = { workspace = true } codex-shell-command = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } +codex-utils-redacted-string = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_with = { workspace = true } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/attestation.rs b/codex-rs/app-server-protocol/src/protocol/v2/attestation.rs index 931539b3f1..d548534c92 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/attestation.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/attestation.rs @@ -1,5 +1,6 @@ use crate::JsonSchema; use crate::TS; +use codex_utils_redacted_string::RedactedString; use serde::Deserialize; use serde::Serialize; @@ -13,5 +14,6 @@ pub struct AttestationGenerateParams {} #[ts(export_to = "v2/")] pub struct AttestationGenerateResponse { /// Opaque client attestation token. - pub token: String, + #[ts(type = "string")] + pub token: RedactedString, } diff --git a/codex-rs/app-server/src/attestation.rs b/codex-rs/app-server/src/attestation.rs index 206c38cee1..3c57382428 100644 --- a/codex-rs/app-server/src/attestation.rs +++ b/codex-rs/app-server/src/attestation.rs @@ -82,11 +82,8 @@ async fn request_attestation_header_value_with_timeout( let result = match timeout(timeout_duration, rx).await { Ok(Ok(Ok(result))) => result, Ok(Ok(Err(err))) => { - warn!( - code = err.code, - message = %err.message, - "attestation generation request failed" - ); + // Don't log err.message because it may contain a token. + warn!(code = err.code, "attestation generation request failed"); return app_server_attestation_header_value( AppServerAttestationStatus::RequestFailed, /*token*/ None, @@ -118,7 +115,10 @@ async fn request_attestation_header_value_with_timeout( Some(&response.token), ), Err(err) => { - warn!("failed to deserialize attestation generation response: {err}"); + warn!( + error_category = ?err.classify(), + "failed to deserialize attestation generation response" + ); app_server_attestation_header_value( AppServerAttestationStatus::MalformedResponse, /*token*/ None, diff --git a/codex-rs/app-server/src/external_auth.rs b/codex-rs/app-server/src/external_auth.rs index d00777f710..d1304077e0 100644 --- a/codex-rs/app-server/src/external_auth.rs +++ b/codex-rs/app-server/src/external_auth.rs @@ -49,10 +49,9 @@ impl ExternalAuthBridge { std::io::Error::other(format!("auth refresh request canceled: {err}")) })?; result.map_err(|err| { - std::io::Error::other(format!( - "auth refresh request failed: code={} message={}", - err.code, err.message - )) + // Don't log err.message because it may contain a token. + let code = err.code; + std::io::Error::other(format!("auth refresh request failed: code={code}")) })? } Err(_) => { @@ -64,13 +63,17 @@ impl ExternalAuthBridge { } }; - let response: ChatgptAuthTokensRefreshResponse = - serde_json::from_value(result).map_err(std::io::Error::other)?; + // Don't propagate parser error messages because they may contain a token. + let response: ChatgptAuthTokensRefreshResponse = serde_json::from_value(result) + .map_err(|_| std::io::Error::other("invalid auth refresh response"))?; let auth = CodexAuth::from_external_chatgpt_tokens( response.access_token.as_str(), response.chatgpt_account_id.as_str(), response.chatgpt_plan_type.as_deref(), - )?; + ) + .map_err(|err| { + std::io::Error::new(err.kind(), "auth refresh returned invalid credentials") + })?; *self .auth .write() diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 62afbca593..54d7a126af 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -829,7 +829,6 @@ impl MessageProcessor { /// Handle an error object received from the peer. pub(crate) async fn process_error(&self, err: JSONRPCError) { - tracing::error!("<- error: {:?}", err); self.outgoing.notify_client_error(err.id, err.error).await; } diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index c5dd088121..dcb670a4a4 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -408,11 +408,12 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { - warn!("client responded with error for {id:?}: {error:?}"); + // Don't log error messages or data because they may contain credentials. + warn!(code = error.code, "client responded with error for {id:?}"); self.analytics_events_client .track_server_request_aborted(now_unix_timestamp_ms(), id.clone()); - if let Err(err) = entry.callback.send(Err(error)) { - warn!("could not notify callback for {id:?} due to: {err:?}"); + if entry.callback.send(Err(error)).is_err() { + warn!("could not notify callback for {id:?}: receiver dropped"); } } None => { @@ -445,10 +446,10 @@ impl OutgoingMessageSender { self.analytics_events_client .track_server_request_aborted(now_unix_timestamp_ms(), entry.request.id().clone()); if let Some(error) = error.as_ref() - && let Err(err) = entry.callback.send(Err(error.clone())) + && entry.callback.send(Err(error.clone())).is_err() { let request_id = entry.request.id(); - warn!("could not notify callback for {request_id:?} due to: {err:?}"); + warn!("could not notify callback for {request_id:?}: receiver dropped"); } } } @@ -503,10 +504,10 @@ impl OutgoingMessageSender { self.analytics_events_client .track_server_request_aborted(now_unix_timestamp_ms(), entry.request.id().clone()); if let Some(error) = error.as_ref() - && let Err(err) = entry.callback.send(Err(error.clone())) + && entry.callback.send(Err(error.clone())).is_err() { let request_id = entry.request.id(); - warn!("could not notify callback for {request_id:?} due to: {err:?}",); + warn!("could not notify callback for {request_id:?}: receiver dropped"); } } } diff --git a/codex-rs/app-server/tests/suite/logging.rs b/codex-rs/app-server/tests/suite/logging.rs index fbd79218d2..31cf07d3df 100644 --- a/codex-rs/app-server/tests/suite/logging.rs +++ b/codex-rs/app-server/tests/suite/logging.rs @@ -1,17 +1,31 @@ use anyhow::Context; use anyhow::Result; +use app_test_support::ChatGptIdTokenClaims; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; use app_test_support::app_server_json_shutdown_event; use app_test_support::create_exec_command_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::encode_id_token; +use app_test_support::write_models_cache; +use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_features::Feature; +use codex_state::LogQuery; +use codex_state::SqliteConfig; +use codex_state::StateRuntime; +use codex_utils_absolute_path::test_support::PathExt; +use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use serde_json::Value; @@ -19,9 +33,214 @@ use serde_json::json; use tempfile::TempDir; use tokio::time::Duration; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const READ_TIMEOUT: Duration = Duration::from_secs(10); +#[tokio::test] +async fn credentials_stay_out_of_persisted_and_feedback_logs() -> Result<()> { + skip_if_no_network!(Ok(())); + + let bearer = "synthetic-provider-bearer"; + let header = "synthetic-provider-header"; + let attestation = "synthetic-attestation-token"; + let account_id = "123e4567-e89b-42d3-a456-426614174011"; + let initial_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("initial@example.com") + .chatgpt_account_id(account_id), + )?; + let refreshed_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("refreshed@example.com") + .chatgpt_account_id(account_id), + )?; + let server = MockServer::start().await; + let success = responses::sse_response(create_final_assistant_message_sse_response("done")?); + let responses = responses::mount_response_sequence( + &server, + vec![success.clone(), ResponseTemplate::new(401), success], + ) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/settings/user")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "commit_attribution_enabled": false, + }))) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let server_uri = server.uri(); + MockResponsesConfig::new(&server_uri) + .with_root_config(&format!("chatgpt_base_url = \"{server_uri}/backend-api\"")) + .with_provider_config("requires_openai_auth = true\nsupports_websockets = false") + .with_extra_config(&format!( + r#" +[model_providers.bearer_provider] +name = "Bearer provider" +base_url = "{server_uri}/v1" +experimental_bearer_token = "{bearer}" +http_headers = {{ X-Credential = "{header}" }} +supports_websockets = false +"# + )) + .write(codex_home.path())?; + write_models_cache(codex_home.path())?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .with_env_overrides(&[("OPENAI_API_KEY", None)]) + .build() + .await?; + let initialized = app_server + .initialize_with_capabilities( + ClientInfo { + name: "codex_desktop".into(), + title: None, + version: "0.1.0".into(), + }, + Some(InitializeCapabilities { + experimental_api: true, + request_attestation: true, + ..Default::default() + }), + ) + .await?; + anyhow::ensure!( + matches!(initialized, JSONRPCMessage::Response(_)), + "initialization failed" + ); + let login_id = app_server + .send_chatgpt_auth_tokens_login_request( + initial_token.clone(), + account_id.into(), + Some("pro".into()), + ) + .await?; + let _: LoginAccountResponse = app_server.read_response(login_id).await?; + + let mut thread_ids = Vec::new(); + for provider in ["bearer_provider", "mock_provider"] { + let thread = app_server + .start_thread(ThreadStartParams { + model_provider: Some(provider.into()), + ..Default::default() + }) + .await? + .thread; + app_server + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout(Duration::from_secs(/*secs*/ 60), async { + loop { + match app_server.read_next_message().await? { + JSONRPCMessage::Request(request) => { + let (request_id, result) = match ServerRequest::try_from(request)? { + ServerRequest::AttestationGenerate { request_id, .. } => { + (request_id, json!({ "token": attestation })) + } + ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => ( + request_id, + json!({ + "accessToken": refreshed_token, + "chatgptAccountId": account_id, + "chatgptPlanType": "pro", + }), + ), + request => anyhow::bail!("unexpected request: {request:?}"), + }; + app_server.send_response(request_id, result).await?; + } + JSONRPCMessage::Notification(notification) + if notification.method == "turn/completed" => + { + let params = notification + .params + .context("missing turn/completed params")?; + assert_eq!(params["turn"]["status"], "completed"); + break Ok::<_, anyhow::Error>(()); + } + JSONRPCMessage::Error(error) => anyhow::bail!("unexpected error: {error:?}"), + JSONRPCMessage::Response(_) | JSONRPCMessage::Notification(_) => {} + } + } + }) + .await??; + thread_ids.push(thread.id); + } + let requests = responses.requests(); + assert_eq!( + requests + .iter() + .map(|request| request.header("authorization")) + .collect::>(), + vec![ + Some(format!("Bearer {bearer}")), + Some(format!("Bearer {initial_token}")), + Some(format!("Bearer {refreshed_token}")), + ] + ); + assert_eq!(requests[0].header("x-credential").as_deref(), Some(header)); + assert_eq!( + requests[2].header("x-oai-attestation"), + Some(format!(r#"{{"v":1,"s":0,"t":"{attestation}"}}"#)) + ); + + // Wait for a later event so buffered logs cannot hide a leak. + let barrier = "credential-log-barrier"; + app_server + .send_response(RequestId::String(barrier.into()), json!({})) + .await?; + let state = StateRuntime::init( + SqliteConfig::new_for_testing(codex_home.path().abs()), + "mock_provider".into(), + ) + .await?; + let thread_ids = thread_ids.iter().map(String::as_str).collect::>(); + let feedback = timeout(Duration::from_secs(/*secs*/ 60), async { + loop { + let logs = + String::from_utf8(state.query_feedback_logs_for_threads(&thread_ids).await?)?; + if logs.contains(barrier) { + break Ok::<_, anyhow::Error>(logs); + } + tokio::time::sleep(Duration::from_millis(/*millis*/ 50)).await; + } + }) + .await??; + let persisted = format!("{:?}", state.query_logs(&LogQuery::default()).await?); + state.close().await; + // The HTTP assertions above prove the credentials were used. The barrier + // confirms earlier queued logs were persisted before we check for leaks. + for (sink, logs) in [("SQLite", persisted), ("feedback", feedback)] { + anyhow::ensure!(logs.contains(barrier), "missing log barrier in {sink} logs"); + for secret in [ + bearer, + header, + &initial_token, + &refreshed_token, + attestation, + ] { + anyhow::ensure!( + !logs.contains(secret), + "credential leaked into {sink} logs: {secret}" + ); + } + } + Ok(()) +} + #[test] fn standalone_app_server_emits_json_info_events() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index 5ffdb90800..0260b05808 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -135,7 +135,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand mcp.send_response( request_id, serde_json::to_value(AttestationGenerateResponse { - token: ATTESTATION_HEADER.to_string(), + token: ATTESTATION_HEADER.into(), })?, ) .await?; diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index db312c9c7e..6228c77d91 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2578,6 +2578,12 @@ impl ProviderAuthReachabilityMode { } fn provider_reachability_plan(config: &Config) -> ReachabilityPlan { + let query_params = config.model_provider.query_params.as_ref().map(|params| { + params + .iter() + .map(|(name, value)| (name.clone(), value.as_str().to_owned())) + .collect::>() + }); let stored_auth = load_auth_dot_json( &config.codex_home, config.cli_auth_credentials_store_mode, @@ -2597,7 +2603,7 @@ fn provider_reachability_plan(config: &Config) -> ReachabilityPlan { &config.model_provider_id, &config.model_provider.name, config.model_provider.base_url.as_deref(), - config.model_provider.query_params.as_ref(), + query_params.as_ref(), config.model_provider.is_amazon_bedrock(), &config.chatgpt_base_url, ); diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 4fe529d743..37a1c32439 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -24,6 +24,7 @@ codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } codex-utils-path-uri = { workspace = true } +codex-utils-redacted-string = { workspace = true } dunce = { workspace = true } futures = { workspace = true, features = ["alloc", "std"] } gethostname = { workspace = true } diff --git a/codex-rs/config/src/thread_config/remote.rs b/codex-rs/config/src/thread_config/remote.rs index 2a52c3b657..8d5b8febe4 100644 --- a/codex-rs/config/src/thread_config/remote.rs +++ b/codex-rs/config/src/thread_config/remote.rs @@ -7,6 +7,7 @@ use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::config_types::ModelProviderAuthInfo; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_redacted_string::RedactedString; use super::SessionThreadConfig; use super::ThreadConfigContext; @@ -174,15 +175,15 @@ fn model_provider_from_proto( base_url: provider.base_url, env_key: provider.env_key, env_key_instructions: provider.env_key_instructions, - experimental_bearer_token: provider.experimental_bearer_token, + experimental_bearer_token: provider.experimental_bearer_token.map(Into::into), auth: provider .auth .map(model_provider_auth_from_proto) .transpose()?, aws: None, wire_api, - query_params: provider.query_params.map(|map| map.values), - http_headers: provider.http_headers.map(|map| map.values), + query_params: provider.query_params.map(redacted_string_map), + http_headers: provider.http_headers.map(redacted_string_map), env_http_headers: provider.env_http_headers.map(|map| map.values), request_max_retries: provider.request_max_retries, stream_max_retries: provider.stream_max_retries, @@ -227,12 +228,12 @@ fn model_provider_to_proto( base_url, env_key, env_key_instructions, - experimental_bearer_token, + experimental_bearer_token: experimental_bearer_token.map(RedactedString::into_inner), auth: auth.map(model_provider_auth_to_proto), wire_api: proto_wire_api(wire_api).into(), query_params: query_params.map(proto_string_map), http_headers: http_headers.map(proto_string_map), - env_http_headers: env_http_headers.map(proto_string_map), + env_http_headers: env_http_headers.map(|values| proto::StringMap { values }), request_max_retries, stream_max_retries, stream_idle_timeout_ms, @@ -257,7 +258,7 @@ fn model_provider_auth_from_proto( Ok(ModelProviderAuthInfo { command: auth.command, - args: auth.args, + args: auth.args.into_iter().map(RedactedString::from).collect(), timeout_ms, refresh_interval_ms: auth.refresh_interval_ms, cwd, @@ -276,16 +277,28 @@ fn model_provider_auth_to_proto(auth: ModelProviderAuthInfo) -> proto::ModelProv proto::ModelProviderAuthInfo { command, - args, + args: args.into_iter().map(RedactedString::into_inner).collect(), timeout_ms: timeout_ms.get(), refresh_interval_ms, cwd: cwd.to_string_lossy().into_owned(), } } +fn redacted_string_map(map: proto::StringMap) -> HashMap { + map.values + .into_iter() + .map(|(name, value)| (name, value.into())) + .collect() +} + #[cfg(test)] -fn proto_string_map(values: HashMap) -> proto::StringMap { - proto::StringMap { values } +fn proto_string_map(values: HashMap) -> proto::StringMap { + proto::StringMap { + values: values + .into_iter() + .map(|(name, value)| (name, value.into_inner())) + .collect(), + } } #[cfg(test)] @@ -422,7 +435,9 @@ mod tests { #[test] fn model_provider_proto_roundtrips_through_domain_type() { - let expected = expected_provider(); + let mut expected = expected_provider(); + expected.auth = None; + expected.experimental_bearer_token = Some("synthetic-provider-token".into()); let proto = model_provider_to_proto("local", expected.clone()); assert!(proto.supports_standalone_web_search); let (id, actual) = model_provider_from_proto(proto).expect("model provider from proto"); @@ -531,7 +546,7 @@ mod tests { experimental_bearer_token: None, auth: Some(ModelProviderAuthInfo { command: "token-helper".to_string(), - args: vec!["--json".to_string()], + args: vec!["--json".into()], timeout_ms: NonZeroU64::new(5_000).expect("non-zero timeout"), refresh_interval_ms: 300_000, cwd: workspace_dir(), @@ -539,12 +554,9 @@ mod tests { wire_api: WireApi::Responses, query_params: Some(HashMap::from([( "api-version".to_string(), - "2026-04-16".to_string(), - )])), - http_headers: Some(HashMap::from([( - "X-Test".to_string(), - "enabled".to_string(), + "2026-04-16".into(), )])), + http_headers: Some(HashMap::from([("X-Test".to_string(), "enabled".into())])), env_http_headers: Some(HashMap::from([( "X-Env".to_string(), "LOCAL_HEADER".to_string(), diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index ff683fb5f9..10817c6a60 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -146,6 +146,7 @@ codex-home = { workspace = true } codex-otel = { workspace = true } codex-test-binary-support = { workspace = true } codex-utils-cargo-bin = { workspace = true } +codex-utils-redacted-string = { workspace = true } codex-web-search-extension = { workspace = true } core_test_support = { workspace = true } ctor = { workspace = true } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b00e1ee1d4..56afc33648 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -998,10 +998,8 @@ env_http_headers = { "x-openai-internal-codex-residency" = "CODEX_TEST_UNSET_RES .as_ref() .expect("environment-backed headers should remain configured"); assert_eq!( - static_headers - .get("X-OpenAI-Internal-Codex-Residency") - .map(String::as_str), - Some("request-override") + static_headers.get("X-OpenAI-Internal-Codex-Residency"), + Some(&"request-override".into()) ); assert_eq!( environment_headers @@ -1010,8 +1008,8 @@ env_http_headers = { "x-openai-internal-codex-residency" = "CODEX_TEST_UNSET_RES Some("CODEX_TEST_UNSET_RESIDENCY_HEADER") ); assert_eq!( - static_headers.get("x-provider-header").map(String::as_str), - Some("preserved") + static_headers.get("x-provider-header"), + Some(&"preserved".into()) ); assert_eq!( environment_headers @@ -1140,7 +1138,7 @@ command = "print-token" expected_provider .http_headers .get_or_insert_default() - .insert("X-Custom-Header".to_string(), "value".to_string()); + .insert("X-Custom-Header".to_string(), "value".into()); assert_eq!(config.model_provider_id, "amazon-bedrock"); assert_eq!(config.model_provider, expected_provider); diff --git a/codex-rs/core/src/config/requirements.rs b/codex-rs/core/src/config/requirements.rs index 1ac2c26ef0..a9bb8f9eab 100644 --- a/codex-rs/core/src/config/requirements.rs +++ b/codex-rs/core/src/config/requirements.rs @@ -7,6 +7,7 @@ use codex_config::types::FeedbackConfigToml; use codex_features::FeatureToml; use codex_login::default_client::RESIDENCY_HEADER_NAME; use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; use std::path::Path; /// Applies managed requirements to regular config before final config construction. @@ -50,14 +51,12 @@ pub(super) fn apply_to_config( ); if requirements.enforce_residency.value().is_some() { for (provider_name, provider) in &config.model_providers { - let has_residency_header = [&provider.http_headers, &provider.env_http_headers] - .into_iter() - .flatten() - .any(|headers| { - headers - .keys() - .any(|name| name.eq_ignore_ascii_case(RESIDENCY_HEADER_NAME)) - }); + let has_residency_header = provider + .http_headers + .iter() + .flat_map(HashMap::keys) + .chain(provider.env_http_headers.iter().flat_map(HashMap::keys)) + .any(|name| name.eq_ignore_ascii_case(RESIDENCY_HEADER_NAME)); if has_residency_header { let warning = format!( diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index e158c0d941..7b4bb5260c 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -1681,7 +1681,7 @@ fn realtime_api_key(auth: Option<&CodexAuth>, provider: &ModelProviderInfo) -> C } if let Some(token) = provider.experimental_bearer_token.clone() { - return Ok(token); + return Ok(token.into_inner()); } if let Some(api_key) = auth.and_then(CodexAuth::api_key) { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 0fcba50dbf..e4dbce2193 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -796,7 +796,7 @@ move /y tokens.next tokens.txt >nul fn auth(&self) -> ModelProviderAuthInfo { ModelProviderAuthInfo { command: self.command.clone(), - args: self.args.clone(), + args: self.args.iter().cloned().map(Into::into).collect(), // Match the model-provider default to avoid brittle shell-startup timing in CI. timeout_ms: non_zero_u64(/*value*/ 5_000), refresh_interval_ms: 60_000, @@ -1438,7 +1438,7 @@ async fn amazon_bedrock_proxy_uses_command_auth_and_custom_headers() { provider .http_headers .get_or_insert_default() - .insert("x-some-header".to_string(), "foo".to_string()); + .insert("x-some-header".to_string(), "foo".into()); send_request_with_provider(provider).await; @@ -3585,13 +3585,13 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { aws: None, query_params: Some(std::collections::HashMap::from([( "api-version".to_string(), - "2025-04-01-preview".to_string(), + "2025-04-01-preview".into(), )])), env_key_instructions: None, wire_api: WireApi::Responses, http_headers: Some(std::collections::HashMap::from([( "Custom-Header".to_string(), - "Value".to_string(), + "Value".into(), )])), env_http_headers: None, request_max_retries: None, @@ -3666,7 +3666,7 @@ async fn env_var_overrides_loaded_auth() { env_key: Some(EXISTING_ENV_VAR_WITH_NON_EMPTY_VALUE.to_string()), query_params: Some(std::collections::HashMap::from([( "api-version".to_string(), - "2025-04-01-preview".to_string(), + "2025-04-01-preview".into(), )])), env_key_instructions: None, experimental_bearer_token: None, @@ -3675,7 +3675,7 @@ async fn env_var_overrides_loaded_auth() { wire_api: WireApi::Responses, http_headers: Some(std::collections::HashMap::from([( "Custom-Header".to_string(), - "Value".to_string(), + "Value".into(), )])), env_http_headers: None, request_max_retries: None, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 601389938d..c797cbdd4f 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -228,7 +228,7 @@ async fn responses_websocket_omits_routing_hint_for_provider_with_own_credential let mut provider = websocket_provider(&server); provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name; - provider.experimental_bearer_token = Some("provider-specific-token".to_string()); + provider.experimental_bearer_token = Some("provider-specific-token".into()); let harness = websocket_harness_with_provider_options_and_auth( provider, /*runtime_metrics_enabled*/ false, diff --git a/codex-rs/core/tests/suite/external_auth.rs b/codex-rs/core/tests/suite/external_auth.rs index 9fad8edd71..2b5593c23d 100644 --- a/codex-rs/core/tests/suite/external_auth.rs +++ b/codex-rs/core/tests/suite/external_auth.rs @@ -8,6 +8,7 @@ use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_model_provider_info::create_oss_provider_with_base_url; +use codex_utils_redacted_string::RedactedString; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -160,7 +161,7 @@ async fn custom_provider_uses_explicit_bearer_without_ambient_account() -> anyho ); let mut provider = create_oss_provider_with_base_url(&format!("{}/v1", server.uri()), WireApi::Responses); - provider.experimental_bearer_token = Some("provider-token".to_string()); + provider.experimental_bearer_token = Some("provider-token".into()); let mut builder = test_codex() .with_auth(CodexAuth::Headers(AuthHeaders::new(headers))) .with_config(move |config| { @@ -253,7 +254,9 @@ async fn amazon_bedrock_aws_auth_refresh_resigns() -> anyhow::Result<()> { region: Some("us-east-1".to_string()), auth_refresh: Some(AwsAuthRefreshConfig { command: "aws".to_string(), - args: Vec::from(["--exact", TEST_NAME, "--skip", HELPER_ARG].map(str::to_string)), + args: Vec::from( + ["--exact", TEST_NAME, "--skip", HELPER_ARG].map(RedactedString::from), + ), timeout_ms: NonZeroU64::new(30_000).expect("timeout should be non-zero"), }), })); diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index 3cafa0c8ff..3179f040d1 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -365,7 +365,7 @@ async fn responses_lite_exposes_standalone_tools_for_actor_authorized_provider() config.model_provider.requires_openai_auth = false; config.model_provider.http_headers = Some(HashMap::from([( "x-openai-actor-authorization".to_string(), - "test-actor-authorization".to_string(), + "test-actor-authorization".into(), )])); }); let test = builder.build(&server).await?; diff --git a/codex-rs/login/src/auth/external_bearer.rs b/codex-rs/login/src/auth/external_bearer.rs index 0a276543ea..9f3fb64871 100644 --- a/codex-rs/login/src/auth/external_bearer.rs +++ b/codex-rs/login/src/auth/external_bearer.rs @@ -5,6 +5,7 @@ use super::manager::ExternalAuthRefreshContext; use codex_protocol::config_types::ModelProviderAuthInfo; use std::fmt; use std::io; +use std::ops::Deref; use std::path::Path; use std::path::PathBuf; use std::process::Stdio; @@ -103,7 +104,7 @@ async fn run_provider_auth_command(config: &ModelProviderAuthInfo) -> io::Result let program = resolve_provider_auth_program(&config.command, &config.cwd)?; let mut command = Command::new(&program); command - .args(&config.args) + .args(config.args.iter().map(Deref::deref)) .current_dir(config.cwd.as_path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) diff --git a/codex-rs/model-provider-info/Cargo.toml b/codex-rs/model-provider-info/Cargo.toml index da018acf66..827dd8744b 100644 --- a/codex-rs/model-provider-info/Cargo.toml +++ b/codex-rs/model-provider-info/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [dependencies] codex-api = { workspace = true } codex-protocol = { workspace = true } +codex-utils-redacted-string = { workspace = true } http = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 3d58aadd7a..f631e7c6f1 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -12,6 +12,7 @@ use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::error::CodexErr; use codex_protocol::error::EnvVarError; use codex_protocol::error::Result as CodexResult; +use codex_utils_redacted_string::RedactedString; use http::HeaderMap; use http::header::HeaderName; use http::header::HeaderValue; @@ -107,7 +108,7 @@ pub struct ModelProviderInfo { /// Value to use with `Authorization: Bearer ` header. Use of this /// config is discouraged in favor of `env_key` for security reasons, but /// this may be necessary when using this programmatically. - pub experimental_bearer_token: Option, + pub experimental_bearer_token: Option, /// Command-backed bearer-token configuration for this provider. pub auth: Option, /// AWS SigV4 auth configuration for this provider. @@ -116,10 +117,10 @@ pub struct ModelProviderInfo { #[serde(default)] pub wire_api: WireApi, /// Optional query parameters to append to the base URL. - pub query_params: Option>, + pub query_params: Option>, /// Additional HTTP headers to include in requests to this provider where /// the (key, value) pairs are the header name and value. - pub http_headers: Option>, + pub http_headers: Option>, /// Optional HTTP headers to include in requests to this provider where the /// (key, value) pairs are the header name and _environment variable_ whose /// value should be used. If the environment variable is not set, or the @@ -169,7 +170,7 @@ pub struct AwsAuthRefreshConfig { pub command: String, /// Arguments passed to the refresh command. #[serde(default)] - pub args: Vec, + pub args: Vec, /// Maximum time to wait for the refresh command to complete. #[serde(default = "default_aws_auth_refresh_timeout_ms")] pub timeout_ms: NonZeroU64, @@ -265,7 +266,9 @@ impl ModelProviderInfo { let mut headers = HeaderMap::with_capacity(capacity); if let Some(extra) = &self.http_headers { for (k, v) in extra { - if let (Ok(name), Ok(value)) = (HeaderName::try_from(k), HeaderValue::try_from(v)) { + if let (Ok(name), Ok(value)) = + (HeaderName::try_from(k), HeaderValue::try_from(v.as_str())) + { headers.insert(name, value); } } @@ -318,7 +321,12 @@ impl ModelProviderInfo { Ok(ApiProvider { name: self.name.clone(), base_url, - query_params: self.query_params.clone(), + query_params: self.query_params.clone().map(|params| { + params + .into_iter() + .map(|(name, value)| (name, value.into_inner())) + .collect() + }), headers, retry, stream_idle_timeout: self.stream_idle_timeout(), @@ -386,7 +394,7 @@ impl ModelProviderInfo { wire_api: WireApi::Responses, query_params: None, http_headers: Some( - [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())] + [("version".to_string(), env!("CARGO_PKG_VERSION").into())] .into_iter() .collect(), ), @@ -434,7 +442,7 @@ impl ModelProviderInfo { query_params: None, http_headers: Some(HashMap::from([( AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_HEADER.to_string(), - AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_VALUE.to_string(), + AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_VALUE.into(), )])), env_http_headers: None, request_max_retries: None, diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index cb8dd5cfc9..c70d59b39a 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -54,7 +54,7 @@ query_params = { api-version = "2025-04-01-preview" } aws: None, wire_api: WireApi::Responses, query_params: Some(maplit::hashmap! { - "api-version".to_string() => "2025-04-01-preview".to_string(), + "api-version".to_string() => "2025-04-01-preview".into(), }), http_headers: None, env_http_headers: None, @@ -92,7 +92,7 @@ supports_standalone_web_search = true wire_api: WireApi::Responses, query_params: None, http_headers: Some(maplit::hashmap! { - "X-Example-Header".to_string() => "example-value".to_string(), + "X-Example-Header".to_string() => "example-value".into(), }), env_http_headers: Some(maplit::hashmap! { "X-Example-Env-Header".to_string() => "EXAMPLE_ENV_VAR".to_string(), @@ -158,7 +158,7 @@ fn test_header_auth_uses_chatgpt_codex_base_url() { fn test_uses_openai_actor_authorization() { let mut provider = ModelProviderInfo { http_headers: Some(maplit::hashmap! { - "X-OpenAI-Actor-Authorization".to_string() => "actor-token".to_string(), + "X-OpenAI-Actor-Authorization".to_string() => "actor-token".into(), }), ..ModelProviderInfo::default() }; @@ -168,12 +168,12 @@ fn test_uses_openai_actor_authorization() { assert!(!provider.uses_openai_actor_authorization()); provider.http_headers = Some(maplit::hashmap! { - OPENAI_ACTOR_AUTHORIZATION_HEADER.to_string() => " ".to_string(), + OPENAI_ACTOR_AUTHORIZATION_HEADER.to_string() => " ".into(), }); assert!(!provider.uses_openai_actor_authorization()); provider.http_headers = Some(maplit::hashmap! { - OPENAI_ACTOR_AUTHORIZATION_HEADER.to_string() => "actor-token".to_string(), + OPENAI_ACTOR_AUTHORIZATION_HEADER.to_string() => "actor-token".into(), }); provider.requires_openai_auth = true; assert!(!provider.uses_openai_actor_authorization()); @@ -199,7 +199,7 @@ args = ["--format=text"] provider.auth, Some(ModelProviderAuthInfo { command: "./scripts/print-token".to_string(), - args: vec!["--format=text".to_string()], + args: vec!["--format=text".into()], timeout_ms: NonZeroU64::new(5_000).unwrap(), refresh_interval_ms: 300_000, cwd: AbsolutePathBuf::resolve_path_against_base(".", base_dir.path()), @@ -231,11 +231,7 @@ args = ["login", "--profile", "codex-bedrock"] region: Some("us-west-2".to_string()), auth_refresh: Some(AwsAuthRefreshConfig { command: "aws".to_string(), - args: vec![ - "login".to_string(), - "--profile".to_string(), - "codex-bedrock".to_string(), - ], + args: vec!["login".into(), "--profile".into(), "codex-bedrock".into()], timeout_ms: NonZeroU64::new(300_000).expect("timeout should be non-zero"), }), }) @@ -262,7 +258,7 @@ fn test_create_amazon_bedrock_provider() { query_params: None, http_headers: Some(maplit::hashmap! { AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_HEADER.to_string() => - AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_VALUE.to_string(), + AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_VALUE.into(), }), env_http_headers: None, request_max_retries: None, @@ -320,7 +316,7 @@ fn test_create_amazon_bedrock_runtime_provider_with_aws_configuration() { fn provider_auth_for_test() -> ModelProviderAuthInfo { ModelProviderAuthInfo { command: "token-fetcher".to_string(), - args: vec!["fetch".to_string()], + args: vec!["fetch".into()], timeout_ms: NonZeroU64::new(5_000).expect("timeout should be non-zero"), refresh_interval_ms: 300_000, cwd: std::env::current_dir() @@ -408,11 +404,7 @@ fn test_merge_configured_model_providers_adds_custom_provider() { fn test_merge_configured_model_providers_applies_amazon_bedrock_aws_override() { let auth_refresh = AwsAuthRefreshConfig { command: "aws".to_string(), - args: vec![ - "login".to_string(), - "--profile".to_string(), - "codex-bedrock".to_string(), - ], + args: vec!["login".into(), "--profile".into(), "codex-bedrock".into()], timeout_ms: NonZeroU64::new(10_000).expect("timeout should be non-zero"), }; let configured_model_providers = std::collections::HashMap::from([( @@ -491,7 +483,7 @@ fn test_merge_configured_model_providers_applies_amazon_bedrock_transport_overri auth_refresh: None, }), http_headers: Some(maplit::hashmap! { - "x-example-header".to_string() => "value".to_string(), + "x-example-header".to_string() => "value".into(), }), ..ModelProviderInfo::default() }, @@ -511,7 +503,7 @@ fn test_merge_configured_model_providers_applies_amazon_bedrock_transport_overri expected_provider .http_headers .get_or_insert_default() - .insert("x-example-header".to_string(), "value".to_string()); + .insert("x-example-header".to_string(), "value".into()); assert_eq!( merge_configured_model_providers( diff --git a/codex-rs/model-provider/Cargo.toml b/codex-rs/model-provider/Cargo.toml index bbb665a19e..e72a55a3a7 100644 --- a/codex-rs/model-provider/Cargo.toml +++ b/codex-rs/model-provider/Cargo.toml @@ -29,6 +29,7 @@ tokio = { workspace = true, features = ["process", "sync", "time"] } tracing = { workspace = true, features = ["log"] } [dev-dependencies] +codex-utils-redacted-string = { workspace = true } pretty_assertions = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/codex-rs/model-provider/src/amazon_bedrock/auth_refresh.rs b/codex-rs/model-provider/src/amazon_bedrock/auth_refresh.rs index 213be9f47d..d15b5673dd 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/auth_refresh.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/auth_refresh.rs @@ -1,4 +1,5 @@ use std::io::IsTerminal; +use std::ops::Deref; use std::process::Stdio; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; @@ -44,7 +45,7 @@ impl AwsAuthRecovery { let mut command = Command::new(&self.config.command); command - .args(&self.config.args) + .args(self.config.args.iter().map(Deref::deref)) .stdin(if std::io::stdin().is_terminal() { Stdio::inherit() } else { diff --git a/codex-rs/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index 5c92cae264..1310281860 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -318,7 +318,7 @@ mod tests { provider.base_url = base_url.map(str::to_string); provider.auth = Some(ModelProviderAuthInfo { command: "token-fetcher".to_string(), - args: vec!["fetch".to_string()], + args: vec!["fetch".into()], timeout_ms: NonZeroU64::new(5_000).expect("timeout should be non-zero"), refresh_interval_ms: 300_000, cwd: std::env::current_dir() @@ -353,7 +353,7 @@ mod tests { region: Some("us-west-2".to_string()), auth_refresh: Some(AwsAuthRefreshConfig { command: "aws".to_string(), - args: vec!["login".to_string()], + args: vec!["login".into()], timeout_ms: NonZeroU64::new(1_000).expect("timeout should be non-zero"), }), }); @@ -397,7 +397,7 @@ mod tests { region: Some("us-west-2".to_string()), auth_refresh: Some(AwsAuthRefreshConfig { command: "aws".to_string(), - args: vec!["login".to_string()], + args: vec!["login".into()], timeout_ms: NonZeroU64::new(1_000).expect("timeout should be non-zero"), }), })), diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 9e2dd61782..8907ce1578 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -294,7 +294,7 @@ fn bearer_auth_for_provider( } if let Some(token) = provider.experimental_bearer_token.clone() { - return Ok(Some(BearerAuthProvider::new(token))); + return Ok(Some(BearerAuthProvider::new(token.into_inner()))); } Ok(None) @@ -512,7 +512,7 @@ mod tests { fn custom_provider_uses_explicit_bearer_instead_of_ambient_auth() { let mut provider = create_oss_provider_with_base_url("http://localhost:11434/v1", WireApi::Responses); - provider.experimental_bearer_token = Some("provider-token".to_string()); + provider.experimental_bearer_token = Some("provider-token".into()); let ambient_auth = CodexAuth::BedrockApiKey(BedrockApiKeyAuth { api_key: "bedrock-api-key-test".to_string(), region: "us-east-1".to_string(), diff --git a/codex-rs/model-provider/src/models_endpoint.rs b/codex-rs/model-provider/src/models_endpoint.rs index e0a01ee5f4..4ccb5b7823 100644 --- a/codex-rs/model-provider/src/models_endpoint.rs +++ b/codex-rs/model-provider/src/models_endpoint.rs @@ -413,7 +413,7 @@ mod tests { let mut provider_info = ModelProviderInfo::create_openai_provider(Some(server.uri())); provider_info.http_headers = Some(std::collections::HashMap::from([( RESIDENCY_HEADER_NAME.to_string(), - "eu".to_string(), + "eu".into(), )])); let endpoint = OpenAiModelsEndpoint { provider_info, @@ -438,9 +438,8 @@ mod tests { .provider_info .http_headers .as_ref() - .and_then(|headers| headers.get(RESIDENCY_HEADER_NAME)) - .map(String::as_str), - Some("eu") + .and_then(|headers| headers.get(RESIDENCY_HEADER_NAME)), + Some(&"eu".into()) ); } } diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index df3755d937..44310865a7 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -519,6 +519,7 @@ mod tests { use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::SessionSource; + use codex_utils_redacted_string::RedactedString; use pretty_assertions::assert_eq; use serde_json::json; use wiremock::Mock; @@ -821,7 +822,7 @@ mod tests { "--skip", counter.to_str().expect("counter path should be UTF-8"), ] - .map(str::to_string), + .map(RedactedString::from), ), timeout_ms: NonZeroU64::new(10_000).expect("timeout should be non-zero"), }), @@ -1169,7 +1170,7 @@ mod tests { .await; let mut provider_info = provider_for(server.uri()); - provider_info.experimental_bearer_token = Some("provider-token".to_string()); + provider_info.experimental_bearer_token = Some("provider-token".into()); let provider = create_model_provider( provider_info, Some(AuthManager::from_auth_for_testing( diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index d80a80f7f2..f7f4b779d5 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -23,6 +23,7 @@ codex-network-proxy = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-image = { workspace = true } codex-utils-path-uri = { workspace = true } +codex-utils-redacted-string = { workspace = true } codex-utils-string = { workspace = true } encoding_rs = { workspace = true } globset = { workspace = true } diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 6db800f7f6..b5ac448ebc 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -1,4 +1,5 @@ use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_redacted_string::RedactedString; use schemars::JsonSchema; use schemars::r#gen::SchemaGenerator; use schemars::schema::InstanceType; @@ -571,7 +572,7 @@ pub struct ModelProviderAuthInfo { /// Command arguments. #[serde(default)] - pub args: Vec, + pub args: Vec, /// Maximum time to wait for the token command to exit successfully. #[serde(default = "default_provider_auth_timeout_ms")] diff --git a/codex-rs/utils/redacted-string/BUILD.bazel b/codex-rs/utils/redacted-string/BUILD.bazel new file mode 100644 index 0000000000..d3ef3a34b7 --- /dev/null +++ b/codex-rs/utils/redacted-string/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "redacted-string", + crate_name = "codex_utils_redacted_string", +) diff --git a/codex-rs/utils/redacted-string/Cargo.toml b/codex-rs/utils/redacted-string/Cargo.toml new file mode 100644 index 0000000000..39f60d8eb1 --- /dev/null +++ b/codex-rs/utils/redacted-string/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "codex-utils-redacted-string" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } + +[lib] +test = false +doctest = false diff --git a/codex-rs/utils/redacted-string/src/lib.rs b/codex-rs/utils/redacted-string/src/lib.rs new file mode 100644 index 0000000000..e0ec95e6e9 --- /dev/null +++ b/codex-rs/utils/redacted-string/src/lib.rs @@ -0,0 +1,49 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::fmt; +use std::ops::Deref; +use std::ops::DerefMut; + +/// A string whose `Debug` output is redacted. +#[derive(Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +pub struct RedactedString(String); + +impl RedactedString { + pub fn into_inner(self) -> String { + self.0 + } +} + +impl Deref for RedactedString { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for RedactedString { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for RedactedString { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From<&str> for RedactedString { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } +} + +impl fmt::Debug for RedactedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("") + } +}