diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs index a1cc44a483..6737a3716e 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod bundled_runtime; mod maintenance; mod runtime_selection; +mod service_endpoints; mod services; mod update_backup; @@ -181,10 +182,12 @@ pub fn run() { // Release builds load the versioned LoopX Chat workspace that ships inside // the installed `loopx` release, so `loopx update` refreshes the frontend // and backend together instead of reusing a separately built asset bundle. + let endpoints = service_endpoints::ServiceEndpoints::allocate(!cfg!(dev)) + .expect("could not allocate LoopX loopback endpoints"); #[cfg(dev)] let web_origin = "http://127.0.0.1:5173".to_string(); #[cfg(not(dev))] - let web_origin = "http://127.0.0.1:8767/chat/".to_string(); + let web_origin = endpoints.workspace_origin(); let services = Arc::new(Mutex::new(None::)); let services_for_setup = Arc::clone(&services); let navigation_origin: Url = web_origin.parse().expect("valid desktop origin"); @@ -277,7 +280,7 @@ pub fn run() { } } } - match maintenance::start_services(&handle) { + match maintenance::start_services(&handle, &endpoints) { Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(200)); continue; @@ -524,20 +527,20 @@ mod tests { #[test] fn maintenance_acl_accepts_both_transports_only_on_the_app_origin() { use tauri::utils::acl::RemoteUrlPattern; - let page: tauri::Url = "http://127.0.0.1:8767/chat/".parse().unwrap(); + let page: tauri::Url = "http://127.0.0.1:49123/chat/".parse().unwrap(); let old: RemoteUrlPattern = page.to_string().parse().unwrap(); - assert!(!old.test(&"http://127.0.0.1:8767".parse().unwrap())); + assert!(!old.test(&"http://127.0.0.1:49123".parse().unwrap())); let pattern: RemoteUrlPattern = super::maintenance_origin(&page).parse().unwrap(); for allowed in [ - "http://127.0.0.1:8767", - "http://127.0.0.1:8767/chat/?goal=x", + "http://127.0.0.1:49123", + "http://127.0.0.1:49123/chat/?goal=x", ] { assert!(pattern.test(&allowed.parse().unwrap()), "{allowed}"); } for denied in [ - "http://127.0.0.1:8766/chat/", - "http://localhost:8767/chat/", - "https://127.0.0.1:8767/chat/", + "http://127.0.0.1:8767/chat/", + "http://localhost:49123/chat/", + "https://127.0.0.1:49123/chat/", "https://example.com/chat/", ] { assert!(!pattern.test(&denied.parse().unwrap()), "{denied}"); diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs index ed6ed401ce..2e412a4fea 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -762,7 +762,10 @@ fn automatic_app_update(app: &AppHandle) { // of the installed App. Diagnostics retain the failed check. } -pub fn start_services(app: &AppHandle) -> Result, String> { +pub fn start_services( + app: &AppHandle, + endpoints: &crate::service_endpoints::ServiceEndpoints, +) -> Result, String> { app.state::() .startup_started .get_or_init(Instant::now); @@ -783,7 +786,7 @@ pub fn start_services(app: &AppHandle) -> Result() .publish("connecting", json!({"service":service})); diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs b/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs new file mode 100644 index 0000000000..3f4f797bcf --- /dev/null +++ b/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs @@ -0,0 +1,85 @@ +use std::net::TcpListener; + +use crate::services::ServiceKind; + +/// A native window keeps one endpoint pair across runtime repairs. Release +/// windows own their services; Vite retains the CLI ports its proxy expects. +#[derive(Clone, Copy)] +pub(crate) struct ServiceEndpoints { + status: u16, + chat: u16, + pub isolated: bool, +} + +impl ServiceEndpoints { + pub fn allocate(isolated: bool) -> std::io::Result { + if !isolated { + return Ok(Self { + status: 8766, + chat: 8767, + isolated, + }); + } + // Keep both reservations until their distinct ports have been chosen. + // The CLI owns binding; an intervening listener fails closed at start. + let status = reserve_private_endpoint()?; + let chat = reserve_private_endpoint()?; + Ok(Self { + status: status.local_addr()?.port(), + chat: chat.local_addr()?.port(), + isolated, + }) + } + + pub fn port(self, kind: ServiceKind) -> u16 { + match kind { + ServiceKind::Status => self.status, + ServiceKind::Chat => self.chat, + } + } + + #[cfg(any(not(dev), test))] + pub fn workspace_origin(self) -> String { + format!("http://127.0.0.1:{}/chat/", self.chat) + } +} + +fn reserve_private_endpoint() -> std::io::Result { + // A host may customize its ephemeral range to include the CLI ports. + for _ in 0..8 { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + if ![8766, 8767].contains(&listener.local_addr()?.port()) { + return Ok(listener); + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "could not reserve a private LoopX endpoint outside the CLI ports", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vite_keeps_its_proxy_endpoints() { + let endpoints = ServiceEndpoints::allocate(false).unwrap(); + assert_eq!(endpoints.port(ServiceKind::Status), 8766); + assert_eq!(endpoints.port(ServiceKind::Chat), 8767); + assert!(!endpoints.isolated); + } + + #[test] + fn packaged_windows_do_not_use_shared_or_duplicate_endpoints() { + let endpoints = ServiceEndpoints::allocate(true).unwrap(); + assert!(endpoints.isolated); + assert_ne!(endpoints.status, endpoints.chat); + assert!(![8766, 8767].contains(&endpoints.status)); + assert!(![8766, 8767].contains(&endpoints.chat)); + assert_eq!( + endpoints.workspace_origin(), + format!("http://127.0.0.1:{}/chat/", endpoints.chat) + ); + } +} diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs index 2c62eb0540..25a3c276e7 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs @@ -1,3 +1,4 @@ +use crate::service_endpoints::ServiceEndpoints; use command_group::{CommandGroup, GroupChild}; use std::{ env, @@ -66,6 +67,11 @@ impl ServiceKind { } fn command_args(self) -> Vec { + self.command_args_on_port(self.port()) + } + + fn command_args_on_port(self, port: u16) -> Vec { + let port = port.to_string(); match self { Self::Status => vec![ "serve-status", @@ -73,7 +79,7 @@ impl ServiceKind { "--host", "127.0.0.1", "--port", - "8766", + &port, "--limit", "80", ], @@ -83,7 +89,7 @@ impl ServiceKind { "--host", "127.0.0.1", "--port", - "8767", + &port, "--no-open", ], } @@ -141,11 +147,12 @@ pub(crate) struct SelectedRuntime { impl ServiceSet { pub(crate) fn start( runtime: &SelectedRuntime, + endpoints: &ServiceEndpoints, progress: impl Fn(&[ServiceKind]) + Sync, ) -> Result { Self::collect(connect_all( SERVICE_KINDS, - |kind| connect(kind, runtime), + |kind| connect(kind, runtime, endpoints), progress, )) } @@ -231,10 +238,18 @@ fn connect_all( }) } -fn connect(kind: ServiceKind, runtime: &SelectedRuntime) -> ServiceOutcome { +fn connect( + kind: ServiceKind, + runtime: &SelectedRuntime, + endpoints: &ServiceEndpoints, +) -> ServiceOutcome { let mut owned = None; let mut healed = false; - let result = connect_service(kind, runtime, &mut owned, &mut healed); + let result = if endpoints.isolated { + connect_owned_service(kind, endpoints.port(kind), runtime, &mut owned) + } else { + connect_service(kind, runtime, &mut owned, &mut healed) + }; ServiceOutcome { owned, healed, @@ -242,6 +257,66 @@ fn connect(kind: ServiceKind, runtime: &SelectedRuntime) -> ServiceOutcome { } } +// Release windows never borrow a listener or restart a LaunchAgent. A matching +// revision alone cannot establish the listener's registry/process ownership. +fn connect_owned_service( + kind: ServiceKind, + port: u16, + runtime: &SelectedRuntime, + owned: &mut Option, +) -> Result<(), ServiceError> { + if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() { + return Err(ServiceError(format!( + "LoopX {} private endpoint on port {port} is already occupied; retry startup", + kind.label() + ))); + } + let mut command = Command::new(&runtime.executable); + configure_runtime_environment(&mut command); + command + .args(kind.command_args_on_port(port)) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let child = command.group_spawn().map_err(|error| { + ServiceError(format!( + "could not start private LoopX {}: {error}", + kind.label() + )) + })?; + *owned = Some(OwnedService { child }); + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + let child = &mut owned.as_mut().expect("owned service").child; + if child + .try_wait() + .map_err(|error| ServiceError(error.to_string()))? + .is_some() + { + return Err(ServiceError(format!( + "private LoopX {} exited before readiness on port {port}", + kind.label() + ))); + } + match probe_on_port(kind, port, runtime.identity.as_ref()) { + Probe::Matching => return Ok(()), + Probe::NotReady => return Err(ServiceError(format!( + "LoopX {} registry is invalid or unreadable on private port {port}; repair the registry and retry", + kind.label() + ))), + Probe::Foreign | Probe::Stale => return Err(ServiceError(format!( + "private LoopX {} reached an unexpected listener on port {port}; retry startup", + kind.label() + ))), + Probe::Unavailable | Probe::Unresponsive => thread::sleep(Duration::from_millis(100)), + } + } + Err(ServiceError(format!( + "private LoopX {} did not become ready on port {port}", + kind.label() + ))) +} + fn connect_service( kind: ServiceKind, runtime: &SelectedRuntime, @@ -1106,6 +1181,32 @@ mod tests { ); } + #[test] + fn private_endpoint_never_adopts_or_stops_an_existing_listener() { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut owned = None; + let runtime = SelectedRuntime { + executable: "not-used-for-an-occupied-endpoint".into(), + identity: None, + }; + let error = + connect_owned_service(ServiceKind::Chat, port, &runtime, &mut owned).unwrap_err(); + assert!(error.to_string().contains("already occupied")); + assert!(owned.is_none()); + assert!(TcpStream::connect(("127.0.0.1", port)).is_ok()); + } + + #[test] + fn private_commands_use_the_window_endpoint_and_keep_loopback_scope() { + for kind in SERVICE_KINDS { + let args = kind.command_args_on_port(49123); + assert!(args.windows(2).any(|pair| pair == ["--port", "49123"])); + assert!(args.windows(2).any(|pair| pair == ["--host", "127.0.0.1"])); + assert!(args.iter().any(|arg| arg == "--global-registry")); + } + } + #[test] fn service_commands_stay_loopback_and_global() { let status = ServiceKind::Status.command_args(); diff --git a/docs/architecture/rfcs/desktop-execution-frontends-v0.md b/docs/architecture/rfcs/desktop-execution-frontends-v0.md index 94ec4bc937..0aab6384d4 100644 --- a/docs/architecture/rfcs/desktop-execution-frontends-v0.md +++ b/docs/architecture/rfcs/desktop-execution-frontends-v0.md @@ -1049,6 +1049,23 @@ pass the shared conformance suite. ## Validation criteria +### Native service ownership + +Packaged native windows use their own loopback status/Chat endpoints and own +only the children they start. A ready service with the same source revision is +not proof of the same registry or App ownership. Window navigation and native +maintenance permissions must follow that exact Chat origin. A runtime repair +keeps the same window endpoints and starts both replacement children from the +one qualified selection; it must not rediscover the global CLI independently. +CLI services and Vite development retain their existing shared ports; packaged App startup no +longer borrows or restarts those services or their LaunchAgents. + +Qualify a packaged window while independent shared services are running: both +HTTP runtime identities and the rendered workspace belong to its selected +runtime; quit, runtime repair and reopen affect only its children. An occupied +private endpoint fails without adopting or terminating its listener. A source +checkout or passing helper test does not qualify this native process boundary. + ### Shared - one binding has at most one active executor and serialized user ingress;