Skip to content
Closed
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
202 changes: 200 additions & 2 deletions apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,50 @@ pub async fn codex_account_add(app: tauri::AppHandle) -> Result<CodexAccount, St
Ok(account)
}

/// Re-run the official Codex login flow for the ambient account without
/// changing account ownership or copying credentials into a managed home.
#[tauri::command]
pub async fn codex_account_reauthenticate(app: tauri::AppHandle) -> Result<CodexAccount, String> {
let runtime = CodexAccountRuntime::new();
let _mutation = runtime.try_begin_mutation().map_err(into_user_message)?;
let target = ambient_account(&load_codex_accounts()?)?;
let manager = CodexAccountManager::new();
let authenticated =
tauri::async_runtime::spawn_blocking(move || manager.reauthenticate(&target, None))
.await
.map_err(|e| e.to_string())?
.map_err(into_user_message)?;

// The login flow replaced the ambient auth file. Reconcile the identity
// before refreshing usage so every surface observes the new session. The
// logged-in record is transient: reconciliation can drop or replace the
// ambient identity, so report only a record that was actually persisted.
let account = match refresh_persisted_accounts(app.clone()) {
Ok(accounts) => canonical_reauthenticated_account(&accounts, &authenticated),
Err(e) => {
// Credential replacement is already committed, but the reconciled
// account set could not be saved. Reporting the transient login
// result would expose an account the store never committed, so the
// persistence error is surfaced instead.
tracing::warn!("Codex login succeeded but account metadata could not be saved: {e}");
Err(e)
}
};
let pending = {
let state = app.state::<Mutex<AppState>>();
let mut state = state.lock().map_err(|e| e.to_string())?;
invalidate_account_usage(&mut state, ProviderId::Codex)
};
events::emit_provider_updated(&app, &pending);

let refresh_app = app.clone();
tauri::async_runtime::spawn(async move {
let _ = do_refresh_providers(&refresh_app).await;
});

account
}

