mirror of
https://github.com/openai/codex.git
synced 2026-10-21 05:22:21 +00:00
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:
@@ -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": {
|
||||
|
||||
+65
@@ -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": [
|
||||
|
||||
+65
@@ -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"
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
+2
-1
File diff suppressed because one or more lines are too long
+5
@@ -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/")]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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!(
|
||||
"{} {}",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(¬ification, 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>> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user