diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index cb34a8b457..e1a8bd91df 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -37,6 +37,14 @@ unknown; this is maintenance of the App's own installation, not proof that one source revision is newer. A provably newer runtime remains selected. Both local HTTP services must expose the selected artifact's identity before the workspace opens. +If anonymous GitHub ancestry lookup is unavailable, the App can reuse an +existing GitHub CLI login for the same fixed public-repository GET. It never +starts login or requests account permissions. Each tool has a three-second +process deadline; missing, failed or unauthenticated `gh` leaves ancestry +unknown. A successful non-ordering answer is not retried under another account. +Core still owns wheel qualification; this read neither changes its identity +rules nor grants Goal or account authority. + The bundled runtime carries its Core package version from the archived wheel metadata. Native validation and ordering use Python's [PEP 440 version format](https://packaging.python.org/en/latest/specifications/version-specifiers/), including alpha, beta, release candidate, development, post-release and local @@ -212,6 +220,11 @@ For native qualification, use a separately identified packaged App with the real backend: select a local Goal and Agent page, quit normally, reopen, and check the same selection on the new port. Also check window-close, an explicit return to Manager and a corrupt preference in that disposable App profile. +Bind the disposable registry and its `common_runtime_root` at the QA launch +entrypoint, and set `LOOPX_USAGE_PING=0` for all synthetic Core invocations: +a caller-only environment can be lost when a window tool or the +system relaunches the bundle. Verify the reopened process's data route before +observing or operating it; never use the installed App as a fault fixture. Retain Session IDs and active-Turn readbacks before and after; navigation is not evidence that work was resumed or accepted. Cross-platform and sustained use require their own qualification. @@ -339,6 +352,22 @@ npm run dev ## Validation +For the ancestry fallback, use that isolated packaged App and a qualified Core +fixture. A QA-only invalid `CURL_CA_BUNDLE` can deny curl's public read while the +existing `gh` still reads the real relation. Compare the old and candidate +native binaries with the same corrupt preference and immutable Core fixture; +read both service identities and the saved selection after the window opens. +Also check that unavailable public transports retain a qualified runtime and +that normal reopening preserves the disposable Session. Remove faults from the +QA launcher afterwards; never change the system proxy or certificate store. + +The viewport below is a separately packaged macOS QA shell with an empty +synthetic Goal registry. It records a successful workspace opening, not model +execution, model-selection qualification or an installed-product upgrade. The +ancestry change adds no new UI control or configuration step. + +![Packaged QA workspace with zero Goals](docs/assets/native-ancestry-qa.jpg) + ```bash cd apps/desktop/loopx-control-plane/src-tauri cargo fmt --check diff --git a/apps/desktop/loopx-control-plane/docs/assets/native-ancestry-qa.jpg b/apps/desktop/loopx-control-plane/docs/assets/native-ancestry-qa.jpg new file mode 100644 index 0000000000..7672729be0 Binary files /dev/null and b/apps/desktop/loopx-control-plane/docs/assets/native-ancestry-qa.jpg differ 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 8c7392bc67..aab18775d1 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod bundled_runtime; #[cfg(target_os = "macos")] mod macos_update; mod maintenance; +mod runtime_ancestry; mod runtime_selection; mod service_endpoints; mod services; 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 a22202597e..07e7e79068 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -678,9 +678,10 @@ fn runtime_state_publishes_own_phase(error: &str) -> bool { } fn resume_runtime(app: &AppHandle) -> Result { + use crate::runtime_ancestry::compare_official_commits; use crate::runtime_selection::{ - compare_official_commits, is_private_runtime, prefer_current_bundle, - prefer_discovered_runtime, same_release_installation, Selection, + is_private_runtime, prefer_current_bundle, prefer_discovered_runtime, + same_release_installation, Selection, }; let mut selection = crate::runtime_selection::selected(app)?; let mut installed = crate::services::runtime_identity_for_executable(&selection.executable); diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/runtime_ancestry.rs b/apps/desktop/loopx-control-plane/src-tauri/src/runtime_ancestry.rs new file mode 100644 index 0000000000..0b3eac95ff --- /dev/null +++ b/apps/desktop/loopx-control-plane/src-tauri/src/runtime_ancestry.rs @@ -0,0 +1,174 @@ +//! Official source-ancestry transport; runtime selection retains its policy. +use serde_json::Value; +use std::{cmp::Ordering, process::Command, time::Duration}; + +pub(crate) fn compare_official_commits(left: &str, right: &str) -> Option { + compare_using(left, right, |command| { + let output = crate::services::timed_output_with_timeout(command, Duration::from_secs(3))?; + output + .status + .success() + .then(|| serde_json::from_slice(&output.stdout).ok()) + .flatten() + }) +} + +fn compare_using( + left: &str, + right: &str, + mut request: impl FnMut(Command) -> Option, +) -> Option { + if ![left, right] + .iter() + .all(|revision| revision.len() == 40 && revision.bytes().all(|c| c.is_ascii_hexdigit())) + { + return None; + } + // Page two retains the relation without downloading page one's file patches. + let path = format!("repos/loopx-project/loopx/compare/{left}...{right}?per_page=1&page=2"); + let url = format!("https://api.github.com/{path}"); + let mut command = Command::new("curl"); + crate::services::configure_runtime_environment(&mut command); + command.args([ + "--fail", + "--silent", + "--show-error", + "--proto", + "=https", + "--max-time", + "2", + "--header", + "Accept: application/vnd.github+json", + &url, + ]); + let payload = request(command).or_else(|| { + // The anonymous API can be rate-limited even when a user's existing + // GitHub CLI can read this same public relation. Never start login or + // let GH_HOST redirect the read to another authority. + let mut command = Command::new("gh"); + crate::services::configure_runtime_environment(&mut command); + command.env("GH_PROMPT_DISABLED", "1").args([ + "api", + "--hostname", + "github.com", + "--method", + "GET", + "--header", + "Accept: application/vnd.github+json", + &path, + ]); + request(command) + })?; + match payload["status"].as_str()? { + "ahead" => Some(Ordering::Less), + "behind" => Some(Ordering::Greater), + "identical" => Some(Ordering::Equal), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + const LEFT: &str = "1111111111111111111111111111111111111111"; + const RIGHT: &str = "2222222222222222222222222222222222222222"; + + #[test] + fn comparison_relation_is_not_a_package_version_or_wall_clock() { + for (status, expected) in [ + ("ahead", Some(Ordering::Less)), + ("behind", Some(Ordering::Greater)), + ("identical", Some(Ordering::Equal)), + ("diverged", None), + ] { + let mut calls = 0; + let actual = compare_using(LEFT, RIGHT, |command| { + calls += 1; + assert_eq!(command.get_program(), "curl"); + Some(serde_json::json!({"status":status})) + }); + assert_eq!(actual, expected); + assert_eq!(calls, 1); + } + } + + #[test] + fn invalid_revisions_do_not_reach_a_process() { + for revision in [ + "", + "--help", + "../private", + "111111111111111111111111111111111111111z", + ] { + assert_eq!( + compare_using(revision, RIGHT, |_| panic!("must not invoke a tool")), + None + ); + } + } + + #[test] + fn anonymous_failure_uses_the_same_public_read_without_starting_login() { + let mut calls = 0; + let actual = compare_using(LEFT, RIGHT, |command| { + calls += 1; + if calls == 1 { + assert_eq!(command.get_program(), "curl"); + return None; + } + assert_eq!(command.get_program(), "gh"); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert_eq!( + args, + [ + "api", + "--hostname", + "github.com", + "--method", + "GET", + "--header", + "Accept: application/vnd.github+json", + &format!( + "repos/loopx-project/loopx/compare/{LEFT}...{RIGHT}?per_page=1&page=2" + ) + ] + ); + assert!(command + .get_envs() + .any(|(key, value)| key == "GH_PROMPT_DISABLED" + && value == Some(std::ffi::OsStr::new("1")))); + Some(serde_json::json!({"status":"ahead"})) + }); + assert_eq!(actual, Some(Ordering::Less)); + assert_eq!(calls, 2); + } + + #[test] + fn unavailable_tools_leave_ancestry_unknown() { + let mut calls = 0; + assert_eq!( + compare_using(LEFT, RIGHT, |_| { + calls += 1; + None + }), + None + ); + assert_eq!(calls, 2); + } + + #[test] + fn a_successful_nonordering_answer_is_not_retried_under_another_account() { + let mut calls = 0; + assert_eq!( + compare_using(LEFT, RIGHT, |_| { + calls += 1; + Some(serde_json::json!({"status":"diverged"})) + }), + None + ); + assert_eq!(calls, 1); + } +} diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs b/apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs index 71c4928da1..086a25a603 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/runtime_selection.rs @@ -269,47 +269,6 @@ pub(crate) fn prefer_current_bundle( } } -pub(crate) fn compare_official_commits(left: &str, right: &str) -> Option { - if ![left, right] - .iter() - .all(|revision| revision.len() == 40 && revision.bytes().all(|c| c.is_ascii_hexdigit())) - { - return None; - } - // GitHub includes file patches only on page one. Page two retains the - // comparison relation without downloading a potentially huge source diff. - // https://docs.github.com/en/rest/commits/commits#compare-two-commits - let url = format!( - "https://api.github.com/repos/loopx-project/loopx/compare/{left}...{right}?per_page=1&page=2" - ); - let mut command = std::process::Command::new("curl"); - crate::services::configure_runtime_environment(&mut command); - command.args([ - "--fail", - "--silent", - "--show-error", - "--proto", - "=https", - "--max-time", - "2", - "--header", - "Accept: application/vnd.github+json", - &url, - ]); - let output = - crate::services::timed_output_with_timeout(command, std::time::Duration::from_secs(3))?; - if !output.status.success() { - return None; - } - let payload: Value = serde_json::from_slice(&output.stdout).ok()?; - match payload["status"].as_str()? { - "ahead" => Some(std::cmp::Ordering::Less), - "behind" => Some(std::cmp::Ordering::Greater), - "identical" => Some(std::cmp::Ordering::Equal), - _ => None, - } -} - #[cfg(test)] mod tests { use super::*;