#[tauri::command]
pub fn codex_account_remove(app: tauri::AppHandle, id: String) -> Result<(), String> {
let runtime = CodexAccountRuntime::new();
Expand Down Expand Up @@ -401,12 +445,46 @@ pub async fn codex_account_restart_desktop(

/// Merge discovered accounts back into the persisted list after identity
/// changes (login/switch) so the store reflects reality.
fn refresh_persisted_accounts(app: tauri::AppHandle) -> Result<(), String> {
///
/// Returns the post-reconciliation accounts, which is exactly the set that was
/// persisted, so callers can report a canonical account from persisted state.
fn refresh_persisted_accounts(app: tauri::AppHandle) -> Result<Vec<CodexAccount>, String> {
let accounts = load_codex_accounts()?;
persist_codex_accounts(&accounts)?;
events::emit_settings_changed(&app);
accounts_changed(&app);
Ok(())
Ok(accounts)
}

/// Select the ambient identity from a persisted account set.
fn ambient_account(accounts: &[CodexAccount]) -> Result<CodexAccount, String> {
accounts
.iter()
.find(|account| account.source == codexbar::codex_accounts::CodexAccountSource::Ambient)
.cloned()
.ok_or_else(|| "No ambient Codex account found.".to_string())
}

/// The account a reauthentication command should report.
///
/// The persisted reconciled set is authoritative. A login that changes the
/// ambient identity produces a fresh persisted record with a new id, while the
/// login helper reuses the pre-login id, so the transient login result is used
/// only to locate its persisted counterpart. Prefer the persisted record
/// matching the authenticated identity, then the persisted ambient record.
/// When neither is present the login was never committed, so the command fails
/// instead of exposing a dropped or replaced transient account.
fn canonical_reauthenticated_account(
accounts: &[CodexAccount],
authenticated: &CodexAccount,
) -> Result<CodexAccount, String> {
if let Some(account) = accounts
.iter()
.find(|account| account.matches(authenticated))
{
return Ok(account.clone());
}
ambient_account(accounts)
}

fn accounts_changed(app: &tauri::AppHandle) {
Expand Down Expand Up @@ -655,6 +733,126 @@ mod tests {
);
}

#[test]
fn ambient_account_selects_only_the_ambient_identity() {
let managed = sample_account();
let mut ambient = managed.clone();
ambient.source = codexbar::codex_accounts::CodexAccountSource::Ambient;

let selected = ambient_account(&[managed, ambient.clone()]).unwrap();

assert_eq!(selected.id, ambient.id);
assert_eq!(
selected.source,
codexbar::codex_accounts::CodexAccountSource::Ambient
);
}

#[test]
fn ambient_account_reports_when_no_ambient_identity_exists() {
assert_eq!(
ambient_account(&[sample_account()]).unwrap_err(),
"No ambient Codex account found."
);
}

#[test]
fn reconciled_ambient_identity_change_replaces_the_login_result() {
let mut stored = sample_account();
stored.source = codexbar::codex_accounts::CodexAccountSource::Ambient;
stored.provider_account_id = Some("old-workspace".into());
stored.email_hint = Some("old@example.com".into());

// Logging in as a different identity at the same ambient home.
let mut fresh = sample_account();
fresh.source = codexbar::codex_accounts::CodexAccountSource::Ambient;
fresh.provider_account_id = Some("new-workspace".into());
fresh.email_hint = Some("new@example.com".into());

let reconciled = reconcile_codex_accounts(&[stored.clone()], &[], Some(fresh));
assert_eq!(reconciled.len(), 1);
assert_ne!(reconciled[0].id, stored.id);

// `reauthenticate` reuses the pre-login id; the command must report the
// reconciled record so it agrees with the persisted store and events.
let mut authenticated = stored.clone();
authenticated.email_hint = Some("new@example.com".into());
let account = canonical_reauthenticated_account(&reconciled, &authenticated).unwrap();
assert_eq!(account.id, reconciled[0].id);
assert_ne!(account.id, authenticated.id);
assert_eq!(
account.source,
codexbar::codex_accounts::CodexAccountSource::Ambient
);
assert_eq!(
account.provider_account_id.as_deref(),
Some("new-workspace")
);
}

#[test]
fn canonical_reauthenticated_account_returns_the_persisted_replacement() {
let mut authenticated = sample_account();
authenticated.source = codexbar::codex_accounts::CodexAccountSource::Ambient;
authenticated.provider_account_id = Some("old-workspace".into());
authenticated.email_hint = Some("old@example.com".into());

let mut persisted = sample_account();
persisted.source = codexbar::codex_accounts::CodexAccountSource::Ambient;
persisted.provider_account_id = Some("new-workspace".into());
persisted.email_hint = Some("new@example.com".into());

let account =
canonical_reauthenticated_account(&[persisted.clone()], &authenticated).unwrap();
assert_eq!(account.id, persisted.id);
assert_ne!(account.id, authenticated.id);
assert_eq!(
account.provider_account_id.as_deref(),
Some("new-workspace")
);
}

#[test]
fn canonical_reauthenticated_account_returns_the_unchanged_persisted_reauth() {
let mut persisted = sample_account();
persisted.source = codexbar::codex_accounts::CodexAccountSource::Ambient;
persisted.nickname = Some("Work".into());
persisted.provider_account_id = Some("workspace".into());

// The login helper does not carry optional stored metadata.
let mut authenticated = persisted.clone();
authenticated.nickname = None;

let account =
canonical_reauthenticated_account(&[persisted.clone()], &authenticated).unwrap();
assert_eq!(account.id, persisted.id);
assert_eq!(account.nickname.as_deref(), Some("Work"));
}

#[test]
fn canonical_reauthenticated_account_does_not_return_a_dropped_login() {
let mut authenticated = sample_account();
authenticated.source = codexbar::codex_accounts::CodexAccountSource::Ambient;
authenticated.provider_account_id = Some("dropped-workspace".into());
authenticated.email_hint = Some("dropped@example.com".into());

// The reconciled set dropped the login identity and holds no ambient
// record to replace it with.
let error =
canonical_reauthenticated_account(&[sample_account()], &authenticated).unwrap_err();
assert_eq!(error, "No ambient Codex account found.");
}

#[test]
fn canonical_reauthenticated_account_rejects_an_uncommitted_persistence_failure() {
let authenticated = sample_account();

// A failed persistence leaves no committed reconciled set; the transient
// login result must not be surfaced in its place.
let error = canonical_reauthenticated_account(&[], &authenticated).unwrap_err();
assert_eq!(error, "No ambient Codex account found.");
}

#[test]
fn sample_account_serializes_camel_case() {
let json = serde_json::to_value(sample_account()).unwrap();
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ fn main() {
commands::claude_account_remove,
commands::claude_account_switch,
commands::codex_account_add,
commands::codex_account_reauthenticate,
commands::codex_account_remove,
commands::codex_account_switch,
commands::codex_account_fetch,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ export const ALL_LOCALE_KEYS = [
"ClaudeAccountsAdded",
"CodexAccountsHint",
"CodexAccountsAddButton",
"CodexAccountsReauthenticateButton",
"CodexAccountsSwitchButton",
"CodexAccountsFetchButton",
"CodexAccountsRemoveButton",
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop-tauri/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,10 @@ export function codexAccountAdd(): Promise<CodexAccount> {
return invoke<CodexAccount>("codex_account_add");
}

export function codexAccountReauthenticate(): Promise<CodexAccount> {
return invoke<CodexAccount>("codex_account_reauthenticate");
}

export function codexAccountRemove(id: string): Promise<void> {
return invoke<void>("codex_account_remove", { id });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const tauriMocks = vi.hoisted(() => ({
getCodexAccountsState: vi.fn(),
codexAccountAdd: vi.fn(),
codexAccountFetch: vi.fn(),
codexAccountReauthenticate: vi.fn(),
codexAccountRemove: vi.fn(),
codexAccountSwitch: vi.fn(),
codexAccountRestartDesktop: vi.fn(),
Expand Down Expand Up @@ -76,6 +77,7 @@ describe("CodexAccountsSection", () => {
expect(screen.getByText("user-2@example.com")).toBeDefined();
expect(screen.getByText("CodexAccountsSourceManaged")).toBeDefined();
expect(screen.getByText("CodexAccountsSourceAmbient")).toBeDefined();
expect(screen.getAllByText("CodexAccountsReauthenticateButton")).toHaveLength(1);
});

it("shows the usage pill and blocked state from a snapshot", async () => {
Expand All @@ -93,6 +95,26 @@ describe("CodexAccountsSection", () => {
});
});

it("offers ambient reauthentication and reloads the account state", async () => {
const ambient = account("ambient", { source: "ambient" });
tauriMocks.getCodexAccountsState
.mockResolvedValueOnce({ accounts: [ambient], snapshots: {} } as CodexAccountsStateBridge)
.mockResolvedValueOnce({ accounts: [ambient], snapshots: { ambient: snapshot(12) } } as CodexAccountsStateBridge);
tauriMocks.codexAccountReauthenticate.mockResolvedValue(ambient);

render(<CodexAccountsSection t={t} />);
await screen.findByText("CodexAccountsReauthenticateButton");

await act(async () => {
screen.getByText("CodexAccountsReauthenticateButton").click();
});

expect(tauriMocks.codexAccountReauthenticate).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(screen.getByText("free · 12%")).toBeDefined();
});
});

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { LocaleKey } from "../../../../../i18n/keys";
import {
codexAccountAdd,
codexAccountFetch,
codexAccountReauthenticate,
codexAccountRemove,
codexAccountRestartDesktop,
codexAccountSwitch,
Expand All @@ -28,9 +29,9 @@ interface Props {
* Multi-account Codex support (ADR 0003). Reads the shared account +
* snapshot store via `get_codex_accounts_state` and drives the
* `codex_account_*` IPC surface: add (login into a managed home), switch the
* active ambient identity, refresh per-account usage, and remove managed
* homes. For MSIX Codex Desktop installs a restart action is offered when a
* session snapshot is available to restore.
* active ambient identity, refresh per-account usage, reauthenticate the
* ambient identity, and remove managed homes. For MSIX Codex Desktop installs
* a restart action is offered when a session snapshot is available to restore.
*/
export function CodexAccountsSection({ t }: Props) {
const [accounts, setAccounts] = useState<CodexAccount[]>([]);
Expand Down Expand Up @@ -120,6 +121,20 @@ export function CodexAccountsSection({ t }: Props) {
}
};

const handleReauthenticate = async () => {
setBusy(true);
setError(null);
setSwitchResult(null);
try {
await codexAccountReauthenticate();
await load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};

const handleRemove = async (id: string) => {
setBusy(true);
setError(null);
Expand Down Expand Up @@ -233,6 +248,16 @@ export function CodexAccountsSection({ t }: Props) {
</span>
</div>
<div className="credential-card__actions">
{account.source === "ambient" && (
<button
type="button"
className="credential-btn credential-btn--secondary"
disabled={busy}
onClick={() => void handleReauthenticate()}
>
{t("CodexAccountsReauthenticateButton")}
</button>
)}
<button
type="button"
className="credential-btn credential-btn--secondary"
Expand Down
3 changes: 3 additions & 0 deletions rust/src/codex_accounts/account_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ impl CodexAccountManager {
account: &CodexAccount,
handle: Option<&ManagedLoginProcess>,
) -> Result<CodexAccount, CodexAccountManagerError> {
// Keep credential replacement exclusive with provider reads and
// refreshes, just like an account switch.
let _credentials = super::CREDENTIAL_OPERATIONS.blocking_write();
self.authenticate_account(
&account.codex_home_path,
account.source,
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ locale_keys! {
ClaudeAccountsAdded,
CodexAccountsHint,
CodexAccountsAddButton,
CodexAccountsReauthenticateButton,
CodexAccountsSwitchButton,
CodexAccountsFetchButton,
CodexAccountsRemoveButton,
Expand Down
3 changes: 2 additions & 1 deletion rust/src/locale/en-US.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,9 @@ ProviderClaudeAllowReadingClaudeCodeCredentialsHelp = Lets CodexBar read (and re
ProviderCodexSparkUsage = Show Codex Spark usage
ProviderCodexSparkUsageHelp = Show Codex Spark quota rows without hiding credits or other extra usage.
CodexAccountsTitle = Codex Accounts
CodexAccountsHint = Choose an account for Codex. Restart running sessions after switching.
CodexAccountsHint = Choose an account for Codex. Use Refresh login to renew the ambient session. Restart running sessions after switching.
CodexAccountsAddButton = Add account
CodexAccountsReauthenticateButton = Refresh login
CodexAccountsSwitchButton = Switch
CodexAccountsFetchButton = Refresh usage
CodexAccountsRemoveButton = Remove
Expand Down