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); + }); +});