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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions codex-rs/app-server-transport/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
89 changes: 89 additions & 0 deletions codex-rs/app-server-transport/src/transport/unix_socket_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String>,
}

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");
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> ...`. 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 <name> ...`. 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 <name>` 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`.
Expand Down
79 changes: 79 additions & 0 deletions codex-rs/app-server/tests/suite/v2/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AuthMode>,
Expand Down Expand Up @@ -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()?;
Expand Down
10 changes: 5 additions & 5 deletions codex-rs/cli/src/profile_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -267,8 +267,8 @@ mod tests {
"email": null,
"accountId": null,
"plan": null,
"usable": false,
"unusableReason": "unsupported_subscription_provider",
"usable": true,
"unusableReason": null,
}
]
})
Expand Down
Loading
Loading