Skip to content
Draft
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,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 guide offers sudo Touch ID support, recovery after
Mac sleep, and package compatibility repairs. Touch ID pairing and the optional
1Password integration are separate explicit choices.

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.

### Repairing update holds in an older guest

Older guests may fail Omarchy Update with conflicting `libaquamarine.so`
Expand Down
8 changes: 4 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions docs/integration-updates.md
Original file line number Diff line number Diff line change
@@ -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.
- Clock recovery: enables the RTC-based recovery timer and verifies it is active.
- Package compatibility: repairs both saved and active holds without upgrading packages.
- 1Password: optional per-user activation after installation, sign-in, system
authentication enablement, and sudo Touch ID pairing. Service activation does
not prove that a biometric authorization succeeded; test by locking 1Password
without quitting it and using its unlock control.

The manager does not replace the kernel, upgrade the graphics stack, install
1Password, 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

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; the package-hold repair also uses pacman's lock.

Guest status diagnostics:

```sh
systemctl status try-omarchy-integrations.service --no-pager
sudo journalctl -u try-omarchy-integrations.service -b -n 40 --no-pager
```
12 changes: 12 additions & 0 deletions guest/scripts/configure-rootfs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,18 @@ 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"
mkdir -p "$root/etc/systemd/system/timers.target.wants"
ln -s /usr/lib/systemd/system/try-omarchy-clock-recovery.timer "$root/etc/systemd/system/timers.target.wants/try-omarchy-clock-recovery.timer"


# 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.
Expand Down
5 changes: 3 additions & 2 deletions guest/scripts/install-onepassword-touch-id.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ set -euo pipefail
guest_user=$1
getent passwd "$guest_user" >/dev/null
source_dir=$(cd "$(dirname "$0")/../native-overlay" && pwd)
python3 -I -c 'import gi; gi.require_version("Gtk", "3.0"); gi.require_version("PolkitAgent", "1.0"); from gi.repository import Gtk, PolkitAgent'
env -u DISPLAY -u WAYLAND_DISPLAY python3 -I -c 'import gi; gi.require_version("Gtk", "3.0"); gi.require_version("PolkitAgent", "1.0"); from gi.repository import Gtk, PolkitAgent'
[[ -f /var/lib/try-omarchy/native-authentication.json ]] || {
echo "Pair this guest using the Touch ID setup first." >&2
exit 1
Expand All @@ -32,5 +32,6 @@ install -o root -g root -m 644 "$source_dir$unit" "$unit"
systemctl daemon-reload
systemctl enable "try-omarchy-onepassword-touch-id@$guest_user.service"
systemctl restart "try-omarchy-onepassword-touch-id@$guest_user.service"
printf '1Password Touch ID enabled. Previous files retained in %s\n' "$backup_dir"
printf '1Password Touch ID integration installed. Previous files retained in %s\n' "$backup_dir"
printf 'Disable with: sudo systemctl disable --now try-omarchy-onepassword-touch-id@%s.service\n' "$guest_user"
printf 'To test: sign in to 1Password, enable system authentication, unlock with your account password, then lock without quitting and try Touch ID.\n'
9 changes: 6 additions & 3 deletions guest/scripts/install-touch-id-menu-entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -45,15 +45,18 @@ 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("{")
if opening < 0 or text[:opening].strip():
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)}"
Expand Down
142 changes: 142 additions & 0 deletions guest/tests/test_integration_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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-onepassword-touch-id.sh', result['files'])
self.assertTrue((self.bundle / 'setup').stat().st_mode & 0o111)

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_review_refreshes_user_menu_only_after_successful_install(self):
for succeeds in (True, False):
with self.subTest(succeeds=succeeds):
events = []
def install(args, **kwargs):
self.assertEqual(args[0], 'sudo')
events.append('install')
if not succeeds:
raise subprocess.CalledProcessError(1, args)
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('builtins.input', side_effect=['1', 'y']), patch('builtins.print'):
if succeeds:
updater.review()
else:
with self.assertRaises(subprocess.CalledProcessError):
updater.review()
self.assertEqual(events, ['install', 'refresh'] if succeeds else ['install'])

def test_unlisted_file_is_rejected(self):
(self.bundle / 'extra').write_text('unreviewed')
with self.assertRaisesRegex(RuntimeError, 'unexpected'):
updater.manifest(self.bundle)

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()
Loading