81039581 ("fix(security): the plugin lane stops being a way in", 2026-08-05) made launch.kind = "command" settable only with the operator's admin token. Every rom-manager entry is <emulator> <args> <rom>, and check_entry_fieldsreturns on the first offending entry — so one ROM refused the whole reconcile and the library sat at zero.
Reproduced live on .21 against the running canary:
lane
payload
result
plugin token
kind: "command"
403 — "…may only be set with the operator's admin token"
plugin token
kind: "steam_appid"
200
admin token
kind: "command"
200
—
GET /library?provider=rom-manager
[]
library/launch.rs already says this happened once before: the Playnite plugin hit the identical wall and was rescued with a typed playnite kind the host resolves itself. That trick doesn't generalise — a Playnite launch is a fixed URI scheme, "whichever emulator the operator installed, with the core and flags they chose" is not.
What this does
The entry carries an opaque key and nothing executable. When a client picks the tile, the host asks the plugin that owns it what to run — at launch time, over the loopback UI port and per-boot secret the plugin already registered (POST /__launch).
What that buys over just letting the plugin write a command into the library:
A stolen plugin token stops being command execution. Planting an entry isn't enough: the host asks the live registered plugin, and a plugin asked about a key it never published answers 404. That's why the ask names the entry instead of trusting the payload.
Nothing executable is persisted or served. No command in library.json, none for GET /library to redact for a paired client.
No stale recipes — same reasoning as xbox resolving its AUMID at launch time.
Which plugin gets asked comes from the entry's provider, which the host stamps from the reconcile URL and never takes from the payload, so a plugin cannot impersonate another.
The host still spawns it
Worth being explicit, because the obvious reading is "let the plugin spawn the emulator" and that does not work. On Linux the command is either gamescope's own argv (a bare-spawn session nests it via set_launch_command) or a spawn carrying the session's compositor env, and the returned Child is what design/session-game-lifetime.md tracks to know the game exited. A plugin-spawned process lands outside the captured session and outside that tracking. So the plugin decides; the host spawns.
Notes for review
library/plugin_launch.rs — the ask: blocking ureq (the host's existing off-runtime client), bounded body, absolute-cwd requirement, no control characters, and a log line for every way it can come back empty.
library/launch.rs — plugin_recipe is tried before both per-OS resolvers, so a failed ask falls through to the existing "no recipe" error naturally.
launch_is_resolvable exists so the async handshake probe never makes the blocking call; native.rs's per-session resolve_launch moves onto spawn_blocking.
plugin-kit: serveUi({launch}) serves the route, and SyncError finally renders its cause — that omission is why a host refusal carrying a fully explanatory 403 reached a plugin's own UI as nothing but "Decode error".
Verified
cargo clippy clean, cargo fmt clean, 7 new plugin_launch tests + 66 library + 46 mgmt green (incl. the unchanged plugin_lane_cannot_set_command_execution_fields). One of the new tests drives the real HTTP call against a stub plugin on a real port, asserting method, bearer and body. plugin-kit: 79 tests, typecheck and biome clean.
Owed
On-glass: nothing has actually launched a ROM through this path yet.
The kit needs publishing before rom-manager can consume it (registry latest is 0.3.3; rom-manager still pins ^0.2.0). Plugin side: unom/punktfunk-plugin-rom-manager#rom-shapes-metadata-and-plugin-launch.
Forward-compatible in the meantime: an unpatched host accepts kind: "plugin" entries (verified — 200, entry lands with platform+region), they're just unlaunchable until a host carrying the kind ships. So the plugin half alone already fixes "0 in library".
## What was wrong
`81039581` ("fix(security): the plugin lane stops being a way in", 2026-08-05) made `launch.kind = "command"` settable only with the operator's admin token. Every rom-manager entry is `<emulator> <args> <rom>`, and `check_entry_fields` **returns on the first offending entry** — so one ROM refused the whole reconcile and the library sat at zero.
Reproduced live on `.21` against the running canary:
| lane | payload | result |
|---|---|---|
| plugin token | `kind: "command"` | **403** — "…may only be set with the operator's admin token" |
| plugin token | `kind: "steam_appid"` | 200 |
| admin token | `kind: "command"` | 200 |
| — | `GET /library?provider=rom-manager` | `[]` |
`library/launch.rs` already says this happened once before: the Playnite plugin hit the identical wall and was rescued with a typed `playnite` kind the host resolves itself. That trick doesn't generalise — a Playnite launch is a fixed URI scheme, "whichever emulator the operator installed, with the core and flags they chose" is not.
## What this does
The entry carries an **opaque key and nothing executable**. When a client picks the tile, the host asks the plugin that owns it what to run — at launch time, over the loopback UI port and per-boot secret the plugin already registered (`POST /__launch`).
What that buys over just letting the plugin write a command into the library:
- **A stolen plugin token stops being command execution.** Planting an entry isn't enough: the host asks the *live registered plugin*, and a plugin asked about a key it never published answers 404. That's why the ask names the entry instead of trusting the payload.
- **Nothing executable is persisted or served.** No command in `library.json`, none for `GET /library` to redact for a paired client.
- **No stale recipes** — same reasoning as `xbox` resolving its AUMID at launch time.
Which plugin gets asked comes from the entry's `provider`, which the host stamps from the reconcile URL and never takes from the payload, so a plugin cannot impersonate another.
### The host still spawns it
Worth being explicit, because the obvious reading is "let the plugin spawn the emulator" and that does not work. On Linux the command is either gamescope's own argv (a bare-spawn session nests it via `set_launch_command`) or a spawn carrying the session's compositor env, and the returned `Child` is what `design/session-game-lifetime.md` tracks to know the game exited. A plugin-spawned process lands outside the captured session *and* outside that tracking. So the plugin decides; the host spawns.
## Notes for review
- `library/plugin_launch.rs` — the ask: blocking `ureq` (the host's existing off-runtime client), bounded body, absolute-cwd requirement, no control characters, and a log line for every way it can come back empty.
- `library/launch.rs` — `plugin_recipe` is tried before both per-OS resolvers, so a failed ask falls through to the existing "no recipe" error naturally.
- `launch_is_resolvable` exists so the **async** handshake probe never makes the blocking call; `native.rs`'s per-session `resolve_launch` moves onto `spawn_blocking`.
- `plugin-kit`: `serveUi({launch})` serves the route, and `SyncError` finally renders its cause — that omission is why a host refusal carrying a fully explanatory 403 reached a plugin's own UI as nothing but "Decode error".
## Verified
`cargo clippy` clean, `cargo fmt` clean, 7 new `plugin_launch` tests + 66 library + 46 mgmt green (incl. the unchanged `plugin_lane_cannot_set_command_execution_fields`). One of the new tests drives the real HTTP call against a stub plugin on a real port, asserting method, bearer and body. plugin-kit: 79 tests, typecheck and biome clean.
## Owed
- **On-glass: nothing has actually launched a ROM through this path yet.**
- The kit needs publishing before rom-manager can consume it (registry `latest` is 0.3.3; rom-manager still pins `^0.2.0`). Plugin side: `unom/punktfunk-plugin-rom-manager#rom-shapes-metadata-and-plugin-launch`.
- Forward-compatible in the meantime: an **unpatched** host accepts `kind: "plugin"` entries (verified — 200, entry lands with platform+region), they're just unlaunchable until a host carrying the kind ships. So the plugin half alone already fixes "0 in library".
The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses
on the FIRST offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`,
stopped putting anything in the library at all. Playnite hit the same wall and was rescued
with a typed kind the host resolves itself; there is no fixed scheme for "whichever emulator
the operator configured, with the core and flags they chose", so that trick does not
generalise.
So the entry now carries an opaque key and nothing executable, and the host asks the plugin
that owns it what to run — at launch time, over the loopback UI port and per-boot secret it
already registered. A stolen plugin token stops being command execution: planting an entry is
not enough, because the live plugin answers 404 for a key it never published. Nothing
executable is persisted or served to a client, and an emulator that moved is picked up on the
next launch instead of leaving a dead tile (the same reasoning as `xbox` resolving its AUMID
at launch time).
The host still SPAWNS it, because only the host can put the process where the stream can see
it: on Linux the line is either gamescope's own argv or a spawn carrying the session's
compositor env, and the returned child is what session-game-lifetime tracks to know the game
exited. A plugin spawning the emulator itself would land it outside both.
- library/plugin_launch.rs — the ask: blocking ureq, bounded body, absolute cwd, no control
characters, and a log line for every way it can come back empty
- library/launch.rs — `plugin_recipe` tried before both per-OS resolvers, plus
`launch_is_resolvable` so the async handshake probe never makes the blocking call
- native.rs — the session's `resolve_launch` moves onto `spawn_blocking`
- plugin-kit — `serveUi({launch})` serves `POST /__launch`; and `SyncError` finally renders
its cause, which is why a host refusal with a fully explanatory 403 could reach a plugin's
own UI as nothing but "Decode error"
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What was wrong
81039581("fix(security): the plugin lane stops being a way in", 2026-08-05) madelaunch.kind = "command"settable only with the operator's admin token. Every rom-manager entry is<emulator> <args> <rom>, andcheck_entry_fieldsreturns on the first offending entry — so one ROM refused the whole reconcile and the library sat at zero.Reproduced live on
.21against the running canary:kind: "command"kind: "steam_appid"kind: "command"GET /library?provider=rom-manager[]library/launch.rsalready says this happened once before: the Playnite plugin hit the identical wall and was rescued with a typedplaynitekind the host resolves itself. That trick doesn't generalise — a Playnite launch is a fixed URI scheme, "whichever emulator the operator installed, with the core and flags they chose" is not.What this does
The entry carries an opaque key and nothing executable. When a client picks the tile, the host asks the plugin that owns it what to run — at launch time, over the loopback UI port and per-boot secret the plugin already registered (
POST /__launch).What that buys over just letting the plugin write a command into the library:
library.json, none forGET /libraryto redact for a paired client.xboxresolving its AUMID at launch time.Which plugin gets asked comes from the entry's
provider, which the host stamps from the reconcile URL and never takes from the payload, so a plugin cannot impersonate another.The host still spawns it
Worth being explicit, because the obvious reading is "let the plugin spawn the emulator" and that does not work. On Linux the command is either gamescope's own argv (a bare-spawn session nests it via
set_launch_command) or a spawn carrying the session's compositor env, and the returnedChildis whatdesign/session-game-lifetime.mdtracks to know the game exited. A plugin-spawned process lands outside the captured session and outside that tracking. So the plugin decides; the host spawns.Notes for review
library/plugin_launch.rs— the ask: blockingureq(the host's existing off-runtime client), bounded body, absolute-cwd requirement, no control characters, and a log line for every way it can come back empty.library/launch.rs—plugin_recipeis tried before both per-OS resolvers, so a failed ask falls through to the existing "no recipe" error naturally.launch_is_resolvableexists so the async handshake probe never makes the blocking call;native.rs's per-sessionresolve_launchmoves ontospawn_blocking.plugin-kit:serveUi({launch})serves the route, andSyncErrorfinally renders its cause — that omission is why a host refusal carrying a fully explanatory 403 reached a plugin's own UI as nothing but "Decode error".Verified
cargo clippyclean,cargo fmtclean, 7 newplugin_launchtests + 66 library + 46 mgmt green (incl. the unchangedplugin_lane_cannot_set_command_execution_fields). One of the new tests drives the real HTTP call against a stub plugin on a real port, asserting method, bearer and body. plugin-kit: 79 tests, typecheck and biome clean.Owed
latestis 0.3.3; rom-manager still pins^0.2.0). Plugin side:unom/punktfunk-plugin-rom-manager#rom-shapes-metadata-and-plugin-launch.kind: "plugin"entries (verified — 200, entry lands with platform+region), they're just unlaunchable until a host carrying the kind ships. So the plugin half alone already fixes "0 in library".pluginlaunch kind, so a scanner can publish tiles the host cannot nameThe 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses on the FIRST offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`, stopped putting anything in the library at all. Playnite hit the same wall and was rescued with a typed kind the host resolves itself; there is no fixed scheme for "whichever emulator the operator configured, with the core and flags they chose", so that trick does not generalise. So the entry now carries an opaque key and nothing executable, and the host asks the plugin that owns it what to run — at launch time, over the loopback UI port and per-boot secret it already registered. A stolen plugin token stops being command execution: planting an entry is not enough, because the live plugin answers 404 for a key it never published. Nothing executable is persisted or served to a client, and an emulator that moved is picked up on the next launch instead of leaving a dead tile (the same reasoning as `xbox` resolving its AUMID at launch time). The host still SPAWNS it, because only the host can put the process where the stream can see it: on Linux the line is either gamescope's own argv or a spawn carrying the session's compositor env, and the returned child is what session-game-lifetime tracks to know the game exited. A plugin spawning the emulator itself would land it outside both. - library/plugin_launch.rs — the ask: blocking ureq, bounded body, absolute cwd, no control characters, and a log line for every way it can come back empty - library/launch.rs — `plugin_recipe` tried before both per-OS resolvers, plus `launch_is_resolvable` so the async handshake probe never makes the blocking call - native.rs — the session's `resolve_launch` moves onto `spawn_blocking` - plugin-kit — `serveUi({launch})` serves `POST /__launch`; and `SyncError` finally renders its cause, which is why a host refusal with a fully explanatory 403 could reach a plugin's own UI as nothing but "Decode error"