diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 0e91afd0..77f7654a 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -42,6 +42,7 @@ remembers doctrine; the branch remembers the blade. - Durable Epiphany state types now live in root crate `epiphany-state-model`. `codex-protocol` re-exports them only so old rollout/API callers keep compiling; do not add new durable `Epiphany*` state structs under `vendor/codex`. `epiphany-core` imports the native model directly and no longer depends on `codex-protocol`. Pressure derives from native `EpiphanyTokenUsageSnapshot`; Codex token telemetry is mapped at the bridge. Codex rollout/event reconstruction now lives in `codex-core::epiphany_rollout`, where the Codex `RolloutItem` / `EventMsg` types belong. Exact retrieval path matching is native to `epiphany-core`, so `epiphany-core` has no direct vendored Codex crate dependency. - State-update contract proof and job launch/interrupt DTO ownership now live in native `epiphany-core`. The old giant `codex_thread.rs` Epiphany state-update test fixture was cut, and `epiphany-codex-bridge` no longer imports Epiphany job launch request types from `codex_core`; vendored `CodexThread` may call the native mutation/launch law for compatibility, but it must not regain ownership of state-update semantics, heartbeat launch-plan policy, or Epiphany DTOs. - MCP remains JSON because MCP is a JSON protocol. The latest processor cut only moved MCP refresh/OAuth/status/resource/tool-call route handling into `codex_message_processor/mcp_routes.rs`, reducing the root processor to about 8,977 lines; it did not claim MCP payloads are CultNet or make MCP an Epiphany-owned runtime contract. +- Codex account auth route handling remains in vendored Codex but now lives in `codex_message_processor/auth_routes.rs`: API-key login, ChatGPT browser/device-code/external-token login, logout, auth status, account, rate limits, and add-credits nudge handling. This preserves Codex ownership of subscription auth while cutting the root processor to about 8,094 lines. - The May 2026 foundation migration in `notes/epiphany-ideal-architecture-rebuild-plan.md` is complete enough to defend. Read `notes/epiphany-architectural-teardown.md` before touching Epiphany control-plane code, but do not treat it as an open-ended excuse to keep shaving the altar. The rebuild moved scene projection, freshness derivation, pressure policy, targeted context shards, bounded graph traversal, job/progress view derivation, planning view derivation, reorientation resume/regather verdict policy, pure CRRC recommendation policy, fixed-lane coordinator decision policy, role-board projection policy, role/reorient result interpretation, role self-persistence review policy, and role/reorient acceptance bundle policy into `epiphany-core`; typed acceptance receipts replaced summary-string identity for live accept paths, runtime links are dual-written on launch, and result read-back now prefers runtime links. - Read `notes/epiphany-ideal-architecture-rebuild-plan.md` immediately after the teardown. It defines the smallest coherent replacement architecture: durable `EpiphanyState`, runtime-spine-owned `RuntimeState`, separate role memory/heartbeat state, `epiphany-core` as policy owner, app-server as adapter, Aquarium as reflector, typed acceptance receipts, and derived view lenses instead of a protocol verb zoo. - Do not continue Aquarium UI, bridge, Face, or dogfood expansion until the teardown has a source-grounded cleanup slice plan. Epiphany is the foundation; patches on patches are not a purification rite, they are how the altar becomes load-bearing garbage. diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 203fea82..69131d11 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ diff --git a/state/map.yaml b/state/map.yaml index 0f76e569..d14b87ac 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -286,9 +286,10 @@ rejected_paths: current_status: summary: The Codex starvation pass has five typing receipts, fifty-two processor/foundation cuts, a real physical extraction in `epiphany-codex-bridge`, a root `epiphany-state-model` crate for durable Epiphany state, a source-grounded Codex auth spine inventory, the native `epiphany-openai-adapter` document boundary, a buildable Codex auth/model wrapper in `epiphany-openai-codex-spine`, the first native runtime caller in `epiphany-openai-runtime`, native heartbeat launch planning/job opening in `epiphany-core`, native state-update contract/validation/application in `epiphany-core`, native exact retrieval path matching in `epiphany-core`, and the route-facing mutation/view/coordinator projection services in `epiphany-codex-bridge`. `codex-protocol` no longer defines the durable `Epiphany*` state structs; it re-exports the native model for legacy rollout/API compatibility while `epiphany-core` imports the native model directly. `epiphany-core` now has no direct vendored Codex dependency: pressure derivation accepts native `EpiphanyTokenUsageSnapshot`, Codex rollout replay moved to `codex-core::epiphany_rollout`, and exact retrieval search uses Epiphany-owned workspace walking/path scoring instead of `codex-file-search`. The bridge owns Codex-facing Epiphany adapters for scene, jobs, context/planning/graph query, retrieval/indexing projection, result projection, runtime-spine result adaptation, launch/prompt doctrine, mutation document mechanics, mutation service update/promote/role-accept/reorient-accept/role-launch/reorient-launch/job-launch/job-interrupt application, live-state projection, pressure/pre-compaction rendering, freshness/reorientation projection, CRRC/coordinator/role-board projection, watcher invalidation adaptation, full `thread/epiphany/view` response construction, freshness/context/graph-query response construction, role/reorient result response construction, distill/propose patch-response construction, coordinator status/view construction, and acceptance/evidence signal helpers. Vendored app-server no longer retains root Epiphany modules; `codex_message_processor.rs` still contains route dispatch, JSON-RPC response shaping, watcher registration/snapshotting calls, and notification emission, but its root no longer imports the evacuated Epiphany policy/protocol registry or owns the watcher invalidation manager. Codex product surfaces for apps, skills, plugins, marketplace, and external agent config import are cut from the live app-server/protocol path: list routes return empty lists only where compatibility shells still exist, mutation/detail routes report disabled where they survive, `externalAgentConfig/*` routes and import-completed notification are gone, helper/import modules and stale implementation-shape tests were deleted, runtime feature enablement no longer supports `apps` or `plugins`, config writes no longer emit plugin-toggle analytics, plugin/skill cache invalidation on config writes is gone, plugin startup warmups and app-list refreshes were removed, and app-server no longer directly depends on `codex-plugin`, `codex-chatgpt`, or `codex-core-plugins`. `Config::to_mcp_config` now keeps user-declared MCP servers but no longer accepts `PluginsManager`, folds plugin MCP servers into the runtime, enables Codex apps, enables skill MCP dependency install, or advertises plugin capability summaries; `TurnContext::apps_enabled()` now returns false so model turns cannot awaken Codex Apps through ChatGPT auth or feature flags; session startup and per-turn context no longer load skills, turns receive an empty `SkillLoadOutcome`, internal `Op::ListSkills` is gone from `codex-protocol` / `codex-core` / TUI command routing, the Codex skill watcher has been collapsed to a no-op marker with no file watcher, skill-root registration, cache-clear task, or `SkillsUpdateAvailable` session listener, model turns no longer run skill mention collection, skill env-var prompts, skill MCP dependency installation, skill instruction injection, or implicit skill invocation analytics from shell tools, and the turn prompt/router path no longer loads plugin/app capability summaries, injects available-skill/available-plugin/plugin instruction fragments, parses plugin/app mentions, records app/plugin invocation telemetry, or filters MCP tools through Codex app connectors. Skill mutation/list/notification is gone from the app-server edge too: `skills/list`, `skills/config/write`, `skills/changed`, `Op::ListSkills`, `EventMsg::ListSkillsResponse`, `EventMsg::SkillsUpdateAvailable`, the app-server handlers, TUI slash/startup/list/manage-skill surfaces, generated schema entries, core skill-list suite, and fake live-reload/developer-message tests were deleted. Skill mention plumbing is also gone from the live TUI/protocol edge: no `UserInput::Skill` variant, app-server schema branch, TUI skill popup, `skills_all` cache, composer `set_skill_mentions`, skill mention insertion, `skill://` / `SKILL.md` history decoding, or stale skill popup snapshot remains. The `codex-core-skills` crate, core `skills` re-export shim, no-op `skills_watcher`, session `SkillsManager` service, and per-turn `TurnSkillsContext` are deleted; core/protocol no longer keep legacy `` or `` prompt compatibility, memory usage classification, snapshot normalization, or cleanup paths. Remaining product-shaped skill residue is also cut from memory templates, app-server docs/tests/warnings, TUI tooltips/placeholders/warnings/comments, app-server v1 docs, agent-tool schema text, stale `UserInput::Skill` test arms, and obsolete feature flags/schema entries. App-instruction prompt compatibility is cut too: no `APPS_INSTRUCTIONS` protocol constants, no `` snapshot canonicalization, and MCP fixture tests now use neutral `demo_mcp` / `calendar_mcp` names instead of `codex_apps` / `/api/codex/apps` branding. The TUI app connector product path is also deleted: no `/apps` slash command, app-link view, connector AppEvent variants, app connector cache/popup/refresh loop, app mention insertion, `app://` mention codec support, stale app-popup tests, or TUI browser-opening shim remains. The `app/list` route and `app/list/updated` notification are gone from app-server protocol, generated schemas, app-server dispatch, test helpers, and docs; `AppInfo`/app-list metadata schema structs are deleted; the orphaned `codex-connectors` crate and `core::connectors` shim are deleted from the workspace; Codex app analytics no longer tracks `codex_app_mentioned` / `codex_app_used` facts, events, reducer arms, client APIs, or dedupe cache state; `[apps.*]`, `Feature::Apps`, `include_apps_instructions`, app config/read schemas, app managed requirements, app prompt tests, and app file-MCP tests are gone; `Feature::Plugins`, `Feature::RemotePlugin`, `plugin://` structured mentions, plugin instruction tags, and stale plugin popup snapshots are gone too; `[plugins]` / `[marketplaces]`, `PluginConfig`, `MarketplaceConfig`, marketplace edit helpers, stale plugin/external-agent README route claims, and generated schema entries are removed from config; and the bundled `codex-skills` sample-skill crate plus embedded imagegen/openai-docs/plugin-creator/skill-creator/skill-installer payloads are deleted from the workspace. The vendored `vendor/codex/sdk` subtree is deleted as non-spine app-server client/release scaffolding, including stale generated Python SDK route types for removed product surfaces; the root pnpm workspace no longer lists `sdk/typescript`. Live MCP tool approval is named `McpToolApproval`, not `AppToolApproval`, and remains under MCP server config; live structured user-input mentions are MCP-shaped only. `codex_message_processor.rs` is about 9,482 lines; root `message_processor.rs` is about 1,292 lines after the external-agent/config-product cuts; `epiphany_read_routes.rs` is about 579 lines after the full view and read-response extractions; `epiphany_mutation_routes.rs` is about 694 lines after route-load/notification helper cuts and product-surface starvation; the old vendored `epiphany_state_helpers.rs` rollout-state loader is deleted and its remaining function lives in `epiphany-codex-bridge::state`; and `codex_thread.rs` is about 1,425 lines after state-update law moved native. `epiphany-openai-adapter` defines CultCache `DatabaseEntry` request/event/receipt/auth-status documents with no Codex app-server or generic JSON dependency. `epiphany-openai-codex-spine` owns the native OpenAI model transport path: it exposes `default_codex_home` and `auth_manager`, projects `epiphany-openai-auth-spine` `AuthManager` / `CodexAuth` into typed `EpiphanyOpenAiAdapterStatus`, builds a local serializable OpenAI Responses request body instead of Codex protocol request/item/config types, opens HTTP Responses streams through `codex-client`, parses Responses SSE frames into typed stream events and receipts, and no longer directly or transitively depends on Codex's Responses client, model-provider stack, `codex-protocol`, `codex-api`, `codex-model-provider`, or `codex-model-provider-info`; it intentionally reaches retained vendored `codex-login` only through `epiphany-openai-auth-spine`. Auth identity, credential storage, and ChatGPT token refresh remain owned by the Codex-compatible auth organ; external bearer command auth and agent identity remain retained Codex auth capabilities to expose only if a typed Epiphany surface needs them. `epiphany-openai-runtime` no longer depends directly on `codex-login`; it writes OpenAI status/request/stream-event/receipt records plus runtime sessions/jobs/results/events into `epiphany-core::runtime_spine_cache`, can emit the assistant text artifact for legacy result consumers, is now the default executor for startup-only repo birth specialists, and now has `run-worker` to load a durable `EpiphanyRuntimeWorkerLaunchRequest`, call the typed OpenAI runtime through the spine, persist typed `EpiphanyRuntimeRoleWorkerResult` / `EpiphanyRuntimeReorientWorkerResult` documents, and complete the original heartbeat/specialist runtime job without Codex worker execution; `codex exec` survives only as explicit fallback in repo-birth startup. `epiphany-mvp-coordinator` now invokes `epiphany-openai-runtime run-worker` for launched runtime job ids and no longer creates a second coordinator-owned shadow runtime job/result as a compensator. `epiphany-core::plan_runtime_spine_heartbeat_launch` validates launch contracts/conflicts and projects durable job bindings/runtime links; `epiphany-core::open_runtime_spine_heartbeat_job` owns the initialize-runtime/ensure-session/create-job sequence and now persists `EpiphanyRuntimeWorkerLaunchRequest` CultCache records keyed by runtime job id, with indexed kind/contract fields plus MessagePack-encoded typed launch documents instead of discarding work intent after queue receipt creation; `roleResult` and `reorientResult` now require typed worker result documents; generic job summaries are lifecycle receipts, not reviewable findings; `epiphany-core::EpiphanyStateUpdate`, `epiphany_state_update_validation_errors`, and `apply_epiphany_state_update` own state-update document shape and mutation law while `codex-core` calls them for compatibility. Vendored `CodexThread::epiphany_launch_job` is now mostly revision check, persistence validation, launch request handoff, and rollout/session writeback for the compatibility route. Vendored `codex-core` no longer has `epiphany_distillation`, `epiphany_promotion`, `epiphany_proposal`, or `epiphany_retrieval` husk modules; it re-exports native surfaces from `epiphany-core` directly, and now owns only the real Codex rollout adapter because it is a host seam. `epiphany-runtime-spine` advertises OpenAI adapter status/model-request/stream-event/receipt CultNet schemas and mutation contracts, so the native caller now has a real storage route instead of only JSON-RPC. MCP itself is correctly JSON at its protocol edge; the target is typed Epiphany intent/result/receipt wrappers around MCP, not replacing MCP JSON. The current OpenAI request path no longer pulls `codex-api`; `codex-login` remains deliberately sealed behind `epiphany-openai-auth-spine`, and the next spine decision is another Epiphany-in-vendor evacuation surface unless a concrete typed auth-status/runtime need requires exposing external bearer or agent-identity auth. latest_cut: > - MCP route handling moved out of root `codex_message_processor.rs` into - `codex_message_processor/mcp_routes.rs`. MCP remains a JSON protocol edge; - this cut only stops the main app-server processor from owning refresh, - OAuth, status listing, resource read, tool-call, and thread-id metadata - shaping inline. The root processor is about 8,977 lines after the cut. + Codex account auth route handling moved out of root + `codex_message_processor.rs` into + `codex_message_processor/auth_routes.rs`, while staying inside vendored + Codex. API-key login, ChatGPT browser/device-code/external-token login, + logout, auth status, account, rate limits, and add-credits nudge handling + remain Codex-owned; the root processor is about 8,094 lines after the cut. next_action: Continue the Codex starvation campaign at the next Epiphany-in-vendor evacuation surface; keep vendored Codex code as host routing/lifecycle glue only, and do not cut the retained `codex-login` auth organ unless replacing it with an explicitly permitted OpenAI path rather than a lookalike. diff --git a/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs b/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs index f4e94067..e1dd4963 100644 --- a/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs +++ b/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs @@ -1,8 +1,10 @@ +mod auth_routes; mod epiphany_automation; mod epiphany_mutation_routes; mod epiphany_read_routes; mod mcp_routes; +use self::auth_routes::ActiveLogin; pub(crate) use self::epiphany_automation::maybe_run_epiphany_coordinator_automation_for_turn_boundary; pub(crate) use self::epiphany_automation::maybe_run_epiphany_pre_compaction_checkpoint_intervention_for_token_count; use crate::bespoke_event_handling::apply_bespoke_event_handling; @@ -365,46 +367,8 @@ struct ThreadListFilters { use_state_db_only: bool, } -// Duration before a browser ChatGPT login attempt is abandoned. -const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); -const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60); -enum ActiveLogin { - Browser { - shutdown_handle: ShutdownHandle, - login_id: Uuid, - }, - DeviceCode { - cancel: CancellationToken, - login_id: Uuid, - }, -} - -impl ActiveLogin { - fn login_id(&self) -> Uuid { - match self { - ActiveLogin::Browser { login_id, .. } | ActiveLogin::DeviceCode { login_id, .. } => { - *login_id - } - } - } - - fn cancel(&self) { - match self { - ActiveLogin::Browser { - shutdown_handle, .. - } => shutdown_handle.shutdown(), - ActiveLogin::DeviceCode { cancel, .. } => cancel.cancel(), - } - } -} - -#[derive(Clone, Copy, Debug)] -enum CancelLoginError { - NotFound, -} - enum ThreadShutdownResult { Complete, SubmitFailed, @@ -416,12 +380,6 @@ enum ThreadReadViewError { Internal(String), } -impl Drop for ActiveLogin { - fn drop(&mut self) { - self.cancel(); - } -} - /// Handles JSON-RPC messages for Codex threads (and legacy conversation APIs). pub(crate) struct CodexMessageProcessor { auth_manager: Arc, @@ -473,13 +431,6 @@ enum EnsureConversationListenerResult { ConnectionClosed, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RefreshTokenRequestOutcome { - NotAttemptedOrSucceeded, - FailedTransiently, - FailedPermanently, -} - struct UnloadingState { delay: Duration, has_subscribers_rx: watch::Receiver, @@ -1179,840 +1130,6 @@ impl CodexMessageProcessor { } } - async fn login_v2(&self, request_id: ConnectionRequestId, params: LoginAccountParams) { - match params { - LoginAccountParams::ApiKey { api_key } => { - self.login_api_key_v2(request_id, LoginApiKeyParams { api_key }) - .await; - } - LoginAccountParams::Chatgpt => { - self.login_chatgpt_v2(request_id).await; - } - LoginAccountParams::ChatgptDeviceCode => { - self.login_chatgpt_device_code_v2(request_id).await; - } - LoginAccountParams::ChatgptAuthTokens { - access_token, - chatgpt_account_id, - chatgpt_plan_type, - } => { - self.login_chatgpt_auth_tokens( - request_id, - access_token, - chatgpt_account_id, - chatgpt_plan_type, - ) - .await; - } - } - } - - fn external_auth_active_error(&self) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it." - .to_string(), - data: None, - } - } - - async fn login_api_key_common( - &self, - params: &LoginApiKeyParams, - ) -> std::result::Result<(), JSONRPCErrorError> { - if self.auth_manager.is_external_chatgpt_auth_active() { - return Err(self.external_auth_active_error()); - } - - if matches!( - self.config.forced_login_method, - Some(ForcedLoginMethod::Chatgpt) - ) { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "API key login is disabled. Use ChatGPT login instead.".to_string(), - data: None, - }); - } - - // Cancel any active login attempt. - { - let mut guard = self.active_login.lock().await; - if let Some(active) = guard.take() { - drop(active); - } - } - - match login_with_api_key( - &self.config.codex_home, - ¶ms.api_key, - self.config.cli_auth_credentials_store_mode, - ) { - Ok(()) => { - self.auth_manager.reload(); - Ok(()) - } - Err(err) => Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to save api key: {err}"), - data: None, - }), - } - } - - async fn login_api_key_v2(&self, request_id: ConnectionRequestId, params: LoginApiKeyParams) { - match self.login_api_key_common(¶ms).await { - Ok(()) => { - let response = codex_app_server_protocol::LoginAccountResponse::ApiKey {}; - self.outgoing.send_response(request_id, response).await; - - let payload_login_completed = AccountLoginCompletedNotification { - login_id: None, - success: true, - error: None, - }; - self.outgoing - .send_server_notification(ServerNotification::AccountLoginCompleted( - payload_login_completed, - )) - .await; - - self.outgoing - .send_server_notification(ServerNotification::AccountUpdated( - self.current_account_updated_notification(), - )) - .await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } - } - - // Build options for a ChatGPT login attempt; performs validation. - async fn login_chatgpt_common( - &self, - ) -> std::result::Result { - let config = self.config.as_ref(); - - if self.auth_manager.is_external_chatgpt_auth_active() { - return Err(self.external_auth_active_error()); - } - - if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "ChatGPT login is disabled. Use API key login instead.".to_string(), - data: None, - }); - } - - let opts = LoginServerOptions { - open_browser: false, - ..LoginServerOptions::new( - config.codex_home.to_path_buf(), - CLIENT_ID.to_string(), - config.forced_chatgpt_workspace_id.clone(), - config.cli_auth_credentials_store_mode, - ) - }; - #[cfg(debug_assertions)] - let opts = { - let mut opts = opts; - if let Ok(issuer) = std::env::var(LOGIN_ISSUER_OVERRIDE_ENV_VAR) - && !issuer.trim().is_empty() - { - opts.issuer = issuer; - } - opts - }; - - Ok(opts) - } - - fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError { - let is_not_found = err.kind() == std::io::ErrorKind::NotFound; - JSONRPCErrorError { - code: if is_not_found { - INVALID_REQUEST_ERROR_CODE - } else { - INTERNAL_ERROR_CODE - }, - message: if is_not_found { - err.to_string() - } else { - format!("failed to request device code: {err}") - }, - data: None, - } - } - - async fn login_chatgpt_v2(&self, request_id: ConnectionRequestId) { - match self.login_chatgpt_common().await { - Ok(opts) => match run_login_server(opts) { - Ok(server) => { - let login_id = Uuid::new_v4(); - let shutdown_handle = server.cancel_handle(); - - // Replace active login if present. - { - let mut guard = self.active_login.lock().await; - if let Some(existing) = guard.take() { - drop(existing); - } - *guard = Some(ActiveLogin::Browser { - shutdown_handle: shutdown_handle.clone(), - login_id, - }); - } - - // Spawn background task to monitor completion. - let outgoing_clone = self.outgoing.clone(); - let active_login = self.active_login.clone(); - let auth_manager = self.auth_manager.clone(); - let config_manager = self.config_manager.clone(); - let chatgpt_base_url = self.config.chatgpt_base_url.clone(); - let auth_url = server.auth_url.clone(); - tokio::spawn(async move { - let (success, error_msg) = match tokio::time::timeout( - LOGIN_CHATGPT_TIMEOUT, - server.block_until_done(), - ) - .await - { - Ok(Ok(())) => (true, None), - Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), - Err(_elapsed) => { - shutdown_handle.shutdown(); - (false, Some("Login timed out".to_string())) - } - }; - - let payload_v2 = AccountLoginCompletedNotification { - login_id: Some(login_id.to_string()), - success, - error: error_msg, - }; - outgoing_clone - .send_server_notification(ServerNotification::AccountLoginCompleted( - payload_v2, - )) - .await; - - if success { - auth_manager.reload(); - config_manager.replace_cloud_requirements_loader( - auth_manager.clone(), - chatgpt_base_url, - ); - config_manager - .sync_default_client_residency_requirement() - .await; - - // Notify clients with the actual current auth mode. - let auth = auth_manager.auth_cached(); - let payload_v2 = AccountUpdatedNotification { - auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), - plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), - }; - outgoing_clone - .send_server_notification(ServerNotification::AccountUpdated( - payload_v2, - )) - .await; - } - - // Clear the active login if it matches this attempt. It may have been replaced or cancelled. - let mut guard = active_login.lock().await; - if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { - *guard = None; - } - }); - - let response = codex_app_server_protocol::LoginAccountResponse::Chatgpt { - login_id: login_id.to_string(), - auth_url, - }; - self.outgoing.send_response(request_id, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to start login server: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - }, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - } - } - } - - async fn login_chatgpt_device_code_v2(&self, request_id: ConnectionRequestId) { - match self.login_chatgpt_common().await { - Ok(opts) => match request_device_code(&opts).await { - Ok(device_code) => { - let login_id = Uuid::new_v4(); - let cancel = CancellationToken::new(); - - { - let mut guard = self.active_login.lock().await; - if let Some(existing) = guard.take() { - drop(existing); - } - *guard = Some(ActiveLogin::DeviceCode { - cancel: cancel.clone(), - login_id, - }); - } - - let verification_url = device_code.verification_url.clone(); - let user_code = device_code.user_code.clone(); - let response = - codex_app_server_protocol::LoginAccountResponse::ChatgptDeviceCode { - login_id: login_id.to_string(), - verification_url, - user_code, - }; - self.outgoing.send_response(request_id, response).await; - - let outgoing_clone = self.outgoing.clone(); - let active_login = self.active_login.clone(); - let auth_manager = self.auth_manager.clone(); - let config_manager = self.config_manager.clone(); - let chatgpt_base_url = self.config.chatgpt_base_url.clone(); - tokio::spawn(async move { - let (success, error_msg) = tokio::select! { - _ = cancel.cancelled() => { - (false, Some("Login was not completed".to_string())) - } - r = complete_device_code_login(opts, device_code) => { - match r { - Ok(()) => (true, None), - Err(err) => (false, Some(err.to_string())), - } - } - }; - - let payload_v2 = AccountLoginCompletedNotification { - login_id: Some(login_id.to_string()), - success, - error: error_msg, - }; - outgoing_clone - .send_server_notification(ServerNotification::AccountLoginCompleted( - payload_v2, - )) - .await; - - if success { - auth_manager.reload(); - config_manager.replace_cloud_requirements_loader( - auth_manager.clone(), - chatgpt_base_url, - ); - config_manager - .sync_default_client_residency_requirement() - .await; - - let auth = auth_manager.auth_cached(); - let payload_v2 = AccountUpdatedNotification { - auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), - plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), - }; - outgoing_clone - .send_server_notification(ServerNotification::AccountUpdated( - payload_v2, - )) - .await; - } - - let mut guard = active_login.lock().await; - if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { - *guard = None; - } - }); - } - Err(err) => { - let error = Self::login_chatgpt_device_code_start_error(err); - self.outgoing.send_error(request_id, error).await; - } - }, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - } - } - } - - async fn cancel_login_chatgpt_common( - &self, - login_id: Uuid, - ) -> std::result::Result<(), CancelLoginError> { - let mut guard = self.active_login.lock().await; - if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { - if let Some(active) = guard.take() { - drop(active); - } - Ok(()) - } else { - Err(CancelLoginError::NotFound) - } - } - - async fn cancel_login_v2( - &self, - request_id: ConnectionRequestId, - params: CancelLoginAccountParams, - ) { - let login_id = params.login_id; - match Uuid::parse_str(&login_id) { - Ok(uuid) => { - let status = match self.cancel_login_chatgpt_common(uuid).await { - Ok(()) => CancelLoginAccountStatus::Canceled, - Err(CancelLoginError::NotFound) => CancelLoginAccountStatus::NotFound, - }; - let response = CancelLoginAccountResponse { status }; - self.outgoing.send_response(request_id, response).await; - } - Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid login id: {login_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn login_chatgpt_auth_tokens( - &self, - request_id: ConnectionRequestId, - access_token: String, - chatgpt_account_id: String, - chatgpt_plan_type: Option, - ) { - if matches!( - self.config.forced_login_method, - Some(ForcedLoginMethod::Api) - ) { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "External ChatGPT auth is disabled. Use API key login instead." - .to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - // Cancel any active login attempt to avoid persisting managed auth state. - { - let mut guard = self.active_login.lock().await; - if let Some(active) = guard.take() { - drop(active); - } - } - - if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() - && chatgpt_account_id != expected_workspace - { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}." - ), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - if let Err(err) = login_with_chatgpt_auth_tokens( - &self.config.codex_home, - &access_token, - &chatgpt_account_id, - chatgpt_plan_type.as_deref(), - ) { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to set external auth: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - self.auth_manager.reload(); - self.config_manager.replace_cloud_requirements_loader( - self.auth_manager.clone(), - self.config.chatgpt_base_url.clone(), - ); - self.config_manager - .sync_default_client_residency_requirement() - .await; - - self.outgoing - .send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {}) - .await; - - let payload_login_completed = AccountLoginCompletedNotification { - login_id: None, - success: true, - error: None, - }; - self.outgoing - .send_server_notification(ServerNotification::AccountLoginCompleted( - payload_login_completed, - )) - .await; - - self.outgoing - .send_server_notification(ServerNotification::AccountUpdated( - self.current_account_updated_notification(), - )) - .await; - } - - async fn logout_common(&self) -> std::result::Result, JSONRPCErrorError> { - // Cancel any active login attempt. - { - let mut guard = self.active_login.lock().await; - if let Some(active) = guard.take() { - drop(active); - } - } - - match self.auth_manager.logout_with_revoke().await { - Ok(_) => {} - Err(err) => { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("logout failed: {err}"), - data: None, - }); - } - } - - // Reflect the current auth method after logout (likely None). - Ok(self - .auth_manager - .auth_cached() - .as_ref() - .map(CodexAuth::api_auth_mode)) - } - - async fn logout_v2(&self, request_id: ConnectionRequestId) { - match self.logout_common().await { - Ok(current_auth_method) => { - self.outgoing - .send_response(request_id, LogoutAccountResponse {}) - .await; - - let payload_v2 = AccountUpdatedNotification { - auth_mode: current_auth_method, - plan_type: None, - }; - self.outgoing - .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) - .await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome { - if self.auth_manager.is_external_chatgpt_auth_active() { - return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded; - } - if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { - let failed_reason = err.failed_reason(); - if failed_reason.is_none() { - tracing::warn!("failed to refresh token while getting account: {err}"); - return RefreshTokenRequestOutcome::FailedTransiently; - } - return RefreshTokenRequestOutcome::FailedPermanently; - } - RefreshTokenRequestOutcome::NotAttemptedOrSucceeded - } - - async fn get_auth_status(&self, request_id: ConnectionRequestId, params: GetAuthStatusParams) { - let include_token = params.include_token.unwrap_or(false); - let do_refresh = params.refresh_token.unwrap_or(false); - - self.refresh_token_if_requested(do_refresh).await; - - // Determine whether auth is required based on the active model provider. - // If a custom provider is configured with `requires_openai_auth == false`, - // then no auth step is required; otherwise, default to requiring auth. - let requires_openai_auth = self.config.model_provider.requires_openai_auth; - - let response = if !requires_openai_auth { - GetAuthStatusResponse { - auth_method: None, - auth_token: None, - requires_openai_auth: Some(false), - } - } else { - let auth = if do_refresh { - self.auth_manager.auth_cached() - } else { - self.auth_manager.auth().await - }; - match auth { - Some(auth) => { - let permanent_refresh_failure = - self.auth_manager.refresh_failure_for_auth(&auth).is_some(); - let auth_mode = auth.api_auth_mode(); - let (reported_auth_method, token_opt) = - if matches!(auth, CodexAuth::AgentIdentity(_)) - || include_token && permanent_refresh_failure - { - (Some(auth_mode), None) - } else { - match auth.get_token() { - Ok(token) if !token.is_empty() => { - let tok = if include_token { Some(token) } else { None }; - (Some(auth_mode), tok) - } - Ok(_) => (None, None), - Err(err) => { - tracing::warn!("failed to get token for auth status: {err}"); - (None, None) - } - } - }; - GetAuthStatusResponse { - auth_method: reported_auth_method, - auth_token: token_opt, - requires_openai_auth: Some(true), - } - } - None => GetAuthStatusResponse { - auth_method: None, - auth_token: None, - requires_openai_auth: Some(true), - }, - } - }; - - self.outgoing.send_response(request_id, response).await; - } - - async fn get_account(&self, request_id: ConnectionRequestId, params: GetAccountParams) { - let do_refresh = params.refresh_token; - - self.refresh_token_if_requested(do_refresh).await; - - // Whether auth is required for the active model provider. - let requires_openai_auth = self.config.model_provider.requires_openai_auth; - - if !requires_openai_auth { - let response = GetAccountResponse { - account: None, - requires_openai_auth, - }; - self.outgoing.send_response(request_id, response).await; - return; - } - - let account = match self.auth_manager.auth_cached() { - Some(auth) => match auth.auth_mode() { - CoreAuthMode::ApiKey => Some(Account::ApiKey {}), - CoreAuthMode::Chatgpt - | CoreAuthMode::ChatgptAuthTokens - | CoreAuthMode::AgentIdentity => { - let email = auth.get_account_email(); - let plan_type = auth.account_plan_type(); - - match (email, plan_type) { - (Some(email), Some(plan_type)) => { - Some(Account::Chatgpt { email, plan_type }) - } - _ => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: - "email and plan type are required for chatgpt authentication" - .to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - } - } - }, - None => None, - }; - - let response = GetAccountResponse { - account, - requires_openai_auth, - }; - self.outgoing.send_response(request_id, response).await; - } - - async fn get_account_rate_limits(&self, request_id: ConnectionRequestId) { - match self.fetch_account_rate_limits().await { - Ok((rate_limits, rate_limits_by_limit_id)) => { - let response = GetAccountRateLimitsResponse { - rate_limits: rate_limits.into(), - rate_limits_by_limit_id: Some( - rate_limits_by_limit_id - .into_iter() - .map(|(limit_id, snapshot)| (limit_id, snapshot.into())) - .collect(), - ), - }; - self.outgoing.send_response(request_id, response).await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn send_add_credits_nudge_email( - &self, - request_id: ConnectionRequestId, - params: SendAddCreditsNudgeEmailParams, - ) { - match self.send_add_credits_nudge_email_inner(params).await { - Ok(status) => { - self.outgoing - .send_response(request_id, SendAddCreditsNudgeEmailResponse { status }) - .await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn send_add_credits_nudge_email_inner( - &self, - params: SendAddCreditsNudgeEmailParams, - ) -> Result { - let Some(auth) = self.auth_manager.auth().await else { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "codex account authentication required to notify workspace owner" - .to_string(), - data: None, - }); - }; - - if !auth.is_chatgpt_auth() { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "chatgpt authentication required to notify workspace owner".to_string(), - data: None, - }); - } - - let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to construct backend client: {err}"), - data: None, - })?; - - match client - .send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type)) - .await - { - Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent), - Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => { - Ok(AddCreditsNudgeEmailStatus::CooldownActive) - } - Err(err) => Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to notify workspace owner: {err}"), - data: None, - }), - } - } - - fn backend_credit_type(value: AddCreditsNudgeCreditType) -> BackendAddCreditsNudgeCreditType { - match value { - AddCreditsNudgeCreditType::Credits => BackendAddCreditsNudgeCreditType::Credits, - AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit, - } - } - - async fn fetch_account_rate_limits( - &self, - ) -> Result< - ( - CoreRateLimitSnapshot, - HashMap, - ), - JSONRPCErrorError, - > { - let Some(auth) = self.auth_manager.auth().await else { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "codex account authentication required to read rate limits".to_string(), - data: None, - }); - }; - - if !auth.is_chatgpt_auth() { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "chatgpt authentication required to read rate limits".to_string(), - data: None, - }); - } - - let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to construct backend client: {err}"), - data: None, - })?; - - let snapshots = client - .get_rate_limits_many() - .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to fetch codex rate limits: {err}"), - data: None, - })?; - if snapshots.is_empty() { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "failed to fetch codex rate limits: no snapshots returned".to_string(), - data: None, - }); - } - - let rate_limits_by_limit_id: HashMap = snapshots - .iter() - .cloned() - .map(|snapshot| { - let limit_id = snapshot - .limit_id - .clone() - .unwrap_or_else(|| "codex".to_string()); - (limit_id, snapshot) - }) - .collect(); - - let primary = snapshots - .iter() - .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex")) - .cloned() - .unwrap_or_else(|| snapshots[0].clone()); - - Ok((primary, rate_limits_by_limit_id)) - } - async fn exec_one_off_command( &self, request_id: ConnectionRequestId, diff --git a/vendor/codex/codex-rs/app-server/src/codex_message_processor/auth_routes.rs b/vendor/codex/codex-rs/app-server/src/codex_message_processor/auth_routes.rs new file mode 100644 index 00000000..b8967649 --- /dev/null +++ b/vendor/codex/codex-rs/app-server/src/codex_message_processor/auth_routes.rs @@ -0,0 +1,901 @@ +use super::*; + +// Duration before a browser ChatGPT login attempt is abandoned. +const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; + +pub(super) enum ActiveLogin { + Browser { + shutdown_handle: ShutdownHandle, + login_id: Uuid, + }, + DeviceCode { + cancel: CancellationToken, + login_id: Uuid, + }, +} + +impl ActiveLogin { + fn login_id(&self) -> Uuid { + match self { + ActiveLogin::Browser { login_id, .. } | ActiveLogin::DeviceCode { login_id, .. } => { + *login_id + } + } + } + + fn cancel(&self) { + match self { + ActiveLogin::Browser { + shutdown_handle, .. + } => shutdown_handle.shutdown(), + ActiveLogin::DeviceCode { cancel, .. } => cancel.cancel(), + } + } +} + +#[derive(Clone, Copy, Debug)] +enum CancelLoginError { + NotFound, +} + +impl Drop for ActiveLogin { + fn drop(&mut self) { + self.cancel(); + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RefreshTokenRequestOutcome { + NotAttemptedOrSucceeded, + FailedTransiently, + FailedPermanently, +} + +impl CodexMessageProcessor { + pub(super) async fn login_v2( + &self, + request_id: ConnectionRequestId, + params: LoginAccountParams, + ) { + match params { + LoginAccountParams::ApiKey { api_key } => { + self.login_api_key_v2(request_id, LoginApiKeyParams { api_key }) + .await; + } + LoginAccountParams::Chatgpt => { + self.login_chatgpt_v2(request_id).await; + } + LoginAccountParams::ChatgptDeviceCode => { + self.login_chatgpt_device_code_v2(request_id).await; + } + LoginAccountParams::ChatgptAuthTokens { + access_token, + chatgpt_account_id, + chatgpt_plan_type, + } => { + self.login_chatgpt_auth_tokens( + request_id, + access_token, + chatgpt_account_id, + chatgpt_plan_type, + ) + .await; + } + } + } + + fn external_auth_active_error(&self) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it." + .to_string(), + data: None, + } + } + + async fn login_api_key_common( + &self, + params: &LoginApiKeyParams, + ) -> std::result::Result<(), JSONRPCErrorError> { + if self.auth_manager.is_external_chatgpt_auth_active() { + return Err(self.external_auth_active_error()); + } + + if matches!( + self.config.forced_login_method, + Some(ForcedLoginMethod::Chatgpt) + ) { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "API key login is disabled. Use ChatGPT login instead.".to_string(), + data: None, + }); + } + + // Cancel any active login attempt. + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + drop(active); + } + } + + match login_with_api_key( + &self.config.codex_home, + ¶ms.api_key, + self.config.cli_auth_credentials_store_mode, + ) { + Ok(()) => { + self.auth_manager.reload(); + Ok(()) + } + Err(err) => Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to save api key: {err}"), + data: None, + }), + } + } + + async fn login_api_key_v2(&self, request_id: ConnectionRequestId, params: LoginApiKeyParams) { + match self.login_api_key_common(¶ms).await { + Ok(()) => { + let response = codex_app_server_protocol::LoginAccountResponse::ApiKey {}; + self.outgoing.send_response(request_id, response).await; + + let payload_login_completed = AccountLoginCompletedNotification { + login_id: None, + success: true, + error: None, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_login_completed, + )) + .await; + + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated( + self.current_account_updated_notification(), + )) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + // Build options for a ChatGPT login attempt; performs validation. + async fn login_chatgpt_common( + &self, + ) -> std::result::Result { + let config = self.config.as_ref(); + + if self.auth_manager.is_external_chatgpt_auth_active() { + return Err(self.external_auth_active_error()); + } + + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "ChatGPT login is disabled. Use API key login instead.".to_string(), + data: None, + }); + } + + let opts = LoginServerOptions { + open_browser: false, + ..LoginServerOptions::new( + config.codex_home.to_path_buf(), + CLIENT_ID.to_string(), + config.forced_chatgpt_workspace_id.clone(), + config.cli_auth_credentials_store_mode, + ) + }; + #[cfg(debug_assertions)] + let opts = { + let mut opts = opts; + if let Ok(issuer) = std::env::var(LOGIN_ISSUER_OVERRIDE_ENV_VAR) + && !issuer.trim().is_empty() + { + opts.issuer = issuer; + } + opts + }; + + Ok(opts) + } + + fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError { + let is_not_found = err.kind() == std::io::ErrorKind::NotFound; + JSONRPCErrorError { + code: if is_not_found { + INVALID_REQUEST_ERROR_CODE + } else { + INTERNAL_ERROR_CODE + }, + message: if is_not_found { + err.to_string() + } else { + format!("failed to request device code: {err}") + }, + data: None, + } + } + + async fn login_chatgpt_v2(&self, request_id: ConnectionRequestId) { + match self.login_chatgpt_common().await { + Ok(opts) => match run_login_server(opts) { + Ok(server) => { + let login_id = Uuid::new_v4(); + let shutdown_handle = server.cancel_handle(); + + // Replace active login if present. + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + drop(existing); + } + *guard = Some(ActiveLogin::Browser { + shutdown_handle: shutdown_handle.clone(), + login_id, + }); + } + + // Spawn background task to monitor completion. + let outgoing_clone = self.outgoing.clone(); + let active_login = self.active_login.clone(); + let auth_manager = self.auth_manager.clone(); + let config_manager = self.config_manager.clone(); + let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + let auth_url = server.auth_url.clone(); + tokio::spawn(async move { + let (success, error_msg) = match tokio::time::timeout( + LOGIN_CHATGPT_TIMEOUT, + server.block_until_done(), + ) + .await + { + Ok(Ok(())) => (true, None), + Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), + Err(_elapsed) => { + shutdown_handle.shutdown(); + (false, Some("Login timed out".to_string())) + } + }; + + let payload_v2 = AccountLoginCompletedNotification { + login_id: Some(login_id.to_string()), + success, + error: error_msg, + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_v2, + )) + .await; + + if success { + auth_manager.reload(); + config_manager.replace_cloud_requirements_loader( + auth_manager.clone(), + chatgpt_base_url, + ); + config_manager + .sync_default_client_residency_requirement() + .await; + + // Notify clients with the actual current auth mode. + let auth = auth_manager.auth_cached(); + let payload_v2 = AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountUpdated( + payload_v2, + )) + .await; + } + + // Clear the active login if it matches this attempt. It may have been replaced or cancelled. + let mut guard = active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + *guard = None; + } + }); + + let response = codex_app_server_protocol::LoginAccountResponse::Chatgpt { + login_id: login_id.to_string(), + auth_url, + }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to start login server: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + }, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } + } + + async fn login_chatgpt_device_code_v2(&self, request_id: ConnectionRequestId) { + match self.login_chatgpt_common().await { + Ok(opts) => match request_device_code(&opts).await { + Ok(device_code) => { + let login_id = Uuid::new_v4(); + let cancel = CancellationToken::new(); + + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + drop(existing); + } + *guard = Some(ActiveLogin::DeviceCode { + cancel: cancel.clone(), + login_id, + }); + } + + let verification_url = device_code.verification_url.clone(); + let user_code = device_code.user_code.clone(); + let response = + codex_app_server_protocol::LoginAccountResponse::ChatgptDeviceCode { + login_id: login_id.to_string(), + verification_url, + user_code, + }; + self.outgoing.send_response(request_id, response).await; + + let outgoing_clone = self.outgoing.clone(); + let active_login = self.active_login.clone(); + let auth_manager = self.auth_manager.clone(); + let config_manager = self.config_manager.clone(); + let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + tokio::spawn(async move { + let (success, error_msg) = tokio::select! { + _ = cancel.cancelled() => { + (false, Some("Login was not completed".to_string())) + } + r = complete_device_code_login(opts, device_code) => { + match r { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + } + } + }; + + let payload_v2 = AccountLoginCompletedNotification { + login_id: Some(login_id.to_string()), + success, + error: error_msg, + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_v2, + )) + .await; + + if success { + auth_manager.reload(); + config_manager.replace_cloud_requirements_loader( + auth_manager.clone(), + chatgpt_base_url, + ); + config_manager + .sync_default_client_residency_requirement() + .await; + + let auth = auth_manager.auth_cached(); + let payload_v2 = AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountUpdated( + payload_v2, + )) + .await; + } + + let mut guard = active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + *guard = None; + } + }); + } + Err(err) => { + let error = Self::login_chatgpt_device_code_start_error(err); + self.outgoing.send_error(request_id, error).await; + } + }, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } + } + + async fn cancel_login_chatgpt_common( + &self, + login_id: Uuid, + ) -> std::result::Result<(), CancelLoginError> { + let mut guard = self.active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + if let Some(active) = guard.take() { + drop(active); + } + Ok(()) + } else { + Err(CancelLoginError::NotFound) + } + } + + pub(super) async fn cancel_login_v2( + &self, + request_id: ConnectionRequestId, + params: CancelLoginAccountParams, + ) { + let login_id = params.login_id; + match Uuid::parse_str(&login_id) { + Ok(uuid) => { + let status = match self.cancel_login_chatgpt_common(uuid).await { + Ok(()) => CancelLoginAccountStatus::Canceled, + Err(CancelLoginError::NotFound) => CancelLoginAccountStatus::NotFound, + }; + let response = CancelLoginAccountResponse { status }; + self.outgoing.send_response(request_id, response).await; + } + Err(_) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid login id: {login_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn login_chatgpt_auth_tokens( + &self, + request_id: ConnectionRequestId, + access_token: String, + chatgpt_account_id: String, + chatgpt_plan_type: Option, + ) { + if matches!( + self.config.forced_login_method, + Some(ForcedLoginMethod::Api) + ) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "External ChatGPT auth is disabled. Use API key login instead." + .to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + // Cancel any active login attempt to avoid persisting managed auth state. + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + drop(active); + } + } + + if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() + && chatgpt_account_id != expected_workspace + { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}." + ), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + if let Err(err) = login_with_chatgpt_auth_tokens( + &self.config.codex_home, + &access_token, + &chatgpt_account_id, + chatgpt_plan_type.as_deref(), + ) { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to set external auth: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + self.auth_manager.reload(); + self.config_manager.replace_cloud_requirements_loader( + self.auth_manager.clone(), + self.config.chatgpt_base_url.clone(), + ); + self.config_manager + .sync_default_client_residency_requirement() + .await; + + self.outgoing + .send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {}) + .await; + + let payload_login_completed = AccountLoginCompletedNotification { + login_id: None, + success: true, + error: None, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_login_completed, + )) + .await; + + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated( + self.current_account_updated_notification(), + )) + .await; + } + + async fn logout_common(&self) -> std::result::Result, JSONRPCErrorError> { + // Cancel any active login attempt. + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + drop(active); + } + } + + match self.auth_manager.logout_with_revoke().await { + Ok(_) => {} + Err(err) => { + return Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("logout failed: {err}"), + data: None, + }); + } + } + + // Reflect the current auth method after logout (likely None). + Ok(self + .auth_manager + .auth_cached() + .as_ref() + .map(CodexAuth::api_auth_mode)) + } + + pub(super) async fn logout_v2(&self, request_id: ConnectionRequestId) { + match self.logout_common().await { + Ok(current_auth_method) => { + self.outgoing + .send_response(request_id, LogoutAccountResponse {}) + .await; + + let payload_v2 = AccountUpdatedNotification { + auth_mode: current_auth_method, + plan_type: None, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome { + if self.auth_manager.is_external_chatgpt_auth_active() { + return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded; + } + if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { + let failed_reason = err.failed_reason(); + if failed_reason.is_none() { + tracing::warn!("failed to refresh token while getting account: {err}"); + return RefreshTokenRequestOutcome::FailedTransiently; + } + return RefreshTokenRequestOutcome::FailedPermanently; + } + RefreshTokenRequestOutcome::NotAttemptedOrSucceeded + } + + pub(super) async fn get_auth_status( + &self, + request_id: ConnectionRequestId, + params: GetAuthStatusParams, + ) { + let include_token = params.include_token.unwrap_or(false); + let do_refresh = params.refresh_token.unwrap_or(false); + + self.refresh_token_if_requested(do_refresh).await; + + // Determine whether auth is required based on the active model provider. + // If a custom provider is configured with `requires_openai_auth == false`, + // then no auth step is required; otherwise, default to requiring auth. + let requires_openai_auth = self.config.model_provider.requires_openai_auth; + + let response = if !requires_openai_auth { + GetAuthStatusResponse { + auth_method: None, + auth_token: None, + requires_openai_auth: Some(false), + } + } else { + let auth = if do_refresh { + self.auth_manager.auth_cached() + } else { + self.auth_manager.auth().await + }; + match auth { + Some(auth) => { + let permanent_refresh_failure = + self.auth_manager.refresh_failure_for_auth(&auth).is_some(); + let auth_mode = auth.api_auth_mode(); + let (reported_auth_method, token_opt) = + if matches!(auth, CodexAuth::AgentIdentity(_)) + || include_token && permanent_refresh_failure + { + (Some(auth_mode), None) + } else { + match auth.get_token() { + Ok(token) if !token.is_empty() => { + let tok = if include_token { Some(token) } else { None }; + (Some(auth_mode), tok) + } + Ok(_) => (None, None), + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + (None, None) + } + } + }; + GetAuthStatusResponse { + auth_method: reported_auth_method, + auth_token: token_opt, + requires_openai_auth: Some(true), + } + } + None => GetAuthStatusResponse { + auth_method: None, + auth_token: None, + requires_openai_auth: Some(true), + }, + } + }; + + self.outgoing.send_response(request_id, response).await; + } + + pub(super) async fn get_account( + &self, + request_id: ConnectionRequestId, + params: GetAccountParams, + ) { + let do_refresh = params.refresh_token; + + self.refresh_token_if_requested(do_refresh).await; + + // Whether auth is required for the active model provider. + let requires_openai_auth = self.config.model_provider.requires_openai_auth; + + if !requires_openai_auth { + let response = GetAccountResponse { + account: None, + requires_openai_auth, + }; + self.outgoing.send_response(request_id, response).await; + return; + } + + let account = match self.auth_manager.auth_cached() { + Some(auth) => match auth.auth_mode() { + CoreAuthMode::ApiKey => Some(Account::ApiKey {}), + CoreAuthMode::Chatgpt + | CoreAuthMode::ChatgptAuthTokens + | CoreAuthMode::AgentIdentity => { + let email = auth.get_account_email(); + let plan_type = auth.account_plan_type(); + + match (email, plan_type) { + (Some(email), Some(plan_type)) => { + Some(Account::Chatgpt { email, plan_type }) + } + _ => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: + "email and plan type are required for chatgpt authentication" + .to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + } + } + }, + None => None, + }; + + let response = GetAccountResponse { + account, + requires_openai_auth, + }; + self.outgoing.send_response(request_id, response).await; + } + + pub(super) async fn get_account_rate_limits(&self, request_id: ConnectionRequestId) { + match self.fetch_account_rate_limits().await { + Ok((rate_limits, rate_limits_by_limit_id)) => { + let response = GetAccountRateLimitsResponse { + rate_limits: rate_limits.into(), + rate_limits_by_limit_id: Some( + rate_limits_by_limit_id + .into_iter() + .map(|(limit_id, snapshot)| (limit_id, snapshot.into())) + .collect(), + ), + }; + self.outgoing.send_response(request_id, response).await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + pub(super) async fn send_add_credits_nudge_email( + &self, + request_id: ConnectionRequestId, + params: SendAddCreditsNudgeEmailParams, + ) { + match self.send_add_credits_nudge_email_inner(params).await { + Ok(status) => { + self.outgoing + .send_response(request_id, SendAddCreditsNudgeEmailResponse { status }) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn send_add_credits_nudge_email_inner( + &self, + params: SendAddCreditsNudgeEmailParams, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "codex account authentication required to notify workspace owner" + .to_string(), + data: None, + }); + }; + + if !auth.is_chatgpt_auth() { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "chatgpt authentication required to notify workspace owner".to_string(), + data: None, + }); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to construct backend client: {err}"), + data: None, + })?; + + match client + .send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type)) + .await + { + Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent), + Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => { + Ok(AddCreditsNudgeEmailStatus::CooldownActive) + } + Err(err) => Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to notify workspace owner: {err}"), + data: None, + }), + } + } + + fn backend_credit_type(value: AddCreditsNudgeCreditType) -> BackendAddCreditsNudgeCreditType { + match value { + AddCreditsNudgeCreditType::Credits => BackendAddCreditsNudgeCreditType::Credits, + AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit, + } + } + + async fn fetch_account_rate_limits( + &self, + ) -> Result< + ( + CoreRateLimitSnapshot, + HashMap, + ), + JSONRPCErrorError, + > { + let Some(auth) = self.auth_manager.auth().await else { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "codex account authentication required to read rate limits".to_string(), + data: None, + }); + }; + + if !auth.is_chatgpt_auth() { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "chatgpt authentication required to read rate limits".to_string(), + data: None, + }); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to construct backend client: {err}"), + data: None, + })?; + + let snapshots = client + .get_rate_limits_many() + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to fetch codex rate limits: {err}"), + data: None, + })?; + if snapshots.is_empty() { + return Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "failed to fetch codex rate limits: no snapshots returned".to_string(), + data: None, + }); + } + + let rate_limits_by_limit_id: HashMap = snapshots + .iter() + .cloned() + .map(|snapshot| { + let limit_id = snapshot + .limit_id + .clone() + .unwrap_or_else(|| "codex".to_string()); + (limit_id, snapshot) + }) + .collect(); + + let primary = snapshots + .iter() + .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex")) + .cloned() + .unwrap_or_else(|| snapshots[0].clone()); + + Ok((primary, rate_limits_by_limit_id)) + } +}