From 248fce3870ef02e1399d3926b3c7b874064e434b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:46:45 +0800 Subject: [PATCH 1/3] fix(desktop): isolate packaged window service ownership Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../loopx-control-plane/src-tauri/src/lib.rs | 21 ++-- .../src-tauri/src/maintenance.rs | 7 +- .../src-tauri/src/service_endpoints.rs | 71 +++++++++++ .../src-tauri/src/services.rs | 111 +++++++++++++++++- .../rfcs/desktop-execution-frontends-v0.md | 15 +++ 5 files changed, 208 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs 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 3bcf2bac4e..97a7d9fee0 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod bundled_runtime; mod maintenance; +mod service_endpoints; mod services; mod update_backup; @@ -180,10 +181,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"); @@ -276,7 +279,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; @@ -506,20 +509,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 34828b8f36..f91dbbbaab 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -732,7 +732,10 @@ fn resume_runtime(app: &AppHandle) -> Result<(), String> { } Ok(()) } -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); @@ -749,7 +752,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..0350e00bfd --- /dev/null +++ b/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs @@ -0,0 +1,71 @@ +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 = TcpListener::bind(("127.0.0.1", 0))?; + let chat = TcpListener::bind(("127.0.0.1", 0))?; + 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) + } +} + +#[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 a187b027c5..ad0c0e1e6c 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", ], } @@ -133,8 +139,15 @@ pub struct ServiceSet { } impl ServiceSet { - pub fn start(progress: impl Fn(&[ServiceKind]) + Sync) -> Result { - Self::collect(connect_all(SERVICE_KINDS, connect, progress)) + pub fn start( + endpoints: &ServiceEndpoints, + progress: impl Fn(&[ServiceKind]) + Sync, + ) -> Result { + Self::collect(connect_all( + SERVICE_KINDS, + |kind| connect(kind, endpoints), + progress, + )) } /// Fold finished connection attempts into one owned set. Every outcome @@ -218,10 +231,14 @@ fn connect_all( }) } -fn connect(kind: ServiceKind) -> ServiceOutcome { +fn connect(kind: ServiceKind, endpoints: &ServiceEndpoints) -> ServiceOutcome { let mut owned = None; let mut healed = false; - let result = connect_service(kind, &mut owned, &mut healed); + let result = if endpoints.isolated { + connect_owned_service(kind, endpoints.port(kind), &mut owned) + } else { + connect_service(kind, &mut owned, &mut healed) + }; ServiceOutcome { owned, healed, @@ -229,6 +246,67 @@ fn connect(kind: ServiceKind) -> 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, + 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 executable = loopx_executable(); + let identity = runtime_identity_for_executable(&executable); + let mut command = Command::new(&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, 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, owned: &mut Option, @@ -989,6 +1067,27 @@ fn classify_response( mod tests { use super::*; + #[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 error = connect_owned_service(ServiceKind::Chat, port, &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 8fd09be6a4..ab231b2398 100644 --- a/docs/architecture/rfcs/desktop-execution-frontends-v0.md +++ b/docs/architecture/rfcs/desktop-execution-frontends-v0.md @@ -1029,6 +1029,21 @@ 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. 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; From 9161eaca0748fc872345b1570b43be3fde3e2f5b Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:02:06 +0800 Subject: [PATCH 2/3] fix(desktop): exclude shared CLI ports from private allocation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../src-tauri/src/service_endpoints.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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 index 0350e00bfd..3f4f797bcf 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs @@ -22,8 +22,8 @@ impl ServiceEndpoints { } // Keep both reservations until their distinct ports have been chosen. // The CLI owns binding; an intervening listener fails closed at start. - let status = TcpListener::bind(("127.0.0.1", 0))?; - let chat = TcpListener::bind(("127.0.0.1", 0))?; + let status = reserve_private_endpoint()?; + let chat = reserve_private_endpoint()?; Ok(Self { status: status.local_addr()?.port(), chat: chat.local_addr()?.port(), @@ -44,6 +44,20 @@ impl ServiceEndpoints { } } +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::*; From 5cb566583bf97f8d3a3a0e71c82c211c57878604 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:47:58 +0800 Subject: [PATCH 3/3] docs(desktop): specify runtime repair ownership Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/architecture/rfcs/desktop-execution-frontends-v0.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/architecture/rfcs/desktop-execution-frontends-v0.md b/docs/architecture/rfcs/desktop-execution-frontends-v0.md index c122be7891..0aab6384d4 100644 --- a/docs/architecture/rfcs/desktop-execution-frontends-v0.md +++ b/docs/architecture/rfcs/desktop-execution-frontends-v0.md @@ -1054,8 +1054,10 @@ pass the shared conformance suite. 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. CLI services and -Vite development retain their existing shared ports; packaged App startup no +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