From 138edb20c11ad8011e2ec98104628e63be7bfe11 Mon Sep 17 00:00:00 2001 From: Mark Baker Date: Mon, 14 Sep 2026 00:00:05 -0400 Subject: [PATCH 1/2] Discover and update sudo integrations in existing VMs --- README.md | 19 ++ docs/architecture.md | 8 +- docs/integration-updates.md | 75 +++++ .../usr/local/bin/try-omarchy-touch-id-test | 10 +- guest/scripts/configure-rootfs.sh | 9 + guest/scripts/install-touch-id-menu-entry.py | 9 +- guest/tests/test_integration_bundle.py | 219 +++++++++++++ guest/tests/test_touch_id_command.py | 7 +- integrations/DESIGN.md | 68 ++++ integrations/build-bundle.py | 37 +++ integrations/setup | 3 + integrations/try-omarchy-integrations | 9 + integrations/try-omarchy-integrations.service | 16 + integrations/updater.py | 309 ++++++++++++++++++ .../GuestIntegrationStatus.swift | 255 +++++++++++++++ .../OmarchyVMHelper/StartMenuWindow.swift | 12 + .../VMApplicationController.swift | 6 + macos/Sources/OmarchyVMHelper/main.swift | 10 + .../GuestIntegrationStatusTests.swift | 70 ++++ macos/build-app.sh | 1 + macos/run-qemu-gpu.sh | 26 ++ scripts/build-cache.py | 5 +- 22 files changed, 1171 insertions(+), 12 deletions(-) create mode 100644 docs/integration-updates.md create mode 100644 guest/tests/test_integration_bundle.py create mode 100644 integrations/DESIGN.md create mode 100755 integrations/build-bundle.py create mode 100755 integrations/setup create mode 100755 integrations/try-omarchy-integrations create mode 100644 integrations/try-omarchy-integrations.service create mode 100644 integrations/updater.py create mode 100644 macos/Sources/OmarchyVMHelper/GuestIntegrationStatus.swift create mode 100644 macos/Tests/OmarchyVMHelperTests/GuestIntegrationStatusTests.swift diff --git a/README.md b/README.md index 9ff86f9a..3fc57f23 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,25 @@ all of that app's factory-image changes to an existing VM, and an in-guest update should not be assumed to reproduce them. A confirmed reset is the deliberate, destructive way to start again from the newest bundled factory. +### Updating integrations in an existing VM + +The Mac launcher’s **VM integrations → Review…** action explains how to add +new Try Omarchy features to an existing VM. It offers a one-time setup command +for guests that do not yet have the integration manager. Run that command in an +Omarchy terminal; it mounts the app’s dedicated read-only bundle and opens a +review before requesting the Linux administrator password. SSH and personal +folder sharing are not required. + +After setup, use **Omarchy Menu → Setup → Try Omarchy Integrations** or run +`try-omarchy-integrations`. The initial guide installs or updates the sudo Touch ID support already bundled +with Try Omarchy. Biometric pairing remains a separate explicit choice. It does +not install pending integrations or upgrade the guest OS. + +The app checks integration status after every VM launch. The launcher labels +cached results **Last check**. A guest that does not respond may need setup or +repair; a timeout is not proof that its components are absent. See +[integration updates](docs/integration-updates.md) for scope and recovery details. + ### Growing an existing VM disk To add capacity without resetting the VM, shut down Omarchy and run the diff --git a/docs/architecture.md b/docs/architecture.md index 1b678d84..31ac0c6e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -262,10 +262,10 @@ but the direct-boot kernel and matching headers, the packaged `try-omarchy-runtime`, and reviewed compatibility backports remain pinned in Try Omarchy's prioritized local repository. Reusing a disk therefore does not silently import a newer app's factory contents, and running the in-guest updater -must not be described as reproducing every factory-image change. Delivering -new Try Omarchy runtime or backport revisions to existing disks requires an -explicitly designed in-guest migration channel; today a factory reset is the -way to opt into the complete new factory. +must not be described as reproducing every factory-image change. The bundled integration manager provides an explicit migration channel for +reviewed guest integrations, with user-approved installation and per-VM status +reporting. It does not replace the pinned kernel or reproduce every factory +change. Factory reset remains the way to opt into the complete new factory. Optional, user-initiated installers run after the factory image has been built and are a separate trust boundary. They may resolve a mutable current release diff --git a/docs/integration-updates.md b/docs/integration-updates.md new file mode 100644 index 00000000..4d3cbabb --- /dev/null +++ b/docs/integration-updates.md @@ -0,0 +1,75 @@ +# Integration updates for existing VMs + +App upgrades retain existing guest disks. The integration manager delivers +reviewed guest features independently of the bundled factory image. + +## First setup + +Open **VM integrations > Review…** in the Mac launcher. Launch Omarchy and paste +the supplied command into an Omarchy terminal. It mounts the app's dedicated +read-only 9p share at `/mnt/try-omarchy-updates` and opens a review. The share is +separate from the optional personal shared folder and needs no SSH connection. + +Choose **Install/update integration support** and review replacements before +confirming. Installation asks for the Linux user's sudo authorization, retains +backups, and verifies each component before recording it as complete. Biometric +enrollment remains a separate action. Existing PAM enrollment is preserved. + +The guide is then available under **Omarchy Menu > Setup > Try Omarchy +Integrations**, or with `try-omarchy-integrations` in the guest terminal. + +## Features and boundaries + +- sudo Touch ID: installs support; pairing is explicit and can be tested or repaired. + +The initial bundle contains only upstream sudo Touch ID support. Additional +integrations can be added after their own upstream review. The manager does not +replace the kernel, upgrade the graphics stack, repair package holds, install +1Password integration, or reproduce every change in a newer factory image. Ordinary package +updates remain with Omarchy Update. No VM reset is required for these integrations. + +## Status + +A dedicated virtio port carries bounded status reports to the host every ten +seconds. Every VM launch starts a new check. After 120 seconds without a valid +report the host shows that setup or repair may be needed and continues listening. +An older, slow, or stopped guest agent cannot be distinguished by silence alone. + +When setup, updates, or repairs may be needed, the app offers a review once per +bundled integration revision for that disk. Choosing Later leaves the VM running +and keeps the review action available. Checks still run on every launch. + +The Mac menu bar provides a live integration status and review action. The +launcher shows the last check for the selected persistent disk. A report of +current components means installed files and relevant services passed inspection; +it does not attest that Touch ID was successfully used. Status messages never +execute commands or authorize host or guest installation. + +## Failure and retry + +An installed bundle with additional integrations is not replaced by this smaller +bundle. Use an app that supports those integrations; their files and enrollment +are left intact. + +The updater verifies the exact bundle inventory and hashes before installation, +then stages a root-private copy. The app signature covers the bundle and manifest; +hashes detect corruption and do not independently establish trust in an app. + +Previous files, the previous installed bundle, and progress are retained under +`/var/lib/try-omarchy/integrations`. A component is marked complete only after +verification. Rerunning skips a previously completed step only when its files and +required services still match. This is resumable installation, not a transactional +rollback of all PAM or systemd effects. A failed step prints its error and leaves +progress and backups available for repair. + +Installation lists existing integration files that differ before asking to +replace them. Unrelated menu entries and package-configuration settings are +preserved. Unsupported or unsafe paths stop the operation. Close Omarchy Update +before installing integrations. An active package transaction blocks installation. + +Guest status diagnostics: + +```sh +systemctl status try-omarchy-integrations.service --no-pager +sudo journalctl -u try-omarchy-integrations.service -b -n 40 --no-pager +``` diff --git a/guest/native-overlay/usr/local/bin/try-omarchy-touch-id-test b/guest/native-overlay/usr/local/bin/try-omarchy-touch-id-test index efc8c9f7..1c2913b1 100755 --- a/guest/native-overlay/usr/local/bin/try-omarchy-touch-id-test +++ b/guest/native-overlay/usr/local/bin/try-omarchy-touch-id-test @@ -2,10 +2,16 @@ set -eu +printf '%s\n' \ + "Testing Touch ID for sudo. Approve the Touch ID prompt on your Mac." \ + "Password fallback is disabled for this test; no guest password will be requested." \ + "" sudo -k if SUDO_ASKPASS=/bin/false sudo -A true; then - echo "sudo authenticated without a guest password. Touch ID was used if its prompt appeared." + echo "Test passed: sudo authenticated without a guest password." + echo "If you approved the Touch ID prompt, Touch ID completed this test." else - echo "Touch ID sudo check failed; password fallback was not accepted." >&2 + printf '\n%s\n' "Touch ID authentication did not complete." >&2 + echo "Password fallback is disabled for this test. Your guest password remains available for normal sudo commands." >&2 exit 1 fi diff --git a/guest/scripts/configure-rootfs.sh b/guest/scripts/configure-rootfs.sh index 3603a7f7..5c90bbc8 100755 --- a/guest/scripts/configure-rootfs.sh +++ b/guest/scripts/configure-rootfs.sh @@ -181,6 +181,15 @@ mkdir -p "$root/usr/local/lib/try-omarchy" install -m 0755 "$guest_dir/scripts/finalize-rootfs.sh" "$root/usr/local/lib/try-omarchy/finalize-rootfs" install -m 0644 "$spec" "$root/usr/share/try-omarchy/build-spec.json" +# Fresh guests report integration status from their first boot. Older guests +# receive the same bundle through the app's explicit bootstrap flow. +python3 "$guest_dir/../integrations/build-bundle.py" "$root/usr/local/share/try-omarchy/integrations" +install -m 0755 "$guest_dir/../integrations/try-omarchy-integrations" "$root/usr/local/bin/try-omarchy-integrations" +install -m 0644 "$guest_dir/../integrations/try-omarchy-integrations.service" "$root/usr/lib/systemd/system/try-omarchy-integrations.service" +mkdir -p "$root/etc/systemd/system/multi-user.target.wants" +ln -s /usr/lib/systemd/system/try-omarchy-integrations.service "$root/etc/systemd/system/multi-user.target.wants/try-omarchy-integrations.service" +python3 "$root/usr/local/share/try-omarchy/integrations/updater.py" stage-menu "$root/etc/skel/.config/omarchy/extensions/omarchy-menu.jsonc" + # Record content digests before the user overlay is copied into $HOME. This is # the machine-readable proof that the compositor/shell runtime came from the # pinned Omarchy tree rather than a frontend reproduction. diff --git a/guest/scripts/install-touch-id-menu-entry.py b/guest/scripts/install-touch-id-menu-entry.py index 0f4683fa..91015c7d 100755 --- a/guest/scripts/install-touch-id-menu-entry.py +++ b/guest/scripts/install-touch-id-menu-entry.py @@ -26,7 +26,7 @@ def fail(message: str) -> None: raise SystemExit(f"install-touch-id-menu-entry: {message}") -def install(path: Path) -> None: +def install(path: Path, previous_entry: str | None = None) -> None: path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) try: info = path.stat(follow_symlinks=False) @@ -45,7 +45,7 @@ def install(path: Path) -> None: text = data.decode("utf-8") except UnicodeDecodeError: fail("menu extension is not UTF-8") - if ENTRY_ID in text: + if ENTRY_ID in text and (previous_entry is None or text.count(previous_entry) != 1): return opening = text.find("{") @@ -53,7 +53,10 @@ def install(path: Path) -> None: fail("menu extension does not start with a JSONC object") if text.rstrip()[-1:] != "}": fail("menu extension is not a JSONC object") - updated = text[: opening + 1] + "\n" + ENTRY + text[opening + 1 :] + if ENTRY_ID in text: + updated = text.replace(previous_entry, ENTRY, 1) + else: + updated = text[: opening + 1] + "\n" + ENTRY + text[opening + 1 :] directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) temporary = f".{path.name}.{secrets.token_hex(8)}" diff --git a/guest/tests/test_integration_bundle.py b/guest/tests/test_integration_bundle.py new file mode 100644 index 00000000..ebf8adfc --- /dev/null +++ b/guest/tests/test_integration_bundle.py @@ -0,0 +1,219 @@ +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +import subprocess +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] + +def module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + +builder = module('integration_builder', ROOT / 'integrations/build-bundle.py') +updater = module('integration_updater', ROOT / 'integrations/updater.py') + +class IntegrationBundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + # macOS /var is a symlink; the production bundle must be canonical. + self.bundle = Path(self.temp.name).resolve() / 'bundle' + builder.build(self.bundle) + + def tearDown(self): + self.temp.cleanup() + + def test_complete_bundle_is_verifiable(self): + result = updater.manifest(self.bundle) + self.assertEqual(result['version'], 1) + self.assertIn('guest/scripts/install-touch-id-sudo.sh', result['files']) + self.assertFalse(any(word in name for name in result['files'] for word in ('onepassword', 'clock-recover', 'repair-update-holds'))) + self.assertTrue((self.bundle / 'setup').stat().st_mode & 0o111) + + def test_smaller_bundle_does_not_remove_installed_support(self): + import hashlib + import shutil + installed = self.bundle.parent / 'installed' + shutil.copytree(self.bundle, installed) + extra = installed / 'additional-integration' + extra.write_text('retained support') + data = json.loads((installed / 'manifest.json').read_text()) + data['files'][extra.name] = updater.digest(extra) + data['identity'] = hashlib.sha256(json.dumps(data['files'], sort_keys=True).encode()).hexdigest() + (installed / 'manifest.json').write_text(json.dumps(data)) + with self.assertRaisesRegex(RuntimeError, 'additional integrations'): + updater.verify_upgrade(installed, self.bundle) + self.assertEqual(extra.read_text(), 'retained support') + updater.verify_upgrade(self.bundle, self.bundle) + + def test_corruption_cannot_execute(self): + (self.bundle / 'setup').write_text('changed') + with self.assertRaisesRegex(RuntimeError, 'verification failed'): + updater.manifest(self.bundle) + + def test_symlink_substitution_is_rejected(self): + target = self.bundle / 'setup' + original = target.read_bytes() + target.unlink() + other = self.bundle.parent / 'outside' + other.write_bytes(original) + target.symlink_to(other) + with self.assertRaisesRegex(RuntimeError, 'symlink'): + updater.manifest(self.bundle) + + def test_manifest_traversal_rejected(self): + path = self.bundle / 'manifest.json' + data = json.loads(path.read_text()) + data['files']['../outside'] = 'a' * 64 + path.write_text(json.dumps(data)) + with self.assertRaisesRegex(RuntimeError, 'path|unexpected'): + updater.manifest(self.bundle) + + def test_menu_refresh_preserves_entries_and_has_omarchy_environment(self): + home = self.bundle.parent / 'home' + menu = home / '.config/omarchy/extensions/omarchy-menu.jsonc' + menu.parent.mkdir(parents=True) + menu.write_text('{\n "custom": {"label":"Keep me","action":"true"},\n}\n') + with patch.object(updater, 'BUNDLE', self.bundle), patch.object(Path, 'home', return_value=home), patch.object(updater, 'run') as run: + run.return_value = subprocess.CompletedProcess([], 0, '', '') + with patch.dict(updater.os.environ, {}, clear=True): + updater.menu_entry() + updater.menu_entry() + self.assertEqual(run.call_args.kwargs['env']['OMARCHY_PATH'], str(home / '.local/share/omarchy')) + text = menu.read_text() + self.assertIn('Keep me', text) + self.assertEqual(text.count('"setup.try-omarchy-integrations"'), 1) + self.assertEqual(text.count('"setup.security.touch-id"'), 1) + + def test_menu_upgrade_replaces_only_the_previous_generated_entry(self): + menu = self.bundle.parent / 'menu.jsonc' + old = ' "setup.try-omarchy-integrations": {"label":"Try Omarchy Integrations","action":"omarchy-launch-floating-terminal-with-presentation /usr/local/bin/try-omarchy-integrations"},\n' + for custom in (False, True): + entry = old.replace('Try Omarchy Integrations', 'My custom label') if custom else old + menu.write_text('{\n' + entry + ' "custom": {"action":"true"},\n}\n') + with patch.object(updater, 'BUNDLE', self.bundle): + updater.menu_entry(menu, refresh=False) + text = menu.read_text() + self.assertIn('"custom": {"action":"true"}', text) + self.assertEqual(text.count('"setup.try-omarchy-integrations"'), 1) + if custom: + self.assertIn(entry, text) + else: + self.assertNotIn(old, text) + self.assertIn('xdg-terminal-exec', text) + + def test_incomplete_install_and_old_running_agent_are_not_current(self): + state = self.bundle.parent / 'state' + state.mkdir() + identity = updater.manifest(self.bundle)['identity'] + with patch.object(updater, 'BUNDLE', self.bundle), patch.object(updater, 'STATE', state), patch.object(updater, 'files_current', return_value=True), patch.object(updater, 'active', return_value=True): + (state / 'progress.json').write_text('{"status":"installing"}') + self.assertEqual(updater.guest_status(identity)['components']['bootstrap'], 'repair') + (state / 'progress.json').write_text('{"status":"complete"}') + self.assertEqual(updater.guest_status(identity)['components']['bootstrap'], 'current') + old = updater.guest_status('b' * 64) + self.assertEqual(old['components']['bootstrap'], 'repair') + self.assertEqual(old['identity'], 'b' * 64) + + def test_install_result_waits_after_success_or_failure(self): + for choice in ('1',): + for interactive in (True, False): + for succeeds in (True, False): + with self.subTest(choice=choice, interactive=interactive, succeeds=succeeds): + events = [] + def install(args, **kwargs): + self.assertEqual(args[0], 'sudo') + self.assertEqual(args[6:], ['sudo']) + events.append('install') + if not succeeds: + raise subprocess.CalledProcessError(1, args) + def answer(prompt): + if 'Choose' in prompt: + return choice + if 'Continue with installation' in prompt: + return 'y' + self.assertIn('Press Enter', prompt) + events.append('acknowledge') + return '' + def message(*args, **kwargs): + if str(args[0]).startswith('Integration setup could not complete'): + events.append('error') + with patch.object(updater, 'BUNDLE', self.bundle), \ + patch.object(updater, 'files_current', return_value=True), \ + patch.object(updater, 'active', return_value=False), \ + patch.object(updater, 'component_paths', return_value=[]), \ + patch.object(updater, 'run', side_effect=install), \ + patch.object(updater, 'menu_entry', side_effect=lambda: events.append('refresh')), \ + patch.object(updater.sys.stdin, 'isatty', return_value=interactive), \ + patch('builtins.input', side_effect=answer), patch('builtins.print', side_effect=message): + if succeeds: + updater.review() + else: + with self.assertRaises(subprocess.CalledProcessError): + updater.review() + expected = ['install', 'refresh'] if succeeds else ['install', 'error'] + self.assertEqual(events, expected + (['acknowledge'] if interactive else [])) + + def test_declined_install_and_exit_do_not_pause(self): + for choice in ('1', '3'): + answers = [choice, 'n'] if choice != '3' else [choice] + with self.subTest(choice=choice), \ + patch.object(updater, 'BUNDLE', self.bundle), \ + patch.object(updater, 'files_current', return_value=True), \ + patch.object(updater, 'active', return_value=False), \ + patch.object(updater, 'component_paths', return_value=[]), \ + patch.object(updater, 'run') as run, \ + patch.object(updater.sys.stdin, 'isatty', return_value=True), \ + patch('builtins.input', side_effect=answers), patch('builtins.print'): + updater.review() + run.assert_not_called() + + def test_unlisted_file_is_rejected(self): + (self.bundle / 'extra').write_text('unreviewed') + with self.assertRaisesRegex(RuntimeError, 'unexpected'): + updater.manifest(self.bundle) + + def test_touch_id_result_waits_for_acknowledgement_and_preserves_failure(self): + for interactive in (True, False): + for status in (0, 1, 130): + with self.subTest(interactive=interactive, status=status): + events = [] + def touch_id(args): + self.assertEqual(args, ['/usr/local/bin/try-omarchy-touch-id']) + events.append('result') + if status: + raise subprocess.CalledProcessError(status, args) + def answer(prompt): + if 'Choose' in prompt: + return '2' + self.assertIn('Press Enter', prompt) + events.append('acknowledge') + return '' + with patch.object(updater, 'BUNDLE', self.bundle), \ + patch.object(updater, 'files_current', return_value=True), \ + patch.object(updater, 'active', return_value=False), \ + patch.object(updater, 'run', side_effect=touch_id), \ + patch.object(updater.sys.stdin, 'isatty', return_value=interactive), \ + patch('builtins.input', side_effect=answer), patch('builtins.print'): + if status: + with self.assertRaises(subprocess.CalledProcessError) as error: + updater.review() + self.assertEqual(error.exception.returncode, status) + else: + updater.review() + self.assertEqual(events, ['result', 'acknowledge'] if interactive else ['result']) + + def test_future_bundle_is_not_installed_by_old_updater(self): + path = self.bundle / 'manifest.json' + data = json.loads(path.read_text()) + data['version'] = 2 + path.write_text(json.dumps(data)) + with self.assertRaisesRegex(RuntimeError, 'newer updater'): + updater.manifest(self.bundle) + +if __name__ == '__main__': + unittest.main() diff --git a/guest/tests/test_touch_id_command.py b/guest/tests/test_touch_id_command.py index 1ffc4c84..04bb370a 100644 --- a/guest/tests/test_touch_id_command.py +++ b/guest/tests/test_touch_id_command.py @@ -28,8 +28,11 @@ def test_password_fallback_is_disabled_and_failure_is_propagated(self) -> None: capture_output=True, text=True, ) self.assertEqual(result.returncode, status) + self.assertIn("Password fallback is disabled for this test", result.stdout) if status: - self.assertIn("check failed", result.stderr) - self.assertEqual(result.stdout, "") + self.assertIn("Touch ID authentication did not complete", result.stderr) + self.assertIn("guest password remains available for normal sudo", result.stderr) + self.assertNotIn("Test passed", result.stdout) else: self.assertIn("without a guest password", result.stdout) + self.assertIn("If you approved the Touch ID prompt", result.stdout) diff --git a/integrations/DESIGN.md b/integrations/DESIGN.md new file mode 100644 index 00000000..d83cc3b6 --- /dev/null +++ b/integrations/DESIGN.md @@ -0,0 +1,68 @@ +# Existing VM integration updates + +Status: standalone integration delivery on current upstream; initial payload is sudo Touch ID support. + +The app bundles a reviewed integration payload independently of the factory disk. +A dedicated read-only 9p share (tryomarchy-updates) exposes it to old guests. +Users approve the first mount/install inside the guest with their Linux password. +No SSH, personal folder sharing, disk mutation from macOS, or typed-command +injection is required. The launcher offers the exact bootstrap command to copy. + +A root-owned guest service reports bounded JSON over a dedicated virtio port. +The host checks on every boot, retains last-known status alongside that VM's disk, +and distinguishes waiting, no response, updates available, and reported current. +Guest messages are advisory: they cannot select host paths or execute host code. +An absent response does not prove absence of the bootstrap. Installations remain +explicitly approved; biometric enrollment is never automatic. + +The initial migration installs upstream sudo Touch ID support. Pending 1Password, +clock-recovery, and package-hold repair features are excluded. A root-owned installed bundle and root-private +journal support verification and retry after interrupted installation. Existing +configuration and service backups are retained. User modifications to managed +files require review instead of silent replacement. Kernel and graphics package +replacement are excluded. + +Validation must cover an old guest with no agent, current/older/newer agents, +malformed/oversized status, interrupted and repeated updates, per-VM state, +customized files, disabled optional features, and an actual old-guest bootstrap. + +## Implemented user journey + +The launcher has a VM integrations row even before bootstrap. Review opens +instructions and a Copy setup command button. It mounts only the app's dedicated +read-only bundle and starts a guest terminal guide. The guide inventories the +supported integration and offers a separate sudo Touch ID pairing/test action. Linux sudo authorization +occurs only after the review confirmation. sudo biometric pairing remains a +separate explicit action. + +A live status menu appears on macOS while QEMU runs. It starts at Checking and +receives guest reports every ten seconds. At 120 seconds without a report it +shows Setup or repair needed; it continues listening for a late boot or repair. +The persisted result is labeled Last check in the launcher. Guest time is never +used to determine freshness. State lives at the VM storage root, keyed by the working disk's file identity, +so reset and alternate VM locations do not inherit another VM's result. The +strict disk-directory inventory remains unchanged. + +Bundle identity covers the exact payload inventory and hashes. The app signature +covers the distributed manifest and files. Hash checking detects corruption; it +is not a substitute for trusting the app supplying the bundle. Guest sudo is an +explicit approval to install that app's code. The status channel cannot request +installation or host actions. + +Progress is committed after each verified migration; retries skip completed, +still-healthy steps. Partial failures are reported and retain backups. This is +resumable installation, not a claim of transactional rollback of arbitrary +systemd/PAM effects. The old bundle is retained when the installed bundle changes. + +## Validation and release gates + +Validate old-guest bootstrap, cancellation, installation, interrupted retry, +configuration and enrollment preservation, and status after a restart. Verify +that the initial bundle contains only installers and support accepted upstream. +Factory package-lock changes are outside this PR. Fresh-image generation uses +that same bundle, so its first report must match the bundled identity. + +Build and test the current branch, and distinguish a clean source build from +app packaging that reuses a released guest or runtime. Biometric enrollment and +actual authorization are separate from installing support and remain explicit +user actions; a report of current files does not prove an authorized Touch ID use. diff --git a/integrations/build-bundle.py b/integrations/build-bundle.py new file mode 100755 index 00000000..f297678a --- /dev/null +++ b/integrations/build-bundle.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Package reviewed guest integrations independently of the factory image.""" +import hashlib +import json +from pathlib import Path +import shutil +import sys + +ROOT = Path(__file__).resolve().parents[1] +FILES = [ + 'scripts/install-touch-id-sudo.sh', 'scripts/install-touch-id-menu-entry.py', + 'native-overlay/usr/local/lib/try-omarchy/native-authentication-broker', + 'native-overlay/usr/local/sbin/try-omarchy-touch-id-enroll', + 'native-overlay/usr/local/sbin/try-omarchy-touch-id-control', + 'native-overlay/usr/local/bin/try-omarchy-touch-id', + 'native-overlay/usr/local/bin/try-omarchy-touch-id-test', + 'native-overlay/etc/udev/rules.d/93-omarchy-native-authentication.rules', +] + + +def build(destination): + destination.mkdir(parents=True, exist_ok=False) + for name in FILES: + target = destination / 'guest' / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / 'guest' / name, target) + for name in ('updater.py', 'setup', 'try-omarchy-integrations', 'try-omarchy-integrations.service'): + shutil.copy2(ROOT / 'integrations' / name, destination / name) + files = {str(p.relative_to(destination)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(destination.rglob('*')) if p.is_file()} + identity = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest() + (destination / 'manifest.json').write_text(json.dumps({ + 'schema': 1, 'version': 1, 'identity': identity, 'files': files, + }, sort_keys=True, indent=2) + '\n') + +if __name__ == '__main__': + build(Path(sys.argv[1])) diff --git a/integrations/setup b/integrations/setup new file mode 100755 index 00000000..622bc3dd --- /dev/null +++ b/integrations/setup @@ -0,0 +1,3 @@ +#!/bin/bash +set -euo pipefail +exec /usr/bin/python3 -I "$(dirname "$0")/updater.py" review diff --git a/integrations/try-omarchy-integrations b/integrations/try-omarchy-integrations new file mode 100755 index 00000000..702e95a3 --- /dev/null +++ b/integrations/try-omarchy-integrations @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail +# Mount only the app's dedicated read-only bundle, never a personal shared folder. +mountpoint=/mnt/try-omarchy-updates +if ! mountpoint -q "$mountpoint"; then + sudo mkdir -p "$mountpoint" + sudo mount -t 9p -o trans=virtio,version=9p2000.L,ro tryomarchy-updates "$mountpoint" +fi +exec /usr/bin/python3 -I "$mountpoint/updater.py" review diff --git a/integrations/try-omarchy-integrations.service b/integrations/try-omarchy-integrations.service new file mode 100644 index 00000000..d44bf271 --- /dev/null +++ b/integrations/try-omarchy-integrations.service @@ -0,0 +1,16 @@ +[Unit] +Description=Report Try Omarchy integration status +After=systemd-udev-settle.service +ConditionPathExists=/dev/virtio-ports/dev.tryomarchy.integrations + +[Service] +ExecStart=/usr/bin/python3 -I /usr/local/share/try-omarchy/integrations/updater.py report +Restart=on-failure +RestartSec=5 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target diff --git a/integrations/updater.py b/integrations/updater.py new file mode 100644 index 00000000..44b5b889 --- /dev/null +++ b/integrations/updater.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Review, install, and report the guest integrations bundled with Try Omarchy.""" +import fcntl +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import pwd +import shutil +import stat +import subprocess +import sys +import tempfile +import time + +sys.dont_write_bytecode = True +BUNDLE = Path(__file__).resolve().parent +STORE = Path('/usr/local/share/try-omarchy/integrations') +STATE = Path('/var/lib/try-omarchy/integrations') +PORT = Path('/dev/virtio-ports/dev.tryomarchy.integrations') +COMPONENTS = { + 'sudo': ('Touch ID support for sudo (pairing remains optional)', 'install-touch-id-sudo.sh'), +} + + +def run(args, **kwargs): + kwargs.setdefault('check', True) + return subprocess.run(args, **kwargs) + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def manifest(directory): + data = json.loads((directory / 'manifest.json').read_text()) + if data.get('schema') != 1 or data.get('version') != 1 or not isinstance(data.get('files'), dict): + raise RuntimeError('This integration bundle needs a newer updater.') + if not 1 <= len(data['files']) <= 100: + raise RuntimeError('Invalid integration file inventory.') + actual = set() + for entry in directory.rglob('*'): + if entry.is_symlink() or not (entry.is_file() or entry.is_dir()): + raise RuntimeError('Integration bundle contains a symlink or special file.') + if entry.is_file() and entry.name != 'manifest.json': + actual.add(str(entry.relative_to(directory))) + if actual != set(data['files']): + raise RuntimeError('Integration bundle has unexpected or missing files.') + for name, expected in data['files'].items(): + relative = Path(name) + if relative.is_absolute() or '..' in relative.parts or not isinstance(expected, str): + raise RuntimeError('Invalid integration file path.') + path = directory / relative + if any(p.is_symlink() for p in [path, *path.parents] if p != directory.parent): + raise RuntimeError('Integration bundle contains a symlink.') + if not path.is_file() or digest(path) != expected: + raise RuntimeError(f'Integration bundle verification failed: {name}') + identity = hashlib.sha256(json.dumps(data['files'], sort_keys=True).encode()).hexdigest() + if identity != data.get('identity'): + raise RuntimeError('Integration manifest identity mismatch.') + return data + + +def atomic_json(path, value): + fd, temporary = tempfile.mkstemp(dir=path.parent) + try: + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream, sort_keys=True) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def safe_destination(path): + for parent in [path, *path.parents]: + if parent.is_symlink(): + raise RuntimeError(f'Refusing symlink destination: {path}') + if parent.exists(): + info = parent.stat() + if info.st_uid != 0 or info.st_mode & 0o022: + raise RuntimeError(f'Destination must be root-owned and not user-writable: {parent}') + + +def component_paths(name, directory=BUNDLE): + overlay = directory / 'guest/native-overlay' + if name == 'sudo': + names = ['usr/local/lib/try-omarchy/native-authentication-broker', + 'usr/local/sbin/try-omarchy-touch-id-enroll', + 'usr/local/sbin/try-omarchy-touch-id-control', + 'usr/local/bin/try-omarchy-touch-id', 'usr/local/bin/try-omarchy-touch-id-test', + 'etc/udev/rules.d/93-omarchy-native-authentication.rules'] + else: + raise RuntimeError('Unsupported integration component: ' + name) + return [(overlay / name, Path('/') / name) for name in names] + + +def files_current(name, directory=BUNDLE): + return all(not target.is_symlink() and target.is_file() and digest(source) == digest(target) + and target.stat().st_uid == 0 and not target.stat().st_mode & 0o022 + and stat.S_IMODE(target.stat().st_mode) == stat.S_IMODE(source.stat().st_mode) + for source, target in component_paths(name, directory)) + + +def active(unit): + return subprocess.run(['systemctl', 'is-active', '--quiet', unit], check=False).returncode == 0 + + +def guest_status(loaded_identity=None): + data = manifest(BUNDLE) + installed = json.loads((STATE / 'state.json').read_text()) if (STATE / 'state.json').exists() else {} + progress = json.loads((STATE / 'progress.json').read_text()) if (STATE / 'progress.json').exists() else {'status': 'complete'} + components = {'bootstrap': 'current' if progress.get('status') == 'complete' else 'repair'} + if loaded_identity is not None and loaded_identity != data['identity']: + components['bootstrap'] = 'repair' + for name in COMPONENTS: + try: + components[name] = 'current' if files_current(name) else 'repair' + except (OSError, ValueError): + components[name] = 'repair' + return {'schema': 1, 'version': data['version'], 'identity': loaded_identity or data['identity'], + 'components': components, 'paired': Path('/var/lib/try-omarchy/native-authentication.json').is_file()} + + +def report(): + loaded_identity = manifest(BUNDLE)['identity'] + while True: + with PORT.open('wb', buffering=0) as channel: + while True: + channel.write(json.dumps(guest_status(loaded_identity), separators=(',', ':')).encode() + b'\n') + time.sleep(10) + + +def verify_upgrade(installed_directory, incoming_directory): + if not (installed_directory / 'manifest.json').is_file(): + return + installed = manifest(installed_directory) + incoming = manifest(incoming_directory) + if set(installed['files']) - set(incoming['files']): + raise RuntimeError('The installed bundle contains additional integrations. Use a matching or newer app; this bundle will not remove their support.') + + +def install(user, selected): + if os.geteuid() != 0: + raise RuntimeError('Installation requires the guest administrator password.') + if 'omarchy.qemu_virgl=1' not in Path('/proc/cmdline').read_text().split(): + raise RuntimeError('Run this installer inside Try Omarchy.') + account = pwd.getpwnam(user) + if account.pw_uid == 0 or os.environ.get('SUDO_UID') != str(account.pw_uid): + raise RuntimeError('Run this command with sudo from your normal Omarchy account.') + manifest(BUNDLE) + for path in (STATE, STORE, Path('/usr/local/bin/try-omarchy-integrations'), + Path('/usr/lib/systemd/system/try-omarchy-integrations.service')): + safe_destination(path) + verify_upgrade(STORE, BUNDLE) + STATE.mkdir(parents=True, exist_ok=True, mode=0o700) + with (STATE / 'install.lock').open('w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + if Path('/var/lib/pacman/db.lck').exists(): + raise RuntimeError('Close the package updater before installing integrations.') + # Stage a private, verified copy before any privileged installer executes. + stage = Path(tempfile.mkdtemp(prefix='bundle-', dir=STATE)) + shutil.copytree(BUNDLE, stage / 'payload', symlinks=True) + payload = stage / 'payload' + data = manifest(payload) + for path in payload.rglob('*'): + os.chown(path, 0, 0) + path.chmod(0o755 if path.is_dir() or path.stat().st_mode & 0o111 else 0o644) + state_path = STATE / 'state.json' + state = json.loads(state_path.read_text()) if state_path.exists() else {'completed': {}} + for name in selected: + # Resume only a verified completed step; a receipt alone is insufficient. + try: + healthy = files_current(name, payload) + except (OSError, ValueError): + healthy = False + if state['completed'].get(name) == data['identity'] and healthy: + print(f'{name}: already verified; retained.') + continue + atomic_json(STATE / 'progress.json', {'component': name, 'status': 'installing'}) + backup = Path(tempfile.mkdtemp(prefix=f'{name}-backup-', dir=STATE)) + for source, target in component_paths(name, payload): + safe_destination(target) + if target.exists(): + dest = backup / str(target).lstrip('/') + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(target, dest) + script = payload / 'guest/scripts' / COMPONENTS[name][1] + args = ['/bin/bash', str(script)] + environment = os.environ.copy() + if name == 'sudo': + # The existing installer otherwise invokes a user helper inside + # our root-private staging tree. Publish menus only after STORE. + environment['SUDO_USER'] = 'root' + run(args, env=environment) + if not files_current(name, payload): + raise RuntimeError(f'{name}: installed files did not pass verification. Backup: {backup}') + state['completed'][name] = data['identity'] + atomic_json(state_path, state) + STORE.parent.mkdir(parents=True, exist_ok=True) + replacement = Path(tempfile.mkdtemp(prefix='.integrations-', dir=STORE.parent)) / 'bundle' + shutil.copytree(payload, replacement) + manifest(replacement) + # Keep the previous bundle so an interrupted replacement can be repaired. + if STORE.exists(): + STORE.rename(stage / 'previous-bundle') + try: + replacement.rename(STORE) + except OSError: + if not STORE.exists() and (stage / 'previous-bundle').exists(): + (stage / 'previous-bundle').rename(STORE) + raise + for source, target in [('try-omarchy-integrations', '/usr/local/bin/try-omarchy-integrations'), + ('try-omarchy-integrations.service', '/usr/lib/systemd/system/try-omarchy-integrations.service')]: + shutil.copy2(payload / source, target) + # Preserve existing user menu entries, including the sudo entry just installed. + run(['runuser', '-u', user, '--', '/usr/bin/python3', '-I', str(STORE / 'updater.py'), 'menu-install']) + run(['systemctl', 'daemon-reload']) + run(['systemctl', 'enable', '--now', 'try-omarchy-integrations.service']) + run(['systemctl', 'restart', 'try-omarchy-integrations.service']) + atomic_json(STATE / 'progress.json', {'status': 'complete'}) + print('Integrations installed and checked. Backups retained in ' + str(STATE)) + print('Open Omarchy Menu > Setup > Try Omarchy Integrations for updates and optional features.') + + +def menu_entry(path=None, refresh=True): + spec = importlib.util.spec_from_file_location('integration_menu', BUNDLE / 'guest/scripts/install-touch-id-menu-entry.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + destination = path or Path.home() / '.config/omarchy/extensions/omarchy-menu.jsonc' + module.install(destination) + module.ENTRY_ID = '"setup.try-omarchy-integrations"' + module.ENTRY = ' "setup.try-omarchy-integrations": {"label":"Try Omarchy Integrations","action":"setsid uwsm-app -- xdg-terminal-exec --app-id=org.omarchy.terminal --title=Try-Omarchy-Integrations -e /usr/local/bin/try-omarchy-integrations"},\n' + previous_entry = ' "setup.try-omarchy-integrations": {"label":"Try Omarchy Integrations","action":"omarchy-launch-floating-terminal-with-presentation /usr/local/bin/try-omarchy-integrations"},\n' + module.install(destination, previous_entry=previous_entry) + if refresh: + environment = os.environ.copy() + environment.setdefault('OMARCHY_PATH', str(Path.home() / '.local/share/omarchy')) + result = run(['omarchy', 'menu', 'refresh'], env=environment, check=False, capture_output=True, text=True) + if result.returncode: + detail = (result.stderr or result.stdout).strip() + print(f'Menu entries saved; live refresh deferred: {detail}. They will load when the Omarchy shell starts.') + + +def review(): + manifest(BUNDLE) + print('\nTry Omarchy Integrations\n') + for name in COMPONENTS: + try: + state = 'installed' if files_current(name) else 'available or needs repair' + except (OSError, ValueError): + state = 'available or needs repair' + print(f' {COMPONENTS[name][0]}: {state}') + print('\n1. Install/update integration support\n2. Set up or test Touch ID for sudo\n3. Exit') + choice = input('\nChoose [1-3]: ').strip() + if choice == '2': + try: + run(['/usr/local/bin/try-omarchy-touch-id']) + finally: + if sys.stdin.isatty(): + input('\nPress Enter to close the Touch ID result…') + return + if choice != '1': + return + selected = list(COMPONENTS) + print('\nExisting integration files may be replaced; backups will be retained.') + for name in selected: + for source, target in component_paths(name): + if target.is_file() and digest(source) != digest(target): + print(' Replace with bundled version: ' + str(target)) + print('Your VM, applications, and personal files will be retained. No OS packages will be upgraded.') + if input('Continue with installation? [y/N] ').strip().lower() != 'y': + return + user = pwd.getpwuid(os.getuid()).pw_name + try: + run(['sudo', '/usr/bin/python3', '-I', str(BUNDLE / 'updater.py'), 'install', user, *selected]) + menu_entry() + except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error: + print('Integration setup could not complete: ' + str(error), file=sys.stderr) + print('Review the messages above before closing. Resolve the reported problem and retry.', file=sys.stderr) + raise + finally: + if sys.stdin.isatty(): + input('\nPress Enter to close the installation result…') + + +if __name__ == '__main__': + try: + action = sys.argv[1] if len(sys.argv) > 1 else 'review' + if action == 'report': + report() + elif action == 'review': + review() + elif action == 'menu': + menu_entry() + elif action == 'menu-install': + menu_entry(refresh=False) + elif action == 'stage-menu' and len(sys.argv) == 3: + menu_entry(Path(sys.argv[2]), refresh=False) + elif action == 'install' and len(sys.argv) >= 4 and all(s in COMPONENTS for s in sys.argv[3:]): + install(sys.argv[2], sys.argv[3:]) + else: + raise RuntimeError('Unknown integration action.') + except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error: + print('Integration setup could not complete: ' + str(error), file=sys.stderr) + print('Previous files and progress are retained. Resolve the reported problem and retry.', file=sys.stderr) + sys.exit(1) diff --git a/macos/Sources/OmarchyVMHelper/GuestIntegrationStatus.swift b/macos/Sources/OmarchyVMHelper/GuestIntegrationStatus.swift new file mode 100644 index 00000000..6d5a6c8a --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/GuestIntegrationStatus.swift @@ -0,0 +1,255 @@ +import AppKit +import Darwin +import Foundation + +struct GuestIntegrationReport: Codable, Equatable { + let schema: Int + let version: Int + let identity: String + let components: [String: String] + let paired: Bool + + static func decode(_ data: Data) throws -> Self { + guard data.count <= 4096 else { throw HelperError.io("integration status exceeds limit") } + let value = try JSONDecoder().decode(Self.self, from: data) + let allowed = Set(["bootstrap", "sudo", "clock", "holds", "onepassword"]) + guard value.schema == 1, value.version > 0, value.version <= 100000, + value.identity.count == 64, + value.identity.allSatisfy({ "0123456789abcdef".contains($0) }), + Set(value.components.keys).isSubset(of: allowed), + Set(["bootstrap", "sudo"]).isSubset(of: Set(value.components.keys)), + value.components.values.allSatisfy({ ["current", "repair", "disabled"].contains($0) }) else { + throw HelperError.io("invalid integration status") + } + return value + } + + func needsReview(expectedIdentity: String) -> Bool { + version <= 1 && Set(components.keys).isSubset(of: Set(["bootstrap", "sudo"])) + && (identity != expectedIdentity || components.values.contains("repair")) + } + + func summary(expectedIdentity: String?) -> String { + if version > 1 { return "Newer guest integration version" } + guard let expectedIdentity else { return "Bundle status unavailable" } + if !Set(components.keys).isSubset(of: Set(["bootstrap", "sudo"])) { + return "Additional guest integrations · use matching app" + } + if identity != expectedIdentity { return "Updates available" } + if components.values.contains("repair") { return "Repair available" } + if !paired { return "Current · Touch ID setup available" } + return "Up to date" + } +} + +struct GuestIntegrationCache: Codable { + let checkedAt: Date + let state: String + let report: GuestIntegrationReport? + + static func url(storageRoot: URL?) -> URL? { + guard let storageRoot, + let attributes = try? FileManager.default.attributesOfItem( + atPath: storageRoot.appendingPathComponent("disks/current/rootfs.ext4").path), + let inode = attributes[.systemFileNumber] as? NSNumber else { return nil } + return storageRoot.appendingPathComponent("integration-status-\(inode.uint64Value).json") + } + + static func read(_ url: URL?) -> Self? { + guard let url, let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + attributes[.type] as? FileAttributeType == .typeRegular, + (attributes[.size] as? NSNumber)?.intValue ?? 100000 > 0, + (attributes[.size] as? NSNumber)?.intValue ?? 100000 <= 8192, + let data = try? Data(contentsOf: url), + let value = try? JSONDecoder().decode(Self.self, from: data) else { return nil } + if let report = value.report, + (try? GuestIntegrationReport.decode(JSONEncoder().encode(report))) == nil { return nil } + return value + } + + static var bundledIdentity: String? { + guard let url = Bundle.main.resourceURL?.appendingPathComponent("integrations/manifest.json"), + let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return object["identity"] as? String + } + + var summary: String { + if state == "no-response" { return "Setup or repair may be needed" } + if state == "checking" { return "Check incomplete · retry on launch" } + return report?.summary(expectedIdentity: Self.bundledIdentity) ?? "Not checked yet" + } +} + +@MainActor +enum GuestIntegrationSetup { + static let command = "sudo mkdir -p /mnt/try-omarchy-updates && (mountpoint -q /mnt/try-omarchy-updates || sudo mount -t 9p -o trans=virtio,version=9p2000.L,ro tryomarchy-updates /mnt/try-omarchy-updates) && bash /mnt/try-omarchy-updates/setup" + + static func show(window: NSWindow? = nil) { + let alert = NSAlert() + alert.messageText = "Review VM integrations" + alert.informativeText = "Inside Omarchy, open Setup > Try Omarchy Integrations. If that entry is missing, copy the command below and paste it into an Omarchy terminal.\n\nReview and install sudo Touch ID support before pairing. Have your Linux password ready. Your existing VM is preserved." + alert.addButton(withTitle: "Copy setup command") + alert.addButton(withTitle: "Close") + let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 440, height: 64)) + scroll.hasVerticalScroller = true + scroll.borderType = .bezelBorder + let field = NSTextView(frame: scroll.contentView.bounds) + field.string = command + field.isEditable = false + field.isSelectable = true + field.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + field.textContainerInset = NSSize(width: 6, height: 6) + field.autoresizingMask = [.width] + field.textContainer?.widthTracksTextView = true + scroll.documentView = field + alert.accessoryView = scroll + let completion: (NSApplication.ModalResponse) -> Void = { response in + if response == .alertFirstButtonReturn { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + } + } + if let window { alert.beginSheetModal(for: window, completionHandler: completion) } + else { completion(alert.runModal()) } + } +} + +@MainActor +final class GuestIntegrationBridge: NSObject { + private let descriptor: Int32 + private let cacheURL: URL + private var item: NSStatusItem? + private var timer: Timer? + private var buffer = Data() + private var discardingLine = false + private let started = ProcessInfo.processInfo.systemUptime + private var lastResponse: TimeInterval? + private var lastState = "" + private var latestReport: GuestIntegrationReport? + private var offeredReview = false + private let targetIdentity: KernelProcessIdentity + + init(targetPID: pid_t, socketPath: String, cachePath: String) throws { + guard let identity = KernelProcessIdentity.capture(processIdentifier: targetPID), identity.isQEMUSystemProcess else { + throw HelperError.io("integration target is not QEMU") + } + self.targetIdentity = identity + cacheURL = URL(fileURLWithPath: cachePath) + var info = stat() + let parent = cacheURL.deletingLastPathComponent().path + guard lstat(parent, &info) == 0, info.st_mode & S_IFMT == S_IFDIR, + info.st_uid == getuid(), info.st_mode & 0o022 == 0 else { + throw HelperError.io("integration cache directory is not private to this user") + } + descriptor = try NativeBridgeSocket.connectSecure(path: socketPath, label: "integration status") + _ = fcntl(descriptor, F_SETFL, O_NONBLOCK) + super.init() + } + + func run() { + NSApp.setActivationPolicy(.accessory) + item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + item?.button?.image = NSImage(systemSymbolName: "puzzlepiece.extension", accessibilityDescription: "VM integrations") + item?.button?.image?.isTemplate = true + let menu = NSMenu() + let status = NSMenuItem(title: "Checking…", action: nil, keyEquivalent: "") + status.isEnabled = false + menu.addItem(status) + menu.addItem(.separator()) + let review = NSMenuItem(title: "Review VM integrations…", action: #selector(review), keyEquivalent: "") + review.target = self + menu.addItem(review) + item?.menu = menu + save(state: "checking", report: nil) + timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.tick() } + } + if let timer { + RunLoop.main.add(timer, forMode: .common) + RunLoop.main.add(timer, forMode: .modalPanel) + } + NSApp.run() + Darwin.close(descriptor) + } + + @objc private func review() { GuestIntegrationSetup.show() } + + private func save(state: String, report: GuestIntegrationReport?) { + let summary = report?.summary(expectedIdentity: GuestIntegrationCache.bundledIdentity) + ?? (state == "checking" ? "Checking…" : "Setup or repair needed") + item?.menu?.items.first?.title = summary + item?.button?.toolTip = "VM integrations: \(summary)" + item?.button?.setAccessibilityLabel("VM integrations: \(summary)") + let value = GuestIntegrationCache(checkedAt: Date(), state: state, report: report) + if let data = try? JSONEncoder().encode(value) { + do { try data.write(to: cacheURL, options: .atomic) } + catch { fputs("[integrations] Could not retain status: \(error.localizedDescription)\n", stderr) } + } + lastState = state + latestReport = report + } + + private func offerReviewIfNeeded() { + guard !offeredReview, let expected = GuestIntegrationCache.bundledIdentity else { return } + let elapsed = ProcessInfo.processInfo.systemUptime - started + let needsReview = lastState == "no-response" + || (latestReport.map { $0.needsReview(expectedIdentity: expected) } ?? false) + guard needsReview, elapsed >= 30 else { return } + let noticeURL = cacheURL.deletingPathExtension().appendingPathExtension("notice") + if let attributes = try? FileManager.default.attributesOfItem(atPath: noticeURL.path), + (attributes[.size] as? NSNumber)?.intValue == 64, + let data = try? Data(contentsOf: noticeURL), data == Data(expected.utf8) { + offeredReview = true + return + } + offeredReview = true + // A repeating timer cannot fire again while its own callback presents a modal. + DispatchQueue.main.async { [weak self] in + self?.presentReview(expected: expected, noticeURL: noticeURL) + } + } + + private func presentReview(expected: String, noticeURL: URL) { + guard targetIdentity.isStillRunning else { return } + let alert = NSAlert() + alert.messageText = "Review your VM integrations" + alert.informativeText = lastState == "no-response" + ? "This VM has not answered its integration check. It may still be starting, or may need the setup included with this app. You can add new features without resetting your VM." + : "This app includes integration updates or repairs for your existing VM. Review them inside Omarchy when you are ready. Installation needs your Linux password." + alert.addButton(withTitle: "Review setup") + alert.addButton(withTitle: "Later") + NSApp.activate() + let response = alert.runModal() + do { try Data(expected.utf8).write(to: noticeURL, options: .atomic) } + catch { fputs("[integrations] Could not retain review preference.\n", stderr) } + if response == .alertFirstButtonReturn { GuestIntegrationSetup.show() } + } + + private func tick() { + if !targetIdentity.isStillRunning { NSApp.terminate(nil); return } + var bytes = [UInt8](repeating: 0, count: 4096) + let count = Darwin.read(descriptor, &bytes, bytes.count) + if count > 0 { + for byte in bytes.prefix(count) { + if byte == 10 { + if !discardingLine, let report = try? GuestIntegrationReport.decode(buffer) { + lastResponse = ProcessInfo.processInfo.systemUptime + save(state: "reported", report: report) + } + buffer.removeAll(keepingCapacity: true) + discardingLine = false + } else if !discardingLine { + buffer.append(byte) + if buffer.count > 4096 { + discardingLine = true + buffer.removeAll(keepingCapacity: true) + } + } + } + } + let elapsed = ProcessInfo.processInfo.systemUptime - (lastResponse ?? started) + if elapsed > 120 && lastState != "no-response" { save(state: "no-response", report: nil) } + offerReviewIfNeeded() + } +} diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index 07501faa..9ef9e94c 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -170,6 +170,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private var networkEditor: NetworkEditor? private let immersiveMode: () -> Bool private let setImmersiveMode: (Bool) -> Void + private let integrationCacheURL: () -> URL? private let launch: () -> Void private let canResetStorage: Bool private let storageLocation: () -> String? @@ -257,6 +258,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { saveNetworkPreferences: @escaping (VMNetworkPreferences) -> String? = { _ in nil }, immersiveMode: @escaping () -> Bool = { true }, setImmersiveMode: @escaping (Bool) -> Void = { _ in }, + integrationCacheURL: @escaping () -> URL? = { nil }, launch: @escaping () -> Void ) { self.accessibilityStatus = accessibilityStatus @@ -286,6 +288,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { self.saveNetworkPreferences = saveNetworkPreferences self.immersiveMode = immersiveMode self.setImmersiveMode = setImmersiveMode + self.integrationCacheURL = integrationCacheURL self.launch = launch window = NSWindow( @@ -441,6 +444,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { return false } + @objc private func reviewIntegrations() { GuestIntegrationSetup.show(window: window) } + private func render() { let preservedScrollOffset = startMenuScrollView?.contentView.bounds.minY ?? 0 startMenuScrollView = nil @@ -638,6 +643,13 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { integrationRowViews.append(storageRow) } integrationRowViews.append(contentsOf: [resourceRow, networkingRow, portForwardingRow, immersiveRow]) + let integrationStatus = GuestIntegrationCache.read(integrationCacheURL()) + integrationRowViews.insert(permissionRow( + symbolName: "arrow.triangle.2.circlepath", title: "VM integrations", + detail: "Last check: \(integrationStatus?.summary ?? "Not checked yet"). Checked again after each VM launch.", + granted: false, statusLabels: ("", ""), + actions: [("REVIEW…", #selector(reviewIntegrations))] + ), at: 0) var permissionRowsAndSeparators: [NSView] = [] for (index, row) in permissionRowViews.enumerated() { diff --git a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift index 50a44e7b..ab5a136a 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -226,6 +226,12 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { FullscreenPreferences(isImmersive: isImmersive) ) }, + integrationCacheURL: { [weak self] in + guard let self else { return nil } + return GuestIntegrationCache.url(storageRoot: QEMUGPUStorageSpaceEstimate.storageRootURL( + environment: self.baseEnvironment, preference: self.storageLocationStore.load() + )) + }, launch: { [weak self] in self?.startVirtualMachine() } diff --git a/macos/Sources/OmarchyVMHelper/main.swift b/macos/Sources/OmarchyVMHelper/main.swift index cd782e63..fcb6c9ee 100644 --- a/macos/Sources/OmarchyVMHelper/main.swift +++ b/macos/Sources/OmarchyVMHelper/main.swift @@ -51,6 +51,16 @@ do { } dispatchMain() } + if arguments.first == "--bridge-integrations" { + guard arguments.count == 4, let pid = Int32(arguments[1]), pid > 1 else { usage() } + NSApplication.shared.setActivationPolicy(.accessory) + try MainActor.assumeIsolated { + let bridge = try GuestIntegrationBridge(targetPID: pid, socketPath: arguments[2], cachePath: arguments[3]) + bridge.run() + } + exit(0) + } + if arguments.first == "--bridge-native-audio" { guard arguments.count == 4, let processIdentifier = Int32(arguments[1]), diff --git a/macos/Tests/OmarchyVMHelperTests/GuestIntegrationStatusTests.swift b/macos/Tests/OmarchyVMHelperTests/GuestIntegrationStatusTests.swift new file mode 100644 index 00000000..9ba3a076 --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/GuestIntegrationStatusTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing +@testable import OmarchyVMHelper + +@Suite("Guest integration status") +struct GuestIntegrationStatusTests { + private let identity = String(repeating: "a", count: 64) + + @Test("Only a complete current report can be up to date") + func states() throws { + let current = GuestIntegrationReport(schema: 1, version: 1, identity: identity, + components: ["bootstrap": "current", "sudo": "current"], paired: true) + let decoded = try GuestIntegrationReport.decode(JSONEncoder().encode(current)) + #expect(decoded.summary(expectedIdentity: identity) == "Up to date") + #expect(decoded.summary(expectedIdentity: String(repeating: "b", count: 64)) == "Updates available") + #expect(decoded.summary(expectedIdentity: nil) == "Bundle status unavailable") + let unpaired = GuestIntegrationReport(schema: 1, version: 1, identity: identity, + components: current.components, paired: false) + #expect(unpaired.summary(expectedIdentity: identity).contains("setup available")) + let repair = GuestIntegrationReport(schema: 1, version: 1, identity: identity, + components: ["bootstrap": "current", "sudo": "repair"], paired: true) + #expect(repair.summary(expectedIdentity: identity) == "Repair available") + } + + @Test("Additional guest integrations are not offered a smaller bundle") + func additionalIntegrations() { + let report = GuestIntegrationReport(schema: 1, version: 1, identity: identity, + components: ["bootstrap": "current", "sudo": "current", "clock": "current"], paired: true) + #expect(report.summary(expectedIdentity: "different").contains("use matching app")) + #expect(!report.needsReview(expectedIdentity: "different")) + } + + @Test("Malformed and incomplete guest reports cannot establish status") + func malformed() { + for raw in ["{}", "[]", String(repeating: "x", count: 4097), + "{\"schema\":1,\"version\":1,\"identity\":\"\(identity)\",\"components\":{},\"paired\":true}"] { + #expect(throws: (any Error).self) { try GuestIntegrationReport.decode(Data(raw.utf8)) } + } + } + + @Test("Newer protocol versions do not trigger a downgrade claim") + func newer() { + let report = GuestIntegrationReport(schema: 1, version: 2, identity: identity, + components: ["bootstrap": "current", "sudo": "current"], paired: true) + #expect(report.summary(expectedIdentity: "older") == "Newer guest integration version") + } + + @Test("Cache stays outside disk inventory and changes when a disk is replaced") + func diskIdentity() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let directory = root.appendingPathComponent("disks/current") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let disk = directory.appendingPathComponent("rootfs.ext4") + try Data("first disk".utf8).write(to: disk) + let first = try #require(GuestIntegrationCache.url(storageRoot: root)) + #expect(first.deletingLastPathComponent().path == root.path) + try FileManager.default.moveItem(at: disk, to: root.appendingPathComponent("retained-disk")) + try Data("new disk".utf8).write(to: disk) + let second = try #require(GuestIntegrationCache.url(storageRoot: root)) + #expect(first != second) + } + + @Test("No response remains distinct from missing bootstrap") + func missing() { + let cache = GuestIntegrationCache(checkedAt: Date(), state: "no-response", report: nil) + #expect(cache.summary == "Setup or repair may be needed") + #expect(!cache.summary.contains("missing")) + } +} diff --git a/macos/build-app.sh b/macos/build-app.sh index 9b79531d..c831dda0 100755 --- a/macos/build-app.sh +++ b/macos/build-app.sh @@ -189,6 +189,7 @@ install -m 0644 "$macos_dir/qemu-persistent-storage.sh" \ install -m 0644 "$macos_dir/qemu-port-forwarding.sh" \ "$contents/Resources/scripts/qemu-port-forwarding.sh" install -m 0644 "$macos_dir/qemu-networking.sh" "$contents/Resources/scripts/qemu-networking.sh" +python3 "$repo_dir/integrations/build-bundle.py" "$contents/Resources/integrations" for guest_resource in \ LICENSE.omarchy \ SHA256SUMS \ diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index 8fbda9a6..7b7b9d28 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -1073,6 +1073,7 @@ authentication_bridge_pid="" camera_bridge_pid="" clipboard_bridge_pid="" network_link_bridge_pid="" +integration_bridge_pid="" terminate_child() { local pid=$1 @@ -1100,6 +1101,9 @@ cleanup() { if [[ $network_link_bridge_pid =~ ^[0-9]+$ ]]; then terminate_child "$network_link_bridge_pid" 20 fi + if [[ $integration_bridge_pid =~ ^[0-9]+$ ]]; then + terminate_child "$integration_bridge_pid" 20 + fi if [[ $qemu_pid =~ ^[0-9]+$ ]]; then terminate_child "$qemu_pid" 40 fi @@ -1372,6 +1376,7 @@ audio_bridge_socket="/tmp/${work_dir##*/}/audio.sock" authentication_bridge_socket="/tmp/${work_dir##*/}/authentication.sock" camera_bridge_socket="/tmp/${work_dir##*/}/camera.sock" clipboard_bridge_socket="/tmp/${work_dir##*/}/clipboard.sock" +integration_bridge_socket="/tmp/${work_dir##*/}/integrations.sock" audio_route_dir="/tmp/${work_dir##*/}/audio-routes" mkdir -m 700 "$work_dir/audio-routes" @@ -1565,6 +1570,16 @@ qemu_args=( -device 'virtserialport,bus=omarchy-serial.0,nr=4,chardev=omarchy-camera-bridge,name=dev.tryomarchy.camera' ) +if [[ -f $resources_dir/integrations/manifest.json ]]; then + integration_share_option=${resources_dir//,/,,}/integrations + qemu_args+=( + -fsdev "local,id=omarchy-updates,path=$integration_share_option,security_model=none,readonly=on" + -device 'virtio-9p-pci,fsdev=omarchy-updates,mount_tag=tryomarchy-updates,romfile=' + -chardev "socket,id=omarchy-integrations,path=$integration_bridge_socket,server=on,wait=off" + -device 'virtserialport,bus=omarchy-serial.0,nr=5,chardev=omarchy-integrations,name=dev.tryomarchy.integrations' + ) +fi + if [[ -n $shared_folder ]]; then # security_model=none performs every host operation as this Mac user and # ignores guest chown requests, so the Mac keeps real modes and ownership. @@ -1677,6 +1692,17 @@ start_camera_bridge() { start_camera_bridge camera_bridge_restarts=0 +if [[ -f $resources_dir/integrations/manifest.json ]]; then + integration_cache="$work_dir/integration-status.json" + if [[ $QEMU_SELECTED_STORAGE_MODE == persistent ]]; then + integration_disk_inode=$(stat -f %i "$working_disk") + integration_cache="${QEMU_PERSISTENT_STORAGE_DISKS_ROOT%/disks}/integration-status-$integration_disk_inode.json" + fi + "$native_bridge" --bridge-integrations "$qemu_pid" "$integration_bridge_socket" \ + "$integration_cache" 9>&- & + integration_bridge_pid=$! +fi + # Bash 3.2 has no `wait -n`. The native-audio bridge is required for the guest # transport, so watch it alongside QEMU and fail if it exits unexpectedly. while true; do diff --git a/scripts/build-cache.py b/scripts/build-cache.py index 7fc61201..6be7e42f 100755 --- a/scripts/build-cache.py +++ b/scripts/build-cache.py @@ -98,7 +98,7 @@ def component_files(root: Path, component: str) -> list[Path]: path for path in regular_files(guest, {".work", "tests"}) if path.relative_to(guest).as_posix() not in {"README.md", "test"} - ] + ] + [p for p in regular_files(root / "integrations") if p.suffix != ".md" and p.name != ".DS_Store"] if component == "runtime": paths = [ @@ -127,6 +127,9 @@ def component_files(root: Path, component: str) -> list[Path]: for path in regular_files(macos, {".build", ".swiftpm", "Tests", "patches"}) if path.relative_to(macos).as_posix() not in excluded_names ] + paths.extend([p for p in regular_files(root / "integrations") if p.suffix != ".md" and p.name != ".DS_Store"]) + paths.extend(regular_files(root / "guest/scripts")) + paths.extend(regular_files(root / "guest/native-overlay")) paths.extend( [ root / "LICENSE", From 205425b84753392381945ec17d799bd53d336755 Mon Sep 17 00:00:00 2001 From: Mark Baker Date: Mon, 14 Sep 2026 00:00:06 -0400 Subject: [PATCH 2/2] Allow matching integration bundles to repair damaged installed files --- guest/tests/test_integration_bundle.py | 7 +++++++ integrations/updater.py | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/guest/tests/test_integration_bundle.py b/guest/tests/test_integration_bundle.py index ebf8adfc..cce0e4b2 100644 --- a/guest/tests/test_integration_bundle.py +++ b/guest/tests/test_integration_bundle.py @@ -50,6 +50,13 @@ def test_smaller_bundle_does_not_remove_installed_support(self): self.assertEqual(extra.read_text(), 'retained support') updater.verify_upgrade(self.bundle, self.bundle) + def test_matching_inventory_allows_repair_of_corrupt_installed_files(self): + import shutil + installed = self.bundle.parent / 'installed' + shutil.copytree(self.bundle, installed) + (installed / 'setup').write_text('damaged installed file') + updater.verify_upgrade(installed, self.bundle) + def test_corruption_cannot_execute(self): (self.bundle / 'setup').write_text('changed') with self.assertRaisesRegex(RuntimeError, 'verification failed'): diff --git a/integrations/updater.py b/integrations/updater.py index 44b5b889..31ecc281 100644 --- a/integrations/updater.py +++ b/integrations/updater.py @@ -136,7 +136,12 @@ def report(): def verify_upgrade(installed_directory, incoming_directory): if not (installed_directory / 'manifest.json').is_file(): return - installed = manifest(installed_directory) + installed = json.loads((installed_directory / 'manifest.json').read_text()) + if installed.get('schema') != 1 or installed.get('version') != 1 or not isinstance(installed.get('files'), dict): + raise RuntimeError('The installed bundle needs a matching or newer updater.') + identity = hashlib.sha256(json.dumps(installed['files'], sort_keys=True).encode()).hexdigest() + if installed.get('identity') != identity: + raise RuntimeError('The installed bundle inventory cannot be verified; retain it and repair its manifest before retrying.') incoming = manifest(incoming_directory) if set(installed['files']) - set(incoming['files']): raise RuntimeError('The installed bundle contains additional integrations. Use a matching or newer app; this bundle will not remove their support.')