From 5872dfc6495a60f56a1b84ab460680ececf64b78 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 8 Aug 2026 23:46:05 +0200 Subject: [PATCH] feat(library): a `plugin` launch kind, so a scanner can publish tiles the host cannot name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` `, 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" --- crates/punktfunk-host/src/library.rs | 2 + crates/punktfunk-host/src/library/custom.rs | 9 + crates/punktfunk-host/src/library/launch.rs | 87 ++++- .../src/library/plugin_launch.rs | 364 ++++++++++++++++++ crates/punktfunk-host/src/mgmt.rs | 8 + crates/punktfunk-host/src/mgmt/plugins.rs | 32 ++ crates/punktfunk-host/src/native.rs | 20 +- crates/punktfunk-host/src/native/handshake.rs | 8 +- plugin-kit/src/errors.ts | 16 +- plugin-kit/src/index.ts | 2 + plugin-kit/src/ui-server.ts | 92 +++++ plugin-kit/src/wire.ts | 11 +- plugin-kit/test/errors.test.ts | 35 +- plugin-kit/test/launch-handler.test.ts | 74 ++++ 14 files changed, 739 insertions(+), 21 deletions(-) create mode 100644 crates/punktfunk-host/src/library/plugin_launch.rs create mode 100644 plugin-kit/test/launch-handler.test.ts diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index 89d55064..8e8a8393 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -33,6 +33,7 @@ mod hidden; mod launch; #[cfg(target_os = "linux")] mod lutris; +mod plugin_launch; mod scanners; mod steam; #[cfg(windows)] @@ -51,6 +52,7 @@ pub use hidden::*; pub use launch::*; #[cfg(target_os = "linux")] pub use lutris::*; +pub use plugin_launch::*; pub use scanners::*; pub use steam::*; #[cfg(windows)] diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index 911710f9..2430dde9 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -476,6 +476,15 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St "entries[{i}]: `launch.value` for kind `xbox` must be `!`" )); } + // `plugin`: the value is an opaque key in the OWNING plugin's own namespace, handed back + // to it at launch time (see `library::ask_plugin_launch`). The host never parses it, so + // the only checks are the ones that keep it loggable and bounded. + if launch.kind == "plugin" && !valid_plugin_entry_key(&launch.value) { + return Err(format!( + "entries[{i}]: `launch.value` for kind `plugin` must be 1–512 chars with no \ + control characters" + )); + } } if let Some(marker) = &e.detect.env_marker { if !valid_env_key(&marker.key) { diff --git a/crates/punktfunk-host/src/library/launch.rs b/crates/punktfunk-host/src/library/launch.rs index c9eb98ad..d82109d9 100644 --- a/crates/punktfunk-host/src/library/launch.rs +++ b/crates/punktfunk-host/src/library/launch.rs @@ -52,7 +52,9 @@ pub fn resolve_launch(id: &str) -> Option { { // Linux runs the command itself, so a title without one has nothing to launch — same answer // (and same warning path) as before this resolution existed. - let command = entry.launch.as_ref().and_then(command_for)?; + let command = plugin_recipe(&entry) + .map(|l| l.command) + .or_else(|| entry.launch.as_ref().and_then(command_for))?; Some(LaunchTarget { game, launcher: entry.role == GameRole::Launcher, @@ -74,9 +76,66 @@ pub fn resolve_launch(id: &str) -> Option { } } +/// The recipe for a `plugin`-kind entry, asked of the plugin that owns it. `None` for every other +/// kind (without doing any I/O), so both per-OS resolvers can simply try this first. +/// +/// This lives beside [`resolve_launch`] / [`launch_title`] rather than inside `command_for` / +/// `windows_launch_for` because it needs the entry's **`provider`** — and that field is the whole +/// authorization story. `provider` is stamped by the host from the reconcile URL +/// (`PUT /library/provider/{provider}`), never taken from the payload, so it is what decides which +/// plugin gets asked. A plugin that plants an entry under someone else's provider only causes that +/// *other* plugin to be asked about a key it never published — which is a 404, not a launch. +/// +/// **Blocking**: see [`ask_plugin_launch`]. `resolve_launch`'s async callers hop through +/// `spawn_blocking`; the handshake probe uses [`launch_is_resolvable`], which never asks. +fn plugin_recipe(entry: &GameEntry) -> Option { + let spec = entry.launch.as_ref()?; + if spec.kind != "plugin" { + return None; + } + let Some(provider) = entry.provider.as_deref() else { + // Only a provider reconcile can author this kind, so this is unreachable short of a + // hand-edited library.json — say so rather than silently doing nothing. + tracing::warn!( + id = %entry.id, + "plugin launch: entry carries no provider, so no plugin can answer for it" + ); + return None; + }; + ask_plugin_launch(provider, &spec.value) +} + +/// Whether `id` will actually launch something — **without asking a plugin**. +/// +/// The handshake needs this one bit to decide dedicated-session routing, and it runs on the async +/// path, so it must not make a blocking call out to a plugin. For a `plugin`-kind entry the cheap +/// answer is "a live plugin is registered under its provider, and the key is well formed"; if that +/// plugin later refuses the ask, the launch fails the same way any unresolvable entry does and the +/// player is left on the session. +#[cfg(not(windows))] +pub fn launch_is_resolvable(id: &str) -> bool { + let Some(entry) = all_games().into_iter().find(|g| g.id == id) else { + return false; + }; + let Some(spec) = entry.launch.as_ref() else { + return false; + }; + if spec.kind == "plugin" { + return valid_plugin_entry_key(&spec.value) + && entry + .provider + .as_deref() + .is_some_and(|p| crate::mgmt::ui_credential(p).is_some()); + } + command_for(spec).is_some() +} + /// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of /// [`resolve_launch`], split out so the appid-validation can be tested without a Steam install). /// +/// The `plugin` kind is deliberately absent: its answer comes from another process, so it is +/// resolved by [`plugin_recipe`] before this is reached. +/// /// - `steam_appid` → `steam steam://rungameid/` (appid validated as digits). /// - `command` → the stored command verbatim. This string comes from the host's own custom store /// (added by the host operator via the admin UI), never from the client, so it is trusted. @@ -126,17 +185,24 @@ fn command_for(spec: &LaunchSpec) -> Option { /// desktop and grabs foreground. #[cfg(windows)] pub fn launch_title(id: &str) -> Result<()> { - let spec = all_games() + let entry = all_games() .into_iter() .find(|g| g.id == id) - .and_then(|g| g.launch) + .filter(|g| g.launch.is_some()) .ok_or_else(|| anyhow::anyhow!("no launchable library entry '{id}'"))?; - let (cmdline, workdir) = windows_launch_for(&spec).ok_or_else(|| { - anyhow::anyhow!( - "library entry '{id}' has no Windows launch recipe (kind '{}')", - spec.kind - ) - })?; + let spec = entry.launch.clone().expect("filtered to Some above"); + // A `plugin` entry's recipe comes from the plugin that owns it, and arrives in the same + // (command line, working dir) shape this path already spawns. `windows_launch_for` has no arm + // for the kind, so a failed ask falls through to the "no recipe" error below. + let (cmdline, workdir) = plugin_recipe(&entry) + .map(|l| (l.command, l.cwd)) + .or_else(|| windows_launch_for(&spec)) + .ok_or_else(|| { + anyhow::anyhow!( + "library entry '{id}' has no Windows launch recipe (kind '{}')", + spec.kind + ) + })?; let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref()) .with_context(|| format!("launch '{id}' in the interactive session"))?; tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session"); @@ -148,6 +214,9 @@ pub fn launch_title(id: &str) -> Result<()> { /// /// CreateProcessAsUserW does NO shell or protocol resolution, so the URI/flags are handed to a /// concrete EXE as plain arguments — a (host-derived) URI string can never reach a command interpreter. +/// +/// The `plugin` kind is deliberately absent: its answer comes from another process, so it is +/// resolved by [`plugin_recipe`] before this is reached. #[cfg(windows)] fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option)> { match spec.kind.as_str() { diff --git a/crates/punktfunk-host/src/library/plugin_launch.rs b/crates/punktfunk-host/src/library/plugin_launch.rs new file mode 100644 index 00000000..04c5caee --- /dev/null +++ b/crates/punktfunk-host/src/library/plugin_launch.rs @@ -0,0 +1,364 @@ +//! The `plugin` launch kind's transport: ask a library plugin what to run for one of **its own** +//! entries, at launch time, over the loopback UI surface it already registered. +//! +//! ## Why the host asks instead of storing a command +//! +//! A ROM tile is ` ` — an operator-configured command line, and the one shape +//! [`super::privileged_field`] refuses from the plugin lane (2026-08-05 review H-1). The Playnite +//! plugin hit the same wall and was rescued with a typed `playnite` kind the host resolves itself +//! (see `command_for`), but that only works because a Playnite launch is a fixed URI scheme. There +//! is no fixed scheme for "some emulator the operator installed, with the core and flags they chose" +//! — the knowledge lives in the plugin, and it is the plugin that owns the hardened quoting seam for +//! it (ROM filenames are untrusted input). +//! +//! So the entry carries an **opaque key** and nothing executable, and the command is fetched from +//! the owning plugin at the moment of an actual launch. What that buys over letting the plugin write +//! `kind = "command"` straight into the library: +//! +//! * **A stolen plugin token is no longer command execution.** Planting an entry is not enough — the +//! host asks the *live registered plugin* what to run, authenticated with the per-boot secret only +//! that process knows. A plugin asked about an entry it never published answers 404 (this is why +//! the ask names the entry rather than trusting the payload), so a forged entry launches nothing. +//! * **Nothing executable is ever persisted or served.** No command lands in `library.json`, and +//! `GET /library` has none to redact for a paired client. +//! * **No stale recipes.** The same reasoning as the `xbox` kind resolving its AUMID at launch time: +//! an emulator that moved, or a config the operator has since edited, is picked up on the next +//! launch instead of leaving an unlaunchable tile behind. +//! +//! The host still *runs* the command, because only the host can put the process where the stream can +//! see it: on Linux the line is either gamescope's own argv (a bare-spawn session nests it) 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 spawning the emulator +//! itself would land it outside the captured session and outside that lifetime. + +use super::*; +use std::io::Read; +use std::time::Duration; + +/// The whole ask, end to end. A plugin resolving one of its own entries is a local lookup against +/// state it already holds, so this is generous for a healthy plugin and short enough that a wedged +/// one cannot hold a launch — or, on the GameStream plane, the data-plane thread that calls this — +/// for longer than a player would keep staring at a tile that did nothing. +const ASK_TIMEOUT: Duration = Duration::from_secs(3); + +/// A command LINE, not a script. Generous for `flatpak run … --core=… "/very/long/rom path"`, +/// bounded so a malformed answer cannot land a megabyte in the logs or in a shell argument. +const MAX_COMMAND: usize = 4096; + +/// Cap the whole response body — the shape is two short strings. +const MAX_BODY: usize = 64 * 1024; + +/// What a plugin answered: the command line to run, and optionally the directory to run it in +/// (emulators that resolve cores or configs relative to their install dir need one). +pub struct PluginLaunch { + pub command: String, + pub cwd: Option, +} + +/// The wire shape of `POST /__launch`'s response. +#[derive(Deserialize)] +struct LaunchReply { + command: String, + #[serde(default)] + cwd: Option, +} + +/// The opaque per-entry key a `plugin` launch carries. It is echoed to the owning plugin as JSON and +/// lands in log lines, so bound it and keep control characters out; everything else is the plugin's +/// own namespace (rom-manager uses its `/` external id). +pub fn valid_plugin_entry_key(v: &str) -> bool { + !v.is_empty() && v.len() <= 512 && !v.chars().any(char::is_control) +} + +/// Ask `plugin` what to run for its entry `key`. +/// +/// `None` — the plugin is not registered/live, has no UI surface, disowns the entry, or answered +/// something unusable. Every arm logs, because from a player's seat all of them look like "the tile +/// did nothing", and the difference is exactly what an operator needs to fix it. +/// +/// **Blocking** (`ureq`, the host's existing off-runtime HTTP client): callers run on a blocking +/// thread. `resolve_launch`'s async callers hop through `spawn_blocking`, and the handshake's +/// "is this launchable at all" probe uses [`super::launch_is_resolvable`], which never asks. +pub fn ask_plugin_launch(plugin: &str, key: &str) -> Option { + if !valid_plugin_entry_key(key) { + tracing::warn!( + plugin, + "plugin launch: entry key failed validation — ignoring" + ); + return None; + } + let Some(cred) = crate::mgmt::ui_credential(plugin) else { + tracing::warn!( + plugin, + entry = key, + "plugin launch: no live plugin registered under that provider id (is it running?) — \ + nothing to launch" + ); + return None; + }; + let agent = ureq::AgentBuilder::new().timeout(ASK_TIMEOUT).build(); + // Loopback + the plugin's own per-boot secret, exactly what the console proxy presents. The + // registration stores a PORT, never an address (mgmt::plugins D5), so this can only ever dial + // this machine. + // `send_string` + an explicit content type rather than `send_json`: that one needs ureq's `json` + // feature, and the body is one field. + let body = serde_json::json!({ "entry": key }).to_string(); + let resp = match agent + .post(&format!("http://127.0.0.1:{}/__launch", cred.port)) + .set("Authorization", &format!("Bearer {}", cred.secret)) + .set("Content-Type", "application/json") + .send_string(&body) + { + Ok(r) => r, + // A plugin that does not know the entry says so with a 404 — the answer a FORGED entry gets, + // and the reason planting one is not enough to make the host run anything. + Err(ureq::Error::Status(404, _)) => { + tracing::warn!( + plugin, + entry = key, + "plugin launch: the plugin does not own an entry with that key — nothing to launch" + ); + return None; + } + Err(ureq::Error::Status(code, _)) => { + tracing::warn!( + plugin, + entry = key, + code, + "plugin launch: the plugin refused to resolve the entry" + ); + return None; + } + Err(e) => { + tracing::warn!( + plugin, + entry = key, + error = %e, + "plugin launch: could not reach the plugin's launch surface" + ); + return None; + } + }; + let mut buf = Vec::new(); + if let Err(e) = resp + .into_reader() + .take((MAX_BODY + 1) as u64) + .read_to_end(&mut buf) + { + tracing::warn!(plugin, entry = key, error = %e, "plugin launch: reading the answer failed"); + return None; + } + if buf.len() > MAX_BODY { + tracing::warn!( + plugin, + entry = key, + "plugin launch: answer exceeds the {MAX_BODY}-byte cap" + ); + return None; + } + let reply: LaunchReply = match serde_json::from_slice(&buf) { + Ok(r) => r, + Err(e) => { + tracing::warn!(plugin, entry = key, error = %e, "plugin launch: answer was not {{command, cwd}}"); + return None; + } + }; + validate_reply(plugin, key, reply) +} + +/// The checks on what came back, split out so they can be tested without a plugin on a port. +fn validate_reply(plugin: &str, key: &str, reply: LaunchReply) -> Option { + let command = reply.command.trim().to_string(); + if command.is_empty() { + tracing::warn!( + plugin, + entry = key, + "plugin launch: answered an empty command" + ); + return None; + } + if command.len() > MAX_COMMAND { + tracing::warn!( + plugin, + entry = key, + "plugin launch: command exceeds the {MAX_COMMAND}-byte cap" + ); + return None; + } + // Hygiene rather than a security boundary — a plugin that wanted two commands could always write + // `a; b`, and composing the line is its job. But a launch command is ONE line: keeping control + // characters out is what makes the logged line the line that ran, and what stops a stray `\r` + // from mangling the Windows `cmd.exe /c` form. + if command.chars().any(char::is_control) { + tracing::warn!( + plugin, + entry = key, + "plugin launch: command contains control characters — refusing it" + ); + return None; + } + let cwd = match reply + .cwd + .as_deref() + .map(str::trim) + .filter(|c| !c.is_empty()) + { + None => None, + Some(dir) => { + let path = PathBuf::from(dir); + // Relative to WHAT? The host's cwd is not the plugin's, and a launch that silently ran + // somewhere unintended is worse than one that says why it did not. + if !path.is_absolute() { + tracing::warn!( + plugin, + entry = key, + cwd = dir, + "plugin launch: working directory must be absolute — refusing it" + ); + return None; + } + Some(path) + } + }; + Some(PluginLaunch { command, cwd }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + /// A one-shot HTTP/1.1 stub on an ephemeral loopback port. Returns the port and a handle that + /// yields the raw request text — so the assertions about what the HOST sent (method, path, + /// bearer, body) live in the test thread, where a failure reads as a failure. + fn stub_plugin(status: u16, body: &'static str) -> (u16, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback"); + let port = listener.local_addr().expect("local addr").port(); + let handle = std::thread::spawn(move || { + let (mut sock, _) = listener.accept().expect("accept"); + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + // Read until the body named by Content-Length has arrived (ureq always sends one here). + loop { + let n = sock.read(&mut chunk).expect("read request"); + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + let text = String::from_utf8_lossy(&buf).to_string(); + if let Some(end) = text.find("\r\n\r\n") { + let len = text[..end] + .lines() + .find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case("content-length") + .then(|| v.trim().parse::().ok())? + }) + .unwrap_or(0); + if buf.len() >= end + 4 + len { + break; + } + } + } + let resp = format!( + "HTTP/1.1 {status} STATUS\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + sock.write_all(resp.as_bytes()).expect("write response"); + let _ = sock.flush(); + String::from_utf8_lossy(&buf).to_string() + }); + (port, handle) + } + + #[test] + fn asks_the_registered_plugin_and_takes_its_answer() { + let (port, server) = + stub_plugin(200, r#"{"command":"retroarch 'smw.sfc'","cwd":"/opt/emu"}"#); + crate::mgmt::register_ui_for_test("stub-launcher", port, "s3cr3t"); + + let got = ask_plugin_launch("stub-launcher", "snes/smw.sfc").expect("a recipe"); + assert_eq!(got.command, "retroarch 'smw.sfc'"); + assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new("/opt/emu"))); + + let req = server.join().expect("stub thread"); + assert!(req.starts_with("POST /__launch "), "request was {req:?}"); + // The plugin's own per-boot secret, the same credential the console proxy presents. + assert!( + req.contains("Bearer s3cr3t"), + "the ask must authenticate: {req:?}" + ); + // The entry key is what the plugin resolves against its own state — it must be on the wire. + assert!( + req.contains(r#""entry":"snes/smw.sfc""#), + "body was {req:?}" + ); + } + + #[test] + fn a_404_means_the_plugin_disowns_the_entry() { + // The forged-entry case: planting a library row is not enough, because the plugin that would + // have to answer for it never published one. + let (port, server) = stub_plugin(404, r#"{"error":"no launchable entry \"forged\""}"#); + crate::mgmt::register_ui_for_test("stub-disowner", port, "s"); + + assert!(ask_plugin_launch("stub-disowner", "forged").is_none()); + server.join().expect("stub thread"); + } + + #[test] + fn an_unregistered_provider_resolves_to_nothing() { + // No live plugin, no port to dial, no launch — and no panic. + assert!(ask_plugin_launch("no-such-plugin-is-registered", "k").is_none()); + } + + fn reply(command: &str, cwd: Option<&str>) -> LaunchReply { + LaunchReply { + command: command.into(), + cwd: cwd.map(str::to_string), + } + } + + #[test] + fn entry_keys_are_bounded_and_printable() { + assert!(valid_plugin_entry_key("snes/Super Mario World.sfc")); + assert!(!valid_plugin_entry_key("")); + assert!(!valid_plugin_entry_key("with\nnewline")); + assert!(!valid_plugin_entry_key("with\0nul")); + assert!(!valid_plugin_entry_key(&"x".repeat(513))); + } + + #[test] + fn a_usable_answer_passes_through_trimmed() { + let got = validate_reply( + "rom-manager", + "snes/smw", + reply(" retroarch 'smw.sfc' \n", None), + ) + .expect("usable"); + assert_eq!(got.command, "retroarch 'smw.sfc'"); + assert!(got.cwd.is_none()); + } + + #[test] + fn empty_and_oversized_and_control_char_commands_are_refused() { + assert!(validate_reply("p", "k", reply(" ", None)).is_none()); + assert!(validate_reply("p", "k", reply(&"x".repeat(MAX_COMMAND + 1), None)).is_none()); + // The interesting one: a second line smuggled into what the host logs as a single command. + assert!(validate_reply("p", "k", reply("retroarch rom\nrm -rf ~", None)).is_none()); + } + + #[test] + fn a_working_directory_must_be_absolute() { + let abs = if cfg!(windows) { r"C:\emu" } else { "/opt/emu" }; + let got = validate_reply("p", "k", reply("run", Some(abs))).expect("absolute cwd is fine"); + assert_eq!(got.cwd.as_deref(), Some(std::path::Path::new(abs))); + assert!(validate_reply("p", "k", reply("run", Some("emu/cores"))).is_none()); + // An empty/whitespace cwd is "no preference", not a refusal. + assert!(validate_reply("p", "k", reply("run", Some(" "))) + .expect("blank cwd is tolerated") + .cwd + .is_none()); + } +} diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 2cd94a31..b08ab35f 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -47,6 +47,14 @@ mod store; mod tests; mod update; +/// Lets `library::plugin_launch`'s tests put a stub plugin in the registry (test-only). +#[cfg(test)] +pub(crate) use plugins::register_ui_for_test; +/// The launch path asks a library plugin what to run for its own entries, and needs the loopback +/// credential this process already holds for it. Re-exported (rather than opening the whole +/// `plugins` module crate-wide) so these two are the ONLY things `mgmt` lends to the library side. +pub(crate) use plugins::ui_credential; + /// Default management port — adjacent to the GameStream block (47984…48010), and the same /// number Sunshine users already associate with "the config UI". pub const DEFAULT_PORT: u16 = 47990; diff --git a/crates/punktfunk-host/src/mgmt/plugins.rs b/crates/punktfunk-host/src/mgmt/plugins.rs index f4ab317f..9f9bfd7d 100644 --- a/crates/punktfunk-host/src/mgmt/plugins.rs +++ b/crates/punktfunk-host/src/mgmt/plugins.rs @@ -286,6 +286,38 @@ pub(crate) fn live_plugin_ids() -> Vec { registry().live_ids() } +/// The loopback `{port, secret}` a live plugin serves its UI on — the credential the **host itself** +/// presents when it asks a library plugin what to run for one of its `plugin`-kind launch entries +/// ([`crate::library::ask_plugin_launch`]). +/// +/// The same lookup the console proxy gets from `GET /plugins/{id}/ui-credential`, exposed in-process +/// so the launch path never round-trips through the management API to reach a port this process +/// already holds. `None` for an unknown, expired, or UI-less plugin — which the launch path reports +/// as "no recipe", exactly like any other unresolvable entry. +pub(crate) fn ui_credential(id: &str) -> Option { + registry().credential(id) +} + +/// Put a live UI registration in the registry directly — **test only**, so the launch path +/// ([`crate::library::ask_plugin_launch`]) can be driven against a stub server without standing up +/// the whole management router just to reach `PUT /plugins/{id}`. +#[cfg(test)] +pub(crate) fn register_ui_for_test(id: &str, port: u16, secret: &str) { + registry().upsert( + id, + Valid { + title: id.to_string(), + version: None, + ui: Some(StoredUi { + port, + secret: secret.to_string(), + icon: None, + }), + category: None, + }, + ); +} + // ---------------------------------------------------------------- validation /// A plugin id: `definePlugin`'s kebab-case name (`^[a-z][a-z0-9-]*$`, ≤64) — the same regex the SDK diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 17baebf5..122de3f3 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1507,11 +1507,17 @@ async fn serve_session( // launcher's on-disk metadata, and the data plane needs three things out of it — what to run, what // to call the title, and how to recognize its process once a launcher has handed off // (design/session-game-lifetime.md §4). - let launch_target = - hello - .launch - .as_deref() - .and_then(|id| match crate::library::resolve_launch(id) { + // + // On a blocking thread: a `plugin`-kind entry resolves by asking the plugin that owns it over + // loopback (`library::ask_plugin_launch`), and this is an async context. + let launch_target = match hello.launch.as_deref() { + None => None, + Some(id) => { + let owned = id.to_string(); + match tokio::task::spawn_blocking(move || crate::library::resolve_launch(&owned)) + .await + .context("resolve the session's library launch")? + { Some(t) => { tracing::info!( launch_id = id, @@ -1528,7 +1534,9 @@ async fn serve_session( ); None } - }); + } + } + }; #[cfg(target_os = "windows")] let launch_for_dp = launch_target.as_ref().and(hello.launch.clone()); #[cfg(not(target_os = "windows"))] diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 18a76d5d..2eae0786 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -270,13 +270,15 @@ pub(super) async fn negotiate( // id must fall back to normal auto routing, not a blank "sleep infinity" gamescope // (review #9). (dedicated is Linux-only, and only there does `resolve_launch` carry a // command — on Windows the concrete process is resolved at launch time instead.) + // `launch_is_resolvable`, not a full `resolve_launch`: a `plugin`-kind entry's command + // is fetched from the owning plugin over loopback, and this runs on the async path. The + // cheap check answers the only question asked here (does this tile launch anything?) + // without a blocking call — see `library::launch_is_resolvable`. #[cfg(not(target_os = "windows"))] let has_resolvable_launch = hello .launch .as_deref() - .and_then(crate::library::resolve_launch) - .and_then(|t| t.command) - .is_some(); + .is_some_and(crate::library::launch_is_resolvable); #[cfg(target_os = "windows")] let has_resolvable_launch = false; let dedicated = crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch); diff --git a/plugin-kit/src/errors.ts b/plugin-kit/src/errors.ts index 45707a9e..74dec6f1 100644 --- a/plugin-kit/src/errors.ts +++ b/plugin-kit/src/errors.ts @@ -80,4 +80,18 @@ export class UiServeError extends Data.TaggedError("UiServeError")<{ export class SyncError extends Data.TaggedError("SyncError")<{ readonly reason: string; readonly cause: unknown; -}> {} +}> { + /** + * Same load-bearing getter as {@link HostRequestError}, and for the same reason one step further + * out: without it `String(e)` is the bare tag `SyncError`, so a plugin that renders its sync + * failure into an API error or a toast shows the operator a word instead of the refusal. + * + * That is how a rom-manager sync refused with a fully explanatory 403 reached its own UI as + * "Decode error" and nothing else — the reason existed at every layer and was dropped at this + * one. `describeCause` unwraps a nested `HostRequestError` through its own message getter, so + * the host's sentence survives the whole way to the surface. + */ + override get message(): string { + return `sync (${this.reason}) failed: ${describeCause(this.cause)}`; + } +} diff --git a/plugin-kit/src/index.ts b/plugin-kit/src/index.ts index d80b2bb2..99fb6bb1 100644 --- a/plugin-kit/src/index.ts +++ b/plugin-kit/src/index.ts @@ -50,6 +50,8 @@ export { deriveConfigJsonSchema, httpApiEnv, makeConfigHandler, + makeLaunchHandler, + type PluginLaunchTarget, type ServeUiConfig, type ServeUiOptions, serveUi, diff --git a/plugin-kit/src/ui-server.ts b/plugin-kit/src/ui-server.ts index d3bbd22e..2b0cec4b 100644 --- a/plugin-kit/src/ui-server.ts +++ b/plugin-kit/src/ui-server.ts @@ -116,6 +116,79 @@ export const makeConfigHandler = ( }; }; +/** What a plugin answers when the host asks how to start one of its own library entries. */ +export interface PluginLaunchTarget { + /** + * The command LINE to run. The plugin composes AND quotes it — the host runs it as-is, so + * anything interpolated from untrusted input (a ROM filename) must already be quoted here. + */ + readonly command: string; + /** Absolute working directory, for a program that resolves cores or configs relative to one. */ + readonly cwd?: string; +} + +/** + * The `/__launch` request handler, split out so it can be driven directly in tests — the wire shape + * is a contract with the HOST, which deserves a real round-trip test rather than a mock. + * + * This is the plugin half of the `plugin` launch kind. A library entry published with + * `launch: {kind: "plugin", value: ""}` carries no command; when a client picks that tile, the + * host asks the plugin that owns it — over this route, on the plugin's loopback UI port, with the + * per-boot secret — what to run, and runs the answer itself (only the host can put the process + * inside the captured session, and it needs the child to know when the game exits). + * + * **Answering `null` is load-bearing.** It becomes a 404, which is what the host gets for an entry + * this plugin never published — and therefore what makes a library entry forged by someone holding a + * stolen plugin token inert rather than arbitrary command execution. Resolve against your own state, + * never by trusting the key. + */ +export const makeLaunchHandler = ( + resolve: (entry: string) => Effect.Effect, +): ((req: Request) => Promise) => { + return async (req: Request): Promise => { + if (req.method !== "POST") { + return new Response("method not allowed", { status: 405 }); + } + let body: unknown; + try { + body = await req.json(); + } catch (cause) { + return Response.json( + { error: "body must be JSON", issue: String(cause) }, + { status: 400 }, + ); + } + const entry = (body as { entry?: unknown } | null)?.entry; + if (typeof entry !== "string" || entry.length === 0) { + return Response.json( + { error: "body must be {entry: string}" }, + { status: 400 }, + ); + } + let target: PluginLaunchTarget | null; + try { + target = await Effect.runPromise(resolve(entry)); + } catch (cause) { + // A resolver that died is not the same as one that disowned the entry: keep 404 meaning + // "not mine" so the host's log says which of the two happened. + return Response.json( + { error: "launch resolution failed", issue: String(cause) }, + { status: 500 }, + ); + } + if (target === null) { + return Response.json( + { error: `no launchable entry "${entry}"` }, + { status: 404 }, + ); + } + return Response.json({ + command: target.command, + ...(target.cwd !== undefined ? { cwd: target.cwd } : {}), + }); + }; +}; + export interface ServeUiOptions { /** Console nav title. */ readonly title: string; @@ -143,6 +216,19 @@ export interface ServeUiOptions { * `/plugin-ui//…` proxy, so there is no new host surface and nothing new exposed to the LAN. */ readonly config?: ServeUiConfig; + /** + * Serve `POST /__launch` — how a plugin answers "what do I run for this entry?" for library + * entries it published with `launch: {kind: "plugin", value: ""}`. + * + * Set this when the plugin's tiles start something the host cannot name on its own (a ROM through + * an emulator, say). The alternative — publishing `kind: "command"` — is refused from the plugin + * lane outright: a stored command line is executed as the host user, and only the operator's own + * token may write one. + * + * Resolve against the plugin's OWN state and answer `null` for anything else; see + * {@link makeLaunchHandler} for why that 404 is the security-relevant case. + */ + readonly launch?: (entry: string) => Effect.Effect; /** * The plugin API: `HttpApiBuilder.layer(api)` + group handler layers + raw routes * (e.g. `sseRoute`), with plugin services already provided. `httpApiEnv` is provided @@ -183,6 +269,9 @@ export const serveUi = ( const serveConfig = opts.config ? makeConfigHandler(opts.config) : undefined; + const serveLaunch = opts.launch + ? makeLaunchHandler(opts.launch) + : undefined; const fetch = async (req: Request): Promise => { const url = new URL(req.url); @@ -192,6 +281,9 @@ export const serveUi = ( if (url.pathname === "/__config") { return serveConfig?.(req) ?? new Response("not found", { status: 404 }); } + if (url.pathname === "/__launch") { + return serveLaunch?.(req) ?? new Response("not found", { status: 404 }); + } if (!url.pathname.startsWith(prefix)) return undefined; // → static SPA return handler(req); }; diff --git a/plugin-kit/src/wire.ts b/plugin-kit/src/wire.ts index 28ea43b6..ffd65d05 100644 --- a/plugin-kit/src/wire.ts +++ b/plugin-kit/src/wire.ts @@ -16,7 +16,9 @@ export type Artwork = typeof Artwork.Type; * How the host should launch a title. **The host owns this vocabulary** — it validates the value * per kind and builds the actual URI / command line itself, so a plugin only ever supplies a * validated value, never a command. That is the security invariant behind the whole provider lane: - * a client sends an entry id, and the host resolves what to run. + * a client sends an entry id, and the host resolves what to run. (`plugin`, below, is the one kind + * whose command the plugin composes — but it is still never *stored*: the host asks the live plugin + * at launch time, so an entry on its own executes nothing.) * * `kind` is a plain string rather than a union so the kit never has to ship a release to keep up * with a host that grew a new kind. The kinds the host understands today: @@ -32,6 +34,13 @@ export type Artwork = typeof Artwork.Type; * | `epic` | `::` or a bare appName | windows | * | `gog` | `exe \t args \t workdir` | windows | * | `aumid` | `!` | windows | + * | `plugin` | an opaque key in THIS plugin's namespace — see below | both | + * + * `plugin` is the escape hatch for a tile the host cannot name on its own (a ROM through whichever + * emulator the operator configured). The value is meaningless to the host: it hands the key back to + * the plugin that published the entry, on its own loopback UI port, and runs the command line that + * comes back. Serve it with `serveUi({launch})`; a plugin that publishes this kind without serving + * `/__launch` grows unlaunchable tiles. * * An unknown kind is accepted on the wire and simply yields no launch recipe on that host, so a * plugin targeting a newer host degrades to an unlaunchable tile rather than a failed reconcile. diff --git a/plugin-kit/test/errors.test.ts b/plugin-kit/test/errors.test.ts index 731f66dd..7010fc97 100644 --- a/plugin-kit/test/errors.test.ts +++ b/plugin-kit/test/errors.test.ts @@ -1,7 +1,7 @@ // What a kit error says when something interpolates it — which is the whole diagnosis surface a // plugin operator gets, because `sync-engine`'s failure path logs `${e.cause}` and nothing else. import { describe, expect, test } from "bun:test"; -import { HostRequestError } from "../src/errors.js"; +import { HostRequestError, SyncError } from "../src/errors.js"; describe("HostRequestError", () => { // Regression for 2026-08-08: this printed the bare tag, so `plugin:lutris sync (startup) @@ -62,3 +62,36 @@ describe("HostRequestError", () => { expect(err.path).toBe("/library/provider/heroic"); }); }); + +describe("SyncError", () => { + // Regression for 2026-08-08 (rom-manager): the host refused every ROM reconcile with a 403 that + // named the offending field AND the fix, `HostRequestError` carried that sentence faithfully — + // and then this class dropped it, because the default string form is the bare tag. The plugin + // rendered `String(e)` into its API error, so the operator's entire diagnosis was the word + // "SyncError" (and, after the undecodable 500, "Decode error"). The chain must survive. + test("carries the nested host explanation, not the bare tag", () => { + const err = new SyncError({ + reason: "manual", + cause: new HostRequestError({ + method: "PUT", + path: "/library/provider/rom-manager", + cause: { + error: + '`launch.kind = "command"` is executed as the host user and may only be set with the operator\'s admin token', + }, + }), + }); + + expect(`${err}`).toContain("manual"); + expect(`${err}`).toContain("/library/provider/rom-manager"); + expect(`${err}`).toContain("launch.kind"); + expect(`${err}`).not.toBe("SyncError"); + expect(`${err}`).not.toContain("[object Object]"); + }); + + test("keeps its tag and fields for catchTag narrowing", () => { + const err = new SyncError({ reason: "startup", cause: "boom" }); + expect(err._tag).toBe("SyncError"); + expect(err.reason).toBe("startup"); + }); +}); diff --git a/plugin-kit/test/launch-handler.test.ts b/plugin-kit/test/launch-handler.test.ts new file mode 100644 index 00000000..c0d01103 --- /dev/null +++ b/plugin-kit/test/launch-handler.test.ts @@ -0,0 +1,74 @@ +// The `/__launch` wire shape — the plugin half of the `plugin` launch kind, and a contract with the +// HOST (`library::ask_plugin_launch`), so it is driven end to end here rather than mocked. +import { describe, expect, test } from "bun:test"; +import { Effect } from "effect"; +import { makeLaunchHandler, type PluginLaunchTarget } from "../src/index.js"; + +const post = (body: unknown, init?: RequestInit): Request => + new Request("http://127.0.0.1/__launch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + ...init, + }); + +/** A resolver that owns exactly one entry — the shape every real plugin's resolver has. */ +const oneEntry = (key: string, target: PluginLaunchTarget) => + makeLaunchHandler((entry) => Effect.succeed(entry === key ? target : null)); + +describe("makeLaunchHandler", () => { + test("answers a known entry with its command", async () => { + const h = oneEntry("snes/smw.sfc", { + command: "retroarch -L snes9x.so '/roms/snes/smw.sfc'", + }); + const res = await h(post({ entry: "snes/smw.sfc" })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + command: "retroarch -L snes9x.so '/roms/snes/smw.sfc'", + }); + }); + + test("carries a working directory only when the plugin set one", async () => { + const withCwd = oneEntry("k", { command: "run", cwd: "/opt/emu" }); + expect(await (await withCwd(post({ entry: "k" }))).json()).toEqual({ + command: "run", + cwd: "/opt/emu", + }); + const without = oneEntry("k", { command: "run" }); + // Absent, not `cwd: undefined` — the host decodes {command, cwd?} and a present-but-null key + // is the exact shape that broke this plugin's own API once before (v0.3.2). + expect( + Object.hasOwn(await (await without(post({ entry: "k" }))).json(), "cwd"), + ).toBe(false); + }); + + test("404s an entry the plugin does not own — the forged-entry case", async () => { + const h = oneEntry("mine", { command: "run" }); + const res = await h(post({ entry: "someone-elses" })); + expect(res.status).toBe(404); + }); + + test("a resolver that dies is a 500, distinct from disowning the entry", async () => { + const h = makeLaunchHandler( + () => Effect.die(new Error("cache unreadable")) as Effect.Effect, + ); + const res = await h(post({ entry: "k" })); + expect(res.status).toBe(500); + }); + + test("refuses a body that is not {entry: string}", async () => { + const h = oneEntry("k", { command: "run" }); + expect((await h(post("not json at all"))).status).toBe(400); + expect((await h(post({}))).status).toBe(400); + expect((await h(post({ entry: 42 }))).status).toBe(400); + expect((await h(post({ entry: "" }))).status).toBe(400); + }); + + test("only POST", async () => { + const h = oneEntry("k", { command: "run" }); + const res = await h( + new Request("http://127.0.0.1/__launch", { method: "GET" }), + ); + expect(res.status).toBe(405); + }); +});