Three symptoms on .21, two defects. Lutris and Heroic appeared in the console sidebar
they explicitly opt out of; Lutris's settings were unreachable from the Library
screen; and Lutris and Steam logged `sync (startup) failed: HostRequestError`.
**The sidebar is a publish gap.** The console is correct — it keeps
`category: "library"` plugins out of the nav (`uiPlugins`, app-shell.tsx) — but the
host reports no category for them at all. `defineLibraryPlugin` sets it and
`sdk/src/ui.ts` forwards it; what SHIPS does not. `@punktfunk/host` was bumped to
0.1.2 on 2026-07-20 and `category` landed 2026-08-05 without a bump, so the registry's
0.1.2 is the pre-category build and every installed scanner registers without one.
Bumps the SDK to 0.1.3 — **inert until it is published**.
Because the field rides the untyped `pf.request` seam so an older host ignores it
rather than rejecting the registration, dropping it is silent by design. `serveUi` now
reads its own directory entry back and warns once when a requested category did not
land, the same way `defineLibraryPlugin` already warns when a store claim did not take.
That is what turns the next occurrence into a log line instead of a bug report.
**The missing settings and the failed sync are ONE defect: a write/read disagreement
about `file://`.** `local_art_bytes` decodes a `file://` value before testing
containment; `validate_art_paths` handed the raw value to `Path::new`, where
`file:///home/u/c.jpg` is a RELATIVE path whose first component is `file:`. It
canonicalized against the cwd, failed, and read as "outside every art root". So the
host refused every cover the kit's own `fileUrl` helper emits — the documented way for
a plugin to publish local art — while the read path would have served those same files.
That the two symptoms share a cause is not obvious and is why this is one commit: the
Library screen's settings control renders only for `origin: "plugin"`, and a source
becomes `plugin` only once it holds a store CLAIM, which is taken during a successful
reconcile. Lutris failed at entry 0 and Steam at entry 3, so neither ever claimed its
store, both stayed `origin: "builtin"`, and neither got a settings button. Heroic
reconciled (its art is http(s)) and has had its settings all along; rom-manager was
never affected because zero entries meant it never applied.
`art_path_is_servable` now decodes first, so both halves of the confinement judge the
same string. Confinement itself is unchanged: an out-of-root path is still refused in
`file://` clothing, which the test asserts alongside the accept case.
Diagnosing this took the HOST's journal, because both surfaces that should have
explained it lied. `HostRequestError` stringified to its bare tag, so the sync engine's
`${e.cause}` logged `HostRequestError` and discarded the method, the path and the
host's own message; it now renders all three, including an object-shaped cause that
used to print `[object Object]`. And the host logged "payload carries a field this lane
may not set" for BOTH refusals in `check_entry_fields`, so a 400 about an art path read
as an auth problem — it now logs the real reason and the entry title.
Verified on .21 (Linux): 463 host tests pass, clippy clean under `-D warnings`,
`cargo fmt --all --check` clean. The new art test fails without the fix and passes with
it. plugin-kit 71 and SDK 72 tests pass, both typecheck clean, biome clean.
84 lines
3.1 KiB
TypeScript
84 lines
3.1 KiB
TypeScript
// Kit-level error taxonomy. `Data.TaggedError` (matching the SDK's idiom in
|
|
// sdk/src/client.ts) — these never cross HTTP; a plugin's UI-API contract defines its own
|
|
// Schema-based errors with status annotations.
|
|
import { Data } from "effect";
|
|
|
|
/**
|
|
* A management-API call through the pf facade failed.
|
|
*
|
|
* The `message` getter is load-bearing, not decoration. `Data.TaggedError`'s default string form is
|
|
* the bare tag, and the sync engine logs `sync (${reason}) failed: ${e.cause}` — so a host that
|
|
* refused a reconcile with a perfectly clear 400 surfaced in the plugin log as exactly
|
|
* `sync (startup) failed: HostRequestError`, with the method, the path and the host's own
|
|
* explanation all discarded. Diagnosing the 2026-08-08 Lutris/Steam art rejection meant reading the
|
|
* HOST's journal instead, because the plugin's own log could not distinguish a validation refusal
|
|
* from the host being down.
|
|
*/
|
|
export class HostRequestError extends Data.TaggedError("HostRequestError")<{
|
|
readonly method: string;
|
|
readonly path: string;
|
|
readonly cause: unknown;
|
|
}> {
|
|
override get message(): string {
|
|
return `${this.method} ${this.path} failed: ${describeCause(this.cause)}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render whatever `pf.request` rejected with into one line.
|
|
*
|
|
* An `Error` stringifies usefully already; a plain object (the host's `{error: "…"}` body, which is
|
|
* what a rejected reconcile actually carries) stringifies to `[object Object]`, which is how the
|
|
* useful half of the message got lost. JSON is the fallback so a body-shaped cause survives, and a
|
|
* cycle or a BigInt degrades to `String(cause)` rather than throwing inside error formatting.
|
|
*/
|
|
const describeCause = (cause: unknown): string => {
|
|
if (cause instanceof Error) return cause.message;
|
|
if (typeof cause === "object" && cause !== null) {
|
|
try {
|
|
return JSON.stringify(cause);
|
|
} catch {
|
|
return String(cause);
|
|
}
|
|
}
|
|
return String(cause);
|
|
};
|
|
|
|
/** config.json exists but does not parse/decode. */
|
|
export class ConfigParseError extends Data.TaggedError("ConfigParseError")<{
|
|
readonly path: string;
|
|
readonly issue: string;
|
|
}> {}
|
|
|
|
/**
|
|
* config.json is group/world-writable (POSIX). This file controls commands run as the
|
|
* host user, so the kit refuses it — the same sshd rule the runner applies to unit files.
|
|
*/
|
|
export class ConfigPermissionError extends Data.TaggedError(
|
|
"ConfigPermissionError",
|
|
)<{
|
|
readonly path: string;
|
|
readonly mode: number;
|
|
}> {
|
|
override get message(): string {
|
|
return `refusing ${this.path}: it is group/world-writable (chmod go-w it first) — this file controls commands run as the host user`;
|
|
}
|
|
}
|
|
|
|
/** Persisting config/state failed. */
|
|
export class ConfigWriteError extends Data.TaggedError("ConfigWriteError")<{
|
|
readonly path: string;
|
|
readonly cause: unknown;
|
|
}> {}
|
|
|
|
/** The plugin UI server could not be started/registered. */
|
|
export class UiServeError extends Data.TaggedError("UiServeError")<{
|
|
readonly cause: unknown;
|
|
}> {}
|
|
|
|
/** A sync pass failed (compute or apply). */
|
|
export class SyncError extends Data.TaggedError("SyncError")<{
|
|
readonly reason: string;
|
|
readonly cause: unknown;
|
|
}> {}
|