From 24bb60441381a48ae55967626e6f08d845041f82 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 25 Jul 2026 23:41:03 +0300 Subject: [PATCH 1/3] fix: Fix profile<->provider logic: lock a profile to its subscription provider/models while the agent can still select any pr --- codex-rs/app-server/tests/suite/v2/account.rs | 79 +++++++++++++++++++ codex-rs/cli/src/profile_cmd.rs | 10 +-- codex-rs/core/src/config/config_tests.rs | 32 ++++++++ codex-rs/core/src/config/mod.rs | 24 +++++- codex-rs/login/src/auth/profile.rs | 17 +++- codex-rs/login/src/auth/profile_tests.rs | 53 ++++++++++--- 6 files changed, 193 insertions(+), 22 deletions(-) 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..21cedb4b1d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5475,6 +5475,38 @@ async fn config_ignores_empty_codewith_auth_profile_env() -> std::io::Result<()> Ok(()) } +#[tokio::test] +#[serial(selected_auth_profile_env)] +async fn config_resolves_selected_auth_profile_from_active_marker() -> 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()?; + codex_login::save_auth_profile_metadata( + codex_home.path(), + "cursor", + codex_login::AuthProfileMetadata { + subscription_provider: codex_login::AuthProfileSubscriptionProvider::Cursor, + last_permissions: None, + }, + )?; + codex_login::switch_auth_profile( + codex_home.path(), + codex_config::types::AuthCredentialsStoreMode::File, + "cursor", + )?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!(config.selected_auth_profile.as_deref(), Some("cursor")); + + 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..292f3cebb6 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -87,9 +87,11 @@ use codex_features::NetworkProxyConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; +use codex_login::AuthProfileError; use codex_login::AuthProfilePermissionSettings; use codex_login::CODEWITH_AUTH_PROFILE_ENV_VAR; use codex_login::CODEX_AUTH_PROFILE_ENV_VAR; +use codex_login::active_auth_profile; use codex_login::load_auth_profile_metadata; use codex_login::validate_auth_profile_name; use codex_mcp::McpConfig; @@ -595,6 +597,7 @@ fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { } fn resolve_selected_auth_profile( + codex_home: &Path, explicit_profile: Option, ) -> std::io::Result> { if let Some(profile) = explicit_profile { @@ -605,9 +608,22 @@ fn resolve_selected_auth_profile( return validate_selected_auth_profile(profile, CODEWITH_AUTH_PROFILE_ENV_VAR).map(Some); } - non_empty_env(CODEX_AUTH_PROFILE_ENV_VAR) - .map(|profile| validate_selected_auth_profile(profile, CODEX_AUTH_PROFILE_ENV_VAR)) - .transpose() + if let Some(profile) = non_empty_env(CODEX_AUTH_PROFILE_ENV_VAR) { + return validate_selected_auth_profile(profile, CODEX_AUTH_PROFILE_ENV_VAR).map(Some); + } + + match active_auth_profile(codex_home).map_err(|err| { + std::io::Error::other(format!("failed to resolve active auth profile: {err}")) + })? { + Some(profile) => match load_auth_profile_metadata(codex_home, &profile) { + Ok(_) => Ok(Some(profile)), + Err(AuthProfileError::ProfileNotFound { .. }) => Ok(None), + Err(err) => Err(std::io::Error::other(format!( + "failed to load active auth profile metadata: {err}" + ))), + }, + None => Ok(None), + } } fn non_empty_env(name: &str) -> Option { @@ -3693,7 +3709,7 @@ impl Config { Some(profile) => profile .map(|profile| validate_selected_auth_profile(profile, "--auth-profile")) .transpose()?, - None => resolve_selected_auth_profile(/*explicit_profile*/ None)?, + None => resolve_selected_auth_profile(&codex_home, /*explicit_profile*/ None)?, }; reject_infinity_agent_auth_profile_inheritance( tool_policy, diff --git a/codex-rs/login/src/auth/profile.rs b/codex-rs/login/src/auth/profile.rs index e9051751d1..3eec0cb377 100644 --- a/codex-rs/login/src/auth/profile.rs +++ b/codex-rs/login/src/auth/profile.rs @@ -350,6 +350,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 +390,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..edc33ac839 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,15 @@ 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(()) } From c1a06eebba16c8f0c3fc97f10ec5e381f2848d9c Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 09:54:56 +0300 Subject: [PATCH 2/3] fix(login): update mirror test for provider-locked load_auth_profile; document profile<->provider locking load_auth_profile now rejects non-ChatGPT profiles before touching auth storage, so mirror_active_auth_profile_skips_external_profiles could no longer see ProfileNotFound. Assert the real invariant instead: nothing was mirrored into the external profile's own storage, and the profile refuses to hand out OpenAI credentials. Also refresh the two docs that described the old behaviour: `codewith profile switch` now persists a selection later processes start on, and non-ChatGPT profiles are selectable (`usable: true`) while never lending OpenAI credentials to model auth. --- codex-rs/app-server/README.md | 2 +- codex-rs/login/src/auth/profile_tests.rs | 11 ++++++++++- docs/authentication.md | 4 ++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 88530816ad..c4fc17255f 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`, `CODEX_AUTH_PROFILE`, or the persisted active profile (`codewith profile switch`) 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 selectable; a profile is 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 their OpenAI credentials to Codewith model auth, so they report `authMode: null`. 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/login/src/auth/profile_tests.rs b/codex-rs/login/src/auth/profile_tests.rs index edc33ac839..ea780bc4c1 100644 --- a/codex-rs/login/src/auth/profile_tests.rs +++ b/codex-rs/login/src/auth/profile_tests.rs @@ -642,9 +642,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/docs/authentication.md b/docs/authentication.md index b5fc91b812..39ac252f87 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -15,6 +15,10 @@ 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. +`codewith profile switch ` records the selection in `auth_profiles/.active`, and later Codewith processes start on that profile unless `--auth-profile`, `CODEWITH_AUTH_PROFILE`, or `CODEX_AUTH_PROFILE` overrides it (those selectors always win). + +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 therefore leaves Codewith without ChatGPT model auth for that process — the agent is still free to select any provider it has configured. + For concurrent sessions, prefer per-session auth profile pinning: ```shell From eb985a26ea04e5ba957252fd31637b9c97665fc5 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 10:44:55 +0300 Subject: [PATCH 3/3] fix(auth): separate ambient active-profile ownership from explicit profile selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous revision made `Config::resolve_selected_auth_profile` fall back to the on-disk `auth_profiles/.active` marker. That turned a marker written by `codewith profile save` / `codewith login --profile` into an ambient, process-wide selector, and five separate systems key off "is a profile selected?" in ways that must not fire for an unpinned process: - permission inheritance (`Config`): a plain `codewith exec` silently inherited the profile's saved `default_permissions` / `approval_policy` / `approvals_reviewer`, changing sandbox and approval posture as an invisible side effect of an unrelated earlier command; - env auth suppression (`load_auth_with_profile`): `CODEX_API_KEY` and `CODEX_ACCESS_TOKEN` were silently ignored on every invocation; - root-auth mirroring (`refresh_and_persist_chatgpt_token`): refreshed tokens stopped being mirrored, so root auth went stale and `profile list` computed `active=false` for the profile it also called selected; - app-server socket/lock namespacing, which keys off the env vars only, so a marker-selected session attached to the root-namespaced daemon; - the Infinity Agent guard, which rejects any named profile outright. Rather than gate five call sites independently, name the distinction once. `selected_auth_profile` goes back to meaning *explicitly pinned* (flag/env) and the marker gets its own type, `RootAuthOwnership`, describing what it actually records: which profile owns the *root login*. Its single credential consequence — a provider-locked profile must never lend OpenAI credentials — is applied where it belongs, on the persisted root-login read, after explicit env and ephemeral credentials have had their turn. `root_auth_ownership` is infallible, so a corrupt `.active` or unreadable `profile.json` can no longer fail `Config::load` and brick the very commands needed to repair it. Such state resolves to `Unresolvable`: credentials fail closed and a startup warning says how to fix it, while a deliberate provider lock stays quiet. Contract note: `profile list --json` no longer emits `unusableReason: "unsupported_subscription_provider"`, and `usable` now means "selectable" rather than "launch-compatible with Codewith model auth". Consumers should branch on `subscriptionProvider`/`authMode` instead. Adds a regression test per defect, plus coverage that the provider lock itself still takes effect for an unpinned process. --- codex-rs/Cargo.lock | 1 + codex-rs/app-server-transport/Cargo.toml | 1 + .../src/transport/unix_socket_tests.rs | 89 +++++++ codex-rs/app-server/README.md | 2 +- codex-rs/core/src/config/config_tests.rs | 243 +++++++++++++++++- codex-rs/core/src/config/mod.rs | 53 ++-- codex-rs/login/src/auth/auth_tests.rs | 152 +++++++++++ codex-rs/login/src/auth/manager.rs | 17 ++ codex-rs/login/src/auth/profile.rs | 92 +++++++ codex-rs/login/src/auth/profile_tests.rs | 178 +++++++++++++ codex-rs/login/src/lib.rs | 2 + docs/authentication.md | 11 +- 12 files changed, 808 insertions(+), 33 deletions(-) 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 c4fc17255f..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`, `CODEX_AUTH_PROFILE`, or the persisted active profile (`codewith profile switch`) 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 selectable; a profile is 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 their OpenAI credentials to Codewith model auth, so they report `authMode: null`. 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/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 21cedb4b1d..b56bea8659 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5475,24 +5475,104 @@ 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_resolves_selected_auth_profile_from_active_marker() -> anyhow::Result<()> { +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()?; - codex_login::save_auth_profile_metadata( + switch_to_external_profile( codex_home.path(), "cursor", - codex_login::AuthProfileMetadata { - subscription_provider: codex_login::AuthProfileSubscriptionProvider::Cursor, - last_permissions: None, - }, + codex_login::AuthProfileSubscriptionProvider::Cursor, )?; - codex_login::switch_auth_profile( + + 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(), - codex_config::types::AuthCredentialsStoreMode::File, - "cursor", + "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( @@ -5502,7 +5582,150 @@ async fn config_resolves_selected_auth_profile_from_active_marker() -> anyhow::R ) .await?; - assert_eq!(config.selected_auth_profile.as_deref(), Some("cursor")); + 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(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 292f3cebb6..a531af8161 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -87,12 +87,12 @@ use codex_features::NetworkProxyConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; -use codex_login::AuthProfileError; use codex_login::AuthProfilePermissionSettings; use codex_login::CODEWITH_AUTH_PROFILE_ENV_VAR; use codex_login::CODEX_AUTH_PROFILE_ENV_VAR; -use codex_login::active_auth_profile; +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; @@ -596,8 +596,19 @@ 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( - codex_home: &Path, explicit_profile: Option, ) -> std::io::Result> { if let Some(profile) = explicit_profile { @@ -608,22 +619,9 @@ fn resolve_selected_auth_profile( return validate_selected_auth_profile(profile, CODEWITH_AUTH_PROFILE_ENV_VAR).map(Some); } - if let Some(profile) = non_empty_env(CODEX_AUTH_PROFILE_ENV_VAR) { - return validate_selected_auth_profile(profile, CODEX_AUTH_PROFILE_ENV_VAR).map(Some); - } - - match active_auth_profile(codex_home).map_err(|err| { - std::io::Error::other(format!("failed to resolve active auth profile: {err}")) - })? { - Some(profile) => match load_auth_profile_metadata(codex_home, &profile) { - Ok(_) => Ok(Some(profile)), - Err(AuthProfileError::ProfileNotFound { .. }) => Ok(None), - Err(err) => Err(std::io::Error::other(format!( - "failed to load active auth profile metadata: {err}" - ))), - }, - None => Ok(None), - } + non_empty_env(CODEX_AUTH_PROFILE_ENV_VAR) + .map(|profile| validate_selected_auth_profile(profile, CODEX_AUTH_PROFILE_ENV_VAR)) + .transpose() } fn non_empty_env(name: &str) -> Option { @@ -3709,12 +3707,27 @@ impl Config { Some(profile) => profile .map(|profile| validate_selected_auth_profile(profile, "--auth-profile")) .transpose()?, - None => resolve_selected_auth_profile(&codex_home, /*explicit_profile*/ None)?, + None => resolve_selected_auth_profile(/*explicit_profile*/ None)?, }; reject_infinity_agent_auth_profile_inheritance( 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 3eec0cb377..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) { diff --git a/codex-rs/login/src/auth/profile_tests.rs b/codex-rs/login/src/auth/profile_tests.rs index ea780bc4c1..e7bb6ff8d1 100644 --- a/codex-rs/login/src/auth/profile_tests.rs +++ b/codex-rs/login/src/auth/profile_tests.rs @@ -615,6 +615,184 @@ fn external_subscription_profiles_ignore_stray_openai_auth() -> anyhow::Result<( 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(()) +} + #[test] fn mirror_active_auth_profile_skips_external_profiles() -> anyhow::Result<()> { let codex_home = tempdir()?; 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 39ac252f87..1889c69c27 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -15,9 +15,16 @@ 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. -`codewith profile switch ` records the selection in `auth_profiles/.active`, and later Codewith processes start on that profile unless `--auth-profile`, `CODEWITH_AUTH_PROFILE`, or `CODEX_AUTH_PROFILE` overrides it (those selectors always win). +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. -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 therefore leaves Codewith without ChatGPT model auth for that process — 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: