Merge pull request '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) from worktree-rom-manager-plugin-launch into main
apple / swift (push) Successful in 1m39s
ci / web (push) Successful in 1m45s
ci / docs-site (push) Successful in 1m35s
ci / bun-nix (push) Successful in 42s
ci / rust-arm64 (push) Successful in 2m45s
deb / build-publish-client-arm64 (push) Successful in 1m4s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish (push) Successful in 3m55s
android / android (push) Successful in 6m31s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 30s
apple / screenshots (push) Successful in 6m8s
ci / rust (push) Canceled after 8m47s
deb / build-publish-host (push) Successful in 8m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m57s
windows-host / package (push) Successful in 11m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
arch / build-publish (push) Successful in 13m1s
apple / swift (push) Successful in 1m39s
ci / web (push) Successful in 1m45s
ci / docs-site (push) Successful in 1m35s
ci / bun-nix (push) Successful in 42s
ci / rust-arm64 (push) Successful in 2m45s
deb / build-publish-client-arm64 (push) Successful in 1m4s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish (push) Successful in 3m55s
android / android (push) Successful in 6m31s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 30s
apple / screenshots (push) Successful in 6m8s
ci / rust (push) Canceled after 8m47s
deb / build-publish-host (push) Successful in 8m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m57s
windows-host / package (push) Successful in 11m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
arch / build-publish (push) Successful in 13m1s
Reviewed-on: #129
This commit was merged in pull request #129.
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -476,6 +476,15 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
|
||||
"entries[{i}]: `launch.value` for kind `xbox` must be `<Identity>!<AppId>`"
|
||||
));
|
||||
}
|
||||
// `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) {
|
||||
|
||||
@@ -52,7 +52,9 @@ pub fn resolve_launch(id: &str) -> Option<LaunchTarget> {
|
||||
{
|
||||
// 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<LaunchTarget> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<PluginLaunch> {
|
||||
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>` (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<String> {
|
||||
/// 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<std::path::PathBuf>)> {
|
||||
match spec.kind.as_str() {
|
||||
|
||||
@@ -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 `<emulator> <args> <rom>` — 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<PathBuf>,
|
||||
}
|
||||
|
||||
/// The wire shape of `POST /__launch`'s response.
|
||||
#[derive(Deserialize)]
|
||||
struct LaunchReply {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
/// 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 `<platform>/<relpath>` 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<PluginLaunch> {
|
||||
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<PluginLaunch> {
|
||||
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<String>) {
|
||||
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::<usize>().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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -286,6 +286,38 @@ pub(crate) fn live_plugin_ids() -> Vec<String> {
|
||||
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<UiCredential> {
|
||||
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
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ export {
|
||||
deriveConfigJsonSchema,
|
||||
httpApiEnv,
|
||||
makeConfigHandler,
|
||||
makeLaunchHandler,
|
||||
type PluginLaunchTarget,
|
||||
type ServeUiConfig,
|
||||
type ServeUiOptions,
|
||||
serveUi,
|
||||
|
||||
@@ -116,6 +116,79 @@ export const makeConfigHandler = <S extends Schema.Top>(
|
||||
};
|
||||
};
|
||||
|
||||
/** 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: "<key>"}` 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<PluginLaunchTarget | null>,
|
||||
): ((req: Request) => Promise<Response>) => {
|
||||
return async (req: Request): Promise<Response> => {
|
||||
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/<id>/…` proxy, so there is no new host surface and nothing new exposed to the LAN.
|
||||
*/
|
||||
readonly config?: ServeUiConfig<Schema.Top>;
|
||||
/**
|
||||
* Serve `POST /__launch` — how a plugin answers "what do I run for this entry?" for library
|
||||
* entries it published with `launch: {kind: "plugin", value: "<key>"}`.
|
||||
*
|
||||
* 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<PluginLaunchTarget | null>;
|
||||
/**
|
||||
* 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<Response | undefined> => {
|
||||
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);
|
||||
};
|
||||
|
||||
+10
-1
@@ -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` | `<namespace>:<catalogItemId>:<appName>` or a bare appName | windows |
|
||||
* | `gog` | `exe \t args \t workdir` | windows |
|
||||
* | `aumid` | `<PFN>!<AppId>` | 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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<null>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user