Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions apps/desktop/loopx-control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,32 @@ desktop release workflow succeeds:
- macOS: `.dmg` plus a zipped `.app` bundle;
- Windows: `.msi` plus an NSIS `.exe` installer.

On Apple Silicon macOS, the App checks its current official channel on launch,
verifies a newer App's updater signature, installs it and restarts automatically.
On Apple Silicon macOS, the App opens its qualified local runtime first, then
checks its current official channel and downloads a newer App in the background.
It verifies the updater signature and prepares the archive locally. The next
normal open verifies that archive again and installs it before starting services;
ordinary background preparation never replaces or restarts an active workspace.
An offline or failed check keeps the installed App usable; it does not certify
that the App is current. Main points to the latest complete signed build,
not arbitrary Git HEAD. Builds without the updater need a one-time replacement.
Windows preview retains manual CLI installation; browser/PWA users continue
with `loopx update`.

Background network requests do not hold service supervision. A slow or failed
check/download cannot prevent local startup or recovery of an owned child.
The existing update panel reports background progress or a prepared update;
manual actions cancel/supersede background work and retain their original
installation, restart, backup and rollback controls. A CLI-requested App upgrade
remains an explicit transaction with its original channel and service-ready
readback. A permission-restricted prepared installation stays on the verified
current App; use the existing explicit update action for native authorization.
The disposable cache is not an update authority: its signed bytes, channel,
version order and sealed bundle version are checked again before replacement.
Rejected cached signatures and verified-safe replacement failures keep local
startup moving, with the failure retained in diagnostics. They do not display
a stopped-startup state while services are connecting. An incomplete target,
failed journal retirement or unknown installation failure still requires recovery.

## Updates And Recovery

From a terminal, `loopx upgrade` coordinates the owning CLI installer and this
Expand All @@ -30,8 +48,8 @@ to the App. A private request receipt contains versions, source revisions,
update phase and the qualified service runtime identity; it contains no Goal
content or credentials. An update check failure remains a failed terminal
upgrade even when the workspace can still open. Normal launches without that
request retain their existing three-second optional startup check; an explicit
terminal upgrade uses the existing thirty-second interactive check budget.
request perform their network check after local services are ready; an explicit
terminal upgrade retains its thirty-second interactive check budget.
The existing update panel shows the same native maintenance phases and recovery
actions. Main is the latest complete signed build, which can lag Git HEAD.

Expand Down Expand Up @@ -278,10 +296,10 @@ secret. Updater signing does not provide Apple notarization. It also does not
change Goal authority, grant capabilities, or stop running agents on behalf of
the user. Services may briefly disconnect during reconciliation.

The optional startup feed check has a three-second deadline so an unavailable
update server cannot hold workspace startup indefinitely. A manual **Check for updates**
allows 45 seconds for the same official feed, including redirects; the panel
keeps showing its checking state while the existing workspace remains readable.
The background feed check has a thirty-second deadline and is outside local
workspace startup. A manual **Check for updates** allows 45 seconds for the same
official feed, including redirects; the panel keeps showing its checking state
while the existing workspace remains readable.
A timeout remains an explicit retryable error, never a claim that the App is
current. The separate 30-second download/signature-verification deadline and
installation/recovery boundaries are unchanged.
Expand Down Expand Up @@ -387,6 +405,16 @@ updates through the actual App, then terminate its exact owned Chat and Status
children separately. Read back both HTTP identities, unchanged peer PID, exact
registry, retained Session and update failure, then normal quit/reopen cleanup.
Never substitute an installed App, real Goal or shared listener as a fixture.
Bind isolation at the QA bundle's executable entrypoint, so a window tool or OS
relaunch cannot drop a caller-only environment. The native services request
`--global-registry`; `LOOPX_REGISTRY` only changes the project default and does
not override that explicit selection. A QA-only CLI launcher must route through
Core's explicit `--registry` and `--runtime-root` arguments instead of that flag.
Fail closed when its fixture binding is absent. Before any UI operation or
public capture, read both service identities, the actual registry/runtime route
and the empty Goal set. A source mismatch invalidates the sample; retain it
privately and stop rather than operating on or publishing the real workspace.
Do not change the user's default registry or `HOME` to obtain isolation.
This qualifies child-exit recovery, not an interrupted model Turn, an
unresponsive process, a signed update installation or cross-OS operation.

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/desktop/loopx-control-plane/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions apps/desktop/loopx-control-plane/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,9 @@ flate2 = "1.1.10"
tar = "0.4.46"
sha2 = "0.11.0"
tempfile = "3.27.0"

[dev-dependencies]
base64 = "0.22"
minisign-verify = "0.2.5"
semver = "1"

