diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f9530c18e9..57397a3476 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2046,6 +2046,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "serial_test", "sha2 0.10.9", "tempfile", "time", diff --git a/codex-rs/app-server-transport/Cargo.toml b/codex-rs/app-server-transport/Cargo.toml index 175890962e..1120a4a379 100644 --- a/codex-rs/app-server-transport/Cargo.toml +++ b/codex-rs/app-server-transport/Cargo.toml @@ -56,4 +56,5 @@ uuid = { workspace = true, features = ["serde", "v7"] } chrono = { workspace = true } codex-config = { workspace = true } pretty_assertions = { workspace = true } +serial_test = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs b/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs index 2dd77c8057..855df1edd7 100644 --- a/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs +++ b/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs @@ -4,7 +4,9 @@ use super::TransportEvent; use super::acquire_app_server_startup_lock; use super::app_server_control_socket_path; use super::app_server_control_socket_path_for_auth_profile; +use super::app_server_startup_lock_path; use super::app_server_startup_lock_path_for_auth_profile; +use super::selected_auth_profile_from_env; use super::start_control_socket_acceptor; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; @@ -232,6 +234,93 @@ fn control_socket_path_can_be_scoped_to_auth_profile() { ); } +/// D4: the control socket and startup lock namespace must agree with the +/// profile the rest of the runtime considers selected. If `Config` resolved a +/// profile that `selected_auth_profile_from_env` does not, a profile session +/// would attach to the root-namespaced daemon, defeating per-profile daemon +/// isolation and leaving a daemon that can outlive the selection while holding +/// the wrong credentials. +#[tokio::test] +#[serial_test::serial(selected_auth_profile_env)] +async fn control_socket_namespace_agrees_with_resolved_config_auth_profile() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let _codewith_guard = RemoveEnvVarGuard::new(codex_login::CODEWITH_AUTH_PROFILE_ENV_VAR); + let _codex_guard = RemoveEnvVarGuard::new(codex_login::CODEX_AUTH_PROFILE_ENV_VAR); + + // An ambient `codewith profile save work` marker, as written by the CLI. + codex_login::login_with_api_key( + temp_dir.path(), + "sk-root", + codex_config::types::AuthCredentialsStoreMode::File, + ) + .expect("root login"); + codex_login::save_current_auth_profile( + temp_dir.path(), + codex_config::types::AuthCredentialsStoreMode::File, + "work", + ) + .expect("save current profile"); + + let config = codex_core::config::Config::load_default_with_cli_overrides_for_codex_home( + temp_dir.path().to_path_buf(), + Vec::new(), + ) + .await + .expect("config loads with an ambient active-profile marker"); + + assert_eq!( + config.selected_auth_profile.as_deref(), + selected_auth_profile_from_env() + .expect("env auth profile") + .as_deref(), + "the app-server socket namespace selector must agree with the profile Config selected", + ); + + assert_eq!( + app_server_control_socket_path(temp_dir.path()).expect("control socket path"), + app_server_control_socket_path_for_auth_profile( + temp_dir.path(), + config.selected_auth_profile.as_deref() + ) + .expect("resolved control socket path"), + ); + assert_eq!( + app_server_startup_lock_path(temp_dir.path()).expect("startup lock path"), + app_server_startup_lock_path_for_auth_profile( + temp_dir.path(), + config.selected_auth_profile.as_deref() + ) + .expect("resolved startup lock path"), + ); +} + +struct RemoveEnvVarGuard { + key: &'static str, + previous: Option, +} + +impl RemoveEnvVarGuard { + fn new(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: guarded by `#[serial]` so no other test thread reads or + // writes the process environment concurrently. + unsafe { std::env::remove_var(key) }; + Self { key, previous } + } +} + +impl Drop for RemoveEnvVarGuard { + fn drop(&mut self) { + // SAFETY: see `RemoveEnvVarGuard::new`. + unsafe { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} + #[test] fn control_socket_path_rejects_invalid_auth_profile() { let temp_dir = tempfile::tempdir().expect("temp dir"); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 88530816ad..e59aad426a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -272,7 +272,7 @@ The OSS package `@hasna/bridge` should treat Codewith as a local control plane a Stable adapter entry points: -- Auth profile inventory: call `codewith profile list --json` to enumerate named profiles and the runtime `currentProfile`. `currentProfile.profileKind` is `default` when Codewith will use the root login and `named` when `--auth-profile`, `CODEWITH_AUTH_PROFILE`, or `CODEX_AUTH_PROFILE` selected a named profile for this process. Each named profile includes `profileKind: "named"`, `selected`, `usable`, `unusableReason`, `authMode`, `subscriptionProvider`, and `accountLabel`. `usable: true` means the profile is launch-compatible with Codewith model auth; metadata-only profiles for non-ChatGPT providers are listed but return `usable: false`. Create or refresh a named login with `codewith login --auth-profile ...`. Do not call `codewith profile switch` for bridge-launched work; that mutates the user's global active login. +- Auth profile inventory: call `codewith profile list --json` to enumerate named profiles and the runtime `currentProfile`. `currentProfile.profileKind` is `default` when Codewith will use the root login and `named` when `--auth-profile`, `CODEWITH_AUTH_PROFILE`, or `CODEX_AUTH_PROFILE` selected a named profile for this process; the persisted active profile (`codewith profile switch`) is reported per-profile via `active` and never changes `currentProfile.profileKind`. Each named profile includes `profileKind: "named"`, `selected`, `active`, `usable`, `unusableReason`, `authMode`, `subscriptionProvider`, and `accountLabel`. **Breaking in this release:** `usable` now means "selectable as an auth profile" rather than "launch-compatible with Codewith model auth", and the `unusableReason` value `"unsupported_subscription_provider"` is no longer emitted. A profile is now only `usable: false` (`unusableReason: "missing_auth"`) when it is a ChatGPT profile with no stored credentials. Profiles locked to a non-ChatGPT subscription provider are selectable and never lend OpenAI credentials to Codewith model auth, so they report `authMode: null` — branch on `subscriptionProvider`/`authMode`, not on `unusableReason`, to decide whether a profile can serve Codewith model auth. Create or refresh a named login with `codewith login --auth-profile ...`. Do not call `codewith profile switch` for bridge-launched work; that mutates the user's global active login. - Profile-aware launch: pass the root CLI option `--auth-profile ` when launching Codewith commands, including TUI, `exec`, `app-server`, and background-agent flows. In app-server v2, use `thread/start.authProfile`, `thread/resume.authProfile`, `thread/fork.authProfile`, `thread/settings/update.authProfile`, and `agent/start.authProfileRef`. Passing JSON `null` selects the default root auth profile; omitting the field preserves the server or thread default. - App-server transport: start the local control endpoint with `codewith remote-control start --json` and read the returned daemon/socket endpoint. Use JSON-RPC over that endpoint; initialize once per connection with `clientInfo.name: "hasna_bridge"` or another bridge-specific client id. - Session state: use `localSession/list` for durable local inventory and `activeSession/list` for currently loaded message-capable peers. Bridge-facing state fields are `threadId`, `runtimeSessionId`/`sessionId`, `peerId`, `cwd`, `authProfile`, `authProfileKind`, `accountLabel`, `status`, `activeFlags`, `capabilities`, `lastSeenAt`/`updatedAt`, and nullable routing details such as `peer`. `authProfileKind` is `unknown`, `default`, or `named`; use it instead of inferring root/default semantics from nullable `authProfile`. diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index e8c4e3c57b..0ed469b4fa 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -39,9 +39,11 @@ use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStatus; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; +use codex_login::AuthProfileMetadata; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_login::login_with_api_key; use codex_login::save_auth_profile; +use codex_login::save_auth_profile_metadata; use codex_protocol::account::PlanType as AccountPlanType; use core_test_support::responses; use pretty_assertions::assert_eq; @@ -177,6 +179,22 @@ fn api_key_profile_summary(name: &str, active: bool) -> AuthProfileSummary { } } +fn external_profile_summary( + name: &str, + subscription_provider: AuthProfileSubscriptionProvider, + active: bool, +) -> AuthProfileSummary { + AuthProfileSummary { + name: name.to_string(), + subscription_provider, + auth_mode: None, + email: None, + account_id: None, + plan: None, + active, + } +} + async fn assert_account_updated_notification( mcp: &mut TestAppServer, auth_mode: Option, @@ -1142,6 +1160,67 @@ async fn auth_profile_rpcs_save_list_and_switch_api_key_profiles() -> Result<()> Ok(()) } +#[tokio::test] +async fn auth_profile_switch_accepts_external_subscription_profiles() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + login_with_api_key( + codex_home.path(), + "sk-root-key", + AuthCredentialsStoreMode::File, + )?; + save_auth_profile_metadata( + codex_home.path(), + "cursor", + AuthProfileMetadata { + subscription_provider: codex_login::AuthProfileSubscriptionProvider::Cursor, + last_permissions: None, + }, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let switch_id = mcp + .send_raw_request("authProfile/switch", Some(json!({ "name": "cursor" }))) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(switch_id)), + ) + .await??; + let switched: AuthProfileSwitchResponse = to_response(resp)?; + + assert_eq!( + switched.profile, + external_profile_summary("cursor", AuthProfileSubscriptionProvider::Cursor, true) + ); + assert_account_updated_notification(&mut mcp, None).await?; + + let list_id = mcp + .send_raw_request("authProfile/list", Some(json!({}))) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let profiles: AuthProfileListResponse = to_response(resp)?; + assert_eq!( + profiles, + AuthProfileListResponse { + data: vec![external_profile_summary( + "cursor", + AuthProfileSubscriptionProvider::Cursor, + true, + )], + next_cursor: None, + } + ); + + Ok(()) +} + #[tokio::test] async fn auth_profile_list_uses_selected_profile_for_active_state() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/src/profile_cmd.rs b/codex-rs/cli/src/profile_cmd.rs index df133a0c45..ef1a079dd7 100644 --- a/codex-rs/cli/src/profile_cmd.rs +++ b/codex-rs/cli/src/profile_cmd.rs @@ -170,9 +170,9 @@ fn profiles_json_value(profiles: &[AuthProfile], selected_auth_profile: Option<& } fn profile_unusable_reason(profile: &AuthProfile) -> Option<&'static str> { - if profile.subscription_provider != AuthProfileSubscriptionProvider::ChatGpt { - Some("unsupported_subscription_provider") - } else if profile.auth_mode.is_none() { + if profile.subscription_provider == AuthProfileSubscriptionProvider::ChatGpt + && profile.auth_mode.is_none() + { Some("missing_auth") } else { None @@ -267,8 +267,8 @@ mod tests { "email": null, "accountId": null, "plan": null, - "usable": false, - "unusableReason": "unsupported_subscription_provider", + "usable": true, + "unusableReason": null, } ] }) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 6ed517c460..b56bea8659 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5475,6 +5475,261 @@ async fn config_ignores_empty_codewith_auth_profile_env() -> std::io::Result<()> Ok(()) } +/// Reproduces `codewith profile save `: snapshots the root login into a +/// named profile and records it in the ambient `auth_profiles/.active` marker. +fn save_root_login_as_active_profile( + codex_home: &Path, + name: &str, + metadata: Option, +) -> anyhow::Result<()> { + let store_mode = codex_config::types::AuthCredentialsStoreMode::File; + codex_login::login_with_api_key(codex_home, "sk-root", store_mode)?; + codex_login::save_current_auth_profile(codex_home, store_mode, name)?; + if let Some(metadata) = metadata { + codex_login::save_auth_profile_metadata(codex_home, name, metadata)?; + } + Ok(()) +} + +/// Reproduces `codewith profile switch ` onto a provider-locked profile. +fn switch_to_external_profile( + codex_home: &Path, + name: &str, + subscription_provider: codex_login::AuthProfileSubscriptionProvider, +) -> anyhow::Result<()> { + let store_mode = codex_config::types::AuthCredentialsStoreMode::File; + codex_login::save_auth_profile_metadata( + codex_home, + name, + codex_login::AuthProfileMetadata { + subscription_provider, + last_permissions: None, + }, + )?; + codex_login::switch_auth_profile(codex_home, store_mode, name)?; + Ok(()) +} + +/// The ambient `auth_profiles/.active` marker records which profile owns the +/// *root login*; it must never scope the current process to a named profile. +/// Doing so silently changes sandbox/approval posture, suppresses env +/// credentials, disables root-auth mirroring, re-namespaces the app-server +/// socket, and trips the Infinity Agent guard — all as an invisible side effect +/// of an unrelated earlier `profile switch`. +#[tokio::test] +#[serial(selected_auth_profile_env)] +async fn config_ignores_active_auth_profile_marker_for_process_selection() -> anyhow::Result<()> { + let _codewith_guard = EnvVarGuard::remove(CODEWITH_AUTH_PROFILE_ENV_VAR); + let _codex_guard = EnvVarGuard::remove(CODEX_AUTH_PROFILE_ENV_VAR); + let codex_home = TempDir::new()?; + switch_to_external_profile( + codex_home.path(), + "cursor", + codex_login::AuthProfileSubscriptionProvider::Cursor, + )?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!(config.selected_auth_profile, None); + + // D7: the Infinity Agent guard keys off the same predicate, so an ambient + // marker must not make a plain load unloadable under that tool policy. + reject_infinity_agent_auth_profile_inheritance( + ToolPolicy::InfinityAgent, + config.selected_auth_profile.as_deref(), + ) + .expect("an ambient active-profile marker must not trip the Infinity Agent guard"); + + Ok(()) +} + +/// D1: a plain invocation must not silently inherit a saved profile's sandbox +/// and approval posture just because that profile is the ambient active one. +#[tokio::test] +#[serial(selected_auth_profile_env)] +async fn config_active_auth_profile_marker_does_not_inherit_saved_permissions() -> anyhow::Result<()> +{ + let _codewith_guard = EnvVarGuard::remove(CODEWITH_AUTH_PROFILE_ENV_VAR); + let _codex_guard = EnvVarGuard::remove(CODEX_AUTH_PROFILE_ENV_VAR); + + let baseline_home = TempDir::new()?; + let baseline = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + baseline_home.abs(), + ) + .await?; + + let codex_home = TempDir::new()?; + save_root_login_as_active_profile( + codex_home.path(), + "work", + Some(saved_auth_profile_permission_metadata( + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, + AskForApproval::Never, + )), + )?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.permission_profile(), + baseline.permissions.permission_profile(), + ); + assert_eq!( + config + .permissions + .active_permission_profile() + .as_ref() + .map(|profile| profile.id.clone()), + baseline + .permissions + .active_permission_profile() + .as_ref() + .map(|profile| profile.id.clone()), + ); + assert_eq!( + config.permissions.approval_policy.value(), + baseline.permissions.approval_policy.value(), + ); + + Ok(()) +} + +/// D2: `CODEX_API_KEY` / `CODEX_ACCESS_TOKEN` are suppressed for processes +/// scoped to a named profile. A single `profile switch` must not silently +/// suppress them for every later invocation on the machine. +#[tokio::test] +#[serial(selected_auth_profile_env, codex_auth_env)] +async fn config_active_auth_profile_marker_does_not_suppress_env_auth() -> anyhow::Result<()> { + let _codewith_guard = EnvVarGuard::remove(CODEWITH_AUTH_PROFILE_ENV_VAR); + let _codex_guard = EnvVarGuard::remove(CODEX_AUTH_PROFILE_ENV_VAR); + let _access_token_guard = EnvVarGuard::remove(codex_login::CODEX_ACCESS_TOKEN_ENV_VAR); + let _api_key_guard = EnvVarGuard::set(codex_login::CODEX_API_KEY_ENV_VAR, "sk-env"); + + let codex_home = TempDir::new()?; + save_root_login_as_active_profile(codex_home.path(), "work", None)?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + let auth_manager = codex_login::AuthManager::shared_from_config( + &config, /*enable_codex_api_key_env*/ true, + ) + .await; + + assert_eq!( + auth_manager + .auth_cached() + .as_ref() + .and_then(codex_login::CodexAuth::api_key), + Some("sk-env"), + ); + + Ok(()) +} + +/// D5: a corrupt marker must not brick `Config::load`, because that also takes +/// out the recovery paths (`codewith login`, `codewith profile list`, +/// `codewith profile switch`). The credential path fails closed instead — see +/// `active_marker_that_cannot_be_resolved_blocks_root_openai_auth` in +/// `codex-login`. +#[tokio::test] +#[serial(selected_auth_profile_env)] +async fn config_load_tolerates_corrupt_active_auth_profile_marker() -> anyhow::Result<()> { + let _codewith_guard = EnvVarGuard::remove(CODEWITH_AUTH_PROFILE_ENV_VAR); + let _codex_guard = EnvVarGuard::remove(CODEX_AUTH_PROFILE_ENV_VAR); + + // (a) `.active` holds a name that is not a legal profile name. + let invalid_name_home = TempDir::new()?; + let profiles_dir = invalid_name_home.path().join("auth_profiles"); + std::fs::create_dir_all(&profiles_dir)?; + std::fs::write(profiles_dir.join(".active"), "../escape")?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + invalid_name_home.abs(), + ) + .await?; + assert_eq!(config.selected_auth_profile, None); + // Failing closed on credentials must not be silent. + assert!( + config + .startup_warnings + .iter() + .any(|warning| warning.contains("which auth profile owns the current login")), + "expected a startup warning about the unresolvable marker, got {:?}", + config.startup_warnings, + ); + + // (b) `.active` names a real profile whose metadata is malformed. + let malformed_home = TempDir::new()?; + save_root_login_as_active_profile(malformed_home.path(), "work", None)?; + std::fs::write( + malformed_home + .path() + .join("auth_profiles") + .join("work") + .join("profile.json"), + "{ not json", + )?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + malformed_home.abs(), + ) + .await?; + assert_eq!(config.selected_auth_profile, None); + assert!( + config + .startup_warnings + .iter() + .any(|warning| warning.contains("which auth profile owns the current login")), + "expected a startup warning about the unreadable profile metadata, got {:?}", + config.startup_warnings, + ); + + // (c) A *deliberate* provider lock is a normal state and must stay quiet. + let external_home = TempDir::new()?; + switch_to_external_profile( + external_home.path(), + "cursor", + codex_login::AuthProfileSubscriptionProvider::Cursor, + )?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + external_home.abs(), + ) + .await?; + assert!( + !config + .startup_warnings + .iter() + .any(|warning| warning.contains("which auth profile owns the current login")), + "a deliberate provider lock must not warn, got {:?}", + config.startup_warnings, + ); + + Ok(()) +} + #[tokio::test] #[serial(selected_auth_profile_env)] async fn config_rejects_invalid_selected_auth_profile() -> std::io::Result<()> { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ed78ec7c1e..a531af8161 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -90,7 +90,9 @@ use codex_login::AuthManagerConfig; use codex_login::AuthProfilePermissionSettings; use codex_login::CODEWITH_AUTH_PROFILE_ENV_VAR; use codex_login::CODEX_AUTH_PROFILE_ENV_VAR; +use codex_login::RootAuthOwnership; use codex_login::load_auth_profile_metadata; +use codex_login::root_auth_ownership; use codex_login::validate_auth_profile_name; use codex_mcp::McpConfig; use codex_memories_read::memory_root; @@ -594,6 +596,18 @@ fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { } } +/// Resolves the auth profile this *process* is scoped to. +/// +/// Only explicit selectors participate: `--auth-profile`, +/// `CODEWITH_AUTH_PROFILE`, and `CODEX_AUTH_PROFILE`. The ambient +/// `auth_profiles/.active` marker written by `codewith profile switch` is +/// deliberately *not* consulted here — see +/// [`codex_login::RootAuthOwnership`] for why those are different concepts. +/// A named selection changes sandbox/approval posture, suppresses global env +/// credentials, disables root-auth mirroring, namespaces the app-server +/// socket, and is rejected outright under the Infinity Agent tool policy; none +/// of that may happen as an invisible side effect of an unrelated earlier +/// command. fn resolve_selected_auth_profile( explicit_profile: Option, ) -> std::io::Result> { @@ -3699,6 +3713,21 @@ impl Config { tool_policy, selected_auth_profile.as_deref(), )?; + // A corrupt `auth_profiles/.active` makes the root login unusable for + // model auth (see `RootAuthOwnership`). That must not fail the load — + // `codewith login` and `codewith profile switch` are how the user + // repairs it — but it does need to be visible rather than silent. A + // deliberate provider lock is a normal state and is not warned about. + if selected_auth_profile.is_none() + && let RootAuthOwnership::Unresolvable { detail, .. } = + root_auth_ownership(&codex_home) + { + startup_warnings.push(format!( + "Codewith cannot tell which auth profile owns the current login, so it will not \ + use those credentials: {detail}. Run `codewith profile switch ` or \ + `codewith login` to repair it." + )); + } let runtime_permission_overrides_present = sandbox_mode.is_some() || permission_profile.is_some() || default_permissions_override.is_some() diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 753e9fa5c1..5aa1625404 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -820,6 +820,158 @@ async fn external_subscription_profile_does_not_fall_back_to_openai_auth() -> an Ok(()) } +/// The point of the provider lock: after `codewith profile switch `, +/// the root login is no longer Codewith's to use for model auth, even though +/// the process itself is not scoped to a named profile. +#[tokio::test] +#[serial(codex_auth_env)] +async fn active_marker_for_external_profile_blocks_root_openai_auth() -> anyhow::Result<()> { + let _access_token_guard = remove_access_token_env_var(); + let _api_key_guard = EnvVarGuard::remove(CODEX_API_KEY_ENV_VAR); + let dir = tempdir()?; + + super::save_auth( + dir.path(), + &api_key_auth_dot_json("root-key"), + AuthCredentialsStoreMode::File, + )?; + save_auth_profile_metadata( + dir.path(), + "claude", + AuthProfileMetadata { + subscription_provider: AuthProfileSubscriptionProvider::ClaudeAi, + last_permissions: None, + }, + )?; + crate::switch_auth_profile(dir.path(), AuthCredentialsStoreMode::File, "claude")?; + + let manager = AuthManager::new_with_auth_profile( + dir.path().to_path_buf(), + /*enable_codex_api_key_env*/ true, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + /*selected_auth_profile*/ None, + ) + .await; + + // The process is *not* scoped to a named profile ... + assert_eq!(manager.selected_auth_profile(), None); + // ... but the root login still refuses to serve OpenAI model auth. + assert_eq!(manager.auth_cached(), None); + + Ok(()) +} + +/// D2 (credential path): the ambient marker must not suppress explicitly +/// provided env credentials. Env is an explicit operator action; the marker is +/// not. +#[tokio::test] +#[serial(codex_auth_env)] +async fn active_marker_does_not_suppress_env_api_key() -> anyhow::Result<()> { + let _access_token_guard = remove_access_token_env_var(); + let _api_key_guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); + let dir = tempdir()?; + + super::save_auth( + dir.path(), + &api_key_auth_dot_json("root-key"), + AuthCredentialsStoreMode::File, + )?; + save_auth_profile_metadata( + dir.path(), + "claude", + AuthProfileMetadata { + subscription_provider: AuthProfileSubscriptionProvider::ClaudeAi, + last_permissions: None, + }, + )?; + crate::switch_auth_profile(dir.path(), AuthCredentialsStoreMode::File, "claude")?; + + let manager = AuthManager::new_with_auth_profile( + dir.path().to_path_buf(), + /*enable_codex_api_key_env*/ true, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + /*selected_auth_profile*/ None, + ) + .await; + + assert_eq!( + manager.auth_cached().as_ref().and_then(CodexAuth::api_key), + Some("sk-env") + ); + + Ok(()) +} + +/// A marker naming a ChatGPT profile is *not* a lock: `switch` copied that +/// profile's credentials into the root login, so root auth stays usable. +#[tokio::test] +#[serial(codex_auth_env)] +async fn active_marker_for_chatgpt_profile_keeps_root_auth_usable() -> anyhow::Result<()> { + let _access_token_guard = remove_access_token_env_var(); + let _api_key_guard = EnvVarGuard::remove(CODEX_API_KEY_ENV_VAR); + let dir = tempdir()?; + + save_auth_profile( + dir.path(), + AuthCredentialsStoreMode::File, + "work", + &api_key_auth_dot_json("work-key"), + )?; + crate::switch_auth_profile(dir.path(), AuthCredentialsStoreMode::File, "work")?; + + let manager = AuthManager::new_with_auth_profile( + dir.path().to_path_buf(), + /*enable_codex_api_key_env*/ true, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + /*selected_auth_profile*/ None, + ) + .await; + + assert_eq!( + manager.auth_cached().as_ref().and_then(CodexAuth::api_key), + Some("work-key") + ); + + Ok(()) +} + +/// D5 (credential path): an unresolvable marker fails closed for credentials +/// while leaving `Config::load` and the repair commands intact. +#[tokio::test] +#[serial(codex_auth_env)] +async fn active_marker_that_cannot_be_resolved_blocks_root_openai_auth() -> anyhow::Result<()> { + let _access_token_guard = remove_access_token_env_var(); + let _api_key_guard = EnvVarGuard::remove(CODEX_API_KEY_ENV_VAR); + let dir = tempdir()?; + + super::save_auth( + dir.path(), + &api_key_auth_dot_json("root-key"), + AuthCredentialsStoreMode::File, + )?; + std::fs::create_dir_all(dir.path().join("auth_profiles"))?; + std::fs::write( + dir.path().join("auth_profiles").join(".active"), + "../escape", + )?; + + let manager = AuthManager::new_with_auth_profile( + dir.path().to_path_buf(), + /*enable_codex_api_key_env*/ true, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + /*selected_auth_profile*/ None, + ) + .await; + + assert_eq!(manager.auth_cached(), None); + + Ok(()) +} + #[tokio::test] #[serial(codex_auth_env)] async fn running_session_switch_accepts_external_subscription_profile_without_openai_auth() diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 8b80088bbd..016ae37a06 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -890,6 +890,23 @@ async fn load_auth_with_profile( return Ok(None); } + // Everything above this point is an *explicit* credential injection (env + // vars, or an externally supplied ephemeral token). The persisted root + // login below is the one thing the ambient `auth_profiles/.active` marker + // claims ownership of, so the provider lock applies only here, and only + // when no profile was explicitly selected for this process. + if selected_auth_profile.is_none() { + let ownership = super::profile::root_auth_ownership(codex_home); + if !ownership.allows_root_model_auth() { + tracing::info!( + ?ownership, + "not using the root login for Codewith model auth; run \ + `codewith profile switch ` to change the active profile" + ); + return Ok(None); + } + } + // Fall back to the configured persistent store (file/keyring/auto) for managed auth. let storage = create_auth_storage(auth_storage_home.clone(), auth_credentials_store_mode); let auth_dot_json = match storage.load()? { diff --git a/codex-rs/login/src/auth/profile.rs b/codex-rs/login/src/auth/profile.rs index e9051751d1..3ad3b902fb 100644 --- a/codex-rs/login/src/auth/profile.rs +++ b/codex-rs/login/src/auth/profile.rs @@ -235,6 +235,98 @@ pub fn active_auth_profile(codex_home: &Path) -> Result, AuthProf Ok(Some(name.to_string())) } +/// Whether the *root* login (`CODEWITH_HOME/auth.json`) is owned by a named +/// auth profile, according to the ambient `auth_profiles/.active` marker. +/// +/// This is deliberately a different concept from the *selected* auth profile +/// (`--auth-profile`, `CODEWITH_AUTH_PROFILE`, `CODEX_AUTH_PROFILE`): +/// +/// * A **selected** profile scopes one process: it reads that profile's own +/// credential storage, inherits that profile's saved permission settings, +/// suppresses global env credentials, disables root-auth mirroring, and gets +/// its own app-server socket namespace. +/// * The **`.active` marker** is ambient and machine-wide. It is written by +/// `codewith profile switch` / `codewith login --profile` and only records +/// *which profile last wrote the root login*. Processes that were not given +/// an explicit selector still run on the root login, so none of the +/// process-scoping consequences above may apply to them. +/// +/// The marker therefore has exactly one credential consequence, modelled here: +/// if it names a profile locked to a non-ChatGPT subscription provider, the +/// root login is not that profile's to give away and its ChatGPT credentials +/// must not be handed to Codewith model auth. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RootAuthOwnership { + /// No marker, or the marker names a profile that no longer exists. The + /// root login is unowned and usable as-is. + Unowned, + /// The marker names a ChatGPT profile. `switch` mirrored that profile's + /// credentials into the root login, so root auth *is* that profile's auth. + ChatGpt { profile: String }, + /// The marker names a profile deliberately locked to another subscription + /// provider. This is a normal, user-chosen state: the root login simply is + /// not available for Codewith model auth until the user switches back. + ExternalProvider { + profile: String, + provider: AuthProfileSubscriptionProvider, + }, + /// Ownership of the root login could not be determined, so it is not safe + /// to assume the credentials are Codewith's to use. Unlike + /// [`RootAuthOwnership::ExternalProvider`] this is an abnormal state worth + /// surfacing to the user. + Unresolvable { + profile: Option, + detail: String, + }, +} + +impl RootAuthOwnership { + /// Whether the persisted root login may be used for Codewith model auth. + pub fn allows_root_model_auth(&self) -> bool { + matches!(self, Self::Unowned | Self::ChatGpt { .. }) + } +} + +/// Resolves [`RootAuthOwnership`] from disk. +/// +/// Infallible by construction: a corrupt marker or unreadable profile metadata +/// must never take down `Config::load`, because that would also brick the +/// commands a user needs in order to *repair* the marker (`codewith login`, +/// `codewith profile list`, `codewith profile switch`). Unresolvable state is +/// reported as [`RootAuthOwnership::Unresolvable`] instead, so the credential +/// path fails closed while the recovery paths keep working. +pub fn root_auth_ownership(codex_home: &Path) -> RootAuthOwnership { + let profile = match active_auth_profile(codex_home) { + Ok(None) => return RootAuthOwnership::Unowned, + Ok(Some(profile)) => profile, + Err(err) => { + return RootAuthOwnership::Unresolvable { + profile: None, + detail: format!("the active auth profile marker could not be read: {err}"), + }; + } + }; + + match load_auth_profile_metadata(codex_home, &profile) { + Ok(metadata) + if metadata.subscription_provider == AuthProfileSubscriptionProvider::ChatGpt => + { + RootAuthOwnership::ChatGpt { profile } + } + Ok(metadata) => RootAuthOwnership::ExternalProvider { + profile, + provider: metadata.subscription_provider, + }, + // The marker points at a profile that has been deleted out from under + // it; there is nothing to lock the root login to. + Err(AuthProfileError::ProfileNotFound { .. }) => RootAuthOwnership::Unowned, + Err(err) => RootAuthOwnership::Unresolvable { + detail: format!("auth profile `{profile}` metadata could not be read: {err}"), + profile: Some(profile), + }, + } +} + pub fn clear_active_auth_profile(codex_home: &Path) -> Result { let path = active_profile_file(codex_home); match fs::remove_file(path) { @@ -350,6 +442,13 @@ pub fn load_auth_profile( ) -> Result { validate_auth_profile_name(name)?; ensure_persistent_auth_storage(auth_credentials_store_mode)?; + let metadata = load_auth_profile_metadata(codex_home, name)?; + if metadata.subscription_provider != AuthProfileSubscriptionProvider::ChatGpt { + return Err(AuthProfileError::NonChatGptProfile { + name: name.to_string(), + provider: metadata.subscription_provider, + }); + } load_profile_auth(codex_home, auth_credentials_store_mode, name) } @@ -383,10 +482,12 @@ pub fn switch_auth_profile( let metadata = load_profile_metadata(&auth_profile_dir(codex_home, name))?; if metadata.subscription_provider != AuthProfileSubscriptionProvider::ChatGpt { - return Err(AuthProfileError::NonChatGptProfile { - name: name.to_string(), - provider: metadata.subscription_provider, - }); + write_active_profile(codex_home, name)?; + return Ok(profile_from_metadata( + name.to_string(), + metadata, + /*active*/ true, + )); } let auth = load_profile_auth(codex_home, auth_credentials_store_mode, name)?; diff --git a/codex-rs/login/src/auth/profile_tests.rs b/codex-rs/login/src/auth/profile_tests.rs index 584557db5e..e7bb6ff8d1 100644 --- a/codex-rs/login/src/auth/profile_tests.rs +++ b/codex-rs/login/src/auth/profile_tests.rs @@ -516,14 +516,24 @@ fn external_subscription_profiles_do_not_require_auth_json() -> anyhow::Result<( }] ); - assert!(matches!( - switch_auth_profile(codex_home.path(), AuthCredentialsStoreMode::File, "claude"), - Err(AuthProfileError::NonChatGptProfile { - name, - provider: AuthProfileSubscriptionProvider::ClaudeAi, - }) if name == "claude" - )); - assert_eq!(active_auth_profile(codex_home.path())?, None); + let switched = + switch_auth_profile(codex_home.path(), AuthCredentialsStoreMode::File, "claude")?; + assert_eq!( + switched, + AuthProfile { + name: "claude".to_string(), + subscription_provider: AuthProfileSubscriptionProvider::ClaudeAi, + auth_mode: None, + email: None, + account_id: None, + plan: None, + active: true, + } + ); + assert_eq!( + active_auth_profile(codex_home.path())?.as_deref(), + Some("claude") + ); assert_eq!(active_storage.load()?, Some(root_auth)); let renamed = rename_auth_profile( @@ -538,7 +548,10 @@ fn external_subscription_profiles_do_not_require_auth_json() -> anyhow::Result<( AuthProfileSubscriptionProvider::ClaudeAi ); assert_eq!(renamed.auth_mode, None); - assert_eq!(active_auth_profile(codex_home.path())?, None); + assert_eq!( + active_auth_profile(codex_home.path())?.as_deref(), + Some("claude-work") + ); assert_eq!( load_auth_profile_metadata(codex_home.path(), "claude-work")?, metadata @@ -550,6 +563,12 @@ fn external_subscription_profiles_do_not_require_auth_json() -> anyhow::Result<( #[test] fn external_subscription_profiles_ignore_stray_openai_auth() -> anyhow::Result<()> { let codex_home = tempdir()?; + let root_auth = auth_with_key("sk-root"); + let active_storage = create_auth_storage( + codex_home.path().to_path_buf(), + AuthCredentialsStoreMode::File, + ); + active_storage.save(&root_auth)?; let metadata = AuthProfileMetadata { subscription_provider: AuthProfileSubscriptionProvider::ClaudeAi, last_permissions: None, @@ -561,6 +580,13 @@ fn external_subscription_profiles_ignore_stray_openai_auth() -> anyhow::Result<( "claude", &auth_with_key("sk-stray"), )?; + assert!(matches!( + load_auth_profile(codex_home.path(), AuthCredentialsStoreMode::File, "claude"), + Err(AuthProfileError::NonChatGptProfile { + name, + provider: AuthProfileSubscriptionProvider::ClaudeAi, + }) if name == "claude" + )); let profiles = list_auth_profiles(codex_home.path(), AuthCredentialsStoreMode::File)?; @@ -577,6 +603,193 @@ fn external_subscription_profiles_ignore_stray_openai_auth() -> anyhow::Result<( }] ); + let switched = + switch_auth_profile(codex_home.path(), AuthCredentialsStoreMode::File, "claude")?; + assert_eq!(switched.auth_mode, None); + assert_eq!( + active_auth_profile(codex_home.path())?.as_deref(), + Some("claude") + ); + assert_eq!(active_storage.load()?, Some(root_auth)); + + Ok(()) +} + +#[test] +fn root_auth_ownership_classifies_the_active_marker() -> anyhow::Result<()> { + let codex_home = tempdir()?; + + // No marker at all. + assert_eq!( + root_auth_ownership(codex_home.path()), + RootAuthOwnership::Unowned + ); + + // Marker naming a ChatGPT profile: `switch` mirrored its credentials into + // the root login, so root auth *is* that profile's auth. + save_auth_profile( + codex_home.path(), + AuthCredentialsStoreMode::File, + "work", + &auth_with_key("sk-work"), + )?; + write_active_profile(codex_home.path(), "work")?; + assert_eq!( + root_auth_ownership(codex_home.path()), + RootAuthOwnership::ChatGpt { + profile: "work".to_string() + } + ); + + // Marker naming a provider-locked profile: root credentials are not its + // to give away. + save_auth_profile_metadata( + codex_home.path(), + "claude", + AuthProfileMetadata { + subscription_provider: AuthProfileSubscriptionProvider::ClaudeAi, + last_permissions: None, + }, + )?; + write_active_profile(codex_home.path(), "claude")?; + assert_eq!( + root_auth_ownership(codex_home.path()), + RootAuthOwnership::ExternalProvider { + profile: "claude".to_string(), + provider: AuthProfileSubscriptionProvider::ClaudeAi, + } + ); + + // Marker naming a profile that no longer exists: nothing to lock to. + delete_auth_profile(codex_home.path(), AuthCredentialsStoreMode::File, "claude")?; + write_active_profile(codex_home.path(), "claude")?; + assert_eq!( + root_auth_ownership(codex_home.path()), + RootAuthOwnership::Unowned + ); + + Ok(()) +} + +/// D5: an unresolvable marker must be reported as `Locked` (so the credential +/// path fails closed) rather than as an error that would take down +/// `Config::load` and with it the commands needed to repair the marker. +#[test] +fn root_auth_ownership_fails_closed_on_a_corrupt_marker() -> anyhow::Result<()> { + // (a) `.active` holds a name that is not a legal profile name. + let invalid_name_home = tempdir()?; + std::fs::create_dir_all(invalid_name_home.path().join("auth_profiles"))?; + std::fs::write( + invalid_name_home + .path() + .join("auth_profiles") + .join(".active"), + "../escape", + )?; + assert!(matches!( + root_auth_ownership(invalid_name_home.path()), + RootAuthOwnership::Unresolvable { profile: None, .. } + )); + + // (b) `.active` names a real profile whose metadata is malformed. + let malformed_home = tempdir()?; + save_auth_profile_metadata( + malformed_home.path(), + "work", + AuthProfileMetadata::default(), + )?; + write_active_profile(malformed_home.path(), "work")?; + std::fs::write( + malformed_home + .path() + .join("auth_profiles") + .join("work") + .join("profile.json"), + "{ not json", + )?; + assert!(matches!( + root_auth_ownership(malformed_home.path()), + RootAuthOwnership::Unresolvable { profile: Some(name), .. } if name == "work" + )); + + // The corrupt-marker case leaves `profile list` working, which is how a + // user discovers what to switch to. + assert!(list_auth_profiles(invalid_name_home.path(), AuthCredentialsStoreMode::File).is_ok()); + // (Malformed `profile.json` still fails `profile list`; that is + // pre-existing behaviour of `list_auth_profiles`, unrelated to the marker, + // and `switch`/`login` remain available to repair it.) + assert!(list_auth_profiles(malformed_home.path(), AuthCredentialsStoreMode::File).is_err()); + save_auth_profile( + malformed_home.path(), + AuthCredentialsStoreMode::File, + "recovered", + &auth_with_key("sk-recovered"), + )?; + switch_auth_profile( + malformed_home.path(), + AuthCredentialsStoreMode::File, + "recovered", + )?; + assert_eq!( + root_auth_ownership(malformed_home.path()), + RootAuthOwnership::ChatGpt { + profile: "recovered".to_string() + } + ); + + Ok(()) +} + +/// D3: with the marker no longer acting as a process selector, refreshed root +/// tokens keep being mirrored into the ChatGPT profile that owns the root +/// login, so `profile list` and the marker do not disagree about which profile +/// is active. +#[test] +fn mirror_active_auth_profile_keeps_list_active_state_in_sync() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let original_auth = auth_with_key("sk-original"); + let refreshed_auth = auth_with_key("sk-refreshed"); + let active_storage = create_auth_storage( + codex_home.path().to_path_buf(), + AuthCredentialsStoreMode::File, + ); + + save_auth_profile( + codex_home.path(), + AuthCredentialsStoreMode::File, + "work", + &original_auth, + )?; + switch_auth_profile(codex_home.path(), AuthCredentialsStoreMode::File, "work")?; + assert!( + list_auth_profiles(codex_home.path(), AuthCredentialsStoreMode::File)? + .iter() + .all(|profile| profile.active) + ); + + // Simulate a token refresh on the root login: the root store is rewritten + // and the owning profile is mirrored. + active_storage.save(&refreshed_auth)?; + mirror_active_auth_profile( + codex_home.path(), + AuthCredentialsStoreMode::File, + &refreshed_auth, + )?; + + assert_eq!( + load_optional_profile_auth(codex_home.path(), AuthCredentialsStoreMode::File, "work")?, + Some(refreshed_auth) + ); + let profiles = list_auth_profiles(codex_home.path(), AuthCredentialsStoreMode::File)?; + assert_eq!( + profiles + .iter() + .filter(|profile| profile.active) + .map(|profile| profile.name.as_str()) + .collect::>(), + vec!["work"], + ); + Ok(()) } @@ -607,9 +820,18 @@ fn mirror_active_auth_profile_skips_external_profiles() -> anyhow::Result<()> { )?; assert_eq!(active_storage.load()?, Some(original_auth)); + // Nothing was mirrored into the external profile's own storage... + assert_eq!( + load_optional_profile_auth(codex_home.path(), AuthCredentialsStoreMode::File, "claude")?, + None + ); + // ...and the profile refuses to hand out OpenAI credentials regardless. assert!(matches!( load_auth_profile(codex_home.path(), AuthCredentialsStoreMode::File, "claude"), - Err(AuthProfileError::ProfileNotFound { name }) if name == "claude" + Err(AuthProfileError::NonChatGptProfile { + name, + provider: AuthProfileSubscriptionProvider::ClaudeAi, + }) if name == "claude" )); Ok(()) diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index cd92e4096f..8a72194a75 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -43,6 +43,7 @@ pub use auth::OPENAI_API_KEY_ENV_VAR; pub use auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; pub use auth::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; pub use auth::RefreshTokenError; +pub use auth::RootAuthOwnership; pub use auth::UnauthorizedRecovery; pub use auth::active_auth_profile; pub use auth::auth_profile_exists; @@ -65,6 +66,7 @@ pub use auth::read_codex_access_token_from_env; pub use auth::read_openai_api_key_from_env; pub use auth::remove_auth_profile; pub use auth::rename_auth_profile; +pub use auth::root_auth_ownership; pub use auth::save_auth; pub use auth::save_auth_profile; pub use auth::save_auth_profile_metadata; diff --git a/docs/authentication.md b/docs/authentication.md index b5fc91b812..1889c69c27 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -15,6 +15,17 @@ codewith profile switch work Profiles are named local credential snapshots stored under `CODEWITH_HOME` using the configured credential storage mode. Switching a profile replaces the active local Codewith credentials with that saved profile. It does not bypass login, logout, or account authorization; each profile must be created from a normal successful login. +Each profile is locked to the subscription provider it was created for. A profile whose provider is not ChatGPT (Claude.ai, Cursor, Grok) can be listed and switched to like any other, but it never lends OpenAI credentials to Codewith model auth: it carries no `auth.json` of its own, refreshed root tokens are not mirrored into it, and any stray `auth.json` left inside it is ignored rather than used. Selecting such a profile leaves Codewith without ChatGPT model auth — the agent is still free to select any provider it has configured. + +### Switching vs. pinning + +These are two different mechanisms, and the difference matters: + +- `codewith profile switch ` changes the **root login** for the whole machine and records the choice in `auth_profiles/.active`. For a ChatGPT profile it copies that profile's credentials into the root login. For a provider-locked profile there are no OpenAI credentials to copy, so the root login keeps whatever it held and Codewith simply stops using it for model auth until you switch back to a ChatGPT profile. Switching does **not** scope later processes to that profile: it does not change their sandbox or approval settings, does not disable `CODEX_API_KEY` / `CODEX_ACCESS_TOKEN`, and does not give them a separate app-server socket. +- `--auth-profile `, `CODEWITH_AUTH_PROFILE`, and `CODEX_AUTH_PROFILE` **pin one process** to a profile. A pinned process reads that profile's own credentials, inherits the permission settings saved with it, ignores global env credentials, and gets its own app-server socket namespace. Pins always win over the persisted active profile. + +If `auth_profiles/.active` or a profile's `profile.json` becomes unreadable, Codewith cannot prove which profile owns the root login, so it fails closed and declines to use root credentials for model auth. Commands do not fail: `codewith profile list`, `codewith profile switch `, and `codewith login` all keep working so you can repair the marker. + For concurrent sessions, prefer per-session auth profile pinning: ```shell