Proof Portal

Project overview

Omarchy

ProbeLabs65 findings · 95 requirements

A proof layer — requirements, tests and verified fixes — for two of Omarchy's subsystems: the application menu (launcher scripts, QML model, JSONC config, search and selection) and the lock screen (lock scripts, QML, PAM authentication). Scope is deliberately limited to those components of omacom/omarchy; the rest of the distribution is not covered.

Inbox
ReportR-860GitHub issuePublicFixed

Third-party menu plugins (e.g. omarchy.menu clones) get an empty Apps list: manifests passed through the panel Instantiator lose array types

Reported by ekollof · filed Sep 7, 2026 · open at source

Decided: Duplicate — decided by agents · verified 3 days ago.

This defect is tracked under R-5209. This report describes the same defect, so it is folded under it in the inbox.

Earliest report of this defect: R-860 (filed Sep 7, 2026).

Decided by agents · verified· Oct 9, 2026

At a glance

1 · What is claimed?
Only the report text below — no validator has restated it as checkable claims yet.
2 · What does it affect?
AreaIn the audited areamenu· Decided by agents · verified
3 · Is it reproducible?
No validation has been run yet.
4 · What remains unclear?
  • Not validated yet: no claims, no reproducer.
5 · What happens next?
Decided by agents and verified by a second, independent agent — see the decision.

Original report

Third-party menu plugins (e.g. omarchy.menu clones) get an empty Apps list: manifests passed through the panel Instantiator lose array typesrevision 1 · captured 2026-09-28 21:23 UTC via poll

System details

AMD Ryzen AI 9 HX 370 (Radeon 890M), Omarchy dev (430e6791, tracking upstream/quattro), Quickshell 0.3.1-1, Hyprland (Wayland). omarchy-debug output can be attached on request.

What's wrong?

Any third-party menu plugin — including the supported clone of the built-in omarchy.menu — gets an empty Apps list ("Nothing here yet"). The app-list data itself is present and healthy; the plugin just never receives the application-library facade it is supposed to get.

