diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index 6f04d8b84f..18e5cb337b 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -64,7 +64,9 @@ If the bundle is newer, or no selected CLI qualifies, the App prepares its bundled snapshot in its own data directory through the existing installer. This updates the CLI used by the App without replacing uv/pip/pipx's command or editing the user's shell profile. An optional failed upgrade retains a qualified -previous runtime. Core's installation-only doctor qualifies non-editable wheel +previous runtime. Every discovered CLI must answer Core's installation-only +doctor successfully, including managed releases: a surviving `release.json` +cannot qualify a broken executable. Core also qualifies non-editable wheel fingerprints: a local reuse fence, not publisher or source attestation. Older CLIs without that readback can use the automatic App-owned fallback. 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 393c0fc20f..70cb3c1266 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs @@ -1043,21 +1043,6 @@ fn resolve_executable_path(executable: &str, search_path: Option<&OsStr>) -> Opt None } -fn runtime_identity_from_manifest(manifest: &serde_json::Value) -> Option { - let release_id = manifest.get("release_id")?.as_str()?; - let package_version = manifest.get("package")?.get("version")?.as_str()?; - let source_revision = manifest - .get("source") - .and_then(|source| source.get("git_commit")) - .and_then(serde_json::Value::as_str); - Some(serde_json::json!({ - "schema_version": "loopx_runtime_identity_v1", - "package_version": package_version, - "release_id": release_id, - "source_revision": source_revision, - })) -} - pub(crate) fn runtime_identity_for_executable(executable: &str) -> Option { runtime_identity_for_executable_with_path(executable, env::var_os("PATH").as_deref()) } @@ -1068,16 +1053,9 @@ fn runtime_identity_for_executable_with_path( ) -> Option { let resolved = resolve_executable_path(executable, search_path)?; let canonical = fs::canonicalize(resolved).ok()?; - let release_root = canonical.parent()?.parent()?; - if let Ok(manifest) = fs::read_to_string(Path::new(release_root).join("release.json")) { - if let Ok(payload) = serde_json::from_str::(&manifest) { - if let Some(identity) = runtime_identity_from_manifest(&payload) { - return Some(identity); - } - } - } // Core owns package qualification and hashing. Do not recreate RECORD, // editable-install or byte-fingerprint rules inside the native shell. + // A surviving release manifest cannot prove its executable still works. let mut command = Command::new(canonical); configure_runtime_environment(&mut command); command.args(["--format", "json", "doctor", "--installation-only"]); @@ -1094,20 +1072,26 @@ fn identity_from_installation_doctor(payload: &serde_json::Value) -> Option bool { @@ -1481,22 +1465,55 @@ mod tests { } #[test] - fn manifest_runtime_identity_is_public_and_exact() { - let manifest = serde_json::json!({ - "release_id": "20260821T164921Z", - "package": {"version": "0.5.1"}, - "source": {"git_commit": "62647e2e299d"}, - }); - - assert_eq!( - runtime_identity_from_manifest(&manifest), - Some(serde_json::json!({ + fn qualified_release_identity_is_public_and_exact() { + let identity = serde_json::json!({ "schema_version": "loopx_runtime_identity_v1", "package_version": "0.5.1", "release_id": "20260821T164921Z", "source_revision": "62647e2e299d", - })) - ); + "path": "PRIVATE", + }); + let qualified = serde_json::json!({"ok":true,"scope":"installation_only", + "service_runtime_identity":identity}); + let observed = identity_from_installation_doctor(&qualified).unwrap(); + assert_eq!(observed["release_id"], identity["release_id"]); + assert_eq!(observed["source_revision"], identity["source_revision"]); + assert_eq!(observed.as_object().unwrap().len(), 4); + assert!(observed.get("path").is_none()); + let mut rejected = qualified.clone(); + rejected["ok"] = serde_json::json!(false); + assert!(identity_from_installation_doctor(&rejected).is_none()); + rejected = qualified.clone(); + rejected["service_runtime_identity"]["release_id"] = serde_json::json!(""); + assert!(identity_from_installation_doctor(&rejected).is_none()); + } + + #[test] + #[cfg(unix)] + fn release_metadata_cannot_qualify_a_broken_or_unready_executable() { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + let scripts = root.path().join("scripts"); + fs::create_dir(&scripts).unwrap(); + let executable = scripts.join("loopx"); + fs::write( + root.path().join("release.json"), + r#"{"release_id":"cached-release","package":{"version":"99.0.0"},"source":{"git_commit":"cached-revision"}}"#, + ) + .unwrap(); + for response in [ + "exit 1", + "printf 'not JSON'", + "printf '%s' '{\"ok\":false,\"scope\":\"installation_only\"}'", + ] { + fs::write(&executable, format!("#!/bin/sh\n{response}\n")).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + assert!( + runtime_identity_for_executable_with_path("loopx", Some(scripts.as_os_str())) + .is_none(), + "a cached manifest must not override failed executable qualification: {response}" + ); + } } #[test] @@ -1519,19 +1536,16 @@ mod tests { ) .expect("fixture release manifest"); + assert_eq!( + resolve_executable_path("loopx", Some(scripts_dir.as_os_str())), + Some(scripts_dir.join(executable_name)) + ); + // A path and manifest still do not make this non-executable fixture usable. let identity = runtime_identity_for_executable_with_path("loopx", Some(scripts_dir.as_os_str())); fs::remove_dir_all(&release_root).expect("remove runtime identity fixture"); - assert_eq!( - identity, - Some(serde_json::json!({ - "schema_version": "loopx_runtime_identity_v1", - "package_version": "0.5.1", - "release_id": "path-release", - "source_revision": "path-revision", - })) - ); + assert!(identity.is_none()); } #[test] diff --git a/docs/assets/personal-workspace/runtime-discovery-after.png b/docs/assets/personal-workspace/runtime-discovery-after.png new file mode 100644 index 0000000000..d5750c300e Binary files /dev/null and b/docs/assets/personal-workspace/runtime-discovery-after.png differ diff --git a/docs/assets/personal-workspace/runtime-discovery-before.png b/docs/assets/personal-workspace/runtime-discovery-before.png new file mode 100644 index 0000000000..43d12c87b7 Binary files /dev/null and b/docs/assets/personal-workspace/runtime-discovery-before.png differ