Fetch account security reminders through app-server (#60082)

## Why

Security reminder requests need to use app-server's active authentication and HTTP runtime, with results bound to the login that fetched them.

## What changed

- Add `account/securitySetup/read`, returning an optional notice with account and user IDs, plus generated protocol schemas and bindings.
- Fetch reminders in app-server only for a matching saved ChatGPT login. Return `notice: null` for ineligible, unavailable, or timed-out reads, and discard results when the login changes during the request.
- Route TUI reminder reads through the new method while retaining notice validation and identity checks. Unsupported servers produce no reminder.

## Testing

Add coverage for shared runtime use across restarts, ineligible authentication modes, and login changes during a fetch. Update TUI coverage for unavailable, unsupported, and mismatched notices.

GitOrigin-RevId: 426fee7ce98df17970957c06876881bc5b005196
This commit is contained in:
cooper-oai
2026-11-07 08:11:37 +00:00
committed by copyberry
parent c4f0e09912
commit 4be90eb069
22 changed files with 707 additions and 78 deletions
@@ -8662,6 +8662,29 @@
"title": "Account/logoutRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/securitySetup/read"
],
"title": "Account/securitySetup/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/securitySetup/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -2301,6 +2301,29 @@
"title": "Account/logoutRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"account/securitySetup/read"
],
"title": "Account/securitySetup/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/securitySetup/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -7016,6 +7039,48 @@
],
"type": "string"
},
"AccountSecuritySetupAction": {
"properties": {
"label": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"label",
"url"
],
"type": "object"
},
"AccountSecuritySetupNotice": {
"properties": {
"accountId": {
"type": "string"
},
"action": {
"$ref": "#/definitions/v2/AccountSecuritySetupAction"
},
"description": {
"type": "string"
},
"title": {
"type": "string"
},
"userId": {
"type": "string"
}
},
"required": [
"accountId",
"action",
"description",
"title",
"userId"
],
"type": "object"
},
"AccountSession": {
"properties": {
"displayName": {
@@ -13399,6 +13464,24 @@
"title": "GetAccountResponse",
"type": "object"
},
"GetAccountSecuritySetupResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Optional security reminder bound to the account that fetched it. Unavailable or ineligible accounts return no notice; this read never changes account security.",
"properties": {
"notice": {
"anyOf": [
{
"$ref": "#/definitions/v2/AccountSecuritySetupNotice"
},
{
"type": "null"
}
]
}
},
"title": "GetAccountSecuritySetupResponse",
"type": "object"
},
"GetAccountTokenUsageParams": {
"properties": {
"threadId": {
@@ -154,6 +154,48 @@
],
"type": "string"
},
"AccountSecuritySetupAction": {
"properties": {
"label": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"label",
"url"
],
"type": "object"
},
"AccountSecuritySetupNotice": {
"properties": {
"accountId": {
"type": "string"
},
"action": {
"$ref": "#/definitions/AccountSecuritySetupAction"
},
"description": {
"type": "string"
},
"title": {
"type": "string"
},
"userId": {
"type": "string"
}
},
"required": [
"accountId",
"action",
"description",
"title",
"userId"
],
"type": "object"
},
"AccountSession": {
"properties": {
"displayName": {
@@ -4056,6 +4098,29 @@
"title": "Account/logoutRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/securitySetup/read"
],
"title": "Account/securitySetup/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/securitySetup/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -9291,6 +9356,24 @@
"title": "GetAccountResponse",
"type": "object"
},
"GetAccountSecuritySetupResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Optional security reminder bound to the account that fetched it. Unavailable or ineligible accounts return no notice; this read never changes account security.",
"properties": {
"notice": {
"anyOf": [
{
"$ref": "#/definitions/AccountSecuritySetupNotice"
},
{
"type": "null"
}
]
}
},
"title": "GetAccountSecuritySetupResponse",
"type": "object"
},
"GetAccountTokenUsageParams": {
"properties": {
"threadId": {
@@ -0,0 +1,62 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AccountSecuritySetupAction": {
"properties": {
"label": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"label",
"url"
],
"type": "object"
},
"AccountSecuritySetupNotice": {
"properties": {
"accountId": {
"type": "string"
},
"action": {
"$ref": "#/definitions/AccountSecuritySetupAction"
},
"description": {
"type": "string"
},
"title": {
"type": "string"
},
"userId": {
"type": "string"
}
},
"required": [
"accountId",
"action",
"description",
"title",
"userId"
],
"type": "object"
}
},
"description": "Optional security reminder bound to the account that fetched it. Unavailable or ineligible accounts return no notice; this read never changes account security.",
"properties": {
"notice": {
"anyOf": [
{
"$ref": "#/definitions/AccountSecuritySetupNotice"
},
{
"type": "null"
}
]
}
},
"title": "GetAccountSecuritySetupResponse",
"type": "object"
}
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 AccountSecuritySetupAction = { label: string, url: string, };
@@ -0,0 +1,6 @@
// 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.
import type { AccountSecuritySetupAction } from "./AccountSecuritySetupAction";
export type AccountSecuritySetupNotice = { accountId: string, userId: string, title: string, description: string, action: AccountSecuritySetupAction, };
@@ -0,0 +1,10 @@
// 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.
import type { AccountSecuritySetupNotice } from "./AccountSecuritySetupNotice";
/**
* Optional security reminder bound to the account that fetched it. Unavailable or
* ineligible accounts return no notice; this read never changes account security.
*/
export type GetAccountSecuritySetupResponse = { notice: AccountSecuritySetupNotice | null, };
@@ -4,6 +4,8 @@ export type { Account } from "./Account";
export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification";
export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification";
export type { AccountRoutingOverride } from "./AccountRoutingOverride";
export type { AccountSecuritySetupAction } from "./AccountSecuritySetupAction";
export type { AccountSecuritySetupNotice } from "./AccountSecuritySetupNotice";
export type { AccountSession } from "./AccountSession";
export type { AccountSessionWorkspace } from "./AccountSessionWorkspace";
export type { AccountSessionWorkspaceKind } from "./AccountSessionWorkspaceKind";
@@ -220,6 +222,7 @@ export type { GetAccountParams } from "./GetAccountParams";
export type { GetAccountRateLimitsParams } from "./GetAccountRateLimitsParams";
export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse";
export type { GetAccountResponse } from "./GetAccountResponse";
export type { GetAccountSecuritySetupResponse } from "./GetAccountSecuritySetupResponse";
export type { GetAccountTokenUsageParams } from "./GetAccountTokenUsageParams";
export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse";
export type { GetWorkspaceMessagesResponse } from "./GetWorkspaceMessagesResponse";
@@ -1421,6 +1421,12 @@ client_request_definitions! {
response: v2::LogoutAccountResponse,
},
GetAccountSecuritySetup => "account/securitySetup/read" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: None,
response: v2::GetAccountSecuritySetupResponse,
},
GetAccountRateLimits => "account/rateLimits/read" {
params: #[ts(optional, as = "Option<GetAccountRateLimitsParamsTypeScript>", inline)] #[serde(default, skip_serializing_if = "Option::is_none")] v2::NullableGetAccountRateLimitsParams,
serialization: None,
@@ -884,3 +884,31 @@ pub struct AccountLoginCompletedNotification {
pub enum DesktopOnboardingEntrypoint {
LifeSciences,
}
/// Optional security reminder bound to the account that fetched it. Unavailable or
/// ineligible accounts return no notice; this read never changes account security.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountSecuritySetupResponse {
pub notice: Option<AccountSecuritySetupNotice>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSecuritySetupNotice {
pub account_id: String,
pub user_id: String,
pub title: String,
pub description: String,
pub action: AccountSecuritySetupAction,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSecuritySetupAction {
pub label: String,
pub url: String,
}
+2
View File
@@ -587,6 +587,8 @@ PAT sessions continue to return no bearer token from `getAuthStatus`.
# Selected workspace routing
`account/securitySetup/read` returns an optional backend-owned security reminder for the saved ChatGPT login. A returned notice includes its account and user IDs; clients must discard it after an account change and validate its copy and action URL before rendering. Ineligible, unavailable, and timed-out reads return `notice: null`. Older app-servers may not support this optional read.
The experimental `account/read.workspaceRouting` response field returns the selected ChatGPT workspace's `chatgptAccountId`, resolved HTTPS `backendOrigin`, and backend-provided `accountRoutingOverride`. The routing value is `us`, `us_cr`, or the explicit `NO_CONSTRAINT` value. API-only and signed-out accounts return `null` and do not need `accounts/check`.
App-server discovers routing for saved ChatGPT logins at startup and for new logins or workspace switches. After requirements and routing are ready, it sends the existing `account/updated` notification. Newly initialized connections also receive this notification once saved-workspace routing is ready, including when discovery finished before the connection initialized. Clients then reread `configRequirements/read` and `account/read`. Saved ChatGPT credentials without a selected workspace ID retain their account information and return `workspaceRouting: null`; app-server does not guess a workspace from the backend's default account. Discovery failures for a selected workspace, including missing or null fields from older backends, return an `account/read` error. They never produce a successful unrestricted result. A later read retries failed discovery. Logout clears the cached routing, and results from earlier authentication owners are discarded. Token refreshes for the same known user and workspace invalidate cached routing without cancelling discovery or failing sign-in. Configuration is reloaded after discovery; a changed backend, model provider, or required backend rejects the result so the next read discovers against current configuration. Account notifications recheck the auth owner generation after waiting for outbound queue capacity. Superseded sign-in attempts emit a failed `account/login/completed` event instead of silently dropping completion. Notifications remain snapshots: clients reread current account and requirements state rather than treating a queued notification as authorization.
@@ -1942,6 +1942,12 @@ impl MessageProcessor {
ClientRequest::GetAuthStatus { params, .. } => {
self.account_processor.get_auth_status(params).await
}
ClientRequest::GetAccountSecuritySetup { .. } => Ok(Some(
self.account_processor
.get_account_security_setup()
.await
.into(),
)),
ClientRequest::GetAccountRateLimits { params, .. } => {
self.account_processor.get_account_rate_limits(params).await
}
@@ -24,6 +24,7 @@ mod enterprise_login;
mod gateway_oauth;
mod gcp_setup;
mod rate_limit_resets;
mod security_setup;
mod workspace_routing;
mod workspace_switching;
@@ -0,0 +1,115 @@
//! Keep optional security reminders on the app-server's existing runtime identity.
//! Only saved ChatGPT credentials matching the active login may fetch a notice.
use super::*;
use codex_app_server_protocol::AccountSecuritySetupAction;
use codex_app_server_protocol::AccountSecuritySetupNotice;
use codex_app_server_protocol::GetAccountSecuritySetupResponse;
use codex_http_client::ClientRouteClass;
use codex_http_client::RouteAwareClientPool;
use serde::Deserialize;
#[derive(Deserialize)]
struct Response {
notice: Option<Notice>,
}
#[derive(Deserialize)]
struct Notice {
title: String,
description: String,
action: AccountSecuritySetupAction,
}
impl AccountRequestProcessor {
pub(crate) async fn get_account_security_setup(&self) -> GetAccountSecuritySetupResponse {
let notice = tokio::time::timeout(Duration::from_secs(/*secs*/ 3), async {
let config = self.load_latest_config().await;
let (auth, factory) = self.auth_manager.auth_with_http_client_factory().await?;
let source_generation = self
.auth_manager
.auth_change_state_for(&auth)?
.source_generation;
let saved = config
.auth_config()
.load_auth(/*enable_codex_api_key_env*/ false)
.await
.ok()
.flatten()?;
if !matches!(saved, CodexAuth::Chatgpt(_)) || saved.is_fedramp_account() {
return None;
}
if auth.auth_mode() != codex_protocol::auth::AuthMode::Chatgpt
|| saved.get_token().ok()? != auth.get_token().ok()?
{
return None;
}
let account_id = auth.get_account_id()?;
let user_id = auth.get_chatgpt_user_id()?;
let url = format!(
"{}/wham/security-setup",
config.chatgpt_base_url.trim_end_matches('/')
);
let runtime = self
.thread_manager
.runtime_auth_for_config(&config)
.ok()?
.filter(|_| codex_runtime_agent_instance::is_openai_api_url(&url));
let resolved = codex_model_provider::auth_provider_for_request(
&auth,
runtime.as_ref(),
/*headers*/ &Default::default(),
)
.await
.ok()?;
let factory = resolved.bind_http_client_factory(factory).ok()?;
let client =
RouteAwareClientPool::new_without_redirects(factory, ClientRouteClass::Api);
let mut headers = resolved
.auth
.apply_auth_headers(Default::default())
.await
.ok()?;
headers.insert("chatgpt-account-id", account_id.parse().ok()?);
resolved.validate_headers(&headers).ok()?;
let response = client
.get(&url)
.headers(headers)
.header(
"User-Agent",
codex_login::default_client::get_codex_user_agent(),
)
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let notice = response.json::<Response>().await.ok()?.notice?;
let current = self.auth_manager.auth().await?;
if self
.auth_manager
.auth_change_state_for(&auth)?
.source_generation
!= source_generation
|| current.auth_mode() != codex_protocol::auth::AuthMode::Chatgpt
|| current.get_account_id().as_ref() != Some(&account_id)
|| current.get_chatgpt_user_id().as_ref() != Some(&user_id)
|| current.is_fedramp_account()
{
return None;
}
Some(AccountSecuritySetupNotice {
account_id,
user_id,
title: notice.title,
description: notice.description,
action: notice.action,
})
})
.await
.ok()
.flatten();
GetAccountSecuritySetupResponse { notice }
}
}
@@ -843,6 +843,24 @@ async fn account_requests_share_runtime_and_rotate_on_restart(mode: &str) -> Res
let apps: codex_app_server_protocol::AppsReadResponse =
timeout(DEFAULT_READ_TIMEOUT, app.read_response(id)).await??;
assert_eq!(apps.apps[0].id, "details-only");
let id = app
.send_request("account/securitySetup/read", /*params*/ None)
.await?;
let response: codex_app_server_protocol::GetAccountSecuritySetupResponse =
timeout(DEFAULT_READ_TIMEOUT, app.read_response(id)).await??;
assert_eq!(
response.notice,
Some(codex_app_server_protocol::AccountSecuritySetupNotice {
account_id: "account-123".into(),
user_id: "user-123".into(),
title: "Set up security".into(),
description: "Account security reminder".into(),
action: codex_app_server_protocol::AccountSecuritySetupAction {
label: "Settings".into(),
url: "https://chatgpt.com/cyber".into(),
},
})
);
}
assert_eq!(std::fs::read(home.path().join("auth.json"))?, before);
return Ok(());
@@ -892,6 +910,17 @@ async fn account_requests_share_runtime_and_rotate_on_restart(mode: &str) -> Res
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/security-setup"))
.respond_with(
ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"notice": {
"title": "Set up security", "description": "Account security reminder",
"action": {"label": "Settings", "url": "https://chatgpt.com/cyber"},
}})),
)
.expect(/*r*/ 2)
.mount(&server)
.await;
run_harness_through_proxy(
&server,
&format!(
@@ -987,3 +1016,80 @@ async fn account_requests_share_runtime_and_rotate_on_restart(mode: &str) -> Res
server.verify().await;
Ok(())
}
#[test_case("api_key")]
#[test_case("fedramp")]
#[test_case("external_token")]
#[test_case("signed_out")]
#[tokio::test]
async fn security_setup_requires_matching_saved_chatgpt_login(kind: &str) -> Result<()> {
use base64::Engine;
let token = format!("e30.{}.c2ln", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
json!({"exp": chrono::Utc::now().timestamp() + 3600,
"https://api.openai.com/auth": {"chatgpt_user_id": "user-123", "chatgpt_account_id": "account-123"}}).to_string()));
let home = TempDir::new()?;
let server = MockServer::start().await;
write_chatgpt_base_url(home.path(), &format!("{}/backend-api", server.uri()))?;
if kind != "signed_out" {
write_chatgpt_auth(
home.path(),
ChatGptAuthFixture::new(&token)
.account_id("account-123")
.claims(ChatGptIdTokenClaims {
chatgpt_account_id: Some("account-123".into()),
chatgpt_user_id: Some("user-123".into()),
chatgpt_account_is_fedramp: kind == "fedramp",
..Default::default()
}),
AuthCredentialsStoreMode::File,
)?;
}
app_test_support::mount_workspace_routing(&server).await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/security-setup"))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"notice": null})))
.expect(/*r*/ 0)
.mount(&server)
.await;
let mut app = TestAppServer::builder()
.with_codex_home(home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
if kind == "api_key" {
let id = app
.send_request(
"account/login/start",
Some(json!({
"type": "apiKey", "apiKey": "selected-api-key",
})),
)
.await?;
let response: serde_json::Value =
timeout(DEFAULT_READ_TIMEOUT, app.read_response(id)).await??;
assert_eq!(response["type"], "apiKey");
}
if kind == "external_token" {
let id = app
.send_request(
"account/login/start",
Some(json!({
"type": "chatgptAuthTokens", "accessToken": token,
"chatgptAccountId": "account-123", "chatgptPlanType": "pro",
})),
)
.await?;
let response: serde_json::Value =
timeout(DEFAULT_READ_TIMEOUT, app.read_response(id)).await??;
assert_eq!(response["type"], "chatgptAuthTokens");
}
let id = app
.send_request("account/securitySetup/read", /*params*/ None)
.await?;
let response: codex_app_server_protocol::GetAccountSecuritySetupResponse =
timeout(DEFAULT_READ_TIMEOUT, app.read_response(id)).await??;
assert_eq!(response.notice, None);
server.verify().await;
Ok(())
}
@@ -140,3 +140,97 @@ async fn identity_is_rechecked_after_backend_response(
assert_eq!(response, expected);
Ok(())
}
#[test_case(false; "same_login")]
#[test_case(true; "same_user_external_login")]
#[tokio::test]
async fn security_setup_rechecks_login_mode_after_backend_response(
change_login: bool,
) -> Result<()> {
use codex_app_server_protocol::GetAccountSecuritySetupResponse;
let token = encode_id_token(
&ChatGptIdTokenClaims::new()
.chatgpt_account_id("workspace-a")
.chatgpt_user_id("user-a"),
)?;
let home = TempDir::new()?;
write_chatgpt_auth(
home.path(),
ChatGptAuthFixture::new(&token)
.account_id("workspace-a")
.chatgpt_user_id("user-a")
.plan_type("team"),
AuthCredentialsStoreMode::File,
)?;
let entered = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
let request_entered = Arc::clone(&entered);
let response_release = Arc::clone(&release);
let listener = TcpListener::bind("127.0.0.1:0").await?;
std::fs::write(
home.path().join("config.toml"),
format!(
"chatgpt_base_url = \"http://{}/backend-api\"\n",
listener.local_addr()?
),
)?;
let expected_authorization = format!("Bearer {token}");
let router = Router::new().route(
"/backend-api/wham/security-setup",
get(move |headers: HeaderMap| {
let expected_authorization = expected_authorization.clone();
let entered = Arc::clone(&request_entered);
let release = Arc::clone(&response_release);
async move {
assert_eq!(headers["authorization"], expected_authorization);
assert_eq!(headers["chatgpt-account-id"], "workspace-a");
entered.notify_one();
release.notified().await;
Json(json!({"notice": {
"title": "Set up security",
"description": "Account security reminder",
"action": {"label": "Settings", "url": "https://chatgpt.com/cyber"}
}}))
}
}),
);
let server = tokio::spawn(async move { axum::serve(listener, router).await });
let mut app = TestAppServer::builder()
.with_codex_home(home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(READ_TIMEOUT)
.await?;
let request = app
.send_request("account/securitySetup/read", /*params*/ None)
.await?;
timeout(READ_TIMEOUT, entered.notified()).await?;
if change_login {
let login = app
.send_chatgpt_auth_tokens_login_request(
token,
"workspace-a".into(),
Some("team".into()),
)
.await?;
let response: LoginAccountResponse =
timeout(READ_TIMEOUT, app.read_response(login)).await??;
assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});
}
release.notify_one();
let response: GetAccountSecuritySetupResponse =
timeout(READ_TIMEOUT, app.read_response(request)).await??;
server.abort();
if change_login {
assert_eq!(response.notice, None);
} else {
let notice = response
.notice
.expect("unchanged saved login gets its notice");
assert_eq!(notice.account_id, "workspace-a");
assert_eq!(notice.user_id, "user-a");
assert_eq!(notice.title, "Set up security");
}
Ok(())
}
@@ -193,16 +193,15 @@ async fn security_setup_fetch_with_default_features_uses_authenticated_codex_end
}
#[tokio::test]
async fn security_setup_skips_fetch_when_server_auth_does_not_match_saved_login() -> Result<()> {
for (auth_method, auth_token) in [
(
Some(AuthMode::ChatgptAuthTokens),
Some("other-account-token"),
),
(Some(AuthMode::ChatgptAuthTokens), Some("saved-token")),
(Some(AuthMode::Chatgpt), Some("other-account-token")),
(Some(AuthMode::Chatgpt), None),
(None, None),
async fn security_setup_skips_unavailable_or_mismatched_server_notice() -> Result<()> {
for response in [
json!({"result": {"notice": null}}),
json!({"error": {"code": -32601, "message": "Method not found"}}),
json!({"result": {"notice": {
"accountId": "other-account", "userId": "other-user",
"title": "Keep using Daybreak mode", "description": "Set up security.",
"action": {"label": "Set up security", "url": "https://chatgpt.com/cyber"}
}}}),
] {
let (mut app, _events, _ops) = make_test_app_with_channels().await;
let backend = wiremock::MockServer::start().await;
@@ -232,15 +231,8 @@ async fn security_setup_skips_fetch_when_server_auth_does_not_match_saved_login(
let daemon = tokio::spawn(async move {
let (stream, _) = listener.accept().await?;
serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| {
assert_eq!(request.method, "getAuthStatus");
assert_eq!(
request.params,
Some(json!({"includeToken": true, "refreshToken": false}))
);
std::future::ready(Some(json!({"result": {
"authMethod": auth_method, "authToken": auth_token,
"requiresOpenaiAuth": true
}})))
assert_eq!(request.method, "account/securitySetup/read");
std::future::ready(Some(response.clone()))
})
.await
});
@@ -265,7 +257,11 @@ async fn security_setup_skips_fetch_when_server_auth_does_not_match_saved_login(
backend.verify().await;
server.shutdown().await?;
let methods = daemon.await??;
assert!(methods.iter().any(|method| method == "getAuthStatus"));
assert!(
methods
.iter()
.any(|method| method == "account/securitySetup/read")
);
}
Ok(())
}
+17 -57
View File
@@ -6,13 +6,9 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::app_server_session::AppServerSession;
use crate::legacy_core::config::Config;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::GetAccountSecuritySetupResponse;
use codex_app_server_protocol::RequestId;
use codex_http_client::ClientRouteClass;
use codex_http_client::RouteAwareClientPool;
use codex_login::CodexAuth;
use serde::Deserialize;
use std::time::Duration;
@@ -31,10 +27,6 @@ pub(crate) struct Action {
pub url: String,
}
#[derive(Deserialize)]
struct Response {
notice: Option<Notice>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Identity {
pub account: String,
@@ -81,58 +73,26 @@ pub(crate) fn prefetch(
let request_handle = server.request_handle();
tokio::spawn(async move {
let result = tokio::time::timeout(Duration::from_secs(3), async {
// Saved credentials must match the connected server, which may use external auth.
let status: GetAuthStatusResponse = request_handle
.request_typed(ClientRequest::GetAuthStatus {
let response: GetAccountSecuritySetupResponse = request_handle
.request_typed(ClientRequest::GetAccountSecuritySetup {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(false),
},
params: None,
})
.await
.ok()?;
let auth = config
.auth_config()
.load_auth(/*enable_codex_api_key_env*/ false)
.await
.ok()
.flatten()?;
if !matches!(auth, CodexAuth::Chatgpt(_)) || auth.is_fedramp_account() {
return None;
}
let saved_token = auth.get_token().ok()?;
if status.auth_method != Some(AuthMode::Chatgpt)
|| status.auth_token.as_deref() != Some(saved_token.as_str())
{
return None;
}
let identity = Identity::from_auth(&auth)?;
let client = RouteAwareClientPool::new_without_redirects(
config.http_client_factory(),
ClientRouteClass::Api,
);
let response = client
.get(format!(
"{}/wham/security-setup",
config.chatgpt_base_url.trim_end_matches('/')
))
.headers(
codex_model_provider::auth_provider_from_auth(&auth)
.to_auth_headers()
.ok()?,
)
.header(
"User-Agent",
codex_login::default_client::get_codex_user_agent(),
)
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let notice = response.json::<Response>().await.ok()?.notice?;
let fetched = response.notice?;
let identity = Identity {
account: fetched.account_id,
user: fetched.user_id,
};
let notice = Notice {
title: fetched.title,
description: fetched.description,
action: Action {
label: fetched.action.label,
url: fetched.action.url,
},
};
tracing::debug!(valid = notice.valid(), "received security setup notice");
if !notice.valid() {
return None;
@@ -52,6 +52,25 @@ class AccountRoutingOverride(Enum):
us_cr = "us_cr"
class AccountSecuritySetupAction(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
label: str
url: str
class AccountSecuritySetupNotice(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
account_id: Annotated[str, Field(alias="accountId")]
action: AccountSecuritySetupAction
description: str
title: str
user_id: Annotated[str, Field(alias="userId")]
class AccountSessionWorkspaceKind(Enum):
personal = "personal"
workspace = "workspace"
@@ -1919,6 +1938,13 @@ class GetAccountRateLimitsParams(BaseModel):
] = None
class GetAccountSecuritySetupResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
notice: AccountSecuritySetupNotice | None = None
class GetAccountTokenUsageParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -8148,6 +8174,18 @@ class AccountLogoutRequest(BaseModel):
params: Annotated[None, Field(None)] = None
class AccountSecuritySetupReadRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: RequestId
method: Annotated[
Literal["account/securitySetup/read"],
Field(title="Account/securitySetup/readRequestMethod"),
]
params: Annotated[None, Field(None)] = None
class AccountRateLimitsReadRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -13699,6 +13737,7 @@ class ClientRequest(
| AccountLoginStartRequest
| AccountLoginCancelRequest
| AccountLogoutRequest
| AccountSecuritySetupReadRequest
| AccountRateLimitsReadRequest
| AccountRateLimitResetCreditConsumeRequest
| AccountUsageReadRequest
@@ -13810,6 +13849,7 @@ class ClientRequest(
| AccountLoginStartRequest
| AccountLoginCancelRequest
| AccountLogoutRequest
| AccountSecuritySetupReadRequest
| AccountRateLimitsReadRequest
| AccountRateLimitResetCreditConsumeRequest
| AccountUsageReadRequest