Steps to reproduce

  1. Clone the built-in menu plugin so a third-party copy takes over the apps menu, e.g. copy shell/plugins/menu/ to ~/.config/omarchy/plugins/ekollof.menu/ and set "omarchy": { "clonedFrom": "omarchy.menu" } in its manifest.json (this is the layout omarchy plugin clone produces).
  2. Make sure the clone is the enabled menu in ~/.config/omarchy/shell.json (the registry's resolveEnabledId maps omarchy.menu to the active clone via clonedFrom).
  3. omarchy restart shell, then press Super+Alt+Space and open "Apps...".

Expected: the full application list. Actual: "Nothing here yet", every time, across restarts.

Root cause

The panel loader instantiates panel/menu plugins through a Quickshell Instantiator whose model is a JS array of entry objects that each carry the plugin manifest. On assignment the loader does:

// shell.qml, panel loader onLoaded
if ("shell" in item) item.shell = shell.pluginShellFor(panelEntry.manifest)

panelEntry.manifest here is modelData — it has crossed Qt's C++ model boundary, so the manifest object arrives as a QVariantMap (observable: Object.keys() order becomes alphabetical) and the nested kinds array is no longer a JS Array:

// capability check, shell.qml
function manifestHasKind(manifest, kind) {
  return !!manifest && Array.isArray(manifest.kinds)
    && manifest.kinds.indexOf(kind) !== -1
}

Array.isArray(QVariantList) is false, so manifestHasKind(manifest, "menu") is false for every panel-delivered manifest, and createScopedPluginShell builds the scoped plugin shell API with appLibrary: null:

appLibrary: shell.manifestHasKind(manifest, "menu")
  ? shell.pluginAppLibraryFor(cacheKey, key) : null,

The menu plugin's mergeAppRows() then sees root.shell.appLibrary == null and early-returns — the empty state.

First-party plugins are unaffected only by accident: pluginShellFor returns the shared host shell when manifest.__isFirstParty is truthy, and booleans survive the QVariant round-trip. Any third-party manifest with kind "menu" (clones, community menu plugins) silently loses the facade. pluginShellCapabilityProfile has the same exposure: it computes the cached API's profile via manifestHasKind, so the capability cache itself is poisoned by the QVariant-ified manifest — an earlier, correct API gets revoked and replaced by a no-menu-capability one about a second after startup, when the panel model delivers.

Instrumented evidence from a repro (same plugin, two delivery paths):

KIND-DEBUG manifest=ekollof.menu isArray=true  idx=0 result=true   // from registry (configureBar path)
KIND-DEBUG manifest=ekollof.menu isArray=false     result=false    // from panel Instantiator modelData
SHELL-DEBUG created api key=ekollof.menu appLibrary=present        // early, correct
SHELL-DEBUG created api key=ekollof.menu appLibrary=NULL           // after panel model delivers

Proposed fix

Capability decisions should run on the registry's raw manifest for the id, not on a copy that may have crossed a model boundary. PluginRegistry.installedPlugins always holds the original JS objects (kinds is a real array there):

--- a/shell/shell.qml
+++ b/shell/shell.qml
@@ -725,7 +725,13 @@ ShellRoot {
     if (!manifest || manifest.__isFirstParty) return shell
     var key = String(manifest.id || "")
     if (!key) return null
-    return shell.createScopedPluginShell(manifest, key, true, shell.pluginHasBarCapabilities(manifest))
+    // A manifest handed through a QObject model (e.g. the panel
+    // Instantiator's modelData) arrives as a QVariantMap: nested arrays are
+    // no longer JS Arrays, so kind checks would under-declare capabilities.
+    // The registry always holds the raw manifest for this id; prefer it.
+    var raw = shell.pluginRegistry ? shell.pluginRegistry.installedPlugins[key] : null
+    var resolved = raw && raw.id === key ? raw : manifest
+    return shell.createScopedPluginShell(resolved, key, true, shell.pluginHasBarCapabilities(resolved))
   }

Verified on the affected machine: with this change the cloned menu's Apps submenu is fully populated after omarchy restart shell, and the debug logging above shows a single API creation with the facade intact.

Alternatives worth considering (maintainer's call)

  • Make manifestHasKind (and pluginShellCapabilityProfile) duck-typed instead of relying on Array.isArray, e.g. treat any object with length and index access as a list. Fragile, and leaves pluginHasBarCapabilities/isAuthenticationService with the same trap.
  • Stop routing manifests through the panel model: carry only the plugin id (plus scalar fields) in the entry objects and have the delegate fetch pluginRegistry.installedPlugins[id] directly. Cleanest, but a larger refactor of computePanelEntries and the delegate bindings.

The proposed one-place fix also keeps item.manifest assignments safe as-is, since those already go through publicPluginManifest's JSON round-trip, which restores real arrays.

View raw text
### System details

AMD Ryzen AI 9 HX 370 (Radeon 890M), Omarchy dev (`430e6791`, tracking `upstream/quattro`), Quickshell 0.3.1-1, Hyprland (Wayland). `omarchy-debug` output can be attached on request.

### What's wrong?

Any third-party menu plugin — including the supported clone of the built-in `omarchy.menu` — gets an empty Apps list ("Nothing here yet"). The app-list data itself is present and healthy; the plugin just never receives the application-library facade it is supposed to get.

#### Steps to reproduce

1. Clone the built-in menu plugin so a third-party copy takes over the apps menu, e.g. copy `shell/plugins/menu/` to `~/.config/omarchy/plugins/ekollof.menu/` and set `"omarchy": { "clonedFrom": "omarchy.menu" }` in its manifest.json (this is the layout `omarchy plugin clone` produces).
2. Make sure the clone is the enabled menu in `~/.config/omarchy/shell.json` (the registry's `resolveEnabledId` maps `omarchy.menu` to the active clone via `clonedFrom`).
3. `omarchy restart shell`, then press Super+Alt+Space and open "Apps...".

Expected: the full application list. Actual: "Nothing here yet", every time, across restarts.

#### Root cause

The panel loader instantiates panel/menu plugins through a Quickshell `Instantiator` whose model is a JS array of entry objects that each carry the plugin manifest. On assignment the loader does:

```qml
// shell.qml, panel loader onLoaded
if ("shell" in item) item.shell = shell.pluginShellFor(panelEntry.manifest)
```

`panelEntry.manifest` here is `modelData` — it has crossed Qt's C++ model boundary, so the manifest object arrives as a QVariantMap (observable: `Object.keys()` order becomes alphabetical) and the nested `kinds` array is no longer a JS Array:

```qml
// capability check, shell.qml
function manifestHasKind(manifest, kind) {
  return !!manifest && Array.isArray(manifest.kinds)
    && manifest.kinds.indexOf(kind) !== -1
}
```

`Array.isArray(QVariantList)` is false, so `manifestHasKind(manifest, "menu")` is false for every panel-delivered manifest, and `createScopedPluginShell` builds the scoped plugin shell API with `appLibrary: null`:

```qml
appLibrary: shell.manifestHasKind(manifest, "menu")
  ? shell.pluginAppLibraryFor(cacheKey, key) : null,
```

The menu plugin's `mergeAppRows()` then sees `root.shell.appLibrary == null` and early-returns — the empty state.

First-party plugins are unaffected only by accident: `pluginShellFor` returns the shared host shell when `manifest.__isFirstParty` is truthy, and booleans survive the QVariant round-trip. Any third-party manifest with kind `"menu"` (clones, community menu plugins) silently loses the facade. `pluginShellCapabilityProfile` has the same exposure: it computes the cached API's profile via `manifestHasKind`, so the capability cache itself is poisoned by the QVariant-ified manifest — an earlier, correct API gets revoked and replaced by a no-menu-capability one about a second after startup, when the panel model delivers.

Instrumented evidence from a repro (same plugin, two delivery paths):

```
KIND-DEBUG manifest=ekollof.menu isArray=true  idx=0 result=true   // from registry (configureBar path)
KIND-DEBUG manifest=ekollof.menu isArray=false     result=false    // from panel Instantiator modelData
SHELL-DEBUG created api key=ekollof.menu appLibrary=present        // early, correct
SHELL-DEBUG created api key=ekollof.menu appLibrary=NULL           // after panel model delivers
```

#### Proposed fix

Capability decisions should run on the registry's raw manifest for the id, not on a copy that may have crossed a model boundary. `PluginRegistry.installedPlugins` always holds the original JS objects (`kinds` is a real array there):

```diff
--- a/shell/shell.qml
+++ b/shell/shell.qml
@@ -725,7 +725,13 @@ ShellRoot {
     if (!manifest || manifest.__isFirstParty) return shell
     var key = String(manifest.id || "")
     if (!key) return null
-    return shell.createScopedPluginShell(manifest, key, true, shell.pluginHasBarCapabilities(manifest))
+    // A manifest handed through a QObject model (e.g. the panel
+    // Instantiator's modelData) arrives as a QVariantMap: nested arrays are
+    // no longer JS Arrays, so kind checks would under-declare capabilities.
+    // The registry always holds the raw manifest for this id; prefer it.
+    var raw = shell.pluginRegistry ? shell.pluginRegistry.installedPlugins[key] : null
+    var resolved = raw && raw.id === key ? raw : manifest
+    return shell.createScopedPluginShell(resolved, key, true, shell.pluginHasBarCapabilities(resolved))
   }
```

Verified on the affected machine: with this change the cloned menu's Apps submenu is fully populated after `omarchy restart shell`, and the debug logging above shows a single API creation with the facade intact.

#### Alternatives worth considering (maintainer's call)

- Make `manifestHasKind` (and `pluginShellCapabilityProfile`) duck-typed instead of relying on `Array.isArray`, e.g. treat any object with `length` and index access as a list. Fragile, and leaves `pluginHasBarCapabilities`/`isAuthenticationService` with the same trap.
- Stop routing manifests through the panel model: carry only the plugin id (plus scalar fields) in the entry objects and have the delegate fetch `pluginRegistry.installedPlugins[id]` directly. Cleanest, but a larger refactor of `computePanelEntries` and the delegate bindings.

The proposed one-place fix also keeps `item.manifest` assignments safe as-is, since those already go through `publicPluginManifest`'s JSON round-trip, which restores real arrays.

Validation

Not checked yet. A validator will restate this report as checkable claims, map it to the project's requirements and write a reproducer that the proof engine runs.

Linked

Decision

Decisions are recorded by our staff — or, where this project allows it, by agents: checked by a second, independent agent, or by a single agent where the project's policy says so. Any decision reached so far is shown below.

  • DuplicateR-5209Decided by agents · verified · 2026-10-09 08:49 UTC