diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index cb34a8b457..33ddd47d1b 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -350,6 +350,40 @@ cd .. npm run build ``` +To qualify the native supervisor against an actual installed Core, first build +the runtime bundle as above with `LOOPX_USAGE_PING=0`, then run from `src-tauri`: + +```sh +qa_root="$(mktemp -d)" +mkdir "$qa_root/state" +printf '%s\n' '{"goals":[]}' > "$qa_root/state/registry.global.json" +( + set -e + export LOOPX_USAGE_PING=0 + export LOOPX_REGISTRY="$qa_root/state/registry.global.json" + export LOOPX_RUNTIME_ROOT="$qa_root/state" + export LOOPX_TEST_PRIVATE_RUNTIME="$qa_root/runtime" + export LOOPX_TEST_BUNDLE="$PWD/../runtime" + cargo test --lib real_private_installer_keeps_the_default_cli_owner -- --ignored --nocapture + cargo test --lib real_private_runtime_serves_and_rejects_stale_identity -- --ignored --nocapture +) +``` + +Both opt-in tests require the telemetry opt-out before invoking Core. A +disposable registry does not isolate machine-wide usage reporting; this +synthetic run must not contribute adoption or installation telemetry. The +opt-out applies only to this test invocation and leaves user consent unchanged. + +This installs only in the disposable root. The second test uses an explicit +local launch pin to route the production supervisor's two services to its own +fresh empty registry and data directory. It checks both artifact identities, +serves the packaged Chat document, reads zero Goals/Sessions, stops and reopens +the services, and rejects +a stale expected identity without leaving owned listeners. It does not launch +the installed App, run a model, or qualify automatic runtime selection, native +window reopening, signed replacement or rendered UI. The incognito WebKit +regression above covers its separate navigation failure boundary. + On macOS, also qualify a released artifact using the opt-in checks below (run from `src-tauri`, replacing the paths with downloaded release artifacts). Keep the updater archive's `.sig` file beside it: diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs b/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs index 04452f2847..858eb610e7 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/bundled_runtime.rs @@ -367,6 +367,11 @@ mod tests { #[test] #[ignore = "requires an isolated private installer root and built App bundle"] fn real_private_installer_keeps_the_default_cli_owner() { + assert_eq!( + std::env::var("LOOPX_USAGE_PING").as_deref(), + Ok("0"), + "synthetic Core qualification requires LOOPX_USAGE_PING=0" + ); let bundle = std::path::PathBuf::from(std::env::var("LOOPX_TEST_BUNDLE").unwrap()); let root = std::path::PathBuf::from(std::env::var("LOOPX_TEST_PRIVATE_RUNTIME").unwrap()); for key in ["LOOPX_REGISTRY", "LOOPX_RUNTIME_ROOT"] { 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 ee681c19d7..ce679ed1ef 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs @@ -1625,3 +1625,7 @@ mod tests { #[cfg(test)] #[path = "services_tests.rs"] mod supervisor_tests; + +#[cfg(all(test, unix))] +#[path = "services_real_runtime_tests.rs"] +mod real_runtime_tests; diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services_real_runtime_tests.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services_real_runtime_tests.rs new file mode 100644 index 0000000000..66f448e630 --- /dev/null +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services_real_runtime_tests.rs @@ -0,0 +1,127 @@ +//! Opt-in production supervisor + installed Core. Never use an active registry. +use super::*; +use std::os::unix::fs::PermissionsExt; + +fn response_body(port: u16, path: &str) -> String { + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + let mut raw = String::new(); + stream + .take(MAX_PROBE_RESPONSE_BYTES + 1) + .read_to_string(&mut raw) + .unwrap(); + assert!(raw.len() as u64 <= MAX_PROBE_RESPONSE_BYTES); + let (headers, body) = raw.split_once("\r\n\r\n").unwrap(); + assert!(headers.starts_with("HTTP/1.0 200 ") || headers.starts_with("HTTP/1.1 200 ")); + body.to_owned() +} + +fn assert_stopped(endpoints: ServiceEndpoints) { + for kind in SERVICE_KINDS { + assert_eq!( + probe_on_port(kind, endpoints.port(kind), None), + Probe::Unavailable + ); + } +} + +#[test] +#[ignore = "requires a disposable Core install and empty isolated registry; see Desktop README"] +fn real_private_runtime_serves_and_rejects_stale_identity() { + assert_eq!( + env::var("LOOPX_USAGE_PING").as_deref(), + Ok("0"), + "synthetic Core qualification requires LOOPX_USAGE_PING=0" + ); + let root = PathBuf::from(env::var("LOOPX_TEST_PRIVATE_RUNTIME").unwrap()); + // A caller's empty registry can still share a runtime with bound private + // conversations. Own a fresh data root rather than trusting that shortcut. + let data = tempfile::tempdir().unwrap(); + let state = fs::canonicalize(data.path()).unwrap(); + let registry = state.join("registry.global.json"); + fs::write(®istry, b"{\"goals\":[]}").unwrap(); + let executable = fs::canonicalize(root.join("bin/loopx")).unwrap(); + let identity = runtime_identity_for_executable(executable.to_str().unwrap()).unwrap(); + + // The production service commands use --global-registry. An explicit local + // launch pin supplies their real CLI route, not a second identity/provider. + // Use shell quoting for paths, including spaces, quotes and dollar signs. + let quoted = |path: &Path| format!("'{}'", path.to_str().unwrap().replace('\'', "'\"'\"'")); + let wrapper = tempfile::tempdir().unwrap(); + let pin = wrapper.path().join("isolated-core"); + fs::write( + &pin, + format!( + "#!/bin/sh\nexec {} --registry {} --runtime-root {} \"$@\"\n", + quoted(&executable), + quoted(®istry), + quoted(&state) + ), + ) + .unwrap(); + fs::set_permissions(&pin, fs::Permissions::from_mode(0o700)).unwrap(); + let runtime = SelectedRuntime { + executable: pin.to_string_lossy().into_owned(), + identity: Some(identity.clone()), + }; + let mut origins = Vec::new(); + for _ in 0..2 { + let endpoints = ServiceEndpoints::allocate(true).unwrap(); + let mut services = ServiceSet::start(&runtime, &endpoints, |_| {}).unwrap(); + for kind in SERVICE_KINDS { + assert_eq!( + probe_on_port(kind, endpoints.port(kind), Some(&identity)), + Probe::Matching + ); + } + let status: serde_json::Value = serde_json::from_str(&response_body( + endpoints.port(ServiceKind::Status), + "/status.json", + )) + .unwrap(); + assert_eq!(status["goal_count"], 0); + assert_eq!( + status["registry"].as_str(), + registry.to_str(), + "the real backend must use only this disposable registry" + ); + let sessions: serde_json::Value = serde_json::from_str(&response_body( + endpoints.port(ServiceKind::Chat), + "/api/chat/sessions", + )) + .unwrap(); + assert!(sessions["sessions"].as_array().is_some_and(Vec::is_empty)); + assert!(response_body(endpoints.port(ServiceKind::Chat), "/chat/") + .to_ascii_lowercase() + .contains("")); + origins.push(endpoints.workspace_origin()); + services.stop(); + assert_stopped(endpoints); + } + + // The production owner must reject an actual backend from the wrong frozen + // artifact and clean up both children, including a successful peer start. + let mut mismatched = identity.clone(); + mismatched["source_revision"] = serde_json::json!("0000000000000000000000000000000000000000"); + let invalid = SelectedRuntime { + executable: runtime.executable.clone(), + identity: Some(mismatched), + }; + let endpoints = ServiceEndpoints::allocate(true).unwrap(); + let error = ServiceSet::start(&invalid, &endpoints, |_| {}) + .err() + .expect("a stale artifact must fail readiness"); + assert!(error.to_string().contains("unexpected listener")); + assert_stopped(endpoints); + println!( + "{}", + serde_json::json!({"private_service_reopens":origins.len(), "workspace_origins":origins, "runtime_identity":identity, "goals":0, "sessions":0, "stale_identity_rejected":true, "owned_children_stopped":true}) + ); +}