mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
Add app server account session lifecycle
This commit is contained in:
@@ -1775,6 +1775,10 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
|
||||
- `account/login/start` — begin login (`apiKey`, `chatgpt`, `chatgptDeviceCode`).
|
||||
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
|
||||
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
|
||||
- `accountSession/add` — save the current managed ChatGPT auth session.
|
||||
- `accountSession/list` — list saved managed ChatGPT auth sessions and optionally refresh their workspace metadata.
|
||||
- `accountSession/switch` — make a saved ChatGPT auth session and workspace current.
|
||||
- `accountSession/logout` — revoke and remove one saved ChatGPT auth session.
|
||||
- `account/logout` — sign out; triggers `account/updated`.
|
||||
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available.
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify).
|
||||
@@ -1860,19 +1864,30 @@ Field notes:
|
||||
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": false, "error": "…" } }
|
||||
```
|
||||
|
||||
### 6) Logout
|
||||
### 6) Save and switch ChatGPT account sessions
|
||||
|
||||
Desktop clients can save the current managed ChatGPT auth payload after login, list saved sessions, and switch the active workspace. These methods return the full saved-session snapshot because the number of interactive account sessions is intentionally small.
|
||||
|
||||
```json
|
||||
{ "method": "account/logout", "id": 6 }
|
||||
{ "id": 6, "result": {} }
|
||||
{ "method": "accountSession/add", "id": 6, "params": { "switchToAddedAccount": true } }
|
||||
{ "method": "accountSession/list", "id": 7, "params": { "refreshWorkspaceMetadata": true } }
|
||||
{ "method": "accountSession/switch", "id": 8, "params": { "sessionId": "<uuid>", "accountId": "<workspace-id>" } }
|
||||
{ "method": "accountSession/logout", "id": 9, "params": { "sessionId": "<uuid>" } }
|
||||
```
|
||||
|
||||
### 7) Logout
|
||||
|
||||
```json
|
||||
{ "method": "account/logout", "id": 10 }
|
||||
{ "id": 10, "result": {} }
|
||||
{ "method": "account/updated", "params": { "authMode": null, "planType": null } }
|
||||
```
|
||||
|
||||
### 7) Rate limits (ChatGPT)
|
||||
### 8) Rate limits (ChatGPT)
|
||||
|
||||
```json
|
||||
{ "method": "account/rateLimits/read", "id": 7 }
|
||||
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } }
|
||||
{ "method": "account/rateLimits/read", "id": 11 }
|
||||
{ "id": 11, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } }
|
||||
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
use base64::Engine;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::AccountSession;
|
||||
use codex_app_server_protocol::AccountSessionWorkspace;
|
||||
use codex_app_server_protocol::AccountSessionWorkspaceKind;
|
||||
use codex_app_server_protocol::AccountSessionWorkspaceStatus;
|
||||
use codex_app_server_protocol::AccountSessionsResponse;
|
||||
use codex_backend_client::AccountEntry;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::AuthDotJson;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::delete_account_session_auth;
|
||||
use codex_login::load_account_session_auth;
|
||||
use codex_login::load_auth_dot_json;
|
||||
use codex_login::logout;
|
||||
use codex_login::revoke_auth_tokens;
|
||||
use codex_login::save_account_session_auth;
|
||||
use codex_login::save_auth;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
const ACCOUNT_SESSIONS_FILE: &str = "account-sessions.json";
|
||||
|
||||
pub(crate) struct AccountSessionsStore<'a> {
|
||||
codex_home: &'a Path,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
chatgpt_base_url: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StoredAccountSessions {
|
||||
active_session_id: Option<String>,
|
||||
sessions: Vec<StoredAccountSession>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StoredAccountSession {
|
||||
session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
auth_json: Option<AuthDotJson>,
|
||||
email: Option<String>,
|
||||
user_id: Option<String>,
|
||||
display_name: Option<String>,
|
||||
image_url: Option<String>,
|
||||
plan: Option<String>,
|
||||
last_used_at: i64,
|
||||
selected_workspace_account_id: Option<String>,
|
||||
workspaces: Vec<AccountSessionWorkspace>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct AccessTokenClaims {
|
||||
#[serde(rename = "https://api.openai.com/profile", default)]
|
||||
profile: AccessTokenProfileClaims,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct AccessTokenProfileClaims {
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(default)]
|
||||
image: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
picture: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> AccountSessionsStore<'a> {
|
||||
pub(crate) fn new(
|
||||
codex_home: &'a Path,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
chatgpt_base_url: &'a str,
|
||||
) -> Self {
|
||||
Self {
|
||||
codex_home,
|
||||
auth_credentials_store_mode,
|
||||
chatgpt_base_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn add(
|
||||
&self,
|
||||
switch_to_added_account: bool,
|
||||
) -> std::io::Result<AccountSessionsResponse> {
|
||||
let mut stored = self.load()?;
|
||||
let mut auth_json = load_auth_dot_json(self.codex_home, self.auth_credentials_store_mode)?
|
||||
.ok_or_else(|| std::io::Error::other("No active ChatGPT auth session to add"))?;
|
||||
let mut session = Self::session_from_auth_json(&auth_json)
|
||||
.ok_or_else(|| std::io::Error::other("No active ChatGPT auth session to add"))?;
|
||||
self.refresh_workspace_metadata(&mut session, &mut auth_json)
|
||||
.await;
|
||||
|
||||
let existing_index = stored.sessions.iter().position(|saved| {
|
||||
session
|
||||
.email
|
||||
.as_ref()
|
||||
.is_some_and(|email| saved.email.as_ref() == Some(email))
|
||||
|| session
|
||||
.user_id
|
||||
.as_ref()
|
||||
.is_some_and(|user_id| saved.user_id.as_ref() == Some(user_id))
|
||||
});
|
||||
if let Some(index) = existing_index {
|
||||
session
|
||||
.session_id
|
||||
.clone_from(&stored.sessions[index].session_id);
|
||||
}
|
||||
let added_session_id = session.session_id.clone();
|
||||
self.save_session_auth(&added_session_id, &auth_json)?;
|
||||
if let Some(index) = existing_index {
|
||||
stored.sessions[index] = session;
|
||||
} else {
|
||||
stored.sessions.push(session);
|
||||
}
|
||||
|
||||
if switch_to_added_account || stored.active_session_id.is_none() {
|
||||
stored.active_session_id = Some(added_session_id);
|
||||
save_auth(
|
||||
self.codex_home,
|
||||
&auth_json,
|
||||
self.auth_credentials_store_mode,
|
||||
)?;
|
||||
} else if let Some(active_session_id) = stored.active_session_id.as_deref() {
|
||||
self.save_active_session_auth(active_session_id)?;
|
||||
}
|
||||
|
||||
self.save(&stored)?;
|
||||
Ok(Self::response(stored))
|
||||
}
|
||||
|
||||
pub(crate) async fn list(
|
||||
&self,
|
||||
refresh_workspace_metadata: bool,
|
||||
) -> std::io::Result<AccountSessionsResponse> {
|
||||
self.sync_active_auth()?;
|
||||
let mut stored = self.load()?;
|
||||
if refresh_workspace_metadata {
|
||||
for session in &mut stored.sessions {
|
||||
let Some(mut auth_json) = self.load_session_auth(&session.session_id)? else {
|
||||
continue;
|
||||
};
|
||||
self.refresh_workspace_metadata(session, &mut auth_json)
|
||||
.await;
|
||||
self.save_session_auth(&session.session_id, &auth_json)?;
|
||||
}
|
||||
self.save(&stored)?;
|
||||
}
|
||||
Ok(Self::response(stored))
|
||||
}
|
||||
|
||||
pub(crate) async fn logout(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> std::io::Result<AccountSessionsResponse> {
|
||||
self.sync_active_auth()?;
|
||||
let mut stored = self.load()?;
|
||||
let index = stored
|
||||
.sessions
|
||||
.iter()
|
||||
.position(|session| session.session_id == session_id)
|
||||
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session not found"))?;
|
||||
let removed = stored.sessions.remove(index);
|
||||
let removed_auth = self.load_session_auth(&removed.session_id)?;
|
||||
if let Err(err) = revoke_auth_tokens(removed_auth.as_ref()).await {
|
||||
tracing::warn!("failed to revoke saved account session during logout: {err}");
|
||||
}
|
||||
delete_account_session_auth(
|
||||
self.codex_home,
|
||||
&removed.session_id,
|
||||
self.auth_credentials_store_mode,
|
||||
)?;
|
||||
|
||||
if stored.active_session_id.as_deref() == Some(session_id) {
|
||||
let newest = stored
|
||||
.sessions
|
||||
.iter()
|
||||
.max_by_key(|session| session.last_used_at);
|
||||
stored.active_session_id = newest.map(|session| session.session_id.clone());
|
||||
match newest {
|
||||
Some(session) => self.save_active_session_auth(&session.session_id)?,
|
||||
None => {
|
||||
logout(self.codex_home, self.auth_credentials_store_mode)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.save(&stored)?;
|
||||
Ok(Self::response(stored))
|
||||
}
|
||||
|
||||
pub(crate) fn switch(
|
||||
&self,
|
||||
session_id: &str,
|
||||
account_id: &str,
|
||||
) -> std::io::Result<AccountSessionsResponse> {
|
||||
self.sync_active_auth()?;
|
||||
let mut stored = self.load()?;
|
||||
let session = stored
|
||||
.sessions
|
||||
.iter_mut()
|
||||
.find(|session| session.session_id == session_id)
|
||||
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session not found"))?;
|
||||
let mut auth_json = self
|
||||
.load_session_auth(&session.session_id)?
|
||||
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session has no tokens"))?;
|
||||
let tokens = auth_json
|
||||
.tokens
|
||||
.as_mut()
|
||||
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session has no tokens"))?;
|
||||
tokens.account_id = Some(account_id.to_string());
|
||||
session.selected_workspace_account_id = Some(account_id.to_string());
|
||||
session.last_used_at = Utc::now().timestamp();
|
||||
stored.active_session_id = Some(session_id.to_string());
|
||||
self.save_session_auth(session_id, &auth_json)?;
|
||||
save_auth(
|
||||
self.codex_home,
|
||||
&auth_json,
|
||||
self.auth_credentials_store_mode,
|
||||
)?;
|
||||
self.save(&stored)?;
|
||||
Ok(Self::response(stored))
|
||||
}
|
||||
|
||||
pub(crate) fn has_sessions(&self) -> std::io::Result<bool> {
|
||||
Ok(self
|
||||
.read()?
|
||||
.is_some_and(|stored| !stored.sessions.is_empty()))
|
||||
}
|
||||
|
||||
pub(crate) fn sync_active_auth(&self) -> std::io::Result<()> {
|
||||
let Some(mut stored) = self.read()? else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.migrate_legacy_auth(&mut stored)? {
|
||||
self.save(&stored)?;
|
||||
}
|
||||
let Some(active_session_id) = stored.active_session_id.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(auth_json) =
|
||||
load_auth_dot_json(self.codex_home, self.auth_credentials_store_mode)?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(session) = stored
|
||||
.sessions
|
||||
.iter_mut()
|
||||
.find(|session| &session.session_id == active_session_id)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if !Self::same_identity(session, &auth_json) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let saved_auth_json = self.load_session_auth(&session.session_id)?;
|
||||
let selected_workspace_account_id = auth_json
|
||||
.tokens
|
||||
.as_ref()
|
||||
.and_then(|tokens| tokens.account_id.clone());
|
||||
if saved_auth_json.as_ref() == Some(&auth_json)
|
||||
&& (selected_workspace_account_id.is_none()
|
||||
|| session.selected_workspace_account_id == selected_workspace_account_id)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.save_session_auth(&session.session_id, &auth_json)?;
|
||||
if selected_workspace_account_id.is_some() {
|
||||
session.selected_workspace_account_id = selected_workspace_account_id;
|
||||
}
|
||||
self.save(&stored)
|
||||
}
|
||||
|
||||
pub(crate) async fn revoke_all_and_clear(&self) -> std::io::Result<()> {
|
||||
let Some(mut stored) = self.read()? else {
|
||||
return self.clear();
|
||||
};
|
||||
self.migrate_legacy_auth(&mut stored)?;
|
||||
for session in stored.sessions {
|
||||
let auth_json = self.load_session_auth(&session.session_id)?;
|
||||
if let Err(err) = revoke_auth_tokens(auth_json.as_ref()).await {
|
||||
tracing::warn!("failed to revoke saved account session during logout: {err}");
|
||||
}
|
||||
delete_account_session_auth(
|
||||
self.codex_home,
|
||||
&session.session_id,
|
||||
self.auth_credentials_store_mode,
|
||||
)?;
|
||||
}
|
||||
self.clear()
|
||||
}
|
||||
|
||||
fn load(&self) -> std::io::Result<StoredAccountSessions> {
|
||||
match self.read()? {
|
||||
Some(mut stored) => {
|
||||
if self.migrate_legacy_auth(&mut stored)? {
|
||||
self.save(&stored)?;
|
||||
}
|
||||
Ok(stored)
|
||||
}
|
||||
None => {
|
||||
let auth_json =
|
||||
load_auth_dot_json(self.codex_home, self.auth_credentials_store_mode)?;
|
||||
let Some(auth_json) = auth_json else {
|
||||
return Ok(StoredAccountSessions::default());
|
||||
};
|
||||
let Some(session) = Self::session_from_auth_json(&auth_json) else {
|
||||
return Ok(StoredAccountSessions::default());
|
||||
};
|
||||
self.save_session_auth(&session.session_id, &auth_json)?;
|
||||
let stored = StoredAccountSessions {
|
||||
active_session_id: Some(session.session_id.clone()),
|
||||
sessions: vec![session],
|
||||
};
|
||||
self.save(&stored)?;
|
||||
Ok(stored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_legacy_auth(&self, stored: &mut StoredAccountSessions) -> std::io::Result<bool> {
|
||||
let mut migrated = false;
|
||||
for session in &mut stored.sessions {
|
||||
if let Some(auth_json) = session.auth_json.take() {
|
||||
self.save_session_auth(&session.session_id, &auth_json)?;
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
fn load_session_auth(&self, session_id: &str) -> std::io::Result<Option<AuthDotJson>> {
|
||||
load_account_session_auth(
|
||||
self.codex_home,
|
||||
session_id,
|
||||
self.auth_credentials_store_mode,
|
||||
)
|
||||
}
|
||||
|
||||
fn save_session_auth(&self, session_id: &str, auth_json: &AuthDotJson) -> std::io::Result<()> {
|
||||
save_account_session_auth(
|
||||
self.codex_home,
|
||||
session_id,
|
||||
auth_json,
|
||||
self.auth_credentials_store_mode,
|
||||
)
|
||||
}
|
||||
|
||||
fn save_active_session_auth(&self, session_id: &str) -> std::io::Result<()> {
|
||||
let auth_json = self
|
||||
.load_session_auth(session_id)?
|
||||
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session has no tokens"))?;
|
||||
save_auth(
|
||||
self.codex_home,
|
||||
&auth_json,
|
||||
self.auth_credentials_store_mode,
|
||||
)
|
||||
}
|
||||
|
||||
fn clear(&self) -> std::io::Result<()> {
|
||||
match std::fs::remove_file(self.path()) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self) -> std::io::Result<Option<StoredAccountSessions>> {
|
||||
match std::fs::read_to_string(self.path()) {
|
||||
Ok(payload) => serde_json::from_str(&payload)
|
||||
.map(Some)
|
||||
.map_err(std::io::Error::other),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn save(&self, sessions: &StoredAccountSessions) -> std::io::Result<()> {
|
||||
let path = self.path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut options = OpenOptions::new();
|
||||
options.truncate(true).write(true).create(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(path)?;
|
||||
file.write_all(serde_json::to_string_pretty(sessions)?.as_bytes())?;
|
||||
file.flush()
|
||||
}
|
||||
|
||||
async fn refresh_workspace_metadata(
|
||||
&self,
|
||||
session: &mut StoredAccountSession,
|
||||
auth_json: &mut AuthDotJson,
|
||||
) {
|
||||
let Ok(auth) = CodexAuth::from_auth_dot_json(
|
||||
self.codex_home,
|
||||
auth_json.clone(),
|
||||
self.auth_credentials_store_mode,
|
||||
Some(self.chatgpt_base_url),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(client) = BackendClient::from_auth(self.chatgpt_base_url, &auth) else {
|
||||
return;
|
||||
};
|
||||
let Ok(accounts) = client.get_accounts_check().await else {
|
||||
return;
|
||||
};
|
||||
session.selected_workspace_account_id = session
|
||||
.selected_workspace_account_id
|
||||
.clone()
|
||||
.or(accounts.default_account_id)
|
||||
.or_else(|| accounts.account_ordering.first().cloned());
|
||||
if let Some(account_id) = session.selected_workspace_account_id.as_ref()
|
||||
&& let Some(tokens) = auth_json.tokens.as_mut()
|
||||
{
|
||||
tokens.account_id = Some(account_id.clone());
|
||||
}
|
||||
session.workspaces = accounts
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(Self::workspace_from_account)
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn session_from_auth_json(auth_json: &AuthDotJson) -> Option<StoredAccountSession> {
|
||||
let Some(tokens) = auth_json.tokens.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let claims = Self::access_token_claims(&tokens.access_token);
|
||||
let selected_workspace_account_id = tokens
|
||||
.account_id
|
||||
.clone()
|
||||
.or_else(|| tokens.id_token.chatgpt_account_id.clone());
|
||||
let workspaces = selected_workspace_account_id
|
||||
.as_ref()
|
||||
.map(|account_id| {
|
||||
vec![AccountSessionWorkspace {
|
||||
account_id: account_id.clone(),
|
||||
name: None,
|
||||
image_url: None,
|
||||
kind: None,
|
||||
status: AccountSessionWorkspaceStatus::Active,
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(StoredAccountSession {
|
||||
session_id: Uuid::now_v7().to_string(),
|
||||
auth_json: None,
|
||||
email: tokens.id_token.email.clone(),
|
||||
user_id: tokens.id_token.chatgpt_user_id.clone(),
|
||||
display_name: claims.profile.name,
|
||||
image_url: claims.profile.picture.or(claims.profile.image),
|
||||
plan: tokens.id_token.get_chatgpt_plan_type_raw(),
|
||||
last_used_at: Utc::now().timestamp(),
|
||||
selected_workspace_account_id,
|
||||
workspaces,
|
||||
})
|
||||
}
|
||||
|
||||
fn access_token_claims(access_token: &str) -> AccessTokenClaims {
|
||||
let Some(payload) = access_token.split('.').nth(1) else {
|
||||
return AccessTokenClaims::default();
|
||||
};
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.ok()
|
||||
.and_then(|payload| serde_json::from_slice(&payload).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn same_identity(session: &StoredAccountSession, auth_json: &AuthDotJson) -> bool {
|
||||
let Some(tokens) = auth_json.tokens.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
session
|
||||
.email
|
||||
.as_ref()
|
||||
.zip(tokens.id_token.email.as_ref())
|
||||
.is_some_and(|(saved, active)| saved == active)
|
||||
|| session
|
||||
.user_id
|
||||
.as_ref()
|
||||
.zip(tokens.id_token.chatgpt_user_id.as_ref())
|
||||
.is_some_and(|(saved, active)| saved == active)
|
||||
}
|
||||
|
||||
fn workspace_from_account(account: AccountEntry) -> AccountSessionWorkspace {
|
||||
let kind = match account.structure.as_str() {
|
||||
"personal" => Some(AccountSessionWorkspaceKind::Personal),
|
||||
"workspace" => Some(AccountSessionWorkspaceKind::Workspace),
|
||||
_ => None,
|
||||
};
|
||||
AccountSessionWorkspace {
|
||||
account_id: account.id,
|
||||
name: account.name,
|
||||
image_url: account.profile_picture_url,
|
||||
kind,
|
||||
status: AccountSessionWorkspaceStatus::Active,
|
||||
}
|
||||
}
|
||||
|
||||
fn response(stored: StoredAccountSessions) -> AccountSessionsResponse {
|
||||
let active_session_id = stored.active_session_id;
|
||||
let mut sessions = stored
|
||||
.sessions
|
||||
.into_iter()
|
||||
.map(|session| AccountSession {
|
||||
is_active: Some(&session.session_id) == active_session_id.as_ref(),
|
||||
session_id: session.session_id,
|
||||
email: session.email,
|
||||
user_id: session.user_id,
|
||||
display_name: session.display_name,
|
||||
image_url: session.image_url,
|
||||
plan: session.plan,
|
||||
last_used_at: session.last_used_at,
|
||||
selected_workspace_account_id: session.selected_workspace_account_id,
|
||||
workspaces: session.workspaces,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
sessions.sort_by_key(|session| std::cmp::Reverse(session.last_used_at));
|
||||
AccountSessionsResponse {
|
||||
active_session_id,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self) -> PathBuf {
|
||||
self.codex_home.join(ACCOUNT_SESSIONS_FILE)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::registry::Registry;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
mod account_sessions;
|
||||
mod analytics_utils;
|
||||
mod app_server_tracing;
|
||||
mod attestation;
|
||||
|
||||
@@ -1286,6 +1286,18 @@ impl MessageProcessor {
|
||||
ClientRequest::CancelLoginAccount { params, .. } => {
|
||||
self.account_processor.cancel_login_account(params).await
|
||||
}
|
||||
ClientRequest::AccountSessionsAdd { params, .. } => {
|
||||
self.account_processor.add_account_session(params).await
|
||||
}
|
||||
ClientRequest::AccountSessionsList { params, .. } => {
|
||||
self.account_processor.list_account_sessions(params).await
|
||||
}
|
||||
ClientRequest::AccountSessionsLogout { params, .. } => {
|
||||
self.account_processor.logout_account_session(params).await
|
||||
}
|
||||
ClientRequest::AccountSessionsSwitch { params, .. } => {
|
||||
self.account_processor.switch_account_session(params).await
|
||||
}
|
||||
ClientRequest::GetAccount { params, .. } => {
|
||||
self.account_processor.get_account(params).await
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::account_sessions::AccountSessionsStore;
|
||||
use crate::bespoke_event_handling::apply_bespoke_event_handling;
|
||||
use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed;
|
||||
use crate::command_exec::CommandExecManager;
|
||||
@@ -22,6 +23,10 @@ use codex_analytics::InputError;
|
||||
use codex_analytics::TurnSteerRequestError;
|
||||
use codex_app_server_protocol::Account;
|
||||
use codex_app_server_protocol::AccountLoginCompletedNotification;
|
||||
use codex_app_server_protocol::AccountSessionsAddParams;
|
||||
use codex_app_server_protocol::AccountSessionsListParams;
|
||||
use codex_app_server_protocol::AccountSessionsLogoutParams;
|
||||
use codex_app_server_protocol::AccountSessionsSwitchParams;
|
||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||
use codex_app_server_protocol::AddCreditsNudgeCreditType;
|
||||
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
|
||||
|
||||
@@ -105,6 +105,55 @@ impl AccountRequestProcessor {
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn add_account_session(
|
||||
&self,
|
||||
params: AccountSessionsAddParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
let response = self
|
||||
.account_sessions_store()
|
||||
.add(params.switch_to_added_account)
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to add account session: {err}")))?;
|
||||
self.sync_auth_after_account_session_change().await;
|
||||
Ok(Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_account_sessions(
|
||||
&self,
|
||||
params: AccountSessionsListParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.account_sessions_store()
|
||||
.list(params.refresh_workspace_metadata)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
.map_err(|err| internal_error(format!("failed to list account sessions: {err}")))
|
||||
}
|
||||
|
||||
pub(crate) async fn logout_account_session(
|
||||
&self,
|
||||
params: AccountSessionsLogoutParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
let response = self
|
||||
.account_sessions_store()
|
||||
.logout(¶ms.session_id)
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to log out account session: {err}")))?;
|
||||
self.sync_auth_after_account_session_change().await;
|
||||
Ok(Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn switch_account_session(
|
||||
&self,
|
||||
params: AccountSessionsSwitchParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
let response = self
|
||||
.account_sessions_store()
|
||||
.switch(¶ms.session_id, ¶ms.account_id)
|
||||
.map_err(|err| internal_error(format!("failed to switch account session: {err}")))?;
|
||||
self.sync_auth_after_account_session_change().await;
|
||||
Ok(Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn get_account(
|
||||
&self,
|
||||
params: GetAccountParams,
|
||||
@@ -159,6 +208,42 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
fn account_sessions_store(&self) -> AccountSessionsStore<'_> {
|
||||
AccountSessionsStore::new(
|
||||
&self.config.codex_home,
|
||||
self.config.cli_auth_credentials_store_mode,
|
||||
&self.config.chatgpt_base_url,
|
||||
)
|
||||
}
|
||||
|
||||
fn sync_active_account_session(&self) -> Result<(), JSONRPCErrorError> {
|
||||
self.account_sessions_store()
|
||||
.sync_active_auth()
|
||||
.map_err(|err| internal_error(format!("failed to sync active account session: {err}")))
|
||||
}
|
||||
|
||||
async fn sync_auth_after_account_session_change(&self) {
|
||||
self.auth_manager.reload().await;
|
||||
self.config_manager.replace_cloud_requirements_loader(
|
||||
self.auth_manager.clone(),
|
||||
self.config.chatgpt_base_url.clone(),
|
||||
);
|
||||
self.config_manager
|
||||
.sync_default_client_residency_requirement()
|
||||
.await;
|
||||
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
&self.config_manager,
|
||||
&self.thread_manager,
|
||||
self.auth_manager.auth_cached(),
|
||||
)
|
||||
.await;
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AccountUpdated(
|
||||
self.current_account_updated_notification(),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
config_manager: &ConfigManager,
|
||||
thread_manager: &Arc<ThreadManager>,
|
||||
@@ -265,6 +350,8 @@ impl AccountRequestProcessor {
|
||||
));
|
||||
}
|
||||
|
||||
self.sync_active_account_session()?;
|
||||
|
||||
// Cancel any active login attempt.
|
||||
{
|
||||
let mut guard = self.active_login.lock().await;
|
||||
@@ -317,9 +404,16 @@ impl AccountRequestProcessor {
|
||||
));
|
||||
}
|
||||
|
||||
self.sync_active_account_session()?;
|
||||
let revoke_previous_auth = !self
|
||||
.account_sessions_store()
|
||||
.has_sessions()
|
||||
.map_err(|err| internal_error(format!("failed to read account sessions: {err}")))?;
|
||||
|
||||
let opts = LoginServerOptions {
|
||||
open_browser: false,
|
||||
codex_streamlined_login,
|
||||
revoke_previous_auth,
|
||||
..LoginServerOptions::new(
|
||||
config.codex_home.to_path_buf(),
|
||||
CLIENT_ID.to_string(),
|
||||
@@ -562,6 +656,8 @@ impl AccountRequestProcessor {
|
||||
));
|
||||
}
|
||||
|
||||
self.sync_active_account_session()?;
|
||||
|
||||
// Cancel any active login attempt to avoid persisting managed auth state.
|
||||
{
|
||||
let mut guard = self.active_login.lock().await;
|
||||
@@ -676,6 +772,12 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
self.sync_active_account_session()?;
|
||||
self.account_sessions_store()
|
||||
.revoke_all_and_clear()
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to clear account sessions: {err}")))?;
|
||||
|
||||
match self.auth_manager.logout_with_revoke().await {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
|
||||
@@ -217,6 +217,7 @@ pub async fn complete_device_code_login(
|
||||
tokens.access_token,
|
||||
tokens.refresh_token,
|
||||
opts.cli_auth_credentials_store_mode,
|
||||
opts.revoke_previous_auth,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ pub struct ServerOptions {
|
||||
pub force_state: Option<String>,
|
||||
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
|
||||
pub codex_streamlined_login: bool,
|
||||
pub revoke_previous_auth: bool,
|
||||
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
}
|
||||
|
||||
@@ -91,6 +92,7 @@ impl ServerOptions {
|
||||
force_state: None,
|
||||
forced_chatgpt_workspace_id,
|
||||
codex_streamlined_login: false,
|
||||
revoke_previous_auth: true,
|
||||
cli_auth_credentials_store_mode,
|
||||
}
|
||||
}
|
||||
@@ -362,6 +364,7 @@ async fn process_request(
|
||||
tokens.access_token.clone(),
|
||||
tokens.refresh_token.clone(),
|
||||
opts.cli_auth_credentials_store_mode,
|
||||
opts.revoke_previous_auth,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -793,6 +796,7 @@ pub(crate) async fn persist_tokens_async(
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
revoke_previous_auth: bool,
|
||||
) -> io::Result<()> {
|
||||
// Reuse existing synchronous logic but run it off the async runtime.
|
||||
let codex_home = codex_home.to_path_buf();
|
||||
@@ -829,7 +833,8 @@ pub(crate) async fn persist_tokens_async(
|
||||
.await
|
||||
.map_err(|e| io::Error::other(format!("persist task failed: {e}")))??;
|
||||
|
||||
if should_revoke_auth_tokens(previous_auth.as_ref(), &auth)
|
||||
if revoke_previous_auth
|
||||
&& should_revoke_auth_tokens(previous_auth.as_ref(), &auth)
|
||||
&& let Err(err) = revoke_auth_tokens(previous_auth.as_ref()).await
|
||||
{
|
||||
warn!("failed to revoke superseded auth tokens after login: {err}");
|
||||
@@ -1227,6 +1232,7 @@ mod tests {
|
||||
"new-access".to_string(),
|
||||
"new-refresh".to_string(),
|
||||
AuthCredentialsStoreMode::File,
|
||||
/*revoke_previous_auth*/ true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1287,6 +1293,7 @@ mod tests {
|
||||
"new-access".to_string(),
|
||||
"shared-refresh".to_string(),
|
||||
AuthCredentialsStoreMode::File,
|
||||
/*revoke_previous_auth*/ true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
|
||||
force_state: Some(state),
|
||||
forced_chatgpt_workspace_id: Some(vec![chatgpt_account_id.to_string()]),
|
||||
codex_streamlined_login: false,
|
||||
revoke_previous_auth: true,
|
||||
};
|
||||
let server = run_login_server(opts)?;
|
||||
assert!(
|
||||
@@ -190,6 +191,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> {
|
||||
force_state: Some(state),
|
||||
forced_chatgpt_workspace_id: None,
|
||||
codex_streamlined_login: false,
|
||||
revoke_previous_auth: true,
|
||||
};
|
||||
let server = run_login_server(opts)?;
|
||||
let login_port = server.actual_port;
|
||||
@@ -233,6 +235,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result<
|
||||
WORKSPACE_ID_SECOND_ALLOWED.to_string(),
|
||||
]),
|
||||
codex_streamlined_login: false,
|
||||
revoke_previous_auth: true,
|
||||
};
|
||||
let server = run_login_server(opts)?;
|
||||
let auth_url = Url::parse(&server.auth_url)?;
|
||||
@@ -271,6 +274,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> {
|
||||
force_state: Some(state.clone()),
|
||||
forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]),
|
||||
codex_streamlined_login: false,
|
||||
revoke_previous_auth: true,
|
||||
};
|
||||
let server = run_login_server(opts)?;
|
||||
assert!(
|
||||
@@ -331,6 +335,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error()
|
||||
force_state: Some(state.clone()),
|
||||
forced_chatgpt_workspace_id: None,
|
||||
codex_streamlined_login: false,
|
||||
revoke_previous_auth: true,
|
||||
};
|
||||
let server = run_login_server(opts)?;
|
||||
let login_port = server.actual_port;
|
||||
@@ -399,6 +404,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<
|
||||
force_state: Some(state.clone()),
|
||||
forced_chatgpt_workspace_id: None,
|
||||
codex_streamlined_login: false,
|
||||
revoke_previous_auth: true,
|
||||
};
|
||||
let server = run_login_server(opts)?;
|
||||
let login_port = server.actual_port;
|
||||
@@ -544,6 +550,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
|
||||
force_state: Some("cancel_state".to_string()),
|
||||
forced_chatgpt_workspace_id: None,
|
||||
codex_streamlined_login: false,
|
||||
revoke_previous_auth: true,
|
||||
};
|
||||
|
||||
let first_server = run_login_server(first_opts)?;
|
||||
@@ -565,6 +572,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
|
||||
force_state: Some("cancel_state_2".to_string()),
|
||||
forced_chatgpt_workspace_id: None,
|
||||
codex_streamlined_login: false,
|
||||
revoke_previous_auth: true,
|
||||
};
|
||||
|
||||
let second_server = run_login_server(second_opts)?;
|
||||
|
||||
Reference in New Issue
Block a user