feat(agent-bus): local loopback command API for agents and scripts - #55
Merged
Merged
Conversation
Adds the opt-in local bus from #6: HTTP and WebSocket as peers on 127.0.0.1, both dispatching the stage commands that already exist rather than a second set of names. Validation happens in the Electron main process against a catalog the window reports, so there is no request/response IPC channel — three one-way hops instead. The window reports what is on stage, agent-bus.cjs answers the caller with the same error codes the UI produces, and the accepted action goes back to the window to apply. That keeps "200 means accepted, not loaded" honest, and it is why resolveStageCommand is imported from src/ by main (the file was already React- and asset-free for node --test). GET /v1/state carries id and label for animations, because a custom folder hashes ids from file paths and a caller cannot invent them, plus playableOnce so nobody has to earn a 409. Avatars stay id-only. The token is minted on first enable, reused after, and stored encrypted beside the VRoid Hub credentials rather than in config.yaml, which the renderer rewrites on every change. Bearer header only. Anything carrying an Origin header is refused on both transports, which keeps every web page out without an allowlist to maintain. Fixed port, no silent fallback: the copied curl example names one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… calls Review pass over the previous commit. A throw anywhere in the request path — realistically `applyAction` reaching a window torn down mid-request — left the caller holding a socket that was never answered. The handler is now a named async function whose rejection becomes a 500, and the socket path replies with the same code. The WebSocket comment claimed the server never sends a frame without an id, which the three malformed-frame replies contradicted. The contract is the one the code actually keeps: every reply carries the field, null when there was nothing to echo, and a future event will carry no id at all. Docs and CHANGELOG say the same. Removed: `clearAgentBusToken` and the exported `writeAgentBusToken` (no caller), `MAX_BODY_BYTES` / `STATUS_BY_CODE` from the module's exports, the `agent-bus:status` IPC handler with its preload and hook wrappers (status already comes back from configure and rotate), and three status fields Settings never read. Also: `reject` inside the factory forced both promise callbacks to be renamed around it, so it is `refuse` now; the Host port check is justified by what it actually does rather than by a hand-wave about DNS rebinding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`src/config/agentBus.js` was the one new pure module without a test, while its sibling `environmentSelection.js` has one. Covers what a hand-edited file can do to it: the bus stays off unless `enabled` is a real boolean, `requireToken` stays on unless explicitly false, and a port that cannot be bound falls back instead of reaching `listen` as NaN — which it would read as "any free port". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.panel-button--danger` carries `width: 100%` and a top margin, because it was written for VRoid Hub's Disconnect, which owns its row. Dropped into a flex row beside Copy token it took the whole width, wrapped its neighbour onto two lines and sat 10px lower than it. The actions are a two-column grid now, so both buttons are the same width whatever their label, and the single-button row spans. The token gets the Port field's box instead of the dimmed caption treatment — it is a value to be read and copied, not a footnote — while the "generated when you enable the bus" placeholder goes back to being prose rather than monospace. Also trims the status line to the address: the route list wrapped to three lines in a column this narrow, and Copy example curl already hands over a working request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator
Author
Contributor
|
Thanks for the thorough work on this, @AUDOSt0ck1ng <3 A few docs / housekeeping items are better as a small follow-up PR so we don’t block the feature:
No need to rework the implementation for any of the above. Ideal follow-ups / next steps (existing issues + a couple we may want to open):
Once the follow-up lands (or even in parallel after merge), we can close #6. Thanks again, this is a big step toward 🙏 |
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the local agent bus discussed in #6: an opt-in loopback command intake so scripts and
agent frameworks can drive the avatar without scraping the UI.
It dispatches the stage commands that already exist rather than introducing a second set of names,
so a panel click, an animation hotkey and a
curlall take the same path and produce the sameerrors.
Design
One dispatch core, two peer transports.
POST /v1/commandandws://…/v1/socketboth call thesame
dispatchinagent-bus.cjs; the socket is not a wrapper over HTTP. Semantics stay instageCommands.jsbelow both. One new dependency,ws.No new IPC shape. This is the one thing that changed since the sketch in the issue. Validation
needs the catalog, and
GET /v1/stateneeds it too — so the window reports it and the main processanswers callers itself, in three one-way hops rather than a request/response channel:
either changes (custom folder swap, avatar change, …).
resolveStageCommandand answers thecaller synchronously — same error codes the UI produces.
StageAction, applied through the sameuseStageCommandspath apanel or hotkey uses.
Acceptance is decided in main, application happens in the renderer, which keeps 200 means accepted,
not loaded honest.
useStageCommandsgrew anapplyActionalongsiderunCommandfor step 3;nothing else about it changed.
main.cjsimportsresolveStageCommandfromsrc/via dynamicimport()(ESM from CommonJS)rather than reimplementing the rules. The file was already free of React and asset imports so
node --testcould load it; the three modules involved are now listed inpackage.json'sbuild.filesso a packaged build ships them.GET /v1/statereturns id and label for animations — a custom folder hashes ids from filepaths, so
"Peace Sign"is the only name a caller can be expected to know — plusplayableOnce, sonobody has to earn a 409 to discover which clips can be one-shots. Avatars stay id-only, as agreed.
Environments and audio sources are listed too (the latter are runtime-dependent and otherwise
undiscoverable), along with
current, so an agent can toggle rather than only set.runtime(version, mode) is added by main, which is the only side that knows it.
modeis called out everywhere. Omitted still meansselect, which persists toconfig.yamlexactly like a menu pick. Every example in the docs and the panel's Copy example curl uses
"once".Security
127.0.0.1only, andHostmust name loopback and the port actually bound.Originheader is refused — 403 on the HTTP route, and the WS handshakeis rejected before it completes. That keeps every web page out, including a local dev server, with
no allowlist to maintain.
Authorization: Beareronly; a token in the query string is refused on purpose. Comparison isconstant-time.
config.yaml: that file is a renderer-owned snapshot rewritten on everystage change, while main needs the secret before a window exists. It lives in
agent-bus.jsoninuserData,safeStorage-encrypted, 0600, written tmp+rename — same shape as the VRoid Hubcredentials store. Minted on first enable, reused thereafter. Without an OS keychain there is
nowhere safe to keep it, so it is held in memory for that run and the panel says so.
reads a 413 instead of a connection error.
curllie.Deviation to flag: minting on first enable means an enabled bus always has a token, which is
stricter than the "token optional, default off" wording in the issue. The bus itself is still off by
default, and
requireToken: falseremains as the escape hatch for a quickcurl.Errors
bad-payload,unknown-commandunauthorizedforbidden-host,forbidden-originunknown-animation,unknown-avatar,unknown-environment,unknown-audio-source,not-foundnot-playable-onceinternal-error— a hop threw, most plausibly a window torn down mid-requestnot-ready— the window has not reported a catalog yet (startup, or a reload)The stage codes are the resolver's own, returned verbatim.
WebSocket
Every client frame needs an
id, echoed on the reply —nullwhen the frame had none to echo, soevery reply carries the field. If outbound state events are added later they will carry no
idat all and a
typeinstead, which a client written today can ignore. That one rule is the onlyfuture-proofing here; no event work in v1, since what the events should be will be much clearer once
something is actually driving this.
Settings and
config.yamlSettings → Agents (desktop): enable toggle, port, require-token with Copy / Regenerate, Copy
example curl, and a status line (listening / port in use / off). Toggling starts and stops the
server live, no restart.
Schema version 2 → 3. Nothing to migrate: an older file simply has no section, and an absent one
means off.
Tests
npm test— 130 passing, 24 of them new.electron/agent-bus.test.cjsdrives the real server over real sockets with the realresolveStageCommand(loaded the same waymain.cjsloads it, so a broken import shows up here):every status mapping, host and origin rejection, token present/absent/near-miss, query-string
token, method/route/content-type, malformed JSON, oversized body,
not-ready, a hop that throws(500 rather than a socket nobody answers), and the WS
idcontract.electron/agent-bus-token.test.cjs: mint-once-and-reuse, rotation, corrupt/missing file.src/config/agentBus.test.mjs: theconfig.yamlsection — off unless a real boolean saysotherwise, token on unless explicitly off, and a port that cannot bind falling back rather than
reaching
listenas NaN.stageCommands.test.mjsstays the source of truth for semantics; the server tests cover transportonly.
Not unit-tested, per what this repo already does: the main-process wiring in
main.cjs, theuseAgentBushook andAgentBusPanel. Those paths are what the manual run below exercised.Manual verification
Built and run as the packaged renderer (
npm run build+electron .), driven withcurland awsclient:GET /v1/statelisted the custom environments folder and all four catalogs, withruntimeandcurrent.animation.playby label with"mode":"once"→ 200, resolved tovrma-03.avatar.set→ 200, and the next/v1/stateshowedcurrent.avatarIdhad actually changed —the full round trip, not just acceptance.
unknown-animation, 409not-playable-once, 403forbidden-originas specified.id.requireToken: true, no token and a wrong token both 401, andagent-bus.jsonwas writtenencrypted.
Not verified live: the accepted-token 200 path. The token is DPAPI-encrypted and only the Settings
panel can read it back, so that one is covered by the unit tests.
Out of scope
MCP (an adapter can sit on the HTTP side later — the bus stays the one control path underneath),
outbound state events, window control, VRoid Hub loading (#18), browser build, remote bind.
Keyword → animation (#7) should call
runCommandin-process when it lands, not HTTP to ourselves.Refs #6
🤖 Generated with Claude Code