rom-manager has been putting 0 games in the library since 08-05 — a plugin launch kind, so a scanner can publish tiles the host cannot name #129

Merged
enricobuehler merged 1 commits from worktree-rom-manager-plugin-launch into main 2026-08-08 22:44:48 +00:00
Owner

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.rsplugin_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".
enricobuehler added 1 commit 2026-08-08 22:30:45 +00:00
feat(library): a plugin launch kind, so a scanner can publish tiles the host cannot name
apple / swift (pull_request) Successful in 1m40s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 3m46s
ci / bun-nix (pull_request) Successful in 54s
ci / rust-arm64 (pull_request) Successful in 5m54s
android / android (pull_request) Successful in 7m29s
ci / rust (pull_request) Successful in 21m2s
5872dfc649
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"
enricobuehler merged commit 78ba2342b5 into main 2026-08-08 22:44:48 +00:00
enricobuehler deleted branch worktree-rom-manager-plugin-launch 2026-08-08 22:44:50 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#129