Surface model provider authentication recovery progress (#41239)

## What changed

- Emit turn-scoped authentication recovery started and completed events when a model provider refreshes expired credentials.
- Add stable `modelProvider/authRecoveryStarted` and `modelProvider/authRecoveryCompleted` app-server notifications with the thread, turn, provider, and user-facing message.
- Show recovery progress in the TUI and `codex exec`, including Amazon Bedrock session reauthentication.

## Testing

- Cover provider recovery success and failure events, app-server routing, client rendering, and Amazon Bedrock credential refresh.

GitOrigin-RevId: 3010c38d0676f18bced27761cf86dd38344d09f9
This commit is contained in:
alexsong-oai
2026-08-28 01:14:02 +00:00
committed by copyberry
parent f1bb4c168d
commit 41d3dc56a0
33 changed files with 611 additions and 11 deletions
@@ -583,6 +583,29 @@
}
]
},
"AuthRecoveryNotification": {
"properties": {
"message": {
"type": "string"
},
"provider": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"message",
"provider",
"threadId",
"turnId"
],
"type": "object"
},
"AutoReviewDecisionSource": {
"description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.",
"enum": [
@@ -7779,6 +7802,46 @@
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"modelProvider/authRecoveryStarted"
],
"title": "ModelProvider/authRecoveryStartedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AuthRecoveryNotification"
}
},
"required": [
"method",
"params"
],
"title": "ModelProvider/authRecoveryStartedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"modelProvider/authRecoveryCompleted"
],
"title": "ModelProvider/authRecoveryCompletedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AuthRecoveryNotification"
}
},
"required": [
"method",
"params"
],
"title": "ModelProvider/authRecoveryCompletedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -5572,6 +5572,46 @@
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"modelProvider/authRecoveryStarted"
],
"title": "ModelProvider/authRecoveryStartedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/AuthRecoveryNotification"
}
},
"required": [
"method",
"params"
],
"title": "ModelProvider/authRecoveryStartedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"modelProvider/authRecoveryCompleted"
],
"title": "ModelProvider/authRecoveryCompletedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/AuthRecoveryNotification"
}
},
"required": [
"method",
"params"
],
"title": "ModelProvider/authRecoveryCompletedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -7669,6 +7709,31 @@
}
]
},
"AuthRecoveryNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"message": {
"type": "string"
},
"provider": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"message",
"provider",
"threadId",
"turnId"
],
"title": "AuthRecoveryNotification",
"type": "object"
},
"AutoCompactTokenLimitScope": {
"description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.",
"oneOf": [
@@ -1245,6 +1245,31 @@
}
]
},
"AuthRecoveryNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"message": {
"type": "string"
},
"provider": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"message",
"provider",
"threadId",
"turnId"
],
"title": "AuthRecoveryNotification",
"type": "object"
},
"AutoCompactTokenLimitScope": {
"description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.",
"oneOf": [
@@ -16430,6 +16455,46 @@
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"modelProvider/authRecoveryStarted"
],
"title": "ModelProvider/authRecoveryStartedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AuthRecoveryNotification"
}
},
"required": [
"method",
"params"
],
"title": "ModelProvider/authRecoveryStartedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"modelProvider/authRecoveryCompleted"
],
"title": "ModelProvider/authRecoveryCompletedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AuthRecoveryNotification"
}
},
"required": [
"method",
"params"
],
"title": "ModelProvider/authRecoveryCompletedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"message": {
"type": "string"
},
"provider": {
"type": "string"
},
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"message",
"provider",
"threadId",
"turnId"
],
"title": "AuthRecoveryNotification",
"type": "object"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AuthRecoveryNotification = { threadId: string, turnId: string, provider: string, message: string, };
@@ -42,6 +42,7 @@ export type { AppsReadResponse } from "./AppsReadResponse";
export type { AskForApproval } from "./AskForApproval";
export type { AttestationGenerateParams } from "./AttestationGenerateParams";
export type { AttestationGenerateResponse } from "./AttestationGenerateResponse";
export type { AuthRecoveryNotification } from "./AuthRecoveryNotification";
export type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource";
export type { AutoReviewRequirements } from "./AutoReviewRequirements";
export type { BrowserUseAccessApprovalLifetime } from "./BrowserUseAccessApprovalLifetime";
@@ -1912,6 +1912,8 @@ server_notification_definitions! {
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification),
ModelVerification => "model/verification" (v2::ModelVerificationNotification),
AuthRecoveryStarted => "modelProvider/authRecoveryStarted" (v2::AuthRecoveryNotification),
AuthRecoveryCompleted => "modelProvider/authRecoveryCompleted" (v2::AuthRecoveryNotification),
#[experimental("turn/moderationMetadata")]
TurnModerationMetadata => "turn/moderationMetadata" (v2::TurnModerationMetadataNotification),
ModelSafetyBufferingUpdated => "model/safetyBuffering/updated" (v2::ModelSafetyBufferingUpdatedNotification),
@@ -5,6 +5,16 @@ use crate::TS;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AuthRecoveryNotification {
pub thread_id: String,
pub turn_id: String,
pub provider: String,
pub message: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+2
View File
@@ -1749,6 +1749,8 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
- `model/safetyBuffering/updated` — `{ threadId, turnId, model, useCases, reasons, showBufferingUi, fasterModel }` when a response enters safety buffering. `fasterModel` is nullable. This notification is transient and is not persisted in rollout history.
- `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks).
- `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`.
- `modelProvider/authRecoveryStarted` — `{ threadId, turnId, provider, message }` when model-provider authentication recovery begins.
- `modelProvider/authRecoveryCompleted` — `{ threadId, turnId, provider, message }` when model-provider authentication recovery succeeds.
- `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation.
`turn/started` carries no items. `turn/completed` carries only the final agent message as a summary fallback; continue consuming `item/*` notifications for the full canonical item list.
@@ -13,6 +13,7 @@ use crate::thread_status::ThreadWatchActiveGuard;
use crate::thread_status::ThreadWatchManager;
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile;
use codex_app_server_protocol::AuthRecoveryNotification;
use codex_app_server_protocol::CodexErrorInfo as V2CodexErrorInfo;
use codex_app_server_protocol::CommandAction as V2ParsedCommand;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
@@ -252,6 +253,30 @@ pub(crate) async fn apply_bespoke_event_handling(
))
.await;
}
EventMsg::AuthRecoveryStarted(event) => {
outgoing
.send_server_notification(ServerNotification::AuthRecoveryStarted(
AuthRecoveryNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id,
provider: event.provider,
message: event.message,
},
))
.await;
}
EventMsg::AuthRecoveryCompleted(event) => {
outgoing
.send_server_notification(ServerNotification::AuthRecoveryCompleted(
AuthRecoveryNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id,
provider: event.provider,
message: event.message,
},
))
.await;
}
EventMsg::Warning(warning_event) => {
let notification = WarningNotification {
thread_id: Some(conversation_id.to_string()),
@@ -2180,6 +2205,7 @@ mod tests {
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::AuthRecoveryEvent;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GuardianAssessmentEvent;
@@ -3418,11 +3444,11 @@ mod tests {
}),
},
conversation_id,
conversation,
thread_manager,
outgoing,
thread_state,
thread_watch_manager,
Arc::clone(&conversation),
Arc::clone(&thread_manager),
outgoing.clone(),
Arc::clone(&thread_state),
thread_watch_manager.clone(),
Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)),
"test-provider".to_string(),
)
@@ -3437,6 +3463,52 @@ mod tests {
}
other => bail!("unexpected message: {other:?}"),
}
for (phase, method, event) in [
(
"started",
"modelProvider/authRecoveryStarted",
EventMsg::AuthRecoveryStarted as fn(AuthRecoveryEvent) -> EventMsg,
),
(
"completed",
"modelProvider/authRecoveryCompleted",
EventMsg::AuthRecoveryCompleted,
),
] {
let message = format!("Authentication recovery {phase}.");
apply_bespoke_event_handling(
Event {
id: "turn-1".to_string(),
msg: event(AuthRecoveryEvent {
provider: "test-provider".to_string(),
message: message.clone(),
}),
},
conversation_id,
Arc::clone(&conversation),
Arc::clone(&thread_manager),
outgoing.clone(),
Arc::clone(&thread_state),
thread_watch_manager.clone(),
Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)),
"test-provider".to_string(),
)
.await;
assert_eq!(
serde_json::to_value(recv_broadcast_notification(&mut rx).await?)?,
json!({
"method": method,
"params": {
"threadId": conversation_id.to_string(),
"turnId": "turn-1",
"provider": "test-provider",
"message": message,
}
})
);
}
Ok(())
}
@@ -1,4 +1,5 @@
use super::*;
use codex_app_server_protocol::AuthRecoveryNotification;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
@@ -191,6 +192,43 @@ async fn experimental_notifications_are_dropped_without_capability() {
writer_rx.try_recv().is_err(),
"experimental notifications should not reach clients without capability"
);
let recovery = AuthRecoveryNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
provider: "Amazon Bedrock".to_string(),
message: "Refreshing AWS authentication.".to_string(),
};
for (method, notification) in [
(
"modelProvider/authRecoveryStarted",
ServerNotification::AuthRecoveryStarted(recovery.clone()),
),
(
"modelProvider/authRecoveryCompleted",
ServerNotification::AuthRecoveryCompleted(recovery),
),
] {
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: app_server_notification(notification),
write_complete_tx: None,
},
)
.await;
let message = writer_rx
.recv()
.await
.expect("stable auth recovery notification should reach clients without capability");
let OutgoingMessage::AppServerNotification(envelope) = message.message else {
panic!("stable auth recovery notification should be delivered");
};
assert_eq!(envelope.notification.to_string(), method);
}
}
#[tokio::test]
+52 -1
View File
@@ -30,6 +30,7 @@ use std::sync::OnceLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use async_channel::Sender;
use codex_api::AgentIdentityTelemetry;
use codex_api::ApiError;
use codex_api::AuthProvider;
@@ -83,6 +84,9 @@ use codex_protocol::config_types::Verbosity as VerbosityConfig;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AuthRecoveryEvent;
use codex_protocol::protocol::Event as ProtocolEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
@@ -291,6 +295,7 @@ pub struct ModelClient {
agent_identity_policy: AgentIdentityAuthPolicy,
prompt_cache_key_override: Option<String>,
free_guardian_enabled: bool,
event_sender: Option<Sender<ProtocolEvent>>,
http_client_factory: HttpClientFactory,
}
@@ -512,6 +517,7 @@ impl ModelClient {
agent_identity_policy,
prompt_cache_key_override: None,
free_guardian_enabled: false,
event_sender: None,
http_client_factory,
}
}
@@ -521,11 +527,13 @@ impl ModelClient {
self
}
pub(crate) fn with_prompt_cache_key_override(
pub(crate) fn with_session_context(
mut self,
prompt_cache_key_override: Option<String>,
event_sender: Sender<ProtocolEvent>,
) -> Self {
self.prompt_cache_key_override = prompt_cache_key_override;
self.event_sender = Some(event_sender);
self
}
@@ -1672,6 +1680,8 @@ impl ModelClientSession {
&mut provider_auth_recovery_attempted,
session_telemetry,
&self.client.state.provider,
self.client.event_sender.as_ref(),
responses_metadata.turn_id.as_deref(),
)
.await?,
);
@@ -1805,6 +1815,8 @@ impl ModelClientSession {
&mut provider_auth_recovery_attempted,
session_telemetry,
&provider,
self.client.event_sender.as_ref(),
responses_metadata.turn_id.as_deref(),
)
.await?,
);
@@ -2380,18 +2392,57 @@ struct WebsocketConnectParams<'a> {
endpoint: ResponsesEndpoint,
}
fn emit_auth_recovery_event(
event_sender: Option<&Sender<ProtocolEvent>>,
turn_id: Option<&str>,
provider: &SharedModelProvider,
message: &str,
event: fn(AuthRecoveryEvent) -> EventMsg,
) {
if let (Some(sender), Some(turn_id)) = (event_sender, turn_id) {
let _ = sender.try_send(ProtocolEvent {
id: turn_id.to_string(),
msg: event(AuthRecoveryEvent {
provider: provider.info().name.clone(),
message: message.to_string(),
}),
});
}
}
async fn handle_unauthorized(
transport: TransportError,
auth_recovery: &mut Option<UnauthorizedRecovery>,
provider_auth_recovery_attempted: &mut bool,
session_telemetry: &SessionTelemetry,
provider: &SharedModelProvider,
event_sender: Option<&Sender<ProtocolEvent>>,
turn_id: Option<&str>,
) -> Result<UnauthorizedRecoveryExecution> {
let debug = extract_response_debug_context(&transport);
if !*provider_auth_recovery_attempted {
*provider_auth_recovery_attempted = true;
let messages = provider.auth_recovery_messages();
if let Some(messages) = messages {
emit_auth_recovery_event(
event_sender,
turn_id,
provider,
messages.started,
EventMsg::AuthRecoveryStarted,
);
}
match provider.recover_from_unauthorized().await {
Ok(ProviderUnauthorizedRecovery::Recovered) => {
if let Some(messages) = messages {
emit_auth_recovery_event(
event_sender,
turn_id,
provider,
messages.succeeded,
EventMsg::AuthRecoveryCompleted,
);
}
return Ok(UnauthorizedRecoveryExecution {
mode: "provider",
phase: "provider_refresh",
+38
View File
@@ -31,6 +31,7 @@ use codex_model_provider::BearerAuthProvider;
use codex_model_provider::ModelProvider;
use codex_model_provider::ModelProviderFuture;
use codex_model_provider::ProviderAccountResult;
use codex_model_provider::ProviderAuthRecoveryMessages;
use codex_model_provider::ProviderUnauthorizedRecovery;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
@@ -895,6 +896,8 @@ async fn bedrock_unauthorized_error_uses_provider_mapping() {
&mut provider_auth_recovery_attempted,
&test_session_telemetry(),
&provider,
/*event_sender*/ None,
/*turn_id*/ None,
)
.await
.expect_err("expired Bedrock signature should fail");
@@ -931,6 +934,13 @@ impl ModelProvider for TestRecoveryProvider {
self.inner.account_state()
}
fn auth_recovery_messages(&self) -> Option<ProviderAuthRecoveryMessages> {
Some(ProviderAuthRecoveryMessages {
started: "Refreshing provider authentication.",
succeeded: "Provider authentication recovered.",
})
}
fn recover_from_unauthorized(
&self,
) -> ModelProviderFuture<'_, codex_protocol::error::Result<ProviderUnauthorizedRecovery>> {
@@ -975,12 +985,15 @@ async fn provider_owned_auth_recovery_is_bounded_and_preserves_unauthorized_fail
let mut auth_recovery = None;
let mut provider_auth_recovery_attempted = false;
let telemetry = test_session_telemetry();
let (event_sender, event_receiver) = async_channel::unbounded();
let result = super::handle_unauthorized(
unauthorized(),
&mut auth_recovery,
&mut provider_auth_recovery_attempted,
&telemetry,
&provider,
Some(&event_sender),
Some("turn-1"),
)
.await;
@@ -998,6 +1011,8 @@ async fn provider_owned_auth_recovery_is_bounded_and_preserves_unauthorized_fail
&mut provider_auth_recovery_attempted,
&telemetry,
&provider,
Some(&event_sender),
Some("turn-1"),
)
.await
.expect_err("provider recovery should not run more than once")
@@ -1011,6 +1026,29 @@ async fn provider_owned_auth_recovery_is_bounded_and_preserves_unauthorized_fail
other => panic!("unexpected error after provider recovery: {other}"),
}
assert_eq!(attempts.load(Ordering::Relaxed), 1);
let events = std::iter::from_fn(|| event_receiver.try_recv().ok())
.map(|event| serde_json::to_value(event).expect("recovery event should serialize"))
.collect::<Vec<_>>();
let mut expected = vec![json!({
"id": "turn-1",
"msg": {
"type": "auth_recovery_started",
"provider": provider.info().name,
"message": "Refreshing provider authentication.",
}
})];
if !should_fail {
expected.push(json!({
"id": "turn-1",
"msg": {
"type": "auth_recovery_completed",
"provider": provider.info().name,
"message": "Provider authentication recovered.",
}
}));
}
assert_eq!(events, expected);
}
}
+2 -1
View File
@@ -1422,11 +1422,12 @@ impl Session {
config.http_client_factory(),
)
.with_free_guardian_enabled(config.free_guardian_enabled())
.with_prompt_cache_key_override(
.with_session_context(
crate::guardian::prompt_cache_key_override_for_review_session(
&session_configuration.session_source,
session_configuration.parent_thread_id,
),
tx_event.clone(),
),
executed_tool_calls: executed_tool_calls.clone(),
code_mode_service: crate::tools::code_mode::CodeModeService::new(
+2
View File
@@ -1797,6 +1797,8 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<(String, Option<
}
EventMsg::Error(_)
| EventMsg::Warning(_)
| EventMsg::AuthRecoveryStarted(_)
| EventMsg::AuthRecoveryCompleted(_)
| EventMsg::GuardianWarning(_)
| EventMsg::RealtimeConversationStarted(_)
| EventMsg::RealtimeConversationSdp(_)
+40 -1
View File
@@ -1,3 +1,4 @@
use codex_core::TurnInputRequest;
use codex_login::AuthHeaders;
use codex_login::CodexAuth;
use codex_login::ExternalAuth;
@@ -9,6 +10,9 @@ 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_protocol::protocol::AuthRecoveryEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::user_input::UserInput;
use codex_utils_redacted_string::RedactedString;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
@@ -317,7 +321,42 @@ async fn amazon_bedrock_aws_auth_refresh_resigns() -> anyhow::Result<()> {
config.model_provider = provider;
});
let test = builder.build_with_auto_env(&server).await?;
test.submit_turn("hello").await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}]))
.await?;
let mut recovery_events = Vec::new();
loop {
match core_test_support::wait_for_event(&test.codex, |_| true).await {
EventMsg::AuthRecoveryStarted(event) => recovery_events.push(("started", event)),
EventMsg::AuthRecoveryCompleted(event) => recovery_events.push(("completed", event)),
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
assert_eq!(
recovery_events,
vec![
(
"started",
AuthRecoveryEvent {
provider: "Amazon Bedrock".to_string(),
message: "AWS session has expired. Reauthenticating...".to_string(),
},
),
(
"completed",
AuthRecoveryEvent {
provider: "Amazon Bedrock".to_string(),
message: "Signed in with AWS.".to_string(),
},
),
]
);
server.verify().await;
Ok(())
}
@@ -239,6 +239,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
CodexStatus::Running
}
ServerNotification::Warning(notification) => self.process_warning(notification.message),
ServerNotification::AuthRecoveryStarted(notification) => {
eprintln!("{}", notification.message);
CodexStatus::Running
}
ServerNotification::AuthRecoveryCompleted(notification) => {
eprintln!("{}", notification.message.style(self.green));
CodexStatus::Running
}
ServerNotification::Error(notification) => {
eprintln!(
"{} {}",
+4
View File
@@ -1419,6 +1419,10 @@ fn should_process_notification(
.thread_id
.as_deref()
.is_none_or(|candidate| candidate == thread_id),
ServerNotification::AuthRecoveryStarted(notification)
| ServerNotification::AuthRecoveryCompleted(notification) => {
notification.thread_id == thread_id && notification.turn_id == turn_id
}
ServerNotification::Error(notification) => {
notification.thread_id == thread_id && notification.turn_id == turn_id
}
+23
View File
@@ -1,4 +1,5 @@
use super::*;
use codex_app_server_protocol::AuthRecoveryNotification;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::ActivePermissionProfile;
@@ -289,6 +290,28 @@ fn runtime_warnings_are_filtered_to_the_primary_thread() {
});
assert_eq!(outcomes, [true, true, false]);
let recovery = AuthRecoveryNotification {
thread_id: primary_thread_id.to_string(),
turn_id: turn_id.to_string(),
provider: "example".to_string(),
message: "Refresh authentication".to_string(),
};
let outcomes = [
ServerNotification::AuthRecoveryStarted(recovery.clone()),
ServerNotification::AuthRecoveryCompleted(recovery.clone()),
ServerNotification::AuthRecoveryStarted(AuthRecoveryNotification {
thread_id: "thread-2".to_string(),
..recovery.clone()
}),
ServerNotification::AuthRecoveryCompleted(AuthRecoveryNotification {
turn_id: "turn-2".to_string(),
..recovery
}),
]
.map(|notification| should_process_notification(&notification, primary_thread_id, turn_id));
assert_eq!(outcomes, [true, true, false, false]);
}
#[tokio::test]
@@ -247,6 +247,8 @@ async fn run_codex_tool_session_inner(
break;
}
EventMsg::Warning(_)
| EventMsg::AuthRecoveryStarted(_)
| EventMsg::AuthRecoveryCompleted(_)
| EventMsg::GuardianWarning(_)
| EventMsg::ModelVerification(_)
| EventMsg::SafetyBuffering(_)
@@ -34,6 +34,7 @@ use crate::provider::ModelProvider;
use crate::provider::ModelProviderFuture;
use crate::provider::ProviderAccountResult;
use crate::provider::ProviderAccountState;
use crate::provider::ProviderAuthRecoveryMessages;
use crate::provider::ProviderCapabilities;
use crate::provider::ProviderUnauthorizedRecovery;
use crate::provider::RemoteCompactionSupport;
@@ -244,6 +245,14 @@ impl ModelProvider for AmazonBedrockModelProvider {
) || (self.uses_aws_auth_recovery() && error::is_refreshable_auth_error(error))
}
fn auth_recovery_messages(&self) -> Option<ProviderAuthRecoveryMessages> {
self.uses_aws_auth_recovery()
.then_some(ProviderAuthRecoveryMessages {
started: "AWS session has expired. Reauthenticating...",
succeeded: "Signed in with AWS.",
})
}
fn recover_from_unauthorized(
&self,
) -> ModelProviderFuture<'_, Result<ProviderUnauthorizedRecovery>> {
+1
View File
@@ -23,6 +23,7 @@ pub use provider::ModelProviderFuture;
pub use provider::ProviderAccountError;
pub use provider::ProviderAccountResult;
pub use provider::ProviderAccountState;
pub use provider::ProviderAuthRecoveryMessages;
pub use provider::ProviderCapabilities;
pub use provider::ProviderUnauthorizedRecovery;
pub use provider::RemoteCompactionSupport;
+12
View File
@@ -92,6 +92,13 @@ pub enum ProviderUnauthorizedRecovery {
Recovered,
}
/// User-facing lifecycle messages for provider-owned authentication recovery.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderAuthRecoveryMessages {
pub started: &'static str,
pub succeeded: &'static str,
}
/// Error returned when a provider cannot construct its app-visible account state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderAccountError {
@@ -192,6 +199,11 @@ pub trait ModelProvider: fmt::Debug + Send + Sync {
)
}
/// Returns lifecycle messages when provider-owned authentication recovery is active.
fn auth_recovery_messages(&self) -> Option<ProviderAuthRecoveryMessages> {
None
}
/// Attempts provider-owned authentication recovery before using the auth manager.
fn recover_from_unauthorized(
&self,
+15
View File
@@ -1340,6 +1340,12 @@ pub enum EventMsg {
/// indicates the turn continued but the user should still be notified.
Warning(WarningEvent),
/// Provider-owned authentication recovery has started for the current turn.
AuthRecoveryStarted(AuthRecoveryEvent),
/// Provider-owned authentication recovery has completed for the current turn.
AuthRecoveryCompleted(AuthRecoveryEvent),
/// Warning issued by the guardian automatic approval reviewer.
GuardianWarning(WarningEvent),
@@ -2053,6 +2059,15 @@ pub struct WarningEvent {
pub message: String,
}
/// User-facing progress for provider-owned authentication recovery.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct AuthRecoveryEvent {
/// Display name of the model provider whose authentication is recovering.
pub provider: String,
/// User-facing description of the authentication recovery stage.
pub message: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
+5 -1
View File
@@ -363,6 +363,8 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option<ToolRuntimeTr
EventMsg::SubAgentActivity(_) => None,
EventMsg::Error(_)
| EventMsg::Warning(_)
| EventMsg::AuthRecoveryStarted(_)
| EventMsg::AuthRecoveryCompleted(_)
| EventMsg::GuardianWarning(_)
| EventMsg::SafetyBuffering(_)
| EventMsg::RealtimeConversationStarted(_)
@@ -442,7 +444,9 @@ pub(crate) fn wrapped_protocol_event_type(event: &EventMsg) -> Option<&'static s
EventMsg::Error(_) => Some("error"),
EventMsg::Warning(_) => Some("warning"),
EventMsg::ShutdownComplete => Some("shutdown_complete"),
EventMsg::GuardianWarning(_)
EventMsg::AuthRecoveryStarted(_)
| EventMsg::AuthRecoveryCompleted(_)
| EventMsg::GuardianWarning(_)
| EventMsg::SafetyBuffering(_)
| EventMsg::RealtimeConversationStarted(_)
| EventMsg::RealtimeConversationRealtime(_)
+2
View File
@@ -148,6 +148,8 @@ pub fn should_persist_event_msg(ev: &EventMsg, history_mode: ThreadHistoryMode)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_)
| EventMsg::Warning(_)
| EventMsg::AuthRecoveryStarted(_)
| EventMsg::AuthRecoveryCompleted(_)
| EventMsg::GuardianWarning(_)
| EventMsg::RealtimeConversationStarted(_)
| EventMsg::RealtimeConversationSdp(_)
@@ -175,6 +175,10 @@ pub(super) fn server_notification_thread_target(
ServerNotification::ThreadRealtimeClosed(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::AuthRecoveryStarted(notification)
| ServerNotification::AuthRecoveryCompleted(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::Warning(notification) => notification.thread_id.as_deref(),
ServerNotification::GuardianWarning(notification) => Some(notification.thread_id.as_str()),
ServerNotification::McpServerStatusUpdated(notification) => {
+8
View File
@@ -152,6 +152,14 @@ impl ChatWidget {
ServerNotification::ModelSafetyBufferingUpdated(notification) => {
self.on_model_safety_buffering_updated(notification, replay_kind)
}
ServerNotification::AuthRecoveryStarted(notification) => {
self.add_info_message(notification.message, /*hint*/ None)
}
ServerNotification::AuthRecoveryCompleted(notification) => {
self.add_plain_history_lines(vec![
vec!["✓ ".green(), notification.message.into()].into(),
]);
}
ServerNotification::Warning(notification) => self.on_warning(notification.message),
ServerNotification::GuardianWarning(notification) => {
if !notification
@@ -1,4 +1,5 @@
use super::*;
use codex_app_server_protocol::AuthRecoveryNotification;
use codex_protocol::error::CodexErr;
use codex_protocol::error::CodexErrorDetails;
use pretty_assertions::assert_eq;
@@ -730,6 +731,32 @@ async fn live_app_server_warning_notification_renders_message() {
),
"expected warning guidance, got {rendered}"
);
let notification = AuthRecoveryNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
provider: "example".to_string(),
message: "Session expired. Sign in again.".to_string(),
};
let mut recovery_messages = String::new();
for notification in [
ServerNotification::AuthRecoveryStarted(notification.clone()),
ServerNotification::AuthRecoveryCompleted(AuthRecoveryNotification {
message: "Signed in successfully.".to_string(),
..notification
}),
] {
chat.handle_server_notification(notification, /*replay_kind*/ None);
let [cell] = drain_insert_history(&mut rx)
.try_into()
.expect("expected one authentication recovery history cell");
recovery_messages.push_str(&lines_to_single_string(&cell));
}
insta::assert_snapshot!(recovery_messages, @r"
• Session expired. Sign in again.
✓ Signed in successfully.
");
}
#[tokio::test]