[target.'cfg(target_os = "macos")'.dependencies]
rustix = { version = "1.1.4", features = ["fs"] }
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/loopx-control-plane/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod bundled_runtime;
#[cfg(target_os = "macos")]
mod macos_update;
mod maintenance;
mod prepared_update;
mod runtime_ancestry;
mod runtime_selection;
mod service_endpoints;
Expand Down Expand Up @@ -238,6 +239,7 @@ pub fn run() {
.expect("workspace handoff lock")
.services_ready();
WorkspaceHandoff::reconcile(&handoff, &handle);
maintenance::start_background_update(&handle);
if healed {
let _ = handle
.notification()
Expand Down
66 changes: 65 additions & 1 deletion apps/desktop/loopx-control-plane/src-tauri/src/macos_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ enum InstallError {

pub(crate) fn install(update: &Update, verified_bytes: &[u8]) -> Result<(), String> {
let executable = std::env::current_exe().map_err(|_| "app_install_failed")?;
match install_verified_archive(&executable, verified_bytes) {
match install_archive(&executable, verified_bytes, Some(&update.version)) {
Ok(()) => Ok(()),
// An administrator-owned installation retains the existing native
// authorization path. Never fall back after extraction or a failed
Expand All @@ -31,7 +31,25 @@ pub(crate) fn install(update: &Update, verified_bytes: &[u8]) -> Result<(), Stri
}
}

// Prepared downloads are activated before this App starts any service. A
// permission-restricted target stays on the working App; its explicit update
// action retains the updater's existing native authorization adapter above.
pub(crate) fn install_prepared(version: &str, verified_bytes: &[u8]) -> Result<(), String> {
let executable = std::env::current_exe().map_err(|_| "app_install_failed")?;
install_archive(&executable, verified_bytes, Some(version))
.map_err(|_| "app_install_failed".into())
}

#[cfg(test)]
fn install_verified_archive(executable: &Path, verified_bytes: &[u8]) -> Result<(), InstallError> {
install_archive(executable, verified_bytes, None)
}

fn install_archive(
executable: &Path,
verified_bytes: &[u8],
expected_version: Option<&str>,
) -> Result<(), InstallError> {
let failed = || InstallError::Failed;
let target = crate::update_backup::installed_bundle_at(executable).map_err(|_| failed())?;
let parent = target.parent().ok_or_else(failed)?;
Expand Down Expand Up @@ -74,6 +92,21 @@ fn install_verified_archive(executable: &Path, verified_bytes: &[u8]) -> Result<
{
return Err(failed());
}
// The archive is signed, but its local cache metadata is not. Bind the
// announced version to the sealed bundle before any replacement so a
// modified cache cannot relabel a valid older archive as a newer update.
if let Some(expected) = expected_version {
let mut command = std::process::Command::new("/usr/libexec/PlistBuddy");
command
.args(["-c", "Print :CFBundleShortVersionString"])
.arg(candidate.join("Contents/Info.plist"));
let matches = crate::services::timed_output(command).is_some_and(|output| {
output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == expected
});
if !matches {
return Err(failed());
}
}

// Retain both sides before the only commit operation. The same atomic
// exchange as rollback keeps the install path occupied, even across a
Expand Down Expand Up @@ -219,6 +252,37 @@ mod tests {
));
}

#[test]
fn local_metadata_cannot_relabel_a_sealed_older_bundle() {
let root = tempfile::tempdir().unwrap();
let target = ad_hoc_signed_synthetic_app(root.path(), "Installed.app");
let candidate = ad_hoc_signed_synthetic_app(root.path(), "Candidate.app");
let plist = candidate.join("Contents/Info.plist");
let content = fs::read_to_string(&plist).unwrap().replace(
"</dict>",
"<key>CFBundleShortVersionString</key><string>1.1.0</string></dict>",
);
fs::write(&plist, content).unwrap();
assert!(std::process::Command::new("codesign")
.args(["--sign", "-", "--force"])
.arg(&candidate)
.status()
.unwrap()
.success());
let bytes = archive(&candidate);
assert_eq!(
install_archive(&synthetic_executable(&target), &bytes, Some("9.0.0")),
Err(InstallError::Failed)
);
assert!(!fs::read_to_string(target.join("Contents/Info.plist"))
.unwrap()
.contains("1.1.0"));
install_archive(&synthetic_executable(&target), &bytes, Some("1.1.0")).unwrap();
assert!(fs::read_to_string(target.join("Contents/Info.plist"))
.unwrap()
.contains("1.1.0"));
}

#[test]
fn only_parent_permission_denial_requests_the_existing_authorization_path() {
use std::os::unix::fs::PermissionsExt;
Expand Down
Loading
Loading