feat(library): a plugin launch kind, so a scanner can publish tiles the host cannot name
apple / swift (pull_request) Successful in 1m40s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m34s
ci / web (pull_request) Successful in 3m46s
ci / bun-nix (pull_request) Successful in 54s
ci / rust-arm64 (pull_request) Successful in 5m54s
android / android (pull_request) Successful in 7m29s
ci / rust (pull_request) Successful in 21m2s

The 2026-08-05 review made `launch.kind = "command"` operator-only, and a reconcile refuses
on the FIRST offending entry — so rom-manager, whose every ROM is `<emulator> <args> <rom>`,
stopped putting anything in the library at all. Playnite hit the same wall and was rescued
with a typed kind the host resolves itself; there is no fixed scheme for "whichever emulator
the operator configured, with the core and flags they chose", so that trick does not
generalise.

So the entry now carries an opaque key and nothing executable, and the host asks the plugin
that owns it what to run — at launch time, over the loopback UI port and per-boot secret it
already registered. A stolen plugin token stops being command execution: planting an entry is
not enough, because the live plugin answers 404 for a key it never published. Nothing
executable is persisted or served to a client, and an emulator that moved is picked up on the
next launch instead of leaving a dead tile (the same reasoning as `xbox` resolving its AUMID
at launch time).

The host still SPAWNS it, because only the host can put the process where the stream can see
it: on Linux the line is either gamescope's own argv or a spawn carrying the session's
compositor env, and the returned child is what session-game-lifetime tracks to know the game
exited. A plugin spawning the emulator itself would land it outside both.

- library/plugin_launch.rs — the ask: blocking ureq, bounded body, absolute cwd, no control
  characters, and a log line for every way it can come back empty
- library/launch.rs — `plugin_recipe` tried before both per-OS resolvers, plus
  `launch_is_resolvable` so the async handshake probe never makes the blocking call
- native.rs — the session's `resolve_launch` moves onto `spawn_blocking`
- plugin-kit — `serveUi({launch})` serves `POST /__launch`; and `SyncError` finally renders
  its cause, which is why a host refusal with a fully explanatory 403 could reach a plugin's
  own UI as nothing but "Decode error"
This commit is contained in:
2026-08-08 23:46:05 +02:00
parent 0cd946acb5
commit 5872dfc649
14 changed files with 739 additions and 21 deletions
+92
View File
@@ -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);
};