Skip to content

fix(loader): explain malformed /api/rpc requests; enforce the __ id r… - #242

Draft
srsholmes wants to merge 7 commits into
mainfrom
claude/agent-instructions-steam-proxy-4ateps
Draft

srsholmes wants to merge 7 commits into
mainfrom
claude/agent-instructions-steam-proxy-4ateps

Conversation

@srsholmes

Copy link
Copy Markdown
Owner

…eservation

Three papercuts on the HTTP RPC surface, all of which only bite callers
that aren't the overlay — which is why they've gone unnoticed.

createRpcHandler drops any request missing id, plugin or method
by returning null, and the route surfaced all three as a bare
{"error": "No response"}. Over HTTP the response is the correlation,
so id is exactly the field a caller has no reason to send; the route's
own header comment documented the body as {plugin, method, args}, with
no id at all. In practice only __broadcast (handled before the handler
ever sees the body) had been exercised over HTTP, so nothing caught it.

Now: id is synthesised when omitted and passed through when supplied,
a missing plugin/method is a 400 that names the field, and a body
that isn't JSON is a 400 rather than sharing the 500 that plugin
exceptions get. The "No response" branch stays as a backstop for a
handler-contract change, with a message that says so.

Separately, packages/types/src/plugin.ts and the plugin-development
guide have both long claimed the loader rejects plugin manifests whose
id starts with __ — the namespace owned by __core:*, __system,
__overlay and __broadcast. Nothing enforced it, so a manifest
declaring __core:game-detection would land in the same plugins map
as the real service and shadow it. discoverPlugins now skips them.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_0153sfNnCXXZ7sK1jULnXuSi

What & why

How it was tested

Checklist

  • bun run typecheck passes (0 errors)
  • bun run lint passes (0 errors)
  • bun run test passes
  • Added/updated tests for new backend/lib behaviour
  • Updated docs / NOTICE / THIRD_PARTY_LICENSES.md if this wraps or bundles third-party code

claude added 7 commits July 31, 2026 20:42
…eservation

Three papercuts on the HTTP RPC surface, all of which only bite callers
that aren't the overlay — which is why they've gone unnoticed.

`createRpcHandler` drops any request missing `id`, `plugin` or `method`
by returning null, and the route surfaced all three as a bare
`{"error": "No response"}`. Over HTTP the response *is* the correlation,
so `id` is exactly the field a caller has no reason to send; the route's
own header comment documented the body as `{plugin, method, args}`, with
no id at all. In practice only `__broadcast` (handled before the handler
ever sees the body) had been exercised over HTTP, so nothing caught it.

Now: `id` is synthesised when omitted and passed through when supplied,
a missing `plugin`/`method` is a 400 that names the field, and a body
that isn't JSON is a 400 rather than sharing the 500 that plugin
exceptions get. The "No response" branch stays as a backstop for a
handler-contract change, with a message that says so.

Separately, packages/types/src/plugin.ts and the plugin-development
guide have both long claimed the loader rejects plugin manifests whose
id starts with `__` — the namespace owned by `__core:*`, `__system`,
`__overlay` and `__broadcast`. Nothing enforced it, so a manifest
declaring `__core:game-detection` would land in the same `plugins` map
as the real service and shadow it. discoverPlugins now skips them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153sfNnCXXZ7sK1jULnXuSi
Loadout's capability surface is already fully reachable over RPC, but
nothing describes it: resolveMethod finds backend methods purely by
reflection, so there is no declared method list, no argument types at
runtime and no descriptions. An agent on a machine with Loadout
installed can reach everything and discover nothing.

This adds the vocabulary and the pure logic for the layer that fixes
that, ahead of the generator and routes that use it.

The shape is split deliberately. AgentDoc is a generated baseline,
derived from each backend's TypeScript and written to a sibling
agent.json; AgentManifest is hand-written curation on the plugin
manifest carrying what a signature can't say — what a method is for,
whether calling it is safe, a worked example. They merge at request
time, so curation is incremental rather than a precondition: ~450
methods across 25 plugins get useful docs on day one and improve
plugin-by-plugin.

Generated docs live in a sibling file rather than on the manifest
because /api/plugins returns raw PluginMeta and the overlay fetches it
every boot — a per-method schema blob there would be paid for by every
one of those requests.

On safety classification: uncurated methods are classified by a narrow
name heuristic (get/list/read/fetch/search/query/find/is/has, at a
camelCase boundary) rather than all defaulting to `write`. Defaulting
everything to `write` is more conservative on paper but would gate all
450 methods, and the first thing anyone would do is switch the gate off
wholesale. Ambiguous prefixes (check*, scan*) deliberately fall through
to `write`. Every inferred class is flagged and rendered as "(inferred)"
so a reader can tell a guess from a declaration.

Markdown is the default rendering because the consumer is a model
reading into a prompt; JSON is the same data on Accept negotiation.
Both are pure and deterministic — no clock, no filesystem — so output
is byte-stable and the route layer stays a thin adapter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153sfNnCXXZ7sK1jULnXuSi
Adds the generator that turns each plugin's backend.ts into a committed
plugins/<id>/agent.json, plus the static AGENTS.md discovery file.

