Exchange workspace token when switching account sessions

This commit is contained in:
Dhruv
2026-06-04 01:27:23 +05:30
parent 0fe23f573a
commit 4a64cc688e
6 changed files with 63 additions and 6 deletions
+1 -1
View File
@@ -1866,7 +1866,7 @@ Field notes:
### 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.
Desktop clients can save the current managed ChatGPT auth payload after login, list saved sessions, and switch the active workspace. Switching a workspace exchanges the saved access token for a newly signed workspace-scoped token without opening a browser. These methods return the full saved-session snapshot because the number of interactive account sessions is intentionally small.
```json
{ "method": "accountSession/add", "id": 6, "params": { "switchToAddedAccount": true } }
+32 -5
View File
@@ -17,6 +17,7 @@ use codex_login::logout;
use codex_login::revoke_account_session_auth;
use codex_login::save_account_session_auth;
use codex_login::save_auth;
use codex_login::token_data::parse_chatgpt_jwt_claims;
use serde::Deserialize;
use serde::Serialize;
use std::fs::OpenOptions;
@@ -202,26 +203,52 @@ impl<'a> AccountSessionsStore<'a> {
Ok(Self::response(stored))
}
pub(crate) fn switch(
pub(crate) async 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
let index = stored
.sessions
.iter_mut()
.find(|session| session.session_id == session_id)
.iter()
.position(|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)?
.load_session_auth(&stored.sessions[index].session_id)?
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session has no tokens"))?;
let auth = CodexAuth::from_account_session_auth_dot_json(
self.codex_home,
&stored.sessions[index].session_id,
auth_json.clone(),
self.auth_credentials_store_mode,
Some(self.chatgpt_base_url),
)
.await?;
let client = BackendClient::from_auth(self.chatgpt_base_url, &auth)
.map_err(std::io::Error::other)?;
let replacement = client
.switch_workspace_token(account_id)
.await
.map_err(std::io::Error::other)?;
let plan = parse_chatgpt_jwt_claims(&replacement.access_token)
.ok()
.and_then(|claims| claims.get_chatgpt_plan_type_raw());
let tokens = auth_json
.tokens
.as_mut()
.ok_or_else(|| std::io::Error::other("Saved ChatGPT account session has no tokens"))?;
tokens.access_token = replacement.access_token;
if let Some(refresh_token) = replacement.refresh_token {
tokens.refresh_token = refresh_token;
}
tokens.account_id = Some(account_id.to_string());
auth_json.last_refresh = Some(Utc::now());
let session = &mut stored.sessions[index];
if let Some(plan) = plan {
session.plan = Some(plan);
}
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());
@@ -149,6 +149,7 @@ impl AccountRequestProcessor {
let response = self
.account_sessions_store()
.switch(&params.session_id, &params.account_id)
.await
.map_err(|err| internal_error(format!("failed to switch account session: {err}")))?;
self.sync_auth_after_account_session_change().await;
Ok(Some(response.into()))
+21
View File
@@ -4,6 +4,7 @@ use crate::types::ConfigBundleResponse;
use crate::types::PaginatedListTaskListItem;
use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind;
use crate::types::RateLimitStatusPayload;
use crate::types::SwitchWorkspaceTokenResponse;
use crate::types::TurnAttemptsSiblingTurnsResponse;
use anyhow::Result;
use codex_api::SharedAuthProvider;
@@ -97,6 +98,11 @@ struct SendAddCreditsNudgeEmailRequest {
credit_type: AddCreditsNudgeCreditType,
}
#[derive(Serialize)]
struct SwitchWorkspaceTokenRequest<'a> {
workspace_id: &'a str,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PathStyle {
/// /api/codex/…
@@ -313,6 +319,21 @@ impl Client {
self.decode_json(&url, &ct, &body)
}
pub async fn switch_workspace_token(
&self,
workspace_id: &str,
) -> Result<SwitchWorkspaceTokenResponse> {
let url = format!("{}/accounts/switch-workspace-token", self.base_url);
let req = self
.http
.post(&url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&SwitchWorkspaceTokenRequest { workspace_id });
let (body, ct) = self.exec_request(req, "POST", &url).await?;
self.decode_json(&url, &ct, &body)
}
pub async fn send_add_credits_nudge_email(
&self,
credit_type: AddCreditsNudgeCreditType,
+1
View File
@@ -13,5 +13,6 @@ pub use types::DeliveredConfigToml;
pub use types::DeliveredRequirementsToml;
pub use types::DeliveredTomlFragment;
pub use types::PaginatedListTaskListItem;
pub use types::SwitchWorkspaceTokenResponse;
pub use types::TaskListItem;
pub use types::TurnAttemptsSiblingTurnsResponse;
+7
View File
@@ -35,6 +35,13 @@ pub struct AccountEntry {
pub structure: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct SwitchWorkspaceTokenResponse {
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
}
#[derive(Deserialize)]
struct RawAccountsCheckResponse {
#[serde(default)]