From 094c913065315082c0cbf353a169c0245779c427 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Wed, 2 Sep 2026 11:03:03 -0600 Subject: [PATCH] Tighten replacement bar plugin boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported-by: Roger Piñol --- agents/skills/shell-dev.md | 2 +- docs/omarchy-shell.md | 6 +- manual/32-shell-plugins.md | 4 +- shell/README.md | 6 +- shell/Ui/PluginBarApi.qml | 3 +- shell/plugins/bar/Bar.qml | 10 +- shell/services/PluginRegistry.qml | 3 +- shell/services/PluginShellApi.qml | 10 +- shell/shell.qml | 38 ++++- .../fixtures/plugin-auth-boundary/shell.qml | 1 + test/shell.d/plugin-auth-boundary-test.sh | 14 ++ test/shell.d/runtime-smoke-test.sh | 133 ++++++++++++++++++ 12 files changed, 206 insertions(+), 24 deletions(-) diff --git a/agents/skills/shell-dev.md b/agents/skills/shell-dev.md index 59cc4e8b..1688c0f1 100644 --- a/agents/skills/shell-dev.md +++ b/agents/skills/shell-dev.md @@ -20,7 +20,7 @@ Run `omarchy-restart-shell` after making changes to QML files. [`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and `shell/services/PluginRegistry.qml` for the current contract; fields such as `activation` are optional. -- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views must be detached snapshots rather than shared objects. Do not expose host objects or authentication services through new third-party-facing properties. +- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views and bar configuration must be detached snapshots rather than shared objects. These facades reduce accidental authority but are not a same-process QML sandbox: a visual bar widget can walk its parent hierarchy to ordinary host objects. Authentication services must therefore remain outside both `ShellRoot._services` and the host QObject tree. Do not expose authentication services through new third-party-facing properties. - Panel / overlay / menu plugins must expose `open(payloadJson)` and `close()` lifecycle methods for `shell summon` and `shell hide`. diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index 52706d0f..cc0cc17b 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -39,7 +39,9 @@ Only one full bar option is active at a time. The built-in `omarchy.bar` is used when `bar.id` is omitted or when a selected third-party bar cannot load. Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key `keepLoaded: true` to survive between summons, and to keep a service mounted across plugin hot-reload (so `omarchy.lock` is not destroyed while Hyprland still holds the session lock). First-party services are loaded at startup. -Entry points are QML `Item`s. Panel, overlay, and menu entry points expose `open(payloadJson)` and `close()` for summon/hide; on load the host injects `omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / `barWidgetRegistry`) as properties. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades instead: ordinary plugins may look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are kept out of the host's public service map and QML object tree, and third-party registry snapshots can be changed only locally without mutating the host registries. +Entry points are QML `Item`s. Panel, overlay, and menu entry points expose `open(payloadJson)` and `close()` for summon/hide; on load the host injects `omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / `barWidgetRegistry`) as properties. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades instead: ordinary plugins may look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are kept out of the host's public service map and QML object tree, and third-party registry/configuration snapshots can be changed only locally without mutating host state. The facades are API boundaries, not same-process QML sandboxes: a visual widget shares the host bar's scene and can walk its parent hierarchy to ordinary host objects. Sensitive state must not rely on the facade alone for isolation. + +A third-party replacement bar can render registered widget components, but widgets it hosts receive a service-less entry facade. Allowing the bar to manufacture an own-service facade for an arbitrary widget would also let it retrieve that plugin's live service object. Service-backed third-party widgets therefore retain their full integration only under the trusted built-in bar; a replacement bar may still provide their target-scoped lifecycle and settings operations. Full schema: [`shell/services/PluginRegistry.qml`](../shell/services/PluginRegistry.qml). @@ -76,7 +78,7 @@ one replaces the active bar, and it is therefore never offered under Disable. Bar widgets may set `barWidget.defaultSection` to `left`, `center`, or `right`; widgets that omit it default to `center`. -Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you before cloning, plugins land disabled so you can review the code before `omarchy plugin enable`, and updates show a diff before touching anything. Commands confirm in a terminal even when given arguments; without one they refuse rather than guess. Add `--yes` to skip every prompt (the path for scripts and agents). The scoped QML interfaces protect shell-owned credentials and cross-plugin controls; they are not an operating-system sandbox, so plugin code still has the same user-level file and process access as the shell. +Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you before cloning, plugins land disabled so you can review the code before `omarchy plugin enable`, and updates show a diff before touching anything. Commands confirm in a terminal even when given arguments; without one they refuse rather than guess. Add `--yes` to skip every prompt (the path for scripts and agents). The scoped interfaces remove direct access to authentication services and avoid handing generic cross-plugin service factories to replacement bars, but visual plugins can still traverse ordinary objects in their shared QML scene. Plugin code also has the same user-level file and process access as the shell. You can still install by hand: drop a plugin into `~/.config/omarchy/plugins//`, run `omarchy-shell shell rescanPlugins`, then diff --git a/manual/32-shell-plugins.md b/manual/32-shell-plugins.md index bf7f1f2d..2e349998 100644 --- a/manual/32-shell-plugins.md +++ b/manual/32-shell-plugins.md @@ -37,7 +37,9 @@ A third-party plugin is just a git repo with a `manifest.json` at its root. omarchy plugin add https://github.com/acme/omarchy-weather.git --enable ``` -Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. The shell does not expose its authentication state or raw host object tree through the third-party plugin interface. A replacement bar receives additional limited capabilities so it can render built-in widgets and orchestrate the configured non-authentication UI, but it still cannot reach authentication services. This is not an operating-system sandbox: plugin code still runs for as long as your session does, with everything your user account can reach. Only add repos you're willing to run, and read them before you enable them. +Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. The third-party plugin interface does not directly expose authentication services, and a replacement bar receives only limited capabilities for configured non-authentication UI. Visual plugins still share the shell's QML scene and can walk ordinary parent objects, while all plugin code runs with everything your user account can reach. Authentication state is protected separately by keeping those services outside the reachable host object graph. Only add repos you're willing to run, and read them before you enable them. + +A replacement bar can render installed widgets, but service-backed third-party widgets may have reduced functionality there because the bar is not allowed to request another plugin's live service object. Switch back to the built-in `omarchy.bar` if such a widget needs its companion service. Then it clones the repo into a staging directory, validates the manifest, refuses the install if another plugin already claims that id, and moves it into `~/.config/omarchy/plugins//`. Without `--enable` it asks whether you want it on now, and you can say no and go read the code first. It never runs anything from the plugin, never executes an install hook, and never asks for sudo — it clones files, checks the manifest, and flips a bit over IPC. diff --git a/shell/README.md b/shell/README.md index d5b50290..a2bc5338 100644 --- a/shell/README.md +++ b/shell/README.md @@ -93,7 +93,9 @@ holds the session lock. The kept instance is not replaced, so code changes to a `keepLoaded` service itself only take effect on a shell restart. First-party services are loaded at startup. -Entry points may declare `omarchyPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry snapshot cannot mutate the host registry. +Entry points may declare `omarchyPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry or configuration snapshot cannot mutate host state. Facades do not isolate visual widgets from the parent hierarchy of the shared QML scene, so sensitive state must remain outside that reachable graph. + +Widgets rendered by a third-party replacement bar receive a service-less entry facade with target-scoped lifecycle and settings operations. Their live service objects are available only when the trusted built-in bar hosts them; otherwise the replacement bar could request and retain any configured widget's service. The full schema lives in `services/PluginRegistry.qml`. @@ -110,7 +112,7 @@ omarchy plugin update # updates every git-managed plugin omarchy plugin remove acme.weather ``` -> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. The scoped QML interfaces protect shell-owned credentials and cross-plugin controls; they are not an operating-system sandbox. Only add repos whose code you are willing to run. +> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. The scoped QML interfaces remove direct authentication-service and generic replacement-bar service lookups, but visual plugins still share and can traverse the ordinary host scene. Only add repos whose code you are willing to run. Each command is **interactive** when run bare in a terminal (gum pickers, confirmation, a diff to review) and fully **non-interactive** when given diff --git a/shell/Ui/PluginBarApi.qml b/shell/Ui/PluginBarApi.qml index c30021a5..03c748b1 100644 --- a/shell/Ui/PluginBarApi.qml +++ b/shell/Ui/PluginBarApi.qml @@ -2,7 +2,8 @@ import QtQuick // Bar surface exposed to an installed third-party widget. Scalar presentation // state is mirrored by Bar.qml and operations are delegated through scoped -// callbacks, so the actual Bar (and its root-shell property) is never retained. +// callbacks. The facade avoids direct host-Bar injection; it cannot isolate a +// visual child from the parent hierarchy of the QML scene that renders it. QtObject { id: api diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index df02a2f7..251536c4 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -232,10 +232,16 @@ Item { if (pluginBarApis[key]) return pluginBarApis[key] var pluginShell = null - if (registered && root.shell && typeof root.shell.pluginShellForId === "function") + if (registered && root.shell && typeof root.shell.pluginShellForId === "function") { + // Only the trusted built-in bar receives ShellRoot and can request a + // service-capable facade for the widget it is instantiating. pluginShell = root.shell.pluginShellForId(moduleName) - else if (!registered && root.shell && typeof root.shell.pluginShellForBarEntry === "function") + } else if (root.shell && typeof root.shell.pluginShellForBarEntry === "function") { + // Replacement bars receive a service-less entry facade. Giving an + // untrusted bar a generic facade factory would let it retrieve another + // third-party plugin's live service object. pluginShell = root.shell.pluginShellForBarEntry(key, moduleName) + } var api = pluginBarApiComponent.createObject(null, { pluginId: key, diff --git a/shell/services/PluginRegistry.qml b/shell/services/PluginRegistry.qml index e24c3a22..e8b8ad36 100644 --- a/shell/services/PluginRegistry.qml +++ b/shell/services/PluginRegistry.qml @@ -78,8 +78,7 @@ QtObject { } } // Every entry point must be a relative path inside the plugin's source - // directory. Reject the whole manifest if anything looks like an attempt - // to escape the plugin's sandbox. + // directory. Reject the whole manifest if an entry point escapes it. for (var key in manifest.entryPoints) { if (!isSafeEntryPoint(manifest.entryPoints[key])) { console.warn("PluginRegistry: unsafe entryPoint '" + key + "'='" diff --git a/shell/services/PluginShellApi.qml b/shell/services/PluginShellApi.qml index faa34e8d..2ce7659c 100644 --- a/shell/services/PluginShellApi.qml +++ b/shell/services/PluginShellApi.qml @@ -5,9 +5,8 @@ import QtQuick // The callbacks are closed over one plugin id by shell.qml. A plugin can call // them directly, but it cannot widen their scope: ordinary plugins are limited // to their own id, and full-bar callbacks independently enforce their explicit -// non-authentication UI scope. Keeping the host shell out of this object's -// properties also prevents ordinary QML object traversal from turning the -// facade back into the root ShellRoot. +// non-authentication UI scope. This object avoids directly injecting the host +// shell, but it is not a QML sandbox: visual plugins share the host object tree. QtObject { id: api @@ -19,7 +18,6 @@ QtObject { property var _serviceLookup: null property var _firstPartyServiceLookup: null - property var _pluginShellLookup: null property var _barEntryShellLookup: null property var _summon: null property var _hide: null @@ -39,10 +37,6 @@ QtObject { ? _firstPartyServiceLookup(String(id || "")) : null } - function pluginShellForId(id) { - return _pluginShellLookup ? _pluginShellLookup(String(id || "")) : null - } - function pluginShellForBarEntry(ownerId, moduleName) { return _barEntryShellLookup ? _barEntryShellLookup(String(ownerId || ""), String(moduleName || "")) : null diff --git a/shell/shell.qml b/shell/shell.qml index 0119d97c..7e31851d 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -114,7 +114,10 @@ ShellRoot { } readonly property var barConfig: shellConfig && Util.isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar - onBarConfigChanged: if (bar && "barConfig" in bar) bar.barConfig = shell.barConfig + onBarConfigChanged: { + if (bar && "barConfig" in bar) + bar.barConfig = shell.barConfigFor(shell.activeBarManifest) + } FileView { id: defaultsFile path: shell.defaultsPath @@ -219,7 +222,7 @@ ShellRoot { if ("manifest" in target) target.manifest = shell.publicPluginManifest(manifest) if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest) if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistryFor(manifest) - if ("barConfig" in target) target.barConfig = shell.barConfig + if ("barConfig" in target) target.barConfig = shell.barConfigFor(manifest) shell.bar = target } @@ -323,6 +326,11 @@ ShellRoot { return JSON.parse(JSON.stringify(shell.barConfig || {})) } + function barConfigFor(manifest) { + return !manifest || manifest.__isFirstParty + ? shell.barConfig : shell.publicBarConfig() + } + function publicBarWidgetSnapshot() { var source = shell.barWidgetRegistry.widgets || {} var snapshot = {} @@ -513,9 +521,6 @@ ShellRoot { _firstPartyServiceLookup: function(requestedId) { return barCapabilities ? (firstPartyServices[requestedId] || null) : null }, - _pluginShellLookup: function(requestedId) { - return barCapabilities ? shell.scopedPluginShellForId(requestedId) : null - }, _barEntryShellLookup: function(ownerId, moduleName) { return barCapabilities ? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null @@ -575,11 +580,34 @@ ShellRoot { var owner = String(ownerId || "") var target = String(moduleName || "") if (!owner || !target) return null + if (!shell.barEntryConfigured(target)) return null var cacheKey = owner + "::" + target if (_pluginBarEntryShellApis[cacheKey]) return _pluginBarEntryShellApis[cacheKey] + + function owns(requestedId) { + return shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) + === shell.pluginRegistry.resolveEnabledId(target) + } + var api = pluginShellApiComponent.createObject(null, { pluginId: target, barConfig: shell.publicBarConfig(), + _summon: function(requestedId, payloadJson) { + return owns(requestedId) + ? shell.summon(shell.pluginRegistry.resolveEnabledId(target), payloadJson) : false + }, + _hide: function(requestedId) { + return owns(requestedId) + ? shell.hide(shell.pluginRegistry.resolveEnabledId(target)) : false + }, + _toggle: function(requestedId, payloadJson) { + return owns(requestedId) + ? shell.toggle(shell.pluginRegistry.resolveEnabledId(target), payloadJson) : false + }, + _isOpen: function(requestedId) { + return owns(requestedId) + ? shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(target)) : false + }, _updateSettings: function(requestedId, settings) { return String(requestedId || "") === target ? shell.updateEntryInline(target, settings) : false diff --git a/test/shell.d/fixtures/plugin-auth-boundary/shell.qml b/test/shell.d/fixtures/plugin-auth-boundary/shell.qml index 9d4582d2..a4968468 100644 --- a/test/shell.d/fixtures/plugin-auth-boundary/shell.qml +++ b/test/shell.d/fixtures/plugin-auth-boundary/shell.qml @@ -78,6 +78,7 @@ ShellRoot { authStoreOwnerUpdatesManifest: root.ownService.manifest && root.ownService.manifest.version === "kept", authStoreImportIsolated: authStoreReader.has("omarchy.lock") === false, + noGenericPluginShellFactory: typeof api.pluginShellForId !== "function", calls: root.calls } result.ok = Object.keys(result).every(function(key) { diff --git a/test/shell.d/plugin-auth-boundary-test.sh b/test/shell.d/plugin-auth-boundary-test.sh index e5eb13c7..7f274e1b 100755 --- a/test/shell.d/plugin-auth-boundary-test.sh +++ b/test/shell.d/plugin-auth-boundary-test.sh @@ -20,6 +20,7 @@ trap cleanup EXIT shell_qml="$ROOT/shell/shell.qml" bar_qml="$ROOT/shell/plugins/bar/Bar.qml" +plugin_shell_api="$ROOT/shell/services/PluginShellApi.qml" # Normalize horizontal and vertical whitespace so the wiring assertions survive # harmless QML reflow. The runtime fixture below behaviorally covers @@ -50,6 +51,19 @@ qml_matches "$shell_qml" 'target\.shell *= *shell\.pluginShellFor\( *manifest *\ fail "full-bar plugins receive a scoped shell facade" pass "third-party entry points receive scoped shell facades" +if qml_matches "$plugin_shell_api" 'function +pluginShellForId\('; then + fail "replacement-bar facade exposes a generic plugin-shell factory" +fi +qml_matches "$bar_qml" 'else if *\( *root\.shell *&& *typeof root\.shell\.pluginShellForBarEntry *=== *"function" *\) *\{[^}]*pluginShell *= *root\.shell\.pluginShellForBarEntry\( *key, *moduleName *\)' || + fail "replacement bars do not fall back to a service-less entry facade" +pass "replacement bars cannot manufacture another plugin's service facade" + +qml_matches "$shell_qml" 'target\.barConfig *= *shell\.barConfigFor\( *manifest *\)' || + fail "initial replacement-bar configuration is not detached" +qml_matches "$shell_qml" 'bar\.barConfig *= *shell\.barConfigFor\( *shell\.activeBarManifest *\)' || + fail "replacement-bar configuration updates are not detached" +pass "replacement bars receive detached configuration snapshots" + qml_matches "$bar_qml" 'target\.bar *= *firstParty *\? *root *: *root\.pluginBarApiFor\( *pluginApiId, *moduleName, *registered *\)' || fail "third-party widgets receive a bar facade instead of the host bar" qml_matches "$bar_qml" 'api\.clickTargets *= *root\.pluginClickTargets\( *api\.pluginId *\)' || diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index 1b0e5fe9..d19c04ab 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -112,6 +112,85 @@ Item { } QML +# A replacement bar must not receive a generic factory for another plugin's +# live service, and its barConfig must be a detached snapshot on both initial +# injection and later host-config updates. +victim_service_id="acme.victim-service" +victim_service_dir="$test_home/.config/omarchy/plugins/$victim_service_id" +mkdir -p "$victim_service_dir" +cat >"$victim_service_dir/manifest.json" <"$victim_service_dir/Service.qml" <<'QML' +import QtQuick + +Item { + property string privateValue: "victim-secret" +} +QML + +review_bar_id="acme.review-bar" +review_bar_dir="$test_home/.config/omarchy/plugins/$review_bar_id" +mkdir -p "$review_bar_dir" +cat >"$review_bar_dir/manifest.json" <"$review_bar_dir/Bar.qml" <<'QML' +import QtQuick +import Quickshell.Io + +Item { + id: root + + property var shell: null + property var barConfig: ({}) + + IpcHandler { + target: "acme-review-bar" + + function probeVictim(): string { + var genericFactory = root.shell + && typeof root.shell.pluginShellForId === "function" + var entryFacade = root.shell + && typeof root.shell.pluginShellForBarEntry === "function" + ? root.shell.pluginShellForBarEntry("probe", "acme.victim-service") : null + var victim = entryFacade && typeof entryFacade.serviceFor === "function" + ? entryFacade.serviceFor("acme.victim-service") : null + return JSON.stringify({ + genericFactory: !!genericFactory, + entryFacade: !!entryFacade, + victimServiceReachable: !!victim + }) + } + + function snapshot(): string { + return JSON.stringify(root.barConfig || {}) + } + + function mutateSnapshot(): string { + if (root.barConfig && root.barConfig.layout + && root.barConfig.layout.left && root.barConfig.layout.left.length > 0) + root.barConfig.layout.left[0].id = "tampered.by.review-bar" + return snapshot() + } + } +} +QML + cat >"$stub_bin/omarchy-update-available" <<'SH' #!/bin/bash echo "Omarchy update available (test)" @@ -434,3 +513,57 @@ jq -e 'all(.bar.layout.right[]; (.id // .) != "omarchy.keyboard-layout")' \ <<<"$(shell_ipc shell listShellConfig)" >/dev/null || fail_with_log "bar put added a second copy of a widget already on the bar" pass "bar put leaves a widget already on the bar alone" + +# Run the replacement-bar probes last: switching bar loaders can transiently +# leave bar-aware panels without a visual host, which should not add noise to +# the default-bar assertions above. +[[ $(shell_ipc shell setPluginEnabled "$victim_service_id" true) == "ok" ]] || + fail_with_log "victim service fixture could not be enabled" +[[ $(shell_ipc shell enablePlugin "$review_bar_id" '{}') == "ok" ]] || + fail_with_log "replacement-bar fixture could not be enabled" + +review_probe="" +for _ in {1..80}; do + review_probe=$(shell_ipc acme-review-bar probeVictim 2>/dev/null || true) + if jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReachable == false' \ + <<<"$review_probe" >/dev/null 2>&1; then + break + fi + if ! kill -0 "$QS_PID" 2>/dev/null; then + fail_with_log "test shell exited while loading the replacement-bar fixture" + fi + sleep 0.1 +done +jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReachable == false' \ + <<<"$review_probe" >/dev/null || { + printf 'Replacement-bar service probe: %s\n' "$review_probe" >&2 + fail_with_log "replacement bar cannot recover another plugin's live service" +} + +bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar') +shell_ipc acme-review-bar mutateSnapshot >/dev/null +bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar') +[[ $bar_config_after == "$bar_config_before" ]] || + fail_with_log "replacement bar mutated the initially injected host configuration" + +[[ $(shell_ipc shell setBarWidget omarchy.clock format '"HH:mm:ss"' '{}') == "ok" ]] || + fail_with_log "host bar configuration could not be updated for snapshot testing" +updated_snapshot="" +for _ in {1..80}; do + updated_snapshot=$(shell_ipc acme-review-bar snapshot 2>/dev/null || true) + if jq -e 'any(.layout.center[]; (.id // .) == "omarchy.clock" and .format == "HH:mm:ss")' \ + <<<"$updated_snapshot" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +jq -e 'any(.layout.center[]; (.id // .) == "omarchy.clock" and .format == "HH:mm:ss")' \ + <<<"$updated_snapshot" >/dev/null || + fail_with_log "replacement bar did not receive the refreshed configuration snapshot" +bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar') +shell_ipc acme-review-bar mutateSnapshot >/dev/null +bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar') +[[ $bar_config_after == "$bar_config_before" ]] || + fail_with_log "replacement bar mutated a refreshed host configuration" + +pass "replacement-bar service and configuration boundaries hold at runtime"