Reflection is where the type information stops — a runtime function
object knows its arity and nothing else — so the signatures have to come
from the source. The TypeScript compiler API reads them, and the output
is committed rather than computed on device because the compiled loader
binary doesn't ship `typescript`. prepare-plugins.sh already copies whole
plugin directories, so this ships with no packaging change.

Across the 25 shipped plugins that's 282 methods and 57 events, with 64%
of methods already carrying a description lifted from existing JSDoc and
only 4 return types that couldn't be lowered to a schema.

Lowering handles primitives, arrays, literal unions, nullability,
intersections and cross-module interfaces (BluetoothDevice resolves
through ./lib/parse, DeviceType becomes an enum). Intersections
specifically: they aren't Object-flagged so they fell through to
unknown, losing a whole shape over one extra optional field —
getProperties() already merges them, they just weren't reaching the
object path. What remains unlowerable is object unions like
`BatteryInfo | { error: string }`, which degrade to the printed
TypeScript; that reads well enough that discriminated-union support
isn't worth the schema complexity yet.

Optionality is recorded once. An optional property's type is
`T | undefined`, so union lowering set schema.optional alongside the
parent's `required` list — the same fact twice, on every optional field
in every file.

AGENTS.md deliberately does not enumerate capabilities: only the running
loader knows which plugins are enabled on a given machine, and a static
list would be wrong everywhere the plugin set differs. Its one job is to
point at GET /api/agent.

Method selection defers to the same rules the RPC dispatcher applies, so
generated docs can't describe something that won't dispatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153sfNnCXXZ7sK1jULnXuSi
prepare-plugins.sh is the one staging step both install paths share —
install-local.sh and the release workflow's plugin tarball — so copying
AGENTS.md there covers both with a single change.

The release tar names its members explicitly, so anything staged has to
be listed there too or it silently doesn't ship; noted inline next to
the tar invocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153sfNnCXXZ7sK1jULnXuSi
Three routes. GET /api/agent indexes the plugins enabled on this
machine; GET /api/agent/<id> documents one plugin's methods; POST
/api/agent/call dispatches with the safety gate applied.

The live server is the source of truth because "what can I do here" is a
property of the machine, not the repo — disabledPlugins decides it, and
a disabled plugin's backend is never loaded. AGENTS.md only points here.
Markdown is the default representation since the reader is usually a
model; JSON on Accept negotiation renders from the same merged data.

/api/agent/call exists so the write gate is real rather than a claim in
the docs. It can't live on /api/rpc — the overlay and every plugin
frontend call writes through that path constantly, and gating it would
break the product — so the gate goes on the endpoint the docs tell
agents to use. An agent could still bypass it via /api/rpc; that's
stated plainly in the rendered docs rather than papered over, since the
token is public on loopback and nothing here is a security boundary. The
point is to make the default path the careful one, and to answer a
blocked write with a specific 403 that names the safety class.

A plugin with no generated agent.json still lists what it can do: the
route reflects live method names and the docs say signatures are
unavailable. Undocumented degrades to less useful, never to invisible.

That fallback surfaced something real. TypeScript's `private` is erased
at runtime, so resolveMethod dispatches private methods happily — only
the underscore convention actually gates RPC exposure. The core services
were exposing broadcastChange and signature as callable endpoints;
renamed to match the convention the dispatcher honours. 141 methods
across the shipped plugins are in the same position, which is a bigger
change than this one should carry — the generated docs already exclude
them, since the generator reads the TypeScript modifier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153sfNnCXXZ7sK1jULnXuSi
Three worked examples of the curation path, chosen to cover the cases
that matter rather than to grind through all 25 plugins: a write-heavy
hardware plugin, a simple read/write one, and a synthetic core service.

What curation buys that a type signature can't:

- Four of tdp-control's 29 methods are loader-invoked lifecycle
  callbacks (handleGameLaunch/Exit, onSuspend/onResume). They're public
  because the broadcast dispatcher needs them, not because an agent
  should call them — now hidden.
- setSmt is reclassified destructive. It offlines CPU threads, so a
  running game stutters or crashes; the inferred class was `write`,
  which understates it badly.
- setGpuFreqRange is a no-op unless setGpuMode("manual") ran first, and
  disconnectDevice moves audio back to the speakers mid-song. Neither
  fact is recoverable from a signature.

Every curated method also drops the "(inferred)" marker and the write
gate starts reading a declaration instead of a name heuristic — visible
as safetyInferred: false in the 403 body.

The core service is curated inline in loader/index.ts: it's synthetic,
so it has no package.json to carry an `agent` block and no generated
agent.json. Without it, "what games are installed" — probably the single
most useful thing here — answered with bare method names and a shrug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153sfNnCXXZ7sK1jULnXuSi
The generated docs are committed, so nothing stops a signature change
from shipping alongside a description of a method that no longer exists.
check:agent-docs regenerates and diffs, which makes drift a build
failure. Verified against a real signature change, not just a
hand-edited JSON file.

knip caught @loadout/agent-docs going undeclared in both workspaces that
import it — added rather than ignored.

The plugin-development guide gets the two things an author needs to know
and can't infer: JSDoc on a public method becomes its agent-facing
description (the cheapest docs you'll ever write), and TypeScript
`private` does NOT hide a method from RPC — only the underscore prefix
does. `private` merely hides it from the generated docs, which is the
worse of the two failure modes: undocumented but callable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153sfNnCXXZ7sK1jULnXuSi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants