From 203e1639c33cea57082503a342f1fd3e632698a2 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Wed, 2 Sep 2026 12:11:15 -0600 Subject: [PATCH] Revoke stale plugin facade capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported-by: Roger PiƱol --- shell/shell.qml | 111 ++++++++++++--- test/shell.d/plugin-auth-boundary-test.sh | 16 +++ test/shell.d/runtime-smoke-test.sh | 157 +++++++++++++++++++++- 3 files changed, 266 insertions(+), 18 deletions(-) diff --git a/shell/shell.qml b/shell/shell.qml index 7e31851d..d9714a9d 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -275,6 +275,7 @@ ShellRoot { property var _services: ({}) property var _pluginShellApis: ({}) + property var _pluginShellApiDescriptors: ({}) property var _pluginBarEntryShellApis: ({}) property var _pluginRegistryApis: ({}) property var _pluginBarWidgetRegistryApis: ({}) @@ -434,7 +435,9 @@ ShellRoot { var proxyKey = cacheKey + "::" + id if (_pluginFirstPartyServiceApis[proxyKey]) return _pluginFirstPartyServiceApis[proxyKey] - function service() { return shell.serviceFor(id) } + function service() { + return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(id)) + } var api = pluginFirstPartyServiceApiComponent.createObject(null, { ownerPluginId: pluginId, serviceId: id, @@ -491,10 +494,67 @@ ShellRoot { return api } + function pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities) { + return [ + allowOwnService ? "own-service" : "no-own-service", + barCapabilities ? "bar" : "no-bar", + shell.manifestHasKind(manifest, "menu") ? "menu" : "no-menu" + ].join("|") + } + + function cacheWithoutKey(cache, key, destroyValue) { + var next = ({}) + for (var existing in cache) { + if (existing === key) { + var value = cache[existing] + if (destroyValue && value && typeof value.destroy === "function") value.destroy() + } else { + next[existing] = cache[existing] + } + } + return next + } + + function cacheWithoutPrefix(cache, prefix) { + var next = ({}) + for (var existing in cache) { + if (existing.indexOf(prefix) === 0) { + var value = cache[existing] + if (value && typeof value.destroy === "function") value.destroy() + } else { + next[existing] = cache[existing] + } + } + return next + } + + function revokePluginShellApi(cacheKey) { + var key = String(cacheKey || "") + if (!key) return + _pluginAppLibraryApis = shell.cacheWithoutKey(_pluginAppLibraryApis, key, true) + _pluginFirstPartyServiceApis = shell.cacheWithoutPrefix(_pluginFirstPartyServiceApis, key + "::") + _pluginBarEntryShellApis = shell.cacheWithoutPrefix(_pluginBarEntryShellApis, key + ":") + _pluginShellApis = shell.cacheWithoutKey(_pluginShellApis, key, true) + _pluginShellApiDescriptors = shell.cacheWithoutKey(_pluginShellApiDescriptors, key, false) + } + function createScopedPluginShell(manifest, cacheKey, allowOwnService, barCapabilities) { - if (_pluginShellApis[cacheKey]) return _pluginShellApis[cacheKey] var key = String(manifest && manifest.id || "") if (!key) return null + var profile = shell.pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities) + var cached = _pluginShellApis[cacheKey] + var descriptor = _pluginShellApiDescriptors[cacheKey] + if (cached && descriptor && descriptor.pluginId === key + && descriptor.profile === profile) return cached + if (cached || descriptor) shell.revokePluginShellApi(cacheKey) + + function currentManifest() { + return shell.pluginRegistry.installedPlugins[key] || null + } + + function hasCurrentBarCapabilities() { + return barCapabilities && shell.pluginHasBarCapabilities(currentManifest()) + } // Construct the narrow service proxies before any plugin binding can call // firstPartyServiceFor(). Creating a QObject while evaluating that binding @@ -519,40 +579,42 @@ ShellRoot { return allowOwnService ? shell.pluginServiceFor(key, requestedId) : null }, _firstPartyServiceLookup: function(requestedId) { - return barCapabilities ? (firstPartyServices[requestedId] || null) : null + if (allowOwnService && shell.pluginOwnsTarget(key, requestedId)) + return shell.pluginServiceFor(key, requestedId) + return hasCurrentBarCapabilities() ? (firstPartyServices[requestedId] || null) : null }, _barEntryShellLookup: function(ownerId, moduleName) { - return barCapabilities + return hasCurrentBarCapabilities() ? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null }, _summon: function(requestedId, payloadJson) { if (!shell.pluginOwnsTarget(key, requestedId) - && !shell.barPluginMayControl(manifest, requestedId)) return false + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) }, _hide: function(requestedId) { if (!shell.pluginOwnsTarget(key, requestedId) - && !shell.barPluginMayControl(manifest, requestedId)) return false + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false return shell.hide(shell.pluginRegistry.resolveEnabledId(requestedId)) }, _toggle: function(requestedId, payloadJson) { if (!shell.pluginOwnsTarget(key, requestedId) - && !shell.barPluginMayControl(manifest, requestedId)) return false + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false return shell.toggle(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) }, _isOpen: function(requestedId) { if (!shell.pluginOwnsTarget(key, requestedId) - && !shell.barPluginMayControl(manifest, requestedId)) return false + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false return shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(requestedId)) }, _updateSettings: function(requestedId, settings) { if (shell.pluginOwnsTarget(key, requestedId)) return shell.updateEntryInline(key, settings) - if (barCapabilities && shell.barEntryConfigured(requestedId)) + if (hasCurrentBarCapabilities() && shell.barEntryConfigured(requestedId)) return shell.updateEntryInline(requestedId, settings) return false }, _mutateBarConfig: function(mutator) { - return barCapabilities ? shell.mutatePluginBarConfig(mutator) : false + return hasCurrentBarCapabilities() ? shell.mutatePluginBarConfig(mutator) : false } }) if (!api) return null @@ -561,6 +623,15 @@ ShellRoot { for (var id in _pluginShellApis) next[id] = _pluginShellApis[id] next[cacheKey] = api _pluginShellApis = next + var descriptorNext = ({}) + for (var descriptorKey in _pluginShellApiDescriptors) + descriptorNext[descriptorKey] = _pluginShellApiDescriptors[descriptorKey] + descriptorNext[cacheKey] = { + pluginId: key, + allowOwnService: allowOwnService === true, + profile: profile + } + _pluginShellApiDescriptors = descriptorNext return api } @@ -679,13 +750,20 @@ ShellRoot { function prunePluginApis() { var plugins = shell.pluginRegistry.installedPlugins - var shellNext = ({}) - for (var shellKey in _pluginShellApis) { + var shellKeys = Object.keys(_pluginShellApis) + for (var si = 0; si < shellKeys.length; si++) { + var shellKey = shellKeys[si] var shellApi = _pluginShellApis[shellKey] - if (shell.pluginApiActive(shellApi, plugins)) shellNext[shellKey] = shellApi - else if (shellApi && typeof shellApi.destroy === "function") shellApi.destroy() + var descriptor = _pluginShellApiDescriptors[shellKey] + var manifest = descriptor ? plugins[descriptor.pluginId] : null + var barCapabilities = descriptor && descriptor.allowOwnService + && shell.pluginHasBarCapabilities(manifest) + var expectedProfile = descriptor + ? shell.pluginShellCapabilityProfile(manifest, descriptor.allowOwnService, barCapabilities) : "" + var active = descriptor && manifest && shell.pluginRegistry.isEnabled(descriptor.pluginId) + if (!active || descriptor.profile !== expectedProfile) + shell.revokePluginShellApi(shellKey) } - _pluginShellApis = shellNext var registryNext = ({}) for (var registryKey in _pluginRegistryApis) { @@ -761,7 +839,7 @@ ShellRoot { } function firstPartyServiceFor(pluginId) { - return serviceFor(pluginId) + return serviceFor(shell.pluginRegistry.resolveEnabledId(pluginId)) } function isAuthenticationService(manifest) { @@ -844,6 +922,7 @@ ShellRoot { } else { // A kept instance outlives the rescan; hand it the fresh manifest. var kept = _services[id] + if (kept && "shell" in kept) kept.shell = shell.pluginShellFor(m) if (kept && "manifest" in kept) kept.manifest = shell.publicPluginManifest(m) continue } diff --git a/test/shell.d/plugin-auth-boundary-test.sh b/test/shell.d/plugin-auth-boundary-test.sh index 7f274e1b..9859699f 100755 --- a/test/shell.d/plugin-auth-boundary-test.sh +++ b/test/shell.d/plugin-auth-boundary-test.sh @@ -95,6 +95,22 @@ qml_matches "$shell_qml" 'shell\.prunePluginApis\( *\)' || fail "disabled plugin facade caches are pruned" pass "plugin facade synchronization is bounded" +qml_matches "$shell_qml" 'descriptor\.profile *!== *expectedProfile[^}]*shell\.revokePluginShellApi\( *shellKey *\)' || + fail "manifest capability changes do not revoke cached plugin facades" +qml_matches "$shell_qml" 'shell\.barPluginMayControl\( *currentManifest\( *\), *requestedId *\)' || + fail "bar lifecycle callbacks do not validate the current manifest" +qml_matches "$shell_qml" 'return hasCurrentBarCapabilities\( *\) *\? *shell\.mutatePluginBarConfig\( *mutator *\) *: *false' || + fail "bar configuration mutation does not validate the current manifest" +pass "manifest changes revoke cached facade capabilities" + +qml_matches "$shell_qml" 'shell\.serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *id *\) *\)' || + fail "narrow first-party service proxies do not resolve enabled clones" +qml_matches "$shell_qml" 'return serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *pluginId *\) *\)' || + fail "trusted first-party service lookups do not resolve enabled clones" +qml_matches "$shell_qml" 'allowOwnService *&& *shell\.pluginOwnsTarget\( *key, *requestedId *\)[^}]*return shell\.pluginServiceFor\( *key, *requestedId *\)' || + fail "cloned widgets cannot use a source id to reach their own service" +pass "service facades resolve enabled clones without widening replacement-bar access" + require_compositor "plugin authentication boundary runtime test" if ! command -v quickshell >/dev/null 2>&1; then diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index d19c04ab..8c444840 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -136,6 +136,66 @@ Item { } QML +# A clone of the built-in media service exercises both supported service paths: +# its own widget receives the raw companion service under the trusted bar, while +# a replacement bar receives only the narrow media proxy resolved to the clone. +media_clone_id="acme.media-clone" +media_clone_dir="$test_home/.config/omarchy/plugins/$media_clone_id" +mkdir -p "$media_clone_dir" +cat >"$media_clone_dir/manifest.json" <"$media_clone_dir/Service.qml" <<'QML' +import QtQuick +import Quickshell.Io + +Item { + property string marker: "clone-service" + property bool enabled: true + property var activePlayer: null + property var sourcePlayers: [] + + function runAction(action, showFeedback, targetKey) {} + function playerKey(player) { return "" } + function selectPlayer(playerKey) {} + + IpcHandler { + target: "acme-media-clone-service" + function ping(): string { return marker } + } +} +QML +cat >"$media_clone_dir/BarWidget.qml" <<'QML' +import QtQuick +import Quickshell.Io + +Item { + id: root + property var bar: null + + IpcHandler { + target: "acme-media-clone-widget" + function probeOwnService(): string { + var service = root.bar && root.bar.shell + ? root.bar.shell.firstPartyServiceFor("omarchy.media") : null + return JSON.stringify({ + reachable: !!service, + marker: service ? String(service.marker || "") : "" + }) + } + } +} +QML + review_bar_id="acme.review-bar" review_bar_dir="$test_home/.config/omarchy/plugins/$review_bar_id" mkdir -p "$review_bar_dir" @@ -145,8 +205,9 @@ cat >"$review_bar_dir/manifest.json" <"$review_bar_dir/Bar.qml" <<'QML' @@ -181,6 +242,12 @@ Item { return JSON.stringify(root.barConfig || {}) } + function probeMediaProxy(): string { + var service = root.shell + ? root.shell.firstPartyServiceFor("omarchy.media") : null + return JSON.stringify({ reachable: !!service, enabled: service ? service.enabled === true : false }) + } + function mutateSnapshot(): string { if (root.barConfig && root.barConfig.layout && root.barConfig.layout.left && root.barConfig.layout.left.length > 0) @@ -190,6 +257,38 @@ Item { } } QML +cat >"$review_bar_dir/Service.qml" <<'QML' +import QtQuick +import Quickshell.Io + +Item { + id: root + property var shell: null + property var retainedShell: null + + onShellChanged: if (!retainedShell && shell) retainedShell = shell + + function mutationAllowed(candidate) { + if (!candidate) return false + try { + return typeof candidate.mutateShellConfig === "function" + && candidate.mutateShellConfig(function(config) {}) === true + } catch (e) { + return false + } + } + + IpcHandler { + target: "acme-review-capability" + function probe(): string { + return JSON.stringify({ + currentAllowed: root.mutationAllowed(root.shell), + retainedAllowed: root.mutationAllowed(root.retainedShell) + }) + } + } +} +QML cat >"$stub_bin/omarchy-update-available" <<'SH' #!/bin/bash @@ -517,6 +616,24 @@ 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 "$media_clone_id" true) == "ok" ]] || + fail_with_log "media clone fixture could not be enabled" +clone_widget_probe="" +for _ in {1..80}; do + clone_widget_probe=$(shell_ipc acme-media-clone-widget probeOwnService 2>/dev/null || true) + if jq -e '.reachable == true and .marker == "clone-service"' \ + <<<"$clone_widget_probe" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +jq -e '.reachable == true and .marker == "clone-service"' \ + <<<"$clone_widget_probe" >/dev/null || { + printf 'Clone own-service probe: %s\n' "$clone_widget_probe" >&2 + fail_with_log "a cloned widget resolves its source id to its own companion service" +} +pass "trusted bar gives a cloned widget its own companion service" + [[ $(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" ]] || @@ -540,6 +657,12 @@ jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReac fail_with_log "replacement bar cannot recover another plugin's live service" } +media_proxy_probe=$(shell_ipc acme-review-bar probeMediaProxy) +jq -e '.reachable == true and .enabled == true' <<<"$media_proxy_probe" >/dev/null || { + printf 'Replacement-bar media proxy probe: %s\n' "$media_proxy_probe" >&2 + fail_with_log "replacement-bar service proxies resolve enabled clones" +} + 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') @@ -567,3 +690,33 @@ bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar') fail_with_log "replacement bar mutated a refreshed host configuration" pass "replacement-bar service and configuration boundaries hold at runtime" + +capability_before=$(shell_ipc acme-review-capability probe) +jq -e '.currentAllowed == true and .retainedAllowed == true' \ + <<<"$capability_before" >/dev/null || + fail_with_log "bar service fixture did not initially receive bar capabilities" + +# Keep the same enabled plugin ID and service instance while dropping the bar +# kind. Both the currently injected facade and a reference retained by the +# plugin must lose the old configuration capability after the manifest rescan. +jq '.kinds = ["service"] | .entryPoints = {"service": "Service.qml"}' \ + "$review_bar_dir/manifest.json" >"$review_bar_dir/manifest.json.tmp" +mv "$review_bar_dir/manifest.json.tmp" "$review_bar_dir/manifest.json" +capability_after="" +for _ in {1..80}; do + capability_after=$(shell_ipc acme-review-capability probe 2>/dev/null || true) + if jq -e '.currentAllowed == false and .retainedAllowed == false' \ + <<<"$capability_after" >/dev/null 2>&1; then + break + fi + if ! kill -0 "$QS_PID" 2>/dev/null; then + fail_with_log "test shell exited while revoking changed manifest capabilities" + fi + sleep 0.1 +done +jq -e '.currentAllowed == false and .retainedAllowed == false' \ + <<<"$capability_after" >/dev/null || { + printf 'Capability revocation probe: %s\n' "$capability_after" >&2 + fail_with_log "cached plugin facades revoke capabilities removed from the manifest" +} +pass "manifest reload revokes cached facade capabilities"