Skip to content
Merged
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
283 changes: 244 additions & 39 deletions apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,12 @@ fn spawn_provider_refreshes(
let app_handle = app.clone();
let fetch_permits = Arc::clone(&fetch_permits);
handles.push(tokio::spawn(async move {
super::codex_accounts::refresh_codex_account_lanes(app_handle, fetch_permits).await;
super::codex_accounts::refresh_codex_account_lanes(
app_handle,
fetch_permits,
generation,
)
.await;
}));
}

Expand Down
8 changes: 2 additions & 6 deletions apps/desktop-tauri/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,14 +522,10 @@ export function codexAccountSnapshots(): Promise<
}

export function codexAccountRestartDesktop(
sessionRoot?: string | null,
backupDestination?: string | null,
restoreSource?: string | null,
switchId: string,
): Promise<void> {
return invoke<void>("codex_account_restart_desktop", {
sessionRoot,
backupDestination,
restoreSource,
switchId,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ describe("CodexAccountsSection", () => {
});
});

it("does not offer a desktop session restart for a no-op switch", async () => {
tauriMocks.getCodexAccountsState.mockResolvedValue({ accounts: [account("1")], snapshots: {} });
tauriMocks.codexAccountSwitch.mockResolvedValue({ switchId: "noop", desktopSessionRestorePath: null } as CodexSwitchResult);
render(<CodexAccountsSection t={t} />);
await screen.findByText("CodexAccountsSwitchButton");
await act(async () => { screen.getByText("CodexAccountsSwitchButton").click(); });
expect(screen.getByText("CodexSwitchSuccess")).toBeDefined();
expect(screen.queryByText("CodexAccountsRestartDesktop")).toBeNull();
expect(tauriMocks.codexAccountRestartDesktop).not.toHaveBeenCalled();
});

it("adds an account and reloads", async () => {
tauriMocks.getCodexAccountsState.mockResolvedValueOnce(
{ accounts: [], snapshots: {} } as CodexAccountsStateBridge,
Expand All @@ -115,12 +126,12 @@ describe("CodexAccountsSection", () => {
expect(tauriMocks.codexAccountAdd).toHaveBeenCalledTimes(1);
});

it("switches an account and offers a desktop restart when a session can be restored", async () => {
it.each([true, false])("offers a desktop restart even for a first switch (saved session: %s)", async (restoreExists) => {
tauriMocks.getCodexAccountsState.mockResolvedValue(
{ accounts: [account("1")], snapshots: {} } as CodexAccountsStateBridge,
);
tauriMocks.codexAccountSwitch.mockResolvedValue(
{ desktopSessionRestoreExists: true, desktopSessionRestorePath: "C:/s", desktopSessionBackupPath: null } as CodexSwitchResult,
{ switchId: "latest-switch", desktopSessionRestoreExists: restoreExists, desktopSessionRestorePath: "C:/s", desktopSessionBackupPath: null } as CodexSwitchResult,
);
render(<CodexAccountsSection t={t} />);
await waitFor(() => {
Expand All @@ -139,6 +150,8 @@ describe("CodexAccountsSection", () => {
screen.getByText("CodexAccountsRestartDesktop").click();
});
expect(tauriMocks.codexAccountRestartDesktop).toHaveBeenCalledTimes(1);
expect(tauriMocks.codexAccountRestartDesktop).toHaveBeenCalledWith("latest-switch");
expect(screen.queryByText("CodexAccountsRestartDesktop")).toBeNull();
});
});

Expand Down Expand Up @@ -190,4 +203,4 @@ describe("CodexAccountsSection containment styles", () => {
expect(actions).toContain("flex-shrink: 0");
expect(actions).toContain("nowrap");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,8 @@ export function CodexAccountsSection({ t }: Props) {
setBusy(true);
setError(null);
try {
await codexAccountRestartDesktop(
null,
switchResult.desktopSessionBackupPath ?? null,
switchResult.desktopSessionRestorePath ?? null,
);
await codexAccountRestartDesktop(switchResult.switchId);
setSwitchResult(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
Expand Down Expand Up @@ -186,7 +183,7 @@ export function CodexAccountsSection({ t }: Props) {
{switchResult && (
<div className="provider-detail-note" role="status">
{t("CodexSwitchSuccess")}
{switchResult.desktopSessionRestoreExists && (
{switchResult.desktopSessionRestorePath && (
<>
{" "}
{t("CodexSwitchRestartPrompt")}{" "}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/types/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,7 @@ export interface CodexAccountUsageSnapshot {
}

export interface CodexSwitchResult {
switchId: string;
materializedAccount: CodexAccount | null;
backupPath: string | null;
ambientAccount: CodexAccount | null;
Expand Down
144 changes: 142 additions & 2 deletions rust/src/codex_accounts/account_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ use chrono::{DateTime, Utc};
use thiserror::Error;
use uuid::Uuid;

use super::api::{AuthBackedIdentity, CodexApiError, load_identity};
use super::api::CodexApiError;
use super::credentials::{AuthBackedIdentity, load_identity};
use super::file_locations::{
ambient_codex_home, auth_backups_directory, codex_desktop_session_root,
desktop_session_snapshot_path, ensure_directories, managed_homes_directory,
Expand All @@ -41,6 +42,7 @@ impl From<CodexApiError> for CodexAccountManagerError {
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodexSwitchResult {
pub switch_id: Uuid,
pub materialized_account: Option<CodexAccount>,
pub backup_path: Option<PathBuf>,
pub ambient_account: Option<CodexAccount>,
Expand Down Expand Up @@ -190,6 +192,9 @@ impl CodexAccountManager {
target: &CodexAccount,
existing: &[CodexAccount],
) -> Result<CodexSwitchResult, CodexAccountManagerError> {
// Called by the shell's blocking worker: keep the guard here so
// cancelling its async caller cannot release it before the copy ends.
let _credentials = super::CREDENTIAL_OPERATIONS.blocking_write();
ensure_directories()?;

let target_auth_path = target.codex_home_path.join("auth.json");
Expand All @@ -200,6 +205,21 @@ impl CodexAccountManager {
}

let ambient_account = self.discover_ambient_account(existing);
if ambient_account
.as_ref()
.is_some_and(|ambient| ambient.matches(target))
{
// A no-op must not replace auth or restore/clear the live session.
return Ok(CodexSwitchResult {
switch_id: Uuid::new_v4(),
materialized_account: None,
backup_path: None,
ambient_account,
desktop_session_backup_path: None,
desktop_session_restore_path: None,
desktop_session_restore_exists: false,
});
}
let session_root = codex_desktop_session_root();
let mut materialized_account: Option<CodexAccount> = None;
if let Some(ambient) = &ambient_account {
Expand Down Expand Up @@ -233,6 +253,7 @@ impl CodexAccountManager {
);

Ok(CodexSwitchResult {
switch_id: Uuid::new_v4(),
materialized_account,
backup_path,
ambient_account: self.discover_ambient_account(existing),
Expand Down Expand Up @@ -369,6 +390,14 @@ impl CodexAccountManager {
account: &CodexAccount,
) -> Result<Vec<PathBuf>, CodexAccountManagerError> {
ensure_directories()?;
if self
.discovered_managed_account(&account.codex_home_path, &[])
.is_some_and(|fresh| !fresh.matches(account))
{
return Err(CodexAccountManagerError::Message(
"This managed home now belongs to a different account. Refresh the account list before removing it.".into(),
));
}
let mut targets: Vec<PathBuf> = vec![
std::path::absolute(&account.codex_home_path)
.unwrap_or_else(|_| account.codex_home_path.clone()),
Expand Down Expand Up @@ -510,7 +539,7 @@ impl CodexAccountManager {
}
}

fn candidate_account(
pub(super) fn candidate_account(
identity: AuthBackedIdentity,
home_path: &Path,
source: CodexAccountSource,
Expand Down Expand Up @@ -738,6 +767,117 @@ mod tests {
super::super::file_locations::clear_app_support_directory_override();
}

#[test]
fn removing_stale_account_preserves_replacement_credentials() {
let dir = tempfile::tempdir().unwrap();
super::super::file_locations::with_app_support_directory(dir.path().to_path_buf());
let home = dir.path().join("managed-homes/replaced");
std::fs::create_dir_all(&home).unwrap();
let stale = make_account(home.clone(), "old@example.com", "old-id");
write_auth(&home, "new@example.com", "new-id");
let before = std::fs::read(home.join("auth.json")).unwrap();
assert!(
CodexAccountManager::new()
.remove_managed_files_if_owned(&stale)
.is_err()
);
assert_eq!(std::fs::read(home.join("auth.json")).unwrap(), before);
super::super::file_locations::clear_app_support_directory_override();
}

#[test]
fn same_identity_switch_preserves_auth_and_has_no_session_restore() {
let dir = tempfile::tempdir().unwrap();
let ambient = dir.path().join("ambient");
let saved = dir.path().join("managed-homes/saved");
let session = dir.path().join("session");
for path in [&ambient, &saved, &session] {
fs::create_dir_all(path).unwrap();
}
write_auth(&ambient, "same@example.com", "same-id");
write_auth(&saved, "same@example.com", "same-id");
fs::write(session.join("Preferences"), "current-session").unwrap();
let auth_before = fs::read(ambient.join("auth.json")).unwrap();
super::super::file_locations::with_app_support_directory(dir.path().to_path_buf());
super::super::file_locations::with_ambient_codex_home(ambient.clone());
super::super::file_locations::with_codex_desktop_session_root(session.clone());
for home in [ambient.clone(), saved] {
let target = make_account(home, "same@example.com", "same-id");
let result = CodexAccountManager::new()
.switch_active_account(&target, &[])
.unwrap();
assert!(result.backup_path.is_none());
assert!(result.materialized_account.is_none());
assert!(result.desktop_session_backup_path.is_none());
assert!(result.desktop_session_restore_path.is_none());
assert_eq!(fs::read(ambient.join("auth.json")).unwrap(), auth_before);
assert_eq!(
fs::read_to_string(session.join("Preferences")).unwrap(),
"current-session"
);
}
super::super::file_locations::clear_app_support_directory_override();
super::super::file_locations::clear_ambient_codex_home_override();
super::super::file_locations::clear_codex_desktop_session_root_override();
}

#[test]
fn switch_waits_for_credential_refresh_before_materializing_outgoing_auth() {
let dir = tempfile::tempdir().unwrap();
let ambient = dir.path().join("ambient");
let saved = dir.path().join("managed-homes/target");
fs::create_dir_all(&ambient).unwrap();
fs::create_dir_all(&saved).unwrap();
write_auth(&ambient, "old@example.com", "old-id");
write_auth(&saved, "new@example.com", "new-id");
let refresh_guard = super::super::CREDENTIAL_OPERATIONS.blocking_read();
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (done_tx, done_rx) = std::sync::mpsc::channel();
let root = dir.path().to_path_buf();
let worker_ambient = ambient.clone();
let worker = std::thread::spawn(move || {
super::super::file_locations::with_app_support_directory(root.clone());
super::super::file_locations::with_ambient_codex_home(worker_ambient);
super::super::file_locations::with_codex_desktop_session_root(root.join("session"));
let target = make_account(saved, "new@example.com", "new-id");
ready_tx.send(()).unwrap();
let result = CodexAccountManager::new().switch_active_account(&target, &[]);
done_tx.send(result).unwrap();
});
ready_rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(matches!(
done_rx.recv_timeout(Duration::from_millis(100)),
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
));
let mut refreshed: serde_json::Value =
serde_json::from_slice(&fs::read(ambient.join("auth.json")).unwrap()).unwrap();
refreshed["tokens"]["access_token"] = serde_json::json!("rotated-old-token");
fs::write(
ambient.join("auth.json"),
serde_json::to_vec(&refreshed).unwrap(),
)
.unwrap();
drop(refresh_guard);
let result = done_rx
.recv_timeout(Duration::from_secs(5))
.unwrap()
.unwrap();
worker.join().unwrap();
let materialized = result.materialized_account.unwrap();
let outgoing: serde_json::Value = serde_json::from_slice(
&fs::read(materialized.codex_home_path.join("auth.json")).unwrap(),
)
.unwrap();
assert_eq!(outgoing["tokens"]["access_token"], "rotated-old-token");
assert_eq!(
load_identity(&ambient)
.unwrap()
.provider_account_id
.as_deref(),
Some("new-id")
);
}

#[test]
fn switch_active_account_updates_global_state_creator_id() {
let dir = tempfile::tempdir().unwrap();
Expand Down
Loading