diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7a03efc60c..b233fa3431 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2216,6 +2216,7 @@ dependencies = [ "codex-core", "codex-exec-server", "codex-feedback", + "codex-login", "codex-protocol", "codex-uds", "codex-utils-absolute-path", diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index 6d9201276c..9568426f0c 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -19,6 +19,7 @@ codex-app-server-transport = { workspace = true } codex-arg0 = { workspace = true } codex-cloud-client = { path = "../cloud-client" } codex-config = { workspace = true } +codex-login = { workspace = true } codex-core = { workspace = true } codex-exec-server = { workspace = true } codex-feedback = { workspace = true } diff --git a/codex-rs/app-server-client/src/cloud_backend/auth.rs b/codex-rs/app-server-client/src/cloud_backend/auth.rs new file mode 100644 index 0000000000..d637bbb755 --- /dev/null +++ b/codex-rs/app-server-client/src/cloud_backend/auth.rs @@ -0,0 +1,53 @@ +//! Opens the internal Cloud preview with the selected ChatGPT credentials and local transport policy. + +use super::start_worker; +use crate::AppServerClient; +use crate::legacy_core::config::Config; +use codex_app_server_protocol as protocol; +use codex_cloud_client as cloud; +use codex_login::token_data::parse_jwt_expiration; +use codex_protocol::account::PlanType; +use color_eyre::eyre::Result; +use color_eyre::eyre::bail; +use color_eyre::eyre::eyre; + +const ORIGIN: &str = "https://codex-cloud-backend.chatgpt.com"; + +pub async fn connect(config: &Config) -> Result { + // The preview borrows Local's policy without owning credential updates. + let factory = config.http_client_factory(); + let policy = factory.network_policy().clone().for_current_account(); + let factory = factory.with_network_policy(policy); + let auth = config + .auth_config() + .load_auth(/*enable_codex_api_key_env*/ false) + .await? + .ok_or_else(|| eyre!("Sign in with ChatGPT and reopen Codex to use Cloud."))?; + if !auth.is_chatgpt_auth() || auth.is_fedramp_account() { + bail!("This account is not supported by the cloud conversation preview."); + } + let account_id = auth + .get_account_id() + .ok_or_else(|| eyre!("Cloud requires a selected account."))?; + let token = auth.get_token()?; + if let Ok(Some(expires)) = parse_jwt_expiration(&token) + && std::time::SystemTime::from(expires) <= std::time::SystemTime::now() + { + bail!("Your ChatGPT credential expired. Reopen Codex to reconnect."); + } + factory.network_policy().acquire(&ORIGIN.parse()?)?; + let credentials = cloud::Credentials::new(&token, &account_id)?; + let client = cloud::Client::new(&factory, ORIGIN, credentials)?; + Ok(start_worker( + client, + protocol::GetAccountResponse { + account: Some(protocol::Account::Chatgpt { + email: auth.get_account_email(), + plan_type: auth.account_plan_type().unwrap_or(PlanType::Unknown), + workspace_name: None, + }), + requires_openai_auth: true, + workspace_routing: None, + }, + )) +} diff --git a/codex-rs/app-server-client/src/cloud_backend/mod.rs b/codex-rs/app-server-client/src/cloud_backend/mod.rs index 8ac8c6ec2e..225a1f9c6c 100644 --- a/codex-rs/app-server-client/src/cloud_backend/mod.rs +++ b/codex-rs/app-server-client/src/cloud_backend/mod.rs @@ -1,6 +1,7 @@ //! Adapts native Cloud requests and the currently open conversation’s reply stream. //! A replacement becomes active only after its native attachment opens successfully. +mod auth; mod events; mod requests; @@ -19,6 +20,8 @@ use futures::stream::BoxStream; use serde_json::Value; use tokio::sync::mpsc; +pub use auth::connect; + type ReplyStream = BoxStream<'static, Result>; /// Rejected locally before an admission request can be sent. @@ -144,7 +147,7 @@ impl Replies { } /// Opens an app-server client backed by the authenticated native Cloud client. -pub fn connect(client: cloud::Client, account: protocol::GetAccountResponse) -> AppServerClient { +fn start_worker(client: cloud::Client, account: protocol::GetAccountResponse) -> AppServerClient { let (app_client, commands, output) = CloudClient::channel(/*capacity*/ 32); let mut requests = requests::Requests { client, diff --git a/codex-rs/cloud-client/src/activation.rs b/codex-rs/cloud-client/src/activation.rs index 8a46ceacde..dd50201230 100644 --- a/codex-rs/cloud-client/src/activation.rs +++ b/codex-rs/cloud-client/src/activation.rs @@ -79,6 +79,7 @@ pub struct ThreadSnapshot { pub thread_source: Option, pub agent_nickname: Option, pub agent_role: Option, + pub git_info: Option, pub name: Option, pub environments: Option>, /// Activation deliberately excludes turns; callers page history separately. @@ -87,6 +88,15 @@ pub struct ThreadSnapshot { raw_protobuf: Bytes, } +/// Optional repository metadata associated with a cloud conversation. +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitInfo { + pub branch: Option, + pub origin_url: Option, + pub sha: Option, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum ThreadHistoryMode { @@ -343,6 +353,11 @@ impl ThreadSnapshot { thread_source: thread.thread_source, agent_nickname: thread.agent_nickname, agent_role: thread.agent_role, + git_info: thread.git_info.map(|git_info| GitInfo { + branch: git_info.branch, + origin_url: git_info.origin_url, + sha: git_info.sha, + }), name: thread.name, environments: thread .environments diff --git a/codex-rs/cloud-client/src/activation_wire.rs b/codex-rs/cloud-client/src/activation_wire.rs index 63f7314849..75b977354b 100644 --- a/codex-rs/cloud-client/src/activation_wire.rs +++ b/codex-rs/cloud-client/src/activation_wire.rs @@ -203,6 +203,8 @@ pub(super) struct Thread { pub ephemeral: Option, #[prost(string, optional, tag = "11")] pub forked_from_id: Option, + #[prost(message, optional, tag = "12")] + pub git_info: Option, #[prost(int32, optional, tag = "13")] pub history_mode: Option, #[prost(string, optional, tag = "14")] @@ -245,6 +247,23 @@ impl fmt::Debug for Thread { } } +#[derive(Clone, Message)] +#[prost(skip_debug)] +pub(super) struct GitInfo { + #[prost(string, optional, tag = "1")] + pub branch: Option, + #[prost(string, optional, tag = "2")] + pub origin_url: Option, + #[prost(string, optional, tag = "3")] + pub sha: Option, +} + +impl fmt::Debug for GitInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("GitInfo([redacted])") + } +} + #[derive(Clone, Message)] pub(super) struct Environments { #[prost(message, repeated, tag = "1")] diff --git a/codex-rs/cloud-client/src/discovery_tests.rs b/codex-rs/cloud-client/src/discovery_tests.rs index 2a2fcf1f7c..b58eb9df7b 100644 --- a/codex-rs/cloud-client/src/discovery_tests.rs +++ b/codex-rs/cloud-client/src/discovery_tests.rs @@ -48,8 +48,11 @@ async fn discovery_preserves_pagination_and_order_on_each_request() { #[tokio::test] async fn discovery_preserves_native_snapshots_and_read_state() { - // Independent native Thread encodings: modern future field (99) and legacy empty metadata. - let thread = Bytes::from_static(b"\x22\x01v\x28\x01\x32\x01/\x48\x00\x72\x01t\x82\x01\x01p\xaa\x01\x00\xda\x01\x01s\xe2\x01\x02\x08\x63\xea\x01\x02\x12\x00\xfa\x01\x00\x80\x02\x02\x98\x06\x01"); + // Independent native Thread encodings: Git metadata and future field (99), and a legacy row. + let thread = Bytes::from_static( + b"\x22\x01v\x28\x01\x32\x01/\x48\x00\x62\x28\x0a\x04main\x12\x18https://example.com/repo\x1a\x06abc123\ + \x72\x01t\x82\x01\x01p\xaa\x01\x00\xda\x01\x01s\xe2\x01\x02\x08\x63\xea\x01\x02\x12\x00\xfa\x01\x00\x80\x02\x02\x98\x06\x01", + ); let legacy = Bytes::from_static(b"\x22\x00\x28\x00\x32\x01/\x48\x00\x72\x06legacy\x82\x01\x00\xaa\x01\x00\xda\x01\x00\xe2\x01\x02\x08\x01\xea\x01\x02\x12\x00\xfa\x01\x00\x80\x02\x00"); let mut data = Vec::new(); for row in [&thread, &legacy, &thread] { @@ -81,6 +84,12 @@ async fn discovery_preserves_native_snapshots_and_read_state() { ), ("legacy", "", "", "") ); + let page_json = serde_json::to_value(&page).unwrap(); + assert_eq!( + page_json["data"][0]["gitInfo"], + serde_json::json!({"branch": "main", "originUrl": "https://example.com/repo", "sha": "abc123"}) + ); + assert!(page.data[1].git_info.is_none()); assert_eq!(page.data[0].status, ThreadStatus::Idle); assert_eq!( page.read_states, diff --git a/codex-rs/cloud-client/src/lib.rs b/codex-rs/cloud-client/src/lib.rs index 7ca207c1e3..c6b35c337e 100644 --- a/codex-rs/cloud-client/src/lib.rs +++ b/codex-rs/cloud-client/src/lib.rs @@ -24,6 +24,7 @@ pub use activation::ApprovalPolicy; pub use activation::ApprovalsReviewer; pub use activation::CollaborationMode; pub use activation::CollaborationSettings; +pub use activation::GitInfo; pub use activation::NetworkAccess; pub use activation::SandboxPolicy; pub use activation::SessionSource; diff --git a/codex-rs/tui/src/app/backend_sessions.rs b/codex-rs/tui/src/app/backend_sessions.rs index fa378dacef..9966b9813a 100644 --- a/codex-rs/tui/src/app/backend_sessions.rs +++ b/codex-rs/tui/src/app/backend_sessions.rs @@ -2,9 +2,12 @@ //! Polling is fair and cancellation-safe; closing one stream never closes another. use super::resume_preparation::BackendSettings; +use crate::AppServerTarget; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerSessionEvent; use crate::backend_id::BackendId; +use crate::legacy_core::config::Config; +use crate::local_settings::LocalSettings; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::ServerNotification; use std::collections::HashMap; @@ -12,10 +15,13 @@ use std::collections::VecDeque; use std::future::Future; use std::task::Poll; +type BackendSettingsById = HashMap; + /// The initial conventional session and any additional sessions registered at launch. pub(crate) struct InitialBackendSessions { initial: AppServerSession, others: Vec, + settings: BackendSettingsById, } impl InitialBackendSessions { @@ -23,24 +29,32 @@ impl InitialBackendSessions { Self { initial, others: Vec::new(), + settings: HashMap::new(), } } - #[allow( - dead_code, - reason = "used by the under-development backend registration path" - )] - pub(crate) fn multiple(initial: AppServerSession, others: Vec) -> Self { - assert!( - others - .iter() - .all(|session| session.backend_id() != initial.backend_id()) + pub(crate) fn register( + &mut self, + connection: AppServerSession, + target: AppServerTarget, + config: Config, + local_settings: LocalSettings, + ) { + self.settings.insert( + connection.backend_id(), + BackendSettings { + target, + config, + local_settings, + }, ); - Self { initial, others } + self.others.push(connection); } - pub(super) fn into_parts(self) -> (AppServerSession, Vec) { - (self.initial, self.others) + pub(super) fn into_parts( + self, + ) -> (AppServerSession, Vec, BackendSettingsById) { + (self.initial, self.others, self.settings) } } @@ -123,11 +137,12 @@ impl BackendSessions { .collect() } - pub(super) fn displayed_name(&self) -> Option { + pub(super) fn displayed_name(&self, target: &AppServerTarget) -> Option { let slot = self.displayed.0; - self.entries[slot].listening.then(|| match slot { - 0 => "Local".to_string(), - index => format!("Connection {}", index + 1), + self.entries[slot].listening.then(|| match (target, slot) { + (AppServerTarget::Cloud, _) => "Cloud".to_string(), + (_, 0) => "Local".to_string(), + (_, index) => format!("Connection {}", index + 1), }) } diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 21216fec23..44577744c1 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -254,6 +254,10 @@ impl App { /// app-server RPC finishes. User-initiated skills refreshes still use the blocking app command path so /// callers that explicitly asked for fresh skill state do not race ahead of their own refresh. pub(super) fn refresh_startup_skills(&mut self, app_server: &AppServerSession) { + if app_server.thread_params_mode() == crate::ThreadParamsMode::Cloud { + self.skill_load_warnings.startup_complete = true; + return; + } let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); let cwd = self.config.cwd.to_path_buf(); diff --git a/codex-rs/tui/src/app/daemon_menu.rs b/codex-rs/tui/src/app/daemon_menu.rs index ada9e88080..920f1485e2 100644 --- a/codex-rs/tui/src/app/daemon_menu.rs +++ b/codex-rs/tui/src/app/daemon_menu.rs @@ -50,7 +50,10 @@ impl App { .map(|connection| format!("Running daemon: {}", connection.version)) .unwrap_or_else(|| "Not connected to the local background server.".to_string()); let mut header = vec![Line::from("Daemon".bold()), Line::from(status.dim())]; - let unavailable = if matches!(self.app_server_target, AppServerTarget::Remote { .. }) { + let unavailable = if matches!( + self.app_server_target, + AppServerTarget::Remote { .. } | AppServerTarget::Cloud + ) { Some( "Manage this server on its host. Local daemon updates are unavailable for remote connections.", ) @@ -160,7 +163,10 @@ impl App { let Some(executable) = &self.daemon_cli_executable else { return; }; - if matches!(self.app_server_target, AppServerTarget::Remote { .. }) { + if matches!( + self.app_server_target, + AppServerTarget::Remote { .. } | AppServerTarget::Cloud + ) { return; } let mut explanation = match source { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 273f7710de..2a9c394e60 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -221,7 +221,8 @@ impl App { } AppEvent::CloseMisalignmentReview => self.chat_widget.show_misalignment_policy_precaution(), AppEvent::SkillsListLoaded { ref cwd, .. } - if cwds_differ(cwd, self.config.cwd.as_path()) => + if app_server.thread_params_mode() == crate::ThreadParamsMode::Cloud + || cwds_differ(cwd, self.config.cwd.as_path()) => { self.skill_load_warnings.startup_complete = true; } @@ -1291,6 +1292,9 @@ impl App { AppEvent::ImagesPrepared(id) => { self.chat_widget.on_images_prepared(id); } + // The Cloud preview does not have a skills catalog. + AppEvent::CodexOp(AppCommand::ListSkills { .. }) + if app_server.thread_params_mode() == crate::ThreadParamsMode::Cloud => {} AppEvent::CodexOp(AppCommand::ListSkills { cwds, force_reload: true }) if self.awaiting_side_replay(tui) && let Some(thread_id) = self.active_thread_id diff --git a/codex-rs/tui/src/app/exit_summary.rs b/codex-rs/tui/src/app/exit_summary.rs index 4cc843d9b4..3b3c8268ea 100644 --- a/codex-rs/tui/src/app/exit_summary.rs +++ b/codex-rs/tui/src/app/exit_summary.rs @@ -26,7 +26,7 @@ impl App { let thread_id = self.chat_widget.thread_id().or(self.primary_thread_id); let disconnect_info = thread_id.and_then(|_| { let command = match &self.app_server_target { - AppServerTarget::Embedded => return None, + AppServerTarget::Embedded | AppServerTarget::Cloud => return None, AppServerTarget::LocalDaemon { .. } => vec!["codex".to_string()], AppServerTarget::Remote { endpoint } => { let address = match endpoint { diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index dc854cc7c5..d6a7078caa 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -43,6 +43,9 @@ pub(super) async fn reconnect( task_tools: ThreadToolTransport, presentation: ReconnectPresentation, ) -> Result { + if matches!(target, AppServerTarget::Cloud) { + color_eyre::eyre::bail!("Reopen Codex to reconnect the cloud backend."); + } let mode = target.thread_params_mode(); if matches!(target, AppServerTarget::Embedded) { color_eyre::eyre::bail!("in-process sessions have no connection to restore"); diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index 835db96986..ccbf24ba08 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -98,6 +98,7 @@ impl App { let uses_remote_workspace_or_environment = crate::uses_remote_workspace_or_environment(&target, &self.environment_manager); if uses_remote_workspace_or_environment + && !matches!(target, AppServerTarget::Cloud) && self.harness_overrides.cwd.is_none() && app_server.remote_cwd_override().is_none() && matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 37ee228f9f..434fe9027e 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -16,6 +16,7 @@ use crate::session_start::SessionStartOutcome; use crate::session_start::cancel_session_start; use crate::session_start::complete_session_start; use crate::unarchive_prompt::run_unarchive_prompt; +use color_eyre::eyre::ContextCompat; fn spawn_startup_thread_start( app_server: &AppServerSession, @@ -61,7 +62,7 @@ pub(super) async fn prepare_fresh_startup_config( ) -> Result { let defaults_cwd = match app_server_target { AppServerTarget::Embedded | AppServerTarget::LocalDaemon { .. } => config.cwd.as_path(), - AppServerTarget::Remote { .. } => { + AppServerTarget::Remote { .. } | AppServerTarget::Cloud => { app_server.remote_cwd_override().unwrap_or(Path::new(".")) } }; @@ -96,22 +97,26 @@ pub(super) fn startup_model( config: &Config, bootstrap: &AppServerBootstrap, server_defaults_read: bool, -) -> String { - config.model.clone().unwrap_or_else(|| { - if server_defaults_read { - // Legacy bootstrap paths may be seeded with local config, which can differ from a - // cleared server model. Use the server catalog when config/read returned model: null. - bootstrap - .available_models - .iter() - .find(|model| model.is_default) - .or_else(|| bootstrap.available_models.first()) - .map(|model| model.model.clone()) - .unwrap_or_else(|| bootstrap.default_model.clone()) - } else { - bootstrap.default_model.clone() - } - }) +) -> Result { + config + .model + .clone() + .or_else(|| { + if server_defaults_read { + // Legacy bootstrap paths may be seeded with local config, which can differ from a + // cleared server model. Use the server catalog when config/read returned model: null. + bootstrap + .available_models + .iter() + .find(|model| model.is_default) + .or_else(|| bootstrap.available_models.first()) + .map(|model| model.model.clone()) + .or_else(|| bootstrap.default_model.clone()) + } else { + bootstrap.default_model.clone() + } + }) + .wrap_err("The initial app-server connection did not provide a startup model.") } impl App { @@ -299,7 +304,7 @@ impl App { exit_info } - let (mut app_server, mut other_backends) = initial_backends.into_parts(); + let (mut app_server, mut other_backends, backend_settings) = initial_backends.into_parts(); // Preserve singleton error propagation while releasing all sessions on multi-backend exits. macro_rules! startup_try { ($result:expr) => { @@ -447,7 +452,7 @@ impl App { &harness_overrides, ); } - let mut model = startup_model(&config, &bootstrap, fresh_start); + let mut model = startup_try!(startup_model(&config, &bootstrap, fresh_start)); let available_models = bootstrap.available_models; let mut remote_connection = crate::status::remote_connection::remote_connection_status_value( @@ -1310,6 +1315,12 @@ See the Codex keymap documentation for supported actions and examples." ) { backends.set_preference_key(backends.displayed_owner(), "local"); } + for (&owner, settings) in &backend_settings { + if settings.target == AppServerTarget::Cloud { + backends.set_preference_key(owner, "cloud"); + } + } + backends.settings = backend_settings; let mut parked_side_completions = HashMap::<_, Vec>::new(); let exit_reason_result = if let Some(exit_reason) = pre_loop_exit_reason { @@ -1330,7 +1341,8 @@ See the Codex keymap documentation for supported actions and examples." } else { None }; - let current_label = next_backend.and_then(|_| backends.displayed_name()); + let current_label = + next_backend.and_then(|_| backends.displayed_name(&app.app_server_target)); app.chat_widget.set_new_backend_control(current_label); if !backends.is_listening(displayed_owner) && !app.pending_startup_thread_start diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index ecbb4d79e7..3ea516e55b 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -43,6 +43,44 @@ use tokio_tungstenite::tungstenite::Error as WebSocketError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::error::ProtocolError; +#[tokio::test] +async fn cloud_skips_skills_refresh_and_ignores_stale_local_result() -> Result<()> { + use futures::FutureExt; + + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + let id = ThreadId::new(); + let cwd = app.config.cwd.to_path_buf(); + app.enqueue_primary_thread_session(test_thread_session(id, cwd.clone()), Vec::new()) + .await?; + let refresh = std::iter::from_fn(|| ops.try_recv().ok()) + .find(|op| matches!(op, Op::ListSkills { .. })) + .expect("session configuration queues a skills refresh"); + + let (client, mut commands, _events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 1); + let mut server = AppServerSession::new( + crate::backend_id::BackendId::new(), + codex_app_server_client::AppServerClient::Cloud(client), + crate::ThreadParamsMode::Cloud, + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + while events.try_recv().is_ok() {} + for event in [ + AppEvent::CodexOp(refresh), + AppEvent::SkillsListLoaded { + cwd, + result: Err("stale local skills refresh".into()), + }, + ] { + Box::pin(app.handle_event(&mut tui, &mut server, event)) + .now_or_never() + .expect("Cloud should not wait for a skills response")?; + } + assert!(events.is_empty()); + assert!(commands.try_recv().is_err()); + Ok(()) +} + #[tokio::test] async fn cloud_enter_recovers_drafts_from_server_receipts() -> Result<()> { use codex_app_server_client::CloudOperation; @@ -1447,6 +1485,49 @@ async fn make_history_test_app() -> Result<(Box, tempfile::TempDir)> { Ok((app, codex_home)) } +#[tokio::test] +async fn cloud_resume_with_current_directory_needs_no_cwd_override() -> Result<()> { + use codex_config::types::ResumeCwdMode; + + let (mut app, codex_home) = make_history_test_app().await?; + std::fs::write( + codex_home.path().join("config.toml"), + "[tui]\nresume_cwd = 'current'\n", + )?; + app.launch_cwd = codex_home.path().to_path_buf(); + let mut backend_settings = app.backend_settings(); + backend_settings.target = AppServerTarget::Cloud; + let (client, _commands, _events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 1); + let mut server = AppServerSession::new( + crate::backend_id::BackendId::new(), + codex_app_server_client::AppServerClient::Cloud(client), + crate::app_server_session::ThreadParamsMode::Cloud, + ); + let session = SessionTarget { + backend_id: server.backend_id(), + path: None, + thread_id: ThreadId::new(), + cwd: Some(test_path_buf("/workspace")), + history_mode: None, + }; + let mut tui = crate::tui::test_support::make_test_tui()?; + + let (config, local_settings) = Box::pin(app.resume_config_for_target( + &mut tui, + &mut server, + &session, + super::super::resume_preparation::ResumeSource::Backend(&backend_settings), + )) + .await + .map_err(|_| color_eyre::eyre::eyre!("Cloud resume should allow its own workspace"))?; + assert_eq!( + (config.cwd, local_settings.tui.resume_cwd), + (app.config.cwd.clone(), Some(ResumeCwdMode::Current)) + ); + Ok(()) +} + #[tokio::test] async fn in_app_remote_resume_picker_lists_all_providers_after_provider_switch() -> Result<()> { let (mut app, _codex_home) = make_history_test_app().await?; diff --git a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs index ad001134f1..b95fffbd50 100644 --- a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs @@ -665,7 +665,10 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res AppServerTarget::Embedded }; let bootstrap = server.bootstrap(&config).await?; - assert_eq!(bootstrap.default_model, "stale-client-model"); + assert_eq!( + bootstrap.default_model.as_deref(), + Some("stale-client-model") + ); let defaults_read = prepare_fresh_startup_config( &mut config, &server, @@ -677,7 +680,8 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res .await?; assert!(!defaults_read.prompt_windows_sandbox); assert_eq!(config.model, None); - let selected_model = startup_model(&config, &bootstrap, /*server_defaults_read*/ true); + let selected_model = + startup_model(&config, &bootstrap, /*server_defaults_read*/ true)?; assert_ne!(selected_model, "stale-client-model"); let started = crate::app_server_session::start_thread_with_request_handle( server.request_handle(), @@ -921,11 +925,18 @@ async fn startup_failure_shuts_down_every_initial_backend() -> Result<()> { crate::app_server_session::ThreadParamsMode::Embedded, ) }); + let mut initial_backends = InitialBackendSessions::singleton(first); + initial_backends.register( + second, + AppServerTarget::Embedded, + app.config.clone(), + app.local_settings.clone(), + ); let mut tui = crate::tui::test_support::make_test_tui()?; let (result, ()) = tokio::join!( run_startup_for_test( &mut tui, - InitialBackendSessions::multiple(first, vec![second]), + initial_backends, app.config.clone(), /*bootstrap*/ None, SessionSelection::StartFresh, diff --git a/codex-rs/tui/src/app_server_connection.rs b/codex-rs/tui/src/app_server_connection.rs index 31d6f528d2..cfb203e6a8 100644 --- a/codex-rs/tui/src/app_server_connection.rs +++ b/codex-rs/tui/src/app_server_connection.rs @@ -16,6 +16,9 @@ use codex_utils_absolute_path::AbsolutePathBuf; pub(crate) async fn connect(target: &AppServerTarget) -> color_eyre::Result { match target { + AppServerTarget::Cloud => { + color_eyre::eyre::bail!("Reopen Codex to reconnect the cloud backend."); + } AppServerTarget::Embedded => { color_eyre::eyre::bail!("embedded sessions have no remote connection") } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 49efbb5445..8676df1968 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -67,8 +67,6 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; -use codex_app_server_protocol::ModelListParams; -use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewThreadModelDefaults; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RequestId; @@ -159,7 +157,6 @@ use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::SubAgentSource; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; -use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use futures::FutureExt; @@ -328,7 +325,8 @@ pub(crate) struct AppServerBootstrap { /// with `has_chatgpt_account` to decide if a startup rate-limit prefetch /// should be fired. pub(crate) requires_openai_auth: bool, - pub(crate) default_model: String, + /// Cloud supplies its model when it starts or resumes a thread. + pub(crate) default_model: Option, pub(crate) feedback_audience: FeedbackAudience, pub(crate) has_chatgpt_account: bool, pub(crate) available_models: Vec, @@ -363,7 +361,7 @@ pub(crate) struct AppServerSession { pub(crate) thread_settings_update_supported: bool, default_model: Option, available_models: Vec, - prefetched_model_list: Option>>, + prefetched_model_list: Option>>>, daybreak_api_key_compat: Arc, managed_new_thread_defaults: Option, external_agent_config_import_id: Mutex>, @@ -810,24 +808,14 @@ impl AppServerSession { self.prefetched_model_list = Some(models); } - fn request_model_list(&mut self) -> impl Future> + use<> { + fn request_model_list(&mut self) -> impl Future>> + use<> { + let mode = self.thread_params_mode; let handle = self.request_handle(); let request_id = self.next_request_id(); async move { - handle - .request_typed(ClientRequest::ModelList { - request_id, - params: ModelListParams { - cursor: None, - limit: None, - include_hidden: Some(true), - model_provider: None, - }, - }) - .await - .map_err(|err| { - bootstrap_request_error("model/list failed during TUI bootstrap", err) - }) + models::list(mode, handle, request_id).await.map_err(|err| { + bootstrap_request_error("model/list failed during TUI bootstrap", err) + }) } } @@ -876,7 +864,7 @@ impl AppServerSession { None => Either::Right(self.request_model_list()), }; let requirements_request_id = self.next_request_id(); - let (models, requirements, collaboration_modes) = tokio::try_join!( + let (mut available_models, requirements, collaboration_modes) = tokio::try_join!( models, async { match self @@ -907,11 +895,6 @@ impl AppServerSession { }, async { Ok(crate::collaboration_modes::list(self.request_handle()).await) }, )?; - let mut available_models = models - .data - .into_iter() - .map(model_preset_from_api_model) - .collect::>(); if let Some(requirements) = &requirements { crate::service_tier_resolution::constrain_server_service_tiers( &mut available_models, @@ -955,9 +938,13 @@ impl AppServerSession { .managed_new_thread_defaults .as_ref() .and_then(|defaults| defaults.model.clone()), - }) - .wrap_err("No models are available. Set `model` explicitly or check your model catalog configuration.")?; - self.default_model = Some(default_model.clone()); + }); + if default_model.is_none() && self.thread_params_mode != ThreadParamsMode::Cloud { + color_eyre::eyre::bail!( + "No models are available. Set `model` explicitly or check your model catalog configuration." + ); + } + self.default_model.clone_from(&default_model); self.available_models = available_models.clone(); let account = SessionAccount::from(account); @@ -2993,6 +2980,7 @@ mod tests { use app_test_support::create_fake_paginated_rollout; use app_test_support::create_fake_rollout; use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::ThreadItemsListCursor; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; @@ -3017,6 +3005,7 @@ mod tests { use codex_utils_absolute_path::test_support::test_path_buf; use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; + use serde_json::json; use tempfile::TempDir; async fn build_config(temp_dir: &TempDir) -> Config { @@ -3192,9 +3181,10 @@ mod tests { } #[tokio::test] - async fn cloud_bootstrap_uses_server_models_without_config_requirements() -> Result<()> { + async fn cloud_bootstrap_waits_for_the_thread_to_supply_its_model() -> Result<()> { let codex_home = tempfile::tempdir()?; - let config = build_config(&codex_home).await; + let mut config = build_config(&codex_home).await; + config.model = Some("local-only-model".to_string()); let (client, mut commands, _events) = codex_app_server_client::CloudClient::channel(/*capacity*/ 3); let mut app_server = AppServerSession::new( @@ -3214,7 +3204,7 @@ mod tests { let (bootstrap, ()) = tokio::join!(app_server.bootstrap_with_account(&config, account), async { - for _ in 0..3 { + for _ in 0..2 { let command = commands.recv().await.expect("bootstrap request"); let codex_app_server_client::CloudOperation::Request(request) = command.operation @@ -3222,15 +3212,6 @@ mod tests { panic!("expected bootstrap request") }; let result = match *request { - ClientRequest::ModelList { .. } => { - Ok(serde_json::to_value(ModelListResponse { - data: vec![api_model_with_upgrade_retirement_at( - /*retirement_at*/ None, - )], - next_cursor: None, - }) - .expect("model response should serialize")) - } ClientRequest::ConfigRequirementsRead { .. } => Err(JSONRPCErrorError { code: JSONRPC_METHOD_NOT_FOUND, message: "method not found".to_string(), @@ -3246,8 +3227,8 @@ mod tests { }); let bootstrap = bootstrap?; - assert_ne!(config.model.as_deref(), Some("current-model")); - assert_eq!(bootstrap.default_model, "current-model"); + assert_eq!(bootstrap.default_model, None); + assert!(bootstrap.available_models.is_empty()); Ok(()) } @@ -4965,6 +4946,7 @@ mod tests { .expect("embedded resume response should map"); assert_eq!(started.session.permission_profile, read_only_profile); + let mut thread = response.thread.clone(); let mut empty_roots_response = response; empty_roots_response.runtime_workspace_roots = Vec::new(); let started = started_thread_from_resume_response( @@ -4975,6 +4957,67 @@ mod tests { .await .expect("resume response should map"); assert_eq!(started.session.runtime_workspace_roots, Vec::new()); + + // Cloud can filter every item in its newest page while older prompts remain. + thread.history_mode = ThreadHistoryMode::Paginated; + let mut turn = thread.turns.pop().unwrap(); + let prompt = turn.items.remove(0); + turn.items.clear(); + turn.items_view = codex_app_server_protocol::TurnItemsView::NotLoaded; + let (client, mut commands, _events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 1); + let mut server = AppServerSession::new( + crate::backend_id::BackendId::new(), + AppServerClient::Cloud(client), + ThreadParamsMode::Cloud, + ); + let worker = tokio::spawn(async move { + while let Some(command) = commands.recv().await { + let codex_app_server_client::CloudOperation::Request(request) = command.operation + else { + panic!("expected history request"); + }; + let reply = match *request { + ClientRequest::ThreadTurnsList { .. } => json!({"data": [turn]}), + ClientRequest::ThreadItemsList { params, .. } if params.cursor.is_none() => { + json!({"data": [], "nextCursor": "older"}) + } + ClientRequest::ThreadItemsList { params, .. } + if params.cursor == Some(ThreadItemsListCursor::Opaque("older".into())) => + { + assert_eq!(params.limit, Some(HISTORY_ITEM_PAGE_LIMIT)); + json!({"data": [{"turnId": turn.id, "item": prompt}], "nextCursor": "oldest"}) + } + request => panic!("unexpected history request: {request:?}"), + }; + command.response.send(Ok(Ok(reply))).unwrap(); + } + }); + let mut settings = LocalSettings::from(&config); + settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal; + settings.tui.terminal_resize_reflow_max_rows = Some(1); + server + .hydrate_initial_thread_history( + &mut thread, + /*turn_cursor*/ None, + /*item_cursor*/ None, + Some(&config), + Some(&settings), + HistoryHydrationScope::Initial, + ) + .await + .expect("Cloud history should load past a hidden page"); + let cells = crate::thread_transcript::thread_to_transcript_cells( + thread, + crate::thread_transcript::RawReasoningVisibility::Hidden, + /*config*/ None, + ); + let lines = cells[0].display_lines(/*width*/ 80); + insta::assert_snapshot!(lines.get(1).map(ToString::to_string).unwrap_or_default(), @"› hello from history"); + let next = server.begin_older_history_page(thread_id); + assert_eq!(next.as_deref(), Some("oldest")); + drop(server); + worker.await.unwrap(); } #[tokio::test] diff --git a/codex-rs/tui/src/app_server_session/collaboration_catalog_tests.rs b/codex-rs/tui/src/app_server_session/collaboration_catalog_tests.rs index 68bb21451d..c70d4c5347 100644 --- a/codex-rs/tui/src/app_server_session/collaboration_catalog_tests.rs +++ b/codex-rs/tui/src/app_server_session/collaboration_catalog_tests.rs @@ -73,7 +73,7 @@ async fn collaboration_catalog_is_optional_and_refetched_on_bootstrap() -> Resul let initial = session.bootstrap(&config).await?; assert_eq!( (initial.default_model, initial.collaboration_modes), - ("task-model".into(), vec![]) + (Some("task-model".into()), vec![]) ); let refreshed = session.bootstrap(&config).await?; assert_eq!( diff --git a/codex-rs/tui/src/app_server_session/history.rs b/codex-rs/tui/src/app_server_session/history.rs index 505b5b469d..2e529299ae 100644 --- a/codex-rs/tui/src/app_server_session/history.rs +++ b/codex-rs/tui/src/app_server_session/history.rs @@ -372,6 +372,19 @@ impl AppServerSession { ) .await?; if page.data.is_empty() { + state.next_item_cursor = advancing_cursor( + state.next_item_cursor.as_deref(), + page.next_cursor, + &mut state.seen_item_cursors, + ); + if self.thread_params_mode() == super::ThreadParamsMode::Cloud + && state.next_item_cursor.is_some() + { + // Unknown native items may fill a page; preserve its continuation + // and charge the requested page against the existing scan budget. + scanned_items = scanned_items.saturating_add(limit as usize); + continue; + } state.next_item_cursor = None; break; } diff --git a/codex-rs/tui/src/app_server_session/models.rs b/codex-rs/tui/src/app_server_session/models.rs index 361c1e9930..c504b78989 100644 --- a/codex-rs/tui/src/app_server_session/models.rs +++ b/codex-rs/tui/src/app_server_session/models.rs @@ -1,9 +1,12 @@ //! Fetch picker models without blocking the event loop and keep new-thread defaults in sync. use super::AppServerSession; +use super::ThreadParamsMode; use super::model_preset_from_api_model; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; @@ -24,28 +27,41 @@ impl AppServerSession { } pub(crate) fn fetch_models(&self, request_id: Uuid, app_event_tx: AppEventSender) { - let request_handle = self.request_handle(); + let models = list( + self.thread_params_mode, + self.request_handle(), + RequestId::String(format!("model-list-{request_id}")), + ); tokio::spawn(async move { - let result = request_handle - .request_typed::(ClientRequest::ModelList { - request_id: RequestId::String(format!("model-list-{request_id}")), - params: ModelListParams { - cursor: None, - limit: None, - include_hidden: Some(true), - model_provider: None, - }, - }) - .await - .map(|response| { - response - .data - .into_iter() - .map(model_preset_from_api_model) - .collect() - }) - .map_err(|err| err.to_string()); + let result = models.await.map_err(|err| err.to_string()); app_event_tx.send(AppEvent::ModelsLoaded { request_id, result }); }); } } + +pub(super) async fn list( + mode: ThreadParamsMode, + request_handle: AppServerRequestHandle, + request_id: RequestId, +) -> Result, TypedRequestError> { + if mode == ThreadParamsMode::Cloud { + // The internal preview uses the model returned by Cloud when the thread opens. + return Ok(Vec::new()); + } + let response: ModelListResponse = request_handle + .request_typed(ClientRequest::ModelList { + request_id, + params: ModelListParams { + cursor: None, + limit: None, + include_hidden: Some(true), + model_provider: None, + }, + }) + .await?; + Ok(response + .data + .into_iter() + .map(model_preset_from_api_model) + .collect()) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a19d50785c..93a3c64941 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -38,6 +38,7 @@ use codex_app_server_client::InProcessClientStartArgs; use codex_app_server_client::RemoteAppServerClient; use codex_app_server_client::RemoteAppServerConnectArgs; pub use codex_app_server_client::RemoteAppServerEndpoint; +use codex_app_server_client::cloud_backend; use codex_app_server_protocol::Account as AppServerAccount; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigWarningNotification; @@ -331,6 +332,7 @@ async fn start_embedded_app_server( #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum AppServerTarget { Embedded, + Cloud, LocalDaemon { endpoint: RemoteAppServerEndpoint, allow_embedded_fallback: bool, @@ -355,7 +357,7 @@ impl AppServerTarget { } pub(crate) fn uses_remote_workspace(&self) -> bool { - matches!(self, Self::Remote { .. }) + matches!(self, Self::Remote { .. } | Self::Cloud) } fn uses_embedded_network_policy(&self) -> bool { @@ -385,12 +387,13 @@ impl AppServerTarget { allow_embedded_fallback: false, .. } - | Self::Remote { .. } => factory, + | Self::Remote { .. } + | Self::Cloud => factory, } } fn auth_config_for_cloud_loader(&self, mut auth_config: AuthConfig) -> AuthConfig { - if self.uses_remote_workspace() { + if matches!(self, Self::Remote { .. }) { // Remove local auth restrictions before loading credentials for a remote // workspace; the remote app server enforces its own authentication policy. auth_config.forced_login_method = None; @@ -401,10 +404,10 @@ impl AppServerTarget { } fn thread_params_mode(&self) -> ThreadParamsMode { - if self.uses_remote_workspace() { - ThreadParamsMode::Remote - } else { - ThreadParamsMode::Embedded + match self { + Self::Embedded | Self::LocalDaemon { .. } => ThreadParamsMode::Embedded, + Self::Remote { .. } => ThreadParamsMode::Remote, + Self::Cloud => ThreadParamsMode::Cloud, } } } @@ -419,9 +422,9 @@ async fn init_state_db_for_app_server_target( .unwrap_or_else(|| config.sqlite_config().state_db_path()); std::io::Error::other(LocalStateDbStartupError::new(database_path, err)) }), - AppServerTarget::LocalDaemon { .. } | AppServerTarget::Remote { .. } => { - Ok(state_db::get_state_db(config).await) - } + AppServerTarget::LocalDaemon { .. } + | AppServerTarget::Remote { .. } + | AppServerTarget::Cloud => Ok(state_db::get_state_db(config).await), } } @@ -1242,6 +1245,7 @@ async fn run_ratatui_app( mut state_db: Option, environment_manager: Arc, embedded_network_policy: codex_app_server_client::EmbeddedNetworkPolicy, + cloud_preview_available: bool, managed_worktree: Option, daemon_startup_warning: Option, managed_daemon: bool, @@ -1331,6 +1335,7 @@ async fn run_ratatui_app( AppServerTarget::Embedded => "in_process", AppServerTarget::LocalDaemon { .. } => "local_daemon", AppServerTarget::Remote { .. } => "remote", + AppServerTarget::Cloud => "cloud", }); AppServerSession::new( backend_id, @@ -2233,11 +2238,42 @@ async fn run_ratatui_app( Ok(StartupHooksReviewOutcome::OpenHooksBrowser(data)) => Some(data), }; + let mut initial_backends = app::backend_sessions::InitialBackendSessions::singleton(app_server); + if cfg!(any(target_os = "macos", target_os = "linux")) + && config.features.enabled(Feature::TuiMultiBackend) + && cloud_preview_available + && !workload_identity_selected + && config + .config_layer_stack + .requirements_toml() + .config_server + .is_none() + { + // A daemon-backed resume can rebuild Config without the local transport handle. + let mut cloud_config = config.clone(); + embedded_network_policy.bind_config(&mut cloud_config); + match Box::pin(cloud_backend::connect(&cloud_config)).await { + Ok(client) => initial_backends.register( + AppServerSession::new( + backend_id::BackendId::new(), + client, + ThreadParamsMode::Cloud, + ), + AppServerTarget::Cloud, + cloud_config, + local_settings.clone(), + ), + Err(error) => tui.insert_history_lines(vec![ratatui::text::Line::from(format!( + "Could not add the Cloud connection. {error}" + ))]), + } + } + // Keep the large event-loop future out of the enclosing startup futures so session // transitions have enough stack headroom to rebuild configuration and the chat widget. let app_result = boxed_future!(App::run( &mut tui, - crate::app::backend_sessions::InitialBackendSessions::singleton(app_server), + initial_backends, config, current_cwd.to_path_buf(), backend_preference_context, @@ -2521,7 +2557,7 @@ fn should_show_provider_setup_wizard( let supported_target = match app_server_target { AppServerTarget::Embedded => true, AppServerTarget::LocalDaemon { .. } => feature == Feature::BedrockSetupWizard, - AppServerTarget::Remote { .. } => false, + AppServerTarget::Remote { .. } | AppServerTarget::Cloud => false, }; supported_target && should_show_login_screen(login_status, requires_openai_auth) diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index d2bf6ba9b2..7397feb331 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -374,6 +374,7 @@ pub(super) async fn run_main_inner( workload_identity_selected, std::env::var_os(codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR).as_deref(), )?; + let cloud_preview_available = app_server_target.uses_embedded_network_policy(); let remote_cwd_override = cli .cwd .clone() @@ -727,6 +728,7 @@ pub(super) async fn run_main_inner( } let selection_reason = match (&app_server_target, daemon_exclusion) { (AppServerTarget::Remote { .. }, _) => "explicit_remote", + (AppServerTarget::Cloud, _) => "cloud", _ if cli.agents_overview => "agents", _ if elevated_warning.is_some() => "elevated_windows", (_, Some("--no-daemon")) => "explicit_no_daemon", @@ -999,6 +1001,7 @@ pub(super) async fn run_main_inner( state_db, environment_manager, embedded_network_policy, + cloud_preview_available, managed_worktree.clone(), daemon_startup_warning, managed_daemon, diff --git a/codex-rs/tui/src/status/remote_connection.rs b/codex-rs/tui/src/status/remote_connection.rs index 2ee0395438..a08b3b760c 100644 --- a/codex-rs/tui/src/status/remote_connection.rs +++ b/codex-rs/tui/src/status/remote_connection.rs @@ -25,6 +25,14 @@ pub(crate) fn remote_connection_status_value( ) -> Option { let endpoint = match app_server_target { AppServerTarget::Embedded => return None, + AppServerTarget::Cloud => { + return Some(RemoteConnectionStatus { + address: "Cloud".into(), + version: server_version.unwrap_or("unknown").into(), + is_local_daemon: false, + is_managed_daemon: false, + }); + } AppServerTarget::LocalDaemon { endpoint, .. } | AppServerTarget::Remote { endpoint } => { endpoint } @@ -89,7 +97,7 @@ pub(crate) fn server_version_notice_key( server_version_notice(client, Some(server))?; let mut hasher = Sha256::new(); let endpoint = match target { - AppServerTarget::Embedded => None, + AppServerTarget::Embedded | AppServerTarget::Cloud => None, AppServerTarget::LocalDaemon { endpoint, .. } | AppServerTarget::Remote { endpoint } => { Some(endpoint) }