Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,33 @@ Manage ChatGPT/Codex OAuth accounts locally and reuse them across provider profi
cc-switch auth status # Show managed account status
cc-switch auth login # Sign in with ChatGPT/Codex OAuth
cc-switch auth list # List signed-in accounts
cc-switch auth default <account-id> # Set the default account
cc-switch auth default <account-id> # Set the managed/proxy default only
cc-switch auth use <account-id> # Activate the account for standalone Codex
cc-switch auth remove <account-id> # Remove an account
```

`auth use` writes the selected ChatGPT login to the effective `CODEX_HOME/auth.json`,
updates the current official Codex provider's auth snapshot (used by quota and temporary
launches), and makes that account the managed default. It leaves `config.toml`, MCP,
skills, and other provider snapshots unchanged. `auth status --json` distinguishes
`default_account_id` from `active_codex_account_id`.

In **Settings → Managed accounts**, press **u** or choose **Use in Codex** from the
account menu. **Space / Set default** retains its existing managed/proxy-only meaning.
Restart Codex or launch a new `codex` process after activation; `/new` in an existing
process does not reload its login. Close old Codex processes before switching if they
might still refresh the shared login file.

Activation requires the official direct Codex provider and file-based credentials
(the default). Third-party routing, proxy takeover, keyring/auto credential storage,
and conflicting forced-login/workspace settings are rejected with an explanation.
Existing refresh-token-only accounts are upgraded on activation through OAuth refresh;
if the server cannot provide a complete login, sign in again with `cc-switch auth login`.
Credentials refreshed by Codex are retained when switching away and back. Pending
credential-copy updates are persisted with the new token and retried after temporary
write failures, including across process restarts. Removing a
managed account or changing `auth default` does not log out the standalone Codex process.

### 🛠️ MCP Server Management

Manage Model Context Protocol servers across Claude, Codex, Gemini, OpenCode, and Hermes.
Expand Down
8 changes: 8 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -770,3 +770,11 @@ src-tauri/src/

- MIT © 原作者:Jason Young
- CLI 分支维护者:saladday

### Codex 官方账号切换

`cc-switch auth use <account-id>` 将托管账号用于下一次独立 Codex 启动,同时更新当前官方供应商的认证快照和托管默认账号,不改写 `config.toml`、MCP 或其他供应商。
在设置的托管账号页面按 **u**,或从账号菜单选择 **在 Codex 中使用**。原有空格和 `auth default` 仍只设置托管默认账号。
切换后请重启 Codex 或启动新进程;已有进程内的 `/new` 不会重新读取登录。切换前请关闭可能刷新共享登录文件的旧 Codex 进程。
仅支持官方直连及文件凭据存储;第三方路由、代理接管、keyring/auto 和冲突的强制登录配置会被拒绝。旧账号首次使用时会通过 OAuth 刷新补全凭据,必要时需重新登录。
`auth status --json` 分别显示 `default_account_id` 和 `active_codex_account_id`。
17 changes: 17 additions & 0 deletions src-tauri/src/cli/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ pub enum AuthCommand {
/// Account id to make default
account_id: String,
},
/// Activate an account for the next standalone Codex process
Use { account_id: String },
/// Remove a ChatGPT account
Remove {
/// Account id to remove
Expand Down Expand Up @@ -64,6 +66,15 @@ pub fn execute(cmd: AuthCommand) -> Result<(), AppError> {
AuthCommand::List { json } => list_accounts(&runtime, json),
AuthCommand::Login { json } => login(&runtime, json),
AuthCommand::Default { account_id } => set_default(&runtime, &account_id),
AuthCommand::Use { account_id } => {
runtime
.block_on(crate::services::codex_account::use_account(
normalize_account_id(&account_id)?,
))
.map_err(AppError::Message)?;
println!("{}", success("Codex account activated. Restart Codex or launch a new Codex process; /new does not reload login."));
Ok(())
}
AuthCommand::Remove { account_id, yes } => remove_account(&runtime, &account_id, yes),
AuthCommand::Logout { yes } => logout(&runtime, yes),
}
Expand Down Expand Up @@ -105,6 +116,12 @@ fn status(runtime: &tokio::runtime::Runtime, json: bool) -> Result<(), AppError>
{
println!("Migration: {error}");
}
println!(
"Codex active: {}",
crate::services::codex_account::active_account_id()
.as_deref()
.unwrap_or("-")
);
println!("Accounts: {}", status.accounts.len());

if !status.accounts.is_empty() {
Expand Down
36 changes: 31 additions & 5 deletions src-tauri/src/cli/commands/internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,41 @@ pub fn execute(cmd: InternalCommand) -> Result<(), AppError> {
codex_home,
auth_only,
} => {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| AppError::Message(e.to_string()))?;
// Capture's DB write, accepted-result read and managed import must be
// serialized together with activation and managed credential refresh.
let _mutation_guard = runtime
.block_on(crate::services::state_coordination::acquire_restore_mutation_guard())
.map_err(AppError::Message)?;
let db = crate::Database::init()?;
if auth_only {
return ProviderService::capture_codex_launch_auth(
&crate::Database::init()?,
ProviderService::capture_codex_launch_auth(&db, &provider_id, &codex_home)?;
} else {
let state = AppState::try_new()?;
ProviderService::capture_codex_temp_launch_snapshot(
&state,
&provider_id,
&codex_home,
);
)?;
}
let state = AppState::try_new()?;
ProviderService::capture_codex_temp_launch_snapshot(&state, &provider_id, &codex_home)
// Use the accepted DB snapshot, not a launch file rejected by optimistic concurrency.
let providers = db.get_all_providers("codex")?;
if let Some(provider) = providers
.get(&provider_id)
.filter(|p| ProviderService::codex_live_write_category(p) == Some("official"))
{
if let Some(auth) = provider.settings_config.get("auth") {
runtime
.block_on(crate::services::codex_account::capture_native_auth_locked(
auth,
))
.map_err(AppError::Message)?;
}
}
Ok(())
}
}
}
4 changes: 4 additions & 0 deletions src-tauri/src/cli/tui/app/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ pub enum Action {
ManagedAuthStartLogin {
auth_provider: String,
},
ManagedAuthUse {
auth_provider: String,
account_id: String,
},
ManagedAuthSetDefault {
auth_provider: String,
account_id: String,
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/src/cli/tui/app/content_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,16 @@ impl App {
KeyCode::Char('r') => Action::ManagedAuthRefresh {
auth_provider: "codex_oauth".to_string(),
},
KeyCode::Char('u') => match self.switch_selected_managed_account() {
Action::ManagedAuthSetDefault {
auth_provider,
account_id,
} => Action::ManagedAuthUse {
auth_provider,
account_id,
},
action => action,
},
KeyCode::Char(' ') => self.switch_selected_managed_account(),
KeyCode::Enter => self.activate_managed_account_row(),
_ => Action::None,
Expand Down
8 changes: 6 additions & 2 deletions src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,7 @@ impl App {
return None;
};

*selected = (*selected).min(1);
*selected = (*selected).min(2);

Some(match key.code {
KeyCode::Esc => {
Expand All @@ -828,7 +828,7 @@ impl App {
Action::None
}
KeyCode::Down => {
*selected = (*selected + 1).min(1);
*selected = (*selected + 1).min(2);
Action::None
}
KeyCode::Enter => {
Expand All @@ -839,6 +839,10 @@ impl App {
auth_provider,
account_id,
},
1 => Action::ManagedAuthUse {
auth_provider,
account_id,
},
_ => Action::ManagedAuthRemove {
auth_provider,
account_id,
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/src/cli/tui/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ mod tests {
authenticated: true,
default_account_id: Some("acc-default".to_string()),
migration_error: None,
active_codex_account_id: None,
accounts: vec![
crate::services::ManagedAuthAccount {
id: "acc-default".to_string(),
Expand Down Expand Up @@ -15500,6 +15501,15 @@ mod tests {
account_id: "acc-alt".to_string(),
selected: 1,
};
assert!(
matches!(app.on_key(key(KeyCode::Enter), &data()), Action::ManagedAuthUse { account_id, .. } if account_id == "acc-alt")
);

app.overlay = Overlay::ManagedAccountActionPicker {
auth_provider: "codex_oauth".to_string(),
account_id: "acc-alt".to_string(),
selected: 2,
};
let action = app.on_key(key(KeyCode::Enter), &data());
assert!(matches!(
action,
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/src/cli/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2220,6 +2220,7 @@ fn cache_invalidation_for_action(action: &Action) -> CacheInvalidation {
| Action::UsageLogDetailRefresh { .. }
| Action::ManagedAuthRefresh { .. }
| Action::ManagedAuthStartLogin { .. }
| Action::ManagedAuthUse { .. }
| Action::ManagedAuthSetDefault { .. }
| Action::ManagedAuthRemove { .. }
| Action::SkillsInstall { .. }
Expand Down Expand Up @@ -3662,7 +3663,17 @@ pub fn run(app_override: Option<AppType>) -> Result<(), AppError> {
if let Some(auth) = managed_auth.as_ref() {
while let Ok(msg) = auth.result_rx.try_recv() {
frame_scheduler.mark_dirty();
let activated = matches!(
&msg,
runtime_systems::ManagedAuthMsg::Used { result: Ok(_) }
);
handle_managed_auth_msg(&mut app, msg);
if activated {
match data::UiData::load(&app.app_type) {
Ok(fresh) => data = fresh,
Err(err) => app.push_toast(err.to_string(), ToastKind::Error),
}
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/cli/tui/runtime_actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,10 @@ pub(crate) fn handle_action(
Action::ManagedAuthStartLogin { auth_provider } => {
settings::managed_auth_start_login(&mut ctx, auth_provider)
}
Action::ManagedAuthUse {
auth_provider,
account_id,
} => settings::managed_auth_use(&mut ctx, auth_provider, account_id),
Action::ManagedAuthSetDefault {
auth_provider,
account_id,
Expand Down
30 changes: 30 additions & 0 deletions src-tauri/src/cli/tui/runtime_actions/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,36 @@ pub(super) fn managed_auth_set_default(
Ok(())
}

pub(super) fn managed_auth_use(
ctx: &mut RuntimeActionContext<'_>,
auth_provider: String,
account_id: String,
) -> Result<(), AppError> {
let Some(tx) = ctx.managed_auth_req_tx else {
ctx.app.push_toast(
texts::tui_toast_managed_auth_worker_unavailable(
texts::tui_error_managed_auth_worker_unavailable(),
),
ToastKind::Warning,
);
return Ok(());
};

ctx.app.managed_auth_loading = true;
if let Err(err) = tx.send(ManagedAuthReq::Use {
auth_provider,
account_id,
}) {
ctx.app.managed_auth_loading = false;
ctx.app.push_toast(
texts::tui_toast_managed_auth_request_failed(&err.to_string()),
ToastKind::Warning,
);
}

Ok(())
}

pub(super) fn managed_auth_remove(
ctx: &mut RuntimeActionContext<'_>,
auth_provider: String,
Expand Down
17 changes: 17 additions & 0 deletions src-tauri/src/cli/tui/runtime_systems/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,7 @@ pub(crate) fn handle_managed_auth_msg(app: &mut App, msg: ManagedAuthMsg) {
authenticated: true,
default_account_id: Some(account.id.clone()),
migration_error: None,
active_codex_account_id: None,
accounts: vec![account.clone()],
});
}
Expand Down Expand Up @@ -1222,6 +1223,22 @@ pub(crate) fn handle_managed_auth_msg(app: &mut App, msg: ManagedAuthMsg) {
);
}
},
ManagedAuthMsg::Used { result } => {
app.managed_auth_loading = false;
match result {
Ok(status) => {
app.managed_auth_status = Some(status);
app.push_toast(
crate::t!(
"Codex account activated. Restart Codex or launch a new process.",
"Codex 账号已启用。请重启 Codex 或启动新进程。"
),
ToastKind::Success,
);
}
Err(err) => app.push_toast(err, ToastKind::Error),
}
}
ManagedAuthMsg::DefaultSet {
auth_provider: _,
account_id: _,
Expand Down
5 changes: 3 additions & 2 deletions src-tauri/src/cli/tui/runtime_systems/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ pub(crate) use handlers::{
#[cfg(test)]
pub(crate) use types::{
build_model_fetch_candidate_urls, model_fetch_strategy_for_field,
parse_model_ids_from_response, ManagedAuthMsg, ManagedSessionOutcome, ProxyMsg, QuotaMsg,
UpdateMsg,
parse_model_ids_from_response, ManagedSessionOutcome, ProxyMsg, QuotaMsg, UpdateMsg,
};
pub(crate) use types::{
build_stream_check_result_lines, fetch_provider_models_for_tui, ModelFetchStrategy,
Expand All @@ -36,3 +35,5 @@ pub(crate) use workers::{
start_speedtest_system, start_stream_check_system, start_update_system,
start_usage_pricing_system, start_webdav_system,
};

pub(crate) use types::ManagedAuthMsg;
7 changes: 7 additions & 0 deletions src-tauri/src/cli/tui/runtime_systems/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,10 @@ pub(crate) enum ManagedAuthReq {
auth_provider: String,
device_code: String,
},
Use {
auth_provider: String,
account_id: String,
},
SetDefault {
auth_provider: String,
account_id: String,
Expand All @@ -614,6 +618,9 @@ pub(crate) enum ManagedAuthMsg {
device_code: String,
result: Result<Option<crate::services::ManagedAuthAccount>, String>,
},
Used {
result: Result<crate::services::ManagedAuthStatus, String>,
},
DefaultSet {
#[allow(dead_code)]
auth_provider: String,
Expand Down
13 changes: 13 additions & 0 deletions src-tauri/src/cli/tui/runtime_systems/workers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,9 @@ fn managed_auth_worker_loop(rx: mpsc::Receiver<ManagedAuthReq>, tx: mpsc::Sender
device_code,
result: Err(err.clone()),
},
ManagedAuthReq::Use { .. } => ManagedAuthMsg::Used {
result: Err(err.clone()),
},
ManagedAuthReq::SetDefault {
auth_provider,
account_id,
Expand Down Expand Up @@ -772,6 +775,16 @@ fn managed_auth_worker_loop(rx: mpsc::Receiver<ManagedAuthReq>, tx: mpsc::Sender
result,
});
}
ManagedAuthReq::Use {
auth_provider,
account_id,
} => {
let result = rt.block_on(async {
crate::services::codex_account::use_account(&account_id).await?;
crate::services::AuthService::get_status(&auth_provider).await
});
let _ = tx.send(ManagedAuthMsg::Used { result });
}
ManagedAuthReq::SetDefault {
auth_provider,
account_id,
Expand Down
Loading